diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs index 1b7ff3b33..62c6ede6b 100644 --- a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs @@ -306,6 +306,10 @@ pub(super) struct ApiUsage { pub prompt_cache_hit_tokens: Option, #[serde(default)] pub completion_tokens_details: Option, + /// Modal reports reasoning tokens directly on `usage` instead of nesting + /// them under `completion_tokens_details`. + #[serde(default)] + pub reasoning_tokens: Option, } #[derive(serde::Deserialize)] @@ -328,6 +332,9 @@ impl ApiUsage { /// cache-write detail tokens are subtracted out of `input_tokens`, and /// reasoning tokens out of `output_tokens`, mirroring the /// `openai_responses` convention. + /// + /// Nested detail fields win over the flat `prompt_cache_hit_tokens` and + /// `reasoning_tokens` spellings that some providers send instead. pub(super) fn token_counts(&self) -> TokenCounts { let cached_detail = self .prompt_tokens_details @@ -344,6 +351,7 @@ impl ApiUsage { .completion_tokens_details .as_ref() .and_then(|d| d.reasoning_tokens) + .or(self.reasoning_tokens) .unwrap_or(0); let (uncached_input, cached) = split_inclusive_token_total(self.prompt_tokens, cached_detail); @@ -560,6 +568,44 @@ mod tests { }); } + #[test] + fn token_counts_accept_modal_reasoning_tokens_field() { + let usage: ApiUsage = serde_json::from_value(serde_json::json!({ + "prompt_tokens": 116, + "completion_tokens": 66, + "reasoning_tokens": 54 + })) + .unwrap(); + + assert_eq!(usage.token_counts(), TokenCounts { + input_tokens: 116, + output_tokens: 12, + reasoning_tokens: 54, + ..TokenCounts::default() + }); + } + + #[test] + fn token_counts_prefer_nested_reasoning_detail_over_top_level() { + let both_spellings: ApiUsage = serde_json::from_value(serde_json::json!({ + "prompt_tokens": 10, + "completion_tokens": 66, + "completion_tokens_details": {"reasoning_tokens": 20}, + "reasoning_tokens": 54 + })) + .unwrap(); + assert_eq!(both_spellings.token_counts().reasoning_tokens, 20); + + let empty_detail: ApiUsage = serde_json::from_value(serde_json::json!({ + "prompt_tokens": 10, + "completion_tokens": 66, + "completion_tokens_details": {}, + "reasoning_tokens": 54 + })) + .unwrap(); + assert_eq!(empty_detail.token_counts().reasoning_tokens, 54); + } + #[test] fn reasoning_accepts_provider_and_openrouter_spellings() { let provider_response: ApiResponse = serde_json::from_value(serde_json::json!({