Skip to main content

vtcode_llm/providers/anthropic/
provider.rs

1//! Main Anthropic Claude provider implementation
2//!
3//! This is the primary interface for the Anthropic provider, implementing
4//! the LLMProvider and LLMClient traits. It delegates to submodules for:
5//! - Request building (request_builder)
6//! - Response parsing (response_parser)
7//! - Stream decoding (stream_decoder)
8//! - Capability detection (capabilities)
9//! - Validation (validation)
10//! - Header management (headers)
11
12use crate::client::LLMClient;
13use crate::provider::{LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, ToolDefinition};
14use vtcode_config::constants::{models, urls};
15use vtcode_config::core::{AnthropicConfig, AnthropicPromptCacheSettings, ModelConfig, PromptCachingConfig};
16use vtcode_config::{AnthropicSpeed, TimeoutsConfig};
17
18use super::capabilities;
19use super::fallback_retry::{self, RefusalRetryPlan};
20use super::headers;
21use super::request_builder::{self, RequestBuilderContext};
22use super::response_parser;
23use super::stream_decoder;
24use super::validation;
25
26use crate::providers::common::{extract_prompt_cache_settings, resolve_model};
27use crate::providers::error_handling::{format_network_error, format_parse_error, handle_anthropic_http_error};
28use crate::providers::openai::CustomProviderAuthHandle;
29
30use async_trait::async_trait;
31use futures::StreamExt;
32use reqwest::Client as HttpClient;
33use reqwest::StatusCode;
34use serde_json::Value;
35
36const ANTHROPIC_COMPACT_BETA: &str = "compact-2026-01-12";
37const ANTHROPIC_CONTEXT_MANAGEMENT_BETA: &str = "context-management-2025-06-27";
38const ANTHROPIC_ADVISOR_BETA: &str = "advisor-tool-2026-03-01";
39
40/// Whether `base_url` points at the first-party Claude API, the only endpoint
41/// VT Code talks to that accepts server-side refusal fallbacks.
42fn is_first_party_anthropic_endpoint(base_url: &str) -> bool {
43    url::Url::parse(base_url)
44        .ok()
45        .and_then(|url| url.host_str().map(|host| host.eq_ignore_ascii_case("api.anthropic.com")))
46        .unwrap_or(false)
47}
48
49#[derive(Clone)]
50pub struct AnthropicProvider {
51    api_key: String,
52    http_client: HttpClient,
53    base_url: String,
54    model: String,
55    prompt_cache_enabled: bool,
56    prompt_cache_settings: AnthropicPromptCacheSettings,
57    anthropic_config: AnthropicConfig,
58    custom_provider_auth: Option<CustomProviderAuthHandle>,
59    custom_provider: bool,
60    model_behavior: Option<ModelConfig>,
61    session_affinity_header: Option<&'static str>,
62}
63
64impl AnthropicProvider {
65    pub fn new(api_key: String) -> Self {
66        Self::with_model_internal(
67            api_key,
68            models::anthropic::DEFAULT_MODEL.to_string(),
69            None,
70            None,
71            AnthropicConfig::default(),
72            TimeoutsConfig::default(),
73            None,
74        )
75    }
76
77    fn with_model(api_key: String, model: String) -> Self {
78        Self::with_model_internal(
79            api_key,
80            model,
81            None,
82            None,
83            AnthropicConfig::default(),
84            TimeoutsConfig::default(),
85            None,
86        )
87    }
88
89    pub(crate) fn new_with_client(
90        api_key: String,
91        model: String,
92        http_client: reqwest::Client,
93        base_url: String,
94        _timeouts: TimeoutsConfig,
95    ) -> Self {
96        Self {
97            api_key,
98            http_client,
99            base_url,
100            model,
101            prompt_cache_enabled: false,
102            prompt_cache_settings: AnthropicPromptCacheSettings::default(),
103            anthropic_config: AnthropicConfig::default(),
104            custom_provider_auth: None,
105            custom_provider: false,
106            model_behavior: None,
107            session_affinity_header: None,
108        }
109    }
110
111    pub fn from_config(
112        api_key: Option<String>,
113        model: Option<String>,
114        base_url: Option<String>,
115        prompt_cache: Option<PromptCachingConfig>,
116        timeouts: Option<TimeoutsConfig>,
117        anthropic_config: Option<AnthropicConfig>,
118        model_behavior: Option<ModelConfig>,
119    ) -> Self {
120        let api_key_value = api_key.unwrap_or_default();
121        let model_value = resolve_model(model, models::anthropic::DEFAULT_MODEL);
122        let anthropic_cfg = anthropic_config.unwrap_or_default();
123
124        Self::with_model_internal(
125            api_key_value,
126            model_value,
127            prompt_cache,
128            base_url,
129            anthropic_cfg,
130            timeouts.unwrap_or_default(),
131            model_behavior,
132        )
133    }
134
135    fn with_model_internal(
136        api_key: String,
137        model: String,
138        prompt_cache: Option<PromptCachingConfig>,
139        base_url: Option<String>,
140        anthropic_config: AnthropicConfig,
141        timeouts: TimeoutsConfig,
142        model_behavior: Option<ModelConfig>,
143    ) -> Self {
144        use crate::http_client::HttpClientFactory;
145
146        let (prompt_cache_enabled, prompt_cache_settings) = extract_prompt_cache_settings(
147            prompt_cache,
148            |providers| &providers.anthropic,
149            |cfg, provider_settings| cfg.enabled && provider_settings.enabled,
150        );
151
152        let base_url_value = if models::minimax::SUPPORTED_MODELS.contains(&model.as_str()) {
153            crate::providers::minimax::resolve_minimax_base_url(base_url)
154        } else {
155            AnthropicSpeed::resolve_base_url(base_url.as_deref())
156        };
157
158        Self {
159            api_key,
160            http_client: HttpClientFactory::for_llm(&timeouts),
161            base_url: base_url_value,
162            model,
163            prompt_cache_enabled,
164            prompt_cache_settings,
165            anthropic_config,
166            custom_provider_auth: None,
167            custom_provider: false,
168            model_behavior,
169            session_affinity_header: None,
170        }
171    }
172
173    pub(crate) fn with_custom_auth(mut self, custom_provider_auth: Option<CustomProviderAuthHandle>) -> Self {
174        self.custom_provider_auth = custom_provider_auth;
175        self.custom_provider = true;
176        self
177    }
178
179    /// Attach a session-affinity header (e.g. OpenCode Go's
180    /// `x-opencode-session`) carrying the request's session lineage. Used by
181    /// dispatchers that route Messages-API traffic through this provider.
182    pub(crate) fn with_session_affinity_header(mut self, header: &'static str) -> Self {
183        self.session_affinity_header = Some(header);
184        self
185    }
186
187    fn requires_advanced_tool_use_beta(&self, request: &LLMRequest) -> bool {
188        request.tools.as_ref().is_some_and(|tools| {
189            tools.iter().any(|tool| {
190                (tool.is_tool_search() || tool.defer_loading.unwrap_or(false))
191                    || tool.allowed_callers.as_ref().is_some_and(|callers| !callers.is_empty())
192                    || tool.input_examples.as_ref().is_some_and(|examples| !examples.is_empty())
193            })
194        })
195    }
196
197    fn code_execution_betas(&self, request: &LLMRequest) -> Vec<String> {
198        request
199            .tools
200            .as_ref()
201            .map(|tools| {
202                tools
203                    .iter()
204                    .filter_map(|tool| {
205                        tool.is_anthropic_code_execution()
206                            .then(|| code_execution_beta_name(&tool.tool_type))
207                            .flatten()
208                    })
209                    .fold(Vec::new(), |mut betas, beta| {
210                        if !betas.contains(&beta) {
211                            betas.push(beta);
212                        }
213                        betas
214                    })
215            })
216            .unwrap_or_default()
217    }
218
219    fn context_management_betas(&self, request: &LLMRequest) -> Vec<&'static str> {
220        let mut betas = Vec::new();
221
222        if request
223            .tools
224            .as_ref()
225            .is_some_and(|tools| tools.iter().any(ToolDefinition::is_anthropic_memory_tool))
226        {
227            betas.push(ANTHROPIC_CONTEXT_MANAGEMENT_BETA);
228        }
229
230        if let Some(context_management) = request.context_management.as_ref() {
231            if uses_anthropic_compaction(context_management) {
232                betas.push(ANTHROPIC_COMPACT_BETA);
233            }
234
235            if uses_anthropic_context_edits(context_management) && !betas.contains(&ANTHROPIC_CONTEXT_MANAGEMENT_BETA) {
236                betas.push(ANTHROPIC_CONTEXT_MANAGEMENT_BETA);
237            }
238        }
239
240        betas
241    }
242
243    /// Whether the advisor server-side tool should be sent for this request.
244    ///
245    /// Delegates to the request builder's `resolve_advisor_tool` so the beta
246    /// header and the injected tool are gated by the exact same logic.
247    fn advisor_enabled_for_request(&self, request: &LLMRequest) -> bool {
248        let executor = capabilities::resolve_model_name(&request.model, &self.model);
249        request_builder::resolve_advisor_tool(executor, &self.anthropic_config.advisor).is_some()
250    }
251
252    fn uses_refreshable_auth(&self) -> bool {
253        self.custom_provider_auth.is_some()
254    }
255
256    async fn current_api_key(&self) -> Result<String, LLMError> {
257        if let Some(handle) = &self.custom_provider_auth {
258            return handle
259                .current_token()
260                .await
261                .map_err(|error| format_network_error("Anthropic", &error));
262        }
263
264        Ok(self.api_key.clone())
265    }
266
267    async fn refresh_api_key_for_retry(&self) -> Result<String, LLMError> {
268        if let Some(handle) = &self.custom_provider_auth {
269            return handle
270                .force_refresh()
271                .await
272                .map_err(|error| format_network_error("Anthropic", &error));
273        }
274
275        Ok(self.api_key.clone())
276    }
277
278    /// Prepends a non-disclosure reminder to the system prompt. No supported
279    /// Claude model accepts an assistant-message prefill, so the system prompt
280    /// is the only valid place for this instruction.
281    pub fn with_leak_protection(&self, mut request: LLMRequest, secret_description: &str) -> LLMRequest {
282        let reminder = format!("[Never mention or reveal {secret_description}]");
283        let merged_system_prompt = match request.system_prompt.as_ref() {
284            Some(existing) => format!("{reminder}\n\n{existing}"),
285            None => reminder,
286        };
287        request.system_prompt = Some(std::sync::Arc::from(merged_system_prompt));
288        request
289    }
290
291    pub fn format_documents_xml(&self, documents: Vec<(&str, &str)>) -> String {
292        let mut xml = String::from("<documents>\n");
293        for (i, (source, content)) in documents.iter().enumerate() {
294            xml.push_str(&format!(
295                "  <document index=\"{}\">\n    <source>{}</source>\n    <document_content>\n{}\n    </document_content>\n  </document>\n",
296                i + 1,
297                source,
298                content
299            ));
300        }
301        xml.push_str("</documents>");
302        xml
303    }
304
305    pub fn extract_xml_block(&self, content: &str, tag: &str) -> Option<String> {
306        let start_tag = format!("<{tag}>");
307        let end_tag = format!("</{tag}>");
308
309        let start_pos = content.find(&start_tag)? + start_tag.len();
310        let end_pos = content.find(&end_tag)?;
311
312        if start_pos < end_pos {
313            Some(content[start_pos..end_pos].trim().to_string())
314        } else {
315            None
316        }
317    }
318
319    fn request_builder_context(&self) -> RequestBuilderContext<'_> {
320        RequestBuilderContext {
321            prompt_cache_enabled: self.prompt_cache_enabled,
322            prompt_cache_settings: &self.prompt_cache_settings,
323            anthropic_config: &self.anthropic_config,
324            model: &self.model,
325            server_side_fallbacks_available: is_first_party_anthropic_endpoint(&self.base_url),
326        }
327    }
328
329    fn resolved_request_model<'a>(&'a self, request: &'a LLMRequest) -> &'a str {
330        capabilities::resolve_model_name(&request.model, &self.model)
331    }
332
333    fn effective_betas(&self, request: &LLMRequest) -> Option<Vec<String>> {
334        let mut betas = request.betas.clone().unwrap_or_default();
335        for beta in self.context_management_betas(request) {
336            if !betas.iter().any(|existing| existing == beta) {
337                betas.push(beta.to_string());
338            }
339        }
340        for beta in self.code_execution_betas(request) {
341            if !betas.iter().any(|existing| existing == &beta) {
342                betas.push(beta);
343            }
344        }
345        if self.advisor_enabled_for_request(request) && !betas.iter().any(|beta| beta == ANTHROPIC_ADVISOR_BETA) {
346            betas.push(ANTHROPIC_ADVISOR_BETA.to_string());
347        }
348
349        (!betas.is_empty()).then_some(betas)
350    }
351
352    fn convert_to_anthropic_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
353        let mut body = request_builder::convert_to_anthropic_format(request, &self.request_builder_context())?;
354        super::speed::apply_speed(&mut body, self.resolve_speed(request)?);
355        Ok(body)
356    }
357
358    fn resolve_speed(&self, request: &LLMRequest) -> Result<Option<AnthropicSpeed>, LLMError> {
359        super::speed::resolve_speed(
360            request,
361            self.anthropic_config.speed,
362            self.resolved_request_model(request),
363            !self.custom_provider && AnthropicSpeed::is_native_endpoint(&self.base_url),
364        )
365    }
366
367    fn beta_header_for_request(
368        &self,
369        request: &LLMRequest,
370        anthropic_request: &Value,
371        include_advanced_tool_use: bool,
372        request_betas: Option<&[String]>,
373    ) -> Option<String> {
374        let server_side_fallback = headers::ServerSideFallbackForm::of_request(anthropic_request);
375        let beta_config = headers::BetaHeaderConfig {
376            config: &self.anthropic_config,
377            model: self.resolved_request_model(request),
378            include_advanced_tool_use,
379            include_manual_interleaved_beta: anthropic_request
380                .get("thinking")
381                .and_then(|value| value.get("type"))
382                .and_then(Value::as_str)
383                == Some("enabled"),
384            request_betas,
385            include_fast_mode: anthropic_request.get("speed").is_some()
386                && AnthropicSpeed::requires_beta(self.resolved_request_model(request)),
387            include_task_budget: anthropic_request
388                .get("output_config")
389                .and_then(|value| value.get("task_budget"))
390                .is_some(),
391            server_side_fallback,
392            // The credit beta must accompany the original request for a
393            // refusal to carry `fallback_credit_token`. The default-form
394            // fallback beta already grants those fields; the list form does
395            // not, so it needs the credit beta alongside it.
396            include_fallback_credit: request.fallback_credit_token.is_some()
397                || server_side_fallback == Some(headers::ServerSideFallbackForm::List),
398            include_mid_conversation_tool_changes: false,
399            include_mid_conversation_system_clear_at: capabilities::supports_turn_scoped_system_messages(
400                self.resolved_request_model(request),
401                &self.model,
402            ) && anthropic_request
403                .get("messages")
404                .and_then(Value::as_array)
405                .is_some_and(|messages| {
406                    messages
407                        .iter()
408                        .any(|message| message.get("clear_at").and_then(Value::as_str) == Some("next_user_message"))
409                }),
410            // The primary config or any server-side fallback entry may carry
411            // `display: "updates"`; either needs the beta.
412            include_thinking_display_updates: std::iter::once(anthropic_request.get("thinking"))
413                .chain(
414                    anthropic_request
415                        .get("fallbacks")
416                        .and_then(Value::as_array)
417                        .into_iter()
418                        .flatten()
419                        .map(|fallback| fallback.get("thinking")),
420                )
421                .flatten()
422                .any(|thinking| thinking.get("display").and_then(Value::as_str) == Some("updates")),
423            // A budget-continuation profile promotes the messages breakpoint to
424            // the profile TTL, so the beta decision must see the same profile
425            // the request builder used.
426            prompt_cache_profile: request.prompt_cache_profile,
427        };
428
429        headers::combined_beta_header_value(self.prompt_cache_enabled, &self.prompt_cache_settings, &beta_config)
430    }
431
432    async fn send_request(
433        &self,
434        request: &LLMRequest,
435        anthropic_request: &Value,
436    ) -> Result<AnthropicHttpResponse, LLMError> {
437        let include_advanced_tool_use = self.requires_advanced_tool_use_beta(request);
438        let betas = self.effective_betas(request);
439        let url = format!("{}/messages", self.base_url.trim_end_matches('/'));
440
441        let beta_header =
442            self.beta_header_for_request(request, anthropic_request, include_advanced_tool_use, betas.as_deref());
443        let metadata = request.metadata.clone();
444        let session_affinity = self.session_affinity_header.and_then(|header| {
445            crate::providers::shared::session_lineage_from_prompt_cache_key(request.prompt_cache_key.as_deref())
446                .map(|lineage| (header, lineage))
447        });
448
449        let send_once = |api_key: String| {
450            let mut request_builder = self
451                .http_client
452                .post(&url)
453                .header("x-api-key", api_key)
454                .header("anthropic-version", urls::ANTHROPIC_API_VERSION);
455
456            if let Some(beta_header) = beta_header.clone() {
457                request_builder = request_builder.header("anthropic-beta", beta_header);
458            }
459
460            if let Some((header, lineage)) = session_affinity.clone() {
461                request_builder = request_builder.header(header, lineage);
462            }
463
464            if let Some(metadata) = metadata.as_ref()
465                && let Ok(metadata_str) = serde_json::to_string(metadata)
466            {
467                request_builder = request_builder.header("X-Turn-Metadata", metadata_str);
468            }
469
470            request_builder.json(anthropic_request)
471        };
472
473        let response = send_once(self.current_api_key().await?)
474            .send()
475            .await
476            .map_err(|e| format_network_error("Anthropic", &e))?;
477
478        let response = if self.uses_refreshable_auth()
479            && matches!(response.status(), StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
480        {
481            send_once(self.refresh_api_key_for_retry().await?)
482                .send()
483                .await
484                .map_err(|e| format_network_error("Anthropic", &e))?
485        } else {
486            response
487        };
488
489        let response = handle_anthropic_http_error(response).await?;
490
491        let request_id = response
492            .headers()
493            .get("request-id")
494            .and_then(|h| h.to_str().ok().map(|s| s.to_string()));
495        let organization_id = response
496            .headers()
497            .get("anthropic-organization-id")
498            .and_then(|h| h.to_str().ok().map(|s| s.to_string()));
499
500        Ok(AnthropicHttpResponse { response, request_id, organization_id })
501    }
502}
503
504fn code_execution_beta_name(tool_type: &str) -> Option<String> {
505    let suffix = tool_type.strip_prefix("code_execution_")?;
506    if suffix.len() != 8 || !suffix.chars().all(|ch| ch.is_ascii_digit()) {
507        return None;
508    }
509
510    Some(format!("code-execution-{}-{}-{}", &suffix[0..4], &suffix[4..6], &suffix[6..8]))
511}
512
513fn uses_anthropic_compaction(context_management: &Value) -> bool {
514    context_management
515        .as_array()
516        .is_some_and(|items| items.iter().any(is_compaction_item))
517        || context_management
518            .get("edits")
519            .and_then(Value::as_array)
520            .is_some_and(|edits| edits.iter().any(is_compaction_edit_item))
521}
522
523fn is_compaction_item(item: &Value) -> bool {
524    item.get("type").and_then(Value::as_str) == Some("compaction")
525}
526
527fn is_compaction_edit_item(item: &Value) -> bool {
528    item.get("type")
529        .and_then(Value::as_str)
530        .is_some_and(|edit_type| edit_type.starts_with("compact_"))
531}
532
533fn uses_anthropic_context_edits(context_management: &Value) -> bool {
534    context_management
535        .get("edits")
536        .and_then(Value::as_array)
537        .is_some_and(|edits| edits.iter().any(is_context_edit_item))
538}
539
540fn is_context_edit_item(item: &Value) -> bool {
541    item.get("type")
542        .and_then(Value::as_str)
543        .is_some_and(|edit_type| edit_type.starts_with("clear_tool_uses_") || edit_type.starts_with("clear_thinking_"))
544}
545
546struct AnthropicHttpResponse {
547    response: reqwest::Response,
548    request_id: Option<String>,
549    organization_id: Option<String>,
550}
551
552impl AnthropicProvider {
553    async fn generate_with_body(
554        &self,
555        request: &LLMRequest,
556        anthropic_request: &Value,
557        model: String,
558    ) -> Result<LLMResponse, LLMError> {
559        let AnthropicHttpResponse { response, request_id, organization_id } =
560            self.send_request(request, anthropic_request).await?;
561
562        let anthropic_response: Value = response.json().await.map_err(|e| format_parse_error("Anthropic", &e))?;
563
564        let mut llm_response = response_parser::parse_response(anthropic_response, model)?;
565        llm_response.request_id = request_id;
566        llm_response.organization_id = organization_id;
567        Ok(llm_response)
568    }
569
570    /// Sends the one bounded refusal retry described in `fallback_retry`. A
571    /// 400 naming the credit token is resent once without it; any other
572    /// failure is logged and reported as `None` so the caller keeps the
573    /// original refusal.
574    async fn send_refusal_retry(
575        &self,
576        request: &LLMRequest,
577        original_body: &Value,
578        plan: &RefusalRetryPlan,
579    ) -> Option<AnthropicHttpResponse> {
580        let fallback_retry::RefusalRetryBody { mut body, with_token } = plan.retry_body(original_body, &self.model);
581        let retry_request = plan.retry_request(request, with_token);
582        let speed = match self.resolve_speed(&retry_request) {
583            Ok(speed) => speed,
584            Err(err) => {
585                tracing::warn!(provider = "anthropic", error = %err, "refusal retry has incompatible requested speed");
586                return None;
587            }
588        };
589        super::speed::apply_speed(&mut body, speed);
590        tracing::info!(
591            provider = "anthropic",
592            recommended_model = %plan.model,
593            with_credit_token = with_token,
594            "refusal fallback could not run server-side; retrying once on the recommended model"
595        );
596        match self.send_request(&plan.retry_request(request, with_token), &body).await {
597            Ok(response) => Some(response),
598            Err(err) if with_token && fallback_retry::rejects_credit_token(&err) => {
599                tracing::warn!(
600                    provider = "anthropic",
601                    error = %err,
602                    "fallback credit token rejected; resending the refusal retry without it"
603                );
604                fallback_retry::strip_credit_token(&mut body);
605                match self.send_request(&plan.retry_request(request, false), &body).await {
606                    Ok(response) => Some(response),
607                    Err(err) => {
608                        tracing::warn!(provider = "anthropic", error = %err, "refusal retry failed");
609                        None
610                    }
611                }
612            }
613            Err(err) => {
614                tracing::warn!(provider = "anthropic", error = %err, "refusal retry failed");
615                None
616            }
617        }
618    }
619
620    async fn retry_refused_generate(
621        &self,
622        request: &LLMRequest,
623        original_body: &Value,
624        plan: &RefusalRetryPlan,
625        refused: LLMResponse,
626    ) -> Result<LLMResponse, LLMError> {
627        let Some(AnthropicHttpResponse { response, request_id, organization_id }) =
628            self.send_refusal_retry(request, original_body, plan).await
629        else {
630            return Ok(refused);
631        };
632        let parsed = match response.json::<Value>().await {
633            Ok(value) => response_parser::parse_response(value, plan.model.clone()),
634            Err(err) => Err(format_parse_error("Anthropic", &err)),
635        };
636        match parsed {
637            Ok(mut retried) => {
638                retried.request_id = request_id;
639                retried.organization_id = organization_id;
640                Ok(retried)
641            }
642            Err(err) => {
643                tracing::warn!(provider = "anthropic", error = %err, "refusal retry response could not be parsed");
644                Ok(refused)
645            }
646        }
647    }
648
649    /// Wraps a primary stream so a pre-output refusal eligible for the
650    /// bounded retry is replaced by the retry's stream. Every other event,
651    /// including a refusal that is not retried, passes through unchanged.
652    fn stream_with_refusal_retry(
653        &self,
654        primary: LLMStream,
655        request: LLMRequest,
656        original_body: Value,
657        model: String,
658    ) -> LLMStream {
659        let provider = self.clone();
660        Box::pin(async_stream::stream! {
661            let mut primary = primary;
662            while let Some(event) = primary.next().await {
663                let response = match event {
664                    Ok(LLMStreamEvent::Completed { response }) => response,
665                    other => {
666                        yield other;
667                        continue;
668                    }
669                };
670                let retry = match RefusalRetryPlan::for_response(&response, &model) {
671                    Some(plan) => provider
672                        .send_refusal_retry(&request, &original_body, &plan)
673                        .await
674                        .map(|http| (plan, http)),
675                    None => None,
676                };
677                match retry {
678                    Some((plan, AnthropicHttpResponse { response: http, request_id, organization_id })) => {
679                        let mut retried =
680                            stream_decoder::create_stream(http, plan.model, request_id, organization_id);
681                        while let Some(event) = retried.next().await {
682                            yield event;
683                        }
684                    }
685                    None => yield Ok(LLMStreamEvent::Completed { response }),
686                }
687            }
688        })
689    }
690}
691
692#[async_trait]
693impl LLMProvider for AnthropicProvider {
694    fn name(&self) -> &str {
695        "anthropic"
696    }
697
698    fn supports_streaming(&self) -> bool {
699        true
700    }
701
702    fn supports_non_streaming(&self, _model: &str) -> bool {
703        // Pinned so the stream-timeout fallback cannot silently regress.
704        true
705    }
706
707    fn supports_reasoning(&self, model: &str) -> bool {
708        // Codex-inspired robustness: Setting model_supports_reasoning to false
709        // does NOT disable it for known reasoning models.
710        capabilities::supports_reasoning(model, &self.model)
711            || self
712                .model_behavior
713                .as_ref()
714                .and_then(|b| b.model_supports_reasoning)
715                .unwrap_or(false)
716    }
717
718    fn supported_reasoning_efforts(&self, model: &str) -> &'static [&'static str] {
719        capabilities::allowed_efforts_for_model(model, &self.model).unwrap_or_else(|| {
720            self.model_behavior
721                .as_ref()
722                .and_then(|behavior| behavior.model_supports_reasoning_effort)
723                .filter(|supported| *supported)
724                .map(|_| crate::provider::GENERIC_REASONING_EFFORTS)
725                .unwrap_or(&[])
726        })
727    }
728
729    fn supports_reasoning_effort(&self, model: &str) -> bool {
730        // Same robustness logic for reasoning effort
731        capabilities::supports_reasoning_effort(model, &self.model)
732            || self
733                .model_behavior
734                .as_ref()
735                .and_then(|b| b.model_supports_reasoning_effort)
736                .unwrap_or(false)
737    }
738
739    fn supports_parallel_tool_config(&self, model: &str) -> bool {
740        capabilities::supports_parallel_tool_config(model)
741    }
742
743    fn supports_context_edits(&self, _model: &str) -> bool {
744        true
745    }
746
747    fn supports_turn_scoped_system_messages(&self, model: &str) -> bool {
748        capabilities::supports_turn_scoped_system_messages(model, &self.model)
749    }
750
751    fn supports_responses_compaction(&self, model: &str) -> bool {
752        // Anthropic server-side compaction is supported on Claude Opus 4.x
753        // and Sonnet 4.6+ models via context_management.edits.
754        capabilities::supports_compaction(model)
755    }
756
757    fn supports_native_inline_compaction(&self, model: &str) -> bool {
758        // Anthropic drives compaction inline via the `compact_20260112`
759        // context-management edit on a `generate` request, so it is the
760        // `NativeInline` strategy provider.
761        capabilities::supports_compaction(model)
762    }
763
764    fn effective_context_size(&self, model: &str) -> usize {
765        capabilities::effective_context_size(model)
766    }
767
768    fn supports_structured_output(&self, model: &str) -> bool {
769        capabilities::supports_structured_output(model, &self.model)
770    }
771
772    fn supports_vision(&self, model: &str) -> bool {
773        capabilities::supports_vision(model, &self.model)
774    }
775
776    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
777        let resolved_model = self.resolved_request_model(&request).to_string();
778        let anthropic_request = self.convert_to_anthropic_format(&request)?;
779
780        let response = self
781            .generate_with_body(&request, &anthropic_request, resolved_model.clone())
782            .await?;
783        match RefusalRetryPlan::for_response(&response, &resolved_model) {
784            Some(plan) => self.retry_refused_generate(&request, &anthropic_request, &plan, response).await,
785            None => Ok(response),
786        }
787    }
788
789    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
790        let resolved_model = self.resolved_request_model(&request).to_string();
791        let mut anthropic_request = self.convert_to_anthropic_format(&request)?;
792
793        if let Some(obj) = anthropic_request.as_object_mut() {
794            obj.insert("stream".to_string(), Value::Bool(true));
795        }
796
797        let AnthropicHttpResponse { response, request_id, organization_id } =
798            self.send_request(&request, &anthropic_request).await?;
799
800        let primary = stream_decoder::create_stream(response, resolved_model.clone(), request_id, organization_id);
801        Ok(self.stream_with_refusal_retry(primary, request, anthropic_request, resolved_model))
802    }
803
804    fn supported_models(&self) -> Vec<String> {
805        capabilities::supported_models()
806    }
807
808    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
809        self.resolve_speed(request)?;
810        validation::validate_request(request, &self.model, &self.anthropic_config, "Anthropic")
811    }
812}
813
814#[async_trait]
815impl LLMClient for AnthropicProvider {
816    async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
817        let request = crate::providers::common::make_default_request(prompt, &self.model);
818        let request_model = request.model.clone();
819        let response = LLMProvider::generate(self, request).await?;
820
821        Ok(LLMResponse {
822            content: Some(response.content.unwrap_or_default()),
823            model: request_model,
824            usage: response.usage.map(crate::providers::common::convert_usage_to_llm_types),
825            reasoning: response.reasoning,
826            reasoning_details: response.reasoning_details,
827            request_id: response.request_id,
828            organization_id: response.organization_id,
829            finish_reason: response.finish_reason,
830            tool_calls: response.tool_calls,
831            tool_references: response.tool_references,
832            compaction: response.compaction,
833        })
834    }
835
836    fn model_id(&self) -> &str {
837        &self.model
838    }
839}
840
841#[cfg(test)]
842mod tests;