stynx_code_provider/infrastructure/
anthropic_provider.rs1use 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}