add(core): safety check downgrade warning (#11964)

Add per-turn notice when a request is downgraded to a fallback model due
to cyber safety checks.

**Changes**

- codex-api: Emit a ServerModel event based on the openai-model response
header and/or response payload (SSE + WebSocket), including when the
model changes mid-stream.
- core: When the server-reported model differs from the requested model,
emit a single per-turn warning explaining the reroute to gpt-5.2 and
directing users to Trusted
    Access verification and the cyber safety explainer.
- app-server (v2): Surface these cyber model-routing warnings as
synthetic userMessage items with text prefixed by Warning: (and document
this behavior).
This commit is contained in:
Fouad Matin
2026-02-16 22:13:36 -08:00
committed by GitHub
parent 08f689843f
commit 02e9006547
12 changed files with 843 additions and 4 deletions

View File

@@ -163,6 +163,7 @@ impl Drop for WsStream {
const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
const X_MODELS_ETAG_HEADER: &str = "x-models-etag";
const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included";
const OPENAI_MODEL_HEADER: &str = "openai-model";
pub struct ResponsesWebsocketConnection {
stream: Arc<Mutex<Option<WsStream>>>,
@@ -170,6 +171,7 @@ pub struct ResponsesWebsocketConnection {
idle_timeout: Duration,
server_reasoning_included: bool,
models_etag: Option<String>,
server_model: Option<String>,
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
}
@@ -179,6 +181,7 @@ impl ResponsesWebsocketConnection {
idle_timeout: Duration,
server_reasoning_included: bool,
models_etag: Option<String>,
server_model: Option<String>,
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
) -> Self {
Self {
@@ -186,6 +189,7 @@ impl ResponsesWebsocketConnection {
idle_timeout,
server_reasoning_included,
models_etag,
server_model,
telemetry,
}
}
@@ -204,12 +208,16 @@ impl ResponsesWebsocketConnection {
let idle_timeout = self.idle_timeout;
let server_reasoning_included = self.server_reasoning_included;
let models_etag = self.models_etag.clone();
let server_model = self.server_model.clone();
let telemetry = self.telemetry.clone();
let request_body = serde_json::to_value(&request).map_err(|err| {
ApiError::Stream(format!("failed to encode websocket request: {err}"))
})?;
tokio::spawn(async move {
if let Some(model) = server_model {
let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await;
}
if let Some(etag) = models_etag {
let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await;
}
@@ -273,13 +281,14 @@ impl<A: AuthProvider> ResponsesWebsocketClient<A> {
merge_request_headers(&self.provider.headers, extra_headers, default_headers);
add_auth_headers_to_header_map(&self.auth, &mut headers);
let (stream, server_reasoning_included, models_etag) =
let (stream, server_reasoning_included, models_etag, server_model) =
connect_websocket(ws_url, headers, turn_state.clone()).await?;
Ok(ResponsesWebsocketConnection::new(
stream,
self.provider.stream_idle_timeout,
server_reasoning_included,
models_etag,
server_model,
telemetry,
))
}
@@ -304,7 +313,7 @@ async fn connect_websocket(
url: Url,
headers: HeaderMap,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<(WsStream, bool, Option<String>), ApiError> {
) -> Result<(WsStream, bool, Option<String>, Option<String>), ApiError> {
ensure_rustls_crypto_provider();
info!("connecting to websocket: {url}");
@@ -341,6 +350,11 @@ async fn connect_websocket(
.get(X_MODELS_ETAG_HEADER)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
let server_model = response
.headers()
.get(OPENAI_MODEL_HEADER)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
if let Some(turn_state) = turn_state
&& let Some(header_value) = response
.headers()
@@ -349,7 +363,12 @@ async fn connect_websocket(
{
let _ = turn_state.set(header_value.to_string());
}
Ok((WsStream::new(stream), reasoning_included, models_etag))
Ok((
WsStream::new(stream),
reasoning_included,
models_etag,
server_model,
))
}
fn websocket_config() -> WebSocketConfig {
@@ -469,6 +488,7 @@ async fn run_websocket_response_stream(
idle_timeout: Duration,
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
) -> Result<(), ApiError> {
let mut last_server_model: Option<String> = None;
let request_text = match serde_json::to_string(&request_body) {
Ok(text) => text,
Err(err) => {
@@ -536,6 +556,14 @@ async fn run_websocket_response_stream(
}
continue;
}
if let Some(model) = event.response_model()
&& last_server_model.as_deref() != Some(model.as_str())
{
let _ = tx_event
.send(Ok(ResponseEvent::ServerModel(model.clone())))
.await;
last_server_model = Some(model);
}
match process_responses_event(event) {
Ok(Some(event)) => {
let is_completed = matches!(event, ResponseEvent::Completed { .. });