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}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct CooldownState {
107 pub until_ms: u64,
108 pub reason: CooldownReason,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case", from = "String")]
119pub enum CooldownReason {
120 AnthropicQuotaExhausted,
122 CodexQuotaExhausted,
124 ClientReport,
126 Other,
128}
129
130impl CooldownReason {
131 pub fn from_reported(reason: &str) -> Self {
133 match reason {
134 "anthropic_quota_exhausted" => Self::AnthropicQuotaExhausted,
135 "codex_quota_exhausted" => Self::CodexQuotaExhausted,
136 "client_report" => Self::ClientReport,
137 _ => Self::Other,
138 }
139 }
140
141 pub fn as_str(self) -> &'static str {
142 match self {
143 Self::AnthropicQuotaExhausted => "anthropic_quota_exhausted",
144 Self::CodexQuotaExhausted => "codex_quota_exhausted",
145 Self::ClientReport => "client_report",
146 Self::Other => "other",
147 }
148 }
149}
150
151impl From<String> for CooldownReason {
152 fn from(reason: String) -> Self {
153 Self::from_reported(&reason)
154 }
155}
156
157impl std::fmt::Display for CooldownReason {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 f.write_str(self.as_str())
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ObservedOutcome {
166 pub at_ms: u64,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub failure: Option<ObservationFailure>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct SelectionState {
175 pub provider: String,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub model: Option<String>,
179 pub at_ms: u64,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub chosen: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub failure: Option<SelectionFailure>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub vend_failure: Option<ObservationFailure>,
189}
190
191fn span(ms: u64) -> String {
192 let secs = ms / 1000;
193 match secs {
194 s if s < 60 => format!("{s}s"),
195 s if s < 3600 => format!("{}m", s / 60),
196 s if s < 86_400 => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
197 s => format!("{}d{:02}h", s / 86_400, (s % 86_400) / 3600),
198 }
199}
200
201fn ago(now_ms: u64, at_ms: u64) -> String {
203 if at_ms <= now_ms {
204 format!("{} ago", span(now_ms - at_ms))
205 } else {
206 format!("in {}", span(at_ms - now_ms))
207 }
208}
209
210fn utc(ms: u64) -> String {
211 chrono::DateTime::from_timestamp_millis(ms as i64)
212 .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
213 .unwrap_or_else(|| ms.to_string())
214}
215
216fn outcome(o: &ObservedOutcome, now_ms: u64) -> String {
217 match &o.failure {
218 None => format!("ok, {}", ago(now_ms, o.at_ms)),
219 Some(f) => format!("{f}, {}", ago(now_ms, o.at_ms)),
220 }
221}
222
223pub fn render_text(report: &AuthStateReport) -> String {
225 use std::fmt::Write;
226 let now = report.generated_at_ms;
227 let mut out = String::new();
228 let source = if report.live {
229 "live: the running daemon's broker"
230 } else {
231 "NOT LIVE: built by this command, which has served no request"
232 };
233 let _ = writeln!(out, "auth state ({source})");
234 let _ = writeln!(out, "store: {}", report.store);
235 if !report.broker_active {
236 let _ = writeln!(
237 out,
238 "no automatic selection or limit report has reached this broker yet; nothing recorded"
239 );
240 }
241 if !report.cleared.is_empty() {
242 let _ = writeln!(
243 out,
244 "cleared (in memory only; credentials untouched): {}",
245 report.cleared.join(", ")
246 );
247 }
248 if report.accounts.is_empty() {
249 let _ = writeln!(out, "no stored accounts");
250 }
251 let mut provider = "";
252 for account in &report.accounts {
253 if account.provider != provider {
254 provider = &account.provider;
255 let _ = writeln!(out, "{provider}");
256 }
257 let expiry = match account.access_expires_ms {
258 Some(t) if t > now => format!("token expires in {}", span(t - now)),
259 Some(_) => "token expired (refreshed on next use)".to_string(),
260 None => "token expiry unknown".to_string(),
261 };
262 let _ = writeln!(
263 out,
264 " {} identity {} {expiry}",
265 account.label,
266 account.identity.as_str()
267 );
268 match &account.cooldown {
269 Some(c) => {
270 let _ = writeln!(
271 out,
272 " cooldown {} left (until {}): {}",
273 span(c.until_ms.saturating_sub(now)),
274 utc(c.until_ms),
275 c.reason
276 );
277 }
278 None => {
279 let _ = writeln!(out, " cooldown none");
280 }
281 }
282 match &account.last_usage {
283 Some(u) => {
284 let last_ok = match (u.failure.is_some(), account.last_usage_ok_ms) {
285 (true, Some(ok)) => format!(" (last ok {})", ago(now, ok)),
286 (true, None) => " (no successful read yet)".to_string(),
287 _ => String::new(),
288 };
289 let _ = writeln!(out, " usage {}{last_ok}", outcome(u, now));
290 }
291 None => {
292 let _ = writeln!(out, " usage not read yet");
293 }
294 }
295 match &account.last_refresh {
296 Some(r) => {
297 let _ = writeln!(out, " refresh {}", outcome(r, now));
298 }
299 None => {
300 let _ = writeln!(out, " refresh none in this process");
301 }
302 }
303 }
304 if !report.selections.is_empty() {
305 let _ = writeln!(out, "last automatic selections");
306 for s in &report.selections {
307 let model = s.model.as_deref().unwrap_or("any model");
308 let what = match (&s.chosen, &s.failure, &s.vend_failure) {
309 (Some(label), _, Some(f)) => format!("chose {label}; token not vended: {f}"),
310 (Some(label), _, None) => format!("chose {label}"),
311 (None, Some(f), _) => {
312 let accounts = f.accounts_field();
313 if accounts.is_empty() {
314 format!("failed: {}", f.kind)
315 } else {
316 format!("failed: {} ({accounts})", f.kind)
317 }
318 }
319 (None, None, _) => "failed".to_string(),
320 };
321 let _ = writeln!(
322 out,
323 " {} {model}: {what}, {}",
324 s.provider,
325 ago(now, s.at_ms)
326 );
327 }
328 }
329 out
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::core::auth::quota_policy::{
336 AccountFailure, AccountFailureKind, SelectionFailureKind,
337 };
338
339 const NOW: u64 = 1_800_000_000_000;
340
341 fn report() -> AuthStateReport {
342 AuthStateReport {
343 schema_version: AUTH_STATE_SCHEMA_VERSION,
344 live: true,
345 broker_active: true,
346 generated_at_ms: NOW,
347 store: "/scratch/auth.json".into(),
348 accounts: vec![
349 AccountState {
350 provider: "anthropic".into(),
351 label: "claude5".into(),
352 identity: IdentityKind::Stable,
353 access_expires_ms: Some(NOW + 3 * 3600 * 1000),
354 cooldown: Some(CooldownState {
355 until_ms: NOW + 41 * 60 * 1000,
356 reason: CooldownReason::AnthropicQuotaExhausted,
357 }),
358 last_usage: Some(ObservedOutcome {
359 at_ms: NOW - 12_000,
360 failure: Some(
361 ObservationFailure::new(AccountFailureKind::UsageRateLimited)
362 .with_status(429)
363 .with_retry_after(Some(120)),
364 ),
365 }),
366 last_usage_ok_ms: Some(NOW - 6 * 60 * 1000),
367 last_refresh: Some(ObservedOutcome {
368 at_ms: NOW - (2 * 3600 + 3 * 60) * 1000,
369 failure: None,
370 }),
371 },
372 AccountState {
373 provider: "anthropic".into(),
374 label: "claude6".into(),
375 identity: IdentityKind::Rotating,
376 access_expires_ms: None,
377 cooldown: None,
378 last_usage: None,
379 last_usage_ok_ms: None,
380 last_refresh: None,
381 },
382 ],
383 selections: vec![SelectionState {
384 provider: "anthropic".into(),
385 model: Some("claude-opus-4-1".into()),
386 at_ms: NOW - 3_000,
387 chosen: None,
388 failure: Some(SelectionFailure {
389 kind: SelectionFailureKind::UsageReadFailed,
390 accounts: vec![AccountFailure {
391 account: "claude5".into(),
392 kind: AccountFailureKind::UsageRateLimited,
393 http_status: Some(429),
394 retry_after_s: Some(120),
395 until_ms: None,
396 cooling_down: false,
397 }],
398 earliest_reset_ms: None,
399 usage_read_retry_ms: None,
400 }),
401 vend_failure: None,
402 }],
403 cleared: vec![],
404 }
405 }
406
407 #[test]
408 fn text_names_each_kind_status_and_age() {
409 let text = render_text(&report());
410 for needle in [
411 "auth state (live: the running daemon's broker)",
412 "anthropic\n claude5 identity stable token expires in 3h00m",
413 "cooldown 41m left (until ",
414 "usage usage_rate_limited, HTTP 429, retry after 120s, 12s ago (last ok 6m ago)",
415 "refresh ok, 2h03m ago",
416 "claude6 identity rotating token expiry unknown",
417 "usage not read yet",
418 "anthropic claude-opus-4-1: failed: usage_read_failed (claude5:usage_rate_limited:429), 3s ago",
419 ] {
420 assert!(text.contains(needle), "{needle:?} missing from:\n{text}");
421 }
422 assert!(
423 text.contains("): anthropic_quota_exhausted\n"),
424 "the cooldown's reason kind:\n{text}"
425 );
426 }
427
428 #[test]
432 fn cooldown_reasons_round_trip_as_fixed_kinds() {
433 for (wire, kind) in [
434 (
435 "anthropic_quota_exhausted",
436 CooldownReason::AnthropicQuotaExhausted,
437 ),
438 ("codex_quota_exhausted", CooldownReason::CodexQuotaExhausted),
439 ("client_report", CooldownReason::ClientReport),
440 ("other", CooldownReason::Other),
441 ("sk-ant-oat01-raw", CooldownReason::Other),
442 ] {
443 let state: CooldownState =
444 serde_json::from_value(serde_json::json!({"until_ms": 1, "reason": wire})).unwrap();
445 assert_eq!(state.reason, kind, "{wire}");
446 assert_eq!(
447 serde_json::to_value(&state).unwrap()["reason"],
448 kind.as_str(),
449 "{wire}"
450 );
451 }
452 }
453
454 #[test]
458 fn kinds_from_a_newer_daemon_decode_as_unknown() {
459 let mut json = serde_json::to_value(report()).unwrap();
460 json["accounts"][0]["identity"] = "per_org".into();
461 json["accounts"][0]["last_usage"]["failure"]["kind"] = "usage_brand_new".into();
462 json["selections"][0]["failure"]["kind"] = "brand_new_headline".into();
463 json["selections"][0]["failure"]["accounts"][0]["kind"] = "usage_brand_new".into();
464 let decoded: AuthStateReport = serde_json::from_value(json).unwrap();
465 assert_eq!(decoded.accounts[0].identity, IdentityKind::Unknown);
466 let usage = decoded.accounts[0].last_usage.as_ref().unwrap();
467 assert_eq!(
468 usage.failure.as_ref().unwrap().kind,
469 AccountFailureKind::Unknown
470 );
471 let failure = decoded.selections[0].failure.as_ref().unwrap();
472 assert_eq!(failure.kind, SelectionFailureKind::Unknown);
473 assert_eq!(failure.accounts[0].kind, AccountFailureKind::Unknown);
474 assert_eq!(decoded.accounts[1].identity, IdentityKind::Rotating);
477 let k = AccountFailureKind::Unknown;
478 assert!(!k.is_transient() && !k.needs_login() && !k.needs_action() && !k.is_limited());
479 let text = render_text(&decoded);
480 assert!(text.contains("identity unknown"), "{text}");
481 assert!(text.contains("failed: unknown (claude5:unknown:429)"), "{text}");
482 }
483
484 #[test]
485 fn a_report_not_from_the_daemon_says_so() {
486 let mut r = report();
487 r.live = false;
488 r.broker_active = false;
489 let text = render_text(&r);
490 assert!(text.starts_with("auth state (NOT LIVE"), "{text}");
491 assert!(text.contains("nothing recorded"), "{text}");
492 }
493
494 #[test]
495 fn json_round_trips_with_its_schema_version() {
496 let r = report();
497 let value = serde_json::to_value(&r).unwrap();
498 assert_eq!(value["schema_version"], AUTH_STATE_SCHEMA_VERSION);
499 assert_eq!(value["accounts"][0]["identity"], "stable");
500 assert_eq!(
501 value["accounts"][0]["last_usage"]["failure"],
502 serde_json::json!({"kind": "usage_rate_limited", "http_status": 429, "retry_after_s": 120})
503 );
504 let back: AuthStateReport = serde_json::from_value(value).unwrap();
505 assert_eq!(back, r);
506 }
507}