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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 时(如 backfill)SELECT `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()?;
|
||||
|
||||
Reference in New Issue
Block a user