Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector

# Conflicts:
#	src-tauri/src/services/provider/live.rs
#	src-tauri/src/services/proxy.rs
This commit is contained in:
Jason
2026-06-15 15:04:01 +08:00
143 changed files with 14006 additions and 4259 deletions
+100 -8
View File
@@ -38,6 +38,11 @@ pub struct ForwardResult {
pub response: ProxyResponse,
pub provider: Provider,
pub claude_api_format: Option<String>,
/// 实际发往上游的模型名(路由接管/模型映射后的真值)。
///
/// usage 归因不能依赖 ctx.request_model(映射前的客户端别名):上游响应
/// 缺失 model 或回显别名时,接管流量会被记成 claude-* 并按其定价计费。
pub outbound_model: Option<String>,
/// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform
/// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。
pub(crate) connection_guard: Option<ActiveConnectionGuard>,
@@ -159,7 +164,7 @@ impl RequestForwarder {
provider_body: &Value,
error: &ProxyError,
) -> bool {
adapter_name == "Claude"
matches!(adapter_name, "Claude" | "Codex")
&& self.rectifier_config.enabled
&& self.rectifier_config.request_media_fallback
&& !already_retried
@@ -463,7 +468,7 @@ impl RequestForwarder {
)
.await
{
Ok((response, claude_api_format)) => {
Ok((response, claude_api_format, outbound_model)) => {
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
@@ -511,6 +516,7 @@ impl RequestForwarder {
response,
provider: provider.clone(),
claude_api_format,
outbound_model,
connection_guard: None,
});
}
@@ -561,7 +567,7 @@ impl RequestForwarder {
)
.await
{
Ok((response, claude_api_format)) => {
Ok((response, claude_api_format, outbound_model)) => {
log::info!(
"[{app_type_str}] [Media] Unsupported-image retry succeeded"
);
@@ -613,6 +619,7 @@ impl RequestForwarder {
response,
provider: provider.clone(),
claude_api_format,
outbound_model,
connection_guard: None,
});
}
@@ -706,7 +713,7 @@ impl RequestForwarder {
)
.await
{
Ok((response, claude_api_format)) => {
Ok((response, claude_api_format, outbound_model)) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
self.record_success_result(
&provider.id,
@@ -761,6 +768,7 @@ impl RequestForwarder {
response,
provider: provider.clone(),
claude_api_format,
outbound_model,
connection_guard: None,
});
}
@@ -871,7 +879,7 @@ impl RequestForwarder {
)
.await
{
Ok((response, claude_api_format)) => {
Ok((response, claude_api_format, outbound_model)) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
self.record_success_result(
&provider.id,
@@ -920,6 +928,7 @@ impl RequestForwarder {
response,
provider: provider.clone(),
claude_api_format,
outbound_model,
connection_guard: None,
});
}
@@ -1077,6 +1086,9 @@ impl RequestForwarder {
}
/// 转发单个请求(使用适配器)
///
/// 成功时返回 `(response, claude_api_format, outbound_model)`,其中
/// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。
#[allow(clippy::too_many_arguments)]
async fn forward(
&self,
@@ -1088,7 +1100,7 @@ impl RequestForwarder {
headers: &axum::http::HeaderMap,
extensions: &Extensions,
adapter: &dyn ProviderAdapter,
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
) -> Result<(ProxyResponse, Option<String>, Option<String>), ProxyError> {
// 使用适配器提取 base_url
let mut base_url = adapter.extract_base_url(provider)?;
@@ -1320,8 +1332,17 @@ impl RequestForwarder {
adapter.build_url(&base_url, &effective_endpoint)
};
// 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 /
// Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖;
// gemini_native 等模型在 URL 中的格式则保留此处的转换前真值。
let mut outbound_model = mapped_body
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string);
// 转换请求体(如果需要)
let request_body = if codex_responses_to_chat {
let mut request_body = if codex_responses_to_chat {
let mut mapped_body = mapped_body;
let restored = self
.codex_chat_history
@@ -1359,9 +1380,21 @@ impl RequestForwarder {
mapped_body
};
if matches!(app_type, AppType::Codex) {
self.apply_media_prevention(&mut request_body, provider);
}
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = prepare_upstream_request_body(request_body);
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
if let Some(m) = filtered_body
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
{
outbound_model = Some(m.to_string());
}
log_prompt_cache_trace(
app_type,
provider,
@@ -1504,6 +1537,18 @@ impl RequestForwarder {
Vec::new()
};
// 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent
// 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。
// Copilot 指纹 UA 不可覆盖。
let custom_user_agent = if is_copilot {
None
} else {
provider
.meta
.as_ref()
.and_then(|meta| meta.custom_user_agent_header().ok().flatten())
};
// --- Copilot 优化器:动态 header 注入 ---
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
copilot_optimization
@@ -1598,6 +1643,7 @@ impl RequestForwarder {
let mut ordered_headers = http::HeaderMap::new();
let mut saw_auth = false;
let mut saw_accept_encoding = false;
let mut saw_user_agent = false;
let mut saw_anthropic_beta = false;
let mut saw_anthropic_version = false;
@@ -1678,6 +1724,19 @@ impl RequestForwarder {
continue;
}
// --- user-agent: provider-level override for local proxy routing ---
if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") {
if !saw_user_agent {
saw_user_agent = true;
if let Some(ref ua) = custom_user_agent {
ordered_headers.append(http::header::USER_AGENT, ua.clone());
} else {
ordered_headers.append(key.clone(), value.clone());
}
}
continue;
}
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
if key_str.eq_ignore_ascii_case("anthropic-beta") {
if !saw_anthropic_beta {
@@ -1727,6 +1786,12 @@ impl RequestForwarder {
);
}
if !saw_user_agent {
if let Some(ref ua) = custom_user_agent {
ordered_headers.append(http::header::USER_AGENT, ua.clone());
}
}
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
if !saw_anthropic_beta {
if let Some(ref beta_val) = anthropic_beta_value {
@@ -1874,7 +1939,7 @@ impl RequestForwarder {
let response = self
.prepare_success_response_for_failover(response, request_is_streaming)
.await?;
Ok((response, resolved_claude_api_format))
Ok((response, resolved_claude_api_format, outbound_model))
} else {
let status_code = status.as_u16();
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
@@ -3298,6 +3363,18 @@ mod tests {
})
}
fn body_with_codex_input_image(model: &str) -> Value {
json!({
"model": model,
"input": [{
"role": "user",
"content": [
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
]
}]
})
}
fn image_unsupported_error() -> ProxyError {
ProxyError::UpstreamError {
status: 400,
@@ -3385,6 +3462,21 @@ mod tests {
assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error()));
}
#[test]
fn reactive_triggers_for_codex_image_url_deserialize_errors() {
let fwd = forwarder_with_rectifier(RectifierConfig::default());
let body = body_with_codex_input_image("deepseek-v4-flash");
let error = ProxyError::UpstreamError {
status: 400,
body: Some(
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
.to_string(),
),
};
assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error));
}
#[test]
fn reactive_skipped_when_media_fallback_off() {
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
+23 -15
View File
@@ -60,37 +60,40 @@ fn gemini_stream_usage_event_filter(data: &str) -> bool {
// ============================================================================
/// Claude 流式响应模型提取(优先使用 usage.model
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
///
/// 空字符串模型名视为缺失(转换层对无回显上游会合成 model:""),
/// 落到 fallback_model(映射后的出站模型或客户端请求模型)。
fn claude_model_extractor(events: &[Value], fallback_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
if let Some(model) = usage.model {
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
return model;
}
}
request_model.to_string()
fallback_model.to_string()
}
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
fn openai_model_extractor(events: &[Value], fallback_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
if let Some(model) = usage.model {
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
return model;
}
}
// 回退:从事件中直接提取
events
.iter()
.find_map(|e| e.get("model")?.as_str())
.unwrap_or(request_model)
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
.unwrap_or(fallback_model)
.to_string()
}
/// Codex 智能流式响应模型提取(自动检测格式)
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
fn codex_auto_model_extractor(events: &[Value], fallback_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
if let Some(model) = usage.model {
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
return model;
}
}
@@ -99,28 +102,33 @@ fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
.iter()
.find_map(|e| {
if e.get("type")?.as_str()? == "response.completed" {
e.get("response")?.get("model")?.as_str()
e.get("response")?
.get("model")?
.as_str()
.filter(|m| !m.is_empty())
} else {
None
}
})
.or_else(|| {
// 再回退:从 OpenAI 格式事件中提取
events.iter().find_map(|e| e.get("model")?.as_str())
events
.iter()
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
})
.unwrap_or(request_model)
.unwrap_or(fallback_model)
.to_string()
}
/// Gemini 流式响应模型提取(优先使用 usage.model
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
fn gemini_model_extractor(events: &[Value], fallback_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
if let Some(model) = usage.model {
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
return model;
}
}
request_model.to_string()
fallback_model.to_string()
}
// ============================================================================
+6
View File
@@ -48,6 +48,11 @@ pub struct RequestContext {
pub current_provider_id: String,
/// 请求中的模型名称
pub request_model: String,
/// 实际发往上游的模型名(路由接管/模型映射后的真值,forward 成功后回填)。
///
/// usage 归因的兜底顺序:上游响应回显 → outbound_model → request_model。
/// 不能直接用 request_model 兜底:接管场景下它是映射前的客户端别名。
pub outbound_model: Option<String>,
/// 日志标签(如 "Claude"、"Codex"、"Gemini"
pub tag: &'static str,
/// 应用类型字符串(如 "claude"、"codex"、"gemini"
@@ -159,6 +164,7 @@ impl RequestContext {
providers,
current_provider_id,
request_model,
outbound_model: None,
tag,
app_type_str,
app_type,
File diff suppressed because it is too large Load Diff
+155 -27
View File
@@ -41,14 +41,7 @@ pub fn replace_images_for_text_only_model(
}
pub fn contains_image_blocks(body: &Value) -> bool {
body.get("messages")
.and_then(Value::as_array)
.is_some_and(|messages| {
messages
.iter()
.filter_map(|message| message.get("content"))
.any(content_has_image_blocks)
})
messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input"))
}
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
@@ -95,6 +88,7 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
"text-only",
"invalid content type",
"invalid message content",
"unknown variant",
"unknown content type",
"unrecognized content type",
"cannot process",
@@ -113,51 +107,124 @@ fn content_has_image_blocks(content: &Value) -> bool {
};
blocks.iter().any(|block| {
block.get("type").and_then(Value::as_str) == Some("image")
is_image_block_type(block.get("type").and_then(Value::as_str))
|| block.get("content").is_some_and(content_has_image_blocks)
})
}
fn replace_images_in_body(body: &mut Value) -> usize {
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
return 0;
};
let message_replacements = body
.get_mut("messages")
.and_then(Value::as_array_mut)
.map(|messages| {
messages
.iter_mut()
.filter_map(|message| message.get_mut("content"))
.map(replace_images_in_content)
.sum()
})
.unwrap_or(0);
messages
.iter_mut()
.filter_map(|message| message.get_mut("content"))
.map(replace_images_in_content)
.sum()
message_replacements
+ body
.get_mut("input")
.map(replace_images_in_responses_input)
.unwrap_or(0)
}
fn replace_images_in_content(content: &mut Value) -> usize {
replace_images_in_content_with_text_type(content, "text")
}
fn replace_images_in_content_with_text_type(content: &mut Value, text_type: &str) -> usize {
let Some(blocks) = content.as_array_mut() else {
return 0;
};
let mut replaced = 0usize;
for block in blocks {
if block.get("type").and_then(Value::as_str) == Some("image") {
let cache_control = block.get("cache_control").cloned();
*block = json!({
"type": "text",
"text": UNSUPPORTED_IMAGE_MARKER
});
if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) {
object.insert("cache_control".to_string(), cache_control);
}
if is_image_block_type(block.get("type").and_then(Value::as_str)) {
replace_image_block_with_text_marker(block, text_type);
replaced += 1;
continue;
}
if let Some(nested_content) = block.get_mut("content") {
replaced += replace_images_in_content(nested_content);
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
}
}
replaced
}
fn messages_have_image_blocks(body: &Value) -> bool {
body.get("messages")
.and_then(Value::as_array)
.is_some_and(|messages| {
messages
.iter()
.filter_map(|message| message.get("content"))
.any(content_has_image_blocks)
})
}
fn responses_input_has_image_blocks(input: Option<&Value>) -> bool {
match input {
Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks),
Some(item @ Value::Object(_)) => responses_input_item_has_image_blocks(item),
_ => false,
}
}
fn responses_input_item_has_image_blocks(item: &Value) -> bool {
if item.get("type").and_then(Value::as_str) == Some("input_image") {
return true;
}
item.get("content").is_some_and(content_has_image_blocks)
}
fn replace_images_in_responses_input(input: &mut Value) -> usize {
match input {
Value::Array(items) => items
.iter_mut()
.map(replace_images_in_responses_input_item)
.sum(),
Value::Object(_) => replace_images_in_responses_input_item(input),
_ => 0,
}
}
fn replace_images_in_responses_input_item(item: &mut Value) -> usize {
let mut replaced = 0usize;
if item.get("type").and_then(Value::as_str) == Some("input_image") {
replace_image_block_with_text_marker(item, "input_text");
replaced += 1;
}
if let Some(content) = item.get_mut("content") {
replaced += replace_images_in_content_with_text_type(content, "input_text");
}
replaced
}
fn is_image_block_type(block_type: Option<&str>) -> bool {
matches!(block_type, Some("image" | "image_url" | "input_image"))
}
fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) {
let cache_control = block.get("cache_control").cloned();
*block = json!({
"type": text_type,
"text": UNSUPPORTED_IMAGE_MARKER
});
if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) {
object.insert("cache_control".to_string(), cache_control);
}
}
fn explicit_model_image_support(provider: &Provider, model: &str) -> Option<bool> {
let settings = &provider.settings_config;
[
@@ -369,6 +436,54 @@ mod tests {
);
}
#[test]
fn known_text_only_models_replace_chat_image_url_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek-v4-flash",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "look" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }
]
}]
});
let count = replace_images_for_text_only_model(&mut body, &provider, true);
assert_eq!(count, 1);
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
assert_eq!(
body["messages"][0]["content"][1]["text"],
UNSUPPORTED_IMAGE_MARKER
);
}
#[test]
fn known_text_only_models_replace_codex_input_image_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek-v4-flash",
"input": [{
"role": "user",
"content": [
{ "type": "input_text", "text": "look" },
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
]
}]
});
let count = replace_images_for_text_only_model(&mut body, &provider, true);
assert_eq!(count, 1);
assert_eq!(body["input"][0]["content"][1]["type"], "input_text");
assert_eq!(
body["input"][0]["content"][1]["text"],
UNSUPPORTED_IMAGE_MARKER
);
}
#[test]
fn explicit_text_modalities_replace_images_before_send() {
let provider = provider(json!({
@@ -654,6 +769,19 @@ mod tests {
assert!(is_unsupported_image_error(&attachment_error));
}
#[test]
fn detects_chat_content_unknown_variant_image_url_errors() {
let error = ProxyError::UpstreamError {
status: 400,
body: Some(
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
.to_string(),
),
};
assert!(is_unsupported_image_error(&error));
}
#[test]
fn heuristic_disabled_keeps_images_for_listed_text_only_models() {
// allow_heuristic = false:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。
+67 -1
View File
@@ -11,6 +11,7 @@ pub struct ModelMapping {
pub haiku_model: Option<String>,
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub fable_model: Option<String>,
pub default_model: Option<String>,
}
@@ -35,6 +36,11 @@ impl ModelMapping {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
fable_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_FABLE_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())
@@ -48,6 +54,7 @@ impl ModelMapping {
self.haiku_model.is_some()
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.fable_model.is_some()
|| self.default_model.is_some()
}
@@ -56,6 +63,16 @@ impl ModelMapping {
let model_lower = original_model.to_lowercase();
// 1. 按模型类型匹配
if model_lower.contains("fable") {
if let Some(ref m) = self.fable_model {
return m.clone();
}
// 未单独配置 fable 档时归入 opus 档,与 Claude Code 官方
// 分类器降级方向一致(fable→opus),避免落到 default 失去层级。
if let Some(ref m) = self.opus_model {
return m.clone();
}
}
if model_lower.contains("haiku") {
if let Some(ref m) = self.haiku_model {
return m.clone();
@@ -154,7 +171,8 @@ mod tests {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
"ANTHROPIC_DEFAULT_FABLE_MODEL": "fable-mapped"
}
}),
website_url: None,
@@ -214,6 +232,54 @@ mod tests {
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_fable_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "fable-mapped");
assert_eq!(mapped, Some("fable-mapped".to_string()));
}
#[test]
fn test_fable_with_one_m_suffix_mapping() {
// Claude Code 实际会发 claude-fable-5[1m] 形态(issue #3980
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-fable-5[1m]"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "fable-mapped");
assert_eq!(mapped, Some("fable-mapped".to_string()));
}
#[test]
fn test_fable_falls_back_to_opus_when_unset() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
}
});
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "opus-mapped");
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_fable_falls_back_to_default_without_opus() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model"
}
});
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "default-model");
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_thinking_does_not_affect_model_mapping() {
// Issue #2081: thinking 参数不应影响模型映射
+41
View File
@@ -409,6 +409,10 @@ pub fn transform_claude_request_for_api_format(
{
result["prompt_cache_key"] = serde_json::json!(key);
}
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游
// 不在 SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 →
// 整笔 input/output/cache 漏记(与 Codex Responses→Chat 路径同源)。
super::transform::inject_openai_stream_include_usage(&mut result);
Ok(result)
}
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
@@ -1617,6 +1621,43 @@ mod tests {
assert!(transformed.get("max_output_tokens").is_some());
}
#[test]
fn test_transform_claude_request_openai_chat_streaming_injects_include_usage() {
let provider = create_provider(json!({
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
}));
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游不在
// SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 → 整笔 usage 漏记。
let body = json!({
"model": "moonshotai/kimi-k2",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128,
"stream": true
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
assert_eq!(transformed["stream"], true);
assert_eq!(transformed["stream_options"]["include_usage"], true);
}
#[test]
fn test_transform_claude_request_openai_chat_non_streaming_omits_stream_options() {
let provider = create_provider(json!({
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
}));
// 非流式请求不应注入 stream_optionsusage 在非流式响应体里恒有)。
let body = json!({
"model": "moonshotai/kimi-k2",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
assert!(transformed.get("stream_options").is_none());
}
#[test]
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
let provider = create_provider_with_meta(
+1 -2
View File
@@ -48,8 +48,7 @@ pub use claude::{
pub use codex::CodexAdapter;
pub use codex::{
apply_codex_chat_upstream_model, codex_provider_upstream_model,
codex_provider_uses_chat_completions, is_origin_only_url, resolve_codex_chat_reasoning_config,
should_convert_codex_responses_to_chat,
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
};
pub use gemini::GeminiAdapter;
+100 -9
View File
@@ -100,15 +100,23 @@ struct ToolBlockState {
const INFINITE_WHITESPACE_THRESHOLD: usize = 500;
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 input_tokens = usage
.prompt_tokens
.saturating_sub(cached)
.saturating_sub(cache_creation);
let mut usage_json = json!({
"input_tokens": usage.prompt_tokens,
"input_tokens": input_tokens,
"output_tokens": usage.completion_tokens
});
if let Some(cached) = extract_cache_read_tokens(usage) {
if cached > 0 {
usage_json["cache_read_input_tokens"] = json!(cached);
}
if let Some(created) = usage.cache_creation_input_tokens {
usage_json["cache_creation_input_tokens"] = json!(created);
if cache_creation > 0 {
usage_json["cache_creation_input_tokens"] = json!(cache_creation);
}
usage_json
}
@@ -223,12 +231,20 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
"output_tokens": 0
});
if let Some(u) = &chunk.usage {
start_usage["input_tokens"] = json!(u.prompt_tokens);
if let Some(cached) = extract_cache_read_tokens(u) {
let cached = extract_cache_read_tokens(u).unwrap_or(0);
let cache_creation =
u.cache_creation_input_tokens.unwrap_or(0);
let input = u
.prompt_tokens
.saturating_sub(cached)
.saturating_sub(cache_creation);
start_usage["input_tokens"] = json!(input);
if cached > 0 {
start_usage["cache_read_input_tokens"] = json!(cached);
}
if let Some(created) = u.cache_creation_input_tokens {
start_usage["cache_creation_input_tokens"] = json!(created);
if cache_creation > 0 {
start_usage["cache_creation_input_tokens"] =
json!(cache_creation);
}
}
@@ -1022,7 +1038,7 @@ mod tests {
message_delta
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64()),
Some(13312)
Some(13212)
);
assert_eq!(
message_delta
@@ -1038,6 +1054,81 @@ mod tests {
);
}
#[tokio::test]
async fn test_usage_chunk_subtracts_cache_read_and_creation_from_input() {
// prompt_tokens(1000) 含 cache_read(600) 与 cache_creation(300);转 Anthropic 后
// input 应为 fresh,守恒:input(100) + cache_read(600) + cache_creation(300) == prompt(1000)。
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: [DONE]\n\n"
);
let events = collect_anthropic_events(input).await;
let message_delta = events
.iter()
.find(|event| event_type(event) == Some("message_delta"))
.expect("should emit message_delta with usage");
// fresh input = 1000 - 600 - 300 = 100
assert_eq!(
message_delta
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64()),
Some(100)
);
assert_eq!(
message_delta
.pointer("/usage/cache_read_input_tokens")
.and_then(|v| v.as_u64()),
Some(600)
);
assert_eq!(
message_delta
.pointer("/usage/cache_creation_input_tokens")
.and_then(|v| v.as_u64()),
Some(300)
);
}
#[tokio::test]
async fn test_usage_chunk_clamps_input_to_zero_when_cache_exceeds_prompt() {
// prompt(100) < cache_read(80)+cache_creation(50)=130saturating 钳到 0,防下溢。
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
let input = concat!(
"data: {\"id\":\"chatcmpl_uf\",\"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_uf\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":80},\"cache_creation_input_tokens\":50}}\n\n",
"data: [DONE]\n\n"
);
let events = collect_anthropic_events(input).await;
let message_delta = events
.iter()
.find(|event| event_type(event) == Some("message_delta"))
.expect("should emit message_delta with usage");
assert_eq!(
message_delta
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64()),
Some(0)
);
assert_eq!(
message_delta
.pointer("/usage/cache_read_input_tokens")
.and_then(|v| v.as_u64()),
Some(80)
);
assert_eq!(
message_delta
.pointer("/usage/cache_creation_input_tokens")
.and_then(|v| v.as_u64()),
Some(50)
);
}
#[tokio::test]
async fn test_message_delta_includes_zero_usage_when_stream_has_no_usage() {
let input = concat!(
+180 -76
View File
@@ -112,6 +112,10 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
}
/// Anthropic 请求 → OpenAI Chat Completions 请求
///
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
#[allow(dead_code)]
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
anthropic_to_openai_with_reasoning_content(body, false)
}
@@ -142,18 +146,13 @@ pub fn anthropic_to_openai_with_reasoning_content(
messages.push(json!({"role": "system", "content": text}));
}
} else if let Some(arr) = system.as_array() {
// 多个 system message — preserve cache_control for compatible proxies
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
let text = strip_leading_anthropic_billing_header(text);
if text.is_empty() {
continue;
}
let mut sys_msg = json!({"role": "system", "content": text});
if let Some(cc) = msg.get("cache_control") {
sys_msg["cache_control"] = cc.clone();
}
messages.push(sys_msg);
messages.push(json!({"role": "system", "content": text}));
}
}
}
@@ -207,18 +206,14 @@ pub fn anthropic_to_openai_with_reasoning_content(
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
.map(|t| {
let mut tool = json!({
json!({
"type": "function",
"function": {
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
"description": t.get("description"),
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
}
});
if let Some(cc) = t.get("cache_control") {
tool["cache_control"] = cc.clone();
}
tool
})
})
.collect();
@@ -234,6 +229,33 @@ pub fn anthropic_to_openai_with_reasoning_content(
Ok(result)
}
/// 为 OpenAI Chat Completions 流式请求注入 `stream_options.include_usage`。
///
/// OpenAI 兼容上游在流式下默认不在 SSE 里返回 usage,必须显式声明 include_usage
/// 才会在末尾吐 usage chunk。缺这一注入会导致流式请求的 token/成本/缓存全部漏记
/// input/output/cache 全为 0)。保留客户端可能透传的其它 stream_options 字段,
/// 仅补 include_usage;非流式请求不动。
///
/// 由 Claude→openai_chatclaude.rs)与 Codex Responses→Chattransform_codex_chat.rs
/// 两条转换路径共用,确保两个客户端方向行为一致。
pub(crate) fn inject_openai_stream_include_usage(result: &mut Value) {
let is_stream = result
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_stream {
return;
}
match result.get_mut("stream_options") {
Some(Value::Object(opts)) => {
opts.insert("include_usage".to_string(), json!(true));
}
_ => {
result["stream_options"] = json!({ "include_usage": true });
}
}
}
/// Translate an Anthropic `tool_choice` into the OpenAI Chat Completions form.
///
/// Anthropic forms:
@@ -294,10 +316,6 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
}
let mut parts = Vec::new();
let mut inherited_cache_control: Option<Value> = None;
let mut cache_control_conflict = false;
let mut saw_cache_control = false;
let mut saw_missing_cache_control = false;
messages.retain(|message| {
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
return true;
@@ -318,28 +336,11 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
_ => {}
}
if let Some(cache_control) = message.get("cache_control") {
saw_cache_control = true;
match &inherited_cache_control {
None => inherited_cache_control = Some(cache_control.clone()),
Some(existing) if existing == cache_control => {}
Some(_) => cache_control_conflict = true,
}
} else {
saw_missing_cache_control = true;
}
false
});
if !parts.is_empty() {
let mut merged = json!({"role": "system", "content": parts.join("\n")});
if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) {
if let Some(cache_control) = inherited_cache_control {
merged["cache_control"] = cache_control;
}
}
messages.insert(0, merged);
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
}
}
@@ -379,11 +380,7 @@ fn convert_message_to_openai(
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
let mut part = json!({"type": "text", "text": text});
if let Some(cc) = block.get("cache_control") {
part["cache_control"] = cc.clone();
}
content_parts.push(part);
content_parts.push(json!({"type": "text", "text": text}));
}
}
"image" => {
@@ -458,14 +455,9 @@ fn convert_message_to_openai(
if content_parts.is_empty() {
msg["content"] = Value::Null;
} else if content_parts.len() == 1 {
// When cache_control is present, keep array format to preserve it
let has_cache_control = content_parts[0].get("cache_control").is_some();
if !has_cache_control {
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
// 单 text block 简化为纯字符串
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
@@ -656,10 +648,31 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
// usage — map cache tokens from OpenAI format to Anthropic format
let usage = body.get("usage").cloned().unwrap_or(json!({}));
// OpenAI prompt_tokens 含缓存命中,Anthropic input_tokens 不含 → 减去 cache_read 与
// cache_creation,使 input 成为 fresh input。本路径以 app_type="claude" 记账(calculator
// 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等:
// input + cache_read + cache_creation == prompt_tokensinclusive 上游)。
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
// 最终 cache_read:直传字段优先于 nestedcache_creation 仅来自直传字段(OpenAI 无此概念)。
let cached = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.pointer("/prompt_tokens_details/cached_tokens")
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
let cache_creation = usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
.unwrap_or(0)
.saturating_sub(cached)
.saturating_sub(cache_creation) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
@@ -670,19 +683,11 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
"output_tokens": output_tokens
});
// OpenAI standard: prompt_tokens_details.cached_tokens
if let Some(cached) = usage
.pointer("/prompt_tokens_details/cached_tokens")
.and_then(|v| v.as_u64())
{
if cached > 0 {
usage_json["cache_read_input_tokens"] = json!(cached);
}
// Some compatible servers return these fields directly
if let Some(v) = usage.get("cache_read_input_tokens") {
usage_json["cache_read_input_tokens"] = v.clone();
}
if let Some(v) = usage.get("cache_creation_input_tokens") {
usage_json["cache_creation_input_tokens"] = v.clone();
if cache_creation > 0 {
usage_json["cache_creation_input_tokens"] = json!(cache_creation);
}
let result = json!({
@@ -829,7 +834,7 @@ mod tests {
}
#[test]
fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() {
fn test_anthropic_to_openai_strips_cache_control_from_merged_system() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
@@ -847,12 +852,12 @@ mod tests {
result["messages"][0]["content"],
"You are Claude Code.\nBe concise."
);
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
assert!(result["messages"][0].get("cache_control").is_none());
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
fn test_anthropic_to_openai_strips_cache_control_from_mixed_system() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
@@ -873,7 +878,7 @@ mod tests {
}
#[test]
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
fn test_anthropic_to_openai_strips_cache_control_from_conflicting_system() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
@@ -1199,7 +1204,7 @@ mod tests {
}
#[test]
fn test_anthropic_to_openai_cache_control_preserved() {
fn test_anthropic_to_openai_strips_all_cache_control() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
@@ -1221,19 +1226,89 @@ mod tests {
});
let result = anthropic_to_openai(input).unwrap();
// System message cache_control preserved
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
// Text block cache_control preserved
assert_eq!(
result["messages"][1]["content"][0]["cache_control"]["type"],
"ephemeral"
// System message: no cache_control
assert!(result["messages"][0].get("cache_control").is_none());
// User message: content simplified to string (no cache_control → flat string)
assert_eq!(result["messages"][1]["content"], "Hello");
// Tool: no cache_control
assert!(result["tools"][0].get("cache_control").is_none());
}
/// 精确复现 Issue #3805 报告的 400 错误场景:
/// GLM/Qwen 等严格校验模型拒绝 cache_control 和 content 数组格式
#[test]
fn test_regression_gh3805_no_cache_control_leak_to_openai() {
let input = json!({
"model": "glm-5.1",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "You are helpful.", "cache_control": {"type": "ephemeral"}}
],
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
]}
],
"tools": [{
"name": "search",
"description": "Search the web",
"input_schema": {"type": "object"},
"cache_control": {"type": "ephemeral"}
}]
});
let result = anthropic_to_openai(input).unwrap();
// 验证: messages 中不存在 cache_control
for (i, msg) in result["messages"].as_array().unwrap().iter().enumerate() {
assert!(
msg.get("cache_control").is_none(),
"messages[{i}] must not have cache_control"
);
}
// 验证: content 中没有 cache_control
for (i, msg) in result["messages"].as_array().unwrap().iter().enumerate() {
if let Some(content) = msg.get("content") {
assert!(
!content.is_array()
|| content
.as_array()
.unwrap()
.iter()
.all(|part| part.get("cache_control").is_none()),
"messages[{i}] content parts must not have cache_control"
);
}
}
// 验证: system content 为纯字符串格式(不是数组)
let sys_msg = &result["messages"][0];
assert_eq!(sys_msg["role"], "system");
assert!(
sys_msg["content"].is_string(),
"system content must be string, got: {}",
sys_msg["content"]
);
assert_eq!(
result["messages"][1]["content"][0]["cache_control"]["ttl"],
"5m"
// 验证: user content 为纯字符串格式(不是数组)
let user_msg = &result["messages"][1];
assert_eq!(user_msg["role"], "user");
assert!(
user_msg["content"].is_string(),
"user content must be string, got: {}",
user_msg["content"]
);
// Tool cache_control preserved
assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral");
// 验证: tools 中不存在 cache_control
if let Some(tools) = result["tools"].as_array() {
for (i, tool) in tools.iter().enumerate() {
assert!(
tool.get("cache_control").is_none(),
"tools[{i}] must not have cache_control"
);
}
}
}
#[test]
@@ -1256,7 +1331,8 @@ mod tests {
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["usage"]["input_tokens"], 100);
// prompt_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20
assert_eq!(result["usage"]["input_tokens"], 20);
assert_eq!(result["usage"]["output_tokens"], 50);
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
}
@@ -1280,10 +1356,38 @@ mod tests {
});
let result = openai_to_anthropic(input).unwrap();
// cache_read(60)+cache_creation(20) 均从 prompt(100) 扣除,fresh = 100 - 60 - 20 = 20
// 守恒:input(20) + cache_read(60) + cache_creation(20) == prompt(100)
assert_eq!(result["usage"]["input_tokens"], 20);
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
}
#[test]
fn test_openai_to_anthropic_clamps_input_when_cache_exceeds_prompt() {
// prompt(100) < cache_read(60)+cache_creation(50)=110saturating 钳到 0,防下溢。
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
let input = json!({
"id": "chatcmpl-uf",
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "x"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 10,
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 50
}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["usage"]["input_tokens"], 0);
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
assert_eq!(result["usage"]["cache_creation_input_tokens"], 50);
}
#[test]
fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {
let input = json!({
@@ -336,21 +336,8 @@ pub fn responses_to_chat_completions_with_reasoning(
// include_usage 才会在末尾吐 usage chunk。Codex CLI 用 Responses 协议、
// 自身不带 stream_options,缺这一注入会导致 kimi/MiniMax 等第三方流式请求的
// token/成本/缓存命中率全部漏记(input/output/cache 全为 0)。
let is_stream = result
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_stream {
match result.get_mut("stream_options") {
// 保留客户端可能透传的其它 stream_options 字段,仅补 include_usage。
Some(Value::Object(opts)) => {
opts.insert("include_usage".to_string(), json!(true));
}
_ => {
result["stream_options"] = json!({ "include_usage": true });
}
}
}
// 与 Claude→openai_chat 路径共用同一 helper,保证两个客户端方向一致。
super::transform::inject_openai_stream_include_usage(&mut result);
Ok(result)
}
@@ -39,6 +39,11 @@ pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool {
id.starts_with(SYNTHESIZED_ID_PREFIX)
}
/// Anthropic 请求 → Gemini 原生请求。
///
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
#[allow(dead_code)]
pub fn anthropic_to_gemini(body: Value) -> Result<Value, ProxyError> {
anthropic_to_gemini_with_shadow(body, None, None, None)
}
@@ -1101,7 +1106,7 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
});
};
let input_tokens = usage
let prompt_tokens = usage
.get("promptTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
@@ -1109,18 +1114,26 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
.get("totalTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let output_tokens = total_tokens.saturating_sub(input_tokens);
let cached_tokens = usage
.get("cachedContentTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
// Gemini 的 promptTokenCount 含缓存命中(cachedContentTokenCount);而 Anthropic
// 语义下 input_tokens 必须是不含 cache 的 fresh input、cache_read 单列。本路径转成
// Anthropic 后以 app_type=claude 记账,calculator 对 claude 设 input_includes_cache_read
// =false 不再从 input 扣 cache,因此这里必须先扣减,否则缓存 token 会被双重计费
// (一次按完整 input 价、一次按 cache_read 价)。output 仍按 total-prompt 计算
// (prompt 是总输入,扣减只作用于 input/cache 的拆分,不影响 output)。
let input_tokens = prompt_tokens.saturating_sub(cached_tokens);
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
let mut result = json!({
"input_tokens": input_tokens,
"output_tokens": output_tokens
});
if let Some(cached) = usage
.get("cachedContentTokenCount")
.and_then(|value| value.as_u64())
{
result["cache_read_input_tokens"] = json!(cached);
if cached_tokens > 0 {
result["cache_read_input_tokens"] = json!(cached_tokens);
}
result
@@ -1370,7 +1383,11 @@ mod tests {
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello from Gemini");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 12);
// input_tokens = promptTokenCount(12) - cachedContentTokenCount(3) = 9fresh input)。
// Gemini 的 promptTokenCount 含缓存命中,但 Anthropic 语义要求 input 不含 cache、
// cache_read 单列;二者相加(9+3)=总输入 12。扣减避免本路径以 app_type=claude
// 记账时把缓存 token 双重计费。
assert_eq!(result["usage"]["input_tokens"], 9);
assert_eq!(result["usage"]["output_tokens"], 8);
assert_eq!(result["usage"]["cache_read_input_tokens"], 3);
}
@@ -355,6 +355,23 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
result["cache_creation_input_tokens"] = v.clone();
}
// OpenAI/Responses 的 input(prompt_tokens/input_tokens)含缓存命中,Anthropic input_tokens 不含
// → 减去 cache_read 与 cache_creation,使其成为 fresh input。本函数在计量意义上是 claude 专属
// Codex Responses 透传走 from_codex_response_*,不调用本函数),故可安全在此扣减。三桶互斥,
// 恒等:input + cache_read + cache_creation == 上游 input(inclusive)。与 build_anthropic_usage_json
// (#2774) 及 transform_gemini 的 saturating_sub 对称;一处同时覆盖非流式与流式(streaming_responses)。
let cached = result
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_creation = result
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if cached > 0 || cache_creation > 0 {
result["input_tokens"] = json!(input.saturating_sub(cached).saturating_sub(cache_creation));
}
result
}
@@ -1156,7 +1173,8 @@ mod tests {
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["usage"]["input_tokens"], 100);
// input_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20
assert_eq!(result["usage"]["input_tokens"], 20);
assert_eq!(result["usage"]["output_tokens"], 50);
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
}
@@ -1180,6 +1198,9 @@ mod tests {
});
let result = responses_to_anthropic(input).unwrap();
// cache_read(60)+cache_creation(20) 均从 input(100) 扣除,fresh = 100 - 60 - 20 = 20
// 守恒:input(20) + cache_read(60) + cache_creation(20) == 上游 input(100)
assert_eq!(result["usage"]["input_tokens"], 20);
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
}
@@ -1642,7 +1663,8 @@ mod tests {
"cached_tokens": 80
}
})));
assert_eq!(result["input_tokens"], json!(100));
// input_tokens(100) 含 nested cached(80),转换后 input 应为 fresh = 100 - 80 = 20
assert_eq!(result["input_tokens"], json!(20));
assert_eq!(result["output_tokens"], json!(50));
assert_eq!(result["cache_read_input_tokens"], json!(80));
}
@@ -1657,9 +1679,26 @@ mod tests {
},
"cache_read_input_tokens": 100
})));
// 直传 cache_read(100) 优先于 nested(80)input(100) - 100 = 0fresh
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested
}
#[test]
fn test_build_usage_clamps_input_when_cache_exceeds_input() {
// input(100) < cache_read(60)+cache_creation(50)=110saturating 钳到 0,防下溢。
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
let result = build_anthropic_usage_from_responses(Some(&json!({
"input_tokens": 100,
"output_tokens": 10,
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 50
})));
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["cache_read_input_tokens"], json!(60));
assert_eq!(result["cache_creation_input_tokens"], json!(50));
}
#[test]
fn test_build_usage_cache_tokens_without_input_output() {
let result = build_anthropic_usage_from_responses(Some(&json!({
+198 -23
View File
@@ -35,28 +35,41 @@ use tokio::sync::Mutex;
/// 根据 content-encoding 解压响应体字节
///
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Vec<u8>, std::io::Error> {
/// 返回 `Ok(None)` 表示编码不受支持、原样透传——此时调用方必须保留
/// content-encoding 头,否则下游(诊断/客户端)会把压缩字节误当明文。
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
match content_encoding {
"gzip" | "x-gzip" => {
let mut decoder = flate2::read::GzDecoder::new(body);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
Ok(Some(decompressed))
}
"deflate" => {
let mut decoder = flate2::read::DeflateDecoder::new(body);
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游发 raw deflate 流。
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规上游必然解压失败,
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
let mut zlib = flate2::read::ZlibDecoder::new(body);
match zlib.read_to_end(&mut decompressed) {
Ok(_) => Ok(Some(decompressed)),
Err(zlib_err) => {
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
let mut decompressed = Vec::new();
let mut raw = flate2::read::DeflateDecoder::new(body);
raw.read_to_end(&mut decompressed)?;
Ok(Some(decompressed))
}
}
}
"br" => {
let mut decompressed = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
Ok(decompressed)
Ok(Some(decompressed))
}
_ => {
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
Ok(body.to_vec())
Ok(None)
}
}
}
@@ -150,10 +163,13 @@ pub(crate) async fn read_decoded_body(
if let Some(encoding) = get_content_encoding(&headers) {
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
match decompress_body(&encoding, &raw_bytes) {
Ok(decompressed) => {
Ok(Some(decompressed)) => {
body_bytes = Bytes::from(decompressed);
decoded = true;
}
// 不支持的编码:原样透传且保留 content-encoding 头,
// 让下游诊断/客户端知道这仍是压缩字节
Ok(None) => {}
Err(e) => {
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
}
@@ -270,14 +286,21 @@ pub async fn handle_non_streaming(
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
// 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
let model = if let Some(ref m) = usage.model {
m.clone()
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
m.to_string()
} else {
ctx.request_model.clone()
};
// 归因优先级:usage 解析出的模型 → 响应 model 字段 → 映射后的出站
// 模型(路由接管真值)→ 客户端请求模型。空字符串视为缺失。
let model = usage
.model
.clone()
.filter(|m| !m.is_empty())
.or_else(|| {
json_value
.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());
spawn_log_usage(
state,
@@ -292,8 +315,10 @@ pub async fn handle_non_streaming(
let model = json_value
.get("model")
.and_then(|m| m.as_str())
.unwrap_or(&ctx.request_model)
.to_string();
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
spawn_log_usage(
state,
ctx,
@@ -318,7 +343,7 @@ pub async fn handle_non_streaming(
state,
ctx,
TokenUsage::default(),
&ctx.request_model,
ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model),
&ctx.request_model,
status.as_u16(),
false,
@@ -500,7 +525,16 @@ fn create_usage_collector(
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
let app_type_str = parser_config.app_type_str;
// 流式事件缺失模型名时的归因兜底:映射后的出站模型(路由接管真值)优先,
// 其次才是客户端请求别名
let fallback_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
// 用 ctx 的 app_type 而不是 parser_config 的:Claude Desktop 流式透传复用
// CLAUDE_PARSER_CONFIGapp_type_str="claude"),按 parser_config 记账会把
// claude-desktop 的行错记到 claude 名下,导致供应商计价覆盖解析不到。
let app_type_str = ctx.app_type_str;
let tag = ctx.tag;
let start_time = ctx.start_time;
let stream_parser = parser_config.stream_parser;
@@ -512,13 +546,14 @@ fn create_usage_collector(
parser_config.stream_event_filter,
move |events, first_token_ms| {
if let Some(usage) = stream_parser(&events) {
let model = model_extractor(&events, &request_model);
let model = model_extractor(&events, &fallback_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
tokio::spawn(async move {
log_usage_internal(
@@ -527,6 +562,7 @@ fn create_usage_collector(
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
first_token_ms,
@@ -537,12 +573,13 @@ fn create_usage_collector(
.await;
});
} else {
let model = model_extractor(&events, &request_model);
let model = model_extractor(&events, &fallback_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
tokio::spawn(async move {
log_usage_internal(
@@ -551,6 +588,7 @@ fn create_usage_collector(
app_type_str,
&model,
&request_model,
&outbound_model,
TokenUsage::default(),
latency_ms,
first_token_ms,
@@ -588,6 +626,11 @@ fn spawn_log_usage(
let app_type_str = ctx.app_type_str.to_string();
let model = model.to_string();
let request_model = request_model.to_string();
// 「按请求计价」模式的锚点:映射后的出站模型,无映射时等于 request_model
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let latency_ms = ctx.latency_ms();
let session_id = ctx.session_id.clone();
@@ -598,6 +641,7 @@ fn spawn_log_usage(
&app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
@@ -618,6 +662,11 @@ pub(crate) fn usage_logging_enabled(state: &ProxyState) -> bool {
}
/// 内部使用量记录函数
///
/// `outbound_model` 是「按请求计价」模式的锚点:实际发往上游的模型
/// (路由接管映射后的真值,无映射时等于 request_model)。该模式的语义是
/// 「按代理发出的请求计价、不信任上游回显」,接管场景下发出的请求模型是
/// 映射后的 Y 而非客户端别名 X,按 X 计价会用错定价表行。
#[allow(clippy::too_many_arguments)]
async fn log_usage_internal(
state: &ProxyState,
@@ -625,6 +674,7 @@ async fn log_usage_internal(
app_type: &str,
model: &str,
request_model: &str,
outbound_model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -638,7 +688,7 @@ async fn log_usage_internal(
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await;
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
request_model
outbound_model
} else {
model
};
@@ -828,6 +878,40 @@ mod tests {
use std::sync::Arc;
use tokio::sync::RwLock;
#[test]
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
// RFC 9110 规范的 deflate = zlib 包裹格式(合规上游发的就是这个)
let payload = br#"{"ok":true}"#;
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, payload).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_deflate_falls_back_to_raw_stream() {
// 部分上游违规发 raw deflate 流,保持兼容
let payload = br#"{"ok":true}"#;
let mut encoder =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, payload).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
// 头被剥掉,下游诊断会把压缩字节误报成明文
let result = decompress_body("zstd", b"\x28\xb5\x2f\xfd").unwrap();
assert!(result.is_none());
}
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(
@@ -1015,6 +1099,7 @@ mod tests {
app_type,
"resp-model",
"req-model",
"req-model",
usage,
10,
None,
@@ -1047,6 +1132,95 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn test_request_pricing_mode_anchors_to_outbound_model() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_pricing_model_source(app_type, "request").await?;
seed_pricing(&db)?;
{
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES ('outbound-model', 'Outbound Model', '4.0', '0')",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
insert_provider(&db, "provider-3", app_type, ProviderMeta::default())?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
// 路由接管场景:客户端请求 req-model($2/M),代理实际发出 outbound-model
// $4/M),上游回显 resp-model。「按请求计价」必须锚定实际发出的模型。
log_usage_internal(
&state,
"provider-3",
app_type,
"resp-model",
"req-model",
"outbound-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (model, request_model, total_cost): (String, String, String) = conn
.query_row(
"SELECT model, request_model, total_cost_usd
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-3"],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
// model / request_model 列不受计价锚点影响
assert_eq!(model, "resp-model");
assert_eq!(request_model, "req-model");
// 按 outbound-model$4/M)计价,而不是 req-model$2/M)或 resp-model$1/M
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("4").unwrap()
);
Ok(())
}
#[tokio::test]
async fn test_claude_desktop_inherits_claude_global_defaults() -> Result<(), AppError> {
use crate::proxy::usage::logger::UsageLogger;
let db = Arc::new(Database::memory()?);
// 全局计费配置只有 claude/codex/gemini 三行;claude-desktop 的
// 全局默认必须继承 claude,而不是静默落回工厂默认(1 / response
db.set_default_cost_multiplier("claude", "1.5").await?;
db.set_pricing_model_source("claude", "request").await?;
let logger = UsageLogger::new(&db);
let (multiplier, source) = logger
.resolve_pricing_config("nonexistent-provider", "claude-desktop")
.await;
assert_eq!(multiplier, Decimal::from_str("1.5").unwrap());
assert_eq!(source, "request");
Ok(())
}
#[tokio::test]
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
@@ -1075,6 +1249,7 @@ mod tests {
app_type,
"resp-model",
"req-model",
"req-model",
usage,
10,
None,
+36 -15
View File
@@ -16,6 +16,11 @@ pub struct RequestLog {
pub app_type: String,
pub model: String,
pub request_model: String,
/// 写入时实际用于计价的模型名(pricing_model_source 解析后的结果)。
/// 落库供回填使用:缺价行补价后必须按写入时的基准重算,而不是
/// 用 model/request_model 猜——路由接管下三者可能各不相同。
/// 错误行(未计价)为空字符串。
pub pricing_model: String,
pub usage: TokenUsage,
pub cost: Option<CostBreakdown>,
pub latency_ms: u64,
@@ -68,18 +73,19 @@ impl<'a> UsageLogger<'a> {
conn.execute(
"INSERT OR REPLACE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
request_id, provider_id, app_type, model, request_model, pricing_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
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)",
) 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)",
rusqlite::params![
log.request_id,
log.provider_id,
log.app_type,
log.model,
log.request_model,
log.pricing_model,
log.usage.input_tokens,
log.usage.output_tokens,
log.usage.cache_read_tokens,
@@ -129,6 +135,8 @@ impl<'a> UsageLogger<'a> {
app_type,
model,
request_model,
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
pricing_model: String::new(),
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -168,6 +176,8 @@ impl<'a> UsageLogger<'a> {
app_type,
model,
request_model,
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
pricing_model: String::new(),
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -203,13 +213,22 @@ impl<'a> UsageLogger<'a> {
provider_id: &str,
app_type: &str,
) -> (Decimal, String) {
let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
"1".to_string()
}
// Claude Desktop 网关没有独立的全局计费配置(proxy_config 的 CHECK 仅
// 允许 claude/codex/gemini,前端也只暴露三项),全局默认继承 claude;
// 供应商级 meta 覆盖仍按 claude-desktop 查找(providers 表按该 app_type 存)。
let default_app_type = if app_type == "claude-desktop" {
"claude"
} else {
app_type
};
let default_multiplier_raw =
match self.db.get_default_cost_multiplier(default_app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
"1".to_string()
}
};
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
Ok(value) => value,
Err(e) => {
@@ -220,13 +239,14 @@ impl<'a> UsageLogger<'a> {
}
};
let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
PRICING_SOURCE_RESPONSE.to_string()
}
};
let default_pricing_source_raw =
match self.db.get_pricing_model_source(default_app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
PRICING_SOURCE_RESPONSE.to_string()
}
};
let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE
|| default_pricing_source_raw == PRICING_SOURCE_REQUEST
{
@@ -325,6 +345,7 @@ impl<'a> UsageLogger<'a> {
app_type,
model,
request_model,
pricing_model,
usage,
cost,
latency_ms,
+82 -1
View File
@@ -37,6 +37,18 @@ impl TokenUsage {
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
/// 是否产生了任一计费维度的 token。
///
/// 用于在写入前过滤全 0 的空 usage:当 OpenAI 兼容上游在流式下省略 usage 时,
/// 转换器会合成一个全 0 的终止事件,若无 message_id 则 `dedup_request_id`
/// 退化为随机 UUID,导致每笔请求插入一条无意义的空行、虚增请求数。
pub fn has_billable_tokens(&self) -> bool {
self.input_tokens > 0
|| self.output_tokens > 0
|| self.cache_read_tokens > 0
|| self.cache_creation_tokens > 0
}
}
/// API 类型
@@ -185,7 +197,11 @@ impl TokenUsage {
}
}
if usage.input_tokens > 0 || usage.output_tokens > 0 {
// 用 has_billable_tokens 而非仅看 input/output:完全缓存命中、无输出的流式请求
// input==0 && output==0 但 cache_read>0)是真实的 cache-read 计费,必须保留。
// Gemini→Anthropic 路径在 input 改为 fresh(promptTokenCount - cachedContentTokenCount)
// 后尤其会出现这种全缓存场景;旧 gate 会把它当成"无 usage"丢弃。
if usage.has_billable_tokens() {
usage.model = model;
usage.message_id = message_id;
Some(usage)
@@ -522,6 +538,71 @@ mod tests {
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_has_billable_tokens_gates_empty_usage() {
// 全 0 usage(如上游省略 usage 时合成的全 0 终止事件)不应计费——
// 这是 Codex 流式空行多记修复(D)的闸门依据。
assert!(!TokenUsage::default().has_billable_tokens());
// 仅有 cache_read 也属于真实计费 token,必须计入。
let only_cache = TokenUsage {
cache_read_tokens: 100,
..Default::default()
};
assert!(only_cache.has_billable_tokens());
let normal = TokenUsage {
input_tokens: 10,
output_tokens: 5,
..Default::default()
};
assert!(normal.has_billable_tokens());
}
#[test]
fn test_claude_stream_cache_only_request_is_recorded() {
// P2 回归:完全缓存命中、无输出的流式请求(input==0 && output==0 但 cache_read>0
// 是真实计费,必须保留——旧 gate `input>0 || output>0` 会把它丢弃。
let events = vec![
json!({
"type": "message_start",
"message": {
"id": "msg_cacheonly",
"model": "claude-opus-4-8",
"usage": {
"input_tokens": 0,
"cache_read_input_tokens": 50000,
"cache_creation_input_tokens": 0
}
}
}),
json!({
"type": "message_delta",
"usage": { "output_tokens": 0 }
}),
];
let usage = TokenUsage::from_claude_stream_events(&events)
.expect("cache-only 流式请求必须被记录,不能被 input/output gate 丢弃");
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.cache_read_tokens, 50000);
assert_eq!(usage.message_id, Some("msg_cacheonly".to_string()));
}
#[test]
fn test_codex_response_auto_returns_some_for_synthetic_all_zero() {
// P3 回归:上游非流式 Chat 省略 usage 时转换器合成的全 0 usagefrom_codex_response_auto
// 仍返回 Some(字段存在、无 positivity check)——证明 handlers 必须用 has_billable_tokens
// 闸门才能挡住空行,单靠 `if let Some` 不够。
let synthetic = json!({
"usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 }
});
let usage = TokenUsage::from_codex_response_auto(&synthetic)
.expect("全 0 usage 字段存在时 from_codex_response_auto 返回 Some");
assert!(
!usage.has_billable_tokens(),
"全 0 usage 必须被 has_billable_tokens 判为非计费,由 handlers 闸门跳过"
);
}
#[test]
fn test_claude_response_parsing_no_model() {
let response = json!({