From e2d1d7203c2ff3841f6ec1ab3812549511acc185 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Wed, 27 May 2026 13:10:49 -0400 Subject: [PATCH] feat(llm): parse cached-token fields in openai-compatible usage Extend the OpenAI-compatible adapter's ApiUsage parsing to read prompt_tokens_details.cached_tokens and cache_write_tokens from response and streaming chunk payloads. Both fields are optional, so this is fully backwards-compatible with providers that don't emit them. Cached and freshly-read tokens are priced differently, so surfacing both keeps catalog cost math accurate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/providers/openai_compatible.rs | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/lib/crates/fabro-llm/src/providers/openai_compatible.rs b/lib/crates/fabro-llm/src/providers/openai_compatible.rs index 3b784dc04..7d24955b1 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/crates/fabro-llm/src/providers/openai_compatible.rs @@ -181,8 +181,18 @@ struct ApiFunction { reason = "Field names mirror the provider API payload." )] struct ApiUsage { - prompt_tokens: i64, - completion_tokens: i64, + prompt_tokens: i64, + completion_tokens: i64, + #[serde(default)] + prompt_tokens_details: Option, + #[serde(default)] + cache_write_tokens: Option, +} + +#[derive(serde::Deserialize, Default)] +struct ApiPromptTokensDetails { + #[serde(default)] + cached_tokens: Option, } // --- Streaming response types --- @@ -587,6 +597,12 @@ impl ProviderAdapter for Adapter { .map_or_else(TokenCounts::default, |u| TokenCounts { input_tokens: u.prompt_tokens, output_tokens: u.completion_tokens, + cache_read_tokens: u + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens) + .unwrap_or(0), + cache_write_tokens: u.cache_write_tokens.unwrap_or(0), ..TokenCounts::default() }); @@ -829,6 +845,12 @@ impl StreamState { self.usage = TokenCounts { input_tokens: usage.prompt_tokens, output_tokens: usage.completion_tokens, + cache_read_tokens: usage + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens) + .unwrap_or(0), + cache_write_tokens: usage.cache_write_tokens.unwrap_or(0), ..TokenCounts::default() }; } @@ -1046,6 +1068,48 @@ mod tests { let usage = chunk.usage.unwrap(); assert_eq!(usage.prompt_tokens, 10); assert_eq!(usage.completion_tokens, 20); + assert!(usage.prompt_tokens_details.is_none()); + assert!(usage.cache_write_tokens.is_none()); + } + + #[test] + fn stream_chunk_usage_parses_cached_tokens() { + let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"prompt_tokens_details":{"cached_tokens":80},"cache_write_tokens":12}}"#; + let chunk: StreamChunk = serde_json::from_str(json).unwrap(); + let usage = chunk.usage.unwrap(); + assert_eq!(usage.prompt_tokens, 100); + assert_eq!(usage.completion_tokens, 50); + assert_eq!( + usage + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens), + Some(80) + ); + assert_eq!(usage.cache_write_tokens, Some(12)); + } + + #[test] + fn stream_state_process_usage_populates_cache_counts() { + let http_resp = + fabro_http::Response::from(http::Response::builder().status(200).body("").unwrap()); + let mut state = StreamState::new( + http_resp, + "openrouter".into(), + "model".into(), + None, + Some(std::time::Duration::from_secs(30)), + ); + + let chunk: StreamChunk = serde_json::from_str( + r#"{"id":"c1","model":"m1","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"prompt_tokens_details":{"cached_tokens":80},"cache_write_tokens":12}}"#, + ) + .unwrap(); + let _ = state.process_chunk(&chunk); + assert_eq!(state.usage.input_tokens, 100); + assert_eq!(state.usage.output_tokens, 50); + assert_eq!(state.usage.cache_read_tokens, 80); + assert_eq!(state.usage.cache_write_tokens, 12); } #[test]