Merge pull request #691 from fabro-sh/fix/modal-reasoning-token-usage

fix(llm): decode Modal reasoning token usage
This commit is contained in:
Bryan Helmkamp 2026-08-01 09:48:36 -04:00 • committed by GitHub
commit 66b282d966
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -306,6 +306,10 @@ pub(super) struct ApiUsage {
pub prompt_cache_hit_tokens: Option<i64>,
#[serde(default)]
pub completion_tokens_details: Option<CompletionTokensDetails>,
/// Modal reports reasoning tokens directly on `usage` instead of nesting
/// them under `completion_tokens_details`.
#[serde(default)]
pub reasoning_tokens: Option<i64>,
}
#[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!({