From 9260a8a2a207799d2913d983a2c6486f5b9f7f1d Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 18 May 2026 16:45:22 +0530 Subject: [PATCH] feat(observability): implement provider access policy denial handling - Added a new function to check for provider access policy denials based on HTTP 403 responses, specifically for cases where requests are rejected for not being from approved coding agents. - Introduced logging for these denials to avoid reporting them to Sentry. - Updated tests to verify the correct classification of access-terminated errors as provider user state messages. --- src/core/observability.rs | 27 +++++++ .../inference/provider/compatible.rs | 35 ++++++++++ src/openhuman/inference/provider/ops.rs | 70 +++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/src/core/observability.rs b/src/core/observability.rs index 8f685f17d8..5134a19ead 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -355,6 +355,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 } @@ -1465,6 +1475,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 80d7711023..ad3e7ebb1f 100644 --- a/src/openhuman/inference/provider/compatible.rs +++ b/src/openhuman/inference/provider/compatible.rs @@ -446,6 +446,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(), @@ -790,6 +797,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(), @@ -1251,6 +1265,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(), @@ -1642,6 +1663,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(), @@ -1779,6 +1807,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 cb3533de8a..0cdfa62d57 100644 --- a/src/openhuman/inference/provider/ops.rs +++ b/src/openhuman/inference/provider/ops.rs @@ -272,6 +272,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, @@ -290,6 +308,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" + ); +} + /// Build a sanitized provider error from a failed HTTP response. /// /// Reports the failure to Sentry with `provider` and `status` tags so @@ -321,6 +357,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); if is_auth_failure && is_backend { tracing::warn!( @@ -343,6 +380,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 should_report_provider_http_failure(status) { crate::core::observability::report_error( message.as_str(), @@ -755,6 +794,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\"}}", + )); + } + } + #[test] fn test_sanitize_api_error_utf8() { let input = "🦀".repeat(MAX_API_ERROR_CHARS + 10);