fix(observability): classify Kimi access_terminated 403 as expected provider user-state (#2090)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YellowSnnowmann
2026-05-19 19:42:51 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 544a68239d
commit ff8d60c3bb
3 changed files with 132 additions and 0 deletions
+27
View File
@@ -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
@@ -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(),
+70
View File
@@ -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 {