mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
Merge main and harden managed Codex account flow
This commit is contained in:
@@ -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
@@ -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 形式,错误响应若仍保留
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",全程不出现 image(issue #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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 found,openai/codex#31967,
|
||||
// 本机 A/B 实测确认)。两个头必须成对发送,缺一即 404;version 需 ≥ 目标模型
|
||||
// catalog 的 minimal_client_version(luna=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),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}\""));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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
@@ -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_tokens(inclusive 上游)。
|
||||
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
|
||||
// 最终 cache_read:直传字段优先于 nested;cache_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!({
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user