mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Treat content policy violations as terminal errors (#60479)
## What changed Recognize `content_policy_violation` in HTTP 400 responses and streamed errors as a dedicated, non-retryable error. Preserve the provider's message and available HTTP status, use a fallback message when needed, and report `CodexErrorInfo::InvalidPrompt` to clients. End the response stream immediately on this error and stop compaction from falling back to the current model after a content policy rejection. Add redacted diagnostic output and a dedicated sampler failure reason. ## Testing Extend coverage for HTTP and wrapped WebSocket errors, missing or blank provider messages, terminal stream failures with retry headers, and content policy rejections during local and remote compaction on model downshift. GitOrigin-RevId: 592170ac30c07b0b303a8d5cb15bd35c8f821b55
This commit is contained in:
@@ -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]",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,6 +56,11 @@ pub enum ApiError {
|
||||
},
|
||||
#[error("invalid prompt: {message}")]
|
||||
InvalidPrompt { message: String },
|
||||
#[error("{message}")]
|
||||
ContentPolicyViolation {
|
||||
message: String,
|
||||
status: Option<StatusCode>,
|
||||
},
|
||||
#[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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -164,6 +164,7 @@ impl SamplingExecution {
|
||||
| ApiError::ModelNotFound { .. }
|
||||
| ApiError::InvalidPrompt { .. }
|
||||
| ApiError::AccessProgramNotEnabled { .. }
|
||||
| ApiError::ContentPolicyViolation { .. }
|
||||
| ApiError::MisalignmentPolicyViolation { .. }
|
||||
| ApiError::CyberPolicy { .. }
|
||||
| ApiError::BioPolicy { .. },
|
||||
|
||||
@@ -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<StatusCode>,
|
||||
},
|
||||
/// 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<u16> {
|
||||
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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user