Skip to main content

dot/provider/anthropic/
mod.rs

1mod auth;
2mod stream;
3mod types;
4
5use auth::{AnthropicAuth, AuthResolved, refresh_oauth_token};
6use stream::process_sse_stream;
7use types::{AnthropicRequest, convert_messages, convert_tools};
8
9use std::{
10    future::Future,
11    pin::Pin,
12    time::{SystemTime, UNIX_EPOCH},
13};
14
15use anyhow::Context;
16use tokio::sync::{mpsc, mpsc::UnboundedReceiver};
17use tracing::warn;
18
19use crate::provider::{Message, Provider, StreamEvent, StreamEventType, ToolDefinition};
20
21pub struct AnthropicProvider {
22    client: reqwest::Client,
23    model: String,
24    auth: tokio::sync::Mutex<AnthropicAuth>,
25    cached_models: std::sync::Mutex<Option<Vec<String>>>,
26}
27
28impl AnthropicProvider {
29    pub fn new_with_api_key(api_key: impl Into<String>, model: impl Into<String>) -> Self {
30        Self {
31            client: reqwest::Client::builder()
32                .user_agent("dot/0.1.0")
33                .build()
34                .expect("Failed to build reqwest client"),
35            model: model.into(),
36            auth: tokio::sync::Mutex::new(AnthropicAuth::ApiKey(api_key.into())),
37            cached_models: std::sync::Mutex::new(None),
38        }
39    }
40
41    pub fn new_with_oauth(
42        access_token: impl Into<String>,
43        refresh_token: impl Into<String>,
44        expires_at: i64,
45        model: impl Into<String>,
46    ) -> Self {
47        Self {
48            client: reqwest::Client::builder()
49                .user_agent("claude-code/2.1.49 (external, cli)")
50                .build()
51                .expect("Failed to build reqwest client"),
52            model: model.into(),
53            auth: tokio::sync::Mutex::new(AnthropicAuth::OAuth {
54                access_token: access_token.into(),
55                refresh_token: refresh_token.into(),
56                expires_at,
57            }),
58            cached_models: std::sync::Mutex::new(None),
59        }
60    }
61
62    async fn fetch_model_context_window(&self, model: &str) -> Option<u32> {
63        let auth = self.resolve_auth().await.ok()?;
64        let url = format!("https://api.anthropic.com/v1/models/{model}");
65        let mut req = self
66            .client
67            .get(&url)
68            .header(&auth.header_name, &auth.header_value)
69            .header("anthropic-version", "2023-06-01");
70        if auth.is_oauth {
71            req = req
72                .header(
73                    "anthropic-beta",
74                    "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14",
75                )
76                .header("user-agent", "claude-code/2.1.49 (external, cli)");
77        }
78        let data: serde_json::Value = req.send().await.ok()?.json().await.ok()?;
79        data["context_window"].as_u64().map(|v| v as u32)
80    }
81
82    fn model_context_window(model: &str) -> u32 {
83        if model.contains("claude") {
84            return 200_000;
85        }
86        0
87    }
88
89    async fn resolve_auth(&self) -> anyhow::Result<AuthResolved> {
90        let mut auth = self.auth.lock().await;
91        match &*auth {
92            AnthropicAuth::ApiKey(key) => Ok(AuthResolved {
93                header_name: "x-api-key".to_string(),
94                header_value: key.clone(),
95                is_oauth: false,
96            }),
97            AnthropicAuth::OAuth {
98                access_token,
99                refresh_token,
100                expires_at,
101            } => {
102                let now = SystemTime::now()
103                    .duration_since(UNIX_EPOCH)
104                    .unwrap_or_default()
105                    .as_secs() as i64;
106                // Handle legacy millis-format expires_at from older credentials
107                let expires_at_secs = if *expires_at > 1_000_000_000_000 {
108                    *expires_at / 1000
109                } else {
110                    *expires_at
111                };
112
113                let token = if now >= expires_at_secs - 60 {
114                    let rt = refresh_token.clone();
115                    match refresh_oauth_token(&self.client, &rt).await {
116                        Ok((new_token, new_expires_at, new_refresh)) => {
117                            if let AnthropicAuth::OAuth {
118                                access_token,
119                                refresh_token,
120                                expires_at,
121                            } = &mut *auth
122                            {
123                                *access_token = new_token.clone();
124                                *expires_at = new_expires_at;
125                                if let Some(ref rt) = new_refresh {
126                                    *refresh_token = rt.clone();
127                                }
128                            }
129                            if let Ok(mut creds) = crate::auth::Credentials::load() {
130                                let cred = crate::auth::ProviderCredential::OAuth {
131                                    access_token: new_token.clone(),
132                                    refresh_token: new_refresh,
133                                    expires_at: Some(new_expires_at),
134                                    api_key: None,
135                                };
136                                creds.set("anthropic", cred);
137                                let _ = creds.save();
138                            }
139                            new_token
140                        }
141                        Err(e) => {
142                            warn!("OAuth token refresh failed: {e}");
143                            access_token.clone()
144                        }
145                    }
146                } else {
147                    access_token.clone()
148                };
149
150                Ok(AuthResolved {
151                    header_name: "Authorization".to_string(),
152                    header_value: format!("Bearer {token}"),
153                    is_oauth: true,
154                })
155            }
156        }
157    }
158}
159
160impl Provider for AnthropicProvider {
161    fn name(&self) -> &str {
162        "anthropic"
163    }
164
165    fn model(&self) -> &str {
166        &self.model
167    }
168
169    fn set_model(&mut self, model: String) {
170        self.model = model;
171    }
172
173    fn available_models(&self) -> Vec<String> {
174        let cache = self.cached_models.lock().unwrap();
175        cache.clone().unwrap_or_default()
176    }
177
178    fn context_window(&self) -> u32 {
179        Self::model_context_window(&self.model)
180    }
181
182    fn supports_server_compaction(&self) -> bool {
183        true
184    }
185
186    fn fetch_context_window(
187        &self,
188    ) -> Pin<Box<dyn Future<Output = anyhow::Result<u32>> + Send + '_>> {
189        Box::pin(async move {
190            if let Some(cw) = self.fetch_model_context_window(&self.model).await {
191                return Ok(cw);
192            }
193            Ok(Self::model_context_window(&self.model))
194        })
195    }
196
197    fn fetch_models(
198        &self,
199    ) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<String>>> + Send + '_>> {
200        Box::pin(async move {
201            {
202                let cache = self.cached_models.lock().unwrap();
203                if let Some(ref models) = *cache {
204                    return Ok(models.clone());
205                }
206            }
207            let auth = self.resolve_auth().await?;
208            let mut all_models: Vec<String> = Vec::new();
209            let mut after_id: Option<String> = None;
210
211            loop {
212                let mut url = "https://api.anthropic.com/v1/models?limit=1000".to_string();
213                if let Some(ref cursor) = after_id {
214                    url.push_str(&format!("&after_id={cursor}"));
215                }
216
217                let mut req = self
218                    .client
219                    .get(&url)
220                    .header(&auth.header_name, &auth.header_value)
221                    .header("anthropic-version", "2023-06-01");
222
223                if auth.is_oauth {
224                    req = req
225                        .header(
226                            "anthropic-beta",
227                            "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14",
228                        )
229                        .header("user-agent", "claude-code/2.1.49 (external, cli)");
230                }
231
232                let resp = req
233                    .send()
234                    .await
235                    .context("Failed to fetch Anthropic models")?;
236
237                if !resp.status().is_success() {
238                    let status = resp.status();
239                    let body = resp.text().await.unwrap_or_default();
240                    return Err(anyhow::anyhow!(
241                        "Anthropic models API error {status}: {body}"
242                    ));
243                }
244
245                let data: serde_json::Value = resp
246                    .json()
247                    .await
248                    .context("Failed to parse Anthropic models response")?;
249
250                if let Some(arr) = data["data"].as_array() {
251                    for m in arr {
252                        if let Some(id) = m["id"].as_str() {
253                            all_models.push(id.to_string());
254                        }
255                    }
256                }
257
258                let has_more = data["has_more"].as_bool().unwrap_or(false);
259                if !has_more {
260                    break;
261                }
262
263                match data["last_id"].as_str() {
264                    Some(last) => after_id = Some(last.to_string()),
265                    None => break,
266                }
267            }
268
269            if all_models.is_empty() {
270                return Err(anyhow::anyhow!("Anthropic models API returned empty list"));
271            }
272
273            all_models.sort();
274            let mut cache = self.cached_models.lock().unwrap();
275            *cache = Some(all_models.clone());
276
277            Ok(all_models)
278        })
279    }
280
281    fn stream(
282        &self,
283        messages: &[Message],
284        system: Option<&str>,
285        tools: &[ToolDefinition],
286        max_tokens: u32,
287        thinking_budget: u32,
288    ) -> Pin<Box<dyn Future<Output = anyhow::Result<UnboundedReceiver<StreamEvent>>> + Send + '_>>
289    {
290        self.stream_with_model(
291            &self.model,
292            messages,
293            system,
294            tools,
295            max_tokens,
296            thinking_budget,
297        )
298    }
299
300    fn stream_with_model(
301        &self,
302        model: &str,
303        messages: &[Message],
304        system: Option<&str>,
305        tools: &[ToolDefinition],
306        max_tokens: u32,
307        thinking_budget: u32,
308    ) -> Pin<Box<dyn Future<Output = anyhow::Result<UnboundedReceiver<StreamEvent>>> + Send + '_>>
309    {
310        let messages = messages.to_vec();
311        let system = system.map(String::from);
312        let tools = tools.to_vec();
313        let model = model.to_string();
314
315        Box::pin(async move {
316            let auth = self.resolve_auth().await?;
317
318            let url = if auth.is_oauth {
319                "https://api.anthropic.com/v1/messages?beta=true".to_string()
320            } else {
321                "https://api.anthropic.com/v1/messages".to_string()
322            };
323
324            let thinking = if thinking_budget >= 1024 {
325                Some(serde_json::json!({
326                    "type": "enabled",
327                    "budget_tokens": thinking_budget,
328                }))
329            } else {
330                None
331            };
332
333            let effective_max_tokens = if thinking_budget >= 1024 {
334                max_tokens.max(thinking_budget.saturating_add(4096))
335            } else {
336                max_tokens
337            };
338
339            let context_management = Some(serde_json::json!({
340                "edits": [{ "type": "compact_20260112" }]
341            }));
342
343            let system_value = if auth.is_oauth {
344                let identity = serde_json::json!({
345                    "type": "text",
346                    "text": "You are Claude Code, Anthropic's official CLI for Claude.",
347                    "cache_control": { "type": "ephemeral" }
348                });
349                match system {
350                    Some(s) => Some(serde_json::json!([
351                        identity,
352                        { "type": "text", "text": s }
353                    ])),
354                    None => Some(serde_json::json!([identity])),
355                }
356            } else {
357                system.map(serde_json::Value::String)
358            };
359
360            let body = AnthropicRequest {
361                model: &model,
362                messages: convert_messages(&messages),
363                max_tokens: effective_max_tokens,
364                stream: true,
365                system: system_value,
366                tools: convert_tools(&tools),
367                temperature: 1.0,
368                thinking,
369                context_management,
370            };
371
372            let mut req_builder = self
373                .client
374                .post(&url)
375                .header(&auth.header_name, &auth.header_value)
376                .header("anthropic-version", "2023-06-01")
377                .header("content-type", "application/json");
378
379            let compact_beta = "compact-2026-01-12";
380            if auth.is_oauth {
381                req_builder = req_builder
382                    .header(
383                        "anthropic-beta",
384                        format!("claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,{compact_beta}"),
385                    )
386                    .header("user-agent", "claude-code/2.1.49 (external, cli)")
387                    .header("anthropic-dangerous-direct-browser-access", "true")
388                    .header("x-app", "cli");
389            } else if thinking_budget >= 1024 {
390                req_builder = req_builder.header(
391                    "anthropic-beta",
392                    format!("interleaved-thinking-2025-05-14,{compact_beta}"),
393                );
394            } else {
395                req_builder = req_builder.header("anthropic-beta", compact_beta);
396            }
397
398            let response = req_builder
399                .json(&body)
400                .send()
401                .await
402                .context("Failed to connect to Anthropic API")?;
403
404            if !response.status().is_success() {
405                let status = response.status();
406                let body_text = response.text().await.unwrap_or_default();
407                return Err(anyhow::anyhow!("Anthropic API error {status}: {body_text}"));
408            }
409
410            let (tx, rx) = mpsc::unbounded_channel::<StreamEvent>();
411            let tx_clone = tx.clone();
412
413            tokio::spawn(async move {
414                if let Err(e) = process_sse_stream(response, tx_clone.clone()).await {
415                    let _ = tx_clone.send(StreamEvent {
416                        event_type: StreamEventType::Error(e.to_string()),
417                    });
418                }
419            });
420
421            Ok(rx)
422        })
423    }
424}