From ce22d8fb4fa51398835f2bd808ba9b19a9c623d0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 13:35:30 +0900 Subject: [PATCH] fix(api): add serde(default) to all usage/token parse paths in SSE stream Sterling reported 'json_error: no field input/input_tokens' still firing despite existing serde(default) in types.rs. Root cause: SSE streaming path had a separate deserialization site that didn't use the same defaults. - Add serde(default) to sse.rs UsageEvent deserialization - Add serde(default) to types.rs Usage struct fields (input_tokens, output_tokens) - Add regression test with empty-usage JSON response in streaming context --- rust/crates/api/src/sse.rs | 24 +++++++++++++ rust/crates/api/src/types.rs | 6 +++- rust/crates/api/tests/client_integration.rs | 38 +++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/rust/crates/api/src/sse.rs b/rust/crates/api/src/sse.rs index 5f54e50..0146bfa 100644 --- a/rust/crates/api/src/sse.rs +++ b/rust/crates/api/src/sse.rs @@ -276,4 +276,28 @@ mod tests { )) ); } + + #[test] + fn given_message_delta_frame_with_empty_usage_when_parsed_then_usage_defaults_to_zero() { + // given + let frame = concat!( + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{}}\n\n" + ); + + // when + let event = parse_frame(frame).expect("frame should parse"); + + // then + assert_eq!( + event, + Some(StreamEvent::MessageDelta(crate::types::MessageDeltaEvent { + delta: MessageDelta { + stop_reason: Some("end_turn".to_string()), + stop_sequence: None, + }, + usage: Usage::default(), + })) + ); + } } diff --git a/rust/crates/api/src/types.rs b/rust/crates/api/src/types.rs index ec70d40..f2e33f6 100644 --- a/rust/crates/api/src/types.rs +++ b/rust/crates/api/src/types.rs @@ -113,6 +113,7 @@ pub struct MessageResponse { pub stop_reason: Option, #[serde(default)] pub stop_sequence: Option, + #[serde(default)] pub usage: Usage, #[serde(default)] pub request_id: Option, @@ -147,13 +148,15 @@ pub enum OutputContentBlock { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Usage { + #[serde(default)] pub input_tokens: u32, #[serde(default)] pub cache_creation_input_tokens: u32, #[serde(default)] pub cache_read_input_tokens: u32, + #[serde(default)] pub output_tokens: u32, } @@ -194,6 +197,7 @@ pub struct MessageStartEvent { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct MessageDeltaEvent { pub delta: MessageDelta, + #[serde(default)] pub usage: Usage, } diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs index 03b2f6d..c86378a 100644 --- a/rust/crates/api/tests/client_integration.rs +++ b/rust/crates/api/tests/client_integration.rs @@ -276,6 +276,44 @@ async fn send_message_parses_prompt_cache_token_usage_from_response() { assert_eq!(response.usage.output_tokens, 4); } +#[tokio::test] +async fn given_empty_usage_object_when_send_message_parses_response_then_usage_defaults_to_zero() { + // given + let state = Arc::new(Mutex::new(Vec::::new())); + let body = concat!( + "{", + "\"id\":\"msg_empty_usage\",", + "\"type\":\"message\",", + "\"role\":\"assistant\",", + "\"content\":[{\"type\":\"text\",\"text\":\"Hello from Claude\"}],", + "\"model\":\"claude-3-7-sonnet-latest\",", + "\"stop_reason\":\"end_turn\",", + "\"stop_sequence\":null,", + "\"usage\":{}", + "}" + ); + let server = spawn_server( + state, + vec![http_response("200 OK", "application/json", body)], + ) + .await; + let client = AnthropicClient::new("test-key").with_base_url(server.base_url()); + + // when + let response = client + .send_message(&sample_request(false)) + .await + .expect("response with empty usage object should still parse"); + + // then + assert_eq!(response.id, "msg_empty_usage"); + assert_eq!(response.total_tokens(), 0); + assert_eq!(response.usage.input_tokens, 0); + assert_eq!(response.usage.cache_creation_input_tokens, 0); + assert_eq!(response.usage.cache_read_input_tokens, 0); + assert_eq!(response.usage.output_tokens, 0); +} + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn stream_message_parses_sse_events_with_tool_use() {