Skip to main content

agent_sdk_providers/impls/
anthropic.rs

1//! Anthropic API provider implementation.
2//!
3//! This module provides an implementation of `LlmProvider` for the Anthropic
4//! Messages API using reqwest for HTTP calls. Supports both streaming and
5//! non-streaming responses.
6
7pub(crate) mod data;
8
9use crate::attachments::validate_request_attachments;
10use crate::provider::{LlmProvider, thinking_for_forced_tool};
11use crate::streaming::{StreamBox, StreamDelta, StreamErrorKind};
12use agent_sdk_foundation::llm::{
13    CacheTtl, ChatOutcome, ChatRequest, ChatResponse, ContentBlock, ThinkingConfig, ThinkingMode,
14    Usage,
15};
16use anyhow::Result;
17use async_trait::async_trait;
18use data::{
19    ApiMessagesRequest, ApiOutputConfig, ApiThinkingConfig, ApiToolChoice, build_api_messages,
20    build_api_tools_with_cache, is_message_stop_event, map_content_blocks, map_stop_reason,
21    parse_sse_event, take_next_sse_event,
22};
23use futures::StreamExt;
24use reqwest::StatusCode;
25
26const API_BASE_URL: &str = "https://api.anthropic.com";
27const API_VERSION: &str = "2023-06-01";
28const CLAUDE_CODE_VERSION: &str = "2.1.75";
29const DEFAULT_SAFE_MAX_OUTPUT_TOKENS: u32 = 32_000;
30/// Max page size the Anthropic `GET /v1/models` endpoint accepts.
31const MODELS_PAGE_LIMIT: u32 = 1000;
32/// Upper bound on pages followed by `list_models`, guarding against a server
33/// that never clears `has_more`. At `MODELS_PAGE_LIMIT` rows/page this covers
34/// far more models than any provider ships.
35const MODELS_MAX_PAGES: usize = 100;
36
37pub const MODEL_HAIKU_35: &str = "claude-3-5-haiku-20241022";
38pub const MODEL_SONNET_35: &str = "claude-3-5-sonnet-20241022";
39pub const MODEL_SONNET_4: &str = "claude-sonnet-4-20250514";
40pub const MODEL_OPUS_4: &str = "claude-opus-4-20250514";
41
42pub const MODEL_HAIKU_45: &str = "claude-haiku-4-5-20251001";
43pub const MODEL_SONNET_45: &str = "claude-sonnet-4-5-20250929";
44pub const MODEL_SONNET_46: &str = "claude-sonnet-4-6";
45pub const MODEL_SONNET_5: &str = "claude-sonnet-5";
46pub const MODEL_OPUS_46: &str = "claude-opus-4-6";
47pub const MODEL_OPUS_47: &str = "claude-opus-4-7";
48pub const MODEL_OPUS_48: &str = "claude-opus-4-8";
49pub const MODEL_FABLE_5: &str = "claude-fable-5";
50
51/// Claude Code tool name mappings for OAuth mode.
52///
53/// When using OAuth tokens, tool names must match Claude Code's exact casing.
54/// The mapper passes unknown names through unchanged, so extra entries here are
55/// harmless — they future-proof against new tools being registered later.
56/// Source: <https://cchistory.mariozechner.at/data/prompts-2.1.11.md>
57const CLAUDE_CODE_TOOLS: &[&str] = &[
58    "Read",
59    "Write",
60    "Edit",
61    "Bash",
62    "Grep",
63    "Glob",
64    "AskUserQuestion",
65    "EnterPlanMode",
66    "ExitPlanMode",
67    "KillShell",
68    "NotebookEdit",
69    "Skill",
70    "Task",
71    "TaskOutput",
72    "TodoWrite",
73    "WebFetch",
74    "WebSearch",
75];
76
77/// Maps a tool name to Claude Code's canonical casing (case-insensitive match).
78fn to_claude_code_name(name: &str) -> String {
79    let lower = name.to_lowercase();
80    for cc_name in CLAUDE_CODE_TOOLS {
81        if cc_name.to_lowercase() == lower {
82            return (*cc_name).to_string();
83        }
84    }
85    name.to_string()
86}
87
88/// Maps a Claude Code tool name back to the original tool name.
89fn from_claude_code_name(name: &str, original_names: &[String]) -> String {
90    let lower = name.to_lowercase();
91    for original in original_names {
92        if original.to_lowercase() == lower {
93            return original.clone();
94        }
95    }
96    name.to_string()
97}
98
99/// Detect two distinct user tool names that differ only by ASCII case.
100///
101/// In OAuth mode tool names are normalized to Claude Code's casing, so two
102/// tools that differ only by case (e.g. `task` and `Task`) would both serialize
103/// to the same wire name — producing duplicate tool definitions in the request
104/// and misrouting every returned `ToolUse` back to whichever original name
105/// appears first. Such a configuration is rejected up front.
106fn oauth_tool_name_collision(
107    tools: Option<&[agent_sdk_foundation::llm::Tool]>,
108) -> Option<(String, String)> {
109    let tools = tools?;
110    for (index, tool) in tools.iter().enumerate() {
111        for other in &tools[index + 1..] {
112            if tool.name != other.name && tool.name.eq_ignore_ascii_case(&other.name) {
113                return Some((tool.name.clone(), other.name.clone()));
114            }
115        }
116    }
117    None
118}
119
120fn oauth_tool_collision_message(first: &str, second: &str) -> String {
121    format!(
122        "OAuth tool names collide case-insensitively: '{first}' and '{second}' would map to the same Claude Code tool name; rename one to disambiguate"
123    )
124}
125
126/// Returns true if the API key is an OAuth token (`sk-ant-oat-*`).
127#[must_use]
128pub fn is_oauth_token(api_key: &str) -> bool {
129    api_key.starts_with("sk-ant-oat")
130}
131
132/// One page of the Anthropic `GET /v1/models` response: the model rows plus the
133/// cursor fields used to follow pagination.
134struct AnthropicModelsPage {
135    models: Vec<crate::provider::ModelInfo>,
136    has_more: bool,
137    last_id: Option<String>,
138}
139
140/// Parse one page of the Anthropic `GET /v1/models` response body.
141///
142/// The Messages API list endpoint returns `{ "data": [{ "id", "display_name",
143/// ... }], "has_more": bool, "last_id": "..." }`. It paginates with a default
144/// `limit` of 20; `has_more` + `last_id` drive the next request. It does not
145/// report token limits, so those fields stay `None`.
146fn parse_models_page(body: &str) -> Result<AnthropicModelsPage> {
147    #[derive(serde::Deserialize)]
148    struct ListResponse {
149        #[serde(default)]
150        data: Vec<ModelRow>,
151        #[serde(default)]
152        has_more: bool,
153        #[serde(default)]
154        last_id: Option<String>,
155    }
156    #[derive(serde::Deserialize)]
157    struct ModelRow {
158        id: String,
159        #[serde(default)]
160        display_name: Option<String>,
161    }
162    let parsed: ListResponse = serde_json::from_str(body)
163        .map_err(|e| anyhow::anyhow!("failed to parse Anthropic models list: {e}"))?;
164    let models = parsed
165        .data
166        .into_iter()
167        .map(|row| crate::provider::ModelInfo {
168            id: row.id,
169            display_name: row.display_name,
170            context_window: None,
171            max_output_tokens: None,
172        })
173        .collect();
174    Ok(AnthropicModelsPage {
175        models,
176        has_more: parsed.has_more,
177        last_id: parsed.last_id,
178    })
179}
180
181/// Cache-control breakpoints resolved for the three cacheable prefixes of an
182/// Anthropic request, in decreasing order of prefix stability. A `None` field
183/// means "do not mark this prefix with `cache_control`".
184struct CacheRegions {
185    tools: Option<data::ApiCacheControl>,
186    system: Option<data::ApiCacheControl>,
187    messages: Option<data::ApiCacheControl>,
188}
189
190impl CacheRegions {
191    /// No caching anywhere (request opted out via `CacheConfig`).
192    const DISABLED: Self = Self {
193        tools: None,
194        system: None,
195        messages: None,
196    };
197}
198
199/// Authentication mode for the Anthropic provider.
200#[derive(Clone, Debug)]
201enum AuthMode {
202    /// Standard API key authentication (x-api-key header).
203    ApiKey,
204    /// OAuth token authentication (Bearer header + Claude Code identity).
205    OAuth,
206}
207
208/// Anthropic LLM provider using the Messages API.
209#[derive(Clone)]
210pub struct AnthropicProvider {
211    client: reqwest::Client,
212    api_key: String,
213    model: String,
214    base_url: String,
215    auth_mode: AuthMode,
216    thinking: Option<ThinkingConfig>,
217    /// Extra headers applied to every request (e.g. for gateway authentication).
218    extra_headers: Vec<(String, String)>,
219}
220
221impl AnthropicProvider {
222    /// The conventional environment variable holding the Anthropic API key.
223    pub const API_KEY_ENV: &'static str = "ANTHROPIC_API_KEY";
224
225    /// Create a new Anthropic provider with the specified API key and model.
226    ///
227    /// Automatically detects OAuth tokens (`sk-ant-oat-*`) and switches to
228    /// Bearer auth with Claude Code identity headers.
229    #[must_use]
230    pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
231        let api_key = api_key.into();
232        let model = model.into();
233        let auth_mode = if is_oauth_token(&api_key) {
234            AuthMode::OAuth
235        } else {
236            AuthMode::ApiKey
237        };
238
239        // Configure client with appropriate timeouts for streaming
240        // - No overall timeout (streaming can take a long time)
241        // - 30 second connect timeout
242        // - TCP keepalive to prevent connection drops
243        let client = reqwest::Client::builder()
244            .connect_timeout(std::time::Duration::from_secs(30))
245            .tcp_keepalive(std::time::Duration::from_secs(30))
246            .build()
247            .unwrap_or_default();
248
249        Self {
250            client,
251            api_key,
252            model,
253            base_url: API_BASE_URL.to_owned(),
254            auth_mode,
255            thinking: None,
256            extra_headers: Vec::new(),
257        }
258    }
259
260    /// Returns whether this provider is using OAuth authentication.
261    #[must_use]
262    pub const fn is_oauth(&self) -> bool {
263        matches!(self.auth_mode, AuthMode::OAuth)
264    }
265
266    /// Applies authentication headers to a request builder.
267    ///
268    /// When `api_key` is empty the provider-specific credential header is
269    /// skipped — useful for BYOK gateways where auth is handled externally
270    /// via `extra_headers`.
271    fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
272        let builder = if self.api_key.is_empty() {
273            builder.header("anthropic-version", API_VERSION)
274        } else {
275            match self.auth_mode {
276                AuthMode::ApiKey => {
277                    let builder = builder
278                        .header("x-api-key", &self.api_key)
279                        .header("anthropic-version", API_VERSION);
280                    // Budget-thinking models (e.g. Sonnet 4.5, Haiku 4.5) only
281                    // emit interleaved (mid-loop) thinking blocks when this beta
282                    // is set. Adaptive-thinking models (4.6+) interleave
283                    // server-side and ignore the header, so gate on the same
284                    // predicate as the OAuth arm to keep the cached request
285                    // prefix minimal and stable. Do NOT add other OAuth-identity
286                    // betas here — API-key auth is not Claude Code.
287                    if self.requires_adaptive_thinking() {
288                        builder
289                    } else {
290                        builder.header("anthropic-beta", "interleaved-thinking-2025-05-14")
291                    }
292                }
293                AuthMode::OAuth => {
294                    // Build beta features list matching Claude Code's behaviour.
295                    // Adaptive-thinking models (4.6) have interleaved thinking
296                    // built-in; for older reasoning models we need the explicit beta.
297                    let mut beta_features = vec![
298                        "claude-code-20250219",
299                        "oauth-2025-04-20",
300                        "fine-grained-tool-streaming-2025-05-14",
301                    ];
302                    if !self.requires_adaptive_thinking() {
303                        beta_features.push("interleaved-thinking-2025-05-14");
304                    }
305                    builder
306                        .header("Authorization", format!("Bearer {}", self.api_key))
307                        .header("anthropic-version", API_VERSION)
308                        .header("anthropic-beta", beta_features.join(","))
309                        .header("user-agent", format!("claude-cli/{CLAUDE_CODE_VERSION}"))
310                        .header("x-app", "cli")
311                }
312            }
313        };
314        self.extra_headers
315            .iter()
316            .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
317    }
318
319    const OAUTH_IDENTITY: &'static str =
320        "You are Claude Code, Anthropic's official CLI for Claude.";
321
322    /// Build the system prompt payload, accounting for OAuth mode.
323    ///
324    /// In OAuth mode the identity string must be a **separate** system block
325    /// (matching the layout Claude Code itself sends).  For API-key auth the
326    /// user-supplied system prompt is sent as a single block.
327    fn build_system_prompt_for_request<'a>(
328        &self,
329        system: &'a str,
330        cache_control: Option<data::ApiCacheControl>,
331    ) -> Option<data::ApiSystemPrompt<'a>> {
332        match self.auth_mode {
333            AuthMode::ApiKey => data::build_api_system_prompt(system, cache_control),
334            AuthMode::OAuth => {
335                let mut blocks = vec![data::ApiSystemBlock {
336                    block_type: "text",
337                    text: Self::OAUTH_IDENTITY,
338                    cache_control: cache_control.clone(),
339                }];
340                if !system.is_empty() {
341                    blocks.push(data::ApiSystemBlock {
342                        block_type: "text",
343                        text: system,
344                        cache_control,
345                    });
346                }
347                Some(data::ApiSystemPrompt::Blocks(blocks))
348            }
349        }
350    }
351
352    /// Resolve the per-prefix cache breakpoints for a request from its optional
353    /// [`CacheConfig`](agent_sdk_foundation::llm::CacheConfig).
354    ///
355    /// With no config (the default) this reproduces the historical behaviour:
356    /// an ephemeral breakpoint on the tools, system, and last-user-message
357    /// prefixes. An opted-out config disables all breakpoints; a TTL flows onto
358    /// every breakpoint; and `max_breakpoints` caps how many prefixes are
359    /// marked, in decreasing order of stability (tools, system, conversation).
360    fn cache_regions(request: &ChatRequest) -> CacheRegions {
361        let (enabled, ttl, max_breakpoints) =
362            request.cache.as_ref().map_or((true, None, None), |cfg| {
363                (cfg.enabled, cfg.ttl, cfg.max_breakpoints)
364            });
365        if !enabled {
366            return CacheRegions::DISABLED;
367        }
368        let control = data::ApiCacheControl::ephemeral_with_ttl(ttl.map(CacheTtl::as_wire_str));
369        let limit = max_breakpoints.unwrap_or(u8::MAX);
370        CacheRegions {
371            tools: (limit >= 1).then(|| control.clone()),
372            system: (limit >= 2).then(|| control.clone()),
373            messages: (limit >= 3).then_some(control),
374        }
375    }
376
377    fn build_cached_api_messages(
378        request: &ChatRequest,
379        cache_control: Option<data::ApiCacheControl>,
380    ) -> Vec<data::ApiMessage> {
381        let mut messages = build_api_messages(request);
382        if let Some(cache_control) = cache_control {
383            data::apply_cache_control_to_last_user_message(&mut messages, cache_control);
384        }
385        messages
386    }
387
388    fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
389        if request.max_tokens_explicit {
390            request.max_tokens
391        } else {
392            self.default_max_tokens()
393        }
394    }
395
396    /// Create a provider using Claude Sonnet, reading the API key from the
397    /// conventional [`ANTHROPIC_API_KEY`](Self::API_KEY_ENV) environment
398    /// variable.
399    ///
400    /// This is the zero-ceremony on-ramp for the quickstart. Use
401    /// [`try_from_env`](Self::try_from_env) if you want to handle a missing
402    /// key without a panic.
403    ///
404    /// # Panics
405    ///
406    /// Panics if `ANTHROPIC_API_KEY` is not set. Prefer
407    /// [`try_from_env`](Self::try_from_env) outside of examples/tests.
408    #[must_use]
409    pub fn from_env() -> Self {
410        Self::try_from_env().unwrap_or_else(|e| panic!("{e}"))
411    }
412
413    /// Create a provider using Claude Sonnet, reading the API key from the
414    /// conventional [`ANTHROPIC_API_KEY`](Self::API_KEY_ENV) environment
415    /// variable.
416    ///
417    /// # Errors
418    ///
419    /// Returns an error if `ANTHROPIC_API_KEY` is unset or not valid UTF-8.
420    pub fn try_from_env() -> Result<Self> {
421        let api_key = std::env::var(Self::API_KEY_ENV).map_err(|_| {
422            anyhow::anyhow!("environment variable `{}` is not set", Self::API_KEY_ENV)
423        })?;
424        Ok(Self::sonnet(api_key))
425    }
426
427    /// Create a provider using Claude Haiku 4.5.
428    #[must_use]
429    pub fn haiku(api_key: impl Into<String>) -> Self {
430        Self::new(api_key, MODEL_HAIKU_45)
431    }
432
433    /// Create a provider using Claude Sonnet 4.6.
434    #[must_use]
435    pub fn sonnet(api_key: impl Into<String>) -> Self {
436        Self::new(api_key, MODEL_SONNET_46)
437    }
438
439    /// Create a provider using Claude Sonnet 4.5.
440    #[must_use]
441    pub fn sonnet_45(api_key: impl Into<String>) -> Self {
442        Self::new(api_key, MODEL_SONNET_45)
443    }
444
445    /// Create a provider using Claude Sonnet 4.6.
446    #[must_use]
447    pub fn sonnet_46(api_key: impl Into<String>) -> Self {
448        Self::new(api_key, MODEL_SONNET_46)
449    }
450
451    /// Create a provider using Claude Opus 4.6.
452    #[must_use]
453    pub fn opus(api_key: impl Into<String>) -> Self {
454        Self::new(api_key, MODEL_OPUS_46)
455    }
456
457    /// Create a provider using Claude Opus 4.7.
458    ///
459    /// Note: Opus 4.7 requires adaptive thinking. Passing a
460    /// `ThinkingConfig` with `ThinkingMode::Enabled { budget_tokens }`
461    /// will return an `InvalidRequest` — use `ThinkingConfig::adaptive()`
462    /// or `ThinkingConfig::adaptive_with_effort(_)` instead.
463    #[must_use]
464    pub fn opus_47(api_key: impl Into<String>) -> Self {
465        Self::new(api_key, MODEL_OPUS_47)
466    }
467
468    /// Create a provider using Claude Opus 4.8.
469    ///
470    /// Note: Opus 4.8 requires adaptive thinking. Passing a
471    /// `ThinkingConfig` with `ThinkingMode::Enabled { budget_tokens }`
472    /// will return an `InvalidRequest` — use `ThinkingConfig::adaptive()`
473    /// or `ThinkingConfig::adaptive_with_effort(_)` instead.
474    #[must_use]
475    pub fn opus_48(api_key: impl Into<String>) -> Self {
476        Self::new(api_key, MODEL_OPUS_48)
477    }
478
479    /// Create a provider using Claude Fable 5.
480    ///
481    /// Note: Fable 5 is adaptive-only — the API applies adaptive thinking
482    /// even when no thinking config is sent, and raw chain of thought is
483    /// never returned (thinking blocks arrive with empty content). Passing a
484    /// `ThinkingConfig` with `ThinkingMode::Enabled { budget_tokens }`
485    /// will return an `InvalidRequest` — use `ThinkingConfig::adaptive()`
486    /// or `ThinkingConfig::adaptive_with_effort(_)` instead.
487    #[must_use]
488    pub fn fable(api_key: impl Into<String>) -> Self {
489        Self::new(api_key, MODEL_FABLE_5)
490    }
491
492    /// Claude Sonnet 5 — adaptive-only like Opus 4.8: manual `budget_tokens`
493    /// returns a 400; use `ThinkingConfig::adaptive()` instead.
494    #[must_use]
495    pub fn sonnet_5(api_key: impl Into<String>) -> Self {
496        Self::new(api_key, MODEL_SONNET_5)
497    }
498
499    /// Set the provider-owned thinking configuration for this model.
500    #[must_use]
501    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
502        self.thinking = Some(thinking);
503        self
504    }
505
506    /// Override the base URL (default: `https://api.anthropic.com`).
507    #[must_use]
508    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
509        self.base_url = base_url.into();
510        self
511    }
512
513    /// Add extra HTTP headers applied to every request.
514    #[must_use]
515    pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
516        self.extra_headers = headers;
517        self
518    }
519
520    fn requires_adaptive_thinking(&self) -> bool {
521        matches!(
522            self.model.as_str(),
523            MODEL_SONNET_46
524                | MODEL_SONNET_5
525                | MODEL_OPUS_46
526                | MODEL_OPUS_47
527                | MODEL_OPUS_48
528                | MODEL_FABLE_5
529        )
530    }
531}
532
533#[async_trait]
534#[allow(clippy::too_many_lines)]
535impl LlmProvider for AnthropicProvider {
536    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
537        let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
538            // Forcing a specific tool is incompatible with extended thinking on
539            // Anthropic (the API 400s), so drop thinking at the wire boundary
540            // even when it was resurrected from the provider-configured default.
541            Ok(thinking) => thinking_for_forced_tool(thinking, request.tool_choice.as_ref()),
542            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
543        };
544        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
545            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
546        }
547        if self.is_oauth()
548            && let Some((first, second)) = oauth_tool_name_collision(request.tools.as_deref())
549        {
550            return Ok(ChatOutcome::InvalidRequest(oauth_tool_collision_message(
551                &first, &second,
552            )));
553        }
554        let CacheRegions {
555            tools: tools_cache,
556            system: system_cache,
557            messages: messages_cache,
558        } = Self::cache_regions(&request);
559        let messages = Self::build_cached_api_messages(&request, messages_cache);
560        let tools = if self.is_oauth() {
561            build_api_tools_with_cache(&request, tools_cache).map(|tools| {
562                tools
563                    .into_iter()
564                    .map(|mut t| {
565                        t.name = to_claude_code_name(&t.name);
566                        t
567                    })
568                    .collect::<Vec<_>>()
569            })
570        } else {
571            build_api_tools_with_cache(&request, tools_cache)
572        };
573        let thinking = thinking_config
574            .as_ref()
575            .map(ApiThinkingConfig::from_thinking_config);
576        let output_config = thinking_config
577            .as_ref()
578            .and_then(|t| t.effort)
579            .map(|effort| ApiOutputConfig { effort });
580
581        let system = self.build_system_prompt_for_request(&request.system, system_cache);
582        let max_tokens = self.effective_max_tokens(&request);
583        let tool_choice = request
584            .tool_choice
585            .as_ref()
586            .map(ApiToolChoice::from_tool_choice);
587
588        let api_request = ApiMessagesRequest {
589            model: Some(&self.model),
590            max_tokens,
591            system,
592            messages: &messages,
593            tools: tools.as_deref(),
594            tool_choice,
595            stream: false,
596            thinking,
597            output_config,
598            anthropic_version: None,
599        };
600
601        log::debug!(
602            "Anthropic LLM request model={} max_tokens={} oauth={}",
603            self.model,
604            max_tokens,
605            self.is_oauth()
606        );
607
608        // Log full request payload for debugging
609        if log::log_enabled!(log::Level::Debug) {
610            match serde_json::to_string_pretty(&api_request) {
611                Ok(json) => log::debug!("Anthropic API request payload:\n{json}"),
612                Err(e) => log::debug!("Failed to serialize request for logging: {e}"),
613            }
614        }
615
616        let builder = self
617            .client
618            .post(format!("{}/v1/messages", self.base_url))
619            .header("Content-Type", "application/json");
620        let response = self
621            .apply_auth(builder)
622            .json(&api_request)
623            .send()
624            .await
625            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
626
627        let status = response.status();
628        // Read `Retry-After` off the 429 response before the body is consumed
629        // (`bytes()` takes the response by value).
630        let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
631            crate::http::retry_after_from_headers(response.headers())
632        } else {
633            None
634        };
635        let bytes = response
636            .bytes()
637            .await
638            .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
639
640        log::debug!(
641            "Anthropic LLM response status={} body_len={}",
642            status,
643            bytes.len()
644        );
645
646        if status == StatusCode::TOO_MANY_REQUESTS {
647            return Ok(ChatOutcome::RateLimited(retry_after));
648        }
649
650        if status.is_server_error() {
651            let body = String::from_utf8_lossy(&bytes);
652            log::error!("Anthropic server error status={status} body={body}");
653            return Ok(ChatOutcome::ServerError(body.into_owned()));
654        }
655
656        if status.is_client_error() {
657            let body = String::from_utf8_lossy(&bytes);
658            log::warn!("Anthropic client error status={status} body={body}");
659            return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
660        }
661
662        let api_response: data::ApiResponse = serde_json::from_slice(&bytes)
663            .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
664
665        // Log the full response for debugging
666        log::debug!(
667            "Anthropic API response: id={} model={} stop_reason={:?} usage={{input_tokens={}, output_tokens={}}} content_blocks={}",
668            api_response.id,
669            api_response.model,
670            api_response.stop_reason,
671            api_response.usage.total_input_tokens(),
672            api_response.usage.output,
673            api_response.content.len()
674        );
675
676        let mut content = map_content_blocks(api_response.content);
677
678        // Reverse-map tool names from Claude Code casing back to original names
679        if self.is_oauth() {
680            let original_names: Vec<String> = request
681                .tools
682                .as_ref()
683                .map(|ts| ts.iter().map(|t| t.name.clone()).collect())
684                .unwrap_or_default();
685            for block in &mut content {
686                if let ContentBlock::ToolUse { name, .. } = block {
687                    *name = from_claude_code_name(name, &original_names);
688                }
689            }
690        }
691
692        let stop_reason = api_response.stop_reason.as_ref().map(map_stop_reason);
693
694        Ok(ChatOutcome::Success(ChatResponse {
695            id: api_response.id,
696            content,
697            model: api_response.model,
698            stop_reason,
699            usage: Usage {
700                input_tokens: api_response.usage.total_input_tokens(),
701                output_tokens: api_response.usage.output,
702                cached_input_tokens: api_response.usage.cached_input_tokens(),
703                cache_creation_input_tokens: api_response.usage.cache_creation_input_tokens(),
704            },
705        }))
706    }
707
708    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
709        Box::pin(async_stream::stream! {
710            let is_oauth = self.is_oauth();
711            let original_tool_names: Vec<String> = request
712                .tools
713                .as_ref()
714                .map(|ts| ts.iter().map(|t| t.name.clone()).collect())
715                .unwrap_or_default();
716
717            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
718                yield Ok(StreamDelta::Error {
719                    message: error.to_string(),
720                    kind: StreamErrorKind::InvalidRequest,
721                });
722                return;
723            }
724
725            if is_oauth
726                && let Some((first, second)) = oauth_tool_name_collision(request.tools.as_deref())
727            {
728                yield Ok(StreamDelta::Error {
729                    message: oauth_tool_collision_message(&first, &second),
730                    kind: StreamErrorKind::InvalidRequest,
731                });
732                return;
733            }
734
735            let CacheRegions {
736                tools: tools_cache,
737                system: system_cache,
738                messages: messages_cache,
739            } = Self::cache_regions(&request);
740            let messages = Self::build_cached_api_messages(&request, messages_cache);
741            let tools = if is_oauth {
742                build_api_tools_with_cache(&request, tools_cache).map(|tools| {
743                    tools
744                        .into_iter()
745                        .map(|mut t| {
746                            t.name = to_claude_code_name(&t.name);
747                            t
748                        })
749                        .collect::<Vec<_>>()
750                })
751            } else {
752                build_api_tools_with_cache(&request, tools_cache)
753            };
754            let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
755                // Forcing a specific tool is incompatible with extended thinking
756                // on Anthropic (the API 400s), so drop thinking at the wire
757                // boundary even when it was resurrected from the
758                // provider-configured default.
759                Ok(thinking) => thinking_for_forced_tool(thinking, request.tool_choice.as_ref()),
760                Err(error) => {
761                    yield Ok(StreamDelta::Error {
762                        message: error.to_string(),
763                        kind: StreamErrorKind::InvalidRequest,
764                    });
765                    return;
766                }
767            };
768            let thinking = thinking_config
769                .as_ref()
770                .map(ApiThinkingConfig::from_thinking_config);
771            let output_config = thinking_config
772                .as_ref()
773                .and_then(|t| t.effort)
774                .map(|effort| ApiOutputConfig { effort });
775
776            let system = self.build_system_prompt_for_request(&request.system, system_cache);
777            let max_tokens = self.effective_max_tokens(&request);
778            let tool_choice = request
779                .tool_choice
780                .as_ref()
781                .map(ApiToolChoice::from_tool_choice);
782
783            let api_request = ApiMessagesRequest {
784                model: Some(&self.model),
785                max_tokens,
786                system,
787                messages: &messages,
788                tools: tools.as_deref(),
789                tool_choice,
790                stream: true,
791                thinking,
792                output_config,
793                anthropic_version: None,
794            };
795
796            log::debug!("Anthropic streaming LLM request model={} max_tokens={} oauth={}", self.model, max_tokens, is_oauth);
797
798            // Log full request payload for debugging
799            if log::log_enabled!(log::Level::Debug) {
800                match serde_json::to_string_pretty(&api_request) {
801                    Ok(json) => log::debug!("Anthropic streaming API request payload:\n{json}"),
802                    Err(e) => log::debug!("Failed to serialize streaming request for logging: {e}"),
803                }
804            }
805
806            let builder = self
807                .client
808                .post(format!("{}/v1/messages", self.base_url))
809                .header("Content-Type", "application/json");
810            let response = match self
811                .apply_auth(builder)
812                .json(&api_request)
813                .send()
814                .await
815            {
816                Ok(r) => r,
817                Err(e) => {
818                    yield Err(anyhow::anyhow!("request failed: {e}"));
819                    return;
820                }
821            };
822
823            let status = response.status();
824
825            if status == StatusCode::TOO_MANY_REQUESTS {
826                yield Ok(StreamDelta::Error {
827                    message: "Rate limited".to_string(),
828                    kind: StreamErrorKind::RateLimited,
829                });
830                return;
831            }
832
833            if status.is_server_error() {
834                let body = response.text().await.unwrap_or_default();
835                log::error!("Anthropic server error status={status} body={body}");
836                yield Ok(StreamDelta::Error {
837                    message: body,
838                    kind: StreamErrorKind::ServerError,
839                });
840                return;
841            }
842
843            if status.is_client_error() {
844                let body = response.text().await.unwrap_or_default();
845                log::warn!("Anthropic client error status={status} body={body}");
846                yield Ok(StreamDelta::Error {
847                    message: body,
848                    kind: StreamErrorKind::InvalidRequest,
849                });
850                return;
851            }
852
853            // Process SSE stream
854            let mut stream = response.bytes_stream();
855            let mut buffer = String::new();
856            let mut input_tokens: u32 = 0;
857            let mut output_tokens: u32 = 0;
858            let mut cached_input_tokens: u32 = 0;
859            let mut cache_creation_input_tokens: u32 = 0;
860            // Track tool IDs by block index for correlating input deltas
861            let mut tool_ids: std::collections::HashMap<usize, String> =
862                std::collections::HashMap::new();
863
864            let mut received_message_stop = false;
865            // Set when Anthropic streamed a terminal `error` event (parsed
866            // into a StreamDelta::Error below). Suppresses the generic
867            // "stream ended without message_stop" fallback so the caller
868            // sees the real error, not a second misleading one.
869            let mut stream_errored = false;
870            let mut pending_stop_reason: Option<agent_sdk_foundation::llm::StopReason> = None;
871            let mut chunk_count: u64 = 0;
872            let mut total_bytes: u64 = 0;
873
874            // Drop guard to detect if the stream is dropped before completion
875            struct StreamDropGuard {
876                completed: bool,
877                chunk_count: u64,
878            }
879            impl Drop for StreamDropGuard {
880                fn drop(&mut self) {
881                    if !self.completed {
882                        // Stream drops are expected when the user cancels a running
883                        // agent loop (Esc / Ctrl-C).  Log at debug level so it does
884                        // not surface as noise in every cancelled session.
885                        log::debug!(
886                            "SSE stream dropped before completion at chunk_count={} (task was likely cancelled)",
887                            self.chunk_count
888                        );
889                    }
890                }
891            }
892            let mut drop_guard = StreamDropGuard { completed: false, chunk_count: 0 };
893
894            log::debug!("Starting SSE stream processing");
895
896            while let Some(chunk_result) = stream.next().await {
897                let chunk = match chunk_result {
898                    Ok(c) => c,
899                    Err(e) => {
900                        log::error!("Stream error while reading chunk error={e} chunk_count={chunk_count} total_bytes={total_bytes}");
901                        yield Err(anyhow::anyhow!("stream error: {e}"));
902                        return;
903                    }
904                };
905
906                chunk_count += 1;
907                total_bytes += chunk.len() as u64;
908                drop_guard.chunk_count = chunk_count;
909
910                // Log progress every 10 chunks to show HTTP stream is alive
911                if chunk_count.is_multiple_of(10) {
912                    log::debug!("SSE chunk progress: chunk_count={chunk_count} total_bytes={total_bytes}");
913                }
914                buffer.push_str(&String::from_utf8_lossy(&chunk));
915
916                // Process complete SSE events (terminated by a blank line)
917                while let Some(event_block) = take_next_sse_event(&mut buffer) {
918                    // Track if we received message_stop
919                    if is_message_stop_event(&event_block) {
920                        log::debug!("Received message_stop event chunk_count={chunk_count} total_bytes={total_bytes}");
921                        received_message_stop = true;
922                    }
923
924                    // Parse SSE event
925                    if let Some(mut delta) = parse_sse_event(
926                        &event_block,
927                        &mut input_tokens,
928                        &mut output_tokens,
929                        &mut cached_input_tokens,
930                        &mut cache_creation_input_tokens,
931                        &mut tool_ids,
932                        &mut pending_stop_reason,
933                    ) {
934                        // Reverse-map tool names from Claude Code casing
935                        if is_oauth
936                            && let StreamDelta::ToolUseStart { ref mut name, .. } = delta
937                        {
938                            *name = from_claude_code_name(name, &original_tool_names);
939                        }
940                        // A terminal error event ends the stream — flag it
941                        // so the no-message_stop fallback stays silent.
942                        if matches!(delta, StreamDelta::Error { .. }) {
943                            stream_errored = true;
944                        }
945                        yield Ok(delta);
946                    }
947                    // After message_stop (which emits Usage), emit Done
948                    if is_message_stop_event(&event_block) {
949                        yield Ok(StreamDelta::Done {
950                            stop_reason: pending_stop_reason.take(),
951                        });
952                    }
953                }
954            }
955
956            log::debug!(
957                "SSE stream ended chunk_count={chunk_count} total_bytes={total_bytes} buffer_remaining={} received_message_stop={received_message_stop}",
958                buffer.len()
959            );
960
961            // Process any remaining buffer content (handles incomplete final chunk)
962            let remaining = buffer.trim();
963            if !remaining.is_empty() {
964                log::debug!(
965                    "Processing remaining buffer content remaining_len={} remaining_preview={}",
966                    remaining.len(),
967                    remaining.chars().take(100).collect::<String>()
968                );
969
970                // Track if remaining buffer contains message_stop
971                if is_message_stop_event(remaining) {
972                    received_message_stop = true;
973                }
974
975                if let Some(mut delta) = parse_sse_event(
976                    remaining,
977                    &mut input_tokens,
978                    &mut output_tokens,
979                    &mut cached_input_tokens,
980                    &mut cache_creation_input_tokens,
981                    &mut tool_ids,
982                    &mut pending_stop_reason,
983                ) {
984                    if is_oauth
985                        && let StreamDelta::ToolUseStart { ref mut name, .. } = delta
986                    {
987                        *name = from_claude_code_name(name, &original_tool_names);
988                    }
989                    if matches!(delta, StreamDelta::Error { .. }) {
990                        stream_errored = true;
991                    }
992                    yield Ok(delta);
993                }
994                // After message_stop (which emits Usage), emit Done
995                if is_message_stop_event(remaining) {
996                    yield Ok(StreamDelta::Done {
997                        stop_reason: pending_stop_reason.take(),
998                    });
999                }
1000            }
1001
1002            // Mark stream as properly completed
1003            drop_guard.completed = true;
1004
1005            // If stream ended without message_stop AND without a parsed
1006            // error event, emit a generic server-error (transient) signal.
1007            // When Anthropic streamed a terminal `error` event, it was
1008            // already surfaced above with its real message + kind — don't
1009            // mask it with this generic one.
1010            if !received_message_stop && !stream_errored {
1011                log::warn!(
1012                    "SSE stream ended without message_stop event - stream may have been interrupted chunk_count={chunk_count} total_bytes={total_bytes}"
1013                );
1014                yield Ok(StreamDelta::Error {
1015                    message: "Stream ended unexpectedly without completion".to_string(),
1016                    kind: StreamErrorKind::ServerError,
1017                });
1018            }
1019        })
1020    }
1021
1022    fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
1023        let Some(thinking) = thinking else {
1024            return Ok(());
1025        };
1026
1027        if self
1028            .capabilities()
1029            .is_some_and(|caps| !caps.supports_thinking)
1030        {
1031            return Err(anyhow::anyhow!(
1032                "thinking is not supported for provider={} model={}",
1033                self.provider(),
1034                self.model()
1035            ));
1036        }
1037
1038        if matches!(thinking.mode, ThinkingMode::Adaptive)
1039            && !self
1040                .capabilities()
1041                .is_some_and(|caps| caps.supports_adaptive_thinking)
1042        {
1043            return Err(anyhow::anyhow!(
1044                "adaptive thinking is not supported for provider={} model={}",
1045                self.provider(),
1046                self.model()
1047            ));
1048        }
1049
1050        if self.requires_adaptive_thinking()
1051            && matches!(thinking.mode, ThinkingMode::Enabled { .. })
1052        {
1053            return Err(anyhow::anyhow!(
1054                "budget_tokens thinking is deprecated for provider={} model={}; use ThinkingConfig::adaptive() instead",
1055                self.provider(),
1056                self.model()
1057            ));
1058        }
1059
1060        Ok(())
1061    }
1062
1063    async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
1064        // The endpoint paginates (default `limit=20`). Request the max page size
1065        // and follow `has_more` / `last_id` until exhausted, capped to avoid an
1066        // unbounded loop if the server never clears `has_more`.
1067        let mut models = Vec::new();
1068        let mut after_id: Option<String> = None;
1069        for _ in 0..MODELS_MAX_PAGES {
1070            let mut query: Vec<(&str, String)> = vec![("limit", MODELS_PAGE_LIMIT.to_string())];
1071            if let Some(after) = &after_id {
1072                query.push(("after_id", after.clone()));
1073            }
1074            let builder = self
1075                .client
1076                .get(format!("{}/v1/models", self.base_url))
1077                .header("Content-Type", "application/json")
1078                .query(&query);
1079            let builder = self.apply_auth(builder);
1080            let body =
1081                crate::impls::model_listing::fetch_model_list_body(builder, "Anthropic").await?;
1082            let page = parse_models_page(&body)?;
1083            models.extend(page.models);
1084            if !page.has_more {
1085                return Ok(models);
1086            }
1087            match page.last_id {
1088                Some(last) => after_id = Some(last),
1089                // `has_more` with no cursor: stop rather than refetch page 1.
1090                None => return Ok(models),
1091            }
1092        }
1093        Ok(models)
1094    }
1095
1096    fn model(&self) -> &str {
1097        &self.model
1098    }
1099
1100    fn provider(&self) -> &'static str {
1101        "anthropic"
1102    }
1103
1104    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1105        self.thinking.as_ref()
1106    }
1107
1108    fn default_max_tokens(&self) -> u32 {
1109        let model_max = self
1110            .capabilities()
1111            .and_then(|caps| caps.max_output_tokens)
1112            .or_else(|| {
1113                crate::model_capabilities::default_max_output_tokens(self.provider(), self.model())
1114            })
1115            .unwrap_or(4096);
1116        model_max.clamp(4096, DEFAULT_SAFE_MAX_OUTPUT_TOKENS)
1117    }
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123
1124    const ANTHROPIC_MODELS_FIXTURE: &str = r#"{
1125      "data": [
1126        {"type": "model", "id": "claude-opus-4-8", "display_name": "Claude Opus 4.8"},
1127        {"type": "model", "id": "claude-sonnet-4-5", "display_name": "Claude Sonnet 4.5"}
1128      ],
1129      "has_more": false
1130    }"#;
1131
1132    #[test]
1133    fn parse_models_page_reads_id_and_display_name() -> anyhow::Result<()> {
1134        let page = parse_models_page(ANTHROPIC_MODELS_FIXTURE)?;
1135        assert_eq!(page.models.len(), 2);
1136        assert_eq!(page.models[0].id, "claude-opus-4-8");
1137        assert_eq!(
1138            page.models[0].display_name.as_deref(),
1139            Some("Claude Opus 4.8")
1140        );
1141        // Anthropic's listing endpoint reports no token limits.
1142        assert_eq!(page.models[0].context_window, None);
1143        assert_eq!(page.models[0].max_output_tokens, None);
1144        // Single, final page.
1145        assert!(!page.has_more);
1146        assert_eq!(page.last_id, None);
1147        Ok(())
1148    }
1149
1150    #[tokio::test]
1151    async fn list_models_follows_pagination_across_pages() -> anyhow::Result<()> {
1152        use wiremock::matchers::{method, path, query_param, query_param_is_missing};
1153        use wiremock::{Mock, MockServer, ResponseTemplate};
1154
1155        let server = MockServer::start().await;
1156
1157        // Page 1: the first request has no `after_id` cursor; it returns
1158        // `has_more: true` with `last_id` pointing at the next page.
1159        Mock::given(method("GET"))
1160            .and(path("/v1/models"))
1161            .and(query_param_is_missing("after_id"))
1162            .respond_with(ResponseTemplate::new(200).set_body_string(
1163                r#"{
1164                  "data": [
1165                    {"type": "model", "id": "claude-opus-4-8", "display_name": "Opus"},
1166                    {"type": "model", "id": "claude-sonnet-4-5", "display_name": "Sonnet"}
1167                  ],
1168                  "has_more": true,
1169                  "last_id": "claude-sonnet-4-5"
1170                }"#,
1171            ))
1172            .mount(&server)
1173            .await;
1174
1175        // Page 2: requested with `after_id=claude-sonnet-4-5`; final page.
1176        Mock::given(method("GET"))
1177            .and(path("/v1/models"))
1178            .and(query_param("after_id", "claude-sonnet-4-5"))
1179            .respond_with(ResponseTemplate::new(200).set_body_string(
1180                r#"{
1181                  "data": [
1182                    {"type": "model", "id": "claude-haiku-4-5", "display_name": "Haiku"}
1183                  ],
1184                  "has_more": false,
1185                  "last_id": "claude-haiku-4-5"
1186                }"#,
1187            ))
1188            .mount(&server)
1189            .await;
1190
1191        let provider = AnthropicProvider::new("test-key-not-a-secret", "claude-test")
1192            .with_base_url(server.uri());
1193        let models = provider.list_models().await?;
1194
1195        // All three models across both pages are returned — none dropped.
1196        let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
1197        assert_eq!(
1198            ids,
1199            vec!["claude-opus-4-8", "claude-sonnet-4-5", "claude-haiku-4-5"]
1200        );
1201        Ok(())
1202    }
1203
1204    // ===================
1205    // Constructor Tests
1206    // ===================
1207
1208    #[test]
1209    fn test_new_creates_provider_with_custom_model() {
1210        let provider = AnthropicProvider::new("test-api-key", "custom-model");
1211
1212        assert_eq!(provider.model(), "custom-model");
1213        assert_eq!(provider.provider(), "anthropic");
1214    }
1215
1216    #[test]
1217    fn test_haiku_factory_creates_haiku_provider() {
1218        let provider = AnthropicProvider::haiku("test-api-key".to_string());
1219
1220        assert_eq!(provider.model(), MODEL_HAIKU_45);
1221        assert_eq!(provider.provider(), "anthropic");
1222    }
1223
1224    #[test]
1225    fn test_only_anthropic_46_models_accept_adaptive_thinking() {
1226        let sonnet_46 = AnthropicProvider::sonnet_46("test-api-key".to_string());
1227        assert!(
1228            sonnet_46
1229                .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1230                .is_ok()
1231        );
1232
1233        let sonnet_45 = AnthropicProvider::sonnet_45("test-api-key".to_string());
1234        let error = sonnet_45
1235            .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1236            .unwrap_err();
1237        assert!(
1238            error
1239                .to_string()
1240                .contains("adaptive thinking is not supported")
1241        );
1242    }
1243
1244    #[test]
1245    fn test_anthropic_46_models_reject_budgeted_thinking() {
1246        let sonnet_46 = AnthropicProvider::sonnet_46("test-api-key".to_string());
1247        let error = sonnet_46
1248            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1249            .unwrap_err();
1250        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1251    }
1252
1253    #[test]
1254    fn test_opus_47_rejects_budgeted_thinking() {
1255        // Opus 4.7 follows the same adaptive-only policy as 4.6. Without
1256        // this guard the provider would serialise `thinking.type.enabled`
1257        // and get a 400 back from the API — we want a clear SDK-level
1258        // error instead.
1259        let opus_47 = AnthropicProvider::opus_47("test-api-key".to_string());
1260        let error = opus_47
1261            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1262            .unwrap_err();
1263        assert!(
1264            error.to_string().contains("ThinkingConfig::adaptive()"),
1265            "expected migration hint, got: {error}"
1266        );
1267    }
1268
1269    #[test]
1270    fn test_opus_47_accepts_adaptive_thinking() {
1271        let opus_47 = AnthropicProvider::opus_47("test-api-key".to_string());
1272        assert!(
1273            opus_47
1274                .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1275                .is_ok()
1276        );
1277        assert!(
1278            opus_47
1279                .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1280                    agent_sdk_foundation::llm::Effort::High
1281                )))
1282                .is_ok()
1283        );
1284    }
1285
1286    #[test]
1287    fn test_opus_47_factory_creates_opus_47_provider() {
1288        let provider = AnthropicProvider::opus_47("test-api-key".to_string());
1289        assert_eq!(provider.model(), MODEL_OPUS_47);
1290        assert_eq!(provider.provider(), "anthropic");
1291    }
1292
1293    #[test]
1294    fn test_opus_48_rejects_budgeted_thinking() {
1295        // Opus 4.8 follows the same adaptive-only policy as 4.6/4.7. Without
1296        // this guard the provider would serialise `thinking.type.enabled`
1297        // and get a 400 back from the API — we want a clear SDK-level
1298        // error instead.
1299        let opus_48 = AnthropicProvider::opus_48("test-api-key".to_string());
1300        let error = opus_48
1301            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1302            .unwrap_err();
1303        assert!(
1304            error.to_string().contains("ThinkingConfig::adaptive()"),
1305            "expected migration hint, got: {error}"
1306        );
1307    }
1308
1309    #[test]
1310    fn test_opus_48_accepts_adaptive_thinking() {
1311        let opus_48 = AnthropicProvider::opus_48("test-api-key".to_string());
1312        assert!(
1313            opus_48
1314                .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1315                .is_ok()
1316        );
1317        assert!(
1318            opus_48
1319                .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1320                    agent_sdk_foundation::llm::Effort::High
1321                )))
1322                .is_ok()
1323        );
1324    }
1325
1326    #[test]
1327    fn test_opus_48_factory_creates_opus_48_provider() {
1328        let provider = AnthropicProvider::opus_48("test-api-key".to_string());
1329        assert_eq!(provider.model(), MODEL_OPUS_48);
1330        assert_eq!(provider.provider(), "anthropic");
1331    }
1332
1333    #[test]
1334    fn test_sonnet_5_rejects_budgeted_thinking() {
1335        // Sonnet 5 is adaptive-only (like Opus 4.8): manual budget_tokens 400s.
1336        // Fail fast at the SDK with a migration hint instead of a 400 from the API.
1337        let sonnet_5 = AnthropicProvider::sonnet_5("test-api-key".to_string());
1338        let error = sonnet_5
1339            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1340            .unwrap_err();
1341        assert!(
1342            error.to_string().contains("ThinkingConfig::adaptive()"),
1343            "expected migration hint, got: {error}"
1344        );
1345    }
1346
1347    #[test]
1348    fn test_sonnet_5_accepts_adaptive_thinking() {
1349        let sonnet_5 = AnthropicProvider::sonnet_5("test-api-key".to_string());
1350        assert!(
1351            sonnet_5
1352                .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1353                .is_ok()
1354        );
1355        assert!(
1356            sonnet_5
1357                .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1358                    agent_sdk_foundation::llm::Effort::High
1359                )))
1360                .is_ok()
1361        );
1362    }
1363
1364    #[test]
1365    fn test_fable_5_rejects_budgeted_thinking() {
1366        // Fable 5 is adaptive-only: the API applies adaptive thinking even
1367        // when `thinking` is unset and rejects budget-based configs. Fail
1368        // fast with a migration hint instead of a 400 from the API.
1369        let fable = AnthropicProvider::fable("test-api-key".to_string());
1370        let error = fable
1371            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1372            .unwrap_err();
1373        assert!(
1374            error.to_string().contains("ThinkingConfig::adaptive()"),
1375            "expected migration hint, got: {error}"
1376        );
1377    }
1378
1379    #[test]
1380    fn test_fable_5_accepts_adaptive_thinking() {
1381        let fable = AnthropicProvider::fable("test-api-key".to_string());
1382        assert!(
1383            fable
1384                .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1385                .is_ok()
1386        );
1387        assert!(
1388            fable
1389                .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1390                    agent_sdk_foundation::llm::Effort::High
1391                )))
1392                .is_ok()
1393        );
1394    }
1395
1396    #[test]
1397    fn test_fable_factory_creates_fable_5_provider() {
1398        let provider = AnthropicProvider::fable("test-api-key".to_string());
1399        assert_eq!(provider.model(), MODEL_FABLE_5);
1400        assert_eq!(provider.provider(), "anthropic");
1401    }
1402
1403    #[test]
1404    fn test_sonnet_factory_creates_sonnet_provider() {
1405        let provider = AnthropicProvider::sonnet("test-api-key".to_string());
1406
1407        assert_eq!(provider.model(), MODEL_SONNET_46);
1408        assert_eq!(provider.provider(), "anthropic");
1409    }
1410
1411    #[test]
1412    fn test_sonnet_45_factory_creates_sonnet_provider() {
1413        let provider = AnthropicProvider::sonnet_45("test-api-key".to_string());
1414
1415        assert_eq!(provider.model(), MODEL_SONNET_45);
1416        assert_eq!(provider.provider(), "anthropic");
1417    }
1418
1419    #[test]
1420    fn test_sonnet_46_factory_creates_sonnet_provider() {
1421        let provider = AnthropicProvider::sonnet_46("test-api-key".to_string());
1422
1423        assert_eq!(provider.model(), MODEL_SONNET_46);
1424        assert_eq!(provider.provider(), "anthropic");
1425    }
1426
1427    #[test]
1428    fn test_opus_factory_creates_opus_provider() {
1429        let provider = AnthropicProvider::opus("test-api-key".to_string());
1430
1431        assert_eq!(provider.model(), MODEL_OPUS_46);
1432        assert_eq!(provider.provider(), "anthropic");
1433    }
1434
1435    // ===================
1436    // Model Constants Tests
1437    // ===================
1438
1439    #[test]
1440    fn test_model_constants_have_expected_values() {
1441        assert!(MODEL_HAIKU_35.contains("haiku"));
1442        assert!(MODEL_SONNET_35.contains("sonnet"));
1443        assert!(MODEL_SONNET_4.contains("sonnet"));
1444        assert!(MODEL_SONNET_46.contains("sonnet"));
1445        assert!(MODEL_OPUS_4.contains("opus"));
1446    }
1447
1448    // ===================
1449    // Clone Tests
1450    // ===================
1451
1452    #[test]
1453    fn test_provider_is_cloneable() {
1454        let provider = AnthropicProvider::new("test-api-key", "test-model");
1455        let cloned = provider.clone();
1456
1457        assert_eq!(provider.model(), cloned.model());
1458        assert_eq!(provider.provider(), cloned.provider());
1459    }
1460
1461    // ===================
1462    // OAuth tool-name collision (finding #15)
1463    // ===================
1464
1465    fn tool(name: &str) -> agent_sdk_foundation::llm::Tool {
1466        agent_sdk_foundation::llm::Tool {
1467            name: name.to_string(),
1468            description: "desc".to_string(),
1469            input_schema: serde_json::json!({ "type": "object" }),
1470            display_name: name.to_string(),
1471            tier: agent_sdk_foundation::ToolTier::Observe,
1472        }
1473    }
1474
1475    fn request_with_tools(tools: Vec<agent_sdk_foundation::llm::Tool>) -> ChatRequest {
1476        ChatRequest {
1477            system: String::new(),
1478            messages: vec![agent_sdk_foundation::llm::Message::user("hi")],
1479            tools: Some(tools),
1480            max_tokens: 1024,
1481            max_tokens_explicit: true,
1482            session_id: None,
1483            cached_content: None,
1484            thinking: None,
1485            tool_choice: None,
1486            response_format: None,
1487            cache: None,
1488        }
1489    }
1490
1491    #[test]
1492    fn test_oauth_tool_name_collision_detects_case_variants() {
1493        let tools = vec![tool("task"), tool("Task")];
1494        let collision = oauth_tool_name_collision(Some(&tools));
1495        assert!(collision.is_some());
1496    }
1497
1498    #[test]
1499    fn test_oauth_tool_name_collision_allows_distinct_names() {
1500        let tools = vec![tool("read"), tool("write"), tool("Read_File")];
1501        assert!(oauth_tool_name_collision(Some(&tools)).is_none());
1502        assert!(oauth_tool_name_collision(None).is_none());
1503    }
1504
1505    #[tokio::test]
1506    async fn test_oauth_chat_rejects_case_colliding_tools() -> anyhow::Result<()> {
1507        // OAuth provider: the collision is caught before any network call.
1508        let provider = AnthropicProvider::new("sk-ant-oat-test", MODEL_SONNET_45);
1509        assert!(provider.is_oauth());
1510        let request = request_with_tools(vec![tool("task"), tool("Task")]);
1511        let outcome = provider.chat(request).await?;
1512        match outcome {
1513            ChatOutcome::InvalidRequest(msg) => {
1514                assert!(msg.contains("collide case-insensitively"), "got: {msg}");
1515            }
1516            other => panic!("expected InvalidRequest, got {other:?}"),
1517        }
1518        Ok(())
1519    }
1520
1521    #[tokio::test]
1522    async fn test_api_key_chat_does_not_apply_oauth_collision_gate() -> anyhow::Result<()> {
1523        // For plain API-key auth there is no remapping, so case-differing tool
1524        // names are not a collision; the request proceeds (and only fails on the
1525        // network call, which we do not make here — we just confirm the gate is
1526        // OAuth-only by checking is_oauth()).
1527        let provider = AnthropicProvider::new("sk-ant-api-test", MODEL_SONNET_45);
1528        assert!(!provider.is_oauth());
1529        let tools = vec![tool("task"), tool("Task")];
1530        // The gate would only trigger in OAuth mode.
1531        assert!(oauth_tool_name_collision(Some(&tools)).is_some());
1532        Ok(())
1533    }
1534
1535    // ===================
1536    // Interleaved-thinking beta on API-key auth (Fix 3)
1537    // ===================
1538
1539    fn apply_auth_beta_header(provider: &AnthropicProvider) -> anyhow::Result<Option<String>> {
1540        let builder = reqwest::Client::new().post("http://localhost/v1/messages");
1541        let request = provider.apply_auth(builder).build()?;
1542        Ok(request
1543            .headers()
1544            .get("anthropic-beta")
1545            .and_then(|value| value.to_str().ok())
1546            .map(str::to_owned))
1547    }
1548
1549    #[test]
1550    fn api_key_auth_sends_interleaved_beta_for_budget_thinking_models() -> anyhow::Result<()> {
1551        // Sonnet 4.5 / Haiku 4.5 are budget-thinking models: interleaved
1552        // mid-loop thinking only appears when this beta header is present.
1553        for provider in [
1554            AnthropicProvider::sonnet_45("test-key-not-a-secret"),
1555            AnthropicProvider::haiku("test-key-not-a-secret"),
1556        ] {
1557            assert!(!provider.is_oauth());
1558            assert_eq!(
1559                apply_auth_beta_header(&provider)?.as_deref(),
1560                Some("interleaved-thinking-2025-05-14"),
1561                "expected interleaved beta for {}",
1562                provider.model()
1563            );
1564        }
1565        Ok(())
1566    }
1567
1568    #[test]
1569    fn api_key_auth_omits_interleaved_beta_for_adaptive_models() -> anyhow::Result<()> {
1570        // Adaptive-thinking models (4.6+) interleave server-side and treat the
1571        // header as deprecated/ignored, so we keep the cached prefix minimal.
1572        for provider in [
1573            AnthropicProvider::opus_48("test-key-not-a-secret"),
1574            AnthropicProvider::sonnet_5("test-key-not-a-secret"),
1575            AnthropicProvider::fable("test-key-not-a-secret"),
1576        ] {
1577            assert!(!provider.is_oauth());
1578            assert_eq!(
1579                apply_auth_beta_header(&provider)?,
1580                None,
1581                "expected no beta header for adaptive model {}",
1582                provider.model()
1583            );
1584        }
1585        Ok(())
1586    }
1587
1588    // ===================
1589    // Forced-tool ⇒ thinking dropped on the wire (Fix 8)
1590    // ===================
1591
1592    /// Drive a `chat` call against a mock server and return the JSON body the
1593    /// provider actually put on the wire. The mock replies with a body the
1594    /// response parser rejects — we only care about the *request*, which
1595    /// wiremock records regardless of how the outcome is parsed.
1596    async fn captured_request_body(
1597        provider: &AnthropicProvider,
1598        request: ChatRequest,
1599        server: &wiremock::MockServer,
1600    ) -> serde_json::Value {
1601        use wiremock::matchers::{method, path};
1602        use wiremock::{Mock, ResponseTemplate};
1603
1604        Mock::given(method("POST"))
1605            .and(path("/v1/messages"))
1606            .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
1607            .mount(server)
1608            .await;
1609
1610        let _ = provider.chat(request).await;
1611
1612        let received = server
1613            .received_requests()
1614            .await
1615            .expect("mock server records requests");
1616        assert_eq!(received.len(), 1, "expected exactly one request");
1617        serde_json::from_slice(&received[0].body).expect("request body is JSON")
1618    }
1619
1620    #[tokio::test]
1621    async fn forced_tool_drops_configured_thinking_on_the_wire() {
1622        // Regression for Fix 8: a Claude provider built `.with_thinking(...)`
1623        // must NOT emit `thinking` on the wire when the request forces a
1624        // specific tool (the Messages API 400s on that pairing). Clearing
1625        // `ChatRequest.thinking` alone is insufficient — `resolve_thinking_config`
1626        // would resurrect the provider-configured default — so the guard lives
1627        // at the wire boundary.
1628        let server = wiremock::MockServer::start().await;
1629        let provider = AnthropicProvider::sonnet_45("sk-ant-api-test")
1630            .with_thinking(ThinkingConfig::new(10_000))
1631            .with_base_url(server.uri());
1632
1633        let mut request = request_with_tools(vec![tool("respond")]);
1634        request.tool_choice = Some(agent_sdk_foundation::llm::ToolChoice::Tool(
1635            "respond".to_owned(),
1636        ));
1637
1638        let body = captured_request_body(&provider, request, &server).await;
1639
1640        assert!(
1641            body.get("thinking").is_none(),
1642            "thinking must be absent when a tool is forced, got: {body}"
1643        );
1644        assert_eq!(
1645            body["tool_choice"]["type"], "tool",
1646            "the forced tool_choice must survive, got: {body}"
1647        );
1648    }
1649
1650    #[tokio::test]
1651    async fn configured_thinking_survives_without_forced_tool() {
1652        // Guard the other direction: `tool_choice = Auto` (the non-forcing case)
1653        // keeps the provider-configured thinking on the wire, so the Fix 8 guard
1654        // is narrowly scoped to tool forcing and does not regress ordinary
1655        // thinking requests.
1656        let server = wiremock::MockServer::start().await;
1657        let provider = AnthropicProvider::sonnet_45("sk-ant-api-test")
1658            .with_thinking(ThinkingConfig::new(10_000))
1659            .with_base_url(server.uri());
1660
1661        let mut request = request_with_tools(vec![tool("read")]);
1662        request.tool_choice = Some(agent_sdk_foundation::llm::ToolChoice::Auto);
1663
1664        let body = captured_request_body(&provider, request, &server).await;
1665
1666        assert_eq!(
1667            body["thinking"]["type"], "enabled",
1668            "configured thinking must survive when no tool is forced, got: {body}"
1669        );
1670    }
1671}