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 `thinking_signature`
8//! that MUST round-trip on the next request. The adapter's
9//! `ModelResponse.thinking_signature` already carries it; we forward
10//! that onto the `FinalResponse` so the reducer can commit it via
11//! `ChatMessage::with_thinking_signature`.
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::ModelProvider;
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_thinking_signature();
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    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
53        let config = build_model_config(&request);
54        // F2: ordered relay — see stream_bridge docs.
55        let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
56        let callback = forward_callback(relay_tx.clone());
57        let chat_fut = self
58            .adapter
59            .chat(&request.messages, &config, Some(callback));
60
61        let response = tokio::select! {
62            biased;
63            _ = ctx.token.cancelled() => {
64                return Err(ModelError::Cancelled);
65            },
66            r = chat_fut => r?,
67        };
68
69        let usage = response.usage.clone();
70        let thinking_signature = response.thinking_signature.clone();
71        let stop_reason = response.stop_reason.clone();
72        // Terminal Done through the ordered relay, then drain (see stream_bridge).
73        let _ = relay_tx.send(StreamEvent::Done {
74            usage: usage.clone(),
75            thinking_signature: thinking_signature.clone(),
76            stop_reason: stop_reason.clone(),
77        });
78        drop(relay_tx);
79        let _ = relay_handle.await;
80
81        Ok(FinalResponse {
82            usage,
83            thinking_signature,
84            tool_calls: response.tool_calls.unwrap_or_default(),
85            stop_reason,
86        })
87    }
88}
89
90fn build_model_config(request: &ChatRequest) -> ModelConfig {
91    ModelConfig {
92        model: request.model_id.clone(),
93        temperature: request.temperature,
94        max_tokens: request.max_tokens,
95        reasoning: request.reasoning,
96        system_prompt: Some(request.system_prompt.clone()),
97        dynamic_system_suffix: request.instructions.clone(),
98        tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
99        ..Default::default()
100    }
101}
102
103fn forward_callback(sink: tokio::sync::mpsc::UnboundedSender<StreamEvent>) -> StreamCallback {
104    Arc::new(move |event: ModelStreamEvent| {
105        let mapped = match event {
106            ModelStreamEvent::Text(s) => StreamEvent::Text(s),
107            ModelStreamEvent::Reasoning(chunk) => StreamEvent::Reasoning(ReasoningChunk {
108                text: chunk.text,
109                signature: chunk.signature,
110            }),
111            ModelStreamEvent::ToolCall(tc) => StreamEvent::ToolCall(tc),
112            ModelStreamEvent::Done { tokens } => StreamEvent::Done {
113                usage: if tokens > 0 {
114                    Some(crate::models::TokenUsage::provider(0, tokens, tokens))
115                } else {
116                    None
117                },
118                thinking_signature: None,
119                stop_reason: None,
120            },
121        };
122        let _ = sink.send(mapped);
123    })
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn build_model_config_maps_fields() {
132        let req = ChatRequest {
133            model_id: "anthropic/claude-opus-4-7".to_string(),
134            messages: vec![],
135            system_prompt: "sys".to_string(),
136            instructions: Some("MERMAID.md content".to_string()),
137            reasoning: crate::models::ReasoningLevel::XHigh,
138            temperature: 0.7,
139            max_tokens: 8192,
140            tools: vec![],
141
142            ollama_num_ctx: None,
143            ollama_allow_ram_offload: None,
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}