Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/core/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions src/openhuman/inference/provider/compatible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
70 changes: 70 additions & 0 deletions src/openhuman/inference/provider/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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!(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this nitpick. The two sibling demotion-log helpers in this same file — log_budget_exhausted_http_400 and log_provider_config_rejection (added on main) — both use tracing::info!. Switching just this one to debug! would break the established pattern for "non-2xx user-state suppressed from Sentry" diagnostic logs. Happy to revisit in a follow-up that demotes all three together if repo policy prefers debug!.

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
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
Loading