diff --git a/backend/crates/providers/openai/src/compat/messages.rs b/backend/crates/providers/openai/src/compat/messages.rs index d8e2745e1..5d462616e 100644 --- a/backend/crates/providers/openai/src/compat/messages.rs +++ b/backend/crates/providers/openai/src/compat/messages.rs @@ -1,8 +1,19 @@ -//! 跨协议生成的 Messages fast 请求补充协议头,不改写原生 Messages 请求 +//! Messages 跨协议转换的目标档位与 fast 协议头适配 use bytes::Bytes; use gateway_core::engine::middleware::MiddlewareHeader; use reqwest::header::HeaderMap; +use serde_json::{Map, Value}; + +pub(crate) fn normalize_translated_codex_service_tier(body: &mut Map) { + // 转换生成的默认档位沿用 Codex 客户端的省略表达,原生 Responses 保留调用方声明 + if matches!( + body.get("service_tier").and_then(Value::as_str), + Some("auto" | "default") + ) { + body.shift_remove("service_tier"); + } +} pub(crate) fn add_translated_fast_beta(headers: &mut Vec, forwarded: &HeaderMap) { const BETA: &[u8] = b"fast-mode-2026-02-01"; diff --git a/backend/crates/providers/openai/src/compat/mod.rs b/backend/crates/providers/openai/src/compat/mod.rs index ba32061d1..820112513 100644 --- a/backend/crates/providers/openai/src/compat/mod.rs +++ b/backend/crates/providers/openai/src/compat/mod.rs @@ -11,7 +11,7 @@ use gateway_core::engine::middleware::MiddlewareHeader; use serde_json::{Map, Value}; pub(crate) use headers::is_non_codex_request_header; -pub(crate) use messages::add_translated_fast_beta; +pub(crate) use messages::{add_translated_fast_beta, normalize_translated_codex_service_tier}; pub(crate) use request::normalize_codex_request_body; pub(crate) use search_tools::declares_hosted_search; diff --git a/backend/crates/providers/openai/src/compat/request.rs b/backend/crates/providers/openai/src/compat/request.rs index 0a71105a7..48f87f45f 100644 --- a/backend/crates/providers/openai/src/compat/request.rs +++ b/backend/crates/providers/openai/src/compat/request.rs @@ -35,13 +35,6 @@ pub(super) fn normalize_non_codex_request_body(body: &mut Map) { } fn normalize_codex_oauth_options(body: &mut Map) { - // Codex Core 省略默认档位,OAuth 不接受公开 API 的 auto/default 字面值 - if matches!( - body.get("service_tier").and_then(Value::as_str), - Some("auto" | "default") - ) { - body.shift_remove("service_tier"); - } // Codex OAuth 拒绝这些客户端选项,即使采样参数取默认值 for field in [ "top_p", diff --git a/backend/crates/providers/openai/src/provider/messages/mod.rs b/backend/crates/providers/openai/src/provider/messages/mod.rs index aa1c7def7..af13984a4 100644 --- a/backend/crates/providers/openai/src/provider/messages/mod.rs +++ b/backend/crates/providers/openai/src/provider/messages/mod.rs @@ -87,8 +87,14 @@ impl CodexProvider { let mut source_context = generate.protocol_payload().context().clone(); retain_target_headers(source, &mut source_context, &mut headers); let translator = translator::ResponsesResponseTranslator::new(original); - let translated = + let mut translated = messages_to_responses_request(original).map_err(|_| invalid_request())?; + if matches!( + selected.lease.authentication(), + CodexRuntimeAuthentication::OAuth(_) + ) { + crate::compat::normalize_translated_codex_service_tier(&mut translated); + } let body = serde_json::to_vec(&translated).map_err(|_| invalid_request())?; let operation = operation .replace_protocol_wire(None, source_context) diff --git a/backend/crates/providers/openai/tests/provider/contract/messages.rs b/backend/crates/providers/openai/tests/provider/contract/messages.rs index 301b30a09..0e196db44 100644 --- a/backend/crates/providers/openai/tests/provider/contract/messages.rs +++ b/backend/crates/providers/openai/tests/provider/contract/messages.rs @@ -302,6 +302,71 @@ async fn messages_and_responses_translate_with_target_header_boundaries() { } } +#[tokio::test] +async fn translated_messages_service_tiers_follow_the_selected_responses_upstream() { + for api_key in [false, true] { + for (options, oauth_tier, api_tier) in [ + (json!({"service_tier":"auto"}), None, Some("auto")), + ( + json!({"service_tier":"standard_only"}), + None, + Some("default"), + ), + (json!({"speed":"fast"}), Some("priority"), Some("priority")), + ] { + let server = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + if api_key { + seed(&store, &server, ApiKeyProtocol::Responses).await; + } else { + create_account(&store, "acct_provider_contract").await; + } + 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(&server) + .await; + let mut body = json!({"model":"gpt-5.4","max_tokens":128,"stream":true,"messages":[{"role":"user","content":"hello"}]}); + body.as_object_mut() + .unwrap() + .extend(options.as_object().unwrap().clone()); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("anthropic", body.as_object().unwrap().clone()) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])), + )); + let mut stream = provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", operation), + context("req_translated_tier", CancellationToken::new()), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + let requests = server.received_requests().await.unwrap(); + let sent = captured_request_body(&requests[0]); + let expected = if api_key { api_tier } else { oauth_tier }; + assert_eq!(sent.get("service_tier").and_then(Value::as_str), expected); + let hint = expected.map_or_else( + || "model=gpt-5.4".to_owned(), + |tier| format!("model=gpt-5.4;tier={tier}"), + ); + assert_eq!(requests[0].headers["x-codex-routing-hint"], hint); + server.verify().await; + } + } +} + #[tokio::test] async fn custom_messages_and_responses_count_locally_without_billable_usage() { for (target, source) in [ diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index cd34950b0..f26385a57 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -8326,7 +8326,7 @@ async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_tra false, Some(json!("default")), Some("priority"), - None, + Some("default"), Some(3_437_500), ), ( @@ -8342,7 +8342,7 @@ async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_tra false, Some(json!("priority")), Some("priority"), - None, + Some("default"), Some(3_437_500), ), ( @@ -8350,7 +8350,7 @@ async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_tra false, Some(json!("fast")), None, - None, + Some("default"), Some(3_437_500), ), ( @@ -8358,7 +8358,7 @@ async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_tra false, Some(json!("default")), None, - None, + Some("default"), Some(3_437_500), ), (FastMode::Disabled, false, None, None, None, Some(3_437_500)), @@ -8431,8 +8431,8 @@ async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_tra true, Some(json!("auto")), None, + Some("auto"), None, - Some(3_437_500), ), (FastMode::Enabled, false, None, None, None, Some(3_437_500)), ( @@ -8440,7 +8440,7 @@ async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_tra false, Some(json!("default")), None, - None, + Some("default"), Some(3_437_500), ), ] { @@ -12622,18 +12622,27 @@ async fn fast_policy_changes_preserve_the_websocket_continuation_and_meter_each_ let mut websocket = crate::transport::accept_codex_test_websocket_with(socket, |request, response| { // 提示头只在首次握手发送;后续档位由各自 response.create 正文指定 - assert_eq!(request.headers()["x-codex-routing-hint"], "model=gpt-5.4"); + assert_eq!( + request.headers()["x-codex-routing-hint"], + "model=gpt-5.4;tier=default" + ); response.headers_mut().insert( "sec-websocket-extensions", "permessage-deflate".parse().unwrap(), ); }) .await; - for (index, tier) in [None, Some("priority"), None, None].into_iter().enumerate() { + for (index, tier) in ["default", "priority", "default", "default"] + .into_iter() + .enumerate() + { let message = websocket.next().await.unwrap().unwrap(); let frame: Value = serde_json::from_str(message.to_text().unwrap()).unwrap(); assert_eq!(frame["type"], "response.create"); - assert_eq!(frame.get("service_tier").and_then(Value::as_str), tier); + assert_eq!( + frame.get("service_tier").and_then(Value::as_str), + Some(tier) + ); assert_eq!( frame.get("previous_response_id").cloned(), index @@ -12790,7 +12799,11 @@ async fn fast_policy_changes_preserve_the_websocket_continuation_and_meter_each_ ); assert_eq!( observed_tier.as_deref(), - (fast_mode == FastMode::Enabled).then_some("priority") + Some(if fast_mode == FastMode::Enabled { + "priority" + } else { + "default" + }) ); assert_eq!( costs, diff --git a/backend/crates/providers/openai/tests/provider/contract/passthrough.rs b/backend/crates/providers/openai/tests/provider/contract/passthrough.rs index 03f0ac97b..74cf2655b 100644 --- a/backend/crates/providers/openai/tests/provider/contract/passthrough.rs +++ b/backend/crates/providers/openai/tests/provider/contract/passthrough.rs @@ -302,13 +302,28 @@ async fn websocket_messages_preserve_text_and_metadata_despite_unrecognized_json } #[tokio::test] -async fn default_service_tiers_are_omitted_only_for_codex_oauth() { - for tier in ["auto", "default", "priority", "flex"] { +async fn responses_service_tiers_reach_each_upstream_on_both_transports() { + for tier in [ + None, + Some(Value::Null), + Some(json!("auto")), + Some(json!("default")), + Some(json!("priority")), + Some(json!("flex")), + ] { for api_key in [false, true] { - let body = json!({"model":"gpt-5.4","input":"hello","service_tier":tier}); - let sent = capture_downstream_request(body, api_key, false, false).await; - let expected = (api_key || !matches!(tier, "auto" | "default")).then_some(tier); - assert_eq!(sent.get("service_tier").and_then(Value::as_str), expected); + for websocket in [false, true] { + let mut body = json!({"model":"gpt-5.4","input":"hello"}); + if let Some(tier) = &tier { + body["service_tier"] = tier.clone(); + } + let sent = capture_downstream_request(body, api_key, websocket, false).await; + assert_eq!( + sent.get("service_tier"), + tier.as_ref(), + "api_key={api_key}, websocket={websocket}" + ); + } } } } diff --git a/docs/api.md b/docs/api.md index 77d240a3c..5c64c51b3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -395,7 +395,7 @@ adaptive 路径将普通强制选择降为 `auto`,保留串行约束。 服务档位按目标协议映射:Responses `default` 对应 Messages `standard_only`,`auto` 保留,无法对应的档位省略。 按 CPA 将 Responses `priority` 与 Messages `speed:fast` 双向映射;这两个字段不代表相同的服务承诺。 跨协议生成 fast 请求时补充 `fast-mode-2026-02-01` beta,并保留已有 beta;目标是否支持 fast 由上游校验。 -Codex OAuth 出站省略 `auto/default`,Responses API Key 保留这些公开 API 参数。 +Messages 转向 Codex OAuth 时省略转换生成的 `auto/default`,转向 Responses API Key 时保留。 会话内配置、工具增删和指令生效期限等扩展需要完整保留时使用同协议上游 Messages 错误使用 `{"type":"error","error":{"type":"...","message":"..."}}`;SSE 开始后使用 `event: error`。 @@ -1946,7 +1946,7 @@ GET 和 POST 均返回 `enabled`、`leadMinutes`、`weeklyTriggerRemainingPercen - 关闭:将 `priority`(含 `fast` 别名)改为显式 `default`,继续处理请求 策略只作用于 OpenAI Responses 的顶层 `service_tier`,不改变 `flex`、`ultrafast` 等其他显式档位、嵌套字段或其他 Provider。 -选定 Codex OAuth 后,出站兼容层按其合同省略 `auto/default`,默认档位仍按标准价格计费;Responses API Key 保留这两个值。 +OAuth 和 Responses API Key 共用上述档位规则,出站兼容层不额外删除原生 Responses 的 `auto/default`。 宿主策略在 request 中间件之后应用,形成 attempt 输入基线;attempt 中间件继续改写档位时,以改写后的实际发送值为准。 HTTP 和每个 WebSocket `response.create` 均使用请求开始时的分组策略,同一请求重试保持该策略; HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造;复用 WS 时不重发握手头,