feat(gdk): expose Anthropic response metadata for observability (#11630)

This commit is contained in:
Alex Hancock
2026-08-31 16:24:36 -04:00
committed by GitHub
parent 4550a2e93d
commit 1d0146631c
4 changed files with 125 additions and 1 deletions
@@ -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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
/// 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<Map<String, Value>>,
}
#[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,
}
}
@@ -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<Usage> {
}
}
/// 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<Map<String, Value>> {
let usage = data.get("usage")?.as_object()?;
let additional: Map<String, Value> = 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<String> = None;
let mut thinking: Option<ThinkingState> = None;
let mut stop_reason: Option<String> = None;
let mut additional_data: Option<Map<String, Value>> = 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!(
+45
View File
@@ -501,6 +501,9 @@ pub struct Usage {
pub reasoning_tokens: Option<i32>,
pub model: String,
pub provider_metadata_json: Option<String>,
/// 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<String>,
}
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::<serde_json::Value>(&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 {
+6
View File
@@ -639,6 +639,12 @@
"type": "Option<String>",
"default": null,
"docs": ""
},
{
"name": "additional_data_json",
"type": "Option<String>",
"default": null,
"docs": "Provider-specific response fields as a JSON object, present only when the provider reported fields with no canonical `Usage` equivalent."
}
],
"variants": [],