mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix Lockfile Fix / auto-fix-main (push) Waiting to run
Nix Lockfile Fix / fix (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Tests / test (1) (push) Waiting to run
Tests / test (2) (push) Waiting to run
Tests / test (3) (push) Waiting to run
Tests / test (4) (push) Waiting to run
Tests / test (5) (push) Waiting to run
Tests / test (6) (push) Waiting to run
Tests / save-durations (push) Blocked by required conditions
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
* feat(tui): HERMES_DEV_CREDITS live-spend dev readout (L0 tracer for usage-aware credits)
L0 of the usage-aware-credits feature: a dev-only, env-gated tracer that
exercises the real header -> CreditsState -> TUI pipe end-to-end behind
HERMES_DEV_CREDITS, de-risking the L1/L5 build before the notice policy exists.
- agent/credits_tracker.py: CreditsState + parse_credits_headers (headers are
strings -> paid_access via == "true", never bool(); retain-last-known; only
subscription_micros may be negative; *_usd kept verbatim).
- run_agent.py: _capture_credits / get_credits_state / get_credits_spent_micros,
session-start baseline latch, + dev-gated "credits" capture log.
- agent/chat_completion_helpers.py: capture on the streaming response.
- agent/agent_init.py: init _credits_state + _credits_session_start_micros.
- tui_gateway/server.py: _get_usage emits dev_credits_spent_micros only when flagged.
- ui-tui appChrome.tsx / types.ts: cents delta status segment + "(dev credits)" banner.
Off by default; silent for normal users. Validated live against staging
(capture log delta matches the TUI segment). Throwaway consumer (readout/log/
banner); credits_tracker + the capture plumbing are the real feature foundation.
* test(credits): lock parser under 9-state matrix + harden validation (L2)
Add tests/agent/test_credits_tracker.py with 92 tests covering the 9-state
matrix (healthy, sub_90pct, grant_exhausted, purchased_only, tool_pool_free,
depleted, debt, missing, no_org) plus validation edge cases: version strict==1
with warn-once latch for v>1, bool-string trap (paid_access/tool_pool_gated_off
== "true"/"false", never bool()), half-pair subscription limit treated as
both-absent while parse succeeds, USD regex ^-?\d+\.\d{2}$, non-int micros
→ None, negative non-subscription micros → None, as_of_ms junk → None, zero
limit ZeroDivision guard.
Harden agent/credits_tracker.py to match the spec:
- Add tool_pool_micros/tool_pool_gated_off/from_header fields to CreditsState
- Add depleted property (== not paid_access, never remaining==0)
- Change used_fraction guard to key off subscription_limit_micros (the actual
denominator) not denominator_kind (metadata)
- Replace fail-soft _safe_int with a sentinel-returning variant; full validation
now returns None on any malformed field rather than silently defaulting
- Add module-level warn-once latch for version > 1
- Add USD regex validation; add denominator_kind allow-list check
- Parse x-nous-tool-pool-* prefix headers (not x-nous-credits-tool-pool-*)
* feat(credits): notice spine — AgentNotice + notice_callback/notice_clear_callback + TUI binding (L1)
L1 of usage-aware credits: the driver-agnostic notice delivery spine that L4's
policy will fire through and L5's TUI render will consume.
- agent/credits_tracker.py: AgentNotice dataclass (text/level/kind/ttl_ms/key/id;
kind defaults "sticky", kept TTL-expressive for a future config seam).
- run_agent.py: AIAgent gains notice_callback + notice_clear_callback slots and
_emit_notice / _emit_notice_clear emitters (swallow all callback errors — a
notice must never break the agent loop; no-op when unbound).
- agent/agent_init.py: thread both callbacks through init_agent.
- tui_gateway/server.py: bind both in _agent_cbs → notification.show / notification.clear
WS events (snake_case payload, matching the existing gateway-event convention).
- ui-tui/src/gatewayTypes.ts: notification.show / notification.clear arms on GatewayEvent.
- tests/run_agent/test_notice_spine.py: 15 tests (emitter fire + fail-open + no-op,
signature threading, TUI binding payload shape).
Messaging push is out of v1 (binds neither callback). CLI binding + the TUI render/
decode land with L4 (firing) and L5 (render) so turn-end flush is wired correctly.
* feat(credits): threshold reconciliation policy + tests (L4.1)
* feat(credits): wire threshold policy into capture + latch (L4.2)
After a fresh header parse, _capture_credits runs evaluate_credits_notices against
the agent's _credits_latch and emits the result — clears first, then shows (so a
recovered depletion clears before the "restored" success lands, and depleted wins
the latest-wins slot). Gated on a bound notice_callback: messaging (no callbacks)
still caches state for /usage but runs no policy. Parse stays fail-open (miss →
keep last-known); the eval/emit path warns on failure rather than swallowing, so a
depletion-notice bug can't vanish silently.
- run_agent.py: _capture_credits split into parse (swallow→miss) + policy (warn);
latch lazy-guarded (object.__new__ safety).
- agent/agent_init.py: init agent._credits_latch = {"active": set(), "seen_below_90": False}.
* feat(tui): render credits notices in the status bar (L5, Strategy B)
The TUI now renders the notification.show / notification.clear gateway events the
agent emits — a level-colored notice overrides the status/verb slot when not busy.
- Notice state machine on turnController (pendingNotice + dedicated noticeTimer +
show/clear/applyNotice/flushPendingNotice/clearNoticeState). createGatewayEventHandler
decodes the events and delegates.
- Render priority busy > notice > status (appChrome StatusRule); notice text rendered
verbatim (its glyph comes from the policy), shrinkable so it never clips model│ctx;
dev-credits banner + Δ segment preserved. UiState.notice is snake_case (matches wire).
- Busy-wins: a notice arriving mid-turn is held and flushed at the THREE turn-end sites
(recordMessageComplete / interruptTurn / recordError) — never idle(), which reset()
also calls (would leak across sessions); reset() clears instead.
- Dedicated noticeTimer (never statusTimer); TTL starts on visibility with an id-guard;
latest-wins cancels the prior timer; clear is key-matched (no-op on mismatch); a sticky
survives a turn (flush no-ops with no pending); session reset clears (no cross-session leak).
- 20 tests (handler/turnController logic incl. R3-C2 timer isolation + render priority).
* feat(credits): cold-start seed for new Nous sessions (L3)
A genuinely-new Nous session has no inference header yet, so seed credits state from
the authoritative GET /api/oauth/account snapshot at session start (in the new-session
branch of _restore_or_build_system_prompt — inline, since the on_session_start plugin
hook gets no agent reference). The seed runs the shared notice policy, so a session that
opens already depleted warns IMMEDIATELY rather than only after the first turn.
- Maps the nested account fields (paid_service_access → paid_access; total_usable /
subscription / purchased on paid_service_access_info; rollover on subscription), each
None-guarded; float dollars → micros via round(d*1e6), *_usd left "" (render formats
from micros — never synthesize a verbatim usd from a float).
- Magnitudes-only: no monthlyCredits on the endpoint → subscription_limit_* unset →
used_fraction None → no warn90 from the seed (% only once a header lands, per D-E).
- Provider-guarded to Nous; fail-open (any error leaves _credits_state None, never
blocks startup); paid_access unknown ⇒ True (never falsely depleted).
- run_agent.py: extracted the warm-path policy/emit block into a shared
_emit_credits_notices() so capture and the seed fire notices identically.
* feat(credits): /usage Nous credits magnitudes view + recovery trigger (L6)
Add Nous credit dollar magnitudes to /usage (subscription / top-up / total
+ rollover + renewal + portal CTA), magnitudes-only per v1 (no % until the
account endpoint exposes a denominator). Reuses the existing account-usage
render machinery via a new pure build_nous_credits_snapshot() that maps a
NousPortalAccountInfo to an AccountUsageSnapshot; no nous branch is added to
fetch_account_usage (keeps the per-provider boundary intact).
CLI /usage also doubles as a depletion-recovery trigger: a force_fresh
account fetch, kept in a SEPARATE local so it never clobbers the
header-sourced agent._credits_state (which alone carries used_fraction). If
paid access recovered while credits.depleted is latched and a notice
consumer is bound, it reuses agent._emit_credits_notices() to clear it.
Gateway /usage displays magnitudes only — messaging binds no notice
consumer, so it performs no recovery emit.
Fail-open throughout: any portal hiccup leaves /usage unaffected.
* refactor(credits): dedupe HERMES_DEV_CREDITS flag parse via shared helpers
The dev-flag truthy check was inlined in three places. Replace with the shared
utils.is_truthy_value (run_agent.py, tui_gateway/server.py — also drops a
redundant inline `import os`) and a hoisted DEV_CREDITS_MODE export in
ui-tui/src/config/env.ts (consumed by appChrome, which also stops recomputing the
env check on every render). Behaviour-preserving; identical truthy set.
* fix(credits): cut dead /usage recovery trigger + bound portal fetches (L6 review)
Adversarial review found the /usage depletion-recovery trigger dead AND broken:
the CLI binds no notice_clear_callback, the TUI runs /usage in a separate
slash-worker subprocess (its own agent/latch), and the no-clobber rule made it
evaluate stale paid_access anyway. Recovery already happens on the next inference
(warm path), so the trigger was redundant — remove it and stop the depleted
notice over-promising.
- cli.py: remove the dead recovery block; bound the /usage portal fetch with a
10s wall-clock timeout (ThreadPoolExecutor) like the per-provider fetch —
urllib's per-socket timeout is not a wall-clock guarantee.
- agent/credits_tracker.py: reword the depleted CTA to "run /usage for balance"
(no false recovery promise; /usage shows fresh magnitudes, sticky clears next turn).
- agent/conversation_loop.py: same wall-clock timeout on the cold-start seed fetch
so a stalled portal can't hang session startup; tidy its time import.
* chore(credits): dev notice-state fixtures (HERMES_DEV_CREDITS_FIXTURE)
Throwaway dev scaffolding to exercise the notice pipeline without real spend or
Redis seeding. Set HERMES_DEV_CREDITS_FIXTURE to a state name (healthy / sub_90pct
/ grant_exhausted / depleted / clear) or a file path whose contents name a state
(re-read each turn → flip states live for recovery testing). _capture_credits
injects the chosen CreditsState instead of parsing real headers and runs the
shared notice policy. Deletable with the rest of the HERMES_DEV_CREDITS scaffolding.
* feat(credits): /usage monthly-grant % gauge
The portal /api/oauth/account subscription block now carries monthly_credits
(the per-period grant allowance, the % denominator). The consumer parsed
monthly_charge but dropped monthly_credits, so /usage stayed magnitudes-only.
Capture monthly_credits into NousPortalSubscriptionInfo + _subscription_from_payload.
build_nous_credits_snapshot emits a Subscription usage window (real % used, routed
through the existing render machinery) when monthly_credits is a finite positive
denominator and credits_remaining is finite and <= cap; otherwise it degrades to
magnitudes-only (older portals, rollover-over-cap, or non-finite payloads).
Guards (adversarial-review-driven): reject non-finite operands (json.loads parses
bare NaN/Infinity by default → would render $nan + a false 100% used), reject
bools, guard div-by-zero (cap>0), and suppress the gauge when remaining > cap
(rollover spanning the period makes the cap a nonsensical denominator → the
$X-of-$Y detail would read as a contradiction). Debt (remaining<0) clamps to 100%.
Money rule preserved: the ratio + magnitudes are computed from numeric float
account fields via display formatting, never by parsing a server *_usd string
(there are none on these dataclasses).
13 gauge tests added (tests/agent/test_nous_credits_gauge.py).
* fix(credits): show /usage Nous block whenever a Nous account is present
/usage runs in a slash-worker subprocess whose resolved inference provider is
often not "nous" even when the user has a Nous account, so gating the Nous
credits block on (provider == "nous") hid it entirely — the account data was
fully available but never rendered.
Gate instead on "a Nous account is logged in": a cheap local auth-state lookup
(get_provider_auth_state('nous') has an access_token) decides whether to attempt
the portal fetch, regardless of which provider inference runs on. In the gateway
the block is also lifted out of the 'if provider:' scope so a Nous-credentialled
user with another (or no) resident inference provider still sees their balance.
Fail-open and the per-fetch wall-clock timeout are preserved.
* fix(credits): show /usage Nous block when there's no live agent (TUI slash-worker)
In the TUI, /usage runs in a slash-worker subprocess that resumes the session
WITHOUT building an agent (self.agent is None), so _show_usage early-returned
"(._.) No active agent" before ever reaching the Nous credits block — which is
agent-independent (a portal fetch gated on Nous auth-state). Extract the block
into _print_nous_credits_block() and run it at the no-agent / no-calls
early-returns too (returns True if it printed, so the fallback message only
shows when there's genuinely nothing).
Verified live against staging: the block + monthly-grant gauge now render in the
slash-worker /usage path (previously hidden). The plain CLI REPL + messaging
paths are unchanged (they have a live agent).
* feat(credits): escalating 50/75/90 usage bands (single status line)
Replace the lone 90%-used warning with three escalating bands (50 info, 75 warn,
90 warn) shown as ONE status-bar line: it displays the highest band the
subscription grant has crossed, replaces the line as usage climbs, steps back
down on recovery, and clears below 50%. No stacking, no per-turn churn.
Bands live in a tunable CREDITS_USAGE_BANDS list; the policy derives everything
from it. Single notice key (credits.usage) with a usage_band latch field so the
notice only re-emits when the band actually changes. The crossing gate
(seen_below_90) is preserved so a fresh live session that opens mid-range stays
quiet until it has been observed below the lowest band (cold-start primes it when
it wants an open-high warning). Denominator math unchanged: % = subscription
grant burn (cap - grant_remaining)/cap, clamped [0,1]; top-up never moves the %.
Migrated test_credits_policy.py to the new key + added TestUsageBands (climb,
step-down, recovery-clear, idempotent, inclusive boundaries).
* feat(credits): hydrate notices at session OPEN via shared seed (TUI + first-turn)
Notices previously only fired inside a conversation turn (first message), so a
session that opened already depleted / past a usage band showed nothing at
'ready'. Extract the cold-start seed into a shared seed_credits_at_session_start()
and call it (a) in the TUI/desktop agent build right after the notice callback is
wired (fires at 'ready', before any message) and (b) as the first-turn fallback in
conversation_loop. Idempotent (skips once _credits_state exists) and fail-open.
The seed now maps monthly_credits -> subscription_limit_micros +
denominator_kind='subscription_cap', so used_fraction is computable at seed time
and usage-band warnings (not just depletion) hydrate on open. Primes the crossing
latch so a session opening already in a band warns immediately. Degrades to
depletion-only when monthly_credits is absent (older portals).
Adds test_credits_cold_start.py covering open-at-band, depletion, debt, no-cap
degradation, and the shared seed (fires/idempotent/skips-non-nous).
* feat(credits): /usage monthly-grant % gauge + fixture support + TUI surfacing
agent/account_usage.py: build_nous_credits_snapshot emits a subscription %% gauge
when the portal supplies a positive, finite monthly_credits denominator with
remaining <= cap (guards reject NaN/Infinity and rollover-over-cap, which would
render $nan or a contradictory $X-of-$Y); degrades to magnitudes-only otherwise.
Adds shared nous_credits_lines() (auth-gated, wall-clock-bounded portal fetch) so
the CLI and TUI /usage render the same block, and _snapshot_from_credits_state()
so HERMES_DEV_CREDITS_FIXTURE drives /usage offline too.
TUI: session.usage RPC carries credits_lines (agent-independent) and the /usage
panel renders them regardless of API-call count or resume state — previously the
TUI's separate /usage implementation only showed token counts.
Money rule preserved: %% and magnitudes come from numeric float account fields via
display formatting, never by parsing a server *_usd string.
* feat(credits): CLI REPL inline notices (parity with TUI)
The plain CLI agent bound no notice callbacks, so credit notices were TUI-only.
Bind notice_callback/notice_clear_callback on the CLI AIAgent; _on_notice renders
a single level-colored line above the prompt (error red / warn yellow / success
green / info dim) via _cprint, and seed credits at session open so a depletion or
usage-band warning shows before the first message — the same hydration the TUI
got. _on_notice_clear is a no-op (the REPL prints lines, no persistent slot).
* test(credits): add sub_50pct + sub_75pct dev fixtures for the new usage bands
The fixture set jumped 10%% -> 90%%; add sub_50pct (uf 0.5 -> band 50 info) and
sub_75pct (uf 0.75 -> band 75 warn) so the new escalating bands are exercisable
via HERMES_DEV_CREDITS_FIXTURE across all three surfaces (notice, session-open
seed, /usage gauge).
* fix(credits): usage-band notice clears on next prompt (not sticky-forever)
A 50/75/90 usage heads-up was sticky and camped the status bar indefinitely. Clear
the visible credits.usage notice when a new turn starts (startMessage), so it shows
until your next prompt then yields. The server latch is unchanged, so it won't
re-nag at the same band — it only re-shows when the band actually changes (climb)
or clears when usage drops below the lowest band. Depletion stays sticky.
* refactor(credits): consolidate the /usage credits block behind nous_credits_lines()
The CLI (_print_nous_credits_block) and the messaging gateway (_handle_usage_command)
each re-implemented the auth-gate + portal fetch + render, and both bypassed the
dev-fixture short-circuit that only the TUI honored — so /usage ignored
HERMES_DEV_CREDITS_FIXTURE on the CLI and in chat. Route both through the shared
agent.account_usage.nous_credits_lines() helper: one fetch/render path, one auth
gate, and the fixture works on every surface (~60 fewer duplicated lines).
The gateway usage test recorded only the last asyncio.to_thread call; /usage now
dispatches both the account fetch and the credits fetch, so it records every call
and matches the account fetch by its provider arg.
* fix(credits): keep the /usage gauge type-safe and log its fail-open path
_is_finite_num is now a TypeGuard[float], so the type checker narrows the gauge
operands (monthly_credits / credits_remaining) and the magnitudes passed to
_fmt_usd through it — no more None-operand warnings on the arithmetic. Add a debug
breadcrumb on the nous_credits_lines portal-fetch fail-open so a dead /usage block
is diagnosable in agent.log without a dev flag.
* fix(credits): harden the header tracker — prod-leak gate, hot-path probe, fire-and-forget seed
- Prod-leak guard: dev fixtures (HERMES_DEV_CREDITS_FIXTURE) now also require
HERMES_DEV_CREDITS, so a stray fixture var can't surface fabricated balances on a
real account. Matches the documented run workflow (both vars set together).
- Hot-path probe: parse_credits_headers checks for the version sentinel header
before allocating a lowercased copy of the response headers — skips that work on
every non-Nous API call. Behaviour-identical and still case-insensitive.
- Fire-and-forget seed: the real portal fetch in seed_credits_at_session_start now
runs in a daemon thread, so a slow/unreachable portal never delays session "ready"
(previously blocked up to 10s). The dev-fixture path stays synchronous; the thread
re-checks idempotency before hydrating (a live header may land first).
- Diagnostics: debug breadcrumbs on the parse and seed fail-open paths so a crashed
parser / dead seed is distinguishable from a legitimate no-headers miss.
Cold-start tests set HERMES_DEV_CREDITS alongside the fixture to match the gate.
* test(tui): fix env-timing in the StatusRule dev-credits assertion
DEV_CREDITS_MODE is read once at module load (config/env), so mutating
process.env.HERMES_DEV_CREDITS inside the test couldn't flip it — the dev-banner
assertion only passed if the env was exported before vitest started, and failed in a
normal run. Move that assertion to a sibling file that mocks config/env with
DEV_CREDITS_MODE: true (scoped, no module-reset / React-identity hazard).
* test(credits): cover the dev-fixture /usage render and usage-band clear-on-prompt
- _snapshot_from_credits_state (the offline /usage renderer) had no direct test:
lock the gauge math, the verbatim *_usd magnitudes, the depletion line and the
fixture marker, plus the no-cap (no gauge) and None-state cases.
- turnController.startMessage had no test for clearing the credits.usage notice on
the next prompt while leaving credits.depleted sticky.
* feat(credits): deliver credit notices over messaging gateways
Bind notice_callback/notice_clear_callback on the per-turn gateway agent
so usage-band / depletion / restored notices reach Telegram/Discord/Slack/
etc. Previously the messaging gateway bound neither callback, so the agent's
_emit_credits_notices early-returned and a chat user crossing a band got
nothing unless they ran /usage manually.
- render_notice_line(): AgentNotice -> single plaintext line (level glyph +
text), plaintext-only so it renders uniformly without per-platform escaping.
Fail-soft on malformed/empty notices.
- Standalone push for every notice (messaging has no persistent status bar):
route through the shared _deliver_platform_notice rail (honors private/
public delivery + thread metadata), scheduled onto the gateway loop via
safe_schedule_threadsafe from the agent's sync worker thread — same pattern
as _status_callback_sync.
- The fired-once latch lives on the cached (reused-in-place) agent and
persists across turns, so a band crosses once -> one push, no per-turn
re-nag. Re-fires only after idle-eviction rebuilds the agent (a reminder).
- Recovery ('Credit access restored') rides the show path (emitted as a
success notice, not a clear). notice_clear_callback is a no-op: a sent
platform message can't be cleanly retracted.
Tests: render glyph/levels/fail-soft + public/private delivery seam through
_deliver_platform_notice + no-adapter no-op.
* fix(credits): don't double the glyph on messaging notices
render_notice_line prepended a per-level glyph, but the notice policy already
bakes the glyph into the text (and the TUI + CLI render it verbatim) — so every
credit notice over messaging came out doubled ("⚠ ⚠ Credits 90% used",
"⛔ ✕ Credit access paused"). Emit the text verbatim instead; drop the now-dead
level→glyph map.
The render tests fed glyph-less text (and the success case only checked
startswith), so the doubling slipped through. Rework them around the verbatim
contract and add an end-to-end regression that runs real evaluate_credits_notices
output through render_notice_line and asserts the line is returned unchanged.
723 lines
34 KiB
Python
723 lines
34 KiB
Python
"""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
|