mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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
@@ -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` 内的同名业务字段;其他未知字段继续透传。
|
||||
|
||||
|
||||
@@ -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 共用此边界。
|
||||
|
||||
Reference in New Issue
Block a user