mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Preserve bio policy errors as a distinct non-retryable error (#46306)
## Why Streaming `bio_policy` failures were classified as generic invalid requests, losing their policy-specific classification. ## What changed - Add `BioPolicy` errors across the API and core protocol, recognizing streaming failures and HTTP 400 responses, including wrapped WebSocket errors. - Preserve server messages and use a biological-risk fallback when the message is missing or blank. - Treat bio policy errors as non-retryable in core and guardian handling, and classify them in diagnostics and telemetry. - Map `BioPolicy` to `other` in the app-server v2 protocol. ## Testing Add coverage for error classification, message preservation and fallbacks, HTTP and wrapped WebSocket responses, guardian retry decisions, and app-server conversion. Extend the core integration test to verify that bio policy failures emit a typed error and complete the turn after a single request. GitOrigin-RevId: 78c2647e8fc8f80297cb8a23fff06ab141099632
This commit is contained in:
BIN
Binary file not shown.
@@ -129,6 +129,7 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
|
||||
CoreCodexErrorInfo::RateLimitExceeded => CodexErrorInfo::RateLimitExceeded,
|
||||
CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
|
||||
CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy,
|
||||
CoreCodexErrorInfo::BioPolicy => CodexErrorInfo::Other,
|
||||
CoreCodexErrorInfo::MisalignmentPolicyViolation => {
|
||||
CodexErrorInfo::MisalignmentPolicyViolation
|
||||
}
|
||||
|
||||
@@ -4671,6 +4671,7 @@ fn codex_error_info_serializes_http_status_code_in_camel_case() {
|
||||
fn core_error_info_converts_to_camel_case() {
|
||||
for (core, expected) in [
|
||||
(CoreCodexErrorInfo::CyberPolicy, json!("cyberPolicy")),
|
||||
(CoreCodexErrorInfo::BioPolicy, json!("other")),
|
||||
(
|
||||
CoreCodexErrorInfo::RateLimitExceeded,
|
||||
json!("rateLimitExceeded"),
|
||||
|
||||
@@ -2375,6 +2375,7 @@ fn websocket_error_detail(err: &ApiError) -> String {
|
||||
| ApiError::RateLimit(_)
|
||||
| ApiError::InvalidRequest { .. }
|
||||
| ApiError::CyberPolicy { .. }
|
||||
| ApiError::BioPolicy { .. }
|
||||
| ApiError::MisalignmentPolicyViolation { .. }
|
||||
| ApiError::ServerOverloaded => format!("handshake error: {err}"),
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
ApiError::CyberPolicy { message } => {
|
||||
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
|
||||
}
|
||||
ApiError::BioPolicy { message } => CodexErr::new(CodexErrorDetails::BioPolicy { message }),
|
||||
ApiError::MisalignmentPolicyViolation {
|
||||
message,
|
||||
misalignment,
|
||||
@@ -117,16 +118,25 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
if status == http::StatusCode::BAD_REQUEST {
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(&body_text)
|
||||
&& let Some(error) = parsed.get("error")
|
||||
&& error.get("code").and_then(Value::as_str)
|
||||
== Some(CYBER_POLICY_ERROR_CODE)
|
||||
&& let Some(code @ (CYBER_POLICY_ERROR_CODE | BIO_POLICY_ERROR_CODE)) =
|
||||
error.get("code").and_then(Value::as_str)
|
||||
{
|
||||
let fallback_message = if code == BIO_POLICY_ERROR_CODE {
|
||||
BIO_POLICY_FALLBACK_MESSAGE
|
||||
} else {
|
||||
CYBER_POLICY_FALLBACK_MESSAGE
|
||||
};
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string());
|
||||
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
|
||||
.unwrap_or_else(|| fallback_message.to_string());
|
||||
if code == BIO_POLICY_ERROR_CODE {
|
||||
CodexErr::new(CodexErrorDetails::BioPolicy { message })
|
||||
} else {
|
||||
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
|
||||
}
|
||||
} else if body_text
|
||||
.contains("The image data you provided does not represent a valid image")
|
||||
{
|
||||
@@ -227,6 +237,8 @@ const X_ERROR_JSON_HEADER: &str = "x-error-json";
|
||||
const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy";
|
||||
const CYBER_POLICY_FALLBACK_MESSAGE: &str =
|
||||
"This request has been flagged for possible cybersecurity risk.";
|
||||
const BIO_POLICY_ERROR_CODE: &str = "bio_policy";
|
||||
const BIO_POLICY_FALLBACK_MESSAGE: &str = "This content was flagged for possible biological risk.";
|
||||
const MISALIGNMENT_POLICY_VIOLATION_ERROR_CODE: &str = "misalignment_policy_violation";
|
||||
const MISALIGNMENT_POLICY_VIOLATION_FALLBACK_MESSAGE: &str =
|
||||
"This request was blocked due to a misalignment policy violation.";
|
||||
|
||||
@@ -181,6 +181,53 @@ fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_preserves_bio_policy() {
|
||||
let err = map_api_error(ApiError::BioPolicy {
|
||||
message: "This request was blocked by bio policy.".to_string(),
|
||||
});
|
||||
assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::BioPolicy);
|
||||
assert_eq!(err.to_string(), "This request was blocked by bio policy.");
|
||||
assert!(!err.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_http_and_wrapped_websocket_bio_policy() {
|
||||
for wrapped in [false, true] {
|
||||
for message in [
|
||||
Some("This request was blocked by bio policy."),
|
||||
None,
|
||||
Some(""),
|
||||
Some(" "),
|
||||
] {
|
||||
let mut body = serde_json::json!({"error": {"code": "bio_policy"}});
|
||||
if let Some(message) = message {
|
||||
body["error"]["message"] = serde_json::json!(message);
|
||||
}
|
||||
if wrapped {
|
||||
body["type"] = serde_json::json!("error");
|
||||
body["status"] = serde_json::json!(400);
|
||||
}
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::BAD_REQUEST,
|
||||
url: None,
|
||||
headers: None,
|
||||
body: Some(body.to_string()),
|
||||
}));
|
||||
|
||||
let expected = message
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.unwrap_or("This content was flagged for possible biological risk.");
|
||||
let CodexErrorDetails::BioPolicy { message } = err.details() else {
|
||||
panic!("expected CodexErrorDetails::BioPolicy, got {err:?}");
|
||||
};
|
||||
assert_eq!(message, expected);
|
||||
assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::BioPolicy);
|
||||
assert!(!err.is_retryable());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_misalignment_policy_violation_from_400_body() {
|
||||
assert_misalignment_policy_violation_from_http_body(http::StatusCode::BAD_REQUEST);
|
||||
|
||||
@@ -35,6 +35,8 @@ pub enum ApiError {
|
||||
InvalidRequest { message: String },
|
||||
#[error("cyber policy: {message}")]
|
||||
CyberPolicy { message: String },
|
||||
#[error("bio policy: {message}")]
|
||||
BioPolicy { message: String },
|
||||
#[error("misalignment policy violation: {message}")]
|
||||
MisalignmentPolicyViolation {
|
||||
message: String,
|
||||
|
||||
@@ -429,6 +429,14 @@ pub fn process_responses_event(
|
||||
} else if is_cyber_policy_error(&error) {
|
||||
let message = cyber_policy_message(error.message);
|
||||
response_error = ApiError::CyberPolicy { message };
|
||||
} else if error.code.as_deref() == Some("bio_policy") {
|
||||
let message = error
|
||||
.message
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
"This content was flagged for possible biological risk.".to_string()
|
||||
});
|
||||
response_error = ApiError::BioPolicy { message };
|
||||
} else if error.code.as_deref() == Some("misalignment_policy_violation") {
|
||||
let message = error
|
||||
.message
|
||||
@@ -443,8 +451,7 @@ pub fn process_responses_event(
|
||||
serde_json::from_value::<MisalignmentErrorDetails>(details).ok()
|
||||
}),
|
||||
};
|
||||
} else if matches!(error.code.as_deref(), Some("invalid_prompt" | "bio_policy"))
|
||||
{
|
||||
} else if error.code.as_deref() == Some("invalid_prompt") {
|
||||
let message = error
|
||||
.message
|
||||
.unwrap_or_else(|| "Invalid request.".to_string());
|
||||
@@ -1366,7 +1373,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_policy_errors_without_type_are_invalid_requests() {
|
||||
async fn content_policy_errors_without_type_preserve_their_classification() {
|
||||
for (code, expected_message) in [
|
||||
(
|
||||
"invalid_prompt",
|
||||
@@ -1396,8 +1403,9 @@ mod tests {
|
||||
let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
Err(ApiError::InvalidRequest { message }) => {
|
||||
match (code, &events[0]) {
|
||||
("invalid_prompt", Err(ApiError::InvalidRequest { message }))
|
||||
| ("bio_policy", Err(ApiError::BioPolicy { message })) => {
|
||||
assert_eq!(message, expected_message);
|
||||
}
|
||||
other => panic!("unexpected event for {code}: {other:?}"),
|
||||
@@ -1405,6 +1413,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bio_policy_error_uses_fallback_for_missing_or_blank_message() {
|
||||
for message in [None, Some(""), Some(" ")] {
|
||||
let mut event = json!({
|
||||
"type": "response.failed",
|
||||
"response": { "error": { "code": "bio_policy" } },
|
||||
});
|
||||
if let Some(message) = message {
|
||||
event["response"]["error"]["message"] = json!(message);
|
||||
}
|
||||
let sse = format!("event: response.failed\ndata: {event}\n\n");
|
||||
let events = collect_events(&[sse.as_bytes()]).await;
|
||||
match events.as_slice() {
|
||||
[Err(ApiError::BioPolicy { message })] => assert_eq!(
|
||||
message,
|
||||
"This content was flagged for possible biological risk."
|
||||
),
|
||||
other => panic!("unexpected events: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_driven_event_kinds() {
|
||||
struct TestCase {
|
||||
|
||||
@@ -38,6 +38,7 @@ const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
|
||||
|
||||
const CYBER_POLICY_MESSAGE: &str =
|
||||
"This request has been flagged for potentially high-risk cyber activity.";
|
||||
const BIO_POLICY_MESSAGE: &str = "This request has been flagged for possible biological risk.";
|
||||
|
||||
fn disabled_text_turn(test: &TestCodex, text: &str) -> TurnInputRequest {
|
||||
let (sandbox_policy, permission_profile) =
|
||||
@@ -105,34 +106,48 @@ async fn openai_model_header_mismatch_emits_warning_event() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case("cyber_policy", CYBER_POLICY_MESSAGE, CodexErrorInfo::CyberPolicy; "cyber")]
|
||||
#[test_case::test_case("bio_policy", BIO_POLICY_MESSAGE, CodexErrorInfo::BioPolicy; "bio")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cyber_policy_response_emits_typed_error_without_retry() -> Result<()> {
|
||||
async fn policy_response_emits_typed_error_without_retry(
|
||||
code: &str,
|
||||
message: &str,
|
||||
error_info: CodexErrorInfo,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": CYBER_POLICY_MESSAGE,
|
||||
"message": message,
|
||||
"type": "invalid_request",
|
||||
"param": null,
|
||||
"code": "cyber_policy"
|
||||
"code": code
|
||||
}
|
||||
}));
|
||||
let mock = mount_response_once(&server, response).await;
|
||||
|
||||
let mut builder = test_codex().with_model(REQUESTED_MODEL);
|
||||
let test = builder.build(&server).await?;
|
||||
let test = builder.build_with_auto_env(&server).await?;
|
||||
|
||||
test.codex
|
||||
.start_or_steer_turn(disabled_text_turn(&test, "trigger cyber policy error"))
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "trigger policy error".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
|
||||
let error = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await;
|
||||
let EventMsg::Error(error) = error else {
|
||||
panic!("expected error event");
|
||||
};
|
||||
assert_eq!(error.message, CYBER_POLICY_MESSAGE);
|
||||
assert_eq!(error.codex_error_info, Some(CodexErrorInfo::CyberPolicy));
|
||||
assert_eq!(error.message, message);
|
||||
assert_eq!(error.codex_error_info, Some(error_info));
|
||||
|
||||
let _ = wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
mock.single_request();
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool {
|
||||
| CodexErrorInfo::SessionBudgetExceeded
|
||||
| CodexErrorInfo::UsageLimitExceeded
|
||||
| CodexErrorInfo::CyberPolicy
|
||||
| CodexErrorInfo::BioPolicy
|
||||
| CodexErrorInfo::MisalignmentPolicyViolation
|
||||
| CodexErrorInfo::Unauthorized
|
||||
| CodexErrorInfo::BadRequest
|
||||
|
||||
@@ -92,6 +92,13 @@ fn guardian_review_retry_only_retries_transient_session_and_parse_errors() {
|
||||
)),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
|
||||
anyhow::anyhow!("bio policy"),
|
||||
CodexErrorInfo::BioPolicy,
|
||||
)),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::parse(anyhow::anyhow!("parse"))),
|
||||
true,
|
||||
|
||||
@@ -49,9 +49,9 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str {
|
||||
ApiError::Retryable { .. } => "retryable_api_error",
|
||||
ApiError::RateLimitExceeded { .. } | ApiError::RateLimit(_) => "rate_limit",
|
||||
ApiError::InvalidRequest { .. } => "invalid_request",
|
||||
ApiError::CyberPolicy { .. } | ApiError::MisalignmentPolicyViolation { .. } => {
|
||||
"policy_error"
|
||||
}
|
||||
ApiError::CyberPolicy { .. }
|
||||
| ApiError::BioPolicy { .. }
|
||||
| ApiError::MisalignmentPolicyViolation { .. } => "policy_error",
|
||||
ApiError::ServerOverloaded => "server_overloaded",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@ impl SamplingExecution {
|
||||
| ApiError::RateLimit(_)
|
||||
| ApiError::InvalidRequest { .. }
|
||||
| ApiError::MisalignmentPolicyViolation { .. }
|
||||
| ApiError::CyberPolicy { .. },
|
||||
| ApiError::CyberPolicy { .. }
|
||||
| ApiError::BioPolicy { .. },
|
||||
) => false,
|
||||
};
|
||||
if retryable && *retries < MAX_SAMPLING_RETRIES {
|
||||
|
||||
@@ -138,6 +138,8 @@ pub enum CodexErrorDetails {
|
||||
#[error("{message}")]
|
||||
CyberPolicy { message: String },
|
||||
#[error("{message}")]
|
||||
BioPolicy { message: String },
|
||||
#[error("{message}")]
|
||||
MisalignmentPolicyViolation {
|
||||
message: String,
|
||||
misalignment: Option<MisalignmentErrorDetails>,
|
||||
@@ -394,6 +396,7 @@ impl CodexErr {
|
||||
| CodexErrorDetails::UsageLimitReached(_)
|
||||
| CodexErrorDetails::ServerOverloaded
|
||||
| CodexErrorDetails::CyberPolicy { .. }
|
||||
| CodexErrorDetails::BioPolicy { .. }
|
||||
| CodexErrorDetails::MisalignmentPolicyViolation { .. } => false,
|
||||
CodexErrorDetails::Stream(..)
|
||||
| CodexErrorDetails::RateLimitExceeded(_)
|
||||
@@ -439,6 +442,7 @@ impl CodexErr {
|
||||
| CodexErrorDetails::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
|
||||
CodexErrorDetails::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
|
||||
CodexErrorDetails::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy,
|
||||
CodexErrorDetails::BioPolicy { .. } => CodexErrorInfo::BioPolicy,
|
||||
CodexErrorDetails::MisalignmentPolicyViolation { .. } => {
|
||||
CodexErrorInfo::MisalignmentPolicyViolation
|
||||
}
|
||||
|
||||
@@ -1858,6 +1858,7 @@ pub enum CodexErrorInfo {
|
||||
RateLimitExceeded,
|
||||
ServerOverloaded,
|
||||
CyberPolicy,
|
||||
BioPolicy,
|
||||
MisalignmentPolicyViolation,
|
||||
HttpConnectionFailed {
|
||||
http_status_code: Option<u16>,
|
||||
@@ -1899,6 +1900,7 @@ impl CodexErrorInfo {
|
||||
| Self::RateLimitExceeded
|
||||
| Self::ServerOverloaded
|
||||
| Self::CyberPolicy
|
||||
| Self::BioPolicy
|
||||
| Self::MisalignmentPolicyViolation
|
||||
| Self::HttpConnectionFailed { .. }
|
||||
| Self::ResponseStreamConnectionFailed { .. }
|
||||
|
||||
@@ -19,10 +19,7 @@ pub struct ResponseDebugContext {
|
||||
pub fn extract_response_debug_context(transport: &TransportError) -> ResponseDebugContext {
|
||||
let mut context = ResponseDebugContext::default();
|
||||
|
||||
let TransportError::Http {
|
||||
headers, body: _, ..
|
||||
} = transport
|
||||
else {
|
||||
let TransportError::Http { headers, .. } = transport else {
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -85,6 +82,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String {
|
||||
ApiError::RateLimit(_) => "rate limit".to_string(),
|
||||
ApiError::InvalidRequest { .. } => "invalid request".to_string(),
|
||||
ApiError::CyberPolicy { .. } => "cyber policy".to_string(),
|
||||
ApiError::BioPolicy { .. } => "bio policy".to_string(),
|
||||
ApiError::MisalignmentPolicyViolation { .. } => "misalignment policy violation".to_string(),
|
||||
ApiError::ServerOverloaded => "server overloaded".to_string(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user