Files
hermes-agent/hermes_cli/nous_account.py
Siddharth Balyan 70f5dc5f46 feat(connectors): the backend API for the desktop Connectors page; connect an app without a chat session (#115191)
* feat(connectors): the backend serves a connector's tool list, cached for 24 hours

The Connectors page opens one app and shows every tool it has. The backend
had no way to read that list.

- `tools/connectors/portal/`: a client for the portal's tool-list route and a
  JSON cache under the Hermes home, one file per portal origin and connector.
  An entry is fresh for 24 hours. After that the read revalidates with the
  stored ETag: 304 keeps the list, 404 deletes the entry, an upstream failure
  serves the stored list marked stale, and a 401 never serves the cache.
- `connectors.tools {slug, refresh}`: account-level, routed by `profile`, no
  chat session. Errors carry a fixed `reason` from one closed set on the rail.
- Every connector model that is not operation state moves into
  `tui_gateway/contracts/connectors.py`. Handlers that no chat session owns
  live in `tui_gateway/methods_connectors_account.py`.

The wire model is tolerant: an unknown facet reads as unclassified and one odd
tool never blanks a connector.

* feat(connectors): catalog, accounts and member tool rules by RPC

The Connectors page needs the app catalog, the connected account of one app,
a way to disconnect it, and the member's own on/off rules. None had an RPC.

- `connectors.catalog`: name, description, category and logo of each app.
- `connectors.accounts`, `connectors.accounts.remove`: read the accounts at
  the tool gateway and remove one by id.
- `connectors.policy.get`: the rule layers that apply to the member, widest
  first. The body is a union on `mode`, so a reader can name who turned a
  tool off.
- `connectors.policy.set`: one change, a union on `type` (the tools of one
  connector, or one connector on or off), with the revision the user saw. A
  stale revision answers `POLICY_CONFLICT`. The backend composes the upstream
  write in one pure function, so no renderer learns the upstream rules.
- Bundled MCP manifests can name their hosted twin with `connector:`, so the
  page can show one card per app.

* feat(connectors): connect an app without a chat session

Every connector RPC took a `session_id`, and a connect that did not come from
the model's tool call minted a link with no watcher. The Connectors page has
no chat session, and its card must flip to connected by itself.

- `connectors.list`, `connectors.connect`, `connectors.operation.status`,
  `connectors.operation.wake` and `connection.respond` take `owner`, a union
  on `type`: `session` (today's behaviour and authorization) or `account`
  (routed by `profile`, authorized by the live transport like `mcp.*`).
  `session_id` is gone from these params; every desktop caller sends `owner`.
- An account connect runs the same operation lifecycle on a background
  thread, under the profile's scope, so the watcher reads the account and
  settles the operation. A second connect for an app that is already
  connecting returns the open operation and mints nothing.
- `connection.update` carries `owner`. An account operation has no session to
  address, so its updates go out on the session-less broadcast path.

* feat(mcp-catalog): eighteen more bundled entries name their hosted connector

A bundled MCP entry and a hosted connector for the same app are one card
on the Connectors page only when the manifest names its hosted twin.
Linear and Notion had the field. These entries get it too: airtable,
asana, attio, calendly, dropbox, figma, railway, supabase, todoist,
betterstack, canva, cloudflare, datadog, intercom, neon, sentry, stripe
and vercel. Atlassian maps to two hosted connectors and Prisma Postgres
is not clearly the same app, so both stay without one.

* refactor(connectors): the account handlers share one gate, one params model and one write table

The six account-level handlers each repeated the availability gate, the
auth catch and the catch-all reply. One decorator now owns that, and each
handler validates its params with its contract model instead of a ladder
of isinstance checks. The five connection RPCs share one guard for the
unexpected-failure reply.

The four write composers for the member rules were the same function
with a different list key and polarity. They are one table now.

The owner union lives in contracts/common.py, so the params side and the
event side stop declaring it twice and the import cycle is gone.

An account operation start carries one event and a flag, so the wait for
the sign-in link blocks instead of polling every 50 ms. run_operation
loses its two account-only parameters; drive_operation is the second
entry point.

Tests: four deleted (they exercised pydantic or the mock), three merged
into tables, two added (a client that still sends the old top-level
session_id is refused; all six account RPCs run off the server loop).
The shared reply helper and the HTTP and managed-client fakes move to
one place each. Comments are one line or gone.

* fix(connectors): a missing tool-list route reads as "unavailable", not "connector gone"

The tool-list read treated every 404 as the portal's "this connector is
not in the catalog" answer. It deleted the cache entry and answered
CONNECTOR_NOT_FOUND, so a page would offer to remove an app that is
connected and works. A portal that does not serve the route yet answers
a bare 404 for every app.

Only the portal's own {"error": "connector_not_found"} means the
connector is gone. Any other 404 is now a tool-list outage: the cached
list is served as stale, or the RPC answers TOOLS_UNAVAILABLE.

* fix(connectors): a connect from the page returns to the app after sign-in

The sign-in link carries a return target only when the session's surface
is the desktop. A chat session binds that surface. An account-owned call
has no chat session, so nothing bound it: the link was minted without a
return target and the browser ended on the portal's done page instead of
coming back to Hermes.

Every account-owned call now runs with the process's own surface bound,
next to its profile scope. The operation thread copies that context, so
the first link and every reissued link carry the return target and the
operation id.

* test(connectors): defer the new connector RPC coverage

The tests for the new account RPCs, the portal client, the tool-list cache
and the rule composer leave this PR and come back in one later change, after
the API is settled. The same was done for #111008.

Kept: the edits that existing tests need because the five connection RPCs
now take `owner` instead of `session_id`, and the rename of the managed
client seam.

Removed: six new test files, their two fakes and the gateway conftest, and
the new cases in test_mcp_catalog.py, test_connectors_gateway_client.py,
gateway-rpc.test.ts and notifications.test.ts. Reverting this commit restores
all of them.

* fix(cli): the connection panel hands the tool thread back at once

The classic CLI's connection callback waited on a queue for the user's first
decision. The operation's watcher starts only after the callback returns, and
the watcher is what polls a hosted account, runs the 300-second deadline and
sees Ctrl+C.

For a hosted connector the panel opens on the sign-in link, where the only
key that filled the queue was Cancel. The account was never polled: the user
signed in, the panel never changed, and Esc reported the app as skipped.
Ctrl+C set the interrupt flag but left the thread parked on the queue, so the
turn never ended.

The callback now opens the panel and returns, as the gateway's callback does
for the desktop and the Ink TUI. The panel's actions already reach the
operation through apply_answer on the UI thread, so the queue is removed. An
install with a form still waits for Connect, because the backend starts no
work for a pending row. Ctrl+C now settles the operation as `interrupt`, and
open rows become `not_connected`.

Checked on the e2e rig with the fake tool gateway: hosted connect completes on
the third status read; Ctrl+C ends the turn and the polling stops; an MCP
install with a plain and a secret field still saves config and both values.

* fix(connectors): "run it again" lives in the library, so the classic CLI can use it

Making a new sign-in link for a failed or expired hosted connector was
implemented only in the JSON-RPC layer (`_reissue`). The classic CLI does not
go through JSON-RPC: its Connect button on a failed row called apply_answer,
which does nothing for a hosted operation because it has no MCP runner. The
panel showed "Waiting…" until the deadline.

`tools.connectors.run.reissue(operation, names)` now holds the checks and the
per-kind action, and returns a refusal reason or None. The gateway maps each
reason to the same JSON-RPC error as before. The CLI calls it for a hosted
row; a refusal is shown on the row. MCP rows keep their path, because Connect
on a failed MCP row re-sends the form values.

Checked on the e2e rig: a scripted failed sign-in, then Connect: a second mint
with `reinitiate: true`, a new link with a new connection id, then connected.

* feat(connectors): the account list and disconnect go through the portal

`connectors.accounts` and `connectors.accounts.remove` called the tool
gateway. They now call the portal's account-management routes
(`GET /api/v1/connectors/accounts`, `DELETE /api/v1/connectors/accounts/{id}`),
which apply the organisation membership checks and write the disconnect audit
row. There is no fallback to the gateway when the portal is unavailable, and a
removal is never retried.

The read of ONE account stays on the gateway (`GET v1/connectors/accounts/{id}`):
the portal has no such route, and the operation watcher polls it once per second.

`ConnectorClient.list_accounts` and `delete_account` are removed. The removed
account's reply model carries `connector`, which both services send.

* fix(connectors): the account RPCs answer what the portal really sends

Checked against the portal source and against the staging and production
services.

- Errors are read from the upstream error code, not the HTTP status. A rule
  write answered 409 for a stale revision and for a user with no organisation;
  both read as "the policy changed". `org_required` is now `ORG_REQUIRED` and
  403 `no_access` is `ORG_ACCESS_DENIED` on every account RPC; only a rejected
  sign-in is `NEEDS_NOUS_AUTH`. `connectors.list` and `connectors.connect` with
  the account owner map these too.
- `connectors.policy.get` and `connectors.policy.set` carry `effective`: the
  portal's own result for this user, with its stamp and without provider or
  subject ids. Nothing is recomputed locally.
- A rule write needs the revision the user saw: `expected_revision` is required
  and must be a revision string; a bad one is refused before any HTTP call.
- A tool row carries `no_auth`; a list without the upstream flag is an invalid
  answer, not `false`.
- `connectors.accounts.remove` returns the app of the removed account. An
  invalid id is `INVALID_PARAMS`.
- The tool-list cache is per signed-in member (a hash of the token's `sub`),
  so two Nous accounts on one profile do not share entries.
- A malformed slug is a local error, not a 404 from a server nobody called.

Live, staging: no revision and a malformed revision refused locally; a good
revision wrote one disabled Gmail tool and returned it in `effective`; the
same revision again answered `POLICY_CONFLICT`; the list row showed the tool;
the restore brought the member rules back to the start. Live, staging and
production, read-only: all 60 tool lists (5483 tools) parse.

* fix(connectors): the operation RPCs match their contract; a settled card cannot start a new link

Found by two adversarial reviews of the RPC layer and its types.

- `connectors.connect` from a chat session with no open operation is refused
  (`UNKNOWN_OPERATION`). It used to call `manage_connections` through the tool
  registry with no card: it made a link nobody watched, returned a reply
  without the required `settled` field, and named an operation that was never
  registered. There is one way into an operation: the agent's call, or the
  account owner's `connectors.connect`. "Run it again" inside an open
  operation is unchanged.
- `connection.update` for a session is routed by session key AND profile; two
  profiles with the same key no longer cross-deliver a sign-in link. The event
  payload gets the same redaction as the RPC replies.
- `connection.respond` runs on the long-handler pool: an approval can start MCP
  OAuth discovery, which blocked every RPC of the gateway while it ran.
- `connectors.list` rows are a closed snake_case model: `connector`, `enabled`,
  `connected`, `connection_status`, `status_reason`, `gateway_disabled_tools`.
  The last one is display data: the gateway enforces the rules, the backend
  only passes the list on. The phantom `name` and `description` are gone, and
  the desktop uses the generated types instead of hand-written copies.
- `tools_listing` (model-only data) no longer rides on `connectors.operation.status`.
- `unavailable` is removed from the target states and settle reasons: nothing
  produces it. The contract generator now fails when a contract enum and its
  domain enum differ.
- `ConnectorErrorReason` is part of the generated TypeScript and OpenRPC.
- The desktop sends `connection.respond` on the socket that holds the session,
  as wake and reissue already did.
- Contract violations are logged every time, at error level.
- An account connect whose prepare step is slow returns the live operation
  instead of an error while the operation keeps running.
- The MCP-manifest `connector` field leaves this PR (it moves to a later one
  on top of the catalog-reader change). `hermes_cli/mcp_catalog.py` and
  `optional-mcps/` are untouched by this PR again.

anti-slop: no net-new findings (15 touched files).

* fix(connectors): the model gets no sign-in link wherever a card exists; side agents cannot connect

The flag that tells the model "a connection card exists" was the session
platform (`== "desktop"`). The Ink TUI and the classic CLI also draw a card,
so there a connector call on an unconnected app handed the model the raw
`connect_url` and told it to pass the link to the user.

- The agent turn now declares how a link can reach the user
  (`tools/connectors/turn.py`): CARD when the agent was built with a
  connection callback, SIDE for a subagent or a background turn, LINK for a
  headless run (`-q`, cron, ACP, api_server, messaging). It is set once per
  tool batch in the agent loop and read by the connector dispatch path, which
  never sees the agent. The session platform decides return-to-app only.
- CARD: the result carries `connect_card_available` and our hint, never the
  link and never the gateway's own hint.
- SIDE: subagents (`delegate_tool`), gateway background turns and the classic
  CLI `/bg` are built with `side_agent=True`. They hold no `manage_connections`
  tool on any path that derives the tool list, and a connector call on an
  unconnected app gets no link, only "report this to the main agent".
- LINK is unchanged.
- The hosted path with no card builds a detached operation, as the MCP path
  does, so no `connection.update` is emitted for an operation no client asked
  for. Names and docstrings that said "off desktop" now say "no card".
- A settled card is dead on the desktop: `reissueConnectionTarget` and
  `respondToConnectionRequest` share one guard and send nothing for a settled
  or unknown operation.
- The model-facing settled result no longer carries `connection_id`; the model
  repeated it to the user.

Shown on the real clients with a real model (rig, fake tool gateway): Ink TUI
and classic CLI get `connect_card_available` and no link, the model opens the
card, the account connects, the retried call succeeds; `-q` still gets the
link; a subagent and a background turn have no `manage_connections` and get
the no-link hint; on the desktop a card settled with Continue has no enabled
control and sends no RPC.

* feat(tools): every call made through tool_search + tool_call shows a real label on all three clients

A bridged call showed as a generic `tool_call` row in the Ink TUI and as
`⚡ tool_call` in the classic CLI, because the display looked the name up in
the tool registry and bridged names are made at run time. The desktop labelled
only batches that were all hosted connector calls, by parsing names itself.

- `tools/tool_labels.py` is the one place that turns a bridged call into a
  label: kind, app, action, emoji and text. Hosted: `connectors__gmail__GMAIL_SEND_EMAIL`
  → "Gmail · send email". MCP: "Linear · list issues". A local deferred tool
  keeps its own emoji, verb and primary-argument preview. A batch gets exactly
  one label per entry, always; an entry with no name gets a generic label.
- Classic CLI: one row per inner call; the duration on the last row; the
  failure text on the row of the call that failed. With friendly labels off
  it prints what it printed before.
- Gateway: tool start, progress and complete events and stored transcript rows
  carry a typed `labels` field. It does not depend on the classic CLI's
  display setting. Clients no longer parse tool names.
- Ink TUI: rows from the labels; the verbose trail keeps Args and Result.
- Desktop: `ConnectorExecution` renders hosted, MCP and mixed turns from the
  labels, one row per call. The labels reach the row under a key no tool
  argument can use. The connect card it drew under a failed tool result is
  gone: after `CONNECTION_REQUIRED` the one way in is the agent's own
  `manage_connections` call.
- `tool_search` and `tool_describe` rows read "Searching tools · <query>" and
  "Reading tool details · N tools".

Shown on the real desktop (video and screenshots), the Ink TUI and the classic
CLI with the rig: hosted rows, MCP rows, a two-entry batch, a failed entry, a
`CONNECTION_REQUIRED` row with no card under it, labels after a reload, and the
desktop rows with the classic CLI setting off.

* fix(connectors): the model can tell "hosted tools unavailable" from "no such tool"; manage_connections routes MCP names correctly

- A failed hosted search or describe used to return nothing, by design, so the
  model saw only local tools and told the user that a connected app was
  missing. The local results are unchanged; when the hosted leg failed, the
  `tool_search` and `tool_describe` results carry
  `connectors: {status: "unavailable", reason: "unreachable" | "sign_in_expired"}`
  and one hint line. A rejected token is `sign_in_expired`; an entitlement
  refusal or a shut gate adds nothing. `tool_describe` no longer lists those
  names under `not_found` next to "search again".
- NS-932. The description now says which side a name belongs to: a bare name
  is a hosted connector account; `mcp: true` only when the user asks for an MCP
  server, a local server or an install, or when the name exists only in the
  catalog; connect and reconnect are hosted verbs, install, enable and
  authorize are MCP verbs. It names the three clients that draw a card.
- A misrouted target is refused with the call that works. Only when the
  gateway does not know the connector (confirmed on that failure path) and the
  name is a catalog entry does the target fail with "X is a local MCP server.
  Call manage_connections with action install ...". It is a per-target
  outcome: other targets of the same call keep their links and their card. A
  vendor failure on a name both sides know stays an ordinary failed row. The
  MCP side mirrors it, and never for an entry that is only not installed.
- "Do not re-ask after a skip or a timeout" no longer stops the model when the
  USER asks for that app again; the description and the settled-result notes
  say so. A builder saw the model refuse a direct user request.

Shown on the Ink TUI and the classic CLI with a real model: a dead gateway and
a 401; "connect fxmail" goes hosted; "install the fx-noauth MCP server" goes
MCP; "connect fx-noauth" reaches the MCP install card in one corrective round
with no hosted mint; a two-target call where one is misrouted still connects
the other with exactly one mint.

* fix(tui): the connection card answers every key, shows what is happening, and is dead once settled

Reproduced on the real Ink TUI with the rig, then fixed:

- The keyboard was dead during the sign-in wait: the card kept a `submitting`
  flag that the normal OAuth path never cleared, and Esc went through the same
  guard. The in-flight state now belongs to the answered row and clears when
  that row moves, when any later frame of the operation arrives, or after
  five seconds. Esc skips the row in every phase; Ctrl+C interrupts the turn
  (the input handler had no branch for this overlay); Shift+arrows scroll the
  transcript and the card ignores them; arrow keys no longer move the text
  cursor and the field focus at once.
- The card was lost at turn idle: the overlay flag was cleared while the
  operation stayed in the store, and a resume dropped the pending card. The
  flag survives idle, a resume shows the pending card again, a session switch
  clears it.
- States with no branch: `not_connected` and a row with no link fell into the
  credential form; `expired` vanished with no note. The title and the row text
  now name the action (connect, reconnect, install, enable, authorize); a
  failed or expired row with no fields offers Try again / Skip; a failed row
  WITH fields reopens the form over the typed draft, with the failure above it.
- A settled card is dead: at settle the overlay closes and one transcript line
  per app states the outcome. A settled or dismissed operation id is
  remembered, so no replay or resume can reopen its card. Esc in the last
  "Finishing…" moment hides the card and still writes the outcome lines.
- A failed `connection.respond` and a browser that did not open are shown on
  the card in one sentence.

Also: `tui_gateway/connector_payload.py` redacted the BOOLEAN `secret` flag of
a credential field to the string "[REDACTED]". On the desktop every credential
field therefore rendered as a password and lost its prefilled default. A
boolean is no longer redacted.

* chore(connectors): remove the comments and docstrings this branch added

Deletions only. Kept: tool directives (`# noqa`, `// eslint-disable`, ...),
`// SAFETY:` lines, and the docstrings of the contract models under
`tui_gateway/contracts/`, which become the descriptions in the generated
OpenRPC and TypeScript.

Checked that no code changed: every Python file has the same AST as before
once docstrings and `pass` are ignored (62 files), and every TypeScript file
prints the same with comments stripped by the TypeScript printer (32 files).
The generated contract files are unchanged.

* fix(connectors): a card restored after a reload answers again; every account RPC names auth and org failures

Found by the end-to-end runs on the pushed head.

- Desktop: after a window reload, Continue on the restored card sent nothing.
  The answer looked up the backend that holds the session with the runtime
  session id, the lookup wants the stored id, and a failed lookup returned
  silently. When the lookup gives no owner the answer now goes out on the
  window's active socket, which is what main does.
- `connectors.policy.get` answered `POLICY_UNAVAILABLE` for a rejected sign-in,
  a refused scope, a non-member and a missing organisation alike: the handler
  runs with the gateway's globals and did not import the reason enum, so its
  own error mapping raised. `connectors.accounts.remove` caught auth failures
  in its generic branch. `org_required` was mapped on `policy.set` only. All
  six account RPCs now answer `NEEDS_NOUS_AUTH`, `FORBIDDEN_SCOPE`,
  `ORG_ACCESS_DENIED` and `ORG_REQUIRED` for those four upstream answers.
2026-09-22 13:57:51 +05:30

635 lines
27 KiB
Python

"""Normalized Nous Portal account entitlement helpers."""
from __future__ import annotations
import hashlib
import json
import threading
import time
import urllib.request
from dataclasses import dataclass, field, fields
from datetime import datetime, timezone
from typing import Any, Literal, Optional
NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "error"]
# Free tool-pool coverage categories, byte-aligned with the Portal's TOOL_COVERAGE_CATEGORIES
# (minted into `tool_access.coverage` on the JWT and /api/oauth/account). `fal-video` is
# intentionally excluded from the pool.
TOOL_COVERAGE_CATEGORIES = ("firecrawl", "fal", "fal-video", "openai-audio", "browser-use", "modal")
_ACCOUNT_INFO_CACHE_TTL = 60
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
@dataclass(frozen=True)
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
@dataclass(frozen=True)
class NousPaidServiceAccessInfo:
allowed: Optional[bool] = None
paid_access: Optional[bool] = None
reason: Optional[str] = None
organisation_id: Optional[str] = None
effective_at_ms: Optional[int] = None
has_active_subscription: Optional[bool] = None
active_subscription_is_paid: Optional[bool] = None
subscription_tier: Optional[int] = None
subscription_monthly_charge: Optional[float] = None
subscription_credits_remaining: Optional[float] = None
purchased_credits_remaining: Optional[float] = None
total_usable_credits: Optional[float] = None
member_spend_cap_exceeded: Optional[bool] = None
member_spend_cap_usd: Optional[float] = None
member_spend_usd: Optional[float] = None
member_spend_cap_remaining_usd: Optional[float] = None
@dataclass(frozen=True)
class NousToolAccessInfo:
"""Free tool-pool entitlement (Portal ``tool_access``), decoupled from paid/billing access.
``enabled``: a positive pool balance is live and not gated off; ``coverage``: tool category ->
whether the pool funds it (FAL video is excluded).
"""
enabled: bool = False
coverage: dict[str, bool] = field(default_factory=dict)
_ANON_ACCOUNT_TIER = "anonymous"
# Every billing / top-up / entitlement surface says exactly this for the free tier (R-USR-1).
FREE_TIER_NEEDS_ACCOUNT = "This needs a Nous account. Run `hermes auth upgrade`."
FREE_TIER_NEEDS_ACCOUNT_CHAT = "This needs a Nous account. Use /login to sign in."
def _is_anonymous_tier(account_info: Optional["NousPortalAccountInfo"]) -> bool:
return account_info is not None and account_info.account_tier == _ANON_ACCOUNT_TIER
@dataclass(frozen=True)
class NousPortalAccountInfo:
logged_in: bool
source: NousAccountInfoSource
fresh: bool
user_id: Optional[str] = None
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
client_id: Optional[str] = None
product_id: Optional[str] = None
nous_client: Optional[str] = None
portal_base_url: Optional[str] = None
inference_base_url: Optional[str] = None
inference_credential_present: bool = False
credential_source: Optional[str] = None
expires_at: Optional[datetime] = None
email: Optional[str] = None
privy_did: Optional[str] = None
subscription: Optional[NousPortalSubscriptionInfo] = None
paid_service_access: Optional[bool] = None
paid_service_access_info: Optional[NousPaidServiceAccessInfo] = None
tool_access: Optional[NousToolAccessInfo] = None
raw_claims: Optional[dict[str, Any]] = None
raw_account: Optional[dict[str, Any]] = None
error: Optional[str] = None
# NAS account tier claim; ``"anonymous"`` is the free tier (no Nous account behind it).
account_tier: Optional[str] = None
# Portal ``managed_tools`` (JWT claim and account API): the portal has enabled connectors for
# this account. ``None`` = the portal did not say (a token minted before the claim shipped).
managed_tools: Optional[bool] = None
@property
def is_paid(self) -> bool:
return self.paid_service_access is True
@property
def is_anonymous_tier(self) -> bool:
"""The free tier: no Nous account, so no billing, credits, or entitlement to speak of."""
return self.account_tier == _ANON_ACCOUNT_TIER
@property
def is_free_tier(self) -> bool:
return self.paid_service_access is False
@property
def tool_gateway_entitled(self) -> bool:
"""Paid access OR a live free tool pool; use ``tool_gateway_entitled_for`` per category."""
return self.paid_service_access is True or bool(self.tool_access and self.tool_access.enabled)
def tool_gateway_entitled_for(self, category: str) -> bool:
"""Paid users are entitled everywhere; pool users only where ``coverage[category]`` is true."""
ta = self.tool_access
return self.paid_service_access is True or bool(ta and ta.enabled and ta.coverage.get(category) is True)
@property
def managed_tools_rolled_out(self) -> bool:
"""Only a literal ``true`` from the portal counts; absent and unknown both read as out."""
return self.managed_tools is True
def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
"""Return the billing URL for a normalized Nous account snapshot."""
try:
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL
except Exception:
DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
base = account_info.portal_base_url if account_info is not None else None
if not _nonblank(base):
base = DEFAULT_NOUS_PORTAL_URL
return f"{base.rstrip('/')}/billing"
def nous_portal_topup_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
"""Portal top-up URL (``?topup=open`` auto-opens the top-up modal).
Prefers the org-pinned ``{base}/orgs/{slug}/billing`` (skips the legacy shim's multi-org
re-resolution); falls back to ``{base}/billing`` when ``org_slug`` is null — never
``/orgs/None/billing``.
"""
base = nous_portal_billing_url(account_info)[: -len("/billing")]
slug = getattr(account_info, "org_slug", None) if account_info is not None else None
if _nonblank(slug):
from urllib.parse import quote
return f"{base}/orgs/{quote(slug.strip(), safe='')}/billing?topup=open"
return f"{base}/billing?topup=open"
def format_nous_portal_entitlement_message(
account_info: Optional[NousPortalAccountInfo], *, capability: str = "this feature",
include_refresh_hint: bool = True, coverage_category: Optional[str] = None,
in_chat: bool = False,
) -> Optional[str]:
"""User-facing guidance for a missing Nous tool-gateway entitlement; ``None`` when entitled.
Entitled = paid access OR a live free pool that covers it (normalized fields, not price:
purchased credits without a subscription count as paid, an exhausted paid subscription does
not). ``coverage_category`` scopes the check to one category; an otherwise-entitled user whose
access doesn't fund it gets a neutral billing nudge, never an "exhausted" message. The
pool-vs-paid distinction is never surfaced.
"""
if _is_anonymous_tier(account_info):
return FREE_TIER_NEEDS_ACCOUNT_CHAT if in_chat else FREE_TIER_NEEDS_ACCOUNT
billing_url = nous_portal_billing_url(account_info)
if account_info is not None:
if coverage_category is not None:
if account_info.tool_gateway_entitled_for(coverage_category):
return None
if account_info.tool_gateway_entitled:
return (
f"{capability} isn't included with your current Nous Portal access. "
f"Add credits or a subscription to enable it at {billing_url}."
)
elif account_info.tool_gateway_entitled:
return None
if account_info is None:
return (
f"Hermes could not verify your Nous Portal entitlement, so {capability} is unavailable. "
f"Run `hermes model` to refresh your login, or check billing at {billing_url}."
)
if not account_info.logged_in:
if account_info.inference_credential_present:
return (
f"Nous inference credentials are configured, but Hermes cannot verify your Nous Portal "
f"paid access for {capability}. Log in with `hermes model` to enable Portal-managed "
f"features. Billing and credits are managed at {billing_url}."
)
return (
f"Log in to Nous Portal to use {capability}: run `hermes model`. "
f"Billing and credits are managed at {billing_url}."
)
if account_info.paid_service_access is None:
detail = f"Hermes could not verify your Nous Portal paid access, so {capability} is unavailable."
if account_info.error:
detail += f" Account lookup failed: {account_info.error}."
if include_refresh_hint:
detail += " Run `hermes model` to refresh your session."
return detail + f" Check billing at {billing_url}."
access = account_info.paid_service_access_info
reason = access.reason if access else None
if reason == "account_missing":
return (
f"Hermes could not find a Nous Portal account or organisation for this login, so {capability} "
f"is unavailable. Run `hermes model` to authenticate again; if the problem persists, contact Nous support."
)
if reason == "no_usable_credits" or account_info.paid_service_access is False:
message = _no_paid_access_message(account_info, capability, billing_url, in_chat=in_chat)
if include_refresh_hint and not account_info.fresh:
message += " If you recently bought credits, run `hermes model` to refresh Hermes."
return message
return (
f"Your Nous Portal account does not currently have paid service access, "
f"so {capability} is unavailable. Add credits or update billing at {billing_url}."
)
def _no_paid_access_message(
account_info: NousPortalAccountInfo, capability: str, billing_url: str, *, in_chat: bool = False,
) -> str:
if _is_anonymous_tier(account_info):
return FREE_TIER_NEEDS_ACCOUNT_CHAT if in_chat else FREE_TIER_NEEDS_ACCOUNT
access = account_info.paid_service_access_info or NousPaidServiceAccessInfo()
active, paid = access.has_active_subscription, access.active_subscription_is_paid
labelled = (
("usable", access.total_usable_credits),
("subscription", access.subscription_credits_remaining),
("purchased", access.purchased_credits_remaining),
)
parts = [f"{label} ${amount:.2f}" for label, amount in labelled if amount is not None]
credit_detail = f" ({', '.join(parts)})" if parts else ""
if access.member_spend_cap_exceeded:
cap, spent = access.member_spend_cap_usd, access.member_spend_usd
cap_detail = ""
if cap is not None and spent is not None:
cap_detail = f" Your organisation's per-member spend cap is ${cap:.2f} and you've spent ${spent:.2f} of it."
elif cap is not None:
cap_detail = f" Your organisation's per-member spend cap is ${cap:.2f}."
return (
f"Your Nous Portal access is paused because you've exceeded the per-member spend cap set by "
f"your organisation.{cap_detail}{credit_detail} Ask your organisation admin to raise the "
f"member spend cap at {billing_url}, then run `hermes model` to refresh."
)
if active and paid:
return (
f"Your Nous Portal credits are exhausted{credit_detail}, so {capability} is unavailable. "
f"Top up or renew credits at {billing_url}."
)
if active and paid is False:
return (
f"Your current Nous Portal plan does not include paid service access, "
f"so {capability} is unavailable. Upgrade or add credits at {billing_url}."
)
if active is False:
return (
f"Your Nous Portal account has no active subscription or usable credits{credit_detail}, "
f"so {capability} is unavailable. Subscribe or add credits at {billing_url}."
)
return (
f"Your Nous Portal account has no usable paid credits{credit_detail}, so "
f"{capability} is unavailable. Add credits or update billing at {billing_url}."
)
def reset_nous_portal_account_info_cache() -> None:
"""Clear the short-lived account-info cache used by tests."""
global _account_info_cache
_account_info_cache = None
def get_nous_portal_account_info(*, force_fresh: bool = False, min_jwt_ttl_seconds: int = 60) -> NousPortalAccountInfo:
"""Normalized Nous Portal account entitlement.
A valid unexpired OAuth JWT serves as a local snapshot (UX gating only; the server stays
authoritative). ``force_fresh=True`` always calls ``/api/oauth/account`` and bypasses the cache.
"""
try:
from hermes_cli.auth import get_provider_auth_state
state = get_provider_auth_state("nous") or {}
except Exception as exc:
return _error_info(error=exc, logged_in=False)
access_token = state.get("access_token")
portal_base_url = _portal_base_url(state)
if not _nonblank(access_token):
return (
_info_from_oauth_pool(force_fresh, min_jwt_ttl_seconds, portal_base_url)
or _info_from_inference_key_pool(portal_base_url)
or NousPortalAccountInfo(logged_in=False, source="none", fresh=False, portal_base_url=portal_base_url)
)
if not force_fresh:
jwt_info = _info_from_valid_jwt(access_token, state, portal_base_url, min_jwt_ttl_seconds)
if jwt_info is not None:
return jwt_info
return _fresh_account_info(state, force_fresh, portal_base_url)
def nous_policy_present() -> Optional[bool]:
"""Whether the caller's org carries a restrictive model/provider policy.
``None`` is unknown (older mint / unreadable claim) and must not be reported as "no policy".
"""
try:
from hermes_cli.auth import get_provider_auth_state, _decode_jwt_claims
access_token = (get_provider_auth_state("nous") or {}).get("access_token")
if not _nonblank(access_token):
return None
claims = _decode_jwt_claims(access_token)
return _coerce_bool(claims.get("policy_present")) if claims else None
except Exception:
return None
def nous_policy_notice(*, removed: bool) -> str:
"""One-line notice for a list the org's policy narrowed, else ``""``.
Blocked models are omitted, which reads as "unsupported"; this says which it is without
enumerating them. ``removed`` must reflect whether the filter actually dropped anything — the
catalog read fails open, so the claim alone would label a full list as filtered.
"""
if not removed or nous_policy_present() is not True:
return ""
return (
"Your organization restricts which models are available — "
"models outside its policy are not listed."
)
def _fresh_account_info(state: dict[str, Any], force_fresh: bool, portal_base_url: Optional[str]) -> NousPortalAccountInfo:
global _account_info_cache
try:
from hermes_cli.auth import get_provider_auth_state, resolve_nous_access_token
access_token = resolve_nous_access_token()
refreshed_state = get_provider_auth_state("nous") or state
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
digest = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
cache_key = f"{portal_base_url or ''}:{digest}"
with _ACCOUNT_INFO_CACHE_LOCK:
if not force_fresh and _account_info_cache is not None:
cached_key, cached_at, cached_info = _account_info_cache
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
return cached_info
info = _info_from_fetched_account(access_token, refreshed_state, portal_base_url)
if info.source != "error":
with _ACCOUNT_INFO_CACHE_LOCK:
_account_info_cache = (cache_key, time.monotonic(), info)
return info
except Exception as exc:
return _error_info(error=exc, logged_in=bool(state.get("access_token")), portal_base_url=portal_base_url)
def _info_from_inference_key_pool(portal_base_url: Optional[str]) -> Optional[NousPortalAccountInfo]:
"""Return an explicit unknown-entitlement snapshot for opaque Nous keys."""
try:
entry = _select_nous_pool_entry()
if entry is None:
return None
if not _nonblank(getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")):
return None
return NousPortalAccountInfo(
logged_in=False, source="inference_key", fresh=False,
portal_base_url=getattr(entry, "portal_base_url", None) or portal_base_url,
inference_base_url=_pool_entry_inference_url(entry),
inference_credential_present=True,
credential_source=f"pool:{getattr(entry, 'label', 'unknown')}",
error="portal_oauth_missing",
)
except Exception:
return None
def _info_from_oauth_pool(
force_fresh: bool, min_jwt_ttl_seconds: int, portal_base_url: Optional[str]
) -> Optional[NousPortalAccountInfo]:
try:
entry = _select_nous_pool_entry()
except Exception:
return None
if entry is None or not _pool_entry_is_portal_oauth(entry):
return None
access_token = entry.access_token # non-blank str: checked by _pool_entry_is_portal_oauth
entry_portal_url = getattr(entry, "portal_base_url", None) or portal_base_url
state = {
"access_token": access_token, "client_id": getattr(entry, "client_id", None),
"inference_base_url": _pool_entry_inference_url(entry), "agent_key": getattr(entry, "agent_key", None),
"credential_source": f"pool:{getattr(entry, 'label', 'unknown')}",
}
if not force_fresh:
jwt_info = _info_from_valid_jwt(access_token, state, entry_portal_url, min_jwt_ttl_seconds)
if jwt_info is not None:
return jwt_info
try:
return _info_from_fetched_account(access_token, state, entry_portal_url)
except Exception as exc:
return _error_info(error=exc, logged_in=True, portal_base_url=entry_portal_url)
def _info_from_fetched_account(
access_token: str, state: dict[str, Any], portal_base_url: Optional[str]
) -> NousPortalAccountInfo:
"""Call ``/api/oauth/account`` and normalize; empty or ``error`` payloads become error infos."""
payload = _fetch_nous_account_info(access_token, portal_base_url)
if not payload:
return _error_info(error="empty_account_response", logged_in=True, portal_base_url=portal_base_url)
if isinstance(payload.get("error"), str):
return _error_info(
error=payload["error"] or "account_response_error", logged_in=True,
portal_base_url=portal_base_url, raw_account=payload,
)
return _info_from_account_payload(payload, state=state, portal_base_url=portal_base_url)
def _pool_entry_inference_url(entry: Any) -> Optional[str]:
return getattr(entry, "inference_base_url", None) or getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None)
def _select_nous_pool_entry() -> Optional[Any]:
"""Pool entry with the latest agent-key expiry, then access expiry, then lowest priority."""
from agent.credential_pool import load_pool
pool = load_pool("nous")
if not pool or not pool.has_credentials():
return None
entries = list(pool.entries())
if not entries:
return None
def _entry_sort_key(entry: Any) -> tuple[float, float, int]:
agent_exp = _parse_iso_timestamp(getattr(entry, "agent_key_expires_at", None)) or 0.0
access_exp = _parse_iso_timestamp(getattr(entry, "expires_at", None)) or 0.0
return (agent_exp, access_exp, -int(getattr(entry, "priority", 0) or 0))
return max(entries, key=_entry_sort_key)
def _pool_entry_is_portal_oauth(entry: Any) -> bool:
if not _nonblank(getattr(entry, "access_token", None)):
return False
auth_type = str(getattr(entry, "auth_type", "") or "").strip().lower()
return auth_type.startswith("oauth") or bool(getattr(entry, "refresh_token", None))
def _fetch_nous_account_info(access_token: str, portal_base_url: Optional[str] = None) -> dict[str, Any]:
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
req = urllib.request.Request(f"{base}/api/oauth/account", headers=headers)
with urllib.request.urlopen(req, timeout=8) as resp:
payload = json.loads(resp.read().decode())
return payload if isinstance(payload, dict) else {}
def _info_from_valid_jwt(
token: str, state: dict[str, Any], portal_base_url: Optional[str], min_jwt_ttl_seconds: int
) -> Optional[NousPortalAccountInfo]:
try:
from hermes_cli.auth import _decode_jwt_claims
except Exception:
return None
claims = _decode_jwt_claims(token)
if not claims:
return None
exp = _coerce_num(claims.get("exp"), float)
if exp is None or exp <= time.time() + max(0, int(min_jwt_ttl_seconds)):
return None
paid_access = _coerce_bool(claims.get("paid_access"))
access_info = NousPaidServiceAccessInfo(
allowed=paid_access, paid_access=paid_access, organisation_id=_coerce_str(claims.get("org_id")),
subscription_tier=_coerce_num(claims.get("subscription_tier"), int),
)
return NousPortalAccountInfo(
logged_in=True, source="jwt", fresh=False,
user_id=_coerce_str(claims.get("sub")),
org_id=_coerce_str(claims.get("org_id")),
client_id=_coerce_str(claims.get("client_id") or state.get("client_id")),
product_id=_coerce_str(claims.get("product_id")),
nous_client=_coerce_str(claims.get("nous_client")),
portal_base_url=portal_base_url,
inference_base_url=_coerce_str(state.get("inference_base_url")),
inference_credential_present=True,
credential_source=_coerce_str(state.get("credential_source")) or "auth_store",
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
paid_service_access=paid_access, paid_service_access_info=access_info,
tool_access=_tool_access_from_value(claims.get("tool_access")),
raw_claims=dict(claims),
account_tier=_coerce_str(claims.get("account_tier")) or _coerce_str(state.get("account_tier")),
managed_tools=_coerce_bool(claims.get("managed_tools")),
)
def _info_from_account_payload(
payload: dict[str, Any], *, state: dict[str, Any], portal_base_url: Optional[str]
) -> NousPortalAccountInfo:
user = _dict_or_empty(payload.get("user"))
organisation = _dict_or_empty(payload.get("organisation"))
access = _coerced_dataclass(NousPaidServiceAccessInfo, payload.get("paid_service_access"))
paid_access = None
if access is not None:
paid_access = access.allowed if access.allowed is not None else access.paid_access
return NousPortalAccountInfo(
logged_in=True, source="account_api", fresh=True,
org_id=_coerce_str(organisation.get("id")) or (access.organisation_id if access else None),
org_slug=_coerce_str(organisation.get("slug")),
org_name=_coerce_str(organisation.get("name")),
client_id=_coerce_str(state.get("client_id")),
portal_base_url=portal_base_url,
inference_base_url=_coerce_str(state.get("inference_base_url")),
inference_credential_present=bool(state.get("access_token") or state.get("agent_key")),
credential_source=_coerce_str(state.get("credential_source")) or "auth_store",
email=_coerce_str(user.get("email")),
privy_did=_coerce_str(user.get("privy_did")),
subscription=_subscription_from_payload(payload.get("subscription")),
paid_service_access=paid_access, paid_service_access_info=access,
tool_access=_tool_access_from_value(payload.get("tool_access")),
raw_account=dict(payload),
account_tier=_coerce_str(payload.get("account_tier")) or _coerce_str(user.get("account_tier"))
or _coerce_str(state.get("account_tier")),
managed_tools=_coerce_bool(payload.get("managed_tools")),
)
def _tool_access_from_value(value: Any) -> Optional[NousToolAccessInfo]:
"""Parse a Portal ``tool_access`` object (JWT claim or account API).
Fails closed: a non-object yields ``None``; only literal ``true`` counts for ``enabled`` and
each coverage entry.
"""
if not isinstance(value, dict):
return None
coverage = {k: v is True for k, v in _dict_or_empty(value.get("coverage")).items() if isinstance(k, str)}
return NousToolAccessInfo(enabled=value.get("enabled") is True, coverage=coverage)
def _coerced_dataclass(cls, value: Any):
"""Build ``cls`` from a payload dict (field names = payload keys), coercing by declared type."""
return cls(**{f.name: _COERCERS[f.type](value.get(f.name)) for f in fields(cls)}) if isinstance(value, dict) else None
def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]:
return _coerced_dataclass(NousPortalSubscriptionInfo, value)
def _error_info(
*, error: object, logged_in: bool, portal_base_url: Optional[str] = None, raw_account: Optional[dict[str, Any]] = None
) -> NousPortalAccountInfo:
return NousPortalAccountInfo(
logged_in=logged_in, source="error", fresh=False, portal_base_url=portal_base_url,
raw_account=raw_account, error=str(error),
)
def _nonblank(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _dict_or_empty(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def resolve_nous_portal_base_url() -> str:
from hermes_cli.auth import _nous_portal_base_url, get_provider_auth_state
return _nous_portal_base_url(get_provider_auth_state("nous") or {})
def _portal_base_url(state: dict[str, Any]) -> Optional[str]:
value = state.get("portal_base_url")
return value.strip().rstrip("/") if _nonblank(value) else None
def _parse_iso_timestamp(value: Any) -> Optional[float]:
if not isinstance(value, str) or not value:
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.fromisoformat(text).timestamp()
except Exception:
return None
def _coerce_str(value: Any) -> Optional[str]:
return value if isinstance(value, str) and value else None
def _coerce_bool(value: Any) -> Optional[bool]:
return value if isinstance(value, bool) else None
def _coerce_num(value: Any, cast):
"""``cast(value)`` or None; bools and None are rejected, not coerced."""
if value is None or isinstance(value, bool):
return None
try:
return cast(value)
except (TypeError, ValueError):
return None
# Annotations are strings (``from __future__ import annotations``).
_COERCERS = {
"Optional[str]": _coerce_str,
"Optional[bool]": _coerce_bool,
"Optional[int]": lambda v: _coerce_num(v, int),
"Optional[float]": lambda v: _coerce_num(v, float),
}