feat(gdk): expose Anthropic response metadata for observability (#11630)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use std::ops::{Add, AddAssign};
|
use std::ops::{Add, AddAssign};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProviderUsage {
|
pub struct ProviderUsage {
|
||||||
@@ -16,6 +17,10 @@ pub struct ProviderUsage {
|
|||||||
pub finish_reasons: Option<Vec<String>>,
|
pub finish_reasons: Option<Vec<String>>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub response_id: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -54,6 +59,7 @@ impl ProviderUsage {
|
|||||||
cost_source: None,
|
cost_source: None,
|
||||||
finish_reasons: None,
|
finish_reasons: None,
|
||||||
response_id: None,
|
response_id: None,
|
||||||
|
additional_data: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use rmcp::model::{
|
|||||||
ResourceContents, Role, Tool,
|
ResourceContents, Role, Tool,
|
||||||
};
|
};
|
||||||
use rmcp::object as json_object;
|
use rmcp::object as json_object;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::str::FromStr;
|
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(
|
fn provider_usage_with_cost(
|
||||||
model: String,
|
model: String,
|
||||||
usage: Usage,
|
usage: Usage,
|
||||||
@@ -896,6 +908,7 @@ where
|
|||||||
let mut message_id: Option<String> = None;
|
let mut message_id: Option<String> = None;
|
||||||
let mut thinking: Option<ThinkingState> = None;
|
let mut thinking: Option<ThinkingState> = None;
|
||||||
let mut stop_reason: Option<String> = 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 {
|
while let Some(line_result) = stream.next().await {
|
||||||
let line = line_result?;
|
let line = line_result?;
|
||||||
@@ -925,6 +938,7 @@ where
|
|||||||
match event.event_type.as_str() {
|
match event.event_type.as_str() {
|
||||||
EVENT_MESSAGE_START => {
|
EVENT_MESSAGE_START => {
|
||||||
if let Some(message_data) = event.data.get("message") {
|
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()) {
|
if let Some(id) = message_data.get("id").and_then(|v| v.as_str()) {
|
||||||
message_id = Some(id.to_string());
|
message_id = Some(id.to_string());
|
||||||
}
|
}
|
||||||
@@ -1104,6 +1118,7 @@ where
|
|||||||
if let Some(mut usage) = final_usage.take() {
|
if let Some(mut usage) = final_usage.take() {
|
||||||
usage.finish_reasons = Some(vec![STOP_REASON_REFUSAL.to_string()]);
|
usage.finish_reasons = Some(vec![STOP_REASON_REFUSAL.to_string()]);
|
||||||
usage.response_id = message_id.clone();
|
usage.response_id = message_id.clone();
|
||||||
|
usage.additional_data = additional_data.clone();
|
||||||
yield (None, Some(usage));
|
yield (None, Some(usage));
|
||||||
}
|
}
|
||||||
Err(ProviderError::Refusal { details, category })?;
|
Err(ProviderError::Refusal { details, category })?;
|
||||||
@@ -1185,6 +1200,7 @@ where
|
|||||||
if let Some(id) = message_id {
|
if let Some(id) = message_id {
|
||||||
usage.response_id = Some(id);
|
usage.response_id = Some(id);
|
||||||
}
|
}
|
||||||
|
usage.additional_data = additional_data;
|
||||||
yield (None, Some(usage));
|
yield (None, Some(usage));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2445,6 +2461,57 @@ mod tests {
|
|||||||
assert_eq!(usage.response_id.as_deref(), Some("msg_1"));
|
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]
|
#[tokio::test]
|
||||||
async fn test_streaming_preserves_provider_cost_from_delta() {
|
async fn test_streaming_preserves_provider_cost_from_delta() {
|
||||||
let events = concat!(
|
let events = concat!(
|
||||||
|
|||||||
@@ -501,6 +501,9 @@ pub struct Usage {
|
|||||||
pub reasoning_tokens: Option<i32>,
|
pub reasoning_tokens: Option<i32>,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub provider_metadata_json: Option<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 {
|
impl Usage {
|
||||||
@@ -514,6 +517,11 @@ impl Usage {
|
|||||||
reasoning_tokens: None,
|
reasoning_tokens: None,
|
||||||
model: usage.model.clone(),
|
model: usage.model.clone(),
|
||||||
provider_metadata_json: Some(serde_json::to_string(usage)?),
|
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");
|
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]
|
#[test]
|
||||||
fn tool_result_content_converts() {
|
fn tool_result_content_converts() {
|
||||||
let content = MessageContent::ToolResult {
|
let content = MessageContent::ToolResult {
|
||||||
|
|||||||
@@ -639,6 +639,12 @@
|
|||||||
"type": "Option<String>",
|
"type": "Option<String>",
|
||||||
"default": null,
|
"default": null,
|
||||||
"docs": ""
|
"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": [],
|
"variants": [],
|
||||||
|
|||||||
Reference in New Issue
Block a user