diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index d84a542475..74877aedb0 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::ContentPolicyViolation { .. } => { + "handshake error: content policy violation".to_string() + } ApiError::AccessProgramNotEnabled { .. } => { "handshake error: access program not enabled".to_string() } @@ -3379,6 +3382,10 @@ mod tests { status: Some(http::StatusCode::NOT_FOUND), }, ApiError::InvalidPrompt { message: message() }, + ApiError::ContentPolicyViolation { + message: message(), + status: None, + }, ApiError::AccessProgramNotEnabled { message: message() }, ApiError::CyberPolicy { message: message() }, ApiError::BioPolicy { message: message() }, @@ -3405,6 +3412,7 @@ mod tests { "handshake error: invalid audio input: [redacted]", "handshake error: model not found: [redacted]", "handshake error: invalid prompt: [redacted]", + "handshake error: content policy violation", "handshake error: access program not enabled", "handshake error: cyber policy: [redacted]", "handshake error: bio policy: [redacted]", diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index eb089df5e4..a0e2e732ab 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::ContentPolicyViolation { .. } | ApiError::CyberPolicy { .. } | ApiError::BioPolicy { .. } | ApiError::MisalignmentPolicyViolation { .. } => None, @@ -104,6 +105,9 @@ fn map_api_error_details(err: ApiError) -> CodexErr { ApiError::InvalidPrompt { message } => { CodexErr::new(CodexErrorDetails::InvalidPrompt { message }) } + ApiError::ContentPolicyViolation { message, status } => { + CodexErrorDetails::ContentPolicyViolation { message, status }.into() + } ApiError::CyberPolicy { message } => { CodexErr::new(CodexErrorDetails::CyberPolicy { message }) } diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 0f047ac89d..97e9c338eb 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -358,6 +358,11 @@ 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 [ + ( + "content_policy_violation", + CodexErrorInfo::InvalidPrompt, + "The input was rejected by the content policy.", + ), ( "bio_policy", CodexErrorInfo::BioPolicy, diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index 647db0b64b..349781cda6 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -56,6 +56,11 @@ pub enum ApiError { }, #[error("invalid prompt: {message}")] InvalidPrompt { message: String }, + #[error("{message}")] + ContentPolicyViolation { + message: String, + status: Option, + }, #[error("cyber policy: {message}")] CyberPolicy { message: String }, #[error("bio policy: {message}")] @@ -87,6 +92,17 @@ pub(crate) fn parse_terminal_provider_error( let error = error?; let code = error.get("code")?.as_str()?; match (code, status) { + ("content_policy_violation", None | Some(StatusCode::BAD_REQUEST)) => { + Some(ApiError::ContentPolicyViolation { + message: error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .unwrap_or("The input was rejected by the content policy.") + .to_owned(), + status, + }) + } // HTTP 403s retain their request ID and headers in the API bridge. ("access_program_not_enabled", None) => Some(ApiError::AccessProgramNotEnabled { message: error diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 91aff22ab0..7768eec16c 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -682,6 +682,7 @@ async fn process_sse_with_treatment( ApiError::FlexUnavailable | ApiError::SessionBudgetExceeded | ApiError::AccessProgramNotEnabled { .. } + | ApiError::ContentPolicyViolation { .. } | ApiError::ResponseIncomplete(_) ) { let _ = tx_event.send(Err(error)).await; 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 1274c009e3..7bf11b10f9 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), + ("content_policy_violation", CodexErrorInfo::InvalidPrompt), ( "access_program_not_enabled", CodexErrorInfo::HttpConnectionFailed { @@ -241,6 +242,7 @@ async fn streamed_retry_after_uses_http_header_validation() { async fn retry_header_survives_other_failed_responses() { for (code, expected_code) in [ ("server_is_overloaded", CodexErrorInfo::ServerOverloaded), + ("safety_violation", CodexErrorInfo::Other), ("unknown_failure", CodexErrorInfo::Other), ] { let event = serde_json::from_value(json!({ diff --git a/codex-rs/core/src/compact_model_fallback.rs b/codex-rs/core/src/compact_model_fallback.rs index a59cc616e8..f712431406 100644 --- a/codex-rs/core/src/compact_model_fallback.rs +++ b/codex-rs/core/src/compact_model_fallback.rs @@ -18,6 +18,7 @@ pub(crate) fn should_retry_with_current_model( | CodexErrorDetails::Interrupted | CodexErrorDetails::SessionBudgetExceeded | CodexErrorDetails::InvalidWorkspaceSelected(_) + | CodexErrorDetails::ContentPolicyViolation { .. } | CodexErrorDetails::ResponseIncomplete(_) ) || (previous.model_info().slug == current.model_info().slug && previous.cyber_access_program == current.cyber_access_program) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 44bcd790e9..7f5dbb2529 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1140,11 +1140,15 @@ async fn reasoning_effort_override_remote_v2_compaction_resets_pinned_effort( Ok(()) } -#[test_case::test_case(non_openai_model_provider; "local")] -#[test_case::test_case(openai_model_provider; "remote")] +#[test_case::test_case(non_openai_model_provider, 403, json!({"detail": {"code": "invalid_workspace_selected"}}); "local workspace denial")] +#[test_case::test_case(openai_model_provider, 403, json!({"detail": {"code": "invalid_workspace_selected"}}); "remote workspace denial")] +#[test_case::test_case(non_openai_model_provider, 400, json!({"error": {"code": "content_policy_violation", "param": "input"}}); "local content policy denial")] +#[test_case::test_case(openai_model_provider, 400, json!({"error": {"code": "content_policy_violation", "param": "input"}}); "remote content policy denial")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn workspace_denial_stops_compaction_on_model_downshift( +async fn explicit_denial_stops_compaction_on_model_downshift( provider: fn(&MockServer) -> ModelProviderInfo, + status: u16, + body: Value, ) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -1177,9 +1181,8 @@ async fn workspace_denial_stops_compaction_on_model_downshift( .await; test.submit_turn("first turn").await?; - let denied = responses::mount_response_handler(&server, |_| { - wiremock::ResponseTemplate::new(403) - .set_body_json(json!({ "detail": { "code": "invalid_workspace_selected" } })) + let denied = responses::mount_response_handler(&server, move |_| { + wiremock::ResponseTemplate::new(status).set_body_json(&body) }) .await; test.codex 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 99b60bec5b..f791a746d3 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs @@ -58,6 +58,7 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str { | ApiError::InvalidAudioInput { .. } | ApiError::InvalidPrompt { .. } => "invalid_request", ApiError::AccessProgramNotEnabled { .. } => "access_program_not_enabled", + ApiError::ContentPolicyViolation { .. } => "content_policy_violation", ApiError::CyberPolicy { .. } | ApiError::BioPolicy { .. } | ApiError::MisalignmentPolicyViolation { .. } => "policy_error", 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 7b869e910b..dacde088f1 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 @@ -164,6 +164,7 @@ impl SamplingExecution { | ApiError::ModelNotFound { .. } | ApiError::InvalidPrompt { .. } | ApiError::AccessProgramNotEnabled { .. } + | ApiError::ContentPolicyViolation { .. } | ApiError::MisalignmentPolicyViolation { .. } | ApiError::CyberPolicy { .. } | ApiError::BioPolicy { .. }, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 08719bc017..46c5612073 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -173,6 +173,12 @@ pub enum CodexErrorDetails { }, #[error("{message}")] InvalidPrompt { message: String }, + /// The provider rejected the input under its content policy. + #[error("{message}")] + ContentPolicyViolation { + message: String, + status: Option, + }, /// Multiple registered tools share the same effective name. #[error("duplicate tool: {0}")] ToolCollision(String), @@ -457,6 +463,7 @@ impl CodexErr { | CodexErrorDetails::ResponseIncomplete(_) | CodexErrorDetails::ModelNotFound { .. } | CodexErrorDetails::InvalidPrompt { .. } + | CodexErrorDetails::ContentPolicyViolation { .. } | CodexErrorDetails::ToolCollision(_) | CodexErrorDetails::RefreshTokenFailed(_) | CodexErrorDetails::GatewayLoginRequired(_) @@ -540,7 +547,8 @@ impl CodexErr { CodexErrorDetails::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy, CodexErrorDetails::BioPolicy { .. } => CodexErrorInfo::BioPolicy, CodexErrorDetails::ModelNotFound { .. } => CodexErrorInfo::ModelNotFound, - CodexErrorDetails::InvalidPrompt { .. } => CodexErrorInfo::InvalidPrompt, + CodexErrorDetails::InvalidPrompt { .. } + | CodexErrorDetails::ContentPolicyViolation { .. } => CodexErrorInfo::InvalidPrompt, CodexErrorDetails::MisalignmentPolicyViolation { .. } => { CodexErrorInfo::MisalignmentPolicyViolation } @@ -593,7 +601,8 @@ impl CodexErr { pub fn http_status_code_value(&self) -> Option { let http_status_code = match &self.details { CodexErrorDetails::FlexUnavailable => Some(StatusCode::TOO_MANY_REQUESTS), - CodexErrorDetails::ModelNotFound { status, .. } => *status, + CodexErrorDetails::ModelNotFound { status, .. } + | CodexErrorDetails::ContentPolicyViolation { status, .. } => *status, CodexErrorDetails::RetryLimit(err) => Some(err.status), CodexErrorDetails::InvalidWorkspaceSelected(err) | CodexErrorDetails::AccessProgramNotEnabled(ProviderRejection::Http(err)) diff --git a/codex-rs/response-debug-context/src/lib.rs b/codex-rs/response-debug-context/src/lib.rs index 5d7564aae1..acc4c466b7 100644 --- a/codex-rs/response-debug-context/src/lib.rs +++ b/codex-rs/response-debug-context/src/lib.rs @@ -94,6 +94,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String { ApiError::RateLimit(_) => "rate limit".to_string(), ApiError::ModelNotFound { .. } => "model not found".to_string(), ApiError::AccessProgramNotEnabled { .. } => "access program not enabled".to_string(), + ApiError::ContentPolicyViolation { .. } => "content policy violation".to_string(), ApiError::InvalidRequest { .. } | ApiError::InvalidAudioInput { .. } | ApiError::InvalidPrompt { .. } => "invalid request".to_string(),