diff --git a/src/engine/llm.rs b/src/engine/llm.rs index f2ee77c..8325054 100644 --- a/src/engine/llm.rs +++ b/src/engine/llm.rs @@ -66,10 +66,15 @@ struct ChatRequest { } /// Response from /chat/completions. +/// +/// `usage` is parsed as raw `serde_json::Value` to avoid serde's duplicate-field +/// detection when a provider sends both legacy (`prompt_tokens`) and new +/// (`input_tokens`) field names simultaneously (e.g. GPT-5.4). The value is +/// converted to a typed `Usage` via [`parse_usage_value`] in post-processing. #[derive(Debug, Clone, Deserialize)] struct ChatResponse { choices: Vec, - usage: Option, + usage: Option, } #[derive(Debug, Clone, Deserialize)] @@ -79,25 +84,61 @@ struct ChatChoice { /// Usage statistics from the LLM API response. /// -/// OpenAI-compatible providers return these in every non-streaming response, -/// and in the final SSE chunk when `stream_options.include_usage` is set. -/// Some providers send them under snake_case (`prompt_tokens`), others under -/// camelCase (`promptTokens`) — both are accepted here. -/// -/// `total_tokens` is accepted but not used by cora (we sum prompt+completion -/// instead) — it is kept for completeness so that providers that omit -/// `prompt_tokens` / `completion_tokens` in favour of a single `total_tokens` -/// can still be surfaced downstream. -#[derive(Debug, Clone, Deserialize)] +/// Constructed via [`parse_usage_value`] which accepts a raw `serde_json::Value` +/// and handles providers that send legacy field names (`prompt_tokens`, +/// `completion_tokens`), new field names (`input_tokens`, `output_tokens`), +/// or both simultaneously (e.g. GPT-5.4). +#[derive(Debug, Clone, Default)] pub(crate) struct Usage { - #[serde(default, alias = "promptTokens", alias = "input_tokens")] prompt_tokens: u32, - #[serde(default, alias = "completionTokens", alias = "output_tokens")] completion_tokens: u32, - #[serde(default, alias = "totalTokens")] total_tokens: u32, } +/// Extract a typed [`Usage`] from a raw `serde_json::Value`. +/// +/// Handles three naming conventions that OpenAI-compatible providers use: +/// +/// | Field | Legacy (OpenAI) | New (GPT-5+) | CamelCase (Azure) | +/// |----------------|-------------------|--------------------|--------------------| +/// | input | `prompt_tokens` | `input_tokens` | `promptTokens` | +/// | output | `completion_tokens` | `output_tokens` | `completionTokens` | +/// | total | `total_tokens` | `total_tokens` | `totalTokens` | +/// +/// Some providers (notably GPT-5.4) send **both** legacy and new names for the +/// same value. Direct serde deserialization with aliases would hit serde_json's +/// duplicate-field guard (>= 1.0.120), so we extract manually via `Value` +/// and pick the first non-zero value in preference order. +fn parse_usage_value(val: &Value) -> Option { + let obj = val.as_object()?; + + let prompt_tokens = obj + .get("prompt_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| obj.get("promptTokens").and_then(|v| v.as_u64())) + .or_else(|| obj.get("input_tokens").and_then(|v| v.as_u64())) + .unwrap_or(0) as u32; + + let completion_tokens = obj + .get("completion_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| obj.get("completionTokens").and_then(|v| v.as_u64())) + .or_else(|| obj.get("output_tokens").and_then(|v| v.as_u64())) + .unwrap_or(0) as u32; + + let total_tokens = obj + .get("total_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| obj.get("totalTokens").and_then(|v| v.as_u64())) + .unwrap_or(0) as u32; + + Some(Usage { + prompt_tokens, + completion_tokens, + total_tokens, + }) +} + impl Usage { /// Effective input tokens. /// @@ -300,10 +341,12 @@ async fn chat_completion( .map(|c| c.message.content.clone()) .unwrap_or_default(); - debug!(tokens = ?parsed.usage, "LLM response received"); - tracing::Span::current().record("tokens_used", parsed.usage.as_ref().map(|u| u.total_tokens)); + let usage = parsed.usage.as_ref().and_then(parse_usage_value); - Ok((content, parsed.usage)) + debug!(tokens = ?usage, "LLM response received"); + tracing::Span::current().record("tokens_used", usage.as_ref().map(|u| u.total_tokens)); + + Ok((content, usage)) } /// Create an animated spinner for LLM operations. @@ -631,18 +674,18 @@ fn extract_stream_content(parsed: &Value) -> Option<&str> { /// The `usage` field appears either at top level (OpenAI convention, sent in /// the final chunk when `stream_options.include_usage` is set) or inside the /// final choice's delta (some Azure / third-party providers). +/// +/// Uses [`parse_usage_value`] to avoid serde's duplicate-field guard when a +/// provider sends both legacy and new field names simultaneously. fn extract_stream_usage(parsed: &Value) -> Option { - parsed - .get("usage") - .and_then(|u| serde_json::from_value::(u.clone()).ok()) - .or_else(|| { - parsed - .get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("delta")) - .and_then(|d| d.get("usage")) - .and_then(|u| serde_json::from_value::(u.clone()).ok()) - }) + parsed.get("usage").and_then(parse_usage_value).or_else(|| { + parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("delta")) + .and_then(|d| d.get("usage")) + .and_then(parse_usage_value) + }) } /// Scan a batch of file contents using the LLM. Returns issues found. @@ -1420,6 +1463,92 @@ mod tests { assert_eq!(token_usage.output_tokens, 150); // total - prompt } + // ─── parse_usage_value (GPT-5.4 dual-field handling) ─── + + #[test] + fn parse_usage_value_legacy_fields() { + // Traditional OpenAI format: prompt_tokens / completion_tokens + let val = serde_json::json!({ + "prompt_tokens": 2615, + "completion_tokens": 581, + "total_tokens": 3196 + }); + let usage = parse_usage_value(&val).unwrap(); + assert_eq!(usage.prompt_tokens, 2615); + assert_eq!(usage.completion_tokens, 581); + assert_eq!(usage.total_tokens, 3196); + } + + #[test] + fn parse_usage_value_new_fields_only() { + // Some providers only send input_tokens / output_tokens + let val = serde_json::json!({ + "input_tokens": 1000, + "output_tokens": 200, + "total_tokens": 1200 + }); + let usage = parse_usage_value(&val).unwrap(); + assert_eq!(usage.prompt_tokens, 1000); + assert_eq!(usage.completion_tokens, 200); + assert_eq!(usage.total_tokens, 1200); + } + + #[test] + fn parse_usage_value_gpt54_dual_fields() { + // GPT-5.4 sends BOTH legacy and new field names — this is the + // scenario that previously caused serde duplicate-field error. + let val = serde_json::json!({ + "prompt_tokens": 2615, + "completion_tokens": 581, + "total_tokens": 3196, + "prompt_tokens_details": {"cached_tokens": 0}, + "completion_tokens_details": {"reasoning_tokens": 0}, + "input_tokens": 2615, + "output_tokens": 581, + "input_tokens_details": null + }); + let usage = parse_usage_value(&val).unwrap(); + // Must prefer primary (prompt_tokens) over alias (input_tokens) + assert_eq!(usage.prompt_tokens, 2615); + assert_eq!(usage.completion_tokens, 581); + assert_eq!(usage.total_tokens, 3196); + } + + #[test] + fn parse_usage_value_camelcase_fields() { + // Azure / some third-party providers use camelCase + let val = serde_json::json!({ + "promptTokens": 500, + "completionTokens": 100, + "totalTokens": 600 + }); + let usage = parse_usage_value(&val).unwrap(); + assert_eq!(usage.prompt_tokens, 500); + assert_eq!(usage.completion_tokens, 100); + assert_eq!(usage.total_tokens, 600); + } + + #[test] + fn parse_usage_value_missing_fields_defaults_to_zero() { + // Partial usage (e.g. streaming final chunk) + let val = serde_json::json!({ + "prompt_tokens": 100 + }); + let usage = parse_usage_value(&val).unwrap(); + assert_eq!(usage.prompt_tokens, 100); + assert_eq!(usage.completion_tokens, 0); + assert_eq!(usage.total_tokens, 0); + } + + #[test] + fn parse_usage_value_non_object_returns_none() { + let val = serde_json::json!("not an object"); + assert!(parse_usage_value(&val).is_none()); + + let val = serde_json::json!(42); + assert!(parse_usage_value(&val).is_none()); + } + // ─── Various issue_type values ─── #[test]