fix(usage): correct Gemini output token calculation

Fix Gemini API output token parsing to use totalTokenCount - promptTokenCount
instead of candidatesTokenCount alone. This ensures thoughtsTokenCount is
included in output statistics.

- Update from_gemini_response to calculate output from total - input
- Update from_gemini_stream_chunks with same logic for consistency
- Fix from_codex_stream_events to use adjusted token calculation
- Add test case for responses with thoughtsTokenCount
- Update existing tests to match new calculation logic
This commit is contained in:
YoVinchen
2025-12-30 23:57:14 +08:00
parent bfa5c3c526
commit f4f0590fd6
2 changed files with 172 additions and 23 deletions
+104 -13
View File
@@ -839,10 +839,38 @@ impl Database {
("gpt-5.2-high", "GPT-5.2", "1.75", "14", "0.175", "0"),
("gpt-5.2-xhigh", "GPT-5.2", "1.75", "14", "0.175", "0"),
("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
("gpt-5.2-codex-low", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
("gpt-5.2-codex-medium", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
("gpt-5.2-codex-high", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
("gpt-5.2-codex-xhigh", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
(
"gpt-5.2-codex-low",
"GPT-5.2 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.2-codex-medium",
"GPT-5.2 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.2-codex-high",
"GPT-5.2 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.2-codex-xhigh",
"GPT-5.2 Codex",
"1.75",
"14",
"0.175",
"0",
),
// GPT-5.1 系列
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
@@ -850,10 +878,38 @@ impl Database {
("gpt-5.1-high", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-minimal", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
("gpt-5.1-codex-mini", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
("gpt-5.1-codex-max", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
("gpt-5.1-codex-max-high", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
("gpt-5.1-codex-max-xhigh", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
(
"gpt-5.1-codex-mini",
"GPT-5.1 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5.1-codex-max",
"GPT-5.1 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5.1-codex-max-high",
"GPT-5.1 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5.1-codex-max-xhigh",
"GPT-5.1 Codex",
"1.25",
"10",
"0.125",
"0",
),
// GPT-5 系列
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-low", "GPT-5", "1.25", "10", "0.125", "0"),
@@ -862,11 +918,46 @@ impl Database {
("gpt-5-minimal", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-codex-low", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-codex-medium", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-codex-high", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-codex-mini", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-codex-mini-medium", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-codex-mini-high", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
(
"gpt-5-codex-medium",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5-codex-high",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5-codex-mini",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5-codex-mini-medium",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5-codex-mini-high",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
// Gemini 3 系列
(
"gemini-3-pro-preview",
+68 -10
View File
@@ -233,7 +233,7 @@ impl TokenUsage {
if event_type == "response.completed" {
if let Some(response) = event.get("response") {
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
return Self::from_codex_response(response);
return Self::from_codex_response_adjusted(response);
}
}
}
@@ -297,9 +297,16 @@ impl TokenUsage {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let prompt_tokens = usage.get("promptTokenCount")?.as_u64()? as u32;
let total_tokens = usage.get("totalTokenCount")?.as_u64()? as u32;
// 输出 tokens = 总 tokens - 输入 tokens
// 这包含了 candidatesTokenCount + thoughtsTokenCount
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
Some(Self {
input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32,
output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32,
input_tokens: prompt_tokens,
output_tokens,
cache_read_tokens: usage
.get("cachedContentTokenCount")
.and_then(|v| v.as_u64())
@@ -313,20 +320,25 @@ impl TokenUsage {
#[allow(dead_code)]
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
let mut total_input = 0u32;
let mut total_output = 0u32;
let mut total_tokens = 0u32;
let mut total_cache_read = 0u32;
let mut model: Option<String> = None;
for chunk in chunks {
if let Some(usage) = chunk.get("usageMetadata") {
// 输入 tokens (通常在所有 chunk 中保持不变)
total_input = usage
.get("promptTokenCount")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
total_output += usage
.get("candidatesTokenCount")
// 总 tokens (包含输入 + 输出 + 思考)
total_tokens = usage
.get("totalTokenCount")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
// 缓存读取 tokens
total_cache_read = usage
.get("cachedContentTokenCount")
.and_then(|v| v.as_u64())
@@ -341,6 +353,9 @@ impl TokenUsage {
}
}
// 输出 tokens = 总 tokens - 输入 tokens
let total_output = total_tokens.saturating_sub(total_input);
if total_input > 0 || total_output > 0 {
Some(Self {
input_tokens: total_input,
@@ -479,15 +494,18 @@ mod tests {
let response = json!({
"modelVersion": "gemini-3-pro-high",
"usageMetadata": {
"promptTokenCount": 100,
"promptTokenCount": 8383,
"candidatesTokenCount": 50,
"thoughtsTokenCount": 114,
"totalTokenCount": 8547,
"cachedContentTokenCount": 20
}
});
let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.input_tokens, 8383);
// output_tokens = totalTokenCount - promptTokenCount = 8547 - 8383 = 164
assert_eq!(usage.output_tokens, 164);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
@@ -499,19 +517,59 @@ mod tests {
let response = json!({
"usageMetadata": {
"promptTokenCount": 100,
"candidatesTokenCount": 50,
"totalTokenCount": 150,
"cachedContentTokenCount": 20
}
});
let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
// output_tokens = totalTokenCount - promptTokenCount = 150 - 100 = 50
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, None);
}
#[test]
fn test_gemini_response_with_thoughts() {
// 测试包含 thoughtsTokenCount 的实际响应
// 这是用户报告的真实场景
let response = json!({
"candidates": [
{
"content": {
"parts": [
{
"text": "",
"thoughtSignature": "EvcECvQE..."
}
],
"role": "model"
},
"finishReason": "STOP"
}
],
"modelVersion": "gemini-3-pro-high",
"responseId": "yupTafqLDu-PjMcPhrOx4QQ",
"usageMetadata": {
"candidatesTokenCount": 50,
"promptTokenCount": 8383,
"thoughtsTokenCount": 114,
"totalTokenCount": 8547
}
});
let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 8383);
// output_tokens = totalTokenCount - promptTokenCount
// = 8547 - 8383 = 164 (包含 candidatesTokenCount 50 + thoughtsTokenCount 114)
assert_eq!(usage.output_tokens, 164);
assert_eq!(usage.cache_read_tokens, 0);
assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
}
#[test]
fn test_codex_response_parsing_cached_tokens_in_details() {
let response = json!({