1use std::collections::HashMap;
6use std::time::{Duration, SystemTime};
7
8use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT};
9use serde_json::Value;
10
11use forge_primitives::usage::oauth::{
12 OauthExtraUsage, OauthUsage, OauthUsageError, OauthUsageWindow,
13};
14use forge_primitives::usage::{ExtraUsage, UsageSnapshot, UsageSourceKind, UsageWindow};
15
16pub const OAUTH_TIMEOUT: Duration = Duration::from_secs(8);
18
19const OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
20const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20";
21
22#[derive(Clone, PartialEq, Eq)]
26pub struct BaseUrlCredential {
27 pub base_url: String,
28 pub bearer: String,
29}
30
31impl std::fmt::Debug for BaseUrlCredential {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.debug_struct("BaseUrlCredential")
36 .field("base_url", &self.base_url)
37 .field("bearer", &"[redacted]")
38 .finish()
39 }
40}
41
42#[derive(Debug, PartialEq, Eq, thiserror::Error)]
45#[error("no usable ANTHROPIC_BASE_URL in the account env for the base-url plan")]
46pub struct MissingBase;
47
48pub fn base_url_credential<S: std::hash::BuildHasher>(
53 env: &HashMap<String, String, S>,
54) -> Result<BaseUrlCredential, MissingBase> {
55 let Some(base_url) =
56 env.get("ANTHROPIC_BASE_URL").map(|value| value.trim()).filter(|value| !value.is_empty())
57 else {
58 return Err(MissingBase);
63 };
64 let bearer = env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str).unwrap_or_default();
65 Ok(BaseUrlCredential { base_url: base_url.to_owned(), bearer: bearer.to_owned() })
66}
67
68pub fn parse_timestamp_value(value: &Value) -> Option<SystemTime> {
73 match value {
74 Value::Number(number) => number
75 .as_i64()
76 .or_else(|| number.as_u64().and_then(|raw| i64::try_from(raw).ok()))
77 .and_then(system_time_from_epoch),
78 Value::String(raw) => parse_iso8601_timestamp(raw)
79 .or_else(|| raw.trim().parse::<i64>().ok().and_then(system_time_from_epoch)),
80 _ => None,
81 }
82}
83
84pub fn system_time_from_epoch(raw: i64) -> Option<SystemTime> {
88 if raw < 0 {
89 return None;
90 }
91 let raw = u64::try_from(raw).ok()?;
92 if raw >= 1_000_000_000_000 {
93 Some(SystemTime::UNIX_EPOCH + Duration::from_millis(raw))
94 } else {
95 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(raw))
96 }
97}
98
99fn parse_iso8601_timestamp(raw: &str) -> Option<SystemTime> {
103 let trimmed = raw.trim();
104 let (date_part, time_part) = trimmed.split_once('T').or_else(|| trimmed.split_once(' '))?;
105
106 let mut date_iter = date_part.split('-');
107 let year = date_iter.next()?.parse::<i32>().ok()?;
108 let month = date_iter.next()?.parse::<u32>().ok()?;
109 let day = date_iter.next()?.parse::<u32>().ok()?;
110
111 let (time_only, offset_seconds) = split_time_and_offset(time_part)?;
112 let mut time_iter = time_only.split(':');
113 let hour = time_iter.next()?.parse::<u32>().ok()?;
114 let minute = time_iter.next()?.parse::<u32>().ok()?;
115 let second_and_fraction = time_iter.next().unwrap_or("0");
116 let (second_raw, fraction_raw) =
117 second_and_fraction.split_once('.').unwrap_or((second_and_fraction, ""));
118 let second = second_raw.parse::<u32>().ok()?;
119
120 let mut nanos = 0u32;
121 let mut factor = 100_000_000u32;
122 for ch in fraction_raw.chars().take(9) {
123 let digit = ch.to_digit(10)?;
124 nanos = nanos.saturating_add(digit.saturating_mul(factor));
125 if factor == 0 {
126 break;
127 }
128 factor /= 10;
129 }
130
131 let days = days_from_civil(year, month, day)?;
132 let day_seconds =
133 i64::from(hour) * 60 * 60 + i64::from(minute) * 60 + i64::from(second) - offset_seconds;
134 let unix_seconds = days.checked_mul(86_400)?.checked_add(day_seconds)?;
135 if unix_seconds < 0 {
136 return None;
137 }
138 let secs = u64::try_from(unix_seconds).ok()?;
139 Some(
140 SystemTime::UNIX_EPOCH + Duration::from_secs(secs) + Duration::from_nanos(u64::from(nanos)),
141 )
142}
143
144fn split_time_and_offset(raw: &str) -> Option<(&str, i64)> {
145 if let Some(rest) = raw.strip_suffix('Z') {
146 return Some((rest, 0));
147 }
148 let split_idx = raw.rfind(['+', '-'])?;
149 let (time_only, offset_str) = raw.split_at(split_idx);
150 let sign: i64 = if offset_str.starts_with('+') { 1 } else { -1 };
151 let offset_str = &offset_str[1..];
152 let (h, m) = offset_str.split_once(':').unwrap_or((offset_str, "0"));
153 let h: i64 = h.parse().ok()?;
154 let m: i64 = m.parse().ok()?;
155 Some((time_only, sign * (h * 3600 + m * 60)))
156}
157
158fn days_from_civil(year: i32, month: u32, day: u32) -> Option<i64> {
160 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
161 return None;
162 }
163 let mut year = i64::from(year);
164 let month = i64::from(month);
165 let day = i64::from(day);
166 year -= i64::from(month <= 2);
167 let era = if year >= 0 { year } else { year - 399 } / 400;
168 let yoe = year - era * 400;
169 let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
170 let doe = yoe * 365 + yoe / 4 - yoe / 100 + day_of_year;
171 Some(era * 146_097 + doe - 719_468)
172}
173
174fn usage_url(base_url: Option<&str>) -> String {
181 match base_url {
182 Some(base) => format!("{}/api/oauth/usage", base.trim_end_matches('/')),
183 None => OAUTH_USAGE_URL.to_owned(),
184 }
185}
186
187pub async fn anthropic_windowed_probe(
194 client: &reqwest::Client,
195 user_agent: &str,
196 base_url: Option<&str>,
197 access_token: &str,
198) -> Result<OauthUsage, OauthUsageError> {
199 let response = client
200 .get(usage_url(base_url))
201 .headers(oauth_headers(user_agent, access_token)?)
202 .send()
203 .await
204 .map_err(|error| OauthUsageError::Network(error.to_string()))?;
205
206 let status = response.status().as_u16();
207 let retry_after = if status == 429 {
213 response
214 .headers()
215 .get(reqwest::header::RETRY_AFTER)
216 .and_then(|v| v.to_str().ok())
217 .and_then(parse_retry_after)
218 } else {
219 None
220 };
221 let body = response
222 .bytes()
223 .await
224 .map_err(|error| OauthUsageError::Network(format!("body read: {error}")))?;
225
226 if status == 200 {
233 tracing::trace!(
234 target: "forge_gateway::anthropic",
235 event_name = "oauth_usage_response",
236 status,
237 outcome = "ok",
238 body_bytes = body.len(),
239 );
240 } else if status == 403 && is_scope_refusal(&body) {
241 tracing::debug!(
244 target: "forge_gateway::anthropic",
245 event_name = "oauth_usage_scope_refusal",
246 status,
247 outcome = "scope_refused",
248 body_suffix = %redacted_body(&body, access_token),
249 );
250 } else {
251 tracing::warn!(
252 target: "forge_gateway::anthropic",
253 event_name = "oauth_usage_response",
254 status,
255 outcome = "non_ok",
256 retry_after_secs = ?retry_after.map(|d| d.as_secs()),
257 body_suffix = %redacted_body(&body, access_token),
258 );
259 }
260
261 match status {
262 200 => serde_json::from_slice::<OauthUsage>(&body).map_err(|error| {
263 OauthUsageError::Decode(redacted_message(&error.to_string(), access_token))
264 }),
265 403 if is_scope_refusal(&body) => Err(OauthUsageError::ScopeInsufficient),
266 401 | 403 => Err(OauthUsageError::Unauthorized(status)),
267 429 => Err(OauthUsageError::RateLimited { retry_after, snapshot: None }),
268 _ => Err(OauthUsageError::HttpStatus(status, redacted_body(&body, access_token))),
269 }
270}
271
272fn oauth_headers(user_agent: &str, access_token: &str) -> Result<HeaderMap, OauthUsageError> {
273 let mut headers = HeaderMap::new();
274 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
275 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
276 headers.insert("anthropic-beta", HeaderValue::from_static(OAUTH_BETA_HEADER));
277 let ua = HeaderValue::from_str(user_agent)
278 .map_err(|error| OauthUsageError::Network(format!("bad UA header: {error}")))?;
279 headers.insert(USER_AGENT, ua);
280 let bearer = HeaderValue::from_str(&format!("Bearer {access_token}"))
281 .map_err(|error| OauthUsageError::Network(format!("bad bearer header: {error}")))?;
282 headers.insert(AUTHORIZATION, bearer);
283 Ok(headers)
284}
285
286fn is_scope_refusal(body: &[u8]) -> bool {
291 let code = serde_json::from_slice::<Value>(body).ok().and_then(|value| {
292 value.get("error")?.get("details")?.get("error_code")?.as_str().map(str::to_owned)
293 });
294 code.as_deref() == Some("oauth_scope_insufficient")
295}
296
297pub fn parse_retry_after(raw: &str) -> Option<Duration> {
304 let trimmed = raw.trim();
305 if let Ok(secs) = trimmed.parse::<u64>() {
306 return Some(Duration::from_secs(secs));
307 }
308 let when = httpdate::parse_http_date(trimmed).ok()?;
309 when.duration_since(SystemTime::now()).ok()
310}
311
312pub fn redacted_body(body: &[u8], bearer: &str) -> String {
324 let text = String::from_utf8_lossy(body).trim().replace('\n', " ");
325 if text.is_empty() {
326 return String::new();
327 }
328 let masked = redacted_message(&text, bearer);
332 let shortened = if masked.chars().count() > 200 {
333 let mut out = masked.chars().take(200).collect::<String>();
334 out.push_str("...");
335 out
336 } else {
337 masked
338 };
339 format!(": {shortened}")
340}
341
342pub fn redacted_message(text: &str, bearer: &str) -> String {
345 let tag: String = bearer.chars().take(TAG).collect();
351 if tag.chars().count() < TAG {
352 if bearer.is_empty() {
353 return text.to_owned();
354 }
355 return text
356 .split_whitespace()
357 .map(|word| {
358 let bare =
362 word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
363 if bare == bearer { "[redacted]" } else { word }
364 })
365 .collect::<Vec<_>>()
366 .join(" ");
367 }
368 let replaced = text.replace(bearer, "[redacted]");
369 let windows: Vec<String> =
373 bearer.chars().collect::<Vec<_>>().windows(TAG).map(|w| w.iter().collect()).collect();
374 let quoted = mask_quoted_runs(&replaced, &windows);
375 quoted
378 .split_whitespace()
379 .map(|word| if windows.iter().any(|w| word.contains(w)) { "[redacted]" } else { word })
380 .collect::<Vec<_>>()
381 .join(" ")
382}
383
384const TAG: usize = 8;
387
388fn mask_quoted_runs(text: &str, windows: &[String]) -> String {
393 let mut out = String::with_capacity(text.len());
394 let mut rest = text;
395 while let Some(open) = rest.find('"') {
396 out.push_str(&rest[..open]);
397 let after = &rest[open + 1..];
398 let Some(close) = after.find('"') else {
399 out.push_str("[redacted]");
400 return out;
401 };
402 let run = &after[..close];
403 if windows.iter().any(|w| run.contains(w)) {
404 out.push_str("[redacted]");
405 } else {
406 out.push('"');
407 out.push_str(run);
408 out.push('"');
409 }
410 rest = &after[close + 1..];
411 }
412 out.push_str(rest);
413 out
414}
415
416pub fn snapshot_from_payload_lenient(payload: OauthUsage) -> UsageSnapshot {
428 UsageSnapshot {
429 source: UsageSourceKind::Oauth,
430 fetched_at: SystemTime::now(),
431 five_hour: map_window(payload.five_hour),
432 seven_day: map_window(payload.seven_day),
433 seven_day_opus: map_window(payload.seven_day_opus),
434 seven_day_sonnet: map_window(payload.seven_day_sonnet),
435 extra_usage: map_extra_usage(payload.extra_usage),
436 spend: None,
437 balance: None,
438 }
439}
440
441pub(crate) fn map_window(payload: Option<OauthUsageWindow>) -> Option<UsageWindow> {
442 let payload = payload?;
443 let utilization = payload.utilization?;
444 Some(UsageWindow {
445 utilization: utilization.clamp(0.0, 100.0),
446 resets_at: payload.resets_at.as_ref().and_then(parse_timestamp_value),
447 reset_description: None,
448 status: None,
449 })
450}
451
452pub(crate) fn map_extra_usage(payload: Option<OauthExtraUsage>) -> Option<ExtraUsage> {
453 let payload = payload?;
454 if payload.is_enabled == Some(false) {
455 return None;
456 }
457 Some(ExtraUsage {
458 monthly_limit: payload.monthly_limit.map(|value| value / 100.0),
459 used_credits: payload.used_credits.map(|value| value / 100.0),
460 utilization: payload.utilization.map(|value| value.clamp(0.0, 100.0)),
461 currency: payload.currency,
462 })
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
475 fn a_logged_body_never_carries_the_key() {
476 let bearer = "sk-or-v1-0123456789abcdef";
477
478 let whole = br#"{"data":{"key":"sk-or-v1-0123456789abcdef","usage_daily":1.5}}"#;
480 let said = redacted_body(whole, bearer);
481 assert!(!said.contains("0123456789abcdef"), "the key never prints: {said}");
482 assert!(said.contains("usage_daily"), "and the drift's evidence stays: {said}");
483
484 let truncated = br#"{"data":{"key":"sk-or-v1-0123456789","usage_daily":1.5}}"#;
486 let said = redacted_body(truncated, bearer);
487 assert!(!said.contains("sk-or-v1-0123456789"), "a truncated copy never prints: {said}");
488 assert!(said.contains("usage_daily"), "and the evidence stays: {said}");
489
490 let echoed = br#"{"error":"invalid key sk-or-v1-0123456789a rejected"}"#;
493 let said = redacted_body(echoed, bearer);
494 assert!(
495 !said.contains("sk-or-v1-0123456789"),
496 "a copy inside a longer run never prints: {said}",
497 );
498
499 let bare = b"upstream said: bad token sk-or-v1-0123456789a, sorry";
501 let said = redacted_body(bare, bearer);
502 assert!(!said.contains("sk-or-v1"), "a bare truncated copy never prints: {said}");
503 assert!(said.contains("upstream said"), "and the host's words stay: {said}");
504
505 let raw = format!("not json: token {bearer} rejected");
507 let said = redacted_body(raw.as_bytes(), bearer);
508 assert!(!said.contains("0123456789abcdef"), "an unquoted token never prints: {said}");
509
510 let padded = format!("{} Token {bearer}", "x".repeat(180));
513 let said = redacted_body(padded.as_bytes(), bearer);
514 assert!(
515 !said.contains("0123456789abcdef"),
516 "the cap never hides a key from the mask: {said}",
517 );
518 assert!(said.contains("Token"), "and the evidence before the key stays: {said}");
519
520 let cut = format!("{} {bearer}", "x".repeat(195));
523 let said = redacted_body(cut.as_bytes(), bearer);
524 assert!(!said.contains("sk-or"), "no fragment of the key survives the cut: {said}");
525
526 let split = br#"{"error":"bad sk-or- v1-0123456789abcdef rejected"}"#;
529 let said = redacted_body(split, bearer);
530 assert!(
531 !said.contains("0123456789abcdef"),
532 "a key split across whitespace never prints: {said}",
533 );
534
535 let broken = br#"bad key "sk-"or-v1-0123456789abcdef here"#;
537 let said = redacted_body(broken, bearer);
538 assert!(
539 !said.contains("0123456789abcdef"),
540 "a key broken across a quote never prints: {said}",
541 );
542
543 let tail = br#"{"data":{"key":"...0123456789abcdef"}}"#;
546 let said = redacted_body(tail, bearer);
547 assert!(!said.contains("0123456789abcdef"), "a tail-only copy never prints: {said}");
548 }
549
550 #[test]
554 fn a_placeholder_bearer_never_eats_the_text() {
555 let said = redacted_body(b"upstream refused the request", "");
556 assert_eq!(said, ": upstream refused the request", "an empty bearer masks nothing");
557
558 let said = redacted_body(b"connecting to localhost:18765 failed", "local");
559 assert!(
560 said.contains("localhost:18765"),
561 "a short bearer does not eat the words containing it: {said}",
562 );
563
564 let said = redacted_message("upstream said: hunter2, apparently", "hunter2");
565 assert_eq!(
566 said, "upstream said: [redacted] apparently",
567 "and a word that IS the short bearer is masked, punctuation and all",
568 );
569 }
570
571 #[test]
574 fn a_redacted_message_never_carries_the_key() {
575 let bearer = "sk-or-v1-0123456789abcdef";
576 let said = redacted_message(
577 "invalid type: string \"sk-or-v1-0123456789abcdef\", expected f64 at line 1 column 8",
578 bearer,
579 );
580 assert!(!said.contains("0123456789abcdef"), "the value never prints: {said}");
581 assert!(said.contains("expected f64"), "and the reason stays: {said}");
582 assert!(said.contains("line 1 column 8"), "with the position: {said}");
583 }
584
585 #[test]
586 fn usage_url_defaults_to_anthropic_host() {
587 assert_eq!(usage_url(None), OAUTH_USAGE_URL);
588 }
589
590 #[test]
591 fn usage_url_uses_base_url_override_and_trims_trailing_slash() {
592 assert_eq!(
593 usage_url(Some("http://localhost:18765")),
594 "http://localhost:18765/api/oauth/usage",
595 );
596 assert_eq!(
597 usage_url(Some("http://localhost:18765/")),
598 "http://localhost:18765/api/oauth/usage",
599 "trailing slash trimmed so host and host/ behave identically",
600 );
601 }
602
603 #[test]
604 fn base_url_credential_reads_the_base_and_bearer_pair() {
605 let mut env = HashMap::new();
606 env.insert("ANTHROPIC_BASE_URL".to_owned(), "http://localhost:18765".to_owned());
607 env.insert("ANTHROPIC_AUTH_TOKEN".to_owned(), "sk-codex".to_owned());
608 env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_owned(), "setup-token".to_owned());
611 assert_eq!(
612 base_url_credential(&env),
613 Ok(BaseUrlCredential {
614 base_url: "http://localhost:18765".to_owned(),
615 bearer: "sk-codex".to_owned(),
616 }),
617 );
618 }
619
620 #[test]
621 fn base_url_credential_trims_whitespace_around_the_base() {
622 let mut env = HashMap::new();
623 env.insert("ANTHROPIC_BASE_URL".to_owned(), " http://localhost:18765 ".to_owned());
624 let credential = base_url_credential(&env).expect("credential");
625 assert_eq!(credential.base_url, "http://localhost:18765");
626 }
627
628 #[test]
631 fn a_credential_never_reaches_the_base_url_credential_debug_output() {
632 let mut env = HashMap::new();
633 env.insert("ANTHROPIC_BASE_URL".to_owned(), "https://proxy.example".to_owned());
634 env.insert("ANTHROPIC_AUTH_TOKEN".to_owned(), "bearer-supersecret".to_owned());
635 let credential = base_url_credential(&env).expect("credential");
636 let rendered = format!("{credential:?}");
637 assert!(!rendered.contains("bearer-supersecret"), "the bearer leaked: {rendered}");
638 assert!(
639 rendered.contains("https://proxy.example"),
640 "the base url stays readable: {rendered}",
641 );
642 }
643
644 #[test]
647 fn base_url_credential_defaults_the_bearer_to_empty() {
648 let mut env = HashMap::new();
649 env.insert("ANTHROPIC_BASE_URL".to_owned(), "http://localhost:18765".to_owned());
650 assert_eq!(base_url_credential(&env).expect("credential").bearer, "");
651 assert_eq!(base_url_credential(&HashMap::new()).expect_err("no base"), MissingBase);
652 }
653
654 #[test]
655 fn base_url_credential_rejects_a_base_that_is_empty_after_trim() {
656 let mut env = HashMap::new();
657 env.insert("ANTHROPIC_BASE_URL".to_owned(), " ".to_owned());
658 assert_eq!(base_url_credential(&env), Err(MissingBase));
659 }
660
661 #[tokio::test]
665 async fn a_down_endpoint_is_a_network_failure_and_the_probe_returns() {
666 let client = reqwest::Client::builder().build().expect("client");
667 let result = tokio::time::timeout(
668 Duration::from_secs(5),
669 anthropic_windowed_probe(&client, "claude-code/1.0.0", Some("http://127.0.0.1:1"), "t"),
670 )
671 .await
672 .expect("the probe returns against an unreachable endpoint");
673 assert!(
674 matches!(result, Err(OauthUsageError::Network(_))),
675 "a refused connect is the Network class, not a status or decode; got {result:?}"
676 );
677 }
678
679 #[test]
680 fn retry_after_integer_seconds_round_trip() {
681 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
682 assert_eq!(parse_retry_after(" 120 "), Some(Duration::from_secs(120)));
683 assert_eq!(parse_retry_after("3600"), Some(Duration::from_secs(3600)));
684 }
685
686 #[test]
687 fn retry_after_http_date_returns_delta_from_now() {
688 let target = SystemTime::now() + Duration::from_secs(3600);
690 let formatted = httpdate::fmt_http_date(target);
691 let parsed = parse_retry_after(&formatted).expect("http-date parses");
692 assert!(parsed.as_secs() >= 3595 && parsed.as_secs() <= 3605, "got {parsed:?}");
694 }
695
696 #[test]
697 fn retry_after_past_http_date_returns_none() {
698 let past = SystemTime::now() - Duration::from_secs(3600);
700 let formatted = httpdate::fmt_http_date(past);
701 assert!(parse_retry_after(&formatted).is_none());
702 }
703
704 #[test]
705 fn retry_after_garbage_returns_none() {
706 assert!(parse_retry_after("not a duration").is_none());
707 assert!(parse_retry_after("").is_none());
708 }
709
710 #[test]
711 fn decodes_sparse_oauth_payload() {
712 let usage: OauthUsage = serde_json::from_slice(
713 br#"{
714 "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
715 "seven_day_sonnet": { "utilization": 5 },
716 "unknown_field": true
717 }"#,
718 )
719 .expect("decode");
720 assert_eq!(usage.five_hour.as_ref().and_then(|w| w.utilization), Some(12.5));
721 assert_eq!(usage.seven_day_sonnet.as_ref().and_then(|w| w.utilization), Some(5.0));
722 assert!(usage.seven_day.is_none());
723 }
724
725 #[test]
726 fn decodes_extra_usage_in_minor_units() {
727 let usage: OauthUsage = serde_json::from_slice(
728 br#"{
729 "five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
730 "extra_usage": {
731 "is_enabled": true,
732 "monthly_limit": 2000,
733 "used_credits": 1240,
734 "utilization": 62,
735 "currency": "USD"
736 }
737 }"#,
738 )
739 .expect("decode");
740 let extra = usage.extra_usage.expect("extra usage");
741 assert_eq!(extra.monthly_limit, Some(2000.0));
742 assert_eq!(extra.used_credits, Some(1240.0));
743 assert_eq!(extra.utilization, Some(62.0));
744 assert_eq!(extra.currency.as_deref(), Some("USD"));
745 }
746
747 #[test]
748 fn lenient_maps_seven_day_only_without_erroring() {
749 let payload: OauthUsage =
756 serde_json::from_slice(br#"{"seven_day":{"utilization":10.0}}"#).expect("decode");
757 let snapshot = snapshot_from_payload_lenient(payload);
758 assert!(snapshot.five_hour.is_none());
759 assert_eq!(snapshot.seven_day.as_ref().map(|window| window.utilization), Some(10.0));
760 }
761
762 #[test]
763 fn lenient_maps_empty_payload_to_all_none_snapshot() {
764 let snapshot = snapshot_from_payload_lenient(OauthUsage::default());
767 assert!(snapshot.five_hour.is_none());
768 assert!(snapshot.seven_day.is_none());
769 assert!(snapshot.seven_day_opus.is_none());
770 assert!(snapshot.seven_day_sonnet.is_none());
771 assert_eq!(snapshot.source, UsageSourceKind::Oauth);
772 }
773
774 #[test]
775 fn lenient_maps_five_hour_only_populated() {
776 let payload: OauthUsage = serde_json::from_slice(
777 br#"{ "five_hour": { "utilization": 42.0, "resets_at": "2025-12-25T12:00:00.000Z" } }"#,
778 )
779 .expect("decode");
780 let snapshot = snapshot_from_payload_lenient(payload);
781 assert_eq!(snapshot.five_hour.as_ref().map(|window| window.utilization), Some(42.0));
782 assert!(snapshot.seven_day.is_none());
783 }
784
785 #[test]
786 fn maps_extra_usage_amounts_in_major_units() {
787 let payload: OauthUsage = serde_json::from_slice(
788 br#"{
789 "five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
790 "extra_usage": {
791 "is_enabled": true,
792 "monthly_limit": 2000,
793 "used_credits": 1240,
794 "utilization": 62,
795 "currency": "USD"
796 }
797 }"#,
798 )
799 .expect("decode");
800 let extra = map_extra_usage(payload.extra_usage).expect("extra usage");
801 assert_eq!(extra.monthly_limit, Some(20.0));
802 assert_eq!(extra.used_credits, Some(12.4));
803 assert_eq!(extra.utilization, Some(62.0));
804 assert_eq!(extra.currency.as_deref(), Some("USD"));
805 }
806
807 #[test]
808 fn map_window_clamps_utilization_and_parses_resets_at() {
809 let payload: OauthUsageWindow = serde_json::from_str(
810 r#"{ "utilization": 140.0, "resets_at": "2025-12-25T12:00:00.000Z" }"#,
811 )
812 .expect("decode");
813 let window = map_window(Some(payload)).expect("window");
814 assert!(
815 (window.utilization - 100.0).abs() < f64::EPSILON,
816 "utilization clamps to the percentage range",
817 );
818 assert_eq!(
819 window.resets_at,
820 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_766_664_000)),
821 );
822 }
823
824 #[test]
828 fn a_403_scope_refusal_body_is_recognized_and_other_403s_are_not() {
829 let refused = br#"{"type":"error","error":{"type":"permission_error","message":"OAuth token does not meet scope requirement user:profile","details":{"error_code":"oauth_scope_insufficient"}}}"#;
830 assert!(
831 is_scope_refusal(refused),
832 "the verified refusal shape classifies as a scope refusal"
833 );
834 assert!(
835 !is_scope_refusal(br#"{"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token","details":{}}}"#),
836 "an authentication_error body is not a scope refusal",
837 );
838 assert!(!is_scope_refusal(b"not json"), "a non-JSON body is not a scope refusal");
839 assert!(
840 !is_scope_refusal(br#"{"error":{"type":"permission_error","message":"no details"}}"#),
841 "a permission_error without the error_code is not a scope refusal",
842 );
843 assert!(
844 !is_scope_refusal(
845 br#"{"error":{"details":{"error_code":"oauth_token_revoked"},"message":"x"}}"#
846 ),
847 "a populated but different error_code is not a scope refusal",
848 );
849 }
850
851 #[test]
852 fn parses_iso8601_timestamp() {
853 let parsed = parse_iso8601_timestamp("2025-12-25T12:00:00.000Z").expect("timestamp");
854 assert_eq!(
855 parsed.duration_since(SystemTime::UNIX_EPOCH).expect("after epoch"),
856 Duration::from_secs(1_766_664_000),
857 "2025-12-25T12:00:00Z == epoch second 1766664000"
858 );
859 }
860
861 #[test]
862 fn parses_numeric_millisecond_timestamp() {
863 let parsed =
864 parse_timestamp_value(&serde_json::json!(1_735_128_000_000_i64)).expect("timestamp");
865 assert_eq!(
866 parsed.duration_since(SystemTime::UNIX_EPOCH).expect("after epoch"),
867 Duration::from_secs(1_735_128_000),
868 "milliseconds must land on the matching epoch second"
869 );
870 }
871
872 #[test]
875 fn parses_negative_offset_timestamp() {
876 let parsed = parse_iso8601_timestamp("2025-12-25T06:30:00-05:30").expect("timestamp");
877 assert_eq!(
878 parsed.duration_since(SystemTime::UNIX_EPOCH).expect("after epoch"),
879 Duration::from_secs(1_766_664_000),
880 "06:30-05:30 is the same instant as 12:00Z"
881 );
882 }
883
884 #[test]
888 fn rejects_pre_epoch_timestamps() {
889 assert!(parse_iso8601_timestamp("1969-12-31T23:59:59.000Z").is_none());
890 }
891}