feat(billing): /credits command — balance + portal top-up handoff#44776
Merged
Conversation
Add the terminal side of the billing slice (phase 2a): start a top-up by
throwing the user to the portal billing page with the top-up modal open. The
terminal does not confirm, poll, or track payment — checkout completes in the
browser and the next /usage shows the new balance.
- nous_account.py: parse organisation.slug/name from /api/oauth/account into
NousPortalAccountInfo; add nous_portal_topup_url() building the org-pinned
{base}/orgs/{slug}/billing?topup=open with a null-slug fallback to the legacy
{base}/billing?topup=open (never /orgs/None/...).
- portal_cli.py: 'hermes portal topup' — fresh account fetch, identity line
(Topping up as <email> / org <name>), browser open with printed-URL fallback,
no-wait closing copy. No polling/confirmation (deferred to 2b).
- account_usage.py: the shared /usage credits block now links the org-pinned
top-up URL (auto-opens the modal) + points to the command.
Depends on NAS #409 (organisation.slug/name + ?topup=open). Do not merge until
that is live on the target env; until then /api/oauth/account returns
organisation: { id } only and the URL falls back to legacy.
Replace the standalone `hermes portal topup` subcommand with an in-session /credits slash command — a focused money surface (balance in, top-up out) that works in the CLI, TUI, and every messaging platform from one registry entry. - commands.py: register /credits (Info category). Slack is at its 50-slash cap, so /credits is routed via /hermes credits on Slack only (new _SLACK_VIA_HERMES_ONLY set) to avoid clamping a canonical command off the native list and breaking Telegram parity; native everywhere else. - account_usage.py: build_credits_view() — one portal fetch → balance lines + identity line + org-pinned top-up URL + depleted flag, consumed by all surfaces. Reuses the same snapshot/URL builder as /usage so numbers match. - cli.py: _show_credits() — balance block + identity line + 3-button panel (Open top-up / Copy link / Cancel) via the existing prompt_toolkit modal. ASK, never auto-launch; headless falls back to printing the URL. - gateway/slash_commands.py: _handle_credits_command() — renders the block + tappable top-up URL + no-wait copy; works on button and plain-text platforms. - /usage credits line now points to /credits. - Retire `hermes portal topup` (portal_cli.py back to baseline); the engine (slug/name parse + nous_portal_topup_url) stays as the shared core. No polling, no payment confirmation (billing phase 2a). Depends on NAS #409.
In the TUI, /credits runs in the slash-worker subprocess where there is no live prompt_toolkit app and stdin is the JSON-RPC pipe. _show_credits called the 3-button modal unconditionally, which fell back to reading stdin → exception → slash.exec rejected → the command produced no output (only the pre-existing 'Credit access paused' banner showed). - _show_credits: when self._app is None (TUI worker / piped / non-interactive), render the text variant — balance block + tappable top-up URL + no-wait line, same affordance as the messaging surfaces — and skip the modal entirely. The 3-button panel still renders in the interactive CLI. - Depleted banner copy: 'run /usage for balance' → 'run /credits to top up' now that /credits is the dedicated money surface (+ tests). - Regression tests: _show_credits with self._app=None renders text and never invokes the modal; logged-out path.
Add a credits.view JSON-RPC method returning the structured CreditsView
(logged_in, balance_lines, identity_line, topup_url, depleted) so the TUI can
render a clickable <Link> top-up button instead of plain text. Account-
independent (portal fetch gated on a logged-in Nous account), fail-open to
{logged_in: false} on any hiccup. Mirrors session.usage's credits-block pattern.
Frontend (TUI-local /credits command + Ink component) lands separately.
TUI-local /credits: fetches the structured balance via the credits.view RPC, prints the balance + identity + top-up URL, then arms the EXISTING confirm overlay (Enter = open top-up in browser via openExternalUrl, Esc = cancel). Reuses ConfirmReq — no new overlay component/state/input handler. Headless (openExternalUrl returns false) falls back to printing the URL. - gatewayTypes.ts: CreditsViewResponse. - commands/credits.ts: the command (mirrors /status's rpc+guarded pattern). - registry.ts: register creditsCommands. - test: balance+overlay armed, headless fallback, no-url, logged-out (4 cases). Matches the CLI /credits 'Enter to open' affordance. Phase 2a: no polling.
CI test_catalog_keys_match_english enforces every locale has the exact key set of en.yaml. The /credits work added gateway.credits.not_logged_in to en.yaml only; add a translated entry to all 15 non-English catalogs so the invariant holds and non-English users don't fall back to English for this string.
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
2 |
unresolved-import |
1 |
First entries
tests/agent/test_credits_view.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
run_agent.py:2886: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
✅ Fixed issues (3):
| Rule | Count |
|---|---|
unresolved-import |
1 |
invalid-assignment |
1 |
unresolved-attribute |
1 |
First entries
tests/hermes_cli/test_portal_cli.py:13: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
hermes_cli/nous_account.py:633: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `None` in union `Any | None | dict[Unknown, Unknown]`
Unchanged: 5692 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
Collaborator
Author
|
@rob-maron Tagging you on this credits-notice item as the soft maintainer (follow-up to #43669). |
AIalliAI
added a commit
to AIalliAI/Hermes
that referenced
this pull request
Jun 12, 2026
Brings in NousResearch#44776 (/credits command), NousResearch#44778 (Teams DOCUMENT attachments), NousResearch#43508 (Yuanbao wechat forward msg), NousResearch#44792 (profile-scope Channels endpoints + per-profile .env seeding).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Adds a
/creditscommand — a focused in-session "balance in, top-up out" surface across CLI, TUI, and messaging. It shows the Nous credit balance and hands the user off to the portal billing page (with the top-up modal auto-open) in their browser. No polling, no payment confirmation — checkout completes in the browser; the next/creditsshows the new balance.Before / after
/usagefirehose (rate limits + token table + cost + context + credits), top-up = a plain/billinglink/credits— focused balance block + identity line + top-up handoff{portal}/billing(legacy shim, multi-org disambiguation){portal}/orgs/{slug}/billing?topup=open(org-pinned, modal auto-opens); null-slug → legacy?topup=open/api/oauth/accountparseorganisation.slug/nameslug/nameintoNousPortalAccountInfoConfirmReqHow it's wired
hermes_cli/nous_account.py— parseorganisation.slug/name; addnous_portal_topup_url()(org-pinned with null-slug fallback, URL-encoded slug, trailing-slash safe).agent/account_usage.py—build_credits_view(): one portal fetch →{logged_in, balance_lines, identity_line, topup_url, depleted}, the surface-agnostic core consumed by all three surfaces (same numbers as/usage).hermes_cli/commands.py— register/credits(oneCommandDef→ CLI dispatch, gateway, Telegram/Slack, autocomplete, TUI parity). Slack is at its hard 50-slash cap, so/creditsis routed via/hermes creditson Slack only (_SLACK_VIA_HERMES_ONLY) — native everywhere else — to avoid clamping/debugoff and breaking Telegram parity.cli.py—_show_credits(): 3-button panel interactively; text variant (balance + URL) in the TUI slash-worker / any non-interactive context (self._app is None), because the modal would read the worker's RPC stdin and crash it.gateway/slash_commands.py—_handle_credits_command(): block + tappable URL + no-wait copy.tui_gateway/server.py—credits.viewRPC returning the structured view.ui-tui/— TUI-local/creditscommand renders the balance and arms the existing confirm overlay (Enter =openExternalUrl, Esc = cancel). No new overlay component/state.Out of scope (deferred)
Terminal polling / payment confirmation / correlation handles / intent records; any billing-API HTTP calls from the terminal; card saving; off-session charging; auto-top-up config from terminal.
Tests
build_credits_viewcore (logged-out, org-pinned, null-slug fallback, depleted, fetch-failure)._show_creditsnon-interactive path renders text and never invokes the modal (regression for the TUI-worker crash); logged-out path./creditsresolves, available on all surfaces); Slack-via-/hermes parity./credits(vitest): balance + overlay armed, headless fallback, no-url, logged-out.npm test --prefix ui-tuifor the new TUI test + slash parity;tsc --noEmitclean.Commits (not squashed — logical steps)
feat(billing): /usage → portal top-up browser handoff— backend slug/name + topup URL builderfeat(billing): /credits command for balance + top-up handoff— CLI/gateway command + Slack-cap curationfix(credits): /credits works in the TUI slash-worker (non-interactive)feat(tui): credits.view RPC for the /credits top-up handofffeat(tui): /credits command with keyboard-driven top-up confirm