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
@@ -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!({