diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e4784fe678..b4220658bb 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -729,6 +729,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c1252489a6..0aebc0f619 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8925,6 +8925,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c71cef12ff..12f2594644 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4706,6 +4706,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 808a08aa98..e187d88bde 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -25,6 +25,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2be7bbeece..7859f862eb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -127,6 +127,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 68829f9364..a2c3896287 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -208,6 +208,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index ad46c3e838..1902e4c963 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -130,6 +130,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index c23c6c3483..8478f0c2fe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -130,6 +130,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index c3e0785fb2..9005c3dc67 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -130,6 +130,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 6e653afcf4..eceddd4711 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -208,6 +208,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index e3772a1921..e8dc720fe4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -130,6 +130,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 7d24279f2d..eb28767305 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -208,6 +208,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 5de7051c89..6c0623606c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -130,6 +130,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index 8397d86eca..37b21c0367 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -127,6 +127,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index a960ccf206..897c7fa8a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -130,6 +130,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 081f54cbb6..7ddc02f7ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -127,6 +127,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 896122af66..2d10343bc1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -127,6 +127,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index aecb15c373..5f891ee76f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -127,6 +127,13 @@ ], "type": "string" }, + { + "description": "The workspace's inference hook explicitly denied this request.", + "enum": [ + "inferenceHookDenied" + ], + "type": "string" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index d83ab0f2aa..561f345bbb 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index c10f65af88..912742c14d 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 2b51044eed..f74612062a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "modelNotFound" | "policyCapacityUnavailable" | "cyberPolicy" | "misalignmentPolicyViolation" | "tooManyDenials" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other" | string | { [key: string]: unknown }; +export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "modelNotFound" | "policyCapacityUnavailable" | "inferenceHookDenied" | "cyberPolicy" | "misalignmentPolicyViolation" | "tooManyDenials" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other" | string | { [key: string]: unknown }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs index 1edd7b4a44..5e63dfa521 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -83,6 +83,8 @@ pub enum CodexErrorInfo { ModelNotFound, // Capacity is unavailable on a route restricted by server policy. PolicyCapacityUnavailable, + /// The workspace's inference hook explicitly denied this request. + InferenceHookDenied, CyberPolicy, MisalignmentPolicyViolation, TooManyDenials, @@ -163,6 +165,7 @@ impl From for CodexErrorInfo { CodexErrorInfo::PolicyCapacityUnavailable } CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, + CoreCodexErrorInfo::InferenceHookDenied => CodexErrorInfo::InferenceHookDenied, CoreCodexErrorInfo::BioPolicy => CodexErrorInfo::Other, CoreCodexErrorInfo::MisalignmentPolicyViolation => { CodexErrorInfo::MisalignmentPolicyViolation diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index d6ea06fc6b..238a645fc0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -4907,6 +4907,10 @@ fn core_error_info_converts_to_camel_case() { json!("policyCapacityUnavailable"), ), (CoreCodexErrorInfo::CyberPolicy, json!("cyberPolicy")), + ( + CoreCodexErrorInfo::InferenceHookDenied, + json!("inferenceHookDenied"), + ), (CoreCodexErrorInfo::BioPolicy, json!("other")), (CoreCodexErrorInfo::InvalidPrompt, json!("other")), ( @@ -4918,9 +4922,11 @@ fn core_error_info_converts_to_camel_case() { json!("rateLimitExceeded"), ), ] { + let converted = CodexErrorInfo::from(core); + assert_eq!(serde_json::to_value(&converted).unwrap(), expected); assert_eq!( - serde_json::to_value(CodexErrorInfo::from(core)).unwrap(), - expected + serde_json::from_value::(expected).unwrap(), + converted ); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 4f117dc2e2..f7ad67d679 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -2818,15 +2818,17 @@ async fn thread_items_read_validates_ids_before_history_access() -> Result<()> { Ok(()) } +#[test_case::test_case("model_not_found", CodexErrorInfo::ModelNotFound, "Access to this deployment ended."; "model_not_found")] +#[test_case::test_case("inference_hook_denied", CodexErrorInfo::InferenceHookDenied, "Blocked by the workspace hook."; "inference_hook_denied")] #[tokio::test] -async fn thread_read_preserves_model_not_found_after_failed_turn() -> Result<()> { +async fn thread_read_preserves_provider_error_after_failed_turn( + code: &str, + expected: CodexErrorInfo, + message: &str, +) -> Result<()> { let server = responses::start_mock_server().await; - let message = "Access to this deployment ended."; - let _response_mock = responses::mount_sse_once( - &server, - responses::sse_failed("resp-1", "model_not_found", message), - ) - .await; + let response_mock = + responses::mount_sse_once(&server, responses::sse_failed("resp-1", code, message)).await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; @@ -2858,10 +2860,7 @@ async fn thread_read_preserves_model_not_found_after_failed_turn() -> Result<()> let live: ErrorNotification = timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("error")).await??; assert!(!live.will_retry); - assert_eq!( - live.error.codex_error_info, - Some(CodexErrorInfo::ModelNotFound) - ); + assert_eq!(live.error.codex_error_info, Some(expected)); assert_eq!(live.error.message, message); timeout( DEFAULT_READ_TIMEOUT, @@ -2883,6 +2882,7 @@ async fn thread_read_preserves_model_not_found_after_failed_turn() -> Result<()> thread.turns.last().and_then(|turn| turn.error.as_ref()), Some(&live.error) ); + assert_eq!(response_mock.requests().len(), 1); Ok(()) } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 74877aedb0..ab43884ac8 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2534,6 +2534,9 @@ fn websocket_error_detail(err: &ApiError) -> String { "handshake error: model not found: [redacted]".to_string() } ApiError::InvalidPrompt { .. } => "handshake error: invalid prompt: [redacted]".to_string(), + ApiError::InferenceHookDenied { .. } => { + "handshake error: inference hook denied: [redacted]".to_string() + } ApiError::ContentPolicyViolation { .. } => { "handshake error: content policy violation".to_string() } diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 6a6823676e..07958edff9 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -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() } diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index c318bce45a..99bd0bc148 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -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()), diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index f379f33324..2793335324 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -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, @@ -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) } diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 333c2aa322..9c12aaed21 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -683,6 +683,7 @@ async fn process_sse_with_treatment( | ApiError::ContextWindowExceeded | ApiError::InvalidRequest { .. } | ApiError::SessionBudgetExceeded + | ApiError::InferenceHookDenied { .. } | ApiError::AccessProgramNotEnabled { .. } | ApiError::ContentPolicyViolation { .. } | ApiError::ResponseIncomplete(_) diff --git a/codex-rs/codex-api/src/sse/responses_error_tests.rs b/codex-rs/codex-api/src/sse/responses_error_tests.rs index 6cf0d763fe..4c31832ea0 100644 --- a/codex-rs/codex-api/src/sse/responses_error_tests.rs +++ b/codex-rs/codex-api/src/sse/responses_error_tests.rs @@ -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, diff --git a/codex-rs/core/src/compact_model_fallback.rs b/codex-rs/core/src/compact_model_fallback.rs index b8b55e8981..5a2973bafd 100644 --- a/codex-rs/core/src/compact_model_fallback.rs +++ b/codex-rs/core/src/compact_model_fallback.rs @@ -17,6 +17,7 @@ pub(crate) fn should_retry_with_current_model( CodexErrorDetails::TurnAborted | CodexErrorDetails::Interrupted | CodexErrorDetails::SessionBudgetExceeded + | CodexErrorDetails::InferenceHookDenied { .. } | CodexErrorDetails::WorkspaceAccessDenied(_) | CodexErrorDetails::ContentPolicyViolation { .. } | CodexErrorDetails::ResponseIncomplete(_) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 1fdf62faca..d775099466 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2036,7 +2036,9 @@ async fn run_sampling_request( } return Err(err); } - CodexErrorDetails::SessionBudgetExceeded | CodexErrorDetails::Fatal(_) => { + CodexErrorDetails::SessionBudgetExceeded + | CodexErrorDetails::InferenceHookDenied { .. } + | CodexErrorDetails::Fatal(_) => { return Err(err); } _ => err, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 5b2f67307d..4b78fd2518 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1200,36 +1200,78 @@ async fn explicit_denial_stops_compaction_on_model_downshift( Ok(()) } -#[test_case::test_case(json!({"input_tokens": 120_000, "output_tokens": 30_000, "total_tokens": 150_000}); "reported_usage")] -#[test_case::test_case(Value::Null; "missing_usage")] +#[test_case::test_case( + json!({ + "type": "response.incomplete", + "response": { + "id": "incomplete-compaction", + "incomplete_details": {"reason": "max_output_tokens"}, + "usage": {"input_tokens": 120_000, "output_tokens": 30_000, "total_tokens": 150_000} + } + }), + "Incomplete response returned, reason: max_output_tokens", + CodexErrorInfo::Other, + 270_000; + "incomplete_reported_usage" +)] +#[test_case::test_case( + json!({ + "type": "response.incomplete", + "response": { + "id": "incomplete-compaction", + "incomplete_details": {"reason": "max_output_tokens"}, + "usage": null + } + }), + "Incomplete response returned, reason: max_output_tokens", + CodexErrorInfo::Other, + 120_000; + "incomplete_missing_usage" +)] +#[test_case::test_case( + json!({ + "type": "error", + "error": {"code": "inference_hook_denied", "message": "Blocked by the workspace hook."} + }), + "Blocked by the workspace hook.", + CodexErrorInfo::InferenceHookDenied, + 120_000; + "inference_hook_denied_error" +)] +#[test_case::test_case( + json!({ + "type": "response.failed", + "response": { + "id": "denied-compaction", + "error": {"code": "inference_hook_denied", "message": "Blocked by the workspace hook."} + } + }), + "Blocked by the workspace hook.", + CodexErrorInfo::InferenceHookDenied, + 120_000; + "inference_hook_denied_response_failed" +)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn incomplete_response_during_compaction_is_terminal_and_counted( - reported_usage: Value, +async fn terminal_compaction_failure_preserves_usage_and_history( + failure_event: Value, + expected_error: &str, + expected_error_info: CodexErrorInfo, + expected_total_tokens: i64, ) -> Result<()> { skip_if_no_network!(Ok(())); - for (provider, expected_error) in [ + for (provider, error_prefix) in [ ( non_openai_model_provider as fn(&MockServer) -> ModelProviderInfo, - "Incomplete response returned, reason: max_output_tokens", - ), - ( - openai_model_provider, - "Error running remote compact task: Incomplete response returned, reason: max_output_tokens", + "", ), + (openai_model_provider, "Error running remote compact task: "), ] { let server = start_mock_server().await; let previous_model = "gpt-5.4"; let selected_model = "gpt-5.2"; - let incomplete = sse(vec![ - ev_assistant_message("discarded", "DISCARDED_INCOMPLETE_SUMMARY"), - json!({ - "type": "response.incomplete", - "response": { - "id": "incomplete-compaction", - "incomplete_details": {"reason": "max_output_tokens"}, - "usage": reported_usage - } - }), + let failure = sse(vec![ + ev_assistant_message("discarded", "DISCARDED_FAILED_SUMMARY"), + failure_event.clone(), ]); let response_mock = mount_sse_sequence( &server, @@ -1238,8 +1280,8 @@ async fn incomplete_response_during_compaction_is_terminal_and_counted( ev_assistant_message("initial", "Initial reply."), ev_completed_with_tokens("initial", /*total_tokens*/ 120_000), ]), - incomplete.clone(), - incomplete, + failure.clone(), + failure, ], ) .await; @@ -1270,7 +1312,7 @@ async fn incomplete_response_during_compaction_is_terminal_and_counted( // includes request overhead that the local history estimate does not count. test.submit_turn(&"x".repeat(440_000)).await?; // A smaller selected model forces compaction with the previous model. - // An incomplete result must stop both retries and fallback to the selected model. + // A terminal failure must stop both retries and fallback to the selected model. test.codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { @@ -1293,29 +1335,33 @@ async fn incomplete_response_during_compaction_is_terminal_and_counted( let mut errors = Vec::new(); wait_for_event(&test.codex, |event| { match event { - EventMsg::Error(error) => errors.push(error.message.clone()), + EventMsg::Error(error) => { + errors.push((error.message.clone(), error.codex_error_info.clone())); + } EventMsg::StreamError(value) => panic!("unexpected compaction retry: {value:?}"), EventMsg::RawResponseCompleted(value) => { - panic!("incomplete response marked complete: {value:?}") + panic!("failed response marked complete: {value:?}") } _ => {} } matches!(event, EventMsg::TurnComplete(_)) }) .await; - assert_eq!(errors, vec![expected_error]); + assert_eq!( + errors, + vec![( + format!("{error_prefix}{expected_error}"), + Some(expected_error_info.clone()) + )] + ); assert_eq!( test.codex .token_usage_info() .await - .expect("partial usage") + .expect("token usage") .total_token_usage .total_tokens, - if reported_usage.is_null() { - 120_000 - } else { - 270_000 - }, + expected_total_tokens, ); assert_eq!( response_mock @@ -1346,7 +1392,7 @@ async fn incomplete_response_during_compaction_is_terminal_and_counted( ); assert!(!body_contains_text( &requests[2].body_json().to_string(), - "DISCARDED_INCOMPLETE_SUMMARY", + "DISCARDED_FAILED_SUMMARY", )); } Ok(()) diff --git a/codex-rs/core/tests/suite/retry_after.rs b/codex-rs/core/tests/suite/retry_after.rs index 81bc8bc349..c2b1d6c9b8 100644 --- a/codex-rs/core/tests/suite/retry_after.rs +++ b/codex-rs/core/tests/suite/retry_after.rs @@ -1446,6 +1446,7 @@ async fn assert_terminal_failure( #[test_case::test_case("flex_unavailable", 429, CodexErrorInfo::FlexUnavailable, "Flex capacity unavailable."; "flex")] #[test_case::test_case("model_not_found", 404, CodexErrorInfo::ModelNotFound, "Access to this deployment ended."; "model")] +#[test_case::test_case("inference_hook_denied", 403, CodexErrorInfo::InferenceHookDenied, "Blocked by the workspace hook."; "inference_hook_denied")] #[tokio::test(flavor = "current_thread")] async fn http_and_sse_provider_errors_are_terminal( code: &str, @@ -1503,6 +1504,7 @@ async fn http_and_sse_provider_errors_are_terminal( #[test_case::test_case("flex_unavailable", 429, CodexErrorInfo::FlexUnavailable, "Flex capacity unavailable."; "flex")] #[test_case::test_case("model_not_found", 404, CodexErrorInfo::ModelNotFound, "Access to this deployment ended."; "model")] +#[test_case::test_case("inference_hook_denied", 403, CodexErrorInfo::InferenceHookDenied, "Blocked by the workspace hook."; "inference_hook_denied")] #[tokio::test(flavor = "current_thread")] async fn websocket_provider_errors_are_terminal( code: &str, diff --git a/codex-rs/ext/guardian-reviewer/src/retry.rs b/codex-rs/ext/guardian-reviewer/src/retry.rs index 2f847c69c2..819e28c46a 100644 --- a/codex-rs/ext/guardian-reviewer/src/retry.rs +++ b/codex-rs/ext/guardian-reviewer/src/retry.rs @@ -197,6 +197,7 @@ fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool { CodexErrorInfo::ContextWindowExceeded | CodexErrorInfo::SessionBudgetExceeded | CodexErrorInfo::UsageLimitExceeded + | CodexErrorInfo::InferenceHookDenied | CodexErrorInfo::CyberPolicy | CodexErrorInfo::BioPolicy | CodexErrorInfo::MisalignmentPolicyViolation diff --git a/codex-rs/ext/guardian-reviewer/src/retry_tests.rs b/codex-rs/ext/guardian-reviewer/src/retry_tests.rs index 6c75d25de2..0ce40b50f0 100644 --- a/codex-rs/ext/guardian-reviewer/src/retry_tests.rs +++ b/codex-rs/ext/guardian-reviewer/src/retry_tests.rs @@ -114,6 +114,13 @@ fn guardian_review_retry_only_retries_recoverable_errors() { )), false, ), + ( + GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info( + anyhow::anyhow!("workspace inference hook denied the request"), + CodexErrorInfo::InferenceHookDenied, + )), + false, + ), ( GuardianReviewSessionOutcome::Completed(Ok(Some(GuardianReviewResponse::new( "invalid assessment".to_owned(), diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs index f791a746d3..a92217b1b0 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs @@ -61,6 +61,7 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str { ApiError::ContentPolicyViolation { .. } => "content_policy_violation", ApiError::CyberPolicy { .. } | ApiError::BioPolicy { .. } + | ApiError::InferenceHookDenied { .. } | ApiError::MisalignmentPolicyViolation { .. } => "policy_error", ApiError::ServerOverloaded { .. } => "server_overloaded", ApiError::PolicyCapacityUnavailable { .. } => "policy_capacity_unavailable", diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs index dacde088f1..b6bcd4d4a4 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs @@ -163,6 +163,7 @@ impl SamplingExecution { | ApiError::InvalidAudioInput { .. } | ApiError::ModelNotFound { .. } | ApiError::InvalidPrompt { .. } + | ApiError::InferenceHookDenied { .. } | ApiError::AccessProgramNotEnabled { .. } | ApiError::ContentPolicyViolation { .. } | ApiError::MisalignmentPolicyViolation { .. } diff --git a/codex-rs/protocol/src/codex_error_info.rs b/codex-rs/protocol/src/codex_error_info.rs index 0239b00283..32d4df8802 100644 --- a/codex-rs/protocol/src/codex_error_info.rs +++ b/codex-rs/protocol/src/codex_error_info.rs @@ -26,6 +26,7 @@ enum CodexErrorInfoWire { ModelNotFound, /// Capacity is unavailable on a route restricted by server policy. PolicyCapacityUnavailable, + InferenceHookDenied, CyberPolicy, BioPolicy, MisalignmentPolicyViolation, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index ecb3eb47e6..ec0292b959 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -173,6 +173,9 @@ pub enum CodexErrorDetails { }, #[error("{message}")] InvalidPrompt { message: String }, + /// The workspace's inference hook explicitly denied this request. + #[error("{message}")] + InferenceHookDenied { message: String }, /// The provider rejected the input under its content policy. #[error("{message}")] ContentPolicyViolation { @@ -463,6 +466,7 @@ impl CodexErr { | CodexErrorDetails::ResponseIncomplete(_) | CodexErrorDetails::ModelNotFound { .. } | CodexErrorDetails::InvalidPrompt { .. } + | CodexErrorDetails::InferenceHookDenied { .. } | CodexErrorDetails::ContentPolicyViolation { .. } | CodexErrorDetails::ToolCollision(_) | CodexErrorDetails::RefreshTokenFailed(_) @@ -544,6 +548,7 @@ impl CodexErr { CodexErrorInfo::PolicyCapacityUnavailable } CodexErrorDetails::FlexUnavailable => CodexErrorInfo::FlexUnavailable, + CodexErrorDetails::InferenceHookDenied { .. } => CodexErrorInfo::InferenceHookDenied, CodexErrorDetails::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy, CodexErrorDetails::BioPolicy { .. } => CodexErrorInfo::BioPolicy, CodexErrorDetails::ModelNotFound { .. } => CodexErrorInfo::ModelNotFound, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 877e37a315..45f795a3d2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2106,6 +2106,8 @@ pub enum CodexErrorInfo { ModelNotFound, /// Capacity is unavailable on a route restricted by server policy. PolicyCapacityUnavailable, + /// The workspace's inference hook explicitly denied this request. + InferenceHookDenied, CyberPolicy, BioPolicy, MisalignmentPolicyViolation, @@ -2153,6 +2155,7 @@ impl CodexErrorInfo { | Self::ServerOverloaded | Self::ModelNotFound | Self::PolicyCapacityUnavailable + | Self::InferenceHookDenied | Self::CyberPolicy | Self::BioPolicy | Self::MisalignmentPolicyViolation diff --git a/codex-rs/response-debug-context/src/lib.rs b/codex-rs/response-debug-context/src/lib.rs index acc4c466b7..f80feb40a5 100644 --- a/codex-rs/response-debug-context/src/lib.rs +++ b/codex-rs/response-debug-context/src/lib.rs @@ -99,6 +99,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String { | ApiError::InvalidAudioInput { .. } | ApiError::InvalidPrompt { .. } => "invalid request".to_string(), ApiError::CyberPolicy { .. } => "cyber policy".to_string(), + ApiError::InferenceHookDenied { .. } => "inference hook denied".to_string(), ApiError::BioPolicy { .. } => "bio policy".to_string(), ApiError::MisalignmentPolicyViolation { .. } => "misalignment policy violation".to_string(), ApiError::ServerOverloaded { .. } => "server overloaded".to_string(), diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index ad8b9d0fa0..8c9130283c 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -8376,6 +8376,7 @@ class ActiveTurnNotSteerableCodexErrorInfo(BaseModel): class CodexErrorInfo( RootModel[ CodexErrorInfoValue + | Literal["inferenceHookDenied"] | HttpConnectionFailedCodexErrorInfo | ResponseStreamConnectionFailedCodexErrorInfo | ResponseStreamDisconnectedCodexErrorInfo @@ -8386,6 +8387,7 @@ class CodexErrorInfo( ): root: Annotated[ CodexErrorInfoValue + | Literal["inferenceHookDenied"] | HttpConnectionFailedCodexErrorInfo | ResponseStreamConnectionFailedCodexErrorInfo | ResponseStreamDisconnectedCodexErrorInfo