mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-14 08:06:40 +00:00
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:
@@ -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
@@ -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 时不重发握手头,
|
||||
|
||||
Reference in New Issue
Block a user