mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
fix(usage): account for cache-write tokens across schema versions
Parse cache_write_tokens from OpenAI usage details and preserve cache creation data across Chat, Responses, and Anthropic conversion paths. Add explicit input-token semantics to request logs and rollups so legacy rows subtract cache reads only while new total-inclusive rows subtract both cache reads and writes. Migrate v12 databases, normalize rollups to fresh input, and cover historical backfill behavior with regression tests.
This commit is contained in:
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -665,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())
|
||||
@@ -678,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")
|
||||
|
||||
@@ -125,19 +125,14 @@ pub(crate) fn map_anthropic_stop_reason_to_status(
|
||||
|
||||
/// Builds Responses usage from Anthropic usage.
|
||||
///
|
||||
/// Anthropic's `input_tokens` is the "cache-excluded" fresh input; OpenAI/Responses'
|
||||
/// `input_tokens` includes cache hits. To keep downstream metering correct, this
|
||||
/// adds them (symmetric to the subtraction done for the Claude side in
|
||||
/// `transform_responses`):
|
||||
/// input_tokens = input + cache_read
|
||||
/// Anthropic's `input_tokens` is the cache-excluded fresh input. OpenAI Responses
|
||||
/// reports total input and exposes cache reads/writes as subsets:
|
||||
/// input_tokens = fresh + cache_read + cache_creation
|
||||
/// input_tokens_details.cached_tokens = cache_read
|
||||
/// input_tokens_details.cache_write_tokens = cache_creation
|
||||
///
|
||||
/// Note: **do not** fold `cache_creation` into `input_tokens`. The Codex billing
|
||||
/// calculator (usage/calculator.rs) only subtracts `cache_read` for codex
|
||||
/// (`billable = input - cache_read`), and separately lists cache-creation cost via
|
||||
/// `cache_creation_input_tokens`; if creation were also added into
|
||||
/// `input_tokens`, it would be double-charged at both the input price and the
|
||||
/// cache-creation price.
|
||||
/// The internal billing parser subtracts both subsets before charging the normal
|
||||
/// input rate, then prices cache reads and writes separately.
|
||||
pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Value {
|
||||
let u = match usage {
|
||||
Some(v) if v.is_object() => v,
|
||||
@@ -166,10 +161,10 @@ pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Val
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let input_tokens = fresh_input.saturating_add(cache_read);
|
||||
let total_tokens = input_tokens
|
||||
.saturating_add(cache_creation)
|
||||
.saturating_add(output);
|
||||
let input_tokens = fresh_input
|
||||
.saturating_add(cache_read)
|
||||
.saturating_add(cache_creation);
|
||||
let total_tokens = input_tokens.saturating_add(output);
|
||||
|
||||
let mut result = json!({
|
||||
"input_tokens": input_tokens,
|
||||
@@ -177,10 +172,14 @@ pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Val
|
||||
"total_tokens": total_tokens,
|
||||
"output_tokens_details": { "reasoning_tokens": reasoning }
|
||||
});
|
||||
if cache_read > 0 {
|
||||
result["input_tokens_details"] = json!({ "cached_tokens": cache_read });
|
||||
if cache_read > 0 || cache_creation > 0 {
|
||||
result["input_tokens_details"] = json!({
|
||||
"cached_tokens": cache_read,
|
||||
"cache_write_tokens": cache_creation
|
||||
});
|
||||
}
|
||||
// Explicitly pass through cache_creation so the downstream usage parser (from_codex_response) attributes billing correctly.
|
||||
// Keep the legacy top-level alias for one compatibility window. New code reads
|
||||
// the official nested cache_write_tokens field first.
|
||||
if cache_creation > 0 {
|
||||
result["cache_creation_input_tokens"] = json!(cache_creation);
|
||||
}
|
||||
@@ -2078,18 +2077,20 @@ mod tests {
|
||||
}
|
||||
});
|
||||
let result = anthropic_response_to_responses(input).unwrap();
|
||||
// input_tokens = fresh + cache_read = 20 + 60 = 80 (excluding cache_creation).
|
||||
// The Codex billing calculator only subtracts cache_read from input (→ billable=fresh=20),
|
||||
// and separately lists cache-creation cost via cache_creation_input_tokens; folding creation into input would double-charge.
|
||||
assert_eq!(result["usage"]["input_tokens"], 80);
|
||||
// Responses input_tokens is the inclusive total: fresh + read + write.
|
||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
||||
assert_eq!(result["usage"]["output_tokens"], 5);
|
||||
assert_eq!(
|
||||
result["usage"]["output_tokens_details"]["reasoning_tokens"],
|
||||
3
|
||||
);
|
||||
// total still includes everything: 80 + cache_creation 20 + output 5 = 105
|
||||
// total includes input total + output exactly once.
|
||||
assert_eq!(result["usage"]["total_tokens"], 105);
|
||||
assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 60);
|
||||
assert_eq!(
|
||||
result["usage"]["input_tokens_details"]["cache_write_tokens"],
|
||||
20
|
||||
);
|
||||
// cache_creation is passed through explicitly for downstream billing attribution (counted only once)
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
|
||||
@@ -1629,12 +1629,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
|
||||
@@ -1653,8 +1667,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
|
||||
@@ -2735,7 +2749,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}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2759,6 +2773,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]
|
||||
|
||||
@@ -259,10 +259,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 +346,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
|
||||
@@ -1710,6 +1724,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!({
|
||||
|
||||
Reference in New Issue
Block a user