Skip to main content

apollo/providers/
anthropic.rs

1//! Anthropic (Claude) provider implementation.
2//! Supports both API keys and OAuth tokens (Claude.dev)
3
4use async_trait::async_trait;
5use serde_json::Value;
6
7use super::retry::send_with_retry;
8use super::traits::*;
9use crate::cost::{CostTracker, TokenUsage};
10use crate::text::truncate_chars;
11use crate::tools::ToolSpec;
12
13pub struct AnthropicProvider {
14    api_key: String,
15    base_url: String,
16    cost_tracker: Option<std::sync::Arc<CostTracker>>,
17}
18
19impl AnthropicProvider {
20    pub fn new(api_key: impl Into<String>) -> Self {
21        Self {
22            api_key: api_key.into(),
23            base_url: "https://api.anthropic.com/v1".to_string(),
24            cost_tracker: None,
25        }
26    }
27
28    /// Create from OAuth token (Claude.dev) or fallback to environment/file
29    pub fn from_env_or_oauth() -> anyhow::Result<Self> {
30        // Try standard API key first
31        if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
32            return Ok(Self::new(key));
33        }
34
35        // Try loading from Claude.dev OAuth credentials
36        if let Ok((token, _, _)) = super::oauth::load_oauth_token_from_file() {
37            return Ok(Self::new(token));
38        }
39
40        Err(anyhow::anyhow!(
41            "No ANTHROPIC_API_KEY found. Set env var or install Claude for Desktop with OAuth token."
42        ))
43    }
44
45    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
46        self.base_url = url.into();
47        self
48    }
49
50    pub fn with_cost_tracker(mut self, tracker: std::sync::Arc<CostTracker>) -> Self {
51        self.cost_tracker = Some(tracker);
52        self
53    }
54
55    /// Convert internal ChatMessage list to Anthropic API format.
56    /// Handles: system (filtered), user, assistant, tool_result → user with content blocks
57    fn build_anthropic_messages(&self, messages: &[ChatMessage]) -> Vec<Value> {
58        let mut result: Vec<Value> = Vec::new();
59
60        for msg in messages {
61            match msg.role.as_str() {
62                "system" => continue, // handled separately
63                "user" => {
64                    result.push(serde_json::json!({
65                        "role": "user",
66                        "content": &msg.content,
67                    }));
68                }
69                "assistant" => {
70                    result.push(serde_json::json!({
71                        "role": "assistant",
72                        "content": &msg.content,
73                    }));
74                }
75                "assistant_tool_use" => {
76                    // Assistant message that requested tool use — reconstruct content blocks
77                    // The content field has the text, tool_use_id has serialized tool calls
78                    if let Some(tool_json) = &msg.tool_use_id {
79                        if let Ok(blocks) = serde_json::from_str::<Vec<Value>>(tool_json) {
80                            result.push(serde_json::json!({
81                                "role": "assistant",
82                                "content": blocks,
83                            }));
84                        }
85                    }
86                }
87                "tool_result" => {
88                    // Anthropic wants tool results as role "user" with tool_result content blocks
89                    if let Some(tool_use_id) = &msg.tool_use_id {
90                        result.push(serde_json::json!({
91                            "role": "user",
92                            "content": [{
93                                "type": "tool_result",
94                                "tool_use_id": tool_use_id,
95                                "content": &msg.content,
96                            }],
97                        }));
98                    }
99                }
100                other => {
101                    // Fallback
102                    result.push(serde_json::json!({
103                        "role": other,
104                        "content": &msg.content,
105                    }));
106                }
107            }
108        }
109
110        result
111    }
112
113    fn build_tools_payload(&self, tools: &[ToolSpec]) -> Vec<Value> {
114        tools
115            .iter()
116            .map(|t| {
117                serde_json::json!({
118                    "name": t.name,
119                    "description": t.description,
120                    "input_schema": t.parameters,
121                })
122            })
123            .collect()
124    }
125
126    /// Extract usage from Anthropic API response and record cost
127    async fn record_usage(&self, data: &Value, model: &str) {
128        if let Some(tracker) = &self.cost_tracker {
129            if let Some(usage_obj) = data.get("usage").and_then(|v| v.as_object()) {
130                let input_tokens = usage_obj
131                    .get("input_tokens")
132                    .and_then(|v| v.as_u64())
133                    .unwrap_or(0) as usize;
134                let output_tokens = usage_obj
135                    .get("output_tokens")
136                    .and_then(|v| v.as_u64())
137                    .unwrap_or(0) as usize;
138
139                let usage = TokenUsage {
140                    input_tokens,
141                    output_tokens,
142                    total_tokens: input_tokens + output_tokens,
143                };
144
145                if let Err(e) = tracker.record(model, usage).await {
146                    tracing::warn!("Failed to record cost: {}", e);
147                }
148            }
149        }
150    }
151}
152
153#[async_trait]
154impl Provider for AnthropicProvider {
155    fn name(&self) -> &str {
156        "anthropic"
157    }
158
159    fn capabilities(&self) -> ProviderCapabilities {
160        ProviderCapabilities {
161            native_tools: true,
162            streaming: true,
163            vision: true,
164            max_context: 200_000,
165        }
166    }
167
168    async fn chat(&self, request: &ChatRequest<'_>) -> anyhow::Result<ChatResponse> {
169        // Create client with 120s socket timeout (LLM calls can be slow)
170        let client = reqwest::Client::builder()
171            .timeout(std::time::Duration::from_secs(120))
172            .build()?;
173
174        // Split system message from conversation (combine multiple system msgs)
175        // Use prompt caching for system prompts (cache_control breakpoint)
176        let system: Option<Value> = {
177            let sys_parts: Vec<&str> = request
178                .messages
179                .iter()
180                .filter(|m| m.role == "system")
181                .map(|m| m.content.as_str())
182                .collect();
183            if sys_parts.is_empty() {
184                None
185            } else {
186                // Wrap system prompt with cache_control for prompt caching
187                Some(serde_json::json!([{
188                    "type": "text",
189                    "text": sys_parts.join("\n\n---\n\n"),
190                    "cache_control": {"type": "ephemeral"}
191                }]))
192            }
193        };
194
195        // Build Anthropic-format messages
196        let messages: Vec<Value> = self.build_anthropic_messages(request.messages);
197
198        let mut body = serde_json::json!({
199            "model": request.model,
200            "messages": messages,
201            "max_tokens": request.max_tokens.unwrap_or(8192),
202            "temperature": request.temperature,
203        });
204
205        if let Some(sys) = system {
206            body["system"] = sys;
207        }
208
209        if let Some(tools) = request.tools {
210            if !tools.is_empty() {
211                body["tools"] = Value::Array(self.build_tools_payload(tools));
212            }
213        }
214
215        // Detect OAuth tokens (sk-ant-oat) vs API keys (sk-ant-api)
216        let is_oauth = self.api_key.contains("sk-ant-oat");
217
218        // OAuth tokens require the system prompt to start with the Claude Code identity prefix
219        if is_oauth {
220            let prefix = serde_json::json!({
221                "type": "text",
222                "text": "You are Claude Code, Anthropic's official CLI for Claude.",
223                "cache_control": {"type": "ephemeral"}
224            });
225            match body.get("system") {
226                Some(Value::Array(blocks)) => {
227                    let mut new_blocks = vec![prefix];
228                    new_blocks.extend(blocks.iter().cloned());
229                    body["system"] = Value::Array(new_blocks);
230                }
231                Some(Value::String(s)) => {
232                    body["system"] = serde_json::json!([
233                        prefix,
234                        {"type": "text", "text": s, "cache_control": {"type": "ephemeral"}}
235                    ]);
236                }
237                None => {
238                    body["system"] = serde_json::json!([prefix]);
239                }
240                _ => {}
241            }
242        }
243
244        let mut req_builder = client
245            .post(format!("{}/messages", self.base_url))
246            .header("content-type", "application/json")
247            .header("anthropic-version", "2023-06-01");
248
249        if is_oauth {
250            req_builder = req_builder
251                .header("Authorization", format!("Bearer {}", self.api_key))
252                .header(
253                    "anthropic-beta",
254                    "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14",
255                )
256                .header("anthropic-dangerous-direct-browser-access", "true");
257        } else {
258            req_builder = req_builder
259                .header("x-api-key", &self.api_key)
260                .header("anthropic-beta", "prompt-caching-2024-07-31");
261        }
262
263        let resp = send_with_retry(req_builder.json(&body), self.name()).await?;
264
265        if !resp.status().is_success() {
266            let status = resp.status();
267            let text = resp.text().await.unwrap_or_default();
268            anyhow::bail!(
269                "Anthropic API error {}: {}",
270                status,
271                truncate_chars(&text, 200)
272            );
273        }
274
275        // Capture response headers for rate limit tracking
276        let headers = resp.headers().clone();
277
278        let data: Value = resp.json().await?;
279
280        // Record usage for cost tracking
281        self.record_usage(&data, request.model).await;
282
283        // Update rate limits from response headers
284        if let Some(tracker) = &self.cost_tracker {
285            tracker.update_rate_limits(&headers).await;
286        }
287
288        let mut text_parts = Vec::new();
289        let mut tool_calls = Vec::new();
290
291        if let Some(content) = data["content"].as_array() {
292            for block in content {
293                match block["type"].as_str() {
294                    Some("text") => {
295                        if let Some(t) = block["text"].as_str() {
296                            text_parts.push(t.to_string());
297                        }
298                    }
299                    Some("tool_use") => {
300                        tool_calls.push(ToolCall {
301                            id: block["id"].as_str().unwrap_or("").to_string(),
302                            name: block["name"].as_str().unwrap_or("").to_string(),
303                            arguments: block["input"].to_string(),
304                        });
305                    }
306                    _ => {}
307                }
308            }
309        }
310
311        let usage = data["usage"].as_object().map(|u| Usage {
312            input_tokens: u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
313            output_tokens: u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
314        });
315
316        Ok(ChatResponse {
317            text: if text_parts.is_empty() {
318                None
319            } else {
320                Some(text_parts.join(""))
321            },
322            tool_calls,
323            usage,
324        })
325    }
326}