diff --git a/src/core/observability.rs b/src/core/observability.rs index c06f35c2d6..c42e96b5c7 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -464,6 +464,16 @@ fn is_provider_user_state_message(lower: &str) -> bool { return true; } + // OPENHUMAN-TAURI-S7: provider policy rejection on Kimi's coding + // endpoint when requests are not sent from an approved coding-agent + // client. Canonical body contains `access_terminated_error` and: + // "currently only available for Coding Agents ...". + if lower.contains("access_terminated_error") + || lower.contains("currently only available for coding agents") + { + return true; + } + false } @@ -1615,6 +1625,23 @@ mod tests { ); } + #[test] + fn classifies_access_terminated_provider_policy_as_provider_user_state() { + assert_eq!( + expected_error_kind( + "custom_openai API error (403 Forbidden): {\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.\",\"type\":\"access_terminated_error\"}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + assert_eq!( + expected_error_kind( + "agent turn failed: custom_openai API error (403): currently only available for coding agents" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + } + #[test] fn does_not_classify_unrelated_500s_as_provider_user_state() { // Sanity check: a generic 500 with no provider-user-state body diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs index 4c71b9a8ef..cee05215f6 100644 --- a/src/openhuman/inference/provider/compatible.rs +++ b/src/openhuman/inference/provider/compatible.rs @@ -472,6 +472,13 @@ impl OpenAiCompatibleProvider { Some(model), status, ); + } else if super::is_provider_access_policy_denied_http_403(status, &error) { + super::log_provider_access_policy_denied_http_403( + "responses_api", + self.name.as_str(), + Some(model), + status, + ); } else if super::should_report_provider_http_failure(status) { crate::core::observability::report_error( message.as_str(), @@ -820,6 +827,13 @@ impl OpenAiCompatibleProvider { Some(native_request.model.as_str()), status, ); + } else if super::is_provider_access_policy_denied_http_403(status, &body) { + super::log_provider_access_policy_denied_http_403( + "streaming_chat", + self.name.as_str(), + Some(native_request.model.as_str()), + status, + ); } else if super::should_report_provider_http_failure(status) { crate::core::observability::report_error( message.as_str(), @@ -1294,6 +1308,13 @@ impl Provider for OpenAiCompatibleProvider { Some(model), status, ); + } else if super::is_provider_access_policy_denied_http_403(status, &error) { + super::log_provider_access_policy_denied_http_403( + "chat_completions", + self.name.as_str(), + Some(model), + status, + ); } else if super::should_report_provider_http_failure(status) { crate::core::observability::report_error( message.as_str(), @@ -1725,6 +1746,13 @@ impl Provider for OpenAiCompatibleProvider { Some(model), status, ); + } else if super::is_provider_access_policy_denied_http_403(status, &error) { + super::log_provider_access_policy_denied_http_403( + "native_chat", + self.name.as_str(), + Some(model), + status, + ); } else if super::should_report_provider_http_failure(status) { crate::core::observability::report_error( message.as_str(), @@ -1862,6 +1890,13 @@ impl Provider for OpenAiCompatibleProvider { Some(model_owned.as_str()), status, ); + } else if super::is_provider_access_policy_denied_http_403(status, &raw_error) { + super::log_provider_access_policy_denied_http_403( + "stream_chat", + provider_name.as_str(), + Some(model_owned.as_str()), + status, + ); } else if super::should_report_provider_http_failure(status) { crate::core::observability::report_error( message.as_str(), diff --git a/src/openhuman/inference/provider/ops.rs b/src/openhuman/inference/provider/ops.rs index ec308d1505..0d638b6d26 100644 --- a/src/openhuman/inference/provider/ops.rs +++ b/src/openhuman/inference/provider/ops.rs @@ -301,6 +301,24 @@ pub(super) fn is_budget_exhausted_http_400(status: reqwest::StatusCode, body: &s status == reqwest::StatusCode::BAD_REQUEST && super::is_budget_exhausted_message(body) } +/// Whether a provider non-2xx response is a deterministic provider-policy +/// denial (not a product bug) that should be demoted from Sentry. +/// +/// Canonical example: Kimi's coding endpoint rejects non-agent clients with +/// HTTP 403 + `access_terminated_error` and a message like: +/// "currently only available for Coding Agents …". +pub(super) fn is_provider_access_policy_denied_http_403( + status: reqwest::StatusCode, + body: &str, +) -> bool { + if status != reqwest::StatusCode::FORBIDDEN { + return false; + } + let lower = body.to_ascii_lowercase(); + lower.contains("access_terminated_error") + || lower.contains("currently only available for coding agents") +} + pub(super) fn log_budget_exhausted_http_400( operation: &str, provider: &str, @@ -319,6 +337,24 @@ pub(super) fn log_budget_exhausted_http_400( ); } +pub(super) fn log_provider_access_policy_denied_http_403( + operation: &str, + provider: &str, + model: Option<&str>, + status: reqwest::StatusCode, +) { + tracing::info!( + domain = "llm_provider", + operation = operation, + provider = provider, + model = model.unwrap_or(""), + status = status.as_u16(), + failure = "non_2xx", + kind = "provider_access_policy", + "[llm_provider] {operation} provider access-policy 403 — not reporting to Sentry" + ); +} + /// Whether a provider non-2xx response is a deterministic /// **configuration-rejection** user-state error (unknown model id, /// abstract tier leaked to a custom provider, model-specific temperature @@ -397,6 +433,7 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E let is_auth_failure = matches!(status.as_u16(), 401 | 403); let is_backend = provider == openhuman_backend::PROVIDER_LABEL; let is_budget_exhausted_user_state = is_budget_exhausted_http_400(status, &body); + let is_provider_access_policy_denied = is_provider_access_policy_denied_http_403(status, &body); let is_provider_config_rejection = is_provider_config_rejection_http(status, provider, &body); if is_auth_failure && is_backend { @@ -420,6 +457,8 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E ); } else if is_budget_exhausted_user_state { log_budget_exhausted_http_400("api_error", provider, None, status); + } else if is_provider_access_policy_denied { + log_provider_access_policy_denied_http_403("api_error", provider, None, status); } else if is_provider_config_rejection { log_provider_config_rejection("api_error", provider, None, status); } else if should_report_provider_http_failure(status) { @@ -878,6 +917,37 @@ mod tests { } } + mod provider_access_policy_suppression { + use super::*; + + const ACCESS_TERMINATED_BODY: &str = + "{\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents.\",\"type\":\"access_terminated_error\"}}"; + + #[test] + fn access_terminated_403_is_suppressed() { + assert!(is_provider_access_policy_denied_http_403( + reqwest::StatusCode::FORBIDDEN, + ACCESS_TERMINATED_BODY, + )); + } + + #[test] + fn access_terminated_non_403_is_not_suppressed() { + assert!(!is_provider_access_policy_denied_http_403( + reqwest::StatusCode::BAD_REQUEST, + ACCESS_TERMINATED_BODY, + )); + } + + #[test] + fn unrelated_403_is_not_suppressed() { + assert!(!is_provider_access_policy_denied_http_403( + reqwest::StatusCode::FORBIDDEN, + "{\"error\":{\"message\":\"forbidden\"}}", + )); + } + } + // Exercises the real `is_provider_config_rejection_http` decision used // by `api_error`, including the inverted provider-aware polarity. mod provider_config_rejection_suppression {