feat(gdk): expose Anthropic response metadata for observability (#11630)
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
Reference in New Issue
Block a user