Skip to main content

noether_engine/llm/
anthropic.rs

1//! Anthropic API provider.
2//!
3//! Calls `api.anthropic.com/v1/messages` directly.
4//! Auth: `ANTHROPIC_API_KEY` environment variable.
5//!
6//! Note: Anthropic does not offer an embeddings API, so this module
7//! only provides an LLM provider.
8
9use crate::llm::{LlmConfig, LlmError, LlmProvider, Message, Role};
10use serde_json::{json, Value};
11
12const ANTHROPIC_API_BASE: &str = "https://api.anthropic.com/v1";
13const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
14const ANTHROPIC_VERSION: &str = "2023-06-01";
15
16// ── LLM provider ────────────────────────────────────────────────────────────
17
18/// Calls `api.anthropic.com/v1/messages` with an API key.
19///
20/// Supports all Anthropic Claude models:
21/// - `claude-sonnet-4-20250514` — balanced (default)
22/// - `claude-opus-4-20250514` — most capable
23/// - `claude-haiku-3-20250414` — fastest, cheapest
24///
25/// Set `ANTHROPIC_API_KEY` to your API key from console.anthropic.com.
26/// Override model with `ANTHROPIC_MODEL`.
27pub struct AnthropicProvider {
28    api_key: String,
29    client: reqwest::blocking::Client,
30}
31
32impl AnthropicProvider {
33    pub fn new(api_key: impl Into<String>) -> Self {
34        let client = reqwest::blocking::Client::builder()
35            .timeout(std::time::Duration::from_secs(120))
36            .connect_timeout(std::time::Duration::from_secs(15))
37            .build()
38            .expect("failed to build reqwest client");
39        Self {
40            api_key: api_key.into(),
41            client,
42        }
43    }
44
45    /// Construct from environment. Returns `Err` if `ANTHROPIC_API_KEY` is not set.
46    pub fn from_env() -> Result<Self, String> {
47        let key = std::env::var("ANTHROPIC_API_KEY")
48            .map_err(|_| "ANTHROPIC_API_KEY is not set".to_string())?;
49        Ok(Self::new(key))
50    }
51}
52
53impl LlmProvider for AnthropicProvider {
54    fn complete(&self, messages: &[Message], config: &LlmConfig) -> Result<String, LlmError> {
55        let url = format!("{ANTHROPIC_API_BASE}/messages");
56
57        let model = std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
58
59        // Anthropic requires system messages in a top-level `system` field,
60        // not in the messages array. Only "user" and "assistant" roles are allowed.
61        let mut system_text: Option<String> = None;
62        let mut msgs: Vec<Value> = Vec::new();
63
64        for m in messages {
65            match m.role {
66                Role::System => {
67                    // Concatenate multiple system messages if present.
68                    match &mut system_text {
69                        Some(existing) => {
70                            existing.push('\n');
71                            existing.push_str(&m.content);
72                        }
73                        None => {
74                            system_text = Some(m.content.clone());
75                        }
76                    }
77                }
78                Role::User => {
79                    msgs.push(json!({"role": "user", "content": m.content}));
80                }
81                Role::Assistant => {
82                    msgs.push(json!({"role": "assistant", "content": m.content}));
83                }
84            }
85        }
86
87        let mut body = json!({
88            "model": model,
89            "max_tokens": config.max_tokens,
90            "messages": msgs,
91        });
92
93        if let Some(sys) = system_text {
94            body["system"] = Value::String(sys);
95        }
96
97        let resp = self
98            .client
99            .post(&url)
100            .header("x-api-key", &self.api_key)
101            .header("anthropic-version", ANTHROPIC_VERSION)
102            .header("content-type", "application/json")
103            .json(&body)
104            .send()
105            .map_err(|e| LlmError::Http(e.to_string()))?;
106
107        let status = resp.status();
108        let text = resp.text().map_err(|e| LlmError::Http(e.to_string()))?;
109
110        if !status.is_success() {
111            return Err(LlmError::Provider(format!(
112                "Anthropic API HTTP {status}: {text}"
113            )));
114        }
115
116        let json: Value =
117            serde_json::from_str(&text).map_err(|e| LlmError::Parse(e.to_string()))?;
118
119        json["content"][0]["text"]
120            .as_str()
121            .map(|s| s.to_string())
122            .ok_or_else(|| LlmError::Parse(format!("unexpected Anthropic response shape: {json}")))
123    }
124}
125
126// ── Tests ────────────────────────────────────────────────────────────────────
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn from_env_errors_without_key() {
134        let saved = std::env::var("ANTHROPIC_API_KEY").ok();
135        std::env::remove_var("ANTHROPIC_API_KEY");
136        assert!(AnthropicProvider::from_env().is_err());
137        if let Some(k) = saved {
138            std::env::set_var("ANTHROPIC_API_KEY", k);
139        }
140    }
141
142    #[test]
143    fn system_message_extraction() {
144        // Verify that system messages are separated from user/assistant messages.
145        let messages = vec![
146            Message::system("You are helpful."),
147            Message::user("Hello"),
148            Message::assistant("Hi there"),
149        ];
150
151        let mut system_text: Option<String> = None;
152        let mut msgs: Vec<Value> = Vec::new();
153
154        for m in &messages {
155            match m.role {
156                Role::System => match &mut system_text {
157                    Some(existing) => {
158                        existing.push('\n');
159                        existing.push_str(&m.content);
160                    }
161                    None => {
162                        system_text = Some(m.content.clone());
163                    }
164                },
165                Role::User => {
166                    msgs.push(json!({"role": "user", "content": m.content}));
167                }
168                Role::Assistant => {
169                    msgs.push(json!({"role": "assistant", "content": m.content}));
170                }
171            }
172        }
173
174        assert_eq!(system_text, Some("You are helpful.".to_string()));
175        assert_eq!(msgs.len(), 2);
176        assert_eq!(msgs[0]["role"], "user");
177        assert_eq!(msgs[1]["role"], "assistant");
178    }
179
180    #[test]
181    fn multiple_system_messages_concatenated() {
182        let messages = vec![
183            Message::system("First instruction."),
184            Message::system("Second instruction."),
185            Message::user("Hello"),
186        ];
187
188        let mut system_text: Option<String> = None;
189        for m in &messages {
190            if matches!(m.role, Role::System) {
191                match &mut system_text {
192                    Some(existing) => {
193                        existing.push('\n');
194                        existing.push_str(&m.content);
195                    }
196                    None => {
197                        system_text = Some(m.content.clone());
198                    }
199                }
200            }
201        }
202
203        assert_eq!(
204            system_text,
205            Some("First instruction.\nSecond instruction.".to_string())
206        );
207    }
208}