From a80b2b98b77e72c27a189c97b2b86eb8603911f0 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Wed, 31 Dec 2025 00:39:55 +0800 Subject: [PATCH] fix(usage): correct cache token billing and add Codex format auto-detection - Avoid double-billing cache tokens by subtracting from input before calculation - Add smart Codex parser that auto-detects OpenAI vs Codex API format - Extract model name from Codex responses for accurate tracking --- src-tauri/src/proxy/handler_config.rs | 18 +-- src-tauri/src/proxy/usage/calculator.rs | 18 ++- src-tauri/src/proxy/usage/parser.rs | 159 +++++++++++++++++++++++- 3 files changed, 182 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/proxy/handler_config.rs b/src-tauri/src/proxy/handler_config.rs index ca2df0787..fcbeb1fa2 100644 --- a/src-tauri/src/proxy/handler_config.rs +++ b/src-tauri/src/proxy/handler_config.rs @@ -58,10 +58,10 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String { .to_string() } -/// Codex Responses API 流式响应模型提取(优先使用 usage.model) -fn codex_model_extractor(events: &[Value], request_model: &str) -> String { +/// Codex 智能流式响应模型提取(自动检测格式) +fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String { // 首先尝试从解析的 usage 中获取模型 - if let Some(usage) = TokenUsage::from_codex_stream_events(events) { + if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) { if let Some(model) = usage.model { return model; } @@ -76,6 +76,10 @@ fn codex_model_extractor(events: &[Value], request_model: &str) -> String { None } }) + .or_else(|| { + // 再回退:从 OpenAI 格式事件中提取 + events.iter().find_map(|e| e.get("model")?.as_str()) + }) .unwrap_or(request_model) .to_string() } @@ -111,11 +115,11 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { app_type_str: "codex", }; -/// Codex Responses API 解析配置(用于 /v1/responses) +/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式) pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { - stream_parser: TokenUsage::from_codex_stream_events, - response_parser: TokenUsage::from_codex_response, - model_extractor: codex_model_extractor, + stream_parser: TokenUsage::from_codex_stream_events_auto, + response_parser: TokenUsage::from_codex_response_auto, + model_extractor: codex_auto_model_extractor, app_type_str: "codex", }; diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs index 7295e13ae..80fd2c673 100644 --- a/src-tauri/src/proxy/usage/calculator.rs +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -35,6 +35,11 @@ impl CostCalculator { /// - `usage`: Token 使用量 /// - `pricing`: 模型定价 /// - `cost_multiplier`: 成本倍数 (provider 自定义) + /// + /// # 计算逻辑 + /// - input_cost: (input_tokens - cache_read_tokens) × 输入价格 + /// - cache_read_cost: cache_read_tokens × 缓存读取价格 + /// - 这样避免缓存部分被重复计费 pub fn calculate( usage: &TokenUsage, pricing: &ModelPricing, @@ -42,7 +47,10 @@ impl CostCalculator { ) -> CostBreakdown { let million = Decimal::from(1_000_000); - let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million + // 计算实际需要按输入价格计费的 token 数(减去缓存命中部分) + let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens); + + let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million * cost_multiplier; let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million @@ -113,8 +121,8 @@ mod tests { let cost = CostCalculator::calculate(&usage, &pricing, multiplier); - // input: 1000 * 3.0 / 1M = 0.003 - assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap()); + // input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分) + assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap()); // output: 500 * 15.0 / 1M = 0.0075 assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap()); // cache_read: 200 * 0.3 / 1M = 0.00006 @@ -124,8 +132,8 @@ mod tests { cost.cache_creation_cost, Decimal::from_str("0.000375").unwrap() ); - // total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935 - assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap()); + // total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335 + assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap()); } #[test] diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 54aa36b15..0a33d0187 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -211,6 +211,12 @@ impl TokenUsage { // 调整 input_tokens: 减去 cached_tokens let adjusted_input = input_tokens.saturating_sub(cached_tokens); + // 提取响应中的模型名称 + let model = body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(Self { input_tokens: adjusted_input, output_tokens, @@ -219,7 +225,7 @@ impl TokenUsage { .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, - model: None, + model, }) } @@ -242,6 +248,51 @@ impl TokenUsage { None } + /// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式 + /// + /// Codex 支持两种 API 格式: + /// - `/v1/responses`: 使用 input_tokens/output_tokens + /// - `/v1/chat/completions`: 使用 prompt_tokens/completion_tokens (OpenAI 格式) + /// + /// 注意:记录原始 input_tokens,费用计算时再减去 cached_tokens + pub fn from_codex_response_auto(body: &Value) -> Option { + let usage = body.get("usage")?; + + // 检测格式:OpenAI 使用 prompt_tokens,Codex 使用 input_tokens + if usage.get("prompt_tokens").is_some() { + log::debug!("[Codex] 检测到 OpenAI 格式 (prompt_tokens)"); + Self::from_openai_response(body) + } else if usage.get("input_tokens").is_some() { + log::debug!("[Codex] 检测到 Codex 格式 (input_tokens)"); + // 使用非调整版本,记录原始 input_tokens + Self::from_codex_response(body) + } else { + log::debug!("[Codex] 无法识别响应格式,usage: {usage:?}"); + None + } + } + + /// 智能 Codex 流式响应解析 - 自动检测 OpenAI 或 Codex 格式 + pub fn from_codex_stream_events_auto(events: &[Value]) -> Option { + log::debug!("[Codex] 智能解析流式事件,共 {} 个事件", events.len()); + + // 先尝试 Codex Responses API 格式 (response.completed 事件) + for event in events { + if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { + if event_type == "response.completed" { + if let Some(response) = event.get("response") { + log::debug!("[Codex] 找到 response.completed 事件"); + return Self::from_codex_response_auto(response); + } + } + } + } + + // 回退到 OpenAI Chat Completions 格式 (最后一个 chunk 包含 usage) + log::debug!("[Codex] 尝试 OpenAI 流式格式"); + Self::from_openai_stream_events(events) + } + /// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens) pub fn from_openai_response(body: &Value) -> Option { let usage = body.get("usage")?; @@ -721,4 +772,110 @@ mod tests { assert_eq!(usage.cache_read_tokens, 50); assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); } + + // ============================================================================ + // 智能 Codex 解析测试 + // ============================================================================ + + #[test] + fn test_codex_response_auto_openai_format() { + // OpenAI 格式 (prompt_tokens/completion_tokens) + let response = json!({ + "model": "gpt-4o", + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 500, + "prompt_tokens_details": { + "cached_tokens": 200 + } + } + }); + + let usage = TokenUsage::from_codex_response_auto(&response).unwrap(); + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 200); + assert_eq!(usage.model, Some("gpt-4o".to_string())); + } + + #[test] + fn test_codex_response_auto_codex_format() { + // Codex 格式 (input_tokens/output_tokens) + let response = json!({ + "model": "o3", + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": { + "cached_tokens": 300 + } + } + }); + + let usage = TokenUsage::from_codex_response_auto(&response).unwrap(); + // 记录原始 input_tokens,不调整 + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 300); + assert_eq!(usage.model, Some("o3".to_string())); + } + + #[test] + fn test_codex_stream_events_auto_codex_format() { + // Codex Responses API 流式格式 (response.completed 事件) + let events = vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp_123" + } + }), + json!({ + "type": "response.completed", + "response": { + "model": "o3", + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": { + "cached_tokens": 200 + } + } + } + }), + ]; + + let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap(); + // 记录原始 input_tokens,不调整 + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 200); + assert_eq!(usage.model, Some("o3".to_string())); + } + + #[test] + fn test_codex_stream_events_auto_openai_format() { + // OpenAI Chat Completions 流式格式 (最后一个 chunk 包含 usage) + let events = vec![ + json!({ + "id": "chatcmpl-123", + "model": "gpt-4o", + "choices": [{"delta": {"content": "Hello"}}] + }), + json!({ + "id": "chatcmpl-123", + "model": "gpt-4o", + "choices": [{"delta": {}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50 + } + }), + ]; + + let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.model, Some("gpt-4o".to_string())); + } }