Skip to main content

mermaid_cli/providers/model/
anthropic.rs

1//! Anthropic provider — wraps `models::adapters::anthropic::AnthropicAdapter`.
2//!
3//! Same pattern as `ollama.rs`: the adapter handles the wire format
4//! (`cache_control` blocks, extended-thinking signature round-trip);
5//! this wrapper plumbs `ChatRequest` / `StreamContext` into it.
6//!
7//! Anthropic is the one provider that emits a `provider_continuation`
8//! that MUST round-trip on the next request. The adapter's
9//! `ModelResponse.provider_continuation` already carries it; we forward
10//! that onto the `FinalResponse` so the reducer can commit it via
11//! `ChatMessage::with_provider_continuation`.
12
13use async_trait::async_trait;
14
15use mermaid_domain::ChatRequest;
16use mermaid_model::models::adapters::anthropic::AnthropicAdapter;
17use mermaid_model::models::{Model, ModelConfig, ModelError, Result};
18
19use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
20use super::{ContextSizing, ModelProvider, RejectionCache, resolve_limits_cached};
21use mermaid_model::models::ModelCapabilities;
22
23/// Anthropic's Messages-API root, and the env var its key lives in.
24///
25/// One definition, next to the provider, like `meta`'s. `ProviderFactory`
26/// builds the endpoint from these and `providers::discovery` reports it from
27/// the same constants — the literal used to be spelled out separately in both.
28pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
29pub const DEFAULT_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
30
31/// Anthropic adapter fronted by `ModelProvider`.
32pub struct AnthropicProvider {
33    adapter: AnthropicAdapter,
34    capabilities: ModelCapabilities,
35    /// Links the adapter's learned rejections to the cross-session cache.
36    rejections: RejectionCache,
37}
38
39impl AnthropicProvider {
40    /// Wrap a fresh [`AnthropicAdapter`] as a `ModelProvider`.
41    ///
42    /// # Errors
43    ///
44    /// Only [`AnthropicAdapter::new`]'s — the HTTP client build. The API is
45    /// not contacted here, so an invalid key or unreachable `base_url` still
46    /// constructs and fails on the first request.
47    pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
48        let adapter = AnthropicAdapter::new(api_key, model_name, base_url)?;
49        let capabilities = adapter.capabilities().clone().with_provider_continuation();
50        Ok(Self {
51            adapter,
52            capabilities,
53            rejections: RejectionCache::default(),
54        })
55    }
56}
57
58#[async_trait]
59impl ModelProvider for AnthropicProvider {
60    fn capabilities(&self) -> &ModelCapabilities {
61        &self.capabilities
62    }
63
64    /// Live limit discovery via Anthropic's Models API (`GET /v1/models/
65    /// {id}` → `max_input_tokens` window + `max_tokens` output ceiling).
66    /// Cache-first via `provider_probes` (TTL-bounded), one live fetch on a
67    /// miss; a fetch failure resolves all-`None` (adapter floors apply).
68    /// Also whether the model takes server-side compaction, which it does
69    /// unless it refused it here or in an earlier session.
70    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
71        let _ = request;
72        let model = Model::name(&self.adapter).to_string();
73        let limits =
74            resolve_limits_cached("anthropic", &model, || self.adapter.fetch_model_limits()).await;
75        let window = limits.as_ref().and_then(|l| l.max_context_tokens);
76        self.rejections
77            .seed("anthropic", &model, self.adapter.param_memory())
78            .await;
79        ContextSizing {
80            model_max: window,
81            effective: window,
82            source: None,
83            max_output: limits.as_ref().and_then(|l| l.max_output_tokens),
84            compacts_natively: self.adapter.compacts_natively(),
85        }
86    }
87
88    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
89        let config = ModelConfig::from(&request);
90        let chat_fut = self
91            .adapter
92            .chat(&request.messages, &config, Some(ctx.sink.clone()));
93
94        // Seed what earlier sessions learned this model rejects, and persist
95        // whatever this turn learns (see `learning`).
96        let learned_model = Model::name(&self.adapter).to_string();
97        let memory = self.adapter.param_memory();
98        self.rejections
99            .seed("anthropic", &learned_model, memory)
100            .await;
101
102        let response = tokio::select! {
103            biased;
104            _ = ctx.token.cancelled() => {
105                return Err(ModelError::Cancelled);
106            },
107            r = chat_fut => {
108                self.rejections.persist("anthropic", &learned_model, memory).await;
109                r?
110            },
111        };
112
113        let usage = response.usage.clone();
114        let provider_continuation = response.provider_continuation.clone();
115        let stop_reason = response.stop_reason.clone();
116        // F3: the wrapper's `Done` is the sole terminal event, and it goes on
117        // the same sink the adapter just finished writing to — so it cannot
118        // overtake a `ToolCall` still in flight. Carrying
119        // `provider_continuation` out of `ModelResponse` here is what lets
120        // multi-turn extended thinking round-trip.
121        let _ = ctx
122            .sink
123            .send(StreamEvent::Done {
124                usage: usage.clone(),
125                provider_continuation: provider_continuation.clone(),
126                stop_reason: stop_reason.clone(),
127            })
128            .await;
129
130        Ok(FinalResponse {
131            usage,
132            provider_continuation,
133            tool_calls: response.tool_calls.unwrap_or_default(),
134            stop_reason,
135        })
136    }
137}