Skip to main content

agent_core/core/auth/
auth_state.rs

1//! `synaps auth state`: what one broker remembers about its accounts (each
2//! account's cooldown, its last usage read and last token refresh exchange,
3//! and the last automatic selection), read from memory only.
4//!
5//! Building a report makes no network request and refreshes nothing; the
6//! account list (labels, access-token expiry, whether an account id is
7//! recorded) comes from the credential store. A report never carries token
8//! material, emails, account ids or identity hashes. `clear` drops the
9//! in-memory cooldowns, usage readings and stickiness of the matched
10//! accounts; it never writes the credential store.
11
12use serde::{Deserialize, Serialize};
13
14use super::quota_policy::{ObservationFailure, SelectionFailure};
15
16/// Version of [`AuthStateReport`]'s JSON shape (`synaps auth state --json`).
17pub const AUTH_STATE_SCHEMA_VERSION: u32 = 1;
18
19/// Which accounts to report on, and whether to clear their state first.
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21pub struct AuthStateQuery {
22    /// Canonical provider id (`anthropic`); `None` = every provider that has
23    /// stored accounts.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub provider: Option<String>,
26    /// One account label (`default` for the bare slot).
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub account: Option<String>,
29    /// Drop the matched accounts' in-memory state before reporting.
30    #[serde(default)]
31    pub clear: bool,
32}
33
34/// One broker's in-memory account state.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct AuthStateReport {
37    pub schema_version: u32,
38    /// Answered by the running daemon's broker (`true`), or built by the
39    /// asking process, which has served no request (`false`).
40    pub live: bool,
41    /// `false`: this process has no local broker yet (no request used
42    /// automatic selection or reported a limit), so nothing is recorded.
43    pub broker_active: bool,
44    pub generated_at_ms: u64,
45    /// Credential store the accounts were listed from.
46    pub store: String,
47    pub accounts: Vec<AccountState>,
48    /// Last automatic selection per provider and model, newest first.
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub selections: Vec<SelectionState>,
51    /// `provider:label` of every account whose state `clear` dropped.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub cleared: Vec<String>,
54}
55
56/// How limits follow an account.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum IdentityKind {
60    /// The slot records the provider account id.
61    Stable,
62    /// No account id recorded: the seat identity derives from refresh
63    /// material and changes when the token rotates (`synaps auth identify`
64    /// records the id).
65    Rotating,
66    /// A kind this build does not know (from a newer daemon).
67    #[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/// One stored account and what the broker remembers about it.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct AccountState {
84    pub provider: String,
85    pub label: String,
86    pub identity: IdentityKind,
87    /// Access-token expiry, epoch ms; `None` when unknown.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub access_expires_ms: Option<u64>,
90    /// An active cooldown (a reported provider limit).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub cooldown: Option<CooldownState>,
93    /// The last usage read of this account (cache hits are not reads).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub last_usage: Option<ObservedOutcome>,
96    /// When a usage read of this account last succeeded, epoch ms.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub last_usage_ok_ms: Option<u64>,
99    /// The last token refresh exchange for this account in this process.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub last_refresh: Option<ObservedOutcome>,
102}
103
104/// A reported provider limit.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct CooldownState {
107    pub until_ms: u64,
108    /// Who reported it, as a fixed kind; never the reporter's own string.
109    pub reason: CooldownReason,
110}
111
112/// Why an account is cooling down: one of the known `report_cooldown`
113/// callers, or `other`. A report carries only this kind, never the string
114/// the reporter passed (which may come from another machine); the log line
115/// written when the cooldown is reported keeps that string. An unknown kind
116/// from a newer daemon reads as `other`.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case", from = "String")]
119pub enum CooldownReason {
120    /// The Anthropic request path saw a quota-exhausted answer.
121    AnthropicQuotaExhausted,
122    /// The Codex request path saw a quota-exhausted answer.
123    CodexQuotaExhausted,
124    /// A broker client reported a limit without a reason.
125    ClientReport,
126    /// Any other reason.
127    Other,
128}
129
130impl CooldownReason {
131    /// The kind of a reporter's reason string.
132    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/// One observation: when it happened and, if it failed, why.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ObservedOutcome {
166    pub at_ms: u64,
167    /// `None`: it succeeded.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub failure: Option<ObservationFailure>,
170}
171
172/// The last automatic selection for one provider and model.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct SelectionState {
175    pub provider: String,
176    /// `None`: any model.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub model: Option<String>,
179    pub at_ms: u64,
180    /// The chosen account (also set when its token could not be vended).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub chosen: Option<String>,
183    /// Why no account was chosen.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub failure: Option<SelectionFailure>,
186    /// Why the chosen account's token could not be vended.
187    #[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
201/// `12s ago` (or `in 3s` for a clock that moved backwards).
202fn 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
223/// Plain-text rendering, one block per account, then the last selections.
224pub 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    /// r2 blocker 3: a reason is a fixed kind on the wire too; a reason
429    /// string this build does not know (a newer daemon's, or a raw string)
430    /// reads as `other`.
431    #[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    /// A report from a daemon one build ahead must still decode: a kind
455    /// this build does not know reads as `unknown` instead of failing the
456    /// whole frame.
457    #[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        // Known kinds are untouched, and an unknown one never claims a
475        // remedy it can't know.
476        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}