Skip to main content

agent_io/llm/anthropic/
mod.rs

1//! Anthropic Claude Chat Model implementation
2
3mod request;
4mod response;
5mod types;
6
7use async_trait::async_trait;
8use derive_builder::Builder;
9use futures::StreamExt;
10use reqwest::Client;
11use std::time::Duration;
12
13use crate::llm::{
14    BaseChatModel, ChatCompletion, ChatStream, LlmError, Message, ToolChoice, ToolDefinition,
15};
16
17use types::*;
18
19const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
20
21/// Anthropic Chat Model
22#[derive(Builder, Clone)]
23#[builder(pattern = "owned", build_fn(skip))]
24pub struct ChatAnthropic {
25    /// Model identifier
26    #[builder(setter(into))]
27    pub(super) model: String,
28    /// API key
29    pub(super) api_key: String,
30    /// Base URL (for proxies)
31    #[builder(setter(into, strip_option), default = "None")]
32    pub(super) base_url: Option<String>,
33    /// Maximum output tokens
34    #[builder(default = "8192")]
35    pub(super) max_tokens: u64,
36    /// Temperature for sampling
37    #[builder(default = "0.2")]
38    pub(super) temperature: f32,
39    /// Prompt cache beta header
40    #[builder(default = r#"Some("prompt-caching-2024-07-31".to_string())"#)]
41    pub(super) prompt_cache_beta: Option<String>,
42    /// Enable extended thinking
43    #[builder(default = "false")]
44    pub(super) thinking: bool,
45    /// Thinking budget (tokens)
46    #[builder(default = "Some(1024)")]
47    pub(super) thinking_budget: Option<u64>,
48    /// HTTP client
49    #[builder(setter(skip))]
50    pub(super) client: Client,
51    /// Context window
52    #[builder(setter(skip))]
53    pub(super) context_window: u64,
54}
55
56impl ChatAnthropic {
57    /// Create a new Anthropic chat model
58    pub fn new(model: impl Into<String>) -> Result<Self, LlmError> {
59        let api_key = std::env::var("ANTHROPIC_API_KEY")
60            .map_err(|_| LlmError::Config("ANTHROPIC_API_KEY not set".into()))?;
61
62        Self::builder().model(model).api_key(api_key).build()
63    }
64
65    /// Create a builder for configuration
66    pub fn builder() -> ChatAnthropicBuilder {
67        ChatAnthropicBuilder::default()
68    }
69
70    /// Get the API URL
71    fn api_url(&self) -> &str {
72        self.base_url.as_deref().unwrap_or(ANTHROPIC_API_URL)
73    }
74
75    /// Build the HTTP client
76    fn build_client() -> Client {
77        Client::builder()
78            .timeout(Duration::from_secs(120))
79            .build()
80            .expect("Failed to create HTTP client")
81    }
82
83    /// Get context window for model
84    fn get_context_window(_model: &str) -> u64 {
85        // All Claude models have 200k context window
86        200_000
87    }
88
89    /// Check if model supports extended thinking
90    fn supports_thinking(&self) -> bool {
91        let model_lower = self.model.to_lowercase();
92        model_lower.contains("claude-3-7-sonnet")
93            || model_lower.contains("claude-3.7")
94            || model_lower.contains("claude-4")
95    }
96}
97
98impl ChatAnthropicBuilder {
99    pub fn build(&self) -> Result<ChatAnthropic, LlmError> {
100        let model = self
101            .model
102            .clone()
103            .ok_or_else(|| LlmError::Config("model is required".into()))?;
104        let api_key = self
105            .api_key
106            .clone()
107            .ok_or_else(|| LlmError::Config("api_key is required".into()))?;
108
109        Ok(ChatAnthropic {
110            context_window: ChatAnthropic::get_context_window(&model),
111            client: ChatAnthropic::build_client(),
112            model,
113            api_key,
114            base_url: self.base_url.clone().flatten(),
115            max_tokens: self.max_tokens.unwrap_or(8192),
116            temperature: self.temperature.unwrap_or(0.2),
117            prompt_cache_beta: self.prompt_cache_beta.clone().flatten(),
118            thinking: self.thinking.unwrap_or(false),
119            thinking_budget: self.thinking_budget.flatten(),
120        })
121    }
122}
123
124#[async_trait]
125impl BaseChatModel for ChatAnthropic {
126    fn model(&self) -> &str {
127        &self.model
128    }
129
130    fn provider(&self) -> &str {
131        "anthropic"
132    }
133
134    fn context_window(&self) -> Option<u64> {
135        Some(self.context_window)
136    }
137
138    async fn invoke(
139        &self,
140        messages: Vec<Message>,
141        tools: Option<Vec<ToolDefinition>>,
142        tool_choice: Option<ToolChoice>,
143    ) -> Result<ChatCompletion, LlmError> {
144        let request = self.build_request(messages, tools, tool_choice, false)?;
145
146        let mut req = self
147            .client
148            .post(self.api_url())
149            .header("x-api-key", &self.api_key)
150            .header("anthropic-version", "2023-06-01")
151            .header("Content-Type", "application/json");
152
153        if let Some(ref beta) = self.prompt_cache_beta {
154            req = req.header("anthropic-beta", beta.as_str());
155        }
156
157        let response = req.json(&request).send().await?;
158
159        if !response.status().is_success() {
160            let status = response.status();
161            let body = response.text().await.unwrap_or_default();
162            return Err(LlmError::Api(format!(
163                "Anthropic API error ({}): {}",
164                status, body
165            )));
166        }
167
168        let completion: AnthropicResponse = response.json().await?;
169        Ok(self.parse_response(completion))
170    }
171
172    async fn invoke_stream(
173        &self,
174        messages: Vec<Message>,
175        tools: Option<Vec<ToolDefinition>>,
176        tool_choice: Option<ToolChoice>,
177    ) -> Result<ChatStream, LlmError> {
178        let request = self.build_request(messages, tools, tool_choice, true)?;
179
180        let mut req = self
181            .client
182            .post(self.api_url())
183            .header("x-api-key", &self.api_key)
184            .header("anthropic-version", "2023-06-01")
185            .header("Content-Type", "application/json");
186
187        if let Some(ref beta) = self.prompt_cache_beta {
188            req = req.header("anthropic-beta", beta.as_str());
189        }
190
191        let response = req.json(&request).send().await?;
192
193        if !response.status().is_success() {
194            let status = response.status();
195            let body = response.text().await.unwrap_or_default();
196            return Err(LlmError::Api(format!(
197                "Anthropic API error ({}): {}",
198                status, body
199            )));
200        }
201
202        // Parse SSE stream
203        let stream = response.bytes_stream().filter_map(|result| async move {
204            match result {
205                Ok(bytes) => {
206                    let text = String::from_utf8_lossy(&bytes);
207                    Self::parse_sse_event(&text)
208                }
209                Err(e) => Some(Err(LlmError::Stream(e.to_string()))),
210            }
211        });
212
213        Ok(Box::pin(stream))
214    }
215
216    fn supports_vision(&self) -> bool {
217        // All Claude 3+ models support vision
218        true
219    }
220}