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!({
+8 -5
View File
@@ -76,10 +76,13 @@ impl CostCalculator {
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
// OpenAI/Gemini 风格的 input_tokens 包含缓存命中,需要扣除后再按输入价计费;
// OpenAI/Gemini 风格的 input_tokens 包含缓存读取和写入,需要扣除后再按输入价计费;
// Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。
let billable_input_tokens = if input_includes_cache_read {
usage.input_tokens.saturating_sub(usage.cache_read_tokens)
usage
.input_tokens
.saturating_sub(usage.cache_read_tokens)
.saturating_sub(usage.cache_creation_tokens)
} else {
usage.input_tokens
};
@@ -197,15 +200,15 @@ mod tests {
let cost = CostCalculator::calculate_for_app("codex", &usage, &pricing, multiplier);
// Codex/OpenAI 语义:input_tokens 包含 cached_tokens,需要扣除 cache_read_tokens
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
// Codex/OpenAI 语义:input_tokens 包含 cache read/write,两桶都需扣除。
assert_eq!(cost.input_cost, Decimal::from_str("0.0021").unwrap());
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
assert_eq!(
cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap()
);
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
}
#[test]
+9 -1
View File
@@ -4,6 +4,7 @@ use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
use super::parser::TokenUsage;
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
use crate::error::AppError;
use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL};
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
use rust_decimal::Decimal;
use std::str::FromStr;
@@ -70,15 +71,21 @@ impl<'a> UsageLogger<'a> {
};
let created_at = chrono::Utc::now().timestamp();
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
conn.execute(
"INSERT OR REPLACE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model, pricing_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_token_semantics,
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, ?24)",
) 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, ?25)",
rusqlite::params![
log.request_id,
log.provider_id,
@@ -90,6 +97,7 @@ impl<'a> UsageLogger<'a> {
log.usage.output_tokens,
log.usage.cache_read_tokens,
log.usage.cache_creation_tokens,
input_token_semantics,
input_cost,
output_cost,
cache_read_cost,
+55 -36
View File
@@ -9,6 +9,24 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn openai_cache_read_tokens(usage: &Value) -> u32 {
usage
.get("cache_read_input_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cached_tokens"))
.or_else(|| usage.pointer("/prompt_tokens_details/cached_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
}
fn openai_cache_write_tokens(usage: &Value) -> u32 {
usage
.get("cache_creation_input_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens"))
.or_else(|| usage.pointer("/prompt_tokens_details/cache_write_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
}
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
@@ -250,25 +268,14 @@ impl TokenUsage {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
Some(Self {
input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -285,19 +292,13 @@ impl TokenUsage {
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 调整 input_tokens: 减去 cached_tokens
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
// 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。
let adjusted_input = input_tokens
.saturating_sub(cached_tokens)
.saturating_sub(cache_write_tokens);
// 提取响应中的模型名称
let model = body
@@ -309,10 +310,7 @@ impl TokenUsage {
input_tokens: adjusted_input,
output_tokens,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -391,11 +389,8 @@ impl TokenUsage {
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?;
// 获取 cached_tokens (可能在 prompt_tokens_details 中)
let cached_tokens = usage
.get("prompt_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 提取响应中的模型名称
let model = body
@@ -407,7 +402,7 @@ impl TokenUsage {
input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: 0,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -797,6 +792,30 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 300);
}
#[test]
fn test_codex_response_parsing_cache_write_tokens_in_details() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 300,
"cache_write_tokens": 200
}
}
});
let usage = TokenUsage::from_codex_response(&response).unwrap();
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.cache_read_tokens, 300);
assert_eq!(usage.cache_creation_tokens, 200);
let adjusted = TokenUsage::from_codex_response_adjusted(&response).unwrap();
assert_eq!(adjusted.input_tokens, 500);
assert_eq!(adjusted.cache_read_tokens, 300);
assert_eq!(adjusted.cache_creation_tokens, 200);
}
#[test]
fn test_codex_response_adjusted() {
let response = json!({