Skip to main content

construct/providers/
anthropic.rs

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    /// Override the maximum output tokens for API requests.
202    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    /// For OAuth tokens, Anthropic requires the system prompt to start with the
230    /// Claude Code identity prefix. This prepends it to any existing system prompt.
231    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    /// Cache system prompts larger than ~1024 tokens (3KB of text)
255    fn should_cache_system(text: &str) -> bool {
256        text.len() > 3072
257    }
258
259    /// Cache conversations with more than 1 non-system message (i.e. after first exchange)
260    fn should_cache_conversation(messages: &[ChatMessage]) -> bool {
261        messages.iter().filter(|m| m.role != "system").count() > 1
262    }
263
264    /// Apply cache control to the last message content block
265    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        // Cache the last tool definition (caches all tools)
295        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                    // Tool results map to role "user"; merge consecutive ones
396                    // into a single message so Anthropic doesn't reject the
397                    // request for having adjacent same-role messages.
398                    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                    // Parse image markers from user message content
413                    let (text, image_refs) = crate::multimodal::parse_image_markers(&msg.content);
414                    let mut content_blocks: Vec<NativeContentOut> = Vec::new();
415
416                    // Add image content blocks for each image reference
417                    for img_ref in &image_refs {
418                        let (media_type, data) = if img_ref.starts_with("data:") {
419                            // Data URI format: data:image/jpeg;base64,/9j/4AAQ...
420                            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                            // Local file path
431                            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                    // Add text content block (skip empty text when images are present)
464                    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                    // Merge into previous user message if present (e.g.
477                    // when a user message immediately follows tool results
478                    // which are also role "user" in Anthropic's format).
479                    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        // Always use Blocks format with cache_control for system prompts
496        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    /// Build a streaming request body from a `NativeChatRequest`.
570    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    /// Parse Anthropic SSE lines from `response` and send `StreamEvent`s to `tx`.
578    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        // Auto-cache last message if conversation is long
851        if Self::should_cache_conversation(request.messages) {
852            Self::apply_cache_to_last_message(&mut messages);
853        }
854
855        // Check for tool_choice override from the agent loop (e.g. "any"
856        // to force tool use for hardware requests).
857        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        // For OAuth tokens, prepend Claude Code identity to system prompt
869        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        // Convert OpenAI-format tool JSON to ToolSpec so we can reuse the
922        // existing `chat()` method which handles full message history,
923        // system prompt extraction, caching, and Anthropic native formatting.
924        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            // Send a minimal request; the goal is TLS + HTTP/2 setup, not a valid response.
969            // Anthropic has no lightweight GET endpoint, so we accept any non-network error.
970            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); // Just over 3072 bytes
1454        assert!(AnthropicProvider::should_cache_system(&large_prompt));
1455    }
1456
1457    #[test]
1458    fn should_cache_system_boundary() {
1459        let boundary_prompt = "a".repeat(3072); // Exactly 3072 bytes
1460        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        // Only 1 non-system message — should not cache
1479        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        // Add 3 non-system messages
1489        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        // Exactly 1 non-system message — should not cache
1505        assert!(!AnthropicProvider::should_cache_conversation(&messages));
1506
1507        // Add one more to cross boundary (>1)
1508        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        // ToolUse should not be affected
1577        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        // Should not panic
1590        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        // System prompts now always use Blocks format with cache_control
1676        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        // System prompt extracted
1736        assert!(system.is_some());
1737        // All 3 non-system messages preserved in order
1738        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    /// Integration test: spin up a mock Anthropic API server, call chat_with_tools
1745    /// with a multi-turn conversation + tools, and verify the request body contains
1746    /// ALL conversation turns and native tool definitions.
1747    #[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        // Captured request body for assertion
1754        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                    // Return a minimal valid Anthropic response
1764                    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        // Create provider pointing at mock server
1784        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        // Multi-turn conversation: system → user (Go code) → assistant (code response) → user (follow-up)
1791        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        // Verify system prompt extracted to top-level field
1827        let system = &body["system"];
1828        assert!(
1829            system.to_string().contains("helpful assistant"),
1830            "System prompt missing: {system}"
1831        );
1832
1833        // Verify ALL conversation turns present in messages array
1834        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        // Turn 1: user with Go request
1843        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        // Turn 2: assistant with Go code
1851        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        // Turn 3: user follow-up
1859        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        // Verify native tools are present
1867        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        // Should have 2 content blocks: image + text
1923        assert_eq!(native_msgs[0].content.len(), 2);
1924
1925        // First block should be image
1926        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        // Second block should be text (parse_image_markers may leave extra spaces)
1936        match &native_msgs[0].content[1] {
1937            NativeContentOut::Text { text, .. } => {
1938                // The text may have extra spaces where the marker was removed
1939                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        // First block should be image
1962        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        // Second block should be placeholder text
1970        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        // The outer "type" is the enum tag, inner "type" (source_type) is renamed
2009        assert!(json.contains(r#""type":"image""#), "JSON: {}", json);
2010        assert!(json.contains(r#""type":"base64""#), "JSON: {}", json); // source_type is serialized as "type"
2011        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        // Simulate a multi-tool-call turn: assistant with two tool_use blocks
2022        // followed by two separate tool result messages.
2023        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        // Should be: user, assistant, user (merged tool results)
2065        // NOT: user, assistant, user, user (which Anthropic rejects)
2066        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        // The merged user message should contain both tool results
2077        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        // Verify that convert_messages never produces adjacent messages with the
2087        // same role, regardless of input ordering.
2088        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}