fix(proxy): exclude cache_read and cache_creation from input on Claude←OpenAI paths

Builds on #2774 (which fixed cache_read for the streaming openai_chat path).
Two gaps remained, both double-counting cache tokens when a Claude client
meters as app_type="claude" (input_includes_cache_read=false):

1. cache_read was still added to input on the non-streaming openai_chat path
   (transform.rs openai_to_anthropic) and the whole openai_responses family
   (transform_responses.rs build_anthropic_usage_from_responses, covering the
   non-streaming call site and both streaming_responses call sites).

2. cache_creation was never subtracted on any converted path, including the
   streaming openai_chat path #2774 had already touched. Claude billing treats
   cache_creation as a separate bucket, so an inclusive upstream carrying a
   direct cache_creation_input_tokens field billed it twice.

All four metering points now compute:
  input = prompt_tokens - cache_read - cache_creation
restoring the invariant input + cache_read + cache_creation == prompt_tokens.
Pure OpenAI upstreams are unaffected (no cache_creation concept/field).

Tests: update direct-cache assertions (40->20), add a streaming conservation
regression test, and pin prompt<cache underflow (saturating clamp to 0) for all
three metering functions. cargo test 1573 pass, clippy clean.

Note: fix is forward-only; historical rows are not recomputed (cost is frozen at
log time and app_type="claude" mixes native + converted rows).
This commit is contained in:
Jason
2026-06-09 21:39:09 +08:00
parent 36a103bbe4
commit cb01593f7d
3 changed files with 189 additions and 22 deletions
+55 -13
View File
@@ -644,10 +644,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())
@@ -658,19 +679,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!({
@@ -1314,7 +1327,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);
}
@@ -1338,10 +1352,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!({