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:
Jason
2026-07-11 12:27:39 +08:00
parent 06039540ff
commit f991726ff0
13 changed files with 443 additions and 102 deletions
+17 -3
View File
@@ -80,6 +80,8 @@ struct Usage {
struct PromptTokensDetails {
#[serde(default)]
cached_tokens: u32,
#[serde(default)]
cache_write_tokens: u32,
}
#[derive(Debug, Clone)]
@@ -103,7 +105,7 @@ fn build_anthropic_usage_json(usage: &Usage) -> Value {
// OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 cache_read 与 cache_creation
// (三桶互斥,恒等 input + cache_read + cache_creation == prompt_tokens)。
let cached = extract_cache_read_tokens(usage).unwrap_or(0);
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
let cache_creation = extract_cache_write_tokens(usage).unwrap_or(0);
let input_tokens = usage
.prompt_tokens
.saturating_sub(cached)
@@ -233,7 +235,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
if let Some(u) = &chunk.usage {
let cached = extract_cache_read_tokens(u).unwrap_or(0);
let cache_creation =
u.cache_creation_input_tokens.unwrap_or(0);
extract_cache_write_tokens(u).unwrap_or(0);
let input = u
.prompt_tokens
.saturating_sub(cached)
@@ -683,6 +685,18 @@ fn extract_cache_read_tokens(usage: &Usage) -> Option<u32> {
.filter(|&v| v > 0)
}
/// Extract cache-write tokens from direct compatibility fields or OpenAI details.
fn extract_cache_write_tokens(usage: &Usage) -> Option<u32> {
if let Some(value) = usage.cache_creation_input_tokens {
return Some(value);
}
usage
.prompt_tokens_details
.as_ref()
.map(|details| details.cache_write_tokens)
.filter(|value| *value > 0)
}
/// 映射停止原因
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
finish_reason.map(|r| {
@@ -1061,7 +1075,7 @@ mod tests {
let input = concat!(
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600},\"cache_creation_input_tokens\":300}}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600,\"cache_write_tokens\":300}}}\n\n",
"data: [DONE]\n\n"
);
+7 -1
View File
@@ -665,7 +665,7 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
// 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等:
// input + cache_read + cache_creation == prompt_tokensinclusive 上游)。
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
// 最终 cache_read:直传字段优先于 nestedcache_creation 仅来自直传字段(OpenAI 无此概念)
// 最终 cache_read/cache_creation:直传字段优先于 OpenAI nested details
let cached = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
@@ -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!({