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
+52 -6
View File
@@ -18,13 +18,16 @@
/// would happen with the opposite default.
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"];
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
pub(crate) const INPUT_TOKEN_SEMANTICS_FRESH: i64 = 2;
/// Build an SQL expression that returns the cache-normalized `input_tokens`
/// for a single row in `proxy_request_logs` or `usage_daily_rollups`.
///
/// For rows whose `app_type` is in [`CACHE_INCLUSIVE_APP_TYPES`] and
/// `input_tokens >= cache_read_tokens`, returns
/// `input_tokens - cache_read_tokens`. For all other rows the original
/// `input_tokens` is returned unchanged.
/// Legacy rows subtract cache reads only. New total-inclusive rows subtract
/// both cache reads and writes. Rollups normalized to fresh input are returned
/// unchanged.
///
/// Pass an empty string to reference the columns directly (no alias),
/// or a table alias such as `"l"` to emit `l.input_tokens` style references.
@@ -40,7 +43,15 @@ pub fn fresh_input_sql(alias: &str) -> String {
.collect::<Vec<_>>()
.join(", ");
format!(
"CASE WHEN {prefix}app_type IN ({app_type_list}) AND {prefix}input_tokens >= {prefix}cache_read_tokens \
"CASE \
WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_FRESH} THEN {prefix}input_tokens \
WHEN {prefix}app_type IN ({app_type_list}) \
AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \
AND {prefix}input_tokens >= ({prefix}cache_read_tokens + {prefix}cache_creation_tokens) \
THEN ({prefix}input_tokens - {prefix}cache_read_tokens - {prefix}cache_creation_tokens) \
WHEN {prefix}app_type IN ({app_type_list}) \
AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_LEGACY} \
AND {prefix}input_tokens >= {prefix}cache_read_tokens \
THEN ({prefix}input_tokens - {prefix}cache_read_tokens) \
ELSE {prefix}input_tokens END"
)
@@ -60,7 +71,8 @@ mod tests {
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_token_semantics INTEGER NOT NULL DEFAULT 0
);",
)
.unwrap();
@@ -131,4 +143,38 @@ mod tests {
let value: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
assert_eq!(value, 100);
}
#[test]
fn fresh_input_subtracts_cache_write_for_total_semantics() {
let conn = setup_conn();
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, app_type, input_tokens, cache_read_tokens,
cache_creation_tokens, input_token_semantics
) VALUES ('codex-total', 'codex', 1000, 300, 200, ?1)",
[INPUT_TOKEN_SEMANTICS_TOTAL],
)
.unwrap();
let expr = fresh_input_sql("l");
let sql = format!("SELECT {expr} FROM proxy_request_logs l");
let value: i64 = conn.query_row(&sql, [], |row| row.get(0)).unwrap();
assert_eq!(value, 500);
}
#[test]
fn fresh_input_keeps_normalized_rollup_value() {
let conn = setup_conn();
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, app_type, input_tokens, cache_read_tokens,
cache_creation_tokens, input_token_semantics
) VALUES ('codex-fresh', 'codex', 500, 300, 200, ?1)",
[INPUT_TOKEN_SEMANTICS_FRESH],
)
.unwrap();
let expr = fresh_input_sql("l");
let sql = format!("SELECT {expr} FROM proxy_request_logs l");
let value: i64 = conn.query_row(&sql, [], |row| row.get(0)).unwrap();
assert_eq!(value, 500);
}
}
+99 -14
View File
@@ -5,7 +5,9 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::ModelPricing;
use crate::services::sql_helpers::fresh_input_sql;
use crate::services::sql_helpers::{
fresh_input_sql, INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL,
};
use chrono::{Local, NaiveDate, TimeZone, Timelike};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
@@ -135,6 +137,9 @@ pub struct RequestLogDetail {
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
/// Internal storage semantics; omitted from the UI/API payload.
#[serde(skip)]
pub input_token_semantics: i64,
pub input_cost_usd: String,
pub output_cost_usd: String,
pub cache_read_cost_usd: String,
@@ -154,15 +159,15 @@ pub struct RequestLogDetail {
pub pricing_model: Option<String>,
}
/// 把 25 列的查询结果映射为 `RequestLogDetail`。
/// 把 26 列的查询结果映射为 `RequestLogDetail`。
///
/// 调用方的 SELECT **必须**按以下顺序返回 25 列:
/// 调用方的 SELECT **必须**按以下顺序返回 26 列:
/// `request_id, provider_id, provider_name, app_type, model, request_model,
/// cost_multiplier, input_tokens, output_tokens, cache_read_tokens,
/// cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd,
/// cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms,
/// first_token_ms, duration_ms, status_code, error_message, created_at,
/// data_source, pricing_model`
/// data_source, pricing_model, input_token_semantics`
///
/// 不需要 provider_name 时(如 backfillSELECT `NULL AS provider_name` 占位即可。
fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result<RequestLogDetail> {
@@ -194,6 +199,7 @@ fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reques
created_at: row.get(22)?,
data_source: row.get(23)?,
pricing_model: row.get(24)?,
input_token_semantics: row.get::<_, i64>(25)?,
})
}
@@ -1526,7 +1532,8 @@ impl Database {
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model
l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model,
l.input_token_semantics
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}
@@ -1569,7 +1576,8 @@ impl Database {
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
is_streaming, latency_ms, first_token_ms, duration_ms,
status_code, error_message, created_at, l.data_source, l.pricing_model
status_code, error_message, created_at, l.data_source, l.pricing_model,
l.input_token_semantics
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
WHERE l.request_id = ?"
@@ -1725,7 +1733,7 @@ impl Database {
input_cost_usd, output_cost_usd, cache_read_cost_usd,
cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms,
first_token_ms, duration_ms, status_code, error_message, created_at,
data_source, pricing_model
data_source, pricing_model, input_token_semantics
FROM proxy_request_logs
WHERE CAST(total_cost_usd AS REAL) <= 0
AND (input_tokens > 0 OR output_tokens > 0
@@ -1806,15 +1814,21 @@ impl Database {
let million = rust_decimal::Decimal::from(1_000_000u64);
// 与 CostCalculator::calculate_for_app 保持一致的计算逻辑:
// 1. Codex/Gemini 的 input_tokens 包含 cache_read_tokens,需要扣除后按输入价计费
// 1. 历史 Codex/Gemini 行只包含 cache read;新 total 行还包含 cache write。
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
let input_includes_cache_read = matches!(log.app_type.as_str(), "codex" | "gemini");
let billable_input_tokens = if input_includes_cache_read {
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
} else {
log.input_tokens as u64
};
let cache_inclusive_app = matches!(log.app_type.as_str(), "codex" | "gemini");
let billable_input_tokens =
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
log.input_tokens as u64
} else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL {
(log.input_tokens as u64)
.saturating_sub(log.cache_read_tokens as u64)
.saturating_sub(log.cache_creation_tokens as u64)
} else {
// v12 and earlier: input included cache reads but excluded cache writes.
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
};
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
@@ -2492,6 +2506,77 @@ mod tests {
Ok(())
}
#[test]
fn test_backfill_distinguishes_legacy_and_total_cache_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
// v12 mirror row: input = fresh + read; creation was reported separately.
insert_usage_log(
&conn,
"legacy-cache-semantics",
"codex",
"p1",
"gpt-5.5",
"proxy",
1000,
800_000,
0,
600_000,
200_000,
200,
"0",
)?;
// v13 proxy row: input = fresh + read + creation.
insert_usage_log(
&conn,
"total-cache-semantics",
"codex",
"p1",
"gpt-5.5",
"proxy",
1001,
1_000_000,
0,
600_000,
200_000,
200,
"0",
)?;
conn.execute(
"UPDATE proxy_request_logs
SET input_token_semantics = ?1
WHERE request_id = 'total-cache-semantics'",
[INPUT_TOKEN_SEMANTICS_TOTAL],
)?;
}
assert_eq!(db.backfill_missing_usage_costs()?, 2);
let conn = lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT request_id, input_cost_usd
FROM proxy_request_logs
WHERE request_id IN ('legacy-cache-semantics', 'total-cache-semantics')
ORDER BY request_id",
)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
rows,
vec![
("legacy-cache-semantics".to_string(), "1.000000".to_string()),
("total-cache-semantics".to_string(), "1.000000".to_string()),
]
);
Ok(())
}
#[test]
fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> {
let db = Database::memory()?;