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