mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(credits): usage-aware credits — in-session notices, /usage view, dev readout (#40011)
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
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.
This commit is contained in:
parent
b91aade176
commit
fcb1944b4f
33 changed files with 4535 additions and 26 deletions
|
|
@ -1,8 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -10,6 +12,11 @@ from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
|
|||
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TypeGuard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
|
@ -113,6 +120,223 @@ def render_account_usage_lines(snapshot: Optional[AccountUsageSnapshot], *, mark
|
|||
return lines
|
||||
|
||||
|
||||
def _fmt_usd(d: float) -> str:
|
||||
return f"${d:,.2f}"
|
||||
|
||||
|
||||
def _is_finite_num(v: Any) -> TypeGuard[float]:
|
||||
"""True iff v is a real numeric value (int or float, not bool, not NaN/Inf).
|
||||
|
||||
Typed as a ``TypeGuard[float]`` so the type checker narrows ``v`` to a real
|
||||
number in the positive branch — callers can then do arithmetic / pass it to
|
||||
``_fmt_usd`` without a None-operand warning.
|
||||
"""
|
||||
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
||||
|
||||
|
||||
def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
"""Map a NousPortalAccountInfo into an AccountUsageSnapshot for /usage.
|
||||
|
||||
Shows dollar magnitudes (subscription / top-up / total) + renewal date + a
|
||||
portal CTA. When the portal supplies a subscription denominator
|
||||
(``monthly_credits``), also emits a subscription-usage window so the renderer
|
||||
shows a real ``% used`` gauge; when it's absent (older portals) the view
|
||||
gracefully degrades to magnitudes-only. Returns None when there's no usable
|
||||
account info to show (fail-open: caller just shows nothing).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.nous_account import nous_portal_billing_url
|
||||
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return None
|
||||
|
||||
access = getattr(account_info, "paid_service_access_info", None)
|
||||
sub = getattr(account_info, "subscription", None)
|
||||
|
||||
windows: list[AccountUsageWindow] = []
|
||||
details: list[str] = []
|
||||
|
||||
# Subscription usage gauge — only when the portal supplies a positive
|
||||
# monthly_credits denominator AND a finite remaining balance that does
|
||||
# not exceed the cap. Money math is on float dollars (allowed: numeric
|
||||
# account fields, NOT a server-provided *_usd string). used = cap -
|
||||
# remaining; clamp [0,100] so a debt balance (remaining < 0) reads 100%.
|
||||
# Excluded on purpose:
|
||||
# - non-finite values (NaN/Infinity slip past isinstance and json.loads
|
||||
# parses bare NaN/Infinity by default) → would render "$nan"/"$inf"
|
||||
# and a falsely-confident gauge;
|
||||
# - remaining > cap (rollover balance spanning the period) → monthly_credits
|
||||
# is no longer a meaningful denominator, and "$X of $Y left" with X>Y
|
||||
# reads as a contradiction. Both fall back to the magnitudes lines.
|
||||
if sub is not None:
|
||||
monthly_credits = getattr(sub, "monthly_credits", None)
|
||||
sub_remaining = getattr(sub, "credits_remaining", None)
|
||||
if (
|
||||
_is_finite_num(monthly_credits)
|
||||
and monthly_credits > 0
|
||||
and _is_finite_num(sub_remaining)
|
||||
and sub_remaining <= monthly_credits
|
||||
):
|
||||
used = monthly_credits - sub_remaining
|
||||
used_pct = max(0.0, min(100.0, used / monthly_credits * 100.0))
|
||||
windows.append(
|
||||
AccountUsageWindow(
|
||||
label="Subscription",
|
||||
used_percent=used_pct,
|
||||
detail=f"{_fmt_usd(sub_remaining)} of {_fmt_usd(monthly_credits)} left",
|
||||
)
|
||||
)
|
||||
|
||||
if access is not None:
|
||||
sub_credits = getattr(access, "subscription_credits_remaining", None)
|
||||
if _is_finite_num(sub_credits):
|
||||
details.append(f"Subscription credits: {_fmt_usd(sub_credits)}")
|
||||
purchased = getattr(access, "purchased_credits_remaining", None)
|
||||
if _is_finite_num(purchased):
|
||||
details.append(f"Top-up credits: {_fmt_usd(purchased)}")
|
||||
total_usable = getattr(access, "total_usable_credits", None)
|
||||
if _is_finite_num(total_usable):
|
||||
details.append(f"Total usable: {_fmt_usd(total_usable)}")
|
||||
|
||||
if sub is not None:
|
||||
rollover = getattr(sub, "rollover_credits", None)
|
||||
if _is_finite_num(rollover) and rollover > 0:
|
||||
details.append(f"Rollover: {_fmt_usd(rollover)}")
|
||||
period_end = getattr(sub, "current_period_end", None)
|
||||
if period_end:
|
||||
details.append(f"Renews: {period_end}")
|
||||
|
||||
paid = getattr(account_info, "paid_service_access", None)
|
||||
if paid is False:
|
||||
details.append("Status: access depleted — top up to restore")
|
||||
|
||||
if not windows and not details:
|
||||
return None
|
||||
|
||||
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
|
||||
|
||||
plan = getattr(sub, "plan", None) if sub is not None else None
|
||||
return AccountUsageSnapshot(
|
||||
provider="nous",
|
||||
source="portal-account",
|
||||
fetched_at=_utc_now(),
|
||||
title="Nous credits",
|
||||
plan=plan,
|
||||
windows=tuple(windows),
|
||||
details=tuple(details),
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list[str]:
|
||||
"""Return rendered Nous-credits /usage lines, or [] when there's nothing to show.
|
||||
|
||||
Account-independent of any live agent: gated on "a Nous account is logged in"
|
||||
(a cheap local auth-state check), then a wall-clock-bounded portal fetch. Shared
|
||||
by the CLI ``_show_usage`` and the TUI ``session.usage`` RPC so both surfaces show
|
||||
the same block regardless of session API-call count or resume state. Fail-open:
|
||||
any auth/portal hiccup or timeout returns [] (the caller shows nothing).
|
||||
|
||||
Dev override: when HERMES_DEV_CREDITS_FIXTURE selects a fixture state, /usage
|
||||
renders from that fixture instead of the real portal (so the block + gauge are
|
||||
testable without a live account). Throwaway scaffolding.
|
||||
"""
|
||||
# Dev fixture short-circuit — render /usage from the injected state, no portal.
|
||||
try:
|
||||
from agent.credits_tracker import dev_fixture_credits_state
|
||||
|
||||
fixture = dev_fixture_credits_state()
|
||||
except Exception:
|
||||
fixture = None
|
||||
if fixture is not None:
|
||||
snapshot = _snapshot_from_credits_state(fixture)
|
||||
return render_account_usage_lines(snapshot, markdown=markdown)
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
||||
if not (isinstance(tok, str) and tok.strip()):
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
account = pool.submit(
|
||||
get_nous_portal_account_info, force_fresh=True
|
||||
).result(timeout=timeout)
|
||||
snapshot = build_nous_credits_snapshot(account)
|
||||
return render_account_usage_lines(snapshot, markdown=markdown)
|
||||
except Exception:
|
||||
# Fail-open (caller shows nothing), but leave a breadcrumb so a dead
|
||||
# /usage credits block is diagnosable in agent.log without a dev flag.
|
||||
logger.debug("credits ▸ /usage portal fetch/render failed (fail-open)", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
"""Map a header-shaped CreditsState (e.g. a dev fixture) to the /usage snapshot.
|
||||
|
||||
Renders the same magnitudes + monthly-grant % window the portal path produces,
|
||||
so HERMES_DEV_CREDITS_FIXTURE can exercise /usage without a live account. The
|
||||
*_usd strings are mock display values here (not server balance to compute on);
|
||||
the % comes from CreditsState.used_fraction (micros math). Fail-open → None.
|
||||
"""
|
||||
try:
|
||||
if state is None:
|
||||
return None
|
||||
|
||||
windows: list[AccountUsageWindow] = []
|
||||
details: list[str] = []
|
||||
|
||||
uf = getattr(state, "used_fraction", None)
|
||||
if isinstance(uf, (int, float)) and math.isfinite(uf):
|
||||
cap_usd = getattr(state, "subscription_limit_usd", None)
|
||||
sub_usd = getattr(state, "subscription_usd", None)
|
||||
detail = None
|
||||
if sub_usd and cap_usd:
|
||||
detail = f"${sub_usd} of ${cap_usd} left"
|
||||
windows.append(
|
||||
AccountUsageWindow(
|
||||
label="Subscription",
|
||||
used_percent=max(0.0, min(100.0, uf * 100.0)),
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
|
||||
sub_usd = getattr(state, "subscription_usd", None)
|
||||
if sub_usd:
|
||||
details.append(f"Subscription credits: ${sub_usd}")
|
||||
purchased_usd = getattr(state, "purchased_usd", None)
|
||||
if purchased_usd:
|
||||
details.append(f"Top-up credits: ${purchased_usd}")
|
||||
remaining_usd = getattr(state, "remaining_usd", None)
|
||||
if remaining_usd:
|
||||
details.append(f"Total usable: ${remaining_usd}")
|
||||
if getattr(state, "paid_access", True) is False:
|
||||
details.append("Status: access depleted — top up to restore")
|
||||
|
||||
if not windows and not details:
|
||||
return None
|
||||
|
||||
details.append("(dev fixture — HERMES_DEV_CREDITS_FIXTURE)")
|
||||
return AccountUsageSnapshot(
|
||||
provider="nous",
|
||||
source="dev-fixture",
|
||||
fetched_at=_utc_now(),
|
||||
title="Nous credits",
|
||||
windows=tuple(windows),
|
||||
details=tuple(details),
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ def init_agent(
|
|||
interim_assistant_callback: callable = None,
|
||||
tool_gen_callback: callable = None,
|
||||
status_callback: callable = None,
|
||||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
|
|
@ -399,6 +401,8 @@ def init_agent(
|
|||
agent.stream_delta_callback = stream_delta_callback
|
||||
agent.interim_assistant_callback = interim_assistant_callback
|
||||
agent.status_callback = status_callback
|
||||
agent.notice_callback = notice_callback
|
||||
agent.notice_clear_callback = notice_clear_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
|
|
@ -507,6 +511,15 @@ def init_agent(
|
|||
# after each API call. Accessed by /usage slash command.
|
||||
agent._rate_limit_state: Optional["RateLimitState"] = None
|
||||
|
||||
# Credits tracking (dev-only, L0 usage-aware-credits) — updated from
|
||||
# x-nous-credits-* response headers after each API call. Session-start
|
||||
# remaining is latched the first time a header is ever seen so we can
|
||||
# report cumulative micros spent. Surfaced behind HERMES_DEV_CREDITS.
|
||||
agent._credits_state = None
|
||||
agent._credits_session_start_micros = None
|
||||
# Threshold-notice latch (L4): active sticky-notice keys + the warn90 crossing gate.
|
||||
agent._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None}
|
||||
|
||||
# OpenRouter response cache hit counter — incremented when
|
||||
# X-OpenRouter-Cache-Status: HIT is seen in streaming response headers.
|
||||
agent._or_cache_hits: int = 0
|
||||
|
|
|
|||
|
|
@ -1733,6 +1733,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
# The OpenAI SDK Stream object exposes the underlying httpx
|
||||
# response via .response before any chunks are consumed.
|
||||
agent._capture_rate_limits(getattr(stream, "response", None))
|
||||
agent._capture_credits(getattr(stream, "response", None))
|
||||
# Snapshot diagnostic headers (cf-ray, x-openrouter-provider, etc.)
|
||||
# so they survive even when the stream dies before any chunk
|
||||
# arrives. Best-effort; never raises.
|
||||
|
|
|
|||
|
|
@ -301,6 +301,19 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
|||
except Exception as exc:
|
||||
logger.warning("on_session_start hook failed: %s", exc)
|
||||
|
||||
# Cold-start credits seed (L3) — fallback for the first-turn path. The TUI/
|
||||
# desktop build seeds at session OPEN (see seed_credits_at_session_start in
|
||||
# tui_gateway), so this call is usually a no-op there (idempotent: skips when
|
||||
# _credits_state already exists). For the plain CLI / any path that didn't seed
|
||||
# at build, it primes credits state from /api/oauth/account (or a fixture) on the
|
||||
# first turn so depletion / usage-band warnings fire. Fail-open inside the helper.
|
||||
try:
|
||||
from agent.credits_tracker import seed_credits_at_session_start
|
||||
|
||||
seed_credits_at_session_start(agent)
|
||||
except Exception:
|
||||
logger.debug("cold-start credits seed failed (fail-open)", exc_info=True)
|
||||
|
||||
# Persist the system prompt snapshot in SQLite. Failure here used
|
||||
# to log at DEBUG, which silently broke prefix-cache reuse on the
|
||||
# gateway path (fresh AIAgent per turn → reads from this row every
|
||||
|
|
|
|||
723
agent/credits_tracker.py
Normal file
723
agent/credits_tracker.py
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
"""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
|
||||
103
cli.py
103
cli.py
|
|
@ -4234,6 +4234,52 @@ class HermesCLI:
|
|||
self._tool_start_time = 0.0 # clear tool timer when switching to thinking
|
||||
self._invalidate()
|
||||
|
||||
def _on_notice(self, notice) -> None:
|
||||
"""Queue an out-of-band AgentNotice for rendering at the next clean boundary.
|
||||
|
||||
Notices fire from inside the agent turn (cold-start seed during _init_agent,
|
||||
per-turn _capture_credits after the API call) — printing immediately races the
|
||||
streaming response and the line gets buried behind the prompt (see _cprint's
|
||||
bg-thread caveat). So we QUEUE here and flush in _flush_credit_notices(), called
|
||||
right after run_conversation returns. Fail-soft: never break the turn.
|
||||
"""
|
||||
try:
|
||||
text = getattr(notice, "text", "") or ""
|
||||
if not text:
|
||||
return
|
||||
level = getattr(notice, "level", "info") or "info"
|
||||
if not hasattr(self, "_pending_credit_notices"):
|
||||
self._pending_credit_notices = []
|
||||
self._pending_credit_notices.append((level, text))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _flush_credit_notices(self) -> None:
|
||||
"""Print any queued credit notices as level-colored lines. Called at turn end
|
||||
(after run_conversation) where _cprint paints cleanly above the prompt."""
|
||||
try:
|
||||
pending = getattr(self, "_pending_credit_notices", None)
|
||||
if not pending:
|
||||
return
|
||||
self._pending_credit_notices = []
|
||||
for level, text in pending:
|
||||
color = {
|
||||
"error": "\033[31m",
|
||||
"warn": "\033[33m",
|
||||
"success": "\033[32m",
|
||||
"info": _DIM,
|
||||
}.get(level, _DIM)
|
||||
_cprint(f" {color}{text}{_RST}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_notice_clear(self, key: str) -> None:
|
||||
"""Notice cleared. The REPL prints lines (no persistent slot to wipe), so
|
||||
this drops any still-queued notice with that key is not tracked by key here;
|
||||
it's a no-op for rendering — kept so the agent's clear callback is bound
|
||||
symmetrically with the show callback (and so future REPL UIs can hook it)."""
|
||||
return
|
||||
|
||||
# ── Streaming display ────────────────────────────────────────────────
|
||||
|
||||
def _current_reasoning_callback(self):
|
||||
|
|
@ -5218,6 +5264,8 @@ class HermesCLI:
|
|||
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
|
||||
stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
|
||||
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
|
||||
notice_callback=self._on_notice,
|
||||
notice_clear_callback=self._on_notice_clear,
|
||||
)
|
||||
# Store reference for atexit memory provider shutdown
|
||||
global _active_agent_ref
|
||||
|
|
@ -5225,6 +5273,16 @@ class HermesCLI:
|
|||
# Route agent status output through prompt_toolkit so ANSI escape
|
||||
# sequences aren't garbled by patch_stdout's StdoutProxy (#2262).
|
||||
self.agent._print_fn = _cprint
|
||||
# Hydrate credits notices at session OPEN (parity with the TUI), so a
|
||||
# depletion / usage-band warning shows before the first message. The
|
||||
# notice_callback is bound above → _on_notice renders the line. Idempotent
|
||||
# + fail-open inside the helper; harmless for non-Nous providers.
|
||||
try:
|
||||
from agent.credits_tracker import seed_credits_at_session_start
|
||||
|
||||
seed_credits_at_session_start(self.agent)
|
||||
except Exception:
|
||||
pass
|
||||
self._active_agent_route_signature = (
|
||||
effective_model,
|
||||
runtime.get("provider"),
|
||||
|
|
@ -10535,16 +10593,24 @@ class HermesCLI:
|
|||
return True
|
||||
|
||||
def _show_usage(self):
|
||||
"""Show rate limits (if available) and session token usage."""
|
||||
"""Rate limits + session token usage (when a live agent exists) + Nous credits.
|
||||
|
||||
The Nous credits block is agent-independent (a portal fetch), so it runs even
|
||||
with no live agent — important for the TUI, where /usage runs in a slash-worker
|
||||
subprocess that resumes the session WITHOUT building an agent (self.agent is None),
|
||||
which would otherwise early-return before any credits showed.
|
||||
"""
|
||||
if not self.agent:
|
||||
print("(._.) No active agent -- send a message first.")
|
||||
if not self._print_nous_credits_block():
|
||||
print("(._.) No active agent -- send a message first.")
|
||||
return
|
||||
|
||||
agent = self.agent
|
||||
calls = agent.session_api_calls
|
||||
|
||||
if calls == 0:
|
||||
print("(._.) No API calls made yet in this session.")
|
||||
if not self._print_nous_credits_block():
|
||||
print("(._.) No API calls made yet in this session.")
|
||||
return
|
||||
|
||||
# ── Rate limits (shown first when available) ────────────────
|
||||
|
|
@ -10638,6 +10704,10 @@ class HermesCLI:
|
|||
for line in account_lines:
|
||||
print(line)
|
||||
|
||||
# Nous credits magnitudes + monthly-grant gauge (agent-independent — also
|
||||
# runs at the no-agent / no-calls early-returns above). See the helper.
|
||||
self._print_nous_credits_block()
|
||||
|
||||
if self.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
for noisy in ('openai', 'openai._base_client', 'httpx', 'httpcore', 'asyncio', 'hpack', 'grpc', 'modal'):
|
||||
|
|
@ -10652,6 +10722,28 @@ class HermesCLI:
|
|||
# Console quietness is enforced by hermes_logging not
|
||||
# installing a console StreamHandler in non-verbose mode.
|
||||
|
||||
def _print_nous_credits_block(self) -> bool:
|
||||
"""Print the Nous credits magnitudes + monthly-grant gauge when a Nous account
|
||||
is logged in. Returns True if it printed anything.
|
||||
|
||||
Delegates to the shared ``agent.account_usage.nous_credits_lines`` helper —
|
||||
the single source for the /usage credits block across CLI, gateway, and TUI.
|
||||
It's agent-independent (a portal fetch gated on "a Nous account is logged in",
|
||||
NOT the inference-provider string), so /usage shows the block even in the TUI
|
||||
slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and
|
||||
wall-clock-bounded inside the helper; also honors HERMES_DEV_CREDITS_FIXTURE
|
||||
for offline testing — same behavior as every other surface.
|
||||
"""
|
||||
from agent.account_usage import nous_credits_lines
|
||||
|
||||
lines = nous_credits_lines()
|
||||
if not lines:
|
||||
return False
|
||||
print()
|
||||
for line in lines:
|
||||
print(f" {line}")
|
||||
return True
|
||||
|
||||
def _show_insights(self, command: str = "/insights"):
|
||||
"""Show usage insights and analytics from session history."""
|
||||
# Parse optional --days flag
|
||||
|
|
@ -12406,6 +12498,11 @@ class HermesCLI:
|
|||
"error": _summary,
|
||||
}
|
||||
finally:
|
||||
# Surface any credit notices queued during the turn (cold-start
|
||||
# seed / per-turn capture) now that the response is done — printing
|
||||
# at this boundary paints cleanly above the prompt instead of being
|
||||
# buried behind the streaming output.
|
||||
self._flush_credit_notices()
|
||||
# Clear thread-local callbacks so a reused thread doesn't
|
||||
# hold stale references to a disposed CLI instance.
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -319,6 +319,21 @@ def _prepare_gateway_status_message(platform: Any, event_type: str, message: str
|
|||
return text
|
||||
|
||||
|
||||
def render_notice_line(notice) -> str:
|
||||
"""Render an AgentNotice to a single plaintext line for messaging platforms.
|
||||
|
||||
Messaging has no persistent status bar (unlike the TUI), so a notice is a
|
||||
one-shot standalone push. The notice policy already bakes the level glyph
|
||||
(⚠ / • / ✕ / ✓) into the text, and the TUI + CLI REPL render that text
|
||||
verbatim — so we emit it as-is here too. Prepending a per-level glyph would
|
||||
DOUBLE it ("⚠ ⚠ Credits 90% used", "⛔ ✕ Credit access paused"). Plaintext
|
||||
only — no markdown — so it renders uniformly across Telegram/Discord/Slack/
|
||||
SMS without per-platform escaping. Fail-soft: a malformed/empty notice
|
||||
degrades to "" rather than raising on the agent's callback path.
|
||||
"""
|
||||
return str(getattr(notice, "text", "") or "").strip()
|
||||
|
||||
|
||||
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata):
|
||||
"""Route a status message through adapter.send_or_update_status when supported.
|
||||
|
||||
|
|
@ -14098,6 +14113,7 @@ class GatewayRunner:
|
|||
# Fetch account usage off the event loop so slow provider APIs don't
|
||||
# block the gateway. Failures are non-fatal -- account_lines stays [].
|
||||
account_lines: list[str] = []
|
||||
credits_lines: list[str] = []
|
||||
if provider:
|
||||
try:
|
||||
account_snapshot = await asyncio.to_thread(
|
||||
|
|
@ -14111,6 +14127,21 @@ class GatewayRunner:
|
|||
if account_snapshot:
|
||||
account_lines = render_account_usage_lines(account_snapshot, markdown=True)
|
||||
|
||||
# ── Nous credits magnitudes + monthly-grant % gauge ─────────────
|
||||
# Shared with the CLI / TUI /usage block via nous_credits_lines(): a single
|
||||
# auth-gate + portal-fetch + render path (which also honors the dev fixture).
|
||||
# Run off the event loop. The helper gates on "a Nous account is logged in"
|
||||
# — NOT the inference provider and NOT nested under `if provider:` — so a
|
||||
# Nous-credentialled user running inference elsewhere (or with none resident)
|
||||
# still sees their balance. NO recovery trigger: messaging binds no notice
|
||||
# consumer, so /usage only displays. Fail-open: never break /usage.
|
||||
try:
|
||||
from agent.account_usage import nous_credits_lines
|
||||
|
||||
credits_lines = await asyncio.to_thread(nous_credits_lines, markdown=True)
|
||||
except Exception:
|
||||
credits_lines = [] # fail-open: never break /usage
|
||||
|
||||
if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0:
|
||||
lines = []
|
||||
|
||||
|
|
@ -14171,6 +14202,9 @@ class GatewayRunner:
|
|||
if account_lines:
|
||||
lines.append("")
|
||||
lines.extend(account_lines)
|
||||
if credits_lines:
|
||||
lines.append("")
|
||||
lines.extend(credits_lines)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
|
@ -14190,9 +14224,18 @@ class GatewayRunner:
|
|||
if account_lines:
|
||||
lines.append("")
|
||||
lines.extend(account_lines)
|
||||
if credits_lines:
|
||||
lines.append("")
|
||||
lines.extend(credits_lines)
|
||||
return "\n".join(lines)
|
||||
if account_lines:
|
||||
return "\n".join(account_lines)
|
||||
if account_lines or credits_lines:
|
||||
# account-only, credits-only, or both — joined with a blank divider.
|
||||
parts = list(account_lines)
|
||||
if credits_lines:
|
||||
if parts:
|
||||
parts.append("")
|
||||
parts.extend(credits_lines)
|
||||
return "\n".join(parts)
|
||||
return t("gateway.usage.no_data")
|
||||
|
||||
async def _handle_insights_command(self, event: MessageEvent) -> str:
|
||||
|
|
@ -17930,6 +17973,38 @@ class GatewayRunner:
|
|||
agent.stream_delta_callback = _stream_delta_cb
|
||||
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
|
||||
agent.status_callback = _status_callback_sync
|
||||
|
||||
# Credits / out-of-band notices (usage bands, depletion, restored).
|
||||
# Messaging has no persistent status bar, so each notice is a
|
||||
# standalone push: render to a single plaintext line and deliver via
|
||||
# the shared _deliver_platform_notice rail (honors private/public +
|
||||
# thread metadata). Fires from the agent's sync worker thread, so we
|
||||
# hop onto the gateway loop with safe_schedule_threadsafe — same
|
||||
# pattern as _status_callback_sync. The fired-once latch lives on the
|
||||
# cached agent and persists across turns, so a band crosses → one
|
||||
# push (no per-turn re-nag). Recovery ("✓ Credit access restored")
|
||||
# rides the same show path (it's emitted as a success notice, not a
|
||||
# clear). The clear callback is a no-op: a sent platform message
|
||||
# can't be cleanly retracted, and the band already fired once.
|
||||
def _notice_callback_sync(notice) -> None:
|
||||
if not _status_adapter or not _run_still_current():
|
||||
return
|
||||
try:
|
||||
line = render_notice_line(notice)
|
||||
except Exception:
|
||||
logger.debug("render_notice_line failed", exc_info=True)
|
||||
return
|
||||
if not line:
|
||||
return
|
||||
safe_schedule_threadsafe(
|
||||
self._deliver_platform_notice(source, line),
|
||||
_loop_for_step,
|
||||
logger=logger,
|
||||
log_message="notice_callback delivery scheduling error",
|
||||
)
|
||||
|
||||
agent.notice_callback = _notice_callback_sync
|
||||
agent.notice_clear_callback = None
|
||||
agent.reasoning_config = reasoning_config
|
||||
agent.service_tier = self._service_tier
|
||||
agent.request_overrides = turn_route.get("request_overrides") or {}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class NousPortalSubscriptionInfo:
|
|||
plan: Optional[str] = None
|
||||
tier: Optional[int] = None
|
||||
monthly_charge: Optional[float] = None
|
||||
monthly_credits: Optional[float] = None
|
||||
current_period_end: Optional[str] = None
|
||||
credits_remaining: Optional[float] = None
|
||||
rollover_credits: Optional[float] = None
|
||||
|
|
@ -662,6 +663,7 @@ def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInf
|
|||
plan=_coerce_str(value.get("plan")),
|
||||
tier=_coerce_int(value.get("tier")),
|
||||
monthly_charge=_coerce_float(value.get("monthly_charge")),
|
||||
monthly_credits=_coerce_float(value.get("monthly_credits")),
|
||||
current_period_end=_coerce_str(value.get("current_period_end")),
|
||||
credits_remaining=_coerce_float(value.get("credits_remaining")),
|
||||
rollover_credits=_coerce_float(value.get("rollover_credits")),
|
||||
|
|
|
|||
146
run_agent.py
146
run_agent.py
|
|
@ -195,7 +195,7 @@ from agent.tool_dispatch_helpers import (
|
|||
_extract_error_preview,
|
||||
_trajectory_normalize_msg, # noqa: F401 # re-exported for tests that `from run_agent import _trajectory_normalize_msg`
|
||||
)
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname, is_truthy_value
|
||||
|
||||
|
||||
|
||||
|
|
@ -379,6 +379,8 @@ class AIAgent:
|
|||
interim_assistant_callback: callable = None,
|
||||
tool_gen_callback: callable = None,
|
||||
status_callback: callable = None,
|
||||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
|
|
@ -449,6 +451,8 @@ class AIAgent:
|
|||
interim_assistant_callback=interim_assistant_callback,
|
||||
tool_gen_callback=tool_gen_callback,
|
||||
status_callback=status_callback,
|
||||
notice_callback=notice_callback,
|
||||
notice_clear_callback=notice_clear_callback,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=reasoning_config,
|
||||
service_tier=service_tier,
|
||||
|
|
@ -795,6 +799,27 @@ class AIAgent:
|
|||
except Exception:
|
||||
logger.debug("status_callback error in _emit_warning", exc_info=True)
|
||||
|
||||
def _emit_notice(self, notice) -> None:
|
||||
"""Fire a structured ``AgentNotice`` to the active driver (TUI / CLI).
|
||||
|
||||
Driver-agnostic: the bound ``notice_callback`` renders it however that
|
||||
driver does (TUI status-bar override, CLI console line). Swallows all
|
||||
callback errors — a notice must NEVER break the agent loop (D-D fail-open).
|
||||
"""
|
||||
if self.notice_callback:
|
||||
try:
|
||||
self.notice_callback(notice)
|
||||
except Exception:
|
||||
logger.debug("notice_callback error in _emit_notice", exc_info=True)
|
||||
|
||||
def _emit_notice_clear(self, key: str) -> None:
|
||||
"""Clear a previously-fired sticky notice by ``key`` (e.g. on recovery)."""
|
||||
if self.notice_clear_callback:
|
||||
try:
|
||||
self.notice_clear_callback(key)
|
||||
except Exception:
|
||||
logger.debug("notice_clear_callback error in _emit_notice_clear", exc_info=True)
|
||||
|
||||
# ── Buffered retry/fallback status ────────────────────────────────────
|
||||
# Retry and fallback chains were flooding the CLI/gateway with status
|
||||
# noise that users found confusing: a single transient 429 could produce
|
||||
|
|
@ -2693,6 +2718,125 @@ class AIAgent:
|
|||
"""Return the last captured RateLimitState, or None."""
|
||||
return self._rate_limit_state
|
||||
|
||||
def _capture_credits(self, http_response: Any) -> None:
|
||||
"""Parse x-nous-credits-* headers, cache CreditsState, fire threshold notices.
|
||||
|
||||
Fail-open throughout — header issues never break the agent loop. The PARSE is
|
||||
swallowed (any error → treated as a miss → keep last-known). The notice
|
||||
EVALUATION/EMIT is a SEPARATE block that WARNS on failure (R1-M2): a bug in the
|
||||
depletion-notice path must not vanish silently under the parse swallow.
|
||||
"""
|
||||
# Dev test fixture (HERMES_DEV_CREDITS_FIXTURE): inject a chosen notice state
|
||||
# each turn for repeatable testing, bypassing real headers. Throwaway scaffolding.
|
||||
try:
|
||||
from agent.credits_tracker import dev_fixture_credits_state
|
||||
_fixture = dev_fixture_credits_state()
|
||||
except Exception:
|
||||
_fixture = None
|
||||
if _fixture is not None:
|
||||
self._credits_state = _fixture
|
||||
if self._credits_session_start_micros is None:
|
||||
self._credits_session_start_micros = _fixture.remaining_micros
|
||||
_latch = getattr(self, "_credits_latch", None)
|
||||
if isinstance(_latch, dict):
|
||||
_latch["seen_below_90"] = True # let warn90 fire without a real crossing
|
||||
_used = _fixture.used_fraction
|
||||
logger.info(
|
||||
"credits ▸ [FIXTURE] remaining=%d (%s) · paid=%s · denom=%s · used=%s "
|
||||
"(real headers bypassed — `echo clear` / unset HERMES_DEV_CREDITS_FIXTURE to restore)",
|
||||
_fixture.remaining_micros,
|
||||
_fixture.remaining_usd or "?",
|
||||
_fixture.paid_access,
|
||||
_fixture.denominator_kind,
|
||||
("%.0f%%" % (_used * 100)) if _used is not None else "n/a",
|
||||
)
|
||||
self._emit_credits_notices()
|
||||
return
|
||||
if http_response is None:
|
||||
return
|
||||
headers = getattr(http_response, "headers", None)
|
||||
if not headers:
|
||||
return
|
||||
_dev = is_truthy_value(os.environ.get("HERMES_DEV_CREDITS"))
|
||||
|
||||
# ── Parse (fail-open → miss; never overwrite good state with None) ──
|
||||
try:
|
||||
from agent.credits_tracker import parse_credits_headers
|
||||
state = parse_credits_headers(headers, provider=self.provider)
|
||||
except Exception:
|
||||
return # parse error → treat as a miss, keep last-known
|
||||
if state is None:
|
||||
if _dev:
|
||||
logger.info(
|
||||
"credits ▸ response had no valid x-nous-credits-* headers "
|
||||
"(miss — producer off / non-Nous path / >TTL stale)"
|
||||
)
|
||||
return
|
||||
|
||||
# retain-last-known: only overwrite on a fresh valid parse
|
||||
self._credits_state = state
|
||||
# Latch session-start remaining the first time we ever see a header
|
||||
if self._credits_session_start_micros is None:
|
||||
self._credits_session_start_micros = state.remaining_micros
|
||||
if _dev:
|
||||
# HERMES_DEV_CREDITS: stream each capture to agent.log — watch live with
|
||||
# `hermes logs -f` (grep 'credits ▸'). Dev-only; silent for normal users.
|
||||
spent = self.get_credits_spent_micros()
|
||||
used = state.used_fraction
|
||||
logger.info(
|
||||
"credits ▸ remaining=%d (%s) · paid=%s · denom=%s · used=%s "
|
||||
"· Δspent=%s · age=%s%s",
|
||||
state.remaining_micros,
|
||||
state.remaining_usd or "?",
|
||||
state.paid_access,
|
||||
state.denominator_kind,
|
||||
("%.0f%%" % (used * 100)) if used is not None else "n/a",
|
||||
("%.1f¢" % (spent / 10000)) if spent is not None else "n/a",
|
||||
("%.0fs" % state.age_seconds) if state.age_seconds != float("inf") else "n/a",
|
||||
(" · disabled=%s" % state.disabled_reason) if state.disabled_reason else "",
|
||||
)
|
||||
|
||||
# Threshold notices — shared with the cold-start seed (see _emit_credits_notices).
|
||||
self._emit_credits_notices()
|
||||
|
||||
def _emit_credits_notices(self) -> None:
|
||||
"""Run the threshold policy on the current credits state and emit notices.
|
||||
|
||||
Shared by the warm path (_capture_credits) and the L3 cold-start seed, so a
|
||||
session that opens already depleted warns immediately — not only after the first
|
||||
inference header. Runs only when a notice consumer is bound (messaging binds none
|
||||
→ state still cached for /usage, no policy). WARNS on failure rather than
|
||||
swallowing (R1-M2): a depletion-path bug must not vanish silently. Emits clears
|
||||
FIRST, then shows (so depleted lands last in a latest-wins slot).
|
||||
"""
|
||||
if getattr(self, "notice_callback", None) is None and getattr(self, "notice_clear_callback", None) is None:
|
||||
return
|
||||
state = getattr(self, "_credits_state", None)
|
||||
if state is None:
|
||||
return
|
||||
try:
|
||||
from agent.credits_tracker import evaluate_credits_notices
|
||||
latch = getattr(self, "_credits_latch", None)
|
||||
if latch is None:
|
||||
latch = self._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None}
|
||||
to_show, to_clear = evaluate_credits_notices(state, latch)
|
||||
for key in to_clear: # clears FIRST …
|
||||
self._emit_notice_clear(key)
|
||||
for notice in to_show: # … then shows (depleted lands last in a latest-wins slot)
|
||||
self._emit_notice(notice)
|
||||
except Exception:
|
||||
logger.warning("credits notice evaluation/emit failed", exc_info=True)
|
||||
|
||||
def get_credits_state(self):
|
||||
"""Return the last captured CreditsState, or None."""
|
||||
return self._credits_state
|
||||
|
||||
def get_credits_spent_micros(self):
|
||||
"""Session-cumulative micros spent = first_seen_remaining - current_remaining. None if no data."""
|
||||
if self._credits_session_start_micros is None or self._credits_state is None:
|
||||
return None
|
||||
return self._credits_session_start_micros - self._credits_state.remaining_micros
|
||||
|
||||
def _check_openrouter_cache_status(self, http_response: Any) -> None:
|
||||
"""Read X-OpenRouter-Cache-Status from response headers and log it.
|
||||
|
||||
|
|
|
|||
192
tests/agent/test_credits_cold_start.py
Normal file
192
tests/agent/test_credits_cold_start.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""Tests for cold-start credits hydration at session open.
|
||||
|
||||
The L3 cold-start seed primes agent._credits_state from /api/oauth/account (or a
|
||||
HERMES_DEV_CREDITS_FIXTURE) so depletion AND the 90% grant warning fire immediately
|
||||
at session open, not only after the first inference header. These tests assert the
|
||||
notice policy fires correctly for a seed-shaped CreditsState with the warn90 latch
|
||||
primed the way conversation_loop does it.
|
||||
"""
|
||||
import time
|
||||
|
||||
from agent.credits_tracker import CreditsState, evaluate_credits_notices
|
||||
|
||||
|
||||
def _cold_start_notices(state: CreditsState):
|
||||
"""Mirror the conversation_loop seed: prime seen_below_90 when used_fraction is
|
||||
computable (the snapshot IS the first observation), then evaluate once."""
|
||||
latch = {"active": set(), "seen_below_90": False}
|
||||
if state.used_fraction is not None:
|
||||
latch["seen_below_90"] = True
|
||||
show, clear = evaluate_credits_notices(state, latch)
|
||||
return [n.key for n in show]
|
||||
|
||||
|
||||
def _state(**kw) -> CreditsState:
|
||||
kw.setdefault("from_header", False)
|
||||
kw.setdefault("captured_at", time.time())
|
||||
return CreditsState(**kw)
|
||||
|
||||
|
||||
def test_cold_start_healthy_no_notice():
|
||||
s = _state(
|
||||
remaining_micros=30_340_000, subscription_micros=18_000_000,
|
||||
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
|
||||
denominator_kind="subscription_cap", paid_access=True,
|
||||
)
|
||||
assert abs(s.used_fraction - 0.1) < 1e-9
|
||||
assert _cold_start_notices(s) == []
|
||||
|
||||
|
||||
def test_cold_start_opens_already_at_90pct_warns():
|
||||
"""A session that OPENS already ≥90% must warn immediately — the seed primes
|
||||
seen_below_90 so warn90 fires without a prior live crossing."""
|
||||
s = _state(
|
||||
remaining_micros=2_000_000, subscription_micros=2_000_000,
|
||||
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
|
||||
denominator_kind="subscription_cap", paid_access=True,
|
||||
)
|
||||
assert s.used_fraction == 0.9
|
||||
assert "credits.usage" in _cold_start_notices(s)
|
||||
|
||||
|
||||
def test_cold_start_grant_exhausted_warns_and_grant_spent():
|
||||
s = _state(
|
||||
remaining_micros=12_340_000, subscription_micros=0,
|
||||
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
|
||||
purchased_micros=12_340_000, denominator_kind="subscription_cap", paid_access=True,
|
||||
)
|
||||
assert s.used_fraction == 1.0
|
||||
keys = _cold_start_notices(s)
|
||||
assert "credits.usage" in keys
|
||||
assert "credits.grant_spent" in keys
|
||||
|
||||
|
||||
def test_cold_start_depleted_warns():
|
||||
s = _state(
|
||||
remaining_micros=0, subscription_micros=0, purchased_micros=0,
|
||||
paid_access=False, disabled_reason="out_of_credits",
|
||||
)
|
||||
assert s.used_fraction is None # no cap → no %, depletion keys off paid_access
|
||||
assert _cold_start_notices(s) == ["credits.depleted"]
|
||||
|
||||
|
||||
def test_cold_start_debt_warns_and_depleted():
|
||||
"""Negative subscription balance (the only signed field) → 100% used + depleted."""
|
||||
s = _state(
|
||||
remaining_micros=0, subscription_micros=-5_000_000,
|
||||
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
|
||||
denominator_kind="subscription_cap", paid_access=False,
|
||||
disabled_reason="out_of_credits",
|
||||
)
|
||||
assert s.used_fraction == 1.0
|
||||
keys = _cold_start_notices(s)
|
||||
assert "credits.usage" in keys
|
||||
assert "credits.depleted" in keys
|
||||
|
||||
|
||||
def test_cold_start_no_cap_degrades_to_depletion_only():
|
||||
"""Without monthly_credits (older portals) the seed sets no limit → used_fraction
|
||||
None → only depletion can fire, never warn90."""
|
||||
healthy_no_cap = _state(
|
||||
remaining_micros=30_000_000, subscription_micros=18_000_000,
|
||||
subscription_limit_micros=None, denominator_kind="none", paid_access=True,
|
||||
)
|
||||
assert healthy_no_cap.used_fraction is None
|
||||
assert _cold_start_notices(healthy_no_cap) == []
|
||||
|
||||
|
||||
def test_dev_fixtures_drive_cold_start():
|
||||
"""Every HERMES_DEV_CREDITS_FIXTURE state produces a valid seed CreditsState."""
|
||||
import os
|
||||
|
||||
from agent.credits_tracker import dev_fixture_credits_state
|
||||
|
||||
expected = {
|
||||
"healthy": [],
|
||||
"sub_90pct": ["credits.usage"],
|
||||
"depleted": ["credits.depleted"],
|
||||
}
|
||||
for name, want in expected.items():
|
||||
os.environ["HERMES_DEV_CREDITS"] = "1" # fixtures gate on the dev flag
|
||||
os.environ["HERMES_DEV_CREDITS_FIXTURE"] = name
|
||||
try:
|
||||
fx = dev_fixture_credits_state()
|
||||
assert fx is not None, name
|
||||
assert _cold_start_notices(fx) == want, (name, _cold_start_notices(fx))
|
||||
finally:
|
||||
os.environ.pop("HERMES_DEV_CREDITS_FIXTURE", None)
|
||||
os.environ.pop("HERMES_DEV_CREDITS", None)
|
||||
|
||||
|
||||
# ── seed_credits_at_session_start: the shared session-open hydrator ───────────
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
"""Minimal agent surface for the seed helper: state slots + an emit that runs
|
||||
the real policy against the latch."""
|
||||
|
||||
def __init__(self, provider="nous"):
|
||||
from agent.credits_tracker import evaluate_credits_notices
|
||||
|
||||
self.provider = provider
|
||||
self._credits_state = None
|
||||
self._credits_session_start_micros = None
|
||||
self._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None}
|
||||
self.emitted: list = []
|
||||
self._eval = evaluate_credits_notices
|
||||
|
||||
def _emit_credits_notices(self):
|
||||
if self._credits_state is None:
|
||||
return
|
||||
show, clear = self._eval(self._credits_state, self._credits_latch)
|
||||
self.emitted.append(([n.key for n in show], clear))
|
||||
|
||||
|
||||
def _seed(agent, fixture):
|
||||
import os
|
||||
|
||||
from agent.credits_tracker import seed_credits_at_session_start
|
||||
|
||||
os.environ["HERMES_DEV_CREDITS"] = "1" # fixtures gate on the dev flag
|
||||
os.environ["HERMES_DEV_CREDITS_FIXTURE"] = fixture
|
||||
try:
|
||||
return seed_credits_at_session_start(agent)
|
||||
finally:
|
||||
os.environ.pop("HERMES_DEV_CREDITS_FIXTURE", None)
|
||||
os.environ.pop("HERMES_DEV_CREDITS", None)
|
||||
|
||||
|
||||
def test_seed_fires_usage_band_at_session_open():
|
||||
a = _FakeAgent()
|
||||
assert _seed(a, "sub_90pct") is True
|
||||
assert a._credits_state is not None
|
||||
assert a.emitted == [(["credits.usage"], [])]
|
||||
|
||||
|
||||
def test_seed_fires_depleted_at_session_open():
|
||||
a = _FakeAgent()
|
||||
assert _seed(a, "depleted") is True
|
||||
assert a.emitted == [(["credits.depleted"], [])]
|
||||
|
||||
|
||||
def test_seed_healthy_no_notice():
|
||||
a = _FakeAgent()
|
||||
assert _seed(a, "healthy") is True
|
||||
assert a.emitted == [([], [])]
|
||||
|
||||
|
||||
def test_seed_is_idempotent():
|
||||
a = _FakeAgent()
|
||||
_seed(a, "sub_90pct")
|
||||
a.emitted = []
|
||||
# second call must no-op (state already populated)
|
||||
assert _seed(a, "sub_90pct") is False
|
||||
assert a.emitted == []
|
||||
|
||||
|
||||
def test_seed_skips_non_nous():
|
||||
from agent.credits_tracker import seed_credits_at_session_start
|
||||
|
||||
a = _FakeAgent(provider="openrouter")
|
||||
assert seed_credits_at_session_start(a) is False
|
||||
assert a._credits_state is None
|
||||
67
tests/agent/test_credits_fixture_snapshot.py
Normal file
67
tests/agent/test_credits_fixture_snapshot.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Tests for _snapshot_from_credits_state — the dev-fixture /usage renderer.
|
||||
|
||||
``build_nous_credits_snapshot`` maps a live portal account; ``_snapshot_from_credits_state``
|
||||
maps a header-shaped CreditsState (e.g. a HERMES_DEV_CREDITS_FIXTURE) into the SAME
|
||||
/usage snapshot shape, so the gauge + magnitudes are exercisable offline. These lock
|
||||
the gauge math, the verbatim *_usd magnitudes (never parseFloat'd), the depletion line,
|
||||
and the dev-fixture marker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.account_usage import _snapshot_from_credits_state
|
||||
from agent.credits_tracker import CreditsState
|
||||
|
||||
|
||||
def _state(**kw) -> CreditsState:
|
||||
kw.setdefault("from_header", True)
|
||||
return CreditsState(**kw)
|
||||
|
||||
|
||||
def test_renders_gauge_magnitudes_and_fixture_marker():
|
||||
# used_fraction = (20 - 10) / 20 = 0.5 → a 50%-used gauge window
|
||||
snap = _snapshot_from_credits_state(_state(
|
||||
remaining_micros=30_340_000, remaining_usd="30.34",
|
||||
subscription_micros=10_000_000, subscription_usd="10.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,
|
||||
))
|
||||
assert snap is not None and snap.provider == "nous"
|
||||
|
||||
win = next(w for w in snap.windows if w.label == "Subscription")
|
||||
assert win.used_percent is not None and abs(win.used_percent - 50.0) < 1e-9
|
||||
assert win.detail == "$10.00 of $20.00 left" # verbatim *_usd strings, not math
|
||||
|
||||
details = list(snap.details)
|
||||
assert "Subscription credits: $10.00" in details
|
||||
assert "Top-up credits: $12.34" in details
|
||||
assert "Total usable: $30.34" in details
|
||||
assert any("dev fixture" in d for d in details) # the offline marker
|
||||
assert all("access depleted" not in d for d in details)
|
||||
|
||||
|
||||
def test_depleted_adds_status_line():
|
||||
snap = _snapshot_from_credits_state(_state(
|
||||
remaining_micros=0, remaining_usd="0.00",
|
||||
subscription_micros=0, subscription_usd="0.00",
|
||||
purchased_micros=0, purchased_usd="0.00",
|
||||
denominator_kind="none", paid_access=False,
|
||||
))
|
||||
assert snap is not None
|
||||
assert any("access depleted" in d for d in snap.details)
|
||||
|
||||
|
||||
def test_no_cap_yields_no_gauge_window():
|
||||
# No subscription cap → used_fraction is None → no gauge window, magnitudes only.
|
||||
snap = _snapshot_from_credits_state(_state(
|
||||
remaining_micros=5_000_000, remaining_usd="5.00",
|
||||
subscription_micros=5_000_000, subscription_usd="5.00",
|
||||
subscription_limit_micros=None, denominator_kind="none", paid_access=True,
|
||||
))
|
||||
assert snap is not None
|
||||
assert all(w.label != "Subscription" for w in snap.windows)
|
||||
assert "Total usable: $5.00" in snap.details
|
||||
|
||||
|
||||
def test_none_state_is_safe():
|
||||
assert _snapshot_from_credits_state(None) is None
|
||||
492
tests/agent/test_credits_policy.py
Normal file
492
tests/agent/test_credits_policy.py
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
"""Tests for evaluate_credits_notices — pure threshold reconciliation policy (L4.1).
|
||||
|
||||
All tests use fresh latch = {"active": set(), "seen_below_90": False, "usage_band": None} per scenario.
|
||||
CreditsState is constructed directly (not parsed from headers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.credits_tracker import (
|
||||
CREDITS_NOTICE_KIND,
|
||||
CREDITS_RESTORED_TTL_MS,
|
||||
AgentNotice,
|
||||
CreditsState,
|
||||
evaluate_credits_notices,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fresh_latch() -> dict:
|
||||
return {"active": set(), "seen_below_90": False, "usage_band": None}
|
||||
|
||||
|
||||
def state_with_fraction(
|
||||
uf: float | None,
|
||||
*,
|
||||
paid_access: bool = True,
|
||||
denominator_kind: str = "subscription_cap",
|
||||
purchased_micros: int = 0,
|
||||
purchased_usd: str = "0.00",
|
||||
subscription_limit_usd: str | None = "20.00",
|
||||
) -> CreditsState:
|
||||
"""Build a minimal CreditsState that yields the desired used_fraction.
|
||||
|
||||
used_fraction = (limit - subscription_micros) / limit
|
||||
|
||||
When uf is None, we set limit to None so used_fraction returns None.
|
||||
"""
|
||||
if uf is None:
|
||||
return CreditsState(
|
||||
subscription_limit_micros=None,
|
||||
subscription_limit_usd=None,
|
||||
subscription_micros=0,
|
||||
denominator_kind="none",
|
||||
paid_access=paid_access,
|
||||
purchased_micros=purchased_micros,
|
||||
purchased_usd=purchased_usd,
|
||||
)
|
||||
# We want (limit - sub) / limit == uf → sub = limit * (1 - uf)
|
||||
limit = 20_000_000 # $20 in micros
|
||||
sub = int(limit * (1.0 - uf))
|
||||
return CreditsState(
|
||||
subscription_limit_micros=limit,
|
||||
subscription_limit_usd=subscription_limit_usd,
|
||||
subscription_micros=sub,
|
||||
denominator_kind=denominator_kind,
|
||||
paid_access=paid_access,
|
||||
purchased_micros=purchased_micros,
|
||||
purchased_usd=purchased_usd,
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 1: crossing 90% threshold ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestWarn90Crossing:
|
||||
def test_below_lowest_band_no_notice_but_latch_set(self):
|
||||
latch = fresh_latch()
|
||||
s = state_with_fraction(0.10) # below the 50% band
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
assert all(n.key != "credits.usage" for n in to_show)
|
||||
assert "credits.usage" not in to_clear
|
||||
assert latch["seen_below_90"] is True
|
||||
|
||||
def test_crossing_to_90_fires_once(self):
|
||||
latch = fresh_latch()
|
||||
# First call: uf < 0.5 — sets seen_below_90 (below lowest band)
|
||||
s1 = state_with_fraction(0.10)
|
||||
evaluate_credits_notices(s1, latch)
|
||||
# Second call: uf >= 0.9 — should fire the usage band at 90
|
||||
s2 = state_with_fraction(0.95)
|
||||
to_show, to_clear = evaluate_credits_notices(s2, latch)
|
||||
keys = [n.key for n in to_show]
|
||||
assert "credits.usage" in keys
|
||||
assert "credits.usage" not in to_clear
|
||||
|
||||
def test_no_refire_on_repeated_over_90(self):
|
||||
latch = fresh_latch()
|
||||
s_below = state_with_fraction(0.10)
|
||||
evaluate_credits_notices(s_below, latch)
|
||||
s_over = state_with_fraction(0.95)
|
||||
evaluate_credits_notices(s_over, latch)
|
||||
# Third call: still ≥ 0.9 — must NOT re-fire
|
||||
to_show, to_clear = evaluate_credits_notices(s_over, latch)
|
||||
assert all(n.key != "credits.usage" for n in to_show)
|
||||
assert "credits.usage" not in to_clear
|
||||
|
||||
|
||||
# ── Scenario 2: recovery + re-cross ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWarn90RecoveryReCross:
|
||||
def test_recovery_clears_warn90(self):
|
||||
latch = fresh_latch()
|
||||
# Cross below → above
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
# Recovery: uf drops back below ALL bands → usage notice clears entirely
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert "credits.usage" not in latch["active"]
|
||||
|
||||
def test_recross_after_recovery_fires_again(self):
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch) # recovery
|
||||
# Re-cross: uf >= 0.9 again — should fire again because the band is clearable
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
keys = [n.key for n in to_show]
|
||||
assert "credits.usage" in keys
|
||||
|
||||
|
||||
# ── Scenario 3: open-already-over (hybrid Q3 gate) ───────────────────────────
|
||||
|
||||
|
||||
class TestOpenAlreadyOver:
|
||||
def test_warn90_does_not_fire_without_seen_below_90(self):
|
||||
"""First call uf≥0.9 with seen_below_90=False — warn90 must NOT fire."""
|
||||
latch = fresh_latch()
|
||||
assert latch["seen_below_90"] is False
|
||||
s = state_with_fraction(0.95)
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
assert all(n.key != "credits.usage" for n in to_show)
|
||||
assert "credits.usage" not in to_clear
|
||||
|
||||
|
||||
# ── Scenario 3b: boundary — exact 0.9 and just-below-1.0 ────────────────────
|
||||
|
||||
|
||||
class TestBoundaryFractions:
|
||||
def test_exact_0_9_fires_warn90(self):
|
||||
"""used_fraction == 0.9 exactly must fire warn90 (threshold is inclusive)."""
|
||||
latch = fresh_latch()
|
||||
# First: prime seen_below_90 with a sub-50% observation
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
# Now construct a state where used_fraction is EXACTLY 0.9:
|
||||
# subscription_limit_micros=20_000_000, subscription_micros=2_000_000
|
||||
# → used = 18_000_000 / 20_000_000 = 0.9 exactly
|
||||
s = CreditsState(
|
||||
subscription_limit_micros=20_000_000,
|
||||
subscription_limit_usd="20.00",
|
||||
subscription_micros=2_000_000,
|
||||
denominator_kind="subscription_cap",
|
||||
paid_access=True,
|
||||
)
|
||||
assert s.used_fraction == 0.9
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
keys = [n.key for n in to_show]
|
||||
assert "credits.usage" in keys
|
||||
assert "credits.usage" not in to_clear
|
||||
|
||||
def test_just_below_1_0_does_not_fire_grant_spent(self):
|
||||
"""subscription_micros = limit - 1 (used_fraction just under 1.0) must NOT fire grant_spent.
|
||||
|
||||
Locks the boundary so a future used_fraction clamp refactor cannot fire
|
||||
grant_spent a micro early.
|
||||
"""
|
||||
latch = fresh_latch()
|
||||
limit = 20_000_000
|
||||
s = CreditsState(
|
||||
subscription_limit_micros=limit,
|
||||
subscription_limit_usd="20.00",
|
||||
subscription_micros=1, # limit - 1 → used_fraction < 1.0
|
||||
denominator_kind="subscription_cap",
|
||||
purchased_micros=5_000_000,
|
||||
purchased_usd="5.00",
|
||||
paid_access=True,
|
||||
)
|
||||
assert s.used_fraction is not None and s.used_fraction < 1.0
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
assert all(n.key != "credits.grant_spent" for n in to_show)
|
||||
assert "credits.grant_spent" not in to_clear
|
||||
|
||||
|
||||
# ── Scenario 4: grant_spent ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGrantSpent:
|
||||
def _grant_state(self, purchased_micros: int = 12_340_000) -> CreditsState:
|
||||
return state_with_fraction(
|
||||
1.0,
|
||||
denominator_kind="subscription_cap",
|
||||
purchased_micros=purchased_micros,
|
||||
purchased_usd="12.34",
|
||||
)
|
||||
|
||||
def test_grant_spent_fires_on_first_obs(self):
|
||||
"""No crossing gate for grant_spent — fires immediately on first obs."""
|
||||
latch = fresh_latch()
|
||||
to_show, to_clear = evaluate_credits_notices(self._grant_state(), latch)
|
||||
keys = [n.key for n in to_show]
|
||||
assert "credits.grant_spent" in keys
|
||||
|
||||
def test_grant_spent_no_refire(self):
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(self._grant_state(), latch)
|
||||
to_show, to_clear = evaluate_credits_notices(self._grant_state(), latch)
|
||||
assert all(n.key != "credits.grant_spent" for n in to_show)
|
||||
assert "credits.grant_spent" not in to_clear
|
||||
|
||||
def test_grant_spent_clears_when_purchased_zero(self):
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(self._grant_state(), latch)
|
||||
# Now purchased → 0: grant_cond becomes False
|
||||
s_no_purchase = state_with_fraction(
|
||||
1.0,
|
||||
denominator_kind="subscription_cap",
|
||||
purchased_micros=0,
|
||||
purchased_usd="0.00",
|
||||
)
|
||||
to_show, to_clear = evaluate_credits_notices(s_no_purchase, latch)
|
||||
assert "credits.grant_spent" in to_clear
|
||||
assert all(n.key != "credits.grant_spent" for n in to_show)
|
||||
|
||||
|
||||
# ── Scenario 5: depleted + recovery ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDepleted:
|
||||
def test_depleted_fires_level_error_kind_sticky(self):
|
||||
latch = fresh_latch()
|
||||
s = CreditsState(paid_access=False)
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
depleted_notices = [n for n in to_show if n.key == "credits.depleted"]
|
||||
assert len(depleted_notices) == 1
|
||||
n = depleted_notices[0]
|
||||
assert n.level == "error"
|
||||
assert n.kind == CREDITS_NOTICE_KIND
|
||||
|
||||
def test_recovery_emits_clear_and_restored(self):
|
||||
latch = fresh_latch()
|
||||
# Fire depleted
|
||||
evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
||||
# Now recovered
|
||||
to_show, to_clear = evaluate_credits_notices(CreditsState(paid_access=True), latch)
|
||||
assert "credits.depleted" in to_clear
|
||||
restored = [n for n in to_show if n.key == "credits.restored"]
|
||||
assert len(restored) == 1
|
||||
r = restored[0]
|
||||
assert r.level == "success"
|
||||
assert r.kind == "ttl"
|
||||
assert r.ttl_ms == CREDITS_RESTORED_TTL_MS
|
||||
|
||||
def test_depleted_refires_after_recovery(self):
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
||||
evaluate_credits_notices(CreditsState(paid_access=True), latch)
|
||||
# Goes depleted again
|
||||
to_show, to_clear = evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
||||
keys = [n.key for n in to_show]
|
||||
assert "credits.depleted" in keys
|
||||
|
||||
|
||||
# ── Scenario 6: denominator none (uf is None) ────────────────────────────────
|
||||
|
||||
|
||||
class TestDenominatorNone:
|
||||
def test_no_warn90_when_uf_none(self):
|
||||
latch = fresh_latch()
|
||||
s = state_with_fraction(None)
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
assert all(n.key != "credits.usage" for n in to_show)
|
||||
assert "credits.usage" not in to_clear
|
||||
|
||||
def test_no_grant_spent_when_uf_none(self):
|
||||
latch = fresh_latch()
|
||||
s = CreditsState(
|
||||
subscription_limit_micros=None,
|
||||
denominator_kind="none",
|
||||
purchased_micros=5_000_000,
|
||||
purchased_usd="5.00",
|
||||
)
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
assert all(n.key != "credits.grant_spent" for n in to_show)
|
||||
|
||||
def test_warn90_clears_when_uf_becomes_none(self):
|
||||
"""If warn90 was active and uf becomes None, it should clear."""
|
||||
latch = fresh_latch()
|
||||
# Establish usage notice active: cross below → above
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
assert "credits.usage" in latch["active"]
|
||||
# Now uf becomes None (denominator changed to "none")
|
||||
s_none = state_with_fraction(None)
|
||||
to_show, to_clear = evaluate_credits_notices(s_none, latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert "credits.usage" not in latch["active"]
|
||||
|
||||
|
||||
# ── Scenario 7: copy / verbatim USD strings ──────────────────────────────────
|
||||
|
||||
|
||||
class TestNoticeCopy:
|
||||
def test_warn90_contains_verbatim_subscription_limit_usd(self):
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
s = state_with_fraction(0.95, subscription_limit_usd="20.00")
|
||||
to_show, _ = evaluate_credits_notices(s, latch)
|
||||
warn_notice = next(n for n in to_show if n.key == "credits.usage")
|
||||
assert "$20.00" in warn_notice.text
|
||||
assert "cap" in warn_notice.text
|
||||
|
||||
def test_grant_spent_contains_verbatim_purchased_usd(self):
|
||||
latch = fresh_latch()
|
||||
s = state_with_fraction(
|
||||
1.0,
|
||||
denominator_kind="subscription_cap",
|
||||
purchased_micros=12_340_000,
|
||||
purchased_usd="12.34",
|
||||
)
|
||||
to_show, _ = evaluate_credits_notices(s, latch)
|
||||
grant_notice = next(n for n in to_show if n.key == "credits.grant_spent")
|
||||
assert "$12.34" in grant_notice.text
|
||||
assert "top-up left" in grant_notice.text
|
||||
|
||||
def test_depleted_mentions_usage_command(self):
|
||||
latch = fresh_latch()
|
||||
s = CreditsState(paid_access=False)
|
||||
to_show, _ = evaluate_credits_notices(s, latch)
|
||||
depleted_notice = next(n for n in to_show if n.key == "credits.depleted")
|
||||
assert "/usage" in depleted_notice.text
|
||||
|
||||
|
||||
# ── Scenario 8: severity order in a single call ──────────────────────────────
|
||||
|
||||
|
||||
class TestSeverityOrder:
|
||||
def test_multiple_new_notices_ordered_ascending_severity(self):
|
||||
"""warn90 < grant_spent < depleted in to_show when all fire in one call."""
|
||||
# Construct a state where all three conditions fire simultaneously
|
||||
# on first call (no latch state yet):
|
||||
# - warn90: uf >= 0.9 AND seen_below_90 must be True → won't fire fresh latch
|
||||
# So we pre-seed seen_below_90=True to allow warn90 to fire.
|
||||
latch = {"active": set(), "seen_below_90": True, "usage_band": None}
|
||||
|
||||
# Build state: subscription_cap, uf >= 1.0, purchased_micros > 0, NOT paid_access
|
||||
# warn90_cond: uf >= 0.9 ✓ (uf=1.0)
|
||||
# grant_cond: subscription_cap + uf >= 1.0 + purchased > 0 ✓
|
||||
# depleted_cond: not paid_access ✓
|
||||
s = CreditsState(
|
||||
subscription_limit_micros=20_000_000,
|
||||
subscription_limit_usd="20.00",
|
||||
subscription_micros=0, # uf = 1.0
|
||||
denominator_kind="subscription_cap",
|
||||
purchased_micros=5_000_000,
|
||||
purchased_usd="5.00",
|
||||
paid_access=False,
|
||||
)
|
||||
to_show, _ = evaluate_credits_notices(s, latch)
|
||||
keys = [n.key for n in to_show]
|
||||
assert "credits.usage" in keys
|
||||
assert "credits.grant_spent" in keys
|
||||
assert "credits.depleted" in keys
|
||||
# Ascending severity: warn90 before grant_spent before depleted
|
||||
assert keys.index("credits.usage") < keys.index("credits.grant_spent")
|
||||
assert keys.index("credits.grant_spent") < keys.index("credits.depleted")
|
||||
|
||||
|
||||
# ── Invariant: never fire + clear same key in one call ────────────────────────
|
||||
|
||||
|
||||
class TestNoFireAndClearSameKey:
|
||||
def test_usage_never_both_fired_and_cleared(self):
|
||||
latch = fresh_latch()
|
||||
# Run many state transitions; across each, assert no key is in both lists
|
||||
states = [
|
||||
state_with_fraction(0.10),
|
||||
state_with_fraction(0.95),
|
||||
state_with_fraction(0.10),
|
||||
state_with_fraction(0.95),
|
||||
state_with_fraction(None),
|
||||
]
|
||||
for s in states:
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
fired_keys = {n.key for n in to_show}
|
||||
cleared_keys = set(to_clear)
|
||||
overlap = fired_keys & cleared_keys
|
||||
assert not overlap, f"Key(s) both fired and cleared: {overlap}"
|
||||
|
||||
def test_depleted_never_both_fired_and_cleared(self):
|
||||
latch = fresh_latch()
|
||||
states = [
|
||||
CreditsState(paid_access=False),
|
||||
CreditsState(paid_access=True),
|
||||
CreditsState(paid_access=False),
|
||||
]
|
||||
for s in states:
|
||||
to_show, to_clear = evaluate_credits_notices(s, latch)
|
||||
fired_keys = {n.key for n in to_show}
|
||||
cleared_keys = set(to_clear)
|
||||
overlap = fired_keys & cleared_keys
|
||||
assert not overlap, f"Key(s) both fired and cleared: {overlap}"
|
||||
|
||||
|
||||
# ── Scenario 9: escalating usage bands (50 → 75 → 90) ────────────────────────
|
||||
|
||||
|
||||
class TestUsageBands:
|
||||
"""The usage notice shows the HIGHEST crossed band as a single escalating line."""
|
||||
|
||||
def _band_text(self, to_show):
|
||||
n = next((n for n in to_show if n.key == "credits.usage"), None)
|
||||
return n.text if n else None
|
||||
|
||||
def test_50_band_fires_info(self):
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch) # prime
|
||||
to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch)
|
||||
n = next(n for n in to_show if n.key == "credits.usage")
|
||||
assert "50%" in n.text and n.level == "info"
|
||||
assert latch["usage_band"] == 50
|
||||
|
||||
def test_75_band_fires_warn(self):
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
to_show, _ = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
||||
n = next(n for n in to_show if n.key == "credits.usage")
|
||||
assert "75%" in n.text and n.level == "warn"
|
||||
assert latch["usage_band"] == 75
|
||||
|
||||
def test_climb_replaces_band(self):
|
||||
"""Climbing 50→75→90 replaces the single line (clear old + show new)."""
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
# 55% → 50 band
|
||||
evaluate_credits_notices(state_with_fraction(0.55), latch)
|
||||
assert latch["usage_band"] == 50
|
||||
# 80% → climbs to 75, clearing the 50 line
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert "75%" in self._band_text(to_show)
|
||||
assert latch["usage_band"] == 75
|
||||
# 95% → climbs to 90
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert "90%" in self._band_text(to_show)
|
||||
assert latch["usage_band"] == 90
|
||||
|
||||
def test_step_down_on_recovery(self):
|
||||
"""Recovering steps the band back down, then clears below the lowest band."""
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
assert latch["usage_band"] == 90
|
||||
# drop to 80% → steps down to 75
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert "75%" in self._band_text(to_show)
|
||||
# drop to 55% → steps down to 50
|
||||
to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch)
|
||||
assert "50%" in self._band_text(to_show)
|
||||
# drop below 50% → clears entirely
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert latch["usage_band"] is None
|
||||
|
||||
def test_no_refire_same_band(self):
|
||||
latch = fresh_latch()
|
||||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
evaluate_credits_notices(state_with_fraction(0.80), latch) # fires 75
|
||||
# still 80% → same band, no re-emit, no clear
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
||||
assert all(n.key != "credits.usage" for n in to_show)
|
||||
assert "credits.usage" not in to_clear
|
||||
|
||||
def test_exact_band_boundaries_inclusive(self):
|
||||
"""Thresholds are inclusive: exactly 0.50 / 0.75 / 0.90 land in their band."""
|
||||
for uf, want in [(0.50, 50), (0.75, 75), (0.90, 90)]:
|
||||
latch = fresh_latch()
|
||||
latch["seen_below_90"] = True # allow firing
|
||||
evaluate_credits_notices(state_with_fraction(uf), latch)
|
||||
assert latch["usage_band"] == want, (uf, latch["usage_band"])
|
||||
|
||||
def test_open_below_lowest_band_no_notice(self):
|
||||
latch = fresh_latch()
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.30), latch)
|
||||
assert all(n.key != "credits.usage" for n in to_show)
|
||||
assert latch["usage_band"] is None
|
||||
909
tests/agent/test_credits_tracker.py
Normal file
909
tests/agent/test_credits_tracker.py
Normal file
|
|
@ -0,0 +1,909 @@
|
|||
"""Tests for agent.credits_tracker — CreditsState + parse_credits_headers.
|
||||
|
||||
Covers the 9-state matrix plus validation edge cases. All header values
|
||||
arrive as STRINGS (the producer calls String(...) on every field).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
import pytest
|
||||
|
||||
from agent.credits_tracker import CreditsState, parse_credits_headers
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def micros(dollars: float) -> str:
|
||||
"""Convert a dollar amount to a micros string for header fixtures."""
|
||||
return str(round(dollars * 1_000_000))
|
||||
|
||||
|
||||
# ── 9-State matrix fixtures ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _base_headers(**overrides) -> dict:
|
||||
"""Base headers present in every valid response."""
|
||||
h = {
|
||||
"x-nous-credits-version": "1",
|
||||
"x-nous-credits-remaining-micros": micros(0),
|
||||
"x-nous-credits-remaining-usd": "0.00",
|
||||
"x-nous-credits-subscription-micros": micros(0),
|
||||
"x-nous-credits-subscription-usd": "0.00",
|
||||
"x-nous-credits-rollover-micros": micros(0),
|
||||
"x-nous-credits-purchased-micros": micros(0),
|
||||
"x-nous-credits-purchased-usd": "0.00",
|
||||
"x-nous-tool-pool-micros": micros(0),
|
||||
"x-nous-tool-pool-gated-off": "false",
|
||||
"x-nous-credits-denominator-kind": "none",
|
||||
"x-nous-credits-paid-access": "true",
|
||||
"x-nous-credits-as-of-ms": "1717000000000",
|
||||
}
|
||||
h.update(overrides)
|
||||
return h
|
||||
|
||||
|
||||
# ── 9 STATES ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
HEALTHY_HEADERS = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-remaining-micros": micros(30.34),
|
||||
"x-nous-credits-remaining-usd": "30.34",
|
||||
"x-nous-credits-subscription-micros": micros(18.00),
|
||||
"x-nous-credits-subscription-usd": "18.00",
|
||||
"x-nous-credits-subscription-limit-micros": micros(20.00),
|
||||
"x-nous-credits-subscription-limit-usd": "20.00",
|
||||
"x-nous-credits-rollover-micros": micros(0),
|
||||
"x-nous-credits-purchased-micros": micros(12.34),
|
||||
"x-nous-credits-purchased-usd": "12.34",
|
||||
"x-nous-tool-pool-micros": micros(2.00),
|
||||
"x-nous-tool-pool-gated-off": "true",
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
"x-nous-credits-paid-access": "true",
|
||||
}
|
||||
)
|
||||
|
||||
SUB_90PCT_HEADERS = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-remaining-micros": micros(2.00),
|
||||
"x-nous-credits-remaining-usd": "2.00",
|
||||
"x-nous-credits-subscription-micros": micros(2.00),
|
||||
"x-nous-credits-subscription-usd": "2.00",
|
||||
"x-nous-credits-subscription-limit-micros": micros(20.00),
|
||||
"x-nous-credits-subscription-limit-usd": "20.00",
|
||||
"x-nous-credits-purchased-micros": micros(0),
|
||||
"x-nous-credits-purchased-usd": "0.00",
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
"x-nous-credits-paid-access": "true",
|
||||
}
|
||||
)
|
||||
|
||||
GRANT_EXHAUSTED_HEADERS = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-remaining-micros": micros(12.34),
|
||||
"x-nous-credits-remaining-usd": "12.34",
|
||||
"x-nous-credits-subscription-micros": micros(0),
|
||||
"x-nous-credits-subscription-usd": "0.00",
|
||||
"x-nous-credits-subscription-limit-micros": micros(20.00),
|
||||
"x-nous-credits-subscription-limit-usd": "20.00",
|
||||
"x-nous-credits-purchased-micros": micros(12.34),
|
||||
"x-nous-credits-purchased-usd": "12.34",
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
"x-nous-credits-paid-access": "true",
|
||||
}
|
||||
)
|
||||
|
||||
PURCHASED_ONLY_HEADERS = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-remaining-micros": micros(30.00),
|
||||
"x-nous-credits-remaining-usd": "30.00",
|
||||
"x-nous-credits-subscription-micros": micros(0),
|
||||
"x-nous-credits-subscription-usd": "0.00",
|
||||
"x-nous-credits-purchased-micros": micros(30.00),
|
||||
"x-nous-credits-purchased-usd": "30.00",
|
||||
"x-nous-credits-denominator-kind": "none",
|
||||
"x-nous-credits-paid-access": "true",
|
||||
# No limit pair — denominator_kind=none
|
||||
}
|
||||
)
|
||||
|
||||
TOOL_POOL_FREE_HEADERS = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-remaining-micros": micros(0.05),
|
||||
"x-nous-credits-remaining-usd": "0.05",
|
||||
"x-nous-tool-pool-micros": micros(0.05),
|
||||
"x-nous-tool-pool-gated-off": "false",
|
||||
"x-nous-credits-paid-access": "true",
|
||||
}
|
||||
)
|
||||
|
||||
DEPLETED_HEADERS = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-remaining-micros": micros(0),
|
||||
"x-nous-credits-remaining-usd": "0.00",
|
||||
"x-nous-credits-subscription-micros": micros(0),
|
||||
"x-nous-credits-subscription-usd": "0.00",
|
||||
"x-nous-credits-purchased-micros": micros(0),
|
||||
"x-nous-credits-purchased-usd": "0.00",
|
||||
"x-nous-credits-paid-access": "false",
|
||||
"x-nous-credits-disabled-reason": "out_of_credits",
|
||||
}
|
||||
)
|
||||
|
||||
DEBT_HEADERS = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-remaining-micros": micros(0),
|
||||
"x-nous-credits-remaining-usd": "0.00",
|
||||
"x-nous-credits-subscription-micros": str(-5_000_000),
|
||||
"x-nous-credits-subscription-usd": "-5.00",
|
||||
"x-nous-credits-purchased-micros": micros(0),
|
||||
"x-nous-credits-purchased-usd": "0.00",
|
||||
"x-nous-credits-paid-access": "false",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── State 1: healthy ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHealthyState:
|
||||
def test_parses_successfully(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state is not None
|
||||
|
||||
def test_from_header_set(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state.from_header is True
|
||||
|
||||
def test_captured_at_set(self):
|
||||
before = time.time()
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
after = time.time()
|
||||
assert before <= state.captured_at <= after
|
||||
|
||||
def test_remaining_fields(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state.remaining_micros == round(30.34 * 1_000_000)
|
||||
assert state.remaining_usd == "30.34"
|
||||
|
||||
def test_subscription_fields(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state.subscription_micros == round(18.00 * 1_000_000)
|
||||
assert state.subscription_usd == "18.00"
|
||||
assert state.subscription_limit_micros == round(20.00 * 1_000_000)
|
||||
assert state.subscription_limit_usd == "20.00"
|
||||
|
||||
def test_rollover_and_purchased(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state.rollover_micros == 0
|
||||
assert state.purchased_micros == round(12.34 * 1_000_000)
|
||||
assert state.purchased_usd == "12.34"
|
||||
|
||||
def test_tool_pool(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state.tool_pool_micros == round(2.00 * 1_000_000)
|
||||
assert state.tool_pool_gated_off is True
|
||||
|
||||
def test_denominator_and_access(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state.denominator_kind == "subscription_cap"
|
||||
assert state.paid_access is True
|
||||
assert state.disabled_reason is None
|
||||
|
||||
def test_used_fraction(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
# (20.00 - 18.00) / 20.00 = 0.10
|
||||
assert state.used_fraction == pytest.approx(0.10)
|
||||
|
||||
def test_has_data(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state.has_data is True
|
||||
|
||||
def test_not_depleted(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
assert state.depleted is False
|
||||
|
||||
def test_age_seconds_reasonable(self):
|
||||
state = parse_credits_headers(HEALTHY_HEADERS)
|
||||
# Should be very small — just parsed
|
||||
assert 0 <= state.age_seconds < 5
|
||||
|
||||
|
||||
# ── State 2: sub_90pct ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSub90Pct:
|
||||
def test_parses_successfully(self):
|
||||
state = parse_credits_headers(SUB_90PCT_HEADERS)
|
||||
assert state is not None
|
||||
|
||||
def test_used_fraction_90pct(self):
|
||||
state = parse_credits_headers(SUB_90PCT_HEADERS)
|
||||
# (20.00 - 2.00) / 20.00 = 0.90
|
||||
assert state.used_fraction == pytest.approx(0.90)
|
||||
|
||||
def test_paid_access(self):
|
||||
state = parse_credits_headers(SUB_90PCT_HEADERS)
|
||||
assert state.paid_access is True
|
||||
assert state.depleted is False
|
||||
|
||||
|
||||
# ── State 3: grant_exhausted ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGrantExhausted:
|
||||
def test_used_fraction_100pct(self):
|
||||
state = parse_credits_headers(GRANT_EXHAUSTED_HEADERS)
|
||||
assert state is not None
|
||||
# subscription_micros=0, limit=20.00 → (20-0)/20 = 1.0
|
||||
assert state.used_fraction == pytest.approx(1.0)
|
||||
|
||||
def test_paid_access_still_true(self):
|
||||
state = parse_credits_headers(GRANT_EXHAUSTED_HEADERS)
|
||||
assert state.paid_access is True
|
||||
assert state.depleted is False
|
||||
|
||||
|
||||
# ── State 4: purchased_only ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPurchasedOnly:
|
||||
def test_parses_successfully(self):
|
||||
state = parse_credits_headers(PURCHASED_ONLY_HEADERS)
|
||||
assert state is not None
|
||||
|
||||
def test_denominator_kind_none(self):
|
||||
state = parse_credits_headers(PURCHASED_ONLY_HEADERS)
|
||||
assert state.denominator_kind == "none"
|
||||
|
||||
def test_used_fraction_is_none_no_limit(self):
|
||||
state = parse_credits_headers(PURCHASED_ONLY_HEADERS)
|
||||
# No subscription_limit_micros → used_fraction is None
|
||||
assert state.used_fraction is None
|
||||
|
||||
def test_no_limit_pair(self):
|
||||
state = parse_credits_headers(PURCHASED_ONLY_HEADERS)
|
||||
assert state.subscription_limit_micros is None
|
||||
assert state.subscription_limit_usd is None
|
||||
|
||||
|
||||
# ── State 5: tool_pool_free ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToolPoolFree:
|
||||
def test_parses_successfully(self):
|
||||
state = parse_credits_headers(TOOL_POOL_FREE_HEADERS)
|
||||
assert state is not None
|
||||
|
||||
def test_tool_pool_gated_off_false(self):
|
||||
state = parse_credits_headers(TOOL_POOL_FREE_HEADERS)
|
||||
assert state.tool_pool_gated_off is False
|
||||
|
||||
def test_tool_pool_micros(self):
|
||||
state = parse_credits_headers(TOOL_POOL_FREE_HEADERS)
|
||||
assert state.tool_pool_micros == round(0.05 * 1_000_000)
|
||||
|
||||
def test_paid_access(self):
|
||||
state = parse_credits_headers(TOOL_POOL_FREE_HEADERS)
|
||||
assert state.paid_access is True
|
||||
|
||||
|
||||
# ── State 6: depleted ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDepleted:
|
||||
def test_parses_successfully(self):
|
||||
state = parse_credits_headers(DEPLETED_HEADERS)
|
||||
assert state is not None
|
||||
|
||||
def test_paid_access_false(self):
|
||||
state = parse_credits_headers(DEPLETED_HEADERS)
|
||||
assert state.paid_access is False
|
||||
|
||||
def test_depleted_true(self):
|
||||
state = parse_credits_headers(DEPLETED_HEADERS)
|
||||
assert state.depleted is True
|
||||
|
||||
def test_disabled_reason(self):
|
||||
state = parse_credits_headers(DEPLETED_HEADERS)
|
||||
assert state.disabled_reason == "out_of_credits"
|
||||
|
||||
def test_remaining_zero(self):
|
||||
state = parse_credits_headers(DEPLETED_HEADERS)
|
||||
assert state.remaining_micros == 0
|
||||
|
||||
|
||||
# ── State 7: debt ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDebt:
|
||||
def test_parses_successfully(self):
|
||||
# Negative subscription_micros should NOT cause the parse to fail
|
||||
state = parse_credits_headers(DEBT_HEADERS)
|
||||
assert state is not None
|
||||
|
||||
def test_negative_subscription_accepted(self):
|
||||
state = parse_credits_headers(DEBT_HEADERS)
|
||||
assert state.subscription_micros == -5_000_000
|
||||
|
||||
def test_negative_subscription_usd_accepted(self):
|
||||
state = parse_credits_headers(DEBT_HEADERS)
|
||||
assert state.subscription_usd == "-5.00"
|
||||
|
||||
def test_paid_access_false(self):
|
||||
state = parse_credits_headers(DEBT_HEADERS)
|
||||
assert state.paid_access is False
|
||||
assert state.depleted is True
|
||||
|
||||
|
||||
# ── State 8: missing ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMissing:
|
||||
def test_no_credits_headers_returns_none(self):
|
||||
state = parse_credits_headers({})
|
||||
assert state is None
|
||||
|
||||
def test_completely_empty_dict(self):
|
||||
assert parse_credits_headers({}) is None
|
||||
|
||||
|
||||
# ── State 9: no_org ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNoOrg:
|
||||
def test_irrelevant_headers_return_none(self):
|
||||
headers = {
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "abc123",
|
||||
"server": "nginx",
|
||||
}
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is None
|
||||
|
||||
def test_api_key_path_no_org_returns_none(self):
|
||||
# Headers that might appear on an api-key path with no org
|
||||
headers = {
|
||||
"content-type": "application/json",
|
||||
"authorization": "Bearer sk-test",
|
||||
}
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
|
||||
# ── Version validation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVersionValidation:
|
||||
def test_version_string_1_parses(self):
|
||||
headers = _base_headers(**{"x-nous-credits-version": "1"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.version == 1
|
||||
|
||||
def test_version_2_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-version": "2"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is None
|
||||
|
||||
def test_version_absent_returns_none(self):
|
||||
headers = {k: v for k, v in _base_headers().items() if k != "x-nous-credits-version"}
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is None
|
||||
|
||||
def test_version_greater_than_1_warns_once(self, caplog):
|
||||
"""Version > 1 must log a warning, and ONLY ONCE across multiple calls."""
|
||||
import agent.credits_tracker as ct
|
||||
|
||||
original = ct._version_warning_emitted
|
||||
try:
|
||||
# Reset the warn-once latch so this test starts clean regardless of order
|
||||
ct._version_warning_emitted = False
|
||||
|
||||
headers = _base_headers(**{"x-nous-credits-version": "3"})
|
||||
with caplog.at_level(logging.WARNING, logger="agent.credits_tracker"):
|
||||
parse_credits_headers(headers)
|
||||
parse_credits_headers(headers)
|
||||
parse_credits_headers(headers)
|
||||
|
||||
warning_records = [r for r in caplog.records if "unsupported" in r.message.lower() or "version" in r.message.lower()]
|
||||
assert len(warning_records) == 1, (
|
||||
f"Expected exactly 1 version warning, got {len(warning_records)}: {[r.message for r in warning_records]}"
|
||||
)
|
||||
finally:
|
||||
ct._version_warning_emitted = original
|
||||
|
||||
def test_version_0_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-version": "0"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_version_non_int_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-version": "abc"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
|
||||
# ── Bool-string trap ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBoolStringTrap:
|
||||
"""Explicit tests for the bool("false") == True trap."""
|
||||
|
||||
def test_paid_access_string_false_means_depleted(self):
|
||||
"""paid_access='false' must yield paid_access=False — NOT True."""
|
||||
headers = _base_headers(**{"x-nous-credits-paid-access": "false"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.paid_access is False
|
||||
assert state.depleted is True
|
||||
|
||||
def test_paid_access_string_true_means_not_depleted(self):
|
||||
headers = _base_headers(**{"x-nous-credits-paid-access": "true"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.paid_access is True
|
||||
assert state.depleted is False
|
||||
|
||||
def test_paid_access_case_insensitive_FALSE(self):
|
||||
headers = _base_headers(**{"x-nous-credits-paid-access": "FALSE"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.paid_access is False
|
||||
|
||||
def test_paid_access_case_insensitive_True(self):
|
||||
headers = _base_headers(**{"x-nous-credits-paid-access": "True"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.paid_access is True
|
||||
|
||||
def test_tool_pool_gated_off_false(self):
|
||||
headers = _base_headers(**{"x-nous-tool-pool-gated-off": "false"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.tool_pool_gated_off is False
|
||||
|
||||
def test_tool_pool_gated_off_true(self):
|
||||
headers = _base_headers(**{"x-nous-tool-pool-gated-off": "true"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.tool_pool_gated_off is True
|
||||
|
||||
|
||||
# ── Tool-pool optional headers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToolPoolOptional:
|
||||
"""x-nous-tool-pool-* headers are optional; absent → defaults; present-but-malformed → miss."""
|
||||
|
||||
def _no_tool_pool_headers(self) -> dict:
|
||||
"""Base headers with BOTH tool-pool headers removed."""
|
||||
h = _base_headers()
|
||||
h.pop("x-nous-tool-pool-micros", None)
|
||||
h.pop("x-nous-tool-pool-gated-off", None)
|
||||
return h
|
||||
|
||||
def test_absent_tool_pool_headers_parse_succeeds(self):
|
||||
"""Valid credits headers with no x-nous-tool-pool-* → parse succeeds."""
|
||||
state = parse_credits_headers(self._no_tool_pool_headers())
|
||||
assert state is not None
|
||||
|
||||
def test_absent_tool_pool_micros_defaults_to_zero(self):
|
||||
state = parse_credits_headers(self._no_tool_pool_headers())
|
||||
assert state.tool_pool_micros == 0
|
||||
|
||||
def test_absent_tool_pool_gated_off_defaults_to_false(self):
|
||||
state = parse_credits_headers(self._no_tool_pool_headers())
|
||||
assert state.tool_pool_gated_off is False
|
||||
|
||||
def test_present_malformed_tool_pool_micros_returns_none(self):
|
||||
"""x-nous-tool-pool-micros present but non-int → parse miss (returns None)."""
|
||||
headers = _base_headers(**{"x-nous-tool-pool-micros": "not-a-number"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_present_negative_tool_pool_micros_returns_none(self):
|
||||
"""x-nous-tool-pool-micros present but negative → parse miss (returns None)."""
|
||||
headers = _base_headers(**{"x-nous-tool-pool-micros": "-1000"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_only_tool_pool_micros_absent_still_succeeds(self):
|
||||
"""Only micros absent (gated-off still present) → tool_pool_micros = 0, parse succeeds."""
|
||||
h = _base_headers()
|
||||
h.pop("x-nous-tool-pool-micros", None)
|
||||
state = parse_credits_headers(h)
|
||||
assert state is not None
|
||||
assert state.tool_pool_micros == 0
|
||||
|
||||
|
||||
# ── Half-pair subscription limit ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHalfPairLimit:
|
||||
def test_only_limit_micros_present_both_absent(self):
|
||||
"""Only -micros present → both None, parse SUCCEEDS."""
|
||||
headers = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-subscription-limit-micros": micros(20.00),
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
}
|
||||
)
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.subscription_limit_micros is None
|
||||
assert state.subscription_limit_usd is None
|
||||
|
||||
def test_only_limit_usd_present_both_absent(self):
|
||||
"""Only -usd present → both None, parse SUCCEEDS."""
|
||||
headers = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-subscription-limit-usd": "20.00",
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
}
|
||||
)
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.subscription_limit_micros is None
|
||||
assert state.subscription_limit_usd is None
|
||||
|
||||
def test_half_pair_used_fraction_is_none(self):
|
||||
"""With no limit pair, used_fraction is None regardless of denominator_kind."""
|
||||
headers = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-subscription-limit-micros": micros(20.00),
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
}
|
||||
)
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.used_fraction is None
|
||||
|
||||
def test_full_pair_present_parsed_correctly(self):
|
||||
"""Both present → both populated, used_fraction computable."""
|
||||
headers = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-subscription-micros": micros(10.00),
|
||||
"x-nous-credits-subscription-usd": "10.00",
|
||||
"x-nous-credits-subscription-limit-micros": micros(20.00),
|
||||
"x-nous-credits-subscription-limit-usd": "20.00",
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
}
|
||||
)
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.subscription_limit_micros == round(20.00 * 1_000_000)
|
||||
assert state.subscription_limit_usd == "20.00"
|
||||
assert state.used_fraction == pytest.approx(0.50)
|
||||
|
||||
|
||||
# ── Negative value validation ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNegativeValues:
|
||||
def test_negative_remaining_micros_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-remaining-micros": "-1000"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_negative_purchased_micros_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-purchased-micros": "-500"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_negative_rollover_micros_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-rollover-micros": "-100"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_negative_limit_micros_returns_none(self):
|
||||
headers = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-subscription-limit-micros": "-1000",
|
||||
"x-nous-credits-subscription-limit-usd": "-0.00",
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
}
|
||||
)
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_negative_subscription_accepted(self):
|
||||
"""subscription_micros is the ONLY field allowed to be negative."""
|
||||
headers = _base_headers(**{"x-nous-credits-subscription-micros": "-5000000",
|
||||
"x-nous-credits-subscription-usd": "-5.00"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.subscription_micros == -5_000_000
|
||||
|
||||
|
||||
# ── USD format validation ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUsdValidation:
|
||||
def test_valid_usd_format(self):
|
||||
headers = _base_headers(**{"x-nous-credits-remaining-usd": "18.00"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.remaining_usd == "18.00"
|
||||
|
||||
def test_usd_one_decimal_returns_none(self):
|
||||
"""'18.0' does not match ^-?\d+\.\d{2}$"""
|
||||
headers = _base_headers(**{"x-nous-credits-remaining-usd": "18.0"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_usd_no_decimal_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-remaining-usd": "18"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_usd_with_dollar_sign_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-remaining-usd": "$18.00"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_usd_with_comma_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-remaining-usd": "1,800.00"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_usd_negative_valid(self):
|
||||
"""Negative USD string should parse (e.g. subscription debt)."""
|
||||
headers = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-subscription-micros": "-5000000",
|
||||
"x-nous-credits-subscription-usd": "-5.00",
|
||||
}
|
||||
)
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.subscription_usd == "-5.00"
|
||||
|
||||
|
||||
# ── Non-int micros validation ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMicrosValidation:
|
||||
def test_non_int_micros_string_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-remaining-micros": "abc"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_float_string_micros_returns_none(self):
|
||||
"""'1.5' is not an integer string — should fail validation."""
|
||||
headers = _base_headers(**{"x-nous-credits-remaining-micros": "1.5"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_non_int_purchased_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-purchased-micros": "abc"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
|
||||
# ── as_of_ms validation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAsOfMs:
|
||||
def test_junk_as_of_ms_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-as-of-ms": "not-a-timestamp"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
def test_valid_as_of_ms(self):
|
||||
headers = _base_headers(**{"x-nous-credits-as-of-ms": "1717000000000"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.as_of_ms == 1717000000000
|
||||
|
||||
|
||||
# ── denominator_kind validation ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDenominatorKind:
|
||||
def test_subscription_cap_valid(self):
|
||||
headers = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
"x-nous-credits-subscription-limit-micros": micros(20.00),
|
||||
"x-nous-credits-subscription-limit-usd": "20.00",
|
||||
}
|
||||
)
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.denominator_kind == "subscription_cap"
|
||||
|
||||
def test_none_valid(self):
|
||||
headers = _base_headers(**{"x-nous-credits-denominator-kind": "none"})
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.denominator_kind == "none"
|
||||
|
||||
def test_invalid_denominator_kind_returns_none(self):
|
||||
headers = _base_headers(**{"x-nous-credits-denominator-kind": "invalid_kind"})
|
||||
assert parse_credits_headers(headers) is None
|
||||
|
||||
|
||||
# ── Zero-division guard ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestZeroDivisionGuard:
|
||||
def test_subscription_limit_zero_used_fraction_is_none(self):
|
||||
"""subscription_limit_micros='0' + subscription_cap → used_fraction is None (no ZeroDivisionError)."""
|
||||
headers = _base_headers(
|
||||
**{
|
||||
"x-nous-credits-subscription-limit-micros": "0",
|
||||
"x-nous-credits-subscription-limit-usd": "0.00",
|
||||
"x-nous-credits-denominator-kind": "subscription_cap",
|
||||
}
|
||||
)
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
# limit == 0, so used_fraction must be None (guard prevents division)
|
||||
assert state.used_fraction is None
|
||||
|
||||
|
||||
# ── Unknown headers ignored ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUnknownHeaders:
|
||||
def test_unknown_extra_header_ignored(self):
|
||||
headers = {
|
||||
**_base_headers(),
|
||||
"x-nous-credits-future-field": "some-value",
|
||||
"x-request-id": "abc123",
|
||||
}
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
|
||||
def test_mixed_with_other_providers_headers(self):
|
||||
headers = {
|
||||
**_base_headers(),
|
||||
"x-ratelimit-limit-requests": "800",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
|
||||
|
||||
# ── Header normalization ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHeaderNormalization:
|
||||
def test_uppercase_headers_parsed(self):
|
||||
headers = {k.upper(): v for k, v in _base_headers().items()}
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
|
||||
def test_mixed_case_headers_parsed(self):
|
||||
headers = {
|
||||
"X-Nous-Credits-Version": "1",
|
||||
"X-Nous-Credits-Remaining-Micros": micros(5.00),
|
||||
"X-Nous-Credits-Remaining-Usd": "5.00",
|
||||
"X-Nous-Credits-Subscription-Micros": micros(5.00),
|
||||
"X-Nous-Credits-Subscription-Usd": "5.00",
|
||||
"X-Nous-Credits-Rollover-Micros": "0",
|
||||
"X-Nous-Credits-Purchased-Micros": "0",
|
||||
"X-Nous-Credits-Purchased-Usd": "0.00",
|
||||
"X-Nous-Tool-Pool-Micros": "0",
|
||||
"X-Nous-Tool-Pool-Gated-Off": "false",
|
||||
"X-Nous-Credits-Denominator-Kind": "none",
|
||||
"X-Nous-Credits-Paid-Access": "true",
|
||||
"X-Nous-Credits-As-Of-Ms": "1717000000000",
|
||||
}
|
||||
state = parse_credits_headers(headers)
|
||||
assert state is not None
|
||||
assert state.remaining_micros == round(5.00 * 1_000_000)
|
||||
|
||||
|
||||
# ── CreditsState dataclass defaults ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreditsStateDefaults:
|
||||
def test_default_state(self):
|
||||
state = CreditsState()
|
||||
assert state.version == 0
|
||||
assert state.remaining_micros == 0
|
||||
assert state.remaining_usd == ""
|
||||
assert state.subscription_micros == 0
|
||||
assert state.subscription_usd == ""
|
||||
assert state.subscription_limit_micros is None
|
||||
assert state.subscription_limit_usd is None
|
||||
assert state.rollover_micros == 0
|
||||
assert state.purchased_micros == 0
|
||||
assert state.purchased_usd == ""
|
||||
assert state.tool_pool_micros == 0
|
||||
assert state.tool_pool_gated_off is False
|
||||
assert state.denominator_kind == "none"
|
||||
assert state.paid_access is True
|
||||
assert state.disabled_reason is None
|
||||
assert state.as_of_ms == 0
|
||||
assert state.captured_at == 0.0
|
||||
assert state.from_header is False
|
||||
|
||||
def test_has_data_false_when_no_captured_at(self):
|
||||
state = CreditsState()
|
||||
assert state.has_data is False
|
||||
|
||||
def test_age_seconds_inf_when_no_data(self):
|
||||
state = CreditsState()
|
||||
assert state.age_seconds == float("inf")
|
||||
|
||||
def test_depleted_false_by_default(self):
|
||||
state = CreditsState()
|
||||
assert state.depleted is False
|
||||
|
||||
def test_used_fraction_none_by_default(self):
|
||||
state = CreditsState()
|
||||
assert state.used_fraction is None
|
||||
|
||||
|
||||
# ── depleted property ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDepletedProperty:
|
||||
def test_depleted_equals_not_paid_access(self):
|
||||
"""depleted must be exactly `not paid_access`, never `remaining==0`."""
|
||||
state = CreditsState(paid_access=False, remaining_micros=0, captured_at=time.time())
|
||||
assert state.depleted is True
|
||||
|
||||
def test_not_depleted_when_paid_access_true(self):
|
||||
state = CreditsState(paid_access=True, remaining_micros=0, captured_at=time.time())
|
||||
# remaining==0 but paid_access is True → NOT depleted
|
||||
assert state.depleted is False
|
||||
|
||||
def test_depleted_independent_of_remaining(self):
|
||||
"""Even with remaining > 0, if paid_access is False, depleted is True."""
|
||||
state = CreditsState(paid_access=False, remaining_micros=1_000_000, captured_at=time.time())
|
||||
assert state.depleted is True
|
||||
|
||||
|
||||
# ── used_fraction edge cases ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUsedFraction:
|
||||
def test_none_without_limit(self):
|
||||
state = CreditsState(
|
||||
denominator_kind="subscription_cap",
|
||||
subscription_limit_micros=None,
|
||||
captured_at=time.time(),
|
||||
)
|
||||
assert state.used_fraction is None
|
||||
|
||||
def test_none_when_limit_zero(self):
|
||||
state = CreditsState(
|
||||
denominator_kind="subscription_cap",
|
||||
subscription_limit_micros=0,
|
||||
subscription_micros=0,
|
||||
captured_at=time.time(),
|
||||
)
|
||||
assert state.used_fraction is None
|
||||
|
||||
def test_clamped_at_zero(self):
|
||||
"""If subscription_micros > limit (over-credited), fraction clamps to 0."""
|
||||
state = CreditsState(
|
||||
denominator_kind="subscription_cap",
|
||||
subscription_limit_micros=10_000_000,
|
||||
subscription_micros=15_000_000, # more than limit
|
||||
captured_at=time.time(),
|
||||
)
|
||||
assert state.used_fraction == pytest.approx(0.0)
|
||||
|
||||
def test_clamped_at_one(self):
|
||||
"""If subscription_micros is very negative (debt), fraction clamps to 1.0."""
|
||||
state = CreditsState(
|
||||
denominator_kind="subscription_cap",
|
||||
subscription_limit_micros=10_000_000,
|
||||
subscription_micros=-5_000_000, # deep debt
|
||||
captured_at=time.time(),
|
||||
)
|
||||
assert state.used_fraction == pytest.approx(1.0)
|
||||
|
||||
def test_guarded_by_limit_field_not_denominator(self):
|
||||
"""used_fraction depends on subscription_limit_micros being truthy, not denominator_kind."""
|
||||
# limit present but denominator_kind="none" — spec says guard on LIMIT FIELD
|
||||
state = CreditsState(
|
||||
denominator_kind="none",
|
||||
subscription_limit_micros=20_000_000,
|
||||
subscription_micros=10_000_000,
|
||||
captured_at=time.time(),
|
||||
)
|
||||
# With limit_micros set, fraction should be computable regardless of denominator_kind
|
||||
assert state.used_fraction == pytest.approx(0.50)
|
||||
|
||||
def test_none_when_denominator_cap_but_no_limit(self):
|
||||
"""denominator_kind=subscription_cap but no limit pair → None."""
|
||||
state = CreditsState(
|
||||
denominator_kind="subscription_cap",
|
||||
subscription_limit_micros=None,
|
||||
subscription_micros=5_000_000,
|
||||
captured_at=time.time(),
|
||||
)
|
||||
assert state.used_fraction is None
|
||||
150
tests/agent/test_nous_credits_gauge.py
Normal file
150
tests/agent/test_nous_credits_gauge.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Tests for the Nous-credits subscription % gauge in build_nous_credits_snapshot.
|
||||
|
||||
Covers the monthly_credits denominator path added when the portal /api/oauth/account
|
||||
subscription block began carrying `monthly_credits`. Magnitudes-only fallback, clamp,
|
||||
and the non-finite / rollover guards (surfaced by adversarial review) are all asserted.
|
||||
"""
|
||||
from hermes_cli.nous_account import (
|
||||
NousPortalAccountInfo,
|
||||
NousPaidServiceAccessInfo,
|
||||
NousPortalSubscriptionInfo,
|
||||
_subscription_from_payload,
|
||||
)
|
||||
from agent.account_usage import build_nous_credits_snapshot, render_account_usage_lines
|
||||
|
||||
|
||||
def _acct(**kwargs):
|
||||
kwargs.setdefault("logged_in", True)
|
||||
kwargs.setdefault("source", "account_api")
|
||||
kwargs.setdefault("fresh", True)
|
||||
kwargs.setdefault("portal_base_url", "https://portal.nousresearch.com")
|
||||
return NousPortalAccountInfo(**kwargs)
|
||||
|
||||
|
||||
def _window(snap):
|
||||
return snap.windows[0] if (snap and snap.windows) else None
|
||||
|
||||
|
||||
def test_parser_captures_monthly_credits():
|
||||
sub = _subscription_from_payload({
|
||||
"plan": "Ultra", "tier": 14, "monthly_charge": 200, "monthly_credits": 220,
|
||||
"current_period_end": "2026-06-28T05:21:54.000Z",
|
||||
"credits_remaining": 219.27341839, "rollover_credits": 0,
|
||||
})
|
||||
assert sub.monthly_credits == 220
|
||||
assert abs(sub.credits_remaining - 219.27341839) < 1e-6
|
||||
|
||||
|
||||
def test_parser_monthly_credits_absent_is_none():
|
||||
sub = _subscription_from_payload({"plan": "Ultra", "credits_remaining": 10.0})
|
||||
assert sub.monthly_credits is None
|
||||
|
||||
|
||||
def test_gauge_present_with_monthly_credits():
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(
|
||||
plan="Ultra", monthly_credits=220, credits_remaining=219.27341839,
|
||||
current_period_end="2026-06-28"),
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(
|
||||
subscription_credits_remaining=219.27, total_usable_credits=219.27),
|
||||
))
|
||||
w = _window(snap)
|
||||
assert w is not None and w.label == "Subscription"
|
||||
assert abs(w.used_percent - (220 - 219.27341839) / 220 * 100) < 1e-9
|
||||
blob = "\n".join(render_account_usage_lines(snap))
|
||||
assert "% used" in blob or "% remaining" in blob
|
||||
assert "of $220.00 left" in blob
|
||||
|
||||
|
||||
def test_gauge_90pct():
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=22.0),
|
||||
))
|
||||
assert abs(_window(snap).used_percent - 90.0) < 1e-9
|
||||
|
||||
|
||||
def test_gauge_debt_clamps_to_100():
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=False,
|
||||
subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=-5.0),
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(subscription_credits_remaining=-5.0),
|
||||
))
|
||||
assert _window(snap).used_percent == 100.0
|
||||
|
||||
|
||||
def test_gauge_at_cap_is_zero_used():
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=220.0),
|
||||
))
|
||||
assert _window(snap).used_percent == 0.0
|
||||
|
||||
|
||||
def test_no_monthly_credits_falls_back_to_magnitudes():
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(plan="Ultra", credits_remaining=-0.79),
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=991.96),
|
||||
))
|
||||
assert _window(snap) is None
|
||||
blob = "\n".join(render_account_usage_lines(snap))
|
||||
assert "%" not in blob
|
||||
assert "Top-up credits: $991.96" in blob
|
||||
|
||||
|
||||
def test_nan_remaining_no_window_no_nan_string():
|
||||
"""json.loads parses bare NaN by default; isinstance(nan, float) is True.
|
||||
The gauge must reject it rather than render '$nan' + a false 100% used."""
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=float("nan")),
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=5.0),
|
||||
))
|
||||
assert _window(snap) is None
|
||||
assert "$nan" not in "\n".join(render_account_usage_lines(snap)).lower()
|
||||
|
||||
|
||||
def test_inf_cap_no_window():
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(monthly_credits=float("inf"), credits_remaining=10.0),
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=5.0),
|
||||
))
|
||||
assert _window(snap) is None
|
||||
|
||||
|
||||
def test_rollover_balance_exceeds_cap_no_window():
|
||||
"""remaining > cap (rollover spanning the period) makes monthly_credits a
|
||||
nonsensical denominator → suppress the gauge, keep magnitudes."""
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=300, rollover_credits=80),
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(subscription_credits_remaining=300.0),
|
||||
))
|
||||
assert _window(snap) is None
|
||||
assert "of $220.00 left" not in "\n".join(render_account_usage_lines(snap))
|
||||
|
||||
|
||||
def test_bool_monthly_credits_no_window():
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(monthly_credits=True, credits_remaining=1.0),
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=5.0),
|
||||
))
|
||||
assert _window(snap) is None
|
||||
|
||||
|
||||
def test_zero_monthly_credits_no_divzero():
|
||||
snap = build_nous_credits_snapshot(_acct(
|
||||
paid_service_access=True,
|
||||
subscription=NousPortalSubscriptionInfo(monthly_credits=0, credits_remaining=0.0),
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=5.0),
|
||||
))
|
||||
assert _window(snap) is None
|
||||
|
||||
|
||||
def test_failopen_none_and_logged_out():
|
||||
assert build_nous_credits_snapshot(None) is None
|
||||
assert build_nous_credits_snapshot(_acct(logged_in=False)) is None
|
||||
126
tests/agent/test_nous_credits_snapshot.py
Normal file
126
tests/agent/test_nous_credits_snapshot.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Tests for build_nous_credits_snapshot (L6-A, magnitudes-only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.account_usage import build_nous_credits_snapshot
|
||||
from hermes_cli.nous_account import (
|
||||
NousPaidServiceAccessInfo,
|
||||
NousPortalAccountInfo,
|
||||
NousPortalSubscriptionInfo,
|
||||
)
|
||||
|
||||
|
||||
def _account(**kwargs) -> NousPortalAccountInfo:
|
||||
kwargs.setdefault("logged_in", True)
|
||||
kwargs.setdefault("source", "account_api")
|
||||
kwargs.setdefault("fresh", True)
|
||||
return NousPortalAccountInfo(**kwargs)
|
||||
|
||||
|
||||
def _all_lines(snapshot) -> list[str]:
|
||||
return list(snapshot.details)
|
||||
|
||||
|
||||
def test_healthy():
|
||||
info = _account(
|
||||
paid_service_access=True,
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(
|
||||
subscription_credits_remaining=18.0,
|
||||
purchased_credits_remaining=12.34,
|
||||
total_usable_credits=30.34,
|
||||
),
|
||||
subscription=NousPortalSubscriptionInfo(
|
||||
plan="Pro",
|
||||
current_period_end="2026-07-01",
|
||||
),
|
||||
)
|
||||
snap = build_nous_credits_snapshot(info)
|
||||
assert snap is not None
|
||||
assert snap.available is True
|
||||
assert snap.plan == "Pro"
|
||||
assert snap.provider == "nous"
|
||||
assert snap.title == "Nous credits"
|
||||
blob = "\n".join(_all_lines(snap))
|
||||
assert "$18.00" in blob
|
||||
assert "$12.34" in blob
|
||||
assert "$30.34" in blob
|
||||
assert "Renews: 2026-07-01" in blob
|
||||
assert "/billing" in blob
|
||||
# money-rule: magnitudes-only, never a percentage
|
||||
assert "%" not in blob
|
||||
|
||||
|
||||
def test_money_rule_no_percent():
|
||||
info = _account(
|
||||
paid_service_access=True,
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(
|
||||
subscription_credits_remaining=18.0,
|
||||
purchased_credits_remaining=12.34,
|
||||
total_usable_credits=30.34,
|
||||
),
|
||||
subscription=NousPortalSubscriptionInfo(plan="Pro"),
|
||||
)
|
||||
snap = build_nous_credits_snapshot(info)
|
||||
assert snap is not None
|
||||
for line in snap.details:
|
||||
assert "%" not in line
|
||||
|
||||
|
||||
def test_depleted():
|
||||
info = _account(
|
||||
paid_service_access=False,
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(
|
||||
subscription_credits_remaining=0.0,
|
||||
purchased_credits_remaining=0.0,
|
||||
total_usable_credits=0.0,
|
||||
),
|
||||
subscription=NousPortalSubscriptionInfo(plan="Pro"),
|
||||
)
|
||||
snap = build_nous_credits_snapshot(info)
|
||||
assert snap is not None
|
||||
blob = "\n".join(_all_lines(snap))
|
||||
assert "access depleted" in blob
|
||||
assert "/billing" in blob
|
||||
|
||||
|
||||
def test_purchased_only():
|
||||
info = _account(
|
||||
paid_service_access=True,
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(
|
||||
subscription_credits_remaining=None,
|
||||
purchased_credits_remaining=30.0,
|
||||
total_usable_credits=30.0,
|
||||
),
|
||||
subscription=None,
|
||||
)
|
||||
snap = build_nous_credits_snapshot(info)
|
||||
assert snap is not None
|
||||
blob = "\n".join(_all_lines(snap))
|
||||
assert "Subscription credits" not in blob
|
||||
assert "Top-up credits: $30.00" in blob
|
||||
assert snap.plan is None
|
||||
|
||||
|
||||
def test_logged_out():
|
||||
info = _account(
|
||||
logged_in=False,
|
||||
paid_service_access=True,
|
||||
paid_service_access_info=NousPaidServiceAccessInfo(
|
||||
total_usable_credits=10.0,
|
||||
),
|
||||
)
|
||||
assert build_nous_credits_snapshot(info) is None
|
||||
|
||||
|
||||
def test_none():
|
||||
assert build_nous_credits_snapshot(None) is None
|
||||
|
||||
|
||||
def test_never_raises_empty():
|
||||
info = _account(
|
||||
paid_service_access=True,
|
||||
paid_service_access_info=None,
|
||||
subscription=None,
|
||||
)
|
||||
# No usable numbers and not depleted -> None, without raising.
|
||||
assert build_nous_credits_snapshot(info) is None
|
||||
174
tests/gateway/test_notice_rendering.py
Normal file
174
tests/gateway/test_notice_rendering.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"""Unit tests for messaging-gateway credit-notice rendering.
|
||||
|
||||
Covers render_notice_line — the pure helper that turns an AgentNotice into the
|
||||
single plaintext line pushed standalone over a messaging platform (no status
|
||||
bar, unlike the TUI). Behavior contracts, not data snapshots.
|
||||
"""
|
||||
from agent.credits_tracker import AgentNotice
|
||||
from gateway.run import render_notice_line
|
||||
|
||||
|
||||
class TestRenderNoticeLine:
|
||||
"""render_notice_line emits the notice text VERBATIM.
|
||||
|
||||
The notice policy already bakes the level glyph (⚠ / • / ✕ / ✓) into the
|
||||
text, and the TUI + CLI REPL render it as-is — so messaging must NOT add a
|
||||
second glyph, which would double it ("⚠ ⚠ Credits 90% used", "⛔ ✕ Credit
|
||||
access paused").
|
||||
"""
|
||||
|
||||
def test_returns_text_verbatim_with_its_baked_glyph(self):
|
||||
assert (
|
||||
render_notice_line(AgentNotice(text="⚠ Credits 90% used · $20.00 cap", level="warn"))
|
||||
== "⚠ Credits 90% used · $20.00 cap"
|
||||
)
|
||||
assert (
|
||||
render_notice_line(AgentNotice(text="• Grant spent · $5.00 top-up left", level="info"))
|
||||
== "• Grant spent · $5.00 top-up left"
|
||||
)
|
||||
assert (
|
||||
render_notice_line(
|
||||
AgentNotice(text="✕ Credit access paused · run /usage for balance", level="error")
|
||||
)
|
||||
== "✕ Credit access paused · run /usage for balance"
|
||||
)
|
||||
|
||||
def test_does_not_prepend_a_second_glyph(self):
|
||||
# Regression: the text already carries its glyph; the level must not add
|
||||
# another (the bug produced "⚠ ⚠ …" / "⛔ ✕ …").
|
||||
line = render_notice_line(AgentNotice(text="⚠ Credits 90% used", level="warn"))
|
||||
assert line == "⚠ Credits 90% used"
|
||||
assert "⚠ ⚠" not in line
|
||||
|
||||
def test_text_is_stripped(self):
|
||||
assert render_notice_line(AgentNotice(text=" ⚠ padded ", level="warn")) == "⚠ padded"
|
||||
|
||||
def test_empty_text_returns_empty_string(self):
|
||||
# Empty/whitespace → "" → the callback suppresses the push. Fail-soft.
|
||||
assert render_notice_line(AgentNotice(text="", level="warn")) == ""
|
||||
assert render_notice_line(AgentNotice(text=" ", level="warn")) == ""
|
||||
|
||||
def test_malformed_notice_does_not_raise(self):
|
||||
# Duck-typed: a stand-in lacking the expected attrs degrades to "".
|
||||
class _Bare:
|
||||
pass
|
||||
|
||||
assert render_notice_line(_Bare()) == ""
|
||||
|
||||
|
||||
def test_real_policy_notices_render_without_doubling():
|
||||
"""End-to-end regression: every notice evaluate_credits_notices emits already
|
||||
carries its glyph, so render_notice_line must return it unchanged (no second
|
||||
glyph prepended) for the messaging push."""
|
||||
from agent.credits_tracker import CreditsState, evaluate_credits_notices
|
||||
|
||||
def _emitted(uf=None, paid=True, purchased=0):
|
||||
latch = {"active": set(), "seen_below_90": True, "usage_band": None}
|
||||
if uf is None:
|
||||
st = CreditsState(
|
||||
subscription_limit_micros=None, subscription_micros=0,
|
||||
denominator_kind="none", paid_access=paid,
|
||||
purchased_micros=purchased, purchased_usd="%.2f" % (purchased / 1e6),
|
||||
)
|
||||
else:
|
||||
lim = 20_000_000
|
||||
st = CreditsState(
|
||||
subscription_limit_micros=lim, subscription_limit_usd="20.00",
|
||||
subscription_micros=int(lim * (1 - uf)), denominator_kind="subscription_cap",
|
||||
paid_access=paid, purchased_micros=purchased,
|
||||
purchased_usd="%.2f" % (purchased / 1e6),
|
||||
)
|
||||
show, _ = evaluate_credits_notices(st, latch)
|
||||
return show
|
||||
|
||||
notices = (
|
||||
_emitted(uf=0.9) # band 90 (warn)
|
||||
+ _emitted(uf=0.5) # band 50 (info)
|
||||
+ _emitted(uf=1.0, purchased=5_000_000) # band 90 + grant_spent
|
||||
+ _emitted(uf=None, paid=False) # depleted
|
||||
)
|
||||
assert notices, "policy produced no notices to check"
|
||||
for n in notices:
|
||||
assert render_notice_line(n) == n.text # verbatim — no prepended glyph
|
||||
|
||||
|
||||
# ── Delivery seam: a rendered notice line goes out via _deliver_platform_notice ──
|
||||
|
||||
import threading
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_source(platform_value="telegram", chat_id="555", user_id="u1"):
|
||||
src = MagicMock()
|
||||
plat = MagicMock()
|
||||
plat.value = platform_value
|
||||
src.platform = plat
|
||||
src.chat_id = chat_id
|
||||
src.user_id = user_id
|
||||
return src
|
||||
|
||||
|
||||
def _make_runner_with_adapter(source, adapter):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {source.platform: adapter}
|
||||
runner.config = MagicMock()
|
||||
runner.config.get_notice_delivery = MagicMock(return_value="public")
|
||||
runner._thread_metadata_for_source = MagicMock(return_value={"thread": "t"})
|
||||
return runner
|
||||
|
||||
|
||||
class TestDeliverNoticeLine:
|
||||
"""The seam between render_notice_line and the platform adapter.
|
||||
|
||||
Proves a rendered credit-notice line reaches adapter.send (public) /
|
||||
send_private_notice (private) through the shared _deliver_platform_notice
|
||||
rail — the path the gateway notice_callback schedules onto the loop.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_delivery_sends_rendered_line(self):
|
||||
source = _make_source()
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=MagicMock(success=True))
|
||||
runner = _make_runner_with_adapter(source, adapter)
|
||||
|
||||
line = render_notice_line(
|
||||
AgentNotice(text="⚠ Credits 90% used · $20.00 cap", level="warn")
|
||||
)
|
||||
await runner._deliver_platform_notice(source, line)
|
||||
|
||||
adapter.send.assert_awaited_once()
|
||||
args, kwargs = adapter.send.call_args
|
||||
assert args[0] == "555"
|
||||
# Delivered verbatim — the policy's single glyph, not a doubled one.
|
||||
assert args[1] == "⚠ Credits 90% used · $20.00 cap"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_delivery_prefers_private_notice(self):
|
||||
source = _make_source()
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=MagicMock(success=True))
|
||||
adapter.send_private_notice = AsyncMock(return_value=MagicMock(success=True))
|
||||
runner = _make_runner_with_adapter(source, adapter)
|
||||
runner.config.get_notice_delivery = MagicMock(return_value="private")
|
||||
|
||||
line = render_notice_line(
|
||||
AgentNotice(text="✓ Credit access restored", level="success")
|
||||
)
|
||||
await runner._deliver_platform_notice(source, line)
|
||||
|
||||
adapter.send_private_notice.assert_awaited_once()
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_adapter_is_a_noop(self):
|
||||
source = _make_source()
|
||||
runner = object.__new__(__import__("gateway.run", fromlist=["GatewayRunner"]).GatewayRunner)
|
||||
runner.adapters = {}
|
||||
# Must not raise when the platform has no registered adapter.
|
||||
await runner._deliver_platform_notice(source, "• anything")
|
||||
|
||||
|
|
@ -223,11 +223,14 @@ class TestUsageAccountSection:
|
|||
{"role": "user", "content": "earlier"},
|
||||
]
|
||||
|
||||
calls = {}
|
||||
calls = []
|
||||
|
||||
async def _fake_to_thread(fn, *args, **kwargs):
|
||||
calls["args"] = args
|
||||
calls["kwargs"] = kwargs
|
||||
# /usage dispatches BOTH the account fetch (fetch_account_usage, called
|
||||
# with the provider positionally) and the Nous credits fetch
|
||||
# (nous_credits_lines, markdown-only) through to_thread — record every
|
||||
# call rather than last-wins so we can pick out the account fetch.
|
||||
calls.append({"args": args, "kwargs": kwargs})
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("gateway.run.asyncio.to_thread", _fake_to_thread)
|
||||
|
|
@ -242,11 +245,14 @@ class TestUsageAccountSection:
|
|||
"Provider: openai-codex (Pro)",
|
||||
],
|
||||
)
|
||||
# The credits block routes through the shared nous_credits_lines() helper;
|
||||
# stub it so this account-section test stays hermetic (no portal/auth lookup).
|
||||
monkeypatch.setattr("agent.account_usage.nous_credits_lines", lambda markdown=False: [])
|
||||
|
||||
event = MagicMock()
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert calls["args"] == ("openai-codex",)
|
||||
assert calls["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
account_call = next(c for c in calls if c["args"] == ("openai-codex",))
|
||||
assert account_call["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
assert "📊 **Session Info**" in result
|
||||
assert "📈 **Account limits**" in result
|
||||
|
|
|
|||
205
tests/run_agent/test_notice_spine.py
Normal file
205
tests/run_agent/test_notice_spine.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Regression tests for the notice-spine (AgentNotice + emitter callbacks).
|
||||
|
||||
Covers:
|
||||
A. _emit_notice / _emit_notice_clear emitter behaviour (bare AIAgent via
|
||||
object.__new__ — same pattern as test_steer.py and test_file_mutation_verifier.py).
|
||||
B. Constructor / init_agent signature threading.
|
||||
C. TUI _agent_cbs notice binding — mirrors the status_callback tests already
|
||||
in tests/test_tui_gateway_server.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.credits_tracker import AgentNotice
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
# ── A. Emitter behaviour ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _bare_agent() -> AIAgent:
|
||||
"""Build an AIAgent without running __init__ (no heavy init required).
|
||||
|
||||
Only the two callback slots used by _emit_notice / _emit_notice_clear are
|
||||
installed — mirrors the pattern in test_steer.py.
|
||||
"""
|
||||
agent = object.__new__(AIAgent)
|
||||
agent.notice_callback = None
|
||||
agent.notice_clear_callback = None
|
||||
return agent
|
||||
|
||||
|
||||
class TestEmitNotice:
|
||||
def test_emit_notice_calls_callback_with_exact_notice(self):
|
||||
agent = _bare_agent()
|
||||
received = []
|
||||
notice = AgentNotice(
|
||||
text="credits 90% used",
|
||||
level="warn",
|
||||
kind="sticky",
|
||||
ttl_ms=None,
|
||||
key="credits.warn90",
|
||||
id="n1",
|
||||
)
|
||||
agent.notice_callback = received.append
|
||||
agent._emit_notice(notice)
|
||||
assert received == [notice]
|
||||
|
||||
def test_emit_notice_clear_calls_callback_with_exact_key(self):
|
||||
agent = _bare_agent()
|
||||
received = []
|
||||
agent.notice_clear_callback = received.append
|
||||
agent._emit_notice_clear("credits.depleted")
|
||||
assert received == ["credits.depleted"]
|
||||
|
||||
def test_emit_notice_swallows_callback_exception(self):
|
||||
agent = _bare_agent()
|
||||
|
||||
def _boom(n):
|
||||
raise RuntimeError("renderer exploded")
|
||||
|
||||
agent.notice_callback = _boom
|
||||
# Must not raise.
|
||||
agent._emit_notice(AgentNotice(text="x"))
|
||||
|
||||
def test_emit_notice_clear_swallows_callback_exception(self):
|
||||
agent = _bare_agent()
|
||||
|
||||
def _boom(key):
|
||||
raise ValueError("clear renderer exploded")
|
||||
|
||||
agent.notice_clear_callback = _boom
|
||||
# Must not raise.
|
||||
agent._emit_notice_clear("some.key")
|
||||
|
||||
def test_emit_notice_no_op_when_callback_is_none(self):
|
||||
agent = _bare_agent()
|
||||
agent.notice_callback = None
|
||||
# Should not raise AttributeError or anything else.
|
||||
agent._emit_notice(AgentNotice(text="x"))
|
||||
|
||||
def test_emit_notice_clear_no_op_when_callback_is_none(self):
|
||||
agent = _bare_agent()
|
||||
agent.notice_clear_callback = None
|
||||
# Should not raise.
|
||||
agent._emit_notice_clear("any.key")
|
||||
|
||||
|
||||
# ── B. Constructor / init_agent signature threading ─────────────────────────
|
||||
|
||||
|
||||
class TestSignatureThreading:
|
||||
def test_agent_init_exposes_notice_callback(self):
|
||||
sig = inspect.signature(AIAgent.__init__)
|
||||
assert "notice_callback" in sig.parameters
|
||||
|
||||
def test_agent_init_exposes_notice_clear_callback(self):
|
||||
sig = inspect.signature(AIAgent.__init__)
|
||||
assert "notice_clear_callback" in sig.parameters
|
||||
|
||||
def test_init_agent_exposes_notice_callback(self):
|
||||
from agent.agent_init import init_agent
|
||||
sig = inspect.signature(init_agent)
|
||||
assert "notice_callback" in sig.parameters
|
||||
|
||||
def test_init_agent_exposes_notice_clear_callback(self):
|
||||
from agent.agent_init import init_agent
|
||||
sig = inspect.signature(init_agent)
|
||||
assert "notice_clear_callback" in sig.parameters
|
||||
|
||||
|
||||
# ── C. TUI _agent_cbs binding ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAgentCbsNoticeBinding:
|
||||
"""Mirror test_status_callback_emits_kind_and_text from test_tui_gateway_server.py."""
|
||||
|
||||
def test_notice_callback_emits_notification_show(self):
|
||||
from tui_gateway import server
|
||||
|
||||
with patch("tui_gateway.server._emit") as mock_emit:
|
||||
cbs = server._agent_cbs("sid123")
|
||||
notice = AgentNotice(
|
||||
text="credits 90% used",
|
||||
level="warn",
|
||||
kind="sticky",
|
||||
ttl_ms=None,
|
||||
key="credits.warn90",
|
||||
id="n1",
|
||||
)
|
||||
cbs["notice_callback"](notice)
|
||||
|
||||
mock_emit.assert_called_once_with(
|
||||
"notification.show",
|
||||
"sid123",
|
||||
{
|
||||
"text": "credits 90% used",
|
||||
"level": "warn",
|
||||
"kind": "sticky",
|
||||
"ttl_ms": None,
|
||||
"key": "credits.warn90",
|
||||
"id": "n1",
|
||||
},
|
||||
)
|
||||
|
||||
def test_notice_callback_payload_is_full_snake_case_dict(self):
|
||||
"""All six snake_case fields must be present in the payload — no extras,
|
||||
no camelCase variants."""
|
||||
from tui_gateway import server
|
||||
|
||||
captured = []
|
||||
with patch("tui_gateway.server._emit", side_effect=lambda *a: captured.append(a)):
|
||||
cbs = server._agent_cbs("sid123")
|
||||
cbs["notice_callback"](
|
||||
AgentNotice(
|
||||
text="credits 90% used",
|
||||
level="warn",
|
||||
kind="sticky",
|
||||
ttl_ms=None,
|
||||
key="credits.warn90",
|
||||
id="n1",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
_event_type, _sid, payload = captured[0]
|
||||
assert set(payload.keys()) == {"text", "level", "kind", "ttl_ms", "key", "id"}
|
||||
|
||||
def test_notice_clear_callback_emits_notification_clear(self):
|
||||
from tui_gateway import server
|
||||
|
||||
with patch("tui_gateway.server._emit") as mock_emit:
|
||||
cbs = server._agent_cbs("sid123")
|
||||
cbs["notice_clear_callback"]("credits.depleted")
|
||||
|
||||
mock_emit.assert_called_once_with(
|
||||
"notification.clear",
|
||||
"sid123",
|
||||
{"key": "credits.depleted"},
|
||||
)
|
||||
|
||||
def test_notice_callback_event_type_is_notification_show(self):
|
||||
from tui_gateway import server
|
||||
|
||||
captured = []
|
||||
with patch("tui_gateway.server._emit", side_effect=lambda *a: captured.append(a)):
|
||||
cbs = server._agent_cbs("sid123")
|
||||
cbs["notice_callback"](AgentNotice(text="any"))
|
||||
|
||||
assert captured[0][0] == "notification.show"
|
||||
|
||||
def test_notice_clear_callback_event_type_is_notification_clear(self):
|
||||
from tui_gateway import server
|
||||
|
||||
captured = []
|
||||
with patch("tui_gateway.server._emit", side_effect=lambda *a: captured.append(a)):
|
||||
cbs = server._agent_cbs("sid123")
|
||||
cbs["notice_clear_callback"]("some.key")
|
||||
|
||||
assert captured[0][0] == "notification.clear"
|
||||
assert captured[0][1] == "sid123"
|
||||
assert captured[0][2] == {"key": "some.key"}
|
||||
|
|
@ -724,6 +724,15 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|||
pass
|
||||
|
||||
_wire_callbacks(sid)
|
||||
# Hydrate credits notices at session OPEN (not just on the first
|
||||
# message), so depletion / usage-band warnings show at "ready". Runs
|
||||
# off the build thread, after the notice_callback is wired. Fail-open.
|
||||
try:
|
||||
from agent.credits_tracker import seed_credits_at_session_start
|
||||
|
||||
seed_credits_at_session_start(agent)
|
||||
except Exception:
|
||||
pass
|
||||
with _sessions_lock:
|
||||
if sid in _sessions:
|
||||
_sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
|
||||
|
|
@ -1679,6 +1688,15 @@ def _get_usage(agent) -> dict:
|
|||
usage["cost_usd"] = float(cost.amount_usd)
|
||||
except Exception:
|
||||
pass
|
||||
# Dev-only live credits-spent readout (L0 usage-aware-credits). Gated on
|
||||
# HERMES_DEV_CREDITS so the payload stays clean when the flag is off.
|
||||
if is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")):
|
||||
try:
|
||||
spent = agent.get_credits_spent_micros()
|
||||
if spent is not None:
|
||||
usage["dev_credits_spent_micros"] = int(spent)
|
||||
except Exception:
|
||||
pass
|
||||
return usage
|
||||
|
||||
|
||||
|
|
@ -2155,6 +2173,24 @@ def _agent_cbs(sid: str) -> dict:
|
|||
"status_callback": lambda kind, text=None: _status_update(
|
||||
sid, str(kind), None if text is None else str(text)
|
||||
),
|
||||
# Credits/notice spine (L1): an AgentNotice fired by the agent becomes a
|
||||
# notification.show WS event; a recovery clear becomes notification.clear.
|
||||
# Snake_case payload to match the existing gateway-event convention.
|
||||
"notice_callback": lambda n: _emit(
|
||||
"notification.show",
|
||||
sid,
|
||||
{
|
||||
"text": n.text,
|
||||
"level": n.level,
|
||||
"kind": n.kind,
|
||||
"ttl_ms": n.ttl_ms,
|
||||
"key": n.key,
|
||||
"id": n.id,
|
||||
},
|
||||
),
|
||||
"notice_clear_callback": lambda key: _emit(
|
||||
"notification.clear", sid, {"key": key}
|
||||
),
|
||||
"clarify_callback": lambda q, c: _block(
|
||||
"clarify.request", sid, {"question": q, "choices": c}
|
||||
),
|
||||
|
|
@ -3592,14 +3628,24 @@ def _(rid, params: dict) -> dict:
|
|||
if err:
|
||||
return err
|
||||
agent = session.get("agent")
|
||||
return _ok(
|
||||
rid,
|
||||
(
|
||||
_get_usage(agent)
|
||||
if agent is not None
|
||||
else {"calls": 0, "input": 0, "output": 0, "total": 0}
|
||||
),
|
||||
usage: dict = (
|
||||
_get_usage(agent)
|
||||
if agent is not None
|
||||
else {"calls": 0, "input": 0, "output": 0, "total": 0}
|
||||
)
|
||||
# Nous credits block — agent-independent (a portal fetch), so it shows even
|
||||
# with zero API calls or on a resumed session. The TUI /usage panel renders
|
||||
# these lines regardless of `calls`. Fail-open: [] when not logged into Nous
|
||||
# or on any portal hiccup.
|
||||
try:
|
||||
from agent.account_usage import nous_credits_lines
|
||||
|
||||
credits = nous_credits_lines()
|
||||
if credits:
|
||||
usage["credits_lines"] = credits
|
||||
except Exception:
|
||||
pass
|
||||
return _ok(rid, usage)
|
||||
|
||||
|
||||
@method("session.status")
|
||||
|
|
|
|||
|
|
@ -54,6 +54,57 @@ const findClickableWithText = (node: ReactNodeLike, needle: string): React.React
|
|||
return findClickableWithText(node.props.children, needle)
|
||||
}
|
||||
|
||||
// Find the innermost element whose own (direct) text content includes the
|
||||
// needle. Used to assert the colour the notice text is rendered with.
|
||||
const findElementWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findElementWithText(child, needle)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!React.isValidElement(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Prefer the deepest matching element so we get the leaf <Text> that
|
||||
// actually carries the colour, not an ancestor Box.
|
||||
const deeper = findElementWithText(node.props.children, needle)
|
||||
|
||||
if (deeper) {
|
||||
return deeper
|
||||
}
|
||||
|
||||
return textContent(node).includes(needle) ? node : null
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 100,
|
||||
cwdLabel: '~/repo',
|
||||
liveSessionCount: 0,
|
||||
model: 'opus-4.8',
|
||||
sessionStartedAt: null,
|
||||
showCost: false,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 },
|
||||
voiceLabel: ''
|
||||
}
|
||||
|
||||
describe('StatusRule session count click target', () => {
|
||||
it('makes the live session count itself clickable', () => {
|
||||
const openSwitcher = vi.fn()
|
||||
|
|
@ -111,3 +162,101 @@ describe('StatusRule session count click target', () => {
|
|||
expect(rendered).not.toContain('$0.5000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule credits notice render priority', () => {
|
||||
it('replaces the idle status with the notice text and keeps model + context', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ credits exhausted' }
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// Notice replaces the status verb slot …
|
||||
expect(rendered).toContain('✕ credits exhausted')
|
||||
expect(rendered).not.toContain('ready')
|
||||
// … but model + context stay visible.
|
||||
expect(rendered).toContain('opus 4.8')
|
||||
expect(rendered).toContain('50k')
|
||||
})
|
||||
|
||||
it('busy wins: the FaceTicker shows, the notice is hidden mid-turn', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
busy: true,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
turnStartedAt: Date.now()
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// Notice must NOT render while busy.
|
||||
expect(rendered).not.toContain('⚠ 90% used')
|
||||
// Model still visible.
|
||||
expect(rendered).toContain('opus 4.8')
|
||||
})
|
||||
|
||||
it('colours the notice by level (error → theme error, success → statusGood)', () => {
|
||||
const errEl = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' }
|
||||
})
|
||||
const errText = findElementWithText(errEl, '✕ exhausted')
|
||||
expect(errText?.props.color).toBe(DEFAULT_THEME.color.error)
|
||||
|
||||
const okEl = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 }
|
||||
})
|
||||
const okText = findElementWithText(okEl, '✓ restored')
|
||||
expect(okText?.props.color).toBe(DEFAULT_THEME.color.statusGood)
|
||||
})
|
||||
|
||||
it('does NOT add a glyph — the notice text is rendered verbatim', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }
|
||||
})
|
||||
const noticeText = findElementWithText(element, '90% used')
|
||||
|
||||
// The leaf carries exactly the policy text — no extra prepended glyph.
|
||||
expect(noticeText?.props.children).toBe('⚠ 90% used')
|
||||
})
|
||||
|
||||
it('the notice text is the shrinkable element (flexShrink=1 + truncate-end) so a long notice ellipsizes', () => {
|
||||
const longText = '⚠ ' + 'x'.repeat(200)
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
cols: 50,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: longText }
|
||||
})
|
||||
|
||||
// The leaf <Text> truncates rather than wrapping/clipping the pinned tail.
|
||||
const noticeText = findElementWithText(element, 'xxxxx')
|
||||
expect(noticeText?.props.wrap).toBe('truncate-end')
|
||||
|
||||
// Its container box yields first (flexShrink=1) so model stays visible.
|
||||
const findShrinkBoxContaining = (node: ReactNodeLike): React.ReactElement | null => {
|
||||
if (!React.isValidElement(node)) {
|
||||
if (Array.isArray(node)) {
|
||||
for (const c of node) {
|
||||
const f = findShrinkBoxContaining(c)
|
||||
if (f) return f
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (node.props.flexShrink === 1 && textContent(node).includes('xxxxx') && node.type !== StatusRule) {
|
||||
// Prefer the closest shrink box that wraps the notice text.
|
||||
const deeper = findShrinkBoxContaining(node.props.children)
|
||||
return deeper ?? node
|
||||
}
|
||||
return findShrinkBoxContaining(node.props.children)
|
||||
}
|
||||
const shrinkBox = findShrinkBoxContaining(element)
|
||||
expect(shrinkBox).not.toBeNull()
|
||||
|
||||
// Model survives on a narrow terminal because the notice yields.
|
||||
expect(textContent(element)).toContain('opus 4.8')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
73
ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx
Normal file
73
ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StatusRule } from '../components/appChrome.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
// DEV_CREDITS_MODE is a module-load-time constant (config/env.ts reads
|
||||
// process.env.HERMES_DEV_CREDITS exactly once, at import). Mutating process.env
|
||||
// inside a test can't flip it after the module is loaded — so mock the module to
|
||||
// the dev-on value for this file. vitest hoists vi.mock above the imports, so
|
||||
// appChrome picks up the mocked flag. Lives in its own file so the override
|
||||
// stays scoped (the other StatusRule tests run with the real, dev-off value).
|
||||
vi.mock('../config/env.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/env.js')>()
|
||||
return { ...actual, DEV_CREDITS_MODE: true }
|
||||
})
|
||||
|
||||
type ReactNodeLike = React.ReactNode
|
||||
|
||||
const textContent = (node: ReactNodeLike): string => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node)
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(textContent).join('')
|
||||
}
|
||||
|
||||
if (React.isValidElement(node)) {
|
||||
return textContent(node.props.children)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 100,
|
||||
cwdLabel: '~/repo',
|
||||
liveSessionCount: 0,
|
||||
model: 'opus-4.8',
|
||||
sessionStartedAt: null,
|
||||
showCost: false,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 },
|
||||
voiceLabel: ''
|
||||
}
|
||||
|
||||
describe('StatusRule dev-credits banner (HERMES_DEV_CREDITS on)', () => {
|
||||
it('keeps the dev-credits banner visible alongside a notice', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
usage: { ...baseProps.usage, dev_credits_spent_micros: 12_345 }
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// The notice and the dev banner coexist …
|
||||
expect(rendered).toContain('⚠ 90% used')
|
||||
expect(rendered).toContain('(dev credits)')
|
||||
// … and the Δ spend segment renders (12345 micros → 1.2¢).
|
||||
expect(rendered).toContain('Δ')
|
||||
})
|
||||
})
|
||||
|
|
@ -1214,4 +1214,316 @@ describe('createGatewayEventHandler', () => {
|
|||
|
||||
expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
|
||||
})
|
||||
|
||||
// ── Credits notice (Strategy B) ──────────────────────────────────────
|
||||
describe('credits notice', () => {
|
||||
it('shows a notice immediately when idle (no turn in flight)', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({
|
||||
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ credits exhausted' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
|
||||
expect(getUiState().notice).toMatchObject({
|
||||
key: 'credits.depleted',
|
||||
kind: 'sticky',
|
||||
level: 'error',
|
||||
text: '✕ credits exhausted'
|
||||
})
|
||||
})
|
||||
|
||||
it('holds a notice arriving mid-turn (busy) and flushes it at message.complete', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
expect(getUiState().busy).toBe(true)
|
||||
|
||||
onEvent({
|
||||
payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
|
||||
// Mid-turn: busy wins, notice is held, not visible yet.
|
||||
expect(getUiState().notice).toBeNull()
|
||||
|
||||
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
|
||||
|
||||
// Turn end flushes the held notice.
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.90', text: '⚠ 90% used' })
|
||||
})
|
||||
|
||||
it('flushes a held notice at interruptTurn (turn-end via ctrl-c)', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const ctx = buildCtx([])
|
||||
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
|
||||
const onEvent = createGatewayEventHandler(ctx)
|
||||
|
||||
patchUiState({ sid: 'sess-1' })
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({
|
||||
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ out' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
expect(getUiState().notice).toBeNull()
|
||||
|
||||
turnController.interruptTurn({
|
||||
appendMessage: vi.fn(),
|
||||
gw: ctx.gateway.gw,
|
||||
sid: 'sess-1',
|
||||
sys: ctx.system.sys
|
||||
})
|
||||
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ out' })
|
||||
} finally {
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('flushes a held notice at recordError (turn-end via error)', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({
|
||||
payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
expect(getUiState().notice).toBeNull()
|
||||
|
||||
onEvent({ payload: { message: 'boom' }, type: 'error' } as any)
|
||||
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.90', text: '⚠ 90% used' })
|
||||
})
|
||||
|
||||
it('latest-wins: a second mid-turn notice replaces the first held one', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({
|
||||
payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
onEvent({
|
||||
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
|
||||
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
|
||||
|
||||
// Only the latest held notice surfaces.
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ exhausted' })
|
||||
})
|
||||
|
||||
it('clears a visible notice only when the clear key matches (no-op otherwise)', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({
|
||||
payload: { key: 'credits.grant_spent', kind: 'sticky', level: 'warn', text: '⚠ grant spent' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
expect(getUiState().notice).not.toBeNull()
|
||||
|
||||
// Stale/late clear for a DIFFERENT key must not wipe the newer notice.
|
||||
onEvent({ payload: { key: 'credits.something_else' }, type: 'notification.clear' } as any)
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.grant_spent' })
|
||||
|
||||
// Matching key clears.
|
||||
onEvent({ payload: { key: 'credits.grant_spent' }, type: 'notification.clear' } as any)
|
||||
expect(getUiState().notice).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a held pending notice on a matching clear before it can surface', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({
|
||||
payload: { key: 'credits.grant_spent', kind: 'sticky', level: 'warn', text: '⚠ grant spent' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
// Clear arrives mid-turn before the held notice flushes.
|
||||
onEvent({ payload: { key: 'credits.grant_spent' }, type: 'notification.clear' } as any)
|
||||
|
||||
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
|
||||
|
||||
// Nothing surfaces — the pending notice was dropped by the matching clear.
|
||||
expect(getUiState().notice).toBeNull()
|
||||
})
|
||||
|
||||
it('a ttl notice self-expires after ttl_ms when applied while idle', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({
|
||||
payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ access restored', ttl_ms: 8000 },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.restored' })
|
||||
|
||||
vi.advanceTimersByTime(7999)
|
||||
expect(getUiState().notice).not.toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(getUiState().notice).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('R3-C2: a ttl notice self-expires even when statusTimer is also armed (timer isolation)', () => {
|
||||
// Regression guard for the whole reason `noticeTimer` is a separate
|
||||
// timer from `statusTimer`. A concurrent `status.update` (goal path)
|
||||
// arms `statusTimer` via restoreStatusAfter; if the two timers shared
|
||||
// a slot, clearing statusTimer would cancel the TTL and the notice
|
||||
// would never self-expire.
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const ctx = buildCtx([])
|
||||
const onEvent = createGatewayEventHandler(ctx)
|
||||
|
||||
// 1. While idle, show a ttl notice → applies immediately, arms noticeTimer.
|
||||
onEvent({
|
||||
payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.restored' })
|
||||
|
||||
// 2. A goal status.update arms turnController.statusTimer (via restoreStatusAfter).
|
||||
onEvent({
|
||||
payload: { kind: 'goal', text: '✓ Goal achieved: some reason' },
|
||||
type: 'status.update'
|
||||
} as any)
|
||||
// statusTimer is now live; notice must still be visible.
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.restored' })
|
||||
|
||||
// 3. Advance past the TTL — the notice's own dedicated timer fires.
|
||||
vi.advanceTimersByTime(8001)
|
||||
|
||||
// 4. Notice self-expired: statusTimer did NOT cancel noticeTimer.
|
||||
expect(getUiState().notice).toBeNull()
|
||||
} finally {
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts the ttl clock when the notice becomes VISIBLE (at turn end), not on arrival', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({
|
||||
payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
|
||||
// Long busy turn: the TTL must NOT have started while held.
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(getUiState().notice).toBeNull()
|
||||
|
||||
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.restored' })
|
||||
|
||||
// Full 8s starts now (on apply), so it survives nearly that long.
|
||||
vi.advanceTimersByTime(7999)
|
||||
expect(getUiState().notice).not.toBeNull()
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(getUiState().notice).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('latest-wins cancels a prior ttl timer so it cannot wipe the newer notice', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({
|
||||
payload: { id: 'a', key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ a', ttl_ms: 5000 },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
|
||||
vi.advanceTimersByTime(4000)
|
||||
|
||||
// A newer sticky arrives before the first's TTL fires.
|
||||
onEvent({
|
||||
payload: { id: 'b', key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ b' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
expect(getUiState().notice).toMatchObject({ id: 'b' })
|
||||
|
||||
// The first notice's stale TTL must NOT clear the newer one.
|
||||
vi.advanceTimersByTime(2000)
|
||||
expect(getUiState().notice).toMatchObject({ id: 'b', text: '✕ b' })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('sticky survives a turn: applied with no pending notice does not clear it', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
// A standing sticky notice from a prior turn.
|
||||
onEvent({
|
||||
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted' })
|
||||
|
||||
// A new turn runs with NO new notice arriving.
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({ payload: { text: 'reply' }, type: 'message.complete' } as any)
|
||||
|
||||
// The standing sticky must REappear untouched at turn end.
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ exhausted' })
|
||||
})
|
||||
|
||||
it('reset()/fullReset() clears pending + timer + visible notice (no cross-session leak)', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
// Session A: a visible sticky + a held pending notice mid-turn.
|
||||
onEvent({
|
||||
payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ A cut' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({
|
||||
payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ A 90%' },
|
||||
type: 'notification.show'
|
||||
} as any)
|
||||
expect(getUiState().notice).toMatchObject({ key: 'credits.depleted' })
|
||||
|
||||
// Session boundary.
|
||||
turnController.fullReset()
|
||||
expect(getUiState().notice).toBeNull()
|
||||
|
||||
// Session B: a turn ends with nothing held — A's notice must not bleed in.
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({ payload: { text: 'B reply' }, type: 'message.complete' } as any)
|
||||
expect(getUiState().notice).toBeNull()
|
||||
} finally {
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores a notification.show with no text', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: { key: 'credits.90', level: 'warn' }, type: 'notification.show' } as any)
|
||||
expect(getUiState().notice).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
32
ui-tui/src/__tests__/turnControllerNotice.test.ts
Normal file
32
ui-tui/src/__tests__/turnControllerNotice.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { turnController } from '../app/turnController.js'
|
||||
import { resetTurnState } from '../app/turnStore.js'
|
||||
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
|
||||
|
||||
// turnController.startMessage() treats the usage-band notice (credits.usage) as
|
||||
// "show until next prompt": a 50/75/90 heads-up flashes, then yields when the next
|
||||
// turn starts. Depletion (and other notices) are sticky until the policy clears them.
|
||||
describe('turnController.startMessage — usage-band notice clears on next prompt', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
resetTurnState()
|
||||
turnController.fullReset()
|
||||
})
|
||||
|
||||
it('clears a standing credits.usage notice when a new turn starts', () => {
|
||||
patchUiState({
|
||||
notice: { key: 'credits.usage', kind: 'sticky', level: 'warn', text: '⚠ Credits 90% used · $20.00 cap' }
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a sticky credits.depleted notice across a new turn', () => {
|
||||
patchUiState({
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /usage for balance' }
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice?.key).toBe('credits.depleted')
|
||||
})
|
||||
})
|
||||
|
|
@ -503,6 +503,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
return
|
||||
}
|
||||
|
||||
case 'notification.show': {
|
||||
// Credits/usage notice from the gateway. Payload is snake_case on the
|
||||
// wire and stays snake_case in UiState.notice (no mapping layer). The
|
||||
// text already carries its own glyph; turnController decides whether to
|
||||
// show now or hold until turn end (FaceTicker wins while busy).
|
||||
const p = ev.payload
|
||||
|
||||
if (!p?.text) {
|
||||
return
|
||||
}
|
||||
|
||||
turnController.showNotice({
|
||||
id: p.id,
|
||||
key: p.key,
|
||||
kind: p.kind ?? 'sticky',
|
||||
level: p.level ?? 'info',
|
||||
text: p.text,
|
||||
ttl_ms: p.ttl_ms ?? null
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'notification.clear':
|
||||
// Key-matched clear only — a stale/late clear must not wipe a newer
|
||||
// notice (turnController guards the key match).
|
||||
turnController.clearNotice(ev.payload?.key)
|
||||
|
||||
return
|
||||
case 'gateway.stderr': {
|
||||
const line = String(ev.payload.line).slice(0, 120)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,22 @@ export type StatusBarMode = 'bottom' | 'off' | 'top'
|
|||
|
||||
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
||||
|
||||
export type NoticeLevel = 'error' | 'info' | 'success' | 'warn'
|
||||
|
||||
// Credits/usage notice surfaced in the status bar. Shape is snake_case to
|
||||
// match the gateway WS wire (`notification.show` payload) and the existing
|
||||
// `Usage` type — no camelCase mapping layer. The `text` already carries its
|
||||
// own leading glyph (⚠ • ✕ ✓) from the Python policy, so the renderer only
|
||||
// colours it by `level` and never adds another glyph.
|
||||
export interface Notice {
|
||||
id?: string
|
||||
key?: string
|
||||
kind?: 'sticky' | 'ttl'
|
||||
level?: NoticeLevel
|
||||
text: string
|
||||
ttl_ms?: null | number
|
||||
}
|
||||
|
||||
// Single source of truth for indicator style names. Union type is
|
||||
// derived from this tuple so adding/removing a style only touches one
|
||||
// line — `useConfigSync` (validation) and `session.ts` (slash arg
|
||||
|
|
@ -106,6 +122,7 @@ export interface UiState {
|
|||
liveSessionCount: number
|
||||
inlineDiffs: boolean
|
||||
mouseTracking: MouseTrackingMode
|
||||
notice: Notice | null
|
||||
pasteCollapseLines: number
|
||||
pasteCollapseChars: number
|
||||
|
||||
|
|
|
|||
|
|
@ -518,7 +518,7 @@ export const sessionCommands: SlashCommand[] = [
|
|||
},
|
||||
|
||||
{
|
||||
help: 'session usage (live counts — worker sees zeros)',
|
||||
help: 'session usage + Nous credits',
|
||||
name: 'usage',
|
||||
run: (_arg, ctx) => {
|
||||
ctx.gateway.rpc<SessionUsageResponse>('session.usage', { session_id: ctx.sid }).then(r => {
|
||||
|
|
@ -532,8 +532,19 @@ export const sessionCommands: SlashCommand[] = [
|
|||
})
|
||||
}
|
||||
|
||||
// Nous credits block is agent-independent (a portal fetch), so it shows
|
||||
// even with zero API calls or on a resumed session. Render it whenever
|
||||
// present, before the token panel.
|
||||
const creditsLines = r?.credits_lines ?? []
|
||||
if (creditsLines.length) {
|
||||
ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }])
|
||||
}
|
||||
|
||||
if (!r?.calls) {
|
||||
return ctx.transcript.sys('no API calls yet')
|
||||
if (!creditsLines.length) {
|
||||
ctx.transcript.sys('no API calls yet')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const f = (v: number | undefined) => (v ?? 0).toLocaleString()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
|
||||
|
||||
import type { Notice } from './interfaces.js'
|
||||
import { resetFlowOverlays } from './overlayStore.js'
|
||||
import { pushSnapshot } from './spawnHistoryStore.js'
|
||||
import { archiveDoneTodos, getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||
|
|
@ -132,6 +133,17 @@ class TurnController {
|
|||
private streamDelay = STREAM_IDLE_BATCH_MS
|
||||
private toolProgressTimer: Timer = null
|
||||
|
||||
// ── Credits notice machinery (Strategy B) ───────────────────────────
|
||||
//
|
||||
// A notice arriving mid-turn must NOT show (FaceTicker wins while busy);
|
||||
// it is held here, latest-wins, and applied at turn end via onTurnEnd().
|
||||
// The TTL clock starts only when the notice becomes VISIBLE (on apply),
|
||||
// never on arrival, so an 8s "restored" notice shows for its full life.
|
||||
// `noticeTimer` is DEDICATED — it is never the shared `statusTimer`.
|
||||
private pendingNotice: Notice | null = null
|
||||
private noticeTimer: Timer = null
|
||||
private noticeIdSeq = 0
|
||||
|
||||
boostStreamingForTyping() {
|
||||
this.streamDelay = STREAM_TYPING_BATCH_MS
|
||||
}
|
||||
|
|
@ -157,6 +169,100 @@ class TurnController {
|
|||
this.statusTimer = clear(this.statusTimer)
|
||||
}
|
||||
|
||||
// ── Notice: arrival ──────────────────────────────────────────────────
|
||||
//
|
||||
// A `notification.show` arrived. If a turn is in flight (`busy`), the
|
||||
// FaceTicker owns the verb slot, so we hold the notice (latest-wins) and
|
||||
// let onTurnEnd() apply it when the turn finishes. If idle, apply it now.
|
||||
// The Python side emits STABLE ids per notice kind (e.g. `credits.warn90`,
|
||||
// `credits.restored`), NOT unique-per-emission ids. The id-guard in
|
||||
// applyNotice() is a defensive backup; the primary latest-wins mechanism is
|
||||
// that applyNotice/clearNotice always cancel the prior timer first.
|
||||
showNotice(notice: Notice) {
|
||||
const stamped: Notice = { ...notice, id: notice.id || `n${++this.noticeIdSeq}` }
|
||||
|
||||
if (getUiState().busy) {
|
||||
this.pendingNotice = stamped
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.applyNotice(stamped)
|
||||
}
|
||||
|
||||
// ── Notice: clear by key (R3-H3 / HIGH-3) ────────────────────────────
|
||||
//
|
||||
// `notification.clear` only clears the visible notice when its key
|
||||
// matches — a stale/late clear must not wipe a NEWER notice. Always drop
|
||||
// a matching pending notice so it can't resurface at the next turn end.
|
||||
clearNotice(key?: string) {
|
||||
if (this.pendingNotice && this.pendingNotice.key === key) {
|
||||
this.pendingNotice = null
|
||||
}
|
||||
|
||||
if (getUiState().notice?.key === key) {
|
||||
this.clearNoticeTimer()
|
||||
patchUiState({ notice: null })
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a notice to the visible UI state and (re)arm its TTL clock.
|
||||
// Latest-wins: clear any prior TTL timer FIRST so an older notice's
|
||||
// expiry can't wipe this one. A 'ttl' notice with `ttl_ms` self-expires;
|
||||
// 'sticky' (default) persists until an explicit clear.
|
||||
private applyNotice(notice: Notice) {
|
||||
this.clearNoticeTimer()
|
||||
patchUiState({ notice })
|
||||
|
||||
if (notice.kind === 'ttl' && typeof notice.ttl_ms === 'number' && notice.ttl_ms > 0) {
|
||||
const id = notice.id
|
||||
|
||||
this.noticeTimer = setTimeout(() => {
|
||||
this.noticeTimer = null
|
||||
|
||||
// Defensive backup: the prior timer was already cancelled by
|
||||
// clearNoticeTimer() when a newer notice was applied, so in
|
||||
// practice this guard only fires for the notice that armed it.
|
||||
if (getUiState().notice?.id === id) {
|
||||
patchUiState({ notice: null })
|
||||
}
|
||||
}, notice.ttl_ms)
|
||||
}
|
||||
}
|
||||
|
||||
private clearNoticeTimer() {
|
||||
this.noticeTimer = clear(this.noticeTimer)
|
||||
}
|
||||
|
||||
// ── Notice: turn-end flush (R3-C1 / R3-H4) ───────────────────────────
|
||||
//
|
||||
// Invoked ONLY by the three real turn-end sites (recordMessageComplete,
|
||||
// interruptTurn, recordError) — NEVER by idle()/reset(), which would leak
|
||||
// session A's notice into session B. Applies a pending NEW notice so it
|
||||
// appears now that FaceTicker has yielded; the TTL clock starts here, when
|
||||
// the notice first becomes visible. With no pending notice this is a
|
||||
// no-op, so a standing sticky notice REappears untouched after the turn.
|
||||
private flushPendingNotice() {
|
||||
if (!this.pendingNotice) {
|
||||
return
|
||||
}
|
||||
|
||||
const notice = this.pendingNotice
|
||||
this.pendingNotice = null
|
||||
this.applyNotice(notice)
|
||||
}
|
||||
|
||||
// Drop all notice state — pending + timer + visible (R3-H5). Called by
|
||||
// reset()/fullReset() so a session A notice can't bleed into session B.
|
||||
private clearNoticeState() {
|
||||
this.pendingNotice = null
|
||||
this.clearNoticeTimer()
|
||||
|
||||
if (getUiState().notice) {
|
||||
patchUiState({ notice: null })
|
||||
}
|
||||
}
|
||||
|
||||
endReasoningPhase() {
|
||||
this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer)
|
||||
patchTurnState({ reasoningActive: false, reasoningStreaming: false })
|
||||
|
|
@ -238,6 +344,9 @@ class TurnController {
|
|||
this.statusTimer = null
|
||||
patchUiState({ status: 'ready' })
|
||||
}, INTERRUPT_COOLDOWN_MS)
|
||||
|
||||
// Real turn end: surface any notice held back while busy.
|
||||
this.flushPendingNotice()
|
||||
}
|
||||
|
||||
pruneTransient() {
|
||||
|
|
@ -440,6 +549,9 @@ class TurnController {
|
|||
this.segmentMessages = []
|
||||
this.turnTools = []
|
||||
this.persistedToolLabels.clear()
|
||||
|
||||
// Real turn end: surface any notice held back while busy.
|
||||
this.flushPendingNotice()
|
||||
}
|
||||
|
||||
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
|
||||
|
|
@ -533,6 +645,10 @@ class TurnController {
|
|||
this.interrupted = false
|
||||
patchTurnState({ activity: [], outcome: '' })
|
||||
|
||||
// Real turn end: surface any notice held back while busy. Done after
|
||||
// idle() flips busy=false so applyNotice() reaches the visible slot.
|
||||
this.flushPendingNotice()
|
||||
|
||||
return { finalMessages, finalText, wasInterrupted }
|
||||
}
|
||||
|
||||
|
|
@ -735,6 +851,9 @@ class TurnController {
|
|||
this.turnTools = []
|
||||
this.toolTokenAcc = 0
|
||||
this.persistedToolLabels.clear()
|
||||
// Session boundary: drop notice state so session A's sticky can't bleed
|
||||
// into session B (R3-H5). reset()/fullReset() CLEAR — they never flush.
|
||||
this.clearNoticeState()
|
||||
patchTurnState({ activity: [], outcome: '' })
|
||||
}
|
||||
|
||||
|
|
@ -788,6 +907,13 @@ class TurnController {
|
|||
this.toolTokenAcc = 0
|
||||
this.interrupted = false
|
||||
this.persistedToolLabels.clear()
|
||||
// Usage-band notices (credits.usage) are "show until next prompt": a 50/75/90
|
||||
// heads-up should flash and then yield, not camp the bar. Clear it as a new
|
||||
// turn starts. Depletion (credits.depleted) and other notices stay — they're
|
||||
// explicitly sticky until the policy clears them.
|
||||
if (getUiState().notice?.key === 'credits.usage') {
|
||||
this.clearNotice('credits.usage')
|
||||
}
|
||||
patchUiState({ busy: true })
|
||||
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const buildUiState = (): UiState => ({
|
|||
liveSessionCount: 0,
|
||||
inlineDiffs: true,
|
||||
mouseTracking: MOUSE_TRACKING,
|
||||
notice: null,
|
||||
pasteCollapseLines: 5,
|
||||
pasteCollapseChars: 2000,
|
||||
sections: {},
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState }
|
|||
import unicodeSpinners from 'unicode-animations'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
import type { IndicatorStyle } from '../app/interfaces.js'
|
||||
import type { IndicatorStyle, Notice } from '../app/interfaces.js'
|
||||
import { useTurnSelector } from '../app/turnStore.js'
|
||||
import { DEV_CREDITS_MODE } from '../config/env.js'
|
||||
import { FACES } from '../content/faces.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
import { fmtDuration } from '../domain/messages.js'
|
||||
|
|
@ -185,6 +186,26 @@ function statusSessionCountLabel(count: number) {
|
|||
return `${count} ${count === 1 ? 'session' : 'sessions'}`
|
||||
}
|
||||
|
||||
// Colour a credits notice by its level. The notice TEXT already carries its
|
||||
// own glyph (⚠ • ✕ ✓) from the Python policy — we only tint it here, never
|
||||
// prepend another glyph. `success` maps to the theme's green status colour.
|
||||
function noticeColor(level: Notice['level'], t: Theme): string {
|
||||
if (level === 'error') {
|
||||
return t.color.error
|
||||
}
|
||||
|
||||
if (level === 'warn') {
|
||||
return t.color.warn
|
||||
}
|
||||
|
||||
if (level === 'success') {
|
||||
return t.color.statusGood
|
||||
}
|
||||
|
||||
// 'info' / undefined — keep it readable but understated.
|
||||
return t.color.accent
|
||||
}
|
||||
|
||||
function ctxBar(pct: number | undefined, w = 10) {
|
||||
const p = Math.max(0, Math.min(100, pct ?? 0))
|
||||
const filled = Math.round((p / 100) * w)
|
||||
|
|
@ -376,6 +397,7 @@ export function StatusRule({
|
|||
modelFast,
|
||||
modelReasoningEffort,
|
||||
indicatorStyle = 'kaomoji',
|
||||
notice,
|
||||
usage,
|
||||
bgCount,
|
||||
liveSessionCount,
|
||||
|
|
@ -403,13 +425,32 @@ export function StatusRule({
|
|||
const bar = !segs.compactCtx && usage.context_max ? ctxBar(pct) : ''
|
||||
const modelText = modelLabel(model, modelReasoningEffort, modelFast)
|
||||
|
||||
// A credits notice replaces the status/verb slot, but only when idle —
|
||||
// while busy the FaceTicker always wins (R1 render priority). The notice
|
||||
// text carries its own glyph; we only tint it (R1) and let it shrink (R3-M7).
|
||||
const showNotice = !busy && !!notice?.text
|
||||
// The notice slot is shrinkable (flexShrink={1}, truncate-end), so reserve
|
||||
// only a small bounded width for it in the essentials budget — enough that
|
||||
// a short notice never gets crushed, but a long one ellipsizes instead of
|
||||
// shoving `model │ ctx` off-screen (R3-M7). Cap at the notice's own width
|
||||
// so short notices reserve exactly what they need.
|
||||
const NOTICE_RESERVE_MAX = 24
|
||||
const noticeReserve = showNotice ? Math.min(stringWidth(notice!.text), NOTICE_RESERVE_MAX) : 0
|
||||
|
||||
// Width of the must-keep left segments (indicator + model + context). They
|
||||
// are pinned (never shrink) and reserved so the cwd/branch on the right
|
||||
// yields first. The busy face width depends on the active /indicator style
|
||||
// (kaomoji is wide + verb; unicode is a bare 1-col spinner).
|
||||
// (kaomoji is wide + verb; unicode is a bare 1-col spinner). When a notice
|
||||
// occupies the slot it reserves only `noticeReserve` (it shrinks/truncates).
|
||||
const slotWidth = busy
|
||||
? busyIndicatorWidth(indicatorStyle, turnStartedAt != null)
|
||||
: showNotice
|
||||
? noticeReserve
|
||||
: stringWidth(status)
|
||||
|
||||
const essentialWidth =
|
||||
stringWidth('─ ') +
|
||||
(busy ? busyIndicatorWidth(indicatorStyle, turnStartedAt != null) : stringWidth(status)) +
|
||||
slotWidth +
|
||||
stringWidth(' │ ') +
|
||||
stringWidth(modelText) +
|
||||
(ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0)
|
||||
|
|
@ -436,6 +477,14 @@ export function StatusRule({
|
|||
const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : ''
|
||||
const compressions = typeof usage.compressions === 'number' ? usage.compressions : 0
|
||||
const costText = typeof usage.cost_usd === 'number' ? `$${usage.cost_usd.toFixed(4)}` : ''
|
||||
// Dev-only readout (HERMES_DEV_CREDITS). The server omits the key entirely unless the
|
||||
// flag is on, so this segment self-hides for normal users. micros→cents is allowed money
|
||||
// math (display formatting) — never parseFloat a *_usd. Signed: a mid-session top-up that
|
||||
// raises remaining nets a negative Δ (honest).
|
||||
const devCreditsText =
|
||||
typeof usage.dev_credits_spent_micros === 'number'
|
||||
? `Δ ${(usage.dev_credits_spent_micros / 10000).toFixed(1)}¢`
|
||||
: ''
|
||||
|
||||
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
|
||||
const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH)
|
||||
|
|
@ -444,6 +493,9 @@ export function StatusRule({
|
|||
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
|
||||
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
|
||||
const showCostSeg = segs.cost && showCost && !!costText && fits(SEP + stringWidth(costText))
|
||||
// No segs flag / no showCost coupling — it's a server-gated dev readout, lowest priority,
|
||||
// so it consumes tail budget LAST and drops first on a narrow terminal.
|
||||
const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText))
|
||||
|
||||
const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => {
|
||||
event.stopImmediatePropagation?.()
|
||||
|
|
@ -461,16 +513,37 @@ export function StatusRule({
|
|||
return (
|
||||
<Box height={1}>
|
||||
<Box flexDirection="row" flexShrink={1} overflow="hidden" width={leftWidth}>
|
||||
{/* Pinned essentials — never shrink, always visible. */}
|
||||
{/* Leading pinned chrome: border + busy face / idle status. When a
|
||||
notice occupies the slot the status text is dropped — the notice
|
||||
renders as a separate shrinkable box below so a long notice
|
||||
ellipsizes instead of crushing model │ ctx (R3-M7). */}
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
<Text color={t.color.border}>{'─ '}</Text>
|
||||
{busy ? (
|
||||
<FaceTicker color={statusColor} startedAt={turnStartedAt} style={indicatorStyle} />
|
||||
) : (
|
||||
) : showNotice ? null : (
|
||||
<Text color={statusColor} wrap="truncate-end">
|
||||
{status}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{/* Notice slot — the only shrinkable left element (R3-M7). Sits in a
|
||||
flexShrink={1} box with truncate-end so it yields/ellipsizes
|
||||
before the pinned model │ ctx box ever clips. */}
|
||||
{showNotice ? (
|
||||
<Box flexDirection="row" flexShrink={1} overflow="hidden">
|
||||
<Text color={noticeColor(notice!.level, t)} wrap="truncate-end">
|
||||
{notice!.text}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{/* Pinned essentials — model + context never shrink, always visible. */}
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
{DEV_CREDITS_MODE ? (
|
||||
<Text color={t.color.warn} wrap="truncate-end">
|
||||
{' (dev credits)'}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
{modelText}
|
||||
|
|
@ -526,6 +599,12 @@ export function StatusRule({
|
|||
{costText}
|
||||
</Text>
|
||||
) : null}
|
||||
{showDevCredits ? (
|
||||
<Text color={t.color.accent} wrap="truncate-end">
|
||||
{' │ '}
|
||||
{devCreditsText}
|
||||
</Text>
|
||||
) : null}
|
||||
{/* SpawnHud isn't part of the tail budget (its width is dynamic), so it
|
||||
renders last — any overflow truncates the HUD itself rather than the
|
||||
budgeted segments before it. It self-hides when no delegation runs. */}
|
||||
|
|
@ -654,6 +733,7 @@ interface StatusRuleProps {
|
|||
modelFast?: boolean
|
||||
modelReasoningEffort?: string
|
||||
indicatorStyle?: IndicatorStyle
|
||||
notice?: Notice | null
|
||||
sessionStartedAt?: null | number
|
||||
showCost: boolean
|
||||
status: string
|
||||
|
|
|
|||
|
|
@ -370,6 +370,7 @@ const StatusRulePane = memo(function StatusRulePane({
|
|||
model={ui.info?.model ?? ''}
|
||||
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
|
||||
modelReasoningEffort={ui.info?.reasoning_effort}
|
||||
notice={ui.notice}
|
||||
onSessionCountClick={() => patchOverlayState({ sessions: true })}
|
||||
sessionStartedAt={status.sessionStartedAt}
|
||||
showCost={ui.showCost}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all
|
|||
|
||||
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
|
||||
|
||||
// HERMES_DEV_CREDITS — dev-only live-spend readout (Δ status segment + "(dev credits)"
|
||||
// banner). Throwaway dev scaffolding; the whole readout gates on this one flag.
|
||||
export const DEV_CREDITS_MODE = truthy(process.env.HERMES_DEV_CREDITS)
|
||||
|
||||
const inlineOverride = parseToggle(process.env.HERMES_TUI_INLINE)
|
||||
|
||||
// Skip AlternateScreen — TUI renders into the primary buffer so the host
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ export interface SessionUsageResponse {
|
|||
context_used?: number
|
||||
cost_status?: 'estimated' | 'exact'
|
||||
cost_usd?: number
|
||||
credits_lines?: string[]
|
||||
input?: number
|
||||
model?: string
|
||||
output?: number
|
||||
|
|
@ -512,6 +513,19 @@ export type GatewayEvent =
|
|||
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
|
||||
| { payload?: undefined; session_id?: string; type: 'message.start' }
|
||||
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
|
||||
| {
|
||||
payload?: {
|
||||
id?: string
|
||||
key?: string
|
||||
kind?: 'sticky' | 'ttl'
|
||||
level?: 'error' | 'info' | 'success' | 'warn'
|
||||
text?: string
|
||||
ttl_ms?: null | number
|
||||
}
|
||||
session_id?: string
|
||||
type: 'notification.show'
|
||||
}
|
||||
| { payload?: { key?: string }; session_id?: string; type: 'notification.clear' }
|
||||
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
|
||||
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
|
||||
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export interface Usage {
|
|||
context_used?: number
|
||||
cost_status?: string
|
||||
cost_usd?: number
|
||||
dev_credits_spent_micros?: number
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue