use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use serde_json::json;
use super::{read_sse, CompletionRequest, CompletionResponse, Provider, ToolCall, Usage};
use crate::error::{Error, Result};
pub use crate::net::REQUEST_TIMEOUT;
const ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
const API_VERSION: &str = "2023-06-01";
const WEB_SEARCH_TYPE: &str = "web_search_20250305";
const WEB_FETCH_TYPE: &str = "web_fetch_20250910";
const WEB_FETCH_BETA: &str = "web-fetch-2025-09-10";
const MAX_TOKENS: u64 = 8192;
pub struct Anthropic {
client: reqwest::Client,
api_key: String,
model: String,
endpoint: String,
}
impl Anthropic {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
client: crate::net::http_client(),
api_key: api_key.into(),
model: model.into(),
endpoint: ENDPOINT.to_string(),
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.client = crate::net::http_client_with_timeout(timeout);
self
}
#[cfg(test)]
pub(crate) fn at(endpoint: impl Into<String>, timeout: std::time::Duration) -> Self {
Self {
client: crate::net::http_client_with_timeout(timeout),
api_key: "test-key".into(),
model: "test-model".into(),
endpoint: endpoint.into(),
}
}
pub fn from_env() -> Result<Self> {
let api_key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| Error::Config("ANTHROPIC_API_KEY is not set".into()))?;
let model = std::env::var("ANTHROPIC_MODEL")
.map_err(|_| Error::Config("ANTHROPIC_MODEL is not set".into()))?;
Ok(Self::new(api_key, model))
}
fn body(&self, request: &CompletionRequest) -> serde_json::Value {
let mut tools: Vec<serde_json::Value> = request
.tools
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"input_schema": t.parameters,
})
})
.collect();
tools.extend(Self::web_tools(request.web.as_ref()));
json!({
"model": request.model.as_deref().unwrap_or(&self.model),
"max_tokens": MAX_TOKENS,
"stream": true,
"system": request.system,
"messages": [
{ "role": "user", "content": Self::user_content(request) },
],
"tools": tools,
})
}
fn web_tools(web: Option<&crate::web::WebAccess>) -> Vec<serde_json::Value> {
let Some(web) = web.filter(|w| w.enabled()) else {
return Vec::new();
};
let (allowed, blocked) = web.vendor_filter();
let entry = |type_: &str, name: &str| {
let mut tool = json!({ "type": type_, "name": name });
let map = tool.as_object_mut().expect("a json object");
if let Some(uses) = web.max_uses {
map.insert("max_uses".into(), json!(uses));
}
if !allowed.is_empty() {
map.insert("allowed_domains".into(), json!(allowed));
}
if !blocked.is_empty() {
map.insert("blocked_domains".into(), json!(blocked));
}
tool
};
let mut tools = Vec::new();
if web.search {
tools.push(entry(WEB_SEARCH_TYPE, "web_search"));
}
if web.fetch {
tools.push(entry(WEB_FETCH_TYPE, "web_fetch"));
}
tools
}
#[cfg(feature = "media")]
fn user_content(request: &CompletionRequest) -> serde_json::Value {
if request.media.is_empty() {
return json!(request.user);
}
let mut parts: Vec<serde_json::Value> = request
.media
.iter()
.map(|m| {
json!({
"type": "image",
"source": {
"type": "base64",
"media_type": m.media_type,
"data": m.base64,
},
})
})
.collect();
parts.push(json!({ "type": "text", "text": request.user }));
json!(parts)
}
#[cfg(not(feature = "media"))]
fn user_content(request: &CompletionRequest) -> serde_json::Value {
json!(request.user)
}
}
impl Provider for Anthropic {
fn name(&self) -> &str {
"anthropic"
}
fn endpoint(&self) -> Option<&str> {
Some(&self.endpoint)
}
#[cfg(feature = "media")]
fn accepts_images(&self) -> bool {
true
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
self.stream(request, &|_| {}).await
}
async fn complete_streaming(
&self,
request: CompletionRequest,
on_token: &(dyn Fn(&str) + Send + Sync),
) -> Result<CompletionResponse> {
self.stream(request, on_token).await
}
}
impl Anthropic {
async fn stream(
&self,
request: CompletionRequest,
on_token: &(dyn Fn(&str) + Send + Sync),
) -> Result<CompletionResponse> {
#[cfg(feature = "media")]
super::ensure_media_accepted(self.name(), self.accepts_images(), &request)?;
let sent = Instant::now();
let mut post = self
.client
.post(&self.endpoint)
.header("x-api-key", &self.api_key)
.header("anthropic-version", API_VERSION);
if request.web.as_ref().is_some_and(|w| w.fetch) {
post = post.header("anthropic-beta", WEB_FETCH_BETA);
}
let resp = post.json(&self.body(&request)).send().await?;
let resp = super::ensure_success(resp).await?;
let mut acc = Accumulator::since(sent);
read_sse(resp, |data| {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(data) {
if value.get("type").and_then(|t| t.as_str()) == Some("message_stop") {
return true;
}
if let Some(delta) = text_delta(&value) {
on_token(delta);
}
acc.ingest(&value);
}
false
})
.await?;
super::ensure_parsed(acc.finish())
}
}
fn text_delta(value: &serde_json::Value) -> Option<&str> {
if value.get("type").and_then(|t| t.as_str()) != Some("content_block_delta") {
return None;
}
let delta = value.get("delta")?;
if delta.get("type").and_then(|t| t.as_str()) != Some("text_delta") {
return None;
}
delta.get("text")?.as_str()
}
#[derive(Default)]
struct Accumulator {
text: String,
tool_calls: BTreeMap<u64, (String, String)>,
input_tokens: u64,
output_tokens: u64,
cache_write_tokens: u64,
cache_read_tokens: u64,
server_tool_requests: u64,
model: Option<String>,
finish_reason: Option<String>,
citations: Vec<crate::web::Citation>,
server_tools: Vec<crate::web::ServerToolCall>,
server_tool_names: BTreeMap<String, String>,
sent: Option<Instant>,
ttft_ms: Option<u64>,
}
impl Accumulator {
fn since(sent: Instant) -> Self {
Self {
sent: Some(sent),
..Default::default()
}
}
fn mark_first_token(&mut self) {
if let Some(sent) = self.sent {
self.ttft_ms
.get_or_insert(sent.elapsed().as_millis() as u64);
}
}
fn ingest(&mut self, value: &serde_json::Value) {
let index = || value.get("index").and_then(|i| i.as_u64()).unwrap_or(0);
match value.get("type").and_then(|t| t.as_str()) {
Some("message_start") => {
if let Some(n) = value
.pointer("/message/usage/input_tokens")
.and_then(|v| v.as_u64())
{
self.input_tokens = n;
}
if let Some(m) = value
.pointer("/message/model")
.and_then(|v| v.as_str())
.filter(|m| !m.is_empty())
{
self.model = Some(m.to_string());
}
self.ingest_usage(value.pointer("/message/usage"));
}
Some("content_block_start") => {
self.mark_first_token();
if let Some(cb) = value.get("content_block") {
match cb.get("type").and_then(|t| t.as_str()) {
Some("tool_use") => {
let name = cb
.get("name")
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string();
self.tool_calls.entry(index()).or_default().0 = name;
}
Some("server_tool_use") => self.ingest_server_tool_use(cb),
Some(t) if t.ends_with("_tool_result") => {
self.ingest_server_tool_result(t, cb);
}
_ => self.ingest_citations(cb.get("citations")),
}
}
}
Some("content_block_delta") => {
self.mark_first_token();
let delta = value.get("delta");
match delta.and_then(|d| d.get("type")).and_then(|t| t.as_str()) {
Some("text_delta") => {
if let Some(t) = delta.and_then(|d| d.get("text")).and_then(|t| t.as_str())
{
self.text.push_str(t);
}
}
Some("input_json_delta") => {
if let Some(p) = delta
.and_then(|d| d.get("partial_json"))
.and_then(|p| p.as_str())
{
self.tool_calls.entry(index()).or_default().1.push_str(p);
}
}
Some("citations_delta") => {
self.push_citation(delta.and_then(|d| d.get("citation")));
}
_ => {}
}
}
Some("message_delta") => {
if let Some(n) = value
.pointer("/usage/output_tokens")
.and_then(|v| v.as_u64())
{
self.output_tokens = n;
}
if let Some(r) = value
.pointer("/delta/stop_reason")
.and_then(|v| v.as_str())
.filter(|r| !r.is_empty())
{
self.finish_reason = Some(r.to_string());
}
self.ingest_usage(value.pointer("/usage"));
}
_ => {}
}
}
fn ingest_server_tool_use(&mut self, block: &serde_json::Value) {
let (Some(id), Some(name)) = (
block.get("id").and_then(|v| v.as_str()),
block.get("name").and_then(|v| v.as_str()),
) else {
return;
};
self.server_tool_names
.insert(id.to_string(), name.to_string());
}
fn ingest_server_tool_result(&mut self, block_type: &str, block: &serde_json::Value) {
let tool = block
.get("tool_use_id")
.and_then(|v| v.as_str())
.and_then(|id| self.server_tool_names.get(id))
.cloned()
.unwrap_or_else(|| block_type.trim_end_matches("_tool_result").to_string());
let content = block.get("content");
let error = content
.and_then(|c| c.get("type"))
.and_then(|t| t.as_str())
.filter(|t| t.ends_with("_error"))
.and(
content
.and_then(|c| c.get("error_code"))
.and_then(|c| c.as_str()),
);
self.server_tools.push(match error {
Some(code) => crate::web::ServerToolCall::failed("anthropic", tool, code),
None => crate::web::ServerToolCall::ok("anthropic", tool),
});
if let Some(results) = content.and_then(|c| c.as_array()) {
for result in results {
self.push_citation(Some(result));
}
}
}
fn ingest_citations(&mut self, citations: Option<&serde_json::Value>) {
let Some(list) = citations.and_then(|c| c.as_array()) else {
return;
};
for citation in list {
self.push_citation(Some(citation));
}
}
fn push_citation(&mut self, citation: Option<&serde_json::Value>) {
let Some(url) = citation
.and_then(|c| c.get("url"))
.and_then(|u| u.as_str())
.filter(|u| !u.is_empty())
else {
return;
};
let text = |key: &str| {
citation
.and_then(|c| c.get(key))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let found = crate::web::Citation {
url: url.to_string(),
title: text("title"),
cited_text: text("cited_text"),
};
if let Some(seen) = self.citations.iter_mut().find(|c| c.url == found.url) {
seen.title = seen.title.take().or(found.title);
seen.cited_text = seen.cited_text.take().or(found.cited_text);
return;
}
self.citations.push(found);
}
fn ingest_usage(&mut self, usage: Option<&serde_json::Value>) {
let Some(usage) = usage else { return };
let get = |k: &str| usage.get(k).and_then(|v| v.as_u64());
if let Some(n) = get("cache_creation_input_tokens") {
self.cache_write_tokens = n;
}
if let Some(n) = get("cache_read_input_tokens") {
self.cache_read_tokens = n;
}
if let Some(counts) = usage.get("server_tool_use").and_then(|v| v.as_object()) {
let sum = counts.values().filter_map(|v| v.as_u64()).sum();
if sum > 0 {
self.server_tool_requests = sum;
}
}
}
fn finish(self) -> CompletionResponse {
let tool_calls = self
.tool_calls
.into_values()
.filter(|(name, _)| !name.is_empty())
.map(|(name, args)| ToolCall {
name,
arguments: serde_json::from_str(if args.is_empty() { "{}" } else { &args })
.unwrap_or(serde_json::Value::Null),
})
.collect();
let prompt = self.input_tokens + self.cache_read_tokens + self.cache_write_tokens;
let total = prompt + self.output_tokens;
CompletionResponse {
text: if self.text.is_empty() {
None
} else {
Some(self.text)
},
tool_calls,
usage: (total > 0).then_some(Usage {
prompt_tokens: prompt,
completion_tokens: self.output_tokens,
total_tokens: total,
cache_read_tokens: self.cache_read_tokens,
cache_write_tokens: self.cache_write_tokens,
reasoning_tokens: 0,
server_tool_requests: self.server_tool_requests,
}),
model: self.model,
finish_reason: self.finish_reason,
ttft_ms: self.ttft_ms,
citations: self.citations,
server_tools: self.server_tools,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ToolSpec;
#[test]
fn body_maps_tools_to_input_schema_and_system_top_level() {
let a = Anthropic::new("k", "claude-x");
#[allow(clippy::needless_update)] let req = CompletionRequest {
system: "sys".into(),
user: "hi".into(),
tools: vec![ToolSpec {
name: "write_file".into(),
description: "w".into(),
parameters: json!({"type":"object"}),
}],
..Default::default()
};
let b = a.body(&req);
assert_eq!(b["system"], "sys");
assert_eq!(b["messages"][0]["content"], "hi");
assert_eq!(b["tools"][0]["name"], "write_file");
assert_eq!(b["tools"][0]["input_schema"], json!({"type":"object"}));
assert!(b["max_tokens"].is_u64());
}
#[test]
fn accumulates_tool_use_from_input_json_deltas_and_usage() {
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"message_start","message":{"usage":{"input_tokens":11}}}));
acc.ingest(&json!({"type":"content_block_start","index":0,
"content_block":{"type":"tool_use","id":"t1","name":"write_file","input":{}}}));
acc.ingest(&json!({"type":"content_block_delta","index":0,
"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"a"}}));
acc.ingest(&json!({"type":"content_block_delta","index":0,
"delta":{"type":"input_json_delta","partial_json":".rs\",\"content\":\"x\"}"}}));
acc.ingest(&json!({"type":"message_delta","usage":{"output_tokens":7}}));
let out = acc.finish();
assert_eq!(out.tool_calls.len(), 1);
assert_eq!(out.tool_calls[0].name, "write_file");
assert_eq!(out.tool_calls[0].arguments["path"], "a.rs");
assert_eq!(out.tool_calls[0].arguments["content"], "x");
let u = out.usage.unwrap();
assert_eq!(u.prompt_tokens, 11);
assert_eq!(u.completion_tokens, 7);
assert_eq!(u.total_tokens, 18);
}
#[test]
fn cache_tokens_the_model_reports_reach_usage_with_the_model_and_stop_reason() {
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"message_start","message":{
"model":"claude-sonnet-4-5",
"usage":{"input_tokens":11,"cache_creation_input_tokens":300,
"cache_read_input_tokens":1_200}}}));
acc.ingest(
&json!({"type":"message_delta","delta":{"stop_reason":"max_tokens"},
"usage":{"output_tokens":7,"server_tool_use":{"web_search_requests":2}}}),
);
let out = acc.finish();
let u = out.usage.unwrap();
assert_eq!(u.cache_write_tokens, 300);
assert_eq!(u.cache_read_tokens, 1_200);
assert_eq!(u.server_tool_requests, 2);
assert_eq!(u.prompt_tokens, 11 + 300 + 1_200);
assert_eq!(u.total_tokens, 11 + 300 + 1_200 + 7);
assert_eq!(u.reasoning_tokens, 0);
assert_eq!(out.model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(out.finish_reason.as_deref(), Some("max_tokens"));
}
#[test]
fn a_stream_that_reports_no_cache_or_stop_reason_yields_zeros_and_nones() {
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"message_start","message":{"usage":{"input_tokens":11}}}));
acc.ingest(&json!({"type":"message_delta","usage":{"output_tokens":7}}));
let out = acc.finish();
let u = out.usage.unwrap();
assert_eq!((u.cache_read_tokens, u.cache_write_tokens), (0, 0));
assert_eq!(u.server_tool_requests, 0);
assert_eq!(u.prompt_tokens, 11);
assert_eq!(u.total_tokens, 18);
assert_eq!(out.model, None);
assert_eq!(out.finish_reason, None);
assert_eq!(out.ttft_ms, None);
}
#[test]
fn the_ttft_clock_stops_at_the_first_content_event() {
let mut acc = Accumulator::since(Instant::now() - Duration::from_millis(40));
acc.ingest(&json!({"type":"content_block_delta","index":0,
"delta":{"type":"text_delta","text":"a"}}));
let first = acc.ttft_ms.expect("measured");
std::thread::sleep(Duration::from_millis(15));
acc.ingest(&json!({"type":"content_block_delta","index":0,
"delta":{"type":"text_delta","text":"b"}}));
assert_eq!(acc.ttft_ms, Some(first), "a later chunk moved the clock");
assert!(
first >= 40,
"the clock runs from the request, got {first}ms"
);
}
#[test]
fn accumulates_plain_text() {
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"content_block_delta","index":0,
"delta":{"type":"text_delta","text":"hello "}}));
acc.ingest(&json!({"type":"content_block_delta","index":0,
"delta":{"type":"text_delta","text":"world"}}));
let out = acc.finish();
assert_eq!(out.text.as_deref(), Some("hello world"));
assert!(out.tool_calls.is_empty());
}
}
#[cfg(test)]
mod web_wire {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use super::*;
use crate::web::WebAccess;
#[allow(clippy::needless_update)] fn req(web: Option<WebAccess>) -> CompletionRequest {
CompletionRequest {
system: "sys".into(),
user: "what shipped this week".into(),
web,
..Default::default()
}
}
#[test]
fn a_declaration_becomes_dated_server_tool_entries() {
let web = WebAccess::search()
.with_fetch()
.max_uses(5)
.allow("docs.rs")
.allow("crates.io");
let b = Anthropic::new("k", "claude-x").body(&req(Some(web)));
let tools = b["tools"].as_array().expect("a tools array");
assert_eq!(tools.len(), 2, "search and fetch, got {tools:?}");
assert_eq!(tools[0]["type"], WEB_SEARCH_TYPE);
assert_eq!(tools[0]["name"], "web_search");
assert_eq!(tools[0]["max_uses"], 5);
assert_eq!(tools[0]["allowed_domains"], json!(["docs.rs", "crates.io"]));
assert_eq!(tools[1]["type"], WEB_FETCH_TYPE);
assert_eq!(tools[1]["name"], "web_fetch");
assert!(tools[0].get("blocked_domains").is_none());
}
#[test]
fn a_block_list_alone_is_sent_as_one() {
let b = Anthropic::new("k", "claude-x")
.body(&req(Some(WebAccess::search().block("evil.test"))));
assert_eq!(b["tools"][0]["blocked_domains"], json!(["evil.test"]));
assert!(b["tools"][0].get("allowed_domains").is_none());
assert!(b["tools"][0].get("max_uses").is_none(), "no cap declared");
}
#[test]
fn no_declaration_sends_the_0_21_0_body() {
let b = Anthropic::new("k", "claude-x").body(&req(None));
assert_eq!(b["tools"], json!([]));
let off =
Anthropic::new("k", "claude-x").body(&req(Some(WebAccess::default().allow("docs.rs"))));
assert_eq!(off["tools"], json!([]));
}
#[test]
fn the_fetch_beta_header_is_sent_only_when_fetch_is_asked_for() {
for (web, expected) in [
(WebAccess::search().with_fetch(), true),
(WebAccess::search(), false),
] {
let head = head_of_request(web);
assert_eq!(
head.contains(&format!("anthropic-beta: {WEB_FETCH_BETA}")),
expected,
"wrong beta header for fetch={}, head was:\n{head}",
expected
);
}
}
fn head_of_request(web: WebAccess) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/v1/messages", listener.local_addr().unwrap());
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let Ok(mut stream) = listener.incoming().next().expect("one connection") else {
return;
};
let mut seen = Vec::new();
let mut byte = [0u8; 1];
while stream.read(&mut byte).unwrap_or(0) == 1 {
seen.push(byte[0]);
if seen.ends_with(b"\r\n\r\n") {
break;
}
}
let _ = tx.send(String::from_utf8_lossy(&seen).to_ascii_lowercase());
let body = "data: {\"type\":\"message_stop\"}\n\n";
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
.as_bytes(),
);
let _ = stream.flush();
});
let provider = Anthropic::at(url, Duration::from_secs(5));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let _ = runtime.block_on(provider.complete(req(Some(web))));
rx.recv().expect("the request head")
}
#[test]
fn citations_arrive_from_deltas_and_from_result_blocks() {
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"content_block_start","index":0,
"content_block":{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search"}}));
acc.ingest(&json!({"type":"content_block_start","index":1,
"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1",
"content":[{"type":"web_search_result","url":"https://docs.rs/io-harness",
"title":"io-harness"}]}}));
acc.ingest(&json!({"type":"content_block_delta","index":2,
"delta":{"type":"text_delta","text":"0.22.0 adds web search"}}));
acc.ingest(&json!({"type":"content_block_delta","index":2,
"delta":{"type":"citations_delta","citation":{"type":"web_search_result_location",
"url":"https://docs.rs/io-harness","title":"io-harness",
"cited_text":"provider-executed web search"}}}));
acc.ingest(
&json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},
"usage":{"output_tokens":9,"server_tool_use":{"web_search_requests":1}}}),
);
let out = acc.finish();
assert_eq!(
out.citations.len(),
1,
"one page, cited twice: {:?}",
out.citations
);
assert_eq!(out.citations[0].url, "https://docs.rs/io-harness");
assert_eq!(out.citations[0].title.as_deref(), Some("io-harness"));
assert_eq!(
out.citations[0].cited_text.as_deref(),
Some("provider-executed web search"),
"the quoted passage arrives on the delta, not on the result block"
);
assert_eq!(
out.server_tools,
vec![crate::web::ServerToolCall::ok("anthropic", "web_search")]
);
assert_eq!(out.usage.unwrap().server_tool_requests, 1);
}
#[test]
fn a_search_that_failed_inside_a_200_is_recorded_as_a_failure() {
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"content_block_start","index":0,
"content_block":{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search"}}));
acc.ingest(&json!({"type":"content_block_start","index":1,
"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1",
"content":{"type":"web_search_tool_result_error",
"error_code":"max_uses_exceeded"}}}));
acc.ingest(&json!({"type":"message_delta","usage":{"output_tokens":3}}));
let out = acc.finish();
assert_eq!(
out.server_tools,
vec![crate::web::ServerToolCall::failed(
"anthropic",
"web_search",
"max_uses_exceeded"
)]
);
assert!(out.citations.is_empty(), "a failed search cites nothing");
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"content_block_start","index":0,
"content_block":{"type":"server_tool_use","id":"srvtoolu_2","name":"web_search"}}));
acc.ingest(&json!({"type":"content_block_start","index":1,
"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_2",
"content":[]}}));
acc.ingest(&json!({"type":"message_delta","usage":{"output_tokens":3}}));
let out = acc.finish();
assert_eq!(out.server_tools.len(), 1);
assert!(out.server_tools[0].succeeded());
assert!(out.citations.is_empty());
}
#[test]
fn a_fetch_result_is_named_by_the_request_that_asked_for_it() {
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"content_block_start","index":0,
"content_block":{"type":"server_tool_use","id":"srvtoolu_9","name":"web_fetch"}}));
acc.ingest(&json!({"type":"content_block_start","index":1,
"content_block":{"type":"web_fetch_tool_result","tool_use_id":"srvtoolu_9",
"content":[{"type":"web_fetch_result","url":"https://example.test/page"}]}}));
acc.ingest(&json!({"type":"message_delta","usage":{"output_tokens":2}}));
let out = acc.finish();
assert_eq!(out.server_tools[0].tool, "web_fetch");
assert_eq!(out.citations[0].url, "https://example.test/page");
assert_eq!(out.citations[0].title, None);
}
#[test]
fn a_stream_with_no_web_activity_reports_none() {
let mut acc = Accumulator::default();
acc.ingest(&json!({"type":"content_block_delta","index":0,
"delta":{"type":"text_delta","text":"hello"}}));
acc.ingest(&json!({"type":"message_delta","usage":{"output_tokens":1}}));
let out = acc.finish();
assert!(out.citations.is_empty());
assert!(out.server_tools.is_empty());
assert_eq!(out.usage.unwrap().server_tool_requests, 0);
}
}
#[cfg(all(test, feature = "media"))]
mod media_wire {
use super::*;
use crate::provider::Media;
#[allow(clippy::needless_update)] fn req_with_image() -> CompletionRequest {
CompletionRequest {
system: "sys".into(),
user: "what is this".into(),
media: vec![Media::image("image/png", &[1, 2, 3]).unwrap()],
..Default::default()
}
}
#[test]
fn an_image_becomes_a_base64_source_block_before_the_text() {
let b = Anthropic::new("k", "claude-x").body(&req_with_image());
let content = &b["messages"][0]["content"];
assert!(content.is_array(), "content must be blocks, got {content}");
assert_eq!(content[0]["type"], "image");
assert_eq!(content[0]["source"]["type"], "base64");
assert_eq!(content[0]["source"]["media_type"], "image/png");
assert_eq!(content[0]["source"]["data"], "AQID");
assert_eq!(content[1]["type"], "text");
assert_eq!(content[1]["text"], "what is this");
}
#[test]
fn a_request_without_an_image_still_sends_a_bare_string() {
#[allow(clippy::needless_update)] let b = Anthropic::new("k", "claude-x").body(&CompletionRequest {
system: "sys".into(),
user: "no picture".into(),
..Default::default()
});
assert_eq!(b["messages"][0]["content"], "no picture");
}
}