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