Merge main and harden managed Codex account flow

This commit is contained in:
SaladDay
2026-07-18 10:06:58 +00:00
276 changed files with 32101 additions and 3292 deletions
+159 -99
View File
@@ -7,25 +7,24 @@ use serde_json::{json, Value};
/// 在请求体关键位置注入 cache_control 断点
pub fn inject(body: &mut Value, config: &OptimizerConfig) {
if !config.cache_injection {
if !config.enabled || !config.cache_injection {
return;
}
let existing = count_existing(body);
// 升级已有断点的 TTL
upgrade_existing_ttl(body, &config.cache_ttl);
if existing > 4 {
// Existing markers are caller-owned. Do not silently delete or reorder
// them; surface the invalid/unsupported total and leave validation to
// the upstream provider.
log::warn!(
"[OPT] cache: existing breakpoint count {existing} exceeds the supported total of 4; preserving caller input"
);
}
let mut budget = 4_usize.saturating_sub(existing);
if budget == 0 {
if existing > 0 {
log::info!(
"[OPT] cache: ttl-upgrade({existing}->{},existing={existing})",
config.cache_ttl
);
} else {
log::info!("[OPT] cache: no-op(existing={existing})");
}
log::info!("[OPT] cache: no-op(existing={existing})");
return;
}
@@ -37,10 +36,7 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
if let Some(last) = tools.last_mut() {
if last.get("cache_control").is_none() {
if let Some(o) = last.as_object_mut() {
o.insert(
"cache_control".to_string(),
make_cache_control(&config.cache_ttl),
);
o.insert("cache_control".to_string(), make_cache_control());
}
budget -= 1;
injected.push("tools");
@@ -64,10 +60,7 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
if let Some(last) = system.last_mut() {
if last.get("cache_control").is_none() {
if let Some(o) = last.as_object_mut() {
o.insert(
"cache_control".to_string(),
make_cache_control(&config.cache_ttl),
);
o.insert("cache_control".to_string(), make_cache_control());
}
budget -= 1;
injected.push("system");
@@ -76,32 +69,33 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
}
}
// (c) 最后一条 assistant 消息的最后一个非 thinking block
// (c) 最后一条可缓存消息的最后一个非 thinking block。工具循环通常以
// user/tool_result 结束;只标 assistant 会让最新稳定前缀无法命中缓存。
if budget > 0 {
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
if let Some(assistant_msg) = messages
.iter_mut()
.rev()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
{
if let Some(content) = assistant_msg
.get_mut("content")
.and_then(|c| c.as_array_mut())
{
// 逆序找最后一个非 thinking/redacted_thinking block
if let Some(block) = content.iter_mut().rev().find(|b| {
let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or("");
bt != "thinking" && bt != "redacted_thinking"
}) {
if block.get("cache_control").is_none() {
if let Some(o) = block.as_object_mut() {
o.insert(
"cache_control".to_string(),
make_cache_control(&config.cache_ttl),
);
}
injected.push("msgs");
for message in messages.iter_mut().rev() {
if inject_message_breakpoint(message) {
budget -= 1;
injected.push("msgs-latest");
break;
}
}
// (d) A second, older user anchor helps long tool-result turns where
// the stable prefix falls outside Anthropic's 20-block lookback from
// the newest breakpoint. Keep this best-effort and inside the 4-BP cap.
if budget > 0 && messages.len() >= 4 {
let mut user_count = 0;
for message in messages.iter_mut().rev() {
if message.get("role").and_then(Value::as_str) != Some("user") {
continue;
}
user_count += 1;
if user_count == 2 {
if inject_message_breakpoint(message) {
injected.push("msgs-prior-user");
}
break;
}
}
}
@@ -112,16 +106,34 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
"[OPT] cache: {}bp({},{},pre={existing})",
injected.len(),
injected.join("+"),
config.cache_ttl,
"5m",
);
}
fn make_cache_control(ttl: &str) -> Value {
if ttl == "5m" {
json!({"type": "ephemeral"})
} else {
json!({"type": "ephemeral", "ttl": ttl})
fn inject_message_breakpoint(message: &mut Value) -> bool {
let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else {
return false;
};
let Some(block) = content.iter_mut().rev().find(|block| {
!matches!(
block.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
}) else {
return false;
};
if block.get("cache_control").is_some() {
return false;
}
let Some(object) = block.as_object_mut() else {
return false;
};
object.insert("cache_control".to_string(), make_cache_control());
true
}
fn make_cache_control() -> Value {
json!({"type": "ephemeral"})
}
fn count_existing(body: &Value) -> usize {
@@ -155,40 +167,6 @@ fn count_existing(body: &Value) -> usize {
count
}
fn upgrade_existing_ttl(body: &mut Value, ttl: &str) {
let upgrade = |val: &mut Value| {
if let Some(cc) = val.get_mut("cache_control").and_then(|c| c.as_object_mut()) {
if ttl == "5m" {
cc.remove("ttl");
} else {
cc.insert("ttl".to_string(), json!(ttl));
}
}
};
if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) {
for tool in tools.iter_mut() {
upgrade(tool);
}
}
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
for block in system.iter_mut() {
upgrade(block);
}
}
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
for msg in messages.iter_mut() {
if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
for block in content.iter_mut() {
upgrade(block);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -199,17 +177,16 @@ mod tests {
enabled: true,
thinking_optimizer: true,
cache_injection: true,
cache_ttl: "1h".to_string(),
}
}
#[test]
fn test_empty_body_no_injection() {
let mut body = json!({"model": "test", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]});
let original = body.clone();
inject(&mut body, &default_config());
// No tools, no system, no assistant → no injection
assert_eq!(body, original);
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_some());
}
#[test]
@@ -230,7 +207,7 @@ mod tests {
// tools last element
assert!(body["tools"][1].get("cache_control").is_some());
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
assert!(body["tools"][1]["cache_control"].get("ttl").is_none());
// system last element
assert!(body["system"][0].get("cache_control").is_some());
// assistant last non-thinking block
@@ -240,26 +217,50 @@ mod tests {
}
#[test]
fn test_existing_four_breakpoints_only_upgrades_ttl() {
fn test_long_history_uses_fourth_prior_user_breakpoint() {
let mut body = json!({
"model":"test",
"tools":[{"name":"tool1"}],
"system":[{"type":"text","text":"sys"}],
"messages":[
{"role":"user","content":[{"type":"text","text":"first"}]},
{"role":"assistant","content":[{"type":"text","text":"answer"}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"c1","content":"result"}]},
{"role":"assistant","content":[{"type":"text","text":"latest"}]}
]
});
inject(&mut body, &default_config());
assert_eq!(count_existing(&body), 4);
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_some());
assert!(body["messages"][3]["content"][0]
.get("cache_control")
.is_some());
}
#[test]
fn test_existing_four_breakpoints_preserve_caller_ttl() {
let mut body = json!({
"model": "test",
"tools": [
{"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "5m"}},
{"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
{"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "1h"}},
{"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "1h"}}
],
"system": [
{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "1h"}}
],
"messages": [
{"role": "assistant", "content": [
{"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
{"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "1h"}}
]}
]
});
inject(&mut body, &default_config());
// All TTLs upgraded to 1h, no new breakpoints
// Existing markers are caller-owned; only newly injected markers are fixed to 5m.
assert_eq!(body["tools"][0]["cache_control"]["ttl"], "1h");
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h");
@@ -292,6 +293,32 @@ mod tests {
.is_some());
}
#[test]
fn test_more_than_four_existing_breakpoints_are_preserved() {
let mut body = json!({
"model": "test",
"tools": [
{"name": "t1", "cache_control": {"type": "ephemeral"}},
{"name": "t2", "cache_control": {"type": "ephemeral"}}
],
"system": [
{"type": "text", "text": "s1", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "s2", "cache_control": {"type": "ephemeral"}}
],
"messages": [{"role": "user", "content": [
{"type": "text", "text": "m1", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "m2"}
]}]
});
inject(&mut body, &default_config());
assert_eq!(count_existing(&body), 5);
assert!(body["messages"][0]["content"][1]
.get("cache_control")
.is_none());
}
#[test]
fn test_system_string_converted_to_array() {
let mut body = json!({
@@ -311,18 +338,14 @@ mod tests {
}
#[test]
fn test_ttl_5m_no_ttl_field() {
let config = OptimizerConfig {
cache_ttl: "5m".to_string(),
..default_config()
};
fn test_standard_five_minute_cache_control_omits_ttl() {
let mut body = json!({
"model": "test",
"tools": [{"name": "tool1"}],
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
});
inject(&mut body, &config);
inject(&mut body, &default_config());
let cc = &body["tools"][0]["cache_control"];
assert_eq!(cc["type"], "ephemeral");
@@ -348,6 +371,24 @@ mod tests {
assert_eq!(body, original);
}
#[test]
fn test_optimizer_disabled_no_change() {
let config = OptimizerConfig {
enabled: false,
cache_injection: true,
..default_config()
};
let mut body = json!({
"model":"test",
"tools":[{"name":"tool1"}],
"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]
});
let original = body.clone();
inject(&mut body, &config);
assert_eq!(body, original);
}
#[test]
fn test_skip_thinking_blocks_in_assistant() {
let mut body = json!({
@@ -374,4 +415,23 @@ mod tests {
.get("cache_control")
.is_none());
}
#[test]
fn test_injects_latest_tool_result_instead_of_older_assistant() {
let mut body = json!({
"messages": [
{"role": "assistant", "content": [{"type": "tool_use", "id": "call_1", "name": "Read", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "done"}]}
]
});
inject(&mut body, &default_config());
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_none());
assert!(body["messages"][1]["content"][0]
.get("cache_control")
.is_some());
}
}
File diff suppressed because it is too large Load Diff
+449 -56
View File
@@ -18,12 +18,18 @@ use super::{
handler_context::RequestContext,
providers::{
codex_chat_common::extract_reasoning_field_text,
codex_chat_history::record_responses_sse_stream, get_adapter, get_claude_api_format,
codex_chat_history::record_responses_sse_stream,
get_adapter, get_claude_api_format,
streaming::create_anthropic_sse_stream,
streaming_codex_anthropic::{
create_responses_sse_stream_from_anthropic_with_context,
responses_sse_events_from_anthropic_message,
},
streaming_codex_chat::create_responses_sse_stream_from_chat_with_context,
streaming_gemini::create_anthropic_sse_stream_from_gemini,
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
transform_codex_chat, transform_gemini, transform_responses,
streaming_responses::create_anthropic_sse_stream_from_responses,
transform, transform_codex_anthropic, transform_codex_chat, transform_gemini,
transform_responses,
},
response_processor::{
create_logged_passthrough_stream, process_response, read_decoded_body,
@@ -277,6 +283,90 @@ fn validate_claude_desktop_gateway_auth(
/// Claude 格式转换处理(独有逻辑)
///
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
struct ClaudeUsageLog {
model: String,
request_model: String,
outbound_model: String,
app_type: &'static str,
provider_id: String,
session_id: String,
usage: TokenUsage,
latency_ms: u64,
status_code: u16,
is_streaming: bool,
}
fn prepare_claude_usage_log(
ctx: &RequestContext,
response: &Value,
status_code: u16,
is_streaming: bool,
) -> Option<ClaudeUsageLog> {
let usage =
TokenUsage::from_claude_response(response).filter(TokenUsage::has_billable_tokens)?;
let model = response
.get("model")
.and_then(Value::as_str)
.filter(|model| !model.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
Some(ClaudeUsageLog {
model,
request_model: ctx.request_model.clone(),
outbound_model: ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone()),
app_type: ctx.app_type_str,
provider_id: ctx.provider.id.clone(),
session_id: ctx.session_id.clone(),
usage,
latency_ms: ctx.latency_ms(),
status_code,
is_streaming,
})
}
async fn write_claude_usage_log(state: &ProxyState, log: ClaudeUsageLog) {
log_usage(
state,
&log.provider_id,
log.app_type,
&log.model,
&log.request_model,
&log.outbound_model,
log.usage,
log.latency_ms,
None,
log.is_streaming,
log.status_code,
Some(log.session_id),
)
.await;
}
fn spawn_claude_usage_log(
state: &ProxyState,
ctx: &RequestContext,
response: &Value,
status_code: u16,
is_streaming: bool,
) {
if !usage_logging_enabled(state) {
return;
}
let Some(log) = prepare_claude_usage_log(ctx, response, status_code, is_streaming) else {
return;
};
let state = state.clone();
tokio::spawn(async move {
write_claude_usage_log(&state, log).await;
});
}
async fn handle_claude_transform(
response: super::hyper_client::ProxyResponse,
ctx: &RequestContext,
@@ -468,8 +558,20 @@ async fn handle_claude_transform(
}
};
// Preserve raw Responses usage so a post-upstream conversion failure still
// records the tokens already consumed by the successful upstream request.
let raw_usage_response = (api_format == "openai_responses").then(|| {
json!({
"id": upstream_response.get("id").cloned().unwrap_or(Value::Null),
"model": upstream_response.get("model").cloned().unwrap_or(Value::Null),
"usage": transform_responses::build_anthropic_usage_from_responses(
upstream_response.get("usage")
)
})
});
// 根据 api_format 选择非流式转换器
let anthropic_response = if api_format == "openai_responses" {
let transform_result = if api_format == "openai_responses" {
transform_responses::responses_to_anthropic(upstream_response)
} else if api_format == "gemini_native" {
transform_gemini::gemini_to_anthropic_with_shadow_and_hints(
@@ -481,58 +583,28 @@ async fn handle_claude_transform(
)
} else {
transform::openai_to_anthropic(upstream_response)
}
.map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
};
let anthropic_response = match transform_result {
Ok(response) => response,
Err(error) => {
log::error!("[Claude] 转换响应失败: {error}");
if usage_logging_enabled(state) {
if let Some(log) = raw_usage_response.as_ref().and_then(|response| {
prepare_claude_usage_log(ctx, response, status.as_u16(), false)
}) {
// The upstream request already succeeded and consumed tokens. Persist
// usage before returning the terminal transform error to the client.
write_claude_usage_log(state, log).await;
}
}
return Err(error);
}
};
// 记录使用量
// 全 0 usage 不落账(对齐 Codex 流式收集器的 skip):SSE 聚合兜底救回的流
// 在上游缺 stream_options.include_usage 时没有 usage,写入只会产生无意义空行
if let Some(usage) =
TokenUsage::from_claude_response(&anthropic_response).filter(|u| u.has_billable_tokens())
{
// 转换后的响应缺失/合成空 model 时,回退到映射后的出站模型(接管真值),
// 再回退到客户端请求别名
let model = anthropic_response
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
let latency_ms = ctx.latency_ms();
let request_model = ctx.request_model.clone();
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let session_id = ctx.session_id.clone();
async move {
log_usage(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
false,
status.as_u16(),
Some(session_id),
)
.await;
}
});
}
spawn_claude_usage_log(state, ctx, &anthropic_response, status.as_u16(), false);
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
@@ -686,6 +758,30 @@ pub async fn handle_chat_completions(
pub async fn handle_responses(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await
}
pub async fn handle_grokbuild_responses(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_for_app(
state,
request,
AppType::GrokBuild,
"Grok Build",
"grokbuild",
)
.await
}
async fn handle_responses_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
@@ -702,7 +798,7 @@ pub async fn handle_responses(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = endpoint_with_query(&uri, "/responses");
let is_stream = body
@@ -714,7 +810,7 @@ pub async fn handle_responses(
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_with_retry(
&AppType::Codex,
&app_type,
method,
&endpoint,
body,
@@ -739,6 +835,18 @@ pub async fn handle_responses(
ctx.provider = result.provider;
let response = result.response;
if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) {
return handle_codex_anthropic_to_responses_transform(
response,
&ctx,
&state,
is_stream,
connection_guard,
codex_tool_context,
)
.await;
}
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
return handle_codex_chat_to_responses_transform(
response,
@@ -765,6 +873,30 @@ pub async fn handle_responses(
pub async fn handle_responses_compact(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await
}
pub async fn handle_grokbuild_responses_compact(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_compact_for_app(
state,
request,
AppType::GrokBuild,
"Grok Build",
"grokbuild",
)
.await
}
async fn handle_responses_compact_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
@@ -781,7 +913,7 @@ pub async fn handle_responses_compact(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = endpoint_with_query(&uri, "/responses/compact");
let is_stream = body
@@ -793,7 +925,7 @@ pub async fn handle_responses_compact(
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_with_retry(
&AppType::Codex,
&app_type,
method,
&endpoint,
body,
@@ -818,6 +950,18 @@ pub async fn handle_responses_compact(
ctx.provider = result.provider;
let response = result.response;
if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) {
return handle_codex_anthropic_to_responses_transform(
response,
&ctx,
&state,
is_stream,
connection_guard,
codex_tool_context,
)
.await;
}
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
return handle_codex_chat_to_responses_transform(
response,
@@ -1065,6 +1209,255 @@ async fn handle_codex_chat_to_responses_transform(
})
}
/// Response-transform handler for the Codex (Responses) ↔ Anthropic Messages gateway.
///
/// Parallel to `handle_codex_chat_to_responses_transform`: the upstream speaks
/// Anthropic Messages, and this converts the response back into the Responses form
/// Codex expects (both streaming and non-streaming). Error bodies reuse
/// `handle_codex_chat_error_response` (whose extraction logic also works for
/// Anthropic's `{"error":{type,message}}`). It does not involve codex_chat_history
/// (tool ids round-trip natively through Anthropic).
async fn handle_codex_anthropic_to_responses_transform(
response: super::hyper_client::ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
is_stream: bool,
connection_guard: Option<ActiveConnectionGuard>,
codex_tool_context: transform_codex_chat::CodexToolContext,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
if !status.is_success() {
return handle_codex_chat_error_response(response, ctx, status).await;
}
// Preserve live streaming when the gateway marks SSE correctly or omits an
// explicit JSON media type. Explicit JSON is buffered below so 2xx error
// envelopes and gateways that ignore stream:true can be converted faithfully.
if response.is_sse() || (is_stream && !response.is_json()) {
let stream = response.bytes_stream();
let sse_stream =
create_responses_sse_stream_from_anthropic_with_context(stream, codex_tool_context);
return build_codex_anthropic_sse_response(
sse_stream,
ctx,
state,
status,
connection_guard,
);
}
let body_timeout =
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
} else {
std::time::Duration::ZERO
};
let (mut response_headers, status, body_bytes) =
read_decoded_body(response, ctx.tag, body_timeout).await?;
let body_str = String::from_utf8_lossy(&body_bytes);
let anthropic_response: Value = match serde_json::from_slice(&body_bytes) {
Ok(value) => value,
// Fallback sniffing symmetric to the chat / claude side (#2234): when the
// upstream returns an Anthropic SSE body with an unmarked Content-Type,
// aggregate it back into a message before continuing the conversion.
Err(_) if body_looks_like_sse(&body_str) => {
log::warn!("[Codex] Upstream returned an unmarked Anthropic SSE body, falling back to aggregation");
transform_codex_anthropic::anthropic_sse_to_message_value(&body_str).map_err(|e| {
log::error!("[Codex] Failed to aggregate Anthropic SSE body: {e}");
e
})?
}
Err(e) => {
log::error!(
"[Codex] Failed to parse Anthropic upstream response: {e}, body: {body_str}"
);
return Err(upstream_body_parse_error(
"Failed to parse upstream anthropic response",
&e,
&response_headers,
&body_str,
));
}
};
if is_stream {
let events =
responses_sse_events_from_anthropic_message(&anthropic_response, codex_tool_context);
let sse_stream = futures::stream::iter(events.into_iter().map(Ok::<Bytes, std::io::Error>));
return build_codex_anthropic_sse_response(
sse_stream,
ctx,
state,
status,
connection_guard,
);
}
let _connection_guard = connection_guard;
let responses_response =
transform_codex_anthropic::anthropic_response_to_responses_with_context(
anthropic_response,
&codex_tool_context,
)
.map_err(|e| {
log::error!("[Codex] Failed to convert Anthropic response to Responses: {e}");
e
})?;
if let Some(usage) = TokenUsage::from_codex_response_auto(&responses_response)
.filter(TokenUsage::has_billable_tokens)
{
let model = responses_response
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
let request_model = ctx.request_model.clone();
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let session_id = ctx.session_id.clone();
let latency_ms = ctx.latency_ms();
async move {
log_usage(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
false,
status.as_u16(),
Some(session_id),
)
.await;
}
});
}
strip_entity_headers_for_rebuilt_body(&mut response_headers);
strip_hop_by_hop_response_headers(&mut response_headers);
response_headers.remove(axum::http::header::CONTENT_TYPE);
let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() {
builder = builder.header(key, value);
}
builder = builder.header(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
let response_body = serde_json::to_vec(&responses_response).map_err(|e| {
log::error!("[Codex] Failed to serialize Responses response: {e}");
ProxyError::TransformError(format!("Failed to serialize responses response: {e}"))
})?;
builder
.body(axum::body::Body::from(response_body))
.map_err(|e| {
log::error!("[Codex] Failed to build Responses response: {e}");
ProxyError::Internal(format!("Failed to build response: {e}"))
})
}
fn build_codex_anthropic_sse_response(
sse_stream: impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
ctx: &RequestContext,
state: &ProxyState,
status: StatusCode,
connection_guard: Option<ActiveConnectionGuard>,
) -> Result<axum::response::Response, ProxyError> {
let usage_collector = if usage_logging_enabled(state) {
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
let fallback_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
let start_time = ctx.start_time;
let session_id = ctx.session_id.clone();
Some(SseUsageCollector::new(
start_time,
Some(codex_stream_usage_event_filter),
move |events, first_token_ms| {
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap_or_default();
if !usage.has_billable_tokens() {
log::debug!("[Codex] Anthropic streaming response usage is all-zero or missing, skipping usage recording");
return;
}
let model = usage
.model
.clone()
.filter(|m| !m.is_empty())
.unwrap_or_else(|| fallback_model.clone());
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
first_token_ms,
true,
status.as_u16(),
Some(session_id),
)
.await;
});
},
))
} else {
None
};
let logged_stream = create_logged_passthrough_stream(
sse_stream,
ctx.tag,
usage_collector,
ctx.streaming_timeout_config(),
connection_guard,
);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"Content-Type",
axum::http::HeaderValue::from_static("text/event-stream"),
);
headers.insert(
"Cache-Control",
axum::http::HeaderValue::from_static("no-cache"),
);
let body = axum::body::Body::from_stream(logged_stream);
Ok((headers, body).into_response())
}
/// 把上游 Chat Completions 的错误响应转换为 Responses API 错误形状。
///
/// 与正常响应分支配套:正常响应已经被改写成 Responses 形式,错误响应若仍保留
+39
View File
@@ -142,6 +142,21 @@ impl ProxyResponse {
.unwrap_or(false)
}
/// Check whether the response explicitly declares a JSON media type.
pub fn is_json(&self) -> bool {
self.content_type()
.map(|content_type| {
let media_type = content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
media_type == "application/json" || media_type.ends_with("+json")
})
.unwrap_or(false)
}
/// Consume the response and collect the full body into `Bytes`.
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
match self {
@@ -737,3 +752,27 @@ impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
std::task::Poll::Ready(Ok(()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn buffered_with_content_type(content_type: Option<&str>) -> ProxyResponse {
let mut headers = http::HeaderMap::new();
if let Some(content_type) = content_type {
headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_str(content_type).unwrap(),
);
}
ProxyResponse::buffered(http::StatusCode::OK, headers, Bytes::new())
}
#[test]
fn json_content_type_detection_accepts_json_suffixes() {
assert!(buffered_with_content_type(Some("application/json; charset=utf-8")).is_json());
assert!(buffered_with_content_type(Some("application/problem+json")).is_json());
assert!(!buffered_with_content_type(Some("text/event-stream")).is_json());
assert!(!buffered_with_content_type(None).is_json());
}
}
+61 -137
View File
@@ -1,3 +1,6 @@
#[cfg(test)]
use crate::model_capabilities::is_confirmed_text_only_model as confirmed_text_only_model;
use crate::model_capabilities::{image_input_capability_from_settings, ImageInputCapability};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
@@ -9,9 +12,9 @@ pub const UNSUPPORTED_IMAGE_MARKER: &str = "[Unsupported Image]";
/// Two paths, both reached only when the caller's media-fallback switch is on:
/// - explicit capability from the provider config (modelCatalog / modalities) is
/// always trusted — it is declaration-driven, never a guess;
/// - the curated `known_text_only_model` list is a heuristic *prediction* and only
/// runs when `allow_heuristic` is true, so a mislabeled multimodal model cannot
/// have its images silently stripped when the user opts out.
/// - the confirmed text-only registry is used for proactive replacement only
/// when `allow_heuristic` is true. This switch controls silent request-body
/// mutation, not the capability truth advertised by the Codex model catalog.
pub fn replace_images_for_text_only_model(
body: &mut Value,
provider: &Provider,
@@ -27,13 +30,9 @@ pub fn replace_images_for_text_only_model(
.map(str::trim)
.unwrap_or("");
match explicit_model_image_support(provider, model) {
Some(true) => return 0,
Some(false) => return replace_images_in_body(body),
None => {}
}
if !allow_heuristic || !known_text_only_model(model) {
if image_input_capability_from_settings(&provider.settings_config, model, allow_heuristic)
!= ImageInputCapability::Unsupported
{
return 0;
}
@@ -63,6 +62,19 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
let message = extract_error_text(body);
let message = message.to_ascii_lowercase();
// 自证性表述:这类短语本身就断言了"仅接受文本",属于模态拒绝,无需再要求
// 错误提到 image/media 等字样——火山方舟等网关的报错是
// "Model only support text input",全程不出现 imageissue #5025)。
// 国产网关的英文常缺三单 s,因此带 s / 不带 s 两种形式都要列。
const TEXT_ONLY_SELF_EVIDENT_HINTS: &[&str] = &["only support text", "only supports text"];
if TEXT_ONLY_SELF_EVIDENT_HINTS
.iter()
.any(|hint| message.contains(hint))
{
return true;
}
let mentions_image = message.contains("image")
|| message.contains("vision")
|| message.contains("multimodal")
@@ -83,7 +95,6 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
"doesn't support",
"do not support",
"don't support",
"only supports text",
"text only",
"text-only",
"invalid content type",
@@ -225,91 +236,6 @@ fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) {
}
}
fn explicit_model_image_support(provider: &Provider, model: &str) -> Option<bool> {
let settings = &provider.settings_config;
[
settings
.get("modelCatalog")
.and_then(|catalog| catalog.get("models")),
settings.get("modelCatalog"),
settings.get("models"),
]
.into_iter()
.flatten()
.find_map(|value| explicit_model_image_support_in_value(value, model))
}
fn known_text_only_model(model: &str) -> bool {
let normalized = normalize_model_id(model);
let tail = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
const EXACT_TAILS: &[&str] = &[
"ark-code-latest",
"deepseek-chat",
"deepseek-reasoner",
"deepseek-v4-flash",
"deepseek-v4-pro",
"glm-5.1",
"kat-coder",
"kat-coder-pro",
"kat-coder-pro v1",
"kat-coder-pro v2",
"kat-coder-pro-v1",
"kat-coder-pro-v2",
"ling-2.5-1t",
"longcat-flash-chat",
"mimo-v2.5-pro",
"us.deepseek.r1-v1",
];
const TAIL_PREFIXES: &[&str] = &["minimax-m2.7", "qwen3-coder", "step-3.5-flash"];
EXACT_TAILS.contains(&tail) || TAIL_PREFIXES.iter().any(|prefix| tail.starts_with(prefix))
}
fn explicit_model_image_support_in_value(value: &Value, model: &str) -> Option<bool> {
if let Some(models) = value.as_array() {
return models.iter().find_map(|entry| {
model_entry_matches(entry, None, model).then(|| explicit_image_support(entry))?
});
}
let object = value.as_object()?;
object.iter().find_map(|(key, entry)| {
model_entry_matches(entry, Some(key), model).then(|| explicit_image_support(entry))?
})
}
fn explicit_image_support(entry: &Value) -> Option<bool> {
if let Some(value) = entry
.get("supportsImage")
.or_else(|| entry.get("supports_image"))
.or_else(|| entry.get("vision"))
.and_then(Value::as_bool)
{
return Some(value);
}
[
entry.get("input"),
entry.pointer("/modalities/input"),
entry.get("input_modalities"),
entry.get("inputModalities"),
]
.into_iter()
.flatten()
.find_map(input_modalities_support_image)
}
fn input_modalities_support_image(value: &Value) -> Option<bool> {
let modalities = value.as_array()?;
Some(modalities.iter().any(|item| {
item.as_str()
.map(str::trim)
.is_some_and(|item| item.eq_ignore_ascii_case("image"))
}))
}
fn extract_error_text(body: &str) -> String {
if let Ok(value) = serde_json::from_str::<Value>(body) {
let candidates = [
@@ -334,43 +260,6 @@ fn extract_error_text(body: &str) -> String {
body.to_string()
}
fn model_entry_matches(entry: &Value, key: Option<&str>, model: &str) -> bool {
key.is_some_and(|key| model_ids_match(key, model))
|| ["model", "id", "name"]
.into_iter()
.filter_map(|field| entry.get(field).and_then(Value::as_str))
.any(|candidate| model_ids_match(candidate, model))
}
fn model_ids_match(candidate: &str, model: &str) -> bool {
let candidate = normalize_model_id(candidate);
let model = normalize_model_id(model);
if candidate.is_empty() || model.is_empty() {
return false;
}
if candidate == model {
return true;
}
let candidate_tail = candidate.rsplit('/').next().unwrap_or(candidate.as_str());
let model_tail = model.rsplit('/').next().unwrap_or(model.as_str());
candidate_tail == model_tail || candidate == model_tail || candidate_tail == model
}
fn normalize_model_id(value: &str) -> String {
let mut normalized = value
.trim()
.trim_start_matches("models/")
.trim()
.to_ascii_lowercase();
if let Some(stripped) =
normalized.strip_suffix(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
{
normalized = stripped.trim().to_string();
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
@@ -415,7 +304,7 @@ mod tests {
}
#[test]
fn known_text_only_models_replace_images_before_send() {
fn confirmed_text_only_models_replace_images_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek/deepseek-v4-pro",
@@ -437,7 +326,7 @@ mod tests {
}
#[test]
fn known_text_only_models_replace_chat_image_url_before_send() {
fn confirmed_text_only_models_replace_chat_image_url_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek-v4-flash",
@@ -461,7 +350,7 @@ mod tests {
}
#[test]
fn known_text_only_models_replace_codex_input_image_before_send() {
fn confirmed_text_only_models_replace_codex_input_image_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek-v4-flash",
@@ -484,6 +373,15 @@ mod tests {
);
}
#[test]
fn longcat_models_are_classified_text_only() {
// LongCat-2.0 (like the retired Flash Chat) is a text-only model; the
// preset ships it in mixed case, so the classifier must normalize first.
assert!(confirmed_text_only_model("LongCat-2.0"));
assert!(confirmed_text_only_model("longcat/LongCat-2.0"));
assert!(confirmed_text_only_model("LongCat-Flash-Chat"));
}
#[test]
fn explicit_text_modalities_replace_images_before_send() {
let provider = provider(json!({
@@ -637,7 +535,7 @@ mod tests {
}
#[test]
fn known_text_only_prefixes_replace_images_before_send() {
fn confirmed_text_only_variant_replaces_images_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "therouter/qwen/qwen3-coder-480b",
@@ -717,6 +615,32 @@ mod tests {
assert!(is_unsupported_image_error(&error));
}
#[test]
fn detects_text_only_errors_without_image_mention() {
// 火山方舟真实报错(issue #5025):不含 image/media 等字样,且英文缺
// 三单 s——旧逻辑的 mentions_image 门与 "only supports text" 提示都拦不住。
let error = ProxyError::UpstreamError {
status: 400,
body: Some(
r#"{"error":{"message":"Model only support text input Request id: 021783"}}"#
.to_string(),
),
};
assert!(is_unsupported_image_error(&error));
}
#[test]
fn glm_52_is_classified_text_only() {
// issue #5025:火山 Coding Plan 的 GLM 5.2 是纯文本端点,
// 映射链 glm-5.2[1M] 归一化后尾部为 glm-5.2。
assert!(confirmed_text_only_model("glm-5.2"));
assert!(confirmed_text_only_model("GLM-5.2[1M]"));
assert!(confirmed_text_only_model("zai-org/GLM-5.2"));
// 未来视觉版(智谱 4v/5v 命名惯例)不能被误判为纯文本。
assert!(!confirmed_text_only_model("glm-5.2v"));
}
#[test]
fn ignores_non_image_errors() {
let error = ProxyError::UpstreamError {
+50
View File
@@ -12,6 +12,7 @@ pub struct ModelMapping {
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub fable_model: Option<String>,
pub subagent_model: Option<String>,
pub default_model: Option<String>,
}
@@ -41,6 +42,11 @@ impl ModelMapping {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
subagent_model: env
.and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
@@ -55,6 +61,7 @@ impl ModelMapping {
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.fable_model.is_some()
|| self.subagent_model.is_some()
|| self.default_model.is_some()
}
@@ -89,6 +96,13 @@ impl ModelMapping {
}
}
if let Some(ref m) = self.subagent_model {
if strip_one_m_suffix_for_upstream(original_model) == strip_one_m_suffix_for_upstream(m)
{
return original_model.to_string();
}
}
// 2. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
@@ -327,6 +341,42 @@ mod tests {
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_subagent_model_preserved_before_default_fallback() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini"
}
});
let body = json!({"model": "gpt-5.4-mini"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "gpt-5.4-mini");
assert_eq!(original, Some("gpt-5.4-mini".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_subagent_model_preserved_with_one_m_suffix_before_default_fallback() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini"
}
});
let body = json!({"model": "gpt-5.4-mini[1M]"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "gpt-5.4-mini[1M]");
assert_eq!(original, Some("gpt-5.4-mini[1M]".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_no_mapping_configured() {
let provider = create_provider_without_mapping();
+1 -1
View File
@@ -124,7 +124,7 @@ pub enum AuthStrategy {
///
/// - Header: `Authorization: Bearer <access_token>`
/// - Header: `ChatGPT-Account-Id: <account_id>` (来自 forwarder 注入)
/// - Header: `originator: cc-switch`
/// - Header: `originator: codex_cli_rs` + `version: <codex 版本>`(成对,后端按此做模型 cohort 路由)
///
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
CodexOAuth,
+15 -4
View File
@@ -24,6 +24,13 @@ const ANTHROPIC_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]";
// Keep hints lowercase; matching lowercases only the input value.
const REASONING_VENDOR_HINTS: &[&str] = &["moonshot", "kimi", "deepseek", "mimo", "xiaomimimo"];
// ChatGPT Codex 后端按 originator+version 组合做模型 cohort 路由:非官方身份会把
// gpt-5.6-luna 解析到未部署的内部引擎(HTTP 404 Model not foundopenai/codex#31967
// 本机 A/B 实测确认)。两个头必须成对发送,缺一即 404;version 需 ≥ 目标模型
// catalog 的 minimal_client_versionluna=0.144.0),新模型抬门槛时同步 bump。
const CODEX_OAUTH_ORIGINATOR: &str = "codex_cli_rs";
const CODEX_OAUTH_CLIENT_VERSION: &str = "0.144.1";
/// 获取 Claude 供应商的 API 格式
///
/// 供 handler/forwarder 外部使用的公开函数。
@@ -666,7 +673,7 @@ impl ProviderAdapter for ClaudeAdapter {
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url)
if self.is_codex_oauth(provider) {
return Ok("https://chatgpt.com/backend-api/codex".to_string());
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
}
// 1. 从 env 中获取
@@ -778,9 +785,9 @@ impl ProviderAdapter for ClaudeAdapter {
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// Codex OAuth: 所有请求统一走 /responses 端点
if base_url == "https://chatgpt.com/backend-api/codex" {
if base_url == super::CHATGPT_CODEX_BASE_URL {
let _ = endpoint; // 忽略原始 endpoint
return "https://chatgpt.com/backend-api/codex/responses".to_string();
return format!("{}/responses", super::CHATGPT_CODEX_BASE_URL);
}
// NOTE:
@@ -843,7 +850,11 @@ impl ProviderAdapter for ClaudeAdapter {
(HeaderName::from_static("authorization"), hv(&bearer)?),
(
HeaderName::from_static("originator"),
HeaderValue::from_static("cc-switch"),
HeaderValue::from_static(CODEX_OAUTH_ORIGINATOR),
),
(
HeaderName::from_static("version"),
HeaderValue::from_static(CODEX_OAUTH_CLIENT_VERSION),
),
]
}
+550 -2
View File
@@ -84,6 +84,158 @@ pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &st
) && codex_provider_uses_chat_completions(provider)
}
/// Whether a converted Codex Responses request may send `prompt_cache_key` to
/// its Chat Completions upstream. Unknown OpenAI-compatible gateways default to
/// false because many reject unsupported request fields with HTTP 400.
pub fn should_send_codex_chat_prompt_cache_key(provider: &Provider) -> bool {
match provider
.meta
.as_ref()
.and_then(|meta| meta.prompt_cache_routing.as_deref())
.unwrap_or("auto")
{
"enabled" => return true,
"disabled" => return false,
_ => {}
}
let base_url = provider
.settings_config
.get("base_url")
.or_else(|| provider.settings_config.get("baseURL"))
.and_then(|value| value.as_str())
.map(ToString::to_string)
.or_else(|| {
provider
.settings_config
.get("config")
.and_then(|value| value.as_str())
.and_then(extract_codex_base_url_from_toml)
});
let Some(base_url) = base_url else {
return false;
};
let Ok(url) = url::Url::parse(&base_url) else {
return false;
};
match url.host_str() {
Some("api.openai.com") => true,
Some("api.kimi.com") => {
let path = url.path().trim_end_matches('/');
path == "/coding" || path.starts_with("/coding/")
}
_ => false,
}
}
/// Add a stable cache-routing key after Responses -> Chat conversion. An
/// explicit client key wins; otherwise only a real client-provided session ID
/// is eligible. Generated per-request UUIDs must never be used here.
pub fn inject_codex_chat_prompt_cache_key(
provider: &Provider,
chat_body: &mut JsonValue,
explicit_key: Option<&str>,
client_session_id: Option<&str>,
) -> bool {
if !should_send_codex_chat_prompt_cache_key(provider) {
return false;
}
let key = explicit_key
.map(str::trim)
.filter(|key| !key.is_empty())
.or_else(|| {
client_session_id
.map(str::trim)
.filter(|session_id| !session_id.is_empty())
});
let Some(key) = key else {
return false;
};
chat_body["prompt_cache_key"] = JsonValue::String(key.to_string());
true
}
/// Whether this Codex provider's real upstream speaks the native Anthropic
/// Messages protocol (`/v1/messages`). The local Codex client always talks to CC
/// Switch through the Responses API, so CC Switch bridges Responses ⇄ Anthropic.
///
/// Determined solely from explicit config (apiFormat / wire_api); no base_url
/// guessing — Anthropic gateway addresses vary widely and guessing easily misfires.
pub fn codex_provider_uses_anthropic(provider: &Provider) -> bool {
if let Some(api_format) = provider
.meta
.as_ref()
.and_then(|meta| meta.api_format.as_deref())
.or_else(|| {
provider
.settings_config
.get("api_format")
.and_then(|v| v.as_str())
})
.or_else(|| {
provider
.settings_config
.get("apiFormat")
.and_then(|v| v.as_str())
})
{
return is_anthropic_wire_api(api_format);
}
provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_wire_api_from_toml)
.map(|wire_api| is_anthropic_wire_api(&wire_api))
.unwrap_or(false)
}
pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint: &str) -> bool {
let path = endpoint
.split_once('?')
.map_or(endpoint, |(path, _query)| path);
matches!(
path,
"/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact"
) && codex_provider_uses_anthropic(provider)
}
/// The single built-in official Codex provider. Unlike managed Codex OAuth
/// providers used by Claude, this route receives authentication from the
/// calling Codex client (`requires_openai_auth = true`).
pub fn is_codex_official_provider(provider: &Provider) -> bool {
provider.id == crate::database::CODEX_OFFICIAL_PROVIDER_ID
&& provider.category.as_deref() == Some("official")
}
/// Resolve the model-catalog tool profile for a Codex provider using the SAME
/// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the
/// generated catalog never disagrees with the routed transform. A provider whose
/// Anthropic upstream is declared only via settings `apiFormat` or TOML `wire_api`
/// (not `meta.api_format`) would otherwise get a `ProxyChat` catalog and emit the
/// freeform `apply_patch` tool that the Anthropic transform then silently drops.
/// Non-Anthropic providers keep the existing `meta.api_format` classification.
pub fn resolve_codex_catalog_tool_profile(
provider: &Provider,
) -> crate::codex_config::CodexCatalogToolProfile {
use crate::codex_config::CodexCatalogToolProfile;
if is_codex_official_provider(provider) {
return CodexCatalogToolProfile::NativeResponses;
}
if codex_provider_uses_anthropic(provider) {
return CodexCatalogToolProfile::Anthropic;
}
CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
)
}
/// Extract the real upstream model configured for a Codex provider.
pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
provider
@@ -98,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_model_from_toml)
.and_then(|config| {
crate::grok_config::extract_model_config(config)
.map(|model| model.model)
.or_else(|| extract_codex_model_from_toml(config))
})
})
}
@@ -129,7 +285,13 @@ pub fn apply_codex_chat_upstream_model(
if !codex_provider_uses_chat_completions(provider) {
return None;
}
apply_codex_upstream_model(provider, body)
}
/// Same model-substitution logic as `apply_codex_chat_upstream_model`, but without
/// the chat gating check. Reused by the anthropic conversion path (the forwarder has
/// already confirmed this provider uses anthropic).
pub fn apply_codex_upstream_model(provider: &Provider, body: &mut JsonValue) -> Option<String> {
let catalog_model_ids = codex_provider_catalog_model_ids(provider);
if let Some(request_model) = body
.get("model")
@@ -345,6 +507,13 @@ fn is_chat_wire_api(value: &str) -> bool {
)
}
fn is_anthropic_wire_api(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"anthropic" | "anthropic_messages" | "anthropic-messages" | "claude" | "messages"
)
}
fn is_chat_completions_url(value: &str) -> bool {
value
.trim_end_matches('/')
@@ -456,6 +625,9 @@ impl CodexAdapter {
}
if let Some(config_str) = config.as_str() {
if let Some((_, key)) = crate::grok_config::extract_credentials(config_str) {
return Some(key);
}
if let Some(key) =
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
{
@@ -480,6 +652,10 @@ impl ProviderAdapter for CodexAdapter {
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
if is_codex_official_provider(provider) {
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
}
// 1. 尝试直接获取 base_url 字段
if let Some(url) = provider
.settings_config
@@ -506,6 +682,9 @@ impl ProviderAdapter for CodexAdapter {
// 尝试解析 TOML 字符串格式
if let Some(config_str) = config.as_str() {
if let Some(url) = crate::grok_config::extract_base_url(config_str) {
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(start) = config_str.find("base_url = \"") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('"') {
@@ -527,8 +706,28 @@ impl ProviderAdapter for CodexAdapter {
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
// Anthropic upstream: the auth field is chosen by the user in the UI (meta.apiKeyField).
// ANTHROPIC_API_KEY → x-api-key (AuthStrategy::Anthropic)
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (default, AuthStrategy::Bearer)
// The two are mutually exclusive to avoid a 401 from the gateway receiving
// both auth headers at once. All other Codex upstreams stay pure Bearer.
let strategy = if codex_provider_uses_anthropic(provider) {
let uses_x_api_key = provider
.meta
.as_ref()
.and_then(|meta| meta.api_key_field.as_deref())
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
.unwrap_or(false);
if uses_x_api_key {
AuthStrategy::Anthropic
} else {
AuthStrategy::Bearer
}
} else {
AuthStrategy::Bearer
};
self.extract_key(provider)
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
.map(|key| AuthInfo::new(key, strategy))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
@@ -569,6 +768,15 @@ impl ProviderAdapter for CodexAdapter {
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
use super::adapter::auth_header_value;
let bearer = format!("Bearer {}", auth.api_key);
// Anthropic gateway: send only x-api-key (anthropic-version is filled in by
// the forwarder). Mutually exclusive with Bearer to avoid a 401 from the
// gateway receiving both auth headers at once.
if auth.strategy == AuthStrategy::Anthropic {
return Ok(vec![(
http::HeaderName::from_static("x-api-key"),
auth_header_value(&auth.api_key)?,
)]);
}
Ok(vec![(
http::HeaderName::from_static("authorization"),
auth_header_value(&bearer)?,
@@ -598,6 +806,155 @@ mod tests {
}
}
#[test]
fn grok_build_toml_exposes_upstream_credentials_and_model() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"config": r#"
[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "upstream-grok-model"
base_url = "https://relay.example.com/v1/"
name = "Example Relay"
api_key = "grok-secret"
api_backend = "responses"
context_window = 500000
"#
}));
assert_eq!(
adapter.extract_base_url(&provider).unwrap(),
"https://relay.example.com/v1"
);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "grok-secret");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
assert_eq!(
codex_provider_upstream_model(&provider).as_deref(),
Some("upstream-grok-model")
);
}
#[test]
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
provider.id = "codex-official".to_string();
provider.category = Some("official".to_string());
let adapter = CodexAdapter::new();
assert!(is_codex_official_provider(&provider));
assert_eq!(
adapter
.extract_base_url(&provider)
.expect("official base url"),
"https://chatgpt.com/backend-api/codex"
);
assert!(adapter.extract_auth(&provider).is_none());
assert_eq!(
adapter.build_url(
"https://chatgpt.com/backend-api/codex",
"/responses/compact"
),
"https://chatgpt.com/backend-api/codex/responses/compact"
);
}
#[test]
fn prompt_cache_routing_auto_enables_known_upstreams_only() {
let kimi = create_provider(json!({
"config": r#"
model_provider = "custom"
[model_providers.custom]
base_url = "https://api.kimi.com/coding/v1"
wire_api = "responses"
"#
}));
let openai = create_provider(json!({
"base_url": "https://api.openai.com/v1"
}));
let unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
assert!(should_send_codex_chat_prompt_cache_key(&kimi));
assert!(should_send_codex_chat_prompt_cache_key(&openai));
assert!(!should_send_codex_chat_prompt_cache_key(&unknown));
}
#[test]
fn prompt_cache_routing_user_override_wins_over_auto_detection() {
let mut kimi = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
kimi.meta = Some(crate::provider::ProviderMeta {
prompt_cache_routing: Some("disabled".to_string()),
..Default::default()
});
assert!(!should_send_codex_chat_prompt_cache_key(&kimi));
let mut unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
unknown.meta = Some(crate::provider::ProviderMeta {
prompt_cache_routing: Some("enabled".to_string()),
..Default::default()
});
assert!(should_send_codex_chat_prompt_cache_key(&unknown));
}
#[test]
fn prompt_cache_key_prefers_explicit_key_then_real_session() {
let provider = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
let mut explicit_body = json!({ "model": "kimi-for-coding" });
assert!(inject_codex_chat_prompt_cache_key(
&provider,
&mut explicit_body,
Some("request-key"),
Some("session-key"),
));
assert_eq!(explicit_body["prompt_cache_key"], "request-key");
let mut session_body = json!({ "model": "kimi-for-coding" });
assert!(inject_codex_chat_prompt_cache_key(
&provider,
&mut session_body,
None,
Some("session-key"),
));
assert_eq!(session_body["prompt_cache_key"], "session-key");
}
#[test]
fn prompt_cache_key_is_not_injected_without_real_session_or_support() {
let kimi = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
let mut no_session_body = json!({ "model": "kimi-for-coding" });
assert!(!inject_codex_chat_prompt_cache_key(
&kimi,
&mut no_session_body,
None,
None,
));
assert!(no_session_body.get("prompt_cache_key").is_none());
let unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
let mut unsupported_body = json!({ "model": "other" });
assert!(!inject_codex_chat_prompt_cache_key(
&unknown,
&mut unsupported_body,
Some("request-key"),
Some("session-key"),
));
assert!(unsupported_body.get("prompt_cache_key").is_none());
}
#[test]
fn test_extract_base_url_direct() {
let adapter = CodexAdapter::new();
@@ -662,6 +1019,197 @@ experimental_bearer_token = "sk-config-key"
assert_eq!(url, "https://api.openai.com/v1/responses");
}
// ==================== anthropic upstream detection ====================
#[test]
fn test_uses_anthropic_from_settings_api_format() {
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(codex_provider_uses_anthropic(&provider));
let provider = create_provider(json!({ "api_format": "anthropic_messages" }));
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_uses_anthropic_from_meta_api_format() {
let mut provider = create_provider(json!({}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("anthropic".to_string()),
..Default::default()
});
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_uses_anthropic_from_toml_wire_api() {
let provider = create_provider(json!({
"config": r#"model_provider = "custom"
[model_providers.custom]
wire_api = "anthropic"
"#
}));
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_anthropic_false_for_chat_and_responses() {
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert!(!codex_provider_uses_anthropic(&chat));
let responses = create_provider(json!({ "apiFormat": "openai_responses" }));
assert!(!codex_provider_uses_anthropic(&responses));
}
#[test]
fn test_anthropic_and_chat_are_mutually_exclusive() {
let anth = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(codex_provider_uses_anthropic(&anth));
assert!(!codex_provider_uses_chat_completions(&anth));
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert!(codex_provider_uses_chat_completions(&chat));
assert!(!codex_provider_uses_anthropic(&chat));
}
#[test]
fn test_should_convert_responses_to_anthropic_path_guard() {
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/responses"
));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/v1/responses/compact"
));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/responses?x=1"
));
assert!(!should_convert_codex_responses_to_anthropic(
&provider,
"/chat/completions"
));
}
#[test]
fn test_resolve_catalog_profile_matches_router() {
use crate::codex_config::CodexCatalogToolProfile;
// Anthropic declared only via TOML wire_api (no meta.api_format) must still
// resolve to the Anthropic catalog profile — this is the routing/catalog
// divergence that let apply_patch leak through.
let toml_anthropic = create_provider(json!({
"config": r#"model_provider = "custom"
[model_providers.custom]
wire_api = "anthropic"
"#
}));
assert_eq!(
resolve_codex_catalog_tool_profile(&toml_anthropic),
CodexCatalogToolProfile::Anthropic
);
// Anthropic via settings apiFormat.
let settings_anthropic = create_provider(json!({ "apiFormat": "anthropic" }));
assert_eq!(
resolve_codex_catalog_tool_profile(&settings_anthropic),
CodexCatalogToolProfile::Anthropic
);
// Native openai_responses (meta) → NativeResponses; chat → ProxyChat.
let mut native = create_provider(json!({}));
native.meta = Some(crate::provider::ProviderMeta {
api_format: Some("openai_responses".to_string()),
..Default::default()
});
assert_eq!(
resolve_codex_catalog_tool_profile(&native),
CodexCatalogToolProfile::NativeResponses
);
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert_eq!(
resolve_codex_catalog_tool_profile(&chat),
CodexCatalogToolProfile::ProxyChat
);
}
#[test]
fn test_apply_codex_upstream_model_preserves_one_m_catalog_model() {
// Regression for the [1m] path: a request model carrying the [1m] marker must
// match its catalog entry and be preserved (not overridden by the provider
// default) so the transform can later strip [1m] and emit the context-1m beta.
// This only works because the forwarder no longer strips [1m] before this call
// on the Anthropic path.
let provider = create_provider(json!({
"config": r#"model_provider = "custom"
model = "claude-opus-4-1"
[model_providers.custom]
wire_api = "anthropic"
"#,
"modelCatalog": {
"models": [
{ "model": "claude-opus-4-1[1m]" }
]
}
}));
let mut body = json!({ "model": "claude-opus-4-1[1m]", "input": "hi" });
let result = apply_codex_upstream_model(&provider, &mut body);
assert_eq!(result.as_deref(), Some("claude-opus-4-1[1m]"));
assert_eq!(
body.get("model").and_then(|v| v.as_str()),
Some("claude-opus-4-1[1m]")
);
}
#[test]
fn test_anthropic_auth_defaults_to_bearer() {
// No meta.apiKeyField (defaults to ANTHROPIC_AUTH_TOKEN) → Authorization: Bearer only
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"apiFormat": "anthropic",
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::Bearer);
let headers = adapter.get_auth_headers(&auth).unwrap();
let names: Vec<String> = headers
.iter()
.map(|(name, _)| name.as_str().to_string())
.collect();
assert_eq!(names, vec!["authorization".to_string()]);
}
#[test]
fn test_anthropic_auth_x_api_key_when_selected() {
// meta.apiKeyField = ANTHROPIC_API_KEY → x-api-key only
let adapter = CodexAdapter::new();
let mut provider = create_provider(json!({
"apiFormat": "anthropic",
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("anthropic".to_string()),
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
..Default::default()
});
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
let headers = adapter.get_auth_headers(&auth).unwrap();
let names: Vec<String> = headers
.iter()
.map(|(name, _)| name.as_str().to_string())
.collect();
assert_eq!(names, vec!["x-api-key".to_string()]);
}
#[test]
fn test_build_url_origin_adds_v1() {
let adapter = CodexAdapter::new();
+484 -83
View File
@@ -202,6 +202,10 @@ struct CodexAccountData {
/// 与原生浏览器登录保持一致的 tokens 字段形状;刷新时若返回新值则更新。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
/// 最近一次取得或采纳这组 OAuth token 的时间。用于在 Codex CLI 与
/// cc-switch 都可能轮换 refresh_token 时拒绝从 live 采纳更旧的一代。
#[serde(default)]
pub token_updated_at_ms: i64,
}
/// 公开的账号信息(返回给前端,复用 GitHubAccount 结构)
@@ -253,6 +257,9 @@ pub struct CodexOAuthManager {
access_tokens: Arc<RwLock<HashMap<String, CachedAccessToken>>>,
/// 每个账号的刷新锁
refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
/// 普通 token 解析/采纳持读锁,账号删除/清空持写锁。删除因此会等待
/// 已在飞 refresh 完成,也不会因过早清理 refresh_locks 产生第二把账号锁。
lifecycle_lock: Arc<RwLock<()>>,
/// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms}
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
@@ -272,6 +279,7 @@ impl CodexOAuthManager {
default_account_id: Arc::new(RwLock::new(None)),
access_tokens: Arc::new(RwLock::new(HashMap::new())),
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
lifecycle_lock: Arc::new(RwLock::new(())),
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
storage_path,
storage_lock: Arc::new(Mutex::new(())),
@@ -420,12 +428,6 @@ impl CodexOAuthManager {
.exchange_code_for_tokens(&success.authorization_code, &success.code_verifier)
.await?;
// 清理 pending device code
{
let mut pending = self.pending_device_codes.write().await;
pending.remove(device_code);
}
let refresh_token = tokens.refresh_token.clone().ok_or_else(|| {
CodexOAuthError::TokenFetchFailed("响应缺少 refresh_token".to_string())
})?;
@@ -435,8 +437,9 @@ impl CodexOAuthManager {
CodexOAuthError::ParseError("无法从 token 中提取 account_id".to_string())
})?;
// 先落账号(写 accounts + 持久化),再按全局 accounts -> access_tokens 顺序、
// 在存在性确认下写 token 缓存,遵守「缓存条目只对应存在的账号」。
let obtained_at_ms = chrono::Utc::now().timestamp_millis();
// 登录提交与该账号的 refresh/adopt 共用一把 generation 锁;账号和
// access cache 一次写入,旧刷新响应因此不能覆盖新登录链。
let account = self
.add_account_internal(
account_id.clone(),
@@ -444,24 +447,15 @@ impl CodexOAuthManager {
email,
// 空字符串视为缺失,避免写出空的 id_token
tokens.id_token.clone().filter(|t| !t.trim().is_empty()),
Some(CachedAccessToken {
token: tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(tokens.expires_in),
obtained_at_ms,
}),
Some(device_code),
)
.await?;
{
let accounts = self.accounts.read().await;
if accounts.contains_key(&account_id) {
let mut tokens_cache = self.access_tokens.write().await;
tokens_cache.insert(
account_id.clone(),
CachedAccessToken {
token: tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(tokens.expires_in),
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
},
);
}
}
Ok(Some(account))
}
@@ -520,12 +514,22 @@ impl CodexOAuthManager {
.await?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(CodexOAuthError::RefreshTokenInvalid);
}
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
let refresh_error_code = extract_refresh_error_code(&text);
if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
|| matches!(
refresh_error_code.as_deref(),
Some(
"refresh_token_expired"
| "refresh_token_reused"
| "refresh_token_invalidated"
)
)
{
return Err(CodexOAuthError::RefreshTokenInvalid);
}
return Err(CodexOAuthError::TokenFetchFailed(format!(
"Refresh 失败: {status} - {text}"
)));
@@ -544,6 +548,7 @@ impl CodexOAuthManager {
&self,
account_id: &str,
) -> Result<String, CodexOAuthError> {
let _lifecycle = self.lifecycle_lock.read().await;
Ok(self.resolve_valid_cached_token(account_id).await?.token)
}
@@ -552,14 +557,9 @@ impl CodexOAuthManager {
/// 返回完整 `CachedAccessToken`,使 token 与其 `obtained_at_ms` 天然配套(写托管
/// auth.json 的 `last_refresh` 直接取用),避免分两次读缓存造成的错配。
///
/// 并发正确性:统一`accounts -> access_tokens` 顺序加锁——读/写 token 缓存前都
/// 在 `accounts` 读锁下确认账号仍存在。配合 `remove_account`/`clear_auth` 在
/// `accounts` 写锁内原子清缓存,杜绝「已删账号的 token 被写回或被继续返回」
///
/// 已知未覆盖边界(ABA,极窄且可恢复):若一次刷新已用旧 refresh_token 在飞(≤30s
/// 超时),期间同一 `account_id` 被 remove 后又重新登录,则旧刷新返回时可能把旧
/// generation 的 token 写进新账号。需要 generation/version 校验才能彻底关闭;因触发
/// 需要「刷新在飞窗口内 remove+重加同一账号」且结果可通过重新登录恢复,暂不引入。
/// 并发正确性:调用方持 lifecycle 读锁;刷新再按 account refresh mutex →
/// accounts → access_tokens → storage 的顺序提交。remove/clear 持 lifecycle 写锁,
/// 因而会等待在飞刷新并阻断同 account_id 的 ABA 重建
async fn resolve_valid_cached_token(
&self,
account_id: &str,
@@ -582,6 +582,30 @@ impl CodexOAuthManager {
let refresh_lock = self.get_refresh_lock(account_id).await;
let _guard = refresh_lock.lock().await;
self.resolve_valid_cached_token_under_lock(account_id).await
}
/// Resolve a token while the caller owns this account's refresh mutex.
/// Keeping this separate lets the full auth-bundle path hold one generation
/// lock across access/id/refresh reads without recursively locking the mutex.
async fn resolve_valid_cached_token_under_lock(
&self,
account_id: &str,
) -> Result<CachedAccessToken, CodexOAuthError> {
// Codex CLI may have advanced the shared refresh-token generation since
// this manager last used the account. Reload it under the same per-account
// lock before deciding whether a network refresh is necessary.
if let Some((live_refresh, live_id_token, live_last_refresh_ms)) =
crate::codex_config::read_codex_live_auth_refresh_for_account(account_id)
{
self.adopt_account_refresh_token_under_lock(
account_id,
live_refresh,
live_id_token,
live_last_refresh_ms,
)
.await?;
}
// double-check(同样在 accounts 读锁下)
{
@@ -597,7 +621,7 @@ impl CodexOAuthManager {
}
}
let refresh_token = {
let mut refresh_token = {
let accounts = self.accounts.read().await;
accounts
.get(account_id)
@@ -605,33 +629,79 @@ impl CodexOAuthManager {
.ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?
};
let new_tokens = self.refresh_with_token(&refresh_token).await?;
let new_tokens = match self.refresh_with_token(&refresh_token).await {
Err(CodexOAuthError::RefreshTokenInvalid) => {
// If Codex CLI refreshed between our pre-read and request, reload
// its newer generation and retry exactly once. Error-code handling
// includes OpenAI's `refresh_token_reused` response.
let Some((live_refresh, live_id_token, live_last_refresh_ms)) =
crate::codex_config::read_codex_live_auth_refresh_for_account(account_id)
.filter(|(token, _, _)| token.trim() != refresh_token.as_str())
else {
return Err(CodexOAuthError::RefreshTokenInvalid);
};
let adopted = self
.adopt_account_refresh_token_under_lock(
account_id,
live_refresh.clone(),
live_id_token,
live_last_refresh_ms,
)
.await?;
if !adopted {
return Err(CodexOAuthError::RefreshTokenInvalid);
}
refresh_token = live_refresh;
self.refresh_with_token(&refresh_token).await?
}
result => result?,
};
let obtained_at_ms = chrono::Utc::now().timestamp_millis();
// 如果服务端返回了新的 refresh_token 或 id_token,更新存储
let mut needs_save = false;
{
let (stored_refresh_token, stored_id_token) = {
let mut accounts = self.accounts.write().await;
if let Some(account) = accounts.get_mut(account_id) {
if let Some(new_refresh) = new_tokens.refresh_token.clone() {
if new_refresh != account.refresh_token {
account.refresh_token = new_refresh;
needs_save = true;
}
}
// 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失,
// 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。
if let Some(new_id_token) = new_tokens
.id_token
.clone()
.filter(|token| !token.trim().is_empty())
{
if account.id_token.as_deref() != Some(new_id_token.as_str()) {
account.id_token = Some(new_id_token);
needs_save = true;
}
let account = accounts
.get_mut(account_id)
.ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?;
// Device re-login and CLI-token adoption use the same account lock,
// but keep a generation CAS here as defense in depth: a response for
// R0 must never overwrite a newly committed R1/N0 chain.
if account.refresh_token != refresh_token {
return Err(CodexOAuthError::TokenFetchFailed(
"账号凭据已更新,已丢弃旧刷新响应".to_string(),
));
}
if let Some(new_refresh) = new_tokens
.refresh_token
.clone()
.filter(|token| !token.trim().is_empty())
{
if new_refresh != account.refresh_token {
account.refresh_token = new_refresh;
needs_save = true;
}
}
}
// 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失,
// 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。
if let Some(new_id_token) = new_tokens
.id_token
.clone()
.filter(|token| !token.trim().is_empty())
{
if account.id_token.as_deref() != Some(new_id_token.as_str()) {
account.id_token = Some(new_id_token);
needs_save = true;
}
}
if account.token_updated_at_ms != obtained_at_ms {
account.token_updated_at_ms = obtained_at_ms;
needs_save = true;
}
(account.refresh_token.clone(), account.id_token.clone())
};
if needs_save {
self.save_to_disk().await?;
}
@@ -639,9 +709,31 @@ impl CodexOAuthManager {
let cached = CachedAccessToken {
token: new_tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(new_tokens.expires_in),
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
obtained_at_ms,
};
let last_refresh = chrono::DateTime::<chrono::Utc>::from_timestamp_millis(obtained_at_ms)
.unwrap_or_else(chrono::Utc::now)
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true);
let refreshed_auth = crate::codex_config::codex_managed_oauth_auth_value(
account_id,
&cached.token,
stored_id_token.as_deref(),
&stored_refresh_token,
&last_refresh,
);
if let Err(err) = crate::codex_config::sync_codex_managed_oauth_live_auth_after_refresh(
account_id,
&refresh_token,
&refreshed_auth,
) {
// The manager token remains valid; a later provider write will
// retry the live synchronization without rolling it back.
log::warn!(
"[CodexOAuth] 同步刷新后的 Codex live auth 失败(account={account_id}: {err}"
);
}
// 在 accounts 读锁下确认账号仍存在,再写缓存:与 remove/clear(持 accounts
// 写锁并原子清缓存)互斥,杜绝把已删账号的 token 写回缓存。
{
@@ -665,13 +757,8 @@ impl CodexOAuthManager {
&self,
account_id: &str,
) -> Result<(String, Option<String>), CodexOAuthError> {
// 先确保 access_token 有效;刷新过程会顺带更新持久化的 id_token
let access_token = self.get_valid_token_for_account(account_id).await?;
let id_token = {
let accounts = self.accounts.read().await;
accounts.get(account_id).and_then(|a| a.id_token.clone())
};
Ok((access_token, id_token))
let bundle = self.get_valid_token_bundle_for_account(account_id).await?;
Ok((bundle.access_token, bundle.id_token))
}
/// 获取写入托管 Codex `auth.json` 所需的完整可刷新 token 束
@@ -684,9 +771,16 @@ impl CodexOAuthManager {
&self,
account_id: &str,
) -> Result<ManagedTokenBundle, CodexOAuthError> {
// access_token 与其获取时间来自同一次解析(缓存命中即同一条目、刷新即新铸),
// 天然配套,杜绝「旧 token + 新时间戳」的错配。
let cached = self.resolve_valid_cached_token(account_id).await?;
let _lifecycle = self.lifecycle_lock.read().await;
let refresh_lock = self.get_refresh_lock(account_id).await;
let _refresh_guard = refresh_lock.lock().await;
// Resolve and read every persistent token field while holding the same
// account generation lock. Otherwise an adoption between these reads
// can create an invalid A0 + R1/ID1 mixed bundle.
let cached = self
.resolve_valid_cached_token_under_lock(account_id)
.await?;
let last_refresh =
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(cached.obtained_at_ms)
.unwrap_or_else(chrono::Utc::now)
@@ -718,7 +812,9 @@ impl CodexOAuthManager {
account_id: &str,
refresh_token: String,
id_token: Option<String>,
last_refresh_ms: Option<i64>,
) -> Result<bool, CodexOAuthError> {
let _lifecycle = self.lifecycle_lock.read().await;
let refresh_token = refresh_token.trim().to_string();
if refresh_token.is_empty() {
return Ok(false);
@@ -727,6 +823,25 @@ impl CodexOAuthManager {
// 覆盖我们刚采纳的 CLI 轮换值。
let refresh_lock = self.get_refresh_lock(account_id).await;
let _guard = refresh_lock.lock().await;
self.adopt_account_refresh_token_under_lock(
account_id,
refresh_token,
id_token,
last_refresh_ms,
)
.await
}
/// Same as `adopt_account_refresh_token`, for callers already holding the
/// per-account refresh lock.
async fn adopt_account_refresh_token_under_lock(
&self,
account_id: &str,
refresh_token: String,
id_token: Option<String>,
last_refresh_ms: Option<i64>,
) -> Result<bool, CodexOAuthError> {
let incoming_id_token = id_token.filter(|token| !token.trim().is_empty());
let mut changed = false;
{
let mut accounts = self.accounts.write().await;
@@ -734,16 +849,46 @@ impl CodexOAuthManager {
// 不是本 manager 托管的账号:不接管、不落盘。
return Ok(false);
};
if account.refresh_token != refresh_token {
// A manager refresh may already have advanced the token generation
// while auth.json still contains the older one. Never roll that
// state back during the preflight/write double-build sequence.
let refresh_changed = account.refresh_token != refresh_token;
let id_token_changed = incoming_id_token
.as_ref()
.is_some_and(|token| account.id_token.as_deref() != Some(token.as_str()));
let material_changed = refresh_changed || id_token_changed;
// Once the manager has a dated generation, any different token
// material must carry a *strictly newer* live timestamp. Equality is
// ambiguous at millisecond precision and therefore cannot authorize
// replacing the manager generation either.
let observed_is_newer = account.token_updated_at_ms <= 0
|| last_refresh_ms.is_some_and(|observed| observed > account.token_updated_at_ms);
if material_changed && !observed_is_newer {
return Ok(false);
}
if refresh_changed {
account.refresh_token = refresh_token;
changed = true;
}
if let Some(id_token) = id_token.filter(|token| !token.trim().is_empty()) {
if let Some(id_token) = incoming_id_token {
if account.id_token.as_deref() != Some(id_token.as_str()) {
account.id_token = Some(id_token);
changed = true;
}
}
if let Some(observed) = last_refresh_ms {
if observed > account.token_updated_at_ms {
account.token_updated_at_ms = observed;
changed = true;
}
} else if material_changed && account.token_updated_at_ms <= 0 {
// One-time migration for stores written before generation
// timestamps existed. Dating the adopted value prevents another
// undated live file from rolling it back later.
account.token_updated_at_ms = chrono::Utc::now().timestamp_millis();
changed = true;
}
// 采纳了 CLI 轮换后的 refresh_token:与之配套的旧 access_token 可能已被
// 服务端提前失效。在同一 accounts 写锁内(accounts -> access_tokens 顺序)
// 清缓存,避免释放锁后被快路径读到旧 token;下次按新 refresh_token 重取。
@@ -782,6 +927,10 @@ impl CodexOAuthManager {
pub async fn remove_account(&self, account_id: &str) -> Result<(), CodexOAuthError> {
log::info!("[CodexOAuth] 移除账号: {account_id}");
// Wait for all in-flight refresh/adopt operations before deleting. New
// token work is blocked until the account, cache, lock and disk state
// have been removed as one lifecycle transition.
let _lifecycle = self.lifecycle_lock.write().await;
{
// 在 accounts 写锁内原子清除该账号的 token 缓存(accounts -> access_tokens
@@ -810,6 +959,7 @@ impl CodexOAuthManager {
}
pub async fn set_default_account(&self, account_id: &str) -> Result<(), CodexOAuthError> {
let _lifecycle = self.lifecycle_lock.read().await;
{
let accounts = self.accounts.read().await;
if !accounts.contains_key(account_id) {
@@ -829,6 +979,12 @@ impl CodexOAuthManager {
pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> {
log::info!("[CodexOAuth] 清除所有认证");
// Acquire lifecycle before storage. Refresh follows lifecycle(read) ->
// account mutex -> storage, so this fixed order cannot deadlock and the
// write guard guarantees no refresh can recreate live/disk state after
// the clear has committed.
let _lifecycle = self.lifecycle_lock.write().await;
// 与 save_to_disk 共用持久化锁:确保「清内存 + 删文件」相对于并发保存原子,
// 不会被一个持有旧快照的 save 复活已清除的账号。
let _persist = self.storage_lock.lock().await;
@@ -892,24 +1048,21 @@ impl CodexOAuthManager {
access_token: &str,
id_token: Option<&str>,
) -> Result<(), CodexOAuthError> {
let obtained_at_ms = chrono::Utc::now().timestamp_millis();
self.add_account_internal(
account_id.to_string(),
"test-refresh-token".to_string(),
Some(format!("{account_id}@example.test")),
id_token.map(|token| token.to_string()),
Some(CachedAccessToken {
token: access_token.to_string(),
expires_at_ms: obtained_at_ms + 3_600_000,
obtained_at_ms,
}),
None,
)
.await?;
let mut tokens = self.access_tokens.write().await;
tokens.insert(
account_id.to_string(),
CachedAccessToken {
token: access_token.to_string(),
expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000,
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
},
);
Ok(())
}
@@ -921,8 +1074,28 @@ impl CodexOAuthManager {
refresh_token: String,
email: Option<String>,
id_token: Option<String>,
initial_access_token: Option<CachedAccessToken>,
pending_device_code: Option<&str>,
) -> Result<GitHubAccount, CodexOAuthError> {
let _lifecycle = self.lifecycle_lock.read().await;
if let Some(device_code) = pending_device_code {
// `clear_auth` owns lifecycle(write) while clearing pending flows.
// Re-check under lifecycle(read) at commit time so a poll that was
// already on the network cannot recreate an account after clear.
if self
.pending_device_codes
.write()
.await
.remove(device_code)
.is_none()
{
return Err(CodexOAuthError::ExpiredToken);
}
}
let refresh_lock = self.get_refresh_lock(&account_id).await;
let _refresh_guard = refresh_lock.lock().await;
let now = chrono::Utc::now().timestamp();
let now_ms = chrono::Utc::now().timestamp_millis();
let data = CodexAccountData {
account_id: account_id.clone(),
@@ -930,6 +1103,7 @@ impl CodexOAuthManager {
refresh_token,
authenticated_at: now,
id_token,
token_updated_at_ms: now_ms,
};
let account = GitHubAccount::from(&data);
@@ -937,6 +1111,12 @@ impl CodexOAuthManager {
{
let mut accounts = self.accounts.write().await;
accounts.insert(account_id.clone(), data);
let mut access_tokens = self.access_tokens.write().await;
if let Some(cached) = initial_access_token {
access_tokens.insert(account_id.clone(), cached);
} else {
access_tokens.remove(&account_id);
}
}
{
@@ -1143,6 +1323,19 @@ fn compute_expires_at_ms(expires_in: Option<i64>) -> i64 {
now_ms + secs * 1000
}
fn extract_refresh_error_code(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
value
.get("error")
.and_then(|error| match error {
serde_json::Value::Object(object) => object.get("code").and_then(|code| code.as_str()),
serde_json::Value::String(code) => Some(code.as_str()),
_ => None,
})
.or_else(|| value.get("code").and_then(|code| code.as_str()))
.map(|code| code.to_ascii_lowercase())
}
/// 解析 JWT 中的 claims
fn parse_jwt_claims(token: &str) -> Option<IdTokenClaims> {
let parts: Vec<&str> = token.split('.').collect();
@@ -1317,6 +1510,8 @@ mod tests {
"rt-secret".to_string(),
Some("user@example.com".to_string()),
None,
None,
None,
)
.await
.unwrap();
@@ -1340,6 +1535,8 @@ mod tests {
"rt".to_string(),
Some("a@example.com".to_string()),
None,
None,
None,
)
.await
.unwrap();
@@ -1349,6 +1546,8 @@ mod tests {
"rt2".to_string(),
Some("b@example.com".to_string()),
None,
None,
None,
)
.await
.unwrap();
@@ -1368,12 +1567,20 @@ mod tests {
.await
.unwrap();
// 采纳 Codex CLI 轮换后的 refresh_token / id_token。
// 采纳带有更新 last_refresh 的 Codex CLI 轮换 refresh_token / id_token。
let manager_updated_at = manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.token_updated_at_ms;
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"rotated-rt".to_string(),
Some("id-2".to_string()),
Some(manager_updated_at.saturating_add(1)),
)
.await
.unwrap();
@@ -1395,14 +1602,208 @@ mod tests {
// 未知账号不接管。
assert!(!manager
.adopt_account_refresh_token("acc-unknown", "x".to_string(), None)
.adopt_account_refresh_token("acc-unknown", "x".to_string(), None, None)
.await
.unwrap());
// 相同值不算变化。
assert!(!manager
.adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None)
.adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None, None)
.await
.unwrap());
}
#[tokio::test]
async fn adopt_account_refresh_token_rejects_older_live_generation() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-1"))
.await
.unwrap();
let manager_updated_at = manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.token_updated_at_ms;
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"stale-live-refresh".to_string(),
None,
Some(manager_updated_at.saturating_sub(1)),
)
.await
.unwrap();
assert!(!changed, "older live state must not roll the manager back");
assert_eq!(
manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.refresh_token,
"test-refresh-token"
);
}
#[tokio::test]
async fn adopt_account_refresh_token_rejects_undated_live_generation() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-1"))
.await
.unwrap();
let changed = manager
.adopt_account_refresh_token("acc-1", "ambiguous-live-refresh".to_string(), None, None)
.await
.unwrap();
assert!(
!changed,
"an undated live token must not roll back a timestamped manager generation"
);
assert_eq!(
manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.refresh_token,
"test-refresh-token"
);
}
#[tokio::test]
async fn adopt_account_refresh_token_rejects_stale_id_token_with_same_refresh() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-new"))
.await
.unwrap();
let manager_updated_at = manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.token_updated_at_ms;
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"test-refresh-token".to_string(),
Some("id-stale".to_string()),
Some(manager_updated_at.saturating_sub(1)),
)
.await
.unwrap();
assert!(!changed);
assert_eq!(
manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.id_token
.as_deref(),
Some("id-new")
);
}
#[tokio::test]
async fn adopt_account_refresh_token_rejects_equal_timestamp_generation() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-1"))
.await
.unwrap();
let manager_updated_at = manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.token_updated_at_ms;
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"same-millisecond-refresh".to_string(),
None,
Some(manager_updated_at),
)
.await
.unwrap();
assert!(!changed);
assert_eq!(
manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.refresh_token,
"test-refresh-token"
);
}
#[tokio::test]
async fn device_commit_rejects_flow_cleared_during_network_poll() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager.pending_device_codes.write().await.insert(
"device-auth-id".to_string(),
PendingDeviceCode {
user_code: "ABCD-EFGH".to_string(),
expires_at_ms: chrono::Utc::now().timestamp_millis() + 60_000,
},
);
manager.clear_auth().await.unwrap();
let result = manager
.add_account_internal(
"acc-after-clear".to_string(),
"refresh-after-clear".to_string(),
None,
None,
None,
Some("device-auth-id"),
)
.await;
assert!(matches!(result, Err(CodexOAuthError::ExpiredToken)));
assert!(manager.list_accounts().await.is_empty());
assert!(!manager.storage_path.exists());
}
#[test]
fn refresh_error_code_accepts_openai_error_shapes() {
assert_eq!(
extract_refresh_error_code(r#"{"error":{"code":"refresh_token_reused"}}"#).as_deref(),
Some("refresh_token_reused")
);
assert_eq!(
extract_refresh_error_code(r#"{"error":"refresh_token_expired"}"#).as_deref(),
Some("refresh_token_expired")
);
assert_eq!(
extract_refresh_error_code(r#"{"code":"REFRESH_TOKEN_INVALIDATED"}"#).as_deref(),
Some("refresh_token_invalidated")
);
assert_eq!(extract_refresh_error_code("not json"), None);
}
}
@@ -0,0 +1,399 @@
//! Shared builders for the OpenAI Responses SSE envelope.
//!
//! The two Codex streaming converters — `streaming_codex_chat` (Chat Completions SSE →
//! Responses SSE) and `streaming_codex_anthropic` (Anthropic Messages SSE → Responses
//! SSE) — have completely different *input* state machines but must emit the identical
//! Responses event stream the Codex client understands. This module owns that output
//! envelope so the two converters cannot drift when an event's shape changes: a wire fix
//! lands here once instead of being mirrored in both files.
//!
//! Each function is pure — it takes primitives or a caller-built `item` `Value` and
//! returns the exact bytes the converters previously constructed inline. Item shapes that
//! vary per converter (including function, namespace, custom, and tool-search calls)
//! are supplied by the caller via the generic
//! `output_item_added` / `output_item_done` helpers.
use bytes::Bytes;
use serde_json::{json, Value};
/// Serialize one Responses SSE event with the standard `event:`/`data:` framing.
pub(crate) fn sse_event(event: &str, data: Value) -> Bytes {
Bytes::from(format!(
"event: {event}\ndata: {}\n\n",
serde_json::to_string(&data).unwrap_or_default()
))
}
// ---------------------------------------------------------------------------
// Response lifecycle (created / in_progress / completed / failed)
// ---------------------------------------------------------------------------
/// `response.created`, wrapping a caller-built `response` object (usage/created_at differ
/// per converter, so the caller supplies the whole object).
pub(crate) fn response_created(response: &Value) -> Bytes {
sse_event(
"response.created",
json!({ "type": "response.created", "response": response }),
)
}
/// `response.in_progress`.
pub(crate) fn response_in_progress(response: &Value) -> Bytes {
sse_event(
"response.in_progress",
json!({ "type": "response.in_progress", "response": response }),
)
}
/// `response.completed`.
pub(crate) fn response_completed(response: &Value) -> Bytes {
sse_event(
"response.completed",
json!({ "type": "response.completed", "response": response }),
)
}
/// `response.failed`.
pub(crate) fn response_failed(response: &Value) -> Bytes {
sse_event(
"response.failed",
json!({ "type": "response.failed", "response": response }),
)
}
// ---------------------------------------------------------------------------
// Generic output-item add/done (item value supplied by the caller)
// ---------------------------------------------------------------------------
/// `response.output_item.added` with a caller-built item (message / reasoning /
/// function_call / custom_tool_call).
pub(crate) fn output_item_added(output_index: u32, item: &Value) -> Bytes {
sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": item
}),
)
}
/// `response.output_item.done` with a caller-built item.
pub(crate) fn output_item_done(output_index: u32, item: &Value) -> Bytes {
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
)
}
// ---------------------------------------------------------------------------
// Assistant message (text) lifecycle
// ---------------------------------------------------------------------------
/// `response.output_item.added` for an in-progress assistant message.
pub(crate) fn message_item_added(output_index: u32, item_id: &str) -> Bytes {
output_item_added(
output_index,
&json!({
"id": item_id,
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": []
}),
)
}
/// `response.content_part.added` for the (empty) output_text part of a message.
pub(crate) fn message_content_part_added(output_index: u32, item_id: &str) -> Bytes {
sse_event(
"response.content_part.added",
json!({
"type": "response.content_part.added",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": { "type": "output_text", "text": "", "annotations": [] }
}),
)
}
/// `response.output_text.delta`.
pub(crate) fn output_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.output_text.delta",
json!({
"type": "response.output_text.delta",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"delta": delta
}),
)
}
/// The completed assistant-message item value.
pub(crate) fn message_item(item_id: &str, text: &str) -> Value {
json!({
"id": item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{ "type": "output_text", "text": text, "annotations": [] }]
})
}
/// Close an assistant message: emits `output_text.done` → `content_part.done` →
/// `output_item.done`, and returns the completed item so the caller can record it.
pub(crate) fn message_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
let item = message_item(item_id, text);
let events = vec![
sse_event(
"response.output_text.done",
json!({
"type": "response.output_text.done",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"text": text
}),
),
sse_event(
"response.content_part.done",
json!({
"type": "response.content_part.done",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": { "type": "output_text", "text": text, "annotations": [] }
}),
),
output_item_done(output_index, &item),
];
(events, item)
}
// ---------------------------------------------------------------------------
// Reasoning (summary) lifecycle
// ---------------------------------------------------------------------------
/// `response.output_item.added` for an in-progress reasoning item.
pub(crate) fn reasoning_item_added(output_index: u32, item_id: &str) -> Bytes {
output_item_added(
output_index,
&json!({
"id": item_id,
"type": "reasoning",
"status": "in_progress",
"summary": []
}),
)
}
/// `response.reasoning_summary_part.added` for the (empty) summary part.
pub(crate) fn reasoning_summary_part_added(output_index: u32, item_id: &str) -> Bytes {
sse_event(
"response.reasoning_summary_part.added",
json!({
"type": "response.reasoning_summary_part.added",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"part": { "type": "summary_text", "text": "" }
}),
)
}
/// `response.reasoning_summary_text.delta`.
pub(crate) fn reasoning_summary_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"delta": delta
}),
)
}
/// The completed reasoning item value (note: no `status` field, matching both converters).
pub(crate) fn reasoning_item(item_id: &str, text: &str) -> Value {
json!({
"id": item_id,
"type": "reasoning",
"summary": [{ "type": "summary_text", "text": text }]
})
}
/// Close a reasoning item: emits `reasoning_summary_text.done` →
/// `reasoning_summary_part.done` → `output_item.done`, and returns the completed item.
pub(crate) fn reasoning_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
let item = reasoning_item(item_id, text);
let events = reasoning_close_with_item(output_index, item_id, text, &item, true);
(events, item)
}
/// Close a reasoning item whose completed shape is supplied by the converter.
/// Anthropic uses this to attach opaque signed/redacted thinking in
/// `encrypted_content` while keeping the standard Responses event lifecycle.
pub(crate) fn reasoning_close_with_item(
output_index: u32,
item_id: &str,
text: &str,
item: &Value,
has_visible_summary: bool,
) -> Vec<Bytes> {
let mut events = Vec::new();
if has_visible_summary {
events.extend([
sse_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"text": text
}),
),
sse_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"part": { "type": "summary_text", "text": text }
}),
),
]);
}
events.push(output_item_done(output_index, item));
events
}
// ---------------------------------------------------------------------------
// Tool-call argument streaming (item value supplied by the caller)
// ---------------------------------------------------------------------------
/// `response.function_call_arguments.delta`.
pub(crate) fn function_call_arguments_delta(
output_index: u32,
item_id: &str,
delta: &str,
) -> Bytes {
sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": item_id,
"output_index": output_index,
"delta": delta
}),
)
}
/// `response.function_call_arguments.done`.
pub(crate) fn function_call_arguments_done(
output_index: u32,
item_id: &str,
arguments: &str,
) -> Bytes {
sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": item_id,
"output_index": output_index,
"arguments": arguments
}),
)
}
/// `response.custom_tool_call_input.delta` (Chat freeform tools only).
pub(crate) fn custom_tool_call_input_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.custom_tool_call_input.delta",
json!({
"type": "response.custom_tool_call_input.delta",
"item_id": item_id,
"output_index": output_index,
"delta": delta
}),
)
}
/// `response.custom_tool_call_input.done` (Chat freeform tools only).
pub(crate) fn custom_tool_call_input_done(output_index: u32, item_id: &str, input: &str) -> Bytes {
sse_event(
"response.custom_tool_call_input.done",
json!({
"type": "response.custom_tool_call_input.done",
"item_id": item_id,
"output_index": output_index,
"input": input
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn body(bytes: &Bytes) -> String {
String::from_utf8(bytes.to_vec()).unwrap()
}
#[test]
fn sse_event_framing() {
let ev = sse_event("response.created", json!({ "a": 1 }));
assert_eq!(body(&ev), "event: response.created\ndata: {\"a\":1}\n\n");
}
#[test]
fn message_close_shapes_match_legacy() {
let (events, item) = message_close(2, "resp_1_msg", "hi");
assert_eq!(events.len(), 3);
assert!(body(&events[0]).contains("\"type\":\"response.output_text.done\""));
assert!(body(&events[0]).contains("\"text\":\"hi\""));
assert!(body(&events[1]).contains("\"type\":\"response.content_part.done\""));
assert!(body(&events[2]).contains("\"type\":\"response.output_item.done\""));
assert_eq!(item["type"], "message");
assert_eq!(item["status"], "completed");
assert_eq!(item["content"][0]["text"], "hi");
}
#[test]
fn reasoning_close_item_has_no_status() {
let (events, item) = reasoning_close(0, "rs_1", "because");
assert_eq!(events.len(), 3);
assert!(body(&events[0]).contains("\"type\":\"response.reasoning_summary_text.done\""));
assert!(body(&events[1]).contains("\"type\":\"response.reasoning_summary_part.done\""));
// The completed reasoning item intentionally carries no `status` field.
assert!(item.get("status").is_none());
assert_eq!(item["summary"][0]["text"], "because");
}
#[test]
fn message_item_added_is_in_progress() {
let ev = message_item_added(0, "m1");
let s = body(&ev);
assert!(s.contains("\"type\":\"response.output_item.added\""));
assert!(s.contains("\"status\":\"in_progress\""));
assert!(s.contains("\"role\":\"assistant\""));
}
#[test]
fn function_call_argument_events() {
assert!(body(&function_call_arguments_delta(1, "fc_x", "{\"a\":"))
.contains("\"type\":\"response.function_call_arguments.delta\""));
assert!(body(&function_call_arguments_done(1, "fc_x", "{\"a\":1}"))
.contains("\"arguments\":\"{\\\"a\\\":1}\""));
}
}
+15 -11
View File
@@ -18,17 +18,21 @@ mod codex;
pub(crate) mod codex_chat_common;
pub mod codex_chat_history;
pub mod codex_oauth_auth;
pub(crate) mod codex_responses_sse;
pub mod copilot_auth;
pub mod copilot_model_map;
mod gemini;
pub(crate) mod gemini_schema;
pub mod gemini_shadow;
pub mod models;
pub(crate) mod reasoning_bridge;
pub mod streaming;
pub mod streaming_codex_anthropic;
pub mod streaming_codex_chat;
pub mod streaming_gemini;
pub mod streaming_responses;
pub mod transform;
pub mod transform_codex_anthropic;
pub mod transform_codex_chat;
pub mod transform_gemini;
pub mod transform_responses;
@@ -37,6 +41,8 @@ use crate::app_config::AppType;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
// 公开导出
pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy};
@@ -47,8 +53,10 @@ pub use claude::{
};
pub use codex::CodexAdapter;
pub use codex::{
apply_codex_chat_upstream_model, codex_provider_upstream_model,
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model,
inject_codex_chat_prompt_cache_key, is_codex_official_provider,
resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config,
should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat,
};
pub use gemini::GeminiAdapter;
@@ -104,7 +112,7 @@ impl ProviderType {
}
ProviderType::OpenRouter => "https://openrouter.ai/api",
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
ProviderType::CodexOAuth => CHATGPT_CODEX_BASE_URL,
}
}
@@ -184,10 +192,8 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex-like type
ProviderType::Codex
}
AppType::GrokBuild => ProviderType::Codex,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
}
}
@@ -238,10 +244,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
AppType::GrokBuild => Box::new(CodexAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
}
}
@@ -0,0 +1,131 @@
//! Opaque reasoning transport helpers shared by the Messages ↔ Responses bridge.
//!
//! The Anthropic Messages protocol has no field for an OpenAI Responses
//! `reasoning` item. To keep stateless tool loops lossless, the complete item is
//! carried in a versioned thinking signature/redacted-thinking payload and
//! restored when the client replays the assistant message.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::{json, Value};
pub(crate) const OPENAI_REASONING_ITEM_PREFIX: &str = "ccswitch-openai-reasoning-v1:";
pub(crate) fn reasoning_summary_text(item: &Value) -> String {
item.get("summary")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|part| {
matches!(
part.get("type").and_then(Value::as_str),
Some("summary_text" | "reasoning_text")
)
.then(|| part.get("text").and_then(Value::as_str))
.flatten()
})
.collect::<Vec<_>>()
.join("")
}
pub(crate) fn encode_openai_reasoning_item(item: &Value) -> Option<String> {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return None;
}
let bytes = serde_json::to_vec(item).ok()?;
Some(format!(
"{OPENAI_REASONING_ITEM_PREFIX}{}",
URL_SAFE_NO_PAD.encode(bytes)
))
}
pub(crate) fn decode_openai_reasoning_item(encoded: &str) -> Option<Value> {
let payload = encoded.strip_prefix(OPENAI_REASONING_ITEM_PREFIX)?;
let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
let item: Value = serde_json::from_slice(&bytes).ok()?;
(item.get("type").and_then(Value::as_str) == Some("reasoning")).then_some(item)
}
pub(crate) fn anthropic_block_from_openai_reasoning_item(item: &Value) -> Option<Value> {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return None;
}
let text = reasoning_summary_text(item);
let has_encrypted_content = item
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty());
if has_encrypted_content {
let envelope = encode_openai_reasoning_item(item)?;
if text.is_empty() {
return Some(json!({
"type": "redacted_thinking",
"data": envelope
}));
}
return Some(json!({
"type": "thinking",
"thinking": text,
"signature": envelope
}));
}
(!text.is_empty()).then(|| {
json!({
"type": "thinking",
"thinking": text
})
})
}
pub(crate) fn openai_reasoning_item_from_anthropic_block(block: &Value) -> Option<Value> {
match block.get("type").and_then(Value::as_str) {
Some("thinking") => block
.get("signature")
.and_then(Value::as_str)
.and_then(decode_openai_reasoning_item),
Some("redacted_thinking") => block
.get("data")
.and_then(Value::as_str)
.and_then(decode_openai_reasoning_item),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openai_reasoning_item_round_trips_through_thinking_signature() {
let item = json!({
"id": "rs_1",
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "Need a tool."}],
"encrypted_content": "opaque"
});
let block = anthropic_block_from_openai_reasoning_item(&item).unwrap();
assert_eq!(block["type"], "thinking");
assert_eq!(
openai_reasoning_item_from_anthropic_block(&block),
Some(item)
);
}
#[test]
fn encrypted_item_without_summary_uses_redacted_thinking() {
let item = json!({
"id": "rs_2",
"type": "reasoning",
"summary": [],
"encrypted_content": "opaque"
});
let block = anthropic_block_from_openai_reasoning_item(&item).unwrap();
assert_eq!(block["type"], "redacted_thinking");
assert_eq!(
openai_reasoning_item_from_anthropic_block(&block),
Some(item)
);
}
}
+17 -3
View File
@@ -80,6 +80,8 @@ struct Usage {
struct PromptTokensDetails {
#[serde(default)]
cached_tokens: u32,
#[serde(default)]
cache_write_tokens: u32,
}
#[derive(Debug, Clone)]
@@ -103,7 +105,7 @@ fn build_anthropic_usage_json(usage: &Usage) -> Value {
// OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 cache_read 与 cache_creation
// (三桶互斥,恒等 input + cache_read + cache_creation == prompt_tokens)。
let cached = extract_cache_read_tokens(usage).unwrap_or(0);
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
let cache_creation = extract_cache_write_tokens(usage).unwrap_or(0);
let input_tokens = usage
.prompt_tokens
.saturating_sub(cached)
@@ -233,7 +235,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
if let Some(u) = &chunk.usage {
let cached = extract_cache_read_tokens(u).unwrap_or(0);
let cache_creation =
u.cache_creation_input_tokens.unwrap_or(0);
extract_cache_write_tokens(u).unwrap_or(0);
let input = u
.prompt_tokens
.saturating_sub(cached)
@@ -683,6 +685,18 @@ fn extract_cache_read_tokens(usage: &Usage) -> Option<u32> {
.filter(|&v| v > 0)
}
/// Extract cache-write tokens from direct compatibility fields or OpenAI details.
fn extract_cache_write_tokens(usage: &Usage) -> Option<u32> {
if let Some(value) = usage.cache_creation_input_tokens {
return Some(value);
}
usage
.prompt_tokens_details
.as_ref()
.map(|details| details.cache_write_tokens)
.filter(|value| *value > 0)
}
/// 映射停止原因
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
finish_reason.map(|r| {
@@ -1061,7 +1075,7 @@ mod tests {
let input = concat!(
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600},\"cache_creation_input_tokens\":300}}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600,\"cache_write_tokens\":300}}}\n\n",
"data: [DONE]\n\n"
);
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
//! OpenAI Chat Completions SSE → OpenAI Responses SSE conversion.
use super::codex_responses_sse as sse;
use super::{
codex_chat_common::{
extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag,
@@ -74,6 +75,7 @@ struct ChatToResponsesState {
reasoning: ReasoningItemState,
inline_think: InlineThinkState,
tools: BTreeMap<usize, ToolCallState>,
next_tool_index_to_add: usize,
output_items: Vec<(u32, Value)>,
latest_usage: Option<Value>,
finish_reason: Option<String>,
@@ -93,6 +95,7 @@ impl Default for ChatToResponsesState {
reasoning: ReasoningItemState::default(),
inline_think: InlineThinkState::default(),
tools: BTreeMap::new(),
next_tool_index_to_add: 0,
output_items: Vec::new(),
latest_usage: None,
finish_reason: None,
@@ -270,20 +273,8 @@ impl ChatToResponsesState {
let response = self.base_response("in_progress", Vec::new());
vec![
sse_event(
"response.created",
json!({
"type": "response.created",
"response": response
}),
),
sse_event(
"response.in_progress",
json!({
"type": "response.in_progress",
"response": self.base_response("in_progress", Vec::new())
}),
),
sse::response_created(&response),
sse::response_in_progress(&response),
]
}
@@ -297,45 +288,16 @@ impl ChatToResponsesState {
self.reasoning.item_id = item_id.clone();
self.reasoning.added = true;
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": {
"id": item_id,
"type": "reasoning",
"status": "in_progress",
"summary": []
}
}),
));
events.push(sse_event(
"response.reasoning_summary_part.added",
json!({
"type": "response.reasoning_summary_part.added",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"part": {
"type": "summary_text",
"text": ""
}
}),
));
events.push(sse::reasoning_item_added(output_index, &item_id));
events.push(sse::reasoning_summary_part_added(output_index, &item_id));
}
self.reasoning.text.push_str(delta);
let output_index = self.reasoning.output_index.unwrap_or(0);
events.push(sse_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"delta": delta
}),
events.push(sse::reasoning_summary_text_delta(
output_index,
&self.reasoning.item_id,
delta,
));
events
@@ -351,47 +313,16 @@ impl ChatToResponsesState {
self.text.item_id = item_id.clone();
self.text.added = true;
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": {
"id": item_id,
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": []
}
}),
));
events.push(sse_event(
"response.content_part.added",
json!({
"type": "response.content_part.added",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"part": {
"type": "output_text",
"text": "",
"annotations": []
}
}),
));
events.push(sse::message_item_added(output_index, &item_id));
events.push(sse::message_content_part_added(output_index, &item_id));
}
self.text.text.push_str(delta);
let output_index = self.text.output_index.unwrap_or(0);
events.push(sse_event(
"response.output_text.delta",
json!({
"type": "response.output_text.delta",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"delta": delta
}),
events.push(sse::output_text_delta(
output_index,
&self.text.item_id,
delta,
));
events
@@ -418,16 +349,16 @@ impl ChatToResponsesState {
.unwrap_or("")
.to_string();
let mut should_add = false;
let mut output_index = None;
let mut item_id = String::new();
let mut pending_arguments = String::new();
let current_name: String;
{
let state = self.tools.entry(chat_index).or_default();
if let Some(id) = id_delta {
state.call_id = id;
if let Some(ref id) = id_delta {
if !id.is_empty() {
state.call_id.clone_from(id);
}
}
if let Some(ref name) = name_delta {
if !name.is_empty() {
@@ -444,10 +375,7 @@ impl ChatToResponsesState {
}
}
if !state.added && !state.call_id.is_empty() && !state.name.is_empty() {
should_add = true;
pending_arguments = state.arguments.clone();
} else if state.added {
if state.added {
output_index = state.output_index;
item_id = state.item_id.clone();
}
@@ -457,26 +385,51 @@ impl ChatToResponsesState {
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&current_name);
let mut events = Vec::new();
if should_add {
if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse::function_call_arguments_delta(
output_index,
&item_id,
&args_delta,
));
}
}
events.extend(self.flush_ready_tool_calls());
events
}
fn flush_ready_tool_calls(&mut self) -> Vec<Bytes> {
// Release consecutive Chat indexes so late identity fragments cannot reorder calls.
let mut events = Vec::new();
loop {
let key = self.next_tool_index_to_add;
let Some(state) = self.tools.get(&key) else {
break;
};
if state.added || state.done {
self.next_tool_index_to_add += 1;
continue;
}
if state.call_id.is_empty() || state.name.is_empty() {
break;
}
let assigned = self.next_output_index();
let Some(state) = self.tools.get_mut(&chat_index) else {
return events;
let Some(state) = self.tools.get_mut(&key) else {
continue;
};
state.added = true;
if state.call_id.is_empty() {
state.call_id = format!("call_{chat_index}");
}
state.output_index = Some(assigned);
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
state.item_id = response_tool_call_item_id_from_chat_name(
&state.call_id,
&state.name,
&self.tool_context,
);
item_id = state.item_id.clone();
let item = response_tool_call_item_from_chat_name(
&item_id,
&state.item_id,
"in_progress",
&state.call_id,
&state.name,
@@ -485,38 +438,18 @@ impl ChatToResponsesState {
&self.tool_context,
);
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": assigned,
"item": item
}),
));
events.push(sse::output_item_added(assigned, &item));
if !pending_arguments.is_empty() && !is_custom_tool {
events.push(sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": state.item_id,
"output_index": assigned,
"delta": pending_arguments
}),
));
}
} else if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": item_id,
"output_index": output_index,
"delta": args_delta
}),
if !state.arguments.is_empty()
&& !self.tool_context.is_custom_tool_chat_name(&state.name)
{
events.push(sse::function_call_arguments_delta(
assigned,
&state.item_id,
&state.arguments,
));
}
self.next_tool_index_to_add += 1;
}
events
@@ -567,13 +500,7 @@ impl ChatToResponsesState {
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
}
events.push(sse_event(
"response.completed",
json!({
"type": "response.completed",
"response": response
}),
));
events.push(sse::response_completed(&response));
self.completed = true;
events
}
@@ -586,50 +513,10 @@ impl ChatToResponsesState {
let output_index = self.reasoning.output_index.unwrap_or(0);
let item_id = self.reasoning.item_id.clone();
let text = self.reasoning.text.clone();
let item = json!({
"id": item_id,
"type": "reasoning",
"summary": [{
"type": "summary_text",
"text": text
}]
});
self.output_items.push((output_index, item.clone()));
let (events, item) = sse::reasoning_close(output_index, &item_id, &text);
self.output_items.push((output_index, item));
self.reasoning.done = true;
vec![
sse_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"text": self.reasoning.text
}),
),
sse_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"part": {
"type": "summary_text",
"text": self.reasoning.text
}
}),
),
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
),
]
events
}
fn finalize_text(&mut self) -> Vec<Bytes> {
@@ -638,54 +525,12 @@ impl ChatToResponsesState {
}
let output_index = self.text.output_index.unwrap_or(0);
let item = json!({
"id": self.text.item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": self.text.text,
"annotations": []
}]
});
self.output_items.push((output_index, item.clone()));
let item_id = self.text.item_id.clone();
let text = self.text.text.clone();
let (events, item) = sse::message_close(output_index, &item_id, &text);
self.output_items.push((output_index, item));
self.text.done = true;
vec![
sse_event(
"response.output_text.done",
json!({
"type": "response.output_text.done",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"text": self.text.text
}),
),
sse_event(
"response.content_part.done",
json!({
"type": "response.content_part.done",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"part": {
"type": "output_text",
"text": self.text.text,
"annotations": []
}
}),
),
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
),
]
events
}
fn finalize_tools(&mut self) -> Vec<Bytes> {
@@ -742,14 +587,7 @@ impl ChatToResponsesState {
Some(&state.reasoning_content),
&self.tool_context,
);
add_event = Some(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": assigned,
"item": item
}),
));
add_event = Some(sse::output_item_added(assigned, &item));
}
if let Some(event) = add_event {
@@ -777,44 +615,25 @@ impl ChatToResponsesState {
if is_custom_tool {
let input = custom_tool_input_from_chat_arguments(&arguments);
if !input.is_empty() {
events.push(sse_event(
"response.custom_tool_call_input.delta",
json!({
"type": "response.custom_tool_call_input.delta",
"item_id": state.item_id,
"output_index": output_index,
"delta": input.clone()
}),
events.push(sse::custom_tool_call_input_delta(
output_index,
&state.item_id,
&input,
));
}
events.push(sse_event(
"response.custom_tool_call_input.done",
json!({
"type": "response.custom_tool_call_input.done",
"item_id": state.item_id,
"output_index": output_index,
"input": input
}),
events.push(sse::custom_tool_call_input_done(
output_index,
&state.item_id,
&input,
));
} else {
events.push(sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": state.item_id,
"output_index": output_index,
"arguments": arguments
}),
events.push(sse::function_call_arguments_done(
output_index,
&state.item_id,
&arguments,
));
}
events.push(sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
));
events.push(sse::output_item_done(output_index, &item));
}
events
@@ -864,13 +683,7 @@ impl ChatToResponsesState {
let mut response = self.base_response("failed", self.completed_output_items());
response["error"] = error;
sse_event(
"response.failed",
json!({
"type": "response.failed",
"response": response
}),
)
sse::response_failed(&response)
}
}
@@ -1030,13 +843,6 @@ fn extract_chat_sse_error(value: &Value) -> (String, Option<String>) {
(message, error_type)
}
fn sse_event(event: &str, data: Value) -> Bytes {
Bytes::from(format!(
"event: {event}\ndata: {}\n\n",
serde_json::to_string(&data).unwrap_or_default()
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1057,6 +863,16 @@ mod tests {
String::from_utf8(bytes.concat()).unwrap()
}
fn parse_sse_events(output: &str) -> Vec<Value> {
output
.split("\n\n")
.filter_map(|block| {
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
serde_json::from_str(data).ok()
})
.collect()
}
#[tokio::test]
async fn converts_text_chat_sse_to_responses_sse() {
let output = collect(vec![
@@ -1129,6 +945,114 @@ mod tests {
assert!(output.contains("\"call_id\":\"call_1\""));
}
#[tokio::test]
async fn preserves_tool_identity_across_empty_continuation_deltas() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_dashscope\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_dashscope\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_dashscope\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\\\"cmd\\\":\\\"date\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let added = events
.iter()
.filter(|event| event["type"] == "response.output_item.added")
.collect::<Vec<_>>();
let done = events
.iter()
.find(|event| event["type"] == "response.output_item.done")
.unwrap();
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
assert_eq!(added.len(), 1);
for item in [&done["item"], &completed["response"]["output"][0]] {
assert_eq!(item["type"], "function_call");
assert_eq!(item["name"], "exec_command");
assert_eq!(item["call_id"], "call_dashscope");
assert_eq!(item["arguments"], r#"{"cmd":"date"}"#);
}
assert!(!output.contains(r#""name":"""#));
assert!(!output.contains(r#""call_id":"""#));
}
#[tokio::test]
async fn preserves_parallel_tool_order_when_earlier_name_arrives_late() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_first\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"{\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_second\",\"type\":\"function\",\"function\":{\"name\":\"second_tool\",\"arguments\":\"{\\\"value\\\":2}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"first_tool\",\"arguments\":\"\\\"value\\\":1}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let added = events
.iter()
.filter(|event| event["type"] == "response.output_item.added")
.collect::<Vec<_>>();
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(added.len(), 2);
assert_eq!(added[0]["output_index"], 0);
assert_eq!(added[0]["item"]["name"], "first_tool");
assert_eq!(added[1]["output_index"], 1);
assert_eq!(added[1]["item"]["name"], "second_tool");
assert_eq!(items[0]["name"], "first_tool");
assert_eq!(items[0]["call_id"], "call_first");
assert_eq!(items[0]["arguments"], r#"{"value":1}"#);
assert_eq!(items[1]["name"], "second_tool");
assert_eq!(items[1]["call_id"], "call_second");
assert_eq!(items[1]["arguments"], r#"{"value":2}"#);
}
#[tokio::test]
async fn finalization_keeps_valid_call_after_unnamed_earlier_call() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_parallel_missing\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_missing\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel_missing\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_valid\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\\\"cmd\\\":\\\"date\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["name"], "exec_command");
assert_eq!(items[0]["call_id"], "call_valid");
assert_eq!(items[0]["arguments"], r#"{"cmd":"date"}"#);
assert!(!output.contains("call_missing"));
}
#[tokio::test]
async fn finalization_keeps_non_contiguous_tool_index() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_sparse\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":2,\"id\":\"call_sparse\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["name"], "read_file");
assert_eq!(items[0]["call_id"], "call_sparse");
assert_eq!(items[0]["arguments"], r#"{"path":"README.md"}"#);
}
#[tokio::test]
async fn restores_custom_tool_input_stream_events() {
let request = json!({
File diff suppressed because it is too large Load Diff
+92 -5
View File
@@ -490,9 +490,21 @@ fn convert_message_to_openai(
Ok(result)
}
/// 清理 JSON schema(移除不支持的 format
pub fn clean_schema(mut schema: Value) -> Value {
/// 清理工具参数的 JSON schema,并为根 schema 补齐 OpenAI 要求的 object 类型。
pub fn clean_schema(schema: Value) -> Value {
clean_schema_inner(schema, true)
}
fn clean_schema_inner(mut schema: Value, is_root: bool) -> Value {
if let Some(obj) = schema.as_object_mut() {
let missing_type = is_root && !obj.contains_key("type");
if missing_type {
obj.insert("type".to_string(), json!("object"));
}
if missing_type && !obj.contains_key("properties") {
obj.insert("properties".to_string(), json!({}));
}
// 移除 "format": "uri"
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
obj.remove("format");
@@ -501,12 +513,12 @@ pub fn clean_schema(mut schema: Value) -> Value {
// 递归清理嵌套 schema
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for (_, value) in properties.iter_mut() {
*value = clean_schema(value.clone());
*value = clean_schema_inner(value.clone(), false);
}
}
if let Some(items) = obj.get_mut("items") {
*items = clean_schema(items.clone());
*items = clean_schema_inner(items.clone(), false);
}
}
schema
@@ -653,7 +665,7 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
// 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等:
// input + cache_read + cache_creation == prompt_tokensinclusive 上游)。
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
// 最终 cache_read:直传字段优先于 nestedcache_creation 仅来自直传字段(OpenAI 无此概念)
// 最终 cache_read/cache_creation:直传字段优先于 OpenAI nested details
let cached = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
@@ -666,6 +678,12 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let cache_creation = usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.pointer("/prompt_tokens_details/cache_write_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
let input_tokens = usage
.get("prompt_tokens")
@@ -831,6 +849,75 @@ mod tests {
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
assert_eq!(
result["tools"][0]["function"]["parameters"]["type"],
json!("object")
);
assert_eq!(
result["tools"][0]["function"]["parameters"]["properties"]["location"]["type"],
json!("string")
);
}
#[test]
fn test_anthropic_to_openai_defaults_missing_tool_schema_type() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_openai(input).unwrap();
let parameters = &result["tools"][0]["function"]["parameters"];
assert_eq!(parameters["type"], json!("object"));
assert_eq!(
parameters["properties"]["location"]["type"],
json!("string")
);
}
#[test]
fn test_anthropic_to_openai_defaults_empty_tool_schema() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Do work"}],
"tools": [{"name": "do_work", "input_schema": {}}]
});
let result = anthropic_to_openai(input).unwrap();
let parameters = &result["tools"][0]["function"]["parameters"];
assert_eq!(parameters, &json!({"type": "object", "properties": {}}));
}
#[test]
fn test_clean_schema_only_defaults_root_to_object() {
let schema = json!({
"properties": {
"nullable_value": {
"anyOf": [{"type": "string"}, {"type": "null"}]
},
"list": {
"items": {"type": "string"}
}
}
});
let result = clean_schema(schema);
assert_eq!(result["type"], json!("object"));
assert_eq!(
result["properties"]["nullable_value"],
json!({"anyOf": [{"type": "string"}, {"type": "null"}]})
);
assert_eq!(
result["properties"]["list"],
json!({"items": {"type": "string"}})
);
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,11 @@ impl CodexToolContext {
.is_some_and(|spec| matches!(&spec.kind, CodexToolKind::Custom))
}
fn chat_name_for_response_function(&self, name: &str, namespace: Option<&str>) -> String {
pub(crate) fn chat_name_for_response_function(
&self,
name: &str,
namespace: Option<&str>,
) -> String {
if let Some(namespace) = namespace.filter(|value| !value.is_empty()) {
if let Some(chat_name) = self
.namespace_name_to_chat_name
@@ -1092,6 +1096,26 @@ fn serialize_tool_definition_for_description(tool: &Value) -> String {
canonical_json_string(tool)
}
/// Normalize a function's `parameters` JSON Schema so `type` is always `"object"`.
///
/// Some Responses tools carry `parameters: null` or `parameters: {"type": null}`,
/// but OpenAI Chat Completions strictly requires `{"type": "object", "properties": {...}}`.
fn normalize_function_parameters(params: Option<&Value>) -> Value {
let mut params = match params {
Some(Value::Object(obj)) => Value::Object(obj.clone()),
_ => json!({"type": "object", "properties": {}}),
};
if let Some(obj) = params.as_object_mut() {
match obj.get("type").and_then(|v| v.as_str()) {
Some("object") => {}
_ => {
obj.insert("type".to_string(), json!("object"));
}
}
}
params
}
fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option<Value> {
if tool.get("type").and_then(|v| v.as_str()) != Some("function") {
return None;
@@ -1106,6 +1130,14 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option
.get_mut("function")
.and_then(|value| value.as_object_mut())
{
// Ensure parameters.type is "object" for strict OpenAI-compatible providers
if let Some(params) = obj.get("parameters") {
let normalized = normalize_function_parameters(Some(params));
if normalized != *params {
obj.insert("parameters".to_string(), normalized);
}
}
obj.insert("name".to_string(), json!(chat_name));
if let Some(strict) = tool.get("strict").cloned() {
obj.entry("strict".to_string()).or_insert(strict);
@@ -1117,7 +1149,7 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option
let mut function = json!({
"name": chat_name,
"description": tool.get("description").cloned().unwrap_or(Value::Null),
"parameters": tool.get("parameters").cloned().unwrap_or_else(|| json!({}))
"parameters": normalize_function_parameters(tool.get("parameters"))
});
if let Some(strict) = tool.get("strict") {
function["strict"] = strict.clone();
@@ -1625,12 +1657,26 @@ pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value {
"total_tokens": total_tokens
});
if let Some(cached) = usage
let cached = usage
.pointer("/prompt_tokens_details/cached_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cached_tokens"))
.and_then(|v| v.as_u64())
{
result["input_tokens_details"] = json!({ "cached_tokens": cached });
.unwrap_or(0);
let cache_write = usage
.pointer("/prompt_tokens_details/cache_write_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens"))
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
if cached > 0 || cache_write > 0 {
result["input_tokens_details"] = json!({
"cached_tokens": cached,
"cache_write_tokens": cache_write
});
}
if let Some(details) = usage
@@ -1649,8 +1695,8 @@ pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value {
if let Some(cache_read) = usage.get("cache_read_input_tokens") {
result["cache_read_input_tokens"] = cache_read.clone();
}
if let Some(cache_creation) = usage.get("cache_creation_input_tokens") {
result["cache_creation_input_tokens"] = cache_creation.clone();
if cache_write > 0 {
result["cache_creation_input_tokens"] = json!(cache_write);
}
result
@@ -2731,7 +2777,7 @@ mod tests {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"prompt_tokens_details": {"cached_tokens": 3}
"prompt_tokens_details": {"cached_tokens": 3, "cache_write_tokens": 2}
}
});
@@ -2755,6 +2801,10 @@ mod tests {
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 3);
assert_eq!(
result["usage"]["input_tokens_details"]["cache_write_tokens"],
2
);
}
#[test]
@@ -11,6 +11,134 @@
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
use serde_json::{json, Value};
use super::reasoning_bridge::{
anthropic_block_from_openai_reasoning_item, openai_reasoning_item_from_anthropic_block,
};
pub(crate) const TOOL_RESULT_ERROR_MARKER: &str = "[cc-switch:tool-result-error]";
fn anthropic_image_to_responses_part(block: &Value) -> Option<Value> {
let source = block.get("source")?;
match source.get("type").and_then(Value::as_str) {
Some("url") => source
.get("url")
.and_then(Value::as_str)
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
.map(|url| json!({"type":"input_image","image_url":url})),
Some("base64") | None => {
let data = source.get("data").and_then(Value::as_str)?;
if data.is_empty() {
return None;
}
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.unwrap_or("image/png");
Some(json!({
"type":"input_image",
"image_url":format!("data:{media_type};base64,{data}")
}))
}
_ => None,
}
}
fn anthropic_document_to_responses_part(block: &Value) -> Option<Value> {
let source = block.get("source")?;
let filename = block
.get("title")
.or_else(|| block.get("filename"))
.and_then(Value::as_str)
.unwrap_or("document.pdf");
match source.get("type").and_then(Value::as_str) {
Some("url") => source
.get("url")
.and_then(Value::as_str)
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
.map(|url| json!({"type":"input_file","file_url":url,"filename":filename})),
Some("base64") => {
let data = source.get("data").and_then(Value::as_str)?;
if data.is_empty() {
return None;
}
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.unwrap_or("application/pdf");
Some(json!({
"type":"input_file",
"file_data":format!("data:{media_type};base64,{data}"),
"filename":filename
}))
}
_ => None,
}
}
fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
let is_error = block.get("is_error").and_then(Value::as_bool) == Some(true);
let content = block.get("content");
if !is_error {
if let Some(Value::String(text)) = content {
return json!(text);
}
}
let mut output = Vec::new();
if is_error {
output.push(json!({"type":"input_text","text":TOOL_RESULT_ERROR_MARKER}));
}
match content {
Some(Value::String(text)) => {
output.push(json!({"type":"input_text","text":text}));
}
Some(Value::Array(blocks)) => {
for part in blocks {
match part.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(text) = part.get("text").and_then(Value::as_str) {
output.push(json!({"type":"input_text","text":text}));
}
}
Some("image") => {
if let Some(image) = anthropic_image_to_responses_part(part) {
output.push(image);
} else {
output.push(json!({
"type":"input_text",
"text":canonical_json_string(part)
}));
}
}
Some("document") => {
if let Some(file) = anthropic_document_to_responses_part(part) {
output.push(file);
} else {
output.push(json!({
"type":"input_text",
"text":canonical_json_string(part)
}));
}
}
_ => output.push(json!({
"type":"input_text",
"text":canonical_json_string(part)
})),
}
}
}
Some(value) => output.push(json!({
"type":"input_text",
"text":canonical_json_string(value)
})),
None => {}
}
Value::Array(output)
}
pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value {
if name != "Read" {
return input;
@@ -247,6 +375,37 @@ pub(crate) fn map_responses_stop_reason(
})
}
fn responses_error_message(body: &Value, fallback: &str) -> String {
body.pointer("/error/message")
.and_then(Value::as_str)
.or_else(|| body.get("message").and_then(Value::as_str))
.or_else(|| body.get("error").and_then(Value::as_str))
.filter(|message| !message.trim().is_empty())
.unwrap_or(fallback)
.to_string()
}
fn validate_responses_terminal_status(body: &Value) -> Result<(), ProxyError> {
let status = body.get("status").and_then(Value::as_str);
let has_error = body.get("error").is_some_and(|error| !error.is_null());
match status {
Some("failed") => Err(ProxyError::TransformError(format!(
"Responses upstream failed: {}",
responses_error_message(body, "response generation failed")
))),
Some("cancelled") => Err(ProxyError::TransformError(format!(
"Responses upstream cancelled the response: {}",
responses_error_message(body, "response generation was cancelled")
))),
_ if has_error => Err(ProxyError::TransformError(format!(
"Responses upstream returned an error envelope: {}",
responses_error_message(body, "unknown upstream error")
))),
_ => Ok(()),
}
}
/// Build Anthropic-style usage JSON from Responses API usage, including cache tokens.
///
/// **Robustness Features**:
@@ -259,10 +418,11 @@ pub(crate) fn map_responses_stop_reason(
/// 1. input_tokens: Anthropic `input_tokens` → OpenAI `prompt_tokens` → default 0
/// 2. output_tokens: Anthropic `output_tokens` → OpenAI `completion_tokens` → default 0
/// 3. cache_read_input_tokens: Direct field → nested input_tokens_details.cached_tokens → prompt_tokens_details.cached_tokens
/// 4. cache_creation_input_tokens: Direct field only
/// 4. cache_creation_input_tokens: Direct field → nested
/// input_tokens_details.cache_write_tokens → prompt_tokens_details.cache_write_tokens
///
/// **Cache Token Priority Order**:
/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value
/// 1. OpenAI nested details (`cached_tokens`, `cache_write_tokens`) as initial values
/// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present
///
/// **Logging**:
@@ -345,6 +505,19 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
result["cache_read_input_tokens"] = json!(cached);
}
}
// GPT-5.6+ reports cache writes in the nested OpenAI token-details object.
// Treat writes as Anthropic cache creation so the downstream client and
// billing layer can distinguish them from fresh input.
let nested_cache_write = u
.pointer("/input_tokens_details/cache_write_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
u.pointer("/prompt_tokens_details/cache_write_tokens")
.and_then(|v| v.as_u64())
});
if let Some(cache_write) = nested_cache_write {
result["cache_creation_input_tokens"] = json!(cache_write);
}
// Step 2: Direct Anthropic-style fields override (authoritative if present)
// These preserve cache tokens even if input/output_tokens are missing
@@ -354,6 +527,9 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
if let Some(v) = u.get("cache_creation_input_tokens") {
result["cache_creation_input_tokens"] = v.clone();
}
if let Some(v) = u.get("cache_creation") {
result["cache_creation"] = v.clone();
}
// OpenAI/Responses 的 input(prompt_tokens/input_tokens)含缓存命中,Anthropic input_tokens 不含
// → 减去 cache_read 与 cache_creation,使其成为 fresh input。本函数在计量意义上是 claude 专属
@@ -381,13 +557,15 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
/// - user/assistant 的 text 内容 → 对应 role 的 message item
/// - tool_use 从 assistant message 中"提升"为独立的 function_call item
/// - tool_result 从 user message 中"提升"为独立的 function_call_output item
/// - thinking blocks → 丢弃
/// - bridge-owned thinking blocks → restore the original Responses reasoning item
/// - unrelated native thinking blocks → 丢弃
fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyError> {
let mut input = Vec::new();
for msg in messages {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
let message_input_start = input.len();
match content {
// 字符串内容
@@ -425,17 +603,22 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
}
"image" => {
if let Some(source) = block.get("source") {
let media_type = source
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("image/png");
let data =
source.get("data").and_then(|d| d.as_str()).unwrap_or("");
message_content.push(json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}")
}));
if let Some(image) = anthropic_image_to_responses_part(block) {
message_content.push(image);
} else {
log::warn!(
"[Responses] Unsupported or invalid Anthropic image block"
);
}
}
"document" => {
if let Some(file) = anthropic_document_to_responses_part(block) {
message_content.push(file);
} else {
log::warn!(
"[Responses] Unsupported or invalid Anthropic document block"
);
}
}
@@ -477,11 +660,7 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("");
let output = match block.get("content") {
Some(Value::String(s)) => s.clone(),
Some(v) => canonical_json_string(v),
None => String::new(),
};
let output = anthropic_tool_result_to_responses_output(block);
input.push(json!({
"type": "function_call_output",
@@ -490,8 +669,19 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
}));
}
"thinking" => {
// 丢弃 thinking blocks(与 openai_chat 一致)
"thinking" | "redacted_thinking" => {
if let Some(reasoning_item) =
openai_reasoning_item_from_anthropic_block(block)
{
if !message_content.is_empty() {
input.push(json!({
"role": role,
"content": message_content.clone()
}));
message_content.clear();
}
input.push(reasoning_item);
}
}
_ => {}
@@ -512,6 +702,26 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
input.push(json!({ "role": role }));
}
}
// A replayed reasoning item is only valid when the same assistant
// generation also contains a following message/function call item.
// Reasoning-only incomplete turns otherwise brick the next request with
// "reasoning item ... without its required following item".
if role == "assistant" {
let mut has_generated_follower = false;
for index in (message_input_start..input.len()).rev() {
let item_type = input[index].get("type").and_then(Value::as_str);
let is_assistant_message =
input[index].get("role").and_then(Value::as_str) == Some("assistant");
if item_type == Some("reasoning") {
if !has_generated_follower {
input.remove(index);
}
} else if item_type == Some("function_call") || is_assistant_message {
has_generated_follower = true;
}
}
}
}
Ok(input)
@@ -519,12 +729,18 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
/// OpenAI Responses 响应 → Anthropic 响应
pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
// A Responses failure can arrive inside an HTTP 2xx response object. Reject it
// before looking at `output`; otherwise `{status:"failed", output:[]}` becomes
// a successful empty Anthropic `end_turn` and hides the upstream error.
validate_responses_terminal_status(&body)?;
let output = body
.get("output")
.and_then(|o| o.as_array())
.ok_or_else(|| ProxyError::TransformError("No output in response".to_string()))?;
let mut content = Vec::new();
let response_completed = body.get("status").and_then(Value::as_str) == Some("completed");
let mut has_tool_use = false;
for item in output {
@@ -559,7 +775,42 @@ pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
let input: Value = if args_str.trim().is_empty() {
json!({})
} else {
match serde_json::from_str(args_str) {
Ok(value) => value,
Err(error) if !response_completed => {
log::warn!(
"[Responses] Replacing incomplete function_call '{name}' arguments with an empty object: {error}"
);
json!({})
}
Err(error) => {
return Err(ProxyError::TransformError(format!(
"Invalid function_call arguments for '{name}': {error}"
)))
}
}
};
if !input.is_object() {
if !response_completed {
log::warn!(
"[Responses] Replacing incomplete function_call '{name}' non-object arguments with an empty object"
);
content.push(json!({
"type": "tool_use",
"id": call_id,
"name": name,
"input": {}
}));
has_tool_use = true;
continue;
}
return Err(ProxyError::TransformError(format!(
"Function call arguments for '{name}' must be a JSON object"
)));
}
let input = sanitize_anthropic_tool_use_input(name, input);
content.push(json!({
@@ -572,26 +823,8 @@ pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
}
"reasoning" => {
// 映射 reasoning summary → thinking block
if let Some(summary) = item.get("summary").and_then(|s| s.as_array()) {
let thinking_text: String = summary
.iter()
.filter_map(|s| {
if s.get("type").and_then(|t| t.as_str()) == Some("summary_text") {
s.get("text").and_then(|t| t.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("");
if !thinking_text.is_empty() {
content.push(json!({
"type": "thinking",
"thinking": thinking_text
}));
}
if let Some(block) = anthropic_block_from_openai_reasoning_item(item) {
content.push(block);
}
}
@@ -770,10 +1003,51 @@ mod tests {
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["name"], "get_weather");
assert!(result["tools"][0].get("parameters").is_some());
assert_eq!(result["tools"][0]["parameters"]["type"], json!("object"));
assert_eq!(
result["tools"][0]["parameters"]["properties"]["location"]["type"],
json!("string")
);
// input_schema should not appear
assert!(result["tools"][0].get("input_schema").is_none());
}
#[test]
fn test_anthropic_to_responses_defaults_missing_tool_schema_type() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
let parameters = &result["tools"][0]["parameters"];
assert_eq!(parameters["type"], json!("object"));
assert_eq!(
parameters["properties"]["location"]["type"],
json!("string")
);
}
#[test]
fn test_anthropic_to_responses_defaults_empty_tool_schema() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Do work"}],
"tools": [{"name": "do_work", "input_schema": {}}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
let parameters = &result["tools"][0]["parameters"];
assert_eq!(parameters, &json!({"type": "object", "properties": {}}));
}
#[test]
fn test_anthropic_to_responses_tool_choice_any_to_required() {
let input = json!({
@@ -855,6 +1129,34 @@ mod tests {
assert_eq!(input_arr[0]["output"], "Sunny, 25°C");
}
#[test]
fn test_anthropic_to_responses_tool_result_preserves_blocks_and_error() {
let input = json!({
"model":"gpt-5",
"messages":[{"role":"user","content":[{
"type":"tool_result",
"tool_use_id":"call_1",
"is_error":true,
"content":[
{"type":"text","text":"command failed"},
{"type":"image","source":{"type":"url","url":"https://example.com/error.png"}},
{"type":"document","title":"trace.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
]
}]}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
let output = result["input"][0]["output"].as_array().unwrap();
assert_eq!(output[0]["text"], TOOL_RESULT_ERROR_MARKER);
assert_eq!(
output[1],
json!({"type":"input_text","text":"command failed"})
);
assert_eq!(output[2]["image_url"], "https://example.com/error.png");
assert_eq!(output[3]["type"], "input_file");
assert_eq!(output[3]["filename"], "trace.pdf");
}
#[test]
fn test_anthropic_to_responses_thinking_discarded() {
let input = json!({
@@ -900,6 +1202,25 @@ mod tests {
assert_eq!(content[1]["image_url"], "data:image/png;base64,abc123");
}
#[test]
fn test_anthropic_to_responses_url_image_and_document() {
let input = json!({
"model":"gpt-5",
"messages":[{"role":"user","content":[
{"type":"image","source":{"type":"url","url":"https://example.com/a.png"}},
{"type":"document","title":"manual.pdf","source":{"type":"url","url":"https://example.com/manual.pdf"}}
]}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
let content = result["input"][0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "input_image");
assert_eq!(content[0]["image_url"], "https://example.com/a.png");
assert_eq!(content[1]["type"], "input_file");
assert_eq!(content[1]["file_url"], "https://example.com/manual.pdf");
assert_eq!(content[1]["filename"], "manual.pdf");
}
#[test]
fn test_responses_to_anthropic_simple() {
let input = json!({
@@ -952,6 +1273,65 @@ mod tests {
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_completed_function_call_empty_arguments_normalizes_to_object() {
let input = json!({
"id": "resp_empty_args",
"status": "completed",
"model": "gpt-5.6",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "ping",
"arguments": ""
}],
"usage": {"input_tokens": 10, "output_tokens": 2}
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["input"], json!({}));
}
#[test]
fn test_incomplete_function_call_invalid_arguments_uses_empty_object() {
let input = json!({
"id": "resp_partial_args",
"status": "incomplete",
"incomplete_details": {"reason": "max_output_tokens"},
"model": "gpt-5.6",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "dangerous_tool",
"arguments": "{\"path\":"
}],
"usage": {"input_tokens": 10, "output_tokens": 2}
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["input"], json!({}));
assert_eq!(result["stop_reason"], "max_tokens");
}
#[test]
fn test_completed_function_call_invalid_arguments_is_error() {
let input = json!({
"id": "resp_bad_args",
"status": "completed",
"model": "gpt-5.6",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "broken_tool",
"arguments": "{\"path\":"
}],
"usage": {"input_tokens": 10, "output_tokens": 2}
});
assert!(matches!(
responses_to_anthropic(input),
Err(ProxyError::TransformError(_))
));
}
#[test]
fn test_responses_to_anthropic_read_drops_empty_pages() {
let input = json!({
@@ -1057,6 +1437,115 @@ mod tests {
assert_eq!(result["content"][1]["text"], "The answer is 42");
}
#[test]
fn test_encrypted_reasoning_round_trips_through_anthropic_history() {
let original = json!({
"type": "reasoning",
"id": "rs_123",
"summary": [{"type": "summary_text", "text": "Need a tool."}],
"encrypted_content": "opaque-ciphertext"
});
let response = json!({
"id": "resp_123",
"status": "completed",
"model": "gpt-5.6",
"output": [original.clone()],
"usage": {"input_tokens": 10, "output_tokens": 2}
});
let anthropic = responses_to_anthropic(response).unwrap();
let thinking = anthropic["content"][0].clone();
assert_eq!(thinking["type"], "thinking");
assert!(thinking["signature"]
.as_str()
.is_some_and(|value| value.starts_with("ccswitch-openai-reasoning-v1:")));
let replay = anthropic_to_responses(
json!({
"model": "gpt-5.6",
"messages": [{"role": "assistant", "content": [
thinking,
{"type": "tool_use", "id": "call_1", "name": "lookup", "input": {}}
]}]
}),
None,
true,
false,
)
.unwrap();
assert_eq!(replay["input"][0], original);
assert_eq!(replay["input"][1]["type"], "function_call");
}
#[test]
fn test_reasoning_only_assistant_turn_is_not_replayed() {
let item = json!({
"type": "reasoning",
"id": "rs_orphan",
"summary": [],
"encrypted_content": "opaque"
});
let block = anthropic_block_from_openai_reasoning_item(&item).unwrap();
let replay = anthropic_to_responses(
json!({
"model": "gpt-5.6",
"messages": [
{"role": "assistant", "content": [block]},
{"role": "user", "content": "continue"}
]
}),
None,
true,
false,
)
.unwrap();
assert_eq!(replay["input"].as_array().unwrap().len(), 1);
assert_eq!(replay["input"][0]["role"], "user");
}
#[test]
fn test_responses_failed_status_is_not_silent_empty_success() {
let input = json!({
"id": "resp_failed",
"status": "failed",
"error": {"type": "server_error", "message": "backend exploded"},
"output": [],
"usage": {"input_tokens": 10, "output_tokens": 0}
});
let error = responses_to_anthropic(input).unwrap_err();
assert!(
matches!(error, ProxyError::TransformError(message) if message.contains("backend exploded"))
);
}
#[test]
fn test_responses_error_envelope_preserves_upstream_message() {
let input = json!({
"error": {"type": "rate_limit_error", "message": "too many requests"}
});
let error = responses_to_anthropic(input).unwrap_err();
assert!(
matches!(error, ProxyError::TransformError(message) if message.contains("too many requests"))
);
}
#[test]
fn test_responses_cancelled_status_is_not_end_turn() {
let input = json!({
"id": "resp_cancelled",
"status": "cancelled",
"output": []
});
let error = responses_to_anthropic(input).unwrap_err();
assert!(
matches!(error, ProxyError::TransformError(message) if message.contains("cancelled"))
);
}
#[test]
fn test_responses_to_anthropic_incomplete_status() {
let input = json!({
@@ -1669,6 +2158,21 @@ mod tests {
assert_eq!(result["cache_read_input_tokens"], json!(80));
}
#[test]
fn test_build_usage_cache_write_tokens_from_nested_details() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"input_tokens": 100,
"output_tokens": 10,
"input_tokens_details": {
"cached_tokens": 30,
"cache_write_tokens": 20
}
})));
assert_eq!(result["input_tokens"], json!(50));
assert_eq!(result["cache_read_input_tokens"], json!(30));
assert_eq!(result["cache_creation_input_tokens"], json!(20));
}
#[test]
fn test_build_usage_cache_tokens_direct_override() {
let result = build_anthropic_usage_from_responses(Some(&json!({
+10
View File
@@ -327,6 +327,12 @@ impl ProxyServer {
.route("/v1/responses", post(handlers::handle_responses))
.route("/v1/v1/responses", post(handlers::handle_responses))
.route("/codex/v1/responses", post(handlers::handle_responses))
// Grok Build uses the Responses protocol but has an independent
// provider namespace and failover queue.
.route(
"/grokbuild/v1/responses",
post(handlers::handle_grokbuild_responses),
)
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
.route(
"/responses/compact",
@@ -344,6 +350,10 @@ impl ProxyServer {
"/codex/v1/responses/compact",
post(handlers::handle_responses_compact),
)
.route(
"/grokbuild/v1/responses/compact",
post(handlers::handle_grokbuild_responses_compact),
)
// Gemini API (支持带前缀和不带前缀)
//
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
+48 -6
View File
@@ -7,7 +7,7 @@ use serde_json::{json, Value};
///
/// 三路径分发:
/// - skip: haiku 模型直接跳过
/// - adaptive: opus-4-8 / opus-4-7 / opus-4-6 / sonnet-4-6 使用 adaptive thinking
/// - adaptive: current adaptive-thinking Claude models use adaptive thinking
/// - legacy: 其他模型注入 enabled thinking + budget_tokens
pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
if !config.thinking_optimizer {
@@ -73,13 +73,42 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
}
}
fn uses_adaptive_thinking(model: &str) -> bool {
let normalized = model.replace('.', "-");
["opus-4-8", "opus-4-7", "opus-4-6", "sonnet-4-6"]
pub(crate) fn uses_adaptive_thinking(model: &str) -> bool {
let normalized = normalize_model_name(model);
[
"fable-5",
"mythos-5",
"mythos-preview",
"sonnet-5",
"opus-4-8",
"opus-4-7",
"opus-4-6",
"sonnet-4-6",
]
.iter()
.any(|needle| normalized.contains(needle))
}
/// Models where omitting `thinking` still leaves adaptive thinking enabled.
pub(crate) fn adaptive_thinking_is_default(model: &str) -> bool {
let normalized = normalize_model_name(model);
["fable-5", "mythos-5", "mythos-preview", "sonnet-5"]
.iter()
.any(|needle| normalized.contains(needle))
}
/// Models that reject `thinking: {"type":"disabled"}`.
pub(crate) fn thinking_cannot_be_disabled(model: &str) -> bool {
let normalized = normalize_model_name(model);
["fable-5", "mythos-5"]
.iter()
.any(|needle| normalized.contains(needle))
}
fn normalize_model_name(model: &str) -> String {
model.trim().to_ascii_lowercase().replace(['.', '_'], "-")
}
/// 追加 beta 标识到 anthropic_beta 数组(去重)
fn append_beta(body: &mut Value, beta: &str) {
match body.get_mut("anthropic_beta") {
@@ -108,7 +137,6 @@ mod tests {
enabled: true,
thinking_optimizer: true,
cache_injection: true,
cache_ttl: "1h".to_string(),
}
}
@@ -117,7 +145,6 @@ mod tests {
enabled: true,
thinking_optimizer: false,
cache_injection: true,
cache_ttl: "1h".to_string(),
}
}
@@ -139,6 +166,21 @@ mod tests {
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
}
#[test]
fn current_generation_models_use_adaptive_thinking() {
for model in [
"claude-sonnet-5",
"anthropic/claude-fable-5",
"claude-mythos-5",
"claude-opus-4.8",
] {
assert!(uses_adaptive_thinking(model), "model={model}");
}
assert!(adaptive_thinking_is_default("claude-sonnet-5"));
assert!(thinking_cannot_be_disabled("claude-fable-5"));
assert!(!thinking_cannot_be_disabled("claude-sonnet-5"));
}
#[test]
fn test_adaptive_opus_4_6() {
let mut body = json!({
+5 -12
View File
@@ -113,6 +113,7 @@ pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub grokbuild: bool,
pub opencode: bool,
pub openclaw: bool,
}
@@ -219,11 +220,11 @@ pub struct RectifierConfig {
/// 让对话不中断。总开关,管辖「显式声明 text-only」与「上游报错后兜底」两条事实驱动路径。
#[serde(default = "default_true")]
pub request_media_fallback: bool,
/// 请求整流:启发式 text-only 模型名匹配(默认开启)
/// 请求整流:确认纯文本注册表的发送前降级(默认开启)
///
/// 在模型未声明能力时,按内置模型名列表预测性地剥离图片(发送前)
/// 受 request_media_fallback 管辖;单独关闭后仅保留「显式声明」与「上游兜底」
/// 避免内置列表把多模态模型误判成 text-only 而静默剥图
/// 在模型未声明能力时,按内置的确认纯文本注册表预先剥离图片
/// 受 request_media_fallback 管辖;单独关闭只停用代理的注册表预判
/// 仍保留「显式声明」与「上游兜底」,且不改变 Codex 模型目录声明
#[serde(default = "default_true")]
pub request_media_heuristic: bool,
}
@@ -264,13 +265,6 @@ pub struct OptimizerConfig {
/// Cache 注入子开关(总开关开启后默认生效)
#[serde(default = "default_true")]
pub cache_injection: bool,
/// Cache TTL: "5m" | "1h"(默认 "1h"
#[serde(default = "default_cache_ttl")]
pub cache_ttl: String,
}
fn default_cache_ttl() -> String {
"1h".to_string()
}
impl Default for OptimizerConfig {
@@ -279,7 +273,6 @@ impl Default for OptimizerConfig {
enabled: false,
thinking_optimizer: true,
cache_injection: true,
cache_ttl: "1h".to_string(),
}
}
}
+28 -6
View File
@@ -59,7 +59,7 @@ impl CostCalculator {
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
Self::calculate_with_cache_semantics(
usage,
pricing,
@@ -76,10 +76,13 @@ impl CostCalculator {
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
// OpenAI/Gemini 风格的 input_tokens 包含缓存命中,需要扣除后再按输入价计费;
// OpenAI/Gemini 风格的 input_tokens 包含缓存读取和写入,需要扣除后再按输入价计费;
// Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。
let billable_input_tokens = if input_includes_cache_read {
usage.input_tokens.saturating_sub(usage.cache_read_tokens)
usage
.input_tokens
.saturating_sub(usage.cache_read_tokens)
.saturating_sub(usage.cache_creation_tokens)
} else {
usage.input_tokens
};
@@ -197,15 +200,34 @@ mod tests {
let cost = CostCalculator::calculate_for_app("codex", &usage, &pricing, multiplier);
// Codex/OpenAI 语义:input_tokens 包含 cached_tokens,需要扣除 cache_read_tokens
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
// Codex/OpenAI 语义:input_tokens 包含 cache read/write,两桶都需扣除。
assert_eq!(cost.input_cost, Decimal::from_str("0.0021").unwrap());
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
assert_eq!(
cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap()
);
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
}
#[test]
fn grokbuild_does_not_double_bill_cached_input() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 0,
cache_read_tokens: 600,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
let pricing = ModelPricing::from_strings("10", "0", "1", "0").unwrap();
let cost = CostCalculator::calculate_for_app("grokbuild", &usage, &pricing, Decimal::ONE);
assert_eq!(cost.input_cost, Decimal::from_str("0.004").unwrap());
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.0006").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.0046").unwrap());
}
#[test]
+45 -1
View File
@@ -4,6 +4,7 @@ use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
use super::parser::TokenUsage;
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
use crate::error::AppError;
use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL};
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
use rust_decimal::Decimal;
use std::str::FromStr;
@@ -70,15 +71,22 @@ impl<'a> UsageLogger<'a> {
};
let created_at = chrono::Utc::now().timestamp();
let input_token_semantics =
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
conn.execute(
"INSERT OR REPLACE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model, pricing_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_token_semantics,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
rusqlite::params![
log.request_id,
log.provider_id,
@@ -90,6 +98,7 @@ impl<'a> UsageLogger<'a> {
log.usage.output_tokens,
log.usage.cache_read_tokens,
log.usage.cache_creation_tokens,
input_token_semantics,
input_cost,
output_cost,
cache_read_cost,
@@ -451,4 +460,39 @@ mod tests {
assert_eq!(error, Some("Internal Server Error".to_string()));
Ok(())
}
#[test]
fn grokbuild_logs_total_input_token_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
let logger = UsageLogger::new(&db);
let log = RequestLog {
request_id: "grok-semantics".to_string(),
provider_id: "grok-provider".to_string(),
app_type: "grokbuild".to_string(),
model: "grok-4.5".to_string(),
request_model: "grok-4.5".to_string(),
pricing_model: String::new(),
usage: TokenUsage::default(),
cost: None,
latency_ms: 1,
first_token_ms: None,
status_code: 200,
error_message: None,
session_id: None,
provider_type: Some("grokbuild".to_string()),
is_streaming: false,
cost_multiplier: "1".to_string(),
};
logger.log_request(&log)?;
let conn = crate::database::lock_conn!(db.conn);
let semantics: i64 = conn.query_row(
"SELECT input_token_semantics FROM proxy_request_logs WHERE request_id = 'grok-semantics'",
[],
|row| row.get(0),
)?;
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
Ok(())
}
}
+55 -36
View File
@@ -9,6 +9,24 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn openai_cache_read_tokens(usage: &Value) -> u32 {
usage
.get("cache_read_input_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cached_tokens"))
.or_else(|| usage.pointer("/prompt_tokens_details/cached_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
}
fn openai_cache_write_tokens(usage: &Value) -> u32 {
usage
.get("cache_creation_input_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens"))
.or_else(|| usage.pointer("/prompt_tokens_details/cache_write_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
}
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
@@ -250,25 +268,14 @@ impl TokenUsage {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
Some(Self {
input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -285,19 +292,13 @@ impl TokenUsage {
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 调整 input_tokens: 减去 cached_tokens
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
// 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。
let adjusted_input = input_tokens
.saturating_sub(cached_tokens)
.saturating_sub(cache_write_tokens);
// 提取响应中的模型名称
let model = body
@@ -309,10 +310,7 @@ impl TokenUsage {
input_tokens: adjusted_input,
output_tokens,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -391,11 +389,8 @@ impl TokenUsage {
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?;
// 获取 cached_tokens (可能在 prompt_tokens_details 中)
let cached_tokens = usage
.get("prompt_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 提取响应中的模型名称
let model = body
@@ -407,7 +402,7 @@ impl TokenUsage {
input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: 0,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -797,6 +792,30 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 300);
}
#[test]
fn test_codex_response_parsing_cache_write_tokens_in_details() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 300,
"cache_write_tokens": 200
}
}
});
let usage = TokenUsage::from_codex_response(&response).unwrap();
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.cache_read_tokens, 300);
assert_eq!(usage.cache_creation_tokens, 200);
let adjusted = TokenUsage::from_codex_response_adjusted(&response).unwrap();
assert_eq!(adjusted.input_tokens, 500);
assert_eq!(adjusted.cache_read_tokens, 300);
assert_eq!(adjusted.cache_creation_tokens, 200);
}
#[test]
fn test_codex_response_adjusted() {
let response = json!({