From 8bd5a136ffabf5cd4868f9eb8aff298d1f25d8ef Mon Sep 17 00:00:00 2001 From: Won Park Date: Tue, 29 Sep 2026 01:05:37 +0000 Subject: [PATCH] Add recovery guidance to content-filter retries (#49119) ## Why Responses stopped by `content_filter` previously followed generic stream-error retry handling without guidance about the block. Retried requests need instructions to explain the limitation and offer a permitted alternative. ## What changed - Recognize content-filter failures as a distinct error while preserving retryability and the existing public error representation. - Append developer guidance to the conversation after each sampling block, retaining delivered output and using the existing retry handling. - Support a model catalog override through `model_messages.content_filter_guidance`. Missing, null, blank, or values exceeding 512 UTF-8 bytes fall back to bundled guidance. - The bundled guidance tells the model to avoid reproducing or working around blocked content, briefly explain the limitation, offer a permitted alternative, and continue unrelated authorized work. ## Testing Add a scenario and request-history snapshot covering repeated blocks, catalog guidance on each retry, retained partial output, and eventual completion. Add error-mapping assertions for retryability, public error classification, and error text. GitOrigin-RevId: ba0c201b3a5a3acf5cfa971332027240e0690225 --- .../app-server/tests/common/models_cache.rs | 1 + codex-rs/cli/src/doctor.rs | 3 +- codex-rs/codex-api/src/api_bridge.rs | 2 + codex-rs/codex-api/src/api_bridge_tests.rs | 19 +++++ codex-rs/codex-api/src/error.rs | 2 + codex-rs/codex-api/src/sse/responses.rs | 3 + .../src/context/content_filter_guidance.rs | 30 ++++++++ codex-rs/core/src/context/mod.rs | 2 + codex-rs/core/src/session/turn.rs | 37 +++++++--- .../suite/catalog_permission_messages.rs | 1 + .../tests/suite/collaboration_instructions.rs | 1 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/scenarios.rs | 3 + .../tests/suite/scenarios_content_filter.rs | 72 +++++++++++++++++++ ...ntent_filter__content_filter_guidance.snap | 26 +++++++ .../guardian-v2/src/async_scorer/metrics.rs | 2 +- .../src/async_scorer/sampler/execution.rs | 1 + .../models-manager/src/model_info_tests.rs | 1 + codex-rs/prompts/src/model_messages.rs | 16 ++++- codex-rs/protocol/src/error.rs | 6 ++ codex-rs/protocol/src/error_tests.rs | 1 + codex-rs/protocol/src/openai_models.rs | 5 ++ codex-rs/response-debug-context/src/lib.rs | 3 + 23 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 codex-rs/core/src/context/content_filter_guidance.rs create mode 100644 codex-rs/core/tests/suite/scenarios_content_filter.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__content_filter__content_filter_guidance.snap diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 0b14bc0035..8110221b6e 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -31,6 +31,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { available_access_programs: preset.available_access_programs.clone(), upgrade: preset.upgrade.as_ref().map(Into::into), model_messages: Some(ModelMessages { + content_filter_guidance: None, persistent_instructions: None, tools: None, instructions_template: Some("base instructions".to_string()), diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index d7014312c5..3dc3d07b83 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2429,7 +2429,8 @@ fn websocket_error_detail(err: &ApiError) -> String { format!("handshake API error: {status} {message}") } ApiError::Stream(message) => format!("handshake stream error: {message}"), - ApiError::ContextWindowExceeded + ApiError::ContentFilter + | ApiError::ContextWindowExceeded | ApiError::QuotaExceeded | ApiError::UsageNotIncluded | ApiError::Retryable { .. } diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 73bc7a787b..abf0bf0d70 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -28,6 +28,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { ApiError::Transport(_) | ApiError::Api { .. } | ApiError::Stream(_) + | ApiError::ContentFilter | ApiError::ContextWindowExceeded | ApiError::QuotaExceeded | ApiError::UsageNotIncluded @@ -56,6 +57,7 @@ fn map_api_error_details(err: ApiError) -> CodexErr { CodexErr::new(CodexErrorDetails::RateLimitExceeded(message)) } ApiError::Stream(msg) => CodexErr::Stream(msg), + ApiError::ContentFilter => CodexErr::new(CodexErrorDetails::ContentFilter), ApiError::ServerOverloaded { .. } => CodexErr::ServerOverloaded, ApiError::FlexUnavailable => CodexErr::new(CodexErrorDetails::FlexUnavailable), ApiError::Api { status, message } => { diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 252d2f2ed8..56c4838ef6 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -11,6 +11,25 @@ fn map_api_error_maps_server_overloaded() { assert!(matches!(err.details(), CodexErrorDetails::ServerOverloaded)); } +#[test] +fn map_api_error_preserves_content_filter_retry_and_public_error() { + let err = map_api_error(ApiError::ContentFilter); + assert!(matches!(err.details(), CodexErrorDetails::ContentFilter)); + assert_eq!( + ( + err.retry_delay(/*retry_count*/ 1).is_some(), + err.to_codex_protocol_error(), + err.to_string(), + ), + ( + true, + CodexErrorInfo::Other, + "stream disconnected before completion: Incomplete response returned, reason: content_filter" + .to_string(), + ) + ); +} + #[tokio::test(start_paused = true)] async fn map_api_error_preserves_retry_delay() { let retry_delay = std::time::Duration::from_secs(17); diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index e2c934ce8e..2834137129 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -14,6 +14,8 @@ pub enum ApiError { Api { status: StatusCode, message: String }, #[error("stream error: {0}")] Stream(String), + #[error("stream error: Incomplete response returned, reason: content_filter")] + ContentFilter, #[error("context window exceeded")] ContextWindowExceeded, #[error("quota exceeded")] diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index c01b13f1fe..6b2a7ed47c 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -425,6 +425,9 @@ pub fn process_responses_event( .and_then(Value::as_str) }); let reason = reason.unwrap_or("unknown"); + if reason == "content_filter" { + return Err(ResponsesEventError::Api(ApiError::ContentFilter)); + } if reason != "interrupted" { let message = format!("Incomplete response returned, reason: {reason}"); return Err(ResponsesEventError::Api(ApiError::Stream(message))); diff --git a/codex-rs/core/src/context/content_filter_guidance.rs b/codex-rs/core/src/context/content_filter_guidance.rs new file mode 100644 index 0000000000..f7c8f8570e --- /dev/null +++ b/codex-rs/core/src/context/content_filter_guidance.rs @@ -0,0 +1,30 @@ +//! Bounded recovery guidance for a response blocked by an unspecified content filter. + +use super::ContextualUserFragment; +use codex_protocol::models::ContentItemKind; + +pub(crate) struct ContentFilterGuidance { + pub(crate) text: String, +} + +impl ContextualUserFragment for ContentFilterGuidance { + fn content_kind(&self) -> ContentItemKind { + ContentItemKind("generic.content_filter_guidance".to_string()) + } + + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + format!("\n{}\n", self.text) + } +} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 36792d3b5b..e9cf47db67 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -6,6 +6,7 @@ mod apps_instructions; mod available_plugins_instructions; mod base_instructions; mod compaction_summary; +mod content_filter_guidance; mod contextual_user_message; mod current_time_reminder; mod developer_instructions; @@ -69,6 +70,7 @@ pub(crate) use codex_guardian_context::PermissionContext as GuardianPermissionCo pub(crate) use codex_prompts::MultiAgentRoleInstructions; pub use codex_prompts::PermissionsInstructions; pub(crate) use compaction_summary::CompactionSummary; +pub(crate) use content_filter_guidance::ContentFilterGuidance; pub(crate) use contextual_user_message::is_contextual_user_fragment; pub(crate) use contextual_user_message::is_guardian_context_message; pub(crate) use contextual_user_message::is_user_authorization_message; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 818d44dfac..53a37b7729 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -11,6 +11,7 @@ use crate::compact::InitialContextInjection; use crate::compact::run_inline_auto_compact_task; use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_remote_auto_compact_task_v2; use crate::connectors; +use crate::context::ContentFilterGuidance; use crate::context::ContextualUserFragment; use crate::context::UserVerificationNotice; use crate::environment_selection::TurnEnvironmentSnapshot; @@ -1697,15 +1698,33 @@ async fn run_sampling_request( let original_input = original_input.get_or_insert(prompt.input); - let retry = handle_response_stream_error( - &mut retry_state, - max_retries, - err, - client_session, - &sess, - &turn_context, - ResponsesStreamRequest::Sampling, - ) + let retry = async { + if matches!(err.details(), CodexErrorDetails::ContentFilter) { + let model_info = &step_context.settings.model_info; + let guidance = ContentFilterGuidance { + text: codex_prompts::ResolvedModelMessages::from_model(model_info) + .content_filter_guidance() + .to_string(), + }; + sess.record_conversation_items( + &turn_context, + model_info, + &[ContextualUserFragment::into(guidance)], + ) + .await; + } + + handle_response_stream_error( + &mut retry_state, + max_retries, + err, + client_session, + &sess, + &turn_context, + ResponsesStreamRequest::Sampling, + ) + .await + } .or_cancel(&preempt) .or_cancel(&cancellation_token) .await?; diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs index 892dfbc4b7..a07dbd8ee8 100644 --- a/codex-rs/core/tests/suite/catalog_permission_messages.rs +++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs @@ -31,6 +31,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul let model_slug = "remote-catalog-permissions-model"; let mut model = model_info_from_slug(model_slug); model.model_messages = Some(ModelMessages { + content_filter_guidance: None, persistent_instructions: None, tools: None, instructions_template: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 8882e18a8b..32c0ced077 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -74,6 +74,7 @@ fn model_with_collaboration_messages( ) -> codex_protocol::openai_models::ModelInfo { let mut model = model_info_from_slug(slug); let model_messages = model.model_messages.get_or_insert(ModelMessages { + content_filter_guidance: None, persistent_instructions: None, tools: None, instructions_template: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 2ffb1b80bd..c1e40be342 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -643,6 +643,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { available_access_programs: None, upgrade: None, model_messages: Some(ModelMessages { + content_filter_guidance: None, persistent_instructions: None, tools: None, instructions_template: Some("base instructions".to_string()), diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 475140e94c..43ab5ec6bd 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -118,6 +118,9 @@ mod guardian_heartbeat; #[path = "scenarios_preparation.rs"] mod preparation; +#[path = "scenarios_content_filter.rs"] +mod content_filter; + #[path = "scenarios_shared_instructions.rs"] mod shared_instructions; diff --git a/codex-rs/core/tests/suite/scenarios_content_filter.rs b/codex-rs/core/tests/suite/scenarios_content_filter.rs new file mode 100644 index 0000000000..9022904ebd --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_content_filter.rs @@ -0,0 +1,72 @@ +//! Content-filter retries retain delivered output and append catalog guidance after each block. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn content_filter_guidance_is_appended_after_each_block() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let test = test_codex() + .with_config(|config| { + configure_scenario_catalog(config); + config.model_provider.stream_max_retries = Some(2); + }) + .with_model_info_override("gpt-5.5", |model| { + model + .model_messages + .get_or_insert_default() + .content_filter_guidance = Some( + "Your previous response was blocked. Offer a permitted alternative.".to_string(), + ); + }) + .build_with_auto_env(&server) + .await?; + let blocked = json!({ + "type": "response.incomplete", + "response": { "incomplete_details": { "reason": "content_filter" } } + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("blocked-response"), + ev_assistant_message("partial-message", "I can help with this topic."), + blocked.clone(), + ]), + sse(vec![ev_response_created("blocked-again-response"), blocked]), + sse(vec![ + ev_response_created("recovery-response"), + ev_assistant_message( + "alternative-message", + "I can help with a permitted alternative.", + ), + ev_completed("recovery-response"), + ]), + ], + ) + .await; + + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Help me with this topic.".to_string(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_event(&test.codex, |event| { + assert!(!matches!(event, EventMsg::Error(_)), "{event:?}"); + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let requests = mock.requests(); + assert_eq!(requests.len(), 3); + insta::assert_snapshot!( + "content_filter_guidance", + context_snapshot::format_request_history_snapshot( + "Repeated content-filter blocks append catalog guidance and retain delivered output.", + &requests, + &ContextSnapshotOptions::default().rewrite_known_segments(), + ) + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__content_filter__content_filter_guidance.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__content_filter__content_filter_guidance.snap new file mode 100644 index 0000000000..85e396a9f1 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__content_filter__content_filter_guidance.snap @@ -0,0 +1,26 @@ +--- +source: core/tests/suite/scenarios_content_filter.rs +expression: "context_snapshot::format_request_history_snapshot(\"Repeated content-filter blocks append catalog guidance and retain delivered output.\",\n&requests, &ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: Repeated content-filter blocks append catalog guidance and retain delivered output. + +## Window 1 +-- request 1 (turn) -- +00:message/developer: + +01:message/user: + +02:message/user: + Help me with this topic. +-- request 2 (turn) -- +03:message/assistant: + I can help with this topic. +04:message/developer: + + Your previous response was blocked. Offer a permitted alternative. + +-- request 3 (turn) -- +05:message/developer: + + Your previous response was blocked. Offer a permitted alternative. + 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 33c59d5478..f629b6ff8c 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs @@ -43,7 +43,7 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str { ApiError::Transport(TransportError::Build(_)) => "request_build_error", ApiError::Transport(TransportError::ResponseTooLarge { .. }) => "response_too_large", ApiError::Transport(TransportError::Policy(_)) => "network_policy_denied", - ApiError::Stream(_) => "stream_error", + ApiError::Stream(_) | ApiError::ContentFilter => "stream_error", ApiError::ContextWindowExceeded => "context_window_exceeded", ApiError::QuotaExceeded => "quota_exceeded", ApiError::UsageNotIncluded => "usage_not_included", 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 e0fc66eb8c..a0d44c2217 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 @@ -54,6 +54,7 @@ impl SamplingExecution { ApiError::Retryable { .. } | ApiError::RateLimitExceeded { .. } | ApiError::Stream(_) + | ApiError::ContentFilter | ApiError::ServerOverloaded { .. } | ApiError::FlexUnavailable, ) diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 1d712bce77..891d896e94 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -82,6 +82,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { computer_use: Some(" # Native policy\r\n\n${native_markdown}\n".to_string()), }; let mut messages = ModelMessages { + content_filter_guidance: None, persistent_instructions: Some(persistent_instructions.to_string()), tools: Some(ToolMessages { send_user_message_async: Some(ToolMessage { diff --git a/codex-rs/prompts/src/model_messages.rs b/codex-rs/prompts/src/model_messages.rs index deb4ed3603..8b043aa3e6 100644 --- a/codex-rs/prompts/src/model_messages.rs +++ b/codex-rs/prompts/src/model_messages.rs @@ -1,5 +1,5 @@ //! Resolves model-owned messages from catalog values and bundled defaults. -//! Resolution preserves sparse catalog data, empty strings, and source identity. +//! Resolution preserves sparse catalog data and source identity, with family-specific validation. //! Prompt composition and runtime settings remain with consumers; each accessor //! selects and resolves only the requested message family. @@ -54,12 +54,14 @@ const REMINDER_MESSAGE_TEMPLATE: &str = concat!( "Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows." ); const PERSISTENT_INSTRUCTIONS: &str = include_str!("../templates/persistent_mode.md"); +const CONTENT_FILTER_GUIDANCE: &str = "Your previous response was blocked by a content filter. Do not treat this as a transient failure or try to reproduce or work around the blocked content through repeated attempts, altered formatting, splitting, encoding, tools, subagents, or later wakes. Briefly explain the limitation and offer a permitted alternative. Continue unrelated authorized work."; +const MAX_CONTENT_FILTER_GUIDANCE_BYTES: usize = 512; /// Resolves model-owned text from catalog overrides or bundled defaults, one family at a time. /// /// The view borrows the consumer's captured model metadata without resolving any families. /// Missing values select bundled text or retain delegation to consumer-owned settings. -/// Explicit empty strings remain overrides. +/// Explicit empty strings remain overrides unless a message family validates them. #[derive(Debug, Clone, Copy)] pub struct ResolvedModelMessages<'a> { catalog_messages: Option<&'a ModelMessages>, @@ -223,4 +225,14 @@ impl<'a> ResolvedModelMessages<'a> { .and_then(|messages| messages.persistent_instructions.as_deref()) .unwrap_or(PERSISTENT_INSTRUCTIONS) } + + /// Resolves bounded recovery guidance, falling back without truncating instructions. + pub fn content_filter_guidance(&self) -> &'a str { + self.catalog_messages + .and_then(|messages| messages.content_filter_guidance.as_deref()) + .filter(|text| { + !text.trim().is_empty() && text.len() <= MAX_CONTENT_FILTER_GUIDANCE_BYTES + }) + .unwrap_or(CONTENT_FILTER_GUIDANCE) + } } diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 2f4180acd0..d0dad844bd 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -94,6 +94,11 @@ pub enum CodexErrorDetails { /// The Session loop treats this as a transient error and will automatically retry the turn. #[error("stream disconnected before completion: {0}")] Stream(String), + /// A response stopped by the content filter. Sampling retries need developer guidance. + #[error( + "stream disconnected before completion: Incomplete response returned, reason: content_filter" + )] + ContentFilter, /// A retryable upstream rate limit received inside the response stream. #[error("rate limit exceeded: {0}")] RateLimitExceeded(String), @@ -411,6 +416,7 @@ impl CodexErr { | CodexErrorDetails::BioPolicy { .. } | CodexErrorDetails::MisalignmentPolicyViolation { .. } => None, CodexErrorDetails::Stream(..) + | CodexErrorDetails::ContentFilter | CodexErrorDetails::RateLimitExceeded(_) | CodexErrorDetails::Timeout | CodexErrorDetails::RequestTimeout diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index ae46d941bd..1efba0f415 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -40,6 +40,7 @@ async fn codex_err_debug_preserves_legacy_shape() { fn retryability_preserves_error_details_distinctions() { let errors = [ (CodexErr::ServerOverloaded, false), + (CodexErr::new(CodexErrorDetails::ContentFilter), true), ( CodexErr::new(CodexErrorDetails::RateLimitExceeded("retry later".into())), true, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index cb0c9f0a94..e5f02ac9e8 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -542,6 +542,10 @@ impl ModelInfo { /// retained to decode catalogs produced before personality selection was removed. #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelMessages { + /// Developer guidance after a content-filter block. Missing, null, blank, or values over + /// 512 UTF-8 bytes use the bundled guidance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_filter_guidance: Option, /// Additional developer instructions for persistent mode. Missing or null uses the built-in /// instructions; an empty string disables them. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1503,6 +1507,7 @@ mod tests { #[test] fn models_response_prefers_template_and_preserves_message_siblings() { let messages = ModelMessages { + content_filter_guidance: Some("Offer a permitted alternative.".to_string()), persistent_instructions: Some("Persistent catalog instructions".to_string()), tools: Some(ToolMessages { send_user_message_async: Some(ToolMessage { diff --git a/codex-rs/response-debug-context/src/lib.rs b/codex-rs/response-debug-context/src/lib.rs index 9b512cf348..e02ec6bc93 100644 --- a/codex-rs/response-debug-context/src/lib.rs +++ b/codex-rs/response-debug-context/src/lib.rs @@ -75,6 +75,9 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String { ApiError::Transport(transport) => telemetry_transport_error_message(transport), ApiError::Api { status, .. } => format!("api error {}", status.as_u16()), ApiError::Stream(err) => err.to_string(), + ApiError::ContentFilter => { + "Incomplete response returned, reason: content_filter".to_string() + } ApiError::ContextWindowExceeded => "context window exceeded".to_string(), ApiError::QuotaExceeded => "quota exceeded".to_string(), ApiError::UsageNotIncluded => "usage not included".to_string(),