diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 8391a34..0f7c805 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -96,6 +96,7 @@ impl StreamingAccumulator { /// 跳过 total_tokens=0 的占位帧,避免覆盖真实值。 fn set_usage(&mut self, usage: OpenAIUsage) { if usage.total_tokens > 0 { + usage.log_cache_diagnostics("stream_final_frame"); self.usage = Some(usage); } } @@ -724,6 +725,7 @@ impl OpenAIProvider { }) .unwrap_or_default(); // 回退场景下也从非流式响应提取 usage + openai_resp.usage.log_cache_diagnostics("non_streaming_fallback"); response.usage = Usage { prompt_tokens: openai_resp.usage.prompt_tokens, completion_tokens: openai_resp.usage.completion_tokens, @@ -1106,6 +1108,19 @@ impl OpenAIUsage { }) .unwrap_or(0) } + + /// 诊断日志:记录 API 是否返回缓存字段及解析后的值(排查"缓存一直不命中"问题)。 + /// field_present=false 说明 API/网关根本没返回缓存字段(中转服务剥离或模型不支持)。 + fn log_cache_diagnostics(&self, source: &str) { + tracing::info!( + source = %source, + prompt_tokens = self.prompt_tokens, + cached_tokens = self.cached_tokens(), + deepseek_cache_field_present = self.prompt_cache_hit_tokens.is_some(), + openai_cache_details_present = self.prompt_tokens_details.is_some(), + "OpenAI usage cache diagnostics" + ); + } } #[async_trait] @@ -1263,11 +1278,14 @@ impl LLMProvider for OpenAIProvider { content, reasoning_content: openai_resp.choices[0].message.reasoning_content.clone(), tool_calls, - usage: Usage { - prompt_tokens: openai_resp.usage.prompt_tokens, - completion_tokens: openai_resp.usage.completion_tokens, - total_tokens: openai_resp.usage.total_tokens, - cached_tokens: openai_resp.usage.cached_tokens(), + usage: { + openai_resp.usage.log_cache_diagnostics("non_streaming"); + Usage { + prompt_tokens: openai_resp.usage.prompt_tokens, + completion_tokens: openai_resp.usage.completion_tokens, + total_tokens: openai_resp.usage.total_tokens, + cached_tokens: openai_resp.usage.cached_tokens(), + } }, }) } @@ -1571,6 +1589,45 @@ mod tests { ); } + #[test] + fn test_usage_parses_deepseek_cache_fields() { + // DeepSeek 官方 API 的 usage 顶层缓存字段 + let usage: OpenAIUsage = serde_json::from_value(json!({ + "prompt_tokens": 345, + "completion_tokens": 564, + "total_tokens": 909, + "prompt_cache_hit_tokens": 128, + "prompt_cache_miss_tokens": 217 + })) + .unwrap(); + + assert_eq!(usage.cached_tokens(), 128); + assert!(usage.prompt_cache_hit_tokens.is_some()); + } + + #[test] + fn test_usage_parses_openai_nested_cache_details() { + // OpenAI 兼容格式:嵌套在 prompt_tokens_details 中 + let usage: OpenAIUsage = serde_json::from_value(json!({ + "prompt_tokens": 1000, + "completion_tokens": 100, + "total_tokens": 1100, + "prompt_tokens_details": { "cached_tokens": 512 } + })) + .unwrap(); + + assert_eq!(usage.cached_tokens(), 512); + + // 无缓存字段时回退为 0 + let no_cache: OpenAIUsage = serde_json::from_value(json!({ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15 + })) + .unwrap(); + assert_eq!(no_cache.cached_tokens(), 0); + } + #[test] fn test_openai_response_parses_json_tool_arguments() { let response: OpenAIResponse = serde_json::from_value(json!({