noether_engine/llm/
anthropic.rs1use 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
16pub 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 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 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 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#[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 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}