From 1d0146631c2fef1840fa25bb45cd36e4e409dc8b Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 31 Aug 2026 16:24:36 -0400 Subject: [PATCH] feat(gdk): expose Anthropic response metadata for observability (#11630) --- .../src/conversation/token_usage.rs | 6 ++ .../src/formats/anthropic.rs | 69 ++++++++++++++++++- crates/goose-sdk/src/bindings.rs | 45 ++++++++++++ documentation/src/data/gdk-api.json | 6 ++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/crates/goose-provider-types/src/conversation/token_usage.rs b/crates/goose-provider-types/src/conversation/token_usage.rs index ff680e69c..2b7f3abb1 100644 --- a/crates/goose-provider-types/src/conversation/token_usage.rs +++ b/crates/goose-provider-types/src/conversation/token_usage.rs @@ -1,6 +1,7 @@ use std::ops::{Add, AddAssign}; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderUsage { @@ -16,6 +17,10 @@ pub struct ProviderUsage { pub finish_reasons: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub response_id: Option, + /// Provider-specific response fields that have no canonical equivalent, kept + /// unstructured so new fields flow through without changing this type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_data: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -54,6 +59,7 @@ impl ProviderUsage { cost_source: None, finish_reasons: None, response_id: None, + additional_data: None, } } diff --git a/crates/goose-provider-types/src/formats/anthropic.rs b/crates/goose-provider-types/src/formats/anthropic.rs index dc030fd99..de34e39f1 100644 --- a/crates/goose-provider-types/src/formats/anthropic.rs +++ b/crates/goose-provider-types/src/formats/anthropic.rs @@ -13,7 +13,7 @@ use rmcp::model::{ ResourceContents, Role, Tool, }; use rmcp::object as json_object; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use std::collections::HashSet; use std::fmt; use std::str::FromStr; @@ -663,6 +663,18 @@ pub fn get_usage(data: &Value) -> Result { } } +/// Anthropic response fields that have no canonical `ProviderUsage` equivalent. +const ADDITIONAL_USAGE_FIELDS: [&str; 1] = ["service_tier"]; + +pub fn get_additional_data(data: &Value) -> Option> { + let usage = data.get("usage")?.as_object()?; + let additional: Map = ADDITIONAL_USAGE_FIELDS + .iter() + .filter_map(|field| Some(((*field).to_string(), usage.get(*field)?.clone()))) + .collect(); + (!additional.is_empty()).then_some(additional) +} + fn provider_usage_with_cost( model: String, usage: Usage, @@ -896,6 +908,7 @@ where let mut message_id: Option = None; let mut thinking: Option = None; let mut stop_reason: Option = None; + let mut additional_data: Option> = None; while let Some(line_result) = stream.next().await { let line = line_result?; @@ -925,6 +938,7 @@ where match event.event_type.as_str() { EVENT_MESSAGE_START => { if let Some(message_data) = event.data.get("message") { + additional_data = get_additional_data(message_data); if let Some(id) = message_data.get("id").and_then(|v| v.as_str()) { message_id = Some(id.to_string()); } @@ -1104,6 +1118,7 @@ where if let Some(mut usage) = final_usage.take() { usage.finish_reasons = Some(vec![STOP_REASON_REFUSAL.to_string()]); usage.response_id = message_id.clone(); + usage.additional_data = additional_data.clone(); yield (None, Some(usage)); } Err(ProviderError::Refusal { details, category })?; @@ -1185,6 +1200,7 @@ where if let Some(id) = message_id { usage.response_id = Some(id); } + usage.additional_data = additional_data; yield (None, Some(usage)); } } @@ -2445,6 +2461,57 @@ mod tests { assert_eq!(usage.response_id.as_deref(), Some("msg_1")); } + async fn streamed_usage(events: &str) -> ProviderUsage { + collect_stream_results(events) + .await + .into_iter() + .filter_map(|r| r.ok().and_then(|(_, usage)| usage)) + .next_back() + .expect("stream should yield usage") + } + + #[tokio::test] + async fn test_streaming_surfaces_additional_usage_data() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-5","usage":{"input_tokens":7,"output_tokens":0,"service_tier":"fast"}}}"#, + "\n", + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#, + "\n", + r#"data: {"type":"content_block_stop","index":0}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":25}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let additional = streamed_usage(events) + .await + .additional_data + .expect("additional data should be reported"); + assert_eq!(additional["service_tier"], json!("fast")); + } + + #[tokio::test] + async fn test_streaming_omits_additional_usage_data_when_absent() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-5","usage":{"input_tokens":7,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#, + "\n", + r#"data: {"type":"content_block_stop","index":0}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":25}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + assert!(streamed_usage(events).await.additional_data.is_none()); + } + #[tokio::test] async fn test_streaming_preserves_provider_cost_from_delta() { let events = concat!( diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs index 87361d57f..6ce9ab478 100644 --- a/crates/goose-sdk/src/bindings.rs +++ b/crates/goose-sdk/src/bindings.rs @@ -501,6 +501,9 @@ pub struct Usage { pub reasoning_tokens: Option, pub model: String, pub provider_metadata_json: Option, + /// Provider-specific response fields as a JSON object, present only when the + /// provider reported fields with no canonical `Usage` equivalent. + pub additional_data_json: Option, } impl Usage { @@ -514,6 +517,11 @@ impl Usage { reasoning_tokens: None, model: usage.model.clone(), provider_metadata_json: Some(serde_json::to_string(usage)?), + additional_data_json: usage + .additional_data + .as_ref() + .map(serde_json::to_string) + .transpose()?, }) } } @@ -1362,6 +1370,43 @@ mod tests { assert_eq!(tool.input_schema["type"], "object"); } + #[test] + fn usage_exposes_provider_additional_data() { + let mut provider_usage = ProviderUsage::new( + "claude-sonnet-4-5".to_string(), + goose_providers::conversation::token_usage::Usage::new(Some(10), Some(5), None), + ); + provider_usage.additional_data = Some( + serde_json::json!({ "service_tier": "fast" }) + .as_object() + .unwrap() + .clone(), + ); + + let additional_data_json = Usage::from_provider_usage(&provider_usage) + .unwrap() + .additional_data_json + .expect("additional data should be exposed"); + + assert_eq!( + serde_json::from_str::(&additional_data_json).unwrap(), + serde_json::json!({ "service_tier": "fast" }) + ); + } + + #[test] + fn usage_omits_provider_additional_data_when_absent() { + let provider_usage = ProviderUsage::new( + "claude-sonnet-4-5".to_string(), + goose_providers::conversation::token_usage::Usage::new(Some(10), Some(5), None), + ); + + assert!(Usage::from_provider_usage(&provider_usage) + .unwrap() + .additional_data_json + .is_none()); + } + #[test] fn tool_result_content_converts() { let content = MessageContent::ToolResult { diff --git a/documentation/src/data/gdk-api.json b/documentation/src/data/gdk-api.json index fa4e81f3c..adc65ddeb 100644 --- a/documentation/src/data/gdk-api.json +++ b/documentation/src/data/gdk-api.json @@ -639,6 +639,12 @@ "type": "Option", "default": null, "docs": "" + }, + { + "name": "additional_data_json", + "type": "Option", + "default": null, + "docs": "Provider-specific response fields as a JSON object, present only when the provider reported fields with no canonical `Usage` equivalent." } ], "variants": [],