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, ToolDefinition};
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, 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}
36
37impl AnthropicProvider {
38    /// Wrap a fresh [`AnthropicAdapter`] as a `ModelProvider`.
39    ///
40    /// # Errors
41    ///
42    /// Only [`AnthropicAdapter::new`]'s — the HTTP client build. The API is
43    /// not contacted here, so an invalid key or unreachable `base_url` still
44    /// constructs and fails on the first request.
45    pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
46        let adapter = AnthropicAdapter::new(api_key, model_name, base_url)?;
47        let capabilities = adapter.capabilities().clone().with_provider_continuation();
48        Ok(Self {
49            adapter,
50            capabilities,
51        })
52    }
53}
54
55#[async_trait]
56impl ModelProvider for AnthropicProvider {
57    fn capabilities(&self) -> &ModelCapabilities {
58        &self.capabilities
59    }
60
61    /// Live limit discovery via Anthropic's Models API (`GET /v1/models/
62    /// {id}` → `max_input_tokens` window + `max_tokens` output ceiling).
63    /// Cache-first via `provider_probes` (TTL-bounded), one live fetch on a
64    /// miss; a fetch failure resolves all-`None` (adapter floors apply).
65    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
66        let _ = request;
67        let model = Model::name(&self.adapter).to_string();
68        let limits =
69            resolve_limits_cached("anthropic", &model, || self.adapter.fetch_model_limits()).await;
70        let window = limits.as_ref().and_then(|l| l.max_context_tokens);
71        ContextSizing {
72            model_max: window,
73            effective: window,
74            source: None,
75            max_output: limits.as_ref().and_then(|l| l.max_output_tokens),
76        }
77    }
78
79    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
80        let config = build_model_config(&request);
81        let chat_fut = self
82            .adapter
83            .chat(&request.messages, &config, Some(ctx.sink.clone()));
84
85        let response = tokio::select! {
86            biased;
87            _ = ctx.token.cancelled() => {
88                return Err(ModelError::Cancelled);
89            },
90            r = chat_fut => r?,
91        };
92
93        let usage = response.usage.clone();
94        let provider_continuation = response.provider_continuation.clone();
95        let stop_reason = response.stop_reason.clone();
96        // F3: the wrapper's `Done` is the sole terminal event, and it goes on
97        // the same sink the adapter just finished writing to — so it cannot
98        // overtake a `ToolCall` still in flight. Carrying
99        // `provider_continuation` out of `ModelResponse` here is what lets
100        // multi-turn extended thinking round-trip.
101        let _ = ctx
102            .sink
103            .send(StreamEvent::Done {
104                usage: usage.clone(),
105                provider_continuation: provider_continuation.clone(),
106                stop_reason: stop_reason.clone(),
107            })
108            .await;
109
110        Ok(FinalResponse {
111            usage,
112            provider_continuation,
113            tool_calls: response.tool_calls.unwrap_or_default(),
114            stop_reason,
115        })
116    }
117}
118
119fn build_model_config(request: &ChatRequest) -> ModelConfig {
120    ModelConfig {
121        model: request.model_id.clone(),
122        temperature: request.temperature,
123        max_tokens: request.max_tokens,
124        reasoning: request.reasoning,
125        system_prompt: Some(request.system_prompt.clone()),
126        dynamic_system_suffix: request.instructions.clone(),
127        tools: request
128            .tools
129            .iter()
130            .map(ToolDefinition::to_openai_json)
131            .collect(),
132        resolved_context_window: request.resolved_context_window,
133        resolved_max_output: request.resolved_max_output,
134        // The adapter maps this to `output_config.format` (native
135        // structured output); client-side validation stays the final gate.
136        output_schema: request.output_schema.clone(),
137        ..Default::default()
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn build_model_config_maps_fields() {
147        let req = ChatRequest {
148            model_id: "anthropic/claude-opus-4-7".to_string(),
149            messages: vec![],
150            system_prompt: "sys".to_string(),
151            instructions: Some("MERMAID.md content".to_string()),
152            reasoning: mermaid_model::models::ReasoningLevel::XHigh,
153            temperature: 0.7,
154            max_tokens: 8192,
155            tools: vec![],
156
157            ollama_num_ctx: None,
158            ollama_allow_ram_offload: None,
159            resolved_context_window: None,
160            resolved_max_output: None,
161            output_schema: None,
162            suppress_auto_compact: false,
163            suppressed_builtin_tools: Vec::new(),
164        };
165        let cfg = build_model_config(&req);
166        assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::XHigh);
167        assert_eq!(cfg.max_tokens, 8192);
168        assert_eq!(
169            cfg.dynamic_system_suffix.as_deref(),
170            Some("MERMAID.md content")
171        );
172    }
173}