Treat inference hook denials as terminal errors (#60511)

## Why

An explicit denial from a workspace inference hook should stop the request, including retries and compaction model fallback.

## What changed

Recognize `inference_hook_denied` provider errors and preserve the provider's message, with a fallback when none is supplied. Expose the distinct `inferenceHookDenied` error through the app-server protocol and generated schemas and SDK types. Treat the denial as non-retryable during turns, compaction, and guardian review.

## Testing

Add coverage for terminal denials over HTTP, SSE, and WebSocket, error serialization and persistence in `thread/read`, and compaction failures that preserve token usage and history without retrying or falling back to another model.

GitOrigin-RevId: 2ebd480f01e52f4e6bd3db8a0f92d5814d18ccb1
This commit is contained in:
ccui-oai
2026-11-10 03:21:26 +00:00
committed by copyberry
parent 1099fe0694
commit 0811df3976
43 changed files with 292 additions and 51 deletions
+4
View File
@@ -43,6 +43,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
| ApiError::InvalidAudioInput { .. }
| ApiError::ModelNotFound { .. }
| ApiError::InvalidPrompt { .. }
| ApiError::InferenceHookDenied { .. }
| ApiError::ContentPolicyViolation { .. }
| ApiError::CyberPolicy { .. }
| ApiError::BioPolicy { .. }
@@ -105,6 +106,9 @@ fn map_api_error_details(err: ApiError) -> CodexErr {
ApiError::InvalidPrompt { message } => {
CodexErr::new(CodexErrorDetails::InvalidPrompt { message })
}
ApiError::InferenceHookDenied { message } => {
CodexErr::new(CodexErrorDetails::InferenceHookDenied { message })
}
ApiError::ContentPolicyViolation { message, status } => {
CodexErrorDetails::ContentPolicyViolation { message, status }.into()
}
+18 -3
View File
@@ -335,6 +335,12 @@ fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
fn map_api_error_preserves_typed_errors() {
let message = "This request was rejected.";
for (error, expected_info) in [
(
ApiError::InferenceHookDenied {
message: message.to_string(),
},
CodexErrorInfo::InferenceHookDenied,
),
(
ApiError::BioPolicy {
message: message.to_string(),
@@ -357,22 +363,31 @@ fn map_api_error_preserves_typed_errors() {
#[test]
fn map_api_error_maps_http_and_wrapped_websocket_typed_errors() {
for (code, expected_info, fallback) in [
for (status, code, expected_info, fallback) in [
(
http::StatusCode::BAD_REQUEST,
"content_policy_violation",
CodexErrorInfo::InvalidPrompt,
"The input was rejected by the content policy.",
),
(
http::StatusCode::BAD_REQUEST,
"bio_policy",
CodexErrorInfo::BioPolicy,
"This content was flagged for possible biological risk.",
),
(
http::StatusCode::BAD_REQUEST,
"invalid_prompt",
CodexErrorInfo::InvalidPrompt,
"Invalid request.",
),
(
http::StatusCode::FORBIDDEN,
"inference_hook_denied",
CodexErrorInfo::InferenceHookDenied,
"Your workspace inference hook blocked this request.",
),
] {
for wrapped in [false, true] {
for (message, expected) in [
@@ -390,11 +405,11 @@ fn map_api_error_maps_http_and_wrapped_websocket_typed_errors() {
}
if wrapped {
body["type"] = serde_json::json!("error");
body["status"] = serde_json::json!(400);
body["status"] = serde_json::json!(status.as_u16());
}
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::BAD_REQUEST,
status,
url: None,
headers: None,
body: Some(body.to_string()),
+9
View File
@@ -57,6 +57,8 @@ pub enum ApiError {
#[error("invalid prompt: {message}")]
InvalidPrompt { message: String },
#[error("{message}")]
InferenceHookDenied { message: String },
#[error("{message}")]
ContentPolicyViolation {
message: String,
status: Option<StatusCode>,
@@ -96,6 +98,13 @@ pub(crate) fn parse_terminal_provider_error(
.and_then(Value::as_str)
.filter(|message| !message.trim().is_empty());
match (code, status) {
("inference_hook_denied", None | Some(StatusCode::FORBIDDEN)) => {
Some(ApiError::InferenceHookDenied {
message: message
.unwrap_or("Your workspace inference hook blocked this request.")
.to_owned(),
})
}
("context_length_exceeded", None | Some(StatusCode::BAD_REQUEST)) => {
Some(ApiError::ContextWindowExceeded)
}
+1
View File
@@ -683,6 +683,7 @@ async fn process_sse_with_treatment(
| ApiError::ContextWindowExceeded
| ApiError::InvalidRequest { .. }
| ApiError::SessionBudgetExceeded
| ApiError::InferenceHookDenied { .. }
| ApiError::AccessProgramNotEnabled { .. }
| ApiError::ContentPolicyViolation { .. }
| ApiError::ResponseIncomplete(_)
@@ -115,6 +115,7 @@ async fn terminal_failure_with_retry_header_ends_stream_immediately() {
for (code, expected) in [
("flex_unavailable", CodexErrorInfo::FlexUnavailable),
("budget_reached", CodexErrorInfo::SessionBudgetExceeded),
("inference_hook_denied", CodexErrorInfo::InferenceHookDenied),
(
"context_length_exceeded",
CodexErrorInfo::ContextWindowExceeded,