use aither_core::llm::{Event as LLMEvent, ReasoningState, Usage as TokenUsage};
use crate::PROVIDER_NAME;
use serde::Deserialize;
use serde_json::Value;
use zenwave::sse::Event;
use crate::error::ClaudeError;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct MessageStartEvent {
pub message: MessageObject,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct MessageObject {
pub id: String,
pub model: String,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub usage: Option<Usage>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct Usage {
pub input_tokens: u32,
pub output_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: Option<u32>,
#[serde(default)]
pub cache_read_input_tokens: Option<u32>,
#[serde(default)]
pub cache_creation: Option<CacheCreation>,
}
impl Usage {
fn cache_write_tokens(&self) -> Option<u32> {
self.cache_creation_input_tokens
.or_else(|| self.cache_creation.as_ref().and_then(CacheCreation::total))
}
}
#[derive(Debug, Deserialize)]
pub struct ContentBlockStartEvent {
pub index: usize,
pub content_block: ContentBlockType,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
#[allow(dead_code)]
pub enum ContentBlockType {
#[serde(rename = "text")]
Text {
text: String,
},
#[serde(rename = "thinking")]
Thinking {
thinking: String,
#[serde(default)]
signature: String,
},
#[serde(rename = "redacted_thinking")]
RedactedThinking {
data: String,
},
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
#[serde(default)]
input: Value,
},
#[serde(rename = "server_tool_use")]
ServerToolUse {
id: String,
name: String,
#[serde(default)]
input: Value,
},
#[serde(rename = "web_search_tool_result")]
WebSearchToolResult {
tool_use_id: String,
#[serde(default)]
content: Value,
},
#[serde(rename = "web_fetch_tool_result")]
WebFetchToolResult {
tool_use_id: String,
#[serde(default)]
content: Value,
},
#[serde(rename = "code_execution_tool_result")]
CodeExecutionToolResult {
tool_use_id: String,
#[serde(default)]
content: Value,
},
#[serde(rename = "bash_code_execution_tool_result")]
BashCodeExecutionToolResult {
tool_use_id: String,
#[serde(default)]
content: Value,
},
#[serde(rename = "text_editor_code_execution_tool_result")]
TextEditorCodeExecutionToolResult {
tool_use_id: String,
#[serde(default)]
content: Value,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize)]
pub struct ContentBlockDeltaEvent {
pub index: usize,
pub delta: DeltaType,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
#[allow(clippy::enum_variant_names)]
pub enum DeltaType {
#[serde(rename = "text_delta")]
TextDelta {
text: String,
},
#[serde(rename = "thinking_delta")]
ThinkingDelta {
thinking: String,
},
#[serde(rename = "signature_delta")]
SignatureDelta {
signature: String,
},
#[serde(rename = "input_json_delta")]
InputJsonDelta {
partial_json: String,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct MessageDeltaEvent {
pub delta: MessageDelta,
#[serde(default)]
pub usage: Option<DeltaUsage>,
}
#[derive(Debug, Deserialize)]
pub struct MessageDelta {
pub stop_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct DeltaUsage {
#[serde(default)]
pub output_tokens: Option<u32>,
#[serde(default)]
pub cache_creation_input_tokens: Option<u32>,
#[serde(default)]
pub cache_read_input_tokens: Option<u32>,
#[serde(default)]
pub cache_creation: Option<CacheCreation>,
}
impl DeltaUsage {
fn cache_write_tokens(&self) -> Option<u32> {
self.cache_creation_input_tokens
.or_else(|| self.cache_creation.as_ref().and_then(CacheCreation::total))
}
}
#[derive(Debug, Deserialize)]
pub struct CacheCreation {
#[serde(default)]
pub ephemeral_5m_input_tokens: Option<u32>,
#[serde(default)]
pub ephemeral_1h_input_tokens: Option<u32>,
}
impl CacheCreation {
fn total(&self) -> Option<u32> {
match (
self.ephemeral_5m_input_tokens,
self.ephemeral_1h_input_tokens,
) {
(None, None) => None,
(a, b) => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
}
}
}
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub input: Value,
}
#[derive(Debug, Deserialize)]
pub struct MessagesResponse {
pub content: Vec<ContentBlockType>,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub usage: Option<Usage>,
}
#[must_use]
pub fn response_events(response: MessagesResponse) -> Vec<LLMEvent> {
let mut state = StreamState::new();
let mut events = Vec::new();
for (index, block) in response.content.into_iter().enumerate() {
events.extend(state.replay_block(index, block));
}
if let Some(usage) = response.usage {
state.uncached_input_tokens = Some(usage.input_tokens);
state.completion_tokens = Some(usage.output_tokens);
state.cache_write_tokens = usage.cache_write_tokens();
state.cache_read_tokens = usage.cache_read_input_tokens;
state.refresh_prompt_tokens();
}
state.stop_reason = response.stop_reason;
events.extend(state.maybe_usage_event());
for call in state.tool_calls {
events.push(LLMEvent::tool_call(call.id, call.name, call.input));
}
events
}
#[derive(Debug, Default)]
pub struct StreamState {
pub blocks: Vec<BlockState>,
pub tool_calls: Vec<ToolCall>,
pub stop_reason: Option<String>,
pub prompt_tokens: Option<u32>,
pub uncached_input_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub cache_read_tokens: Option<u32>,
pub cache_write_tokens: Option<u32>,
pub usage_emitted: bool,
}
#[derive(Debug, Clone)]
pub enum BlockState {
Text(String),
Thinking {
text: String,
signature: String,
},
RedactedThinking(String),
ToolUse {
id: String,
name: String,
input_json: String,
},
ServerToolUse {
id: String,
name: String,
input_json: String,
},
}
impl StreamState {
pub fn new() -> Self {
Self::default()
}
fn maybe_usage_event(&mut self) -> Option<LLMEvent> {
if self.usage_emitted {
return None;
}
if self.prompt_tokens.is_none()
&& self.completion_tokens.is_none()
&& self.cache_read_tokens.is_none()
&& self.cache_write_tokens.is_none()
&& self.stop_reason.is_none()
{
return None;
}
self.usage_emitted = true;
let total_tokens = match (self.prompt_tokens, self.completion_tokens) {
(Some(prompt), Some(completion)) => Some(prompt + completion),
_ => None,
};
Some(LLMEvent::Usage(TokenUsage {
prompt_tokens: self.prompt_tokens,
completion_tokens: self.completion_tokens,
total_tokens,
reasoning_tokens: None,
cache_read_tokens: self.cache_read_tokens,
cache_write_tokens: self.cache_write_tokens,
cost_usd: None,
stop_reason: self.stop_reason.clone(),
}))
}
fn refresh_prompt_tokens(&mut self) {
let parts = [
self.uncached_input_tokens,
self.cache_read_tokens,
self.cache_write_tokens,
];
self.prompt_tokens = parts.iter().any(Option::is_some).then(|| {
parts
.iter()
.flatten()
.fold(0u32, |total, part| total.saturating_add(*part))
});
}
}
pub fn parse_event(event: &Event, state: &mut StreamState) -> Result<Vec<LLMEvent>, ClaudeError> {
let data = event.text_data();
match event.event().unwrap_or("") {
"message_start" => {
state.apply_message_start(serde_json::from_str(data)?);
Ok(Vec::new())
}
"content_block_start" => Ok(state.begin_block(serde_json::from_str(data)?)),
"content_block_delta" => Ok(state.apply_block_delta(serde_json::from_str(data)?)),
"content_block_stop" => {
#[derive(Deserialize)]
struct StopEvent {
index: usize,
}
let ev: StopEvent = serde_json::from_str(data)?;
Ok(state.finish_block(ev.index))
}
"message_delta" => {
state.apply_message_delta(serde_json::from_str(data)?);
Ok(Vec::new())
}
"message_stop" => Ok(state.maybe_usage_event().into_iter().collect()),
"ping" | "" => Ok(Vec::new()),
"error" => Err(parse_error_event(data)),
unknown => {
tracing::debug!("Unknown Claude SSE event type: {unknown}");
Ok(Vec::new())
}
}
}
fn parse_error_event(data: &str) -> ClaudeError {
#[derive(Deserialize)]
struct ErrorEvent {
error: ErrorDetail,
}
#[derive(Deserialize)]
struct ErrorDetail {
#[serde(rename = "type")]
error_type: String,
message: String,
}
serde_json::from_str::<ErrorEvent>(data).map_or_else(
|_| ClaudeError::Api(data.to_string()),
|ev| ClaudeError::StreamEvent {
error_type: ev.error.error_type,
message: ev.error.message,
},
)
}
impl StreamState {
fn apply_message_start(&mut self, ev: MessageStartEvent) {
let Some(usage) = ev.message.usage else {
return;
};
self.uncached_input_tokens = Some(usage.input_tokens);
self.completion_tokens = Some(usage.output_tokens);
self.cache_write_tokens = usage.cache_write_tokens();
self.cache_read_tokens = usage.cache_read_input_tokens;
self.refresh_prompt_tokens();
}
fn begin_block(&mut self, ev: ContentBlockStartEvent) -> Vec<LLMEvent> {
ensure_block_capacity(self, ev.index);
if let Some((tool, tool_use_id, content)) = ev.content_block.server_tool_result() {
return vec![LLMEvent::builtin_result(
tool_use_id,
tool,
server_tool_result_text(tool, content),
)];
}
match ev.content_block {
ContentBlockType::Text { text } => {
self.blocks[ev.index] = BlockState::Text(text.clone());
if text.is_empty() {
Vec::new()
} else {
vec![LLMEvent::Text(text)]
}
}
ContentBlockType::Thinking {
thinking,
signature,
} => {
self.blocks[ev.index] = BlockState::Thinking {
text: thinking.clone(),
signature,
};
if thinking.is_empty() {
Vec::new()
} else {
vec![LLMEvent::Reasoning(thinking)]
}
}
ContentBlockType::RedactedThinking { data } => {
self.blocks[ev.index] = BlockState::RedactedThinking(data);
Vec::new()
}
ContentBlockType::ToolUse { id, name, .. } => {
let started = LLMEvent::ToolCallDelta {
id: id.clone(),
name: name.clone(),
arguments_fragment: String::new(),
};
self.blocks[ev.index] = BlockState::ToolUse {
id,
name,
input_json: String::new(),
};
vec![started]
}
ContentBlockType::ServerToolUse { id, name, .. } => {
self.blocks[ev.index] = BlockState::ServerToolUse {
id,
name,
input_json: String::new(),
};
Vec::new()
}
_ => Vec::new(),
}
}
fn apply_block_delta(&mut self, ev: ContentBlockDeltaEvent) -> Vec<LLMEvent> {
let Some(block) = self.blocks.get_mut(ev.index) else {
return Vec::new();
};
match (block, ev.delta) {
(BlockState::Text(text), DeltaType::TextDelta { text: delta }) => {
text.push_str(&delta);
vec![LLMEvent::Text(delta)]
}
(BlockState::Thinking { text, .. }, DeltaType::ThinkingDelta { thinking: delta }) => {
text.push_str(&delta);
vec![LLMEvent::Reasoning(delta)]
}
(
BlockState::Thinking { signature, .. },
DeltaType::SignatureDelta {
signature: delta, ..
},
) => {
signature.push_str(&delta);
Vec::new()
}
(
BlockState::ToolUse {
id,
name,
input_json,
},
DeltaType::InputJsonDelta { partial_json },
) => {
input_json.push_str(&partial_json);
vec![LLMEvent::ToolCallDelta {
id: id.clone(),
name: name.clone(),
arguments_fragment: partial_json,
}]
}
(
BlockState::ServerToolUse { input_json, .. },
DeltaType::InputJsonDelta { partial_json },
) => {
input_json.push_str(&partial_json);
Vec::new()
}
_ => Vec::new(),
}
}
fn finish_block(&mut self, index: usize) -> Vec<LLMEvent> {
match self.blocks.get(index) {
Some(BlockState::ToolUse {
id,
name,
input_json,
}) => {
let input = serde_json::from_str(input_json)
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
self.tool_calls.push(ToolCall {
id: id.clone(),
name: name.clone(),
input,
});
Vec::new()
}
Some(BlockState::Thinking { text, signature }) => {
if signature.is_empty() {
return Vec::new();
}
let block = serde_json::json!({
"type": "thinking",
"thinking": text,
"signature": signature,
});
vec![LLMEvent::ReasoningState(ReasoningState::new(
PROVIDER_NAME,
block.to_string(),
))]
}
Some(BlockState::RedactedThinking(data)) => {
let block = serde_json::json!({
"type": "redacted_thinking",
"data": data,
});
vec![LLMEvent::ReasoningState(ReasoningState::new(
PROVIDER_NAME,
block.to_string(),
))]
}
Some(BlockState::ServerToolUse {
id,
name,
input_json,
}) => {
let input = serde_json::from_str::<Value>(input_json)
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
vec![LLMEvent::builtin_call(
id,
name,
server_tool_call_detail(&input),
)]
}
Some(BlockState::Text(_)) | None => Vec::new(),
}
}
fn apply_message_delta(&mut self, ev: MessageDeltaEvent) {
self.stop_reason = ev.delta.stop_reason;
let Some(usage) = ev.usage else {
return;
};
if let Some(output_tokens) = usage.output_tokens {
self.completion_tokens = Some(output_tokens);
}
if let Some(cache_write_tokens) = usage.cache_write_tokens() {
self.cache_write_tokens = Some(cache_write_tokens);
}
if let Some(cache_read_tokens) = usage.cache_read_input_tokens {
self.cache_read_tokens = Some(cache_read_tokens);
}
self.refresh_prompt_tokens();
}
fn replay_block(&mut self, index: usize, block: ContentBlockType) -> Vec<LLMEvent> {
let input_json = match &block {
ContentBlockType::ToolUse { input, .. }
| ContentBlockType::ServerToolUse { input, .. }
if !input.is_null() =>
{
Some(input.to_string())
}
_ => None,
};
let mut events = self.begin_block(ContentBlockStartEvent {
index,
content_block: block,
});
if let Some(partial_json) = input_json {
events.extend(self.apply_block_delta(ContentBlockDeltaEvent {
index,
delta: DeltaType::InputJsonDelta { partial_json },
}));
}
events.extend(self.finish_block(index));
events
}
}
fn ensure_block_capacity(state: &mut StreamState, index: usize) {
while state.blocks.len() <= index {
state.blocks.push(BlockState::Text(String::new()));
}
}
pub fn should_skip_event(event: &Event) -> bool {
let data = event.text_data();
data.is_empty() || event.event() == Some("ping")
}
impl ContentBlockType {
fn server_tool_result(&self) -> Option<(&'static str, &str, &Value)> {
Some(match self {
Self::WebSearchToolResult {
tool_use_id,
content,
} => ("web_search", tool_use_id, content),
Self::WebFetchToolResult {
tool_use_id,
content,
} => ("web_fetch", tool_use_id, content),
Self::CodeExecutionToolResult {
tool_use_id,
content,
} => ("code_execution", tool_use_id, content),
Self::BashCodeExecutionToolResult {
tool_use_id,
content,
} => ("bash_code_execution", tool_use_id, content),
Self::TextEditorCodeExecutionToolResult {
tool_use_id,
content,
} => ("text_editor_code_execution", tool_use_id, content),
_ => return None,
})
}
}
fn server_tool_call_detail(input: &Value) -> String {
for key in ["query", "url", "command", "code"] {
if let Some(value) = input.get(key).and_then(Value::as_str) {
return value.to_string();
}
}
match input {
Value::Object(map) if map.is_empty() => String::new(),
other => other.to_string(),
}
}
fn server_tool_result_text(tool: &str, content: &Value) -> String {
if let Some(code) = content.get("error_code").and_then(Value::as_str) {
return format!("error: {code}");
}
if tool == "web_search" {
if let Some(hits) = content.get("content").and_then(Value::as_array) {
return format!("{} results", hits.len());
}
if content.is_array() {
return format!("{} results", content.as_array().map_or(0, Vec::len));
}
}
if tool == "web_fetch"
&& let Some(url) = content.get("url").and_then(Value::as_str)
{
return format!("fetched {url}");
}
if let Some(text) = ["stdout", "stderr"]
.iter()
.filter_map(|key| content.get(*key).and_then(Value::as_str))
.find(|text| !text.is_empty())
{
return text.chars().take(200).collect();
}
"completed".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn block_start(data: &str) -> ContentBlockStartEvent {
serde_json::from_str(data).expect("content_block_start parses")
}
#[test]
fn server_tool_use_emits_builtin_call_at_block_close() {
let mut state = StreamState::new();
let events = state.begin_block(block_start(
r#"{"index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{}}}"#,
));
assert!(events.is_empty());
let events = state.apply_block_delta(
serde_json::from_str(
r#"{"index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"rust\"}"}}"#,
)
.expect("delta parses"),
);
assert!(events.is_empty());
match state.finish_block(0).as_slice() {
[LLMEvent::BuiltInToolCall { id, tool, detail }] => {
assert_eq!(id, "srvtoolu_1");
assert_eq!(tool, "web_search");
assert_eq!(detail, "rust");
}
other => panic!("expected BuiltInToolCall, got {other:?}"),
}
assert!(state.tool_calls.is_empty());
}
#[test]
fn web_search_tool_result_emits_builtin_result() {
let mut state = StreamState::new();
match state
.begin_block(block_start(
r#"{"index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":{"type":"web_search_result_block","content":[{"type":"web_search_result","title":"t","url":"u"}]}}}"#,
))
.as_slice()
{
[LLMEvent::BuiltInToolResult { id, tool, result }] => {
assert_eq!(id, "srvtoolu_1");
assert_eq!(tool, "web_search");
assert_eq!(result, "1 results");
}
other => panic!("expected BuiltInToolResult, got {other:?}"),
}
}
#[test]
fn unknown_block_types_are_tolerated() {
let mut state = StreamState::new();
let events = state.begin_block(block_start(
r#"{"index":0,"content_block":{"type":"some_future_block","id":"x"}}"#,
));
assert!(events.is_empty());
}
#[test]
fn usage_event_includes_claude_cache_token_stats() {
let mut state = StreamState::new();
state.prompt_tokens = Some(120);
state.completion_tokens = Some(30);
state.cache_read_tokens = Some(90);
state.cache_write_tokens = Some(45);
state.stop_reason = Some("end_turn".to_string());
let event = state
.maybe_usage_event()
.expect("usage event should be emitted");
match event {
LLMEvent::Usage(usage) => {
assert_eq!(usage.prompt_tokens, Some(120));
assert_eq!(usage.completion_tokens, Some(30));
assert_eq!(usage.total_tokens, Some(150));
assert_eq!(usage.cache_read_tokens, Some(90));
assert_eq!(usage.cache_write_tokens, Some(45));
assert_eq!(usage.stop_reason.as_deref(), Some("end_turn"));
}
_ => panic!("expected usage event"),
}
}
#[test]
fn usage_event_is_not_reemitted() {
let mut state = StreamState::new();
state.prompt_tokens = Some(1);
assert!(state.maybe_usage_event().is_some());
assert!(state.maybe_usage_event().is_none());
}
#[test]
fn cache_creation_total_sums_5m_and_1h_tokens() {
let cache_creation = CacheCreation {
ephemeral_5m_input_tokens: Some(20),
ephemeral_1h_input_tokens: Some(30),
};
assert_eq!(cache_creation.total(), Some(50));
}
#[test]
fn refresh_prompt_tokens_adds_uncached_and_cache_tokens() {
let mut state = StreamState::new();
state.uncached_input_tokens = Some(40);
state.cache_read_tokens = Some(30);
state.cache_write_tokens = Some(10);
state.refresh_prompt_tokens();
assert_eq!(state.prompt_tokens, Some(80));
}
#[test]
fn response_events_matches_streaming_projection() {
let response: MessagesResponse = serde_json::from_str(
r#"{
"content": [
{"type": "text", "text": "Checking the weather."},
{"type": "thinking", "thinking": "Need a forecast.", "signature": "sig_1"},
{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Paris"}},
{"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "Paris weather"}},
{"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{"type": "web_search_result", "title": "t", "url": "u"}]}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 40, "output_tokens": 30, "cache_read_input_tokens": 10, "cache_creation_input_tokens": 5}
}"#,
)
.expect("response parses");
let streamed = [
(
"message_start",
r#"{"message":{"id":"msg_1","model":"claude-test","usage":{"input_tokens":40,"output_tokens":1,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}}"#,
),
(
"content_block_start",
r#"{"index":0,"content_block":{"type":"text","text":""}}"#,
),
(
"content_block_delta",
r#"{"index":0,"delta":{"type":"text_delta","text":"Checking the weather."}}"#,
),
("content_block_stop", r#"{"index":0}"#),
(
"content_block_start",
r#"{"index":1,"content_block":{"type":"thinking","thinking":""}}"#,
),
(
"content_block_delta",
r#"{"index":1,"delta":{"type":"thinking_delta","thinking":"Need a forecast."}}"#,
),
(
"content_block_delta",
r#"{"index":1,"delta":{"type":"signature_delta","signature":"sig_1"}}"#,
),
("content_block_stop", r#"{"index":1}"#),
(
"content_block_start",
r#"{"index":2,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}}"#,
),
(
"content_block_delta",
r#"{"index":2,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"Paris\"}"}}"#,
),
("content_block_stop", r#"{"index":2}"#),
(
"content_block_start",
r#"{"index":3,"content_block":{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{}}}"#,
),
(
"content_block_delta",
r#"{"index":3,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"Paris weather\"}"}}"#,
),
("content_block_stop", r#"{"index":3}"#),
(
"content_block_start",
r#"{"index":4,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":[{"type":"web_search_result","title":"t","url":"u"}]}}"#,
),
("content_block_stop", r#"{"index":4}"#),
(
"message_delta",
r#"{"delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":30}}"#,
),
("message_stop", r#"{"type":"message_stop"}"#),
];
let mut state = StreamState::new();
let mut expected = Vec::new();
for (kind, data) in streamed {
expected.extend(
parse_event(&Event::from_data(data).with_event(kind), &mut state)
.expect("SSE event parses"),
);
}
expected.extend(
std::mem::take(&mut state.tool_calls)
.into_iter()
.map(|call| LLMEvent::tool_call(call.id, call.name, call.input)),
);
assert!(
expected
.iter()
.any(|event| matches!(event, LLMEvent::BuiltInToolCall { .. }))
);
assert!(
expected
.iter()
.any(|event| matches!(event, LLMEvent::BuiltInToolResult { .. }))
);
assert_eq!(
format!("{:?}", response_events(response)),
format!("{expected:?}")
);
}
}