fix(openai): default missing downstream store to false (#134)

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-17 14:54:52 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 47d08560a7
commit dcc61915e9
7 changed files with 134 additions and 13 deletions
@@ -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<String, Value>) {
pub(in crate::transport) fn normalize_codex_request_body(body: &mut Map<String, Value>) {
// 官方 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。
@@ -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;
@@ -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);
}
@@ -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::<Value>(&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::<Value>(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")
}
@@ -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"),
+2 -1
View File
@@ -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` 内的同名业务字段;其他未知字段继续透传。
+1 -1
View File
@@ -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 共用此边界。