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