diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cc2989c..795d7f5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **OpenAI Responses API usage parsing robustness**: Hardened `build_anthropic_usage_from_responses()` and the Responses → Anthropic SSE translator so a missing or malformed upstream `usage` no longer produces `"usage": null` in `message_delta`. This unblocks strict Anthropic clients (notably the VSCode Claude Code extension) that crashed with "Cannot read properties of null (reading 'output_tokens')" against providers such as Codex OAuth and DashScope's `compatible-mode/v1/responses` endpoint. Added OpenAI field-name fallbacks (`prompt_tokens` / `completion_tokens`), null/empty/partial object handling, and preserved cache token fields even when input/output tokens are missing (#2422). + ## [3.14.1] - 2026-04-23 Development since v3.14.0 focuses on Codex OAuth stability, tray usage visibility, Skills import/install reliability, Gemini session restore paths, and simplifying Hermes configuration health handling. diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index 1eba5033e..c7f63a14e 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -170,9 +170,12 @@ pub fn create_anthropic_sse_stream_from_responses) -> Value { let u = match usage { - Some(v) if !v.is_null() => v, + Some(v) if !v.is_null() && v.is_object() => v, _ => { return json!({ "input_tokens": 0, @@ -232,15 +248,56 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val } }; - let input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0); - let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + // Detect empty object {} and log warning + if u.as_object().map(|obj| obj.is_empty()).unwrap_or(false) { + log::warn!("[Responses] Empty usage object received, using defaults"); + return json!({ + "input_tokens": 0, + "output_tokens": 0 + }); + } + + // Extract input_tokens with OpenAI field name fallback + // Priority: input_tokens (Anthropic) → prompt_tokens (OpenAI) → 0 + let input = u + .get("input_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| { + let prompt_tokens = u.get("prompt_tokens").and_then(|v| v.as_u64()); + if prompt_tokens.is_some() { + log::debug!( + "[Responses] Using OpenAI field name fallback 'prompt_tokens' for input_tokens" + ); + } + prompt_tokens + }) + .unwrap_or(0); + + // Extract output_tokens with OpenAI field name fallback + // Priority: output_tokens (Anthropic) → completion_tokens (OpenAI) → 0 + let output = u.get("output_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| { + let completion_tokens = u.get("completion_tokens").and_then(|v| v.as_u64()); + if completion_tokens.is_some() { + log::debug!("[Responses] Using OpenAI field name fallback 'completion_tokens' for output_tokens"); + } + completion_tokens + }) + .unwrap_or(0); + + // Log if only one field present (partial object). Streaming chunks legitimately + // arrive with partial usage, so this stays at debug level to avoid noise. + if (input == 0 && output > 0) || (input > 0 && output == 0) { + log::debug!("[Responses] Partial usage object: {:?}", u); + } let mut result = json!({ "input_tokens": input, "output_tokens": output }); - // Step 1: OpenAI nested details as fallback + // Step 1: OpenAI nested details as fallback for cache tokens // OpenAI Responses API: input_tokens_details.cached_tokens if let Some(cached) = u .pointer("/input_tokens_details/cached_tokens") @@ -259,6 +316,7 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val } // Step 2: Direct Anthropic-style fields override (authoritative if present) + // These preserve cache tokens even if input/output_tokens are missing if let Some(v) = u.get("cache_read_input_tokens") { result["cache_read_input_tokens"] = v.clone(); } @@ -1352,4 +1410,106 @@ mod tests { "非 Codex OAuth 路径下 tools 在客户端未送时不应被注入" ); } + + // ==================== Usage Field Robustness Tests ==================== + + #[test] + fn test_build_usage_from_null_parameter() { + let result = build_anthropic_usage_from_responses(None); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(0)); + } + + #[test] + fn test_build_usage_from_null_json_value() { + let result = build_anthropic_usage_from_responses(Some(&json!(null))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(0)); + } + + #[test] + fn test_build_usage_from_empty_object() { + let result = build_anthropic_usage_from_responses(Some(&json!({}))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(0)); + } + + #[test] + fn test_build_usage_from_partial_input_only() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100 + }))); + assert_eq!(result["input_tokens"], json!(100)); + assert_eq!(result["output_tokens"], json!(0)); + } + + #[test] + fn test_build_usage_from_partial_output_only() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "output_tokens": 50 + }))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(50)); + } + + #[test] + fn test_build_usage_with_openai_field_names() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "prompt_tokens": 120, + "completion_tokens": 45 + }))); + assert_eq!(result["input_tokens"], json!(120)); + assert_eq!(result["output_tokens"], json!(45)); + } + + #[test] + fn test_build_usage_anthropic_names_precedence() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100, + "prompt_tokens": 120, + "output_tokens": 50, + "completion_tokens": 45 + }))); + assert_eq!(result["input_tokens"], json!(100)); // Anthropic name takes precedence + assert_eq!(result["output_tokens"], json!(50)); // Anthropic name takes precedence + } + + #[test] + fn test_build_usage_cache_tokens_from_nested_details() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100, + "output_tokens": 50, + "input_tokens_details": { + "cached_tokens": 80 + } + }))); + assert_eq!(result["input_tokens"], json!(100)); + assert_eq!(result["output_tokens"], json!(50)); + assert_eq!(result["cache_read_input_tokens"], json!(80)); + } + + #[test] + fn test_build_usage_cache_tokens_direct_override() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100, + "output_tokens": 50, + "input_tokens_details": { + "cached_tokens": 80 + }, + "cache_read_input_tokens": 100 + }))); + assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested + } + + #[test] + fn test_build_usage_cache_tokens_without_input_output() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "cache_read_input_tokens": 60, + "cache_creation_input_tokens": 20 + }))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(0)); + assert_eq!(result["cache_read_input_tokens"], json!(60)); + assert_eq!(result["cache_creation_input_tokens"], json!(20)); + } }