fix(usage): OpenAI 缺失 input_tokens 时从 total 回推

- 同步与流式两条路径均在 input_tokens 为 0 且 total > output 时用 total - output 兜底
- 补充对应单元测试
This commit is contained in:
fawney19
2026-04-24 22:08:23 +08:00
parent 67b092253d
commit 5b3593038d
3 changed files with 41 additions and 3 deletions

View File

@@ -388,7 +388,7 @@ fn resolve_openai_cli_finish_reason(output: &[Value]) -> String {
fn standardized_usage_from_openai_usage(value: &Value) -> Option<StandardizedUsage> {
let usage = value.as_object()?;
let input_tokens = usage
let mut input_tokens = usage
.get("input_tokens")
.or_else(|| usage.get("prompt_tokens"))
.and_then(Value::as_i64)
@@ -428,6 +428,9 @@ fn standardized_usage_from_openai_usage(value: &Value) -> Option<StandardizedUsa
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
);
if input_tokens == 0 && total_tokens > output_tokens {
input_tokens = total_tokens.saturating_sub(output_tokens);
}
let mut standardized_usage = StandardizedUsage::new();
standardized_usage.input_tokens = input_tokens;
standardized_usage.output_tokens = output_tokens;
@@ -443,12 +446,28 @@ fn standardized_usage_from_openai_usage(value: &Value) -> Option<StandardizedUsa
mod tests {
use serde_json::json;
use super::maybe_bridge_standard_sync_json_to_stream;
use super::{maybe_bridge_standard_sync_json_to_stream, standardized_usage_from_openai_usage};
fn utf8(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).expect("utf8 should decode")
}
#[test]
fn openai_sync_usage_derives_missing_input_tokens_from_total() {
let usage = standardized_usage_from_openai_usage(&json!({
"output_tokens": 177,
"total_tokens": 20_612,
"input_tokens_details": {
"cached_tokens": 19_840,
},
}))
.expect("usage should parse");
assert_eq!(usage.input_tokens, 20_435);
assert_eq!(usage.output_tokens, 177);
assert_eq!(usage.cache_read_tokens, 19_840);
}
#[test]
fn bridges_openai_image_sync_json_to_generation_completed_sse() {
let report_context = json!({

View File

@@ -1822,6 +1822,22 @@ mod tests {
sequence_numbers
}
#[test]
fn openai_usage_derives_missing_input_tokens_from_total() {
let usage = canonical_usage_from_openai_usage(Some(&json!({
"output_tokens": 177,
"total_tokens": 20_612,
"input_tokens_details": {
"cached_tokens": 19_840,
},
})))
.expect("usage should parse");
assert_eq!(usage.input_tokens, 20_435);
assert_eq!(usage.output_tokens, 177);
assert_eq!(usage.cache_read_tokens, 19_840);
}
#[test]
fn openai_cli_client_emitter_emits_doc_like_text_events() {
let mut emitter = OpenAICliClientEmitter::default();

View File

@@ -89,7 +89,7 @@ pub fn resolve_identity(
pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
let usage = value?.as_object()?;
let input_tokens = usage
let mut input_tokens = usage
.get("input_tokens")
.or_else(|| usage.get("prompt_tokens"))
.and_then(Value::as_u64)
@@ -129,6 +129,9 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
);
if input_tokens == 0 && total_tokens > output_tokens {
input_tokens = total_tokens.saturating_sub(output_tokens);
}
Some(CanonicalUsage {
input_tokens,
output_tokens,