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        Self {
67            client: Client::new(),
68            model: std::sync::Mutex::new(default_model.to_string()),
69            credential,
70            mode,
71            thinking: Arc::new(AtomicBool::new(false)),
72            max_tokens: AtomicU32::new(MAX_TOKENS),
73            thinking_budget: std::sync::Mutex::new(None),
74            effort: std::sync::Mutex::new(None),
75        }
76    }
77
78    pub fn set_model(&self, model: &str) {
79        if let Ok(mut m) = self.model.lock() {
80            *m = model.to_string();
81        }
82    }
83
84    pub fn set_max_tokens(&self, n: u32) {
85        self.max_tokens.store(n, Ordering::Relaxed);
86    }
87
88    pub fn set_thinking_budget(&self, budget: u32) {
89        self.thinking.store(true, Ordering::Relaxed);
90        *self.thinking_budget.lock().unwrap() = Some(budget);
91    }
92
93    pub fn set_effort(&self, effort: &str) {
94        self.thinking.store(true, Ordering::Relaxed);
95        *self.effort.lock().unwrap() = Some(effort.to_string());
96    }
97
98    pub fn get_effort(&self) -> Option<String> {
99        self.effort.lock().ok().and_then(|g| g.clone())
100    }
101
102    pub fn clear_effort(&self) {
103        *self.effort.lock().unwrap() = None;
104    }
105
106    pub fn model_name(&self) -> String {
107        self.effective_model()
108    }
109
110    pub fn toggle_thinking(&self) -> bool {
111        let current = self.thinking.load(Ordering::Relaxed);
112        let next = !current;
113        self.thinking.store(next, Ordering::Relaxed);
114        next
115    }
116
117    pub fn thinking_enabled(&self) -> bool {
118        self.thinking.load(Ordering::Relaxed)
119    }
120
121    pub fn is_oauth(&self) -> bool {
122        self.credential.is_oauth()
123    }
124
125    pub async fn fetch_usage(&self) -> AppResult<Utilization> {
126        let access_token = match &self.credential {
127            Credential::ClaudeCodeOAuth { access_token, .. } => access_token.clone(),
128            _ => {
129                return Err(AppError::Provider(
130                    "/usage is only available for Claude AI subscribers (OAuth login)".to_string(),
131                ));
132            }
133        };
134
135        let url = format!("{}/api/oauth/usage", self.credential.base_url());
136        let response = self
137            .client
138            .get(&url)
139            .header("Authorization", format!("Bearer {access_token}"))
140            .header("anthropic-beta", "oauth-2025-04-20")
141            .header("anthropic-dangerous-direct-browser-access", "true")
142            .header("User-Agent", "claude-cli/2.1.87 (external, cli)")
143            .header("Content-Type", "application/json")
144            .timeout(std::time::Duration::from_secs(5))
145            .send()
146            .await
147            .map_err(|e| AppError::Provider(format!("usage request failed: {e}")))?;
148
149        if !response.status().is_success() {
150            let body = response.text().await.unwrap_or_default();
151            return Err(AppError::Provider(format!("usage API error: {body}")));
152        }
153
154        response
155            .json::<Utilization>()
156            .await
157            .map_err(|e| AppError::Provider(format!("failed to parse usage response: {e}")))
158    }
159
160    fn effective_model(&self) -> String {
161        let stored = self.model.lock().map(|m| m.clone()).unwrap_or_default();
162        match stored.as_str() {
163            "opusplan" => {
164                if PermissionMode::load(&self.mode) == PermissionMode::Plan {
165                    OPUS_MODEL.to_string()
166                } else if self.credential.is_oauth() {
167                    OAUTH_DEFAULT_MODEL.to_string()
168                } else {
169                    DEFAULT_MODEL.to_string()
170                }
171            }
172            _ => stored,
173        }
174    }
175}
176
177#[async_trait::async_trait]
178impl Provider for AnthropicProvider {
179    async fn stream(
180        &self,
181        conversation: &Conversation,
182        tools: &[Value],
183    ) -> AppResult<BoxStream<'static, StreamEvent>> {
184        let base_url = self.credential.base_url();
185        let model_display = self.effective_model();
186        let thinking = self.thinking.load(Ordering::Relaxed);
187        let max_tokens = self.max_tokens.load(Ordering::Relaxed);
188        let thinking_budget = *self.thinking_budget.lock().unwrap();
189        let effort = self.effort.lock().unwrap().clone();
190        let body = build_request_body(&self.credential, &model_display, conversation, tools, thinking, max_tokens, thinking_budget, effort.as_deref());
191
192        let request = match &self.credential {
193            Credential::ClaudeCodeOAuth { access_token, .. } | Credential::AuthToken { token: access_token, .. } => {
194                let url = format!("{base_url}/v1/messages?beta=true");
195                tracing::debug!(model = %model_display, url = %url, "sending OAuth request");
196
197                let mut beta = OAUTH_BETA_HEADER.to_string();
198                if effort.is_some() {
199                    beta.push(',');
200                    beta.push_str(EFFORT_BETA_HEADER);
201                }
202
203                self.client
204                    .post(&url)
205                    .header("Authorization", format!("Bearer {access_token}"))
206                    .header("anthropic-version", "2023-06-01")
207                    .header("anthropic-beta", beta)
208                    .header("anthropic-dangerous-direct-browser-access", "true")
209                    .header("User-Agent", "claude-cli/2.1.87 (external, cli)")
210                    .header("x-app", "cli")
211                    .header("content-type", "application/json")
212            }
213            Credential::ApiKey { api_key, .. } => {
214                let url = format!("{base_url}/v1/messages");
215                tracing::debug!(model = %model_display, url = %url, "sending API key request");
216
217                let mut rb = self.client
218                    .post(&url)
219                    .header("Authorization", format!("Bearer {api_key}"))
220                    .header("x-api-key", api_key)
221                    .header("anthropic-version", "2023-06-01")
222                    .header("content-type", "application/json");
223
224                let mut beta = if thinking {
225                    "prompt-caching-2024-07-31,interleaved-thinking-2025-05-14".to_string()
226                } else {
227                    "prompt-caching-2024-07-31".to_string()
228                };
229                if effort.is_some() {
230                    beta.push(',');
231                    beta.push_str(EFFORT_BETA_HEADER);
232                }
233                rb = rb.header("anthropic-beta", beta);
234                rb
235            }
236        };
237
238        let response = request
239            .json(&body)
240            .send()
241            .await
242            .map_err(|e| AppError::Provider(format!("request failed: {e}")))?;
243
244        let status = response.status();
245        if !status.is_success() {
246            let body = response
247                .text()
248                .await
249                .unwrap_or_else(|_| "failed to read body".into());
250            return Err(AppError::Provider(format!(
251                "API returned {status}: {body}"
252            )));
253        }
254
255        let byte_stream = response.bytes_stream();
256
257        let event_stream = byte_stream
258            .scan(String::new(), |buf, chunk| {
259                let events: Vec<StreamEvent> = match chunk {
260                    Err(e) => vec![StreamEvent::Error { message: e.to_string() }],
261                    Ok(bytes) => {
262                        buf.push_str(&String::from_utf8_lossy(&bytes));
263                        let mut events = Vec::new();
264                        while let Some(pos) = buf.find("\n\n") {
265                            let block = buf[..pos].to_string();
266                            *buf = buf[pos + 2..].to_string();
267                            if let Some((et, d)) = parse_sse_block(&block) {
268                                events.extend(parse_sse_event(&et, &d));
269                            }
270                        }
271                        events
272                    }
273                };
274                async move { Some(events) }
275            })
276            .flat_map(futures::stream::iter);
277
278        Ok(Box::pin(event_stream))
279    }
280}