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