use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::completion::{
AnthropicCompatibleProvider, AnthropicCompletionRequest, Content, GenericCompletionModel,
Usage, anthropic_usage_totals, map_finish_reason,
};
use crate::completion::{CompletionError, CompletionRequest};
use crate::http_client::sse::GenericEventSource;
use crate::http_client::{self, HttpClientExt};
use crate::message::ReasoningContent;
use crate::providers::internal::adapter::{AdapterOutput, WireAdapter, WireFrame};
use crate::providers::internal::sse_transport::{
OpenLog, SseTransportOptions, open_wire_stream, skip_blank_frames,
};
use crate::providers::internal::wire::{self, WireEvent};
use crate::streaming::{
self, MintKind, RawStreamingChoice, RawStreamingResult, StreamFinal, StreamPartId,
ToolCallDeltaContent, ToolInputEnd, UnparseableToolInput,
};
use crate::telemetry::{CompletionOperation, SpanCombinator};
use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
use std::collections::HashMap;
fn streaming_body(request: &AnthropicCompletionRequest) -> Result<Value, CompletionError> {
let mut body = serde_json::to_value(request)?;
if let Some(map) = body.as_object_mut() {
map.insert("stream".to_string(), Value::Bool(true));
if map.contains_key("tools") {
map.entry("tool_choice")
.or_insert_with(|| json!({ "type": "auto" }));
} else {
map.remove("tool_choice");
}
}
Ok(body)
}
const KNOWN_EVENT_TYPES: &[&str] = &[
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
"ping",
"error",
];
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StreamingEvent {
MessageStart {
#[serde(default)]
message: Option<MessageStart>,
},
ContentBlockStart {
index: usize,
content_block: Content,
},
ContentBlockDelta {
index: usize,
delta: ContentDelta,
},
ContentBlockStop {
index: usize,
},
MessageDelta {
delta: MessageDelta,
usage: PartialUsage,
},
MessageStop,
Ping,
Error {
error: serde_json::Value,
},
}
#[derive(Debug, Deserialize)]
pub struct MessageStart {
pub id: String,
pub role: String,
pub content: Vec<Content>,
pub model: String,
pub stop_reason: Option<String>,
pub stop_sequence: Option<String>,
pub usage: Usage,
}
#[derive(Debug)]
pub enum ContentDelta {
TextDelta {
text: String,
},
InputJsonDelta {
partial_json: String,
},
ThinkingDelta {
thinking: String,
},
SignatureDelta {
signature: String,
},
CitationsDelta {
citation: super::completion::Citation,
},
Unknown(serde_json::Value),
}
impl<'de> Deserialize<'de> for ContentDelta {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
if !value.is_object() {
return Err(serde::de::Error::custom("content delta must be an object"));
}
let str_field = |tag: &str, field: &str| -> Result<String, D::Error> {
value
.get(field)
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"`{tag}` content delta is missing a string `{field}` field"
))
})
};
match value.get("type").cloned() {
Some(serde_json::Value::String(tag)) => match tag.as_str() {
"text_delta" => Ok(Self::TextDelta {
text: str_field("text_delta", "text")?,
}),
"input_json_delta" => Ok(Self::InputJsonDelta {
partial_json: str_field("input_json_delta", "partial_json")?,
}),
"thinking_delta" => Ok(Self::ThinkingDelta {
thinking: str_field("thinking_delta", "thinking")?,
}),
"signature_delta" => Ok(Self::SignatureDelta {
signature: str_field("signature_delta", "signature")?,
}),
"citations_delta" => {
let citation = value.get("citation").cloned().ok_or_else(|| {
serde::de::Error::custom(
"`citations_delta` content delta is missing a `citation` field",
)
})?;
Ok(Self::CitationsDelta {
citation: serde_json::from_value(citation)
.map_err(serde::de::Error::custom)?,
})
}
_ => Ok(Self::Unknown(value)),
},
Some(_) => Err(serde::de::Error::custom(
"content delta `type` must be a string",
)),
None => Err(serde::de::Error::custom(
"content delta is missing a `type` field",
)),
}
}
}
#[derive(Debug, Deserialize)]
pub struct MessageDelta {
pub stop_reason: Option<String>,
pub stop_sequence: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Serialize, Default)]
pub struct PartialUsage {
pub output_tokens: usize,
#[serde(default)]
pub input_tokens: Option<usize>,
#[serde(default)]
pub cache_creation_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation: Option<super::completion::CacheCreation>,
#[serde(default)]
pub cache_read_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens_details: Option<super::completion::OutputTokensDetails>,
}
impl From<&PartialUsage> for crate::completion::Usage {
fn from(value: &PartialUsage) -> crate::completion::Usage {
anthropic_usage_totals(
value.input_tokens.unwrap_or_default() as u64,
value.output_tokens as u64,
value.cache_read_input_tokens,
value.cache_creation_input_tokens,
value.output_tokens_details,
)
}
}
impl From<PartialUsage> for crate::completion::Usage {
fn from(value: PartialUsage) -> crate::completion::Usage {
(&value).into()
}
}
struct ServerToolUseState {
name: String,
id: String,
initial_input: Value,
input_json: String,
}
#[derive(Default)]
struct ThinkingState {
signature: String,
initial_signature: String,
}
impl ThinkingState {
fn into_signature(self) -> Option<String> {
let signature = if self.signature.is_empty() {
self.initial_signature
} else {
self.signature
};
(!signature.is_empty()).then_some(signature)
}
}
#[derive(Default)]
struct AnthropicAdapter {
current_tool_call: Option<String>,
server_tool_uses: HashMap<usize, ServerToolUseState>,
current_thinking: Option<ThinkingState>,
input_tokens: u64,
cache_creation: Option<super::completion::CacheCreation>,
message_id: Option<String>,
response_model: Option<String>,
failed: bool,
}
impl WireAdapter for AnthropicAdapter {
type Frame = WireFrame;
type Event = StreamingEvent;
type Response = StreamingCompletionResponse;
fn classify(&self, frame: WireFrame) -> WireEvent<StreamingEvent> {
wire::classify_tagged_frame(&frame.as_str(), "type", |event_type| {
KNOWN_EVENT_TYPES.contains(&event_type)
})
}
fn interpret(&mut self, event: StreamingEvent, out: &mut AdapterOutput<Self::Response>) {
if self.failed {
return;
}
match &event {
StreamingEvent::MessageStart { message } => {
let Some(message) = message else { return };
self.input_tokens = message.usage.input_tokens;
self.cache_creation = message.usage.cache_creation.clone();
self.message_id = Some(message.id.clone());
self.response_model = Some(message.model.clone());
let span = tracing::Span::current();
span.record("gen_ai.response.id", &message.id);
span.record("gen_ai.response.model", &message.model);
return;
}
StreamingEvent::MessageDelta { delta, usage } => {
let Some(reason) = delta.stop_reason.as_ref() else {
return;
};
let usage = PartialUsage {
output_tokens: usage.output_tokens,
input_tokens: usage
.input_tokens
.filter(|tokens| *tokens > 0)
.or_else(|| usize::try_from(self.input_tokens).ok()),
cache_creation_input_tokens: usage.cache_creation_input_tokens,
cache_creation: usage
.cache_creation
.clone()
.or_else(|| self.cache_creation.clone()),
cache_read_input_tokens: usage.cache_read_input_tokens,
output_tokens_details: usage.output_tokens_details,
};
let span = tracing::Span::current();
span.record_token_usage(&crate::completion::Usage::from(&usage));
out.push(Ok(RawStreamingChoice::FinalResponse(
StreamingCompletionResponse {
usage,
stop_reason: Some(reason.clone()),
stop_sequence: delta.stop_sequence.clone(),
message_id: self.message_id.clone(),
model: self.response_model.clone(),
provider_request_id: None,
},
)));
return;
}
StreamingEvent::Error { error } => {
self.failed = true;
let body = serde_json::json!({ "type": "error", "error": error }).to_string();
out.push(Err(crate::provider_response::completion_error_from_body(
body,
)));
return;
}
_ => {}
}
if let Some(result) = handle_event(
&event,
&mut self.current_tool_call,
&mut self.server_tool_uses,
&mut self.current_thinking,
) {
out.push(result);
}
}
fn finish(&mut self, _out: &mut AdapterOutput<Self::Response>) {
}
fn is_finished(&self) -> bool {
self.failed
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct StreamingCompletionResponse {
pub usage: PartialUsage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_sequence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_request_id: Option<String>,
}
impl From<(&str, StreamingCompletionResponse)> for StreamFinal {
fn from((provider, response): (&str, StreamingCompletionResponse)) -> Self {
StreamFinal::new(provider, crate::completion::Usage::from(&response.usage))
.with_optional_finish_reason(response.stop_reason.as_deref().map(map_finish_reason))
.with_optional_message_id(response.message_id)
.with_optional_provider_request_id(response.provider_request_id)
.with_optional_model(response.model)
}
}
impl<Ext, T> GenericCompletionModel<Ext, T>
where
T: HttpClientExt + Clone + Default + 'static,
Ext: AnthropicCompatibleProvider + Clone + WasmCompatSend + WasmCompatSync + 'static,
{
pub async fn raw_stream(
&self,
completion_request: CompletionRequest,
) -> Result<RawStreamingResult<StreamingCompletionResponse>, CompletionError> {
let (span, request) =
self.prepare_request(completion_request, CompletionOperation::ChatStreaming)?;
let body = streaming_body(&request)?;
crate::providers::internal::trace_json(
crate::providers::internal::LogTarget::Completions,
"Anthropic completion request",
&body,
);
let body: Vec<u8> = serde_json::to_vec(&body)?;
let req = self
.client
.post("/v1/messages")?
.body(body)
.map_err(http_client::Error::Protocol)?;
let event_source = GenericEventSource::new(self.client.clone(), req);
let (event_source, request_id_slot) = match Ext::REQUEST_ID_HEADER {
Some(header) => {
let (event_source, slot) = event_source.capture_request_id(header);
(event_source, Some(slot))
}
None => (event_source, None),
};
let stream = open_wire_stream(
event_source,
SseTransportOptions {
open_log: OpenLog::Silent,
stream_ended_is_error: true,
log_transport_errors: false,
},
skip_blank_frames,
AnthropicAdapter::default(),
span,
);
Ok(
crate::providers::internal::sse_transport::stamp_terminal_request_id(
stream,
request_id_slot,
Ext::REQUEST_ID_HEADER,
|response, id| response.provider_request_id = Some(id),
),
)
}
pub(crate) async fn stream(
&self,
completion_request: CompletionRequest,
) -> Result<streaming::StreamingCompletionResponse, CompletionError> {
let stream = self.raw_stream(completion_request).await?;
let normalized = streaming::normalize_stream(stream, |response| {
Ok(StreamFinal::from((Ext::PROVIDER_NAME, response)))
});
Ok(streaming::StreamingCompletionResponse::stream(
Ext::PROVIDER_NAME,
normalized,
))
}
}
fn handle_event(
event: &StreamingEvent,
current_tool_call: &mut Option<String>,
server_tool_uses: &mut HashMap<usize, ServerToolUseState>,
current_thinking: &mut Option<ThinkingState>,
) -> Option<Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>> {
match event {
StreamingEvent::ContentBlockDelta { index, delta } => match delta {
ContentDelta::TextDelta { text } => {
if current_tool_call.is_none() {
return Some(Ok(RawStreamingChoice::Message(text.clone())));
}
None
}
ContentDelta::InputJsonDelta { partial_json } => {
if let Some(server_tool_use) = server_tool_uses.get_mut(index) {
server_tool_use.input_json.push_str(partial_json);
return None;
}
if let Some(id) = current_tool_call {
return Some(Ok(RawStreamingChoice::ToolCallDelta {
id: StreamPartId::wire(id.clone()),
content: ToolCallDeltaContent::Delta(partial_json.clone()),
}));
}
None
}
ContentDelta::ThinkingDelta { thinking } => {
current_thinking.get_or_insert_with(ThinkingState::default);
Some(Ok(RawStreamingChoice::ReasoningDelta {
id: MintKind::Block.for_wire_index(*index as u64),
provider_id: None,
reasoning: thinking.clone(),
}))
}
ContentDelta::SignatureDelta { signature } => {
current_thinking
.get_or_insert_with(ThinkingState::default)
.signature
.push_str(signature);
None
}
ContentDelta::CitationsDelta { citation } => {
crate::message::AdditionalParams::from_entries([("citations", json!([citation]))])
.map(|params| Ok(RawStreamingChoice::TextAdditionalParams(params)))
}
ContentDelta::Unknown(value) => {
tracing::warn!(
delta_type = value.get("type").and_then(serde_json::Value::as_str),
"skipping unrecognized Anthropic content delta type"
);
None
}
},
StreamingEvent::ContentBlockStart {
index,
content_block,
} => match content_block {
Content::Text {
text: _,
citations,
cache_control: _,
} => {
let additional_params = crate::message::AdditionalParams::from_entries(
(!citations.is_empty()).then(|| ("citations", json!(citations))),
);
Some(Ok(RawStreamingChoice::TextStart {
id: MintKind::Block.for_wire_index(*index as u64),
additional_params,
}))
}
Content::ServerToolUse { id, name, input } => {
server_tool_uses.insert(
*index,
ServerToolUseState {
name: name.clone(),
id: id.clone(),
initial_input: input.clone(),
input_json: String::new(),
},
);
None
}
raw @ (Content::WebSearchToolResult { .. }
| Content::CodeExecutionToolResult { .. }) => Some(Ok(RawStreamingChoice::TextStart {
id: MintKind::Block.for_wire_index(*index as u64),
additional_params: crate::message::AdditionalParams::from_entries([(
super::completion::ANTHROPIC_RAW_CONTENT_KEY,
json!(raw),
)]),
})),
Content::ToolUse { id, name, .. } => {
*current_tool_call = Some(id.clone());
Some(Ok(RawStreamingChoice::ToolCallDelta {
id: StreamPartId::wire(id.clone()),
content: ToolCallDeltaContent::Name(name.clone()),
}))
}
Content::Thinking {
thinking,
signature,
} => {
*current_thinking = Some(ThinkingState {
signature: String::new(),
initial_signature: signature.clone().unwrap_or_default(),
});
(!thinking.is_empty()).then(|| {
Ok(RawStreamingChoice::ReasoningDelta {
id: MintKind::Block.for_wire_index(*index as u64),
provider_id: None,
reasoning: thinking.clone(),
})
})
}
Content::RedactedThinking { data } => Some(Ok(RawStreamingChoice::Reasoning {
id: MintKind::Block.for_wire_index(*index as u64),
provider_id: None,
content: ReasoningContent::Redacted { data: data.clone() },
})),
_ => None,
},
StreamingEvent::ContentBlockStop { index } => {
if let Some(thinking_state) = Option::take(current_thinking) {
return Some(Ok(RawStreamingChoice::ReasoningEnd {
id: MintKind::Block.for_wire_index(*index as u64),
reasoning: None,
signature: thinking_state.into_signature(),
wire_sent: true,
}));
}
if let Some(server_tool_use) = server_tool_uses.remove(index) {
let input = if server_tool_use.input_json.is_empty() {
if server_tool_use.initial_input.is_null() {
json!({})
} else {
server_tool_use.initial_input
}
} else {
match serde_json::from_str(&server_tool_use.input_json) {
Ok(json_value) => json_value,
Err(e) => return Some(Err(CompletionError::from(e))),
}
};
return Some(Ok(RawStreamingChoice::TextStart {
id: MintKind::Block.for_wire_index(*index as u64),
additional_params: crate::message::AdditionalParams::from_entries([(
super::completion::ANTHROPIC_RAW_CONTENT_KEY,
json!(Content::ServerToolUse {
id: server_tool_use.id,
name: server_tool_use.name,
input,
}),
)]),
}));
}
Option::take(current_tool_call).map(|id| {
Ok(RawStreamingChoice::ToolInputEnd(ToolInputEnd::new(
id,
UnparseableToolInput::Error,
)))
})
}
StreamingEvent::MessageStart { .. }
| StreamingEvent::MessageDelta { .. }
| StreamingEvent::MessageStop
| StreamingEvent::Ping
| StreamingEvent::Error { .. } => None,
}
}
#[cfg(test)]
mod tests {
use super::super::completion::{
AnthropicRequestParams, CLAUDE_OPUS_4_8, CacheControl, CacheTtl, Message, SystemContent,
apply_prompt_cache_control, build_tool_definitions, resolve_top_level_cache_control,
};
use super::*;
use crate::completion::Message as RigMessage;
use crate::completion::request::Document as RigDocument;
use crate::streaming::RawStreamingToolCall;
use async_stream::stream;
use futures::StreamExt;
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
fn to_stream_result(
stream: impl futures::Stream<
Item = Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>,
> + Send
+ 'static,
) -> crate::streaming::StreamingResult {
crate::streaming::normalize_stream(Box::pin(stream), |response| {
Ok(StreamFinal::from(("anthropic", response)))
})
}
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
fn to_stream_result(
stream: impl futures::Stream<
Item = Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>,
> + 'static,
) -> crate::streaming::StreamingResult {
crate::streaming::normalize_stream(Box::pin(stream), |response| {
Ok(StreamFinal::from(("anthropic", response)))
})
}
fn built_streaming_body(
model: &str,
request: CompletionRequest,
strict_tools: bool,
) -> Result<Value, CompletionError> {
let typed = AnthropicCompletionRequest::try_from_params::<
crate::providers::anthropic::client::AnthropicExt,
>(
AnthropicRequestParams {
model,
request,
prompt_caching: false,
automatic_caching: false,
automatic_caching_ttl: None,
static_prefix_cache_ttl: None,
},
strict_tools,
)?;
streaming_body(&typed)
}
#[test]
fn test_streaming_tool_build_marks_final_combined_tool() {
let mut additional_params = json!({
"tools": [{
"name": "provider_tool",
"description": "Provider tool",
"input_schema": {"type": "object"}
}]
});
let mut tools =
build_tool_definitions::<crate::providers::anthropic::client::AnthropicExt>(
vec![crate::completion::ToolDefinition {
name: "rig_tool".to_string(),
description: "Rig tool".to_string(),
parameters: json!({"type": "object", "properties": {}}),
}],
&mut additional_params,
false,
)
.unwrap();
let mut system: Vec<SystemContent> = Vec::new();
let mut messages: Vec<Message> = Vec::new();
apply_prompt_cache_control(&mut system, &mut messages, &mut tools, true, None, None)
.unwrap();
assert_eq!(tools.len(), 2);
assert!(tools[0].get("cache_control").is_none());
assert_eq!(tools[1]["name"], "provider_tool");
assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
}
#[test]
fn streaming_request_keeps_documents_after_leading_system_messages() {
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec![
RigMessage::system("System prompt"),
RigMessage::assistant("Earlier assistant turn"),
RigMessage::system("Mid-conversation instruction"),
RigMessage::user("Prompt"),
],
documents: vec![RigDocument {
id: "doc1".to_string(),
text: "Document text.".to_string(),
additional_props: Default::default(),
}],
tools: vec![],
temperature: None,
max_tokens: Some(64),
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
};
let body = built_streaming_body(CLAUDE_OPUS_4_8, request, false)
.expect("streaming request body should build");
assert_eq!(body["system"][0]["text"], "System prompt");
assert_eq!(body["system"][1]["text"], "Mid-conversation instruction");
let messages = body["messages"]
.as_array()
.expect("messages should be array");
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "user");
assert!(
messages[0].to_string().contains("<file id: doc1>"),
"document message should follow top-level system: {messages:?}"
);
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[2]["role"], "user");
assert_eq!(
messages
.iter()
.filter(|message| message.to_string().contains("<file id: doc1>"))
.count(),
1,
"document message should appear exactly once: {messages:?}"
);
}
#[test]
fn streaming_body_is_blocking_body_plus_stream_flag_and_carries_output_schema() {
let schema: schemars::Schema = serde_json::from_value(json!({
"title": "WeatherResponse",
"type": "object",
"properties": { "city": { "type": "string" } }
}))
.expect("schema should deserialize");
let request = CompletionRequest {
model: None,
preamble: Some("You are helpful".to_string()),
chat_history: vec![RigMessage::user("What's the weather?")],
documents: vec![],
tools: vec![],
temperature: Some(0.5),
max_tokens: Some(64),
tool_choice: None,
additional_params: None,
output_schema: Some(schema),
record_telemetry_content: false,
};
let streaming_body = built_streaming_body(CLAUDE_OPUS_4_8, request.clone(), false)
.expect("streaming request body should build");
assert_eq!(streaming_body["stream"], serde_json::Value::Bool(true));
assert_eq!(
streaming_body["output_config"]["format"]["type"],
"json_schema"
);
assert!(
streaming_body["output_config"]["format"]["schema"].is_object(),
"streaming body must carry the structured-output schema: {streaming_body}"
);
let blocking = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
model: CLAUDE_OPUS_4_8,
request,
prompt_caching: false,
automatic_caching: false,
automatic_caching_ttl: None,
static_prefix_cache_ttl: None,
})
.expect("blocking request body should build");
let mut expected = serde_json::to_value(&blocking).expect("serialize blocking body");
expected
.as_object_mut()
.expect("body is an object")
.insert("stream".to_string(), serde_json::Value::Bool(true));
assert_eq!(streaming_body, expected);
}
#[test]
fn streaming_body_keeps_explicit_tool_choice_auto_when_tools_present_but_unset() {
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec![RigMessage::user("Add 2 and 3")],
documents: vec![],
tools: vec![crate::completion::ToolDefinition {
name: "add".to_string(),
description: "Add x and y".to_string(),
parameters: json!({
"type": "object",
"properties": { "x": { "type": "integer" } }
}),
}],
temperature: None,
max_tokens: Some(64),
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
};
let body = built_streaming_body(CLAUDE_OPUS_4_8, request, false)
.expect("streaming request body should build");
assert_eq!(body["tool_choice"], json!({ "type": "auto" }));
assert!(body["tools"].is_array());
}
#[test]
fn streaming_body_applies_strict_tool_opt_in() {
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec![RigMessage::user("Look this up")],
documents: vec![],
tools: vec![crate::completion::ToolDefinition {
name: "lookup".to_string(),
description: "Look up a value".to_string(),
parameters: json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}),
}],
temperature: None,
max_tokens: Some(64),
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
};
let body = built_streaming_body(CLAUDE_OPUS_4_8, request, true)
.expect("streaming request body should build");
assert_eq!(body["tools"][0]["strict"], true);
assert_eq!(
body["tools"][0]["input_schema"]["additionalProperties"],
false
);
assert_eq!(
body["tools"][0]["input_schema"]["required"],
json!(["query"])
);
}
#[test]
fn streaming_body_drops_tool_choice_when_no_tools_are_advertised() {
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec![RigMessage::user("Hi")],
documents: vec![],
tools: vec![],
temperature: None,
max_tokens: Some(64),
tool_choice: Some(crate::message::ToolChoice::Auto),
additional_params: None,
output_schema: None,
record_telemetry_content: false,
};
let body = built_streaming_body(CLAUDE_OPUS_4_8, request, false)
.expect("streaming request body should build");
assert!(
body.get("tool_choice").is_none(),
"tool_choice must be omitted when no tools are advertised: {body}"
);
assert!(body.get("tools").is_none());
}
#[test]
fn test_streaming_prompt_cache_control_uses_raw_top_level_ttl() {
let mut additional_params = json!({
"cache_control": {"type": "ephemeral", "ttl": "1h"}
});
let top_level_cache_control =
resolve_top_level_cache_control(false, None, &mut additional_params).unwrap();
let mut tools =
build_tool_definitions::<crate::providers::anthropic::client::AnthropicExt>(
vec![crate::completion::ToolDefinition {
name: "rig_tool".to_string(),
description: "Rig tool".to_string(),
parameters: json!({"type": "object", "properties": {}}),
}],
&mut additional_params,
false,
)
.unwrap();
let mut system = vec![SystemContent::Text {
text: "System prompt".to_string(),
cache_control: None,
}];
let mut messages: Vec<Message> = Vec::new();
apply_prompt_cache_control(
&mut system,
&mut messages,
&mut tools,
true,
None,
top_level_cache_control.as_ref(),
)
.unwrap();
assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
match &system[0] {
SystemContent::Text {
cache_control: Some(CacheControl::Ephemeral { ttl }),
..
} => assert_eq!(ttl.as_ref(), Some(&CacheTtl::OneHour)),
other => panic!("expected system cache_control, got {other:?}"),
}
assert!(additional_params.get("cache_control").is_none());
}
fn handle_event(
event: &StreamingEvent,
current_tool_call: &mut Option<String>,
current_thinking: &mut Option<ThinkingState>,
) -> Option<Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>> {
let mut server_tool_uses = HashMap::new();
super::handle_event(
event,
current_tool_call,
&mut server_tool_uses,
current_thinking,
)
}
#[test]
fn test_thinking_delta_deserialization() {
let json = r#"{"type": "thinking_delta", "thinking": "Let me think about this..."}"#;
let delta: ContentDelta = serde_json::from_str(json).unwrap();
match delta {
ContentDelta::ThinkingDelta { thinking } => {
assert_eq!(thinking, "Let me think about this...");
}
_ => panic!("Expected ThinkingDelta variant"),
}
}
#[test]
fn test_signature_delta_deserialization() {
let json = r#"{"type": "signature_delta", "signature": "abc123def456"}"#;
let delta: ContentDelta = serde_json::from_str(json).unwrap();
match delta {
ContentDelta::SignatureDelta { signature } => {
assert_eq!(signature, "abc123def456");
}
_ => panic!("Expected SignatureDelta variant"),
}
}
#[test]
fn test_thinking_delta_streaming_event_deserialization() {
let json = r#"{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "thinking_delta",
"thinking": "First, I need to understand the problem."
}
}"#;
let event: StreamingEvent = serde_json::from_str(json).unwrap();
match event {
StreamingEvent::ContentBlockDelta { index, delta } => {
assert_eq!(index, 0);
match delta {
ContentDelta::ThinkingDelta { thinking } => {
assert_eq!(thinking, "First, I need to understand the problem.");
}
_ => panic!("Expected ThinkingDelta"),
}
}
_ => panic!("Expected ContentBlockDelta event"),
}
}
#[test]
fn test_signature_delta_streaming_event_deserialization() {
let json = r#"{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "signature_delta",
"signature": "ErUBCkYICBgCIkCaGbqC85F4"
}
}"#;
let event: StreamingEvent = serde_json::from_str(json).unwrap();
match event {
StreamingEvent::ContentBlockDelta { index, delta } => {
assert_eq!(index, 0);
match delta {
ContentDelta::SignatureDelta { signature } => {
assert_eq!(signature, "ErUBCkYICBgCIkCaGbqC85F4");
}
_ => panic!("Expected SignatureDelta"),
}
}
_ => panic!("Expected ContentBlockDelta event"),
}
}
#[test]
fn test_handle_thinking_delta_event() {
let event = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::ThinkingDelta {
thinking: "Analyzing the request...".to_string(),
},
};
let mut tool_call_state = None;
let mut thinking_state = None;
let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
assert!(result.is_some());
let choice = result.unwrap().unwrap();
match choice {
RawStreamingChoice::ReasoningDelta { id, reasoning, .. } => {
assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(0));
assert_eq!(reasoning, "Analyzing the request...");
}
_ => panic!("Expected ReasoningDelta choice"),
}
assert!(thinking_state.is_some());
}
#[test]
fn test_handle_signature_delta_event() {
let event = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::SignatureDelta {
signature: "test_signature".to_string(),
},
};
let mut tool_call_state = None;
let mut thinking_state = None;
let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
assert!(result.is_none());
assert!(thinking_state.is_some());
assert_eq!(thinking_state.unwrap().signature, "test_signature");
}
#[test]
fn test_handle_redacted_thinking_content_block_start_event() {
let event = StreamingEvent::ContentBlockStart {
index: 0,
content_block: Content::RedactedThinking {
data: "redacted_blob".to_string(),
},
};
let mut tool_call_state = None;
let mut thinking_state = None;
let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
assert!(result.is_some());
match result.unwrap().unwrap() {
RawStreamingChoice::Reasoning {
content: ReasoningContent::Redacted { data },
..
} => {
assert_eq!(data, "redacted_blob");
}
_ => panic!("Expected Redacted reasoning chunk"),
}
}
#[test]
fn signature_only_thinking_block_survives_content_block_stop() {
let mut tool_call_state = None;
let mut thinking_state = None;
let start = StreamingEvent::ContentBlockStart {
index: 0,
content_block: Content::Thinking {
thinking: String::new(),
signature: Some(String::new()),
},
};
assert!(handle_event(&start, &mut tool_call_state, &mut thinking_state).is_none());
let signature = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::SignatureDelta {
signature: "the_whole_signature".to_string(),
},
};
assert!(handle_event(&signature, &mut tool_call_state, &mut thinking_state).is_none());
let stop = StreamingEvent::ContentBlockStop { index: 0 };
let result = handle_event(&stop, &mut tool_call_state, &mut thinking_state)
.expect("signature-only thinking block must not be dropped")
.expect("thinking block should not be an error");
match result {
RawStreamingChoice::ReasoningEnd { id, signature, .. } => {
assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(0));
assert_eq!(signature.as_deref(), Some("the_whole_signature"));
}
other => panic!("Expected a signed lifecycle end, got {other:?}"),
}
}
#[test]
fn signature_delivered_only_on_content_block_start_is_kept() {
let mut tool_call_state = None;
let mut thinking_state = None;
let start = StreamingEvent::ContentBlockStart {
index: 0,
content_block: Content::Thinking {
thinking: String::new(),
signature: Some("up_front_signature".to_string()),
},
};
assert!(handle_event(&start, &mut tool_call_state, &mut thinking_state).is_none());
let stop = StreamingEvent::ContentBlockStop { index: 0 };
match handle_event(&stop, &mut tool_call_state, &mut thinking_state)
.expect("an up-front signature must not be dropped")
.expect("thinking block should not be an error")
{
RawStreamingChoice::ReasoningEnd { signature, .. } => {
assert_eq!(signature.as_deref(), Some("up_front_signature"));
}
other => panic!("Expected a signed lifecycle end, got {other:?}"),
}
}
#[test]
fn signature_deltas_supersede_the_opening_signature() {
let mut tool_call_state = None;
let mut thinking_state = None;
let start = StreamingEvent::ContentBlockStart {
index: 0,
content_block: Content::Thinking {
thinking: String::new(),
signature: Some("opening".to_string()),
},
};
assert!(handle_event(&start, &mut tool_call_state, &mut thinking_state).is_none());
for fragment in ["delta_", "assembled"] {
let signature = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::SignatureDelta {
signature: fragment.to_string(),
},
};
assert!(handle_event(&signature, &mut tool_call_state, &mut thinking_state).is_none());
}
let stop = StreamingEvent::ContentBlockStop { index: 0 };
match handle_event(&stop, &mut tool_call_state, &mut thinking_state)
.expect("thinking block should be restated")
.expect("thinking block should not be an error")
{
RawStreamingChoice::ReasoningEnd { signature, .. } => {
assert_eq!(signature.as_deref(), Some("delta_assembled"))
}
other => panic!("Expected a signed lifecycle end, got {other:?}"),
}
}
#[test]
fn thinking_block_start_text_streams_as_the_first_delta() {
let mut tool_call_state = None;
let mut thinking_state = None;
let start = StreamingEvent::ContentBlockStart {
index: 2,
content_block: Content::Thinking {
thinking: "opening ".to_string(),
signature: None,
},
};
match handle_event(&start, &mut tool_call_state, &mut thinking_state)
.expect("the opening text streams")
.expect("not an error")
{
RawStreamingChoice::ReasoningDelta { id, reasoning, .. } => {
assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(2));
assert_eq!(reasoning, "opening ");
}
other => panic!("Expected the opening delta, got {other:?}"),
}
let delta = StreamingEvent::ContentBlockDelta {
index: 2,
delta: ContentDelta::ThinkingDelta {
thinking: "rest".to_string(),
},
};
assert!(handle_event(&delta, &mut tool_call_state, &mut thinking_state).is_some());
let stop = StreamingEvent::ContentBlockStop { index: 2 };
match handle_event(&stop, &mut tool_call_state, &mut thinking_state)
.expect("the stop emits the lifecycle end")
.expect("not an error")
{
RawStreamingChoice::ReasoningEnd {
id,
reasoning: None,
signature: None,
wire_sent: true,
} => {
assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(2));
}
other => panic!("Expected a bare lifecycle end, got {other:?}"),
}
}
#[test]
fn wholly_empty_thinking_block_is_dropped() {
let mut tool_call_state = None;
let mut thinking_state = None;
let start = StreamingEvent::ContentBlockStart {
index: 0,
content_block: Content::Thinking {
thinking: String::new(),
signature: None,
},
};
assert!(handle_event(&start, &mut tool_call_state, &mut thinking_state).is_none());
let stop = StreamingEvent::ContentBlockStop { index: 0 };
match handle_event(&stop, &mut tool_call_state, &mut thinking_state)
.expect("the stop emits the lifecycle end")
.expect("not an error")
{
RawStreamingChoice::ReasoningEnd {
reasoning: None,
signature: None,
..
} => {}
other => panic!("Expected a bare lifecycle end, got {other:?}"),
}
}
#[test]
fn test_handle_text_delta_event() {
let event = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::TextDelta {
text: "Hello, world!".to_string(),
},
};
let mut tool_call_state = None;
let mut thinking_state = None;
let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
assert!(result.is_some());
let choice = result.unwrap().unwrap();
match choice {
RawStreamingChoice::Message(text) => {
assert_eq!(text, "Hello, world!");
}
_ => panic!("Expected Message choice"),
}
}
#[test]
fn test_handle_text_block_start_event() {
let event = StreamingEvent::ContentBlockStart {
index: 0,
content_block: Content::Text {
text: String::new(),
citations: Vec::new(),
cache_control: None,
},
};
let mut tool_call_state = None;
let mut thinking_state = None;
let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
assert!(result.is_some());
let choice = result.unwrap().unwrap();
assert!(matches!(
choice,
RawStreamingChoice::TextStart {
additional_params: None,
..
}
));
}
#[test]
fn test_thinking_delta_does_not_interfere_with_tool_calls() {
let event = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::ThinkingDelta {
thinking: "Thinking while tool is active...".to_string(),
},
};
let mut tool_call_state = Some("tool_123".to_string());
let mut thinking_state = None;
let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
assert!(result.is_some());
let choice = result.unwrap().unwrap();
match choice {
RawStreamingChoice::ReasoningDelta { reasoning, .. } => {
assert_eq!(reasoning, "Thinking while tool is active...");
}
_ => panic!("Expected ReasoningDelta choice"),
}
assert!(tool_call_state.is_some());
}
#[test]
fn test_handle_input_json_delta_event() {
let event = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::InputJsonDelta {
partial_json: "{\"arg\":\"value".to_string(),
},
};
let mut tool_call_state = Some("tool_123".to_string());
let mut thinking_state = None;
let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
assert!(result.is_some());
let choice = result.unwrap().unwrap();
match choice {
RawStreamingChoice::ToolCallDelta { id, content } => {
assert_eq!(id, crate::streaming::StreamPartId::wire("tool_123"));
match content {
ToolCallDeltaContent::Delta(delta) => assert_eq!(delta, "{\"arg\":\"value"),
_ => panic!("Expected Delta content"),
}
}
_ => panic!("Expected ToolCallDelta choice, got {:?}", choice),
}
assert!(tool_call_state.is_some());
}
#[test]
fn test_tool_call_accumulation_with_multiple_deltas() {
let mut tool_call_state = Some("tool_123".to_string());
let mut thinking_state = None;
let event1 = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::InputJsonDelta {
partial_json: "{\"location\":".to_string(),
},
};
let result1 = handle_event(&event1, &mut tool_call_state, &mut thinking_state);
assert!(result1.is_some());
let event2 = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::InputJsonDelta {
partial_json: "\"Paris\",".to_string(),
},
};
let result2 = handle_event(&event2, &mut tool_call_state, &mut thinking_state);
assert!(result2.is_some());
let event3 = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::InputJsonDelta {
partial_json: "\"temp\":\"20C\"}".to_string(),
},
};
let result3 = handle_event(&event3, &mut tool_call_state, &mut thinking_state);
assert!(result3.is_some());
assert!(tool_call_state.is_some());
let stop_event = StreamingEvent::ContentBlockStop { index: 0 };
let final_result = handle_event(&stop_event, &mut tool_call_state, &mut thinking_state);
assert!(final_result.is_some());
match final_result.unwrap().unwrap() {
RawStreamingChoice::ToolInputEnd(end) => {
assert_eq!(end.id, crate::streaming::StreamPartId::wire("tool_123"));
assert!(matches!(
end.on_unparseable,
crate::streaming::UnparseableToolInput::Error
));
}
other => panic!("Expected ToolInputEnd, got {:?}", other),
}
assert!(tool_call_state.is_none());
}
#[test]
fn test_citations_delta_streaming_event_deserialization() {
let json = r#"{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "citations_delta",
"citation": {
"type": "char_location",
"cited_text": "The grass is green.",
"document_index": 0,
"document_title": "Example",
"start_char_index": 0,
"end_char_index": 20
}
}
}"#;
let event: StreamingEvent = serde_json::from_str(json).unwrap();
let StreamingEvent::ContentBlockDelta { index, delta } = event else {
panic!("expected ContentBlockDelta");
};
assert_eq!(index, 0);
let ContentDelta::CitationsDelta { citation } = delta else {
panic!("expected CitationsDelta");
};
let crate::providers::anthropic::completion::Citation::CharLocation(citation) = citation
else {
panic!("expected CharLocation");
};
assert_eq!(citation.start_char_index, 0);
assert_eq!(citation.end_char_index, 20);
}
#[test]
fn test_search_result_citations_delta_streaming_event_deserialization() {
let json = r#"{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "citations_delta",
"citation": {
"type": "search_result_location",
"cited_text": "API requests require a key.",
"source": "https://docs.example.com/api-reference",
"title": "API Reference",
"search_result_index": 0,
"start_block_index": 0,
"end_block_index": 1
}
}
}"#;
let event: StreamingEvent = serde_json::from_str(json).unwrap();
let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
panic!("expected ContentBlockDelta");
};
let ContentDelta::CitationsDelta { citation } = delta else {
panic!("expected CitationsDelta");
};
assert!(matches!(
citation,
crate::providers::anthropic::completion::Citation::SearchResultLocation(
crate::providers::anthropic::completion::SearchResultLocationCitation {
search_result_index: 0,
start_block_index: 0,
end_block_index: 1,
..
}
)
));
}
#[test]
fn test_web_search_result_citations_delta_streaming_event_deserialization() {
let json = r#"{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "citations_delta",
"citation": {
"type": "web_search_result_location",
"cited_text": "Claude Shannon was a mathematician.",
"url": "https://example.com/shannon",
"title": "Claude Shannon",
"encrypted_index": "encrypted-reference"
}
}
}"#;
let event: StreamingEvent = serde_json::from_str(json).unwrap();
let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
panic!("expected ContentBlockDelta");
};
let ContentDelta::CitationsDelta { citation } = delta else {
panic!("expected CitationsDelta");
};
assert!(matches!(
citation,
crate::providers::anthropic::completion::Citation::WebSearchResultLocation(ref citation)
if citation.url == "https://example.com/shannon"
&& citation.encrypted_index == "encrypted-reference"
));
}
#[test]
fn test_web_search_result_citations_delta_allows_null_title() {
let json = r#"{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "citations_delta",
"citation": {
"type": "web_search_result_location",
"cited_text": "Claude Shannon was a mathematician.",
"url": "https://example.com/shannon",
"title": null,
"encrypted_index": "encrypted-reference"
}
}
}"#;
let event: StreamingEvent = serde_json::from_str(json).unwrap();
let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
panic!("expected ContentBlockDelta");
};
let ContentDelta::CitationsDelta { citation } = delta else {
panic!("expected CitationsDelta");
};
assert!(matches!(
citation,
crate::providers::anthropic::completion::Citation::WebSearchResultLocation(
crate::providers::anthropic::completion::WebSearchResultLocationCitation {
title: None,
..
}
)
));
}
#[test]
fn test_text_content_block_start_allows_null_citations() {
let json = r#"{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "text",
"text": "",
"citations": null
}
}"#;
let event: StreamingEvent = serde_json::from_str(json).unwrap();
let StreamingEvent::ContentBlockStart { content_block, .. } = event else {
panic!("expected ContentBlockStart");
};
let Content::Text {
text, citations, ..
} = content_block
else {
panic!("expected text content block");
};
assert_eq!(text, "");
assert!(citations.is_empty());
}
#[test]
fn test_web_search_content_block_start_events_deserialize() {
let server_tool_use = r#"{
"type": "content_block_start",
"index": 1,
"content_block": {
"type": "server_tool_use",
"id": "srvtoolu_01",
"name": "web_search",
"input": {
"query": "claude shannon birth date"
}
}
}"#;
let event: StreamingEvent = serde_json::from_str(server_tool_use).unwrap();
assert!(matches!(
event,
StreamingEvent::ContentBlockStart {
content_block: Content::ServerToolUse {
ref id,
ref name,
ref input
},
..
} if id == "srvtoolu_01"
&& name == "web_search"
&& input["query"] == "claude shannon birth date"
));
let web_search_tool_result = r#"{
"type": "content_block_start",
"index": 2,
"content_block": {
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01",
"content": [{
"type": "web_search_result",
"url": "https://example.com/shannon",
"title": "Claude Shannon",
"encrypted_content": "encrypted-content"
}]
}
}"#;
let event: StreamingEvent = serde_json::from_str(web_search_tool_result).unwrap();
assert!(matches!(
event,
StreamingEvent::ContentBlockStart {
content_block: Content::WebSearchToolResult {
ref tool_use_id,
ref content
},
..
} if tool_use_id == "srvtoolu_01"
&& content[0]["encrypted_content"] == "encrypted-content"
));
}
#[test]
fn test_code_execution_tool_result_block_is_preserved() {
let event: StreamingEvent = serde_json::from_value(serde_json::json!({
"type": "content_block_start",
"index": 1,
"content_block": {
"type": "code_execution_tool_result",
"tool_use_id": "srvtoolu_01",
"content": {
"type": "code_execution_result",
"return_code": 0,
"stdout": "42\n",
"stderr": "",
"content": []
}
}
}))
.unwrap();
let mut tool_call_state = None;
let mut server_tool_uses = HashMap::new();
let mut thinking_state = None;
let choice = super::handle_event(
&event,
&mut tool_call_state,
&mut server_tool_uses,
&mut thinking_state,
)
.expect("code_execution_tool_result block should produce raw metadata")
.unwrap();
let RawStreamingChoice::TextStart {
id,
additional_params: Some(additional_params),
} = choice
else {
panic!("expected text-start metadata for code_execution_tool_result");
};
assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(1));
assert_eq!(
additional_params[crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["type"],
"code_execution_tool_result"
);
assert_eq!(
additional_params[crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["content"]
["stdout"],
"42\n"
);
}
#[tokio::test]
async fn test_streaming_web_search_blocks_are_preserved_on_final_choice() {
let raw_stream = stream! {
let mut tool_call_state = None;
let mut server_tool_uses = HashMap::new();
let mut thinking_state = None;
let server_tool_use_start = super::handle_event(
&StreamingEvent::ContentBlockStart {
index: 0,
content_block: Content::ServerToolUse {
id: "srvtoolu_01".to_string(),
name: "web_search".to_string(),
input: serde_json::Value::Null,
},
},
&mut tool_call_state,
&mut server_tool_uses,
&mut thinking_state,
);
assert!(
server_tool_use_start.is_none(),
"server_tool_use start should be accumulated until its input JSON is complete"
);
let server_tool_use_delta = super::handle_event(
&StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::InputJsonDelta {
partial_json: r#"{"query":"claude shannon birth date"}"#.to_string(),
},
},
&mut tool_call_state,
&mut server_tool_uses,
&mut thinking_state,
);
assert!(
server_tool_use_delta.is_none(),
"server_tool_use input JSON should not be emitted as a Rig tool-call delta"
);
yield super::handle_event(
&StreamingEvent::ContentBlockStop { index: 0 },
&mut tool_call_state,
&mut server_tool_uses,
&mut thinking_state,
)
.expect("server_tool_use stop should produce completed raw metadata");
yield super::handle_event(
&StreamingEvent::ContentBlockStart {
index: 1,
content_block: Content::WebSearchToolResult {
tool_use_id: "srvtoolu_01".to_string(),
content: serde_json::json!([{
"type": "web_search_result",
"url": "https://example.com/shannon",
"title": "Claude Shannon",
"encrypted_content": "encrypted-content"
}]),
},
},
&mut tool_call_state,
&mut server_tool_uses,
&mut thinking_state,
)
.expect("web_search_tool_result block should produce raw metadata");
yield super::handle_event(
&StreamingEvent::ContentBlockStart {
index: 2,
content_block: Content::Text {
text: String::new(),
citations: Vec::new(),
cache_control: None,
},
},
&mut tool_call_state,
&mut server_tool_uses,
&mut thinking_state,
)
.expect("text block start should produce a raw choice");
yield super::handle_event(
&StreamingEvent::ContentBlockDelta {
index: 2,
delta: ContentDelta::TextDelta {
text: "Claude Shannon was born on April 30, 1916.".to_string(),
},
},
&mut tool_call_state,
&mut server_tool_uses,
&mut thinking_state,
)
.expect("text delta should produce a raw choice");
yield super::handle_event(
&StreamingEvent::ContentBlockDelta {
index: 2,
delta: ContentDelta::CitationsDelta {
citation: crate::providers::anthropic::completion::Citation::WebSearchResultLocation(
crate::providers::anthropic::completion::WebSearchResultLocationCitation {
cited_text: "Claude Shannon was born on April 30, 1916."
.to_string(),
url: "https://example.com/shannon".to_string(),
title: Some("Claude Shannon".to_string()),
encrypted_index: "encrypted-index".to_string(),
},
),
},
},
&mut tool_call_state,
&mut server_tool_uses,
&mut thinking_state,
)
.expect("citation delta should produce a raw choice");
yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse::default()));
};
let mut stream = crate::streaming::StreamingCompletionResponse::stream(
"anthropic",
to_stream_result(raw_stream),
);
while stream.next().await.is_some() {}
let choice_items: Vec<crate::message::AssistantContent> =
stream.choice.clone().into_iter().collect();
assert_eq!(choice_items.len(), 3);
assert!(
choice_items
.iter()
.all(|item| !matches!(item, crate::message::AssistantContent::ToolCall(_))),
"provider-owned web-search blocks must not become Rig client tool calls"
);
let Some(crate::message::AssistantContent::Text(server_tool_use)) = choice_items.first()
else {
panic!("expected raw server_tool_use metadata");
};
assert_eq!(
server_tool_use.additional_params.as_ref().unwrap()
[crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["type"],
"server_tool_use"
);
assert_eq!(
server_tool_use.additional_params.as_ref().unwrap()
[crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["input"]["query"],
"claude shannon birth date"
);
let Some(crate::message::AssistantContent::Text(web_search_result)) = choice_items.get(1)
else {
panic!("expected raw web_search_tool_result metadata");
};
assert_eq!(
web_search_result.additional_params.as_ref().unwrap()
[crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["content"][0]
["encrypted_content"],
"encrypted-content"
);
let Some(crate::message::AssistantContent::Text(answer)) = choice_items.get(2) else {
panic!("expected answer text");
};
assert_eq!(answer.text, "Claude Shannon was born on April 30, 1916.");
let citations = crate::providers::anthropic::completion::anthropic_citations(answer)
.expect("expected preserved citations");
assert!(matches!(
citations.first(),
Some(crate::providers::anthropic::completion::Citation::WebSearchResultLocation(citation))
if citation.encrypted_index == "encrypted-index"
));
}
#[test]
fn test_handle_citations_delta_event_preserves_metadata() {
let event = StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::CitationsDelta {
citation: crate::providers::anthropic::completion::Citation::CharLocation(
crate::providers::anthropic::completion::CharLocationCitation {
cited_text: "The grass is green.".to_string(),
document_index: 0,
document_title: Some("Example".to_string()),
start_char_index: 0,
end_char_index: 20,
},
),
},
};
let mut tool_call_state = None;
let mut thinking_state = None;
let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
assert!(result.is_some());
let choice = result.unwrap().unwrap();
let RawStreamingChoice::TextAdditionalParams(additional_params) = choice else {
panic!("expected TextAdditionalParams choice");
};
assert_eq!(additional_params["citations"][0]["type"], "char_location");
}
#[tokio::test]
async fn test_streaming_citation_deltas_are_preserved_on_final_text() {
let citation = crate::providers::anthropic::completion::Citation::CharLocation(
crate::providers::anthropic::completion::CharLocationCitation {
cited_text: "The grass is green.".to_string(),
document_index: 0,
document_title: Some("Example".to_string()),
start_char_index: 0,
end_char_index: 20,
},
);
let raw_stream = stream! {
let mut tool_call_state = None;
let mut thinking_state = None;
yield handle_event(
&StreamingEvent::ContentBlockStart {
index: 0,
content_block: Content::Text {
text: String::new(),
citations: Vec::new(),
cache_control: None,
},
},
&mut tool_call_state,
&mut thinking_state,
)
.expect("text block start should produce a raw choice");
yield handle_event(
&StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::TextDelta {
text: "the grass is green".to_string(),
},
},
&mut tool_call_state,
&mut thinking_state,
)
.expect("text delta should produce a raw choice");
yield handle_event(
&StreamingEvent::ContentBlockDelta {
index: 0,
delta: ContentDelta::CitationsDelta {
citation: crate::providers::anthropic::completion::Citation::CharLocation(
crate::providers::anthropic::completion::CharLocationCitation {
cited_text: "The grass is green.".to_string(),
document_index: 0,
document_title: Some("Example".to_string()),
start_char_index: 0,
end_char_index: 20,
},
),
},
},
&mut tool_call_state,
&mut thinking_state,
)
.expect("citation delta should produce a raw choice");
yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse::default()));
};
let mut stream = crate::streaming::StreamingCompletionResponse::stream(
"anthropic",
to_stream_result(raw_stream),
);
while stream.next().await.is_some() {}
let choice_items: Vec<crate::message::AssistantContent> =
stream.choice.clone().into_iter().collect();
let Some(crate::message::AssistantContent::Text(text)) = choice_items.first() else {
panic!("expected accumulated text item");
};
assert_eq!(text.text, "the grass is green");
let citations = crate::providers::anthropic::completion::anthropic_citations(text).unwrap();
assert_eq!(citations, vec![citation]);
}
#[test]
fn classify_dispatches_on_the_known_event_list() {
let adapter = AnthropicAdapter::default();
let frame =
WireFrame::Text(r#"{"type":"something_new_from_anthropic","field":"x"}"#.into());
assert!(matches!(
adapter.classify(frame),
crate::providers::internal::wire::WireEvent::Unknown { event_type, .. }
if event_type == "something_new_from_anthropic"
));
let frame = WireFrame::Text(r#"{"type":"ping"}"#.into());
assert!(matches!(
adapter.classify(frame),
crate::providers::internal::wire::WireEvent::Known(StreamingEvent::Ping)
));
let frame = WireFrame::Text("{not json".into());
assert!(matches!(
adapter.classify(frame),
crate::providers::internal::wire::WireEvent::Corrupt(_)
));
}
#[test]
fn novel_nested_delta_type_is_a_known_noop() {
let adapter = AnthropicAdapter::default();
let frame = WireFrame::Text(
r#"{"type":"content_block_delta","index":0,"delta":{"type":"banana_delta","x":1}}"#
.into(),
);
let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(frame)
else {
panic!("a novel nested delta type must stay a Known event");
};
let mut adapter = AnthropicAdapter::default();
let mut out = Vec::new();
adapter.interpret(event, &mut out);
assert!(out.is_empty(), "an unmodeled nested delta is a no-op");
}
#[test]
fn per_ttl_cache_creation_split_carries_from_message_start_to_terminal() {
let mut adapter = AnthropicAdapter::default();
let mut out = Vec::new();
let start = WireFrame::Text(
r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"output_tokens":1,"cache_creation_input_tokens":9702,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_1h_input_tokens":9366,"ephemeral_5m_input_tokens":336}}}}"#
.into(),
);
let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(start)
else {
panic!("message_start must classify Known");
};
adapter.interpret(event, &mut out);
let delta = WireFrame::Text(
r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":7,"input_tokens":3,"cache_creation_input_tokens":9702,"cache_read_input_tokens":0}}"#
.into(),
);
let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(delta)
else {
panic!("message_delta must classify Known");
};
adapter.interpret(event, &mut out);
let terminal = out
.iter()
.find_map(|item| match item {
Ok(crate::streaming::RawStreamingChoice::FinalResponse(response)) => {
Some(response.clone())
}
_ => None,
})
.expect("terminal message_delta must yield a final response");
let split = terminal
.usage
.cache_creation
.expect("terminal usage must carry the message_start cache_creation split");
assert_eq!(split.ephemeral_1h_input_tokens, 9366);
assert_eq!(split.ephemeral_5m_input_tokens, 336);
assert_eq!(terminal.usage.cache_creation_input_tokens, Some(9702));
}
#[test]
fn delta_missing_its_type_is_corrupt_not_skipped() {
let adapter = AnthropicAdapter::default();
let frame = WireFrame::Text(
r#"{"type":"content_block_delta","index":0,"delta":{"text":"hello"}}"#.into(),
);
assert!(matches!(
adapter.classify(frame),
crate::providers::internal::wire::WireEvent::Corrupt(_)
));
}
#[test]
fn known_nested_delta_tag_with_defective_payload_is_corrupt() {
let adapter = AnthropicAdapter::default();
let frame = WireFrame::Text(
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":42}}"#
.into(),
);
assert!(matches!(
adapter.classify(frame),
crate::providers::internal::wire::WireEvent::Corrupt(_)
));
}
#[test]
fn top_level_error_event_surfaces_as_a_provider_error() {
let adapter = AnthropicAdapter::default();
let frame = WireFrame::Text(
r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#.into(),
);
let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(frame)
else {
panic!("the error envelope must classify as a Known event");
};
let mut adapter = AnthropicAdapter::default();
let mut out = Vec::new();
adapter.interpret(event, &mut out);
assert_eq!(out.len(), 1, "the error envelope maps to one error item");
let Some(Err(error)) = out.pop() else {
panic!("the error envelope must surface as an Err item");
};
let body = error
.provider_response_body()
.expect("the provider's error payload must be preserved");
assert!(
body.contains("overloaded_error") && body.contains("Overloaded"),
"the full envelope must survive into the error body, got: {body}"
);
}
#[test]
fn message_start_with_null_message_is_a_known_noop() {
let adapter = AnthropicAdapter::default();
let frame = WireFrame::Text(r#"{"type":"message_start","message":null}"#.into());
let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(frame)
else {
panic!("null-message message_start must stay a known event");
};
let mut adapter = AnthropicAdapter::default();
let mut out = Vec::new();
adapter.interpret(event, &mut out);
assert!(out.is_empty(), "a message-less message_start is a no-op");
}
#[tokio::test]
async fn terminal_record_normalizes_stop_reason_usage_and_metadata() {
let raw_stream = stream! {
yield Ok(RawStreamingChoice::Message("hi".to_string()));
yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
usage: PartialUsage {
output_tokens: 5,
input_tokens: Some(3),
cache_creation_input_tokens: None,
cache_creation: None,
cache_read_input_tokens: Some(2),
output_tokens_details: None,
},
stop_reason: Some("max_tokens".to_string()),
stop_sequence: None,
message_id: Some("msg_1".to_string()),
model: Some(CLAUDE_OPUS_4_8.to_string()),
provider_request_id: None,
}));
};
let mut stream = crate::streaming::StreamingCompletionResponse::stream(
"anthropic",
to_stream_result(raw_stream),
);
while stream.next().await.is_some() {}
let terminal = stream.response.expect("expected a terminal record");
assert_eq!(terminal.provider, "anthropic");
assert_eq!(terminal.message_id.as_deref(), Some("msg_1"));
assert_eq!(terminal.model.as_deref(), Some(CLAUDE_OPUS_4_8));
assert_eq!(
terminal.finish_reason,
Some(crate::completion::FinishReason::Length)
);
assert_eq!(terminal.usage.input_tokens, 3);
assert_eq!(terminal.usage.output_tokens, 5);
assert_eq!(terminal.usage.cached_input_tokens, 2);
assert_eq!(terminal.usage.total_tokens, 10);
}
#[tokio::test]
async fn terminal_record_upgrades_end_turn_to_tool_calls_after_a_streamed_tool_call() {
let raw_stream = stream! {
yield Ok(RawStreamingChoice::ToolCall(RawStreamingToolCall::new(
"toolu_1".to_string(),
"add".to_string(),
json!({"x": 1}),
)));
yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
stop_reason: Some("end_turn".to_string()),
..Default::default()
}));
};
let mut stream = crate::streaming::StreamingCompletionResponse::stream(
"anthropic",
to_stream_result(raw_stream),
);
while stream.next().await.is_some() {}
let terminal = stream.response.expect("expected a terminal record");
assert_eq!(
terminal.finish_reason,
Some(crate::completion::FinishReason::ToolCalls)
);
}
#[tokio::test]
async fn unknown_stop_reason_survives_onto_the_terminal_record() {
let raw_stream = stream! {
yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
stop_reason: Some("pause_turn".to_string()),
..Default::default()
}));
};
let mut stream = crate::streaming::StreamingCompletionResponse::stream(
"anthropic",
to_stream_result(raw_stream),
);
while stream.next().await.is_some() {}
let terminal = stream.response.expect("expected a terminal record");
assert_eq!(
terminal.finish_reason,
Some(crate::completion::FinishReason::Other(
"pause_turn".to_owned()
))
);
}
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
mod terminal_emission {
use super::super::super::completion::CLAUDE_SONNET_4_6;
use crate::client::CompletionClient;
use crate::completion::CompletionModel as _;
use crate::providers::anthropic::Client;
use crate::streaming::StreamedAssistantContent;
use crate::test_utils::MockStreamingClient;
use futures::StreamExt;
const MESSAGE_START: &str = r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"output_tokens":0}}}"#;
const TEXT_START: &str =
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
const TEXT_DELTA: &str =
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}"#;
const MESSAGE_DELTA: &str = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":3}}"#;
fn sse(frames: &[&str]) -> bytes::Bytes {
bytes::Bytes::from(
frames
.iter()
.map(|frame| format!("data: {frame}\n\n"))
.collect::<String>(),
)
}
async fn collect(
sse_bytes: bytes::Bytes,
) -> (
Vec<String>,
bool,
bool,
crate::streaming::StreamingCompletionResponse,
) {
let client = Client::builder()
.api_key("test-key")
.http_client(MockStreamingClient { sse_bytes })
.build()
.expect("build client");
let model = client.completion_model(CLAUDE_SONNET_4_6);
let request = model.completion_request("hello").build();
let mut stream = crate::completion::CompletionModel::stream(&model, request)
.await
.expect("stream should open");
let mut texts = Vec::new();
let mut saw_error = false;
let mut saw_terminal = false;
while let Some(item) = stream.next().await {
match item {
Ok(StreamedAssistantContent::Text(text)) => texts.push(text.text),
Ok(StreamedAssistantContent::Final(_)) => saw_terminal = true,
Ok(_) => {}
Err(_) => saw_error = true,
}
}
(texts, saw_error, saw_terminal, stream)
}
#[tokio::test]
async fn truncated_stream_yields_content_but_no_terminal_record() {
let (texts, saw_error, saw_terminal, stream) =
collect(sse(&[MESSAGE_START, TEXT_START, TEXT_DELTA])).await;
assert_eq!(texts, ["hi"]);
assert!(!saw_error);
assert!(
!saw_terminal,
"EOF without message_delta must not synthesize a terminal record"
);
assert!(stream.response.is_none());
}
#[tokio::test]
async fn errored_stream_forwards_the_error_and_no_terminal_record() {
use crate::test_utils::SequencedStreamingHttpClient;
let client = Client::builder()
.api_key("test-key")
.http_client(SequencedStreamingHttpClient::new(vec![
Ok(sse(&[MESSAGE_START, TEXT_START, TEXT_DELTA])),
Err(crate::http_client::Error::InvalidStatusCodeWithMessage(
http::StatusCode::BAD_GATEWAY,
"connection reset".to_string(),
)),
]))
.build()
.expect("build client");
let model = client.completion_model(CLAUDE_SONNET_4_6);
let request = model.completion_request("hello").build();
let mut stream = crate::completion::CompletionModel::stream(&model, request)
.await
.expect("stream should open");
let mut texts = Vec::new();
let mut saw_error = false;
let mut saw_terminal = false;
while let Some(item) = stream.next().await {
match item {
Ok(StreamedAssistantContent::Text(text)) => texts.push(text.text),
Ok(StreamedAssistantContent::Final(_)) => saw_terminal = true,
Ok(_) => {}
Err(_) => saw_error = true,
}
}
assert_eq!(texts, ["hi"]);
assert!(saw_error, "the transport failure must reach the consumer");
assert!(
!saw_terminal,
"a failed stream must not synthesize a terminal record"
);
assert!(stream.response.is_none());
}
#[tokio::test]
async fn provider_error_event_stops_the_stream_before_a_later_terminal() {
const ERROR_EVENT: &str =
r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#;
let (texts, saw_error, saw_terminal, stream) = collect(sse(&[
MESSAGE_START,
TEXT_START,
TEXT_DELTA,
ERROR_EVENT,
MESSAGE_DELTA,
]))
.await;
assert_eq!(texts, ["hi"]);
assert!(saw_error, "the provider error must reach the consumer");
assert!(
!saw_terminal,
"a message_delta after an in-band provider error must not read as a completed turn"
);
assert!(stream.response.is_none());
}
#[tokio::test]
async fn input_tokens_prefer_the_terminal_delta_and_fall_back_to_message_start() {
fn message_start(input_tokens: usize) -> String {
format!(
r#"{{"type":"message_start","message":{{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{{"input_tokens":{input_tokens},"output_tokens":0}}}}}}"#
)
}
fn message_delta(input_tokens: usize) -> String {
format!(
r#"{{"type":"message_delta","delta":{{"stop_reason":"end_turn","stop_sequence":null}},"usage":{{"input_tokens":{input_tokens},"output_tokens":3}}}}"#
)
}
for (start, delta, expected, case) in [
(
message_start(0),
message_delta(9),
9,
"a gateway reporting the prompt size on message_delta must reach the consumer",
),
(
message_start(5),
MESSAGE_DELTA.to_owned(),
5,
"a delta without input_tokens falls back to message_start",
),
(
message_start(5),
message_delta(5),
5,
"agreeing frames report that count",
),
(
message_start(5),
message_delta(0),
5,
"a zero on the delta must not erase the message_start count",
),
] {
let (_texts, _saw_error, saw_terminal, stream) =
collect(sse(&[&start, TEXT_START, TEXT_DELTA, &delta])).await;
assert!(saw_terminal, "{case}: the turn must complete");
let terminal = stream.response.expect("terminal record");
assert_eq!(terminal.usage.input_tokens, expected, "{case}");
}
}
#[tokio::test]
async fn malformed_frame_then_eof_yields_error_and_no_terminal_record() {
let (texts, saw_error, saw_terminal, stream) =
collect(sse(&[MESSAGE_START, TEXT_START, TEXT_DELTA, "{not json"])).await;
assert_eq!(texts, ["hi"]);
assert!(saw_error, "the malformed frame must reach the consumer");
assert!(
!saw_terminal,
"a parse error followed by EOF must not read as a completed turn"
);
assert!(stream.response.is_none());
}
#[tokio::test]
async fn malformed_frame_then_real_terminal_still_completes_the_stream() {
let (texts, saw_error, saw_terminal, stream) = collect(sse(&[
MESSAGE_START,
TEXT_START,
TEXT_DELTA,
"{not json",
MESSAGE_DELTA,
]))
.await;
assert_eq!(texts, ["hi"]);
assert!(saw_error, "the malformed frame must reach the consumer");
assert!(
saw_terminal,
"a genuine message_delta after a parse error still completes the stream"
);
let terminal = stream.response.expect("terminal record");
assert_eq!(
terminal.finish_reason,
Some(crate::completion::FinishReason::Stop)
);
assert_eq!(terminal.message_id.as_deref(), Some("msg_1"));
}
#[tokio::test]
async fn terminal_raw_round_trips_into_the_terminal_type() {
const STOP_SEQUENCE_DELTA: &str = r#"{"type":"message_delta","delta":{"stop_reason":"stop_sequence","stop_sequence":"alpha"},"usage":{"output_tokens":3}}"#;
let client = Client::builder()
.api_key("test-key")
.http_client(MockStreamingClient {
sse_bytes: sse(&[MESSAGE_START, TEXT_START, TEXT_DELTA, STOP_SEQUENCE_DELTA]),
})
.build()
.expect("build client");
let model = client.completion_model(CLAUDE_SONNET_4_6);
let request = model.completion_request("hello").build();
let mut stream = crate::completion::CompletionModel::stream(&model, request)
.await
.expect("stream should open");
while let Some(item) = stream.next().await {
item.expect("stream item");
}
let terminal = stream.response.expect("terminal record");
let raw = &terminal.raw;
let typed: super::super::StreamingCompletionResponse =
serde_json::from_value(raw.clone()).expect("raw must deserialize");
assert_eq!(
serde_json::to_value(&typed).expect("re-serialize"),
*raw,
"the capture must be exactly what the terminal type serializes to"
);
assert_eq!(typed.stop_reason.as_deref(), Some("stop_sequence"));
assert_eq!(typed.stop_sequence.as_deref(), Some("alpha"));
assert_eq!(typed.message_id.as_deref(), Some("msg_1"));
let renormalized = crate::streaming::StreamFinal::from(("anthropic", typed));
assert_eq!(terminal.identity(), renormalized.identity());
assert_eq!(terminal.finish_reason, renormalized.finish_reason);
assert_eq!(terminal.model, renormalized.model);
assert_eq!(terminal.usage, renormalized.usage);
assert_eq!(
terminal.finish_reason,
Some(crate::completion::FinishReason::Stop)
);
assert_eq!(terminal.usage.output_tokens, 3);
}
}
}