1use serde::{Deserialize, Serialize};
13
14use super::quota_policy::{ObservationFailure, SelectionFailure};
15
16pub const AUTH_STATE_SCHEMA_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21pub struct AuthStateQuery {
22 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub provider: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub account: Option<String>,
29 #[serde(default)]
31 pub clear: bool,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct AuthStateReport {
37 pub schema_version: u32,
38 pub live: bool,
41 pub broker_active: bool,
44 pub generated_at_ms: u64,
45 pub store: String,
47 pub accounts: Vec<AccountState>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub selections: Vec<SelectionState>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub cleared: Vec<String>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum IdentityKind {
60 Stable,
62 Rotating,
66 #[serde(other)]
68 Unknown,
69}
70
71impl IdentityKind {
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::Stable => "stable",
75 Self::Rotating => "rotating",
76 Self::Unknown => "unknown",
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct AccountState {
84 pub provider: String,
85 pub label: String,
86 pub identity: IdentityKind,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub access_expires_ms: Option<u64>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub cooldown: Option<CooldownState>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub last_usage: Option<ObservedOutcome>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub last_usage_ok_ms: Option<u64>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub last_refresh: Option<ObservedOutcome>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub refresh_hold: Option<RefreshHoldState>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct RefreshHoldState {
111 pub failure: ObservationFailure,
112 pub refused_at_ms: u64,
114 pub until_ms: u64,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct CooldownState {
122 pub until_ms: u64,
123 pub reason: CooldownReason,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case", from = "String")]
134pub enum CooldownReason {
135 AnthropicQuotaExhausted,
137 CodexQuotaExhausted,
139 ClientReport,
141 Other,
143}
144
145impl CooldownReason {
146 pub fn from_reported(reason: &str) -> Self {
148 match reason {
149 "anthropic_quota_exhausted" => Self::AnthropicQuotaExhausted,
150 "codex_quota_exhausted" => Self::CodexQuotaExhausted,
151 "client_report" => Self::ClientReport,
152 _ => Self::Other,
153 }
154 }
155
156 pub fn as_str(self) -> &'static str {
157 match self {
158 Self::AnthropicQuotaExhausted => "anthropic_quota_exhausted",
159 Self::CodexQuotaExhausted => "codex_quota_exhausted",
160 Self::ClientReport => "client_report",
161 Self::Other => "other",
162 }
163 }
164}
165
166impl From<String> for CooldownReason {
167 fn from(reason: String) -> Self {
168 Self::from_reported(&reason)
169 }
170}
171
172impl std::fmt::Display for CooldownReason {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.write_str(self.as_str())
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct ObservedOutcome {
181 pub at_ms: u64,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub failure: Option<ObservationFailure>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct SelectionState {
190 pub provider: String,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub model: Option<String>,
194 pub at_ms: u64,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub chosen: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub failure: Option<SelectionFailure>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub vend_failure: Option<ObservationFailure>,
204}
205
206fn span(ms: u64) -> String {
207 let secs = ms / 1000;
208 match secs {
209 s if s < 60 => format!("{s}s"),
210 s if s < 3600 => format!("{}m", s / 60),
211 s if s < 86_400 => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
212 s => format!("{}d{:02}h", s / 86_400, (s % 86_400) / 3600),
213 }
214}
215
216fn ago(now_ms: u64, at_ms: u64) -> String {
218 if at_ms <= now_ms {
219 format!("{} ago", span(now_ms - at_ms))
220 } else {
221 format!("in {}", span(at_ms - now_ms))
222 }
223}
224
225fn utc(ms: u64) -> String {
226 chrono::DateTime::from_timestamp_millis(ms as i64)
227 .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
228 .unwrap_or_else(|| ms.to_string())
229}
230
231fn outcome(o: &ObservedOutcome, now_ms: u64) -> String {
232 match &o.failure {
233 None => format!("ok, {}", ago(now_ms, o.at_ms)),
234 Some(f) => format!("{f}, {}", ago(now_ms, o.at_ms)),
235 }
236}
237
238pub fn render_text(report: &AuthStateReport) -> String {
240 use std::fmt::Write;
241 let now = report.generated_at_ms;
242 let mut out = String::new();
243 let source = if report.live {
244 "live: the running daemon's broker"
245 } else {
246 "NOT LIVE: built by this command, which has served no request"
247 };
248 let _ = writeln!(out, "auth state ({source})");
249 let _ = writeln!(out, "store: {}", report.store);
250 if !report.broker_active {
251 let _ = writeln!(
252 out,
253 "no automatic selection or limit report has reached this broker yet; nothing recorded"
254 );
255 }
256 if !report.cleared.is_empty() {
257 let _ = writeln!(
258 out,
259 "cleared (in memory only; credentials untouched): {}",
260 report.cleared.join(", ")
261 );
262 }
263 if report.accounts.is_empty() {
264 let _ = writeln!(out, "no stored accounts");
265 }
266 let mut provider = "";
267 for account in &report.accounts {
268 if account.provider != provider {
269 provider = &account.provider;
270 let _ = writeln!(out, "{provider}");
271 }
272 let expiry = match account.access_expires_ms {
273 Some(t) if t > now => format!("token expires in {}", span(t - now)),
274 Some(_) => "token expired (refreshed on next use)".to_string(),
275 None => "token expiry unknown".to_string(),
276 };
277 let _ = writeln!(
278 out,
279 " {} identity {} {expiry}",
280 account.label,
281 account.identity.as_str()
282 );
283 match &account.cooldown {
284 Some(c) => {
285 let _ = writeln!(
286 out,
287 " cooldown {} left (until {}): {}",
288 span(c.until_ms.saturating_sub(now)),
289 utc(c.until_ms),
290 c.reason
291 );
292 }
293 None => {
294 let _ = writeln!(out, " cooldown none");
295 }
296 }
297 match &account.last_usage {
298 Some(u) => {
299 let last_ok = match (u.failure.is_some(), account.last_usage_ok_ms) {
300 (true, Some(ok)) => format!(" (last ok {})", ago(now, ok)),
301 (true, None) => " (no successful read yet)".to_string(),
302 _ => String::new(),
303 };
304 let _ = writeln!(out, " usage {}{last_ok}", outcome(u, now));
305 }
306 None => {
307 let _ = writeln!(out, " usage not read yet");
308 }
309 }
310 match &account.last_refresh {
311 Some(r) => {
312 let _ = writeln!(out, " refresh {}", outcome(r, now));
313 }
314 None => {
315 let _ = writeln!(out, " refresh none in this process");
316 }
317 }
318 if let Some(h) = &account.refresh_hold {
319 let _ = writeln!(
320 out,
321 " held refresh refused ({}), {}; held until the stored login changes or {}",
322 h.failure,
323 ago(now, h.refused_at_ms),
324 utc(h.until_ms)
325 );
326 }
327 }
328 if !report.selections.is_empty() {
329 let _ = writeln!(out, "last automatic selections");
330 for s in &report.selections {
331 let model = s.model.as_deref().unwrap_or("any model");
332 let what = match (&s.chosen, &s.failure, &s.vend_failure) {
333 (Some(label), _, Some(f)) => format!("chose {label}; token not vended: {f}"),
334 (Some(label), _, None) => format!("chose {label}"),
335 (None, Some(f), _) => {
336 let accounts = f.accounts_field();
337 if accounts.is_empty() {
338 format!("failed: {}", f.kind)
339 } else {
340 format!("failed: {} ({accounts})", f.kind)
341 }
342 }
343 (None, None, _) => "failed".to_string(),
344 };
345 let _ = writeln!(
346 out,
347 " {} {model}: {what}, {}",
348 s.provider,
349 ago(now, s.at_ms)
350 );
351 }
352 }
353 out
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::core::auth::quota_policy::{
360 AccountFailure, AccountFailureKind, SelectionFailureKind,
361 };
362
363 const NOW: u64 = 1_800_000_000_000;
364
365 fn report() -> AuthStateReport {
366 AuthStateReport {
367 schema_version: AUTH_STATE_SCHEMA_VERSION,
368 live: true,
369 broker_active: true,
370 generated_at_ms: NOW,
371 store: "/scratch/auth.json".into(),
372 accounts: vec![
373 AccountState {
374 provider: "anthropic".into(),
375 label: "claude5".into(),
376 identity: IdentityKind::Stable,
377 access_expires_ms: Some(NOW + 3 * 3600 * 1000),
378 cooldown: Some(CooldownState {
379 until_ms: NOW + 41 * 60 * 1000,
380 reason: CooldownReason::AnthropicQuotaExhausted,
381 }),
382 last_usage: Some(ObservedOutcome {
383 at_ms: NOW - 12_000,
384 failure: Some(
385 ObservationFailure::new(AccountFailureKind::UsageRateLimited)
386 .with_status(429)
387 .with_retry_after(Some(120)),
388 ),
389 }),
390 last_usage_ok_ms: Some(NOW - 6 * 60 * 1000),
391 last_refresh: Some(ObservedOutcome {
392 at_ms: NOW - (2 * 3600 + 3 * 60) * 1000,
393 failure: None,
394 }),
395 refresh_hold: None,
396 },
397 AccountState {
398 provider: "anthropic".into(),
399 label: "claude6".into(),
400 identity: IdentityKind::Rotating,
401 access_expires_ms: None,
402 cooldown: None,
403 last_usage: None,
404 last_usage_ok_ms: None,
405 last_refresh: None,
406 refresh_hold: Some(RefreshHoldState {
407 failure: ObservationFailure::new(AccountFailureKind::RefreshRejected)
408 .with_status(400),
409 refused_at_ms: NOW - 4 * 60 * 1000,
410 until_ms: NOW + 11 * 60 * 1000,
411 }),
412 },
413 ],
414 selections: vec![SelectionState {
415 provider: "anthropic".into(),
416 model: Some("claude-opus-4-1".into()),
417 at_ms: NOW - 3_000,
418 chosen: None,
419 failure: Some(SelectionFailure {
420 kind: SelectionFailureKind::UsageReadFailed,
421 accounts: vec![AccountFailure {
422 account: "claude5".into(),
423 kind: AccountFailureKind::UsageRateLimited,
424 http_status: Some(429),
425 retry_after_s: Some(120),
426 until_ms: None,
427 cooling_down: false,
428 }],
429 earliest_reset_ms: None,
430 usage_read_retry_ms: None,
431 }),
432 vend_failure: None,
433 }],
434 cleared: vec![],
435 }
436 }
437
438 #[test]
439 fn text_names_each_kind_status_and_age() {
440 let text = render_text(&report());
441 for needle in [
442 "auth state (live: the running daemon's broker)",
443 "anthropic\n claude5 identity stable token expires in 3h00m",
444 "cooldown 41m left (until ",
445 "usage usage_rate_limited, HTTP 429, retry after 120s, 12s ago (last ok 6m ago)",
446 "refresh ok, 2h03m ago",
447 "claude6 identity rotating token expiry unknown",
448 "usage not read yet",
449 "anthropic claude-opus-4-1: failed: usage_read_failed (claude5:usage_rate_limited:429), 3s ago",
450 ] {
451 assert!(text.contains(needle), "{needle:?} missing from:\n{text}");
452 }
453 let held = format!(
454 " held refresh refused (refresh_rejected, HTTP 400), 4m ago; \
455 held until the stored login changes or {}\n",
456 utc(NOW + 11 * 60 * 1000)
457 );
458 assert!(text.contains(&held), "{held:?} missing from:\n{text}");
459 assert_eq!(text.matches(" held ").count(), 1, "only claude6 is held:\n{text}");
460 assert!(
461 text.contains("): anthropic_quota_exhausted\n"),
462 "the cooldown's reason kind:\n{text}"
463 );
464 }
465
466 #[test]
470 fn cooldown_reasons_round_trip_as_fixed_kinds() {
471 for (wire, kind) in [
472 (
473 "anthropic_quota_exhausted",
474 CooldownReason::AnthropicQuotaExhausted,
475 ),
476 ("codex_quota_exhausted", CooldownReason::CodexQuotaExhausted),
477 ("client_report", CooldownReason::ClientReport),
478 ("other", CooldownReason::Other),
479 ("sk-ant-oat01-raw", CooldownReason::Other),
480 ] {
481 let state: CooldownState =
482 serde_json::from_value(serde_json::json!({"until_ms": 1, "reason": wire})).unwrap();
483 assert_eq!(state.reason, kind, "{wire}");
484 assert_eq!(
485 serde_json::to_value(&state).unwrap()["reason"],
486 kind.as_str(),
487 "{wire}"
488 );
489 }
490 }
491
492 #[test]
496 fn kinds_from_a_newer_daemon_decode_as_unknown() {
497 let mut json = serde_json::to_value(report()).unwrap();
498 json["accounts"][0]["identity"] = "per_org".into();
499 json["accounts"][0]["last_usage"]["failure"]["kind"] = "usage_brand_new".into();
500 json["selections"][0]["failure"]["kind"] = "brand_new_headline".into();
501 json["selections"][0]["failure"]["accounts"][0]["kind"] = "usage_brand_new".into();
502 let decoded: AuthStateReport = serde_json::from_value(json).unwrap();
503 assert_eq!(decoded.accounts[0].identity, IdentityKind::Unknown);
504 let usage = decoded.accounts[0].last_usage.as_ref().unwrap();
505 assert_eq!(
506 usage.failure.as_ref().unwrap().kind,
507 AccountFailureKind::Unknown
508 );
509 let failure = decoded.selections[0].failure.as_ref().unwrap();
510 assert_eq!(failure.kind, SelectionFailureKind::Unknown);
511 assert_eq!(failure.accounts[0].kind, AccountFailureKind::Unknown);
512 assert_eq!(decoded.accounts[1].identity, IdentityKind::Rotating);
515 let k = AccountFailureKind::Unknown;
516 assert!(!k.is_transient() && !k.needs_login() && !k.needs_action() && !k.is_limited());
517 let text = render_text(&decoded);
518 assert!(text.contains("identity unknown"), "{text}");
519 assert!(text.contains("failed: unknown (claude5:unknown:429)"), "{text}");
520 }
521
522 #[test]
523 fn a_report_not_from_the_daemon_says_so() {
524 let mut r = report();
525 r.live = false;
526 r.broker_active = false;
527 let text = render_text(&r);
528 assert!(text.starts_with("auth state (NOT LIVE"), "{text}");
529 assert!(text.contains("nothing recorded"), "{text}");
530 }
531
532 #[test]
533 fn json_round_trips_with_its_schema_version() {
534 let r = report();
535 let value = serde_json::to_value(&r).unwrap();
536 assert_eq!(value["schema_version"], AUTH_STATE_SCHEMA_VERSION);
537 assert_eq!(value["accounts"][0]["identity"], "stable");
538 assert_eq!(
539 value["accounts"][0]["last_usage"]["failure"],
540 serde_json::json!({"kind": "usage_rate_limited", "http_status": 429, "retry_after_s": 120})
541 );
542 let back: AuthStateReport = serde_json::from_value(value).unwrap();
543 assert_eq!(back, r);
544 }
545}