From dcc61915e9735f1597d26cfae2f12eb8014259d8 Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:54:52 +0800 Subject: [PATCH] fix(openai): default missing downstream store to false (#134) Co-authored-by: Zyy Co-authored-by: Codex --- .../openai/src/transport/downstream/body.rs | 9 +- .../openai/src/transport/downstream/mod.rs | 2 +- .../providers/openai/src/transport/request.rs | 4 +- .../openai/tests/transport/downstream/body.rs | 125 +++++++++++++++++- .../openai/tests/transport/request.rs | 2 +- docs/api.md | 3 +- docs/architecture.md | 2 +- 7 files changed, 134 insertions(+), 13 deletions(-) diff --git a/backend/crates/providers/openai/src/transport/downstream/body.rs b/backend/crates/providers/openai/src/transport/downstream/body.rs index edb1a698d..135a6aa84 100644 --- a/backend/crates/providers/openai/src/transport/downstream/body.rs +++ b/backend/crates/providers/openai/src/transport/downstream/body.rs @@ -1,12 +1,15 @@ -//! 下游客户端发送的非 Codex 模型请求参数兼容。 +//! 下游客户端模型请求正文的 Codex 协议兼容。 use serde_json::{Map, Value}; -/// 仅移除已确认不适用于 Codex Responses 的顶层参数,不递归清洗业务正文。 +/// 补齐 Codex 请求缺省字段并移除已确认不适用的顶层参数,不递归清洗业务正文。 /// /// 兼容基准是 Codex Core/Desktop 的模型请求,不是公开 OpenAI Responses API。 /// 未知字段继续透传,不能因官方请求结构中没有某个字段就将其列入过滤规则。 -pub(in crate::transport) fn strip_non_codex_request_fields(body: &mut Map) { +pub(in crate::transport) fn normalize_codex_request_body(body: &mut Map) { + // 官方 Core/Desktop 显式发送 store=false;仅为缺字段的下游请求补齐,保留显式值。 + body.entry("store").or_insert(Value::Bool(false)); + for field in [ // Pi 普通 Responses 适配将 maxTokens 映射为 max_output_tokens, // temperature 则原样写入;Pi 的 Codex 适配也可能发送 temperature。 diff --git a/backend/crates/providers/openai/src/transport/downstream/mod.rs b/backend/crates/providers/openai/src/transport/downstream/mod.rs index d129022b3..d9c1be555 100644 --- a/backend/crates/providers/openai/src/transport/downstream/mod.rs +++ b/backend/crates/providers/openai/src/transport/downstream/mod.rs @@ -4,5 +4,5 @@ mod body; mod headers; -pub(super) use body::strip_non_codex_request_fields; +pub(super) use body::normalize_codex_request_body; pub(super) use headers::is_non_codex_request_header; diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index 3384445c6..350fdf489 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -14,7 +14,7 @@ use serde::Serialize as _; use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; -use crate::transport::downstream::{is_non_codex_request_header, strip_non_codex_request_fields}; +use crate::transport::downstream::{is_non_codex_request_header, normalize_codex_request_body}; use crate::transport::headers::is_managed_identity_header; use crate::transport::profile::CodexRequestLocation; use crate::transport::protocol::responses::{ @@ -120,7 +120,7 @@ fn adapt_codex_responses_body( location: Option<&CodexRequestLocation>, ) { body.insert("model".to_owned(), Value::String(upstream_model.to_owned())); - strip_non_codex_request_fields(body); + normalize_codex_request_body(body); if let Some(location) = location { align_structured_location_fields(body, Utc::now(), location); } diff --git a/backend/crates/providers/openai/tests/transport/downstream/body.rs b/backend/crates/providers/openai/tests/transport/downstream/body.rs index b8983f7f1..656a708c8 100644 --- a/backend/crates/providers/openai/tests/transport/downstream/body.rs +++ b/backend/crates/providers/openai/tests/transport/downstream/body.rs @@ -1,6 +1,7 @@ use gateway_core::operation::{GenerateRequest, ProtocolPayload}; use provider_openai::encode_generate_request; -use serde_json::{Value, json}; + +use super::super::*; #[test] fn encoder_should_adapt_pi_responses_parameters_without_losing_codex_fields() { @@ -43,13 +44,13 @@ fn encoder_should_adapt_pi_responses_parameters_without_losing_codex_fields() { } #[test] -fn encoder_should_preserve_unknown_fields_and_nested_business_parameters() { - let body = json!({ +fn encoder_should_default_missing_store_without_changing_unknown_or_nested_fields() { + let mut body = json!({ "model": "client-model", "input": "temperature and max_output_tokens are tool parameter names", "max_tokens": 256, "future_options": {"temperature": 0.7}, - "client_metadata": {"prompt_cache_retention": "business-value"}, + "client_metadata": {"prompt_cache_retention": "business-value", "store": true}, "tools": [{ "type": "function", "name": "configure_sampler", "parameters": { @@ -72,5 +73,121 @@ fn encoder_should_preserve_unknown_fields_and_nested_business_parameters() { ) .expect("encode business parameters"); + body["store"] = json!(false); assert_eq!(Value::Object(encoded.body().clone()), body); } + +#[test] +fn encoder_should_preserve_explicit_store_values() { + for store in [json!(false), json!(true), Value::Null, json!("false")] { + let body = json!({"model": "gpt-test", "input": "hello", "store": store}); + let encoded = encode_downstream_request(body.clone()); + + assert_eq!(Value::Object(encoded.body().clone()), body); + } +} + +#[tokio::test] +async fn backend_http_should_send_default_store_for_downstream_request() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind HTTP server"); + let address = listener.local_addr().expect("HTTP server address"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept HTTP client"); + let raw = read_http_request_with_body(&mut stream).await; + write_completed_sse_response(&mut stream).await; + let head_end = raw + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("HTTP head terminator") + + 4; + let body = zstd::stream::decode_all(&raw[head_end..]).expect("decode request body"); + serde_json::from_slice::(&body).expect("HTTP request JSON") + }); + let mut request = encode_downstream_request(json!({"model": "gpt-test", "input": "hello"})); + request.force_http_sse = true; + let client = CodexBackendClient::new( + reqwest::Client::builder() + .no_proxy() + .build() + .expect("HTTP client"), + format!("http://{address}"), + test_wire_profile(), + ); + + client + .create_response( + &request, + request_context("req_default_store_http", Some("test-account")), + ) + .await + .expect("HTTP response"); + + assert_eq!( + server.await.expect("HTTP server task"), + json!({ + "model": "gpt-test", "input": "hello", "store": false, "stream": true + }) + ); +} + +#[tokio::test] +async fn backend_websocket_should_send_default_store_for_downstream_request() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket server"); + let address = listener.local_addr().expect("WebSocket server address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept WebSocket client"); + let mut websocket = accept_codex_test_websocket(stream).await; + let message = websocket + .next() + .await + .expect("response.create") + .expect("valid frame"); + let body = serde_json::from_str::(message.to_text().expect("text frame")) + .expect("WebSocket request JSON"); + websocket + .send(Message::Text( + completed_websocket_response("resp_default_store", 1, 1).into(), + )) + .await + .expect("send terminal event"); + body + }); + let mut request = encode_downstream_request(json!({"model": "gpt-test", "input": "hello"})); + request.use_websocket = true; + let client = CodexBackendClient::new( + reqwest::Client::builder() + .no_proxy() + .build() + .expect("HTTP client"), + format!("http://{address}"), + test_wire_profile(), + ) + .with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)))); + + client + .create_response( + &request, + request_context("req_default_store_ws", Some("test-account")), + ) + .await + .expect("WebSocket response"); + + let body = server.await.expect("WebSocket server task"); + assert_eq!(body.get("store"), Some(&json!(false))); +} + +fn encode_downstream_request(body: Value) -> CodexResponsesRequest { + let payload = + ProtocolPayload::json_object("openai", body.as_object().expect("request object").clone()) + .expect("OpenAI payload"); + encode_generate_request( + &GenerateRequest::from_protocol_payload(payload), + "gpt-test", + None, + ) + .expect("encode downstream request") +} diff --git a/backend/crates/providers/openai/tests/transport/request.rs b/backend/crates/providers/openai/tests/transport/request.rs index 2934a6e77..694fcbf42 100644 --- a/backend/crates/providers/openai/tests/transport/request.rs +++ b/backend/crates/providers/openai/tests/transport/request.rs @@ -118,7 +118,7 @@ fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_ assert_eq!(encoded.body().get("model"), Some(&json!("gpt-routed"))); assert!(encoded.body().get("stream").is_none()); - assert!(encoded.body().get("store").is_none()); + assert_eq!(encoded.body().get("store"), Some(&json!(false))); assert_eq!(encoded.body().get("tool_choice"), Some(&json!("auto"))); assert_eq!( encoded.body().get("future_official_field"), diff --git a/docs/api.md b/docs/api.md index cd6b3cb05..8c06c9578 100644 --- a/docs/api.md +++ b/docs/api.md @@ -188,7 +188,8 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和 `traceparent`、`tracestate` 不因属于追踪字段而被删除。 Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_output_tokens` 和 -`prompt_cache_retention`。 +`prompt_cache_retention`。缺少顶层 `store` 时补齐 `false`,与官方 Codex 客户端一致; +显式提供的值保持原样。HTTP/SSE 与 WebSocket 共用这条正文兼容规则。 `prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。过滤只作用于顶层, 不删除工具参数 schema、输入内容或 `client_metadata` 内的同名业务字段;其他未知字段继续透传。 diff --git a/docs/architecture.md b/docs/architecture.md index 7a992be93..176300234 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -205,7 +205,7 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配 - Responses 的业务扩展头保留原始多值字节。API 负责剥离鉴权、账号身份和 HTTP 传输字段, 并提取会话语义;`gateway-protocol` 共享 HTTP 传输与网关链路字段分类。客户端兼容规则集中在 `providers/openai/src/transport/downstream/`:`headers.rs` 管理下游环境头和已提取语义的头部别名, - `body.rs` 管理不适用于 Codex Responses 的已知顶层参数;兼容基准为 Codex Core/Desktop 请求协议, + `body.rs` 管理已知顶层参数的过滤和缺省值补齐;兼容基准为 Codex Core/Desktop 请求协议, 不持有账号身份保护或会话规范化逻辑。 Provider 在 `transport/request.rs` 解码不透明头时组合兼容、身份与 HTTP 规则, 同时调用正文兼容规则;HTTP/SSE 与 WebSocket 共用此边界。