1use std::time::Duration;
47
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50
51use super::broker::BrokerError;
52use super::provider::OAuthProviderId;
53
54pub const SCHEMA_VERSION: u32 = 1;
58
59pub const ANTHROPIC_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
61pub const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
63pub const CODEX_RESET_CREDITS_URL: &str =
66 "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
67pub const KIMI_USAGE_URL: &str = "https://api.kimi.com/coding/v1/usages";
69pub const GROK_BILLING_URL: &str = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
71
72pub const USAGE_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
74pub const USAGE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
76pub const USAGE_MAX_BODY_BYTES: usize = 256 * 1024;
78
79const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
80const CODEX_ACCOUNT_HEADER: &str = "chatgpt-account-id";
81const GROK_TOKEN_AUTH_HEADER: &str = "x-xai-token-auth";
82const GROK_TOKEN_AUTH_VALUE: &str = "xai-grok-cli";
83const USER_AGENT: &str = concat!("SynapsCLI/", env!("CARGO_PKG_VERSION"));
84
85const SECS_PER_MINUTE: u64 = 60;
86const SECS_PER_HOUR: u64 = 3_600;
87const SECS_PER_DAY: u64 = 86_400;
88const SECS_PER_WEEK: u64 = 604_800;
89
90pub fn supports_usage(provider: OAuthProviderId) -> bool {
93 matches!(
94 provider,
95 OAuthProviderId::Anthropic
96 | OAuthProviderId::OpenAiCodex
97 | OAuthProviderId::KimiCode
98 | OAuthProviderId::Xai
99 )
100}
101
102pub fn supported_usage_providers() -> &'static [OAuthProviderId] {
104 &[
105 OAuthProviderId::Anthropic,
106 OAuthProviderId::OpenAiCodex,
107 OAuthProviderId::KimiCode,
108 OAuthProviderId::Xai,
109 ]
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "kind", rename_all = "snake_case")]
118pub enum UsageError {
119 UnsupportedProvider { provider: String },
121 AccountIdUnavailable,
124 IdentityMismatch,
127 InvalidEndpointOverride,
129 Unauthorized { status: u16 },
131 RateLimited {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 retry_after_s: Option<u64>,
136 },
137 UpstreamStatus {
140 status: u16,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 retry_after_s: Option<u64>,
143 },
144 Redirected { status: u16 },
146 Timeout,
148 Transport { detail: String },
150 BodyTooLarge { cap: usize },
151 Malformed { detail: String },
153}
154
155impl UsageError {
156 pub fn kind(&self) -> &'static str {
158 match self {
159 Self::UnsupportedProvider { .. } => "unsupported_provider",
160 Self::AccountIdUnavailable => "account_id_unavailable",
161 Self::IdentityMismatch => "identity_mismatch",
162 Self::InvalidEndpointOverride => "invalid_endpoint_override",
163 Self::Unauthorized { .. } => "unauthorized",
164 Self::RateLimited { .. } => "rate_limited",
165 Self::UpstreamStatus { .. } => "upstream_status",
166 Self::Redirected { .. } => "redirected",
167 Self::Timeout => "timeout",
168 Self::Transport { .. } => "transport",
169 Self::BodyTooLarge { .. } => "body_too_large",
170 Self::Malformed { .. } => "malformed",
171 }
172 }
173
174 pub fn is_auth_failure(&self) -> bool {
176 matches!(
177 self,
178 Self::Unauthorized { .. } | Self::AccountIdUnavailable | Self::IdentityMismatch
179 )
180 }
181
182 pub fn failure(&self) -> super::quota_policy::ObservationFailure {
185 use super::quota_policy::{AccountFailureKind as K, ObservationFailure};
186 match self {
187 Self::UnsupportedProvider { .. } | Self::InvalidEndpointOverride => {
188 ObservationFailure::new(K::Unsupported)
189 }
190 Self::AccountIdUnavailable | Self::IdentityMismatch => {
191 ObservationFailure::new(K::UsageUnauthorized)
192 }
193 Self::Unauthorized { status } => {
194 ObservationFailure::new(K::UsageUnauthorized).with_status(*status)
195 }
196 Self::RateLimited { retry_after_s } => ObservationFailure::new(K::UsageRateLimited)
197 .with_status(429)
198 .with_retry_after(*retry_after_s),
199 Self::UpstreamStatus {
200 status,
201 retry_after_s,
202 } => ObservationFailure::new(K::UsageUpstream)
203 .with_status(*status)
204 .with_retry_after(*retry_after_s),
205 Self::Redirected { status } => {
206 ObservationFailure::new(K::UsageUpstream).with_status(*status)
207 }
208 Self::Timeout => ObservationFailure::new(K::UsageTimeout),
210 Self::Transport { .. } => ObservationFailure::new(K::UsageTransport),
211 Self::BodyTooLarge { .. } | Self::Malformed { .. } => {
212 ObservationFailure::new(K::UsageMalformed)
213 }
214 }
215 }
216
217 pub fn into_broker_error(self) -> BrokerError {
219 match self {
220 Self::UnsupportedProvider { provider } => BrokerError::UnsupportedCapability {
221 provider,
222 capability: "usage".to_string(),
223 },
224 Self::Unauthorized { .. } | Self::AccountIdUnavailable | Self::IdentityMismatch => {
225 BrokerError::Credential(format!("usage: {self}"))
226 }
227 Self::InvalidEndpointOverride => BrokerError::Denied(format!("usage: {self}")),
228 other => BrokerError::Transport(format!("usage: {other}")),
229 }
230 }
231}
232
233impl std::fmt::Display for UsageError {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 match self {
236 Self::UnsupportedProvider { provider } => {
237 write!(f, "no usage adapter for provider '{provider}'")
238 }
239 Self::AccountIdUnavailable => {
240 write!(f, "access token carries no account id; cannot pair account header")
241 }
242 Self::IdentityMismatch => {
243 write!(f, "usage response names a different account than the token")
244 }
245 Self::InvalidEndpointOverride => {
246 write!(f, "endpoint override must be a loopback test server")
247 }
248 Self::Unauthorized { status } => {
249 write!(f, "provider rejected the access token (HTTP {status})")
250 }
251 Self::RateLimited {
252 retry_after_s: Some(secs),
253 } => write!(
254 f,
255 "usage endpoint rate limited (HTTP 429, retry after {secs}s)"
256 ),
257 Self::RateLimited {
258 retry_after_s: None,
259 } => {
260 write!(f, "usage endpoint rate limited (HTTP 429)")
261 }
262 Self::UpstreamStatus {
263 status,
264 retry_after_s: Some(secs),
265 } => write!(
266 f,
267 "usage request failed: HTTP {status}, retry after {secs}s"
268 ),
269 Self::UpstreamStatus { status, .. } => write!(f, "usage request failed: HTTP {status}"),
270 Self::Redirected { status } => {
271 write!(f, "usage endpoint redirected (HTTP {status}); not followed")
272 }
273 Self::Timeout => write!(f, "usage request timed out"),
274 Self::Transport { detail } => write!(f, "usage transport error: {detail}"),
275 Self::BodyTooLarge { cap } => write!(f, "usage response exceeded {cap} bytes"),
276 Self::Malformed { detail } => write!(f, "usage response malformed: {detail}"),
277 }
278 }
279}
280
281impl std::error::Error for UsageError {}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288#[serde(tag = "state", rename_all = "snake_case")]
289pub enum UsedPercent {
290 Valid { percent: f64 },
291 Unknown { reason: String },
292}
293
294pub const OVERRUN_PERCENT_MAX: f64 = 1000.0;
299
300impl UsedPercent {
301 pub fn valid(&self) -> Option<f64> {
302 match self {
303 Self::Valid { percent } => Some(*percent),
304 Self::Unknown { .. } => None,
305 }
306 }
307 pub fn is_known(&self) -> bool {
308 matches!(self, Self::Valid { .. })
309 }
310 fn unknown(reason: &str) -> Self {
311 Self::Unknown {
312 reason: reason.to_string(),
313 }
314 }
315 pub fn from_f64(value: f64) -> Self {
319 if !value.is_finite() {
320 Self::unknown("not a number")
321 } else if value > 100.0 && value <= OVERRUN_PERCENT_MAX {
322 Self::Valid { percent: 100.0 }
323 } else if !(0.0..=100.0).contains(&value) {
324 Self::unknown("out of range")
325 } else {
326 Self::Valid { percent: value }
327 }
328 }
329 fn from_json(value: Option<&Value>) -> Self {
331 match value {
332 None => Self::unknown("missing"),
333 Some(Value::Null) => Self::unknown("null"),
334 Some(v) => match number_from_value(v) {
335 Some(n) => Self::from_f64(n),
336 None => Self::unknown("not a number"),
337 },
338 }
339 }
340 fn from_ratio(used: Option<f64>, limit: Option<f64>) -> Self {
342 match (used, limit) {
343 (None, _) | (_, None) => Self::unknown("missing"),
344 (_, Some(l)) if l <= 0.0 => Self::unknown("limit is zero"),
345 (Some(u), Some(l)) => Self::from_f64(u / l * 100.0),
346 }
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(tag = "kind", rename_all = "snake_case")]
353pub enum WindowScope {
354 Account,
356 Model { model: String },
358 Feature { feature: String },
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365pub enum ResetKind {
366 QuotaWindow,
368 BillingRenewal,
370}
371
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub struct UsageWindow {
375 pub id: String,
377 pub label: String,
379 pub scope: WindowScope,
380 pub duration_secs: Option<u64>,
382 pub used_percent: UsedPercent,
383 pub used: Option<f64>,
385 pub limit: Option<f64>,
386 pub reset_at: Option<u64>,
388 pub reset_kind: ResetKind,
389 pub limit_reached: Option<bool>,
391}
392
393impl UsageWindow {
394 pub fn is_exhausted(&self) -> bool {
396 self.limit_reached == Some(true) || self.used_percent.valid().is_some_and(|p| p >= 100.0)
397 }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum Availability {
404 Available,
405 Exhausted,
406 Unknown,
407}
408
409#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct ModelAvailability {
417 pub model: String,
418 pub availability: Availability,
419 pub used_percent: UsedPercent,
420 pub reset_at: Option<u64>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub credits_would_enable: Option<bool>,
428 #[serde(default)]
430 pub source: String,
431}
432
433impl ModelAvailability {
434 pub fn matches_model(&self, requested: &str) -> bool {
438 let entry = self.model.to_ascii_lowercase();
439 let req = requested.trim().to_ascii_lowercase();
440 if entry.is_empty() || req.is_empty() {
441 return false;
442 }
443 if entry == req {
444 return true;
445 }
446 req.split(['-', '_', '/', ':', '.']).any(|tok| tok == entry)
447 }
448}
449
450#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
452pub struct CreditBalance {
453 pub kind: String,
455 pub has_credits: Option<bool>,
456 pub unlimited: Option<bool>,
457 pub remaining: Option<f64>,
458 pub total: Option<f64>,
459 pub unit: Option<String>,
460 pub renews_at: Option<u64>,
462}
463
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466pub struct BankedResets {
467 pub available_count: Option<u64>,
469 pub credits: Vec<BankedResetCredit>,
470 pub inventory_error: Option<String>,
472}
473
474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
475pub struct BankedResetCredit {
476 pub reset_type: Option<String>,
477 pub granted_at: Option<u64>,
478 pub expires_at: Option<u64>,
479}
480
481#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
483pub struct UsageSnapshot {
484 pub schema_version: u32,
485 pub provider: String,
487 pub account: String,
489 pub observed_at: u64,
491 pub plan: Option<String>,
492 pub limit_reached: Option<bool>,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub spend_control_reached: Option<bool>,
498 pub windows: Vec<UsageWindow>,
499 pub model_availability: Vec<ModelAvailability>,
500 pub credits: Option<CreditBalance>,
501 pub banked_resets: Option<BankedResets>,
502 pub identity_prefix: Option<String>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub seat_fingerprint: Option<String>,
508 pub notes: Vec<String>,
510}
511
512impl UsageSnapshot {
513 fn empty(provider: OAuthProviderId, account: &str, observed_at: u64) -> Self {
514 Self {
515 schema_version: SCHEMA_VERSION,
516 provider: provider.as_str().to_string(),
517 account: account.to_string(),
518 observed_at,
519 plan: None,
520 limit_reached: None,
521 spend_control_reached: None,
522 windows: Vec::new(),
523 model_availability: Vec::new(),
524 credits: None,
525 banked_resets: None,
526 identity_prefix: None,
527 seat_fingerprint: None,
528 notes: Vec::new(),
529 }
530 }
531
532 pub fn is_stale(&self, now_ms: u64, max_age_ms: u64) -> bool {
535 now_ms < self.observed_at || now_ms - self.observed_at > max_age_ms
536 }
537
538 pub fn window(&self, id: &str) -> Option<&UsageWindow> {
539 self.windows.iter().find(|w| w.id == id)
540 }
541
542 pub fn account_windows(&self) -> impl Iterator<Item = &UsageWindow> {
543 self.windows
544 .iter()
545 .filter(|w| matches!(w.scope, WindowScope::Account))
546 }
547
548 pub fn longest_account_window(&self) -> Option<&UsageWindow> {
551 self.account_windows()
552 .max_by_key(|w| (w.duration_secs.is_some(), w.duration_secs.unwrap_or(0)))
553 }
554
555 pub fn has_proven_capacity(&self, now_ms: u64, max_age_ms: u64) -> bool {
559 if self.is_stale(now_ms, max_age_ms)
560 || self.limit_reached == Some(true)
561 || self.spend_control_reached == Some(true)
562 {
563 return false;
564 }
565 let mut any = false;
566 for w in self.account_windows() {
567 any = true;
568 match w.used_percent.valid() {
569 Some(p) if p < 100.0 && w.limit_reached != Some(true) => {}
570 _ => return false,
571 }
572 }
573 any
574 }
575
576 pub fn model_availability_for(&self, model: &str) -> Option<&ModelAvailability> {
578 self.model_availability
579 .iter()
580 .find(|m| m.matches_model(model))
581 }
582
583 pub fn has_proven_capacity_for_model(&self, model: &str, now_ms: u64, max_age_ms: u64) -> bool {
589 if !self.has_proven_capacity(now_ms, max_age_ms) {
590 return false;
591 }
592 match self.model_availability_for(model) {
593 Some(entry) => entry.availability == Availability::Available,
594 None => true,
595 }
596 }
597
598 pub fn earliest_reset_at(&self) -> Option<u64> {
600 self.windows
601 .iter()
602 .filter_map(|w| w.reset_at)
603 .chain(self.model_availability.iter().filter_map(|m| m.reset_at))
604 .min()
605 }
606
607 fn derive_model_availability(&mut self) {
613 let mut derived: Vec<ModelAvailability> = Vec::new();
614 for w in &self.windows {
615 let WindowScope::Model { model } = &w.scope else {
616 continue;
617 };
618 let from_window = ModelAvailability {
619 model: model.clone(),
620 availability: if w.is_exhausted() {
621 Availability::Exhausted
622 } else if w.used_percent.is_known() {
623 Availability::Available
624 } else {
625 Availability::Unknown
626 },
627 used_percent: w.used_percent.clone(),
628 reset_at: w.reset_at,
629 credits_would_enable: None,
630 source: "window".into(),
631 };
632 match self
633 .model_availability
634 .iter_mut()
635 .find(|m| m.model.eq_ignore_ascii_case(model))
636 {
637 Some(explicit) => {
638 if from_window.availability == Availability::Exhausted {
639 explicit.availability = Availability::Exhausted;
640 }
641 if !explicit.used_percent.is_known() {
642 explicit.used_percent = from_window.used_percent;
643 }
644 if explicit.reset_at.is_none() {
645 explicit.reset_at = from_window.reset_at;
646 }
647 explicit.source = "merged".into();
648 }
649 None => derived.push(from_window),
650 }
651 }
652 self.model_availability.extend(derived);
653 }
654
655 fn note(&mut self, text: &str) {
656 if !self.notes.iter().any(|n| n == text) {
657 self.notes.push(text.to_string());
658 }
659 }
660}
661
662#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
667#[serde(tag = "status", rename_all = "snake_case")]
668pub enum AccountUsageOutcome {
669 Ok { snapshot: Box<UsageSnapshot> },
670 Error { error: UsageErrorSummary },
671}
672
673impl AccountUsageOutcome {
674 pub fn ok(snapshot: UsageSnapshot) -> Self {
675 Self::Ok {
676 snapshot: Box::new(snapshot),
677 }
678 }
679 pub fn error(error: &UsageError) -> Self {
680 Self::Error {
681 error: error.into(),
682 }
683 }
684 pub fn snapshot(&self) -> Option<&UsageSnapshot> {
685 match self {
686 Self::Ok { snapshot } => Some(snapshot),
687 Self::Error { .. } => None,
688 }
689 }
690}
691
692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694pub struct UsageErrorSummary {
695 pub kind: String,
696 pub message: String,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub http_status: Option<u16>,
699}
700
701impl From<&UsageError> for UsageErrorSummary {
702 fn from(e: &UsageError) -> Self {
703 let http_status = match e {
704 UsageError::Unauthorized { status }
705 | UsageError::UpstreamStatus { status, .. }
706 | UsageError::Redirected { status } => Some(*status),
707 UsageError::RateLimited { .. } => Some(429),
708 _ => None,
709 };
710 Self {
711 kind: e.kind().to_string(),
712 message: e.to_string(),
713 http_status,
714 }
715 }
716}
717
718impl UsageErrorSummary {
719 pub fn other(kind: &str, message: impl Into<String>) -> Self {
721 Self {
722 kind: kind.to_string(),
723 message: message.into(),
724 http_status: None,
725 }
726 }
727}
728
729#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
730pub struct AccountUsageEntry {
731 pub provider: String,
732 pub account: String,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub identity: Option<String>,
736 #[serde(flatten)]
737 pub outcome: AccountUsageOutcome,
738}
739
740#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
742pub struct UsageReport {
743 pub schema_version: u32,
744 pub observed_at: u64,
745 pub source: String,
747 pub accounts: Vec<AccountUsageEntry>,
748}
749
750impl UsageReport {
751 pub fn new(source: &str, observed_at: u64) -> Self {
752 Self {
753 schema_version: SCHEMA_VERSION,
754 observed_at,
755 source: source.to_string(),
756 accounts: Vec::new(),
757 }
758 }
759 pub fn ok_count(&self) -> usize {
760 self.accounts
761 .iter()
762 .filter(|a| matches!(a.outcome, AccountUsageOutcome::Ok { .. }))
763 .count()
764 }
765 pub fn error_count(&self) -> usize {
766 self.accounts.len() - self.ok_count()
767 }
768}
769
770fn number_from_value(v: &Value) -> Option<f64> {
773 match v {
774 Value::Number(n) => n.as_f64().filter(|f| f.is_finite()),
775 Value::String(s) => s.trim().parse::<f64>().ok().filter(|f| f.is_finite()),
776 _ => None,
777 }
778}
779
780fn u64_from_value(v: &Value) -> Option<u64> {
781 number_from_value(v).filter(|f| *f >= 0.0 && *f <= u64::MAX as f64).map(|f| f as u64)
782}
783
784fn string_from_value(v: Option<&Value>) -> Option<String> {
785 v.and_then(Value::as_str)
786 .map(str::trim)
787 .filter(|s| !s.is_empty())
788 .map(ToString::to_string)
789}
790
791fn bool_from_value(v: Option<&Value>) -> Option<bool> {
792 v.and_then(Value::as_bool)
793}
794
795fn epoch_ms_from_rfc3339(s: &str) -> Option<u64> {
796 chrono::DateTime::parse_from_rfc3339(s.trim())
797 .ok()
798 .map(|dt| dt.timestamp_millis())
799 .filter(|ms| *ms >= 0)
800 .map(|ms| ms as u64)
801}
802
803const EPOCH_MS_THRESHOLD: f64 = 1.0e11;
806
807fn epoch_ms_from_value(v: Option<&Value>) -> Option<u64> {
809 match v? {
810 Value::String(s) => epoch_ms_from_rfc3339(s).or_else(|| {
811 s.trim()
812 .parse::<f64>()
813 .ok()
814 .and_then(epoch_ms_from_number)
815 }),
816 Value::Number(n) => n.as_f64().and_then(epoch_ms_from_number),
817 _ => None,
818 }
819}
820
821fn epoch_ms_from_number(n: f64) -> Option<u64> {
822 if !n.is_finite() || n <= 0.0 {
823 return None;
824 }
825 let ms = if n > EPOCH_MS_THRESHOLD { n } else { n * 1000.0 };
826 (ms <= u64::MAX as f64).then_some(ms as u64)
827}
828
829pub fn duration_label(secs: u64) -> String {
831 if secs == 0 {
832 "0s".into()
833 } else if secs.is_multiple_of(SECS_PER_DAY) {
834 format!("{}d", secs / SECS_PER_DAY)
835 } else if secs.is_multiple_of(SECS_PER_HOUR) {
836 format!("{}h", secs / SECS_PER_HOUR)
837 } else if secs.is_multiple_of(SECS_PER_MINUTE) {
838 format!("{}m", secs / SECS_PER_MINUTE)
839 } else {
840 format!("{secs}s")
841 }
842}
843
844fn identity_prefix(id: &str) -> String {
845 id.chars().take(8).collect()
846}
847
848fn parse_json_object(body: &str) -> Result<serde_json::Map<String, Value>, UsageError> {
849 let root: Value = serde_json::from_str(body).map_err(|_| UsageError::Malformed {
850 detail: "body is not JSON".into(),
851 })?;
852 match root {
853 Value::Object(map) => Ok(map),
854 _ => Err(UsageError::Malformed {
855 detail: "top-level value is not an object".into(),
856 }),
857 }
858}
859
860fn anthropic_duration_hint(key: &str) -> Option<u64> {
864 let mut parts = key.splitn(3, '_');
865 let count = parts.next()?;
866 let unit = parts.next()?;
867 let n: u64 = match count {
868 "one" => 1,
869 "two" => 2,
870 "three" => 3,
871 "four" => 4,
872 "five" => 5,
873 "six" => 6,
874 "seven" => 7,
875 "eight" => 8,
876 "nine" => 9,
877 "ten" => 10,
878 "twelve" => 12,
879 "thirty" => 30,
880 other => other.parse().ok()?,
881 };
882 let unit_secs = match unit {
883 "minute" | "minutes" => SECS_PER_MINUTE,
884 "hour" | "hours" => SECS_PER_HOUR,
885 "day" | "days" => SECS_PER_DAY,
886 "week" | "weeks" => SECS_PER_WEEK,
887 _ => return None,
888 };
889 n.checked_mul(unit_secs)
890}
891
892fn anthropic_scope(key: &str) -> WindowScope {
894 if key == "extra_usage" {
895 return WindowScope::Feature {
896 feature: "extra_usage".into(),
897 };
898 }
899 let mut parts = key.splitn(3, '_');
900 let (_count, _unit, rest) = (parts.next(), parts.next(), parts.next());
901 match rest {
902 None => WindowScope::Account,
903 Some("oauth_apps") => WindowScope::Feature {
904 feature: "oauth_apps".into(),
905 },
906 Some(model) if !model.is_empty() => WindowScope::Model {
907 model: model.to_string(),
908 },
909 Some(_) => WindowScope::Account,
910 }
911}
912
913fn extra_usage_shown(val: &Value) -> bool {
916 val.get("is_enabled").and_then(Value::as_bool) == Some(true)
917 && val.get("utilization").is_some_and(Value::is_number)
918}
919
920pub fn parse_anthropic_usage(
923 body: &str,
924 account_label: &str,
925 observed_at: u64,
926) -> Result<UsageSnapshot, UsageError> {
927 let map = parse_json_object(body)?;
928 let mut snap = UsageSnapshot::empty(OAuthProviderId::Anthropic, account_label, observed_at);
929 let mut skipped_null = 0usize;
930 let mut unrecognized = 0usize;
931 for (key, val) in &map {
932 if key == "extra_usage" && !extra_usage_shown(val) {
936 continue;
937 }
938 match val {
939 Value::Object(w) if w.contains_key("utilization") || w.contains_key("resets_at") => {
940 let used_percent = UsedPercent::from_json(w.get("utilization"));
941 let reset_at = match w.get("resets_at") {
942 None | Some(Value::Null) => None,
943 Some(v) => {
944 let parsed = epoch_ms_from_value(Some(v));
945 if parsed.is_none() {
946 snap.note("reset timestamp unparseable");
947 }
948 parsed
949 }
950 };
951 let duration_secs = anthropic_duration_hint(key);
952 snap.windows.push(UsageWindow {
953 id: key.clone(),
954 label: duration_secs
955 .map(duration_label)
956 .unwrap_or_else(|| "unknown".into()),
957 scope: anthropic_scope(key),
958 duration_secs,
959 used_percent,
960 used: None,
961 limit: None,
962 reset_at,
963 reset_kind: ResetKind::QuotaWindow,
964 limit_reached: None,
965 });
966 }
967 Value::Null => skipped_null += 1,
968 _ => unrecognized += 1,
969 }
970 }
971 if skipped_null > 0 {
972 snap.note("null windows skipped");
973 }
974 if unrecognized > 0 {
975 snap.note("unrecognized fields ignored");
976 }
977 if snap.windows.is_empty() {
978 snap.note("no usage windows in response");
979 }
980 snap.derive_model_availability();
981 Ok(snap)
982}
983
984fn codex_window(
989 snap: &mut UsageSnapshot,
990 id: &str,
991 scope: WindowScope,
992 raw: &Value,
993 observed_at: u64,
994) -> Option<UsageWindow> {
995 let w = match raw {
996 Value::Object(w) => w,
997 Value::Null => {
998 snap.note("null window skipped");
999 return None;
1000 }
1001 _ => {
1002 snap.note("window is not an object");
1003 return None;
1004 }
1005 };
1006 let used_percent = UsedPercent::from_json(w.get("used_percent"));
1007 let duration_secs = w.get("limit_window_seconds").and_then(u64_from_value);
1008 let mut reset_at = epoch_ms_from_value(w.get("reset_at"));
1009 if reset_at.is_none() {
1010 if let Some(after) = w.get("reset_after_seconds").and_then(u64_from_value) {
1011 reset_at = observed_at.checked_add(after.saturating_mul(1000));
1012 snap.note("reset derived from reset_after_seconds");
1013 }
1014 }
1015 Some(UsageWindow {
1016 id: id.to_string(),
1017 label: duration_secs
1018 .map(duration_label)
1019 .unwrap_or_else(|| "unknown".into()),
1020 scope,
1021 duration_secs,
1022 used_percent,
1023 used: None,
1024 limit: None,
1025 reset_at,
1026 reset_kind: ResetKind::QuotaWindow,
1027 limit_reached: None,
1028 })
1029}
1030
1031fn codex_status_windows(
1034 snap: &mut UsageSnapshot,
1035 prefix: &str,
1036 scope: WindowScope,
1037 raw: &Value,
1038 observed_at: u64,
1039) -> Option<bool> {
1040 let status = raw.as_object()?;
1041 let limit_reached = bool_from_value(status.get("limit_reached"));
1042 for (suffix, key) in [("primary", "primary_window"), ("secondary", "secondary_window")] {
1043 match status.get(key) {
1044 None => {}
1045 Some(v) => {
1046 if let Some(mut w) =
1047 codex_window(snap, &format!("{prefix}{suffix}"), scope.clone(), v, observed_at)
1048 {
1049 w.limit_reached = limit_reached;
1050 snap.windows.push(w);
1051 }
1052 }
1053 }
1054 }
1055 limit_reached
1056}
1057
1058fn codex_banked_resets(raw: &Value) -> Option<BankedResets> {
1059 let obj = raw.as_object()?;
1060 let available_count = obj.get("available_count").and_then(u64_from_value);
1061 let credits = obj
1062 .get("credits")
1063 .and_then(Value::as_array)
1064 .map(|items| {
1065 items
1066 .iter()
1067 .filter_map(Value::as_object)
1068 .map(|c| BankedResetCredit {
1069 reset_type: string_from_value(c.get("reset_type")),
1070 granted_at: epoch_ms_from_value(c.get("granted_at")),
1071 expires_at: epoch_ms_from_value(c.get("expires_at")),
1072 })
1073 .collect()
1074 })
1075 .unwrap_or_default();
1076 Some(BankedResets {
1077 available_count,
1078 credits,
1079 inventory_error: None,
1080 })
1081}
1082
1083pub fn parse_codex_usage(
1086 body: &str,
1087 account_label: &str,
1088 observed_at: u64,
1089 expected_account_id: Option<&str>,
1090) -> Result<UsageSnapshot, UsageError> {
1091 let map = parse_json_object(body)?;
1092 let mut snap = UsageSnapshot::empty(OAuthProviderId::OpenAiCodex, account_label, observed_at);
1093
1094 if let Some(id) = string_from_value(map.get("account_id")) {
1095 if let Some(expected) = expected_account_id {
1096 if id != expected {
1097 return Err(UsageError::IdentityMismatch);
1098 }
1099 }
1100 snap.identity_prefix = Some(identity_prefix(&id));
1101 } else if let Some(expected) = expected_account_id {
1102 snap.identity_prefix = Some(identity_prefix(expected));
1103 }
1104
1105 snap.seat_fingerprint = expected_account_id
1108 .and_then(|id| super::seat_fingerprint(OAuthProviderId::OpenAiCodex, id));
1109
1110 snap.plan = string_from_value(map.get("plan_type"));
1111
1112 match map.get("rate_limit") {
1113 Some(rl @ Value::Object(_)) => {
1114 snap.limit_reached = codex_status_windows(&mut snap, "", WindowScope::Account, rl, observed_at);
1115 }
1116 Some(Value::Null) | None => snap.note("rate_limit missing"),
1117 Some(_) => snap.note("rate_limit is not an object"),
1118 }
1119
1120 if let Some(cr @ Value::Object(_)) = map.get("code_review_rate_limit") {
1121 codex_status_windows(
1122 &mut snap,
1123 "code_review.",
1124 WindowScope::Feature {
1125 feature: "code_review".into(),
1126 },
1127 cr,
1128 observed_at,
1129 );
1130 }
1131
1132 if let Some(extra) = map.get("additional_rate_limits").and_then(Value::as_array) {
1133 for (i, item) in extra.iter().enumerate() {
1134 let Some(obj) = item.as_object() else {
1135 snap.note("additional rate limit is not an object");
1136 continue;
1137 };
1138 let name = string_from_value(obj.get("limit_name"))
1139 .or_else(|| string_from_value(obj.get("metered_feature")))
1140 .unwrap_or_else(|| format!("additional[{i}]"));
1141 let scope = match string_from_value(obj.get("normal_model_slug")) {
1142 Some(model) => WindowScope::Model { model },
1143 None => WindowScope::Feature {
1144 feature: name.clone(),
1145 },
1146 };
1147 if let Some(rl) = obj.get("rate_limit") {
1148 codex_status_windows(&mut snap, &format!("{name}."), scope, rl, observed_at);
1149 }
1150 }
1151 }
1152
1153 match map.get("model_usage") {
1158 Some(Value::Object(models)) => {
1159 for (model, raw) in models {
1160 let Some(entry) = raw.as_object() else {
1161 snap.note("model usage entry is not an object");
1162 continue;
1163 };
1164 let availability = match entry.get("available") {
1165 Some(Value::Bool(true)) => Availability::Available,
1166 Some(Value::Bool(false)) => Availability::Exhausted,
1167 _ => Availability::Unknown,
1168 };
1169 let reset_at = match entry.get("available_at") {
1170 None | Some(Value::Null) => None,
1171 Some(v) => {
1172 let parsed = epoch_ms_from_value(Some(v));
1173 if parsed.is_none() {
1174 snap.note("reset timestamp unparseable");
1175 }
1176 parsed
1177 }
1178 };
1179 snap.model_availability.push(ModelAvailability {
1180 model: model.clone(),
1181 availability,
1182 used_percent: UsedPercent::from_json(entry.get("used_percent")),
1183 reset_at,
1184 credits_would_enable: bool_from_value(entry.get("credits_would_enable")),
1185 source: "model_usage".into(),
1186 });
1187 }
1188 }
1189 Some(Value::Null) | None => {}
1190 Some(_) => snap.note("model usage field is not an object"),
1191 }
1192
1193 match map.get("spend_control") {
1197 Some(Value::Object(sc)) => {
1198 let mut reached = bool_from_value(sc.get("reached"));
1199 for (_key, nested) in sc {
1200 let Some(limit) = nested.as_object() else {
1201 continue;
1202 };
1203 if bool_from_value(limit.get("reached")) == Some(true) {
1204 reached = Some(true);
1205 }
1206 let remaining = limit.get("remaining_percent").and_then(number_from_value);
1207 let resets_at = epoch_ms_from_value(limit.get("resets_at"));
1208 if remaining.is_some() || resets_at.is_some() {
1209 snap.windows.push(UsageWindow {
1210 id: "spend_control".into(),
1211 label: "spend control".into(),
1212 scope: WindowScope::Feature {
1213 feature: "spend_control".into(),
1214 },
1215 duration_secs: None,
1216 used_percent: match remaining {
1217 Some(r) => UsedPercent::from_f64(100.0 - r),
1218 None => UsedPercent::unknown("missing"),
1219 },
1220 used: None,
1221 limit: limit.get("limit").and_then(number_from_value),
1222 reset_at: resets_at,
1223 reset_kind: ResetKind::BillingRenewal,
1224 limit_reached: bool_from_value(limit.get("reached")),
1225 });
1226 }
1227 }
1228 snap.spend_control_reached = reached;
1229 }
1230 Some(Value::Null) | None => {}
1231 Some(_) => snap.note("spend control field is not an object"),
1232 }
1233
1234 if let Some(credits) = map.get("credits").and_then(Value::as_object) {
1235 snap.credits = Some(CreditBalance {
1236 kind: "codex_credits".into(),
1237 has_credits: bool_from_value(credits.get("has_credits")),
1238 unlimited: bool_from_value(credits.get("unlimited")),
1239 remaining: credits.get("balance").and_then(number_from_value),
1240 total: None,
1241 unit: Some("credits".into()),
1242 renews_at: None,
1243 });
1244 }
1245
1246 match map.get("rate_limit_reset_credits") {
1247 Some(v @ Value::Object(_)) => snap.banked_resets = codex_banked_resets(v),
1248 Some(Value::Null) | None => {}
1249 Some(_) => snap.note("reset credits field is not an object"),
1250 }
1251
1252 if snap.windows.is_empty() {
1253 snap.note("no usage windows in response");
1254 }
1255 snap.derive_model_availability();
1256 Ok(snap)
1257}
1258
1259pub fn parse_codex_reset_credits(body: &str) -> Result<BankedResets, UsageError> {
1261 let map = parse_json_object(body)?;
1262 codex_banked_resets(&Value::Object(map)).ok_or_else(|| UsageError::Malformed {
1263 detail: "reset credits payload has unexpected shape".into(),
1264 })
1265}
1266
1267fn kimi_unit_secs(raw: Option<&Value>) -> Option<u64> {
1270 match raw?.as_str()? {
1271 "TIME_UNIT_MINUTE" | "minute" => Some(SECS_PER_MINUTE),
1272 "TIME_UNIT_HOUR" | "hour" => Some(SECS_PER_HOUR),
1273 "TIME_UNIT_DAY" | "day" => Some(SECS_PER_DAY),
1274 "TIME_UNIT_WEEK" | "week" => Some(SECS_PER_WEEK),
1275 _ => None,
1276 }
1277}
1278
1279fn kimi_window_secs(raw: Option<&Value>) -> Option<u64> {
1280 let w = raw?.as_object()?;
1281 let duration = w.get("duration").and_then(u64_from_value)?;
1282 let unit = kimi_unit_secs(w.get("timeUnit"))?;
1283 duration.checked_mul(unit)
1284}
1285
1286fn kimi_row(
1288 snap: &mut UsageSnapshot,
1289 id: &str,
1290 label_hint: Option<String>,
1291 duration_secs: Option<u64>,
1292 raw: Option<&Value>,
1293) -> Option<UsageWindow> {
1294 let row = raw?.as_object()?;
1295 let used = row.get("used").and_then(number_from_value);
1296 let limit = row.get("limit").and_then(number_from_value);
1297 if used.is_none() && limit.is_none() {
1298 return None;
1299 }
1300 let reset_at = match row.get("resetTime") {
1301 None | Some(Value::Null) => None,
1302 Some(v) => {
1303 let parsed = epoch_ms_from_value(Some(v));
1304 if parsed.is_none() {
1305 snap.note("reset timestamp unparseable");
1306 }
1307 parsed
1308 }
1309 };
1310 let label = label_hint
1311 .or_else(|| duration_secs.map(duration_label))
1312 .unwrap_or_else(|| "unknown".into());
1313 Some(UsageWindow {
1314 id: id.to_string(),
1315 label,
1316 scope: WindowScope::Account,
1317 duration_secs,
1318 used_percent: UsedPercent::from_ratio(used, limit),
1319 used,
1320 limit,
1321 reset_at,
1322 reset_kind: ResetKind::QuotaWindow,
1323 limit_reached: None,
1324 })
1325}
1326
1327pub fn parse_kimi_usage(
1329 body: &str,
1330 account_label: &str,
1331 observed_at: u64,
1332) -> Result<UsageSnapshot, UsageError> {
1333 let map = parse_json_object(body)?;
1334 let mut snap = UsageSnapshot::empty(OAuthProviderId::KimiCode, account_label, observed_at);
1335
1336 if let Some(w) = kimi_row(&mut snap, "usage", None, Some(SECS_PER_WEEK), map.get("usage")) {
1338 snap.windows.push(w);
1339 } else if map.contains_key("usage") {
1340 snap.note("summary usage row unparseable");
1341 }
1342
1343 match map.get("limits") {
1344 Some(Value::Array(items)) => {
1345 for (i, item) in items.iter().enumerate() {
1346 let Some(obj) = item.as_object() else {
1347 snap.note("limit row is not an object");
1348 continue;
1349 };
1350 let duration = kimi_window_secs(obj.get("window"));
1351 let name = string_from_value(obj.get("name")).or_else(|| {
1352 obj.get("detail")
1353 .and_then(Value::as_object)
1354 .and_then(|d| string_from_value(d.get("name")))
1355 });
1356 let id = format!("limits[{i}]");
1357 match kimi_row(&mut snap, &id, name, duration, obj.get("detail")) {
1358 Some(w) => snap.windows.push(w),
1359 None => snap.note("limit row unparseable"),
1360 }
1361 }
1362 }
1363 Some(Value::Null) | None => {}
1364 Some(_) => snap.note("limits field is not an array"),
1365 }
1366
1367 if let Some(wallet) = map.get("boosterWallet").and_then(Value::as_object) {
1368 if let Some(balance) = wallet.get("balance").and_then(Value::as_object) {
1369 const FIXED_POINT_PER_CENT: f64 = 1.0e6;
1371 let total = balance
1372 .get("amount")
1373 .and_then(number_from_value)
1374 .map(|v| v / FIXED_POINT_PER_CENT / 100.0);
1375 let remaining = balance
1376 .get("amountLeft")
1377 .and_then(number_from_value)
1378 .map(|v| v / FIXED_POINT_PER_CENT / 100.0);
1379 let currency = ["monthlyChargeLimit", "monthlyUsed"]
1380 .iter()
1381 .filter_map(|k| wallet.get(*k).and_then(Value::as_object))
1382 .find_map(|m| string_from_value(m.get("currency")))
1383 .unwrap_or_else(|| "USD".into());
1384 if total.is_some() || remaining.is_some() {
1385 snap.credits = Some(CreditBalance {
1386 kind: "kimi_booster".into(),
1387 has_credits: remaining.map(|r| r > 0.0),
1388 unlimited: None,
1389 remaining,
1390 total,
1391 unit: Some(currency),
1392 renews_at: None,
1393 });
1394 }
1395 }
1396 }
1397
1398 if snap.windows.is_empty() {
1399 snap.note("no usage windows in response");
1400 }
1401 snap.derive_model_availability();
1402 Ok(snap)
1403}
1404
1405fn grok_lookup<'a>(
1409 root: &'a serde_json::Map<String, Value>,
1410 config: Option<&'a serde_json::Map<String, Value>>,
1411 key: &str,
1412) -> Option<&'a Value> {
1413 config
1414 .and_then(|c| c.get(key))
1415 .filter(|v| !v.is_null())
1416 .or_else(|| root.get(key).filter(|v| !v.is_null()))
1417}
1418
1419fn grok_val(v: Option<&Value>) -> Option<f64> {
1421 match v? {
1422 Value::Object(o) => o.get("val").and_then(number_from_value),
1423 other => number_from_value(other),
1424 }
1425}
1426
1427pub fn parse_grok_billing(
1435 body: &str,
1436 account_label: &str,
1437 observed_at: u64,
1438) -> Result<UsageSnapshot, UsageError> {
1439 let map = parse_json_object(body)?;
1440 let mut snap = UsageSnapshot::empty(OAuthProviderId::Xai, account_label, observed_at);
1441 let config = map.get("config").and_then(Value::as_object);
1442
1443 let used = grok_val(grok_lookup(&map, config, "onDemandUsed"));
1444 let cap = grok_val(grok_lookup(&map, config, "onDemandCap"));
1445
1446 let percent_field = grok_lookup(&map, config, "creditUsagePercent");
1447 let used_percent = match percent_field {
1448 Some(v) => match number_from_value(v) {
1449 Some(n) => UsedPercent::from_f64(n),
1450 None => UsedPercent::unknown("not a number"),
1451 },
1452 None => match (used, cap) {
1453 (Some(_), Some(_)) => {
1454 snap.note("percent derived from on-demand counters");
1455 UsedPercent::from_ratio(used, cap)
1456 }
1457 _ => UsedPercent::unknown("missing"),
1458 },
1459 };
1460
1461 let reset_at = grok_lookup(&map, config, "currentPeriod")
1462 .and_then(Value::as_object)
1463 .and_then(|p| epoch_ms_from_value(p.get("end")))
1464 .or_else(|| epoch_ms_from_value(grok_lookup(&map, config, "billingPeriodEnd")));
1465 let duration_secs = grok_lookup(&map, config, "billingPeriodMinutes")
1466 .and_then(u64_from_value)
1467 .and_then(|m| m.checked_mul(SECS_PER_MINUTE));
1468
1469 let recognized = percent_field.is_some()
1472 || reset_at.is_some()
1473 || duration_secs.is_some()
1474 || used.is_some()
1475 || cap.is_some();
1476 if recognized {
1477 snap.windows.push(UsageWindow {
1478 id: "credits".into(),
1479 label: duration_secs
1480 .map(duration_label)
1481 .unwrap_or_else(|| "billing period".into()),
1482 scope: WindowScope::Account,
1483 duration_secs,
1484 used_percent,
1485 used,
1486 limit: cap,
1487 reset_at,
1488 reset_kind: ResetKind::BillingRenewal,
1489 limit_reached: None,
1490 });
1491 if used.is_some() || cap.is_some() {
1492 snap.credits = Some(CreditBalance {
1493 kind: "grok_credits".into(),
1494 has_credits: match (used, cap) {
1495 (Some(u), Some(c)) => Some(u < c),
1496 _ => None,
1497 },
1498 unlimited: None,
1499 remaining: match (used, cap) {
1500 (Some(u), Some(c)) => Some((c - u).max(0.0)),
1501 _ => None,
1502 },
1503 total: cap,
1504 unit: Some("credits".into()),
1505 renews_at: reset_at,
1506 });
1507 }
1508 } else {
1509 snap.note("unrecognized billing schema");
1510 }
1511 snap.note("grok billing schema unverified against a live account");
1512 snap.derive_model_availability();
1513 Ok(snap)
1514}
1515
1516#[derive(Debug, Clone)]
1520pub struct UsageFetchOptions {
1521 pub endpoint_override: Option<String>,
1525 pub inventory_endpoint_override: Option<String>,
1527 pub include_inventory: bool,
1530 pub timeout: Duration,
1531 pub max_body_bytes: usize,
1532}
1533
1534impl Default for UsageFetchOptions {
1535 fn default() -> Self {
1536 Self {
1537 endpoint_override: None,
1538 inventory_endpoint_override: None,
1539 include_inventory: false,
1540 timeout: USAGE_REQUEST_TIMEOUT,
1541 max_body_bytes: USAGE_MAX_BODY_BYTES,
1542 }
1543 }
1544}
1545
1546pub(crate) fn is_loopback_url(raw: &str) -> bool {
1548 let Ok(url) = url::Url::parse(raw) else {
1549 return false;
1550 };
1551 if !matches!(url.scheme(), "http" | "https") {
1552 return false;
1553 }
1554 match url.host() {
1555 Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
1556 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1557 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1558 None => false,
1559 }
1560}
1561
1562fn resolve_endpoint(pinned: &str, override_url: Option<&str>) -> Result<String, UsageError> {
1563 match override_url {
1564 None => Ok(pinned.to_string()),
1565 Some(u) if is_loopback_url(u) => Ok(u.to_string()),
1566 Some(_) => Err(UsageError::InvalidEndpointOverride),
1567 }
1568}
1569
1570#[derive(Clone)]
1573pub struct UsageClient {
1574 http: reqwest::Client,
1575}
1576
1577impl std::fmt::Debug for UsageClient {
1578 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1579 f.write_str("UsageClient")
1580 }
1581}
1582
1583impl UsageClient {
1584 pub fn new() -> Result<Self, UsageError> {
1585 let http = reqwest::Client::builder()
1586 .redirect(reqwest::redirect::Policy::none())
1587 .connect_timeout(USAGE_CONNECT_TIMEOUT)
1588 .timeout(USAGE_REQUEST_TIMEOUT)
1589 .tls_built_in_webpki_certs(true)
1590 .tls_built_in_native_certs(true)
1591 .user_agent(USER_AGENT)
1592 .build()
1593 .map_err(|_| UsageError::Transport {
1594 detail: "could not build HTTP client".into(),
1595 })?;
1596 Ok(Self { http })
1597 }
1598}
1599
1600fn classify_reqwest_error(e: &reqwest::Error) -> UsageError {
1601 if e.is_timeout() {
1602 UsageError::Timeout
1603 } else {
1604 let detail = if e.is_connect() {
1605 "connect"
1606 } else if e.is_request() {
1607 "request"
1608 } else if e.is_body() || e.is_decode() {
1609 "body"
1610 } else if e.is_builder() {
1611 "builder"
1612 } else {
1613 "unknown"
1614 };
1615 UsageError::Transport {
1616 detail: detail.into(),
1617 }
1618 }
1619}
1620
1621async fn read_body_capped(resp: reqwest::Response, cap: usize) -> Result<String, UsageError> {
1623 use futures::StreamExt;
1624 let mut buf: Vec<u8> = Vec::new();
1625 let mut stream = resp.bytes_stream();
1626 while let Some(chunk) = stream.next().await {
1627 let chunk = chunk.map_err(|e| classify_reqwest_error(&e))?;
1628 if buf.len().saturating_add(chunk.len()) > cap {
1629 return Err(UsageError::BodyTooLarge { cap });
1630 }
1631 buf.extend_from_slice(&chunk);
1632 }
1633 String::from_utf8(buf).map_err(|_| UsageError::Malformed {
1634 detail: "body is not UTF-8".into(),
1635 })
1636}
1637
1638const MAX_RETRY_AFTER_SECS: u64 = 24 * 60 * 60;
1640
1641fn retry_after_secs(headers: &reqwest::header::HeaderMap) -> Option<u64> {
1645 headers
1646 .get(reqwest::header::RETRY_AFTER)?
1647 .to_str()
1648 .ok()?
1649 .trim()
1650 .parse::<u64>()
1651 .ok()
1652 .map(|secs| secs.min(MAX_RETRY_AFTER_SECS))
1653}
1654
1655async fn get_json_text(
1657 client: &UsageClient,
1658 url: &str,
1659 headers: &[(&str, String)],
1660 opts: &UsageFetchOptions,
1661) -> Result<String, UsageError> {
1662 let mut req = client
1663 .http
1664 .get(url)
1665 .timeout(opts.timeout)
1666 .header("Accept", "application/json");
1667 for (k, v) in headers {
1668 req = req.header(*k, v);
1669 }
1670 let resp = req.send().await.map_err(|e| classify_reqwest_error(&e))?;
1671 let status = resp.status().as_u16();
1672 match status {
1674 200..=299 => {}
1675 300..=399 => {
1676 drop(resp);
1677 return Err(UsageError::Redirected { status });
1678 }
1679 401 | 403 => {
1680 drop(resp);
1681 return Err(UsageError::Unauthorized { status });
1682 }
1683 429 => {
1684 let retry_after_s = retry_after_secs(resp.headers());
1685 drop(resp);
1686 return Err(UsageError::RateLimited { retry_after_s });
1687 }
1688 _ => {
1689 let retry_after_s = retry_after_secs(resp.headers());
1690 drop(resp);
1691 return Err(UsageError::UpstreamStatus {
1692 status,
1693 retry_after_s,
1694 });
1695 }
1696 }
1697 read_body_capped(resp, opts.max_body_bytes).await
1698}
1699
1700pub async fn fetch_usage(
1703 client: &UsageClient,
1704 provider: OAuthProviderId,
1705 account_label: &str,
1706 access_token: &str,
1707 opts: &UsageFetchOptions,
1708) -> Result<UsageSnapshot, UsageError> {
1709 if !supports_usage(provider) {
1710 return Err(UsageError::UnsupportedProvider {
1711 provider: provider.as_str().to_string(),
1712 });
1713 }
1714 let bearer = format!("Bearer {access_token}");
1715 match provider {
1716 OAuthProviderId::Anthropic => {
1717 let url = resolve_endpoint(ANTHROPIC_USAGE_URL, opts.endpoint_override.as_deref())?;
1718 let headers = [
1719 ("Authorization", bearer),
1720 ("anthropic-beta", ANTHROPIC_OAUTH_BETA.to_string()),
1721 ];
1722 let body = get_json_text(client, &url, &headers, opts).await?;
1723 parse_anthropic_usage(&body, account_label, crate::epoch_millis())
1724 }
1725 OAuthProviderId::OpenAiCodex => {
1726 let account_id = super::openai_codex::extract_account_id(access_token)
1729 .ok_or(UsageError::AccountIdUnavailable)?;
1730 let url = resolve_endpoint(CODEX_USAGE_URL, opts.endpoint_override.as_deref())?;
1731 let inventory_url = resolve_endpoint(
1732 CODEX_RESET_CREDITS_URL,
1733 opts.inventory_endpoint_override.as_deref(),
1734 )?;
1735 let headers = [
1736 ("Authorization", bearer),
1737 (CODEX_ACCOUNT_HEADER, account_id.clone()),
1738 ];
1739 let body = get_json_text(client, &url, &headers, opts).await?;
1740 let mut snap =
1741 parse_codex_usage(&body, account_label, crate::epoch_millis(), Some(&account_id))?;
1742 if opts.include_inventory {
1743 let inventory = match get_json_text(client, &inventory_url, &headers, opts).await {
1744 Ok(text) => parse_codex_reset_credits(&text),
1745 Err(e) => Err(e),
1746 };
1747 let banked = snap.banked_resets.get_or_insert(BankedResets {
1748 available_count: None,
1749 credits: Vec::new(),
1750 inventory_error: None,
1751 });
1752 match inventory {
1753 Ok(detail) => {
1754 if detail.available_count.is_some() {
1755 banked.available_count = detail.available_count;
1756 }
1757 banked.credits = detail.credits;
1758 banked.inventory_error = None;
1759 }
1760 Err(e) => {
1761 banked.inventory_error = Some(e.kind().to_string());
1763 }
1764 }
1765 }
1766 Ok(snap)
1767 }
1768 OAuthProviderId::KimiCode => {
1769 let url = resolve_endpoint(KIMI_USAGE_URL, opts.endpoint_override.as_deref())?;
1772 let headers = [("Authorization", bearer)];
1773 let body = get_json_text(client, &url, &headers, opts).await?;
1774 parse_kimi_usage(&body, account_label, crate::epoch_millis())
1775 }
1776 OAuthProviderId::Xai => {
1777 let url = resolve_endpoint(GROK_BILLING_URL, opts.endpoint_override.as_deref())?;
1778 let headers = [
1779 ("Authorization", bearer),
1780 (GROK_TOKEN_AUTH_HEADER, GROK_TOKEN_AUTH_VALUE.to_string()),
1781 ];
1782 let body = get_json_text(client, &url, &headers, opts).await?;
1783 parse_grok_billing(&body, account_label, crate::epoch_millis())
1784 }
1785 OAuthProviderId::GitHubCopilot | OAuthProviderId::GoogleGemini | OAuthProviderId::Muse => {
1788 Err(UsageError::UnsupportedProvider {
1789 provider: provider.as_str().to_string(),
1790 })
1791 }
1792 }
1793}
1794
1795#[cfg(test)]
1798mod tests {
1799 use super::*;
1800 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1801
1802 const T0: u64 = 1_758_400_000_000; fn secret_free(text: &str, secrets: &[&str]) {
1805 for s in secrets {
1806 assert!(!text.contains(s), "secret material leaked: {text}");
1807 }
1808 }
1809
1810 #[test]
1813 fn used_percent_validation() {
1814 assert_eq!(UsedPercent::from_f64(0.0).valid(), Some(0.0));
1815 assert_eq!(UsedPercent::from_f64(100.0).valid(), Some(100.0));
1816 assert_eq!(UsedPercent::from_f64(100.5).valid(), Some(100.0));
1819 assert_eq!(UsedPercent::from_f64(250.0).valid(), Some(100.0));
1820 assert_eq!(UsedPercent::from_f64(1000.0).valid(), Some(100.0));
1821 assert_eq!(UsedPercent::from_f64(1000.5), UsedPercent::unknown("out of range"));
1822 assert_eq!(UsedPercent::from_f64(-0.1), UsedPercent::unknown("out of range"));
1823 assert_eq!(UsedPercent::from_f64(f64::NAN), UsedPercent::unknown("not a number"));
1824 assert_eq!(UsedPercent::from_f64(f64::INFINITY), UsedPercent::unknown("not a number"));
1825 assert_eq!(UsedPercent::from_json(None), UsedPercent::unknown("missing"));
1826 assert_eq!(UsedPercent::from_json(Some(&Value::Null)), UsedPercent::unknown("null"));
1827 assert_eq!(
1828 UsedPercent::from_json(Some(&serde_json::json!("abc"))),
1829 UsedPercent::unknown("not a number")
1830 );
1831 assert_eq!(UsedPercent::from_json(Some(&serde_json::json!("42.5"))).valid(), Some(42.5));
1832 assert_eq!(UsedPercent::from_ratio(Some(5.0), Some(0.0)), UsedPercent::unknown("limit is zero"));
1833 assert_eq!(UsedPercent::from_ratio(Some(25.0), Some(100.0)).valid(), Some(25.0));
1834 }
1835
1836 #[test]
1837 fn used_percent_json_is_tagged_never_bare() {
1838 let v = serde_json::to_value(UsedPercent::Valid { percent: 12.5 }).unwrap();
1839 assert_eq!(v, serde_json::json!({"state":"valid","percent":12.5}));
1840 let u = serde_json::to_value(UsedPercent::unknown("null")).unwrap();
1841 assert_eq!(u, serde_json::json!({"state":"unknown","reason":"null"}));
1842 let back: UsedPercent = serde_json::from_value(v).unwrap();
1843 assert_eq!(back.valid(), Some(12.5));
1844 }
1845
1846 #[test]
1847 fn epoch_helpers() {
1848 assert_eq!(epoch_ms_from_rfc3339("2025-09-20T20:26:40Z"), Some(T0));
1849 assert_eq!(epoch_ms_from_rfc3339("2025-09-20T22:26:40+02:00"), Some(T0));
1850 assert_eq!(epoch_ms_from_rfc3339("not a date"), None);
1851 assert_eq!(epoch_ms_from_value(Some(&serde_json::json!(1_758_400_000))), Some(T0));
1852 assert_eq!(epoch_ms_from_value(Some(&serde_json::json!(1_758_400_000_000u64))), Some(T0));
1853 assert_eq!(epoch_ms_from_value(Some(&serde_json::json!("1758400000"))), Some(T0));
1854 assert_eq!(epoch_ms_from_value(Some(&Value::Null)), None);
1855 assert_eq!(epoch_ms_from_value(Some(&serde_json::json!(-5))), None);
1856 assert_eq!(epoch_ms_from_value(None), None);
1857 assert_eq!(duration_label(18000), "5h");
1858 assert_eq!(duration_label(604800), "7d");
1859 assert_eq!(duration_label(300), "5m");
1860 assert_eq!(duration_label(90), "90s");
1861 }
1862
1863 const ANTHROPIC_FIXTURE: &str = r#"{
1866 "five_hour": {"utilization": 12.5, "resets_at": "2025-09-20T22:00:00Z"},
1867 "seven_day": {"utilization": 60, "resets_at": "2025-09-24T00:00:00Z"},
1868 "seven_day_oauth_apps": {"utilization": 3, "resets_at": "2025-09-24T00:00:00Z"},
1869 "seven_day_opus": null,
1870 "seven_day_sonnet": {"utilization": 100, "resets_at": "2025-09-24T00:00:00Z"},
1871 "extra_usage": {"is_enabled": false}
1872 }"#;
1873
1874 #[test]
1875 fn anthropic_valid_fixture() {
1876 let s = parse_anthropic_usage(ANTHROPIC_FIXTURE, "default", T0).unwrap();
1877 assert_eq!(s.provider, "anthropic");
1878 assert_eq!(s.account, "default");
1879 assert_eq!(s.observed_at, T0);
1880 assert_eq!(s.windows.len(), 4);
1881 let fh = s.window("five_hour").unwrap();
1882 assert_eq!(fh.duration_secs, Some(18000));
1883 assert_eq!(fh.label, "5h");
1884 assert_eq!(fh.used_percent.valid(), Some(12.5));
1885 assert_eq!(fh.reset_at, epoch_ms_from_rfc3339("2025-09-20T22:00:00Z"));
1886 assert_eq!(fh.scope, WindowScope::Account);
1887 let sd = s.window("seven_day").unwrap();
1888 assert_eq!(sd.duration_secs, Some(604800));
1889 assert_eq!(sd.scope, WindowScope::Account);
1890 assert_eq!(
1891 s.window("seven_day_oauth_apps").unwrap().scope,
1892 WindowScope::Feature { feature: "oauth_apps".into() }
1893 );
1894 let sonnet = s.window("seven_day_sonnet").unwrap();
1895 assert_eq!(sonnet.scope, WindowScope::Model { model: "sonnet".into() });
1896 assert!(sonnet.is_exhausted());
1897 assert_eq!(s.model_availability.len(), 1);
1898 assert_eq!(s.model_availability[0].model, "sonnet");
1899 assert_eq!(s.model_availability[0].availability, Availability::Exhausted);
1900 assert!(s.notes.iter().any(|n| n == "null windows skipped"));
1901 assert!(s.window("extra_usage").is_none());
1904 assert!(!s.notes.iter().any(|n| n == "unrecognized fields ignored"));
1905 assert_eq!(s.limit_reached, None);
1906 assert!(s.has_proven_capacity(T0 + 1000, 60_000));
1908 assert_eq!(s.longest_account_window().unwrap().id, "seven_day");
1909 assert_eq!(s.earliest_reset_at(), epoch_ms_from_rfc3339("2025-09-20T22:00:00Z"));
1910 }
1911
1912 #[test]
1916 fn anthropic_extra_usage_is_kept_only_when_enabled_with_a_numeric_utilization() {
1917 let parse = |extra: &str| {
1918 let body = format!(
1919 r#"{{"five_hour": {{"utilization": 10, "resets_at": "2025-09-20T22:00:00Z"}}, "extra_usage": {extra}}}"#
1920 );
1921 parse_anthropic_usage(&body, "a", T0).unwrap()
1922 };
1923 for omitted in [
1924 r#"{"is_enabled": false, "utilization": 0}"#,
1925 r#"{"is_enabled": false, "utilization": null}"#,
1926 r#"{"is_enabled": false, "monthly_limit": null, "used_credits": null, "utilization": null}"#,
1927 r#"{"is_enabled": false}"#,
1928 r#"{"is_enabled": true, "utilization": null}"#,
1929 r#"{"is_enabled": true, "utilization": "42"}"#,
1930 r#"{"is_enabled": true}"#,
1931 r#"{"is_enabled": "true", "utilization": 7}"#,
1932 r#"{"utilization": 7}"#,
1933 "null",
1934 ] {
1935 let s = parse(omitted);
1936 assert!(s.window("extra_usage").is_none(), "{omitted}");
1937 assert_eq!(s.windows.len(), 1, "{omitted}");
1938 assert!(s.notes.is_empty(), "{omitted}: {:?}", s.notes);
1939 }
1940 for (shown, percent) in [
1941 (r#"{"is_enabled": true, "utilization": 0}"#, 0.0),
1942 (
1943 r#"{"is_enabled": true, "monthly_limit": 5000, "used_credits": 2100, "utilization": 42}"#,
1944 42.0,
1945 ),
1946 ] {
1947 let s = parse(shown);
1948 let w = s.window("extra_usage").unwrap_or_else(|| panic!("{shown}"));
1949 assert_eq!(w.used_percent.valid(), Some(percent), "{shown}");
1950 assert_eq!(w.scope, WindowScope::Feature { feature: "extra_usage".into() });
1951 assert!(s.notes.is_empty(), "{shown}: {:?}", s.notes);
1952 }
1953 let s = parse_anthropic_usage(
1955 r#"{"five_hour": {"utilization": 10}, "extra_usage": {"is_enabled": false}, "new_counter": 3}"#,
1956 "a",
1957 T0,
1958 )
1959 .unwrap();
1960 assert!(s.notes.iter().any(|n| n == "unrecognized fields ignored"));
1961 }
1962
1963 #[test]
1964 fn anthropic_missing_null_malformed_percent_and_reset() {
1965 let body = r#"{
1966 "five_hour": {"resets_at": "2025-09-20T22:00:00Z"},
1967 "seven_day": {"utilization": null, "resets_at": null},
1968 "seven_day_sonnet": {"utilization": "lots", "resets_at": "yesterday"},
1969 "seven_day_opus": {"utilization": 250, "resets_at": 1758400000},
1970 "one_day": {"utilization": -1}
1971 }"#;
1972 let s = parse_anthropic_usage(body, "a", T0).unwrap();
1973 assert_eq!(s.window("five_hour").unwrap().used_percent, UsedPercent::unknown("missing"));
1974 assert_eq!(s.window("seven_day").unwrap().used_percent, UsedPercent::unknown("null"));
1975 assert_eq!(s.window("seven_day").unwrap().reset_at, None);
1976 let sonnet = s.window("seven_day_sonnet").unwrap();
1977 assert_eq!(sonnet.used_percent, UsedPercent::unknown("not a number"));
1978 assert_eq!(sonnet.reset_at, None);
1979 assert!(s.notes.iter().any(|n| n == "reset timestamp unparseable"));
1980 let opus = s.window("seven_day_opus").unwrap();
1981 assert_eq!(opus.used_percent.valid(), Some(100.0), "overrun is exhausted");
1982 assert_eq!(opus.reset_at, Some(T0), "unix seconds accepted");
1983 assert_eq!(s.window("one_day").unwrap().duration_secs, Some(86400));
1984 assert_eq!(s.window("one_day").unwrap().used_percent, UsedPercent::unknown("out of range"));
1985 assert!(!s.has_proven_capacity(T0, 60_000));
1987 assert!(s.model_availability.iter().all(|m| m.availability
1989 == if m.model.contains("opus") {
1990 Availability::Exhausted
1991 } else {
1992 Availability::Unknown
1993 }));
1994 secret_free(&format!("{:?}", s.notes), &["lots", "yesterday"]);
1996 }
1997
1998 #[test]
1999 fn anthropic_rejects_non_json_and_non_object() {
2000 assert_eq!(
2001 parse_anthropic_usage("<html>", "a", T0).unwrap_err(),
2002 UsageError::Malformed { detail: "body is not JSON".into() }
2003 );
2004 assert_eq!(
2005 parse_anthropic_usage("[1,2]", "a", T0).unwrap_err(),
2006 UsageError::Malformed { detail: "top-level value is not an object".into() }
2007 );
2008 let empty = parse_anthropic_usage("{}", "a", T0).unwrap();
2009 assert!(empty.windows.is_empty());
2010 assert!(empty.notes.iter().any(|n| n == "no usage windows in response"));
2011 assert!(!empty.has_proven_capacity(T0, 60_000));
2012 }
2013
2014 fn codex_fixture(secondary: &str) -> String {
2017 format!(
2018 r#"{{
2019 "plan_type": "pro",
2020 "account_id": "acct_1234567890",
2021 "rate_limit": {{
2022 "allowed": true,
2023 "limit_reached": false,
2024 "primary_window": {{"used_percent": 37, "limit_window_seconds": 604800,
2025 "reset_after_seconds": 100000, "reset_at": 1758500000}},
2026 "secondary_window": {secondary}
2027 }},
2028 "code_review_rate_limit": {{
2029 "allowed": true, "limit_reached": true,
2030 "primary_window": {{"used_percent": 100, "limit_window_seconds": 18000, "reset_after_seconds": 120}}
2031 }},
2032 "additional_rate_limits": [
2033 {{"limit_name": "codex-auto-review", "metered_feature": "auto_review",
2034 "rate_limit": {{"allowed": true, "limit_reached": false,
2035 "primary_window": {{"used_percent": 5, "limit_window_seconds": 86400, "reset_at": 1758450000}}}}}},
2036 {{"limit_name": "gpt-5-mini", "normal_model_slug": "gpt-5-mini",
2037 "rate_limit": {{"limit_reached": false,
2038 "primary_window": {{"used_percent": 99.5, "limit_window_seconds": 18000}}}}}}
2039 ],
2040 "credits": {{"has_credits": true, "unlimited": false, "balance": "12.50"}},
2041 "rate_limit_reset_credits": {{"available_count": 2}}
2042 }}"#
2043 )
2044 }
2045
2046 #[test]
2047 fn codex_weekly_primary_and_null_secondary() {
2048 let s = parse_codex_usage(&codex_fixture("null"), "astra2", T0, Some("acct_1234567890")).unwrap();
2049 assert_eq!(s.provider, "openai-codex");
2050 assert_eq!(s.account, "astra2");
2051 assert_eq!(s.plan.as_deref(), Some("pro"));
2052 assert_eq!(s.identity_prefix.as_deref(), Some("acct_123"));
2053 assert_eq!(s.seat_fingerprint,
2054 super::super::seat_fingerprint(OAuthProviderId::OpenAiCodex, "acct_1234567890"));
2055 let unpaired = parse_codex_usage(&codex_fixture("null"), "astra2", T0, None).unwrap();
2056 assert!(unpaired.seat_fingerprint.is_none());
2057 assert_eq!(s.limit_reached, Some(false));
2058 let p = s.window("primary").unwrap();
2059 assert_eq!(p.duration_secs, Some(604800), "primary is weekly, not assumed 5h");
2060 assert_eq!(p.label, "7d");
2061 assert_eq!(p.used_percent.valid(), Some(37.0));
2062 assert_eq!(p.reset_at, Some(1_758_500_000_000), "provider reset_at is authoritative");
2063 assert_eq!(p.limit_reached, Some(false));
2064 assert!(s.window("secondary").is_none());
2065 assert!(s.notes.iter().any(|n| n == "null window skipped"));
2066 let cr = s.window("code_review.primary").unwrap();
2068 assert_eq!(cr.scope, WindowScope::Feature { feature: "code_review".into() });
2069 assert_eq!(cr.reset_at, Some(T0 + 120_000));
2070 assert_eq!(cr.limit_reached, Some(true));
2071 assert!(cr.is_exhausted());
2072 assert!(s.notes.iter().any(|n| n == "reset derived from reset_after_seconds"));
2073 assert_eq!(
2075 s.window("codex-auto-review.primary").unwrap().scope,
2076 WindowScope::Feature { feature: "codex-auto-review".into() }
2077 );
2078 let mini = s.window("gpt-5-mini.primary").unwrap();
2079 assert_eq!(mini.scope, WindowScope::Model { model: "gpt-5-mini".into() });
2080 assert_eq!(s.model_availability.len(), 1);
2081 assert_eq!(s.model_availability[0].availability, Availability::Available);
2082 let c = s.credits.as_ref().unwrap();
2084 assert_eq!(c.kind, "codex_credits");
2085 assert_eq!(c.remaining, Some(12.5));
2086 assert_eq!(c.has_credits, Some(true));
2087 let b = s.banked_resets.as_ref().unwrap();
2088 assert_eq!(b.available_count, Some(2));
2089 assert!(b.credits.is_empty());
2090 assert_eq!(b.inventory_error, None);
2091 assert!(s.has_proven_capacity(T0, 60_000));
2094 assert_eq!(s.longest_account_window().unwrap().id, "primary");
2095 }
2096
2097 #[test]
2098 fn codex_secondary_present_and_limit_reached_blocks_capacity() {
2099 let body = codex_fixture(
2100 r#"{"used_percent": 100, "limit_window_seconds": 18000, "reset_at": 1758410000}"#,
2101 )
2102 .replace(r#""limit_reached": false,
2103 "primary_window""#, r#""limit_reached": true,
2104 "primary_window""#);
2105 let s = parse_codex_usage(&body, "x", T0, None).unwrap();
2106 let sec = s.window("secondary").unwrap();
2107 assert_eq!(sec.duration_secs, Some(18000));
2108 assert!(sec.is_exhausted());
2109 assert_eq!(s.limit_reached, Some(true));
2110 assert!(!s.has_proven_capacity(T0, 60_000));
2111 assert_eq!(s.earliest_reset_at(), Some(T0 + 120_000));
2112 }
2113
2114 #[test]
2115 fn codex_identity_mismatch_fails_closed_and_absent_id_is_tolerated() {
2116 let err = parse_codex_usage(&codex_fixture("null"), "x", T0, Some("acct_other")).unwrap_err();
2117 assert_eq!(err, UsageError::IdentityMismatch);
2118 assert!(err.is_auth_failure());
2119 let body = codex_fixture("null").replace(r#""account_id": "acct_1234567890","#, "");
2121 let s = parse_codex_usage(&body, "x", T0, Some("acct_zzzzzzzzz")).unwrap();
2122 assert_eq!(s.identity_prefix.as_deref(), Some("acct_zzz"));
2123 }
2124
2125 #[test]
2126 fn codex_missing_and_malformed_fields_stay_unknown() {
2127 let body = r#"{
2128 "rate_limit": {
2129 "primary_window": {"used_percent": null, "limit_window_seconds": "week", "reset_at": "soon"},
2130 "secondary_window": {"used_percent": 101}
2131 },
2132 "rate_limit_reset_credits": null,
2133 "credits": {"has_credits": "yes"}
2134 }"#;
2135 let s = parse_codex_usage(body, "x", T0, None).unwrap();
2136 let p = s.window("primary").unwrap();
2137 assert_eq!(p.used_percent, UsedPercent::unknown("null"));
2138 assert_eq!(p.duration_secs, None);
2139 assert_eq!(p.label, "unknown");
2140 assert_eq!(p.reset_at, None);
2141 assert_eq!(s.window("secondary").unwrap().used_percent.valid(), Some(100.0));
2142 assert_eq!(s.limit_reached, None);
2143 assert!(s.banked_resets.is_none());
2144 assert_eq!(s.credits.as_ref().unwrap().has_credits, None);
2145 assert!(!s.has_proven_capacity(T0, 60_000));
2146 secret_free(&format!("{:?}", s.notes), &["soon", "week"]);
2147
2148 let none = parse_codex_usage(r#"{"plan_type": "plus"}"#, "x", T0, None).unwrap();
2149 assert!(none.windows.is_empty());
2150 assert!(none.notes.iter().any(|n| n == "rate_limit missing"));
2151 assert!(!none.has_proven_capacity(T0, 60_000));
2152 }
2153
2154 const CODEX_ASTRA_FIXTURE: &str = r#"{
2158 "plan_type": "pro",
2159 "rate_limit": {
2160 "allowed": true, "limit_reached": false,
2161 "primary_window": {"used_percent": 10, "limit_window_seconds": 604800, "reset_at": 1758900000},
2162 "secondary_window": {"used_percent": 4, "limit_window_seconds": 18000, "reset_at": 1758410000}
2163 },
2164 "model_usage": {
2165 "gpt-6-astra": {"available": false, "available_at": "2026-01-05T09:00:00Z", "credits_would_enable": true},
2166 "gpt-5.1-codex": {"available": true},
2167 "gpt-5-mini": {"available": "maybe", "available_at": "later"},
2168 "broken": 7
2169 },
2170 "spend_control": {"reached": false,
2171 "workspace": {"reached": false, "limit": 500, "remaining_percent": 80, "resets_at": 1759276800}},
2172 "credits": {"has_credits": false, "unlimited": false, "balance": "0"}
2173 }"#;
2174
2175 #[test]
2176 fn codex_model_usage_gates_astra_while_generic_quota_has_room() {
2177 let s = parse_codex_usage(CODEX_ASTRA_FIXTURE, "astra1", T0, None).unwrap();
2178 assert!(s.has_proven_capacity(T0, 60_000));
2180 let astra = s.model_availability_for("gpt-6-astra").unwrap();
2182 assert_eq!(astra.availability, Availability::Exhausted);
2183 assert_eq!(astra.reset_at, epoch_ms_from_rfc3339("2026-01-05T09:00:00Z"));
2184 assert_eq!(astra.credits_would_enable, Some(true));
2185 assert_eq!(astra.source, "model_usage");
2186 assert_eq!(astra.used_percent, UsedPercent::unknown("missing"));
2187 assert!(!s.has_proven_capacity_for_model("gpt-6-astra", T0, 60_000));
2188 assert!(!s.has_proven_capacity_for_model("GPT-6-Astra", T0, 60_000), "case-insensitive");
2189 assert!(s.has_proven_capacity_for_model("gpt-5.1-codex", T0, 60_000));
2191 assert!(s.has_proven_capacity_for_model("gpt-5.1-codex-max", T0, 60_000), "unmentioned → account capacity");
2192 let mini = s.model_availability_for("gpt-5-mini").unwrap();
2194 assert_eq!(mini.availability, Availability::Unknown);
2195 assert_eq!(mini.reset_at, None);
2196 assert!(!s.has_proven_capacity_for_model("gpt-5-mini", T0, 60_000));
2197 assert!(s.notes.iter().any(|n| n == "reset timestamp unparseable"));
2198 assert!(s.notes.iter().any(|n| n == "model usage entry is not an object"));
2199 secret_free(&format!("{:?}", s.notes), &["maybe", "later", "broken"]);
2200 assert_eq!(s.spend_control_reached, Some(false));
2202 let sc = s.window("spend_control").unwrap();
2203 assert_eq!(sc.used_percent.valid(), Some(20.0));
2204 assert_eq!(sc.reset_at, Some(1_759_276_800_000));
2205 assert_eq!(sc.reset_kind, ResetKind::BillingRenewal);
2206 assert_eq!(s.earliest_reset_at(), Some(1_758_410_000_000));
2209 let v = serde_json::to_value(&s).unwrap();
2211 let astra_json = v["model_availability"]
2212 .as_array()
2213 .unwrap()
2214 .iter()
2215 .find(|m| m["model"] == "gpt-6-astra")
2216 .expect("astra entry serialized");
2217 assert_eq!(astra_json["availability"], "exhausted");
2218 assert_eq!(astra_json["credits_would_enable"], true);
2219 assert_eq!(astra_json["source"], "model_usage");
2220 }
2221
2222 #[test]
2223 fn codex_spend_control_reached_blocks_capacity() {
2224 let top = CODEX_ASTRA_FIXTURE.replace(r#""spend_control": {"reached": false,"#, r#""spend_control": {"reached": true,"#);
2225 let s = parse_codex_usage(&top, "a", T0, None).unwrap();
2226 assert_eq!(s.spend_control_reached, Some(true));
2227 assert!(!s.has_proven_capacity(T0, 60_000));
2228 assert!(!s.has_proven_capacity_for_model("gpt-5.1-codex", T0, 60_000));
2229 let nested = CODEX_ASTRA_FIXTURE.replace(r#""workspace": {"reached": false,"#, r#""workspace": {"reached": true,"#);
2231 let s = parse_codex_usage(&nested, "a", T0, None).unwrap();
2232 assert_eq!(s.spend_control_reached, Some(true));
2233 assert!(!s.has_proven_capacity(T0, 60_000));
2234 let s = parse_codex_usage(&codex_fixture("null"), "a", T0, None).unwrap();
2236 assert_eq!(s.spend_control_reached, None);
2237 assert!(s.has_proven_capacity(T0, 60_000));
2238 }
2239
2240 #[test]
2241 fn model_availability_merge_never_overwrites_explicit_entry() {
2242 let body = r#"{
2245 "rate_limit": {"limit_reached": false, "primary_window": {"used_percent": 1, "limit_window_seconds": 18000}},
2246 "model_usage": {"gpt-5-mini": {"available": true, "available_at": "2025-09-21T00:00:00Z"},
2247 "gpt-6-astra": {"available": true}},
2248 "additional_rate_limits": [
2249 {"limit_name": "mini", "normal_model_slug": "gpt-5-mini",
2250 "rate_limit": {"primary_window": {"used_percent": 100, "reset_at": 1758500000}}}
2251 ]
2252 }"#;
2253 let s = parse_codex_usage(body, "a", T0, None).unwrap();
2254 assert_eq!(s.model_availability.len(), 2, "merged, not duplicated");
2255 let mini = s.model_availability_for("gpt-5-mini").unwrap();
2256 assert_eq!(mini.availability, Availability::Exhausted);
2257 assert_eq!(mini.used_percent.valid(), Some(100.0));
2258 assert_eq!(mini.reset_at, epoch_ms_from_rfc3339("2025-09-21T00:00:00Z"), "explicit reset kept");
2259 assert_eq!(mini.source, "merged");
2260 let astra = s.model_availability_for("gpt-6-astra").unwrap();
2261 assert_eq!(astra.availability, Availability::Available);
2262 assert_eq!(astra.source, "model_usage");
2263 assert!(!s.has_proven_capacity_for_model("gpt-5-mini", T0, 60_000));
2264 assert!(s.has_proven_capacity_for_model("gpt-6-astra", T0, 60_000));
2265 let a = parse_anthropic_usage(ANTHROPIC_FIXTURE, "d", T0).unwrap();
2267 assert!(!a.has_proven_capacity_for_model("claude-sonnet-4-5", T0, 60_000), "sonnet window is at 100%");
2268 assert!(a.has_proven_capacity_for_model("claude-opus-4-1", T0, 60_000), "opus window was null → unmentioned");
2269 assert!(a.model_availability_for("sonnetx").is_none(), "token match, not substring");
2270 assert!(a.model_availability_for("claude-3-7-sonnet-latest").is_some());
2271 }
2272
2273 #[test]
2274 fn codex_reset_credit_inventory_parses_details() {
2275 let body = r#"{"available_count": 1, "credits": [
2276 {"credit_id": "c1", "reset_type": "weekly", "granted_at": 1758300000, "expires_at": "2025-10-20T00:00:00Z"},
2277 {"reset_type": null, "expires_at": "garbage"}
2278 ]}"#;
2279 let b = parse_codex_reset_credits(body).unwrap();
2280 assert_eq!(b.available_count, Some(1));
2281 assert_eq!(b.credits.len(), 2);
2282 assert_eq!(b.credits[0].reset_type.as_deref(), Some("weekly"));
2283 assert_eq!(b.credits[0].granted_at, Some(1_758_300_000_000));
2284 assert_eq!(b.credits[0].expires_at, epoch_ms_from_rfc3339("2025-10-20T00:00:00Z"));
2285 assert_eq!(b.credits[1].expires_at, None);
2286 assert!(parse_codex_reset_credits("[]").is_err());
2287 let unknown = parse_codex_reset_credits("{}").unwrap();
2288 assert_eq!(unknown.available_count, None, "unknown is not zero");
2289 }
2290
2291 const KIMI_FIXTURE: &str = r#"{
2294 "usage": {"limit": "1000", "used": "250", "remaining": "750", "resetTime": "2025-09-24T00:00:00Z"},
2295 "limits": [
2296 {"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"},
2297 "detail": {"limit": 100, "used": 100, "resetTime": "2025-09-20T22:00:00Z"}},
2298 {"name": "weekly", "window": {"duration": 1, "timeUnit": "TIME_UNIT_WEEK"},
2299 "detail": {"limit": "0", "used": "0"}},
2300 {"window": {"duration": 1, "timeUnit": "TIME_UNIT_FORTNIGHT"},
2301 "detail": {"limit": 10, "used": "many", "resetTime": 1758400000}}
2302 ],
2303 "boosterWallet": {
2304 "balance": {"type": "BOOSTER", "amount": 2000000000, "amountLeft": 500000000},
2305 "monthlyChargeLimit": {"priceInCents": 5000, "currency": "USD"}
2306 }
2307 }"#;
2308
2309 #[test]
2310 fn kimi_valid_fixture() {
2311 let s = parse_kimi_usage(KIMI_FIXTURE, "m27", T0).unwrap();
2312 assert_eq!(s.provider, "kimi-code");
2313 let summary = s.window("usage").unwrap();
2314 assert_eq!(summary.duration_secs, Some(604800), "summary implies one week");
2315 assert_eq!(summary.used_percent.valid(), Some(25.0));
2316 assert_eq!(summary.used, Some(250.0));
2317 assert_eq!(summary.limit, Some(1000.0));
2318 assert_eq!(summary.reset_at, epoch_ms_from_rfc3339("2025-09-24T00:00:00Z"));
2319 let w0 = s.window("limits[0]").unwrap();
2320 assert_eq!(w0.duration_secs, Some(18000), "300 minutes = 5h");
2321 assert_eq!(w0.label, "5h");
2322 assert!(w0.is_exhausted());
2323 let w1 = s.window("limits[1]").unwrap();
2324 assert_eq!(w1.label, "weekly");
2325 assert_eq!(w1.used_percent, UsedPercent::unknown("limit is zero"));
2326 let w2 = s.window("limits[2]").unwrap();
2327 assert_eq!(w2.duration_secs, None, "unknown time unit → unknown duration");
2328 assert_eq!(w2.used_percent, UsedPercent::unknown("missing"), "non-numeric used");
2329 assert_eq!(w2.reset_at, Some(T0));
2330 assert!(!s.has_proven_capacity(T0, 60_000));
2332 let c = s.credits.as_ref().unwrap();
2333 assert_eq!(c.kind, "kimi_booster");
2334 assert_eq!(c.total, Some(20.0));
2335 assert_eq!(c.remaining, Some(5.0));
2336 assert_eq!(c.unit.as_deref(), Some("USD"));
2337 assert_eq!(c.has_credits, Some(true));
2338 }
2339
2340 #[test]
2341 fn kimi_missing_and_malformed() {
2342 let s = parse_kimi_usage(r#"{"usage": {"remaining": "5"}, "limits": "nope"}"#, "x", T0).unwrap();
2343 assert!(s.windows.is_empty());
2344 assert!(s.notes.iter().any(|n| n == "summary usage row unparseable"));
2345 assert!(s.notes.iter().any(|n| n == "limits field is not an array"));
2346 assert!(s.notes.iter().any(|n| n == "no usage windows in response"));
2347 assert!(parse_kimi_usage("null", "x", T0).is_err());
2348 let ok = parse_kimi_usage(
2349 r#"{"limits": [{"window": {"duration": 5, "timeUnit": "TIME_UNIT_HOUR"}, "detail": {"limit": 100, "used": 10}}]}"#,
2350 "x",
2351 T0,
2352 )
2353 .unwrap();
2354 assert!(ok.has_proven_capacity(T0, 60_000));
2355 assert_eq!(ok.window("limits[0]").unwrap().reset_at, None);
2356 }
2357
2358 #[test]
2361 fn grok_quotakit_shape_with_percent() {
2362 let body = r#"{"config": {"creditUsagePercent": 42.5, "billingPeriodMinutes": 43200,
2363 "currentPeriod": {"start": "2025-09-01T00:00:00Z", "end": "2025-10-01T00:00:00Z"}},
2364 "onDemandUsed": {"val": 425}, "onDemandCap": {"val": 1000}}"#;
2365 let s = parse_grok_billing(body, "default", T0).unwrap();
2366 assert_eq!(s.provider, "xai-auth");
2367 let w = s.window("credits").unwrap();
2368 assert_eq!(w.used_percent.valid(), Some(42.5));
2369 assert_eq!(w.duration_secs, Some(43200 * 60));
2370 assert_eq!(w.label, "30d");
2371 assert_eq!(w.reset_at, epoch_ms_from_rfc3339("2025-10-01T00:00:00Z"));
2372 assert_eq!(w.reset_kind, ResetKind::BillingRenewal);
2373 assert_eq!(w.used, Some(425.0));
2374 assert_eq!(w.limit, Some(1000.0));
2375 let c = s.credits.as_ref().unwrap();
2376 assert_eq!(c.remaining, Some(575.0));
2377 assert_eq!(c.renews_at, w.reset_at);
2378 assert!(s.has_proven_capacity(T0, 60_000));
2379 assert!(s.notes.iter().any(|n| n.contains("unverified")));
2380 }
2381
2382 #[test]
2383 fn grok_fallback_ratio_and_billing_period_end() {
2384 let body = r#"{"onDemandUsed": {"val": "250"}, "onDemandCap": {"val": 1000},
2385 "config": {"billingPeriodEnd": 1759276800}}"#;
2386 let s = parse_grok_billing(body, "x", T0).unwrap();
2387 let w = s.window("credits").unwrap();
2388 assert_eq!(w.used_percent.valid(), Some(25.0));
2389 assert_eq!(w.reset_at, Some(1_759_276_800_000));
2390 assert_eq!(w.label, "billing period");
2391 assert!(s.notes.iter().any(|n| n == "percent derived from on-demand counters"));
2392 }
2393
2394 #[test]
2395 fn grok_unknown_when_no_fraction_or_schema() {
2396 let s = parse_grok_billing(r#"{"config": {"billingPeriodMinutes": 1440}}"#, "x", T0).unwrap();
2398 let w = s.window("credits").unwrap();
2399 assert_eq!(w.used_percent, UsedPercent::unknown("missing"));
2400 assert!(!s.has_proven_capacity(T0, 60_000));
2401 let s = parse_grok_billing(r#"{"config": {"creditUsagePercent": 140}}"#, "x", T0).unwrap();
2403 assert_eq!(s.window("credits").unwrap().used_percent.valid(), Some(100.0));
2404 let s = parse_grok_billing(r#"{"config": {"creditUsagePercent": -5}}"#, "x", T0).unwrap();
2406 assert_eq!(s.window("credits").unwrap().used_percent, UsedPercent::unknown("out of range"));
2407 let s = parse_grok_billing(r#"{"onDemandUsed": {"val": 0}, "onDemandCap": {"val": 0}}"#, "x", T0).unwrap();
2409 assert_eq!(s.window("credits").unwrap().used_percent, UsedPercent::unknown("limit is zero"));
2410 let s = parse_grok_billing(r#"{"sk_live_secretkey": 1, "mystery": {"x": 2}}"#, "x", T0).unwrap();
2412 assert!(s.windows.is_empty());
2413 assert!(s.notes.iter().any(|n| n == "unrecognized billing schema"));
2414 secret_free(&serde_json::to_string(&s).unwrap(), &["sk_live", "mystery"]);
2415 assert!(parse_grok_billing("", "x", T0).is_err());
2416 }
2417
2418 #[test]
2421 fn staleness_and_json_round_trip() {
2422 let s = parse_codex_usage(&codex_fixture("null"), "a", T0, None).unwrap();
2423 assert!(!s.is_stale(T0 + 1000, 5_000));
2424 assert!(s.is_stale(T0 + 6_000, 5_000));
2425 assert!(s.is_stale(T0 - 1, 5_000), "future observation is stale");
2426 assert!(!s.has_proven_capacity(T0 + 10_000, 5_000), "stale is not capacity");
2427 let json = serde_json::to_string_pretty(&s).unwrap();
2428 let back: UsageSnapshot = serde_json::from_str(&json).unwrap();
2429 assert_eq!(back, s);
2430 let v: Value = serde_json::from_str(&json).unwrap();
2431 assert_eq!(v["schema_version"], SCHEMA_VERSION);
2432 assert_eq!(v["windows"][0]["used_percent"]["state"], "valid");
2433 assert_eq!(v["windows"][0]["scope"]["kind"], "account");
2434 assert_eq!(v["windows"][0]["reset_kind"], "quota_window");
2435 }
2436
2437 #[test]
2438 fn usage_entry_accepts_older_reports_without_identity() {
2439 let entry: AccountUsageEntry = serde_json::from_value(serde_json::json!({
2440 "provider": "anthropic", "account": "default", "status": "error",
2441 "error": {"kind": "credential", "message": "unavailable", "http_status": null}
2442 })).unwrap();
2443 assert_eq!(entry.identity, None);
2444 assert!(serde_json::to_value(entry).unwrap().get("identity").is_none());
2445 }
2446
2447 #[test]
2448 fn report_round_trip_with_partial_errors() {
2449 let snap = parse_anthropic_usage(ANTHROPIC_FIXTURE, "default", T0).unwrap();
2450 let mut report = UsageReport::new("local", T0);
2451 report.accounts.push(AccountUsageEntry {
2452 provider: "anthropic".into(),
2453 account: "default".into(),
2454 identity: None,
2455 outcome: AccountUsageOutcome::ok(snap),
2456 });
2457 report.accounts.push(AccountUsageEntry {
2458 provider: "openai-codex".into(),
2459 account: "astra2".into(),
2460 identity: None,
2461 outcome: AccountUsageOutcome::error(&UsageError::Unauthorized { status: 401 }),
2462 });
2463 assert_eq!(report.ok_count(), 1);
2464 assert_eq!(report.error_count(), 1);
2465 let v = serde_json::to_value(&report).unwrap();
2466 assert_eq!(v["accounts"][0]["status"], "ok");
2467 assert_eq!(v["accounts"][1]["status"], "error");
2468 assert_eq!(v["accounts"][1]["error"]["kind"], "unauthorized");
2469 assert_eq!(v["accounts"][1]["error"]["http_status"], 401);
2470 let back: UsageReport = serde_json::from_value(v).unwrap();
2471 assert_eq!(back, report);
2472 }
2473
2474 #[test]
2475 fn error_display_and_broker_mapping_are_secret_free() {
2476 let e = UsageError::Transport { detail: "connect".into() };
2477 assert_eq!(e.kind(), "transport");
2478 assert!(matches!(e.clone().into_broker_error(), BrokerError::Transport(_)));
2479 assert!(matches!(
2480 UsageError::Unauthorized { status: 401 }.into_broker_error(),
2481 BrokerError::Credential(_)
2482 ));
2483 assert!(matches!(
2484 UsageError::UnsupportedProvider { provider: "github-copilot".into() }.into_broker_error(),
2485 BrokerError::UnsupportedCapability { capability, .. } if capability == "usage"
2486 ));
2487 assert!(matches!(
2488 UsageError::InvalidEndpointOverride.into_broker_error(),
2489 BrokerError::Denied(_)
2490 ));
2491 let v = serde_json::to_value(UsageError::UpstreamStatus {
2492 status: 502,
2493 retry_after_s: None,
2494 })
2495 .unwrap();
2496 assert_eq!(v, serde_json::json!({"kind":"upstream_status","status":502}));
2497 assert!(!supports_usage(OAuthProviderId::GitHubCopilot));
2498 assert!(!supports_usage(OAuthProviderId::GoogleGemini));
2499 assert_eq!(supported_usage_providers().len(), 4);
2500 }
2501
2502 #[test]
2503 fn endpoint_override_is_loopback_only() {
2504 assert!(is_loopback_url("http://127.0.0.1:8080/usage"));
2505 assert!(is_loopback_url("http://localhost:1/x"));
2506 assert!(is_loopback_url("http://[::1]:9/x"));
2507 assert!(!is_loopback_url("https://api.anthropic.com/api/oauth/usage"));
2508 assert!(!is_loopback_url("http://127.0.0.1.evil.example/x"));
2509 assert!(!is_loopback_url("ftp://127.0.0.1/x"));
2510 assert!(!is_loopback_url("not a url"));
2511 assert_eq!(
2512 resolve_endpoint(CODEX_USAGE_URL, Some("https://attacker.example/usage")).unwrap_err(),
2513 UsageError::InvalidEndpointOverride
2514 );
2515 assert_eq!(resolve_endpoint(CODEX_USAGE_URL, None).unwrap(), CODEX_USAGE_URL);
2516 }
2517
2518 fn codex_token(account_id: Option<&str>) -> String {
2521 let payload = match account_id {
2522 Some(id) => serde_json::json!({"https://api.openai.com/auth": {"chatgpt_account_id": id}}),
2523 None => serde_json::json!({"sub": "nobody"}),
2524 };
2525 format!(
2526 "hdr.{}.sig",
2527 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
2528 )
2529 }
2530
2531 async fn spawn(app: axum::Router) -> String {
2532 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2533 let addr = listener.local_addr().unwrap();
2534 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2535 format!("http://{addr}")
2536 }
2537
2538 fn opts(url: &str) -> UsageFetchOptions {
2539 UsageFetchOptions {
2540 endpoint_override: Some(url.to_string()),
2541 ..Default::default()
2542 }
2543 }
2544
2545 #[tokio::test]
2546 async fn fetch_unsupported_provider_makes_no_request() {
2547 let client = UsageClient::new().unwrap();
2548 let err = fetch_usage(
2549 &client,
2550 OAuthProviderId::GitHubCopilot,
2551 "default",
2552 "tok",
2553 &opts("http://127.0.0.1:9/never"),
2554 )
2555 .await
2556 .unwrap_err();
2557 assert!(matches!(err, UsageError::UnsupportedProvider { .. }));
2558 }
2559
2560 #[tokio::test]
2561 async fn fetch_rejects_non_loopback_override_before_sending() {
2562 let client = UsageClient::new().unwrap();
2563 let err = fetch_usage(
2564 &client,
2565 OAuthProviderId::Anthropic,
2566 "default",
2567 "tok",
2568 &opts("https://evil.example/usage"),
2569 )
2570 .await
2571 .unwrap_err();
2572 assert_eq!(err, UsageError::InvalidEndpointOverride);
2573 }
2574
2575 #[tokio::test]
2576 async fn fetch_anthropic_sends_bearer_and_beta_header() {
2577 use axum::{extract::Request, routing::get};
2578 let app = axum::Router::new().route(
2579 "/usage",
2580 get(|req: Request| async move {
2581 let h = req.headers();
2582 assert_eq!(h.get("authorization").unwrap(), "Bearer anthropic-access-token");
2583 assert_eq!(h.get("anthropic-beta").unwrap(), ANTHROPIC_OAUTH_BETA);
2584 assert_eq!(h.get("accept").unwrap(), "application/json");
2585 assert!(h.get("user-agent").unwrap().to_str().unwrap().starts_with("SynapsCLI/"));
2586 ([("content-type", "application/json")], ANTHROPIC_FIXTURE)
2587 }),
2588 );
2589 let url = spawn(app).await;
2590 let client = UsageClient::new().unwrap();
2591 let s = fetch_usage(
2592 &client,
2593 OAuthProviderId::Anthropic,
2594 "default",
2595 "anthropic-access-token",
2596 &opts(&format!("{url}/usage")),
2597 )
2598 .await
2599 .unwrap();
2600 assert_eq!(s.windows.len(), 4);
2601 assert!(s.observed_at >= T0);
2602 secret_free(&serde_json::to_string(&s).unwrap(), &["anthropic-access-token"]);
2603 }
2604
2605 #[tokio::test]
2606 async fn fetch_codex_pairs_account_header_with_token_and_reads_inventory() {
2607 use axum::{extract::Request, routing::get};
2608 let app = axum::Router::new()
2609 .route(
2610 "/wham/usage",
2611 get(|req: Request| async move {
2612 let h = req.headers();
2613 assert_eq!(h.get("authorization").unwrap().to_str().unwrap(), format!("Bearer {}", codex_token(Some("acct_1234567890"))));
2614 assert_eq!(h.get(CODEX_ACCOUNT_HEADER).unwrap(), "acct_1234567890");
2615 codex_fixture("null")
2616 }),
2617 )
2618 .route(
2619 "/wham/rate-limit-reset-credits",
2620 get(|req: Request| async move {
2621 assert_eq!(req.headers().get(CODEX_ACCOUNT_HEADER).unwrap(), "acct_1234567890");
2622 r#"{"available_count": 3, "credits": [{"reset_type": "weekly", "expires_at": 1760000000}]}"#
2623 }),
2624 );
2625 let url = spawn(app).await;
2626 let client = UsageClient::new().unwrap();
2627 let o = UsageFetchOptions {
2628 endpoint_override: Some(format!("{url}/wham/usage")),
2629 inventory_endpoint_override: Some(format!("{url}/wham/rate-limit-reset-credits")),
2630 include_inventory: true,
2631 ..Default::default()
2632 };
2633 let s = fetch_usage(&client, OAuthProviderId::OpenAiCodex, "astra2", &codex_token(Some("acct_1234567890")), &o)
2634 .await
2635 .unwrap();
2636 let b = s.banked_resets.as_ref().unwrap();
2637 assert_eq!(b.available_count, Some(3), "inventory detail overrides summary count");
2638 assert_eq!(b.credits.len(), 1);
2639 assert_eq!(b.credits[0].expires_at, Some(1_760_000_000_000));
2640 assert_eq!(b.inventory_error, None);
2641 }
2642
2643 #[tokio::test]
2644 async fn fetch_codex_inventory_failure_is_partial_not_total() {
2645 use axum::routing::get;
2646 let app = axum::Router::new()
2647 .route("/wham/usage", get(|| async { codex_fixture("null") }))
2648 .route(
2649 "/wham/rate-limit-reset-credits",
2650 get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "boom-secret-body") }),
2651 );
2652 let url = spawn(app).await;
2653 let client = UsageClient::new().unwrap();
2654 let o = UsageFetchOptions {
2655 endpoint_override: Some(format!("{url}/wham/usage")),
2656 inventory_endpoint_override: Some(format!("{url}/wham/rate-limit-reset-credits")),
2657 include_inventory: true,
2658 ..Default::default()
2659 };
2660 let s = fetch_usage(&client, OAuthProviderId::OpenAiCodex, "a", &codex_token(Some("acct_1234567890")), &o)
2661 .await
2662 .unwrap();
2663 assert_eq!(s.window("primary").unwrap().used_percent.valid(), Some(37.0));
2664 let b = s.banked_resets.as_ref().unwrap();
2665 assert_eq!(b.available_count, Some(2), "summary count survives");
2666 assert_eq!(b.inventory_error.as_deref(), Some("upstream_status"));
2667 secret_free(&serde_json::to_string(&s).unwrap(), &["boom-secret-body"]);
2668 }
2669
2670 #[tokio::test]
2671 async fn fetch_codex_without_account_claim_makes_no_request() {
2672 use axum::routing::get;
2673 let app = axum::Router::new().route(
2674 "/wham/usage",
2675 get(|| async {
2676 if true {
2677 panic!("request must not be sent without a paired account id");
2678 }
2679 "{}"
2680 }),
2681 );
2682 let url = spawn(app).await;
2683 let client = UsageClient::new().unwrap();
2684 let err = fetch_usage(&client, OAuthProviderId::OpenAiCodex, "a", &codex_token(None), &opts(&format!("{url}/wham/usage")))
2685 .await
2686 .unwrap_err();
2687 assert_eq!(err, UsageError::AccountIdUnavailable);
2688 assert!(matches!(err.into_broker_error(), BrokerError::Credential(_)));
2689 }
2690
2691 #[tokio::test]
2692 async fn fetch_codex_identity_mismatch_from_server() {
2693 use axum::routing::get;
2694 let app = axum::Router::new().route(
2695 "/wham/usage",
2696 get(|| async { codex_fixture("null").replace("acct_1234567890", "acct_someoneelse") }),
2697 );
2698 let url = spawn(app).await;
2699 let client = UsageClient::new().unwrap();
2700 let err = fetch_usage(&client, OAuthProviderId::OpenAiCodex, "a", &codex_token(Some("acct_1234567890")), &opts(&format!("{url}/wham/usage")))
2701 .await
2702 .unwrap_err();
2703 assert_eq!(err, UsageError::IdentityMismatch);
2704 }
2705
2706 #[tokio::test]
2707 async fn fetch_kimi_sends_only_bearer_and_accept() {
2708 use axum::{extract::Request, routing::get};
2709 let app = axum::Router::new().route(
2710 "/coding/v1/usages",
2711 get(|req: Request| async move {
2712 let h = req.headers();
2713 assert_eq!(h.get("authorization").unwrap(), "Bearer kimi-token");
2714 assert!(h.get("x-msh-device-id").is_none(), "no device identity on usage reads");
2715 KIMI_FIXTURE
2716 }),
2717 );
2718 let url = spawn(app).await;
2719 let client = UsageClient::new().unwrap();
2720 let s = fetch_usage(&client, OAuthProviderId::KimiCode, "m27", "kimi-token", &opts(&format!("{url}/coding/v1/usages")))
2721 .await
2722 .unwrap();
2723 assert_eq!(s.window("usage").unwrap().used_percent.valid(), Some(25.0));
2724 }
2725
2726 #[tokio::test]
2727 async fn fetch_grok_sends_token_auth_header() {
2728 use axum::{extract::Request, routing::get};
2729 let app = axum::Router::new().route(
2730 "/v1/billing",
2731 get(|req: Request| async move {
2732 let h = req.headers();
2733 assert_eq!(h.get("authorization").unwrap(), "Bearer grok-token");
2734 assert_eq!(h.get(GROK_TOKEN_AUTH_HEADER).unwrap(), GROK_TOKEN_AUTH_VALUE);
2735 assert_eq!(req.uri().query(), Some("format=credits"));
2736 r#"{"config": {"creditUsagePercent": 10}}"#
2737 }),
2738 );
2739 let url = spawn(app).await;
2740 let client = UsageClient::new().unwrap();
2741 let s = fetch_usage(&client, OAuthProviderId::Xai, "default", "grok-token", &opts(&format!("{url}/v1/billing?format=credits")))
2742 .await
2743 .unwrap();
2744 assert_eq!(s.window("credits").unwrap().used_percent.valid(), Some(10.0));
2745 }
2746
2747 #[tokio::test]
2748 async fn fetch_status_classification_drops_upstream_bodies() {
2749 use axum::routing::get;
2750 let hostile = "{\"error\":\"leak-me-ZZZ\",\"token\":\"sk-ant-secret\"}";
2751 let app = axum::Router::new()
2752 .route(
2753 "/401",
2754 get(move || async move { (axum::http::StatusCode::UNAUTHORIZED, hostile) }),
2755 )
2756 .route(
2757 "/403",
2758 get(move || async move { (axum::http::StatusCode::FORBIDDEN, hostile) }),
2759 )
2760 .route(
2761 "/429",
2762 get(move || async move { (axum::http::StatusCode::TOO_MANY_REQUESTS, hostile) }),
2763 )
2764 .route(
2765 "/429r",
2766 get(move || async move {
2767 (
2768 axum::http::StatusCode::TOO_MANY_REQUESTS,
2769 [("retry-after", "120")],
2770 hostile,
2771 )
2772 }),
2773 )
2774 .route(
2775 "/429big",
2776 get(move || async move {
2777 (
2778 axum::http::StatusCode::TOO_MANY_REQUESTS,
2779 [("retry-after", "999999999")],
2780 hostile,
2781 )
2782 }),
2783 )
2784 .route(
2785 "/429date",
2786 get(move || async move {
2787 (
2788 axum::http::StatusCode::TOO_MANY_REQUESTS,
2789 [("retry-after", "Wed, 21 Oct 2015 07:28:00 GMT")],
2790 hostile,
2791 )
2792 }),
2793 )
2794 .route(
2795 "/500",
2796 get(
2797 move || async move { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, hostile) },
2798 ),
2799 )
2800 .route(
2801 "/503r",
2802 get(move || async move {
2803 (
2804 axum::http::StatusCode::SERVICE_UNAVAILABLE,
2805 [("retry-after", "30")],
2806 hostile,
2807 )
2808 }),
2809 )
2810 .route(
2811 "/503date",
2812 get(move || async move {
2813 (
2814 axum::http::StatusCode::SERVICE_UNAVAILABLE,
2815 [("retry-after", "Wed, 21 Oct 2015 07:28:00 GMT")],
2816 hostile,
2817 )
2818 }),
2819 )
2820 .route(
2821 "/302",
2822 get(move || async move {
2823 (
2824 axum::http::StatusCode::FOUND,
2825 [("location", "http://127.0.0.1:9/elsewhere")],
2826 hostile,
2827 )
2828 }),
2829 );
2830 let url = spawn(app).await;
2831 let client = UsageClient::new().unwrap();
2832 let cases: [(&str, UsageError); 10] = [
2833 ("401", UsageError::Unauthorized { status: 401 }),
2834 ("403", UsageError::Unauthorized { status: 403 }),
2835 (
2836 "429",
2837 UsageError::RateLimited {
2838 retry_after_s: None,
2839 },
2840 ),
2841 (
2842 "429r",
2843 UsageError::RateLimited {
2844 retry_after_s: Some(120),
2845 },
2846 ),
2847 (
2848 "429big",
2849 UsageError::RateLimited {
2850 retry_after_s: Some(86_400),
2851 },
2852 ),
2853 (
2854 "429date",
2855 UsageError::RateLimited {
2856 retry_after_s: None,
2857 },
2858 ),
2859 (
2860 "500",
2861 UsageError::UpstreamStatus {
2862 status: 500,
2863 retry_after_s: None,
2864 },
2865 ),
2866 (
2869 "503r",
2870 UsageError::UpstreamStatus {
2871 status: 503,
2872 retry_after_s: Some(30),
2873 },
2874 ),
2875 (
2876 "503date",
2877 UsageError::UpstreamStatus {
2878 status: 503,
2879 retry_after_s: None,
2880 },
2881 ),
2882 ("302", UsageError::Redirected { status: 302 }),
2883 ];
2884 for (path, expected) in cases {
2885 let err = fetch_usage(&client, OAuthProviderId::Anthropic, "d", "tok-ZZ-secret", &opts(&format!("{url}/{path}")))
2886 .await
2887 .unwrap_err();
2888 assert_eq!(err, expected, "path {path}");
2889 secret_free(&err.to_string(), &["leak-me", "sk-ant", "tok-ZZ"]);
2890 secret_free(&serde_json::to_string(&err).unwrap(), &["leak-me", "sk-ant"]);
2891 }
2892 let failure = UsageError::UpstreamStatus {
2894 status: 503,
2895 retry_after_s: Some(30),
2896 }
2897 .failure();
2898 assert_eq!(failure.http_status, Some(503));
2899 assert_eq!(failure.retry_after_s, Some(30));
2900 }
2901
2902 #[tokio::test]
2903 async fn fetch_body_cap_and_timeout_and_malformed() {
2904 use axum::routing::get;
2905 let app = axum::Router::new()
2906 .route("/big", get(|| async { format!("{{\"five_hour\":{{\"utilization\":1,\"pad\":\"{}\"}}}}", "x".repeat(4096)) }))
2907 .route(
2908 "/slow",
2909 get(|| async {
2910 tokio::time::sleep(Duration::from_secs(5)).await;
2911 "{}"
2912 }),
2913 )
2914 .route("/html", get(|| async { "<html>not json</html>" }));
2915 let url = spawn(app).await;
2916 let client = UsageClient::new().unwrap();
2917
2918 let o = UsageFetchOptions {
2919 endpoint_override: Some(format!("{url}/big")),
2920 max_body_bytes: 1024,
2921 ..Default::default()
2922 };
2923 let err = fetch_usage(&client, OAuthProviderId::Anthropic, "d", "tok", &o).await.unwrap_err();
2924 assert_eq!(err, UsageError::BodyTooLarge { cap: 1024 });
2925
2926 let o = UsageFetchOptions {
2927 endpoint_override: Some(format!("{url}/slow")),
2928 timeout: Duration::from_millis(300),
2929 ..Default::default()
2930 };
2931 let err = fetch_usage(&client, OAuthProviderId::Anthropic, "d", "tok", &o).await.unwrap_err();
2932 assert_eq!(err, UsageError::Timeout);
2933
2934 let err = fetch_usage(&client, OAuthProviderId::Anthropic, "d", "tok", &opts(&format!("{url}/html")))
2935 .await
2936 .unwrap_err();
2937 assert_eq!(err, UsageError::Malformed { detail: "body is not JSON".into() });
2938 }
2939}