Handle bio policy errors in Codex (#31439)

## Summary

- Treat streamed Responses `bio_policy` failures as terminal invalid
requests instead of retryable stream errors.
- Recognize the new biology policy code and message in the TUI while
preserving the legacy `invalid_prompt` contract.
- Keep the existing dedicated biology safety notice and add
regression/snapshot coverage for all supported error shapes.

## Why

[openai/openai#1068559](https://github.com/openai/openai/pull/1068559)
gates a Responses API contract change from `invalid_prompt` and the
legacy message to `bio_policy` and new biology copy.

Without this compatibility change, streamed blocks are retried as
transient failures and the OSS TUI falls back to a generic/raw error
instead of the dedicated safety notice.

## Validation

- `just test -p codex-api` — 137 passed
- `just test -p codex-tui
app_server_safety_access_errors_render_dedicated_notice` — passed
- `just fix -p codex-api`
- `just fix -p codex-tui`
- `just fmt`
- `just test -p codex-tui` — 2,957 passed; two reproducible failures
remain in untouched Guardian feature-flag persistence tests:
-
`update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default`
-
`update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`
This commit is contained in:
Francis Chalissery
2026-07-07 10:33:42 -07:00
committed by GitHub
parent f6e251c3ac
commit 78df1237d1
4 changed files with 89 additions and 30 deletions

View File

@@ -398,7 +398,8 @@ 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 is_invalid_prompt_error(&error) {
} else if matches!(error.code.as_deref(), Some("invalid_prompt" | "bio_policy"))
{
let message = error
.message
.unwrap_or_else(|| "Invalid request.".to_string());
@@ -632,10 +633,6 @@ fn is_usage_not_included(error: &Error) -> bool {
error.code.as_deref() == Some("usage_not_included")
}
fn is_invalid_prompt_error(error: &Error) -> bool {
error.code.as_deref() == Some("invalid_prompt")
}
fn is_cyber_policy_error(error: &Error) -> bool {
error.code.as_deref() == Some("cyber_policy")
}
@@ -1076,23 +1073,42 @@ mod tests {
}
#[tokio::test]
async fn invalid_prompt_without_type_is_invalid_request() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#;
async fn content_policy_errors_without_type_are_invalid_requests() {
for (code, expected_message) in [
(
"invalid_prompt",
"Invalid prompt: we've limited access to this content for safety reasons.",
),
(
"bio_policy",
"This content was flagged for possible biological risk.",
),
] {
let raw_error = json!({
"type": "response.failed",
"sequence_number": 3,
"response": {
"id": "resp_content_policy_no_type",
"object": "response",
"created_at": 1759771628,
"status": "failed",
"background": false,
"error": { "code": code, "message": expected_message },
"incomplete_details": null,
},
})
.to_string();
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
let events = collect_events(&[sse1.as_bytes()]).await;
let events = collect_events(&[sse1.as_bytes()]).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::InvalidRequest { message }) => {
assert_eq!(
message,
"Invalid prompt: we've limited access to this content for safety reasons."
);
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::InvalidRequest { message }) => {
assert_eq!(message, expected_message);
}
other => panic!("unexpected event for {code}: {other:?}"),
}
other => panic!("unexpected event: {other:?}"),
}
}

View File

@@ -1182,11 +1182,26 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() {
#[tokio::test]
async fn app_server_safety_access_errors_render_dedicated_notice() {
let message = "Invalid prompt: we've limited access to this content for safety reasons.";
for message in [
message.to_string(),
json!({ "error": { "message": message } }).to_string(),
] {
let legacy_message = "Invalid prompt: we've limited access to this content for safety reasons.";
let bio_policy_message = "This content was flagged for possible biological risk.";
let cases = [
("legacy plain message", legacy_message.to_string()),
(
"legacy JSON message",
json!({ "error": { "message": legacy_message } }).to_string(),
),
("bio policy plain message", bio_policy_message.to_string()),
(
"bio policy JSON message",
json!({ "error": { "message": bio_policy_message } }).to_string(),
),
(
"bio policy code",
json!({ "error": { "code": "bio_policy", "message": "copy may change" } }).to_string(),
),
];
let mut rendered_cases = Vec::new();
for (case, message) in cases {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_non_retry_error(message, /*codex_error_info*/ None);
@@ -1195,8 +1210,17 @@ async fn app_server_safety_access_errors_render_dedicated_notice() {
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("This content can't be shown"));
assert!(rendered.contains("biological research"));
assert!(!rendered.contains("Invalid prompt:"));
rendered_cases.push((case, rendered));
}
let canonical = &rendered_cases[0].1;
for (case, rendered) in &rendered_cases[1..] {
assert_eq!(rendered, canonical, "unexpected rendering for {case}");
}
insta::assert_snapshot!(
"app_server_bio_policy_error_renders_dedicated_notice",
rendered_cases.last().unwrap().1.as_str()
);
}
#[tokio::test]

View File

@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/app_server.rs
expression: rendered_cases.last().unwrap().1.as_str()
---
ⓘ This content can't be shown
We take extra caution with requests involving biological research and
applications that could pose safety risks. Eligible researchers can apply
for Trusted Access.
Trusted Access: https://www.openai.com/form/trusted-access-for-biology-
research/
Learn more: https://help.openai.com/en/articles/20001326

View File

@@ -5,8 +5,15 @@
use super::*;
const SAFETY_ACCESS_BLOCK_PREFIX: &str =
const LEGACY_SAFETY_ACCESS_BLOCK_PREFIX: &str =
"Invalid prompt: we've limited access to this content for safety reasons.";
const BIO_POLICY_SAFETY_ACCESS_BLOCK_PREFIX: &str =
"This content was flagged for possible biological risk.";
fn is_safety_access_block_message(message: &str) -> bool {
message.starts_with(LEGACY_SAFETY_ACCESS_BLOCK_PREFIX)
|| message.starts_with(BIO_POLICY_SAFETY_ACCESS_BLOCK_PREFIX)
}
impl ChatWidget {
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
@@ -430,11 +437,12 @@ impl ChatWidget {
.is_some_and(is_app_server_cyber_policy_error)
{
self.on_cyber_policy_error();
} else if message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX)
} else if is_safety_access_block_message(&message)
|| serde_json::from_str::<serde_json::Value>(&message).is_ok_and(|response| {
response["error"]["message"]
.as_str()
.is_some_and(|message| message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX))
response["error"]["code"].as_str() == Some("bio_policy")
|| response["error"]["message"]
.as_str()
.is_some_and(is_safety_access_block_message)
})
{
self.input_queue.submit_pending_steers_after_interrupt = false;