Files
hermes-agent/agent/credits_tracker.py
Brooklyn Nicholson ac80df1410 fix(credits): stop desktop reap/resume from re-announcing the same usage band
Desktop rebuilds the AIAgent per turn (idle reap -> next message re-mint),
and agent_init.py gives every rebuilt agent a brand-new, empty _credits_latch
via new_credits_latch(). seed_credits_at_session_start() -> _hydrate_seed_state()
then unconditionally primes latch["seen_below_90"] on that fresh latch and
evaluates once -- correct for a genuinely new session opening mid-band, but on
a reap/resume rebuild it makes evaluate_credits_notices() see
shown_band=None vs. current_band=<the same band as before>, so it re-fires
"You've used $X of your $Y cap" on every message even though the user already
saw that exact notice moments ago on the previous incarnation of the same
session (#101578).

agent.session_id is stable across these rebuilds even though the agent object
and its latch are not, so add a small process-lifetime cache
(_seen_usage_bands, bounded to 500 entries, MRU eviction) keyed by session_id
that remembers the last usage_band actually shown. _hydrate_seed_state()
restores it into the fresh latch before evaluating, so a rebuild with unchanged
usage stays quiet, while a rebuild after a genuine crossing (recorded via the
same warm-path write in rate_limit_credits._emit_credits_notices, the single
chokepoint both the seed and live-header paths share) still fires normally.
Deliberately not persisted anywhere durable -- a real process restart is a real
"session open" and should still warn immediately, matching the existing
cold-start seed behavior for a session that opens already in a band. An agent
with no session_id (plain CLI, never rebuilt) falls back to the pre-fix
behavior unchanged.

Tests (tests/agent/test_credits_cold_start.py): a rebuild with the same
session_id and unchanged usage does not re-fire; a rebuild after a genuine
band change still fires (and clears the old key); a different session_id is
never suppressed by another session's history; an agent without a session_id
degrades to the old always-prime behavior without raising.

Fixes #101578

Co-authored-by: Edizzier <umit.ediz@hotmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 17:59:52 -05:00

514 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Nous credits: parse ``x-nous-credits-*`` / ``x-nous-tool-pool-*`` response
headers into a validated CreditsState (depletion = paid_access, subscription-cap
used_fraction, warn-once schema-version gating) and drive the notice policy.
Header contract: see ``_HEADER_FIELDS``. Money is micros ints only; ``*_usd``
strings are preserved verbatim (never re-parsed to float)."""
from __future__ import annotations
import logging
import os
import re
import threading
import time
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Mapping, Optional
from utils import is_truthy_value
logger = logging.getLogger(__name__)
_version_warning_emitted: bool = False # warn-once latch (per process)
_VALID_DENOMINATOR_KINDS = frozenset({"subscription_cap", "none"})
_USD_RE = re.compile(r"^-?\d+\.\d{2}$") # optional minus, digits, exactly 2 decimals
_SENTINEL = object() # "parse failed"
def _safe_int(value: Any) -> Any:
"""Exact int (money-safe) or ``_SENTINEL``. ``int()`` directly, NOT ``int(float())``
(precision loss above 2**53 corrupts money); float-shaped strings fail."""
try:
return _SENTINEL if value is None else int(str(value))
except (TypeError, ValueError):
return _SENTINEL
@dataclass
class CreditsState:
"""Credits state parsed from x-nous-credits-* response headers."""
version: int = 0
remaining_micros: int = 0
remaining_usd: str = ""
subscription_micros: int = 0 # SIGNED — the ONLY field allowed negative (debt)
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 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:
return time.time() - self.captured_at if self.has_data else float("inf")
@property
def depleted(self) -> bool:
"""``paid_access == False`` ONLY — ``remaining_micros == 0`` is a false positive
when the balance is zero but access is live (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]; None without a computable
denominator. Guarded on the LIMIT FIELD (the real denominator), not ``denominator_kind``."""
lim = self.subscription_limit_micros
if not isinstance(lim, int) or lim <= 0:
return None
return max(0.0, min(1.0, (lim - self.subscription_micros) / lim))
# ── Credits policy constants. Switching notices sticky→TTL later also needs a
# paired *_TTL_MS per notice kind (AgentNotice has the field; not plumbed yet).
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): (threshold_fraction, level, label_pct). One
# escalating line shows the HIGHEST band reached; climbing replaces it, recovery steps down.
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"
# Min subscription balance counting as "grant not yet spent" for the grant_spent
# gate. 1¢: portal-seeded states (float dollars → micros) can carry sub-cent residue
# where headers report 0 — without the floor a seed opens the gate and the first
# header re-creates the at-open nag.
GRANT_UNSPENT_MIN_MICROS = 10_000
def new_credits_latch() -> dict:
"""Fresh notice latch for :func:`evaluate_credits_notices`; every producer builds it here so a new gate key lands everywhere."""
return {"active": set(), "seen_below_90": False, "usage_band": None, "seen_grant_unspent": False}
@dataclass
class AgentNotice:
"""Driver-agnostic out-of-band notice (``AIAgent.notice_callback`` / ``notice_clear_callback``); each driver
renders its own way. ``kind``/``ttl_ms`` stay expressive so a future config can switch v1's sticky notices to TTL."""
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
def _sticky_notice(text: str, level: str, key: str) -> AgentNotice:
return AgentNotice(text=text, level=level, kind=CREDITS_NOTICE_KIND, key=key, id=key)
def _is_nous_welcome_route(base_url: str) -> bool:
"""True when *base_url* is the Nous welcome host, which serves only the free tier. Local data only;
False wherever the free tier is not built in. The host is the evidence, not the model name: the paid
inference host can serve ``nous/welcome`` to a named account, and that account's depletion is real."""
try:
from hermes_cli.anon_auth import route_is_welcome_host
except ImportError:
return False
return route_is_welcome_host(base_url)
def is_free_tier_model(model: str, base_url: str = "") -> bool:
"""True when *model* is a Nous free-tier model, using ONLY local data: (1) ``:free`` suffix — canonical
Nous free SKU marker; (2) ``stealth/`` prefix — stealth-preview SKUs are free without the suffix
(naming-convention trust: a PAID ``stealth/`` model would wrongly suppress the banner); (3) a PEEK into
``hermes_cli.models``' pricing cache (filled by the model picker; a miss never fetches). Fail-open to
False (depleted notice still shows): a wrong warning is recoverable noise; hiding it masks a real block."""
if not model:
return False
if model.endswith(":free") or model.startswith("stealth/"):
return True
if not base_url:
return False
# (4) the Nous free tier: the welcome host serves only the free tier. A free-tier identity carries $0
# by design, so the portal seed reports paid_access=False for it; that is not a depleted account, and
# "run /topup" means nothing to it. Local data only, same as the rules above.
if _is_nous_welcome_route(base_url):
return True
try:
from hermes_cli.models import _is_model_free
from hermes_cli.models_pricing import peek_cached_pricing
pricing = peek_cached_pricing(base_url) # owns the /v1-suffix and auth-state key details
return bool(pricing) and _is_model_free(model, pricing)
except Exception:
return False
def evaluate_credits_notices(state: CreditsState, latch: dict, *, model_is_free: bool = False) -> tuple[list[AgentNotice], list[str]]:
"""Reconcile credits notices against the latch (see :func:`new_credits_latch`); mutates ``latch`` IN
PLACE. Pure — no I/O, no agent/run_agent imports. ``model_is_free`` suppresses ``credits.depleted`` (a
depleted account on a free model keeps inferencing) WITHOUT emitting "restored" — that fires only on a
genuine ``paid_access`` flip back to True. Returns ``(to_show, to_clear)``; caller emits to_clear FIRST."""
to_show: list[AgentNotice] = []
to_clear: list[str] = []
uf, active = state.used_fraction, latch["active"]
# Crossing latch: band notices fire only once uf was observed below the LOWEST
# band, so a session opening mid-range doesn't fire on its first observation
# (the cold-start seed primes this when it WANTS an open-high warning).
if uf is not None and uf < CREDITS_USAGE_BANDS[0][0]:
latch["seen_below_90"] = True
# Grant-spent gate: fires only after this session OBSERVED the grant unspent
# (≥1¢). Opening at grant-spent is a steady STATE (/usage carries it), not an
# event. Unlike seen_below_90, seeds must NOT prime this gate.
if uf is not None and uf < 1.0 and state.subscription_micros >= GRANT_UNSPENT_MIN_MICROS:
latch["seen_grant_unspent"] = True
# Highest band reached (ascending → last match wins); None below all. Top-up
# suppression: with purchased credits the cap gauge is the wrong denominator
# ("90% used" on $50 of top-up is noise; it used to stick PERMANENTLY beside
# grant_spent at >=100%) — grant_spent covers the cap-reached case, and a
# mid-session top-up flips current_band → None so the clear path removes the line.
current_band: Optional[tuple[float, str, int]] = None
if uf is not None and state.purchased_micros <= 0:
current_band = next((b for b in reversed(CREDITS_USAGE_BANDS) if uf >= b[0]), None)
# ── usage gauge: highest crossed band only; replace on band change (climb or
# step-down); clear below the lowest band or when the denominator vanishes.
target_band = current_band[2] if (current_band and latch["seen_below_90"]) else None
if target_band != latch.get("usage_band"):
if CREDITS_USAGE_KEY in active:
to_clear.append(CREDITS_USAGE_KEY)
active.discard(CREDITS_USAGE_KEY)
if target_band is not None:
# Absolute dollars used (a bare "N%" is only meaningful against a Nous cap): cap − remaining,
# clamped [0, cap]; "$?" if a producer set the limit without its *_usd. Re-emits on band change only.
level = current_band[1] # type: ignore[index] (current_band set when target_band set)
lim = state.subscription_limit_micros or 0
used_usd = f"{max(0, min(lim, lim - state.subscription_micros)) / 1_000_000:.2f}" if lim else "?"
text = f"{'⚠' if level == 'warn' else '•'} You've used ${used_usd} of your ${state.subscription_limit_usd or '?'} cap"
to_show.append(_sticky_notice(text, level, CREDITS_USAGE_KEY))
active.add(CREDITS_USAGE_KEY)
latch["usage_band"] = target_band
# ── grant_spent: the gate guards only the SHOW and is CONSUMED by it — one
# announcement per crossing. A header flicker (uf → None → 1.0) clears the
# line but cannot re-announce; only a renewal re-opening the gate (fresh ≥1¢
# observation) arms the next. .get(): default closed for hand-built latches.
grant_cond = (
state.denominator_kind == "subscription_cap" and uf is not None and uf >= 1.0 and state.purchased_micros > 0
)
if grant_cond and "credits.grant_spent" not in active and latch.get("seen_grant_unspent", False):
to_show.append(_sticky_notice(f"• Grant spent · ${state.purchased_usd} top-up left", "info", "credits.grant_spent"))
active.add("credits.grant_spent")
latch["seen_grant_unspent"] = False
elif "credits.grant_spent" in active and not grant_cond:
to_clear.append("credits.grant_spent")
active.discard("credits.grant_spent")
# ── depleted: suppressed while the model is free (inference still works).
depleted_cond = not state.paid_access
show_depleted = depleted_cond and not model_is_free
if show_depleted and "credits.depleted" not in active:
to_show.append(_sticky_notice("✕ Credit access paused · run /topup to top up", "error", "credits.depleted"))
active.add("credits.depleted")
elif "credits.depleted" in active and not show_depleted:
to_clear.append("credits.depleted")
active.discard("credits.depleted")
if not depleted_cond: # genuine recovery only — a free-model switch while depleted is NOT "restored"
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)
# Header contract: (field, kind[, default-when-absent]); a field is REQUIRED unless it has a default.
# Header name = ``x-nous-credits-<field>`` (``x-nous-<field>`` for tool_pool_*), underscores → dashes.
# micros: int >= 0 ("signed": may be negative); usd: the server's formatted string ^-?\d+\.\d{2}$
# (never re-parsed); bool: "true"/"false" STRING. Handled inline: subscription-limit-* (PAIRED/optional),
# denominator-kind ("subscription_cap" | "none"), disabled-reason (omitted when null).
_HEADER_FIELDS: tuple[tuple, ...] = (
("remaining_micros", "micros"), ("subscription_micros", "signed"), ("rollover_micros", "micros"),
("purchased_micros", "micros"), ("as_of_ms", "micros"), ("tool_pool_micros", "micros", 0),
("remaining_usd", "usd"), ("subscription_usd", "usd"), ("purchased_usd", "usd"),
("paid_access", "bool", True), # absent → fail-open (assume access)
("tool_pool_gated_off", "bool", False),
)
def _header_name(field: str) -> str:
return "x-nous-" + ("" if field.startswith("tool_pool_") else "credits-") + field.replace("_", "-")
def _parse_field(kind: str, raw: Optional[str], default: Any = _SENTINEL) -> Any:
"""One header value → field value; ``default`` when absent, ``_SENTINEL`` on a contract violation."""
if raw is None:
return default
if kind in ("micros", "signed"):
val = _safe_int(raw)
return _SENTINEL if val is _SENTINEL or (kind == "micros" and val < 0) else val
if kind == "usd":
return raw if _USD_RE.match(raw) else _SENTINEL
flag = raw.strip().lower()
return _SENTINEL if flag not in ("true", "false") else flag == "true"
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.
None (miss) on ANY of: no version header; version != 1 (> 1 also warns once);
a required field violating ``_HEADER_FIELDS``; unknown ``denominator_kind``;
any unexpected exception. Fail-open on the subscription_limit pair: a
half-pair (only -micros or only -usd) parses as both-absent (both None)."""
global _version_warning_emitted
try:
# Cheap probe before the lowercase copy (header names are case-insensitive): bail when the
# version header is absent — the hot path for non-Nous providers.
if not any(k.lower() == "x-nous-credits-version" for k in headers):
return None
lowered = {k.lower(): v for k, v in headers.items()}
version_val = _safe_int(lowered.get("x-nous-credits-version"))
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
fields: dict[str, Any] = {
name: _parse_field(kind, lowered.get(_header_name(name)), *default) for name, kind, *default in _HEADER_FIELDS
}
lim_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros")
lim_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd")
if lim_micros_raw is not None and lim_usd_raw is not None:
fields["subscription_limit_micros"] = _parse_field("micros", lim_micros_raw)
fields["subscription_limit_usd"] = _parse_field("usd", lim_usd_raw)
denominator_kind = lowered.get("x-nous-credits-denominator-kind", "none")
if _SENTINEL in fields.values() or denominator_kind not in _VALID_DENOMINATOR_KINDS:
return None
disabled_reason = lowered.get("x-nous-credits-disabled-reason") # None if absent (omitted when null)
return CreditsState(version=version_val, denominator_kind=denominator_kind, disabled_reason=disabled_reason,
captured_at=time.time(), from_header=True, **fields)
except Exception: # fail-open → miss; the breadcrumb distinguishes a parser regression from a no-headers response
logger.debug("credits ▸ parse_credits_headers raised (fail-open miss)", exc_info=True)
return None
# ── Dev fixtures (HERMES_DEV_CREDITS_FIXTURE): throwaway scaffolding to trigger any notice state
# without real spend. Value is a state NAME or a FILE PATH whose contents are a name (re-read every
# turn → `echo depleted > /tmp/cf` flips live). Drives per-turn notices, the cold-start seed, and /usage.
def _fixture(remaining: str, subscription: str, limit: Optional[str] = None, purchased: Optional[str] = None,
*, paid: bool = True, reason: Optional[str] = None) -> dict:
"""Fixture spec from *_usd strings; micros derived exactly (Decimal)."""
d: dict = {}
for field, usd in (("remaining", remaining), ("subscription", subscription), ("subscription_limit", limit), ("purchased", purchased)):
if usd is not None:
d[f"{field}_micros"], d[f"{field}_usd"] = int(Decimal(usd) * 1_000_000), usd
if limit is not None:
d["denominator_kind"] = "subscription_cap"
d["paid_access"] = paid
return d if reason is None else {**d, "disabled_reason": reason}
_DEV_FIXTURES: dict[str, dict] = {
"healthy": _fixture("30.34", "18.00", "20.00", "12.34"), # used_fraction ~0.1, paid → no notice (recovery target)
"sub_50pct": _fixture("10.00", "10.00", "20.00"), # used_fraction == 0.5 → credits.usage band 50 (info)
"sub_75pct": _fixture("5.00", "5.00", "20.00"), # used_fraction == 0.75 → band 75 (warn)
"sub_90pct": _fixture("2.00", "2.00", "20.00"), # used_fraction == 0.9 → band 90 (warn)
# uf == 1.0 + purchased>0 → SILENT at open (crossing-gated); flip healthy →
# grant_exhausted via the fixture-file path to see credits.grant_spent
"grant_exhausted": _fixture("12.34", "0.00", "20.00", "12.34"),
"depleted": _fixture("0.00", "0.00", None, "0.00", paid=False, reason="out_of_credits"), # → credits.depleted
# subscription in debt (negative, the only signed field) → depleted
"debt": _fixture("0.00", "-5.00", "20.00", "0.00", paid=False, reason="out_of_credits"),
}
def dev_fixture_credits_state() -> Optional[CreditsState]:
"""Fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None (unknown name / unset). Prod-leak guard:
applies ONLY when HERMES_DEV_CREDITS is also on, so a stray fixture env var never surfaces fabricated balances."""
name = os.environ.get("HERMES_DEV_CREDITS_FIXTURE", "").strip()
if not name or not is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")):
return None
if os.path.sep in name or "/" in name: # looks like a path → read the name from the file
try:
with open(name, "r", encoding="utf-8-sig") as fh:
name = fh.read().strip()
except OSError:
return None
if not (spec := _DEV_FIXTURES.get(name.lower())):
return None
# Stamp what the REAL parser always guarantees so a fixture is field-identical to a
# parse_credits_headers() result: version 1 and a valid purchased_usd (zero top-up = "0.00").
return CreditsState(**{"version": 1, "purchased_usd": "0.00", **spec}, 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 (formatting account floats is allowed; parsing a server *_usd is not). Fail-open → None."""
try:
acc = getattr(info, "paid_service_access_info", None)
sub = getattr(info, "subscription", None)
def _money(dollars) -> tuple[int, str]: # (micros, display usd); (0, "") when absent
return (int(round(dollars * 1_000_000)), f"{dollars:.2f}") if isinstance(dollars, (int, float)) else (0, "")
fields: dict[str, Any] = {}
for prefix, attr in (("remaining", "total_usable_credits"), ("subscription", "subscription_credits_remaining"),
("purchased", "purchased_credits_remaining")):
fields[f"{prefix}_micros"], fields[f"{prefix}_usd"] = _money(getattr(acc, attr, None))
monthly = getattr(sub, "monthly_credits", None)
cap = _money(monthly) if isinstance(monthly, (int, float)) and monthly > 0 else (None, None)
paid = getattr(info, "paid_service_access", None)
return CreditsState(
**fields, subscription_limit_micros=cap[0], subscription_limit_usd=cap[1], from_header=False, captured_at=time.time(),
rollover_micros=_money(getattr(sub, "rollover_credits", None))[0], paid_access=paid if isinstance(paid, bool) else True,
denominator_kind="subscription_cap" if cap[0] is not None else "none",
)
except Exception:
logger.debug("credits ▸ seed account→state mapping failed", exc_info=True)
return None
# Process-lifetime memory of the usage band last shown per session_id: see
# _remember_shown_band. Bounded MRU so a long-lived gateway cannot grow it without limit.
_seen_usage_bands: dict = {}
_SEEN_USAGE_BANDS_MAX = 500
def _remember_shown_band(session_id, band) -> None:
"""Remember the usage band last evaluated for ``session_id`` (process-lifetime).
Desktop reap/resume rebuilds the AIAgent per turn, so the in-memory ``_credits_latch``
(and its ``usage_band``) never survives across messages — every rebuild re-announces the
current band as a fresh crossing (#101578). ``agent.session_id`` IS stable across those
rebuilds, so this map lets ``_hydrate_seed_state`` restore the band the user already saw.
Deliberately NOT durable: a real process restart is a real ``session open`` and the
cold-start seed should still warn immediately. Bounded (MRU eviction) so a long-lived
gateway juggling many sessions cannot grow it without limit."""
if not session_id:
return
_seen_usage_bands.pop(session_id, None) # re-insert at the MRU end
_seen_usage_bands[session_id] = band
if len(_seen_usage_bands) > _SEEN_USAGE_BANDS_MAX:
_seen_usage_bands.pop(next(iter(_seen_usage_bands)))
def _hydrate_seed_state(agent, state) -> None:
"""Install a seed CreditsState on the agent and fire the notice policy once. Primes the crossing gate:
the cold-start snapshot IS the first observation, so a session opening in a band warns immediately."""
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 # ONLY this gate — priming seen_grant_unspent would revive the steady-state nag
# Restore the band this logical session already showed (desktop reap/resume rebuilds the
# agent object but keeps session_id) so an unchanged band is not re-announced per message.
prior_band = _seen_usage_bands.get(getattr(agent, "session_id", None))
if prior_band is not None:
latch["usage_band"] = prior_band
latch["active"].add(CREDITS_USAGE_KEY)
_rerun_notice_policy(agent)
def _rerun_notice_policy(agent) -> None:
if callable(emit := getattr(agent, "_emit_credits_notices", None)):
emit()
def _warm_nous_pricing_cache() -> None:
"""Fill the in-process Nous pricing catalog so :func:`is_free_tier_model`'s peek can answer.
The peek never fetches and nothing else warms it during session start, so a cold process reads
an EMPTY catalog and a subscription-billed model — which spends no credits — still draws the
depleted banner. Fail-open: a miss leaves the peek exactly as it was."""
try:
from hermes_cli.models_pricing import get_pricing_for_provider
get_pricing_for_provider("nous")
except Exception:
logger.debug("credits ▸ nous pricing warm failed", exc_info=True)
def rewarm_pricing_before_depleted_notice(agent) -> bool:
"""Depleted account, model the peek cannot vouch for, Nous catalog cold: start a background warm
whose completion re-runs the policy and return True so the caller defers the depleted decision
to it — the session-start warm is one-shot but the catalog expires after
``_NOUS_CATALOG_TTL_SECONDS``, and a cold peek would bring the banner back for a
subscription-billed model. False = decide now: peek warm, not on Nous, a warm in flight (the
warm's own re-run included — fail-open, a failed fetch leaves the peek cold and the banner
shows), or a fetch already failed and the cache is still refusing to dial. May raise; the notice
path that calls it catches."""
base_url = getattr(agent, "base_url", "") or ""
if getattr(agent, "provider", "") != "nous" or not base_url:
return False
from hermes_cli.models_pricing import peek_cached_pricing, pricing_fetch_suppressed
if peek_cached_pricing(base_url) or pricing_fetch_suppressed(base_url):
return False
inflight = getattr(agent, "_credits_pricing_warm", None)
if inflight is not None and inflight.is_alive():
return False
def _warm_then_rerun() -> None:
_warm_nous_pricing_cache()
_rerun_notice_policy(agent)
from agent.memory_provider import spawn_context_thread
agent._credits_pricing_warm = spawn_context_thread(_warm_then_rerun, name="credits-pricing-warm")
agent._credits_pricing_warm.start()
return True
def seed_credits_at_session_start(agent) -> bool:
"""Hydrate agent._credits_state from the portal account (or a dev fixture) and fire the notice policy so
warnings show at session OPEN (TUI/desktop "ready" and plain-CLI first-turn setup). Idempotent once a seed
or real header populated _credits_state. Returns True iff it seeded this call. Never raises."""
try:
if getattr(agent, "provider", "") != "nous" or getattr(agent, "_credits_state", None) is not None:
return False
try:
fixture = dev_fixture_credits_state()
except Exception:
fixture = None
if fixture is not None: # synchronous: instant, and tests rely on state + notice landing before return
_hydrate_seed_state(agent, fixture)
return True
def _bg_seed() -> None: # FIRE-AND-FORGET: a slow portal must never delay "ready"
try:
from hermes_cli.nous_account import get_nous_portal_account_info
# BEFORE the policy runs (either branch below): the free-model gate only PEEKS the
# pricing cache, and this thread is the first thing a chat session runs that can
# afford to fill it.
_warm_nous_pricing_cache()
info = get_nous_portal_account_info(force_fresh=True)
if getattr(agent, "_credits_state", None) is not None:
# A live inference header beat us — don't clobber it, but DO re-run the policy:
# it evaluated against the cold cache and may be showing a banner the warm
# catalog now suppresses.
_rerun_notice_policy(agent)
return
if (state := _credits_state_from_account(info)) is not None:
_hydrate_seed_state(agent, state)
except Exception:
logger.debug("credits ▸ session-start seed (background) failed", exc_info=True)
from agent.memory_provider import spawn_context_thread
spawn_context_thread(_bg_seed, name="credits-seed").start() # the warm reads the profile's auth store
return True
except Exception:
logger.debug("credits ▸ session-start seed failed (fail-open)", exc_info=True) # innermost log: diagnosable dead seed
return False