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 crate::domain::ChatRequest;
16use crate::models::adapters::anthropic::AnthropicAdapter;
17use crate::models::{Model, ModelConfig, ModelError, Result};
18
19use super::super::capabilities::Capabilities;
20use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
21use super::{ContextSizing, ModelProvider, resolve_limits_cached};
22
23/// Anthropic adapter fronted by `ModelProvider`.
24pub struct AnthropicProvider {
25    adapter: AnthropicAdapter,
26    capabilities: Capabilities,
27}
28
29impl AnthropicProvider {
30    pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
31        let adapter = AnthropicAdapter::new(api_key, model_name, base_url)?;
32        let capabilities =
33            Capabilities::from_legacy(adapter.capabilities()).with_provider_continuation();
34        Ok(Self {
35            adapter,
36            capabilities,
37        })
38    }
39}
40
41#[async_trait]
42impl ModelProvider for AnthropicProvider {
43    fn capabilities(&self) -> &Capabilities {
44        &self.capabilities
45    }
46
47    /// Live limit discovery via Anthropic's Models API (`GET /v1/models/
48    /// {id}` → `max_input_tokens` window + `max_tokens` output ceiling).
49    /// Cache-first via `provider_probes` (TTL-bounded), one live fetch on a
50    /// miss; a fetch failure resolves all-`None` (adapter floors apply).
51    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
52        let _ = request;
53        let model = Model::name(&self.adapter).to_string();
54        let limits =
55            resolve_limits_cached("anthropic", &model, || self.adapter.fetch_model_limits()).await;
56        let window = limits.as_ref().and_then(|l| l.max_context_tokens);
57        ContextSizing {
58            model_max: window,
59            effective: window,
60            source: None,
61            max_output: limits.as_ref().and_then(|l| l.max_output_tokens),
62        }
63    }
64
65    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
66        let config = build_model_config(&request);
67        // F2: ordered relay — see stream_bridge docs.
68        let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
69        let callback = super::stream_bridge::forward_callback(relay_tx.clone());
70        let chat_fut = self
71            .adapter
72            .chat(&request.messages, &config, Some(callback));
73
74        let response = tokio::select! {
75            biased;
76            _ = ctx.token.cancelled() => {
77                return Err(ModelError::Cancelled);
78            },
79            r = chat_fut => r?,
80        };
81
82        let usage = response.usage.clone();
83        let provider_continuation = response.provider_continuation.clone();
84        let stop_reason = response.stop_reason.clone();
85        // Terminal Done through the ordered relay, then drain (see stream_bridge).
86        let _ = relay_tx.send(StreamEvent::Done {
87            usage: usage.clone(),
88            provider_continuation: provider_continuation.clone(),
89            stop_reason: stop_reason.clone(),
90        });
91        drop(relay_tx);
92        crate::utils::join_logged(relay_handle.take(), "stream_relay").await;
93
94        Ok(FinalResponse {
95            usage,
96            provider_continuation,
97            tool_calls: response.tool_calls.unwrap_or_default(),
98            stop_reason,
99        })
100    }
101}
102
103fn build_model_config(request: &ChatRequest) -> ModelConfig {
104    ModelConfig {
105        model: request.model_id.clone(),
106        temperature: request.temperature,
107        max_tokens: request.max_tokens,
108        reasoning: request.reasoning,
109        system_prompt: Some(request.system_prompt.clone()),
110        dynamic_system_suffix: request.instructions.clone(),
111        tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
112        resolved_context_window: request.resolved_context_window,
113        resolved_max_output: request.resolved_max_output,
114        // The adapter maps this to `output_config.format` (native
115        // structured output); client-side validation stays the final gate.
116        output_schema: request.output_schema.clone(),
117        ..Default::default()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn build_model_config_maps_fields() {
127        let req = ChatRequest {
128            model_id: "anthropic/claude-opus-4-7".to_string(),
129            messages: vec![],
130            system_prompt: "sys".to_string(),
131            instructions: Some("MERMAID.md content".to_string()),
132            reasoning: crate::models::ReasoningLevel::XHigh,
133            temperature: 0.7,
134            max_tokens: 8192,
135            tools: vec![],
136
137            ollama_num_ctx: None,
138            ollama_allow_ram_offload: None,
139            resolved_context_window: None,
140            resolved_max_output: None,
141            output_schema: None,
142            suppress_auto_compact: false,
143            suppressed_builtin_tools: Vec::new(),
144        };
145        let cfg = build_model_config(&req);
146        assert_eq!(cfg.reasoning, crate::models::ReasoningLevel::XHigh);
147        assert_eq!(cfg.max_tokens, 8192);
148        assert_eq!(
149            cfg.dynamic_system_suffix.as_deref(),
150            Some("MERMAID.md content")
151        );
152    }
153}