1use crate::providers::traits::{
2 ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
3 Provider, ProviderCapabilities, StreamChunk, StreamError, StreamEvent, StreamOptions,
4 StreamResult, TokenUsage, ToolCall as ProviderToolCall,
5};
6use crate::tools::ToolSpec;
7use async_trait::async_trait;
8use base64::Engine as _;
9use futures_util::stream::{self, StreamExt};
10use reqwest::Client;
11use serde::{Deserialize, Serialize};
12
13pub struct AnthropicProvider {
14 credential: Option<String>,
15 base_url: String,
16 max_tokens: u32,
17}
18
19const DEFAULT_ANTHROPIC_MAX_TOKENS: u32 = 4096;
20
21#[derive(Debug, Serialize)]
22struct ChatRequest {
23 model: String,
24 max_tokens: u32,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 system: Option<String>,
27 messages: Vec<Message>,
28 temperature: f64,
29}
30
31#[derive(Debug, Serialize)]
32struct Message {
33 role: String,
34 content: String,
35}
36
37#[derive(Debug, Deserialize)]
38struct ChatResponse {
39 content: Vec<ContentBlock>,
40}
41
42#[derive(Debug, Deserialize)]
43struct ContentBlock {
44 #[serde(rename = "type")]
45 kind: String,
46 #[serde(default)]
47 text: Option<String>,
48}
49
50#[derive(Debug, Serialize)]
51struct NativeChatRequest<'a> {
52 model: String,
53 max_tokens: u32,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 system: Option<SystemPrompt>,
56 messages: Vec<NativeMessage>,
57 temperature: f64,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 tools: Option<Vec<NativeToolSpec<'a>>>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 tool_choice: Option<serde_json::Value>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 stream: Option<bool>,
64}
65
66#[derive(Debug, Serialize)]
67struct NativeMessage {
68 role: String,
69 content: Vec<NativeContentOut>,
70}
71
72#[derive(Debug, Serialize)]
73struct ImageSource {
74 #[serde(rename = "type")]
75 source_type: String,
76 media_type: String,
77 data: String,
78}
79
80#[derive(Debug, Serialize)]
81#[serde(tag = "type")]
82enum NativeContentOut {
83 #[serde(rename = "text")]
84 Text {
85 text: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 cache_control: Option<CacheControl>,
88 },
89 #[serde(rename = "image")]
90 Image { source: ImageSource },
91 #[serde(rename = "tool_use")]
92 ToolUse {
93 id: String,
94 name: String,
95 input: serde_json::Value,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 cache_control: Option<CacheControl>,
98 },
99 #[serde(rename = "tool_result")]
100 ToolResult {
101 tool_use_id: String,
102 content: String,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 cache_control: Option<CacheControl>,
105 },
106}
107
108#[derive(Debug, Serialize)]
109struct NativeToolSpec<'a> {
110 name: &'a str,
111 description: &'a str,
112 input_schema: &'a serde_json::Value,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 cache_control: Option<CacheControl>,
115}
116
117#[derive(Debug, Clone, Serialize)]
118struct CacheControl {
119 #[serde(rename = "type")]
120 cache_type: String,
121}
122
123impl CacheControl {
124 fn ephemeral() -> Self {
125 Self {
126 cache_type: "ephemeral".to_string(),
127 }
128 }
129}
130
131#[derive(Debug, Serialize)]
132#[serde(untagged)]
133enum SystemPrompt {
134 String(String),
135 Blocks(Vec<SystemBlock>),
136}
137
138#[derive(Debug, Serialize)]
139struct SystemBlock {
140 #[serde(rename = "type")]
141 block_type: String,
142 text: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 cache_control: Option<CacheControl>,
145}
146
147#[derive(Debug, Deserialize)]
148struct NativeChatResponse {
149 #[serde(default)]
150 content: Vec<NativeContentIn>,
151 #[serde(default)]
152 usage: Option<AnthropicUsage>,
153}
154
155#[derive(Debug, Deserialize)]
156struct AnthropicUsage {
157 #[serde(default)]
158 input_tokens: Option<u64>,
159 #[serde(default)]
160 output_tokens: Option<u64>,
161 #[serde(default)]
162 cache_creation_input_tokens: Option<u64>,
163 #[serde(default)]
164 cache_read_input_tokens: Option<u64>,
165}
166
167#[derive(Debug, Deserialize)]
168struct NativeContentIn {
169 #[serde(rename = "type")]
170 kind: String,
171 #[serde(default)]
172 text: Option<String>,
173 #[serde(default)]
174 id: Option<String>,
175 #[serde(default)]
176 name: Option<String>,
177 #[serde(default)]
178 input: Option<serde_json::Value>,
179}
180
181impl AnthropicProvider {
182 pub fn new(credential: Option<&str>) -> Self {
183 Self::with_base_url(credential, None)
184 }
185
186 pub fn with_base_url(credential: Option<&str>, base_url: Option<&str>) -> Self {
187 let base_url = base_url
188 .map(|u| u.trim_end_matches('/'))
189 .unwrap_or("https://api.anthropic.com")
190 .to_string();
191 Self {
192 credential: credential
193 .map(str::trim)
194 .filter(|k| !k.is_empty())
195 .map(ToString::to_string),
196 base_url,
197 max_tokens: DEFAULT_ANTHROPIC_MAX_TOKENS,
198 }
199 }
200
201 pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
203 self.max_tokens = max_tokens;
204 self
205 }
206
207 fn is_setup_token(token: &str) -> bool {
208 token.starts_with("sk-ant-oat01-")
209 }
210
211 fn apply_auth(
212 &self,
213 request: reqwest::RequestBuilder,
214 credential: &str,
215 ) -> reqwest::RequestBuilder {
216 if Self::is_setup_token(credential) {
217 request
218 .header("Authorization", format!("Bearer {credential}"))
219 .header(
220 "anthropic-beta",
221 "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14",
222 )
223 .header("anthropic-dangerous-direct-browser-access", "true")
224 } else {
225 request.header("x-api-key", credential)
226 }
227 }
228
229 fn apply_oauth_system_prompt(system: Option<SystemPrompt>) -> Option<SystemPrompt> {
232 let prefix = SystemBlock {
233 block_type: "text".to_string(),
234 text: "You are Claude Code, Anthropic's official CLI for Claude.".to_string(),
235 cache_control: Some(CacheControl::ephemeral()),
236 };
237 match system {
238 Some(SystemPrompt::Blocks(mut blocks)) => {
239 blocks.insert(0, prefix);
240 Some(SystemPrompt::Blocks(blocks))
241 }
242 Some(SystemPrompt::String(s)) => Some(SystemPrompt::Blocks(vec![
243 prefix,
244 SystemBlock {
245 block_type: "text".to_string(),
246 text: s,
247 cache_control: Some(CacheControl::ephemeral()),
248 },
249 ])),
250 None => Some(SystemPrompt::Blocks(vec![prefix])),
251 }
252 }
253
254 fn should_cache_system(text: &str) -> bool {
256 text.len() > 3072
257 }
258
259 fn should_cache_conversation(messages: &[ChatMessage]) -> bool {
261 messages.iter().filter(|m| m.role != "system").count() > 1
262 }
263
264 fn apply_cache_to_last_message(messages: &mut [NativeMessage]) {
266 if let Some(last_msg) = messages.last_mut() {
267 if let Some(last_content) = last_msg.content.last_mut() {
268 match last_content {
269 NativeContentOut::Text { cache_control, .. }
270 | NativeContentOut::ToolResult { cache_control, .. } => {
271 *cache_control = Some(CacheControl::ephemeral());
272 }
273 NativeContentOut::ToolUse { .. } | NativeContentOut::Image { .. } => {}
274 }
275 }
276 }
277 }
278
279 fn convert_tools<'a>(tools: Option<&'a [ToolSpec]>) -> Option<Vec<NativeToolSpec<'a>>> {
280 let items = tools?;
281 if items.is_empty() {
282 return None;
283 }
284 let mut native_tools: Vec<NativeToolSpec<'a>> = items
285 .iter()
286 .map(|tool| NativeToolSpec {
287 name: &tool.name,
288 description: &tool.description,
289 input_schema: &tool.parameters,
290 cache_control: None,
291 })
292 .collect();
293
294 if let Some(last_tool) = native_tools.last_mut() {
296 last_tool.cache_control = Some(CacheControl::ephemeral());
297 }
298
299 Some(native_tools)
300 }
301
302 fn parse_assistant_tool_call_message(content: &str) -> Option<Vec<NativeContentOut>> {
303 let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
304 let tool_calls = value
305 .get("tool_calls")
306 .and_then(|v| serde_json::from_value::<Vec<ProviderToolCall>>(v.clone()).ok())?;
307
308 let mut blocks = Vec::new();
309 if let Some(text) = value
310 .get("content")
311 .and_then(serde_json::Value::as_str)
312 .map(str::trim)
313 .filter(|t| !t.is_empty())
314 {
315 blocks.push(NativeContentOut::Text {
316 text: text.to_string(),
317 cache_control: None,
318 });
319 }
320 for call in tool_calls {
321 let input = serde_json::from_str::<serde_json::Value>(&call.arguments)
322 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
323 blocks.push(NativeContentOut::ToolUse {
324 id: call.id,
325 name: call.name,
326 input,
327 cache_control: None,
328 });
329 }
330 Some(blocks)
331 }
332
333 fn parse_tool_result_message(content: &str) -> Option<NativeMessage> {
334 let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
335 let tool_use_id = value
336 .get("tool_call_id")
337 .and_then(serde_json::Value::as_str)?
338 .to_string();
339 let result = value
340 .get("content")
341 .and_then(serde_json::Value::as_str)
342 .unwrap_or("")
343 .to_string();
344 Some(NativeMessage {
345 role: "user".to_string(),
346 content: vec![NativeContentOut::ToolResult {
347 tool_use_id,
348 content: result,
349 cache_control: None,
350 }],
351 })
352 }
353
354 fn convert_messages(messages: &[ChatMessage]) -> (Option<SystemPrompt>, Vec<NativeMessage>) {
355 let mut system_text = None;
356 let mut native_messages = Vec::new();
357
358 for msg in messages {
359 match msg.role.as_str() {
360 "system" => {
361 if system_text.is_none() {
362 system_text = Some(msg.content.clone());
363 }
364 }
365 "assistant" => {
366 if let Some(blocks) = Self::parse_assistant_tool_call_message(&msg.content) {
367 native_messages.push(NativeMessage {
368 role: "assistant".to_string(),
369 content: blocks,
370 });
371 } else if !msg.content.trim().is_empty() {
372 native_messages.push(NativeMessage {
373 role: "assistant".to_string(),
374 content: vec![NativeContentOut::Text {
375 text: msg.content.clone(),
376 cache_control: None,
377 }],
378 });
379 }
380 }
381 "tool" => {
382 let tool_msg = if let Some(tr) = Self::parse_tool_result_message(&msg.content) {
383 tr
384 } else if !msg.content.trim().is_empty() {
385 NativeMessage {
386 role: "user".to_string(),
387 content: vec![NativeContentOut::Text {
388 text: msg.content.clone(),
389 cache_control: None,
390 }],
391 }
392 } else {
393 continue;
394 };
395 if native_messages
399 .last()
400 .is_some_and(|m| m.role == tool_msg.role)
401 {
402 native_messages
403 .last_mut()
404 .unwrap()
405 .content
406 .extend(tool_msg.content);
407 } else {
408 native_messages.push(tool_msg);
409 }
410 }
411 _ => {
412 let (text, image_refs) = crate::multimodal::parse_image_markers(&msg.content);
414 let mut content_blocks: Vec<NativeContentOut> = Vec::new();
415
416 for img_ref in &image_refs {
418 let (media_type, data) = if img_ref.starts_with("data:") {
419 if let Some(comma) = img_ref.find(',') {
421 let header = &img_ref[5..comma];
422 let mime =
423 header.split(';').next().unwrap_or("image/jpeg").to_string();
424 let b64 = img_ref[comma + 1..].trim().to_string();
425 (mime, b64)
426 } else {
427 continue;
428 }
429 } else if std::path::Path::new(img_ref.trim()).exists() {
430 match std::fs::read(img_ref.trim()) {
432 Ok(bytes) => {
433 let b64 =
434 base64::engine::general_purpose::STANDARD.encode(&bytes);
435 let ext = std::path::Path::new(img_ref.trim())
436 .extension()
437 .and_then(|e| e.to_str())
438 .unwrap_or("jpg");
439 let mime = match ext {
440 "png" => "image/png",
441 "gif" => "image/gif",
442 "webp" => "image/webp",
443 _ => "image/jpeg",
444 }
445 .to_string();
446 (mime, b64)
447 }
448 Err(_) => continue,
449 }
450 } else {
451 continue;
452 };
453
454 content_blocks.push(NativeContentOut::Image {
455 source: ImageSource {
456 source_type: "base64".to_string(),
457 media_type,
458 data,
459 },
460 });
461 }
462
463 if text.is_empty() && !image_refs.is_empty() {
465 content_blocks.push(NativeContentOut::Text {
466 text: "[image]".to_string(),
467 cache_control: None,
468 });
469 } else if !text.trim().is_empty() {
470 content_blocks.push(NativeContentOut::Text {
471 text,
472 cache_control: None,
473 });
474 }
475
476 if native_messages.last().is_some_and(|m| m.role == "user") {
480 native_messages
481 .last_mut()
482 .unwrap()
483 .content
484 .extend(content_blocks);
485 } else {
486 native_messages.push(NativeMessage {
487 role: "user".to_string(),
488 content: content_blocks,
489 });
490 }
491 }
492 }
493 }
494
495 let system_prompt = system_text.map(|text| {
497 SystemPrompt::Blocks(vec![SystemBlock {
498 block_type: "text".to_string(),
499 text,
500 cache_control: Some(CacheControl::ephemeral()),
501 }])
502 });
503
504 (system_prompt, native_messages)
505 }
506
507 fn parse_text_response(response: ChatResponse) -> anyhow::Result<String> {
508 response
509 .content
510 .into_iter()
511 .find(|c| c.kind == "text")
512 .and_then(|c| c.text)
513 .ok_or_else(|| anyhow::anyhow!("No response from Anthropic"))
514 }
515
516 fn parse_native_response(response: NativeChatResponse) -> ProviderChatResponse {
517 let mut text_parts = Vec::new();
518 let mut tool_calls = Vec::new();
519
520 let usage = response.usage.map(|u| TokenUsage {
521 input_tokens: u.input_tokens,
522 output_tokens: u.output_tokens,
523 cached_input_tokens: u.cache_read_input_tokens,
524 });
525
526 for block in response.content {
527 match block.kind.as_str() {
528 "text" => {
529 if let Some(text) = block.text.map(|t| t.trim().to_string()) {
530 if !text.is_empty() {
531 text_parts.push(text);
532 }
533 }
534 }
535 "tool_use" => {
536 let name = block.name.unwrap_or_default();
537 if name.is_empty() {
538 continue;
539 }
540 let arguments = block
541 .input
542 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
543 tool_calls.push(ProviderToolCall {
544 id: block.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
545 name,
546 arguments: arguments.to_string(),
547 });
548 }
549 _ => {}
550 }
551 }
552
553 ProviderChatResponse {
554 text: if text_parts.is_empty() {
555 None
556 } else {
557 Some(text_parts.join("\n"))
558 },
559 tool_calls,
560 usage,
561 reasoning_content: None,
562 }
563 }
564
565 fn http_client(&self) -> Client {
566 crate::config::build_runtime_proxy_client_with_timeouts("provider.anthropic", 120, 10)
567 }
568
569 fn build_streaming_request(request: &NativeChatRequest<'_>) -> serde_json::Value {
571 let mut body =
572 serde_json::to_value(request).expect("NativeChatRequest should serialize to JSON");
573 body["stream"] = serde_json::Value::Bool(true);
574 body
575 }
576
577 async fn parse_anthropic_sse(
579 response: reqwest::Response,
580 tx: &tokio::sync::mpsc::Sender<StreamResult<StreamEvent>>,
581 ) {
582 use tokio::io::AsyncBufReadExt;
583 use tokio_util::io::StreamReader;
584
585 let byte_stream = response
586 .bytes_stream()
587 .map(|result| result.map_err(std::io::Error::other));
588 let reader = StreamReader::new(byte_stream);
589 let mut lines = reader.lines();
590
591 let mut tool_id: Option<String> = None;
592 let mut tool_name: Option<String> = None;
593 let mut tool_input_json = String::new();
594
595 while let Ok(Some(line)) = lines.next_line().await {
596 let line = line.trim().to_string();
597 if !line.starts_with("data: ") {
598 continue;
599 }
600 let json_str = &line["data: ".len()..];
601
602 let event: serde_json::Value = match serde_json::from_str(json_str) {
603 Ok(v) => v,
604 Err(_) => continue,
605 };
606
607 let event_type = event
608 .get("type")
609 .and_then(|t| t.as_str())
610 .unwrap_or_default();
611
612 match event_type {
613 "message_start" => {
614 let model = event
615 .get("message")
616 .and_then(|m| m.get("model"))
617 .and_then(|m| m.as_str())
618 .unwrap_or("unknown");
619 let usage_obj = event.get("message").and_then(|m| m.get("usage"));
620 let input_tokens = usage_obj
621 .and_then(|u| u.get("input_tokens"))
622 .and_then(|t| t.as_u64())
623 .unwrap_or(0);
624 let cache_read = usage_obj
625 .and_then(|u| u.get("cache_read_input_tokens"))
626 .and_then(|t| t.as_u64());
627 tracing::debug!(
628 model = %model,
629 input_tokens = input_tokens,
630 "Anthropic stream: message_start"
631 );
632 let _ = tx
633 .send(Ok(StreamEvent::Usage(
634 crate::providers::traits::TokenUsage {
635 input_tokens: Some(input_tokens),
636 output_tokens: None,
637 cached_input_tokens: cache_read,
638 },
639 )))
640 .await;
641 }
642 "content_block_start" => {
643 if let Some(block) = event.get("content_block") {
644 let block_type = block
645 .get("type")
646 .and_then(|t| t.as_str())
647 .unwrap_or_default();
648 if block_type == "tool_use" {
649 if let Some(id) = tool_id.take() {
650 let name = tool_name.take().unwrap_or_default();
651 let input = std::mem::take(&mut tool_input_json);
652 let _ = tx
653 .send(Ok(StreamEvent::ToolCall(ProviderToolCall {
654 id,
655 name,
656 arguments: input,
657 })))
658 .await;
659 }
660 tool_id = block
661 .get("id")
662 .and_then(|v| v.as_str())
663 .map(ToString::to_string);
664 tool_name = block
665 .get("name")
666 .and_then(|v| v.as_str())
667 .map(ToString::to_string);
668 tool_input_json.clear();
669 }
670 }
671 }
672 "content_block_delta" => {
673 if let Some(delta) = event.get("delta") {
674 let delta_type = delta
675 .get("type")
676 .and_then(|t| t.as_str())
677 .unwrap_or_default();
678 match delta_type {
679 "text_delta" => {
680 if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
681 if !text.is_empty()
682 && tx
683 .send(Ok(StreamEvent::TextDelta(StreamChunk::delta(
684 text.to_string(),
685 ))))
686 .await
687 .is_err()
688 {
689 return;
690 }
691 }
692 }
693 "input_json_delta" => {
694 if let Some(json) =
695 delta.get("partial_json").and_then(|j| j.as_str())
696 {
697 tool_input_json.push_str(json);
698 }
699 }
700 _ => {}
701 }
702 }
703 }
704 "content_block_stop" => {
705 if let Some(id) = tool_id.take() {
706 let name = tool_name.take().unwrap_or_default();
707 let input = std::mem::take(&mut tool_input_json);
708 let _ = tx
709 .send(Ok(StreamEvent::ToolCall(ProviderToolCall {
710 id,
711 name,
712 arguments: input,
713 })))
714 .await;
715 }
716 }
717 "message_delta" => {
718 let stop_reason = event
719 .get("delta")
720 .and_then(|d| d.get("stop_reason"))
721 .and_then(|s| s.as_str())
722 .unwrap_or("none");
723 let output_tokens = event
724 .get("usage")
725 .and_then(|u| u.get("output_tokens"))
726 .and_then(|t| t.as_u64())
727 .unwrap_or(0);
728 if stop_reason == "max_tokens" {
729 tracing::warn!(
730 output_tokens = output_tokens,
731 "Anthropic response truncated: hit max_tokens limit. Increase provider_max_tokens in config."
732 );
733 } else {
734 tracing::debug!(
735 stop_reason = %stop_reason,
736 output_tokens = output_tokens,
737 "Anthropic stream: message_delta"
738 );
739 }
740 if output_tokens > 0 {
741 let _ = tx
742 .send(Ok(StreamEvent::Usage(
743 crate::providers::traits::TokenUsage {
744 input_tokens: None,
745 output_tokens: Some(output_tokens),
746 cached_input_tokens: None,
747 },
748 )))
749 .await;
750 }
751 }
752 "message_stop" => {
753 tracing::debug!("Anthropic stream: message_stop");
754 let _ = tx.send(Ok(StreamEvent::Final)).await;
755 return;
756 }
757 "error" => {
758 let msg = event
759 .get("error")
760 .and_then(|e| e.get("message"))
761 .and_then(|m| m.as_str())
762 .unwrap_or("unknown streaming error");
763 let _ = tx.send(Err(StreamError::Provider(msg.to_string()))).await;
764 return;
765 }
766 _ => {}
767 }
768 }
769
770 let _ = tx.send(Ok(StreamEvent::Final)).await;
771 }
772}
773
774#[async_trait]
775impl Provider for AnthropicProvider {
776 async fn chat_with_system(
777 &self,
778 system_prompt: Option<&str>,
779 message: &str,
780 model: &str,
781 temperature: f64,
782 ) -> anyhow::Result<String> {
783 let credential = self.credential.as_ref().ok_or_else(|| {
784 anyhow::anyhow!(
785 "Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)."
786 )
787 })?;
788
789 let system = system_prompt.map(|s| SystemPrompt::String(s.to_string()));
790 let system = if Self::is_setup_token(credential) {
791 Self::apply_oauth_system_prompt(system)
792 } else {
793 system
794 };
795
796 tracing::debug!(max_tokens = self.max_tokens, model = %model, "Anthropic API request");
797 let request = NativeChatRequest {
798 model: model.to_string(),
799 max_tokens: self.max_tokens,
800 system,
801 messages: vec![NativeMessage {
802 role: "user".to_string(),
803 content: vec![NativeContentOut::Text {
804 text: message.to_string(),
805 cache_control: None,
806 }],
807 }],
808 temperature,
809 tools: None,
810 tool_choice: None,
811 stream: None,
812 };
813
814 let mut request = self
815 .http_client()
816 .post(format!("{}/v1/messages", self.base_url))
817 .header("anthropic-version", "2023-06-01")
818 .header("content-type", "application/json")
819 .json(&request);
820
821 request = self.apply_auth(request, credential);
822
823 let response = request.send().await?;
824
825 if !response.status().is_success() {
826 return Err(super::api_error("Anthropic", response).await);
827 }
828
829 let chat_response: NativeChatResponse = response.json().await?;
830 let parsed = Self::parse_native_response(chat_response);
831 parsed
832 .text
833 .ok_or_else(|| anyhow::anyhow!("No response from Anthropic"))
834 }
835
836 async fn chat(
837 &self,
838 request: ProviderChatRequest<'_>,
839 model: &str,
840 temperature: f64,
841 ) -> anyhow::Result<ProviderChatResponse> {
842 let credential = self.credential.as_ref().ok_or_else(|| {
843 anyhow::anyhow!(
844 "Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)."
845 )
846 })?;
847
848 let (system_prompt, mut messages) = Self::convert_messages(request.messages);
849
850 if Self::should_cache_conversation(request.messages) {
852 Self::apply_cache_to_last_message(&mut messages);
853 }
854
855 let tool_choice_override = crate::agent::loop_::TOOL_CHOICE_OVERRIDE
858 .try_with(Clone::clone)
859 .ok()
860 .flatten();
861 let native_tools = Self::convert_tools(request.tools);
862 let tool_choice = if native_tools.is_some() {
863 tool_choice_override.map(|tc| serde_json::json!({ "type": tc }))
864 } else {
865 None
866 };
867
868 let system_prompt = if Self::is_setup_token(credential) {
870 Self::apply_oauth_system_prompt(system_prompt)
871 } else {
872 system_prompt
873 };
874 tracing::debug!(max_tokens = self.max_tokens, model = %model, "Anthropic streaming API request");
875 let native_request = NativeChatRequest {
876 model: model.to_string(),
877 max_tokens: self.max_tokens,
878 system: system_prompt,
879 messages,
880 temperature,
881 tools: native_tools,
882 tool_choice,
883 stream: None,
884 };
885
886 let req = self
887 .http_client()
888 .post(format!("{}/v1/messages", self.base_url))
889 .header("anthropic-version", "2023-06-01")
890 .header("content-type", "application/json")
891 .json(&native_request);
892
893 let response = self.apply_auth(req, credential).send().await?;
894 if !response.status().is_success() {
895 return Err(super::api_error("Anthropic", response).await);
896 }
897
898 let native_response: NativeChatResponse = response.json().await?;
899 Ok(Self::parse_native_response(native_response))
900 }
901
902 fn capabilities(&self) -> ProviderCapabilities {
903 ProviderCapabilities {
904 native_tool_calling: true,
905 vision: true,
906 prompt_caching: true,
907 }
908 }
909
910 fn supports_native_tools(&self) -> bool {
911 true
912 }
913
914 async fn chat_with_tools(
915 &self,
916 messages: &[ChatMessage],
917 tools: &[serde_json::Value],
918 model: &str,
919 temperature: f64,
920 ) -> anyhow::Result<ProviderChatResponse> {
921 let tool_specs: Vec<ToolSpec> = tools
925 .iter()
926 .filter_map(|t| {
927 let func = t.get("function").or_else(|| {
928 tracing::warn!("Skipping malformed tool definition (missing 'function' key)");
929 None
930 })?;
931 let name = func.get("name").and_then(|n| n.as_str()).or_else(|| {
932 tracing::warn!("Skipping tool with missing or non-string 'name'");
933 None
934 })?;
935 Some(ToolSpec {
936 name: name.to_string(),
937 description: func
938 .get("description")
939 .and_then(|d| d.as_str())
940 .unwrap_or("")
941 .to_string(),
942 parameters: func
943 .get("parameters")
944 .cloned()
945 .unwrap_or(serde_json::json!({"type": "object"})),
946 })
947 })
948 .collect();
949
950 let request = ProviderChatRequest {
951 messages,
952 tools: if tool_specs.is_empty() {
953 None
954 } else {
955 Some(&tool_specs)
956 },
957 };
958 self.chat(request, model, temperature).await
959 }
960
961 async fn warmup(&self) -> anyhow::Result<()> {
962 if let Some(credential) = self.credential.as_ref() {
963 let mut request = self
964 .http_client()
965 .post(format!("{}/v1/messages", self.base_url))
966 .header("anthropic-version", "2023-06-01");
967 request = self.apply_auth(request, credential);
968 let _ = request.send().await?;
971 }
972 Ok(())
973 }
974
975 fn supports_streaming(&self) -> bool {
976 true
977 }
978
979 fn supports_streaming_tool_events(&self) -> bool {
980 true
981 }
982
983 fn stream_chat(
984 &self,
985 request: ProviderChatRequest<'_>,
986 model: &str,
987 temperature: f64,
988 options: StreamOptions,
989 ) -> stream::BoxStream<'static, StreamResult<StreamEvent>> {
990 if !options.enabled {
991 return stream::once(async { Ok(StreamEvent::Final) }).boxed();
992 }
993
994 let credential = match self.credential.as_ref() {
995 Some(c) => c.clone(),
996 None => {
997 return stream::once(async {
998 Err(StreamError::Provider(
999 "Anthropic credentials not set".to_string(),
1000 ))
1001 })
1002 .boxed();
1003 }
1004 };
1005
1006 let (system_prompt, mut messages) = Self::convert_messages(request.messages);
1007 if Self::should_cache_conversation(request.messages) {
1008 Self::apply_cache_to_last_message(&mut messages);
1009 }
1010
1011 let tool_choice_override = crate::agent::loop_::TOOL_CHOICE_OVERRIDE
1012 .try_with(Clone::clone)
1013 .ok()
1014 .flatten();
1015 let native_tools = Self::convert_tools(request.tools);
1016 let tool_choice = if native_tools.is_some() {
1017 tool_choice_override.map(|tc| serde_json::json!({ "type": tc }))
1018 } else {
1019 None
1020 };
1021
1022 let system_prompt = if Self::is_setup_token(&credential) {
1023 Self::apply_oauth_system_prompt(system_prompt)
1024 } else {
1025 system_prompt
1026 };
1027
1028 tracing::debug!(max_tokens = self.max_tokens, model = %model, "Anthropic stream_chat request");
1029 let native_request = NativeChatRequest {
1030 model: model.to_string(),
1031 max_tokens: self.max_tokens,
1032 system: system_prompt,
1033 messages,
1034 temperature,
1035 tools: native_tools,
1036 tool_choice,
1037 stream: Some(true),
1038 };
1039
1040 let body = Self::build_streaming_request(&native_request);
1041 let client = self.http_client();
1042 let url = format!("{}/v1/messages", self.base_url);
1043 let is_oauth = Self::is_setup_token(&credential);
1044
1045 let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamEvent>>(64);
1046
1047 tokio::spawn(async move {
1048 let mut req = client
1049 .post(&url)
1050 .header("anthropic-version", "2023-06-01")
1051 .header("content-type", "application/json")
1052 .json(&body);
1053
1054 if is_oauth {
1055 req = req
1056 .header("Authorization", format!("Bearer {credential}"))
1057 .header(
1058 "anthropic-beta",
1059 "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14",
1060 )
1061 .header("anthropic-dangerous-direct-browser-access", "true");
1062 } else {
1063 req = req.header("x-api-key", &credential);
1064 }
1065
1066 let response = match req.send().await {
1067 Ok(r) => r,
1068 Err(e) => {
1069 let _ = tx.send(Err(StreamError::Http(e))).await;
1070 return;
1071 }
1072 };
1073
1074 if !response.status().is_success() {
1075 let status = response.status();
1076 let error = response
1077 .text()
1078 .await
1079 .unwrap_or_else(|_| format!("HTTP error: {status}"));
1080 let _ = tx
1081 .send(Err(StreamError::Provider(format!("{status}: {error}"))))
1082 .await;
1083 return;
1084 }
1085
1086 Self::parse_anthropic_sse(response, &tx).await;
1087 });
1088
1089 stream::unfold(rx, |mut rx| async move {
1090 rx.recv().await.map(|event| (event, rx))
1091 })
1092 .boxed()
1093 }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 use super::*;
1099 use crate::auth::anthropic_token::{AnthropicAuthKind, detect_auth_kind};
1100
1101 #[test]
1102 fn creates_with_key() {
1103 let p = AnthropicProvider::new(Some("anthropic-test-credential"));
1104 assert!(p.credential.is_some());
1105 assert_eq!(p.credential.as_deref(), Some("anthropic-test-credential"));
1106 assert_eq!(p.base_url, "https://api.anthropic.com");
1107 }
1108
1109 #[test]
1110 fn creates_without_key() {
1111 let p = AnthropicProvider::new(None);
1112 assert!(p.credential.is_none());
1113 assert_eq!(p.base_url, "https://api.anthropic.com");
1114 }
1115
1116 #[test]
1117 fn creates_with_empty_key() {
1118 let p = AnthropicProvider::new(Some(""));
1119 assert!(p.credential.is_none());
1120 }
1121
1122 #[test]
1123 fn creates_with_whitespace_key() {
1124 let p = AnthropicProvider::new(Some(" anthropic-test-credential "));
1125 assert!(p.credential.is_some());
1126 assert_eq!(p.credential.as_deref(), Some("anthropic-test-credential"));
1127 }
1128
1129 #[test]
1130 fn creates_with_custom_base_url() {
1131 let p = AnthropicProvider::with_base_url(
1132 Some("anthropic-credential"),
1133 Some("https://api.example.com"),
1134 );
1135 assert_eq!(p.base_url, "https://api.example.com");
1136 assert_eq!(p.credential.as_deref(), Some("anthropic-credential"));
1137 }
1138
1139 #[test]
1140 fn custom_base_url_trims_trailing_slash() {
1141 let p = AnthropicProvider::with_base_url(None, Some("https://api.example.com/"));
1142 assert_eq!(p.base_url, "https://api.example.com");
1143 }
1144
1145 #[test]
1146 fn default_base_url_when_none_provided() {
1147 let p = AnthropicProvider::with_base_url(None, None);
1148 assert_eq!(p.base_url, "https://api.anthropic.com");
1149 }
1150
1151 #[tokio::test]
1152 async fn chat_fails_without_key() {
1153 let p = AnthropicProvider::new(None);
1154 let result = p
1155 .chat_with_system(None, "hello", "claude-3-opus", 0.7)
1156 .await;
1157 assert!(result.is_err());
1158 let err = result.unwrap_err().to_string();
1159 assert!(
1160 err.contains("credentials not set"),
1161 "Expected key error, got: {err}"
1162 );
1163 }
1164
1165 #[test]
1166 fn setup_token_detection_works() {
1167 assert!(AnthropicProvider::is_setup_token("sk-ant-oat01-abcdef"));
1168 assert!(!AnthropicProvider::is_setup_token("sk-ant-api-key"));
1169 }
1170
1171 #[test]
1172 fn apply_auth_uses_bearer_and_beta_for_setup_tokens() {
1173 let provider = AnthropicProvider::new(None);
1174 let request = provider
1175 .apply_auth(
1176 provider
1177 .http_client()
1178 .get("https://api.anthropic.com/v1/models"),
1179 "sk-ant-oat01-test-token",
1180 )
1181 .build()
1182 .expect("request should build");
1183
1184 assert_eq!(
1185 request
1186 .headers()
1187 .get("authorization")
1188 .and_then(|v| v.to_str().ok()),
1189 Some("Bearer sk-ant-oat01-test-token")
1190 );
1191 assert_eq!(
1192 request
1193 .headers()
1194 .get("anthropic-beta")
1195 .and_then(|v| v.to_str().ok()),
1196 Some("claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14")
1197 );
1198 assert_eq!(
1199 request
1200 .headers()
1201 .get("anthropic-dangerous-direct-browser-access")
1202 .and_then(|v| v.to_str().ok()),
1203 Some("true")
1204 );
1205 assert!(request.headers().get("x-api-key").is_none());
1206 }
1207
1208 #[test]
1209 fn apply_auth_uses_x_api_key_for_regular_tokens() {
1210 let provider = AnthropicProvider::new(None);
1211 let request = provider
1212 .apply_auth(
1213 provider
1214 .http_client()
1215 .get("https://api.anthropic.com/v1/models"),
1216 "sk-ant-api-key",
1217 )
1218 .build()
1219 .expect("request should build");
1220
1221 assert_eq!(
1222 request
1223 .headers()
1224 .get("x-api-key")
1225 .and_then(|v| v.to_str().ok()),
1226 Some("sk-ant-api-key")
1227 );
1228 assert!(request.headers().get("authorization").is_none());
1229 assert!(request.headers().get("anthropic-beta").is_none());
1230 }
1231
1232 #[tokio::test]
1233 async fn chat_with_system_fails_without_key() {
1234 let p = AnthropicProvider::new(None);
1235 let result = p
1236 .chat_with_system(Some("You are Construct"), "hello", "claude-3-opus", 0.7)
1237 .await;
1238 assert!(result.is_err());
1239 }
1240
1241 #[test]
1242 fn chat_request_serializes_without_system() {
1243 let req = ChatRequest {
1244 model: "claude-3-opus".to_string(),
1245 max_tokens: 4096,
1246 system: None,
1247 messages: vec![Message {
1248 role: "user".to_string(),
1249 content: "hello".to_string(),
1250 }],
1251 temperature: 0.7,
1252 };
1253 let json = serde_json::to_string(&req).unwrap();
1254 assert!(
1255 !json.contains("system"),
1256 "system field should be skipped when None"
1257 );
1258 assert!(json.contains("claude-3-opus"));
1259 assert!(json.contains("hello"));
1260 }
1261
1262 #[test]
1263 fn chat_request_serializes_with_system() {
1264 let req = ChatRequest {
1265 model: "claude-3-opus".to_string(),
1266 max_tokens: 4096,
1267 system: Some("You are Construct".to_string()),
1268 messages: vec![Message {
1269 role: "user".to_string(),
1270 content: "hello".to_string(),
1271 }],
1272 temperature: 0.7,
1273 };
1274 let json = serde_json::to_string(&req).unwrap();
1275 assert!(json.contains("\"system\":\"You are Construct\""));
1276 }
1277
1278 #[test]
1279 fn chat_response_deserializes() {
1280 let json = r#"{"content":[{"type":"text","text":"Hello there!"}]}"#;
1281 let resp: ChatResponse = serde_json::from_str(json).unwrap();
1282 assert_eq!(resp.content.len(), 1);
1283 assert_eq!(resp.content[0].kind, "text");
1284 assert_eq!(resp.content[0].text.as_deref(), Some("Hello there!"));
1285 }
1286
1287 #[test]
1288 fn chat_response_empty_content() {
1289 let json = r#"{"content":[]}"#;
1290 let resp: ChatResponse = serde_json::from_str(json).unwrap();
1291 assert!(resp.content.is_empty());
1292 }
1293
1294 #[test]
1295 fn chat_response_multiple_blocks() {
1296 let json =
1297 r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#;
1298 let resp: ChatResponse = serde_json::from_str(json).unwrap();
1299 assert_eq!(resp.content.len(), 2);
1300 assert_eq!(resp.content[0].text.as_deref(), Some("First"));
1301 assert_eq!(resp.content[1].text.as_deref(), Some("Second"));
1302 }
1303
1304 #[test]
1305 fn temperature_range_serializes() {
1306 for temp in [0.0, 0.5, 1.0, 2.0] {
1307 let req = ChatRequest {
1308 model: "claude-3-opus".to_string(),
1309 max_tokens: 4096,
1310 system: None,
1311 messages: vec![],
1312 temperature: temp,
1313 };
1314 let json = serde_json::to_string(&req).unwrap();
1315 assert!(json.contains(&format!("{temp}")));
1316 }
1317 }
1318
1319 #[test]
1320 fn detects_auth_from_jwt_shape() {
1321 let kind = detect_auth_kind("a.b.c", None);
1322 assert_eq!(kind, AnthropicAuthKind::Authorization);
1323 }
1324
1325 #[test]
1326 fn cache_control_serializes_correctly() {
1327 let cache = CacheControl::ephemeral();
1328 let json = serde_json::to_string(&cache).unwrap();
1329 assert_eq!(json, r#"{"type":"ephemeral"}"#);
1330 }
1331
1332 #[test]
1333 fn system_prompt_string_variant_serializes() {
1334 let prompt = SystemPrompt::String("You are a helpful assistant".to_string());
1335 let json = serde_json::to_string(&prompt).unwrap();
1336 assert_eq!(json, r#""You are a helpful assistant""#);
1337 }
1338
1339 #[test]
1340 fn system_prompt_blocks_variant_serializes() {
1341 let prompt = SystemPrompt::Blocks(vec![SystemBlock {
1342 block_type: "text".to_string(),
1343 text: "You are a helpful assistant".to_string(),
1344 cache_control: Some(CacheControl::ephemeral()),
1345 }]);
1346 let json = serde_json::to_string(&prompt).unwrap();
1347 assert!(json.contains(r#""type":"text""#));
1348 assert!(json.contains("You are a helpful assistant"));
1349 assert!(json.contains(r#""type":"ephemeral""#));
1350 }
1351
1352 #[test]
1353 fn system_prompt_blocks_without_cache_control() {
1354 let prompt = SystemPrompt::Blocks(vec![SystemBlock {
1355 block_type: "text".to_string(),
1356 text: "Short prompt".to_string(),
1357 cache_control: None,
1358 }]);
1359 let json = serde_json::to_string(&prompt).unwrap();
1360 assert!(json.contains("Short prompt"));
1361 assert!(!json.contains("cache_control"));
1362 }
1363
1364 #[test]
1365 fn native_content_text_without_cache_control() {
1366 let content = NativeContentOut::Text {
1367 text: "Hello".to_string(),
1368 cache_control: None,
1369 };
1370 let json = serde_json::to_string(&content).unwrap();
1371 assert!(json.contains(r#""type":"text""#));
1372 assert!(json.contains("Hello"));
1373 assert!(!json.contains("cache_control"));
1374 }
1375
1376 #[test]
1377 fn native_content_text_with_cache_control() {
1378 let content = NativeContentOut::Text {
1379 text: "Hello".to_string(),
1380 cache_control: Some(CacheControl::ephemeral()),
1381 };
1382 let json = serde_json::to_string(&content).unwrap();
1383 assert!(json.contains(r#""type":"text""#));
1384 assert!(json.contains("Hello"));
1385 assert!(json.contains(r#""cache_control":{"type":"ephemeral"}"#));
1386 }
1387
1388 #[test]
1389 fn native_content_tool_use_without_cache_control() {
1390 let content = NativeContentOut::ToolUse {
1391 id: "tool_123".to_string(),
1392 name: "get_weather".to_string(),
1393 input: serde_json::json!({"location": "San Francisco"}),
1394 cache_control: None,
1395 };
1396 let json = serde_json::to_string(&content).unwrap();
1397 assert!(json.contains(r#""type":"tool_use""#));
1398 assert!(json.contains("tool_123"));
1399 assert!(json.contains("get_weather"));
1400 assert!(!json.contains("cache_control"));
1401 }
1402
1403 #[test]
1404 fn native_content_tool_result_with_cache_control() {
1405 let content = NativeContentOut::ToolResult {
1406 tool_use_id: "tool_123".to_string(),
1407 content: "Result data".to_string(),
1408 cache_control: Some(CacheControl::ephemeral()),
1409 };
1410 let json = serde_json::to_string(&content).unwrap();
1411 assert!(json.contains(r#""type":"tool_result""#));
1412 assert!(json.contains("tool_123"));
1413 assert!(json.contains("Result data"));
1414 assert!(json.contains(r#""cache_control":{"type":"ephemeral"}"#));
1415 }
1416
1417 #[test]
1418 fn native_tool_spec_without_cache_control() {
1419 let schema = serde_json::json!({"type": "object"});
1420 let tool = NativeToolSpec {
1421 name: "get_weather",
1422 description: "Get weather info",
1423 input_schema: &schema,
1424 cache_control: None,
1425 };
1426 let json = serde_json::to_string(&tool).unwrap();
1427 assert!(json.contains("get_weather"));
1428 assert!(!json.contains("cache_control"));
1429 }
1430
1431 #[test]
1432 fn native_tool_spec_with_cache_control() {
1433 let schema = serde_json::json!({"type": "object"});
1434 let tool = NativeToolSpec {
1435 name: "get_weather",
1436 description: "Get weather info",
1437 input_schema: &schema,
1438 cache_control: Some(CacheControl::ephemeral()),
1439 };
1440 let json = serde_json::to_string(&tool).unwrap();
1441 assert!(json.contains("get_weather"));
1442 assert!(json.contains(r#""cache_control":{"type":"ephemeral"}"#));
1443 }
1444
1445 #[test]
1446 fn should_cache_system_small_prompt() {
1447 let small_prompt = "You are a helpful assistant.";
1448 assert!(!AnthropicProvider::should_cache_system(small_prompt));
1449 }
1450
1451 #[test]
1452 fn should_cache_system_large_prompt() {
1453 let large_prompt = "a".repeat(3073); assert!(AnthropicProvider::should_cache_system(&large_prompt));
1455 }
1456
1457 #[test]
1458 fn should_cache_system_boundary() {
1459 let boundary_prompt = "a".repeat(3072); assert!(!AnthropicProvider::should_cache_system(&boundary_prompt));
1461
1462 let over_boundary = "a".repeat(3073);
1463 assert!(AnthropicProvider::should_cache_system(&over_boundary));
1464 }
1465
1466 #[test]
1467 fn should_cache_conversation_short() {
1468 let messages = vec![
1469 ChatMessage {
1470 role: "system".to_string(),
1471 content: "System prompt".to_string(),
1472 },
1473 ChatMessage {
1474 role: "user".to_string(),
1475 content: "Hello".to_string(),
1476 },
1477 ];
1478 assert!(!AnthropicProvider::should_cache_conversation(&messages));
1480 }
1481
1482 #[test]
1483 fn should_cache_conversation_long() {
1484 let mut messages = vec![ChatMessage {
1485 role: "system".to_string(),
1486 content: "System prompt".to_string(),
1487 }];
1488 for i in 0..3 {
1490 messages.push(ChatMessage {
1491 role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
1492 content: format!("Message {i}"),
1493 });
1494 }
1495 assert!(AnthropicProvider::should_cache_conversation(&messages));
1496 }
1497
1498 #[test]
1499 fn should_cache_conversation_boundary() {
1500 let messages = vec![ChatMessage {
1501 role: "user".to_string(),
1502 content: "Hello".to_string(),
1503 }];
1504 assert!(!AnthropicProvider::should_cache_conversation(&messages));
1506
1507 let messages = vec![
1509 ChatMessage {
1510 role: "user".to_string(),
1511 content: "Hello".to_string(),
1512 },
1513 ChatMessage {
1514 role: "assistant".to_string(),
1515 content: "Hi".to_string(),
1516 },
1517 ];
1518 assert!(AnthropicProvider::should_cache_conversation(&messages));
1519 }
1520
1521 #[test]
1522 fn apply_cache_to_last_message_text() {
1523 let mut messages = vec![NativeMessage {
1524 role: "user".to_string(),
1525 content: vec![NativeContentOut::Text {
1526 text: "Hello".to_string(),
1527 cache_control: None,
1528 }],
1529 }];
1530
1531 AnthropicProvider::apply_cache_to_last_message(&mut messages);
1532
1533 match &messages[0].content[0] {
1534 NativeContentOut::Text { cache_control, .. } => {
1535 assert!(cache_control.is_some());
1536 }
1537 _ => panic!("Expected Text variant"),
1538 }
1539 }
1540
1541 #[test]
1542 fn apply_cache_to_last_message_tool_result() {
1543 let mut messages = vec![NativeMessage {
1544 role: "user".to_string(),
1545 content: vec![NativeContentOut::ToolResult {
1546 tool_use_id: "tool_123".to_string(),
1547 content: "Result".to_string(),
1548 cache_control: None,
1549 }],
1550 }];
1551
1552 AnthropicProvider::apply_cache_to_last_message(&mut messages);
1553
1554 match &messages[0].content[0] {
1555 NativeContentOut::ToolResult { cache_control, .. } => {
1556 assert!(cache_control.is_some());
1557 }
1558 _ => panic!("Expected ToolResult variant"),
1559 }
1560 }
1561
1562 #[test]
1563 fn apply_cache_to_last_message_does_not_affect_tool_use() {
1564 let mut messages = vec![NativeMessage {
1565 role: "assistant".to_string(),
1566 content: vec![NativeContentOut::ToolUse {
1567 id: "tool_123".to_string(),
1568 name: "get_weather".to_string(),
1569 input: serde_json::json!({}),
1570 cache_control: None,
1571 }],
1572 }];
1573
1574 AnthropicProvider::apply_cache_to_last_message(&mut messages);
1575
1576 match &messages[0].content[0] {
1578 NativeContentOut::ToolUse { cache_control, .. } => {
1579 assert!(cache_control.is_none());
1580 }
1581 _ => panic!("Expected ToolUse variant"),
1582 }
1583 }
1584
1585 #[test]
1586 fn apply_cache_empty_messages() {
1587 let mut messages = vec![];
1588 AnthropicProvider::apply_cache_to_last_message(&mut messages);
1589 assert!(messages.is_empty());
1591 }
1592
1593 #[test]
1594 fn convert_tools_adds_cache_to_last_tool() {
1595 let tools = vec![
1596 ToolSpec {
1597 name: "tool1".to_string(),
1598 description: "First tool".to_string(),
1599 parameters: serde_json::json!({"type": "object"}),
1600 },
1601 ToolSpec {
1602 name: "tool2".to_string(),
1603 description: "Second tool".to_string(),
1604 parameters: serde_json::json!({"type": "object"}),
1605 },
1606 ];
1607
1608 let native_tools = AnthropicProvider::convert_tools(Some(&tools)).unwrap();
1609
1610 assert_eq!(native_tools.len(), 2);
1611 assert!(native_tools[0].cache_control.is_none());
1612 assert!(native_tools[1].cache_control.is_some());
1613 }
1614
1615 #[test]
1616 fn convert_tools_single_tool_gets_cache() {
1617 let tools = vec![ToolSpec {
1618 name: "tool1".to_string(),
1619 description: "Only tool".to_string(),
1620 parameters: serde_json::json!({"type": "object"}),
1621 }];
1622
1623 let native_tools = AnthropicProvider::convert_tools(Some(&tools)).unwrap();
1624
1625 assert_eq!(native_tools.len(), 1);
1626 assert!(native_tools[0].cache_control.is_some());
1627 }
1628
1629 #[test]
1630 fn convert_messages_small_system_prompt_uses_blocks_with_cache() {
1631 let messages = vec![ChatMessage {
1632 role: "system".to_string(),
1633 content: "Short system prompt".to_string(),
1634 }];
1635
1636 let (system_prompt, _) = AnthropicProvider::convert_messages(&messages);
1637
1638 match system_prompt.unwrap() {
1639 SystemPrompt::Blocks(blocks) => {
1640 assert_eq!(blocks.len(), 1);
1641 assert_eq!(blocks[0].text, "Short system prompt");
1642 assert!(
1643 blocks[0].cache_control.is_some(),
1644 "Small system prompts should have cache_control"
1645 );
1646 }
1647 SystemPrompt::String(_) => {
1648 panic!("Expected Blocks variant with cache_control for small prompt")
1649 }
1650 }
1651 }
1652
1653 #[test]
1654 fn convert_messages_large_system_prompt() {
1655 let large_content = "a".repeat(3073);
1656 let messages = vec![ChatMessage {
1657 role: "system".to_string(),
1658 content: large_content.clone(),
1659 }];
1660
1661 let (system_prompt, _) = AnthropicProvider::convert_messages(&messages);
1662
1663 match system_prompt.unwrap() {
1664 SystemPrompt::Blocks(blocks) => {
1665 assert_eq!(blocks.len(), 1);
1666 assert_eq!(blocks[0].text, large_content);
1667 assert!(blocks[0].cache_control.is_some());
1668 }
1669 SystemPrompt::String(_) => panic!("Expected Blocks variant for large prompt"),
1670 }
1671 }
1672
1673 #[test]
1674 fn native_chat_request_with_blocks_system() {
1675 let req = NativeChatRequest {
1677 model: "claude-3-opus".to_string(),
1678 max_tokens: 4096,
1679 system: Some(SystemPrompt::Blocks(vec![SystemBlock {
1680 block_type: "text".to_string(),
1681 text: "System".to_string(),
1682 cache_control: Some(CacheControl::ephemeral()),
1683 }])),
1684 messages: vec![NativeMessage {
1685 role: "user".to_string(),
1686 content: vec![NativeContentOut::Text {
1687 text: "Hello".to_string(),
1688 cache_control: None,
1689 }],
1690 }],
1691 temperature: 0.7,
1692 tools: None,
1693 tool_choice: None,
1694 stream: None,
1695 };
1696
1697 let json = serde_json::to_string(&req).unwrap();
1698 assert!(json.contains("System"));
1699 assert!(
1700 json.contains(r#""cache_control":{"type":"ephemeral"}"#),
1701 "System prompt should include cache_control"
1702 );
1703 }
1704
1705 #[tokio::test]
1706 async fn warmup_without_key_is_noop() {
1707 let provider = AnthropicProvider::new(None);
1708 let result = provider.warmup().await;
1709 assert!(result.is_ok());
1710 }
1711
1712 #[test]
1713 fn convert_messages_preserves_multi_turn_history() {
1714 let messages = vec![
1715 ChatMessage {
1716 role: "system".to_string(),
1717 content: "You are helpful.".to_string(),
1718 },
1719 ChatMessage {
1720 role: "user".to_string(),
1721 content: "gen a 2 sum in golang".to_string(),
1722 },
1723 ChatMessage {
1724 role: "assistant".to_string(),
1725 content: "```go\nfunc twoSum(nums []int) {}\n```".to_string(),
1726 },
1727 ChatMessage {
1728 role: "user".to_string(),
1729 content: "what's meaning of make here?".to_string(),
1730 },
1731 ];
1732
1733 let (system, native_msgs) = AnthropicProvider::convert_messages(&messages);
1734
1735 assert!(system.is_some());
1737 assert_eq!(native_msgs.len(), 3);
1739 assert_eq!(native_msgs[0].role, "user");
1740 assert_eq!(native_msgs[1].role, "assistant");
1741 assert_eq!(native_msgs[2].role, "user");
1742 }
1743
1744 #[tokio::test]
1748 async fn chat_with_tools_sends_full_history_and_native_tools() {
1749 use axum::{Json, Router, routing::post};
1750 use std::sync::{Arc, Mutex};
1751 use tokio::net::TcpListener;
1752
1753 let captured: Arc<Mutex<Option<serde_json::Value>>> = Arc::new(Mutex::new(None));
1755 let captured_clone = captured.clone();
1756
1757 let app = Router::new().route(
1758 "/v1/messages",
1759 post(move |Json(body): Json<serde_json::Value>| {
1760 let cap = captured_clone.clone();
1761 async move {
1762 *cap.lock().unwrap() = Some(body);
1763 Json(serde_json::json!({
1765 "id": "msg_test",
1766 "type": "message",
1767 "role": "assistant",
1768 "content": [{"type": "text", "text": "The make function creates a map."}],
1769 "model": "claude-opus-4-6",
1770 "stop_reason": "end_turn",
1771 "usage": {"input_tokens": 100, "output_tokens": 20}
1772 }))
1773 }
1774 }),
1775 );
1776
1777 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1778 let addr = listener.local_addr().unwrap();
1779 let server_handle = tokio::spawn(async move {
1780 axum::serve(listener, app).await.unwrap();
1781 });
1782
1783 let provider = AnthropicProvider {
1785 credential: Some("test-key".to_string()),
1786 base_url: format!("http://{addr}"),
1787 max_tokens: DEFAULT_ANTHROPIC_MAX_TOKENS,
1788 };
1789
1790 let messages = vec![
1792 ChatMessage::system("You are a helpful assistant."),
1793 ChatMessage::user("gen a 2 sum in golang"),
1794 ChatMessage::assistant(
1795 "```go\nfunc twoSum(nums []int, target int) []int {\n m := make(map[int]int)\n for i, n := range nums {\n if j, ok := m[target-n]; ok {\n return []int{j, i}\n }\n m[n] = i\n }\n return nil\n}\n```",
1796 ),
1797 ChatMessage::user("what's meaning of make here?"),
1798 ];
1799
1800 let tools = vec![serde_json::json!({
1801 "type": "function",
1802 "function": {
1803 "name": "shell",
1804 "description": "Run a shell command",
1805 "parameters": {
1806 "type": "object",
1807 "properties": {
1808 "command": {"type": "string"}
1809 },
1810 "required": ["command"]
1811 }
1812 }
1813 })];
1814
1815 let result = provider
1816 .chat_with_tools(&messages, &tools, "claude-opus-4-6", 0.7)
1817 .await;
1818 assert!(result.is_ok(), "chat_with_tools failed: {:?}", result.err());
1819
1820 let body = captured
1821 .lock()
1822 .unwrap()
1823 .take()
1824 .expect("No request captured");
1825
1826 let system = &body["system"];
1828 assert!(
1829 system.to_string().contains("helpful assistant"),
1830 "System prompt missing: {system}"
1831 );
1832
1833 let msgs = body["messages"].as_array().expect("messages not an array");
1835 assert_eq!(
1836 msgs.len(),
1837 3,
1838 "Expected 3 messages (2 user + 1 assistant), got {}",
1839 msgs.len()
1840 );
1841
1842 assert_eq!(msgs[0]["role"], "user");
1844 let turn1_text = msgs[0]["content"].to_string();
1845 assert!(
1846 turn1_text.contains("2 sum"),
1847 "Turn 1 missing Go request: {turn1_text}"
1848 );
1849
1850 assert_eq!(msgs[1]["role"], "assistant");
1852 let turn2_text = msgs[1]["content"].to_string();
1853 assert!(
1854 turn2_text.contains("make(map[int]int)"),
1855 "Turn 2 missing Go code: {turn2_text}"
1856 );
1857
1858 assert_eq!(msgs[2]["role"], "user");
1860 let turn3_text = msgs[2]["content"].to_string();
1861 assert!(
1862 turn3_text.contains("meaning of make"),
1863 "Turn 3 missing follow-up: {turn3_text}"
1864 );
1865
1866 let api_tools = body["tools"].as_array().expect("tools not an array");
1868 assert_eq!(api_tools.len(), 1);
1869 assert_eq!(api_tools[0]["name"], "shell");
1870 assert!(
1871 api_tools[0]["input_schema"].is_object(),
1872 "Missing input_schema"
1873 );
1874
1875 server_handle.abort();
1876 }
1877
1878 #[test]
1879 fn native_response_parses_usage() {
1880 let json = r#"{
1881 "content": [{"type": "text", "text": "Hello"}],
1882 "usage": {"input_tokens": 300, "output_tokens": 75}
1883 }"#;
1884 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1885 let result = AnthropicProvider::parse_native_response(resp);
1886 let usage = result.usage.unwrap();
1887 assert_eq!(usage.input_tokens, Some(300));
1888 assert_eq!(usage.output_tokens, Some(75));
1889 }
1890
1891 #[test]
1892 fn native_response_parses_without_usage() {
1893 let json = r#"{"content": [{"type": "text", "text": "Hello"}]}"#;
1894 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1895 let result = AnthropicProvider::parse_native_response(resp);
1896 assert!(result.usage.is_none());
1897 }
1898
1899 #[test]
1900 fn capabilities_returns_vision_and_native_tools() {
1901 let provider = AnthropicProvider::new(Some("test-key"));
1902 let caps = provider.capabilities();
1903 assert!(
1904 caps.native_tool_calling,
1905 "Anthropic should support native tool calling"
1906 );
1907 assert!(caps.vision, "Anthropic should support vision");
1908 }
1909
1910 #[test]
1911 fn convert_messages_with_image_marker_data_uri() {
1912 let messages = vec![ChatMessage {
1913 role: "user".to_string(),
1914 content: "Check this image: [IMAGE:data:image/jpeg;base64,/9j/4AAQ] What do you see?"
1915 .to_string(),
1916 }];
1917
1918 let (_, native_msgs) = AnthropicProvider::convert_messages(&messages);
1919
1920 assert_eq!(native_msgs.len(), 1);
1921 assert_eq!(native_msgs[0].role, "user");
1922 assert_eq!(native_msgs[0].content.len(), 2);
1924
1925 match &native_msgs[0].content[0] {
1927 NativeContentOut::Image { source } => {
1928 assert_eq!(source.source_type, "base64");
1929 assert_eq!(source.media_type, "image/jpeg");
1930 assert_eq!(source.data, "/9j/4AAQ");
1931 }
1932 _ => panic!("Expected Image content block"),
1933 }
1934
1935 match &native_msgs[0].content[1] {
1937 NativeContentOut::Text { text, .. } => {
1938 assert!(
1940 text.contains("Check this image:") && text.contains("What do you see?"),
1941 "Expected text to contain 'Check this image:' and 'What do you see?', got: {}",
1942 text
1943 );
1944 }
1945 _ => panic!("Expected Text content block"),
1946 }
1947 }
1948
1949 #[test]
1950 fn convert_messages_with_only_image_marker() {
1951 let messages = vec![ChatMessage {
1952 role: "user".to_string(),
1953 content: "[IMAGE:data:image/png;base64,iVBORw0KGgo]".to_string(),
1954 }];
1955
1956 let (_, native_msgs) = AnthropicProvider::convert_messages(&messages);
1957
1958 assert_eq!(native_msgs.len(), 1);
1959 assert_eq!(native_msgs[0].content.len(), 2);
1960
1961 match &native_msgs[0].content[0] {
1963 NativeContentOut::Image { source } => {
1964 assert_eq!(source.media_type, "image/png");
1965 }
1966 _ => panic!("Expected Image content block"),
1967 }
1968
1969 match &native_msgs[0].content[1] {
1971 NativeContentOut::Text { text, .. } => {
1972 assert_eq!(text, "[image]");
1973 }
1974 _ => panic!("Expected Text content block with [image] placeholder"),
1975 }
1976 }
1977
1978 #[test]
1979 fn convert_messages_without_image_marker() {
1980 let messages = vec![ChatMessage {
1981 role: "user".to_string(),
1982 content: "Hello, how are you?".to_string(),
1983 }];
1984
1985 let (_, native_msgs) = AnthropicProvider::convert_messages(&messages);
1986
1987 assert_eq!(native_msgs.len(), 1);
1988 assert_eq!(native_msgs[0].content.len(), 1);
1989
1990 match &native_msgs[0].content[0] {
1991 NativeContentOut::Text { text, .. } => {
1992 assert_eq!(text, "Hello, how are you?");
1993 }
1994 _ => panic!("Expected Text content block"),
1995 }
1996 }
1997
1998 #[test]
1999 fn image_content_serializes_correctly() {
2000 let content = NativeContentOut::Image {
2001 source: ImageSource {
2002 source_type: "base64".to_string(),
2003 media_type: "image/jpeg".to_string(),
2004 data: "testdata".to_string(),
2005 },
2006 };
2007 let json = serde_json::to_string(&content).unwrap();
2008 assert!(json.contains(r#""type":"image""#), "JSON: {}", json);
2010 assert!(json.contains(r#""type":"base64""#), "JSON: {}", json); assert!(
2012 json.contains(r#""media_type":"image/jpeg""#),
2013 "JSON: {}",
2014 json
2015 );
2016 assert!(json.contains(r#""data":"testdata""#), "JSON: {}", json);
2017 }
2018
2019 #[test]
2020 fn convert_messages_merges_consecutive_tool_results() {
2021 let messages = vec![
2024 ChatMessage {
2025 role: "system".to_string(),
2026 content: "You are helpful.".to_string(),
2027 },
2028 ChatMessage {
2029 role: "user".to_string(),
2030 content: "Do two things.".to_string(),
2031 },
2032 ChatMessage {
2033 role: "assistant".to_string(),
2034 content: serde_json::json!({
2035 "content": "",
2036 "tool_calls": [
2037 {"id": "call_1", "name": "shell", "arguments": "{\"command\":\"ls\"}"},
2038 {"id": "call_2", "name": "shell", "arguments": "{\"command\":\"pwd\"}"}
2039 ]
2040 })
2041 .to_string(),
2042 },
2043 ChatMessage {
2044 role: "tool".to_string(),
2045 content: serde_json::json!({
2046 "tool_call_id": "call_1",
2047 "content": "file1.txt\nfile2.txt"
2048 })
2049 .to_string(),
2050 },
2051 ChatMessage {
2052 role: "tool".to_string(),
2053 content: serde_json::json!({
2054 "tool_call_id": "call_2",
2055 "content": "/home/user"
2056 })
2057 .to_string(),
2058 },
2059 ];
2060
2061 let (system, native_msgs) = AnthropicProvider::convert_messages(&messages);
2062
2063 assert!(system.is_some());
2064 assert_eq!(
2067 native_msgs.len(),
2068 3,
2069 "Expected 3 messages (user, assistant, merged tool results), got {}.\nRoles: {:?}",
2070 native_msgs.len(),
2071 native_msgs.iter().map(|m| &m.role).collect::<Vec<_>>()
2072 );
2073 assert_eq!(native_msgs[0].role, "user");
2074 assert_eq!(native_msgs[1].role, "assistant");
2075 assert_eq!(native_msgs[2].role, "user");
2076 assert_eq!(
2078 native_msgs[2].content.len(),
2079 2,
2080 "Expected 2 tool_result blocks in merged message"
2081 );
2082 }
2083
2084 #[test]
2085 fn convert_messages_no_adjacent_same_role() {
2086 let messages = vec![
2089 ChatMessage {
2090 role: "user".to_string(),
2091 content: "Hello".to_string(),
2092 },
2093 ChatMessage {
2094 role: "assistant".to_string(),
2095 content: serde_json::json!({
2096 "content": "I'll run a command",
2097 "tool_calls": [
2098 {"id": "tc1", "name": "shell", "arguments": "{\"command\":\"echo hi\"}"}
2099 ]
2100 })
2101 .to_string(),
2102 },
2103 ChatMessage {
2104 role: "tool".to_string(),
2105 content: serde_json::json!({
2106 "tool_call_id": "tc1",
2107 "content": "hi"
2108 })
2109 .to_string(),
2110 },
2111 ChatMessage {
2112 role: "user".to_string(),
2113 content: "Thanks!".to_string(),
2114 },
2115 ];
2116
2117 let (_system, native_msgs) = AnthropicProvider::convert_messages(&messages);
2118
2119 for window in native_msgs.windows(2) {
2120 assert_ne!(
2121 window[0].role, window[1].role,
2122 "Adjacent messages must not share the same role: found two '{}' messages in a row",
2123 window[0].role
2124 );
2125 }
2126 }
2127}