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