1use std::collections::BTreeMap;
2
3use async_trait::async_trait;
4use serde_json::{json, Value};
5
6use crate::http::{secret_header, Http};
7use crate::openai::{parse_arguments, usage};
8use crate::stream::{self, parse_data, Accumulator, ChatStream, StreamEvent};
9use crate::{
10 ApiError, ChatRequest, ChatResponse, ImageSource, LlmError, LlmProvider, Message, ModelInfo,
11 Part, Result, Role, StopReason, DEFAULT_MAX_TOKENS,
12};
13
14const API_VERSION: &str = "2023-06-01";
16const VERTEX_API_VERSION: &str = "vertex-2023-10-16";
18
19#[derive(Clone)]
22pub struct AnthropicProvider {
23 http: Http,
24 api_key: String,
25 base_url: String,
26 vertex: Option<Vertex>,
27}
28
29#[derive(Clone, Debug)]
30pub(crate) struct Vertex {
31 pub(crate) project: String,
32 pub(crate) region: String,
33}
34
35impl Vertex {
36 pub(crate) fn base_url(region: &str) -> String {
37 if region == "global" {
38 "https://aiplatform.googleapis.com/v1".to_string()
39 } else {
40 format!("https://{region}-aiplatform.googleapis.com/v1")
41 }
42 }
43
44 pub(crate) fn url(
46 &self,
47 provider: &str,
48 base_url: &str,
49 publisher: &str,
50 model: &str,
51 method: &str,
52 ) -> Result<String> {
53 let project = crate::checked_path_segment(provider, "project", &self.project, &[])?;
54 let region = crate::checked_path_segment(provider, "region", &self.region, &[])?;
55 let model = crate::checked_path_segment(provider, "model", model, &['@'])?;
56 Ok(format!(
57 "{base_url}/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{method}"
58 ))
59 }
60}
61
62impl std::fmt::Debug for AnthropicProvider {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("AnthropicProvider")
65 .field("api_key", &"<redacted>")
66 .field("base_url", &self.base_url)
67 .field("vertex", &self.vertex)
68 .field("max_retries", &self.http.max_retries)
69 .finish()
70 }
71}
72
73impl AnthropicProvider {
74 pub fn new(api_key: impl Into<String>) -> Self {
75 Self {
76 http: Http::default(),
77 api_key: api_key.into(),
78 base_url: "https://api.anthropic.com".to_string(),
79 vertex: None,
80 }
81 }
82
83 pub fn vertex(
86 project: impl Into<String>,
87 region: impl Into<String>,
88 access_token: impl Into<String>,
89 ) -> Self {
90 let region = region.into();
91 Self {
92 http: Http::default(),
93 api_key: access_token.into(),
94 base_url: Vertex::base_url(®ion),
95 vertex: Some(Vertex {
96 project: project.into(),
97 region,
98 }),
99 }
100 }
101
102 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
103 self.base_url = base_url.into().trim_end_matches('/').to_string();
104 self
105 }
106
107 pub fn with_client(mut self, client: reqwest::Client) -> Self {
109 self.http.client = client;
110 self
111 }
112
113 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
115 self.http.max_retries = max_retries;
116 self
117 }
118
119 fn build_body(req: &ChatRequest) -> Value {
120 let mut system_parts: Vec<String> = req.system.iter().cloned().collect();
122 let mut messages = Vec::new();
123 for message in &req.messages {
124 if message.role == Role::System {
125 system_parts.push(message.text_content());
126 } else {
127 messages.push(to_anthropic_message(message));
128 }
129 }
130
131 let mut body = json!({
132 "model": req.model,
133 "max_tokens": req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
134 "messages": messages,
135 });
136 if !system_parts.is_empty() {
137 body["system"] = json!(system_parts.join("\n\n"));
138 }
139 if !req.tools.is_empty() {
140 let tools: Vec<Value> = req
141 .tools
142 .iter()
143 .map(|t| {
144 json!({
145 "name": t.name,
146 "description": t.description,
147 "input_schema": t.parameters,
148 })
149 })
150 .collect();
151 body["tools"] = json!(tools);
152 }
153 if let Some(temperature) = req.temperature {
154 body["temperature"] = json!(temperature);
155 }
156 let mut output_config = serde_json::Map::new();
157 if let Some(format) = &req.response_format {
158 output_config.insert(
159 "format".into(),
160 json!({ "type": "json_schema", "schema": format.schema }),
161 );
162 }
163 if let Some(effort) = req.reasoning {
164 body["thinking"] = json!({ "type": "adaptive", "display": "summarized" });
166 output_config.insert("effort".into(), json!(effort.as_str()));
167 }
168 if !output_config.is_empty() {
169 body["output_config"] = Value::Object(output_config);
170 }
171 body
172 }
173
174 fn request(&self, url: String) -> Result<reqwest::RequestBuilder> {
175 let builder = self.http.client.post(url);
176 Ok(if self.vertex.is_some() {
177 builder.header(
178 reqwest::header::AUTHORIZATION,
179 secret_header("AnthropicProvider", &format!("Bearer {}", self.api_key))?,
180 )
181 } else {
182 builder
183 .header(
184 "x-api-key",
185 secret_header("AnthropicProvider", &self.api_key)?,
186 )
187 .header("anthropic-version", API_VERSION)
188 })
189 }
190
191 fn chat_request(&self, req: &ChatRequest, stream: bool) -> Result<reqwest::RequestBuilder> {
193 crate::validate(
195 "AnthropicProvider",
196 &self.api_key,
197 true,
198 &self.base_url,
199 128,
200 req,
201 )?;
202 let mut body = Self::build_body(req);
203 if stream {
204 body["stream"] = json!(true);
205 }
206 let url = match &self.vertex {
207 Some(vertex) => {
208 if let Some(obj) = body.as_object_mut() {
210 obj.remove("model");
211 obj.insert("anthropic_version".into(), json!(VERTEX_API_VERSION));
212 }
213 let method = if stream {
214 "streamRawPredict"
215 } else {
216 "rawPredict"
217 };
218 vertex.url(
219 "AnthropicProvider",
220 &self.base_url,
221 "anthropic",
222 &req.model,
223 method,
224 )?
225 }
226 None => format!("{}/v1/messages", self.base_url),
227 };
228 Ok(self.request(url)?.json(&body))
229 }
230}
231
232fn to_anthropic_message(message: &Message) -> Value {
233 let role = if message.role == Role::Assistant {
235 "assistant"
236 } else {
237 "user"
238 };
239 let content: Vec<Value> = message
240 .parts
241 .iter()
242 .filter_map(to_anthropic_block)
243 .collect();
244 json!({ "role": role, "content": content })
245}
246
247fn to_anthropic_block(part: &Part) -> Option<Value> {
248 Some(match part {
249 Part::Text { text } if text.is_empty() => return None,
251 Part::Text { text } => json!({ "type": "text", "text": text }),
252 Part::Image { source } => {
253 let source = match source {
254 ImageSource::Url { url } => json!({ "type": "url", "url": url }),
255 ImageSource::Base64 { media_type, data } => {
256 json!({ "type": "base64", "media_type": media_type, "data": data })
257 }
258 };
259 json!({ "type": "image", "source": source })
260 }
261 Part::Reasoning { raw, .. } if raw.is_object() => raw.clone(),
263 Part::Reasoning { .. } => return None,
264 Part::Other { raw } if raw.is_object() => raw.clone(),
266 Part::Other { .. } => return None,
267 Part::ToolCall {
268 id,
269 name,
270 arguments,
271 ..
272 } => {
273 let input = if arguments.is_object() {
275 arguments.clone()
276 } else {
277 json!({})
278 };
279 json!({ "type": "tool_use", "id": id, "name": name, "input": input })
280 }
281 Part::ToolResult {
282 call_id,
283 content,
284 is_error,
285 } => json!({
286 "type": "tool_result",
287 "tool_use_id": call_id,
288 "content": content,
289 "is_error": is_error,
290 }),
291 })
292}
293
294fn parse_response(v: &Value) -> Result<ChatResponse> {
295 let blocks = v["content"]
296 .as_array()
297 .ok_or_else(|| LlmError::InvalidResponse("missing field `content`".into()))?;
298
299 let mut parts = Vec::new();
300 for block in blocks {
301 match block["type"].as_str() {
302 Some("text") => {
303 if let Some(text) = block["text"].as_str().filter(|t| !t.is_empty()) {
304 parts.push(Part::text(text));
305 }
306 }
307 Some("tool_use") => parts.push(Part::ToolCall {
308 id: block["id"].as_str().unwrap_or_default().to_string(),
309 name: block["name"].as_str().unwrap_or_default().to_string(),
310 arguments: block["input"].clone(),
311 signature: None,
312 }),
313 Some("thinking") => parts.push(Part::Reasoning {
314 text: block["thinking"].as_str().unwrap_or_default().to_string(),
315 raw: block.clone(),
316 }),
317 Some("redacted_thinking") => parts.push(Part::Reasoning {
318 text: String::new(),
319 raw: block.clone(),
320 }),
321 Some(_) if block.is_object() => parts.push(Part::Other { raw: block.clone() }),
324 _ => {}
325 }
326 }
327
328 let stop_reason = match v["stop_reason"].as_str() {
329 Some("end_turn" | "stop_sequence") => StopReason::EndTurn,
330 Some("tool_use") => StopReason::ToolUse,
331 Some("max_tokens" | "model_context_window_exceeded") => StopReason::MaxTokens,
332 Some("pause_turn") => StopReason::Paused,
333 Some("refusal") => StopReason::Refusal,
334 other => StopReason::Other(other.unwrap_or("unknown").to_string()),
335 };
336
337 Ok(ChatResponse {
338 model: v["model"].as_str().unwrap_or_default().to_string(),
339 message: Message::new(Role::Assistant, parts),
340 stop_reason,
341 usage: usage(&v["usage"], "input_tokens", "output_tokens"),
342 })
343}
344
345#[derive(Default)]
348struct AnthropicStream {
349 message: Value,
350 blocks: BTreeMap<u64, Value>,
352 tool_json: BTreeMap<u64, String>,
353 done: bool,
354}
355
356impl Accumulator for AnthropicStream {
357 fn on_event(&mut self, _event: &str, data: &str) -> Result<Vec<StreamEvent>> {
358 let v = parse_data(data)?;
359 let index = v["index"].as_u64().unwrap_or(0);
360 let mut out = Vec::new();
361 match v["type"].as_str() {
362 Some("message_start") => {
363 if !v["message"].is_object() {
364 return Err(LlmError::InvalidResponse(
365 "malformed message_start event".into(),
366 ));
367 }
368 self.message = v["message"].clone();
369 }
370 Some("content_block_start") => {
371 let block = v["content_block"].clone();
372 if !block.is_object() {
373 return Err(LlmError::InvalidResponse(
374 "malformed content_block_start event".into(),
375 ));
376 }
377 if block["type"] == "tool_use" {
378 out.push(StreamEvent::ToolCallStart {
379 id: block["id"].as_str().unwrap_or_default().to_string(),
380 name: block["name"].as_str().unwrap_or_default().to_string(),
381 });
382 self.tool_json.insert(index, String::new());
383 }
384 self.blocks.insert(index, block);
385 }
386 Some("content_block_delta") => {
387 let delta = &v["delta"];
388 let Some(block) = self.blocks.get_mut(&index) else {
389 return Ok(out);
390 };
391 match delta["type"].as_str() {
392 Some("text_delta") => {
393 let text = delta["text"].as_str().unwrap_or_default();
394 append(block, "text", text);
395 out.push(StreamEvent::TextDelta(text.to_string()));
396 }
397 Some("thinking_delta") => {
398 let text = delta["thinking"].as_str().unwrap_or_default();
399 append(block, "thinking", text);
400 out.push(StreamEvent::ReasoningDelta(text.to_string()));
401 }
402 Some("signature_delta") => {
403 append(
404 block,
405 "signature",
406 delta["signature"].as_str().unwrap_or_default(),
407 );
408 }
409 Some("input_json_delta") => {
410 let json = delta["partial_json"].as_str().unwrap_or_default();
411 self.tool_json.entry(index).or_default().push_str(json);
412 out.push(StreamEvent::ToolCallDelta {
413 id: block["id"].as_str().unwrap_or_default().to_string(),
414 arguments: json.to_string(),
415 });
416 }
417 _ => {}
418 }
419 }
420 Some("content_block_stop") => {
421 if let (Some(json), Some(block)) =
422 (self.tool_json.remove(&index), self.blocks.get_mut(&index))
423 {
424 block["input"] = parse_arguments(&json);
425 }
426 }
427 Some("message_delta") => {
428 if !self.message.is_object() {
429 self.message = json!({});
430 }
431 if let Some(reason) = v["delta"]["stop_reason"].as_str() {
432 self.message["stop_reason"] = json!(reason);
433 }
434 if let Some(tokens) = v["usage"]["output_tokens"].as_u64() {
435 if !self.message["usage"].is_object() {
436 self.message["usage"] = json!({});
437 }
438 self.message["usage"]["output_tokens"] = json!(tokens);
439 }
440 }
441 Some("message_stop") => self.done = true,
442 Some("error") => {
443 return Err(LlmError::Api(Box::new(ApiError::from_stream_error(
444 &v["error"],
445 ))));
446 }
447 _ => {}
449 }
450 Ok(out)
451 }
452
453 fn finish(&mut self) -> Result<ChatResponse> {
454 if !self.done {
455 return Err(crate::stream::ended_early());
456 }
457 let mut message = std::mem::take(&mut self.message);
458 message["content"] = json!(std::mem::take(&mut self.blocks)
459 .into_values()
460 .collect::<Vec<_>>());
461 parse_response(&message)
462 }
463}
464
465fn append(block: &mut Value, field: &str, text: &str) {
467 match &mut block[field] {
468 Value::String(current) => current.push_str(text),
469 other => *other = json!(text),
470 }
471}
472
473#[async_trait]
474impl LlmProvider for AnthropicProvider {
475 async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
476 let builder = self.chat_request(req, false)?;
477 parse_response(&self.http.json(builder).await?)
478 }
479
480 async fn chat_stream(&self, req: &ChatRequest) -> Result<ChatStream> {
481 let builder = self.chat_request(req, true)?;
482 let response = self.http.send(builder).await?;
483 Ok(stream::drive(response, Box::<AnthropicStream>::default()))
484 }
485
486 async fn list_models(&self) -> Result<Vec<ModelInfo>> {
487 if self.vertex.is_some() {
488 return Err(LlmError::InvalidArgument(
489 "AnthropicProvider: list_models is not available on Vertex AI".into(),
490 ));
491 }
492 crate::validate_endpoint("AnthropicProvider", &self.api_key, true, &self.base_url)?;
493 let builder = self
494 .http
495 .client
496 .get(format!("{}/v1/models?limit=1000", self.base_url))
497 .header(
498 "x-api-key",
499 secret_header("AnthropicProvider", &self.api_key)?,
500 )
501 .header("anthropic-version", API_VERSION);
502 let v = self.http.json(builder).await?;
503 Ok(v["data"]
504 .as_array()
505 .into_iter()
506 .flatten()
507 .filter_map(|m| {
508 Some(ModelInfo {
509 id: m["id"].as_str()?.to_string(),
510 display_name: m["display_name"].as_str().map(str::to_string),
511 })
512 })
513 .collect())
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::{ReasoningEffort, Tool};
521
522 #[test]
523 fn moves_system_and_maps_tool_results() {
524 let req = ChatRequest::new("claude-opus-5-5")
525 .system("sys")
526 .tool(Tool::new("f", "d", json!({"type": "object"})))
527 .message(Message::user("hi"))
528 .message(Message::tool_result("toolu_1", "42", false));
529 let body = AnthropicProvider::build_body(&req);
530 assert_eq!(body["system"], "sys");
531 assert_eq!(body["max_tokens"], DEFAULT_MAX_TOKENS);
532 assert_eq!(body["tools"][0]["input_schema"]["type"], "object");
533 assert_eq!(body["messages"][1]["role"], "user");
534 assert_eq!(body["messages"][1]["content"][0]["type"], "tool_result");
535 assert_eq!(body["messages"][1]["content"][0]["tool_use_id"], "toolu_1");
536 }
537
538 #[test]
539 fn builds_images_schema_and_thinking() {
540 let req = ChatRequest::new("claude-opus-5-5")
541 .json_schema("answer", json!({"type": "object"}))
542 .reasoning(ReasoningEffort::Low)
543 .message(Message::user_with_image(
544 "what?",
545 Part::image_url("https://example.com/a.png"),
546 ));
547 let body = AnthropicProvider::build_body(&req);
548 let image = &body["messages"][0]["content"][1];
549 assert_eq!(image["type"], "image");
550 assert_eq!(image["source"]["url"], "https://example.com/a.png");
551 assert_eq!(body["output_config"]["format"]["type"], "json_schema");
552 assert_eq!(body["output_config"]["effort"], "low");
553 assert_eq!(body["thinking"]["type"], "adaptive");
554 }
555
556 #[test]
557 fn thinking_blocks_round_trip_unchanged() {
558 let v = json!({
559 "model": "claude-opus-5-5",
560 "content": [
561 { "type": "thinking", "thinking": "plan", "signature": "sig123" },
562 { "type": "tool_use", "id": "toolu_1", "name": "f", "input": { "x": 1 } }
563 ],
564 "stop_reason": "tool_use",
565 "usage": { "input_tokens": 3, "output_tokens": 9 }
566 });
567 let resp = parse_response(&v).unwrap();
568 assert_eq!(resp.reasoning(), "plan");
569 let sent = to_anthropic_message(&resp.message);
570 assert_eq!(sent["content"][0], v["content"][0]);
571 assert_eq!(sent["content"][1]["type"], "tool_use");
572 }
573
574 #[test]
575 fn stream_rebuilds_message() {
576 let mut acc = AnthropicStream::default();
577 let events = [
578 r#"{"type":"message_start","message":{"model":"claude-opus-5-5","content":[],"usage":{"input_tokens":5,"output_tokens":1}}}"#,
579 r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
580 r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#,
581 r#"{"type":"content_block_stop","index":0}"#,
582 r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"f","input":{}}}"#,
583 r#"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"x\":"}}"#,
584 r#"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"1}"}}"#,
585 r#"{"type":"content_block_stop","index":1}"#,
586 r#"{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":12}}"#,
587 r#"{"type":"message_stop"}"#,
588 ];
589 for e in events {
590 acc.on_event("", e).unwrap();
591 }
592 let resp = acc.finish().unwrap();
593 assert_eq!(resp.text(), "Hi");
594 assert_eq!(resp.tool_calls()[0].arguments["x"], 1);
595 assert_eq!(resp.stop_reason, StopReason::ToolUse);
596 assert_eq!(resp.usage.input_tokens, 5);
597 assert_eq!(resp.usage.output_tokens, 12);
598 }
599
600 #[test]
601 fn stream_error_event_maps_to_api_error() {
602 let mut acc = AnthropicStream::default();
603 let err = acc
604 .on_event(
605 "error",
606 r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
607 )
608 .unwrap_err();
609 assert_eq!(err.status(), Some(529));
610 assert!(err.is_retryable());
611 }
612
613 #[test]
614 fn vertex_url_and_body() {
615 let p = AnthropicProvider::vertex("my-proj", "us-east5", "token");
616 let req = ChatRequest::new("claude-opus-5-5").message(Message::user("hi"));
617 let built = p.chat_request(&req, false).unwrap().build().unwrap();
618 assert_eq!(
619 built.url().as_str(),
620 "https://us-east5-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-east5/publishers/anthropic/models/claude-opus-5-5:rawPredict"
621 );
622 let body: Value =
623 serde_json::from_slice(built.body().unwrap().as_bytes().unwrap()).unwrap();
624 assert!(body.get("model").is_none());
625 assert_eq!(body["anthropic_version"], VERTEX_API_VERSION);
626 assert!(built.headers()["authorization"].is_sensitive());
627
628 let bad = AnthropicProvider::vertex("../x", "us-east5", "token");
629 assert!(bad.chat_request(&req, false).is_err());
630 }
631
632 #[test]
633 fn debug_redacts_api_key() {
634 let debug = format!("{:?}", AnthropicProvider::new("sk-ant-secret"));
635 assert!(!debug.contains("sk-ant-secret"));
636 }
637}
638
639#[cfg(test)]
640mod regression_tests {
641 use super::*;
642
643 #[test]
644 fn server_tool_blocks_are_kept_for_a_paused_turn() {
645 let v = json!({
646 "content": [{ "type": "server_tool_use", "id": "srv_1", "name": "web_search", "input": {} }],
647 "stop_reason": "pause_turn"
648 });
649 let resp = parse_response(&v).unwrap();
650 assert_eq!(resp.stop_reason, StopReason::Paused);
651 assert_eq!(
652 to_anthropic_message(&resp.message)["content"][0],
653 v["content"][0]
654 );
655 }
656
657 #[test]
658 fn raw_string_arguments_are_not_sent_as_input() {
659 let msg = Message::new(
660 Role::Assistant,
661 vec![Part::ToolCall {
662 id: "t".into(),
663 name: "f".into(),
664 arguments: json!("{\"x\":"),
665 signature: None,
666 }],
667 );
668 assert_eq!(to_anthropic_message(&msg)["content"][0]["input"], json!({}));
669 }
670
671 #[test]
672 fn malformed_events_do_not_panic() {
673 let mut acc = AnthropicStream::default();
674 assert!(acc
675 .on_event("", r#"{"type":"message_start","message":5}"#)
676 .is_err());
677 assert!(acc
678 .on_event(
679 "",
680 r#"{"type":"content_block_start","index":0,"content_block":"x"}"#
681 )
682 .is_err());
683 let mut acc = AnthropicStream::default();
684 acc.on_event("", r#"{"type":"message_start","message":{"usage":1}}"#)
685 .unwrap();
686 acc.on_event(
687 "",
688 r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":3}}"#,
689 )
690 .unwrap();
691 }
692}