use serde::{Deserialize, Serialize};
use super::quota_policy::{ObservationFailure, SelectionFailure};
pub const AUTH_STATE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStateQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
#[serde(default)]
pub clear: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStateReport {
pub schema_version: u32,
pub live: bool,
pub broker_active: bool,
pub generated_at_ms: u64,
pub store: String,
pub accounts: Vec<AccountState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub selections: Vec<SelectionState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cleared: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentityKind {
Stable,
Rotating,
#[serde(other)]
Unknown,
}
impl IdentityKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Rotating => "rotating",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountState {
pub provider: String,
pub label: String,
pub identity: IdentityKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_expires_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cooldown: Option<CooldownState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_usage: Option<ObservedOutcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_usage_ok_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_refresh: Option<ObservedOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CooldownState {
pub until_ms: u64,
pub reason: CooldownReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", from = "String")]
pub enum CooldownReason {
AnthropicQuotaExhausted,
CodexQuotaExhausted,
ClientReport,
Other,
}
impl CooldownReason {
pub fn from_reported(reason: &str) -> Self {
match reason {
"anthropic_quota_exhausted" => Self::AnthropicQuotaExhausted,
"codex_quota_exhausted" => Self::CodexQuotaExhausted,
"client_report" => Self::ClientReport,
_ => Self::Other,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::AnthropicQuotaExhausted => "anthropic_quota_exhausted",
Self::CodexQuotaExhausted => "codex_quota_exhausted",
Self::ClientReport => "client_report",
Self::Other => "other",
}
}
}
impl From<String> for CooldownReason {
fn from(reason: String) -> Self {
Self::from_reported(&reason)
}
}
impl std::fmt::Display for CooldownReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservedOutcome {
pub at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<ObservationFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SelectionState {
pub provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chosen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<SelectionFailure>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vend_failure: Option<ObservationFailure>,
}
fn span(ms: u64) -> String {
let secs = ms / 1000;
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
s => format!("{}d{:02}h", s / 86_400, (s % 86_400) / 3600),
}
}
fn ago(now_ms: u64, at_ms: u64) -> String {
if at_ms <= now_ms {
format!("{} ago", span(now_ms - at_ms))
} else {
format!("in {}", span(at_ms - now_ms))
}
}
fn utc(ms: u64) -> String {
chrono::DateTime::from_timestamp_millis(ms as i64)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| ms.to_string())
}
fn outcome(o: &ObservedOutcome, now_ms: u64) -> String {
match &o.failure {
None => format!("ok, {}", ago(now_ms, o.at_ms)),
Some(f) => format!("{f}, {}", ago(now_ms, o.at_ms)),
}
}
pub fn render_text(report: &AuthStateReport) -> String {
use std::fmt::Write;
let now = report.generated_at_ms;
let mut out = String::new();
let source = if report.live {
"live: the running daemon's broker"
} else {
"NOT LIVE: built by this command, which has served no request"
};
let _ = writeln!(out, "auth state ({source})");
let _ = writeln!(out, "store: {}", report.store);
if !report.broker_active {
let _ = writeln!(
out,
"no automatic selection or limit report has reached this broker yet; nothing recorded"
);
}
if !report.cleared.is_empty() {
let _ = writeln!(
out,
"cleared (in memory only; credentials untouched): {}",
report.cleared.join(", ")
);
}
if report.accounts.is_empty() {
let _ = writeln!(out, "no stored accounts");
}
let mut provider = "";
for account in &report.accounts {
if account.provider != provider {
provider = &account.provider;
let _ = writeln!(out, "{provider}");
}
let expiry = match account.access_expires_ms {
Some(t) if t > now => format!("token expires in {}", span(t - now)),
Some(_) => "token expired (refreshed on next use)".to_string(),
None => "token expiry unknown".to_string(),
};
let _ = writeln!(
out,
" {} identity {} {expiry}",
account.label,
account.identity.as_str()
);
match &account.cooldown {
Some(c) => {
let _ = writeln!(
out,
" cooldown {} left (until {}): {}",
span(c.until_ms.saturating_sub(now)),
utc(c.until_ms),
c.reason
);
}
None => {
let _ = writeln!(out, " cooldown none");
}
}
match &account.last_usage {
Some(u) => {
let last_ok = match (u.failure.is_some(), account.last_usage_ok_ms) {
(true, Some(ok)) => format!(" (last ok {})", ago(now, ok)),
(true, None) => " (no successful read yet)".to_string(),
_ => String::new(),
};
let _ = writeln!(out, " usage {}{last_ok}", outcome(u, now));
}
None => {
let _ = writeln!(out, " usage not read yet");
}
}
match &account.last_refresh {
Some(r) => {
let _ = writeln!(out, " refresh {}", outcome(r, now));
}
None => {
let _ = writeln!(out, " refresh none in this process");
}
}
}
if !report.selections.is_empty() {
let _ = writeln!(out, "last automatic selections");
for s in &report.selections {
let model = s.model.as_deref().unwrap_or("any model");
let what = match (&s.chosen, &s.failure, &s.vend_failure) {
(Some(label), _, Some(f)) => format!("chose {label}; token not vended: {f}"),
(Some(label), _, None) => format!("chose {label}"),
(None, Some(f), _) => {
let accounts = f.accounts_field();
if accounts.is_empty() {
format!("failed: {}", f.kind)
} else {
format!("failed: {} ({accounts})", f.kind)
}
}
(None, None, _) => "failed".to_string(),
};
let _ = writeln!(
out,
" {} {model}: {what}, {}",
s.provider,
ago(now, s.at_ms)
);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::auth::quota_policy::{
AccountFailure, AccountFailureKind, SelectionFailureKind,
};
const NOW: u64 = 1_800_000_000_000;
fn report() -> AuthStateReport {
AuthStateReport {
schema_version: AUTH_STATE_SCHEMA_VERSION,
live: true,
broker_active: true,
generated_at_ms: NOW,
store: "/scratch/auth.json".into(),
accounts: vec![
AccountState {
provider: "anthropic".into(),
label: "claude5".into(),
identity: IdentityKind::Stable,
access_expires_ms: Some(NOW + 3 * 3600 * 1000),
cooldown: Some(CooldownState {
until_ms: NOW + 41 * 60 * 1000,
reason: CooldownReason::AnthropicQuotaExhausted,
}),
last_usage: Some(ObservedOutcome {
at_ms: NOW - 12_000,
failure: Some(
ObservationFailure::new(AccountFailureKind::UsageRateLimited)
.with_status(429)
.with_retry_after(Some(120)),
),
}),
last_usage_ok_ms: Some(NOW - 6 * 60 * 1000),
last_refresh: Some(ObservedOutcome {
at_ms: NOW - (2 * 3600 + 3 * 60) * 1000,
failure: None,
}),
},
AccountState {
provider: "anthropic".into(),
label: "claude6".into(),
identity: IdentityKind::Rotating,
access_expires_ms: None,
cooldown: None,
last_usage: None,
last_usage_ok_ms: None,
last_refresh: None,
},
],
selections: vec![SelectionState {
provider: "anthropic".into(),
model: Some("claude-opus-4-1".into()),
at_ms: NOW - 3_000,
chosen: None,
failure: Some(SelectionFailure {
kind: SelectionFailureKind::UsageReadFailed,
accounts: vec![AccountFailure {
account: "claude5".into(),
kind: AccountFailureKind::UsageRateLimited,
http_status: Some(429),
retry_after_s: Some(120),
until_ms: None,
cooling_down: false,
}],
earliest_reset_ms: None,
usage_read_retry_ms: None,
}),
vend_failure: None,
}],
cleared: vec![],
}
}
#[test]
fn text_names_each_kind_status_and_age() {
let text = render_text(&report());
for needle in [
"auth state (live: the running daemon's broker)",
"anthropic\n claude5 identity stable token expires in 3h00m",
"cooldown 41m left (until ",
"usage usage_rate_limited, HTTP 429, retry after 120s, 12s ago (last ok 6m ago)",
"refresh ok, 2h03m ago",
"claude6 identity rotating token expiry unknown",
"usage not read yet",
"anthropic claude-opus-4-1: failed: usage_read_failed (claude5:usage_rate_limited:429), 3s ago",
] {
assert!(text.contains(needle), "{needle:?} missing from:\n{text}");
}
assert!(
text.contains("): anthropic_quota_exhausted\n"),
"the cooldown's reason kind:\n{text}"
);
}
#[test]
fn cooldown_reasons_round_trip_as_fixed_kinds() {
for (wire, kind) in [
(
"anthropic_quota_exhausted",
CooldownReason::AnthropicQuotaExhausted,
),
("codex_quota_exhausted", CooldownReason::CodexQuotaExhausted),
("client_report", CooldownReason::ClientReport),
("other", CooldownReason::Other),
("sk-ant-oat01-raw", CooldownReason::Other),
] {
let state: CooldownState =
serde_json::from_value(serde_json::json!({"until_ms": 1, "reason": wire})).unwrap();
assert_eq!(state.reason, kind, "{wire}");
assert_eq!(
serde_json::to_value(&state).unwrap()["reason"],
kind.as_str(),
"{wire}"
);
}
}
#[test]
fn kinds_from_a_newer_daemon_decode_as_unknown() {
let mut json = serde_json::to_value(report()).unwrap();
json["accounts"][0]["identity"] = "per_org".into();
json["accounts"][0]["last_usage"]["failure"]["kind"] = "usage_brand_new".into();
json["selections"][0]["failure"]["kind"] = "brand_new_headline".into();
json["selections"][0]["failure"]["accounts"][0]["kind"] = "usage_brand_new".into();
let decoded: AuthStateReport = serde_json::from_value(json).unwrap();
assert_eq!(decoded.accounts[0].identity, IdentityKind::Unknown);
let usage = decoded.accounts[0].last_usage.as_ref().unwrap();
assert_eq!(
usage.failure.as_ref().unwrap().kind,
AccountFailureKind::Unknown
);
let failure = decoded.selections[0].failure.as_ref().unwrap();
assert_eq!(failure.kind, SelectionFailureKind::Unknown);
assert_eq!(failure.accounts[0].kind, AccountFailureKind::Unknown);
assert_eq!(decoded.accounts[1].identity, IdentityKind::Rotating);
let k = AccountFailureKind::Unknown;
assert!(!k.is_transient() && !k.needs_login() && !k.needs_action() && !k.is_limited());
let text = render_text(&decoded);
assert!(text.contains("identity unknown"), "{text}");
assert!(text.contains("failed: unknown (claude5:unknown:429)"), "{text}");
}
#[test]
fn a_report_not_from_the_daemon_says_so() {
let mut r = report();
r.live = false;
r.broker_active = false;
let text = render_text(&r);
assert!(text.starts_with("auth state (NOT LIVE"), "{text}");
assert!(text.contains("nothing recorded"), "{text}");
}
#[test]
fn json_round_trips_with_its_schema_version() {
let r = report();
let value = serde_json::to_value(&r).unwrap();
assert_eq!(value["schema_version"], AUTH_STATE_SCHEMA_VERSION);
assert_eq!(value["accounts"][0]["identity"], "stable");
assert_eq!(
value["accounts"][0]["last_usage"]["failure"],
serde_json::json!({"kind": "usage_rate_limited", "http_status": 429, "retry_after_s": 120})
);
let back: AuthStateReport = serde_json::from_value(value).unwrap();
assert_eq!(back, r);
}
}