fix(openai): preserve native Responses service tiers

Limit default tier omission to Messages-to-OAuth conversion so native Responses retain their requested tier and routing hint.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-11 11:33:01 +08:00
co-authored by Codex
parent 1b24029a97
commit 36ba79ca5e
8 changed files with 131 additions and 28 deletions
@@ -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<String, Value>) {
// 转换生成的默认档位沿用 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<MiddlewareHeader>, forwarded: &HeaderMap) {
const BETA: &[u8] = b"fast-mode-2026-02-01";
@@ -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;
@@ -35,13 +35,6 @@ pub(super) fn normalize_non_codex_request_body(body: &mut Map<String, Value>) {
}
fn normalize_codex_oauth_options(body: &mut Map<String, Value>) {
// 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",
@@ -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)
@@ -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 [
@@ -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,
@@ -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}"
);
}
}
}
}
+2 -2
View File
@@ -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 时不重发握手头,