1use serde::Serialize;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum TelemetryLevel {
19 #[default]
20 Off,
21 Basic,
22 Full,
23}
24
25impl TelemetryLevel {
26 pub fn from_str_key(s: &str) -> Self {
27 match s.trim().to_ascii_lowercase().as_str() {
28 "basic" | "on" | "1" | "true" => Self::Basic,
29 "full" => Self::Full,
30 _ => Self::Off,
31 }
32 }
33
34 pub fn enabled(&self) -> bool {
35 !matches!(self, Self::Off)
36 }
37
38 pub fn as_str(&self) -> &'static str {
39 match self {
40 Self::Off => "off",
41 Self::Basic => "basic",
42 Self::Full => "full",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Default, Serialize)]
49pub struct UsageRecord {
50 pub input: u64,
51 pub output: u64,
52 pub cache_read: u64,
53 pub cache_write: u64,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub cache_write_5m: Option<u64>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub cache_write_1h: Option<u64>,
60 pub hit_pct: f64,
62}
63
64impl UsageRecord {
65 pub fn compute_hit_pct(&mut self) {
66 let total = self.input + self.cache_read + self.cache_write;
67 self.hit_pct = if total > 0 {
68 (self.cache_read as f64 / total as f64 * 1000.0).round() / 10.0
69 } else {
70 0.0
71 };
72 }
73}
74
75#[derive(Debug, Clone, Default, Serialize)]
78pub struct RateLimitRecord {
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub requests_limit: Option<u64>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub requests_remaining: Option<u64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub tokens_limit: Option<u64>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub tokens_remaining: Option<u64>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub input_tokens_remaining: Option<u64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub output_tokens_remaining: Option<u64>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub tokens_reset: Option<String>,
94}
95
96impl RateLimitRecord {
97 pub fn is_empty(&self) -> bool {
98 self.requests_limit.is_none()
99 && self.requests_remaining.is_none()
100 && self.tokens_limit.is_none()
101 && self.tokens_remaining.is_none()
102 && self.input_tokens_remaining.is_none()
103 && self.output_tokens_remaining.is_none()
104 && self.tokens_reset.is_none()
105 }
106}
107
108#[derive(Debug, Clone, Serialize)]
111pub struct CacheDiagRecord {
112 pub miss_reason: String,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub missed_tokens: Option<u64>,
117}
118
119#[derive(Debug, Clone, Default, Serialize)]
121pub struct ContextRecord {
122 pub messages: usize,
123 pub tools: usize,
124 pub system_bytes: usize,
125 pub breakpoints: Vec<usize>,
127}
128
129#[derive(Debug, Clone, Default, Serialize)]
131pub struct TelemetryRecord {
132 pub ts: u64,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub request_id: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub msg_id: Option<String>,
139 pub model: String,
140 pub attempt: u32,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub refusal_retries_used: Option<u32>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub ttft_ms: Option<u64>,
148 pub total_ms: u64,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub stop_reason: Option<String>,
152 pub usage: UsageRecord,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub ratelimit: Option<RateLimitRecord>,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub cache_diag: Option<CacheDiagRecord>,
157 pub context: ContextRecord,
158}
159
160impl TelemetryRecord {
161 pub fn now_ms() -> u64 {
162 SystemTime::now()
163 .duration_since(UNIX_EPOCH)
164 .map(|d| d.as_millis() as u64)
165 .unwrap_or(0)
166 }
167}
168
169fn header_u64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
171 headers.get(name)?.to_str().ok()?.parse().ok()
172}
173
174fn header_string(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
176 Some(headers.get(name)?.to_str().ok()?.to_string())
177}
178
179pub fn ratelimit_from_headers(headers: &reqwest::header::HeaderMap) -> RateLimitRecord {
181 RateLimitRecord {
182 requests_limit: header_u64(headers, "anthropic-ratelimit-requests-limit"),
183 requests_remaining: header_u64(headers, "anthropic-ratelimit-requests-remaining"),
184 tokens_limit: header_u64(headers, "anthropic-ratelimit-tokens-limit"),
185 tokens_remaining: header_u64(headers, "anthropic-ratelimit-tokens-remaining"),
186 input_tokens_remaining: header_u64(headers, "anthropic-ratelimit-input-tokens-remaining"),
187 output_tokens_remaining: header_u64(headers, "anthropic-ratelimit-output-tokens-remaining"),
188 tokens_reset: header_string(headers, "anthropic-ratelimit-tokens-reset"),
189 }
190}
191
192pub fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
194 header_string(headers, "request-id")
195}
196
197pub const RETRY_DELAY_CAP: Duration = Duration::from_secs(60);
200
201pub fn retry_delay_from_headers(
218 headers: &reqwest::header::HeaderMap,
219 attempt: u32,
220) -> (Duration, bool) {
221 let now_secs = SystemTime::now()
222 .duration_since(UNIX_EPOCH)
223 .map(|d| d.as_secs())
224 .unwrap_or(0);
225
226 if let Some(ra) = header_string(headers, "retry-after") {
228 let ra = ra.trim();
229 if let Ok(secs) = ra.parse::<u64>() {
231 let d = Duration::from_secs(secs).min(RETRY_DELAY_CAP);
232 return (d, true);
233 }
234 if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(ra) {
236 let reset_secs = dt.timestamp().max(0) as u64;
237 let wait = reset_secs.saturating_sub(now_secs);
238 let d = Duration::from_secs(wait).min(RETRY_DELAY_CAP);
239 return (d, true);
240 }
241 }
242
243 let mut min_wait: Option<u64> = None;
245 for name in &[
246 "anthropic-ratelimit-tokens-reset",
247 "anthropic-ratelimit-requests-reset",
248 ] {
249 if let Some(ts) = header_string(headers, name) {
250 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts.trim()) {
251 let reset_secs = dt.timestamp().max(0) as u64;
252 let wait = reset_secs.saturating_sub(now_secs);
253 min_wait = Some(min_wait.map_or(wait, |prev| prev.min(wait)));
254 }
255 }
256 }
257 if let Some(wait) = min_wait {
258 let d = Duration::from_secs(wait).min(RETRY_DELAY_CAP);
259 return (d, true);
260 }
261
262 let d = Duration::from_millis(1000 * 2u64.pow(attempt.saturating_sub(1)));
264 (d, false)
265}
266
267fn default_log_path() -> Option<std::path::PathBuf> {
269 let home = std::env::var("HOME").ok()?;
270 Some(std::path::PathBuf::from(home).join(".cache/synaps/api-log.jsonl"))
271}
272
273pub fn write_record(record: &TelemetryRecord) {
279 let Some(path) = default_log_path() else { return };
280 let Ok(line) = serde_json::to_string(record) else { return };
281
282 if let Some(parent) = path.parent() {
283 let _ = std::fs::create_dir_all(parent);
284 }
285
286 const MAX_BYTES: u64 = 50 * 1024 * 1024;
290 if let Ok(meta) = std::fs::metadata(&path) {
291 if meta.len() > MAX_BYTES {
292 let mut rotated = path.as_os_str().to_owned();
293 rotated.push(".1");
294 let _ = std::fs::rename(&path, std::path::PathBuf::from(rotated));
295 }
296 }
297
298 use std::os::unix::fs::OpenOptionsExt;
299
300 let result = std::fs::OpenOptions::new()
301 .create(true)
302 .append(true)
303 .mode(0o600)
304 .custom_flags(libc::O_NOFOLLOW)
305 .open(&path);
306 if let Ok(mut f) = result {
307 use std::io::Write;
308 let _ = writeln!(f, "{}", line);
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn level_parses_known_values() {
318 assert_eq!(TelemetryLevel::from_str_key("off"), TelemetryLevel::Off);
319 assert_eq!(TelemetryLevel::from_str_key("basic"), TelemetryLevel::Basic);
320 assert_eq!(TelemetryLevel::from_str_key("full"), TelemetryLevel::Full);
321 assert_eq!(TelemetryLevel::from_str_key("FULL"), TelemetryLevel::Full);
322 assert_eq!(TelemetryLevel::from_str_key("true"), TelemetryLevel::Basic);
323 assert_eq!(TelemetryLevel::from_str_key("garbage"), TelemetryLevel::Off);
324 assert_eq!(TelemetryLevel::from_str_key(""), TelemetryLevel::Off);
325 }
326
327 #[test]
328 fn level_enabled() {
329 assert!(!TelemetryLevel::Off.enabled());
330 assert!(TelemetryLevel::Basic.enabled());
331 assert!(TelemetryLevel::Full.enabled());
332 }
333
334 #[test]
335 fn hit_pct_computation() {
336 let mut u = UsageRecord {
337 input: 100,
338 cache_read: 800,
339 cache_write: 100,
340 ..Default::default()
341 };
342 u.compute_hit_pct();
343 assert_eq!(u.hit_pct, 80.0);
344 }
345
346 #[test]
347 fn hit_pct_zero_total() {
348 let mut u = UsageRecord::default();
349 u.compute_hit_pct();
350 assert_eq!(u.hit_pct, 0.0);
351 }
352
353 #[test]
354 fn hit_pct_rounds_to_one_decimal() {
355 let mut u = UsageRecord {
356 input: 1,
357 cache_read: 2,
358 cache_write: 0,
359 ..Default::default()
360 };
361 u.compute_hit_pct();
362 assert_eq!(u.hit_pct, 66.7);
363 }
364
365 #[test]
366 fn record_serializes_skipping_none_fields() {
367 let record = TelemetryRecord {
368 ts: 1,
369 model: "claude-sonnet-4-6".to_string(),
370 attempt: 1,
371 total_ms: 100,
372 ..Default::default()
373 };
374 let json = serde_json::to_string(&record).unwrap();
375 assert!(!json.contains("request_id"));
376 assert!(!json.contains("ratelimit"));
377 assert!(!json.contains("cache_diag"));
378 assert!(json.contains("\"model\":\"claude-sonnet-4-6\""));
379 }
380
381 #[test]
382 fn ratelimit_empty_detection() {
383 assert!(RateLimitRecord::default().is_empty());
384 let r = RateLimitRecord {
385 requests_remaining: Some(10),
386 ..Default::default()
387 };
388 assert!(!r.is_empty());
389 }
390
391 #[test]
392 fn ratelimit_parses_headers() {
393 let mut headers = reqwest::header::HeaderMap::new();
394 headers.insert("anthropic-ratelimit-requests-limit", "5000".parse().unwrap());
395 headers.insert("anthropic-ratelimit-requests-remaining", "4900".parse().unwrap());
396 headers.insert("anthropic-ratelimit-tokens-reset", "2026-06-11T01:46:00Z".parse().unwrap());
397 let r = ratelimit_from_headers(&headers);
398 assert_eq!(r.requests_limit, Some(5000));
399 assert_eq!(r.requests_remaining, Some(4900));
400 assert_eq!(r.tokens_reset.as_deref(), Some("2026-06-11T01:46:00Z"));
401 assert_eq!(r.tokens_limit, None);
402 }
403
404 #[test]
405 fn ratelimit_ignores_malformed_values() {
406 let mut headers = reqwest::header::HeaderMap::new();
407 headers.insert("anthropic-ratelimit-requests-limit", "not-a-number".parse().unwrap());
408 let r = ratelimit_from_headers(&headers);
409 assert_eq!(r.requests_limit, None);
410 }
411}
412
413#[cfg(test)]
414mod retry_delay_tests {
415 use super::*;
416 use std::time::{Duration, SystemTime, UNIX_EPOCH};
417
418 #[test]
419 fn integer_retry_after() {
420 let mut h = reqwest::header::HeaderMap::new();
421 h.insert("retry-after", "45".parse().unwrap());
422 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
423 assert_eq!(d, Duration::from_secs(45));
424 assert!(from_hdr);
425 }
426
427 #[test]
428 fn integer_retry_after_capped() {
429 let mut h = reqwest::header::HeaderMap::new();
430 h.insert("retry-after", "300".parse().unwrap()); let (d, from_hdr) = retry_delay_from_headers(&h, 1);
432 assert_eq!(d, RETRY_DELAY_CAP);
433 assert!(from_hdr);
434 }
435
436 #[test]
437 fn rfc3339_reset_future() {
438 let future_secs = SystemTime::now()
439 .duration_since(UNIX_EPOCH)
440 .unwrap()
441 .as_secs()
442 + 30;
443 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
444 let ts = dt.to_rfc3339();
445
446 let mut h = reqwest::header::HeaderMap::new();
447 h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
448 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
449 assert!(d.as_secs() >= 28 && d.as_secs() <= 32, "unexpected delay: {:?}", d);
450 assert!(from_hdr);
451 }
452
453 #[test]
454 fn rfc3339_reset_beyond_cap() {
455 let future_secs = SystemTime::now()
456 .duration_since(UNIX_EPOCH)
457 .unwrap()
458 .as_secs()
459 + 600;
460 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
461 let ts = dt.to_rfc3339();
462
463 let mut h = reqwest::header::HeaderMap::new();
464 h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
465 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
466 assert_eq!(d, RETRY_DELAY_CAP);
467 assert!(from_hdr);
468 }
469
470 #[test]
471 fn no_headers_exponential_fallback() {
472 let h = reqwest::header::HeaderMap::new();
473 let (d1, hdr1) = retry_delay_from_headers(&h, 1);
474 let (d2, hdr2) = retry_delay_from_headers(&h, 2);
475 let (d3, hdr3) = retry_delay_from_headers(&h, 3);
476 assert_eq!(d1, Duration::from_secs(1));
477 assert_eq!(d2, Duration::from_secs(2));
478 assert_eq!(d3, Duration::from_secs(4));
479 assert!(!hdr1 && !hdr2 && !hdr3);
480 }
481
482 #[test]
483 fn prefers_retry_after_over_rfc3339() {
484 let future_secs = SystemTime::now()
485 .duration_since(UNIX_EPOCH)
486 .unwrap()
487 .as_secs()
488 + 30;
489 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
490 let ts = dt.to_rfc3339();
491
492 let mut h = reqwest::header::HeaderMap::new();
493 h.insert("retry-after", "10".parse().unwrap());
494 h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
495 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
496 assert_eq!(d, Duration::from_secs(10));
497 assert!(from_hdr);
498 }
499
500 #[test]
501 fn min_of_multiple_ratelimit_reset_headers() {
502 let now = SystemTime::now()
504 .duration_since(UNIX_EPOCH)
505 .unwrap()
506 .as_secs();
507 let tokens_dt = chrono::DateTime::<chrono::Utc>::from_timestamp((now + 15) as i64, 0).unwrap();
508 let requests_dt = chrono::DateTime::<chrono::Utc>::from_timestamp((now + 45) as i64, 0).unwrap();
509
510 let mut h = reqwest::header::HeaderMap::new();
511 h.insert("anthropic-ratelimit-tokens-reset", tokens_dt.to_rfc3339().parse().unwrap());
512 h.insert("anthropic-ratelimit-requests-reset", requests_dt.to_rfc3339().parse().unwrap());
513 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
514 assert!(d.as_secs() >= 13 && d.as_secs() <= 17, "should be ~15s, got {:?}", d);
515 assert!(from_hdr);
516 }
517}