"""Credits tracking for Nous inference API responses. Parses x-nous-credits-* (and optional x-nous-tool-pool-*) headers from inference responses into a validated CreditsState dataclass. Provides depletion detection (paid_access), subscription-cap used_fraction, and warn-once schema-version gating. This is the hardened parser used by all live consumers (run_agent, tui_gateway) — not a dev-only shim. Header schema (x-nous-credits-* family): x-nous-credits-version contract/schema version x-nous-credits-remaining-micros total remaining balance (micros) x-nous-credits-remaining-usd same, formatted USD string x-nous-credits-subscription-micros subscription balance (SIGNED; may be negative/debt) x-nous-credits-subscription-usd same, formatted USD string x-nous-credits-subscription-limit-micros subscription cap (PAIRED/optional) x-nous-credits-subscription-limit-usd same, formatted USD string (PAIRED/optional) x-nous-credits-rollover-micros rolled-over balance (micros) x-nous-credits-purchased-micros purchased balance (micros) x-nous-credits-purchased-usd same, formatted USD string x-nous-credits-denominator-kind "subscription_cap" | "none" x-nous-credits-paid-access "true" | "false" (STRING!) x-nous-credits-disabled-reason reason string (header omitted when null) x-nous-credits-as-of-ms server-side timestamp (ms epoch) Tool-pool headers use a SEPARATE prefix: x-nous-tool-pool-micros tool-pool balance (micros) x-nous-tool-pool-gated-off "true" | "false" (STRING!) Money is handled as micros ints only; *_usd values are preserved verbatim as the raw strings the server sent (never re-parsed to float). """ from __future__ import annotations import logging import os import re import time from dataclasses import dataclass from typing import Any, Mapping, Optional from utils import is_truthy_value logger = logging.getLogger(__name__) # Warn-once latch: emit the version-unsupported warning at most once per process. _version_warning_emitted: bool = False # Valid denominator kinds (exhaustive set from the API contract). _VALID_DENOMINATOR_KINDS = frozenset({"subscription_cap", "none"}) # USD format: optional leading minus, one-or-more digits, dot, exactly 2 digits. _USD_RE = re.compile(r"^-?\d+\.\d{2}$") # ── Internal helpers ───────────────────────────────────────────────────────── _SENTINEL = object() # singleton sentinel for "parse failed" def _safe_int(value: Any) -> Any: """Parse a header value to an exact int (money-safe). The contract guarantees every ``*_micros`` field is an integer string — we parse with ``int()`` directly, NOT ``int(float(...))``, to avoid float- precision loss above 2**53 that would silently corrupt large money values. Returns the parsed int, or ``_SENTINEL`` if the value is not a valid integer string (including float-shaped strings like "1.5"). The sentinel lets callers detect the failure and return None from the overall parse (fail-hard-on-bad- input, not silently coerce). """ if value is None: return _SENTINEL try: return int(str(value)) except (TypeError, ValueError): return _SENTINEL def _validate_usd(value: Optional[str]) -> bool: """Return True iff value is a non-None string matching ^-?\\d+\\.\\d{2}$.""" if value is None: return False return bool(_USD_RE.match(value)) # ── CreditsState dataclass ─────────────────────────────────────────────────── @dataclass class CreditsState: """Full credits state parsed from x-nous-credits-* response headers.""" version: int = 0 remaining_micros: int = 0 remaining_usd: str = "" subscription_micros: int = 0 # SIGNED — may be negative (debt). ONLY field allowed negative. subscription_usd: str = "" subscription_limit_micros: Optional[int] = None # PAIRED + OPTIONAL (only when subscription_cap) subscription_limit_usd: Optional[str] = None rollover_micros: int = 0 purchased_micros: int = 0 purchased_usd: str = "" tool_pool_micros: int = 0 tool_pool_gated_off: bool = False denominator_kind: str = "none" # "subscription_cap" | "none" paid_access: bool = True # depletion keys off THIS == False, NEVER remaining==0 disabled_reason: Optional[str] = None # header omitted entirely when null as_of_ms: int = 0 captured_at: float = 0.0 # time.time() when this was captured from_header: bool = False # True only when populated by parse_credits_headers() @property def has_data(self) -> bool: return self.captured_at > 0 @property def age_seconds(self) -> float: if not self.has_data: return float("inf") return time.time() - self.captured_at @property def depleted(self) -> bool: """True when the account has lost paid access. Keyed off ``paid_access == False`` ONLY — never ``remaining_micros == 0``, which would give a false positive whenever the balance is zero but access is still live (e.g. subscription renewal pending). """ return not self.paid_access @property def used_fraction(self) -> Optional[float]: """Fraction of the subscription cap consumed, in [0.0, 1.0]. Computable only when ``subscription_limit_micros`` is a truthy (non-zero, non-None) int. Guarded on the LIMIT FIELD, not ``denominator_kind`` — the limit field is the real denominator; ``denominator_kind`` is metadata. Returns None when there is no computable denominator (no limit, or limit==0). """ if not isinstance(self.subscription_limit_micros, int): return None if self.subscription_limit_micros <= 0: return None used = self.subscription_limit_micros - self.subscription_micros return max(0.0, min(1.0, used / self.subscription_limit_micros)) # ── Credits policy constants ───────────────────────────────────────────────── # Switching credits notices from sticky→TTL later would also require wiring a # paired *_TTL_MS companion for each notice kind — the field exists on AgentNotice # but is not yet plumbed through the policy loop. CREDITS_NOTICE_KIND = "sticky" # v1: credits notices are sticky CREDITS_RESTORED_TTL_MS = 8000 # the only TTL notice in v1 (depletion-recovery confirmation) # Usage-gauge bands (ascending). Each is (threshold_fraction, level, label_pct). # The notice shows the HIGHEST band the current used_fraction has reached — a single # escalating status-bar line (50 → 75 → 90), not three stacked notices. Crossing the # next band up replaces the line; recovering below a band steps it back down. Edit # this list to retune the bands; the policy derives everything from it. CREDITS_USAGE_BANDS: tuple[tuple[float, str, int], ...] = ( (0.50, "info", 50), (0.75, "warn", 75), (0.90, "warn", 90), ) CREDITS_USAGE_KEY = "credits.usage" # single key for the escalating usage notice # ── AgentNotice (out-of-band notice payload; driver-agnostic) ──────────────── @dataclass class AgentNotice: """A structured, driver-agnostic out-of-band notice. The agent fires these via ``AIAgent.notice_callback`` (and clears them via ``notice_clear_callback``); each driver renders it its own way — the TUI as a status-bar override, the CLI as a console line, etc. v1 credits notices are all ``kind="sticky"``; ``kind``/``ttl_ms`` are kept fully expressive so a future config/slash-command can switch them to TTL without touching the policy (a single default seam — see L4). """ text: str level: str = "info" # info | warn | error | success kind: str = "sticky" # sticky | ttl ttl_ms: Optional[int] = None # honored only when kind == "ttl" key: Optional[str] = None # dedupe / fired-once-latch / clear key id: Optional[str] = None # ── evaluate_credits_notices (pure reconciliation function) ────────────────── def evaluate_credits_notices( state: CreditsState, latch: dict, ) -> tuple[list[AgentNotice], list[str]]: """Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE. latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}. Returns ``(to_show: list[AgentNotice], to_clear: list[str])``. Caller emits to_clear FIRST, then to_show. Pure function — no I/O, no agent/run_agent imports. """ to_show: list[AgentNotice] = [] to_clear: list[str] = [] uf = state.used_fraction # Crossing latch: once we've observed uf below the LOWEST band, escalating # usage notices may fire. This prevents a brand-new session that opens # mid-range from firing spuriously on the first observation (the cold-start # seed primes this explicitly when it WANTS an open-high warning). _lowest_band = CREDITS_USAGE_BANDS[0][0] if uf is not None and uf < _lowest_band: latch["seen_below_90"] = True # gate opened: usage-band notices may now fire active = latch["active"] # ── Conditions ─────────────────────────────────────────────────────────── # Highest band whose threshold the current usage has reached (None below all). current_band: Optional[tuple[float, str, int]] = None if uf is not None: for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest if uf >= band[0]: current_band = band grant_cond = ( state.denominator_kind == "subscription_cap" and uf is not None and uf >= 1.0 and state.purchased_micros > 0 ) depleted_cond = not state.paid_access # ── usage gauge (escalating single notice: 50 → 75 → 90) ────────────────── # Show only the highest crossed band; replace the line when the band changes # (climb or step-down on recovery); clear entirely when usage drops below the # lowest band or the denominator disappears (uf is None). shown_band = latch.get("usage_band") # the pct label currently displayed, or None target_band = current_band[2] if (current_band and latch["seen_below_90"]) else None if target_band != shown_band: if CREDITS_USAGE_KEY in active: to_clear.append(CREDITS_USAGE_KEY) active.discard(CREDITS_USAGE_KEY) if target_band is not None: # Belt-and-suspenders: a producer could set subscription_limit_micros # without subscription_limit_usd. Render "$? cap" rather than "$None cap". _cap_usd = state.subscription_limit_usd or "?" _level = current_band[1] # type: ignore[index] (current_band set when target_band set) to_show.append( AgentNotice( text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap", level=_level, kind=CREDITS_NOTICE_KIND, key=CREDITS_USAGE_KEY, id=CREDITS_USAGE_KEY, ) ) active.add(CREDITS_USAGE_KEY) latch["usage_band"] = target_band # ── grant_spent ────────────────────────────────────────────────────────── if grant_cond and "credits.grant_spent" not in active: to_show.append( AgentNotice( text=f"• Grant spent · ${state.purchased_usd} top-up left", level="info", kind=CREDITS_NOTICE_KIND, key="credits.grant_spent", id="credits.grant_spent", ) ) active.add("credits.grant_spent") elif "credits.grant_spent" in active and not grant_cond: to_clear.append("credits.grant_spent") active.discard("credits.grant_spent") # ── depleted ───────────────────────────────────────────────────────────── if depleted_cond and "credits.depleted" not in active: to_show.append( AgentNotice( text="✕ Credit access paused · run /usage for balance", level="error", kind=CREDITS_NOTICE_KIND, key="credits.depleted", id="credits.depleted", ) ) active.add("credits.depleted") elif "credits.depleted" in active and not depleted_cond: to_clear.append("credits.depleted") active.discard("credits.depleted") # Recovery: also emit the success notice to_show.append( AgentNotice( text="✓ Credit access restored", level="success", kind="ttl", ttl_ms=CREDITS_RESTORED_TTL_MS, key="credits.restored", id="credits.restored", ) ) return (to_show, to_clear) # ── parse_credits_headers ──────────────────────────────────────────────────── def parse_credits_headers( headers: Mapping[str, str], provider: str = "", ) -> Optional[CreditsState]: """Parse x-nous-credits-* (and x-nous-tool-pool-*) headers into a CreditsState. Returns None (miss) on ANY of: - No ``x-nous-credits-version`` header present. - Version != 1 (> 1 also emits a one-time logger.warning). - Any ``*_micros`` field is non-integer, or negative for a non-subscription field. - Any ``*_usd`` field doesn't match ``^-?\\d+\\.\\d{2}$``. - ``denominator_kind`` is not in {"subscription_cap", "none"}. - ``paid_access`` / ``tool_pool_gated_off`` is not exactly "true"/"false". - ``as_of_ms`` is not a valid integer. - Any unexpected exception. Fail-open on the subscription_limit pair: a half-pair (only -micros or only -usd present) is treated as both-absent; the overall parse STILL SUCCEEDS but with subscription_limit_micros/usd both None. """ global _version_warning_emitted try: # Cheap probe before the full lowercase copy: bail when the version # sentinel header is absent (the common case for non-Nous providers, on # every API call) — skips allocating a dict over the whole response's # headers on the hot path, while preserving case-insensitivity. Behaviour # is identical: a missing version header was already a None return below. if not any(k.lower() == "x-nous-credits-version" for k in headers): return None # Normalize to lowercase so lookups work regardless of how the server # capitalises headers (HTTP header names are case-insensitive per RFC 7230). lowered = {k.lower(): v for k, v in headers.items()} # ── Version check ──────────────────────────────────────────────────── # Must be present and exactly 1; > 1 warns once then returns None. version_raw = lowered.get("x-nous-credits-version") if version_raw is None: return None version_val = _safe_int(version_raw) if version_val is _SENTINEL: return None if version_val != 1: if version_val > 1 and not _version_warning_emitted: _version_warning_emitted = True logger.warning( "credits header version %d unsupported, ignoring — update Hermes", version_val, ) return None # ── Helper: parse a required non-negative int field (fail → None) ─── def _req_nonneg(key: str) -> Any: raw = lowered.get(key) val = _safe_int(raw) if val is _SENTINEL: return _SENTINEL if val < 0: return _SENTINEL return val # ── Helper: parse a required int field that may be negative (subscription only) ─ def _req_int(key: str) -> Any: raw = lowered.get(key) val = _safe_int(raw) if val is _SENTINEL: return _SENTINEL return val # ── Parse micros fields ────────────────────────────────────────────── remaining_micros = _req_nonneg("x-nous-credits-remaining-micros") if remaining_micros is _SENTINEL: return None subscription_micros = _req_int("x-nous-credits-subscription-micros") if subscription_micros is _SENTINEL: return None rollover_micros = _req_nonneg("x-nous-credits-rollover-micros") if rollover_micros is _SENTINEL: return None purchased_micros = _req_nonneg("x-nous-credits-purchased-micros") if purchased_micros is _SENTINEL: return None # tool_pool_micros is OPTIONAL: absent → 0 (default); present-but-invalid → None (miss). _tp_raw = lowered.get("x-nous-tool-pool-micros") if _tp_raw is None: tool_pool_micros = 0 else: _tp_val = _safe_int(_tp_raw) if _tp_val is _SENTINEL or _tp_val < 0: return None tool_pool_micros = _tp_val as_of_ms = _req_nonneg("x-nous-credits-as-of-ms") if as_of_ms is _SENTINEL: return None # ── Validate USD strings ───────────────────────────────────────────── remaining_usd = lowered.get("x-nous-credits-remaining-usd", "") if not _validate_usd(remaining_usd): return None subscription_usd = lowered.get("x-nous-credits-subscription-usd", "") if not _validate_usd(subscription_usd): return None purchased_usd = lowered.get("x-nous-credits-purchased-usd", "") if not _validate_usd(purchased_usd): return None # ── subscription_limit_* PAIRED + OPTIONAL ─────────────────────────── # Both present → validate both; half-pair → treat BOTH as absent (parse # still succeeds, just with no limit pair). sub_limit_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros") sub_limit_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd") subscription_limit_micros: Optional[int] = None subscription_limit_usd: Optional[str] = None if sub_limit_micros_raw is not None and sub_limit_usd_raw is not None: # Both present — validate both; any invalid → return None (bad data) lm = _safe_int(sub_limit_micros_raw) if lm is _SENTINEL: return None if lm < 0: return None if not _validate_usd(sub_limit_usd_raw): return None subscription_limit_micros = lm subscription_limit_usd = sub_limit_usd_raw # else: half-pair or both absent → leave both None, parse continues # ── denominator_kind ───────────────────────────────────────────────── denominator_kind = lowered.get("x-nous-credits-denominator-kind", "none") if denominator_kind not in _VALID_DENOMINATOR_KINDS: return None # ── paid_access / tool_pool_gated_off ──────────────────────────────── # Both must be exactly "true" or "false" (case-insensitive). An absent # paid_access header → fail-open (assume access); absent tool_pool_gated_off # → default False. Present but invalid → return None. if "x-nous-credits-paid-access" in lowered: pa_raw = lowered["x-nous-credits-paid-access"].strip().lower() if pa_raw not in ("true", "false"): return None paid_access = pa_raw == "true" else: paid_access = True # fail-open if "x-nous-tool-pool-gated-off" in lowered: tpgo_raw = lowered["x-nous-tool-pool-gated-off"].strip().lower() if tpgo_raw not in ("true", "false"): return None tool_pool_gated_off = tpgo_raw == "true" else: tool_pool_gated_off = False # ── disabled_reason: header omitted when null ──────────────────────── disabled_reason = lowered.get("x-nous-credits-disabled-reason") # None if absent return CreditsState( version=version_val, remaining_micros=remaining_micros, remaining_usd=remaining_usd, subscription_micros=subscription_micros, subscription_usd=subscription_usd, subscription_limit_micros=subscription_limit_micros, subscription_limit_usd=subscription_limit_usd, rollover_micros=rollover_micros, purchased_micros=purchased_micros, purchased_usd=purchased_usd, tool_pool_micros=tool_pool_micros, tool_pool_gated_off=tool_pool_gated_off, denominator_kind=denominator_kind, paid_access=paid_access, disabled_reason=disabled_reason, as_of_ms=as_of_ms, captured_at=time.time(), from_header=True, ) except Exception: # Fail-open → miss, but leave a breadcrumb so a parser/import regression # (feature silently dead) is distinguishable from a legitimate no-headers # response in agent.log, without needing a dev flag. logger.debug("credits ▸ parse_credits_headers raised (fail-open miss)", exc_info=True) return None # ── Dev test fixtures (HERMES_DEV_CREDITS_FIXTURE) ─────────────────────────── # Throwaway dev scaffolding: trigger any notice state on demand for testing, # without real spend or Redis seeding. Set HERMES_DEV_CREDITS_FIXTURE to either a # state NAME (fixed for the session) or a FILE PATH whose contents are a state # name (re-read every turn → flip states live: `echo depleted > /tmp/cf`, take a # turn; `echo healthy > /tmp/cf`, take a turn → recovery). # # A fixture drives THREE surfaces uniformly, so the whole credits UX is testable # offline: (1) the per-turn capture/notice path (_capture_credits), (2) the # cold-start seed at session open (conversation_loop → depletion/warn90 hydrate # immediately), and (3) the /usage view (nous_credits_lines renders the fixture). # `clear` / `none` / unset → real behaviour. Delete with the rest of the # HERMES_DEV_CREDITS scaffolding. _DEV_FIXTURES: dict[str, dict] = { "healthy": dict( # used_fraction ~0.1, paid → no notice (recovery target) remaining_micros=30_340_000, remaining_usd="30.34", subscription_micros=18_000_000, subscription_usd="18.00", subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", purchased_micros=12_340_000, purchased_usd="12.34", denominator_kind="subscription_cap", paid_access=True, ), "sub_50pct": dict( # used_fraction == 0.5 → credits.usage band 50 (info) remaining_micros=10_000_000, remaining_usd="10.00", subscription_micros=10_000_000, subscription_usd="10.00", subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", denominator_kind="subscription_cap", paid_access=True, ), "sub_75pct": dict( # used_fraction == 0.75 → credits.usage band 75 (warn) remaining_micros=5_000_000, remaining_usd="5.00", subscription_micros=5_000_000, subscription_usd="5.00", subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", denominator_kind="subscription_cap", paid_access=True, ), "sub_90pct": dict( # used_fraction == 0.9 → credits.usage band 90 (warn) remaining_micros=2_000_000, remaining_usd="2.00", subscription_micros=2_000_000, subscription_usd="2.00", subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", denominator_kind="subscription_cap", paid_access=True, ), "grant_exhausted": dict( # used_fraction == 1.0 + purchased>0 → credits.grant_spent remaining_micros=12_340_000, remaining_usd="12.34", subscription_micros=0, subscription_usd="0.00", subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", purchased_micros=12_340_000, purchased_usd="12.34", denominator_kind="subscription_cap", paid_access=True, ), "depleted": dict( # paid_access False → credits.depleted (sticky) remaining_micros=0, remaining_usd="0.00", subscription_micros=0, subscription_usd="0.00", purchased_micros=0, purchased_usd="0.00", paid_access=False, disabled_reason="out_of_credits", ), "debt": dict( # subscription in debt (negative, the only signed field) → depleted remaining_micros=0, remaining_usd="0.00", subscription_micros=-5_000_000, subscription_usd="-5.00", subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", purchased_micros=0, purchased_usd="0.00", denominator_kind="subscription_cap", paid_access=False, disabled_reason="out_of_credits", ), } def dev_fixture_credits_state() -> Optional[CreditsState]: """Return a fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None. The env value is a state name, OR a path to a file whose contents are a state name (re-read each call → flip states live without a restart). Unknown name / "clear" / "none" / unset → None (normal behaviour). Throwaway test scaffolding. Hard prod-leak guard: a fixture applies ONLY when the dev flag HERMES_DEV_CREDITS is also on, so a stray HERMES_DEV_CREDITS_FIXTURE (leaked into a shell profile, a container env, a launch plist, …) can never surface fabricated balances/notices on a real account. """ if not is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): return None raw = os.environ.get("HERMES_DEV_CREDITS_FIXTURE", "").strip() if not raw: return None name = raw if os.path.sep in raw or "/" in raw: # looks like a path → read the name from the file try: with open(raw, "r", encoding="utf-8") as fh: name = fh.read().strip() except OSError: return None spec = _DEV_FIXTURES.get(name.lower()) if not spec: return None # Stamp the fields the REAL parser always guarantees, so a fixture state is # field-identical to a parse_credits_headers() result from equivalent headers # (verified by the differential test): version is always 1, and purchased_usd # is always a valid usd string (the parser rejects a missing/empty one, so a # real zero-top-up account still carries "0.00"). Specs may override these. merged = {"version": 1, "purchased_usd": "0.00", **spec} return CreditsState(**merged, from_header=True, captured_at=time.time()) def _credits_state_from_account(info) -> Optional[CreditsState]: """Map a NousPortalAccountInfo into a header-shaped CreditsState for the seed. Float account dollars → micros (plus a DISPLAY *_usd string — allowed, since we're formatting account floats, NOT parsing a server-provided *_usd). Returns None if the account can't yield a usable state (fail-open).""" try: _acc = getattr(info, "paid_service_access_info", None) _sub = getattr(info, "subscription", None) def _to_micros(dollars): return int(round(dollars * 1_000_000)) if isinstance(dollars, (int, float)) else 0 def _to_usd(dollars): # DISPLAY formatting of an account float (not a server *_usd string); # "" when absent so render/notice copy falls back gracefully. return f"{dollars:.2f}" if isinstance(dollars, (int, float)) else "" _monthly = getattr(_sub, "monthly_credits", None) _has_cap = isinstance(_monthly, (int, float)) and _monthly > 0 _paid = getattr(info, "paid_service_access", None) return CreditsState( remaining_micros=_to_micros(getattr(_acc, "total_usable_credits", None)), remaining_usd=_to_usd(getattr(_acc, "total_usable_credits", None)), subscription_micros=_to_micros(getattr(_acc, "subscription_credits_remaining", None)), subscription_usd=_to_usd(getattr(_acc, "subscription_credits_remaining", None)), subscription_limit_micros=_to_micros(_monthly) if _has_cap else None, subscription_limit_usd=_to_usd(_monthly) if _has_cap else None, purchased_micros=_to_micros(getattr(_acc, "purchased_credits_remaining", None)), purchased_usd=_to_usd(getattr(_acc, "purchased_credits_remaining", None)), rollover_micros=_to_micros(getattr(_sub, "rollover_credits", None)), denominator_kind="subscription_cap" if _has_cap else "none", paid_access=_paid if isinstance(_paid, bool) else True, from_header=False, captured_at=time.time(), ) except Exception: logger.debug("credits ▸ seed account→state mapping failed", exc_info=True) return None def _hydrate_seed_state(agent, state) -> None: """Install a seed CreditsState on the agent and fire the notice policy once. Sets _credits_state, latches session-start remaining, and primes the crossing gate (the cold-start snapshot IS the first observation, so a session that opens already in a band warns immediately — the live header path keeps true crossing semantics), then emits. Safe to call from a worker thread: emit already runs off-thread in the TUI build path.""" agent._credits_state = state if getattr(agent, "_credits_session_start_micros", None) is None: agent._credits_session_start_micros = state.remaining_micros _latch = getattr(agent, "_credits_latch", None) if isinstance(_latch, dict) and state.used_fraction is not None: _latch["seen_below_90"] = True emit = getattr(agent, "_emit_credits_notices", None) if callable(emit): emit() def seed_credits_at_session_start(agent) -> bool: """Hydrate agent._credits_state from /api/oauth/account (or a dev fixture) and fire the notice policy, so depletion / usage-band warnings show at session OPEN. Shared by (a) the TUI/desktop agent build (fires at "ready", before any message) and (b) the first-turn conversation setup (fallback for plain CLI / when the build path didn't seed). Idempotent: a second call is a no-op once a seed or a real header has already populated _credits_state. Returns True if it seeded this call, False otherwise (not nous / already seeded / fail-open error). Never raises — credits must never block session startup. """ try: if getattr(agent, "provider", "") != "nous": return False # Idempotent: don't re-seed if state already exists (seed or live header). if getattr(agent, "_credits_state", None) is not None: return False fixture = None try: fixture = dev_fixture_credits_state() except Exception: fixture = None if fixture is not None: # Synchronous: a fixture is instant (no network), and tests rely on the # state + notice landing before this returns. _hydrate_seed_state(agent, fixture) return True # Real portal fetch is FIRE-AND-FORGET: a slow/unreachable portal must never # delay session "ready". A daemon thread hydrates + emits when it resolves, # re-checking idempotency first (a live inference header may land before it). import threading def _bg_seed() -> None: try: from hermes_cli.nous_account import get_nous_portal_account_info info = get_nous_portal_account_info(force_fresh=True) if getattr(agent, "_credits_state", None) is not None: return # a live inference header beat us — don't clobber it state = _credits_state_from_account(info) if state is not None: _hydrate_seed_state(agent, state) except Exception: logger.debug("credits ▸ session-start seed (background) failed", exc_info=True) threading.Thread(target=_bg_seed, name="credits-seed", daemon=True).start() return True except Exception: # Fail-open: any auth/portal hiccup leaves _credits_state as-is, never blocks. # Innermost log across all four call sites (TUI build / CLI build / first # turn / desktop), so a dead session-open seed is diagnosable in agent.log. logger.debug("credits ▸ session-start seed failed (fail-open)", exc_info=True) return False