diff --git a/crates/switchyard-translation/src/codecs/openai_chat/mod.rs b/crates/switchyard-translation/src/codecs/openai_chat/mod.rs index 766f312c6..05adec1ed 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/mod.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/mod.rs @@ -9,4 +9,4 @@ mod stream; pub use buffered::OpenAiChatCodec; pub use stream::OpenAiChatStreamCodec; -pub(crate) use buffered::{decode_file_source, decode_image_source}; +pub(crate) use buffered::{decode_file_source, decode_image_source, parse_arguments}; diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 998505913..824494899 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -11,7 +11,7 @@ use crate::codecs::common::{ collect_responses_reasoning_text, encrypted_reasoning_data, encrypted_reasoning_item_id, is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks, }; -use crate::codecs::openai_chat::{decode_file_source, decode_image_source}; +use crate::codecs::openai_chat::{decode_file_source, decode_image_source, parse_arguments}; use crate::codecs::openai_media::{ ImagePayload, file_payload, file_source_text, image_payload, image_source_text, }; @@ -334,6 +334,18 @@ impl FormatCodec for OpenAiResponsesCodec { } } } + if !has_output + && let Some(text) = body + .get("output_text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + has_output = true; + content.push(ContentBlock::Text { + text: text.to_string(), + }); + stop_reason = Some(StopReason::EndTurn); + } // The truncation signal is on the response, not the output items. if body.get("status").and_then(Value::as_str) == Some("incomplete") && body @@ -1774,7 +1786,9 @@ fn decode_responses_output_item( .and_then(Value::as_str) .unwrap_or_default() .to_string(), - arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})), + arguments: super::call_arguments(item) + .map(parse_arguments) + .unwrap_or_else(|| json!({})), })], stop_reason: Some(StopReason::ToolUse), })), @@ -2019,57 +2033,88 @@ fn decode_responses_usage(value: Option<&Value>) -> Usage { let Some(value) = value.and_then(Value::as_object) else { return Usage::default(); }; - let aggregate_input_tokens = value - .get("input_tokens") - .or_else(|| value.get("prompt_tokens")) - .and_then(Value::as_u64); - let cached_input_tokens = value - .get("input_tokens_details") - .or_else(|| value.get("prompt_tokens_details")) - .and_then(|details| details.get("cached_tokens")) - .and_then(Value::as_u64); - let cache_creation_input_tokens = value - .get("input_tokens_details") - .and_then(|details| details.get("cache_write_tokens")) - .and_then(Value::as_u64) - .or_else(|| { - value.get("prompt_tokens_details").and_then(|details| { - details - .get("cache_write_tokens") - .and_then(Value::as_u64) - .or_else(|| details.get("cache_creation_tokens").and_then(Value::as_u64)) - }) - }); + let aggregate_input_tokens = super::usage_u64( + value, + &[ + "input_tokens", + "inputTokens", + "prompt_tokens", + "promptTokens", + ], + ); + let cached_input_tokens = + super::usage_u64(value, &["cache_read_input_tokens", "cacheReadInputTokens"]).or_else( + || { + super::usage_detail_u64( + value, + &[ + "input_tokens_details", + "inputTokensDetails", + "prompt_tokens_details", + "promptTokensDetails", + ], + &["cached_tokens", "cachedTokens"], + ) + }, + ); + let cache_creation_input_tokens = super::usage_u64( + value, + &[ + "cache_creation_input_tokens", + "cacheCreationInputTokens", + "cacheWriteInputTokens", + ], + ) + .or_else(|| { + super::usage_detail_u64( + value, + &[ + "input_tokens_details", + "inputTokensDetails", + "prompt_tokens_details", + "promptTokensDetails", + ], + &[ + "cache_write_tokens", + "cacheWriteTokens", + "cache_creation_tokens", + "cacheCreationTokens", + ], + ) + }); let input_tokens = aggregate_input_tokens.map(|tokens| { tokens .saturating_sub(cached_input_tokens.unwrap_or(0)) .saturating_sub(cache_creation_input_tokens.unwrap_or(0)) }); - let output_tokens = value - .get("output_tokens") - .or_else(|| value.get("completion_tokens")) - .and_then(Value::as_u64); + let output_tokens = super::usage_u64( + value, + &[ + "output_tokens", + "outputTokens", + "completion_tokens", + "completionTokens", + ], + ); Usage { input_tokens, cache: Usage::cache_details(cached_input_tokens, cache_creation_input_tokens), output_tokens, - total_tokens: value - .get("total_tokens") - .and_then(Value::as_u64) - .or_else(|| { - aggregate_input_tokens - .zip(output_tokens) - .map(|(input, output)| input + output) - }), - reasoning_tokens: value - .get("output_tokens_details") - .and_then(|details| details.get("reasoning_tokens")) - .or_else(|| { - value - .get("completion_tokens_details") - .and_then(|details| details.get("reasoning_tokens")) - }) - .and_then(Value::as_u64), + total_tokens: super::usage_u64(value, &["total_tokens", "totalTokens"]).or_else(|| { + aggregate_input_tokens + .zip(output_tokens) + .map(|(input, output)| input + output) + }), + reasoning_tokens: super::usage_detail_u64( + value, + &[ + "output_tokens_details", + "outputTokensDetails", + "completion_tokens_details", + "completionTokensDetails", + ], + &["reasoning_tokens", "reasoningTokens", "thinkingTokens"], + ), } } diff --git a/crates/switchyard-translation/src/codecs/responses/mod.rs b/crates/switchyard-translation/src/codecs/responses/mod.rs index 3939a6c8c..3db6d5caf 100644 --- a/crates/switchyard-translation/src/codecs/responses/mod.rs +++ b/crates/switchyard-translation/src/codecs/responses/mod.rs @@ -3,12 +3,38 @@ //! OpenAI Responses buffered and streaming codecs. +use serde_json::{Map, Value}; + mod buffered; mod stream; pub use buffered::OpenAiResponsesCodec; pub use stream::OpenAiResponsesStreamCodec; +pub(super) fn call_arguments(item: &Map) -> Option<&Value> { + ["arguments", "input", "payload"] + .into_iter() + .filter_map(|field| item.get(field)) + .find(|value| !value.is_null() && !matches!(value, Value::String(text) if text.is_empty())) +} + +pub(super) fn usage_u64(usage: &Map, fields: &[&str]) -> Option { + fields + .iter() + .find_map(|field| usage.get(*field).and_then(Value::as_u64)) +} + +pub(super) fn usage_detail_u64( + usage: &Map, + detail_fields: &[&str], + value_fields: &[&str], +) -> Option { + detail_fields.iter().find_map(|detail_field| { + let details = usage.get(*detail_field)?.as_object()?; + usage_u64(details, value_fields) + }) +} + pub(crate) fn is_native_output(kind: &str) -> bool { matches!( kind, diff --git a/crates/switchyard-translation/src/codecs/responses/stream.rs b/crates/switchyard-translation/src/codecs/responses/stream.rs index a179d4ee7..548a05a47 100644 --- a/crates/switchyard-translation/src/codecs/responses/stream.rs +++ b/crates/switchyard-translation/src/codecs/responses/stream.rs @@ -745,23 +745,26 @@ fn decode_responses_completed_item( return Vec::new(); } state.decoded_tool_call = true; - let custom_arguments = (item_type == Some("custom_tool_call")).then(|| { - json!({ + let tool = state.tool_states.entry(index).or_default(); + let arguments_delta = if item_type == Some("custom_tool_call") { + let arguments = json!({ crate::codex_custom_tools::INPUT_ARGUMENT: item.get("input").and_then(Value::as_str).unwrap_or_default() }) - .to_string() - }); - let arguments = custom_arguments - .as_deref() - .or_else(|| item.get("arguments").and_then(Value::as_str)); - let tool = state.tool_states.entry(index).or_default(); - let arguments_delta = - match arguments.map(|arguments| snapshot_suffix(&mut tool.decoded_arguments, arguments)) { + .to_string(); + match snapshot_suffix(&mut tool.decoded_arguments, &arguments) { + Ok(delta) => delta, + Err(error) => return vec![error], + } + } else { + match super::call_arguments(item) + .map(|snapshot| completed_arguments_suffix(&mut tool.decoded_arguments, snapshot)) + { Some(Ok(delta)) => delta, Some(Err(error)) => return vec![error], None => None, - }; + } + }; let id = item .get("call_id") .or_else(|| item.get("id")) @@ -781,6 +784,26 @@ fn decode_responses_completed_item( Vec::new() } +fn completed_arguments_suffix( + decoded: &mut String, + snapshot: &Value, +) -> Result, LlmResponseChunk> { + if !snapshot.is_string() + && !decoded.is_empty() + && serde_json::from_str::(decoded) + .ok() + .as_ref() + .is_some_and(|parsed| parsed == snapshot) + { + return Ok(None); + } + + match snapshot { + Value::String(text) => snapshot_suffix(decoded, text), + value => snapshot_suffix(decoded, &value.to_string()), + } +} + // A snapshot may extend streamed content, but cannot retract content already sent. fn snapshot_suffix( decoded: &mut String, @@ -1061,38 +1084,88 @@ fn encode_responses_tool_delta( // Normalizes OpenAI Responses token usage fields. fn responses_usage(usage: &serde_json::Map) -> Usage { - let aggregate_input_tokens = usage.get("input_tokens").and_then(Value::as_u64); - let cached_input_tokens = usage - .get("input_tokens_details") - .and_then(|details| details.get("cached_tokens")) - .and_then(Value::as_u64); - let cache_creation_input_tokens = usage - .get("input_tokens_details") - .and_then(|details| details.get("cache_write_tokens")) - .and_then(Value::as_u64); + let aggregate_input_tokens = super::usage_u64( + usage, + &[ + "input_tokens", + "inputTokens", + "prompt_tokens", + "promptTokens", + ], + ); + let cached_input_tokens = + super::usage_u64(usage, &["cache_read_input_tokens", "cacheReadInputTokens"]).or_else( + || { + super::usage_detail_u64( + usage, + &[ + "input_tokens_details", + "inputTokensDetails", + "prompt_tokens_details", + "promptTokensDetails", + ], + &["cached_tokens", "cachedTokens"], + ) + }, + ); + let cache_creation_input_tokens = super::usage_u64( + usage, + &[ + "cache_creation_input_tokens", + "cacheCreationInputTokens", + "cacheWriteInputTokens", + ], + ) + .or_else(|| { + super::usage_detail_u64( + usage, + &[ + "input_tokens_details", + "inputTokensDetails", + "prompt_tokens_details", + "promptTokensDetails", + ], + &[ + "cache_write_tokens", + "cacheWriteTokens", + "cache_creation_tokens", + "cacheCreationTokens", + ], + ) + }); let input_tokens = aggregate_input_tokens.map(|tokens| { tokens .saturating_sub(cached_input_tokens.unwrap_or(0)) .saturating_sub(cache_creation_input_tokens.unwrap_or(0)) }); - let output_tokens = usage.get("output_tokens").and_then(Value::as_u64); + let output_tokens = super::usage_u64( + usage, + &[ + "output_tokens", + "outputTokens", + "completion_tokens", + "completionTokens", + ], + ); Usage { input_tokens, cache: Usage::cache_details(cached_input_tokens, cache_creation_input_tokens), output_tokens, - total_tokens: usage - .get("total_tokens") - .and_then(Value::as_u64) - .or_else(|| Some(aggregate_input_tokens.unwrap_or(0) + output_tokens.unwrap_or(0))), - reasoning_tokens: usage - .get("output_tokens_details") - .and_then(|details| details.get("reasoning_tokens")) - .or_else(|| { - usage - .get("completion_tokens_details") - .and_then(|details| details.get("reasoning_tokens")) - }) - .and_then(Value::as_u64), + total_tokens: super::usage_u64(usage, &["total_tokens", "totalTokens"]).or_else(|| { + aggregate_input_tokens + .zip(output_tokens) + .map(|(input, output)| input + output) + }), + reasoning_tokens: super::usage_detail_u64( + usage, + &[ + "output_tokens_details", + "outputTokensDetails", + "completion_tokens_details", + "completionTokensDetails", + ], + &["reasoning_tokens", "reasoningTokens", "thinkingTokens"], + ), } } diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index 4280fa3f1..6c4fceae1 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -846,6 +846,239 @@ fn openai_chat_response_with_tool_call_translates_to_responses_output_item() -> Ok(()) } +#[test] +fn responses_buffered_function_call_uses_first_present_argument_source() -> TestResult { + use switchyard_translation::ContentBlock; + + let engine = TranslationEngine::default(); + let cases = [ + ( + json!({"arguments": {"source": "arguments"}, "input": {"source": "input"}}), + json!({"source": "arguments"}), + ), + (json!({"arguments": ["direct", 1]}), json!(["direct", 1])), + ( + json!({"arguments": r#"{"source":"json-string"}"#}), + json!({"source": "json-string"}), + ), + (json!({"arguments": "not-json"}), json!({"raw": "not-json"})), + ( + json!({"arguments": null, "input": {"source": "input"}}), + json!({"source": "input"}), + ), + ( + json!({"arguments": "", "input": null, "payload": ["payload", 2]}), + json!(["payload", 2]), + ), + ]; + + for (fields, expected) in cases { + let mut item = fields + .as_object() + .ok_or("call fields must be an object")? + .clone(); + item.insert("type".into(), json!("function_call")); + item.insert("call_id".into(), json!("call_1")); + item.insert("name".into(), json!("lookup")); + let body = json!({ + "id": "resp_call", + "status": "completed", + "output": [item], + }); + let decoded = engine + .decode_response( + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )? + .response; + let Some(ContentBlock::ToolCall(call)) = decoded.outputs[0].content.first() else { + return Err("expected a decoded tool call".into()); + }; + assert_eq!(call.arguments, expected); + } + Ok(()) +} + +#[test] +fn responses_buffered_uses_output_text_only_without_decoded_output() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy::default(); + let fallback = json!({ + "id": "resp_fallback", + "status": "completed", + "output": [], + "output_text": "fallback text", + }); + let translated = engine + .translate_response( + WireFormat::OpenAiResponses, + WireFormat::AnthropicMessages, + &fallback, + &policy, + )? + .body; + assert_eq!( + translated["content"], + json!([{"type": "text", "text": "fallback text"}]) + ); + assert_eq!(translated["stop_reason"], "end_turn"); + + let mut incomplete = fallback.clone(); + incomplete["status"] = json!("incomplete"); + incomplete["incomplete_details"] = json!({"reason": "max_output_tokens"}); + let translated = engine + .translate_response( + WireFormat::OpenAiResponses, + WireFormat::AnthropicMessages, + &incomplete, + &policy, + )? + .body; + assert_eq!( + translated["content"], + json!([{"type": "text", "text": "fallback text"}]) + ); + assert_eq!(translated["stop_reason"], "max_tokens"); + + let primary = json!({ + "id": "resp_primary", + "status": "completed", + "output_text": "must not be appended", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "primary text"}], + }], + }); + let translated = engine + .translate_response( + WireFormat::OpenAiResponses, + WireFormat::AnthropicMessages, + &primary, + &policy, + )? + .body; + assert_eq!( + translated["content"], + json!([{"type": "text", "text": "primary text"}]) + ); + assert_eq!(translated["stop_reason"], "end_turn"); + Ok(()) +} + +#[test] +fn responses_buffered_decodes_camel_case_usage_aliases() -> TestResult { + let engine = TranslationEngine::default(); + let cases = [ + ( + "Responses camel and direct cache", + json!({ + "inputTokens": 100, + "prompt_tokens": 900, + "outputTokens": 20, + "completion_tokens": 90, + "totalTokens": 120, + "cacheReadInputTokens": 7, + "cacheCreationInputTokens": 3, + "input_tokens_details": {"cached_tokens": 70, "cache_write_tokens": 30}, + "outputTokensDetails": {"reasoningTokens": 4}, + }), + (90, 7, 3, 20, 120, 4), + ), + ( + "Chat camel and nested cache", + json!({ + "promptTokens": 50, + "completionTokens": 8, + "totalTokens": 58, + "promptTokensDetails": { + "cachedTokens": 5, + "cacheCreationTokens": 2, + }, + "completionTokensDetails": {"thinkingTokens": 3}, + }), + (43, 5, 2, 8, 58, 3), + ), + ( + "input camel details and cache-write alias", + json!({ + "inputTokens": 40, + "outputTokens": 5, + "inputTokensDetails": { + "cachedTokens": 4, + "cacheWriteTokens": 1, + }, + "prompt_tokens_details": {"cached_tokens": 14, "cache_write_tokens": 11}, + "outputTokensDetails": {"thinkingTokens": 2}, + }), + (35, 4, 1, 5, 45, 2), + ), + ( + "alternate direct cache-write alias", + json!({ + "inputTokens": 20, + "outputTokens": 1, + "cacheWriteInputTokens": 2, + }), + (18, 0, 2, 1, 21, 0), + ), + ( + "snake Responses and direct cache take precedence", + json!({ + "input_tokens": 100, + "inputTokens": 200, + "prompt_tokens": 300, + "promptTokens": 400, + "output_tokens": 20, + "outputTokens": 30, + "completion_tokens": 40, + "completionTokens": 50, + "total_tokens": 120, + "totalTokens": 999, + "cache_read_input_tokens": 7, + "cacheReadInputTokens": 8, + "inputTokensDetails": {"cachedTokens": 70, "cacheWriteTokens": 30}, + "cache_creation_input_tokens": 3, + "cacheWriteInputTokens": 9, + "output_tokens_details": {"reasoning_tokens": 4}, + "outputTokensDetails": {"reasoningTokens": 40}, + }), + (90, 7, 3, 20, 120, 4), + ), + ]; + + for (label, raw_usage, expected) in cases { + let body = json!({ + "id": "resp_usage", + "status": "completed", + "output": [], + "usage": raw_usage, + }); + let usage = engine + .decode_response( + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )? + .response + .usage; + assert_eq!( + ( + usage.input_tokens.unwrap_or_default(), + usage.cached_input_tokens().unwrap_or_default(), + usage.cache_creation_input_tokens().unwrap_or_default(), + usage.output_tokens.unwrap_or_default(), + usage.total_tokens.unwrap_or_default(), + usage.reasoning_tokens.unwrap_or_default(), + ), + expected, + "{label}", + ); + } + Ok(()) +} + // Verifies mixed assistant text and tool calls both survive into Responses output. #[test] fn openai_chat_response_with_text_and_tool_call_translates_both_to_responses() -> TestResult { diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index e22511c59..5713fdd17 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -1033,6 +1033,45 @@ fn responses_stream_usage_without_total_keeps_cached_tokens_in_total() -> TestRe Ok(()) } +#[test] +fn responses_stream_total_requires_both_counters_without_explicit_total() -> TestResult { + let engine = TranslationEngine::default(); + let cases = [ + ("input only", json!({"input_tokens": 8}), None), + ("output only", json!({"output_tokens": 3}), None), + ( + "explicit total", + json!({"input_tokens": 8, "total_tokens": 17}), + Some(17), + ), + ]; + + for (label, usage, expected_total) in cases { + let event = json!({ + "type": "response.completed", + "response": { + "id": "resp_usage", + "status": "completed", + "output": [], + "usage": usage, + }, + }); + let mut state = StreamTranslationState::default(); + let decoded = engine.decode_stream_event(&mut state, WireFormat::OpenAiResponses, event)?; + let normalized_usage = decoded + .normalized() + .iter() + .find_map(|chunk| match chunk { + LlmResponseChunk::Usage(usage) => Some(usage), + _ => None, + }) + .ok_or("missing terminal usage")?; + + assert_eq!(normalized_usage.total_tokens, expected_total, "{label}"); + } + Ok(()) +} + // Verifies Responses text deltas become OpenAI Chat content chunks. #[test] fn responses_stream_delta_translates_to_openai_chat_chunk() -> TestResult { @@ -2370,6 +2409,225 @@ fn responses_decode_emits_tool_arguments_once() -> TestResult { Ok(()) } +#[test] +fn responses_completed_snapshots_accept_structured_and_fallback_call_arguments() -> TestResult { + let engine = TranslationEngine::default(); + let cases = [ + ( + json!({"arguments": {"city": "Paris"}}), + json!({"city": "Paris"}), + ), + (json!({"arguments": ["Paris", 2]}), json!(["Paris", 2])), + ( + json!({"arguments": null, "input": {"city": "Rome"}}), + json!({"city": "Rome"}), + ), + ( + json!({"arguments": "", "input": null, "payload": ["Oslo"]}), + json!(["Oslo"]), + ), + ]; + + for (fields, expected) in cases { + let mut item = fields + .as_object() + .ok_or("call fields must be an object")? + .clone(); + item.insert("type".into(), json!("function_call")); + item.insert("call_id".into(), json!("call_1")); + item.insert("name".into(), json!("lookup")); + let event = json!({ + "type": "response.completed", + "response": { + "id": "resp_structured", + "status": "completed", + "output": [item], + }, + }); + let mut state = StreamTranslationState::default(); + let decoded = engine.decode_stream_event(&mut state, WireFormat::OpenAiResponses, event)?; + let arguments = decoded + .normalized() + .iter() + .find_map(|chunk| match chunk { + LlmResponseChunk::ToolCallDelta { + arguments_delta: Some(arguments), + .. + } => Some(arguments.as_str()), + _ => None, + }) + .ok_or("missing completed arguments")?; + assert_eq!(serde_json::from_str::(arguments)?, expected); + assert!(decoded.normalized().iter().any(|chunk| matches!( + chunk, + LlmResponseChunk::MessageStop { reason: Some(reason) } if reason == "tool_use" + ))); + } + Ok(()) +} + +#[test] +fn responses_structured_snapshot_matches_full_string_delta_semantically() -> TestResult { + let engine = TranslationEngine::default(); + let full_delta = r#"{"b":2,"a":1}"#; + let item = json!({ + "type": "function_call", + "call_id": "call_1", + "name": "lookup", + "arguments": {"a": 1, "b": 2}, + }); + let events = [ + json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "delta": full_delta, + }), + json!({"type": "response.output_item.done", "output_index": 0, "item": item}), + json!({ + "type": "response.completed", + "response": {"status": "completed", "output": [item]}, + }), + ]; + let mut state = StreamTranslationState::default(); + let mut seen = String::new(); + for event in events { + let decoded = engine.decode_stream_event(&mut state, WireFormat::OpenAiResponses, event)?; + for chunk in decoded.normalized() { + if let LlmResponseChunk::ToolCallDelta { + arguments_delta: Some(delta), + .. + } = chunk + { + seen.push_str(delta); + } + assert!(!matches!(chunk, LlmResponseChunk::StreamError { .. })); + } + } + assert_eq!(seen, full_delta); + assert_eq!( + serde_json::from_str::(&seen)?, + json!({"a": 1, "b": 2}) + ); + Ok(()) +} + +#[test] +fn responses_stream_decodes_camel_case_usage_aliases() -> TestResult { + let engine = TranslationEngine::default(); + let cases = [ + ( + "Responses camel and direct cache", + json!({ + "inputTokens": 100, + "prompt_tokens": 900, + "outputTokens": 20, + "completion_tokens": 90, + "totalTokens": 120, + "cacheReadInputTokens": 7, + "cacheCreationInputTokens": 3, + "input_tokens_details": {"cached_tokens": 70, "cache_write_tokens": 30}, + "outputTokensDetails": {"reasoningTokens": 4}, + }), + (90, 7, 3, 20, 120, 4), + ), + ( + "Chat camel and nested cache", + json!({ + "promptTokens": 50, + "completionTokens": 8, + "totalTokens": 58, + "promptTokensDetails": { + "cachedTokens": 5, + "cacheCreationTokens": 2, + }, + "completionTokensDetails": {"thinkingTokens": 3}, + }), + (43, 5, 2, 8, 58, 3), + ), + ( + "input camel details and cache-write alias", + json!({ + "inputTokens": 40, + "outputTokens": 5, + "inputTokensDetails": { + "cachedTokens": 4, + "cacheWriteTokens": 1, + }, + "prompt_tokens_details": {"cached_tokens": 14, "cache_write_tokens": 11}, + "outputTokensDetails": {"thinkingTokens": 2}, + }), + (35, 4, 1, 5, 45, 2), + ), + ( + "alternate direct cache-write alias", + json!({ + "inputTokens": 20, + "outputTokens": 1, + "cacheWriteInputTokens": 2, + }), + (18, 0, 2, 1, 21, 0), + ), + ( + "snake Responses and direct cache take precedence", + json!({ + "input_tokens": 100, + "inputTokens": 200, + "prompt_tokens": 300, + "promptTokens": 400, + "output_tokens": 20, + "outputTokens": 30, + "completion_tokens": 40, + "completionTokens": 50, + "total_tokens": 120, + "totalTokens": 999, + "cache_read_input_tokens": 7, + "cacheReadInputTokens": 8, + "inputTokensDetails": {"cachedTokens": 70, "cacheWriteTokens": 30}, + "cache_creation_input_tokens": 3, + "cacheWriteInputTokens": 9, + "output_tokens_details": {"reasoning_tokens": 4}, + "outputTokensDetails": {"reasoningTokens": 40}, + }), + (90, 7, 3, 20, 120, 4), + ), + ]; + + for (label, raw_usage, expected) in cases { + let event = json!({ + "type": "response.completed", + "response": { + "id": "resp_usage", + "status": "completed", + "output": [], + "usage": raw_usage, + }, + }); + let mut state = StreamTranslationState::default(); + let decoded = engine.decode_stream_event(&mut state, WireFormat::OpenAiResponses, event)?; + let usage = decoded + .normalized() + .iter() + .find_map(|chunk| match chunk { + LlmResponseChunk::Usage(usage) => Some(usage), + _ => None, + }) + .ok_or("missing terminal usage")?; + assert_eq!( + ( + usage.input_tokens.unwrap_or_default(), + usage.cached_input_tokens().unwrap_or_default(), + usage.cache_creation_input_tokens().unwrap_or_default(), + usage.output_tokens.unwrap_or_default(), + usage.total_tokens.unwrap_or_default(), + usage.reasoning_tokens.unwrap_or_default(), + ), + expected, + "{label}", + ); + } + Ok(()) +} + // A Responses `reasoning` output item may carry only `encrypted_content`, with no // plaintext. The stream decoder must surface it as a `reasoning.encrypted` detail so a // caller that buffers the stream (the escalation router) still holds something the