From dc0cdf2930c05684588e1c7b63ad7741e5407a1a Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:09:23 +0800 Subject: [PATCH] fix(openai): normalize rejected reasoning replay fields Refs #194, #200. Co-authored-by: Codex --- .../providers/openai/src/provider/mod.rs | 8 +- .../openai/src/transport/downstream/body.rs | 31 +++ .../openai/src/transport/downstream/mod.rs | 1 + .../providers/openai/src/transport/mod.rs | 1 + .../openai/tests/provider/contract.rs | 214 ++++++++++++++++++ docs/api.md | 7 +- docs/architecture.md | 5 +- 7 files changed, 263 insertions(+), 4 deletions(-) diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 2214a1a81..77121f2e1 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -92,7 +92,7 @@ use crate::transport::{ CodexBackendJsonResponse, CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexRateLimitUpdates, CodexRequestContext, CodexResponseMetadata, CodexResponseMetadataUpdates, CodexTransportMetrics, CodexUpstreamDiagnostics, - CodexWebSocketPool, endpoint_url, + CodexWebSocketPool, endpoint_url, normalize_non_codex_request_body, }; mod execution; @@ -549,6 +549,12 @@ impl Provider for CodexProvider { lease.installation_id(), account_scope, ); + if matches!( + lease.authentication(), + crate::credential::CodexRuntimeAuthentication::OAuth(_) + ) { + normalize_non_codex_request_body(upstream_request.body_mut()); + } // 每次执行从原始请求编码,选定出口后再覆盖,避免换号时携带上次位置。 if let Some(location) = lease .account() diff --git a/backend/crates/providers/openai/src/transport/downstream/body.rs b/backend/crates/providers/openai/src/transport/downstream/body.rs index 73327fc4c..1d90b05c2 100644 --- a/backend/crates/providers/openai/src/transport/downstream/body.rs +++ b/backend/crates/providers/openai/src/transport/downstream/body.rs @@ -2,6 +2,37 @@ use serde_json::{Map, Value, json}; +/// 非官方客户端及跨客户端历史回填的兼容入口;调用方必须已选定 Codex/OAuth 上游。 +/// 只处理已验证被拒绝的字段,不根据客户端品牌推断整份历史是否合法。 +pub(crate) fn normalize_non_codex_request_body(body: &mut Map) { + let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) else { + return; + }; + for item in input { + let Some(item) = item.as_object_mut() else { + continue; + }; + if item.get("type").and_then(Value::as_str) != Some("reasoning") { + continue; + } + // SDK 输出项的 status 不属于 Codex reasoning 输入合同;其他项的 status 可能合法。 + item.shift_remove("status"); + // Codex 只接受空 content 数组;仅在加密历史仍可回填时去掉冗余明文, + // 没有加密内容的历史不自动丢弃,保留给上游明确拒绝。 + if item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|content| !content.trim().is_empty()) + && item + .get("content") + .and_then(Value::as_array) + .is_some_and(|content| !content.is_empty()) + { + item.shift_remove("content"); + } + } +} + /// 补齐 Codex 请求缺省字段并适配已确认不兼容的请求形状,不递归清洗业务正文。 /// /// 兼容基准是 Codex Core/Desktop 的模型请求,不是公开 OpenAI Responses API。 diff --git a/backend/crates/providers/openai/src/transport/downstream/mod.rs b/backend/crates/providers/openai/src/transport/downstream/mod.rs index d9c1be555..18deb91eb 100644 --- a/backend/crates/providers/openai/src/transport/downstream/mod.rs +++ b/backend/crates/providers/openai/src/transport/downstream/mod.rs @@ -5,4 +5,5 @@ mod body; mod headers; pub(super) use body::normalize_codex_request_body; +pub(crate) use body::normalize_non_codex_request_body; pub(super) use headers::is_non_codex_request_header; diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index bdd8d3d69..41671b260 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -19,6 +19,7 @@ mod response_meta; pub(crate) mod session; pub mod subscription; mod time; +pub(crate) use downstream::normalize_non_codex_request_body; pub(crate) use endpoints::valid_upstream_base_url; pub mod tls; pub mod usage; diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 4d9b9d201..f9173b032 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -267,6 +267,220 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c const OFFICIAL_FIXTURE: &[u8] = include_bytes!("../transport/fixtures/official_models_snapshot.json"); + +#[tokio::test] +async fn replay_compatibility_should_remove_only_reasoning_status_on_both_transports() { + let input = json!([ + {"type":"message","role":"user","status":"completed","content":[{"type":"input_text","text":"hello"}]}, + {"type":"reasoning","id":"rs_replay","status":"completed","summary":[],"content":[],"encrypted_content":"test-cipher","extension":{"status":"keep","content":[1]}}, + {"type":"function_call","call_id":"call_replay","name":"echo","arguments":"{}","status":"completed"}, + {"type":"function_call_output","call_id":"call_replay","output":{"status":"keep","content":[1]},"status":"completed"}, + {"type":"tool_search_output","call_id":"call_search","status":"completed","tools":[]}, + {"type":"reasoning","status":"in_progress","summary":[],"encrypted_content":"second-test-cipher"}, + {"type":"future_item","status":"keep","content":[1]}, + "opaque-item" + ]); + let mut expected = input.clone(); + for index in [1, 5] { + expected[index] + .as_object_mut() + .unwrap() + .shift_remove("status"); + } + for websocket in [false, true] { + let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await; + // 比较序列化结果,同时保护未修改字段的顺序。 + assert_eq!(actual["input"].to_string(), expected.to_string()); + } +} + +#[tokio::test] +async fn replay_compatibility_should_keep_encrypted_history_when_removing_nonempty_content() { + let input = json!([ + {"type":"reasoning","id":"rs_replay","summary":[{"type":"summary_text","text":"keep summary"}],"content":[{"type":"reasoning_text","text":"synthetic replay"}],"encrypted_content":"test-cipher","extension":9007199254740993_u64}, + {"type":"compaction","id":"cmp_replay","content":[{"type":"text","text":"keep compaction"}],"encrypted_content":"compaction-test-cipher"}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"keep answer"}]} + ]); + let mut expected = input.clone(); + expected[0].as_object_mut().unwrap().shift_remove("content"); + for websocket in [false, true] { + let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await; + assert_eq!(actual["input"].to_string(), expected.to_string()); + } +} + +#[tokio::test] +async fn replay_compatibility_should_preserve_plaintext_only_and_unrecognized_content_shapes() { + let input = json!([ + {"type":"reasoning","summary":[],"content":[{"type":"reasoning_text","text":"only history"}]}, + {"type":"reasoning","summary":[],"content":[1],"encrypted_content":""}, + {"type":"reasoning","summary":[],"content":[1],"encrypted_content":" "}, + {"type":"reasoning","summary":[],"content":[1],"encrypted_content":null}, + {"type":"reasoning","summary":[],"content":[1],"encrypted_content":42}, + {"type":"reasoning","summary":[],"content":{"status":"keep"},"encrypted_content":"test-cipher"}, + {"type":"reasoning","summary":[],"content":"keep","encrypted_content":"test-cipher"} + ]); + for websocket in [false, true] { + let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await; + assert_eq!(actual["input"].to_string(), input.to_string()); + } +} + +#[tokio::test] +async fn replay_compatibility_should_leave_normal_official_history_unchanged() { + let input = json!([ + {"type":"reasoning","id":"rs_empty","summary":[],"content":[],"encrypted_content":"test-cipher"}, + {"type":"reasoning","id":"rs_null","summary":[],"content":null,"encrypted_content":"test-cipher"}, + {"type":"reasoning","id":"rs_absent","summary":[],"encrypted_content":"test-cipher"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"do not change status/content"}]}, + {"type":"custom_tool_call","call_id":"call_custom","status":"completed","name":"custom","input":"keep"}, + {"type":"tool_search_call","call_id":"call_search","status":"completed","arguments":{}}, + {"type":"mcp_call","status":"completed","output":"keep"}, + {"summary":[],"content":[1],"status":"keep"}, + {"type":"future_item","content":[1],"status":"keep"}, + null, + "opaque-item" + ]); + for websocket in [false, true] { + let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await; + assert_eq!(actual["input"].to_string(), input.to_string()); + } +} + +#[tokio::test] +async fn replay_compatibility_should_not_apply_codex_rules_to_api_key_accounts() { + let input = json!([{ + "type":"reasoning","id":"rs_api","status":"completed","summary":[], + "content":[{"type":"reasoning_text","text":"API-specific history"}], + "encrypted_content":"test-cipher" + }]); + for websocket in [false, true] { + let actual = capture_replay_compatibility_request(input.clone(), true, websocket).await; + assert_eq!(actual["input"].to_string(), input.to_string()); + } +} + +#[tokio::test] +async fn replay_compatibility_should_preserve_non_array_input_without_guessing() { + for input in [ + Value::Null, + json!({"type":"reasoning","status":"keep"}), + json!(7), + ] { + let actual = capture_replay_compatibility_request(input.clone(), false, false).await; + assert_eq!(actual["input"], input); + } +} + +async fn capture_replay_compatibility_request( + input: Value, + api_key: bool, + websocket: bool, +) -> Value { + let http = MockServer::start().await; + let (base_url, websocket_server) = if websocket { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let task = + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_codex_test_websocket(stream).await; + let frame = websocket.next().await.unwrap().unwrap(); + let body: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + websocket.send(Message::Text(json!({ + "type":"response.completed","response":{ + "id":"resp_replay","model":"gpt-5.4","status":"completed","output":[], + "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2} + } + }).to_string().into())).await.unwrap(); + body + }); + (base_url, Some(task)) + } else { + Mock::given(method("POST")) + .and(path(if api_key { + "/responses" + } else { + "/codex/responses" + })) + .respond_with( + ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"), + ) + .expect(1) + .mount(&http) + .await; + (http.uri(), None) + }; + let store = Arc::new(MemoryAccountStore::default()); + if api_key { + store + .seed_api_key( + "acct_provider_contract", + base_url.clone(), + if websocket { + provider_openai::credential::ApiKeyTransport::PreferWebsocket + } else { + provider_openai::credential::ApiKeyTransport::Http + }, + ) + .await; + } else { + create_account(&store, "acct_provider_contract").await; + } + let original = json!({ + "model":"gpt-5.4","store":false,"stream":true,"input":input, + "instructions":"Preserve the original instructions.", + "tools":[{"type":"function","name":"echo","parameters":{"type":"object","properties":{"status":{"type":"string"},"content":{"type":"array"}}}}], + "future_field":{"status":"keep","content":[1]}, + "client_metadata":{"extension":{"status":"keep","content":[1]}} + }); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", original.as_object().unwrap().clone()) + .unwrap() + .with_context(Map::from_iter([( + "use_websocket".to_owned(), + json!(websocket), + )])), + )); + let provider = provider_with_base_url(&store, base_url); + let mut stream = provider + .execute( + planned_request("openai", operation), + context("req_replay_compatibility", CancellationToken::new()), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.expect("successful upstream completion"); + } + let actual = if let Some(server) = websocket_server { + timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + } else { + let requests = http.received_requests().await.unwrap(); + assert_eq!( + requests.len(), + 1, + "compatibility must run before the first send, without retry" + ); + captured_request_body(&requests[0]) + }; + for field in ["model", "store", "instructions", "tools", "future_field"] { + assert_eq!( + actual[field].to_string(), + original[field].to_string(), + "unexpected change in {field}" + ); + } + assert_eq!( + actual["client_metadata"]["extension"], + original["client_metadata"]["extension"] + ); + actual +} + const CAPTURE_COMPLETED_SSE: &str = concat!( "event: response.completed\n", "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_scope_capture\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n" diff --git a/docs/api.md b/docs/api.md index 3220562f1..970a9e525 100644 --- a/docs/api.md +++ b/docs/api.md @@ -195,9 +195,14 @@ Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_ou 因为 Codex 后端只接受条目数组;数组及其他类型原样透传。`input` 数组中显式指定 `type: "message"` 且 `role: "system"` 的消息,其角色转换为 Codex 接受的 `developer`;消息内容、顺序、其他字段和顶层 `instructions` 保持不变。HTTP/SSE 与 WebSocket 共用这条正文兼容规则。 -`prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。过滤只作用于顶层, +`prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。上述参数过滤只作用于顶层, 不删除工具参数 schema、输入内容或 `client_metadata` 内的同名业务字段;其他未知字段继续透传。 +Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品牌区分:显式 `type: "reasoning"` +的 `input` 项移除顶层 `status`;该项具有非空字符串 `encrypted_content` 时,还会移除非空数组 +`content`。其他字段及顺序保持不变,缺少非空加密载荷的明文历史由上游判定。 +普通消息、工具项及未知类型不受此规则影响,API Key 上游不应用此规则。 + 请求头过滤不提供客户端匿名化;系统提示词、工具定义、工具结果、工作目录及其他业务 metadata 保持原有语义,可能包含客户端环境信息。 diff --git a/docs/architecture.md b/docs/architecture.md index 009421d99..fd9cea0ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -207,7 +207,7 @@ OpenAI 的 OAuth 与 API Key 共用现有账号和事务。API Key 的 Base URL 通过通用画像输出客户端目录,OAuth 原生对象保留。通用账号层按 Provider 提交的 credential state 调度,不以是否存在 上游用户 ID 推断可用性;OAuth 未完成身份投影时由 Provider 保持 `unknown`。状态恢复和未补齐身份的凭据轮换保留 `unknown`。 API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。 -OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证与传输配置。 +OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。 OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配置的模型权限仍由 Core 与选号链路执行。 - OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone @@ -219,7 +219,8 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配 `body.rs` 管理已知顶层参数的过滤、缺省值补齐和已确认不兼容的 `input` 形状适配;兼容基准为 Codex Core/Desktop 请求协议, 不持有账号身份保护或会话规范化逻辑。 Provider 在 `transport/request.rs` 解码不透明头时组合兼容、身份与 HTTP 规则, - 同时调用正文兼容规则;HTTP/SSE 与 WebSocket 共用此边界。 + 同时调用不依赖账号的正文兼容规则。`body.rs` 中非官方客户端及跨客户端历史回填兼容的入口, + 由 Provider 在选定 Codex/OAuth 账号后、HTTP/WS 分流前调用;API Key 上游跳过该入口。 `transport/headers.rs` 负责上游身份保护和官方头组装。 会话别名只规范化请求头,不清除正文身份字段;未知业务扩展与响应诊断头不受影响,字段见 [Responses 合同](api.md#3-openai-数据面与模型目录)。提示词、工具及业务正文不做客户端品牌清洗。