Skip to main content

stynx_code_provider/infrastructure/
anthropic_provider.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
3
4use stynx_code_auth::Credential;
5use stynx_code_errors::{AppError, AppResult};
6use stynx_code_types::{Conversation, PermissionMode, Provider, StreamEvent};
7use futures::stream::BoxStream;
8use futures::StreamExt;
9use reqwest::Client;
10use serde_json::Value;
11
12use super::sse_parser::{parse_sse_block, parse_sse_event};
13use super::request_builder::build_request_body;
14
15#[derive(Debug, Clone, serde::Deserialize)]
16pub struct RateLimit {
17    pub utilization: Option<f64>,
18    pub resets_at: Option<String>,
19}
20
21#[derive(Debug, Clone, serde::Deserialize)]
22pub struct ExtraUsage {
23    pub is_enabled: bool,
24    pub monthly_limit: Option<f64>,
25    pub used_credits: Option<f64>,
26    pub utilization: Option<f64>,
27}
28
29#[derive(Debug, Clone, Default, serde::Deserialize)]
30pub struct Utilization {
31    pub five_hour: Option<RateLimit>,
32    pub seven_day: Option<RateLimit>,
33    pub seven_day_opus: Option<RateLimit>,
34    pub seven_day_sonnet: Option<RateLimit>,
35    pub extra_usage: Option<ExtraUsage>,
36}
37
38const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4-20250514";
39pub(crate) const OAUTH_DEFAULT_MODEL: &str = "claude-sonnet-4-6";
40const OPUS_MODEL: &str = "claude-opus-4-6";
41pub(crate) const MAX_TOKENS: u32 = 4096;
42
43pub(crate) const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20,interleaved-thinking-2025-05-14,claude-code-20250219,prompt-caching-2024-07-31";
44pub(crate) const EFFORT_BETA_HEADER: &str = "effort-2025-11-24";
45pub(crate) const BILLING_HEADER_LINE: &str = "x-anthropic-billing-header: cc_version=2.1.87.d34; cc_entrypoint=cli;";
46
47pub struct AnthropicProvider {
48    client: Client,
49    credential: Credential,
50    model: std::sync::Mutex<String>,
51    mode: Arc<AtomicU8>,
52    thinking: Arc<AtomicBool>,
53    max_tokens: AtomicU32,
54    thinking_budget: std::sync::Mutex<Option<u32>>,
55    effort: std::sync::Mutex<Option<String>>,
56}
57
58impl AnthropicProvider {
59    pub fn new(credential: Credential, mode: Arc<AtomicU8>) -> Self {
60        let default_model = if credential.is_oauth() {
61            OAUTH_DEFAULT_MODEL
62        } else {
63            DEFAULT_MODEL
64        };
65
66        let client = Client::builder()
67            .connect_timeout(std::time::Duration::from_secs(30))
68            .pool_idle_timeout(std::time::Duration::from_secs(90))
69            .tcp_keepalive(std::time::Duration::from_secs(60))
70            .read_timeout(std::time::Duration::from_secs(180))
71            .build()
72            .unwrap_or_else(|_| Client::new());
73
74        Self {
75            client,
76            model: std::sync::Mutex::new(default_model.to_string()),
77            credential,
78            mode,
79            thinking: Arc::new(AtomicBool::new(false)),
80            max_tokens: AtomicU32::new(MAX_TOKENS),
81            thinking_budget: std::sync::Mutex::new(None),
82            effort: std::sync::Mutex::new(None),
83        }
84    }
85
86    pub fn set_model(&self, model: &str) {
87        if let Ok(mut m) = self.model.lock() {
88            *m = model.to_string();
89        }
90    }
91
92    pub fn set_max_tokens(&self, n: u32) {
93        self.max_tokens.store(n, Ordering::Relaxed);
94    }
95
96    pub fn set_thinking_budget(&self, budget: u32) {
97        self.thinking.store(true, Ordering::Relaxed);
98        *self.thinking_budget.lock().unwrap() = Some(budget);
99    }
100
101    pub fn set_effort(&self, effort: &str) {
102        self.thinking.store(true, Ordering::Relaxed);
103        *self.effort.lock().unwrap() = Some(effort.to_string());
104    }
105
106    pub fn get_effort(&self) -> Option<String> {
107        self.effort.lock().ok().and_then(|g| g.clone())
108    }
109
110    pub fn clear_effort(&self) {
111        *self.effort.lock().unwrap() = None;
112    }
113
114    pub fn model_name(&self) -> String {
115        self.effective_model()
116    }
117
118    pub fn toggle_thinking(&self) -> bool {
119        let current = self.thinking.load(Ordering::Relaxed);
120        let next = !current;
121        self.thinking.store(next, Ordering::Relaxed);
122        next
123    }
124
125    pub fn thinking_enabled(&self) -> bool {
126        self.thinking.load(Ordering::Relaxed)
127    }
128
129    pub fn is_oauth(&self) -> bool {
130        self.credential.is_oauth()
131    }
132
133    pub async fn fetch_usage(&self) -> AppResult<Utilization> {
134        let access_token = match &self.credential {
135            Credential::ClaudeCodeOAuth { access_token, .. } => access_token.clone(),
136            _ => {
137                return Err(AppError::Provider(
138                    "/usage is only available for Claude AI subscribers (OAuth login)".to_string(),
139                ));
140            }
141        };
142
143        let url = format!("{}/api/oauth/usage", self.credential.base_url());
144        let response = self
145            .client
146            .get(&url)
147            .header("Authorization", format!("Bearer {access_token}"))
148            .header("anthropic-beta", "oauth-2025-04-20")
149            .header("anthropic-dangerous-direct-browser-access", "true")
150            .header("User-Agent", "claude-cli/2.1.87 (external, cli)")
151            .header("Content-Type", "application/json")
152            .timeout(std::time::Duration::from_secs(5))
153            .send()
154            .await
155            .map_err(|e| AppError::Provider(format!("usage request failed: {e}")))?;
156
157        if !response.status().is_success() {
158            let body = response.text().await.unwrap_or_default();
159            return Err(AppError::Provider(format!("usage API error: {body}")));
160        }
161
162        response
163            .json::<Utilization>()
164            .await
165            .map_err(|e| AppError::Provider(format!("failed to parse usage response: {e}")))
166    }
167
168    fn effective_model(&self) -> String {
169        let stored = self.model.lock().map(|m| m.clone()).unwrap_or_default();
170        match stored.as_str() {
171            "opusplan" => {
172                if PermissionMode::load(&self.mode) == PermissionMode::Plan {
173                    OPUS_MODEL.to_string()
174                } else if self.credential.is_oauth() {
175                    OAUTH_DEFAULT_MODEL.to_string()
176                } else {
177                    DEFAULT_MODEL.to_string()
178                }
179            }
180            _ => stored,
181        }
182    }
183}
184
185#[async_trait::async_trait]
186impl Provider for AnthropicProvider {
187    fn model_name(&self) -> String { self.effective_model() }
188    fn set_model(&self, model: &str) { AnthropicProvider::set_model(self, model); }
189    fn set_max_tokens(&self, n: u32) { AnthropicProvider::set_max_tokens(self, n); }
190    fn set_thinking_budget(&self, budget: u32) { AnthropicProvider::set_thinking_budget(self, budget); }
191    fn set_effort(&self, level: &str) { AnthropicProvider::set_effort(self, level); }
192    fn clear_effort(&self) { AnthropicProvider::clear_effort(self); }
193    fn get_effort(&self) -> Option<String> { AnthropicProvider::get_effort(self) }
194    fn toggle_thinking(&self) -> bool { AnthropicProvider::toggle_thinking(self) }
195
196    async fn stream(
197        &self,
198        conversation: &Conversation,
199        tools: &[Value],
200    ) -> AppResult<BoxStream<'static, StreamEvent>> {
201        let base_url = self.credential.base_url();
202        let model_display = self.effective_model();
203        let thinking = self.thinking.load(Ordering::Relaxed);
204        let max_tokens = self.max_tokens.load(Ordering::Relaxed);
205        let thinking_budget = *self.thinking_budget.lock().unwrap();
206        let effort = self.effort.lock().unwrap().clone();
207        let body = build_request_body(&self.credential, &model_display, conversation, tools, thinking, max_tokens, thinking_budget, effort.as_deref());
208
209        let request = match &self.credential {
210            Credential::ClaudeCodeOAuth { access_token, .. } | Credential::AuthToken { token: access_token, .. } => {
211                let url = format!("{base_url}/v1/messages?beta=true");
212                tracing::debug!(model = %model_display, url = %url, "sending OAuth request");
213
214                let mut beta = OAUTH_BETA_HEADER.to_string();
215                if effort.is_some() {
216                    beta.push(',');
217                    beta.push_str(EFFORT_BETA_HEADER);
218                }
219
220                self.client
221                    .post(&url)
222                    .header("Authorization", format!("Bearer {access_token}"))
223                    .header("anthropic-version", "2023-06-01")
224                    .header("anthropic-beta", beta)
225                    .header("anthropic-dangerous-direct-browser-access", "true")
226                    .header("User-Agent", "claude-cli/2.1.87 (external, cli)")
227                    .header("x-app", "cli")
228                    .header("content-type", "application/json")
229            }
230            Credential::ApiKey { api_key, .. } => {
231                let url = format!("{base_url}/v1/messages");
232                tracing::debug!(model = %model_display, url = %url, "sending API key request");
233
234                let mut rb = self.client
235                    .post(&url)
236                    .header("Authorization", format!("Bearer {api_key}"))
237                    .header("x-api-key", api_key)
238                    .header("anthropic-version", "2023-06-01")
239                    .header("content-type", "application/json");
240
241                let mut beta = if thinking {
242                    "prompt-caching-2024-07-31,interleaved-thinking-2025-05-14".to_string()
243                } else {
244                    "prompt-caching-2024-07-31".to_string()
245                };
246                if effort.is_some() {
247                    beta.push(',');
248                    beta.push_str(EFFORT_BETA_HEADER);
249                }
250                rb = rb.header("anthropic-beta", beta);
251                rb
252            }
253        };
254
255        let response = request
256            .json(&body)
257            .send()
258            .await
259            .map_err(|e| AppError::Provider(format!("request failed: {e}")))?;
260
261        let status = response.status();
262        if !status.is_success() {
263            let retry_after = response.headers()
264                .get("retry-after")
265                .and_then(|v| v.to_str().ok())
266                .map(str::to_string);
267            let body = response
268                .text()
269                .await
270                .unwrap_or_else(|_| "failed to read body".into());
271            let prefix = if status.as_u16() == 429 {
272                let ms = retry_after.as_deref()
273                    .and_then(|v| v.parse::<f64>().ok())
274                    .map(|s| (s * 1000.0) as u64)
275                    .unwrap_or(60_000);
276                format!("[retry_after_ms={ms}] ")
277            } else {
278                String::new()
279            };
280            return Err(AppError::Provider(format!(
281                "{prefix}API returned {status}: {body}"
282            )));
283        }
284
285        let byte_stream = response.bytes_stream();
286
287        let event_stream = byte_stream
288            .scan(String::new(), |buf, chunk| {
289                let events: Vec<StreamEvent> = match chunk {
290                    Err(e) => vec![StreamEvent::Error { message: e.to_string() }],
291                    Ok(bytes) => {
292                        buf.push_str(&String::from_utf8_lossy(&bytes));
293                        let mut events = Vec::new();
294                        while let Some(pos) = buf.find("\n\n") {
295                            let block = buf[..pos].to_string();
296                            *buf = buf[pos + 2..].to_string();
297                            if let Some((et, d)) = parse_sse_block(&block) {
298                                events.extend(parse_sse_event(&et, &d));
299                            }
300                        }
301                        events
302                    }
303                };
304                async move { Some(events) }
305            })
306            .flat_map(futures::stream::iter);
307
308        Ok(Box::pin(event_stream))
309    }
310}