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