use std::future::Future;
use std::pin::Pin;
use futures::stream::Stream;
use serde_json::Value;
use std::time::Duration;
use crate::api::ApiClient;
use crate::api::error::ApiError;
use crate::message::{Message, MessagePart, Role};
use crate::stream::{
DeltaPart, IndexedDelta, MessageDelta, MessageDeltaPayload, MessageMetadata, MessageStart,
PartStart, StreamEvent, StreamStopReason, Usage,
};
use crate::structured::ToolConstraint;
use crate::structured::tighten_json_schema;
use crate::tool::ToolSchema;
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
const ANTHROPIC_VERSION: &str = "2023-06-01";
pub(super) const DEFAULT_MAX_TOKENS: u32 = 8192;
pub struct AnthropicClient {
http: reqwest::Client,
api_key: String,
base_url: String,
model: std::sync::Mutex<String>,
max_tokens: u32,
}
impl AnthropicClient {
#[must_use]
pub fn builder() -> AnthropicClientBuilder {
AnthropicClientBuilder::default()
}
pub fn from_env() -> Result<Self, ApiError> {
let api_key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| ApiError::auth_invalid_key("ANTHROPIC_API_KEY not set"))?;
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Self::builder()
.with_api_key(api_key)
.with_base_url(base_url)
.with_model(model)
.build()
}
async fn post_messages(
http: &reqwest::Client,
url: &str,
api_key: &str,
body: &Value,
) -> Result<reqwest::Response, ApiError> {
let mut key_header = reqwest::header::HeaderValue::from_str(api_key)
.map_err(|e| ApiError::auth_invalid_key(format!("invalid api key header: {e}")))?;
key_header.set_sensitive(true);
super::post_json_checked(
http,
url,
&[
(
reqwest::header::HeaderName::from_static("x-api-key"),
key_header,
),
(
reqwest::header::HeaderName::from_static("anthropic-version"),
reqwest::header::HeaderValue::from_static(ANTHROPIC_VERSION),
),
],
body,
)
.await
}
fn messages_url(&self) -> String {
format!("{}/v1/messages", self.base_url)
}
fn build_response(raw: &Value) -> crate::api::NonStreamingResponse {
let mut parts: Vec<MessagePart> = Vec::new();
if let Some(blocks) = raw.get("content").and_then(|c| c.as_array()) {
for block in blocks {
match block.get("type").and_then(|t| t.as_str()) {
Some("text") => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
parts.push(MessagePart::text(text));
}
}
Some("tool_use") => {
let id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(Value::Null);
parts.push(MessagePart::tool_call(id, name, input));
}
_ => {}
}
}
}
let stop_reason = raw
.get("stop_reason")
.and_then(|r| r.as_str())
.and_then(StreamStopReason::from_api_str)
.unwrap_or(StreamStopReason::EndTurn);
let usage = extract_usage(raw);
crate::api::NonStreamingResponse {
message: Message::new(Role::Assistant, parts),
stop_reason,
usage,
}
}
}
fn extract_usage(raw: &Value) -> Option<Usage> {
extract_usage_object(raw.get("usage")?)
}
fn extract_usage_object(usage: &Value) -> Option<Usage> {
let input = usage
.get("input_tokens")
.and_then(Value::as_u64)
.map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX));
let output = usage
.get("output_tokens")
.and_then(Value::as_u64)
.map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX));
(input > 0 || output > 0).then(|| Usage::new(input, output))
}
impl ApiClient for AnthropicClient {
fn model(&self) -> String {
crate::error::recover_guard(self.model.lock()).clone()
}
fn base_url(&self) -> String {
self.base_url.clone()
}
fn set_model(&self, model: &str) -> bool {
if model.trim().is_empty() {
return false;
}
*crate::error::recover_guard(self.model.lock()) = model.to_string();
true
}
fn stream_messages(
&self,
request: &crate::api::StreamRequest,
) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
self.stream_messages_with_options(request, crate::structured::RequestOptions::default())
}
fn create_message(
&self,
request: &crate::api::StreamRequest,
) -> Pin<Box<dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_>>
{
self.create_message_with_options(request, crate::structured::RequestOptions::default())
}
fn stream_messages_with_options(
&self,
request: &crate::api::StreamRequest,
options: crate::structured::RequestOptions,
) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
#[cfg(feature = "grammar")]
if matches!(&options.tool_constraint, ToolConstraint::Grammar(_)) {
return Box::pin(futures::stream::once(async move {
Err(grammar_unsupported_error())
}));
}
if options.response_format.as_ref().is_some_and(|rf| rf.strict) {
return Box::pin(futures::stream::once(async move {
Err(strict_unsupported_error("Anthropic"))
}));
}
let system = request.system.clone();
let tools = request.tools.clone();
let model = options
.model
.clone()
.unwrap_or_else(|| crate::error::recover_guard(self.model.lock()).clone());
let rf = options.response_format.as_ref();
let body = build_request_body(
&RequestBodySpec {
model: &model,
messages: &request.messages,
system: system.as_deref(),
tools: tools.as_deref(),
response_format: rf,
tool_constraint: &options.tool_constraint,
},
true,
self.max_tokens,
);
let url = self.messages_url();
let api_key = self.api_key.clone();
let http = self.http.clone();
Box::pin(async_stream::try_stream! {
let resp = Self::post_messages(&http, &url, &api_key, &body).await?;
let mut sse = SseReader::from_response(resp);
let mut emitter = StreamEmitter::default();
while let Some((event_type, data)) = sse.next_event().await? {
emitter.process_event(&event_type, data);
if emitter.error_recorded() {
break;
}
for ev in emitter.drain() {
yield ev;
}
}
for ev in emitter.finish()? {
yield ev;
}
})
}
fn create_message_with_options(
&self,
request: &crate::api::StreamRequest,
options: crate::structured::RequestOptions,
) -> Pin<Box<dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_>>
{
#[cfg(feature = "grammar")]
if matches!(&options.tool_constraint, ToolConstraint::Grammar(_)) {
return Box::pin(async move { Err(grammar_unsupported_error()) });
}
if options.response_format.as_ref().is_some_and(|rf| rf.strict) {
return Box::pin(async move { Err(strict_unsupported_error("Anthropic")) });
}
let system = request.system.clone();
let tools = request.tools.clone();
let model = options
.model
.clone()
.unwrap_or_else(|| crate::error::recover_guard(self.model.lock()).clone());
let rf = options.response_format.as_ref();
let body = build_request_body(
&RequestBodySpec {
model: &model,
messages: &request.messages,
system: system.as_deref(),
tools: tools.as_deref(),
response_format: rf,
tool_constraint: &options.tool_constraint,
},
false,
self.max_tokens,
);
let url = self.messages_url();
Box::pin(async move {
let resp = Self::post_messages(&self.http, &url, &self.api_key, &body).await?;
let resp = super::read_bounded_body(resp).await?;
let raw = serde_json::from_slice::<Value>(&resp)
.map_err(|e| ApiError::http(e.to_string()))?;
Ok(Self::build_response(&raw))
})
}
}
pub struct AnthropicClientBuilder {
api_key: Option<String>,
base_url: String,
model: String,
max_tokens: u32,
http: super::HttpClientConfig,
}
impl Default for AnthropicClientBuilder {
fn default() -> Self {
Self {
api_key: None,
base_url: DEFAULT_BASE_URL.into(),
model: DEFAULT_MODEL.into(),
max_tokens: DEFAULT_MAX_TOKENS,
http: super::HttpClientConfig::default(),
}
}
}
impl AnthropicClientBuilder {
#[must_use]
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into().trim_end_matches('/').to_string();
self
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
#[must_use]
pub fn with_max_tokens(mut self, tokens: u32) -> Self {
self.max_tokens = tokens;
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.http = self.http.with_timeout(timeout);
self
}
#[must_use]
pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.http = self.http.with_connect_timeout(timeout);
self
}
#[must_use]
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http = self.http.with_http_client(client);
self
}
#[must_use]
pub fn with_pool_max_idle_per_host(mut self, n: usize) -> Self {
self.http = self.http.with_pool_max_idle_per_host(n);
self
}
#[must_use]
pub fn with_pool_idle_timeout(mut self, d: Duration) -> Self {
self.http = self.http.with_pool_idle_timeout(d);
self
}
#[must_use]
pub fn with_tcp_keepalive(mut self, d: Duration) -> Self {
self.http = self.http.with_tcp_keepalive(d);
self
}
#[must_use]
pub fn with_tcp_nodelay(mut self, enabled: bool) -> Self {
self.http = self.http.with_tcp_nodelay(enabled);
self
}
pub fn build(self) -> Result<AnthropicClient, ApiError> {
let api_key = self
.api_key
.ok_or_else(|| ApiError::auth_invalid_key("API key not provided"))?;
let http = self.http.build()?;
if self.max_tokens == 0 {
return Err(ApiError::config(
"max_tokens must be at least 1 — the Messages API rejects 0",
));
}
Ok(AnthropicClient {
http,
api_key,
base_url: self.base_url,
model: std::sync::Mutex::new(self.model),
max_tokens: self.max_tokens,
})
}
}
struct RequestBodySpec<'a> {
model: &'a str,
messages: &'a [Message],
system: Option<&'a str>,
tools: Option<&'a [ToolSchema]>,
response_format: Option<&'a crate::structured::ResponseFormat>,
tool_constraint: &'a ToolConstraint,
}
#[cfg(feature = "grammar")]
fn grammar_unsupported_error() -> ApiError {
ApiError::config_validation(
"the Anthropic Messages API has no grammar-constrained decoding; \
use ToolConstraint::Strict or an OpenAI-compatible endpoint",
)
}
fn strict_unsupported_error(provider: &str) -> ApiError {
ApiError::config_validation(format!(
"the {provider} API cannot express response_format.strict; the \
request would be served non-strict — use an OpenAI-compatible \
endpoint or drop strict"
))
}
fn build_request_body(spec: &RequestBodySpec<'_>, stream: bool, max_tokens: u32) -> Value {
let RequestBodySpec {
model,
messages,
system,
tools,
response_format,
tool_constraint,
} = spec;
let tools = tools.filter(|t| !t.is_empty());
let (non_system, effective_system) = super::fold_system_messages(messages, *system);
let msgs: Vec<Value> = non_system.iter().map(|m| convert_message(m)).collect();
let effective_system = effective_system.unwrap_or_default();
let (tools_val, tool_choice) = if let Some(rf) = response_format {
let forced_tool = serde_json::json!({
"name": rf.name,
"description": "Return the result via this tool",
"input_schema": rf.schema,
});
let choice = serde_json::json!({
"type": "tool",
"name": rf.name,
});
(Some(vec![forced_tool]), Some(choice))
} else {
let strict = matches!(tool_constraint, ToolConstraint::Strict);
(tools.map(|t| convert_tools(t, strict)), None)
};
let mut body = serde_json::json!({
"model": model,
"max_tokens": max_tokens,
"messages": msgs,
"system": effective_system,
"stream": stream,
"tools": tools_val,
});
if tools_val.is_none()
&& let Some(obj) = body.as_object_mut()
{
obj.remove("tools");
}
if let Some(choice) = tool_choice
&& let Some(obj) = body.as_object_mut()
{
obj.insert("tool_choice".to_string(), choice);
}
body
}
pub(super) fn convert_message(m: &Message) -> Value {
let role = match m.role {
Role::User | Role::System => "user",
Role::Assistant => "assistant",
};
let mut text_parts: Vec<&str> = Vec::new();
let mut tool_calls: Vec<Value> = Vec::new();
let mut tool_results: Vec<Value> = Vec::new();
for p in &m.parts {
match p {
MessagePart::Text { text } => text_parts.push(text.as_str()),
MessagePart::ToolCall { id, name, input } => {
tool_calls.push(serde_json::json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input,
}));
}
MessagePart::ToolResult {
call_id,
output,
is_error,
..
} => {
let mut block = serde_json::json!({
"type": "tool_result",
"tool_use_id": call_id,
"content": output.to_string(),
});
if matches!(is_error, Some(true))
&& let Some(obj) = block.as_object_mut()
{
obj.insert("is_error".to_string(), Value::Bool(true));
}
tool_results.push(block);
}
MessagePart::Image { .. } => {}
}
}
let has_tool_content = !(tool_calls.is_empty() && tool_results.is_empty());
if !has_tool_content && text_parts.len() == 1 {
let text = text_parts.first().copied().unwrap_or_default();
serde_json::json!({ "role": role, "content": text })
} else if !has_tool_content {
let blocks: Vec<Value> = text_parts
.iter()
.map(|t| serde_json::json!({"type": "text", "text": t}))
.collect();
serde_json::json!({ "role": role, "content": blocks })
} else {
let mut blocks: Vec<Value> = Vec::new();
if !text_parts.is_empty() {
let text = text_parts.join("");
blocks.push(serde_json::json!({"type": "text", "text": text}));
}
blocks.extend(tool_calls);
blocks.extend(tool_results);
serde_json::json!({ "role": role, "content": blocks })
}
}
pub(super) fn convert_tools(tools: &[ToolSchema], strict: bool) -> Vec<Value> {
tools
.iter()
.map(|t| {
let input_schema = if strict {
tighten_json_schema(&t.input_schema)
} else {
t.input_schema.clone()
};
serde_json::json!({
"name": t.tool,
"description": &t.description,
"input_schema": input_schema,
})
})
.collect()
}
use super::sse::SseReader;
impl SseReader {
async fn next_event(&mut self) -> Result<Option<(String, Option<Value>)>, ApiError> {
let mut event_type = String::new();
let mut data = String::new();
let mut have_event = false;
loop {
while let Some(line) = self.take_line()? {
if line.is_empty() {
if have_event {
let parsed = Self::parse_event_data(&data, &event_type);
return Ok(Some((event_type, parsed)));
}
continue;
}
if let Some(ev) = super::sse_event_type(&line) {
event_type = ev.into();
have_event = true;
} else if let Some(d) = super::sse_data_payload(&line) {
if data.is_empty() {
data = d.into();
} else {
data.push('\n');
data.push_str(d);
}
have_event = true;
}
}
if self.next_chunk().await?.is_none() {
if have_event {
let parsed = Self::parse_event_data(&data, &event_type);
return Ok(Some((event_type, parsed)));
}
return Ok(None);
}
}
}
fn parse_event_data(data: &str, event_type: &str) -> Option<Value> {
if data.is_empty() {
return None;
}
match serde_json::from_str(data) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(
error = %e,
event_type = %event_type,
data_len = data.len(),
"failed to parse Anthropic SSE data, skipping"
);
None
}
}
}
}
#[derive(Default)]
pub(super) struct StreamEmitter {
started: bool,
text_index: Option<usize>,
tool_parts_open: usize,
current_tool_index: Option<usize>,
thinking_part_open: bool,
thinking_index: Option<usize>,
finished: bool,
error: Option<ApiError>,
start_usage: Usage,
pending: Vec<StreamEvent>,
}
impl StreamEmitter {
pub(super) fn process_event(&mut self, event_type: &str, data: Option<Value>) {
if self.finished {
return;
}
match event_type {
"message_start" => self.on_message_start(data.as_ref()),
"content_block_start" => self.on_block_start(data),
"content_block_delta" => self.on_block_delta(data),
"content_block_stop" => self.on_block_stop(data),
"message_delta" => self.on_message_delta(data),
"message_stop" => self.on_message_stop(),
"error" => self.on_error(data.as_ref()),
_ => {}
}
}
fn on_message_start(&mut self, data: Option<&Value>) {
if self.started {
return;
}
self.started = true;
let (id, model) = match data {
Some(v) => (
v.pointer("/message/id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
v.pointer("/message/model")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
),
None => (String::new(), String::new()),
};
self.start_usage = data
.and_then(|v| v.pointer("/message/usage"))
.and_then(extract_usage_object)
.unwrap_or_default();
self.push(StreamEvent::MessageStart(MessageStart {
message: MessageMetadata {
id,
role: "assistant".into(),
model,
},
}));
}
fn on_block_start(&mut self, data: Option<Value>) {
let Some(v) = data else { return };
let block_type = v.pointer("/content_block/type").and_then(Value::as_str);
let index = v
.pointer("/index")
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(0);
match block_type {
Some("tool_use") => {
let id = v
.pointer("/content_block/id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let name = v
.pointer("/content_block/name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
self.push(StreamEvent::PartStart(PartStart {
index,
part: Some(MessagePart::ToolCall {
id,
name,
input: Value::Null,
}),
}));
self.current_tool_index = Some(index);
self.tool_parts_open = self.tool_parts_open.saturating_add(1);
}
Some("text") => {
self.text_index = Some(index);
self.push(StreamEvent::PartStart(PartStart {
index,
part: Some(MessagePart::text("")),
}));
}
Some("thinking" | "redacted_thinking") => {
self.thinking_part_open = true;
self.thinking_index = Some(index);
self.push(StreamEvent::PartStart(PartStart { index, part: None }));
if matches!(block_type, Some("redacted_thinking")) {
self.push(StreamEvent::IndexedDelta(IndexedDelta {
index,
delta: DeltaPart::Thinking {
text: String::new(),
},
}));
}
}
_ => {}
}
}
fn on_block_delta(&mut self, data: Option<Value>) {
let Some(v) = data else { return };
let delta_type = v.pointer("/delta/type").and_then(Value::as_str);
match delta_type {
Some("text_delta") => {
let text = v
.pointer("/delta/text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if !text.is_empty() {
let text_index = self.text_index.unwrap_or(0);
self.push(StreamEvent::IndexedDelta(IndexedDelta {
index: text_index,
delta: DeltaPart::Text { text },
}));
}
}
Some("input_json_delta") => {
let json = v
.pointer("/delta/partial_json")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if !json.is_empty() {
let tool_index = self.current_tool_index.unwrap_or(0);
self.push(StreamEvent::IndexedDelta(IndexedDelta {
index: tool_index,
delta: DeltaPart::InputJson { partial_json: json },
}));
}
}
Some("thinking_delta") => {
let text = v
.pointer("/delta/thinking")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if !text.is_empty() {
let thinking_index = self.thinking_index.unwrap_or(0);
self.push(StreamEvent::IndexedDelta(IndexedDelta {
index: thinking_index,
delta: DeltaPart::Thinking { text },
}));
}
}
_ => {}
}
}
fn on_block_stop(&mut self, _data: Option<Value>) {
if let Some(index) = self.text_index.take() {
self.push(StreamEvent::PartStop { index: Some(index) });
} else if self.thinking_part_open {
self.thinking_part_open = false;
let index = self.thinking_index.take();
self.push(StreamEvent::PartStop { index });
} else if self.tool_parts_open > 0 {
self.tool_parts_open = self.tool_parts_open.saturating_sub(1);
let index = self.current_tool_index.take();
self.push(StreamEvent::PartStop { index });
}
}
fn on_message_delta(&mut self, data: Option<Value>) {
if self.finished {
return;
}
let Some(v) = data else { return };
let stop_reason = v
.pointer("/delta/stop_reason")
.and_then(Value::as_str)
.map(|s| StreamStopReason::from_api_str(s).unwrap_or(StreamStopReason::EndTurn));
let delta_in = v
.pointer("/usage/input_tokens")
.and_then(Value::as_u64)
.map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX));
let delta_out = v
.pointer("/usage/output_tokens")
.and_then(Value::as_u64)
.map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX));
let in_tok = delta_in.max(self.start_usage.input_tokens);
let out_tok = delta_out.max(self.start_usage.output_tokens);
let usage = if in_tok > 0 || out_tok > 0 {
Some(Usage::new(in_tok, out_tok))
} else {
None
};
self.push(StreamEvent::MessageDelta(MessageDelta {
delta: MessageDeltaPayload {
stop_reason: stop_reason.map(|r| r.to_api_str().into()),
},
usage,
}));
}
fn on_message_stop(&mut self) {
self.finished = true;
if self.thinking_part_open {
self.push(StreamEvent::PartStop {
index: self.thinking_index,
});
}
if self.text_index.is_some() {
self.push(StreamEvent::PartStop {
index: self.text_index,
});
}
for _ in 0..self.tool_parts_open {
self.push(StreamEvent::PartStop { index: None });
}
self.thinking_part_open = false;
self.thinking_index = None;
self.tool_parts_open = 0;
self.text_index = None;
self.push(StreamEvent::MessageStop);
}
fn on_error(&mut self, data: Option<&Value>) {
if self.error.is_some() {
return;
}
let (kind, message) = match data {
Some(v) => (
v.pointer("/error/type")
.and_then(Value::as_str)
.unwrap_or("error")
.to_string(),
v.pointer("/error/message")
.and_then(Value::as_str)
.unwrap_or("stream failed")
.to_string(),
),
None => ("error".to_string(), "stream failed".to_string()),
};
let detail = format!("{kind}: {message}");
self.error = Some(
if matches!(kind.as_str(), "rate_limit_error" | "overloaded_error") {
ApiError::RateLimit {
retry_after: None,
message: detail,
}
} else {
ApiError::api(detail)
},
);
}
pub(super) fn finish(&mut self) -> Result<Vec<StreamEvent>, ApiError> {
if let Some(err) = self.error.take() {
return Err(err);
}
Ok(self.drain())
}
fn error_recorded(&self) -> bool {
self.error.is_some()
}
pub(super) fn drain(&mut self) -> Vec<StreamEvent> {
std::mem::take(&mut self.pending)
}
fn push(&mut self, ev: StreamEvent) {
self.pending.push(ev);
}
}
#[cfg(test)]
mod tests {
#[test]
fn emitter_ignores_events_after_message_stop() {
let mut emitter = StreamEmitter::default();
emitter.process_event(
"message_stop",
Some(serde_json::json!({"type": "message_stop"})),
);
assert!(!emitter.drain().is_empty(), "message_stop emits the stop");
emitter.process_event(
"content_block_delta",
Some(serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "late"},
})),
);
assert!(
emitter.drain().is_empty(),
"a desynced stream must not append parts after the stop"
);
assert!(
emitter.finish().unwrap_or_default().is_empty(),
"nothing queued behind the guard"
);
emitter.process_event(
"error",
Some(serde_json::json!({
"type": "error",
"error": {"type": "overloaded_error", "message": "late"}
})),
);
assert!(
emitter.finish().is_ok(),
"a late error after a completed message is swallowed — the \
stream already terminated cleanly, the contradiction is not \
allowed to fail it"
);
}
use super::*;
use crate::message::{Message, MessagePart, Role, ToolContent};
#[tokio::test]
async fn strict_response_format_is_rejected_loudly() {
use crate::structured::{RequestOptions, ResponseFormat};
let client = AnthropicClient::builder()
.with_api_key("k")
.with_base_url("http://localhost:1".to_string())
.build()
.unwrap();
let rf = ResponseFormat {
name: "out".to_string(),
schema: serde_json::json!({"type": "object"}),
strict: true,
};
let err = client
.create_message_with_options(
&crate::api::StreamRequest::new(vec![]),
RequestOptions::default().with_response_format(rf),
)
.await
.expect_err("strict must fail fast, not be silently dropped");
assert!(
err.to_string().contains("strict"),
"the error names the dropped field: {err}"
);
assert_eq!(
err.code(),
crate::api::error::ErrorCode::ConfigValidationError,
"a semantic capability rejection classifies as a validation \
failure, not a parse error"
);
}
#[test]
fn request_body_user_text_single_string() {
let msgs = vec![Message::user("hello")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[0]["content"], "hello");
}
#[test]
fn request_body_includes_system() {
let msgs = vec![Message::user("hi")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: Some("be brief"),
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
assert_eq!(body["system"], "be brief");
}
#[test]
fn request_body_system_empty_when_none() {
let msgs = vec![Message::user("hi")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
assert_eq!(body["system"], "");
}
#[test]
fn request_body_model() {
let msgs = vec![Message::user("hi")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-sonnet-4",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
assert_eq!(body["model"], "claude-sonnet-4");
}
#[test]
fn request_body_max_tokens() {
let msgs = vec![Message::user("hi")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
assert_eq!(body["max_tokens"], DEFAULT_MAX_TOKENS);
}
#[test]
fn request_body_user_role() {
let msgs = vec![Message::user("hi")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
assert_eq!(body["messages"][0]["role"], "user");
}
#[test]
fn request_body_assistant_role() {
let msgs = vec![Message::new(
Role::Assistant,
vec![MessagePart::text("hello")],
)];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
assert_eq!(body["messages"][0]["role"], "assistant");
assert_eq!(body["messages"][0]["content"], "hello");
}
#[test]
fn request_body_assistant_tool_calls() {
let msgs = vec![Message::new(
Role::Assistant,
vec![MessagePart::ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({"msg": "hi"}),
}],
)];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let msg = &body["messages"][0];
assert_eq!(msg["role"], "assistant");
let content = msg["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "tool_use");
assert_eq!(content[0]["id"], "call_1");
assert_eq!(content[0]["name"], "echo");
assert_eq!(content[0]["input"]["msg"], "hi");
}
#[test]
fn request_body_tool_result() {
let msgs = vec![Message::new(
Role::User,
vec![MessagePart::ToolResult {
call_id: "call_1".into(),
name: "echo".into(),
output: ToolContent::from_string("result text"),
is_error: None,
}],
)];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let msg = &body["messages"][0];
assert_eq!(msg["role"], "user");
let content = msg["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "call_1");
assert_eq!(content[0]["content"], "result text");
}
#[test]
fn request_body_includes_tools() {
let msgs = vec![Message::user("hi")];
let tools = vec![ToolSchema {
tool: "search".into(),
description: "Search the web".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: Some(&tools),
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let tools_arr = body["tools"].as_array().unwrap();
assert_eq!(tools_arr.len(), 1);
assert_eq!(tools_arr[0]["name"], "search");
assert_eq!(tools_arr[0]["description"], "Search the web");
assert_eq!(tools_arr[0]["input_schema"]["type"], "object");
}
#[test]
fn request_body_tools_absent_when_none() {
let msgs = vec![Message::user("hi")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
assert!(body.get("tools").is_none());
}
#[test]
fn request_body_assistant_with_text_and_tool_call() {
let msgs = vec![Message::new(
Role::Assistant,
vec![
MessagePart::text("Let me search."),
MessagePart::ToolCall {
id: "call_1".into(),
name: "search".into(),
input: serde_json::json!({"q": "rust"}),
},
],
)];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let msg = &body["messages"][0];
assert_eq!(msg["role"], "assistant");
let content = msg["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "tool_use");
}
#[test]
fn request_body_multiple_messages() {
let msgs = vec![
Message::user("hello"),
Message::new(Role::Assistant, vec![MessagePart::text("hi")]),
Message::user("bye"),
];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[2]["role"], "user");
}
#[test]
fn convert_tools_shape() {
let tools = vec![ToolSchema {
tool: "calc".into(),
description: "Calculate".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let out = convert_tools(&tools, false);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["name"], "calc");
assert_eq!(out[0]["description"], "Calculate");
}
#[test]
fn builder_requires_api_key() {
let result = AnthropicClient::builder().build();
assert!(result.is_err());
}
#[test]
fn builder_succeeds_with_key() {
let client = AnthropicClient::builder()
.with_api_key("sk-test")
.build()
.unwrap();
assert_eq!(client.model(), DEFAULT_MODEL);
}
#[test]
fn builder_custom_base_url_and_model() {
let client = AnthropicClient::builder()
.with_api_key("sk-test")
.with_base_url("https://custom.example.com")
.with_model("claude-3-haiku")
.build()
.unwrap();
assert_eq!(client.model(), "claude-3-haiku");
}
#[test]
fn emitter_message_start() {
let mut em = StreamEmitter::default();
let data = serde_json::json!({
"message": {"id": "msg_1", "model": "claude-3"}
});
em.on_message_start(Some(&data));
let events = em.drain();
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::MessageStart(_)))
);
}
#[test]
fn emitter_text_delta() {
let mut em = StreamEmitter::default();
em.on_block_start(Some(serde_json::json!({
"index": 0,
"content_block": {"type": "text"}
})));
em.drain();
em.on_block_delta(Some(serde_json::json!({
"delta": {"type": "text_delta", "text": "hi"}
})));
let events = em.drain();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::IndexedDelta(_)));
}
#[test]
fn emitter_text_after_tool_uses_server_index_not_zero() {
let mut em = StreamEmitter::default();
em.on_block_start(Some(serde_json::json!({
"index": 0,
"content_block": {"type": "tool_use", "id": "t1", "name": "echo"}
})));
em.drain();
em.on_block_start(Some(serde_json::json!({
"index": 1,
"content_block": {"type": "text"}
})));
let starts = em.drain();
let text_start = starts
.iter()
.find(|e| matches!(e, StreamEvent::PartStart(ps) if ps.index == 1))
.expect("text PartStart must carry the server index 1");
em.on_block_delta(Some(serde_json::json!({
"delta": {"type": "text_delta", "text": "after"}
})));
let deltas = em.drain();
match &deltas[0] {
StreamEvent::IndexedDelta(d) => {
assert_eq!(d.index, 1, "text delta must use the server index, not 0");
}
other => panic!("expected IndexedDelta, got {other:?}"),
}
let StreamEvent::PartStart(ps) = text_start else {
panic!("matched event must be a PartStart");
};
assert!(
ps.part
.as_ref()
.is_some_and(crate::message::MessagePart::is_text)
);
}
#[test]
fn emitter_tool_use_block() {
let mut em = StreamEmitter::default();
em.on_block_start(Some(serde_json::json!({
"index": 1,
"content_block": {"type": "tool_use", "id": "t1", "name": "echo"}
})));
let events = em.drain();
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::PartStart(_)))
);
assert_eq!(em.tool_parts_open, 1);
em.on_block_delta(Some(serde_json::json!({
"delta": {"type": "input_json_delta", "partial_json": "{\"a\":"}
})));
let events2 = em.drain();
assert_eq!(events2.len(), 1);
}
#[test]
fn emitter_block_stop_closes_text() {
let mut em = StreamEmitter::default();
em.text_index = Some(0);
em.on_block_stop(None);
let events = em.drain();
assert!(matches!(events[0], StreamEvent::PartStop { .. }));
assert!(em.text_index.is_none());
}
#[test]
fn emitter_message_delta_with_usage() {
let mut em = StreamEmitter::default();
em.on_message_delta(Some(serde_json::json!({
"delta": {"stop_reason": "end_turn"},
"usage": {"input_tokens": 10, "output_tokens": 20}
})));
let events = em.drain();
if let StreamEvent::MessageDelta(md) = &events[0] {
assert_eq!(md.delta.stop_reason.as_deref(), Some("end_turn"));
assert_eq!(md.usage.as_ref().unwrap().input_tokens, 10);
assert_eq!(md.usage.as_ref().unwrap().output_tokens, 20);
} else {
panic!("expected MessageDelta");
}
}
#[test]
fn emitter_message_stop_closes_parts() {
let mut em = StreamEmitter::default();
em.text_index = Some(0);
em.tool_parts_open = 2;
em.on_message_stop();
let events = em.drain();
assert_eq!(events.len(), 4);
assert!(matches!(events[0], StreamEvent::PartStop { .. }));
assert!(matches!(events[1], StreamEvent::PartStop { .. }));
assert!(matches!(events[2], StreamEvent::PartStop { .. }));
assert!(
matches!(events.last(), Some(StreamEvent::MessageStop)),
"message_stop must emit the terminal MessageStop after the PartStops: {events:?}"
);
}
#[test]
fn emitter_message_stop_then_finish_no_duplicate() {
let mut em = StreamEmitter::default();
em.started = true;
em.on_message_stop();
let after_stop = em.drain();
assert!(
after_stop
.iter()
.any(|e| matches!(e, StreamEvent::MessageStop))
);
let after_finish = em.finish().expect("no recorded error");
assert!(
after_finish
.iter()
.all(|e| !matches!(e, StreamEvent::MessageStop)),
"finish() must not emit a second MessageStop after on_message_stop: {after_finish:?}"
);
}
#[test]
fn emitter_finish_emits_no_stop_without_message_stop() {
let mut em = StreamEmitter::default();
em.started = true;
em.finished = false;
let events = em.finish().expect("no recorded error");
assert!(
events
.iter()
.all(|e| !matches!(e, StreamEvent::MessageStop)),
"a stream that ends without message_stop is truncated — no synthetic stop may dress it up"
);
}
#[test]
fn emitter_finish_noop_if_already_stopped() {
let mut em = StreamEmitter::default();
em.started = true;
em.finished = true;
let events = em.finish().expect("no recorded error");
assert!(events.is_empty());
}
#[test]
fn midstream_error_event_surfaces_as_an_error() {
let mut em = StreamEmitter::default();
em.process_event("message_start", Some(serde_json::json!({})));
em.process_event(
"content_block_start",
Some(serde_json::json!({
"index": 0,
"content_block": {"type": "text", "text": ""}
})),
);
em.process_event(
"content_block_delta",
Some(serde_json::json!({
"index": 0,
"delta": {"type": "text_delta", "text": "partial"}
})),
);
em.process_event(
"error",
Some(serde_json::json!({
"type": "error",
"error": {"type": "overloaded_error", "message": "Overloaded"}
})),
);
assert!(
em.error_recorded(),
"an event: error mid-stream must be recorded"
);
let drained = em.drain();
assert!(
drained
.iter()
.all(|e| !matches!(e, StreamEvent::MessageStop)),
"no clean MessageStop may be emitted alongside the failure: {drained:?}"
);
let err = em
.finish()
.expect_err("finish must surface the recorded error");
assert!(
err.to_string().contains("overloaded_error"),
"the terminal error must name the provider's type: {err}"
);
assert!(
err.to_string().contains("Overloaded"),
"the terminal error must carry the provider's message: {err}"
);
}
#[test]
fn builder_rejects_zero_max_tokens() {
let built = AnthropicClient::builder()
.with_api_key("test")
.with_max_tokens(0)
.build();
let Err(err) = built else {
panic!("a zero max_tokens must fail at build time")
};
assert!(
err.to_string().contains("max_tokens"),
"the error must name the field: {err}"
);
}
#[cfg(feature = "grammar")]
#[tokio::test]
async fn grammar_constraint_errors_loudly() {
let client = AnthropicClient::builder()
.with_api_key("test")
.build()
.unwrap();
let opts = crate::structured::RequestOptions::default().with_tool_constraint(
crate::structured::ToolConstraint::Grammar(std::sync::Arc::new(
crate::provider::grammar::JsonSchemaGrammar::from_schemas(&[]),
)),
);
let mut stream =
client.stream_messages_with_options(&crate::api::StreamRequest::new(vec![]), opts);
let first = futures::StreamExt::next(&mut stream).await;
assert!(
matches!(&first, Some(Err(err)) if err.to_string().contains("grammar")),
"a grammar constraint must be rejected, not silently downgraded: {first:?}"
);
}
#[test]
fn emitter_downward_delta_revision_loses_to_the_start_latch() {
let mut em = StreamEmitter::default();
em.process_event(
"message_start",
Some(serde_json::json!({
"type": "message_start",
"message": {"usage": {"input_tokens": 25, "output_tokens": 1}}
})),
);
em.drain();
em.process_event(
"message_delta",
Some(serde_json::json!({
"type": "message_delta",
"delta": {"stop_reason": "end_turn"},
"usage": {"input_tokens": 12, "output_tokens": 5}
})),
);
let events = em.drain();
let usage = events
.iter()
.find_map(|e| match e {
StreamEvent::MessageDelta(MessageDelta { usage, .. }) => *usage,
_ => None,
})
.expect("the terminal delta carries usage");
assert_eq!(
usage.input_tokens, 25,
"the merge is max-by-assumption: a downward revision loses to the latched start value"
);
assert_eq!(usage.output_tokens, 5);
}
#[test]
fn midstream_rate_limit_family_errors_classify_as_rate_limit() {
for kind in ["rate_limit_error", "overloaded_error"] {
let mut em = StreamEmitter::default();
em.process_event(
"error",
Some(serde_json::json!({
"type": "error",
"error": {"type": kind, "message": "slow down"}
})),
);
let err = em
.finish()
.expect_err("finish must surface the recorded error");
assert!(
matches!(err, ApiError::RateLimit { .. }),
"{kind} is the SSE form of a rate-limit status and must classify as RateLimit"
);
}
let mut em = StreamEmitter::default();
em.process_event(
"error",
Some(serde_json::json!({
"type": "error",
"error": {"type": "invalid_request_error", "message": "bad shape"}
})),
);
let err = em
.finish()
.expect_err("finish must surface the recorded error");
assert!(
!matches!(err, ApiError::RateLimit { .. }),
"non-rate-limit errors stay provider errors: {err}"
);
}
#[test]
fn sse_reader_take_line_extracts_newline() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "event: ping\n".into(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
assert_eq!(reader.take_line().unwrap().unwrap(), "event: ping");
assert!(reader.buf.is_empty());
}
#[test]
fn sse_reader_take_line_none_without_newline() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "partial".into(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
assert!(reader.take_line().unwrap().is_none());
}
#[test]
fn sse_reader_take_line_multiple() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "line1\nline2\n".into(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
assert_eq!(reader.take_line().unwrap().unwrap(), "line1");
assert_eq!(reader.take_line().unwrap().unwrap(), "line2");
}
#[test]
fn sse_reader_take_line_trims_cr() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "data: hi\r\n".into(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
assert_eq!(reader.take_line().unwrap().unwrap(), "data: hi");
}
#[test]
fn builder_timeouts_applied_on_build() {
let client = AnthropicClient::builder()
.with_api_key("sk-test")
.with_timeout(Duration::from_mins(3))
.with_connect_timeout(Duration::from_secs(15))
.build();
assert!(client.is_ok(), "build should succeed with valid timeouts");
}
#[tokio::test]
async fn sse_reader_take_line_splits_on_newline() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "event: message_start\ndata: {}\n\n"
.to_string()
.into_bytes(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
assert_eq!(
reader.take_line().unwrap(),
Some("event: message_start".to_string())
);
assert_eq!(reader.take_line().unwrap(), Some("data: {}".to_string()));
assert_eq!(reader.take_line().unwrap(), Some(String::new()));
}
#[tokio::test]
async fn sse_reader_next_event_extracts_payload() {
let chunk = "event: content_block_delta\ndata: {\"type\":\"text_delta\"}\n\n";
let stream =
futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(chunk.to_string().into())]);
let mut reader = SseReader {
bytes: Box::pin(stream),
buf: Vec::new(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
let result = reader.next_event().await.unwrap();
assert!(result.is_some());
let (event_type, data) = result.unwrap();
assert_eq!(event_type, "content_block_delta");
assert!(data.is_some());
}
#[tokio::test]
async fn sse_reader_next_event_concatenates_multiline_data() {
let chunk = "event: content_block_delta\ndata: {\"type\":\"text_delta\",\ndata: \"text\":\"hello\"}\n\n";
let stream =
futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(chunk.to_string().into())]);
let mut reader = SseReader {
bytes: Box::pin(stream),
buf: Vec::new(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
let result = reader.next_event().await.unwrap();
assert!(result.is_some());
let (event_type, data) = result.unwrap();
assert_eq!(event_type, "content_block_delta");
assert!(
data.is_some(),
"multi-line data should concatenate into valid JSON"
);
let parsed = data.unwrap();
assert_eq!(parsed["type"], "text_delta");
assert_eq!(parsed["text"], "hello");
}
#[tokio::test]
async fn sse_reader_next_event_malformed_data_returns_none_value() {
let chunk = "event: ping\ndata: not valid json\n\n";
let stream =
futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(chunk.to_string().into())]);
let mut reader = SseReader {
bytes: Box::pin(stream),
buf: Vec::new(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
let result = reader.next_event().await.unwrap();
assert!(result.is_some());
let (event_type, data) = result.unwrap();
assert_eq!(event_type, "ping");
assert!(data.is_none(), "malformed JSON should yield None data");
}
#[tokio::test]
async fn sse_reader_buffer_overflow_returns_error() {
let huge = "x".repeat(2 * 1024 * 1024);
let stream = futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(huge.into())]);
let mut reader = super::SseReader {
bytes: Box::pin(stream),
buf: Vec::new(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
let result = reader.next_event().await;
assert!(result.is_err(), "should error on buffer overflow");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("SSE buffer"),
"error should mention SSE buffer: {err_msg}"
);
}
#[test]
fn max_response_body_is_ten_mb() {
assert_eq!(super::super::MAX_RESPONSE_BODY, 10 * 1024 * 1024);
}
#[test]
fn request_body_response_format_forces_tool() {
let msgs = vec![Message::user("classify this")];
let rf = crate::structured::ResponseFormat::new(
"action",
serde_json::json!({"type": "object", "properties": {"x": {"type": "string"}}}),
);
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: Some(&rf),
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let tools = body["tools"].as_array().expect("tools should be an array");
assert_eq!(tools.len(), 1, "should have exactly one forced tool");
assert_eq!(tools[0]["name"], "action");
assert_eq!(tools[0]["input_schema"], rf.schema);
assert_eq!(body["tool_choice"]["type"], "tool");
assert_eq!(body["tool_choice"]["name"], "action");
}
#[test]
fn request_body_response_format_suppresses_caller_tools() {
let msgs = vec![Message::user("hi")];
let caller_tool = ToolSchema {
tool: "read".into(),
description: "Read a file".into(),
input_schema: serde_json::json!({"type": "object"}),
};
let rf =
crate::structured::ResponseFormat::new("result", serde_json::json!({"type": "object"}));
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: Some(&[caller_tool]),
response_format: Some(&rf),
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let tools = body["tools"].as_array().expect("tools should be an array");
assert_eq!(tools.len(), 1);
assert_eq!(
tools[0]["name"], "result",
"caller tools should be suppressed"
);
}
#[test]
fn request_body_no_response_format_has_no_tool_choice() {
let msgs = vec![Message::user("hi")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
assert!(
body.get("tool_choice").is_none(),
"tool_choice should only appear with response_format"
);
}
#[test]
fn extract_structured_from_tool_call_part() {
let client = AnthropicClient::builder()
.with_api_key("test")
.build()
.unwrap();
let message = Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"tu_1",
"action",
serde_json::json!({"tool": "write", "args": {}}),
)],
);
let value = client.extract_structured(&message);
assert_eq!(value["tool"], "write");
}
#[test]
fn extract_structured_text_only_falls_back_to_string() {
let client = AnthropicClient::builder()
.with_api_key("test")
.build()
.unwrap();
let message = Message::assistant("I cannot do that.");
let value = client.extract_structured(&message);
assert_eq!(value, serde_json::json!("I cannot do that."));
}
#[test]
fn build_response_maps_text_block_and_end_turn() {
let raw = serde_json::json!({
"content": [{"type": "text", "text": "hello there"}],
"stop_reason": "end_turn"
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.message.role, Role::Assistant);
assert_eq!(response.message.text_content(), "hello there");
assert_eq!(response.stop_reason, StreamStopReason::EndTurn);
}
#[test]
fn build_response_maps_tool_use_block_and_tool_call() {
let raw = serde_json::json!({
"content": [{
"type": "tool_use",
"id": "toolu_1",
"name": "search",
"input": {"q": "rust"}
}],
"stop_reason": "tool_use"
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.message.parts.len(), 1);
match &response.message.parts[0] {
MessagePart::ToolCall { id, name, input } => {
assert_eq!(id, "toolu_1");
assert_eq!(name, "search");
assert_eq!(input, &serde_json::json!({"q": "rust"}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
assert_eq!(response.stop_reason, StreamStopReason::ToolCall);
}
#[test]
fn build_response_preserves_block_order() {
let raw = serde_json::json!({
"content": [
{"type": "text", "text": "thinking..."},
{"type": "tool_use", "id": "t1", "name": "a", "input": {}},
{"type": "text", "text": "done"}
],
"stop_reason": "end_turn"
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.message.parts.len(), 3);
assert!(response.message.parts[0].is_text());
assert!(response.message.parts[1].is_tool_call());
assert!(response.message.parts[2].is_text());
}
#[test]
fn build_response_skips_thinking_blocks() {
let raw = serde_json::json!({
"content": [
{"type": "thinking", "thinking": "internal reasoning"},
{"type": "text", "text": "visible answer"}
],
"stop_reason": "end_turn"
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.message.parts.len(), 1);
assert_eq!(response.message.text_content(), "visible answer");
}
#[test]
fn build_response_maps_max_tokens_stop_reason() {
let raw = serde_json::json!({
"content": [{"type": "text", "text": "truncated"}],
"stop_reason": "max_tokens"
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.stop_reason, StreamStopReason::MaxTokens);
}
#[test]
fn build_response_unknown_stop_reason_defaults_to_end_turn() {
let raw = serde_json::json!({
"content": [{"type": "text", "text": "hi"}],
"stop_reason": "something_new"
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.stop_reason, StreamStopReason::EndTurn);
}
#[test]
fn build_response_missing_stop_reason_defaults_to_end_turn() {
let raw = serde_json::json!({
"content": [{"type": "text", "text": "hi"}]
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.stop_reason, StreamStopReason::EndTurn);
}
#[test]
fn build_response_empty_content_yields_empty_message() {
let raw = serde_json::json!({"content": [], "stop_reason": "end_turn"});
let response = AnthropicClient::build_response(&raw);
assert!(response.message.parts.is_empty());
assert_eq!(response.stop_reason, StreamStopReason::EndTurn);
}
#[test]
fn build_response_extracts_usage() {
let raw = serde_json::json!({
"content": [{"type": "text", "text": "hi"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 30, "output_tokens": 12}
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.usage.expect("usage").input_tokens, 30);
assert_eq!(response.usage.expect("usage").output_tokens, 12);
}
#[test]
fn build_response_missing_usage_is_none() {
let raw = serde_json::json!({
"content": [{"type": "text", "text": "hi"}],
"stop_reason": "end_turn"
});
let response = AnthropicClient::build_response(&raw);
assert!(response.usage.is_none());
}
#[test]
fn build_response_missing_content_yields_empty_message() {
let raw = serde_json::json!({"stop_reason": "end_turn"});
let response = AnthropicClient::build_response(&raw);
assert!(response.message.parts.is_empty());
assert_eq!(response.stop_reason, StreamStopReason::EndTurn);
}
#[test]
fn build_response_text_block_missing_text_is_skipped() {
let raw = serde_json::json!({
"content": [
{"type": "text"},
{"type": "text", "text": "valid"}
],
"stop_reason": "end_turn"
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.message.parts.len(), 1);
assert_eq!(response.message.text_content(), "valid");
}
#[test]
fn build_response_tool_use_missing_input_defaults_to_null() {
let raw = serde_json::json!({
"content": [{"type": "tool_use", "id": "tu_1", "name": "search"}],
"stop_reason": "tool_use"
});
let response = AnthropicClient::build_response(&raw);
match &response.message.parts[0] {
MessagePart::ToolCall { input, .. } => {
assert_eq!(input, &Value::Null);
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn build_response_maps_stop_sequence_reason() {
let raw = serde_json::json!({
"content": [{"type": "text", "text": "stopped"}],
"stop_reason": "stop_sequence"
});
let response = AnthropicClient::build_response(&raw);
assert_eq!(response.stop_reason, StreamStopReason::StopSequence);
}
#[test]
fn anthropic_strict_tightens_input_schema() {
let msgs = vec![Message::user("hi")];
let tools = vec![ToolSchema {
tool: "echo".into(),
description: "Echo".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {"msg": {"type": "string"}}
}),
}];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: Some(&tools),
response_format: None,
tool_constraint: &ToolConstraint::Strict,
},
false,
DEFAULT_MAX_TOKENS,
);
let tools_arr = body["tools"].as_array().unwrap();
assert_eq!(tools_arr.len(), 1);
let input_schema = &tools_arr[0]["input_schema"];
assert_eq!(input_schema["additionalProperties"], false);
let required = input_schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "msg");
}
#[test]
fn anthropic_none_constraint_unchanged_shape() {
let msgs = vec![Message::user("hi")];
let tools = vec![ToolSchema {
tool: "echo".into(),
description: "Echo".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: Some(&tools),
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let tools_arr = body["tools"].as_array().unwrap();
assert_eq!(
tools_arr[0]["input_schema"],
serde_json::json!({"type": "object"})
);
assert!(
tools_arr[0]["input_schema"]
.get("additionalProperties")
.is_none()
);
}
#[test]
fn anthropic_strict_does_not_emit_tool_choice() {
let msgs = vec![Message::user("hi")];
let tools = vec![ToolSchema {
tool: "echo".into(),
description: "Echo".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: Some(&tools),
response_format: None,
tool_constraint: &ToolConstraint::Strict,
},
false,
DEFAULT_MAX_TOKENS,
);
assert!(
body.get("tool_choice").is_none(),
"tool_choice must not appear under Strict"
);
}
#[test]
fn anthropic_strict_suppressed_when_response_format_set() {
let msgs = vec![Message::user("hi")];
let caller_tool = ToolSchema {
tool: "read".into(),
description: "Read a file".into(),
input_schema: serde_json::json!({"type": "object"}),
};
let rf =
crate::structured::ResponseFormat::new("result", serde_json::json!({"type": "object"}));
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: Some(&[caller_tool]),
response_format: Some(&rf),
tool_constraint: &ToolConstraint::Strict,
},
false,
DEFAULT_MAX_TOKENS,
);
let tools = body["tools"].as_array().expect("tools should be an array");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["name"], "result");
assert_eq!(body["tool_choice"]["type"], "tool");
assert_eq!(body["tool_choice"]["name"], "result");
}
fn system_role_msg(text: &str) -> Message {
Message::new(Role::System, vec![MessagePart::text(text)])
}
#[test]
fn request_body_system_role_folded_into_system_field() {
let msgs = vec![
Message::user("hello"),
system_role_msg("stay on task"),
Message::assistant("working"),
];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let messages = body["messages"].as_array().expect("messages is an array");
assert_eq!(messages.len(), 2, "system-role message is filtered out");
for m in messages {
assert_ne!(
m["role"].as_str().unwrap_or(""),
"system",
"no inline system-role message should be emitted"
);
}
assert_eq!(body["system"], "stay on task");
}
#[test]
fn request_body_system_role_merges_with_caller_system() {
let msgs = vec![Message::user("hi"), system_role_msg("reminder")];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: Some("be brief"),
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let system = body["system"].as_str().expect("system is a string");
assert!(
system.starts_with("be brief"),
"caller system prompt comes first: got {system:?}"
);
assert!(
system.contains("reminder"),
"folded text is appended: got {system:?}"
);
assert!(
system.contains('\n'),
"caller prompt and folded text are newline-separated: got {system:?}"
);
}
#[test]
fn request_body_system_role_preserves_message_order() {
let msgs = vec![
Message::user("first"),
system_role_msg("mid reminder"),
Message::assistant("second"),
Message::user("third"),
];
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &msgs,
system: None,
tools: None,
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
DEFAULT_MAX_TOKENS,
);
let messages = body["messages"].as_array().expect("messages is an array");
let roles: Vec<&str> = messages
.iter()
.map(|m| m["role"].as_str().unwrap_or(""))
.collect();
assert_eq!(roles, vec!["user", "assistant", "user"]);
assert_eq!(messages[0]["content"], "first");
assert_eq!(messages[2]["content"], "third");
}
#[test]
fn emitter_thinking_delta_emits_thinking_variant() {
let mut em = StreamEmitter::default();
em.on_block_start(Some(serde_json::json!({
"index": 0,
"content_block": {"type": "thinking"}
})));
em.drain();
em.on_block_delta(Some(serde_json::json!({
"delta": {"type": "thinking_delta", "thinking": "reasoning here"}
})));
let events = em.drain();
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::IndexedDelta(d) => match &d.delta {
DeltaPart::Thinking { text } => assert_eq!(text, "reasoning here"),
other => panic!("expected Thinking, got {other:?}"),
},
other => panic!("expected IndexedDelta, got {other:?}"),
}
}
#[test]
fn emitter_signature_delta_is_ignored() {
let mut em = StreamEmitter::default();
em.on_block_start(Some(serde_json::json!({
"index": 0,
"content_block": {"type": "thinking"}
})));
em.on_block_delta(Some(serde_json::json!({
"delta": {"type": "thinking_delta", "thinking": "visible reasoning"}
})));
em.drain();
em.on_block_delta(Some(serde_json::json!({
"delta": {"type": "signature_delta", "signature": "opaque_base64_blob"}
})));
let events = em.drain();
assert!(
events.is_empty(),
"signature_delta must not emit any events: got {events:?}"
);
}
#[test]
fn emitter_redacted_thinking_emits_empty_delta() {
let mut em = StreamEmitter::default();
em.on_block_start(Some(serde_json::json!({
"index": 0,
"content_block": {"type": "redacted_thinking"}
})));
let events = em.drain();
assert_eq!(events.len(), 2, "expected PartStart + empty Thinking delta");
assert!(matches!(events[0], StreamEvent::PartStart(_)));
match &events[1] {
StreamEvent::IndexedDelta(d) => match &d.delta {
DeltaPart::Thinking { text } => {
assert!(text.is_empty(), "redacted thinking → empty text");
}
other => panic!("expected Thinking, got {other:?}"),
},
other => panic!("expected IndexedDelta, got {other:?}"),
}
assert!(em.thinking_part_open, "thinking_part_open set");
}
#[test]
fn emitter_thinking_block_stop_closes_part() {
let mut em = StreamEmitter::default();
em.on_block_start(Some(serde_json::json!({
"index": 0,
"content_block": {"type": "thinking"}
})));
em.on_block_delta(Some(serde_json::json!({
"delta": {"type": "thinking_delta", "thinking": "reasoning"}
})));
em.drain();
em.on_block_stop(None);
let events = em.drain();
assert_eq!(events.len(), 1, "exactly one PartStop");
assert!(matches!(events[0], StreamEvent::PartStop { .. }));
assert!(!em.thinking_part_open, "thinking_part_open reset");
assert!(em.thinking_index.is_none(), "thinking_index cleared");
}
#[test]
fn emitter_message_stop_closes_open_thinking_block() {
let mut em = StreamEmitter::default();
em.on_block_start(Some(serde_json::json!({
"index": 0,
"content_block": {"type": "thinking"}
})));
em.on_block_delta(Some(serde_json::json!({
"delta": {"type": "thinking_delta", "thinking": "reasoning"}
})));
em.drain();
assert!(
em.thinking_part_open,
"test precondition: thinking lane open"
);
em.on_message_stop();
let events = em.drain();
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::PartStop { .. })),
"message_stop must emit a PartStop for the open thinking block"
);
assert!(
events.iter().any(|e| matches!(e, StreamEvent::MessageStop)),
"message_stop must emit the terminal MessageStop"
);
assert!(
!em.thinking_part_open,
"thinking_part_open must be reset by message_stop"
);
assert!(
em.thinking_index.is_none(),
"thinking_index must be cleared by message_stop"
);
let stop_idx = events
.iter()
.position(|e| matches!(e, StreamEvent::PartStop { .. }))
.expect("PartStop present");
let msg_stop_idx = events
.iter()
.position(|e| matches!(e, StreamEvent::MessageStop))
.expect("MessageStop present");
assert!(stop_idx < msg_stop_idx, "PartStop must precede MessageStop");
}
#[test]
fn request_body_omits_tools_for_empty_slice() {
let body = build_request_body(
&RequestBodySpec {
model: "claude-3",
messages: &[crate::message::Message::user("hi")],
system: None,
tools: Some(&[]),
response_format: None,
tool_constraint: &ToolConstraint::None,
},
false,
1024,
);
assert!(
body.get("tools").is_none(),
"an empty tool list must be omitted, not sent as []; got {}",
body.get("tools").unwrap_or(&serde_json::Value::Null)
);
}
#[test]
fn convert_message_marks_error_tool_results() {
let msg = crate::message::Message::new(
crate::message::Role::User,
vec![crate::message::MessagePart::tool_result(
"c1",
"bash",
crate::message::ToolContent::from_string("exit 1"),
true,
)],
);
let json = convert_message(&msg);
assert_eq!(
json.pointer("/content/0/is_error"),
Some(&serde_json::json!(true)),
"Anthropic's tool_result block supports is_error and must receive it; got {json}"
);
let ok = crate::message::Message::new(
crate::message::Role::User,
vec![crate::message::MessagePart::tool_result(
"c2",
"search",
crate::message::ToolContent::from_string("[]"),
false,
)],
);
let ok_json = convert_message(&ok);
assert!(
ok_json.pointer("/content/0/is_error").is_none(),
"successful results carry no is_error (the wire default is false): {ok_json}"
);
}
#[test]
fn messages_url_has_no_double_slash_for_trailing_slash_base() {
let bare = AnthropicClient::builder()
.with_api_key("k")
.with_base_url("https://api.example.com")
.build()
.expect("client builds");
let slashed = AnthropicClient::builder()
.with_api_key("k")
.with_base_url("https://api.example.com/")
.build()
.expect("client builds");
assert_eq!(
slashed.messages_url(),
bare.messages_url(),
"a trailing-slash base URL must join to the same request URL as the bare one"
);
}
#[test]
fn emitter_streams_input_tokens_from_message_start() {
let mut em = StreamEmitter::default();
let start_data = serde_json::json!({
"message": {
"id": "msg_1",
"model": "claude-3",
"usage": {"input_tokens": 25, "output_tokens": 1}
}
});
em.on_message_start(Some(&start_data));
em.drain();
let delta_data = serde_json::json!({
"delta": {"stop_reason": "end_turn"},
"usage": {"output_tokens": 15}
});
em.on_message_delta(Some(delta_data));
let events = em.drain();
let usage = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(MessageDelta { usage, .. }) => *usage,
_ => None,
});
let usage = usage.expect("MessageDelta must carry usage");
assert_eq!(
usage.input_tokens, 25,
"input tokens arrive on message_start and must survive to the terminal usage"
);
assert_eq!(usage.output_tokens, 15);
}
#[test]
fn streamed_and_non_streamed_usage_agree() {
let raw = serde_json::json!({
"id": "msg_1",
"content": [{"type": "text", "text": "hi there"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 25, "output_tokens": 15}
});
let non_streamed = AnthropicClient::build_response(&raw);
let mut em = StreamEmitter::default();
em.on_message_start(Some(&serde_json::json!({
"message": {
"id": "msg_1",
"model": "claude-3",
"usage": {"input_tokens": 25, "output_tokens": 1}
}
})));
em.drain();
em.on_message_delta(Some(serde_json::json!({
"delta": {"stop_reason": "end_turn"},
"usage": {"output_tokens": 15}
})));
let streamed = em.drain().iter().find_map(|e| match e {
StreamEvent::MessageDelta(MessageDelta { usage, .. }) => *usage,
_ => None,
});
assert_eq!(
streamed, non_streamed.usage,
"the same exchange served streaming and non-streaming must report identical usage"
);
}
#[tokio::test]
async fn sse_data_line_without_space_is_parsed() {
let data = "event: message_start\ndata:{\"message\":{\"id\":\"m1\"}}\n\n";
let mut reader = SseReader {
bytes: Box::pin(futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(
data.to_string().into(),
)])),
buf: Vec::new(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
let parsed = reader.next_event().await.expect("reader must not err");
let (_, payload) = parsed.expect("the event must be delivered");
assert!(
payload.is_some(),
"spec-legal 'data:' line must carry the payload, not be dropped"
);
}
#[tokio::test]
async fn request_model_override_replaces_the_body_model_on_stream() {
use futures::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap();
let head = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
drop(sock.write_all(head.as_bytes()).await);
String::from_utf8_lossy(&buf[..n]).into_owned()
});
let client = AnthropicClient::builder()
.with_api_key("k")
.with_base_url(format!("http://{addr}"))
.build()
.unwrap();
let options = crate::structured::RequestOptions::new().with_model("override-model");
let mut stream =
client.stream_messages_with_options(&crate::api::StreamRequest::new(vec![]), options);
let _ = stream.next().await;
let request = server.await.unwrap();
assert!(
request.contains("\"model\":\"override-model\""),
"the streaming path must honor the per-request model override: {request}"
);
}
#[tokio::test]
async fn request_model_override_replaces_the_body_model() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap();
let head = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
drop(sock.write_all(head.as_bytes()).await);
String::from_utf8_lossy(&buf[..n]).into_owned()
});
let client = AnthropicClient::builder()
.with_api_key("k")
.with_base_url(format!("http://{addr}"))
.build()
.unwrap();
let options = crate::structured::RequestOptions::new().with_model("override-model");
drop(
client
.create_message_with_options(&crate::api::StreamRequest::new(vec![]), options)
.await,
);
let request = server.await.unwrap();
assert!(
request.contains("\"model\":\"override-model\""),
"the per-request override must replace the body's model field: {request}"
);
}
#[tokio::test]
async fn sse_multi_line_data_joins_spaced_and_compact_forms() {
let data = "event: message_start\ndata: {\"a\":\ndata:1}\n\n";
let mut reader = SseReader {
bytes: Box::pin(futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(
data.to_string().into(),
)])),
buf: Vec::new(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
let parsed = reader.next_event().await.expect("reader must not err");
let (_, payload) = parsed.expect("the event must be delivered");
let payload = payload.expect("the joined payload must parse");
assert_eq!(
payload,
serde_json::json!({"a": 1}),
"data lines of either spacing form concatenate into one payload"
);
}
#[test]
fn emitter_delta_usage_revision_wins_over_start_latch() {
let mut em = StreamEmitter::default();
em.on_message_start(Some(&serde_json::json!({
"message": {
"id": "msg_1",
"model": "claude-3",
"usage": {"input_tokens": 25, "output_tokens": 1}
}
})));
em.drain();
em.on_message_delta(Some(serde_json::json!({
"delta": {"stop_reason": "end_turn"},
"usage": {"input_tokens": 40, "output_tokens": 15}
})));
let events = em.drain();
let usage = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(MessageDelta { usage, .. }) => *usage,
_ => None,
});
let usage = usage.expect("MessageDelta must carry usage");
assert_eq!(
(usage.input_tokens, usage.output_tokens),
(40, 15),
"a server-tools turn whose delta revises the counts upward must not be under-reported"
);
}
#[test]
fn emitter_message_delta_without_usage_still_reports_latched_tokens() {
let mut em = StreamEmitter::default();
em.on_message_start(Some(&serde_json::json!({
"message": {
"id": "msg_1",
"model": "claude-3",
"usage": {"input_tokens": 25, "output_tokens": 1}
}
})));
em.drain();
em.on_message_delta(Some(serde_json::json!({
"delta": {"stop_reason": "end_turn"}
})));
let events = em.drain();
let usage = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(MessageDelta { usage, .. }) => *usage,
_ => None,
});
let usage = usage.expect("the latch alone must still produce a usage report");
assert_eq!((usage.input_tokens, usage.output_tokens), (25, 1));
}
#[test]
fn emitter_duplicate_message_start_keeps_first_usage_latch() {
let mut em = StreamEmitter::default();
em.on_message_start(Some(&serde_json::json!({
"message": {
"id": "msg_1",
"model": "claude-3",
"usage": {"input_tokens": 25, "output_tokens": 1}
}
})));
em.on_message_start(Some(&serde_json::json!({
"message": {
"id": "msg_1",
"model": "claude-3",
"usage": {"input_tokens": 99, "output_tokens": 99}
}
})));
em.drain();
em.on_message_delta(Some(serde_json::json!({
"delta": {"stop_reason": "end_turn"},
"usage": {"output_tokens": 15}
})));
let events = em.drain();
let usage = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(MessageDelta { usage, .. }) => *usage,
_ => None,
});
let usage = usage.expect("MessageDelta must carry usage");
assert_eq!(
(usage.input_tokens, usage.output_tokens),
(25, 15),
"only the first message_start latches; a replayed one must not overwrite it"
);
}
#[test]
fn emitter_message_delta_without_usage_omits_usage() {
let mut em = StreamEmitter::default();
em.on_message_start(Some(&serde_json::json!({
"message": {"id": "msg_1", "model": "claude-3"}
})));
em.drain();
em.on_message_delta(Some(serde_json::json!({
"delta": {"stop_reason": "end_turn"}
})));
let events = em.drain();
let usage = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(MessageDelta { usage, .. }) => *usage,
_ => None,
});
assert_eq!(
usage, None,
"with no usage on either event, the delta carries no usage, matching the non-streaming path"
);
}
#[test]
fn extract_usage_object_defaults_malformed_counts_to_zero() {
let malformed = serde_json::json!({
"input_tokens": "many",
"output_tokens": -5
});
assert_eq!(
extract_usage_object(&malformed),
None,
"non-numeric counts default to zero, and an all-zero report is None"
);
let partial = serde_json::json!({"input_tokens": 12});
assert_eq!(
extract_usage_object(&partial),
Some(Usage::new(12, 0)),
"a missing output count defaults to zero without dropping the input report"
);
}
async fn serve_sse(body: &'static str) -> (String, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
drop(sock.read(&mut buf).await);
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n",
body.len()
);
drop(sock.write_all(head.as_bytes()).await);
drop(sock.write_all(body.as_bytes()).await);
drop(sock.flush().await);
});
(format!("http://{addr}"), server)
}
async fn collect_stream_telemetry(
mut stream: Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send>>,
) -> (bool, Option<Usage>) {
use futures::StreamExt;
let mut usage = None;
let mut saw_start = false;
while let Some(event) = stream.next().await {
match event.expect("stream must not err") {
StreamEvent::MessageStart(_) => saw_start = true,
StreamEvent::MessageDelta(MessageDelta { usage: u, .. }) => usage = u,
_ => {}
}
}
(saw_start, usage)
}
#[tokio::test]
async fn streamed_turn_reports_latched_input_tokens_over_the_wire() {
let body = concat!(
"event: message_start\n",
"data:{\"message\":{\"id\":\"m1\",\"model\":\"claude-3\",",
"\"usage\":{\"input_tokens\":25,\"output_tokens\":1}}}\n\n",
"event: message_delta\n",
"data:{\"delta\":{\"stop_reason\":\"end_turn\"},",
"\"usage\":{\"output_tokens\":15}}\n\n",
"event: message_stop\n",
"data:{}\n\n",
);
let (url, server) = serve_sse(body).await;
let client = AnthropicClient::builder()
.with_api_key("k")
.with_base_url(url)
.build()
.unwrap();
let (saw_start, usage) = collect_stream_telemetry(
client.stream_messages(&crate::api::StreamRequest::new(vec![Message::user("hi")])),
)
.await;
server.await.unwrap();
assert!(saw_start, "the stream must deliver its message_start");
let usage = usage.expect("the terminal MessageDelta must carry usage");
assert_eq!(
(usage.input_tokens, usage.output_tokens),
(25, 15),
"a compact-data-line wire must yield the latched input tokens and the delta's output tokens"
);
}
#[tokio::test]
async fn sse_event_line_without_space_dispatches_the_event() {
let data = "event:message_start\ndata: {\"message\":{\"id\":\"m1\"}}\n\n";
let mut reader = SseReader {
bytes: Box::pin(futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(
data.to_string().into(),
)])),
buf: Vec::new(),
#[cfg(feature = "openai")]
done_marker_seen: false,
};
let parsed = reader.next_event().await.expect("reader must not err");
let (event_type, payload) = parsed.expect("the event must be delivered");
assert_eq!(
event_type, "message_start",
"spec-legal 'event:' line must dispatch under its event type, not as unknown"
);
assert!(
payload.is_some(),
"the paired data payload must be delivered"
);
}
#[tokio::test]
async fn streamed_turn_dispatches_compact_event_lines_over_the_wire() {
let body = concat!(
"event:message_start\n",
"data: {\"message\":{\"id\":\"m1\",\"model\":\"claude-3\",",
"\"usage\":{\"input_tokens\":25,\"output_tokens\":1}}}\n\n",
"event:message_delta\n",
"data: {\"delta\":{\"stop_reason\":\"end_turn\"},",
"\"usage\":{\"output_tokens\":15}}\n\n",
"event:message_stop\n",
"data: {}\n\n",
);
let (url, server) = serve_sse(body).await;
let client = AnthropicClient::builder()
.with_api_key("k")
.with_base_url(url)
.build()
.unwrap();
let (saw_start, usage) = collect_stream_telemetry(
client.stream_messages(&crate::api::StreamRequest::new(vec![Message::user("hi")])),
)
.await;
server.await.unwrap();
assert!(
saw_start,
"compact event lines must dispatch their events, not silence the whole stream"
);
let usage = usage.expect("the terminal MessageDelta must carry usage");
assert_eq!(
(usage.input_tokens, usage.output_tokens),
(25, 15),
"a compact-event-line wire must report the same usage as the spaced form"
);
}
}