mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Usage-window plan support
This commit is contained in:
1 parent
493208879a
commit
15307dece7
184 files changed
+11074
-392
No files matched your search
@@ -145,7 +145,9 @@ class SlashCommandsMixin:
|
||||
current_provider, target_provider, new_model = self._switch_model(state, args)
|
||||
provider_label = getattr(state.agent, "provider", None) or target_provider or current_provider or "openrouter"
|
||||
logger.info("Session %s: model switched to %s", state.session_id, new_model)
|
||||
return f"Model switched to: {new_model}\nProvider: {provider_label}"
|
||||
from hermes_cli.model_picker_usage import large_model_notice
|
||||
notice = large_model_notice(new_model, target_provider or "", lang="en") # window plans only; cache-only read; ACP text is English
|
||||
return f"Model switched to: {new_model}\nProvider: {provider_label}" + (f"\n⚠ {notice}" if notice else "")
|
||||
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
|
||||
+209
-12
@@ -2,7 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone, UTC
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
@@ -14,12 +15,18 @@ from hermes_cli.auth_codex import _codex_pool_route_base_url
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_time import safe_strftime
|
||||
|
||||
from agent.usage_gauges import BP_FULL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TypeGuard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEPLETED_LINE = "Status: access depleted — top up to restore"
|
||||
# Usage-window plans: allowances are shown as percent used.
|
||||
_WINDOW_TOP_TIER_USED_UP = "Monthly large-model usage is used up."
|
||||
_WINDOW_PAUSED_LINE = ("Status: included usage paused. Your subscription payment failed. Update your card to restore it. "
|
||||
"Until then, Hermes uses your credits.")
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
@@ -55,6 +62,9 @@ class AccountUsageSnapshot:
|
||||
# Exact decoded provider response body (no headers/credentials) for integrations that need
|
||||
# fields Hermes does not normalize yet. Only populated by providers that fetch a JSON body.
|
||||
raw: Optional[dict] = None
|
||||
# Usage-window plans only: the stacked bar layout. Every other snapshot
|
||||
# renders exactly as before.
|
||||
usage_window: bool = False
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -100,9 +110,22 @@ def _format_reset(dt: Optional[datetime]) -> str:
|
||||
return f"in {hours}h {minutes}m ({stamp})" if hours else f"in {minutes}m ({stamp})"
|
||||
|
||||
|
||||
def _reset_phrase(dt: Optional[datetime]) -> str:
|
||||
"""``resets in 23h`` — no wall-clock stamp (those wrap and collide in the TUI)."""
|
||||
if not dt:
|
||||
return "resets soon"
|
||||
try:
|
||||
from hermes_cli.model_picker_usage import format_resets_in
|
||||
return format_resets_in(int(dt.timestamp() * 1000))
|
||||
except Exception:
|
||||
return f"resets {_format_reset(dt)}"
|
||||
|
||||
|
||||
def render_account_usage_lines(snapshot: Optional[AccountUsageSnapshot], *, markdown: bool = False) -> list[str]:
|
||||
if not snapshot:
|
||||
return []
|
||||
if snapshot.usage_window:
|
||||
return _render_usage_window_lines(snapshot)
|
||||
bold = "**" if markdown else ""
|
||||
plan = f" ({snapshot.plan})" if snapshot.plan else ""
|
||||
lines = [f"📈 {bold}{snapshot.title}{bold}", f"Provider: {snapshot.provider}{plan}"]
|
||||
@@ -123,6 +146,37 @@ def render_account_usage_lines(snapshot: Optional[AccountUsageSnapshot], *, mark
|
||||
return lines
|
||||
|
||||
|
||||
def _render_usage_window_lines(snapshot: AccountUsageSnapshot) -> list[str]:
|
||||
"""Usage-window plans: plain stacked bar lines for CLI ``/usage`` and the TUI panel. No emoji,
|
||||
no ANSI, no markdown — Ink ``<Text>`` would otherwise count escape bytes as width and smear the wrap."""
|
||||
if snapshot.plan:
|
||||
lines = [snapshot.plan]
|
||||
else:
|
||||
lines = [snapshot.title, f"Provider: {snapshot.provider}"]
|
||||
label_w = max((len(window.label) for window in snapshot.windows), default=0)
|
||||
for window in snapshot.windows:
|
||||
label = f"{window.label:<{label_w}}" if label_w else window.label
|
||||
if window.used_percent is None:
|
||||
base = f"{label} unavailable"
|
||||
else:
|
||||
used = max(0.0, min(100.0, float(window.used_percent))) / 100.0
|
||||
try:
|
||||
from hermes_cli.model_picker_usage import format_percent_used, render_bar
|
||||
bar, pct = render_bar(used), format_percent_used(used)
|
||||
except Exception:
|
||||
bar, pct = "", f"{int(used * 100)}%"
|
||||
base = f"{label} {bar} {pct} used" if bar else f"{label} {pct} used"
|
||||
if window.reset_at:
|
||||
base += f" · {_reset_phrase(window.reset_at)}"
|
||||
elif window.detail:
|
||||
base += f" · {window.detail}"
|
||||
lines.append(base)
|
||||
lines.extend(snapshot.details)
|
||||
if snapshot.unavailable_reason:
|
||||
lines.append(f"Unavailable: {snapshot.unavailable_reason}")
|
||||
return lines
|
||||
|
||||
|
||||
def _fmt_usd(d: float) -> str:
|
||||
return f"${d:,.2f}"
|
||||
|
||||
@@ -136,16 +190,76 @@ def _is_finite_num(v: Any) -> TypeGuard[float]:
|
||||
return _is_num(v) and not isinstance(v, bool) and math.isfinite(v)
|
||||
|
||||
|
||||
def _nous_snapshot(windows: list, details: list, tail: list, *, source: str, plan: Optional[str] = None) -> Optional[AccountUsageSnapshot]:
|
||||
def _nous_snapshot(windows: list, details: list, tail: list, *, source: str, plan: Optional[str] = None,
|
||||
usage_window: bool = False) -> Optional[AccountUsageSnapshot]:
|
||||
"""Nous snapshot with *tail* lines appended, or None when there is nothing to show."""
|
||||
if not windows and not details:
|
||||
return None
|
||||
return _snapshot("nous", source, windows, details + tail, title="Nous credits", plan=plan)
|
||||
return _snapshot("nous", source, windows, details + tail, title="Nous credits", plan=plan, usage_window=usage_window)
|
||||
|
||||
|
||||
def _bp_window(label: str, used_bp: Any, resets_at_ms: Any) -> Optional[AccountUsageWindow]:
|
||||
"""One usage-window gauge row from basis points + an epoch-ms reset; None when either is not an int."""
|
||||
if isinstance(used_bp, bool) or not isinstance(used_bp, int) or isinstance(resets_at_ms, bool) or not isinstance(resets_at_ms, int):
|
||||
return None
|
||||
try:
|
||||
reset_at = datetime.fromtimestamp(resets_at_ms / 1000, tz=timezone.utc)
|
||||
except (ValueError, OverflowError, OSError):
|
||||
return None # an implausible server stamp drops the row, never the whole /usage view
|
||||
return AccountUsageWindow(label=label, used_percent=max(0.0, min(100.0, used_bp / BP_FULL * 100.0)),
|
||||
reset_at=reset_at)
|
||||
|
||||
|
||||
def _window_top_tier_line(reset_at: Optional[datetime], keep_going_on_credits: Any = None) -> str:
|
||||
"""``Monthly large-model usage is used up. Resets Nov 3. Past this, large models use your credits.`` (local
|
||||
date). With Keep going on credits off, credits don't unlock it, so the line points at the other included models."""
|
||||
resets = ""
|
||||
if isinstance(reset_at, datetime):
|
||||
local = reset_at.astimezone()
|
||||
resets = f" Resets {local:%b} {local.day}."
|
||||
tail = " Your other included usage is still available." if keep_going_on_credits is False else " Past this, large models use your credits."
|
||||
return f"{_WINDOW_TOP_TIER_USED_UP}{resets}{tail}"
|
||||
|
||||
|
||||
_WINDOW_KEEP_GOING_OFF_LINE = "Keep going on credits: off. Hermes waits for the reset (turn on in /topup)."
|
||||
_WINDOW_KEEP_GOING_ON_LINE = "Keep going on credits: on. Uses your credits when included usage runs out."
|
||||
|
||||
|
||||
def _usage_window_rows(gauges: Any, *, paused_reason: Any,
|
||||
keep_going_on_credits: Any = None) -> tuple[list[AccountUsageWindow], list[str]]:
|
||||
"""One row per server allowance, in the server's order, plus the top-tier, payment-paused and top-up-off
|
||||
lines. Empty unless the list has an allowance that stops every model. ``keep_going_on_credits``: the
|
||||
account setting (None: an older server)."""
|
||||
from agent.usage_gauges import SCOPE_TOP_TIER, all_scope, gauge_title, parse_gauges
|
||||
parsed = parse_gauges(gauges)
|
||||
if not parsed or not all_scope(parsed):
|
||||
return [], []
|
||||
out: list[AccountUsageWindow] = []
|
||||
top_row: Optional[AccountUsageWindow] = None
|
||||
top_full = False
|
||||
for g in parsed:
|
||||
row = _bp_window(gauge_title(g), g.used_bp, g.resets_at_ms)
|
||||
if row is None:
|
||||
continue
|
||||
out.append(row)
|
||||
if g.scope == SCOPE_TOP_TIER:
|
||||
top_row, top_full = row, g.full
|
||||
lines: list[str] = []
|
||||
if top_full:
|
||||
lines.append(_window_top_tier_line(top_row.reset_at if top_row else None, keep_going_on_credits))
|
||||
if isinstance(paused_reason, str) and paused_reason:
|
||||
lines.append(_WINDOW_PAUSED_LINE)
|
||||
elif keep_going_on_credits is False:
|
||||
lines.append(_WINDOW_KEEP_GOING_OFF_LINE)
|
||||
elif keep_going_on_credits is True:
|
||||
lines.append(_WINDOW_KEEP_GOING_ON_LINE)
|
||||
return out, lines
|
||||
|
||||
|
||||
def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
"""NousPortalAccountInfo → /usage snapshot: dollar magnitudes + renewal date + portal CTA, plus a ``% used``
|
||||
gauge when the portal supplies ``monthly_credits``. Fail-open → None."""
|
||||
gauge when the portal supplies ``monthly_credits``. A usage-window account (``usage_window`` present) shows
|
||||
its allowances as percent used, the large-model line and its top-up balance instead. Fail-open → None."""
|
||||
try:
|
||||
from hermes_cli.nous_account import nous_portal_topup_url
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
@@ -154,6 +268,24 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
sub = getattr(account_info, "subscription", None)
|
||||
windows: list[AccountUsageWindow] = []
|
||||
details: list[str] = []
|
||||
uw = getattr(account_info, "usage_window", None)
|
||||
if uw is not None:
|
||||
windows, window_lines = _usage_window_rows(
|
||||
getattr(uw, "gauges", None), paused_reason=getattr(uw, "paused_reason", None),
|
||||
keep_going_on_credits=getattr(uw, "keep_going_on_credits", None),
|
||||
)
|
||||
if windows: # usage-window account: percents, plus the top-up balance
|
||||
details.extend(window_lines)
|
||||
topup = getattr(access, "purchased_credits_remaining", None) if access is not None else None
|
||||
if _is_finite_num(topup):
|
||||
details.append(f"Top-up credits: {_fmt_usd(topup)}")
|
||||
if getattr(account_info, "paid_service_access", None) is False and not window_lines:
|
||||
details.append(_DEPLETED_LINE)
|
||||
if sub is not None and (period_end := getattr(sub, "current_period_end", None)):
|
||||
from agent.billing_usage import format_renews
|
||||
details.append(f"Renews: {format_renews(period_end) or period_end}")
|
||||
return _nous_snapshot(windows, details, ["Top up via /topup"], source="portal-account",
|
||||
plan=getattr(sub, "plan", None) if sub is not None else None, usage_window=True)
|
||||
# Gauge needs a positive cap AND a finite remaining <= cap (numeric fields, NOT a server *_usd); used =
|
||||
# cap - remaining clamped [0,100] so debt reads 100%. NaN/Inf (json.loads accepts bare NaN → "$nan") and
|
||||
# remaining > cap (rollover makes the cap a meaningless denominator) fall back to the magnitudes lines.
|
||||
@@ -221,26 +353,79 @@ def _fetch_portal_account(timeout: float):
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
|
||||
def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list[str]:
|
||||
def _header_gauge_percent(bp: Any) -> Optional[float]:
|
||||
if isinstance(bp, bool) or not isinstance(bp, int):
|
||||
return None
|
||||
return max(0.0, min(100.0, bp / BP_FULL * 100.0))
|
||||
|
||||
|
||||
def apply_header_gauge(snapshot: Optional[AccountUsageSnapshot], credits_state: Any) -> Optional[AccountUsageSnapshot]:
|
||||
"""Raise window percents to the last response headers when those are ahead of the portal.
|
||||
|
||||
The portal counter waits on spend-log ingestion. The headers already include finished included
|
||||
calls, and a large-model call that drew top-up reports that allowance full."""
|
||||
if snapshot is None or not snapshot.usage_window or credits_state is None:
|
||||
return snapshot
|
||||
if not getattr(credits_state, "from_header", False) or not getattr(credits_state, "is_usage_window", False):
|
||||
return snapshot
|
||||
# Row title → (header used bp, that gauge's reset). A header from before its gauge reset describes the
|
||||
# old window: the portal's fresh number stands.
|
||||
from agent.usage_gauges import gauge_title
|
||||
mapping = {gauge_title(g): (g.used_bp, g.resets_at_ms) for g in getattr(credits_state, "gauges", ()) or ()}
|
||||
now_ms = int(time.time() * 1000)
|
||||
windows: list[AccountUsageWindow] = []
|
||||
changed = False
|
||||
for window in snapshot.windows:
|
||||
bp, resets_at_ms = mapping.get(window.label, (None, None))
|
||||
stale = isinstance(resets_at_ms, int) and not isinstance(resets_at_ms, bool) and now_ms >= resets_at_ms
|
||||
header = None if stale else _header_gauge_percent(bp)
|
||||
current = window.used_percent
|
||||
if header is None or current is None or header <= float(current):
|
||||
windows.append(window)
|
||||
continue
|
||||
changed = True
|
||||
windows.append(replace(window, used_percent=header))
|
||||
if not changed:
|
||||
return snapshot
|
||||
details = snapshot.details
|
||||
top_gauge = getattr(credits_state, "top_tier_gauge", None)
|
||||
top = next((w for w in windows if top_gauge is not None and w.label == gauge_title(top_gauge)), None)
|
||||
if top is not None and (top.used_percent or 0) >= 100:
|
||||
if not any(line.startswith(_WINDOW_TOP_TIER_USED_UP) for line in details):
|
||||
details = details + (_window_top_tier_line(top.reset_at, getattr(credits_state, "keep_going_on_credits", None)),)
|
||||
return replace(snapshot, windows=tuple(windows), details=details)
|
||||
|
||||
|
||||
def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0, credits_state: Any = None) -> list[str]:
|
||||
"""Rendered Nous-credits /usage lines, or [] when there's nothing to show. Independent of any live agent
|
||||
(logged-in gate, then a bounded portal fetch); shared by CLI ``_show_usage`` and the TUI ``session.usage`` RPC.
|
||||
Fail-open: any hiccup or timeout → []. HERMES_DEV_CREDITS_FIXTURE renders from the fixture instead of the portal."""
|
||||
Fail-open: any hiccup or timeout → []. HERMES_DEV_CREDITS_FIXTURE renders from the fixture instead of the portal.
|
||||
``credits_state`` is the last response's header gauge; when it is ahead of the portal, the bars use it."""
|
||||
return nous_credits_view(markdown=markdown, timeout=timeout, credits_state=credits_state)[0]
|
||||
|
||||
|
||||
def nous_credits_view(*, markdown: bool = False, timeout: float = 10.0, credits_state: Any = None) -> tuple[list[str], bool]:
|
||||
"""``nous_credits_lines`` plus whether they are the usage-window layout (callers keep today's
|
||||
presentation for everyone else). Fail-open → ``([], False)``."""
|
||||
try:
|
||||
from agent.credits_tracker import dev_fixture_credits_state
|
||||
fixture = dev_fixture_credits_state()
|
||||
except Exception:
|
||||
fixture = None
|
||||
if fixture is not None:
|
||||
return render_account_usage_lines(_snapshot_from_credits_state(fixture), markdown=markdown)
|
||||
snapshot = _snapshot_from_credits_state(fixture)
|
||||
return render_account_usage_lines(snapshot, markdown=markdown), bool(snapshot and snapshot.usage_window)
|
||||
if not _nous_logged_in():
|
||||
return []
|
||||
return [], False
|
||||
try:
|
||||
snapshot = build_nous_credits_snapshot(_fetch_portal_account(timeout))
|
||||
return render_account_usage_lines(snapshot, markdown=markdown)
|
||||
snapshot = apply_header_gauge(
|
||||
build_nous_credits_snapshot(_fetch_portal_account(timeout)), credits_state,
|
||||
)
|
||||
return render_account_usage_lines(snapshot, markdown=markdown), bool(snapshot and snapshot.usage_window)
|
||||
except Exception:
|
||||
# Fail-open; breadcrumb so a dead /usage credits block is diagnosable.
|
||||
logger.debug("credits ▸ /usage portal fetch/render failed (fail-open)", exc_info=True)
|
||||
return []
|
||||
return [], False
|
||||
|
||||
|
||||
def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
@@ -251,6 +436,18 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
return None
|
||||
windows: list[AccountUsageWindow] = []
|
||||
details: list[str] = []
|
||||
if getattr(state, "is_usage_window", False):
|
||||
windows, window_lines = _usage_window_rows(
|
||||
state.gauges, paused_reason=state.paused_reason,
|
||||
keep_going_on_credits=getattr(state, "keep_going_on_credits", None),
|
||||
)
|
||||
details.extend(window_lines)
|
||||
if getattr(state, "purchased_usd", None):
|
||||
details.append(f"Top-up credits: ${state.purchased_usd}")
|
||||
if getattr(state, "paid_access", True) is False and not window_lines:
|
||||
details.append(_DEPLETED_LINE)
|
||||
return _nous_snapshot(windows, details, ["(dev fixture — HERMES_DEV_CREDITS_FIXTURE)"], source="dev-fixture",
|
||||
usage_window=True)
|
||||
uf = getattr(state, "used_fraction", None)
|
||||
sub_usd = getattr(state, "subscription_usd", None)
|
||||
cap_usd = getattr(state, "subscription_limit_usd", None)
|
||||
@@ -300,7 +497,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
|
||||
balance_lines = [
|
||||
line
|
||||
for line in render_account_usage_lines(build_nous_credits_snapshot(account), markdown=markdown)
|
||||
if not line.lstrip().startswith(("Top up:", "(or run"))
|
||||
if not line.lstrip().startswith(("Top up:", "Top up via", "(or run"))
|
||||
]
|
||||
who = [str(v) for v in (getattr(account, "email", None),) if v]
|
||||
org_name = getattr(account, "org_name", None)
|
||||
|
||||
@@ -1055,7 +1055,10 @@ def recover_with_credential_pool(
|
||||
# need opposite cooldowns.
|
||||
if effective_reason is not None:
|
||||
failure_reason = effective_reason.value
|
||||
if effective_reason == FailoverReason.billing and billing_unverified:
|
||||
if effective_reason == FailoverReason.billing and (error_context or {}).get("usage_window"):
|
||||
from agent.credential_pool_model_cooldowns import FAILURE_REASON_USAGE_WINDOW
|
||||
failure_reason = FAILURE_REASON_USAGE_WINDOW
|
||||
elif effective_reason == FailoverReason.billing and billing_unverified:
|
||||
# Ambiguous billing body: size the cooldown as transient, not a 1-hour bench.
|
||||
from agent.credential_pool import FAILURE_REASON_BILLING_UNVERIFIED
|
||||
failure_reason = FAILURE_REASON_BILLING_UNVERIFIED
|
||||
|
||||
@@ -123,6 +123,12 @@ def usage_model_from_account(account_info: Any) -> UsageModel:
|
||||
try:
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return UsageModel(available=False)
|
||||
# Usage-window plans have no monthly credit grant, so the credit bars don't apply;
|
||||
# /usage falls through to the percent-window renderer instead. A partial/empty block is not a
|
||||
# window plan (same completeness rule as the credits seed).
|
||||
from agent.credits_tracker import _window_seed_fields
|
||||
if _window_seed_fields(getattr(account_info, "usage_window", None)) is not None:
|
||||
return UsageModel(available=False)
|
||||
# Sub-structs are dataclasses or None; getattr(None, ..., None) is None, so no guards needed.
|
||||
access = getattr(account_info, "paid_service_access_info", None)
|
||||
sub = getattr(account_info, "subscription", None)
|
||||
|
||||
@@ -159,6 +159,9 @@ class BillingState(OrgRoleCapability):
|
||||
payment_method: Optional[PaymentMethodInfo] = None
|
||||
monthly_cap: Optional[MonthlyCap] = None
|
||||
auto_reload: Optional[AutoReload] = None
|
||||
# Window plans only: "Keep going on credits". None below ADMIN, on any
|
||||
# other plan, and from a portal that predates the setting (no control offered).
|
||||
keep_going_on_credits: Optional[bool] = None
|
||||
portal_url: Optional[str] = None
|
||||
error: Optional[str] = None # set when the fetch failed (vs cleanly not-logged-in)
|
||||
|
||||
@@ -241,10 +244,17 @@ def billing_state_from_payload(payload: dict[str, Any], *, portal_url: Optional[
|
||||
payment_method=_parse_payment_method(payload.get("paymentMethod")),
|
||||
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
|
||||
auto_reload=_parse_auto_reload(payload.get("autoReload")),
|
||||
keep_going_on_credits=_parse_keep_going_on_credits(payload.get("keepGoingOnCredits")),
|
||||
portal_url=portal_url,
|
||||
)
|
||||
|
||||
|
||||
def _parse_keep_going_on_credits(raw: Any) -> Optional[bool]:
|
||||
"""``{enabled: bool}`` → the bool; anything else (null, absent, malformed) → None."""
|
||||
enabled = raw.get("enabled") if isinstance(raw, dict) else None
|
||||
return enabled if isinstance(enabled, bool) else None
|
||||
|
||||
|
||||
def fetch_portal_state(
|
||||
endpoint: str, label: str, *, failed: Callable[..., Any], parse: Callable[[dict, Optional[str]], Any],
|
||||
portal_fallback: Callable[[str], str], timeout: float, log: logging.Logger,
|
||||
|
||||
@@ -792,7 +792,42 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
|
||||
# request transform. No-op unless this really is the OpenAI SDK, so the
|
||||
# MoA facade above and the suite's stand-in clients are unaffected.
|
||||
api_kwargs = bypass_chat_sdk_request_transform(api_kwargs, request_client)
|
||||
return request_client.chat.completions.create(**api_kwargs)
|
||||
return _chat_completion_capturing_headers(agent, request_client, api_kwargs)
|
||||
|
||||
|
||||
def _chat_completion_capturing_headers(agent, client, api_kwargs: dict):
|
||||
"""Non-streaming ``chat.completions.create`` that also feeds the response headers to the
|
||||
rate-limit / credits capture, as ``_chat_stream_created`` does for streams.
|
||||
|
||||
The parsed ``ChatCompletion`` drops headers, so without this a non-streaming turn never
|
||||
observes ``x-nous-credits-*`` (usage-window ``spend_source`` / gauges go stale). The SDK's
|
||||
``with_raw_response`` returns the same request with headers attached; test doubles
|
||||
(``Mock`` clients, namespaces without the attribute) keep the plain call. Usage-window plans
|
||||
only: every other provider, and a Nous session not yet known to be on a window plan (the
|
||||
session-start seed or a stream header establishes it), keep the plain call; headers are
|
||||
captured only when the response itself is the usage-window kind. A capture failure never
|
||||
fails the turn.
|
||||
"""
|
||||
# `is True`: a Mock agent's invented attribute is not a window plan.
|
||||
state = getattr(agent, "_credits_state", None)
|
||||
if getattr(agent, "provider", "") != "nous" or getattr(state, "is_usage_window", False) is not True:
|
||||
return client.chat.completions.create(**api_kwargs)
|
||||
raw_api = getattr(getattr(getattr(client, "chat", None), "completions", None), "with_raw_response", None)
|
||||
# Test doubles (unittest.mock) invent every attribute; checked by module so production never
|
||||
# imports unittest.
|
||||
if raw_api is None or any(cls.__module__ == "unittest.mock" for cls in type(client).__mro__):
|
||||
return client.chat.completions.create(**api_kwargs)
|
||||
raw = raw_api.create(**api_kwargs)
|
||||
from agent.credits_tracker import response_is_usage_window
|
||||
if not response_is_usage_window(raw):
|
||||
return raw.parse()
|
||||
for capture in (agent._capture_rate_limits, agent._capture_credits,
|
||||
agent._capture_nous_model_switch, agent._check_openrouter_cache_status):
|
||||
try:
|
||||
capture(raw)
|
||||
except Exception:
|
||||
logger.debug("non-streaming header capture failed", exc_info=True)
|
||||
return raw.parse()
|
||||
|
||||
|
||||
def should_use_direct_api_call(agent) -> bool:
|
||||
|
||||
@@ -479,13 +479,18 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _nous_entitlement_message(capability: str) -> str:
|
||||
def _nous_entitlement_message(capability: str, *, billing: bool = False) -> str:
|
||||
"""``billing``: the request hit a billing wall. A usage-window account then gets its own next
|
||||
step (top-up link); its plan is still live, so the entitlement copy alone would say nothing."""
|
||||
try:
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
format_usage_window_block_guidance,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
if billing and (window_guidance := format_usage_window_block_guidance(account_info)):
|
||||
return window_guidance
|
||||
return format_nous_portal_entitlement_message(
|
||||
account_info, capability=capability, in_chat=True
|
||||
) or ""
|
||||
@@ -529,7 +534,7 @@ def _billing_or_entitlement_message(
|
||||
*, capability: str, provider: str, base_url: str, model: str, unverified: bool = False
|
||||
) -> str:
|
||||
if _is_nous_inference_route(provider, base_url):
|
||||
return _nous_entitlement_message(capability)
|
||||
return _nous_entitlement_message(capability, billing=True)
|
||||
|
||||
provider_label = (provider or "").strip() or "the selected provider"
|
||||
model_label = (model or "").strip() or "the selected model"
|
||||
@@ -606,20 +611,79 @@ def _billing_terminal_label(summary: str, unverified: bool) -> str:
|
||||
return f"Billing or credits exhausted: {summary}"
|
||||
|
||||
|
||||
def _usage_window_refusal(agent, classified, api_messages=None) -> tuple[str, Optional[float], Optional[dict]]:
|
||||
"""A usage-window 402 in the words of the credits limit line ("You've hit your daily limit · resets
|
||||
15:56 · /topup to add credits") plus when that allowance frees up (epoch
|
||||
seconds), so the chat and the error card say exactly what the notice says and the card can offer
|
||||
a retry at the reset. The third item is the structured limit (:class:`agent.plan_limits.LimitHit`
|
||||
payload: what ran out, the recommended models that still work, the credits option) every surface
|
||||
renders as buttons or a sentence. ``("", None, None)`` for every other billing failure."""
|
||||
context = getattr(classified, "error_context", None) or {}
|
||||
if not context.get("usage_window"):
|
||||
return "", None, None
|
||||
try:
|
||||
from agent.credits_tracker import _window_hard_stop, window_stop_resets_at_ms
|
||||
state = getattr(agent, "_credits_state", None)
|
||||
stop = _window_hard_stop(state, model_is_free=False) if state is not None else None
|
||||
# The server may word the refusal itself (``display_message`` on the 402): shown verbatim, so a
|
||||
# reword ships with the API. Without it, the stop line Hermes builds from the gauges.
|
||||
display = context.get("display_message")
|
||||
server_message = str(getattr(classified, "message", "") or "").strip()
|
||||
if isinstance(display, str) and display.strip():
|
||||
message = display.strip()
|
||||
elif stop:
|
||||
message = stop.removeprefix("✕ ").strip()
|
||||
elif server_message:
|
||||
# Local state says nothing is spent (stale snapshot, or it thinks credits will pay) but the
|
||||
# server refused with the window code: its own sentence still beats the generic "out of
|
||||
# credits" wall, and keeps the turn on the window card (one place, Open billing) instead of
|
||||
# a billing toast + banner + Switch provider card.
|
||||
message = server_message
|
||||
else:
|
||||
return "", None, None
|
||||
resets_ms = window_stop_resets_at_ms(state) if state is not None else None
|
||||
limit = None
|
||||
try:
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
from agent.plan_limits import limit_hit
|
||||
hit = limit_hit(agent, message=message,
|
||||
needed_tokens=estimate_messages_tokens_rough(api_messages or []) if api_messages else None)
|
||||
limit = hit.to_payload() if hit is not None else None
|
||||
except Exception:
|
||||
limit = None
|
||||
return (message, resets_ms / 1000 if isinstance(resets_ms, int) and resets_ms > 0 else None, limit)
|
||||
except Exception:
|
||||
return "", None, None
|
||||
|
||||
|
||||
def _billing_failure_result(
|
||||
*, classified, summary: str, messages, api_call_count: int, provider: str, base_url, model: str,
|
||||
guidance: Optional[str] = None,
|
||||
usage_window: tuple[str, Optional[float], Optional[dict]] = ("", None, None),
|
||||
) -> dict:
|
||||
"""Structured terminal result for a billing-classified failure — the single construction
|
||||
point for the non-retryable abort and max-retries paths (#82154)."""
|
||||
point for the non-retryable abort and max-retries paths (#82154). A usage-window refusal
|
||||
reads as its one stop-line sentence instead of the generic "billing exhausted" text."""
|
||||
unverified = bool(getattr(classified, "billing_unverified", False))
|
||||
if guidance is None:
|
||||
if guidance is None and not (tuple(usage_window) + ("",))[0]: # a window refusal is its own message: no portal fetch
|
||||
guidance = _billing_or_entitlement_message(
|
||||
capability="model access", provider=provider, base_url=str(base_url), model=model,
|
||||
unverified=unverified,
|
||||
)
|
||||
final = _billing_terminal_label(summary, unverified) + (f"\n\n{guidance}" if guidance else "")
|
||||
usage_window_message, usage_window_resets_at, usage_limit = (tuple(usage_window) + (None, None, None))[:3]
|
||||
final = usage_window_message or (
|
||||
_billing_terminal_label(summary, unverified) + (f"\n\n{guidance}" if guidance else ""))
|
||||
extra: dict = {}
|
||||
if usage_window_message:
|
||||
extra["usage_window_message"] = usage_window_message
|
||||
if usage_window_resets_at is not None:
|
||||
extra["failure_resets_at"] = usage_window_resets_at
|
||||
if usage_limit:
|
||||
# Structured: the CLI and the messaging gateway read it off the result; the error surface
|
||||
# carries it to the TUI and the desktop card.
|
||||
extra["usage_limit"] = usage_limit
|
||||
return {
|
||||
**extra,
|
||||
"final_response": final, "messages": messages, "api_calls": api_call_count,
|
||||
"completed": False, "failed": True, "error": summary,
|
||||
"failure_reason": classified.reason.value,
|
||||
|
||||
@@ -17,6 +17,9 @@ if TYPE_CHECKING:
|
||||
# A Codex ChatGPT-account model entitlement 400 is a plan property, not a window: bench the
|
||||
# (credential, model) pair until an explicit ``hermes auth reset`` clears model_cooldowns (#71970).
|
||||
MODEL_ENTITLEMENT_BENCH_SECONDS = 365 * 24 * 60 * 60
|
||||
# A usage-window plan limit: this model is refused until the allowance resets; every other model
|
||||
# (a Free or non-Large one) stays usable on the same credential.
|
||||
FAILURE_REASON_USAGE_WINDOW = "usage_window"
|
||||
|
||||
|
||||
def model_cooldown_until(entry: PooledCredential, model: Optional[str]) -> Optional[float]:
|
||||
@@ -67,7 +70,7 @@ class CredentialPoolModelCooldownMixin:
|
||||
|
||||
if not model:
|
||||
return False
|
||||
if failure_reason == "model_entitlement":
|
||||
if failure_reason in ("model_entitlement", FAILURE_REASON_USAGE_WINDOW):
|
||||
return True
|
||||
return (
|
||||
self.provider == "anthropic" and status_code == 429
|
||||
|
||||
+407
-15
@@ -17,12 +17,27 @@ from typing import Any, Mapping, Optional
|
||||
|
||||
from utils import is_truthy_value
|
||||
|
||||
from agent.usage_gauges import (
|
||||
BP_FULL,
|
||||
SCOPE_TOP_TIER,
|
||||
WindowGauge,
|
||||
all_scope,
|
||||
binding_gauge,
|
||||
gauge_limit_name,
|
||||
parse_gauges,
|
||||
top_tier_gauge,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_version_warning_emitted: bool = False # warn-once latch (per process)
|
||||
_VALID_DENOMINATOR_KINDS = frozenset({"subscription_cap", "none"})
|
||||
_VALID_DENOMINATOR_KINDS = frozenset({"subscription_cap", "none", "usage_window"})
|
||||
_USD_RE = re.compile(r"^-?\d+\.\d{2}$") # optional minus, digits, exactly 2 decimals
|
||||
_SENTINEL = object() # "parse failed"
|
||||
# Usage-window plans: the allowances are the server's gauge list
|
||||
# (agent/usage_gauges.py), percents in basis points. Which allowances exist, their
|
||||
# order and which models each stops all come from the server.
|
||||
_VALID_SPEND_SOURCES = frozenset({"included", "payg"})
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> Any:
|
||||
@@ -50,12 +65,28 @@ class CreditsState:
|
||||
purchased_usd: str = ""
|
||||
tool_pool_micros: int = 0
|
||||
tool_pool_gated_off: bool = False
|
||||
denominator_kind: str = "none" # "subscription_cap" | "none"
|
||||
denominator_kind: str = "none" # "subscription_cap" | "none" | "usage_window"
|
||||
paid_access: bool = True # depletion keys off THIS == False, NEVER remaining==0
|
||||
disabled_reason: Optional[str] = None # header omitted entirely when null
|
||||
as_of_ms: int = 0
|
||||
captured_at: float = 0.0 # time.time() when captured
|
||||
from_header: bool = False # True only when populated by parse_credits_headers()
|
||||
# ── usage_window only (empty / defaults otherwise): the server's allowances, in display order.
|
||||
gauges: tuple[WindowGauge, ...] = ()
|
||||
spend_source: Optional[str] = None # "included" | "payg" — which wallet the LAST request drew on
|
||||
# An admitted reply the server did not meter (no spend source: a skipped guard). A full gauge on it is no
|
||||
# refusal, so it raises no limit stop; the next metered reply or 402 decides.
|
||||
unmetered: bool = False
|
||||
paused_reason: Optional[str] = None # e.g. "payment_past_due": the window is withheld, not spent
|
||||
auto_top_up_enabled: bool = False # seed-only today (the header set does not carry it)
|
||||
# The account setting "Keep going on credits". False = a used-up
|
||||
# allowance stops (402) even with a balance; None = a server that predates it (top-up is spent).
|
||||
keep_going_on_credits: Optional[bool] = None
|
||||
|
||||
@property
|
||||
def credits_pay(self) -> bool:
|
||||
"""Top-up credits will cover Hermes usage once the included allowance is used up."""
|
||||
return self.purchased_micros > 0 and self.keep_going_on_credits is not False
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
@@ -71,15 +102,59 @@ class CreditsState:
|
||||
when the balance is zero but access is live (renewal pending)."""
|
||||
return not self.paid_access
|
||||
|
||||
@property
|
||||
def is_usage_window(self) -> bool:
|
||||
"""A usage-window plan with at least one allowance that stops every paid model."""
|
||||
return self.denominator_kind == "usage_window" and bool(all_scope(self.gauges))
|
||||
|
||||
@property
|
||||
def binding_gauge(self) -> Optional[WindowGauge]:
|
||||
"""The all-scope allowance that binds (see :func:`usage_gauges.binding_gauge`). Usage window only."""
|
||||
return binding_gauge(self.gauges) if self.is_usage_window else None
|
||||
|
||||
@property
|
||||
def top_tier_gauge(self) -> Optional[WindowGauge]:
|
||||
return top_tier_gauge(self.gauges) if self.is_usage_window else None
|
||||
|
||||
@property
|
||||
def used_fraction(self) -> Optional[float]:
|
||||
"""Fraction of the subscription cap consumed in [0.0, 1.0]; None without a computable
|
||||
denominator. Guarded on the LIMIT FIELD (the real denominator), not ``denominator_kind``."""
|
||||
"""Fraction of the cap consumed in [0.0, 1.0]; None without a computable denominator.
|
||||
Credit-grant: guarded on the LIMIT FIELD (the real denominator), not ``denominator_kind``.
|
||||
Usage window: the binding all-scope allowance (whichever stops every model first)."""
|
||||
if self.is_usage_window:
|
||||
return self.binding_gauge.used_fraction # type: ignore[union-attr]
|
||||
lim = self.subscription_limit_micros
|
||||
if not isinstance(lim, int) or lim <= 0:
|
||||
return None
|
||||
return max(0.0, min(1.0, (lim - self.subscription_micros) / lim))
|
||||
|
||||
@property
|
||||
def window_resets_at_ms(self) -> Optional[int]:
|
||||
"""Reset instant of the binding allowance."""
|
||||
g = self.binding_gauge
|
||||
return g.resets_at_ms if g is not None else None
|
||||
|
||||
@property
|
||||
def top_tier_resets_at_ms(self) -> Optional[int]:
|
||||
g = self.top_tier_gauge
|
||||
return g.resets_at_ms if g is not None else None
|
||||
|
||||
@property
|
||||
def top_tier_used_bp(self) -> Optional[int]:
|
||||
g = self.top_tier_gauge
|
||||
return g.used_bp if g is not None else None
|
||||
|
||||
@property
|
||||
def window_exhausted(self) -> bool:
|
||||
"""An allowance that stops every paid model is full. Paused (payment) is NOT exhausted: nothing was spent."""
|
||||
return self.is_usage_window and any(g.full for g in all_scope(self.gauges))
|
||||
|
||||
@property
|
||||
def top_tier_exhausted(self) -> bool:
|
||||
"""The top-tier allowance is full (a percent, like the others — not a blocked flag)."""
|
||||
g = self.top_tier_gauge
|
||||
return g is not None and g.full
|
||||
|
||||
|
||||
# ── Credits policy constants. Switching notices sticky→TTL later also needs a
|
||||
# paired *_TTL_MS per notice kind (AgentNotice has the field; not plumbed yet).
|
||||
@@ -96,9 +171,188 @@ CREDITS_USAGE_KEY = "credits.usage"
|
||||
GRANT_UNSPENT_MIN_MICROS = 10_000
|
||||
|
||||
|
||||
# Usage-window notice keys. ``credits.window_payg`` is the one-shot "now spending top-up credits" line;
|
||||
# ``credits.depleted`` is reused for the hard stop so the TUI/desktop native-notification mapping
|
||||
# (keyed on ``credits.depleted`` / ``credits.restored``) keeps working without a client change.
|
||||
CREDITS_WINDOW_PAYG_KEY = "credits.window_payg"
|
||||
CREDITS_DEPLETED_KEY = "credits.depleted"
|
||||
# A window plan's limit line (day / week / month / top-tier used up, credits won't pay). Its own key so the
|
||||
# desktop can leave it to the refused turn's error card, which says the same sentence with buttons; the
|
||||
# CLI and TUI show it like any other notice. Payment-paused and depleted stay on ``credits.depleted``.
|
||||
CREDITS_WINDOW_LIMIT_KEY = "credits.window_limit"
|
||||
CREDITS_RESTORED_KEY = "credits.restored"
|
||||
CREDITS_GRANT_SPENT_KEY = "credits.grant_spent"
|
||||
_DEPLETED_TEXT = "✕ Credit access paused · run /topup to top up"
|
||||
_PAUSED_TEXT = "✕ Included usage paused · your subscription payment failed · update your card, or add credits in /topup to keep going"
|
||||
_TOP_TIER_LIMIT = "You've hit your monthly large-model limit"
|
||||
_AUTO_TOP_UP_SUFFIX = " · auto top-up is on"
|
||||
|
||||
|
||||
def new_credits_latch() -> dict:
|
||||
"""Fresh notice latch for :func:`evaluate_credits_notices`; every producer builds it here so a new gate key lands everywhere."""
|
||||
return {"active": set(), "seen_below_90": False, "usage_band": None, "seen_grant_unspent": False}
|
||||
return {"active": set(), "seen_below_90": False, "usage_band": None, "seen_grant_unspent": False, "texts": {}}
|
||||
|
||||
|
||||
def format_window_reset(resets_at_ms: Optional[int], now_ms: Optional[int] = None) -> str:
|
||||
"""Local-time reset for notices: ``HH:MM`` within 24h, ``Tue HH:MM`` within a week, ``Nov 3`` beyond (the
|
||||
monthly large-model allowance), ``shortly`` once passed (the server rolls the clock on the next request, so a stale
|
||||
stamp is never contradicted)."""
|
||||
if not isinstance(resets_at_ms, int) or resets_at_ms <= 0:
|
||||
return "the next reset"
|
||||
now = int(time.time() * 1000) if now_ms is None else now_ms
|
||||
if resets_at_ms <= now:
|
||||
return "shortly"
|
||||
try:
|
||||
stamp = time.localtime(resets_at_ms / 1000)
|
||||
except (ValueError, OverflowError, OSError):
|
||||
return "the next reset"
|
||||
left = resets_at_ms - now
|
||||
if left >= 7 * 24 * 3600 * 1000:
|
||||
return f"{time.strftime('%b', stamp)} {stamp.tm_mday}"
|
||||
return time.strftime("%H:%M" if left < 24 * 3600 * 1000 else "%a %H:%M", stamp)
|
||||
|
||||
|
||||
def _resets(resets_at_ms: Optional[int]) -> str:
|
||||
"""`` · resets 15:56``; empty when the instant is unknown."""
|
||||
return f" · resets {format_window_reset(resets_at_ms)}" if isinstance(resets_at_ms, int) and resets_at_ms > 0 else ""
|
||||
|
||||
|
||||
def _spent_window(state: CreditsState) -> tuple[str, Optional[int]]:
|
||||
"""The binding allowance's limit name (``daily``; an unknown id: the server's label) and its reset."""
|
||||
g = state.binding_gauge
|
||||
return (gauge_limit_name(g), g.resets_at_ms) if g is not None else ("daily", None)
|
||||
|
||||
|
||||
# A window plan's approach warning: once per allowance per reset, at this fraction (the 50 / 75 bands are
|
||||
# noise on a plan whose gauges reset daily). The percent always comes from the server.
|
||||
WINDOW_APPROACH_FRACTION = 0.90
|
||||
CREDITS_WINDOW_APPROACH_PREFIX = "credits.window."
|
||||
WINDOW_APPROACH_TTL_MS = 10_000
|
||||
|
||||
|
||||
def window_approach_key(gauge_id: str) -> str:
|
||||
"""``credits.window.<gauge id>``: one key per allowance, so a client replaces it in place."""
|
||||
return CREDITS_WINDOW_APPROACH_PREFIX + gauge_id
|
||||
|
||||
|
||||
def _window_approach_text(g: WindowGauge) -> str:
|
||||
"""``⚠ 90% of your weekly limit used · resets Thu 09:00``."""
|
||||
pct = min(99, int(g.used_fraction * 100))
|
||||
return f"⚠ {pct}% of your {gauge_limit_name(g)} limit used{_resets(g.resets_at_ms)}"
|
||||
|
||||
|
||||
def _window_hard_stop(state: CreditsState, *, model_is_free: bool) -> Optional[str]:
|
||||
"""Blocking copy for a usage-window account, or None. ``paid_access`` is the portal's word (dunning);
|
||||
then: payment paused with no credits (credits keep a paused plan going whatever the setting says; a
|
||||
card update restores included usage), the large-model allowance used with no pay path, the window used with no
|
||||
pay path. A free model keeps inferencing, so only the payment-paused line survives it.
|
||||
With Keep going on credits off, a balance is not a pay path: the way on is that account setting, so
|
||||
the copy names it instead of adding credits."""
|
||||
purchased = state.purchased_micros
|
||||
if state.paused_reason and purchased <= 0:
|
||||
return _PAUSED_TEXT
|
||||
if not state.paid_access:
|
||||
return None if model_is_free else _DEPLETED_TEXT
|
||||
if model_is_free or state.credits_pay:
|
||||
return None
|
||||
if state.unmetered:
|
||||
return None
|
||||
action = _KEEP_GOING_OFF_ACTION if state.keep_going_on_credits is False else _ADD_CREDITS_ACTION
|
||||
# The large-model allowance ran out AND the last response was a block: the API stamps ``spend-source`` on every
|
||||
# admitted request and never on a 402, so header-sourced + no source = this model was refused. An
|
||||
# ``included`` source means the current model is not top-tier (still admitted) and a seed cannot know
|
||||
# which model the session will use — neither is a stop (/usage carries the top-tier line).
|
||||
if state.top_tier_exhausted and state.from_header and state.spend_source is None:
|
||||
auto = _AUTO_TOP_UP_SUFFIX if state.auto_top_up_enabled and action == _ADD_CREDITS_ACTION else ""
|
||||
return f"✕ {_TOP_TIER_LIMIT}{_resets(state.top_tier_resets_at_ms)} · {action}{auto}"
|
||||
if state.window_exhausted:
|
||||
name, resets = _spent_window(state)
|
||||
return f"✕ You've hit your {name} limit{_resets(resets)} · {action}"
|
||||
return None
|
||||
|
||||
|
||||
def window_stop_resets_at_ms(state: CreditsState) -> Optional[int]:
|
||||
"""When the allowance behind :func:`_window_hard_stop`'s limit line frees up (same branch order)."""
|
||||
if state.unmetered:
|
||||
return None
|
||||
if state.top_tier_exhausted and state.from_header and state.spend_source is None:
|
||||
return state.top_tier_resets_at_ms
|
||||
return _spent_window(state)[1] if state.window_exhausted else None
|
||||
|
||||
|
||||
# "Keep going on credits": the account setting (/topup or Portal billing), off by default on a window plan.
|
||||
_KEEP_GOING_OFF_ACTION = "turn on Keep going in /topup"
|
||||
_ADD_CREDITS_ACTION = "/topup to add credits"
|
||||
|
||||
|
||||
def _window_payg_text(state: CreditsState) -> Optional[str]:
|
||||
"""The one-shot PAYG line: the last request drew on top-up credits instead of the window. None otherwise."""
|
||||
if not (state.spend_source == "payg" and state.purchased_micros > 0):
|
||||
return None
|
||||
if state.window_exhausted:
|
||||
name, resets = _spent_window(state)
|
||||
text = f"• You've hit your {name} limit{_resets(resets)} · now using credits"
|
||||
elif state.top_tier_exhausted:
|
||||
text = f"• {_TOP_TIER_LIMIT}{_resets(state.top_tier_resets_at_ms)} · large models now use credits"
|
||||
else:
|
||||
text = "• Now using credits"
|
||||
return text + (_AUTO_TOP_UP_SUFFIX if state.auto_top_up_enabled else "")
|
||||
|
||||
|
||||
def _reconcile_sticky(key: str, text: Optional[str], level: str, latch: dict, to_show: list, to_clear: list) -> None:
|
||||
"""Show / replace / clear one sticky line by key: a text change re-emits (clear first), None clears."""
|
||||
active, texts = latch["active"], latch.setdefault("texts", {})
|
||||
if text is not None and (key not in active or texts.get(key) != text):
|
||||
if key in active:
|
||||
to_clear.append(key)
|
||||
to_show.append(_sticky_notice(text, level, key))
|
||||
active.add(key)
|
||||
texts[key] = text
|
||||
elif text is None and key in active:
|
||||
to_clear.append(key)
|
||||
active.discard(key)
|
||||
texts.pop(key, None)
|
||||
|
||||
|
||||
def _evaluate_window_notices(state: CreditsState, latch: dict, *, model_is_free: bool,
|
||||
model_is_top_tier: bool = False) -> tuple[list, list]:
|
||||
"""Notice policy for a usage-window account (see :func:`evaluate_credits_notices` for the contract).
|
||||
|
||||
Approach: each allowance warns once at 90% per reset (``credits.window.<id>``, a TTL heads-up; the
|
||||
limit line takes over when it runs out). The top-tier allowance warns only while the session is on a
|
||||
top-tier model (no other model moves it); a free model warns about nothing. grant_spent never fires (no
|
||||
grant); a payment pause / depleted account reuses ``credits.depleted`` and its ``credits.restored``."""
|
||||
to_show: list = []
|
||||
to_clear: list = []
|
||||
active = latch["active"]
|
||||
warned: dict = latch.setdefault("window_warned", {})
|
||||
if CREDITS_USAGE_KEY in active: # the credit-grant band does not apply to a window plan
|
||||
_reconcile_sticky(CREDITS_USAGE_KEY, None, "info", latch, to_show, to_clear)
|
||||
latch["usage_band"] = None
|
||||
for g in state.gauges:
|
||||
relevant = not model_is_free and (g.scope != SCOPE_TOP_TIER or model_is_top_tier)
|
||||
if relevant and not g.full and g.used_fraction >= WINDOW_APPROACH_FRACTION \
|
||||
and warned.get(g.id) != g.resets_at_ms:
|
||||
# A heads-up, not a state: shown once (TTL) and never re-shown for the same reset.
|
||||
warned[g.id] = g.resets_at_ms
|
||||
key = window_approach_key(g.id)
|
||||
to_show.append(AgentNotice(text=_window_approach_text(g), level="warn", kind="ttl",
|
||||
ttl_ms=WINDOW_APPROACH_TTL_MS, key=key, id=key))
|
||||
if CREDITS_GRANT_SPENT_KEY in active: # a plan flip mid-session: the grant line has no meaning here
|
||||
to_clear.append(CREDITS_GRANT_SPENT_KEY)
|
||||
active.discard(CREDITS_GRANT_SPENT_KEY)
|
||||
|
||||
stop = _window_hard_stop(state, model_is_free=model_is_free)
|
||||
limit = stop is not None and stop not in (_PAUSED_TEXT, _DEPLETED_TEXT)
|
||||
was_stopped = CREDITS_DEPLETED_KEY in active or CREDITS_WINDOW_LIMIT_KEY in active
|
||||
_reconcile_sticky(CREDITS_WINDOW_LIMIT_KEY, stop if limit else None, "error", latch, to_show, to_clear)
|
||||
_reconcile_sticky(CREDITS_DEPLETED_KEY, None if limit else stop, "error", latch, to_show, to_clear)
|
||||
if was_stopped and stop is None and state.paid_access and not model_is_free:
|
||||
# Genuine recovery (a reset, a top-up, a paid renewal) — a free-model switch is not "restored".
|
||||
to_show.append(AgentNotice(text="✓ Credit access restored", level="success", kind="ttl",
|
||||
ttl_ms=CREDITS_RESTORED_TTL_MS, key=CREDITS_RESTORED_KEY, id=CREDITS_RESTORED_KEY))
|
||||
_reconcile_sticky(CREDITS_WINDOW_PAYG_KEY, None if stop else _window_payg_text(state), "warn", latch, to_show, to_clear)
|
||||
return (to_show, to_clear)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -156,14 +410,24 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_credits_notices(state: CreditsState, latch: dict, *, model_is_free: bool = False) -> tuple[list[AgentNotice], list[str]]:
|
||||
def evaluate_credits_notices(state: CreditsState, latch: dict, *, model_is_free: bool = False,
|
||||
model_is_top_tier: bool = False) -> tuple[list[AgentNotice], list[str]]:
|
||||
"""Reconcile credits notices against the latch (see :func:`new_credits_latch`); mutates ``latch`` IN
|
||||
PLACE. Pure — no I/O, no agent/run_agent imports. ``model_is_free`` suppresses ``credits.depleted`` (a
|
||||
depleted account on a free model keeps inferencing) WITHOUT emitting "restored" — that fires only on a
|
||||
genuine ``paid_access`` flip back to True. Returns ``(to_show, to_clear)``; caller emits to_clear FIRST."""
|
||||
genuine ``paid_access`` flip back to True. Returns ``(to_show, to_clear)``; caller emits to_clear FIRST.
|
||||
A usage-window state (day/week percents, no grant) takes the window policy; everything else is unchanged."""
|
||||
if state.is_usage_window:
|
||||
return _evaluate_window_notices(state, latch, model_is_free=model_is_free,
|
||||
model_is_top_tier=model_is_top_tier)
|
||||
to_show: list[AgentNotice] = []
|
||||
to_clear: list[str] = []
|
||||
uf, active = state.used_fraction, latch["active"]
|
||||
for window_key in (CREDITS_WINDOW_PAYG_KEY, CREDITS_WINDOW_LIMIT_KEY):
|
||||
if window_key in active: # only ever set by the window policy; a stale line after a plan change
|
||||
to_clear.append(window_key)
|
||||
active.discard(window_key)
|
||||
latch.get("texts", {}).pop(window_key, None)
|
||||
# Crossing latch: band notices fire only once uf was observed below the LOWEST
|
||||
# band, so a session opening mid-range doesn't fire on its first observation
|
||||
# (the cold-start seed primes this when it WANTS an open-high warning).
|
||||
@@ -236,7 +500,7 @@ def evaluate_credits_notices(state: CreditsState, latch: dict, *, model_is_free:
|
||||
|
||||
# Header contract: (field, kind[, default-when-absent]); a field is REQUIRED unless it has a default.
|
||||
# Header name = ``x-nous-credits-<field>`` (``x-nous-<field>`` for tool_pool_*), underscores → dashes.
|
||||
# micros: int >= 0 ("signed": may be negative); usd: the server's formatted string ^-?\d+\.\d{2}$
|
||||
# micros: int >= 0 ("signed": may be negative; "bp": 0..10000); usd: the server's formatted string ^-?\d+\.\d{2}$
|
||||
# (never re-parsed); bool: "true"/"false" STRING. Handled inline: subscription-limit-* (PAIRED/optional),
|
||||
# denominator-kind ("subscription_cap" | "none"), disabled-reason (omitted when null).
|
||||
_HEADER_FIELDS: tuple[tuple, ...] = (
|
||||
@@ -246,19 +510,51 @@ _HEADER_FIELDS: tuple[tuple, ...] = (
|
||||
("paid_access", "bool", True), # absent → fail-open (assume access)
|
||||
("tool_pool_gated_off", "bool", False),
|
||||
)
|
||||
# Credit-grant plan fields. Required on a credit note; a usage_window note may omit them (the server can
|
||||
# retire them with legacy plans). Malformed is still a miss. The top-up balance (purchased_*) is optional on a
|
||||
# window note too: a refused (402) request carries only the window headers; the capture keeps the last balance.
|
||||
_GRANT_ONLY_FIELDS = frozenset({
|
||||
"remaining_micros", "remaining_usd", "subscription_micros", "subscription_usd", "rollover_micros", "as_of_ms",
|
||||
})
|
||||
_WINDOW_OPTIONAL_FIELDS = _GRANT_ONLY_FIELDS | {"purchased_micros", "purchased_usd"}
|
||||
|
||||
|
||||
# Usage-window headers, read only when ``denominator-kind`` is ``usage_window``: the gauge list
|
||||
# (``x-nous-credits-window-gauges``, REQUIRED: a note that names the kind but has no readable list is a
|
||||
# miss), plus the optional ``-spend-source`` / ``-window-paused-reason`` / ``-keep-going-on-credits``.
|
||||
_GAUGES_HEADER = "x-nous-credits-window-gauges"
|
||||
|
||||
|
||||
def _header_name(field: str) -> str:
|
||||
return "x-nous-" + ("" if field.startswith("tool_pool_") else "credits-") + field.replace("_", "-")
|
||||
|
||||
|
||||
def _parse_window_fields(lowered: Mapping[str, str]) -> Optional[dict[str, Any]]:
|
||||
"""Usage-window fields from lowercased headers; None when the gauge list is absent or unreadable.
|
||||
``spend_source`` outside its vocabulary reads as absent (fail-open)."""
|
||||
gauges = parse_gauges(lowered.get(_GAUGES_HEADER))
|
||||
if gauges is None or not all_scope(gauges):
|
||||
return None
|
||||
out: dict[str, Any] = {"gauges": gauges}
|
||||
source = lowered.get("x-nous-credits-spend-source")
|
||||
out["spend_source"] = source if source in _VALID_SPEND_SOURCES else None
|
||||
paused = lowered.get("x-nous-credits-window-paused-reason")
|
||||
out["paused_reason"] = paused.strip() if isinstance(paused, str) and paused.strip() else None
|
||||
keep_going = (lowered.get("x-nous-credits-keep-going-on-credits") or "").strip().lower()
|
||||
out["keep_going_on_credits"] = {"on": True, "off": False}.get(keep_going) # anything else: unknown (older server)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_field(kind: str, raw: Optional[str], default: Any = _SENTINEL) -> Any:
|
||||
"""One header value → field value; ``default`` when absent, ``_SENTINEL`` on a contract violation."""
|
||||
if raw is None:
|
||||
return default
|
||||
if kind in ("micros", "signed"):
|
||||
if kind in ("micros", "signed", "bp"):
|
||||
val = _safe_int(raw)
|
||||
return _SENTINEL if val is _SENTINEL or (kind == "micros" and val < 0) else val
|
||||
if val is _SENTINEL or (kind != "signed" and val < 0):
|
||||
return _SENTINEL
|
||||
# bp: basis points of a window, 0..10000 inclusive (anything past is a violation).
|
||||
return _SENTINEL if kind == "bp" and val > BP_FULL else val
|
||||
if kind == "usd":
|
||||
return raw if _USD_RE.match(raw) else _SENTINEL
|
||||
flag = raw.strip().lower()
|
||||
@@ -286,17 +582,26 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt
|
||||
_version_warning_emitted = True
|
||||
logger.warning("credits header version %d unsupported, ignoring — update Hermes", version_val)
|
||||
return None
|
||||
fields: dict[str, Any] = {
|
||||
name: _parse_field(kind, lowered.get(_header_name(name)), *default) for name, kind, *default in _HEADER_FIELDS
|
||||
}
|
||||
denominator_kind = lowered.get("x-nous-credits-denominator-kind", "none")
|
||||
window_note = denominator_kind == "usage_window"
|
||||
fields: dict[str, Any] = {}
|
||||
for name, kind, *default in _HEADER_FIELDS:
|
||||
raw = lowered.get(_header_name(name))
|
||||
if raw is None and window_note and name in _WINDOW_OPTIONAL_FIELDS:
|
||||
continue # a window note may drop these: dataclass default
|
||||
fields[name] = _parse_field(kind, raw, *default)
|
||||
lim_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros")
|
||||
lim_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd")
|
||||
if lim_micros_raw is not None and lim_usd_raw is not None:
|
||||
fields["subscription_limit_micros"] = _parse_field("micros", lim_micros_raw)
|
||||
fields["subscription_limit_usd"] = _parse_field("usd", lim_usd_raw)
|
||||
denominator_kind = lowered.get("x-nous-credits-denominator-kind", "none")
|
||||
if _SENTINEL in fields.values() or denominator_kind not in _VALID_DENOMINATOR_KINDS:
|
||||
return None
|
||||
if window_note:
|
||||
window = _parse_window_fields(lowered)
|
||||
if window is None:
|
||||
return None
|
||||
fields.update(window)
|
||||
disabled_reason = lowered.get("x-nous-credits-disabled-reason") # None if absent (omitted when null)
|
||||
return CreditsState(version=version_val, denominator_kind=denominator_kind, disabled_reason=disabled_reason,
|
||||
captured_at=time.time(), from_header=True, **fields)
|
||||
@@ -305,6 +610,20 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt
|
||||
return None
|
||||
|
||||
|
||||
def response_is_usage_window(http_response: Any) -> bool:
|
||||
"""True iff *http_response* carries complete usage-window ``x-nous-credits-*`` headers (the same
|
||||
test as ``CreditsState.is_usage_window``). Lets the window-only header captures leave every other
|
||||
account untouched. Never raises."""
|
||||
try:
|
||||
headers = getattr(http_response, "headers", None) if http_response is not None else None
|
||||
if not headers:
|
||||
return False
|
||||
state = parse_credits_headers(headers)
|
||||
return state is not None and state.is_usage_window
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── Dev fixtures (HERMES_DEV_CREDITS_FIXTURE): throwaway scaffolding to trigger any notice state
|
||||
# without real spend. Value is a state NAME or a FILE PATH whose contents are a name (re-read every
|
||||
# turn → `echo depleted > /tmp/cf` flips live). Drives per-turn notices, the cold-start seed, and /usage.
|
||||
@@ -370,16 +689,40 @@ def _credits_state_from_account(info) -> Optional[CreditsState]:
|
||||
monthly = getattr(sub, "monthly_credits", None)
|
||||
cap = _money(monthly) if isinstance(monthly, (int, float)) and monthly > 0 else (None, None)
|
||||
paid = getattr(info, "paid_service_access", None)
|
||||
window = _window_seed_fields(getattr(info, "usage_window", None))
|
||||
if window is not None:
|
||||
# A usage-window account: the gauge is the window, the grant fields are meaningless (kept 0).
|
||||
fields["subscription_micros"], fields["subscription_usd"] = 0, "0.00"
|
||||
cap = (None, None)
|
||||
return CreditsState(
|
||||
**fields, subscription_limit_micros=cap[0], subscription_limit_usd=cap[1], from_header=False, captured_at=time.time(),
|
||||
rollover_micros=_money(getattr(sub, "rollover_credits", None))[0], paid_access=paid if isinstance(paid, bool) else True,
|
||||
denominator_kind="subscription_cap" if cap[0] is not None else "none",
|
||||
denominator_kind="usage_window" if window is not None else ("subscription_cap" if cap[0] is not None else "none"),
|
||||
**(window or {}),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("credits ▸ seed account→state mapping failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _window_seed_fields(uw) -> Optional[dict[str, Any]]:
|
||||
"""Portal ``usage_window`` (NousUsageWindowInfo or dict) → CreditsState window fields; None unless it
|
||||
carries a readable gauge list with an all-scope allowance (same requirement as the header parser)."""
|
||||
if uw is None:
|
||||
return None
|
||||
get = uw.get if isinstance(uw, dict) else (lambda k, d=None: getattr(uw, k, d))
|
||||
gauges = parse_gauges(get("gauges"))
|
||||
if gauges is None or not all_scope(gauges):
|
||||
return None
|
||||
out: dict[str, Any] = {"gauges": gauges}
|
||||
out["auto_top_up_enabled"] = get("auto_top_up_enabled") is True
|
||||
keep_going = get("keep_going_on_credits")
|
||||
out["keep_going_on_credits"] = keep_going if isinstance(keep_going, bool) else None
|
||||
paused = get("paused_reason")
|
||||
out["paused_reason"] = paused if isinstance(paused, str) and paused else None
|
||||
return out
|
||||
|
||||
|
||||
# Process-lifetime memory of the usage band last shown per session_id: see
|
||||
# _remember_shown_band. Bounded MRU so a long-lived gateway cannot grow it without limit.
|
||||
_seen_usage_bands: dict = {}
|
||||
@@ -404,6 +747,55 @@ def _remember_shown_band(session_id, band) -> None:
|
||||
_seen_usage_bands.pop(next(iter(_seen_usage_bands)))
|
||||
|
||||
|
||||
# Window plans: the same per-session memory for the window notices (limit / now-using-credits / paused lines
|
||||
# and the 90% heads-ups), so a rebuilt agent does not re-show a line this session already showed.
|
||||
_WINDOW_NOTICE_KEYS = (CREDITS_WINDOW_PAYG_KEY, CREDITS_WINDOW_LIMIT_KEY, CREDITS_DEPLETED_KEY)
|
||||
_seen_window_notices: dict = {}
|
||||
|
||||
|
||||
def restore_window_notices(session_id, latch: dict) -> None:
|
||||
"""Load what this session already showed into a fresh latch (once per latch). Never raises."""
|
||||
try:
|
||||
if not session_id or latch.get("window_restored"):
|
||||
return
|
||||
latch["window_restored"] = True
|
||||
saved = _seen_window_notices.get(session_id)
|
||||
if not saved:
|
||||
return
|
||||
texts = latch.setdefault("texts", {})
|
||||
for key, text in saved["texts"].items():
|
||||
if key not in latch["active"]:
|
||||
latch["active"].add(key)
|
||||
texts[key] = text
|
||||
latch.setdefault("window_warned", {}).update(saved["warned"])
|
||||
except Exception:
|
||||
logger.debug("window notice restore failed", exc_info=True)
|
||||
|
||||
|
||||
def forget_window_notices(session_id) -> None:
|
||||
"""The session is no longer on a window plan: drop its saved lines. Never raises."""
|
||||
try:
|
||||
_seen_window_notices.pop(session_id, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def remember_window_notices(session_id, latch: dict) -> None:
|
||||
"""Record the window notices this session shows now (process lifetime, bounded MRU). Never raises."""
|
||||
try:
|
||||
if not session_id:
|
||||
return
|
||||
texts = latch.get("texts", {})
|
||||
entry = {"texts": {k: texts[k] for k in _WINDOW_NOTICE_KEYS if k in latch["active"] and k in texts},
|
||||
"warned": dict(latch.get("window_warned", {}))}
|
||||
_seen_window_notices.pop(session_id, None)
|
||||
_seen_window_notices[session_id] = entry
|
||||
if len(_seen_window_notices) > _SEEN_USAGE_BANDS_MAX:
|
||||
_seen_window_notices.pop(next(iter(_seen_window_notices)))
|
||||
except Exception:
|
||||
logger.debug("window notice remember failed", exc_info=True)
|
||||
|
||||
|
||||
def _hydrate_seed_state(agent, state) -> None:
|
||||
"""Install a seed CreditsState on the agent and fire the notice policy once. Primes the crossing gate:
|
||||
the cold-start snapshot IS the first observation, so a session opening in a band warns immediately."""
|
||||
|
||||
@@ -126,6 +126,11 @@ _UNVERIFIED_BILLING_PATTERNS = ("out of extra usage",)
|
||||
# scoped on purpose: other providers' billing codes on a 403 stay auth failures.
|
||||
_XAI_SPENDING_LIMIT_ERROR_CODE = "personal-team-blocked:spending-limit"
|
||||
|
||||
# Nous usage-window plan: included usage (or the large-model allowance) is spent AND
|
||||
# there are no top-up credits. Billing so the pool may rotate to a key the user
|
||||
# owns; the message names the reset, so it must never read as transient.
|
||||
_NOUS_USAGE_WINDOW_402_CODE = "usage_window_and_credits_exhausted"
|
||||
|
||||
# Structured codes meaning the account cannot serve paid traffic.
|
||||
_BILLING_ERROR_CODES = frozenset({
|
||||
"insufficient_quota", "billing_not_active", "payment_required", "insufficient_credits",
|
||||
@@ -138,6 +143,7 @@ _BILLING_ERROR_CODES = frozenset({
|
||||
"organization_usage_limit_exceeded", "project_spend_limit_exceeded",
|
||||
# Nous paid model behind an empty credit balance arrives as a 404 (#115702).
|
||||
"insufficient_credits_for_paid_model",
|
||||
_NOUS_USAGE_WINDOW_402_CODE,
|
||||
})
|
||||
|
||||
# Transient rate limiting. Bedrock "Throttling error: Too many tokens" also
|
||||
@@ -1096,14 +1102,44 @@ def _status_5xx(c: _Ctx) -> Verdict:
|
||||
return _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_SERVER_ERROR
|
||||
|
||||
|
||||
def _classify_402(error_msg: str, result_fn: Callable[..., Any]) -> Any:
|
||||
"""Disambiguate 402: "usage limit, try again in 5 minutes" is a periodic quota, not billing."""
|
||||
def _is_nous_route(c: "_Ctx") -> bool:
|
||||
"""The usage-window 402 code is Nous's: any other upstream sending it keeps main's text rules."""
|
||||
if (c.provider or "").strip().lower() == "nous":
|
||||
return True
|
||||
from utils import base_url_host_matches
|
||||
return bool(c.base_url) and base_url_host_matches(c.base_url, "nousresearch.com")
|
||||
|
||||
|
||||
def _classify_402(error_msg: str, result_fn: Callable[..., Any], code: str = "", body: Any = None) -> Any:
|
||||
"""Disambiguate 402: "usage limit, try again in 5 minutes" is a periodic quota, not billing.
|
||||
The Nous usage-window code is decisive (its prose names the reset time, which must not
|
||||
demote it to a transient rate limit); every other 402 keeps the text rules. A usage-window 402's
|
||||
optional ``display_message`` (server-set wording) rides in ``error_context`` for the refusal line."""
|
||||
if code == _NOUS_USAGE_WINDOW_402_CODE:
|
||||
# Tagged so recovery skips the paid-entitlement refresh (none restores included usage before the reset).
|
||||
context: dict[str, Any] = {"usage_window": True}
|
||||
if display := _usage_window_display_message(body):
|
||||
context["display_message"] = display
|
||||
# No fallback: another model is the user's call (the limit card offers it), never automatic.
|
||||
return result_fn(**{**_V_BILLING, "should_fallback": False}, error_context=context)
|
||||
transient = any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) and any(
|
||||
p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS
|
||||
)
|
||||
return result_fn(**(_V_RATE_LIMIT if transient else _V_BILLING))
|
||||
|
||||
|
||||
def _usage_window_display_message(body: Any) -> str:
|
||||
"""The server's optional wording for a usage-window 402 (top level or under ``error``), trimmed and
|
||||
capped; "" when absent."""
|
||||
if not isinstance(body, dict):
|
||||
return ""
|
||||
for holder in (body, body.get("error")):
|
||||
value = holder.get("display_message") if isinstance(holder, dict) else None
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()[:500]
|
||||
return ""
|
||||
|
||||
|
||||
def _has_large_inline_image(content: Any) -> bool:
|
||||
"""True when a rejected ``content`` list carries a ``data:image/`` part the shrink pass would rewrite
|
||||
(over ``conversation_compression._IMAGE_SHRINK_TARGET_BYTES``; below it a shrink retry is a no-op)."""
|
||||
@@ -1224,7 +1260,8 @@ def _classify_image_tool_422(c: _Ctx) -> Verdict:
|
||||
# retry-safe (RFC 9110 §15.5.9; proxies emit it when generation outruns the
|
||||
# read window). Unlisted 4xx → format_error, 5xx → server_error.
|
||||
_STATUS_HANDLERS: dict[int, Callable[[_Ctx], Verdict]] = {
|
||||
400: _classify_400, 401: lambda c: _V_AUTH_ROTATE, 402: lambda c: _classify_402(c.msg, dict),
|
||||
400: _classify_400, 401: lambda c: _V_AUTH_ROTATE,
|
||||
402: lambda c: _classify_402(c.msg, dict, c.code if _is_nous_route(c) else "", c.body),
|
||||
403: _status_403, 404: _status_404, 408: lambda c: _V_TIMEOUT, 413: lambda c: _V_PAYLOAD_TOO_LARGE,
|
||||
422: lambda c: _classify_image_tool_422(c),
|
||||
429: _status_429, 500: _status_5xx, 502: _status_5xx,
|
||||
|
||||
@@ -171,6 +171,18 @@ def build_error_surface_from_result(result: Any, provider: str = "", model: str
|
||||
if error_text and _disk_full(error_text):
|
||||
return _surface(LAYER_DISK, "disk_full", False, provider, model)
|
||||
if result.get("billing_block") or reason in ("billing", "billing_unverified"):
|
||||
if window_message := str(result.get("usage_window_message") or ""):
|
||||
# A window plan's limit, not an empty account: the card shows the stop-line sentence.
|
||||
# Retry is the natural next click once Keep going / a top-up is on, and at the reset.
|
||||
surface = _surface(LAYER_BILLING, "usage_window", True, provider, model)
|
||||
surface["message"] = window_message
|
||||
if isinstance(resets_at := result.get("failure_resets_at"), (int, float)) and not isinstance(resets_at, bool):
|
||||
surface["resets_at"] = float(resets_at)
|
||||
if isinstance(limit := result.get("usage_limit"), dict):
|
||||
# What ran out, the plan's recommended models that still work, and the credits
|
||||
# option: the card's one-click buttons (agent/plan_limits.py).
|
||||
surface["usage_limit"] = limit
|
||||
return surface
|
||||
return _surface(LAYER_BILLING, reason or "billing", False, provider, model)
|
||||
# The Nous free tier refused or could not serve the turn (``agent/turn_recovery.py``
|
||||
# stamps ``free_tier``): its own code, so a client offers the free sign-in rather than an
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Usage-window plans: what a limit blocks, and what to offer instead.
|
||||
|
||||
One rule decides everywhere, so the picker's "used up" note, the limit card's suggestion and the switch
|
||||
button can never disagree:
|
||||
|
||||
* a free model is never blocked;
|
||||
* a full gauge with scope ``all`` (day / week / month / any new one) blocks every paid model;
|
||||
* a full ``top_tier`` gauge blocks the models the gateway marks top tier.
|
||||
|
||||
Hermes never switches model on its own. When a request is refused, :func:`limit_hit` describes the
|
||||
limit and names up to two models from the plan's recommended list (in order) that still
|
||||
work. Each surface renders that as buttons where it already has them, otherwise as a sentence.
|
||||
|
||||
Everything here reads cached data only (credits headers, the portal account snapshot, the cached
|
||||
catalog) and never raises: a failure means "no suggestion".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER = "nous"
|
||||
# Leave room for the reply: a suggestion must fit the conversation with this many tokens to spare.
|
||||
_CONTEXT_HEADROOM_TOKENS = 8_000
|
||||
_MAX_SUGGESTIONS = 2
|
||||
|
||||
NOTE_FREE = "free"
|
||||
NOTE_INCLUDED = "included"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelSuggestion:
|
||||
model: str # routed id, e.g. "example/model-a"
|
||||
label: str # display name ("Model A"), the id when the catalog has none
|
||||
note: str # NOTE_FREE | NOTE_INCLUDED
|
||||
provider: str = PROVIDER
|
||||
|
||||
def to_payload(self) -> dict[str, str]:
|
||||
return {"model": self.model, "label": self.label, "note": self.note, "provider": self.provider}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreditsOption:
|
||||
"""The second way on, past a limit: spend top-up credits.
|
||||
|
||||
``use_credits``: Keep going on credits is off and there is a balance, so turning it on lets the
|
||||
request through. ``add_credits``: no balance (or the setting is already on), so the way on is a
|
||||
top-up. Interactive surfaces render it as a button; messaging points at the Portal (a chat button
|
||||
could spend the owner's money for anyone in the room)."""
|
||||
action: str # "use_credits" | "add_credits"
|
||||
|
||||
def to_payload(self) -> dict[str, str]:
|
||||
return {"action": self.action}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LimitHit:
|
||||
gauge_id: str # day | week | month | top_tier | <server id>
|
||||
scope: str # all | top_tier
|
||||
limit_name: str # "daily" / "weekly" / "monthly large-model" / the server label
|
||||
resets_at_ms: Optional[int]
|
||||
message: str # the limit sentence (the server's display_message wins)
|
||||
suggestions: tuple[ModelSuggestion, ...] = ()
|
||||
credits: Optional[CreditsOption] = None
|
||||
current_model: str = ""
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"gauge_id": self.gauge_id, "scope": self.scope, "limit_name": self.limit_name,
|
||||
"resets_at_ms": self.resets_at_ms, "message": self.message,
|
||||
"suggestions": [s.to_payload() for s in self.suggestions],
|
||||
"credits": self.credits.to_payload() if self.credits else None,
|
||||
"current_model": self.current_model,
|
||||
}
|
||||
|
||||
|
||||
# ── catalog reads ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def cached_pricing(base_url: str) -> dict:
|
||||
try:
|
||||
from hermes_cli.models_pricing import peek_cached_pricing
|
||||
return peek_cached_pricing(base_url) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _billed_elsewhere(model: str, pricing: Optional[dict]) -> bool:
|
||||
try:
|
||||
from hermes_cli.models import _is_subscription_billed
|
||||
return _is_subscription_billed((pricing or {}).get(model))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_free(model: str, pricing: Optional[dict]) -> bool:
|
||||
"""$0 to the plan: a ``:free`` id or a row the catalog lists as free."""
|
||||
if not model:
|
||||
return False
|
||||
if model.endswith(":free"):
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.models import _is_model_free
|
||||
return bool(pricing) and _is_model_free(model, pricing)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_top_tier(model: str, pricing: Optional[dict]) -> bool:
|
||||
"""The gateway's ``top_tier`` mark. Unmarked or no catalog: not top tier."""
|
||||
entry = (pricing or {}).get(model) if model else None
|
||||
if is_free(model, pricing):
|
||||
return False
|
||||
return isinstance(entry, dict) and entry.get("top_tier") is True
|
||||
|
||||
|
||||
def model_label(model: str, pricing: Optional[dict]) -> str:
|
||||
entry = (pricing or {}).get(model)
|
||||
name = entry.get("name") if isinstance(entry, dict) else None
|
||||
return name.strip() if isinstance(name, str) and name.strip() else model
|
||||
|
||||
|
||||
# ── the one rule ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def blocking_gauge(model: str, gauges: Iterable[Any], *, free: bool, top_tier: bool) -> Optional[Any]:
|
||||
"""The full gauge that stops *model*, or None while it still has included room. Free models are
|
||||
never blocked. Of several full gauges, the one that frees up last (that is when the model works)."""
|
||||
if free:
|
||||
return None
|
||||
from agent.usage_gauges import SCOPE_ALL, SCOPE_TOP_TIER
|
||||
full = [g for g in gauges
|
||||
if getattr(g, "full", False) and (g.scope == SCOPE_ALL or (g.scope == SCOPE_TOP_TIER and top_tier))]
|
||||
return max(full, key=lambda g: g.resets_at_ms or 0) if full else None
|
||||
|
||||
|
||||
def is_blocked(model: str, gauges: Iterable[Any], pricing: Optional[dict]) -> bool:
|
||||
return blocking_gauge(model, gauges, free=is_free(model, pricing),
|
||||
top_tier=is_top_tier(model, pricing)) is not None
|
||||
|
||||
|
||||
# ── suggestions ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _plan_usage() -> Any:
|
||||
# Plan shape only (featured models), so the last account snapshot at any age answers: the 60s
|
||||
# cache has long lapsed by the time a window runs out mid-session.
|
||||
try:
|
||||
from hermes_cli.model_picker_usage import nous_picker_usage
|
||||
return nous_picker_usage(any_age=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _portal_free_models() -> list[str]:
|
||||
"""The Portal's free picks, from what Hermes already holds (in-process, else the
|
||||
last copy on disk). Never fetches: this runs mid-turn."""
|
||||
try:
|
||||
from hermes_cli import models as m
|
||||
payload = next((data for data, _ts in reversed(list(m._nous_recommended_cache.values())) if data), None)
|
||||
if payload is None:
|
||||
disk = m._read_nous_recommended_disk(m._resolve_nous_portal_url())
|
||||
payload = disk[0] if disk else None
|
||||
entries = payload.get("freeRecommendedModels") if isinstance(payload, dict) else None
|
||||
return [n for e in (entries or []) if (n := m._extract_model_name(e))]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _fits_context(model: str, needed_tokens: Optional[int], base_url: str) -> bool:
|
||||
if not needed_tokens or needed_tokens <= 0:
|
||||
return True
|
||||
try:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
ctx = get_model_context_length(model, base_url=base_url, provider=PROVIDER)
|
||||
return not ctx or ctx >= needed_tokens + _CONTEXT_HEADROOM_TOKENS
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def suggest_models(
|
||||
current_model: str, gauges: Iterable[Any], *, pricing: Optional[dict] = None, featured: Iterable[str] = (),
|
||||
needed_tokens: Optional[int] = None, base_url: str = "", limit: int = _MAX_SUGGESTIONS,
|
||||
) -> tuple[ModelSuggestion, ...]:
|
||||
"""The plan's recommended models (in order) that the full gauges do not block, then the Portal's free picks.
|
||||
Never the current model, only a model whose context window holds the conversation."""
|
||||
gauges = tuple(gauges)
|
||||
pool = [m for m in featured if isinstance(m, str) and m]
|
||||
pool += [m for m in _portal_free_models() if m not in pool]
|
||||
out: list[ModelSuggestion] = []
|
||||
for model in pool:
|
||||
if model == current_model or _billed_elsewhere(model, pricing):
|
||||
continue
|
||||
if is_blocked(model, gauges, pricing) or not _fits_context(model, needed_tokens, base_url):
|
||||
continue
|
||||
out.append(ModelSuggestion(model=model, label=model_label(model, pricing),
|
||||
note=NOTE_FREE if is_free(model, pricing) else NOTE_INCLUDED))
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def credits_option(state: Any) -> Optional[CreditsOption]:
|
||||
"""How credits could carry on past the limit (see :class:`CreditsOption`). None while paused (a card
|
||||
update restores included usage; the paused copy says so)."""
|
||||
if state is None or getattr(state, "paused_reason", None):
|
||||
return None
|
||||
purchased = int(getattr(state, "purchased_micros", 0) or 0)
|
||||
if getattr(state, "keep_going_on_credits", None) is False and purchased > 0:
|
||||
return CreditsOption("use_credits")
|
||||
return CreditsOption("add_credits")
|
||||
|
||||
|
||||
def limit_hit(agent: Any, *, message: str, needed_tokens: Optional[int] = None) -> Optional[LimitHit]:
|
||||
"""After a usage-window 402: which limit stopped *agent*'s current model, when it resets, and what to
|
||||
offer. None when the state names no full gauge for this model (a paused plan, a stale snapshot)."""
|
||||
try:
|
||||
state = getattr(agent, "_credits_state", None)
|
||||
if state is None or not getattr(state, "is_usage_window", False):
|
||||
return None
|
||||
model = str(getattr(agent, "model", "") or "")
|
||||
base_url = str(getattr(agent, "base_url", "") or "")
|
||||
pricing = cached_pricing(base_url)
|
||||
gauges = tuple(getattr(state, "gauges", ()) or ())
|
||||
g = blocking_gauge(model, gauges, free=is_free(model, pricing), top_tier=is_top_tier(model, pricing))
|
||||
if g is None:
|
||||
# The 402 says this model was refused; without a top-tier mark in the cache a full top-tier
|
||||
# gauge is still the only explanation.
|
||||
from agent.usage_gauges import top_tier_gauge
|
||||
top = top_tier_gauge(gauges)
|
||||
g = top if top is not None and top.full else None
|
||||
if g is None:
|
||||
return None
|
||||
from agent.usage_gauges import gauge_limit_name
|
||||
usage = _plan_usage()
|
||||
featured = tuple(getattr(usage, "featured_model_ids", ()) or ())
|
||||
return LimitHit(
|
||||
gauge_id=g.id, scope=g.scope, limit_name=gauge_limit_name(g), resets_at_ms=g.resets_at_ms,
|
||||
message=message, current_model=model, credits=credits_option(state),
|
||||
suggestions=suggest_models(model, gauges, pricing=pricing, featured=featured,
|
||||
needed_tokens=needed_tokens, base_url=base_url),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("plan limits: limit_hit failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# ── copy ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def portal_wording(text: str) -> str:
|
||||
"""A refusal line for a surface without ``/topup`` (messaging): point its credits steps at the Portal."""
|
||||
return (text.replace("turn on Keep going in /topup", "turn on Keep going in the Portal")
|
||||
.replace("/topup to add credits", "add credits in the Portal")
|
||||
.replace("add credits in /topup", "add credits in the Portal"))
|
||||
|
||||
|
||||
def suggestion_phrase(s: ModelSuggestion) -> str:
|
||||
"""``Model A (free)``."""
|
||||
return f"{s.label} ({s.note})"
|
||||
|
||||
|
||||
def next_steps_text(hit: LimitHit, *, model_command: str = "/model", topup_command: Optional[str] = "/topup",
|
||||
portal: bool = False) -> str:
|
||||
"""One line on how to keep going, for a surface without buttons. ``model_command`` is the platform's
|
||||
own spelling (e.g. ``!model``). ``portal``: point at the Portal for credits (messaging),
|
||||
instead of a command that would spend from the chat."""
|
||||
parts: list[str] = []
|
||||
if hit.suggestions:
|
||||
parts.append(f"Switch to {suggestion_phrase(hit.suggestions[0])} with {model_command}")
|
||||
else:
|
||||
parts.append(f"Pick another model with {model_command}")
|
||||
if hit.credits is not None:
|
||||
if portal or not topup_command:
|
||||
parts.append("turn on Keep going in the Portal to use credits" if hit.credits.action == "use_credits"
|
||||
else "add credits in the Portal")
|
||||
else:
|
||||
parts.append(f"turn on Keep going in {topup_command} to use credits" if hit.credits.action == "use_credits"
|
||||
else f"add credits with {topup_command}")
|
||||
return ", or ".join(parts) + "."
|
||||
|
||||
|
||||
def limit_next_steps_from_payload(limit: dict, *, model_command: str = "/model", topup_command: Optional[str] = "/topup",
|
||||
portal: bool = False, markup: bool = False) -> str:
|
||||
""":func:`next_steps_text` for a :meth:`LimitHit.to_payload` dict (what the result carries). ``markup``
|
||||
bolds the commands for a Rich panel."""
|
||||
sug = [ModelSuggestion(model=str(s.get("model") or ""), label=str(s.get("label") or s.get("model") or ""),
|
||||
note=str(s.get("note") or NOTE_INCLUDED))
|
||||
for s in (limit.get("suggestions") or []) if isinstance(s, dict) and s.get("model")]
|
||||
credits = limit.get("credits") if isinstance(limit.get("credits"), dict) else None
|
||||
hit = LimitHit(gauge_id=str(limit.get("gauge_id") or ""), scope=str(limit.get("scope") or ""),
|
||||
limit_name=str(limit.get("limit_name") or ""), resets_at_ms=limit.get("resets_at_ms"),
|
||||
message=str(limit.get("message") or ""), suggestions=tuple(sug),
|
||||
credits=CreditsOption(str(credits.get("action"))) if credits and credits.get("action") else None)
|
||||
if markup:
|
||||
model_command, topup_command = f"[bold]{model_command}[/]", (f"[bold]{topup_command}[/]" if topup_command else None)
|
||||
return next_steps_text(hit, model_command=model_command, topup_command=topup_command, portal=portal)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Keep the cached plan snapshot in step with what the gateway says on each reply.
|
||||
|
||||
The pickers read a cached account snapshot (``hermes_cli.nous_account``) and a cached Nous catalog
|
||||
(``hermes_cli.models_pricing``), so a plan change made in the browser (upgrade, downgrade, cancel) or a
|
||||
limit filling / resetting would only show after the next session opened. Every Nous reply already
|
||||
carries the usage-window headers, so this compares them with the snapshot and, only when they
|
||||
disagree, refreshes both caches once in the background. No extra call on an ordinary turn, and
|
||||
nothing at all for a reply that is not from Nous.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_inflight = False
|
||||
# What the headers said the last time a refresh ran. The portal can trail the gateway by a few minutes
|
||||
# (usage is ingested in batches), so the same mismatch is refreshed once, not on every reply meanwhile.
|
||||
_last_attempt: Optional[tuple[bool, frozenset[str]]] = None
|
||||
|
||||
|
||||
def _snapshot_full_ids(snapshot: Any) -> Optional[frozenset[str]]:
|
||||
"""Ids of the full allowances in the cached snapshot; None when it has no window plan."""
|
||||
window = getattr(snapshot, "usage_window", None)
|
||||
from agent.credits_tracker import _window_seed_fields
|
||||
if _window_seed_fields(window) is None:
|
||||
return None
|
||||
return frozenset(g.id for g in (getattr(window, "gauges", None) or ()) if g.full)
|
||||
|
||||
|
||||
def plan_snapshot_is_stale(state: Any, snapshot: Any) -> bool:
|
||||
"""True when a reply's credits state contradicts the cached snapshot in a way a picker shows:
|
||||
window headers but a non-window snapshot (upgrade), the reverse (downgrade or cancel), or a
|
||||
different set of full allowances (a limit filled or reset). No snapshot yet: nothing to fix (the
|
||||
session-start seed is fetching it)."""
|
||||
if state is None or snapshot is None or getattr(snapshot, "source", None) == "error":
|
||||
return False
|
||||
on_window = bool(getattr(state, "is_usage_window", False))
|
||||
full_ids = _snapshot_full_ids(snapshot)
|
||||
if on_window != (full_ids is not None):
|
||||
return True
|
||||
if not on_window:
|
||||
return False
|
||||
return frozenset(g.id for g in getattr(state, "gauges", ()) if g.full) != full_ids
|
||||
|
||||
|
||||
def _refresh(plan_changed: bool) -> None:
|
||||
global _inflight
|
||||
try:
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
get_nous_portal_account_info(force_fresh=True)
|
||||
if plan_changed:
|
||||
# The per-model levels (Large / Free tags) come with the catalog; refetch so they move with the plan.
|
||||
from hermes_cli.models_pricing import get_pricing_for_provider
|
||||
get_pricing_for_provider("nous", force_refresh=True)
|
||||
except Exception:
|
||||
logger.debug("plan sync ▸ refresh failed (fail-open)", exc_info=True)
|
||||
global _last_attempt
|
||||
_last_attempt = None # a transient portal error must not block the next re-sync
|
||||
finally:
|
||||
with _lock:
|
||||
_inflight = False
|
||||
|
||||
|
||||
def sync_plan_snapshot(state: Any) -> bool:
|
||||
"""Start one background refresh when *state* (a fresh ``CreditsState`` from reply headers) says the
|
||||
cached plan is out of date. Returns True iff a refresh started. Never raises, never blocks."""
|
||||
global _inflight, _last_attempt
|
||||
try:
|
||||
if not getattr(state, "is_usage_window", False):
|
||||
# Non-window reply: only a cached window snapshot (a downgrade / cancel) can be stale. Peek the
|
||||
# raw cache first so every other account pays no auth-state read per reply.
|
||||
from hermes_cli import nous_account
|
||||
cached = nous_account._account_info_cache
|
||||
if cached is None or _snapshot_full_ids(cached[2]) is None:
|
||||
_last_attempt = None
|
||||
return False
|
||||
from hermes_cli.nous_account import cached_nous_portal_account_info
|
||||
snapshot = cached_nous_portal_account_info(any_age=True)
|
||||
if not plan_snapshot_is_stale(state, snapshot):
|
||||
_last_attempt = None # in step again: the next mismatch gets its own refresh
|
||||
return False
|
||||
on_window = bool(getattr(state, "is_usage_window", False))
|
||||
seen = (on_window, frozenset(g.id for g in getattr(state, "gauges", ()) if g.full))
|
||||
plan_changed = on_window != (_snapshot_full_ids(snapshot) is not None)
|
||||
with _lock:
|
||||
if _inflight or seen == _last_attempt:
|
||||
return False
|
||||
_inflight, _last_attempt = True, seen
|
||||
threading.Thread(target=_refresh, args=(plan_changed,), name="nous-plan-sync", daemon=True).start()
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("plan sync ▸ check failed (fail-open)", exc_info=True)
|
||||
with _lock:
|
||||
_inflight = False
|
||||
return False
|
||||
@@ -13,6 +13,15 @@ from utils import is_truthy_value
|
||||
logger = logging.getLogger("run_agent")
|
||||
|
||||
|
||||
|
||||
def _is_nous_agent(agent) -> bool:
|
||||
"""True for the Nous provider (or a route to a nousresearch.com host)."""
|
||||
if (getattr(agent, "provider", "") or "").strip().lower() == "nous":
|
||||
return True
|
||||
from utils import base_url_host_matches
|
||||
base_url = str(getattr(agent, "base_url", "") or "")
|
||||
return bool(base_url) and base_url_host_matches(base_url, "nousresearch.com")
|
||||
|
||||
def _response_headers(http_response: Any):
|
||||
"""Headers of a response, or None when there is nothing to parse."""
|
||||
return getattr(http_response, "headers", None) if http_response is not None else None
|
||||
@@ -68,7 +77,7 @@ class RateLimitCreditsMixin:
|
||||
self._capture_rate_limits(http_response)
|
||||
self._capture_credits(http_response)
|
||||
|
||||
def _capture_credits(self, http_response: Any) -> None:
|
||||
def _capture_credits(self, http_response: Any, *, refused: bool = False) -> None:
|
||||
"""Parse x-nous-credits-* headers, cache CreditsState, fire threshold notices.
|
||||
|
||||
The PARSE is swallowed (miss → keep last-known); notice EVALUATION WARNS on failure so a
|
||||
@@ -109,7 +118,26 @@ class RateLimitCreditsMixin:
|
||||
"(miss — producer off / non-Nous path / >TTL stale)")
|
||||
return
|
||||
|
||||
if state.is_usage_window and not _is_nous_agent(self):
|
||||
return # window headers from any other upstream are ignored (no window UI, no Keep going button)
|
||||
if state.is_usage_window and not any(k.lower() == "x-nous-credits-purchased-micros" for k in headers):
|
||||
# A refused request's headers omit the top-up balance: keep the last known one, not "no credits".
|
||||
prior = getattr(self, "_credits_state", None)
|
||||
if prior is not None:
|
||||
import dataclasses
|
||||
state = dataclasses.replace(state, purchased_micros=prior.purchased_micros,
|
||||
purchased_usd=prior.purchased_usd)
|
||||
if state.is_usage_window and not refused and state.spend_source is None:
|
||||
import dataclasses
|
||||
state = dataclasses.replace(state, unmetered=True) # admitted but not metered: no limit stop from it
|
||||
_adopt_credits_state(self, state)
|
||||
try:
|
||||
# A reply that contradicts the cached plan (upgrade, cancel, a limit filled or reset)
|
||||
# refreshes it once in the background, so pickers catch up without a new session.
|
||||
from agent.plan_snapshot_sync import sync_plan_snapshot
|
||||
sync_plan_snapshot(state)
|
||||
except Exception:
|
||||
pass
|
||||
if dev:
|
||||
# HERMES_DEV_CREDITS streams each capture to agent.log (`hermes logs -f`, grep 'credits ▸').
|
||||
spent = self.get_credits_spent_micros()
|
||||
@@ -141,9 +169,29 @@ class RateLimitCreditsMixin:
|
||||
latch = self._credits_latch = new_credits_latch()
|
||||
# Free-model gate: a depleted account can still inference on a free model. Local data only.
|
||||
model_is_free = is_free_tier_model(getattr(self, "model", "") or "", getattr(self, "base_url", "") or "")
|
||||
if state.depleted and not model_is_free and rewarm_pricing_before_depleted_notice(self):
|
||||
# Any blocking state (depleted, or a used-up window / large-model allowance) needs a warm catalog to
|
||||
# tell a model billed elsewhere apart once the 300s pricing cache lapses.
|
||||
blocking = state.depleted or state.window_exhausted or state.top_tier_exhausted
|
||||
if blocking and not model_is_free and rewarm_pricing_before_depleted_notice(self):
|
||||
return # a cold catalog cannot say the model is billed elsewhere; the warm's re-run decides
|
||||
to_show, to_clear = evaluate_credits_notices(state, latch, model_is_free=model_is_free)
|
||||
model_is_top_tier = False
|
||||
if state.is_usage_window and not model_is_free:
|
||||
# The top-tier approach warning only matters while on a top-tier model (gateway mark).
|
||||
from agent.plan_limits import cached_pricing, is_top_tier
|
||||
model_is_top_tier = is_top_tier(getattr(self, "model", "") or "",
|
||||
cached_pricing(getattr(self, "base_url", "") or ""))
|
||||
session_id = getattr(self, "session_id", None)
|
||||
if state.is_usage_window:
|
||||
from agent.credits_tracker import restore_window_notices
|
||||
restore_window_notices(session_id, latch)
|
||||
to_show, to_clear = evaluate_credits_notices(
|
||||
state, latch, model_is_free=model_is_free, model_is_top_tier=model_is_top_tier)
|
||||
if state.is_usage_window:
|
||||
from agent.credits_tracker import remember_window_notices
|
||||
remember_window_notices(session_id, latch)
|
||||
elif session_id:
|
||||
from agent.credits_tracker import forget_window_notices
|
||||
forget_window_notices(session_id)
|
||||
for key in to_clear:
|
||||
self._emit_notice_clear(key)
|
||||
for notice in to_show:
|
||||
|
||||
@@ -89,6 +89,19 @@ def handle_api_error(
|
||||
status_code = getattr(api_error, "status_code", None)
|
||||
error_context = agent._extract_api_error_context(api_error)
|
||||
|
||||
# A Nous 402/403 carries the same x-nous-credits-* headers as a success, and a usage-window plan's
|
||||
# "ran out for this model" / "payment paused" state is only visible on the blocking response itself
|
||||
# (the stream path captures headers after the stream opens, which a 402 never does). Fail-open.
|
||||
# Usage-window responses only: every other account keeps main's behaviour (no capture here).
|
||||
if status_code in (402, 403) and getattr(agent, "provider", "") == "nous":
|
||||
try:
|
||||
from agent.credits_tracker import response_is_usage_window
|
||||
response = getattr(api_error, "response", None)
|
||||
if response_is_usage_window(response):
|
||||
agent._capture_credits(response, refused=True)
|
||||
except Exception:
|
||||
logger.debug("credits ▸ header capture on %s failed (fail-open)", status_code, exc_info=True)
|
||||
|
||||
# Process is exiting mid-flight: retries/rotation/fallbacks are futile and the
|
||||
# retry trace spams the shell. One log line.
|
||||
from tools.interpreter_shutdown import interpreter_shutting_down
|
||||
|
||||
+12
-3
@@ -650,6 +650,8 @@ def recover_after_classification(
|
||||
|
||||
if (
|
||||
classified.reason == FailoverReason.billing
|
||||
# A usage-window 402 is the plan's own limit: refreshing credentials cannot lift it.
|
||||
and not (getattr(classified, "error_context", None) or {}).get("usage_window")
|
||||
and _is_nous_inference_route(
|
||||
getattr(agent, "provider", "") or "", getattr(agent, "base_url", "") or ""
|
||||
)
|
||||
@@ -660,9 +662,13 @@ def recover_after_classification(
|
||||
_vlines(agent, "🔐 Nous paid access verified — refreshed runtime credentials and retrying request...")
|
||||
return True, False
|
||||
|
||||
_pool_context = error_context
|
||||
if (getattr(classified, "error_context", None) or {}).get("usage_window"):
|
||||
# A plan limit benches only the refused model on the credential, until its reset.
|
||||
_pool_context = {**(error_context if isinstance(error_context, dict) else {}), "usage_window": True}
|
||||
recovered_with_pool, _retry.has_retried_429 = agent._recover_with_credential_pool(
|
||||
status_code=status_code, has_retried_429=_retry.has_retried_429,
|
||||
classified_reason=classified.reason, error_context=error_context,
|
||||
classified_reason=classified.reason, error_context=_pool_context,
|
||||
billing_unverified=classified.billing_unverified,
|
||||
)
|
||||
if recovered_with_pool:
|
||||
@@ -994,7 +1000,9 @@ def nonretryable_client_error_result(
|
||||
the retry trace, print auth / billing / content-policy / TLS guidance, persist (skipped
|
||||
for likely context-overflow 400s so the failure does not grow the session), build result."""
|
||||
# Result/guidance helpers stay in the loop module (tests import + patch them there).
|
||||
from agent.conversation_loop import _billing_failure_result, _content_policy_blocked_result
|
||||
from agent.conversation_loop import (
|
||||
_billing_failure_result, _content_policy_blocked_result, _usage_window_refusal,
|
||||
)
|
||||
|
||||
if api_kwargs is not None:
|
||||
agent._dump_api_request_debug(api_kwargs, reason="non_retryable_client_error", error=api_error)
|
||||
@@ -1081,6 +1089,7 @@ def nonretryable_client_error_result(
|
||||
return _billing_failure_result(
|
||||
classified=classified, summary=_nonretryable_summary, messages=messages,
|
||||
api_call_count=api_call_count, provider=provider, base_url=base_url, model=model,
|
||||
usage_window=_usage_window_refusal(agent, classified, api_messages),
|
||||
)
|
||||
if _welcome_hint:
|
||||
# A free-tier refusal is fully explained by its own sentence; the raw provider summary
|
||||
@@ -1891,7 +1900,7 @@ def route_classified_error(
|
||||
_should_fallback = (
|
||||
(is_rate_limited and _wrapped_output_cap_budget is None)
|
||||
or (_is_transport_failure and retry_count >= 2)
|
||||
)
|
||||
) and not (getattr(classified, "error_context", None) or {}).get("usage_window") # never switch models on a plan limit
|
||||
if _should_fallback and agent._fallback_index < len(agent._fallback_chain):
|
||||
# No eager fallback while credential pool rotation may recover. Exception: an
|
||||
# upstream-aggregator 429 — the pool can't help, always fall back.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Usage-window allowances as the server sends them: one list, in display order.
|
||||
|
||||
The Portal account (``usage_window.gauges``) and the API (``x-nous-credits-window-gauges``, also on the 402)
|
||||
send the same shape, so Hermes draws whatever arrives and a new allowance needs no Hermes release.
|
||||
``scope`` says which models a full gauge stops: ``all`` = every paid model, ``top_tier`` = the models the
|
||||
gateway marks top tier. Free models draw on nothing.
|
||||
|
||||
Hermes keeps only its own words for the ids it knows (they are translated and fitted to each surface);
|
||||
any other id shows the server's ``label``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
BP_FULL = 10_000
|
||||
SCOPE_ALL = "all"
|
||||
SCOPE_TOP_TIER = "top_tier"
|
||||
_SCOPES = frozenset({SCOPE_ALL, SCOPE_TOP_TIER})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowGauge:
|
||||
id: str
|
||||
scope: str # SCOPE_ALL | SCOPE_TOP_TIER
|
||||
label: str # the server's display fallback
|
||||
used_bp: int # 0..10000
|
||||
resets_at_ms: Optional[int] = None
|
||||
|
||||
@property
|
||||
def full(self) -> bool:
|
||||
return self.used_bp >= BP_FULL
|
||||
|
||||
@property
|
||||
def used_fraction(self) -> float:
|
||||
return min(1.0, max(0.0, self.used_bp / BP_FULL))
|
||||
|
||||
|
||||
def parse_gauges(value: Any) -> Optional[tuple[WindowGauge, ...]]:
|
||||
"""The wire list (a JSON array, or its string form from a header) → gauges in order. None when absent,
|
||||
unreadable or empty. An entry with an unknown scope, a repeated id or a bad percent is dropped: an
|
||||
older Hermes must never misread a kind of limit it does not know."""
|
||||
if isinstance(value, tuple) and all(isinstance(g, WindowGauge) for g in value):
|
||||
return value or None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
out: list[WindowGauge] = []
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
gid, scope, bp = item.get("id"), item.get("scope"), item.get("used_bp")
|
||||
if not isinstance(gid, str) or not gid or gid in seen or scope not in _SCOPES:
|
||||
continue
|
||||
if isinstance(bp, bool) or not isinstance(bp, int) or not 0 <= bp <= BP_FULL:
|
||||
continue
|
||||
reset = item.get("resets_at_ms")
|
||||
reset = reset if isinstance(reset, int) and not isinstance(reset, bool) and reset > 0 else None
|
||||
label = item.get("label")
|
||||
seen.add(gid)
|
||||
out.append(WindowGauge(gid, scope, label.strip() if isinstance(label, str) and label.strip() else gid,
|
||||
bp, reset))
|
||||
return tuple(out) or None
|
||||
|
||||
|
||||
def all_scope(gauges: Iterable[WindowGauge]) -> tuple[WindowGauge, ...]:
|
||||
return tuple(g for g in gauges if g.scope == SCOPE_ALL)
|
||||
|
||||
|
||||
def top_tier_gauge(gauges: Iterable[WindowGauge]) -> Optional[WindowGauge]:
|
||||
return next((g for g in gauges if g.scope == SCOPE_TOP_TIER), None)
|
||||
|
||||
|
||||
def binding_gauge(gauges: Iterable[WindowGauge]) -> Optional[WindowGauge]:
|
||||
"""The all-scope gauge that binds: of the full ones, the one that frees up LAST (with the day and the
|
||||
week both full, the day's reset alone lets nothing through); else the fullest (ties → first listed)."""
|
||||
alls = all_scope(gauges)
|
||||
if not alls:
|
||||
return None
|
||||
full = [g for g in alls if g.full]
|
||||
if full:
|
||||
return max(full, key=lambda g: g.resets_at_ms or 0)
|
||||
return max(alls, key=lambda g: g.used_bp)
|
||||
|
||||
|
||||
# Hermes's words for the ids it knows: (row title, "for <phrase>", "your <name> limit", picker short name).
|
||||
_WORDS: dict[str, tuple[str, str, str, str]] = {
|
||||
"day": ("Today", "today", "daily", "today"),
|
||||
"week": ("This week", "this week", "weekly", "week"),
|
||||
"month": ("This month", "this month", "monthly", "month"),
|
||||
"top_tier": ("Large models (monthly)", "your monthly large-model usage", "monthly large-model", "large models (monthly)"),
|
||||
}
|
||||
|
||||
|
||||
def _words(g: WindowGauge) -> Optional[tuple[str, str, str, str]]:
|
||||
return _WORDS.get(g.id)
|
||||
|
||||
|
||||
def gauge_title(g: WindowGauge) -> str:
|
||||
"""Row title in ``/usage``: ``Today``; an unknown id shows the server's label."""
|
||||
return w[0] if (w := _words(g)) else g.label
|
||||
|
||||
|
||||
def gauge_limit_name(g: WindowGauge) -> str:
|
||||
"""``You've hit your <name> limit``: ``daily``; an unknown id shows the server's label."""
|
||||
return w[2] if (w := _words(g)) else g.label
|
||||
|
||||
|
||||
def gauge_short(g: WindowGauge) -> str:
|
||||
"""Picker summary row name: ``today``; an unknown id shows the server's label."""
|
||||
return w[3] if (w := _words(g)) else g.label
|
||||
+6
-2
@@ -393,8 +393,12 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
|
||||
}
|
||||
|
||||
// Structured billing wall forwarded by the gateway (out of credits /
|
||||
// payment required) — cache it + raise a billing-specific toast.
|
||||
if (payload?.billing) {
|
||||
// payment required) — cache it + raise a billing-specific toast. A window
|
||||
// plan's limit is said once, by the turn's error card (its buttons are the
|
||||
// ways on); a toast and banner would only repeat it.
|
||||
const usageWindowWall = parseErrorSurface(payload?.error_surface)?.code === 'usage_window'
|
||||
|
||||
if (payload?.billing && !usageWindowWall) {
|
||||
surfaceBillingBlock(sessionId, payload.billing)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface BillingApi {
|
||||
scheduleSubscriptionChange: (tierId: string) => Promise<BillingResult<BillingMutationResponse>>
|
||||
stepUp: (sessionId?: string) => Promise<BillingResult<BillingMutationResponse>>
|
||||
updateAutoReload: (input: UpdateAutoReloadInput) => Promise<BillingResult<BillingMutationResponse>>
|
||||
/** Window plans: "Keep going on credits" (billing.keep_going_on_credits). */
|
||||
updateKeepGoingOnCredits: (enabled: boolean) => Promise<BillingResult<BillingMutationResponse>>
|
||||
}
|
||||
|
||||
interface RefusalRecord {
|
||||
@@ -174,7 +176,9 @@ export const createBillingApi = (requestGateway: BillingRequestGateway): Billing
|
||||
enabled: input.enabled,
|
||||
...(input.threshold_usd !== undefined ? { threshold: input.threshold_usd } : {}),
|
||||
...(input.reload_to_usd !== undefined ? { top_up_amount: input.reload_to_usd } : {})
|
||||
})
|
||||
}),
|
||||
updateKeepGoingOnCredits: enabled =>
|
||||
callBilling<BillingMutationResponse>(requestGateway, 'billing.keep_going_on_credits', { enabled })
|
||||
})
|
||||
|
||||
// An override for the gateway-backed api — DEV fixtures provide a simulated
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BillingResult } from './api'
|
||||
import { postTrainBillingState, subscriberPersonalSubscriptionState } from './dev-fixtures'
|
||||
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
|
||||
|
||||
export {
|
||||
@@ -33,3 +34,92 @@ export const endpointUnavailableSubscription = {
|
||||
message: 'Subscription endpoint is not available.'
|
||||
}
|
||||
} satisfies BillingResult<SubscriptionStateResponse>
|
||||
|
||||
// A usage-window plan: the dollar `usage` model is unavailable and the page draws the
|
||||
// Today / This week / This month / Large models gauges instead.
|
||||
// `month` is optional: an older portal sends no month allowance, so no month row.
|
||||
function windowPlan(
|
||||
name: string,
|
||||
{
|
||||
day,
|
||||
week,
|
||||
month,
|
||||
topTier,
|
||||
paused = false
|
||||
}: { day: number; week: number; month?: number; topTier: number; paused?: boolean }
|
||||
) {
|
||||
const now = Date.now()
|
||||
|
||||
const billing = {
|
||||
...postTrainBillingState,
|
||||
balance_display: '$12.40',
|
||||
balance_usd: '12.40',
|
||||
monthly_cap: null,
|
||||
org_name: `${name} Fixture`,
|
||||
plan_usage: {
|
||||
gauges: [
|
||||
{ label: 'day', left_fraction: day, resets_at_ms: now + 7 * 3_600_000, scope: 'all', style: styleFor(day) },
|
||||
{
|
||||
label: 'week',
|
||||
left_fraction: week,
|
||||
resets_at_ms: now + 4 * 86_400_000 + 3_600_000,
|
||||
scope: 'all',
|
||||
style: styleFor(week)
|
||||
},
|
||||
...(month == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: 'month',
|
||||
left_fraction: month,
|
||||
resets_at_ms: now + 18 * 86_400_000,
|
||||
scope: 'all',
|
||||
style: styleFor(month)
|
||||
}
|
||||
]),
|
||||
{
|
||||
label: 'top_tier',
|
||||
left_fraction: topTier,
|
||||
resets_at_ms: now + 7 * 3_600_000,
|
||||
scope: 'top_tier',
|
||||
style: styleFor(topTier)
|
||||
}
|
||||
],
|
||||
featured_model_ids: ['example/model-a', 'example/model-c'],
|
||||
top_tier_model_ids: ['example/model-c'],
|
||||
kind: 'usage_window',
|
||||
paused_reason: paused ? 'payment_failed' : null,
|
||||
plan: 'Window Plan'
|
||||
},
|
||||
usage: { available: false }
|
||||
} satisfies BillingStateResponse
|
||||
|
||||
const subscription = {
|
||||
...subscriberPersonalSubscriptionState,
|
||||
current: {
|
||||
...subscriberPersonalSubscriptionState.current,
|
||||
credits_remaining: null,
|
||||
cycle_ends_at: '2026-10-11T08:14:55.000Z',
|
||||
monthly_credits: '0',
|
||||
tier_id: 'window-plan',
|
||||
tier_name: 'Window Plan'
|
||||
},
|
||||
org_name: `${name} Fixture`,
|
||||
plan_usage: billing.plan_usage,
|
||||
tiers: [],
|
||||
usage: { available: false }
|
||||
} satisfies SubscriptionStateResponse
|
||||
|
||||
return { billing: okBilling(billing), subscription: okSubscription(subscription) }
|
||||
}
|
||||
|
||||
// Mirrors hermes_cli/model_picker_usage.py::gauge_style (green > 0.5, yellow >= 0.2, red below).
|
||||
function styleFor(left: number): string {
|
||||
return left < 0.2 ? 'red' : left < 0.5 ? 'yellow' : 'green'
|
||||
}
|
||||
|
||||
export const windowFixtures = {
|
||||
'window-healthy': windowPlan('Window Healthy', { day: 0.76, week: 0.93, month: 0.97, topTier: 1 }),
|
||||
'window-low': windowPlan('Window Low', { day: 0.12, week: 0.41, topTier: 0 }),
|
||||
'window-paused': windowPlan('Window Paused', { day: 0.6, week: 0.8, topTier: 0.5, paused: true })
|
||||
}
|
||||
@@ -32,7 +32,8 @@ const apiMocks = vi.hoisted(() => ({
|
||||
resumeSubscription: vi.fn(),
|
||||
scheduleSubscriptionChange: vi.fn(),
|
||||
stepUp: vi.fn(),
|
||||
updateAutoReload: vi.fn()
|
||||
updateAutoReload: vi.fn(),
|
||||
updateKeepGoingOnCredits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
@@ -47,7 +48,8 @@ vi.mock('./api', () => ({
|
||||
resumeSubscription: apiMocks.resumeSubscription,
|
||||
scheduleSubscriptionChange: apiMocks.scheduleSubscriptionChange,
|
||||
stepUp: apiMocks.stepUp,
|
||||
updateAutoReload: apiMocks.updateAutoReload
|
||||
updateAutoReload: apiMocks.updateAutoReload,
|
||||
updateKeepGoingOnCredits: apiMocks.updateKeepGoingOnCredits
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -206,6 +208,25 @@ describe('BillingSettings', () => {
|
||||
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows "Keep going on credits" only on window plans and flips it', async () => {
|
||||
renderBilling()
|
||||
await screen.findByRole('button', { name: 'Manage' })
|
||||
expect(screen.queryByRole('switch', { name: 'Keep going on credits' })).toBeNull()
|
||||
cleanup()
|
||||
|
||||
apiMocks.fetchBillingState.mockResolvedValue(okBilling({ ...todayBillingState, keep_going_on_credits: false }))
|
||||
apiMocks.updateKeepGoingOnCredits.mockResolvedValue({ data: { ok: true }, ok: true })
|
||||
const client = renderBilling()
|
||||
const invalidate = vi.spyOn(client, 'invalidateQueries')
|
||||
|
||||
const toggle = await screen.findByRole('switch', { name: 'Keep going on credits' })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
fireEvent.click(toggle)
|
||||
|
||||
await waitFor(() => expect(apiMocks.updateKeepGoingOnCredits).toHaveBeenCalledWith(true))
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] }))
|
||||
})
|
||||
|
||||
it('renders the enabled auto-refill row without crashing when the card is null', async () => {
|
||||
apiMocks.fetchBillingState.mockResolvedValue(
|
||||
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } })
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { RowValue } from './account-row-value'
|
||||
import { BillingApiProvider } from './api'
|
||||
import { AutoReloadRow } from './auto-reload-row'
|
||||
import { KeepGoingOnCreditsRow } from './keep-going-on-credits-row'
|
||||
import { clampAmount, formatMoney } from './billing-amounts'
|
||||
import { CurrentPlanCard } from './current-plan-card'
|
||||
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
|
||||
@@ -337,13 +338,25 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa
|
||||
// Plain shared primitive — no bespoke track chrome. Only the fill tone carries
|
||||
// billing meaning: destructive when over-limit, green for healthy remaining
|
||||
// credits, muted otherwise. Color rides the sanctioned `fillClassName` override.
|
||||
const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
|
||||
// Window gauges add the picker's middle rung: yellow once more than half is used.
|
||||
const isOk =
|
||||
resolvedBar.state === 'ok' &&
|
||||
(resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup' || resolvedBar.tone === 'window')
|
||||
|
||||
const fillClassName =
|
||||
resolvedBar.state === 'danger'
|
||||
? undefined
|
||||
: resolvedBar.state === 'warn'
|
||||
? 'bg-(--ui-yellow)'
|
||||
: isOk
|
||||
? 'bg-(--ui-green)'
|
||||
: 'bg-muted-foreground/45'
|
||||
|
||||
return (
|
||||
<Progress
|
||||
aria-label={resolvedBar.label}
|
||||
destructive={resolvedBar.state === 'danger'}
|
||||
fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'}
|
||||
fillClassName={fillClassName}
|
||||
fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }}
|
||||
size="lg"
|
||||
value={resolvedBar.value}
|
||||
@@ -369,7 +382,11 @@ function UsageRow({ row }: { row: BillingUsageRowView }) {
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 whitespace-nowrap text-[length:var(--conversation-text-font-size)] font-medium tabular-nums @2xl:w-[220px] @2xl:flex-none @2xl:text-right',
|
||||
row.bar?.state === 'danger' ? 'text-destructive' : 'text-foreground'
|
||||
row.bar?.state === 'danger'
|
||||
? 'text-destructive'
|
||||
: row.bar?.state === 'warn'
|
||||
? 'text-(--ui-yellow)'
|
||||
: 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{row.value}
|
||||
@@ -548,7 +565,7 @@ function BillingSettingsContent({
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{(paymentRow || accountRows.length > 0) && (
|
||||
{(paymentRow || accountRows.length > 0 || billing?.keep_going_on_credits != null) && (
|
||||
<SettingsSection
|
||||
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
|
||||
icon={CreditCard}
|
||||
@@ -557,11 +574,13 @@ function BillingSettingsContent({
|
||||
{accountRows.map(row => (
|
||||
<AccountRow billing={billing} key={row.id} row={row} />
|
||||
))}
|
||||
{/* Window plans only (null for other plans and non-admins). */}
|
||||
{billing?.keep_going_on_credits != null && <KeepGoingOnCreditsRow enabled={billing.keep_going_on_credits} />}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{view.usageRows.length > 0 && (
|
||||
<SettingsSection icon={BarChart3} title={b.sections.usage}>
|
||||
<SettingsSection icon={BarChart3} meta={view.usagePlanName} title={b.sections.usage}>
|
||||
<div className="@container">
|
||||
{view.usageRows.map(row => (
|
||||
<UsageRow key={row.id} row={row} />
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
stepUp: vi.fn(),
|
||||
updateKeepGoingOnCredits: vi.fn()
|
||||
}))
|
||||
|
||||
const gatewayMock = vi.hoisted(() => {
|
||||
const handlers = new Map<string, Set<(event: unknown) => void>>()
|
||||
|
||||
return {
|
||||
emit: (eventName: string, event: unknown) => handlers.get(eventName)?.forEach(handler => handler(event)),
|
||||
gateway: {
|
||||
on: (eventName: string, handler: (event: unknown) => void) => {
|
||||
const set = handlers.get(eventName) ?? new Set()
|
||||
handlers.set(eventName, set)
|
||||
set.add(handler)
|
||||
|
||||
return () => set.delete(handler)
|
||||
}
|
||||
},
|
||||
reset: () => handlers.clear()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store/gateway', async () => {
|
||||
const { atom } = (await vi.importActual('nanostores')) as { atom: (value: unknown) => unknown }
|
||||
|
||||
return { $gateway: atom(gatewayMock.gateway) }
|
||||
})
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
useBillingApi: () => apiMocks
|
||||
}))
|
||||
|
||||
import { KeepGoingOnCreditsRow } from './keep-going-on-credits-row'
|
||||
import { $stepUpInFlight } from './use-step-up'
|
||||
|
||||
const NEEDS_APPROVAL = { ok: false, refusal: { kind: 'insufficient_scope', message: '' } }
|
||||
|
||||
function renderRow(enabled = false) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
return render(createElement(QueryClientProvider, { client }, createElement(KeepGoingOnCreditsRow, { enabled })))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiMocks.stepUp.mockReset()
|
||||
apiMocks.updateKeepGoingOnCredits.mockReset()
|
||||
gatewayMock.reset()
|
||||
$stepUpInFlight.set(false)
|
||||
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { openExternal: vi.fn() } })
|
||||
})
|
||||
|
||||
describe('KeepGoingOnCreditsRow', () => {
|
||||
it('without Remote Spending: one click opens the approval, holds the switch, then applies the change', async () => {
|
||||
let resolveStepUp: (value: unknown) => void = () => {}
|
||||
apiMocks.updateKeepGoingOnCredits
|
||||
.mockResolvedValueOnce(NEEDS_APPROVAL)
|
||||
.mockResolvedValueOnce({ data: {}, ok: true })
|
||||
apiMocks.stepUp.mockReturnValue(new Promise(resolve => (resolveStepUp = resolve)))
|
||||
|
||||
renderRow(false)
|
||||
const toggle = screen.getByRole('switch')
|
||||
fireEvent.click(toggle)
|
||||
|
||||
// The approval is in flight: one line under the switch, the switch stays on, billing refetches pause.
|
||||
expect(await screen.findByText(/Approve Remote Spending in your browser/)).toBeTruthy()
|
||||
expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true')
|
||||
expect($stepUpInFlight.get()).toBe(true)
|
||||
|
||||
act(() => {
|
||||
gatewayMock.emit('billing.step_up.verification', {
|
||||
payload: { user_code: 'ABCD-1234', verification_url: 'https://portal.nousresearch.com/device' }
|
||||
})
|
||||
})
|
||||
|
||||
// The browser opens on its own (no second "Verify" click).
|
||||
expect(window.hermesDesktop?.openExternal).toHaveBeenCalledWith('https://portal.nousresearch.com/device')
|
||||
expect(screen.getByText('ABCD-1234')).toBeTruthy()
|
||||
|
||||
await act(async () => resolveStepUp({ data: { granted: true, ok: true }, ok: true }))
|
||||
|
||||
// Approved: the SAME change is re-applied automatically, and the prompt goes away.
|
||||
await waitFor(() => expect(apiMocks.updateKeepGoingOnCredits).toHaveBeenCalledTimes(2))
|
||||
expect(apiMocks.updateKeepGoingOnCredits).toHaveBeenLastCalledWith(true)
|
||||
await waitFor(() => expect(screen.queryByText(/Approve Remote Spending/)).toBeNull())
|
||||
expect($stepUpInFlight.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('a declined approval puts the switch back and says why, once', async () => {
|
||||
apiMocks.updateKeepGoingOnCredits.mockResolvedValue(NEEDS_APPROVAL)
|
||||
apiMocks.stepUp.mockResolvedValue({ data: { granted: false, ok: true }, ok: true })
|
||||
|
||||
renderRow(false)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(await screen.findByText(/Verification was not approved/)).toBeTruthy()
|
||||
await waitFor(() => expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('false'))
|
||||
expect(apiMocks.updateKeepGoingOnCredits).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByText(/Remote Spending needs approval/)).toBeNull()
|
||||
})
|
||||
|
||||
it('with Remote Spending already allowed: a plain toggle, no approval', async () => {
|
||||
apiMocks.updateKeepGoingOnCredits.mockResolvedValue({ data: {}, ok: true })
|
||||
|
||||
renderRow(true)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
await waitFor(() => expect(apiMocks.updateKeepGoingOnCredits).toHaveBeenCalledWith(false))
|
||||
expect(apiMocks.stepUp).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/Approve Remote Spending/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KeepGoingOnCreditsRow after an approval', () => {
|
||||
it('a refusal right after approving offers Try again, never the generic approval/top-up copy', async () => {
|
||||
apiMocks.updateKeepGoingOnCredits
|
||||
.mockResolvedValueOnce(NEEDS_APPROVAL)
|
||||
.mockResolvedValueOnce(NEEDS_APPROVAL)
|
||||
.mockResolvedValueOnce({ data: {}, ok: true })
|
||||
apiMocks.stepUp.mockResolvedValue({ data: { granted: true, ok: true }, ok: true })
|
||||
|
||||
renderRow(true)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(await screen.findByText(/approved, but the change didn't save/)).toBeTruthy()
|
||||
expect(screen.queryByText(/Start a top-up/)).toBeNull()
|
||||
expect(screen.queryByText('Verify to continue')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText('Try again'))
|
||||
await waitFor(() => expect(apiMocks.updateKeepGoingOnCredits).toHaveBeenCalledTimes(3))
|
||||
expect(apiMocks.updateKeepGoingOnCredits).toHaveBeenLastCalledWith(false)
|
||||
expect(apiMocks.stepUp).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => expect(screen.queryByText(/didn't save/)).toBeNull())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ExternalLink } from '@/lib/icons'
|
||||
|
||||
import { ToggleRow } from '../primitives'
|
||||
|
||||
import type { BillingRefusal } from './api'
|
||||
import { useBillingApi } from './api'
|
||||
import { BillingRefusalInline } from './inline-feedback'
|
||||
import { useStepUpFlow } from './use-step-up'
|
||||
|
||||
const CAPTION = 'mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)]'
|
||||
|
||||
/**
|
||||
* Window plans: "Keep going on credits", the same account setting as the Portal's Billing > Topup
|
||||
* switch. Optimistic like the Portal switch.
|
||||
*
|
||||
* Changing it needs Remote Spending (billing:manage) on this terminal. Without it, the click starts the
|
||||
* approval itself: the browser opens, the switch holds the requested position, ONE line under it says
|
||||
* what to do, and the change applies on its own once approved. No refuse → verify → click-again loop,
|
||||
* and no second Remote Spending prompt elsewhere on the page.
|
||||
*/
|
||||
export function KeepGoingOnCreditsRow({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.billing.keepGoingOnCredits
|
||||
const api = useBillingApi()
|
||||
const queryClient = useQueryClient()
|
||||
const stepUp = useStepUpFlow({ autoOpen: true })
|
||||
const [optimistic, setOptimistic] = useState<boolean | null>(null)
|
||||
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
|
||||
const [approving, setApproving] = useState(false)
|
||||
// Approved, yet the change was still refused (e.g. an older backend replayed the pre-approval token).
|
||||
const [retryValue, setRetryValue] = useState<boolean | null>(null)
|
||||
const checked = optimistic ?? enabled
|
||||
|
||||
const settle = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||
setOptimistic(null)
|
||||
}
|
||||
|
||||
const apply = async (next: boolean, afterApproval = false): Promise<void> => {
|
||||
const result = await api.updateKeepGoingOnCredits(next)
|
||||
|
||||
if (result.ok) {
|
||||
setRetryValue(null)
|
||||
|
||||
return settle()
|
||||
}
|
||||
|
||||
if (result.refusal.kind === 'insufficient_scope' && afterApproval) {
|
||||
// Never the generic "start a top-up" copy here: the user just approved. Offer a plain retry.
|
||||
setRetryValue(next)
|
||||
|
||||
return settle()
|
||||
}
|
||||
|
||||
if (result.refusal.kind === 'insufficient_scope') {
|
||||
// Needs Remote Spending: approve, then apply the SAME change (the switch keeps the requested position).
|
||||
setApproving(true)
|
||||
const granted = await stepUp.start()
|
||||
setApproving(false)
|
||||
|
||||
if (granted) {
|
||||
stepUp.dismiss() // the change itself is the confirmation; no extra "Verification complete" line
|
||||
|
||||
return apply(next, true)
|
||||
}
|
||||
|
||||
// Denied / failed / cancelled: the switch goes back; a denial keeps its one-line reason below.
|
||||
return settle()
|
||||
}
|
||||
|
||||
setRefusal(result.refusal)
|
||||
|
||||
return settle()
|
||||
}
|
||||
|
||||
const onChange = (next: boolean) => {
|
||||
setOptimistic(next)
|
||||
setRefusal(null)
|
||||
setRetryValue(null)
|
||||
stepUp.dismiss()
|
||||
void apply(next)
|
||||
}
|
||||
|
||||
let below = <BillingRefusalInline refusal={refusal} />
|
||||
|
||||
if (approving) {
|
||||
below = (
|
||||
<div className={`${CAPTION} text-(--ui-text-tertiary)`}>
|
||||
<span>{copy.approveInBrowser}</span>
|
||||
{stepUp.verification?.code && (
|
||||
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{stepUp.verification.code}</span>
|
||||
)}
|
||||
{stepUp.verification && (
|
||||
<Button onClick={stepUp.openVerification} size="sm" type="button" variant="outline">
|
||||
{copy.openAgain}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
// dismiss() makes the pending start() return early, so put the switch back here.
|
||||
stepUp.dismiss()
|
||||
setApproving(false)
|
||||
void settle()
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} else if (retryValue !== null) {
|
||||
below = (
|
||||
<div className={`${CAPTION} text-(--ui-text-tertiary)`}>
|
||||
<span>{copy.approvedRetry}</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next = retryValue
|
||||
setRetryValue(null)
|
||||
setOptimistic(next)
|
||||
void apply(next, true)
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{copy.tryAgain}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} else if (stepUp.message?.kind === 'error') {
|
||||
below = (
|
||||
<div className={`${CAPTION} text-(--ui-text-tertiary)`}>
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{stepUp.message.title}:</span> {stepUp.message.text}
|
||||
</span>
|
||||
<Button onClick={stepUp.dismiss} size="sm" type="button" variant="ghost">
|
||||
{t.settings.billing.stepUp.dismiss}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleRow
|
||||
below={below}
|
||||
checked={checked}
|
||||
description={copy.description}
|
||||
disabled={optimistic !== null}
|
||||
label={copy.title}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export function createSimulatedBillingApi(fixture: SimulatedFixture): BillingApi
|
||||
return ok({ message: 'Downgrade scheduled.', ok: true })
|
||||
},
|
||||
stepUp: async () => ok({ granted: true, ok: true }),
|
||||
updateAutoReload: async () => ok({ ok: true })
|
||||
updateAutoReload: async () => ok({ ok: true }),
|
||||
updateKeepGoingOnCredits: async () => ok({ ok: true })
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlanUsage } from '@hermes/shared'
|
||||
import type {
|
||||
BillingAutoReload,
|
||||
BillingCardInfo,
|
||||
@@ -38,6 +39,7 @@ export type {
|
||||
BillingMutationResponse,
|
||||
BillingRefusalCode,
|
||||
ChargeFailureReason,
|
||||
PlanUsage,
|
||||
SharedBillingStateResponse,
|
||||
SubscriptionPreviewResponse,
|
||||
SubscriptionStateResponse,
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
postTrainBillingState,
|
||||
postTrainSubscriptionState,
|
||||
todayBillingState,
|
||||
todaySubscriptionState
|
||||
todaySubscriptionState,
|
||||
windowFixtures
|
||||
} from './fixtures.test-util'
|
||||
import { buildManageSubscriptionUrl, deriveBillingView, formatMonthlyCreditsDelta } from './use-billing-state'
|
||||
|
||||
@@ -60,6 +61,95 @@ function monthlyCapRowForSpent(spent: string) {
|
||||
return view.usageRows.find(row => row.id === 'monthly_cap')
|
||||
}
|
||||
|
||||
describe('deriveBillingView (usage-window plans)', () => {
|
||||
it('replaces the subscription credits row with today / week / month / large-model gauges', () => {
|
||||
const fixture = windowFixtures['window-healthy']
|
||||
const view = deriveBillingView(fixture.billing, fixture.subscription)
|
||||
|
||||
expect(view.usageRows.map(row => row.id)).toEqual([
|
||||
'window_day',
|
||||
'window_week',
|
||||
'window_month',
|
||||
'window_top_tier',
|
||||
'topup_credits'
|
||||
])
|
||||
expect(view.usageRows[0]).toMatchObject({
|
||||
bar: { state: 'ok', tone: 'window' },
|
||||
title: 'Today',
|
||||
value: '24% used'
|
||||
})
|
||||
// Fixture clocks are captured at import, so allow the boundary tick (7h → 6h, 4d 1h → 4d).
|
||||
expect(view.usageRows[0]?.caption).toMatch(/^Resets in [67]h$/)
|
||||
expect(view.usageRows[0]?.bar?.value).toBeCloseTo(0.24)
|
||||
expect(view.usageRows[1]).toMatchObject({ title: 'This week', value: '7% used' })
|
||||
expect(view.usageRows[1]?.caption).toMatch(/^Resets in 4d( 1h)?$/)
|
||||
expect(view.usageRows[2]).toMatchObject({ title: 'This month', value: '3% used' })
|
||||
expect(view.usageRows[2]?.caption).toMatch(/^Resets in (18|17)d/)
|
||||
expect(view.usageRows[3]).toMatchObject({ title: 'Large models (monthly)', value: '0% used' })
|
||||
expect(JSON.stringify(view.usageRows.slice(0, 4))).not.toMatch(/\$/)
|
||||
})
|
||||
|
||||
it('shows no month row when the server sends no month gauge (older portal)', () => {
|
||||
const fixture = windowFixtures['window-low']
|
||||
const view = deriveBillingView(fixture.billing, fixture.subscription)
|
||||
|
||||
expect(view.usageRows.map(row => row.id)).toEqual(['window_day', 'window_week', 'window_top_tier', 'topup_credits'])
|
||||
})
|
||||
|
||||
it('colours a running-down window yellow then red, and names an exhausted large-model allowance', () => {
|
||||
const fixture = windowFixtures['window-low']
|
||||
const billing = fixture.billing?.ok
|
||||
? { ...fixture.billing, data: { ...fixture.billing.data, keep_going_on_credits: true } }
|
||||
: fixture.billing
|
||||
const view = deriveBillingView(billing, fixture.subscription)
|
||||
|
||||
expect(view.usageRows[0]).toMatchObject({ bar: { state: 'danger' }, value: '88% used' })
|
||||
expect(view.usageRows[1]).toMatchObject({ bar: { state: 'warn' }, value: '59% used' })
|
||||
expect(view.usageRows[2]).toMatchObject({
|
||||
bar: { state: 'danger', value: 1 },
|
||||
caption: 'Monthly allowance used up, credits cover the rest',
|
||||
value: '100% used'
|
||||
})
|
||||
})
|
||||
|
||||
it('never says credits cover a used-up large-model allowance unless Keep going on credits is known on', () => {
|
||||
const fixture = windowFixtures['window-low']
|
||||
|
||||
// Off, and unknown (a member, or an older portal: the setting is not sent).
|
||||
for (const keep_going_on_credits of [false, null]) {
|
||||
const billing = fixture.billing?.ok
|
||||
? { ...fixture.billing, data: { ...fixture.billing.data, keep_going_on_credits } }
|
||||
: fixture.billing
|
||||
const view = deriveBillingView(billing, fixture.subscription)
|
||||
|
||||
expect(view.usageRows[2]?.caption).toBe('Monthly allowance used up, other included models still work')
|
||||
}
|
||||
})
|
||||
|
||||
it('captions every gauge as paused when the renewal payment failed', () => {
|
||||
const fixture = windowFixtures['window-paused']
|
||||
const view = deriveBillingView(fixture.billing, fixture.subscription)
|
||||
|
||||
for (const row of view.usageRows.slice(0, 3)) {
|
||||
expect(row.caption).toBe('Paused: subscription payment failed')
|
||||
}
|
||||
})
|
||||
|
||||
it('names the window plan on the Usage section, and nothing on a credit-grant plan', () => {
|
||||
const win = windowFixtures['window-healthy']
|
||||
expect(deriveBillingView(win.billing, win.subscription).usagePlanName).toBe('Window Plan')
|
||||
expect(
|
||||
deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState)).usagePlanName
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the dollar subscription row for a credit-grant plan (no plan_usage window)', () => {
|
||||
const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
|
||||
|
||||
expect(view.usageRows.map(row => row.id)).toEqual(['subscription_credits', 'topup_credits', 'monthly_cap'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveBillingView', () => {
|
||||
it.each(['zh', 'zh-hant'] as const)(
|
||||
'localizes every billing fixture in %s without changing status, amounts or control policy',
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { gaugeScope, gaugeTitle } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { Translations } from '@/i18n'
|
||||
import { en } from '@/i18n/en'
|
||||
import { fmtDate } from '@/lib/time'
|
||||
import { formatPercentUsed, formatResetsIn } from '@/lib/usage-percent'
|
||||
import { FREE_TIER_MODEL } from '@/store/free-tier'
|
||||
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
|
||||
|
||||
import type { BillingRefusal, BillingResult } from './api'
|
||||
import { useBillingApi } from './api'
|
||||
import { resolveRefusal } from './errors'
|
||||
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
|
||||
import type {
|
||||
BillingStateResponse,
|
||||
PlanUsage,
|
||||
SubscriptionStateResponse,
|
||||
SubscriptionTierOption,
|
||||
UsageModelData
|
||||
} from './types'
|
||||
import { $stepUpInFlight } from './use-step-up'
|
||||
|
||||
export const EMPTY_BILLING_VALUE = '—'
|
||||
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
|
||||
@@ -136,13 +146,14 @@ export type BillingPlanTierView =
|
||||
export interface BillingUsageRowView {
|
||||
bar?: {
|
||||
label: string
|
||||
state: 'danger' | 'neutral' | 'ok'
|
||||
tone: 'cap' | 'subscription' | 'topup'
|
||||
state: 'danger' | 'neutral' | 'ok' | 'warn'
|
||||
tone: 'cap' | 'subscription' | 'topup' | 'window'
|
||||
track?: 'danger'
|
||||
value: number
|
||||
}
|
||||
caption: string
|
||||
id: 'monthly_cap' | 'subscription_credits' | 'topup_credits'
|
||||
/** `window_<server id>` for a usage-window allowance. */
|
||||
id: 'monthly_cap' | 'subscription_credits' | 'topup_credits' | `window_${string}`
|
||||
title: string
|
||||
value: string
|
||||
}
|
||||
@@ -163,14 +174,19 @@ export interface BillingView {
|
||||
tiers: BillingPlanTierView[]
|
||||
/** One-time top-up section row. */
|
||||
topupRow?: BillingAccountRowView
|
||||
/** Usage-window plan name for the Usage section; absent on every other plan. */
|
||||
usagePlanName?: string
|
||||
usageRows: BillingUsageRowView[]
|
||||
}
|
||||
|
||||
export function useBillingState(enabled = true) {
|
||||
const api = useBillingApi()
|
||||
// Paused while a Remote Spending approval swaps this terminal's login (see $stepUpInFlight).
|
||||
const pausedForStepUp = useStore($stepUpInFlight)
|
||||
|
||||
return useQuery({
|
||||
...BILLING_QUERY_OPTIONS,
|
||||
...(pausedForStepUp ? { refetchInterval: false, refetchOnWindowFocus: false } : {}),
|
||||
enabled,
|
||||
queryFn: () => api.fetchBillingState(),
|
||||
queryKey: ['billing', 'state']
|
||||
@@ -268,10 +284,24 @@ export function deriveBillingView(
|
||||
],
|
||||
tiers,
|
||||
topupRow: buyCreditsRow(billing, b),
|
||||
usagePlanName: windowPlanName(billing, subscription),
|
||||
usageRows: deriveUsageRows(billing, subscription, b)
|
||||
}
|
||||
}
|
||||
|
||||
function windowPlanName(
|
||||
billing: BillingStateResponse,
|
||||
subscription: null | SubscriptionStateResponse
|
||||
): string | undefined {
|
||||
const planUsage = subscription?.plan_usage ?? billing.plan_usage
|
||||
|
||||
if (planUsage?.kind !== 'usage_window') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return planUsage.plan ?? subscription?.current?.tier_name ?? undefined
|
||||
}
|
||||
|
||||
export function buildManageSubscriptionUrl(
|
||||
subscription?: null | Pick<SubscriptionStateResponse, 'org_id' | 'portal_url'>,
|
||||
fallbackPortalUrl?: null | string,
|
||||
@@ -744,43 +774,54 @@ function deriveUsageRows(
|
||||
): BillingUsageRowView[] {
|
||||
const rows: BillingUsageRowView[] = []
|
||||
const current = subscription?.current
|
||||
const remaining = parseAmount(current?.credits_remaining)
|
||||
const monthly = parseAmount(current?.monthly_credits)
|
||||
const usage = subscription?.usage ?? billing.usage
|
||||
const planUsage = subscription?.plan_usage ?? billing.plan_usage
|
||||
|
||||
// Remaining can go slightly negative (usage settles after credits hit zero).
|
||||
// A raw "-$0.79 left" reads as broken — clamp to $0 and name the overage.
|
||||
const subscriptionValue =
|
||||
remaining != null && monthly != null
|
||||
? remaining < 0
|
||||
? b.state.usage.subscriptionCredits.valueOver(
|
||||
formatMoney(0),
|
||||
formatMoney(monthly),
|
||||
formatMoney(Math.abs(remaining))
|
||||
)
|
||||
: b.state.usage.subscriptionCredits.valueOf(formatMoney(remaining), formatMoney(monthly))
|
||||
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
|
||||
// A usage-window plan shows each allowance as percent used. Those gauges replace
|
||||
// the subscription-credits row; the top-up balance still follows below.
|
||||
if (planUsage?.kind === 'usage_window') {
|
||||
// "Credits cover the rest" only when the setting is known to be on: members
|
||||
// (and older portals) don't get it, and "other included models still work"
|
||||
// is true either way.
|
||||
rows.push(...deriveWindowRows(planUsage, b, billing.keep_going_on_credits !== true))
|
||||
} else {
|
||||
const remaining = parseAmount(current?.credits_remaining)
|
||||
const monthly = parseAmount(current?.monthly_credits)
|
||||
|
||||
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
|
||||
// Remaining can go slightly negative (usage settles after credits hit zero).
|
||||
// A raw "-$0.79 left" reads as broken — clamp to $0 and name the overage.
|
||||
const subscriptionValue =
|
||||
remaining != null && monthly != null
|
||||
? remaining < 0
|
||||
? b.state.usage.subscriptionCredits.valueOver(
|
||||
formatMoney(0),
|
||||
formatMoney(monthly),
|
||||
formatMoney(Math.abs(remaining))
|
||||
)
|
||||
: b.state.usage.subscriptionCredits.valueOf(formatMoney(remaining), formatMoney(monthly))
|
||||
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
|
||||
|
||||
rows.push({
|
||||
bar:
|
||||
remainingFraction != null
|
||||
? {
|
||||
label: b.state.usage.subscriptionCredits.barLabel,
|
||||
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
|
||||
tone: 'subscription',
|
||||
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
|
||||
value: clamp01(remainingFraction)
|
||||
}
|
||||
: undefined,
|
||||
caption: b.state.usage.subscriptionCredits.captionResets(
|
||||
formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at)
|
||||
),
|
||||
id: 'subscription_credits',
|
||||
title: b.state.usage.subscriptionCredits.title,
|
||||
value: subscriptionValue
|
||||
})
|
||||
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
|
||||
|
||||
rows.push({
|
||||
bar:
|
||||
remainingFraction != null
|
||||
? {
|
||||
label: b.state.usage.subscriptionCredits.barLabel,
|
||||
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
|
||||
tone: 'subscription',
|
||||
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
|
||||
value: clamp01(remainingFraction)
|
||||
}
|
||||
: undefined,
|
||||
caption: b.state.usage.subscriptionCredits.captionResets(
|
||||
formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at)
|
||||
),
|
||||
id: 'subscription_credits',
|
||||
title: b.state.usage.subscriptionCredits.title,
|
||||
value: subscriptionValue
|
||||
})
|
||||
}
|
||||
|
||||
const topupValue = topupCreditsValue(billing, usage)
|
||||
|
||||
@@ -828,6 +869,60 @@ function deriveUsageRows(
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Window gauges → usage rows: one per server allowance, in the server's order (today, week, month,
|
||||
* top-tier today; a new allowance just appears). Known ids keep their translated titles; any other id
|
||||
* shows the server's title. Mirrors the CLI/TUI `/usage` block and the portal: `NN% used` +
|
||||
* `resets in …`, the bar fills as the allowance is used, coloured by the gateway's `style`
|
||||
* (green → yellow → red). `keepGoingOff`: the account's "Keep going on
|
||||
* credits" is off, so a used-up top-tier allowance is not covered by credits (the other included
|
||||
* models still work).
|
||||
*/
|
||||
function deriveWindowRows(
|
||||
planUsage: PlanUsage,
|
||||
b: Translations['settings']['billing'],
|
||||
keepGoingOff: boolean
|
||||
): BillingUsageRowView[] {
|
||||
const copy = b.state.usage.window
|
||||
const words = { day: copy.today, month: copy.month, top_tier: copy.topTier, week: copy.week }
|
||||
|
||||
return (planUsage.gauges ?? []).map(gauge => {
|
||||
const title = gaugeTitle(gauge, words)
|
||||
const left = clamp01(gauge.left_fraction)
|
||||
const used = 1 - left
|
||||
|
||||
// Under a minute away (or already past, from a stale poll): still a reset, never "Included usage".
|
||||
const resets =
|
||||
formatResetsIn(gauge.resets_at_ms) ??
|
||||
(typeof gauge.resets_at_ms === 'number' && gauge.resets_at_ms > 0 ? '1m' : null)
|
||||
|
||||
const paused = Boolean(planUsage.paused_reason)
|
||||
const exhausted = left <= 0
|
||||
|
||||
return {
|
||||
bar: {
|
||||
label: copy.barLabel(title),
|
||||
state: gauge.style === 'red' ? 'danger' : gauge.style === 'yellow' ? 'warn' : 'ok',
|
||||
tone: 'window',
|
||||
track: exhausted ? 'danger' : undefined,
|
||||
value: used
|
||||
},
|
||||
caption: paused
|
||||
? copy.captionPaused
|
||||
: gaugeScope(gauge) === 'top_tier' && exhausted
|
||||
? keepGoingOff
|
||||
? copy.captionTopTierUsedUpKeepGoingOff
|
||||
: copy.captionTopTierUsedUp
|
||||
: resets
|
||||
? copy.captionResetsIn(resets)
|
||||
: copy.captionIncluded,
|
||||
id: `window_${gauge.label}`,
|
||||
title,
|
||||
value: copy.valueUsed(formatPercentUsed(used))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function displayBalance(billing: BillingStateResponse): string {
|
||||
return nonEmpty(billing.balance_display) ?? formatMoney(billing.balance_usd)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { atom } from 'nanostores'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -22,7 +23,15 @@ export interface StepUpMessage {
|
||||
title: string
|
||||
}
|
||||
|
||||
export function useStepUpFlow() {
|
||||
/**
|
||||
* True while a Remote Spending approval is in flight. The device flow swaps this terminal's login when it
|
||||
* lands, so Billing pauses its focus/interval refetches meanwhile: a refetch on the old login mid-swap can
|
||||
* be refused, and a refused billing.state collapses the page (every money control, the row that started
|
||||
* the approval included, disappears). The flow's own completion refetches once on the new login.
|
||||
*/
|
||||
export const $stepUpInFlight = atom(false)
|
||||
|
||||
export function useStepUpFlow({ autoOpen = false }: { autoOpen?: boolean } = {}) {
|
||||
const { t } = useI18n()
|
||||
const b = t.settings.billing
|
||||
const api = useBillingApi()
|
||||
@@ -47,6 +56,7 @@ export function useStepUpFlow() {
|
||||
const dismiss = useCallback(() => {
|
||||
runIdRef.current += 1
|
||||
runningRef.current = false
|
||||
$stepUpInFlight.set(false)
|
||||
unsubscribe()
|
||||
setMessage(null)
|
||||
setPhase('idle')
|
||||
@@ -55,6 +65,10 @@ export function useStepUpFlow() {
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (runningRef.current) {
|
||||
$stepUpInFlight.set(false)
|
||||
}
|
||||
|
||||
runIdRef.current += 1
|
||||
runningRef.current = false
|
||||
unsubscribe()
|
||||
@@ -70,12 +84,14 @@ export function useStepUpFlow() {
|
||||
void window.hermesDesktop?.openExternal?.(verification.url)
|
||||
}, [verification?.url])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
/** Resolves true once Remote Spending is allowed (billing queries already refetched), else false. */
|
||||
const start = useCallback(async (): Promise<boolean> => {
|
||||
if (runningRef.current) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
runningRef.current = true
|
||||
$stepUpInFlight.set(true)
|
||||
const runId = runIdRef.current + 1
|
||||
|
||||
runIdRef.current = runId
|
||||
@@ -98,27 +114,36 @@ export function useStepUpFlow() {
|
||||
url
|
||||
})
|
||||
setPhase('verifying')
|
||||
|
||||
// The user just asked for this (clicked a control that needs it): open the approval page
|
||||
// right away, like sign-in does, instead of making them click a second button for it.
|
||||
if (autoOpen) {
|
||||
void window.hermesDesktop?.openExternal?.(url)
|
||||
}
|
||||
}) ?? null
|
||||
|
||||
const result = await api.stepUp()
|
||||
|
||||
if (runIdRef.current !== runId) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
runningRef.current = false
|
||||
$stepUpInFlight.set(false)
|
||||
unsubscribe()
|
||||
setPhase('idle')
|
||||
setVerification(null)
|
||||
|
||||
if (!result.ok) {
|
||||
setMessage({ status: 'refusal', refusal: result.refusal })
|
||||
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (!result.data.granted) {
|
||||
setMessage({ status: 'denied' })
|
||||
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
@@ -126,7 +151,9 @@ export function useStepUpFlow() {
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] })
|
||||
])
|
||||
setMessage({ status: 'success' })
|
||||
}, [api, gateway, queryClient, unsubscribe])
|
||||
|
||||
return true
|
||||
}, [api, autoOpen, gateway, queryClient, unsubscribe])
|
||||
|
||||
let displayMessage: StepUpMessage | null = null
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { isSubmitEnter } from '@/lib/ime'
|
||||
import { catalogProviderMatches, findCatalogProvider } from '@/lib/model-options'
|
||||
import { composerServiceTier } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { showLargeModelNotice } from '@/store/agent-notices'
|
||||
import { $customModels, withCustomModels } from '@/store/custom-models'
|
||||
import { setMainModelAssignment } from '@/store/model-assignment'
|
||||
import { notify, notifyError, readableError } from '@/store/notifications'
|
||||
@@ -902,6 +903,10 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
const provider = result.provider || selectedProvider
|
||||
const model = result.model || selectedModel
|
||||
setMainModel({ provider, model })
|
||||
|
||||
if (result.notice) {
|
||||
showLargeModelNotice(result.notice)
|
||||
}
|
||||
setSwitchStaleAux(result.stale_aux ?? [])
|
||||
notify({ kind: 'success', title: m.mainAppliedTitle, message: m.mainAppliedMessage(model) })
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { gaugeTitle } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
@@ -37,6 +38,7 @@ import { type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { resolveSessionTimerSince } from '@/lib/session-timer-since'
|
||||
import { cacheHitLabel, contextBarLabel, LiveDuration, tokensPerSecondLabel, usageContextLabel } from '@/lib/statusbar'
|
||||
import { rollbackSentence } from '@/lib/update-copy'
|
||||
import { formatPercentUsed, formatResetsIn } from '@/lib/usage-percent'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveVersionStatus } from '@/lib/version-status'
|
||||
@@ -342,6 +344,8 @@ export function useStatusbarItems({
|
||||
// Both ride the same usage payload the context meter does (session.usage
|
||||
// ticks mid-turn, message.complete after) — no extra RPC, no polling.
|
||||
const cacheHit = cacheHitLabel(currentUsage)
|
||||
const planGauge = currentUsage.plan_gauge
|
||||
const planUsed = planGauge ? Math.min(1, Math.max(0, planGauge.used_bp / 10_000)) : 0
|
||||
const tokensPerSecond = tokensPerSecondLabel(currentUsage)
|
||||
|
||||
// Dial the viewed profile directly: the ambient `requestGateway` is the
|
||||
@@ -716,6 +720,28 @@ export function useStatusbarItems({
|
||||
toggleLabel: copy.toggleSessionTimer,
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
// Window plans only: today's allowance (else the one that binds) at a glance; opens Billing.
|
||||
className:
|
||||
planUsed >= 0.8
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: planUsed >= 0.5
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: undefined,
|
||||
hidden: !planGauge,
|
||||
icon: <Activity className="size-3" />,
|
||||
id: 'plan-usage',
|
||||
label: planGauge
|
||||
? `${gaugeTitle({ label: planGauge.id, title: planGauge.label }, { day: t.modelPicker.today, month: t.modelPicker.month, top_tier: t.modelPicker.topTier, week: t.modelPicker.week })} ${formatPercentUsed(planUsed)}`
|
||||
: undefined,
|
||||
title: planGauge
|
||||
? copy.planUsageTitle(formatPercentUsed(planUsed), formatResetsIn(planGauge.resets_at_ms) ?? '')
|
||||
: undefined,
|
||||
to: `${SETTINGS_ROUTE}?tab=billing`,
|
||||
// Listed in the bar's show/hide menu only for window plans; everyone else never sees the entry.
|
||||
toggleLabel: planGauge ? copy.togglePlanUsage : undefined,
|
||||
variant: 'link'
|
||||
},
|
||||
systemResourcesItem,
|
||||
{
|
||||
...approvalModeItem,
|
||||
@@ -752,7 +778,10 @@ export function useStatusbarItems({
|
||||
gaugeUsage,
|
||||
sessionStartedAt,
|
||||
gatewayState,
|
||||
planGauge,
|
||||
planUsed,
|
||||
systemResourcesItem,
|
||||
t.modelPicker,
|
||||
terminalShowing,
|
||||
tokensPerSecond,
|
||||
turnStartedAt
|
||||
|
||||
@@ -148,6 +148,65 @@ describe('the current row effort', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage-window plan rows', () => {
|
||||
it('says a spent all-model allowance once, never as a note on every row', async () => {
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
authenticated: true,
|
||||
models: ['example/model-b', 'example/model-free:free'],
|
||||
name: 'Nous',
|
||||
plan_usage: {
|
||||
gauges: [{ label: 'day', left_fraction: 0, scope: 'all', style: 'red' }],
|
||||
kind: 'usage_window'
|
||||
},
|
||||
slug: 'nous',
|
||||
usage_levels: { 'example/model-free:free': 'free', 'example/model-b': 'medium' }
|
||||
}
|
||||
]
|
||||
})
|
||||
renderMenu({ model: 'example/model-free:free', provider: 'nous' })
|
||||
|
||||
await screen.findByText('Free')
|
||||
// Only Large / Free carry a tag. A spent all-model allowance shows once under the provider, not on each row.
|
||||
expect(screen.queryByText('Medium')).toBeNull()
|
||||
expect(screen.getByText('Daily limit reached', { exact: false })).toBeTruthy()
|
||||
expect(screen.queryByText('used up')).toBeNull()
|
||||
})
|
||||
|
||||
it('marks only Large rows when the large-model limit is spent; pay as you go is said once, not per row', async () => {
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
authenticated: true,
|
||||
models: ['example/model-b', 'example/model-a', 'example/model-free:free'],
|
||||
name: 'Nous',
|
||||
plan_usage: {
|
||||
credits_pay: true,
|
||||
gauges: [
|
||||
{ label: 'day', left_fraction: 0.5, scope: 'all', style: 'green' },
|
||||
{ label: 'top_tier', left_fraction: 0, scope: 'top_tier', style: 'red' }
|
||||
],
|
||||
kind: 'usage_window'
|
||||
},
|
||||
slug: 'nous',
|
||||
usage_levels: {
|
||||
'example/model-a': 'low',
|
||||
'example/model-free:free': 'free',
|
||||
'example/model-b': 'top_tier'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
renderMenu({ model: 'example/model-free:free', provider: 'nous' })
|
||||
|
||||
await screen.findByText('Large')
|
||||
expect(screen.queryByText('Small')).toBeNull()
|
||||
expect(screen.queryByText('uses credits')).toBeNull()
|
||||
expect(screen.getByText('· Large models now use your credits (pay as you go)', { exact: false })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the reasoning-effort badge (#51833)', () => {
|
||||
it('renders the effort as its own Badge chip beside the name, never inside it', async () => {
|
||||
renderMenu({ effort: 'high', model: 'gemini-2.5-flash', provider: 'google' })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ModelOptionProvider, ModelOptionsResult, ModelPricing } from '@hermes/shared'
|
||||
import { DEFAULT_REASONING_EFFORT } from '@hermes/shared'
|
||||
import { DEFAULT_REASONING_EFFORT, planBlocks, sectionAt, usageLevel, usageTag } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { PlanLimitLine, UsageLevelTag } from '@/components/model-picker'
|
||||
import { ProviderStatusChip } from '@/components/provider-status-chip'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -271,7 +272,21 @@ export function ModelCatalogMenu({
|
||||
// Gateway-first even with no session: a connected (possibly remote)
|
||||
// gateway owns the model catalog, including virtual providers the local
|
||||
// REST fallback can't know about (#53817).
|
||||
queryFn: (): Promise<ModelOptionsResult> => requestModelOptions({ gateway, profile, request, sessionId })
|
||||
queryFn: (): Promise<ModelOptionsResult> => requestModelOptions({ gateway, profile, request, sessionId }),
|
||||
// Nous only: re-read on every open (cached list shows instantly, the re-read swaps in when it
|
||||
// lands). The plan (usage-window labels, used-up notes) loads with the first turn, after the
|
||||
// list was first fetched at boot, so the 60s cache would hide it until "Refresh models".
|
||||
// A cheap local cache-only read (the plan may not be known yet, so any Nous row); every other
|
||||
// provider keeps the 60s cache.
|
||||
refetchOnMount: query => (query.state.data?.providers?.some(isNous) ? 'always' : true),
|
||||
// A cache-only open on a fresh backend ships rows marked pending while pricing warms in the
|
||||
// background; re-read a few times so window-plan labels appear without "Refresh models".
|
||||
// Bounded (~10s): a failed pricing fetch can leave rows pending, and an open menu must not
|
||||
// poll forever. Only runs while the menu is mounted and a row is pending.
|
||||
refetchInterval: query =>
|
||||
query.state.dataUpdateCount < 6 && query.state.data?.providers?.some(p => isNous(p) && p.pricing_pending)
|
||||
? 2_000
|
||||
: false
|
||||
})
|
||||
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
@@ -662,12 +677,18 @@ export function ModelCatalogMenu({
|
||||
})
|
||||
}
|
||||
|
||||
// Keep the selected row in view while arrowing through the scrollable list.
|
||||
// Keep the selected row in view while arrowing through the scrollable list. Window plans open at
|
||||
// the top (their Recommended picks); the current row stays highlighted and is scrolled to once the
|
||||
// user arrows or searches. Every other account keeps opening on the current model.
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const opensAtTop = Boolean(providers?.some(p => p.plan_usage))
|
||||
const followKb = !opensAtTop || kbOverride !== null || Boolean(q)
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [kbActiveKey])
|
||||
if (followKb) {
|
||||
listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [followKb, kbActiveKey])
|
||||
|
||||
const kbRowProps = (key: string) => {
|
||||
const active = kbActiveKey === key
|
||||
@@ -803,22 +824,31 @@ export function ModelCatalogMenu({
|
||||
/>
|
||||
<ProviderStatusChip className="ml-auto mr-0.5" provider={group.provider} />
|
||||
</DropdownMenuItem>
|
||||
{/* Window plans past an allowance: one line saying what happens next (pay as you go
|
||||
or wait for the reset) instead of a note on every row. */}
|
||||
{!collapsed && !search ? <PlanLimitLine className="px-2 pb-1" provider={group.provider} /> : null}
|
||||
{!collapsed &&
|
||||
group.families.map(family => (
|
||||
<ModelFamilyRow
|
||||
controller={controller}
|
||||
current={current}
|
||||
defaultEffort={defaultEffort}
|
||||
family={family}
|
||||
favorite={favoriteSet.has(favoriteModelKey(group.provider.slug, family.id))}
|
||||
kbProps={kbRowProps(`${group.provider.slug}:${family.id}`)}
|
||||
key={`${group.provider.slug}:${family.id}`}
|
||||
loadingModels={loadingModels}
|
||||
onSelect={selectFamily}
|
||||
onSubOpenChange={handleSubOpenChange}
|
||||
provider={group.provider}
|
||||
search={search}
|
||||
/>
|
||||
group.families.map((family, familyIdx) => (
|
||||
<Fragment key={`${group.provider.slug}:${family.id}`}>
|
||||
{!search && sectionTitle(group, familyIdx, copyPicker) ? (
|
||||
<DropdownMenuLabel className="px-2 pb-0.5 pt-1.5 text-[0.6rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
|
||||
{sectionTitle(group, familyIdx, copyPicker)}
|
||||
</DropdownMenuLabel>
|
||||
) : null}
|
||||
<ModelFamilyRow
|
||||
controller={controller}
|
||||
current={current}
|
||||
defaultEffort={defaultEffort}
|
||||
family={family}
|
||||
favorite={favoriteSet.has(favoriteModelKey(group.provider.slug, family.id))}
|
||||
kbProps={kbRowProps(`${group.provider.slug}:${family.id}`)}
|
||||
loadingModels={loadingModels}
|
||||
onSelect={selectFamily}
|
||||
onSubOpenChange={handleSubOpenChange}
|
||||
provider={group.provider}
|
||||
search={search}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{!collapsed &&
|
||||
slug === LOCAL_PROVIDER_SLUG &&
|
||||
@@ -1026,6 +1056,9 @@ function ModelFamilyRow({
|
||||
// fronts the base, so fall back to it when only the fast variant is unpriced.
|
||||
const pricing = provider.pricing?.[family.id] ?? (family.fastId ? provider.pricing?.[family.fastId] : undefined)
|
||||
|
||||
// Window plans: no price (the plan covers it); the tag shows only for Large / Free.
|
||||
const rowLevel = usageLevel(provider, family.id)
|
||||
|
||||
// Managed local model loading into memory right now: real load percent,
|
||||
// keyed by exact model id (remote providers never collide with GGUF stems).
|
||||
const loadProgress = loadingModels[family.id] ?? (family.fastId ? loadingModels[family.fastId] : undefined)
|
||||
@@ -1154,7 +1187,8 @@ function ModelFamilyRow({
|
||||
<span className="text-[0.62rem] tabular-nums text-(--ui-text-tertiary)">{loadProgress.percent}%</span>
|
||||
</span>
|
||||
) : null}
|
||||
{showPricing && pricing ? <ModelPrice pricing={pricing} /> : null}
|
||||
{showPricing && pricing && !rowLevel ? <ModelPrice pricing={pricing} /> : null}
|
||||
{rowLevel ? <UsageLevelTag level={usageTag(provider, family.id)} /> : null}
|
||||
{settings.map(setting => (
|
||||
<ModelChip key={setting} setting>
|
||||
{setting}
|
||||
@@ -1226,7 +1260,8 @@ function familyLimit(
|
||||
): { reset: null | string; tone?: string } {
|
||||
const ms = modelResetMs(provider, family.id) ?? (family.fastId ? modelResetMs(provider, family.fastId) : null)
|
||||
const reset = ms === null ? null : formatReset(ms)
|
||||
const dim = !isCurrent && (reset !== null || accountResetMs(provider) !== null)
|
||||
// Window plans: a model a full allowance stops dims too; the limit line under the provider says why.
|
||||
const dim = !isCurrent && (reset !== null || accountResetMs(provider) !== null || planBlocks(provider, family.id))
|
||||
|
||||
return { reset, tone: dim ? 'text-(--ui-text-tertiary)' : undefined }
|
||||
}
|
||||
@@ -1393,6 +1428,27 @@ function groupModels(
|
||||
return groups
|
||||
}
|
||||
|
||||
// The Nous row: the only one the menu re-reads on open or while its pricing warms (window-plan labels).
|
||||
function isNous(provider: ModelOptionProvider): boolean {
|
||||
return provider.slug === 'nous'
|
||||
}
|
||||
|
||||
// Window plans, no query: "Recommended" above the first of the plan's featured families and
|
||||
// "Other models" above the first family after them. The backend already lists the picks first.
|
||||
function sectionTitle(
|
||||
group: ProviderGroup,
|
||||
familyIdx: number,
|
||||
words: { sectionOther: string; sectionRecommended: string }
|
||||
): null | string {
|
||||
const section = sectionAt(
|
||||
group.provider,
|
||||
group.families.map(family => family.id),
|
||||
familyIdx
|
||||
)
|
||||
|
||||
return section === 'recommended' ? words.sectionRecommended : section === 'other' ? words.sectionOther : null
|
||||
}
|
||||
|
||||
// Small hooks kept at the bottom so the component reads top-down.
|
||||
function useStoreCollapsed(): string[] {
|
||||
return useStore($collapsedProviders)
|
||||
|
||||
@@ -12,6 +12,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { en } from '@/i18n/en'
|
||||
import type { ErrorCardCopy } from '@/i18n/types'
|
||||
import { $displayTimestamps } from '@/store/display-timestamps'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { type SessionTileDelegate, setSessionTileDelegate } from '@/store/session-states'
|
||||
|
||||
import { stubThreadEnvironment } from '../test-utils'
|
||||
|
||||
@@ -389,6 +391,76 @@ describe('scheduled retry at the usage-limit reset (#98852)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("a window plan's limit: one-click switch and credits (no silent switch)", () => {
|
||||
const limitMessage = (credits: 'add_credits' | 'use_credits', suggestions = true) =>
|
||||
failedMessage(
|
||||
{
|
||||
code: 'usage_window',
|
||||
layer: 'billing',
|
||||
message: "You've hit your daily limit · resets 15:56",
|
||||
provider: 'nous',
|
||||
retryable: true,
|
||||
// The parsed surface (lib/error-surface.ts parseUsageLimit), as chat-runtime stores it.
|
||||
usageLimit: {
|
||||
credits,
|
||||
suggestions: suggestions
|
||||
? [{ label: 'Model Free', model: 'example/model-free:free', note: 'free', provider: 'nous' }]
|
||||
: []
|
||||
}
|
||||
},
|
||||
'HTTP 402: usage window'
|
||||
)
|
||||
|
||||
afterEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('switches THIS session to the recommended model, then re-runs the turn', async () => {
|
||||
const executeSlash = vi.fn(async () => {})
|
||||
const onReload = vi.fn(async () => {})
|
||||
setSessionTileDelegate({ executeSlash } as unknown as SessionTileDelegate)
|
||||
$activeSessionId.set('rt-1')
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Harness assistant={limitMessage('add_credits')} onReload={onReload} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Switch to Model Free (free)' }))
|
||||
|
||||
await waitFor(() => expect(onReload).toHaveBeenCalledTimes(1))
|
||||
expect(executeSlash).toHaveBeenCalledWith('/model example/model-free:free --provider nous --session', 'rt-1')
|
||||
// No balance: the credits way on is Billing, labelled for what it does.
|
||||
expect(screen.getByRole('button', { name: 'Open billing settings' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Use credits' })).toBeNull()
|
||||
// The generic provider menu is replaced by the one-click switch.
|
||||
expect(screen.queryByRole('button', { name: 'Switch provider' })).toBeNull()
|
||||
})
|
||||
|
||||
it('with a balance and Keep going off, offers Use credits', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Harness assistant={limitMessage('use_credits')} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Use credits' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Switch to Model Free (free)' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('with no model to offer, still points at choosing one', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Harness assistant={limitMessage('add_credits', false)} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Open billing settings' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /^Switch to / })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rejected API key recovery', () => {
|
||||
it('names the key as the problem and deep-links Settings → Keys to that env var', async () => {
|
||||
render(
|
||||
|
||||
@@ -16,6 +16,7 @@ import { requestModelMenuToggle } from '@/app/chat/composer/focus'
|
||||
import { useComposerScope } from '@/app/chat/composer/scope'
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { useBillingApi } from '@/app/settings/billing/api'
|
||||
import { dispatchedTo } from '@/components/assistant-ui/thread/agent-delivery'
|
||||
import { ChangedFilesCard } from '@/components/assistant-ui/thread/changed-files-card'
|
||||
import {
|
||||
@@ -47,7 +48,8 @@ import {
|
||||
formatLimitReset,
|
||||
formatResetClock,
|
||||
isOAuthReauthSurface,
|
||||
scheduledRetryDelayMs
|
||||
scheduledRetryDelayMs,
|
||||
type UsageLimitSuggestion
|
||||
} from '@/lib/error-surface'
|
||||
import { errorCardText } from '@/lib/error-surface-copy'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -67,6 +69,7 @@ import { markAssistantIdSpoken } from '@/lib/spoken-reply'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { requestBillingSettings } from '@/store/billing-block'
|
||||
import { DESKTOP_BUTTON_ACTIONS, recordAction } from '@/store/desktop-metrics'
|
||||
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
@@ -739,6 +742,87 @@ const CompressConversationAction: FC<{ label: string }> = ({ label }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// usage_window: "Switch to Model A" — the plan's recommended model that
|
||||
// still works past the limit (agent/plan_limits.py picked it). Switches THIS
|
||||
// session through the same slash path a typed `/model <id> --provider nous
|
||||
// --session` takes (session.info repaints the composer), then re-runs the
|
||||
// refused turn. Session-only on purpose: the user's default stays put.
|
||||
const SwitchToModelAction: FC<{ suggestion: UsageLimitSuggestion }> = ({ suggestion }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const aui = useAui()
|
||||
const view = useSessionView()
|
||||
const sessionId = useStore(view.$runtimeId)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const switchAndRetry = useCallback(async () => {
|
||||
const delegate = sessionTileDelegate()
|
||||
|
||||
if (!sessionId || !delegate) {
|
||||
notifyError(new Error('slash delegate unavailable'), copy.errorSwitchModelFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('submit')
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
await delegate.executeSlash(`/model ${suggestion.model} --provider ${suggestion.provider} --session`, sessionId)
|
||||
aui.message().reload()
|
||||
} catch (error) {
|
||||
notifyError(error, copy.errorSwitchModelFailed)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [aui, copy.errorSwitchModelFailed, sessionId, suggestion.model, suggestion.provider])
|
||||
|
||||
return (
|
||||
<button className="aui-error-action" disabled={busy} onClick={() => void switchAndRetry()} type="button">
|
||||
{busy && <Loader2Icon className="size-3 animate-spin" />}
|
||||
{copy.errorSwitchToModel(suggestion.label, suggestion.note)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// usage_window, Keep going off with a balance: "Use credits" turns the account
|
||||
// setting on (the same call as Settings → Billing's switch), then re-runs the
|
||||
// turn. A refusal (remote spending not allowed yet, step-up) opens Billing, which
|
||||
// explains and fixes it.
|
||||
const UseCreditsAction: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const aui = useAui()
|
||||
const api = useBillingApi()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const turnOnCredits = useCallback(async () => {
|
||||
triggerHaptic('submit')
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
const result = await api.updateKeepGoingOnCredits(true)
|
||||
|
||||
if (result.ok) {
|
||||
aui.message().reload()
|
||||
} else {
|
||||
requestBillingSettings()
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError(error, copy.errorUseCreditsFailed)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [api, aui, copy.errorUseCreditsFailed])
|
||||
|
||||
return (
|
||||
<button className="aui-error-action" disabled={busy} onClick={() => void turnOnCredits()} type="button">
|
||||
{busy && <Loader2Icon className="size-3 animate-spin" />}
|
||||
{copy.errorUseCredits}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// One client-side retry of THIS turn at the provider's own reset moment (#98852,
|
||||
// option B): arm → visible countdown + Cancel → fires `message().reload()` — the
|
||||
// exact call behind the Retry button — exactly once. Nothing persists: closing
|
||||
@@ -931,8 +1015,12 @@ const ErrorRecoveryActions: FC = () => {
|
||||
// Retry will work instead of guessing (#98852). Informational only: no automatic retry.
|
||||
const limitReset = formatLimitReset(surface?.resetsAt)
|
||||
|
||||
const suggestion = surface?.usageLimit?.suggestions[0]
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{plan.switchToModel && suggestion && <SwitchToModelAction suggestion={suggestion} />}
|
||||
{plan.useCredits && <UseCreditsAction />}
|
||||
{plan.editMessage && <EditPreviousMessageAction label={copy.editMessage} />}
|
||||
{plan.compress && <CompressConversationAction label={copy.errorCompressConversation} />}
|
||||
{plan.chooseModel && (
|
||||
@@ -990,22 +1078,35 @@ const ErrorRecoveryActions: FC = () => {
|
||||
</span>
|
||||
)}
|
||||
{plan.retry && surface?.resetsAt !== undefined && <ScheduledRetryAction resetsAt={surface.resetsAt} />}
|
||||
{plan.switchProvider && inRouter && <SwitchProviderAction label={copy.errorSwitchProvider} />}
|
||||
{localFolders && (
|
||||
{plan.openBilling && (
|
||||
<button className="aui-error-action" onClick={() => requestBillingSettings()} type="button">
|
||||
{copy.errorOpenBillingSettings}
|
||||
</button>
|
||||
)}
|
||||
{plan.switchProvider && inRouter && (
|
||||
<SwitchProviderAction
|
||||
label={surface?.code === 'usage_window' ? copy.errorChooseModel : copy.errorSwitchProvider}
|
||||
/>
|
||||
)}
|
||||
{plan.diagnostics && localFolders && (
|
||||
<button className="aui-error-action" onClick={() => void openLogs()} type="button">
|
||||
{remoteConnection ? copy.errorOpenDesktopLogs : copy.errorOpenLogs}
|
||||
</button>
|
||||
)}
|
||||
<button className="aui-error-action" onClick={() => requestSendDiagnostics(diagnosticsText())} type="button">
|
||||
<Upload className="size-3" />
|
||||
{copy.errorSendDiagnostics}
|
||||
</button>
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="aui-error-action"
|
||||
label={copy.errorCopyDiagnostics}
|
||||
text={diagnosticsText}
|
||||
/>
|
||||
{plan.diagnostics && (
|
||||
<button className="aui-error-action" onClick={() => requestSendDiagnostics(diagnosticsText())} type="button">
|
||||
<Upload className="size-3" />
|
||||
{copy.errorSendDiagnostics}
|
||||
</button>
|
||||
)}
|
||||
{plan.diagnostics && (
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="aui-error-action"
|
||||
label={copy.errorCopyDiagnostics}
|
||||
text={diagnosticsText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,3 +114,23 @@ describe('system message timestamp text separation', () => {
|
||||
expectTimestampSeparated(container, 'rerun tests')
|
||||
})
|
||||
})
|
||||
|
||||
describe('slash output face', () => {
|
||||
const body = (container: HTMLElement) => container.querySelector('[data-role="system"] .whitespace-pre-wrap')
|
||||
|
||||
it('keeps column-padded output without a plan gauge in the default face', () => {
|
||||
const { container } = render(
|
||||
<Harness text={'slash:/usage\nSession Token Usage\nInput tokens: 13,527'} />
|
||||
)
|
||||
|
||||
expect(body(container)?.className).not.toContain('font-mono')
|
||||
})
|
||||
|
||||
it('renders a usage-window /usage block (plan gauges) monospace', () => {
|
||||
const output =
|
||||
'Session Token Usage\nInput tokens: 13,527\n\nNous Plan\nToday [|| ] 20% used'
|
||||
const { container } = render(<Harness text={`slash:/usage\n${output}`} />)
|
||||
|
||||
expect(body(container)?.className).toContain('font-mono')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,9 @@ import { cn } from '@/lib/utils'
|
||||
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
|
||||
const STEER_NOTE_RE = /^steer:(?<text>[\s\S]+)$/
|
||||
const REVIEW_NOTE_RE = /^review:(?<label>[^:\n]+):?\s*(?<detail>[\s\S]*)$/
|
||||
// A usage-window plan gauge exactly as the backend renders it: `[` + 10 cells of
|
||||
// `|` then spaces + `]`. Only a window plan's /usage output carries one.
|
||||
const USAGE_WINDOW_GAUGE_RE = /\[(?=[| ]{10}\])\|* *\]/
|
||||
|
||||
interface BackgroundResultProps {
|
||||
text: string
|
||||
@@ -138,6 +141,9 @@ export const SystemMessage: FC = () => {
|
||||
// multiline output (catalogs, usage tables) needs left-aligned, wider room
|
||||
// or the column alignment breaks.
|
||||
const multiline = output.includes('\n')
|
||||
// A window plan's /usage (token table + `[|||||| ]` plan gauges) only lines
|
||||
// up in a monospace face. Every other output keeps the default face.
|
||||
const tabular = multiline && USAGE_WINDOW_GAUGE_RE.test(output)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
@@ -150,7 +156,12 @@ export const SystemMessage: FC = () => {
|
||||
>
|
||||
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
|
||||
{multiline ? (
|
||||
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
|
||||
<LinkifiedText
|
||||
className={cn('mt-0.5 block whitespace-pre-wrap', tabular && 'font-mono text-[0.65rem] leading-[1.15rem]')}
|
||||
explicitOnly
|
||||
pretty={false}
|
||||
text={output}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
|
||||
@@ -167,6 +167,282 @@ describe('ModelPickerDialog download rows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const WINDOW_USAGE = {
|
||||
kind: 'usage_window' as const,
|
||||
plan: 'Window Plan',
|
||||
gauges: [
|
||||
{ label: 'day', left_fraction: 0.82, scope: 'all', style: 'green' },
|
||||
{ label: 'week', left_fraction: 0.97, scope: 'all', style: 'green' }
|
||||
],
|
||||
top_tier_model_ids: ['example/model-b']
|
||||
}
|
||||
|
||||
// The gateway's per-plan words, passed through by the backend (Hermes never computes them).
|
||||
const WINDOW_LEVELS = {
|
||||
'example/model-d': 'medium',
|
||||
'example/model-b': 'top_tier',
|
||||
'example/model-free:free': 'free'
|
||||
}
|
||||
|
||||
// What an older backend sent for a credit-grant subscription; the picker no longer draws it.
|
||||
const CREDIT_GRANT_USAGE = {
|
||||
kind: 'subscription_cap',
|
||||
plan: 'Credit Plan',
|
||||
gauges: [{ label: 'credits', left_fraction: 0.62, style: 'green' }]
|
||||
}
|
||||
|
||||
describe('ModelPickerDialog plan usage', () => {
|
||||
it('shows usage words and no prices on a usage-window plan', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d', 'example/model-b', 'example/model-free:free'],
|
||||
authenticated: true,
|
||||
plan_usage: WINDOW_USAGE,
|
||||
usage_levels: WINDOW_LEVELS
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
expect(await screen.findByText('Every model below is included', { exact: false })).toBeTruthy()
|
||||
// Gauges read as used and fill up: 0.82 left → 18% used, 0.97 left → 3% used.
|
||||
expect(screen.getByText('18% used')).toBeTruthy()
|
||||
expect(screen.getByText('3% used')).toBeTruthy()
|
||||
// Only Large / Free carry a tag; the footnote shows because a top-tier row is listed.
|
||||
expect(screen.getByText('Free')).toBeTruthy()
|
||||
expect(screen.queryByText('Medium')).toBeNull()
|
||||
expect(screen.getByText('Large')).toBeTruthy()
|
||||
expect(screen.getByText('Large models also use your monthly large-model limit.')).toBeTruthy()
|
||||
expect(screen.getByText('Window Plan')).toBeTruthy()
|
||||
expect(screen.queryByText('★')).toBeNull()
|
||||
// No featured picks in the plan: one flat list, no section titles.
|
||||
expect(screen.queryByText('Recommended')).toBeNull()
|
||||
expect(screen.queryByText(/\$1\.00/)).toBeNull()
|
||||
expect(screen.queryByText(/Input \/ Output/)).toBeNull()
|
||||
// An older backend sends no month gauge: no month row.
|
||||
expect(screen.queryByText('Month')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a month row when the plan sends one', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d'],
|
||||
authenticated: true,
|
||||
plan_usage: {
|
||||
...WINDOW_USAGE,
|
||||
gauges: [...WINDOW_USAGE.gauges, { label: 'month', left_fraction: 0.6, scope: 'all', style: 'green' }]
|
||||
},
|
||||
usage_levels: WINDOW_LEVELS
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
expect(await screen.findByText('Month')).toBeTruthy()
|
||||
expect(screen.getByText('40% used')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('draws a gauge the client has no words for with the server title', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d'],
|
||||
authenticated: true,
|
||||
plan_usage: {
|
||||
...WINDOW_USAGE,
|
||||
gauges: [
|
||||
{ label: 'five_hour', left_fraction: 0.5, scope: 'all', style: 'yellow', title: 'Next 5 hours' },
|
||||
...WINDOW_USAGE.gauges
|
||||
]
|
||||
},
|
||||
usage_levels: WINDOW_LEVELS
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
expect(await screen.findByText('Next 5 hours')).toBeTruthy()
|
||||
expect(screen.getByText('50% used')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('dims rows a spent allowance stops instead of annotating them, never a free row', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d', 'example/model-b', 'example/model-free:free'],
|
||||
authenticated: true,
|
||||
plan_usage: {
|
||||
...WINDOW_USAGE,
|
||||
gauges: [
|
||||
{ label: 'day', left_fraction: 0.5, scope: 'all', style: 'green' },
|
||||
{ label: 'top_tier', left_fraction: 0, scope: 'top_tier', style: 'red' }
|
||||
]
|
||||
},
|
||||
usage_levels: WINDOW_LEVELS
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
// Only the top-tier row draws on the spent top-tier allowance: it dims; Medium and Free still have room.
|
||||
const large = await screen.findByText('example/model-b')
|
||||
expect(large.closest('span')?.className).toContain('text-muted-foreground')
|
||||
expect(screen.getByText('example/model-free:free').closest('span')?.className).not.toContain(
|
||||
'text-muted-foreground'
|
||||
)
|
||||
expect(screen.queryByText('used up', { exact: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('a spent day is one line above the list, not a note on every paid row', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d', 'example/model-b', 'example/model-free:free'],
|
||||
authenticated: true,
|
||||
plan_usage: {
|
||||
...WINDOW_USAGE,
|
||||
gauges: [{ label: 'day', left_fraction: 0, scope: 'all', style: 'red' }]
|
||||
},
|
||||
usage_levels: WINDOW_LEVELS
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
// A spent all-model allowance is said once, above the list, not on every paid row.
|
||||
expect(await screen.findByText('Daily limit reached', { exact: false })).toBeTruthy()
|
||||
expect(screen.queryByText('used up')).toBeNull()
|
||||
})
|
||||
|
||||
it('says paid models now use credits (pay as you go) when top-up credits will pay', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d', 'example/model-b', 'example/model-free:free'],
|
||||
authenticated: true,
|
||||
plan_usage: {
|
||||
...WINDOW_USAGE,
|
||||
credits_pay: true,
|
||||
gauges: [{ label: 'week', left_fraction: 0, scope: 'all', style: 'red' }]
|
||||
},
|
||||
usage_levels: WINDOW_LEVELS
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
expect(await screen.findByText('Weekly limit reached', { exact: false })).toBeTruthy()
|
||||
expect(screen.getByText('· paid models now use your credits (pay as you go)', { exact: false })).toBeTruthy()
|
||||
expect(screen.queryByText('used up')).toBeNull()
|
||||
expect(screen.queryByText('uses credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('says pay as you go once in the limit line, not on each Large row, when credits pay', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d', 'example/model-b', 'example/model-free:free'],
|
||||
authenticated: true,
|
||||
plan_usage: {
|
||||
...WINDOW_USAGE,
|
||||
credits_pay: true,
|
||||
gauges: [
|
||||
{ label: 'day', left_fraction: 0.5, scope: 'all', style: 'green' },
|
||||
{ label: 'top_tier', left_fraction: 0, scope: 'top_tier', style: 'red' }
|
||||
]
|
||||
},
|
||||
usage_levels: WINDOW_LEVELS
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
expect(
|
||||
await screen.findByText('· Large models now use your credits (pay as you go)', { exact: false })
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByText('uses credits')).toBeNull()
|
||||
expect(screen.queryByText('used up', { exact: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps prices and shows no plan chrome for a credit-grant subscription', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d'],
|
||||
authenticated: true,
|
||||
pricing: {
|
||||
'example/model-d': { input: '$1.00', output: '$5.00', free: false }
|
||||
},
|
||||
plan_usage: CREDIT_GRANT_USAGE
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
expect(await screen.findByText('$1.00 / $5.00')).toBeTruthy()
|
||||
expect(screen.queryByText('Credit Plan', { exact: false })).toBeNull()
|
||||
expect(screen.queryByText(/% used/)).toBeNull()
|
||||
})
|
||||
|
||||
it('splits Recommended from Other models on a usage-window plan, flat while searching', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-b', 'example/model-d', 'example/model-free:free'],
|
||||
authenticated: true,
|
||||
plan_usage: { ...WINDOW_USAGE, featured_model_ids: ['example/model-b'] },
|
||||
usage_levels: WINDOW_LEVELS
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
expect(await screen.findByText('Recommended')).toBeTruthy()
|
||||
expect(screen.getByText('Other models')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no sections or usage words for a credit-grant account', async () => {
|
||||
vi.mocked(requestModelOptions).mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
slug: 'nous',
|
||||
name: 'Nous',
|
||||
models: ['example/model-d'],
|
||||
authenticated: true,
|
||||
pricing: { 'example/model-d': { input: '$1.00', output: '$5.00', free: false } },
|
||||
plan_usage: { ...CREDIT_GRANT_USAGE, featured_model_ids: ['example/model-d'] },
|
||||
usage_levels: { 'example/model-d': 'top_tier' }
|
||||
}
|
||||
]
|
||||
})
|
||||
renderPicker({ currentModel: 'example/model-d', currentProvider: 'nous' })
|
||||
|
||||
expect(await screen.findByText('$1.00 / $5.00')).toBeTruthy()
|
||||
expect(screen.queryByText('Recommended')).toBeNull()
|
||||
expect(screen.queryByText('Large')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelPickerDialog search ranking', () => {
|
||||
// Rows must come out in the order the shared fuzzyRank produces — the same
|
||||
// helper the web and TUI pickers use — so a query ranks identically on
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModelOptionProvider, ModelPricing } from '@hermes/shared'
|
||||
import type { ModelOptionProvider, ModelPricing, PlanUsage, PlanUsageGauge } from '@hermes/shared'
|
||||
import { gaugeTitle, hasTopTier, planBlocks, planLimit, splitRecommended, usageTag } from '@hermes/shared'
|
||||
import { fuzzyRank, modelSearchText } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -9,6 +10,7 @@ import { catalogProviderMatches, modelOptionsQueryKey, requestModelOptions } fro
|
||||
import { currentPickerSelection } from '@/lib/model-status-label'
|
||||
import { accountResetMs, formatReset, modelResetMs } from '@/lib/provider-limit'
|
||||
import { foldIncludes, normalize } from '@/lib/text'
|
||||
import { formatPercentUsed, formatResetsIn } from '@/lib/usage-percent'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $customModels, addCustomModel, customModelCandidate, withCustomModels } from '@/store/custom-models'
|
||||
import { $localModelsEnabled } from '@/store/local-models-flag'
|
||||
@@ -315,7 +317,8 @@ function ModelResults({
|
||||
|
||||
const groups = configured.map(provider => ({
|
||||
provider,
|
||||
// Empty query: the backend's curated order, verbatim.
|
||||
// Empty query: the backend's curated order, verbatim (it already leads a
|
||||
// window plan with its featured picks).
|
||||
models: rankModels(provider, provider.models ?? []),
|
||||
downloads: provider.slug === LOCAL_PROVIDER_SLUG ? visibleDownloads : []
|
||||
}))
|
||||
@@ -345,8 +348,77 @@ function ModelResults({
|
||||
const unavailable = new Set(provider.unavailable_models ?? [])
|
||||
const accountLimited = accountResetMs(provider) !== null
|
||||
|
||||
const renderModel = (model: string) => {
|
||||
const isCurrent = model === currentModel && catalogProviderMatches(provider, currentProvider)
|
||||
const price = provider.pricing?.[model]
|
||||
const locked = unavailable.has(model)
|
||||
const resetMs = modelResetMs(provider, model)
|
||||
const resetLabel = resetMs === null ? null : formatReset(resetMs)
|
||||
// Window plans: a model a full allowance stops looks unavailable; the limit line says why, once.
|
||||
const dimmed = !locked && (accountLimited || resetLabel !== null || planBlocks(provider, model))
|
||||
// Managed local model loading into memory right now: show the
|
||||
// real load percent inline (keyed by exact model id — remote
|
||||
// providers never match).
|
||||
const loadProgress = loadingModels[model]
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(
|
||||
'flex items-center gap-2 pl-6 font-mono',
|
||||
isCurrent &&
|
||||
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground',
|
||||
locked && 'cursor-not-allowed opacity-45'
|
||||
)}
|
||||
disabled={locked}
|
||||
key={`${provider.slug}:${model}`}
|
||||
onSelect={() => {
|
||||
if (!locked) {
|
||||
onSelectModel(provider, model)
|
||||
}
|
||||
}}
|
||||
value={`${provider.slug}:${model}`}
|
||||
>
|
||||
<span className={cn('min-w-0 flex-1 truncate', dimmed && !isCurrent && 'text-muted-foreground')}>
|
||||
<HighlightMatches foldSeparators query={search} text={model} />
|
||||
</span>
|
||||
{loadProgress && (
|
||||
<span className="flex shrink-0 items-center gap-1.5" title={copy.loadingIntoMemory}>
|
||||
<span className="h-1 w-16 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(2, loadProgress.percent)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[0.62rem] tabular-nums text-muted-foreground">{loadProgress.percent}%</span>
|
||||
</span>
|
||||
)}
|
||||
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">{copy.pro}</span>}
|
||||
{resetLabel && (
|
||||
<Badge
|
||||
className="font-sans tabular-nums"
|
||||
size="xs"
|
||||
title={t.shell.modelMenu.modelLimitedTip(resetLabel)}
|
||||
variant="warn"
|
||||
>
|
||||
{t.shell.modelMenu.modelResets(resetLabel)}
|
||||
</Badge>
|
||||
)}
|
||||
{provider.plan_usage?.kind === 'usage_window' ? (
|
||||
<UsageLevelTag isCurrent={isCurrent} level={usageTag(provider, model)} />
|
||||
) : (
|
||||
<ModelPrice isCurrent={isCurrent} price={price} />
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
// Window plans, no query: "Recommended" (the plan's picks) then "Other models".
|
||||
// A query is its own ranking, so the list stays flat while searching.
|
||||
const split = q ? null : splitRecommended(provider, models)
|
||||
|
||||
return (
|
||||
<CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}>
|
||||
{provider.plan_usage && <PlanUsageSummary provider={provider} usage={provider.plan_usage} />}
|
||||
{provider.warning && (
|
||||
<div className="px-2 pb-2">
|
||||
<InlineNotice className="px-2.5 py-1.5 text-xs" kind="warning">
|
||||
@@ -354,66 +426,21 @@ function ModelResults({
|
||||
</InlineNotice>
|
||||
</div>
|
||||
)}
|
||||
{models.map(model => {
|
||||
const isCurrent = model === currentModel && catalogProviderMatches(provider, currentProvider)
|
||||
const price = provider.pricing?.[model]
|
||||
const locked = unavailable.has(model)
|
||||
const resetMs = modelResetMs(provider, model)
|
||||
const resetLabel = resetMs === null ? null : formatReset(resetMs)
|
||||
const dimmed = !locked && (accountLimited || resetLabel !== null)
|
||||
// Managed local model loading into memory right now: show the
|
||||
// real load percent inline (keyed by exact model id — remote
|
||||
// providers never match).
|
||||
const loadProgress = loadingModels[model]
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(
|
||||
'flex items-center gap-2 pl-6 font-mono',
|
||||
isCurrent &&
|
||||
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground',
|
||||
locked && 'cursor-not-allowed opacity-45'
|
||||
)}
|
||||
disabled={locked}
|
||||
key={`${provider.slug}:${model}`}
|
||||
onSelect={() => {
|
||||
if (!locked) {
|
||||
onSelectModel(provider, model)
|
||||
}
|
||||
}}
|
||||
value={`${provider.slug}:${model}`}
|
||||
>
|
||||
<span className={cn('min-w-0 flex-1 truncate', dimmed && !isCurrent && 'text-muted-foreground')}>
|
||||
<HighlightMatches foldSeparators query={search} text={model} />
|
||||
</span>
|
||||
{loadProgress && (
|
||||
<span className="flex shrink-0 items-center gap-1.5" title={copy.loadingIntoMemory}>
|
||||
<span className="h-1 w-16 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(2, loadProgress.percent)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[0.62rem] tabular-nums text-muted-foreground">{loadProgress.percent}%</span>
|
||||
</span>
|
||||
)}
|
||||
{locked && (
|
||||
<span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">{copy.pro}</span>
|
||||
)}
|
||||
{resetLabel && (
|
||||
<Badge
|
||||
className="font-sans tabular-nums"
|
||||
size="xs"
|
||||
title={t.shell.modelMenu.modelLimitedTip(resetLabel)}
|
||||
variant="warn"
|
||||
>
|
||||
{t.shell.modelMenu.modelResets(resetLabel)}
|
||||
</Badge>
|
||||
)}
|
||||
<ModelPrice isCurrent={isCurrent} price={price} />
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{split ? (
|
||||
<>
|
||||
<ModelSectionLabel>{copy.sectionRecommended}</ModelSectionLabel>
|
||||
{split.recommended.map(renderModel)}
|
||||
{split.other.length > 0 && <ModelSectionLabel>{copy.sectionOther}</ModelSectionLabel>}
|
||||
{split.other.map(renderModel)}
|
||||
</>
|
||||
) : (
|
||||
models.map(renderModel)
|
||||
)}
|
||||
{hasTopTier(provider, models) && (
|
||||
<div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground">
|
||||
{copy.largeFootnote}
|
||||
</div>
|
||||
)}
|
||||
{groupDownloads.map(job => (
|
||||
<DownloadingModelRow jobId={job.jobId} key={job.jobId} owner={owner} target={job.target} />
|
||||
))}
|
||||
@@ -510,6 +537,205 @@ function DownloadingModelRow({
|
||||
)
|
||||
}
|
||||
|
||||
function gaugeLabel(gauge: PlanUsageGauge, copy: { today: string; week: string; month: string; topTier: string }) {
|
||||
return gaugeTitle(gauge, { day: copy.today, month: copy.month, top_tier: copy.topTier, week: copy.week })
|
||||
}
|
||||
|
||||
/** Bracketed `[|||| ]` allowance meter (fills as it is USED); a sliver always shows one
|
||||
* cell, not-quite-full never shows all ten. */
|
||||
function AsciiBar({
|
||||
fill,
|
||||
isCurrent,
|
||||
percentLabel,
|
||||
style
|
||||
}: {
|
||||
fill: number
|
||||
isCurrent?: boolean
|
||||
percentLabel?: string
|
||||
style: string
|
||||
}) {
|
||||
const cells = 10
|
||||
const value = Math.min(1, Math.max(0, fill))
|
||||
let filled = Math.round(value * cells)
|
||||
|
||||
if (value > 0 && filled === 0) {
|
||||
filled = 1
|
||||
}
|
||||
|
||||
if (value < 1 && filled === cells) {
|
||||
filled = cells - 1
|
||||
}
|
||||
|
||||
// Green / yellow / red: how much of the allowance is used.
|
||||
const color =
|
||||
style === 'red'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: style === 'yellow'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-emerald-600 dark:text-emerald-400'
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 font-mono" title={percentLabel}>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'whitespace-pre text-[0.62rem] leading-none tracking-[-0.08em]',
|
||||
isCurrent ? 'text-primary-foreground/85' : color
|
||||
)}
|
||||
>
|
||||
[{'|'.repeat(filled)}
|
||||
{' '.repeat(cells - filled)}]
|
||||
</span>
|
||||
{percentLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.62rem] tabular-nums',
|
||||
isCurrent ? 'text-primary-foreground/70' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{percentLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Allowance gauge: fills as it is used, `NN% used` (same rule as the portal and CLI). */
|
||||
function UsageBar({ gauge, isCurrent }: { gauge: PlanUsageGauge; isCurrent?: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const used = 1 - Math.min(1, Math.max(0, gauge.left_fraction))
|
||||
|
||||
return (
|
||||
<AsciiBar
|
||||
fill={used}
|
||||
isCurrent={isCurrent}
|
||||
percentLabel={t.modelPicker.percentUsed(formatPercentUsed(used))}
|
||||
style={gauge.style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Window plans: "Recommended" / "Other models" title inside a provider group (not selectable). */
|
||||
function ModelSectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="px-6 pb-1 pt-2 text-[0.62rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Window plans: Large (its own monthly limit) or Free (never runs out); other models carry no tag. */
|
||||
export function UsageLevelTag({
|
||||
isCurrent = false,
|
||||
level
|
||||
}: {
|
||||
isCurrent?: boolean
|
||||
level: 'free' | 'top_tier' | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!level) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-sm px-1 py-0.5 font-sans text-[0.62rem] font-medium',
|
||||
isCurrent
|
||||
? 'bg-primary-foreground/20 text-primary-foreground'
|
||||
: level === 'free'
|
||||
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
|
||||
)}
|
||||
title={level === 'top_tier' ? t.modelPicker.largeFootnote : undefined}
|
||||
>
|
||||
{t.modelPicker.usageLevels[level]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Window plans: one line above the model list once an allowance is full, saying what happens next —
|
||||
* paid (or Large) models now bill your top-up credits, pay as you go, or they wait for the reset.
|
||||
* Renders nothing while every allowance has room, for every other account, and while paused.
|
||||
*/
|
||||
export function PlanLimitLine({ className, provider }: { className?: string; provider: ModelOptionProvider }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.modelPicker
|
||||
const limit = planLimit(provider)
|
||||
|
||||
if (!limit) {
|
||||
return null
|
||||
}
|
||||
|
||||
const when = formatResetsIn(limit.gauge.resets_at_ms)
|
||||
const name = gaugeTitle(limit.gauge, copy.limitNames)
|
||||
const head = `${copy.limitReached(name)}${when ? ` · ${copy.resetsIn(when)}` : ''}`
|
||||
|
||||
const then =
|
||||
limit.then === 'credits'
|
||||
? limit.scope === 'all'
|
||||
? copy.limitThenCredits
|
||||
: copy.limitThenCreditsLarge
|
||||
: limit.then === 'wait'
|
||||
? limit.scope === 'all'
|
||||
? copy.limitThenWait
|
||||
: copy.limitThenWaitLarge
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={cn('text-[0.62rem] leading-snug', className)}>
|
||||
<span className={limit.then === 'wait' ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground'}>
|
||||
{head}
|
||||
</span>
|
||||
{then ? (
|
||||
<span className={limit.then === 'credits' ? 'text-foreground' : 'text-muted-foreground'}> · {then}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanUsageSummary({ provider, usage }: { provider: ModelOptionProvider; usage: PlanUsage }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.modelPicker
|
||||
|
||||
const windowGauges = usage.gauges ?? []
|
||||
|
||||
// Window plans only: any other shape (an older backend's credit-grant gauge) shows nothing.
|
||||
if (usage.kind !== 'usage_window') {
|
||||
return null
|
||||
}
|
||||
|
||||
const limited = planLimit(provider) !== null
|
||||
|
||||
return (
|
||||
<div className="px-6 pb-2 pt-0.5 text-[0.62rem] leading-relaxed text-muted-foreground">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{limited ? (
|
||||
<PlanLimitLine provider={provider} />
|
||||
) : (
|
||||
<div>
|
||||
{copy.included}
|
||||
{usage.paused_reason ? ` · ${copy.pausedRenewal}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{windowGauges.map(g => {
|
||||
const when = formatResetsIn(g.resets_at_ms)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 font-mono" key={g.label}>
|
||||
<span className="w-[4.5rem] shrink-0">{gaugeLabel(g, copy)}</span>
|
||||
<UsageBar gauge={g} />
|
||||
{when ? <span className="text-muted-foreground">{copy.resetsIn(when)}</span> : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
|
||||
// Renders nothing when pricing is unavailable for the model.
|
||||
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
|
||||
@@ -602,8 +828,14 @@ function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
|
||||
// account on the free plan; `free_tier_row` is the no-account route's own
|
||||
// row. Either way the user is on free inference, so say so. Never match the
|
||||
// route by name — the label is copy.
|
||||
const planName = provider.plan_usage?.plan
|
||||
|
||||
const tierBadge =
|
||||
provider.free_tier === true || provider.free_tier_row === true ? (
|
||||
provider.plan_usage?.kind === 'usage_window' && planName ? (
|
||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||
{planName}
|
||||
</span>
|
||||
) : provider.free_tier === true || provider.free_tier_row === true ? (
|
||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
{copy.freeTier}
|
||||
</span>
|
||||
|
||||
@@ -27,6 +27,12 @@ export const arAssistant = {
|
||||
credentialsHeading: 'بيانات الاعتماد'
|
||||
},
|
||||
thread: {
|
||||
errorCodes: {
|
||||
usage_window: {
|
||||
title: 'نفد الاستخدام المضمّن',
|
||||
body: 'نفد الاستخدام المضمّن في خطتك حتى إعادة التعيين. أضف رصيدًا عبر /topup للمتابعة.'
|
||||
}
|
||||
},
|
||||
loadingSession: 'جار تحميل الجلسة...',
|
||||
showEarlier: 'عرض الرسائل الأقدم',
|
||||
loadingResponse: 'جار تحميل الرد...',
|
||||
@@ -57,6 +63,12 @@ export const arAssistant = {
|
||||
streaming: 'خطأ في اتصال البث'
|
||||
},
|
||||
errorRetry: 'إعادة المحاولة',
|
||||
errorSwitchToModel: (model, note) => (note ? `التبديل إلى ${model} (${note})` : `التبديل إلى ${model}`),
|
||||
errorSwitchModelFailed: 'تعذر تبديل النموذج',
|
||||
errorUseCredits: 'استخدام الرصيد',
|
||||
errorUseCreditsFailed: 'تعذر تفعيل الاستمرار بالرصيد',
|
||||
errorAddCredits: 'إضافة رصيد',
|
||||
errorOpenBillingSettings: 'فتح إعدادات الفوترة',
|
||||
errorLimitResets: time => `يُعاد ضبط الحد عند ${time}`,
|
||||
errorRetryAtReset: time => `إعادة المحاولة عند إعادة ضبط الحد (${time})`,
|
||||
errorRetryScheduled: (time, wait) => `ستتم إعادة المحاولة عند ${time} — بعد ${wait}`,
|
||||
|
||||
@@ -12,6 +12,35 @@ export const arSettings = {
|
||||
noResults: 'لا توجد لغة مطابقة'
|
||||
},
|
||||
settings: {
|
||||
// Window plans (Nous subscription with included usage). Other billing strings: overlay-gaps.json.
|
||||
billing: {
|
||||
keepGoingOnCredits: {
|
||||
title: 'الاستمرار بالرصيد',
|
||||
description: 'استخدم رصيدك عند نفاد الاستخدام المضمّن. عند الإيقاف: ينتظر Hermes إعادة التعيين.',
|
||||
approveInBrowser: 'وافق على الإنفاق عن بُعد في متصفحك لتغيير هذا. يُطبَّق فور الموافقة.',
|
||||
openAgain: 'الفتح مرة أخرى',
|
||||
cancel: 'إلغاء',
|
||||
approvedRetry: 'تمت الموافقة على الإنفاق عن بُعد، لكن لم يُحفظ التغيير.',
|
||||
tryAgain: 'إعادة المحاولة'
|
||||
},
|
||||
state: {
|
||||
usage: {
|
||||
window: {
|
||||
today: 'اليوم',
|
||||
week: 'هذا الأسبوع',
|
||||
month: 'هذا الشهر',
|
||||
topTier: 'النماذج الكبيرة (شهريًا)',
|
||||
barLabel: title => `المستخدم من الاستخدام المضمّن: ${title}`,
|
||||
captionPaused: 'متوقف مؤقتًا: فشل دفع الاشتراك',
|
||||
captionTopTierUsedUp: 'نفدت الحصة الشهرية، ويغطي الرصيد الباقي',
|
||||
captionTopTierUsedUpKeepGoingOff: 'نفدت الحصة الشهرية، ولا تزال النماذج المضمّنة الأخرى تعمل',
|
||||
captionResetsIn: duration => `يُعاد التعيين خلال ${duration}`,
|
||||
captionIncluded: 'الاستخدام المضمّن',
|
||||
valueUsed: percent => `${percent} مستخدم`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
subpages: {
|
||||
appearanceTheme: 'السمة',
|
||||
appearanceTypography: 'الخطوط والتحجيم',
|
||||
@@ -934,7 +963,26 @@ export const arSettings = {
|
||||
priceTitle: 'السعر',
|
||||
customModel: 'نموذج مخصص',
|
||||
addCustomModelAction: 'إضافة نموذج مخصص…',
|
||||
customModelPlaceholder: 'اكتب معرّف النموذج، مثل openai/gpt-5'
|
||||
customModelPlaceholder: 'اكتب معرّف النموذج، مثل openai/gpt-5',
|
||||
today: 'اليوم',
|
||||
week: 'الأسبوع',
|
||||
month: 'الشهر',
|
||||
topTier: 'النماذج الكبيرة (شهري)',
|
||||
included: 'كل النماذج أدناه مشمولة',
|
||||
pausedRenewal: 'متوقف: التجديد غير مدفوع',
|
||||
includedUsedUp: 'نفد الاستخدام المضمن',
|
||||
limitReached: name => `تم بلوغ الحد ${name}`,
|
||||
limitNames: { day: 'اليومي', month: 'الشهري', top_tier: 'الشهري للنماذج الكبيرة', week: 'الأسبوعي' },
|
||||
limitThenCredits: 'النماذج المدفوعة تستخدم رصيدك الآن (الدفع حسب الاستخدام)',
|
||||
limitThenCreditsLarge: 'النماذج الكبيرة تستخدم رصيدك الآن (الدفع حسب الاستخدام)',
|
||||
limitThenWait: 'النماذج المجانية فقط حتى ذلك الحين',
|
||||
limitThenWaitLarge: 'النماذج الكبيرة متوقفة مؤقتًا حتى ذلك الحين',
|
||||
percentUsed: percent => `${percent} مستخدم`,
|
||||
resetsIn: duration => `يُعاد التعيين خلال ${duration}`,
|
||||
usageLevels: { free: 'مجاني', top_tier: 'كبير' },
|
||||
sectionRecommended: 'موصى بها',
|
||||
sectionOther: 'نماذج أخرى',
|
||||
largeFootnote: 'تستهلك النماذج الكبيرة أيضًا حدّك الشهري للنماذج الكبيرة.'
|
||||
},
|
||||
modelVisibility: {
|
||||
title: 'النماذج',
|
||||
|
||||
@@ -2046,6 +2046,17 @@ export const deOverrides = {
|
||||
effectScheduled: (targetName: string, effectiveAt: string, creditsDelta?: string) =>
|
||||
`Wechsel zu ${targetName} – wirksam ${effectiveAt}. Jetzt keine Abbuchung; bis dahin behalten Sie Ihren aktuellen Tarif.${creditsDelta ? ` Änderung der monatlichen Credits: ${creditsDelta}.` : ''}`
|
||||
},
|
||||
keepGoingOnCredits: {
|
||||
title: 'Mit Credits weitermachen',
|
||||
description:
|
||||
'Nutzt Ihre Credits, wenn die enthaltene Nutzung aufgebraucht ist. Aus: Hermes wartet auf das Zurücksetzen.',
|
||||
approveInBrowser:
|
||||
'Bestätige Remote Spending im Browser, um das zu ändern. Es wird übernommen, sobald du bestätigst.',
|
||||
openAgain: 'Erneut öffnen',
|
||||
cancel: 'Abbrechen',
|
||||
approvedRetry: 'Remote Spending ist bestätigt, aber die Änderung wurde nicht gespeichert.',
|
||||
tryAgain: 'Erneut versuchen'
|
||||
},
|
||||
autoReload: {
|
||||
threshold: 'Schwellenwert',
|
||||
thresholdAria: 'Schwellenwert für automatisches Aufladen',
|
||||
@@ -2125,6 +2136,20 @@ export const deOverrides = {
|
||||
captionDefault: 'Standardobergrenze',
|
||||
captionSpending: 'Monatliche Remote-Ausgaben',
|
||||
valueUsed: (spent: string, limit: string) => `${spent} von ${limit} genutzt`
|
||||
},
|
||||
window: {
|
||||
today: 'Heute',
|
||||
week: 'Diese Woche',
|
||||
month: 'Dieser Monat',
|
||||
topTier: 'Große Modelle (monatlich)',
|
||||
barLabel: (title: string) => `${title}: genutztes Inklusivvolumen`,
|
||||
captionPaused: 'Pausiert: Abo-Zahlung fehlgeschlagen',
|
||||
captionTopTierUsedUp: 'Monatskontingent aufgebraucht, Credits decken den Rest',
|
||||
captionTopTierUsedUpKeepGoingOff:
|
||||
'Monatskontingent aufgebraucht, andere enthaltene Modelle funktionieren weiter',
|
||||
captionResetsIn: (duration: string) => `Wird in ${duration} zurückgesetzt`,
|
||||
captionIncluded: 'Inklusivvolumen',
|
||||
valueUsed: (percent: string) => `${percent} genutzt`
|
||||
}
|
||||
},
|
||||
planCard: {
|
||||
@@ -4494,7 +4519,31 @@ export const deOverrides = {
|
||||
wasPrice: 'war',
|
||||
customModel: 'Eigenes Modell',
|
||||
addCustomModelAction: 'Eigenes Modell hinzufügen…',
|
||||
customModelPlaceholder: 'Modell-ID eingeben, z. B. openai/gpt-5'
|
||||
customModelPlaceholder: 'Modell-ID eingeben, z. B. openai/gpt-5',
|
||||
today: 'Heute',
|
||||
week: 'Woche',
|
||||
month: 'Monat',
|
||||
topTier: 'Große Modelle (monatlich)',
|
||||
included: 'Alle Modelle unten sind enthalten',
|
||||
pausedRenewal: 'pausiert: Verlängerung unbezahlt',
|
||||
includedUsedUp: 'Inklusivvolumen aufgebraucht',
|
||||
limitReached: (name: string) => `${name} erreicht`,
|
||||
limitNames: {
|
||||
day: 'Tageslimit',
|
||||
month: 'Monatslimit',
|
||||
top_tier: 'Monatslimit für große Modelle',
|
||||
week: 'Wochenlimit'
|
||||
},
|
||||
limitThenCredits: 'kostenpflichtige Modelle nutzen jetzt dein Guthaben (Pay-as-you-go)',
|
||||
limitThenCreditsLarge: 'Große Modelle nutzen jetzt dein Guthaben (Pay-as-you-go)',
|
||||
limitThenWait: 'bis dahin nur kostenlose Modelle',
|
||||
limitThenWaitLarge: 'Große Modelle bis dahin pausiert',
|
||||
percentUsed: (percent: string) => `${percent} genutzt`,
|
||||
resetsIn: (duration: string) => `Zurücksetzung in ${duration}`,
|
||||
usageLevels: { free: 'Kostenlos', top_tier: 'Groß' },
|
||||
sectionRecommended: 'Empfohlen',
|
||||
sectionOther: 'Weitere Modelle',
|
||||
largeFootnote: 'Große Modelle zählen auch zu deinem Monatslimit für große Modelle.'
|
||||
},
|
||||
modelVisibility: {
|
||||
title: 'Modelle',
|
||||
@@ -5099,6 +5148,10 @@ export const deOverrides = {
|
||||
free_tier_refused: {
|
||||
title: 'Hermes konnte das ohne Anmeldung nicht senden',
|
||||
body: 'Eine Anmeldung mit einem Nous-Konto ist kostenlos.'
|
||||
},
|
||||
usage_window: {
|
||||
title: 'Enthaltene Nutzung aufgebraucht',
|
||||
body: 'Die enthaltene Nutzung Ihres Plans ist bis zum Zurücksetzen aufgebraucht. Laden Sie in /topup Credits auf, um weiterzumachen.'
|
||||
}
|
||||
},
|
||||
errorAuthKinds: {
|
||||
@@ -5115,6 +5168,12 @@ export const deOverrides = {
|
||||
errorGenericProvider: 'Der KI-Dienst',
|
||||
errorToastTitle: 'Hermes konnte die Antwort nicht fertigstellen',
|
||||
errorRetry: 'Erneut versuchen',
|
||||
errorSwitchToModel: (model, note) => (note ? `Zu ${model} wechseln (${note})` : `Zu ${model} wechseln`),
|
||||
errorSwitchModelFailed: 'Modell konnte nicht gewechselt werden',
|
||||
errorUseCredits: 'Guthaben nutzen',
|
||||
errorUseCreditsFailed: 'Mit Guthaben weitermachen konnte nicht aktiviert werden',
|
||||
errorAddCredits: 'Guthaben aufladen',
|
||||
errorOpenBillingSettings: 'Abrechnungseinstellungen öffnen',
|
||||
errorLimitResets: (time: string) => `Limit wird um ${time} zurückgesetzt`,
|
||||
errorRetryAtReset: (time: string) => `Erneut versuchen, wenn das Limit zurückgesetzt wird (${time})`,
|
||||
errorRetryScheduled: (time: string, wait: string) => `Neuer Versuch um ${time} – in ${wait}`,
|
||||
|
||||
@@ -1692,6 +1692,16 @@ export const en: Translations = {
|
||||
effectScheduled: (targetName, effectiveAt, creditsDelta) =>
|
||||
`Change to ${targetName} — takes effect ${effectiveAt}. No charge now; you keep your current plan until then.${creditsDelta ? ` Monthly credits change: ${creditsDelta}.` : ''}`
|
||||
},
|
||||
keepGoingOnCredits: {
|
||||
title: 'Keep going on credits',
|
||||
description:
|
||||
'Use your credits when included usage runs out. Off: Hermes waits for the reset.',
|
||||
approveInBrowser: 'Approve Remote Spending in your browser to change this. It applies as soon as you approve.',
|
||||
openAgain: 'Open again',
|
||||
cancel: 'Cancel',
|
||||
approvedRetry: 'Remote Spending is approved, but the change didn\'t save.',
|
||||
tryAgain: 'Try again'
|
||||
},
|
||||
autoReload: {
|
||||
threshold: 'Threshold',
|
||||
thresholdAria: 'Auto-refill threshold',
|
||||
@@ -1769,7 +1779,20 @@ export const en: Translations = {
|
||||
captionDefault: 'Default ceiling',
|
||||
captionSpending: 'Monthly remote spending',
|
||||
valueUsed: (spent, limit) => `${spent} of ${limit} used`
|
||||
}
|
||||
},
|
||||
window: {
|
||||
today: 'Today',
|
||||
week: 'This week',
|
||||
month: 'This month',
|
||||
topTier: 'Large models (monthly)',
|
||||
barLabel: title => `${title} included usage used`,
|
||||
captionPaused: 'Paused: subscription payment failed',
|
||||
captionTopTierUsedUp: 'Monthly allowance used up, credits cover the rest',
|
||||
captionTopTierUsedUpKeepGoingOff: 'Monthly allowance used up, other included models still work',
|
||||
captionResetsIn: duration => `Resets in ${duration}`,
|
||||
captionIncluded: 'Included usage',
|
||||
valueUsed: percent => `${percent} used`
|
||||
},
|
||||
},
|
||||
planCard: {
|
||||
freeTier: 'Free',
|
||||
@@ -4054,7 +4077,26 @@ export const en: Translations = {
|
||||
wasPrice: 'was',
|
||||
customModel: 'Custom model',
|
||||
addCustomModelAction: 'Add custom model…',
|
||||
customModelPlaceholder: 'Type a model id, e.g. openai/gpt-5'
|
||||
customModelPlaceholder: 'Type a model id, e.g. openai/gpt-5',
|
||||
today: 'Today',
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
topTier: 'Large models (monthly)',
|
||||
included: 'Every model below is included',
|
||||
pausedRenewal: 'paused: renewal unpaid',
|
||||
includedUsedUp: 'Included usage used up',
|
||||
limitReached: name => `${name.charAt(0).toUpperCase()}${name.slice(1)} limit reached`,
|
||||
limitNames: { day: 'daily', month: 'monthly', top_tier: 'monthly large-model', week: 'weekly' },
|
||||
limitThenCredits: 'paid models now use your credits (pay as you go)',
|
||||
limitThenCreditsLarge: 'Large models now use your credits (pay as you go)',
|
||||
limitThenWait: 'only Free models until then',
|
||||
limitThenWaitLarge: 'Large models paused until then',
|
||||
percentUsed: percent => `${percent} used`,
|
||||
resetsIn: duration => `resets in ${duration}`,
|
||||
usageLevels: { free: 'Free', top_tier: 'Large' },
|
||||
sectionRecommended: 'Recommended',
|
||||
sectionOther: 'Other models',
|
||||
largeFootnote: 'Large models also use your monthly large-model limit.'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
@@ -4173,6 +4215,9 @@ export const en: Translations = {
|
||||
toggleWorkspace: 'Workspace',
|
||||
cacheHitRateTitle: 'Prompt cache hit rate this session — cached tokens cost less, so higher is cheaper',
|
||||
tokensPerSecondTitle: 'Output tokens per second, averaged over the last 10 model calls',
|
||||
togglePlanUsage: 'Plan usage',
|
||||
planUsageTitle: (used, resetsIn) =>
|
||||
`${used} of your plan's usage used${resetsIn ? ` · resets in ${resetsIn}` : ''} · open Billing`,
|
||||
agents: 'Agents',
|
||||
closeAgents: 'Close agents',
|
||||
openAgents: 'Open agents',
|
||||
@@ -4674,6 +4719,10 @@ export const en: Translations = {
|
||||
free_tier_refused: {
|
||||
title: "Hermes couldn't send that without signing in",
|
||||
body: 'Signing in with a Nous account is free.'
|
||||
},
|
||||
usage_window: {
|
||||
title: 'Included usage used up',
|
||||
body: "Your plan's included usage is used up until it resets. Add credits in /topup to keep going."
|
||||
}
|
||||
},
|
||||
errorAuthKinds: {
|
||||
@@ -4696,6 +4745,12 @@ export const en: Translations = {
|
||||
errorStartNewSession: 'Start new session',
|
||||
errorSwitchProvider: 'Switch provider',
|
||||
errorChooseModel: 'Choose a model',
|
||||
errorSwitchToModel: (model, note) => (note ? `Switch to ${model} (${note})` : `Switch to ${model}`),
|
||||
errorSwitchModelFailed: 'Could not switch model',
|
||||
errorUseCredits: 'Use credits',
|
||||
errorUseCreditsFailed: 'Could not turn on Keep going on credits',
|
||||
errorAddCredits: 'Add credits',
|
||||
errorOpenBillingSettings: 'Open billing settings',
|
||||
errorCompressConversation: 'Compress conversation',
|
||||
errorCompressFailed: 'Could not compress the conversation',
|
||||
errorOpenHermesFolder: 'Open Hermes folder',
|
||||
|
||||
@@ -2039,6 +2039,15 @@ export const esOverrides = {
|
||||
effectScheduled: (targetName: string, effectiveAt: string, creditsDelta?: string) =>
|
||||
`Cambio a ${targetName}: entra en vigor ${effectiveAt}. No se cobra nada ahora; conservas tu plan actual hasta entonces.${creditsDelta ? ` Cambio de créditos mensuales: ${creditsDelta}.` : ''}`
|
||||
},
|
||||
keepGoingOnCredits: {
|
||||
title: 'Seguir con créditos',
|
||||
description: 'Usa tus créditos cuando se agote el uso incluido. Desactivado: Hermes espera al reinicio.',
|
||||
approveInBrowser: 'Aprueba Remote Spending en tu navegador para cambiar esto. Se aplica en cuanto lo apruebes.',
|
||||
openAgain: 'Abrir de nuevo',
|
||||
cancel: 'Cancelar',
|
||||
approvedRetry: 'Remote Spending está aprobado, pero el cambio no se guardó.',
|
||||
tryAgain: 'Reintentar'
|
||||
},
|
||||
autoReload: {
|
||||
threshold: 'Umbral',
|
||||
thresholdAria: 'Umbral de recarga automática',
|
||||
@@ -2118,6 +2127,19 @@ export const esOverrides = {
|
||||
captionDefault: 'Límite predeterminado',
|
||||
captionSpending: 'Gasto remoto mensual',
|
||||
valueUsed: (spent: string, limit: string) => `${spent} de ${limit} usados`
|
||||
},
|
||||
window: {
|
||||
today: 'Hoy',
|
||||
week: 'Esta semana',
|
||||
month: 'Este mes',
|
||||
topTier: 'Modelos grandes (mensual)',
|
||||
barLabel: (title: string) => `${title}: uso incluido consumido`,
|
||||
captionPaused: 'En pausa: falló el pago de la suscripción',
|
||||
captionTopTierUsedUp: 'Cuota mensual agotada, los créditos cubren el resto',
|
||||
captionTopTierUsedUpKeepGoingOff: 'Cuota mensual agotada, los demás modelos incluidos siguen funcionando',
|
||||
captionResetsIn: (duration: string) => `Se restablece en ${duration}`,
|
||||
captionIncluded: 'Uso incluido',
|
||||
valueUsed: (percent: string) => `${percent} usado`
|
||||
}
|
||||
},
|
||||
planCard: {
|
||||
@@ -4489,7 +4511,26 @@ export const esOverrides = {
|
||||
wasPrice: 'antes',
|
||||
customModel: 'Modelo personalizado',
|
||||
addCustomModelAction: 'Añadir modelo personalizado…',
|
||||
customModelPlaceholder: 'Escribe un ID de modelo, p. ej., openai/gpt-5'
|
||||
customModelPlaceholder: 'Escribe un ID de modelo, p. ej., openai/gpt-5',
|
||||
today: 'Hoy',
|
||||
week: 'Semana',
|
||||
month: 'Mes',
|
||||
topTier: 'Modelos grandes (mensual)',
|
||||
included: 'Todos los modelos de abajo están incluidos',
|
||||
pausedRenewal: 'en pausa: renovación impagada',
|
||||
includedUsedUp: 'Uso incluido agotado',
|
||||
limitReached: (name: string) => `Límite ${name} alcanzado`,
|
||||
limitNames: { day: 'diario', month: 'mensual', top_tier: 'mensual de modelos grandes', week: 'semanal' },
|
||||
limitThenCredits: 'los modelos de pago ahora usan tus créditos (pago por uso)',
|
||||
limitThenCreditsLarge: 'los modelos grandes ahora usan tus créditos (pago por uso)',
|
||||
limitThenWait: 'solo modelos gratuitos hasta entonces',
|
||||
limitThenWaitLarge: 'modelos grandes en pausa hasta entonces',
|
||||
percentUsed: (percent: string) => `${percent} usado`,
|
||||
resetsIn: (duration: string) => `se restablece en ${duration}`,
|
||||
usageLevels: { free: 'Gratis', top_tier: 'Grande' },
|
||||
sectionRecommended: 'Recomendados',
|
||||
sectionOther: 'Otros modelos',
|
||||
largeFootnote: 'Los modelos grandes también cuentan para tu límite mensual de modelos grandes.'
|
||||
},
|
||||
modelVisibility: {
|
||||
title: 'Modelos',
|
||||
@@ -5092,6 +5133,10 @@ export const esOverrides = {
|
||||
free_tier_refused: {
|
||||
title: 'Hermes no pudo enviarlo sin iniciar sesión',
|
||||
body: 'Iniciar sesión con una cuenta de Nous es gratis.'
|
||||
},
|
||||
usage_window: {
|
||||
title: 'Uso incluido agotado',
|
||||
body: 'El uso incluido de tu plan está agotado hasta el reinicio. Añade créditos en /topup para seguir.'
|
||||
}
|
||||
},
|
||||
errorAuthKinds: {
|
||||
@@ -5108,6 +5153,12 @@ export const esOverrides = {
|
||||
errorGenericProvider: 'El servicio de IA',
|
||||
errorToastTitle: 'Hermes no pudo terminar la respuesta',
|
||||
errorRetry: 'Reintentar',
|
||||
errorSwitchToModel: (model, note) => (note ? `Cambiar a ${model} (${note})` : `Cambiar a ${model}`),
|
||||
errorSwitchModelFailed: 'No se pudo cambiar de modelo',
|
||||
errorUseCredits: 'Usar créditos',
|
||||
errorUseCreditsFailed: 'No se pudo activar Seguir con créditos',
|
||||
errorAddCredits: 'Añadir créditos',
|
||||
errorOpenBillingSettings: 'Abrir ajustes de facturación',
|
||||
errorLimitResets: (time: string) => `El límite se restablece a las ${time}`,
|
||||
errorRetryAtReset: (time: string) => `Reintentar cuando se restablezca el límite (${time})`,
|
||||
errorRetryScheduled: (time: string, wait: string) => `Reintentando a las ${time}, dentro de ${wait}`,
|
||||
|
||||
@@ -2049,6 +2049,17 @@ export const frOverrides = {
|
||||
effectScheduled: (targetName: string, effectiveAt: string, creditsDelta?: string) =>
|
||||
`Passage à ${targetName} — effectif ${effectiveAt}. Aucun débit maintenant ; vous conservez votre forfait actuel jusque-là.${creditsDelta ? ` Variation des crédits mensuels : ${creditsDelta}.` : ''}`
|
||||
},
|
||||
keepGoingOnCredits: {
|
||||
title: 'Continuer avec les crédits',
|
||||
description:
|
||||
'Utilise vos crédits quand l’usage inclus est épuisé. Désactivé : Hermes attend la réinitialisation.',
|
||||
approveInBrowser:
|
||||
"Approuvez Remote Spending dans votre navigateur pour modifier ce réglage. Il s'applique dès votre approbation.",
|
||||
openAgain: 'Rouvrir',
|
||||
cancel: 'Annuler',
|
||||
approvedRetry: "Remote Spending est approuvé, mais la modification n'a pas été enregistrée.",
|
||||
tryAgain: 'Réessayer'
|
||||
},
|
||||
autoReload: {
|
||||
threshold: 'Seuil',
|
||||
thresholdAria: 'Seuil de recharge automatique',
|
||||
@@ -2129,6 +2140,19 @@ export const frOverrides = {
|
||||
captionDefault: 'Plafond par défaut',
|
||||
captionSpending: 'Dépenses mensuelles à distance',
|
||||
valueUsed: (spent: string, limit: string) => `${spent} sur ${limit} utilisés`
|
||||
},
|
||||
window: {
|
||||
today: "Aujourd'hui",
|
||||
week: 'Cette semaine',
|
||||
month: 'Ce mois-ci',
|
||||
topTier: 'Grands modèles (mensuel)',
|
||||
barLabel: (title: string) => `${title} : usage inclus consommé`,
|
||||
captionPaused: "En pause : échec du paiement de l'abonnement",
|
||||
captionTopTierUsedUp: 'Quota mensuel épuisé, les crédits couvrent le reste',
|
||||
captionTopTierUsedUpKeepGoingOff: 'Quota mensuel épuisé, les autres modèles inclus fonctionnent toujours',
|
||||
captionResetsIn: (duration: string) => `Réinitialisation dans ${duration}`,
|
||||
captionIncluded: 'Usage inclus',
|
||||
valueUsed: (percent: string) => `${percent} utilisé`
|
||||
}
|
||||
},
|
||||
planCard: {
|
||||
@@ -4502,7 +4526,31 @@ export const frOverrides = {
|
||||
wasPrice: 'était',
|
||||
customModel: 'Modèle personnalisé',
|
||||
addCustomModelAction: 'Ajouter un modèle personnalisé…',
|
||||
customModelPlaceholder: 'Saisissez un ID de modèle, p. ex. openai/gpt-5'
|
||||
customModelPlaceholder: 'Saisissez un ID de modèle, p. ex. openai/gpt-5',
|
||||
today: "Aujourd'hui",
|
||||
week: 'Semaine',
|
||||
month: 'Mois',
|
||||
topTier: 'Grands modèles (mensuel)',
|
||||
included: 'Tous les modèles ci-dessous sont inclus',
|
||||
pausedRenewal: 'en pause : renouvellement impayé',
|
||||
includedUsedUp: 'Usage inclus épuisé',
|
||||
limitReached: (name: string) => `Limite ${name} atteinte`,
|
||||
limitNames: {
|
||||
day: 'quotidienne',
|
||||
month: 'mensuelle',
|
||||
top_tier: 'mensuelle des grands modèles',
|
||||
week: 'hebdomadaire'
|
||||
},
|
||||
limitThenCredits: "les modèles payants utilisent maintenant vos crédits (paiement à l'usage)",
|
||||
limitThenCreditsLarge: "les grands modèles utilisent maintenant vos crédits (paiement à l'usage)",
|
||||
limitThenWait: "uniquement les modèles gratuits d'ici là",
|
||||
limitThenWaitLarge: "grands modèles en pause d'ici là",
|
||||
percentUsed: (percent: string) => `${percent} utilisé`,
|
||||
resetsIn: (duration: string) => `réinitialisation dans ${duration}`,
|
||||
usageLevels: { free: 'Gratuit', top_tier: 'Grand' },
|
||||
sectionRecommended: 'Recommandés',
|
||||
sectionOther: 'Autres modèles',
|
||||
largeFootnote: 'Les grands modèles comptent aussi dans votre limite mensuelle des grands modèles.'
|
||||
},
|
||||
modelVisibility: {
|
||||
title: 'Modèles',
|
||||
@@ -5105,6 +5153,10 @@ export const frOverrides = {
|
||||
free_tier_refused: {
|
||||
title: "Hermes n'a pas pu envoyer ce message sans connexion",
|
||||
body: 'La connexion avec un compte Nous est gratuite.'
|
||||
},
|
||||
usage_window: {
|
||||
title: 'Usage inclus épuisé',
|
||||
body: 'L’usage inclus de votre forfait est épuisé jusqu’à la réinitialisation. Ajoutez des crédits dans /topup pour continuer.'
|
||||
}
|
||||
},
|
||||
errorAuthKinds: {
|
||||
@@ -5121,6 +5173,12 @@ export const frOverrides = {
|
||||
errorGenericProvider: "Le service d'IA",
|
||||
errorToastTitle: "Hermes n'a pas pu terminer la réponse",
|
||||
errorRetry: 'Réessayer',
|
||||
errorSwitchToModel: (model, note) => (note ? `Passer à ${model} (${note})` : `Passer à ${model}`),
|
||||
errorSwitchModelFailed: 'Impossible de changer de modèle',
|
||||
errorUseCredits: 'Utiliser les crédits',
|
||||
errorUseCreditsFailed: 'Impossible d’activer Continuer avec les crédits',
|
||||
errorAddCredits: 'Ajouter des crédits',
|
||||
errorOpenBillingSettings: 'Ouvrir les réglages de facturation',
|
||||
errorLimitResets: (time: string) => `Le quota se réinitialise à ${time}`,
|
||||
errorRetryAtReset: (time: string) => `Réessayer à la réinitialisation du quota (${time})`,
|
||||
errorRetryScheduled: (time: string, wait: string) => `Nouvel essai à ${time} — dans ${wait}`,
|
||||
|
||||
@@ -242,6 +242,35 @@ export const jaOverrides = {
|
||||
},
|
||||
|
||||
settings: {
|
||||
// Window plans (Nous subscription with included usage). Other billing strings: overlay-gaps.json.
|
||||
billing: {
|
||||
keepGoingOnCredits: {
|
||||
title: 'クレジットで続行',
|
||||
description: '含まれる利用量を使い切ったらクレジットを使います。オフ: Hermes はリセットを待ちます。',
|
||||
approveInBrowser: '変更するにはブラウザでリモート支出を承認してください。承認するとすぐに適用されます。',
|
||||
openAgain: 'もう一度開く',
|
||||
cancel: 'キャンセル',
|
||||
approvedRetry: 'リモート支出は承認されましたが、変更を保存できませんでした。',
|
||||
tryAgain: '再試行'
|
||||
},
|
||||
state: {
|
||||
usage: {
|
||||
window: {
|
||||
today: '今日',
|
||||
week: '今週',
|
||||
month: '今月',
|
||||
topTier: '大型モデル(月間)',
|
||||
barLabel: title => `${title}の含まれる利用量の使用率`,
|
||||
captionPaused: '一時停止中: サブスクリプションの支払いに失敗しました',
|
||||
captionTopTierUsedUp: '月間の枠を使い切りました。残りはクレジットで支払われます',
|
||||
captionTopTierUsedUpKeepGoingOff: '月間の枠を使い切りました。他の含まれるモデルは引き続き使えます',
|
||||
captionResetsIn: duration => `${duration}後にリセット`,
|
||||
captionIncluded: '含まれる利用量',
|
||||
valueUsed: percent => `${percent} 使用`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
uninstallSection: {
|
||||
dangerZone: '危険ゾーン',
|
||||
checkingInstalled: 'インストール内容を確認中…',
|
||||
@@ -2884,7 +2913,26 @@ export const jaOverrides = {
|
||||
wasPrice: '旧価格',
|
||||
customModel: 'カスタムモデル',
|
||||
addCustomModelAction: 'カスタムモデルを追加…',
|
||||
customModelPlaceholder: 'モデル ID を入力(例: openai/gpt-5)'
|
||||
customModelPlaceholder: 'モデル ID を入力(例: openai/gpt-5)',
|
||||
today: '今日',
|
||||
week: '今週',
|
||||
month: '今月',
|
||||
topTier: '大型モデル(月間)',
|
||||
included: '以下のモデルはすべて含まれます',
|
||||
pausedRenewal: '一時停止: 未払い',
|
||||
includedUsedUp: '含まれる利用量を使い切りました',
|
||||
limitReached: name => `${name}の上限に達しました`,
|
||||
limitNames: { day: '1日', month: '月間', top_tier: '大型モデルの月間', week: '週間' },
|
||||
limitThenCredits: '有料モデルはクレジットを使用中(従量課金)',
|
||||
limitThenCreditsLarge: '大型モデルはクレジットを使用中(従量課金)',
|
||||
limitThenWait: 'それまでは無料モデルのみ',
|
||||
limitThenWaitLarge: 'それまで大型モデルは一時停止',
|
||||
percentUsed: percent => `${percent} 使用済み`,
|
||||
resetsIn: duration => `${duration}後にリセット`,
|
||||
usageLevels: { free: '無料', top_tier: '大' },
|
||||
sectionRecommended: 'おすすめ',
|
||||
sectionOther: 'その他のモデル',
|
||||
largeFootnote: '大型モデルは大型モデルの月間上限も消費します。'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
@@ -3385,6 +3433,10 @@ export const jaOverrides = {
|
||||
title: '安全な接続に失敗しました',
|
||||
body: provider =>
|
||||
`Hermes は ${provider} との安全な接続を検証できませんでした。ネットワークやプロキシの設定を確認するか、プロバイダーを切り替えて再送してください。`
|
||||
},
|
||||
usage_window: {
|
||||
title: '含まれる利用量を使い切りました',
|
||||
body: 'プランに含まれる利用量はリセットまで使い切りました。続けるには /topup でクレジットを追加してください。'
|
||||
}
|
||||
},
|
||||
errorLayers: {
|
||||
@@ -3399,6 +3451,12 @@ export const jaOverrides = {
|
||||
streaming: 'ストリーミング接続のエラー'
|
||||
},
|
||||
errorRetry: '再試行',
|
||||
errorSwitchToModel: (model, note) => (note ? `${model} に切り替え(${note})` : `${model} に切り替え`),
|
||||
errorSwitchModelFailed: 'モデルを切り替えられませんでした',
|
||||
errorUseCredits: 'クレジットを使う',
|
||||
errorUseCreditsFailed: 'クレジットで続行を有効にできませんでした',
|
||||
errorAddCredits: 'クレジットを追加',
|
||||
errorOpenBillingSettings: '請求設定を開く',
|
||||
errorLimitResets: time => `制限は ${time} にリセットされます`,
|
||||
errorRetryAtReset: time => `制限のリセット時に再試行(${time})`,
|
||||
errorRetryScheduled: (time, wait) => `${time} に再試行 — 残り ${wait}`,
|
||||
|
||||
@@ -321,11 +321,6 @@
|
||||
"connectors.connectError": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.connectErrorFor": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.connected": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.directory.allApps": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.directory.receiptConnected": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.directory.receiptNone": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.directory.receiptTimedOut": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.directory.suggested": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.disabled": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.disclaimer": ["ar","ja","ru","zh-hant"],
|
||||
"connectors.empty": ["ar","ja","ru","zh-hant"],
|
||||
@@ -1887,6 +1882,7 @@
|
||||
"shell.statusbar.messagingStopped": ["de","es","fr"],
|
||||
"shell.statusbar.openStarmap": ["ar"],
|
||||
"shell.statusbar.openWebhooks": ["ar","ja","zh-hant"],
|
||||
"shell.statusbar.planUsageTitle": ["ar","de","es","fr","ja","ru","zh","zh-hant"],
|
||||
"shell.statusbar.releaseAvailable": ["de","es","fr","ja","ru","zh-hant"],
|
||||
"shell.statusbar.resetStatusbar": ["ar","ja","zh-hant"],
|
||||
"shell.statusbar.starmap": ["ar"],
|
||||
@@ -1903,6 +1899,7 @@
|
||||
"shell.statusbar.toggleCommandCenter": ["ar","ja","zh-hant"],
|
||||
"shell.statusbar.toggleContextUsage": ["ar","ja","zh-hant"],
|
||||
"shell.statusbar.toggleFreeTier": ["ar","ja","ru","zh-hant"],
|
||||
"shell.statusbar.togglePlanUsage": ["ar","de","es","fr","ja","ru","zh","zh-hant"],
|
||||
"shell.statusbar.toggleRunningTimer": ["ar","ja","zh-hant"],
|
||||
"shell.statusbar.toggleSessionTimer": ["ar","ja","zh-hant"],
|
||||
"shell.statusbar.toggleTerminal": ["ar","ja","zh-hant"],
|
||||
@@ -1922,9 +1919,9 @@
|
||||
"sidebar.projects.baseBranchNone": ["ar"],
|
||||
"sidebar.projects.baseBranchPlaceholder": ["ar"],
|
||||
"sidebar.projects.branchOff": ["ar"],
|
||||
"sidebar.projects.moveBusy": ["ar","ja","zh-hant"],
|
||||
"sidebar.projects.moveFailed": ["ar","ja","zh-hant"],
|
||||
"sidebar.projects.moveNoFolder": ["ar","ja","zh-hant"],
|
||||
"sidebar.projects.moveBusy": ["ar","ja","zh-hant"],
|
||||
"sidebar.projects.moveNoProjects": ["ar","ja","zh-hant"],
|
||||
"sidebar.projects.moveToProject": ["ar","ja","zh-hant"],
|
||||
"sidebar.projects.movedTo": ["ar","ja","zh-hant"],
|
||||
|
||||
@@ -351,6 +351,36 @@ export const ruOverrides = {
|
||||
noResults: 'Языки не найдены'
|
||||
},
|
||||
settings: {
|
||||
// Window plans (Nous subscription with included usage). Other billing strings: overlay-gaps.json.
|
||||
billing: {
|
||||
keepGoingOnCredits: {
|
||||
title: 'Продолжать за кредиты',
|
||||
description: 'Тратить кредиты, когда включённый объём закончится. Выключено: Hermes ждёт сброса.',
|
||||
approveInBrowser:
|
||||
'Чтобы изменить это, одобрите удалённые траты в браузере. Изменение применится сразу после одобрения.',
|
||||
openAgain: 'Открыть снова',
|
||||
cancel: 'Отмена',
|
||||
approvedRetry: 'Удалённые траты одобрены, но изменение не сохранилось.',
|
||||
tryAgain: 'Повторить'
|
||||
},
|
||||
state: {
|
||||
usage: {
|
||||
window: {
|
||||
today: 'Сегодня',
|
||||
week: 'Эта неделя',
|
||||
month: 'Этот месяц',
|
||||
topTier: 'Крупные модели (месяц)',
|
||||
barLabel: title => `${title}: использовано включённого объёма`,
|
||||
captionPaused: 'Приостановлено: платёж по подписке не прошёл',
|
||||
captionTopTierUsedUp: 'Месячный лимит исчерпан, остальное оплачивается кредитами',
|
||||
captionTopTierUsedUpKeepGoingOff: 'Месячный лимит исчерпан, другие включённые модели работают',
|
||||
captionResetsIn: duration => `Сброс через ${duration}`,
|
||||
captionIncluded: 'Включённый объём',
|
||||
valueUsed: percent => `использовано ${percent}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
subpages: {
|
||||
appearanceTheme: 'Тема',
|
||||
appearanceTypography: 'Шрифты и масштаб',
|
||||
@@ -3182,7 +3212,31 @@ export const ruOverrides = {
|
||||
wasPrice: 'было',
|
||||
customModel: 'Своя модель',
|
||||
addCustomModelAction: 'Добавить свою модель…',
|
||||
customModelPlaceholder: 'Введите id модели, например openai/gpt-5'
|
||||
customModelPlaceholder: 'Введите id модели, например openai/gpt-5',
|
||||
today: 'Сегодня',
|
||||
week: 'Неделя',
|
||||
month: 'Месяц',
|
||||
topTier: 'Крупные модели (в месяц)',
|
||||
included: 'Все модели ниже включены',
|
||||
pausedRenewal: 'пауза: оплата не прошла',
|
||||
includedUsedUp: 'Включённый объём исчерпан',
|
||||
limitReached: name => `Достигнут ${name}`,
|
||||
limitNames: {
|
||||
day: 'дневной лимит',
|
||||
month: 'месячный лимит',
|
||||
top_tier: 'месячный лимит крупных моделей',
|
||||
week: 'недельный лимит'
|
||||
},
|
||||
limitThenCredits: 'платные модели теперь используют ваши кредиты (оплата по факту)',
|
||||
limitThenCreditsLarge: 'крупные модели теперь используют ваши кредиты (оплата по факту)',
|
||||
limitThenWait: 'до сброса только бесплатные модели',
|
||||
limitThenWaitLarge: 'крупные модели приостановлены до сброса',
|
||||
percentUsed: percent => `${percent} использовано`,
|
||||
resetsIn: duration => `сброс через ${duration}`,
|
||||
usageLevels: { free: 'Бесплатно', top_tier: 'Крупная' },
|
||||
sectionRecommended: 'Рекомендуемые',
|
||||
sectionOther: 'Другие модели',
|
||||
largeFootnote: 'Крупные модели также расходуют месячный лимит крупных моделей.'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
@@ -3580,6 +3634,19 @@ export const ruOverrides = {
|
||||
},
|
||||
assistant: {
|
||||
thread: {
|
||||
errorCodes: {
|
||||
usage_window: {
|
||||
title: 'Включённый объём исчерпан',
|
||||
body: 'Включённый объём вашего плана исчерпан до сброса. Пополните кредиты в /topup, чтобы продолжить.'
|
||||
}
|
||||
},
|
||||
errorSwitchToModel: (model: string, note?: string) =>
|
||||
note ? `Переключиться на ${model} (${note})` : `Переключиться на ${model}`,
|
||||
errorSwitchModelFailed: 'Не удалось переключить модель',
|
||||
errorUseCredits: 'Использовать кредиты',
|
||||
errorUseCreditsFailed: 'Не удалось включить «Продолжать за кредиты»',
|
||||
errorAddCredits: 'Пополнить кредиты',
|
||||
errorOpenBillingSettings: 'Открыть настройки оплаты',
|
||||
loadingSession: 'Загрузка сеанса',
|
||||
showEarlier: 'Показать ранние сообщения',
|
||||
loadingResponse: 'Hermes загружает ответ',
|
||||
|
||||
@@ -1548,6 +1548,17 @@ export interface Translations extends NoticeTranslations {
|
||||
cancel: string
|
||||
effectScheduled: (targetName: string, effectiveAt: string, creditsDelta: string) => string
|
||||
}
|
||||
keepGoingOnCredits: {
|
||||
title: string
|
||||
description: string
|
||||
/** Shown under the switch while the Remote Spending approval is open in the browser. */
|
||||
approveInBrowser: string
|
||||
openAgain: string
|
||||
cancel: string
|
||||
/** Approved, but the change was still refused: shown with a Try again button. */
|
||||
approvedRetry: string
|
||||
tryAgain: string
|
||||
}
|
||||
autoReload: {
|
||||
threshold: string
|
||||
thresholdAria: string
|
||||
@@ -1616,6 +1627,19 @@ export interface Translations extends NoticeTranslations {
|
||||
captionSpending: string
|
||||
valueUsed: (spent: string, limit: string) => string
|
||||
}
|
||||
window: {
|
||||
today: string
|
||||
week: string
|
||||
month: string
|
||||
topTier: string
|
||||
barLabel: (title: string) => string
|
||||
captionPaused: string
|
||||
captionTopTierUsedUp: string
|
||||
captionTopTierUsedUpKeepGoingOff: string
|
||||
captionResetsIn: (duration: string) => string
|
||||
captionIncluded: string
|
||||
valueUsed: (percent: string) => string
|
||||
}
|
||||
}
|
||||
planCard: {
|
||||
freeTier: string
|
||||
@@ -3494,6 +3518,28 @@ export interface Translations extends NoticeTranslations {
|
||||
customModel: string
|
||||
addCustomModelAction: string
|
||||
customModelPlaceholder: string
|
||||
today: string
|
||||
week: string
|
||||
month: string
|
||||
topTier: string
|
||||
included: string
|
||||
pausedRenewal: string
|
||||
includedUsedUp: string
|
||||
/** Row note: past its included usage, this model bills top-up credits (pay as you go). */
|
||||
/** "Weekly limit reached" from a limit name ("weekly"). */
|
||||
limitReached: (name: string) => string
|
||||
limitNames: { day: string; month: string; top_tier: string; week: string }
|
||||
limitThenCredits: string
|
||||
limitThenCreditsLarge: string
|
||||
limitThenWait: string
|
||||
limitThenWaitLarge: string
|
||||
percentUsed: (percent: string) => string
|
||||
resetsIn: (duration: string) => string
|
||||
/** Window plans: the gateway's per-model word (free / low / medium / top_tier). */
|
||||
usageLevels: { free: string; top_tier: string }
|
||||
sectionRecommended: string
|
||||
sectionOther: string
|
||||
largeFootnote: string
|
||||
}
|
||||
|
||||
modelVisibility: {
|
||||
@@ -3612,6 +3658,8 @@ export interface Translations extends NoticeTranslations {
|
||||
toggleWorkspace: string
|
||||
cacheHitRateTitle: string
|
||||
tokensPerSecondTitle: string
|
||||
togglePlanUsage: string
|
||||
planUsageTitle: (used: string, resetsIn: string) => string
|
||||
agents: string
|
||||
closeAgents: string
|
||||
openAgents: string
|
||||
@@ -3980,6 +4028,17 @@ export interface Translations extends NoticeTranslations {
|
||||
errorStartNewSession: string
|
||||
errorSwitchProvider: string
|
||||
errorChooseModel: string
|
||||
/** usage_window: one click to the plan's recommended model; `note` is "free" / "included". */
|
||||
errorSwitchToModel: (model: string, note: string) => string
|
||||
errorSwitchModelFailed: string
|
||||
/** usage_window: turn on Keep going on credits, then re-run the turn. */
|
||||
errorUseCredits: string
|
||||
errorUseCreditsFailed: string
|
||||
/** usage_window with no balance: opens Settings → Billing to add credits. */
|
||||
errorAddCredits: string
|
||||
/** Error card button: opens Settings → Billing in-app (Keep going, buy credits). */
|
||||
errorOpenBillingSettings: string
|
||||
/** Error card body when a Nous account is out of credits (its button opens Settings → Billing). */
|
||||
errorCompressConversation: string
|
||||
errorCompressFailed: string
|
||||
errorOpenHermesFolder: string
|
||||
|
||||
@@ -96,6 +96,10 @@ export const zhHantAssistant = {
|
||||
ssl_cert_verification: {
|
||||
title: '安全連線失敗',
|
||||
body: provider => `Hermes 無法驗證與 ${provider} 的安全連線。請檢查網路或代理設定,或切換服務商後重新傳送。`
|
||||
},
|
||||
usage_window: {
|
||||
title: '包含用量已用完',
|
||||
body: '你的方案包含的用量已用完,將在重設後恢復。在 /topup 中新增額度即可繼續。'
|
||||
}
|
||||
},
|
||||
errorLayers: {
|
||||
@@ -110,6 +114,12 @@ export const zhHantAssistant = {
|
||||
streaming: '串流連線錯誤'
|
||||
},
|
||||
errorRetry: '重試',
|
||||
errorSwitchToModel: (model, note) => (note ? `切換到 ${model}(${note})` : `切換到 ${model}`),
|
||||
errorSwitchModelFailed: '無法切換模型',
|
||||
errorUseCredits: '使用額度',
|
||||
errorUseCreditsFailed: '無法開啟用額度繼續',
|
||||
errorAddCredits: '新增額度',
|
||||
errorOpenBillingSettings: '開啟帳單設定',
|
||||
errorLimitResets: time => `限額將於 ${time} 重設`,
|
||||
errorRetryAtReset: time => `限額重設後重試(${time})`,
|
||||
errorRetryScheduled: (time, wait) => `將於 ${time} 重試 — 還剩 ${wait}`,
|
||||
|
||||
@@ -1295,6 +1295,15 @@ export const zhHantSettings = {
|
||||
effectScheduled: (targetName, effectiveAt, creditsDelta) =>
|
||||
`變更為 ${targetName},於 ${effectiveAt} 生效。現在不扣款;在此之前保留目前方案。${creditsDelta ? `每月額度變化:${creditsDelta}。` : ''}`
|
||||
},
|
||||
keepGoingOnCredits: {
|
||||
title: '用額度繼續',
|
||||
description: '包含的用量用完後,使用你的額度。關閉時,Hermes 等待重設。',
|
||||
approveInBrowser: '請在瀏覽器中核准 Remote Spending 以變更此設定。核准後立即生效。',
|
||||
openAgain: '再次開啟',
|
||||
cancel: '取消',
|
||||
approvedRetry: 'Remote Spending 已核准,但變更未儲存。',
|
||||
tryAgain: '重試'
|
||||
},
|
||||
autoReload: {
|
||||
threshold: '門檻',
|
||||
thresholdAria: '自動儲值門檻',
|
||||
@@ -1365,6 +1374,19 @@ export const zhHantSettings = {
|
||||
captionDefault: '預設上限',
|
||||
captionSpending: '每月遠端支出',
|
||||
valueUsed: (spent, limit) => `${limit} 中已用 ${spent}`
|
||||
},
|
||||
window: {
|
||||
today: '今天',
|
||||
week: '本週',
|
||||
month: '本月',
|
||||
topTier: '大型模型(每月)',
|
||||
barLabel: title => `${title}已用包含用量`,
|
||||
captionPaused: '已暫停:訂閱付款失敗',
|
||||
captionTopTierUsedUp: '每月額度已用完,剩餘部分由你的額度支付',
|
||||
captionTopTierUsedUpKeepGoingOff: '每月額度已用完,其他包含的模型仍可使用',
|
||||
captionResetsIn: duration => `${duration}後重設`,
|
||||
captionIncluded: '包含用量',
|
||||
valueUsed: percent => `已用 ${percent}`
|
||||
}
|
||||
},
|
||||
planCard: {
|
||||
@@ -1596,7 +1618,26 @@ export const zhHantSettings = {
|
||||
wasPrice: '原價',
|
||||
customModel: '自訂模型',
|
||||
addCustomModelAction: '新增自訂模型…',
|
||||
customModelPlaceholder: '輸入模型 ID,例如 openai/gpt-5'
|
||||
customModelPlaceholder: '輸入模型 ID,例如 openai/gpt-5',
|
||||
today: '今日',
|
||||
week: '本週',
|
||||
month: '本月',
|
||||
topTier: '大型模型(每月)',
|
||||
included: '下列模型均已包含',
|
||||
pausedRenewal: '已暫停:續費未到帳',
|
||||
includedUsedUp: '包含用量已用完',
|
||||
limitReached: name => `已達${name}上限`,
|
||||
limitNames: { day: '每日', month: '每月', top_tier: '每月大型模型', week: '每週' },
|
||||
limitThenCredits: '付費模型現在使用你的額度(按量付費)',
|
||||
limitThenCreditsLarge: '大型模型現在使用你的額度(按量付費)',
|
||||
limitThenWait: '在此之前僅可使用免費模型',
|
||||
limitThenWaitLarge: '在此之前大型模型已暫停',
|
||||
percentUsed: percent => `已用 ${percent}`,
|
||||
resetsIn: duration => `${duration}後重設`,
|
||||
usageLevels: { free: '免費', top_tier: '大' },
|
||||
sectionRecommended: '推薦',
|
||||
sectionOther: '其他模型',
|
||||
largeFootnote: '大型模型也會消耗你的每月大型模型額度。'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
|
||||
@@ -1720,6 +1720,15 @@ export const zhOverrides = {
|
||||
effectScheduled: (targetName, effectiveAt, creditsDelta) =>
|
||||
`更改为 ${targetName},于 ${effectiveAt} 生效。现在不扣费;在此之前保留当前套餐。${creditsDelta ? `每月额度变化:${creditsDelta}。` : ''}`
|
||||
},
|
||||
keepGoingOnCredits: {
|
||||
title: '用额度继续',
|
||||
description: '包含的用量用完后,使用你的额度。关闭时,Hermes 等待重置。',
|
||||
approveInBrowser: '请在浏览器中批准 Remote Spending 以更改此设置。批准后立即生效。',
|
||||
openAgain: '再次打开',
|
||||
cancel: '取消',
|
||||
approvedRetry: 'Remote Spending 已批准,但更改未保存。',
|
||||
tryAgain: '重试'
|
||||
},
|
||||
autoReload: {
|
||||
threshold: '阈值',
|
||||
thresholdAria: '自动充值阈值',
|
||||
@@ -1790,6 +1799,19 @@ export const zhOverrides = {
|
||||
captionDefault: '默认上限',
|
||||
captionSpending: '每月远程支出',
|
||||
valueUsed: (spent, limit) => `${limit} 中已用 ${spent}`
|
||||
},
|
||||
window: {
|
||||
today: '今天',
|
||||
week: '本周',
|
||||
month: '本月',
|
||||
topTier: '大型模型(每月)',
|
||||
barLabel: title => `${title}已用包含用量`,
|
||||
captionPaused: '已暂停:订阅付款失败',
|
||||
captionTopTierUsedUp: '每月额度已用完,剩余部分由你的额度支付',
|
||||
captionTopTierUsedUpKeepGoingOff: '每月额度已用完,其他包含的模型仍可使用',
|
||||
captionResetsIn: duration => `${duration}后重置`,
|
||||
captionIncluded: '包含用量',
|
||||
valueUsed: percent => `已用 ${percent}`
|
||||
}
|
||||
},
|
||||
planCard: {
|
||||
@@ -3912,7 +3934,26 @@ export const zhOverrides = {
|
||||
wasPrice: '原价',
|
||||
customModel: '自定义模型',
|
||||
addCustomModelAction: '添加自定义模型…',
|
||||
customModelPlaceholder: '输入模型 ID,例如 openai/gpt-5'
|
||||
customModelPlaceholder: '输入模型 ID,例如 openai/gpt-5',
|
||||
today: '今日',
|
||||
week: '本周',
|
||||
month: '本月',
|
||||
topTier: '大型模型(每月)',
|
||||
included: '下列模型均已包含',
|
||||
pausedRenewal: '已暂停:续费未到账',
|
||||
includedUsedUp: '包含用量已用完',
|
||||
limitReached: name => `已达${name}上限`,
|
||||
limitNames: { day: '每日', month: '每月', top_tier: '每月大型模型', week: '每周' },
|
||||
limitThenCredits: '付费模型现在使用你的额度(按量付费)',
|
||||
limitThenCreditsLarge: '大型模型现在使用你的额度(按量付费)',
|
||||
limitThenWait: '在此之前仅可使用免费模型',
|
||||
limitThenWaitLarge: '在此之前大型模型已暂停',
|
||||
percentUsed: percent => `已用 ${percent}`,
|
||||
resetsIn: duration => `${duration}后重置`,
|
||||
usageLevels: { free: '免费', top_tier: '大' },
|
||||
sectionRecommended: '推荐',
|
||||
sectionOther: '其他模型',
|
||||
largeFootnote: '大型模型也会消耗你的每月大型模型额度。'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
@@ -4407,6 +4448,10 @@ export const zhOverrides = {
|
||||
ssl_cert_verification: {
|
||||
title: '安全连接失败',
|
||||
body: provider => `Hermes 无法验证与 ${provider} 的安全连接。请检查网络或代理设置,或切换服务商后重新发送。`
|
||||
},
|
||||
usage_window: {
|
||||
title: '包含用量已用完',
|
||||
body: '你的方案包含的用量已用完,将在重置后恢复。在 /topup 中添加额度即可继续。'
|
||||
}
|
||||
},
|
||||
errorLayers: {
|
||||
@@ -4421,6 +4466,12 @@ export const zhOverrides = {
|
||||
streaming: '流式连接错误'
|
||||
},
|
||||
errorRetry: '重试',
|
||||
errorSwitchToModel: (model, note) => (note ? `切换到 ${model}(${note})` : `切换到 ${model}`),
|
||||
errorSwitchModelFailed: '无法切换模型',
|
||||
errorUseCredits: '使用额度',
|
||||
errorUseCreditsFailed: '无法开启用额度继续',
|
||||
errorAddCredits: '添加额度',
|
||||
errorOpenBillingSettings: '打开账单设置',
|
||||
errorLimitResets: time => `限额将于 ${time} 重置`,
|
||||
errorRetryAtReset: time => `限额重置后重试(${time})`,
|
||||
errorRetryScheduled: (time, wait) => `将于 ${time} 重试 — 还剩 ${wait}`,
|
||||
|
||||
@@ -45,10 +45,12 @@ export function errorCardText(
|
||||
if ('code' in key) {
|
||||
const copy = thread.errorCodes[key.code]
|
||||
|
||||
// A free-tier refusal arrives with the backend's own sentence (the wait, the
|
||||
// model, the way forward); the table body is only the fallback for an older backend.
|
||||
// Free-tier and usage-window refusals arrive with the backend's own sentence (the
|
||||
// wait, the model, the way forward); the table body is only the fallback for an older backend.
|
||||
const ownSentence = isFreeTierSurface(surface) || surface?.code === 'usage_window'
|
||||
|
||||
return {
|
||||
body: (isFreeTierSurface(surface) && surface?.message) || render(copy.body, provider),
|
||||
body: (ownSentence && surface?.message) || render(copy.body, provider),
|
||||
title: render(copy.title, provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,82 @@ describe('free-tier refusals', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage-window refusal', () => {
|
||||
it('shows the stop-line sentence, not the out-of-credits copy', () => {
|
||||
const message = "You've hit your daily limit · resets 15:56 · turn on Keep going in /topup"
|
||||
const surface = parseErrorSurface({ code: 'usage_window', layer: 'billing', message, retryable: false })!
|
||||
const { body, title } = errorCardText(en.assistant.thread, surface)
|
||||
expect(title).toBe(en.assistant.thread.errorCodes.usage_window.title)
|
||||
expect(body).toBe(message)
|
||||
const plain = parseErrorSurface({ code: 'billing', layer: 'billing', provider: 'nous', retryable: false })!
|
||||
expect(body).not.toBe(errorCardText(en.assistant.thread, plain).body)
|
||||
})
|
||||
|
||||
it('an older backend (no structured limit): Open billing, Retry and the model menu; no diagnostics', () => {
|
||||
const surface = parseErrorSurface({
|
||||
code: 'usage_window',
|
||||
layer: 'billing',
|
||||
resets_at: 1_800_000_000,
|
||||
retryable: true
|
||||
})!
|
||||
|
||||
const plan = errorRecoveryPlan(surface)
|
||||
expect(plan.openBilling && plan.retry && plan.switchProvider).toBe(true)
|
||||
expect(plan.switchToModel || plan.useCredits).toBe(false)
|
||||
expect(plan.diagnostics).toBe(false)
|
||||
expect(surface.resetsAt).toBe(1_800_000_000)
|
||||
// Every other billing failure keeps today's card.
|
||||
const plain = errorRecoveryPlan(parseErrorSurface({ code: 'billing', layer: 'billing', retryable: false }))
|
||||
expect(plain.openBilling).toBe(false)
|
||||
expect(plain.diagnostics).toBe(true)
|
||||
})
|
||||
|
||||
it('with a structured limit: one-click Switch to <model> and Use credits, no provider menu', () => {
|
||||
const surface = parseErrorSurface({
|
||||
code: 'usage_window',
|
||||
layer: 'billing',
|
||||
retryable: true,
|
||||
usage_limit: {
|
||||
credits: { action: 'use_credits' },
|
||||
suggestions: [
|
||||
{ label: 'Model A', model: 'example/model-a', note: 'included', provider: 'nous' },
|
||||
{ model: 'bad' },
|
||||
{ label: 'x' }
|
||||
]
|
||||
}
|
||||
})!
|
||||
|
||||
expect(surface.usageLimit).toEqual({
|
||||
credits: 'use_credits',
|
||||
suggestions: [
|
||||
{ label: 'Model A', model: 'example/model-a', note: 'included', provider: 'nous' },
|
||||
{ label: 'bad', model: 'bad', note: '', provider: 'nous' }
|
||||
]
|
||||
})
|
||||
const plan = errorRecoveryPlan(surface)
|
||||
expect(plan.switchToModel && plan.useCredits && plan.retry).toBe(true)
|
||||
expect(plan.openBilling || plan.switchProvider || plan.diagnostics).toBe(false)
|
||||
|
||||
// No balance: Add credits (Billing) instead of Use credits.
|
||||
const add = errorRecoveryPlan(
|
||||
parseErrorSurface({
|
||||
code: 'usage_window',
|
||||
layer: 'billing',
|
||||
retryable: true,
|
||||
usage_limit: { credits: { action: 'add_credits' }, suggestions: [] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(add.openBilling).toBe(true)
|
||||
expect(add.useCredits || add.switchToModel || add.switchProvider).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to its own table body when an older backend sent no sentence', () => {
|
||||
const bare = parseErrorSurface({ code: 'usage_window', layer: 'billing', retryable: false })!
|
||||
expect(errorCardText(en.assistant.thread, bare).body).toBe(en.assistant.thread.errorCodes.usage_window.body)
|
||||
})
|
||||
})
|
||||
|
||||
describe('limit reset (#98852)', () => {
|
||||
it('parses resets_at and renders "HH:mm (in Nh MMm)" while the reset is ahead', () => {
|
||||
const now = Date.UTC(2026, 0, 1, 12, 0, 0)
|
||||
@@ -215,3 +291,16 @@ describe('limit reset (#98852)', () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('out of credits (not a window plan)', () => {
|
||||
it('keeps the card unchanged for Nous and other providers alike', () => {
|
||||
for (const provider of ['nous', 'example']) {
|
||||
const plan = errorRecoveryPlan(
|
||||
parseErrorSurface({ code: 'billing', layer: 'billing', provider, retryable: false })!
|
||||
)
|
||||
|
||||
expect(plan.openBilling).toBe(false)
|
||||
expect(plan.switchProvider && plan.diagnostics).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -65,7 +65,10 @@ export const ERROR_CODE_KEYS = [
|
||||
'free_tier_model_not_free',
|
||||
'free_tier_route',
|
||||
'free_tier_outage',
|
||||
'free_tier_refused'
|
||||
'free_tier_refused',
|
||||
// A usage-window plan limit ran out and credits won't pay. The backend's
|
||||
// stop-line sentence (which limit, the reset, the way on) rides in `message`.
|
||||
'usage_window'
|
||||
] as const
|
||||
|
||||
export type ErrorCodeKey = (typeof ERROR_CODE_KEYS)[number]
|
||||
@@ -98,6 +101,52 @@ export interface ErrorSurface {
|
||||
* `resets_at` body field on a 429). Rendered as "Limit resets at HH:mm" next
|
||||
* to Retry. Absent when the provider named no reset or on older backends. */
|
||||
resetsAt?: number
|
||||
/** usage_window only: the plan's recommended models that still work past the
|
||||
* limit (best first) and the credits option — the card's one-click buttons
|
||||
* (agent/plan_limits.py). Absent on older backends. */
|
||||
usageLimit?: UsageLimitSurface
|
||||
}
|
||||
|
||||
export interface UsageLimitSuggestion {
|
||||
model: string
|
||||
label: string
|
||||
/** 'free' | 'included' */
|
||||
note: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface UsageLimitSurface {
|
||||
suggestions: UsageLimitSuggestion[]
|
||||
/** use_credits: Keep going is off and there is a balance. add_credits: top up. */
|
||||
credits?: 'add_credits' | 'use_credits'
|
||||
}
|
||||
|
||||
function parseUsageLimit(value: unknown): UsageLimitSurface | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const raw = value as { credits?: { action?: unknown } | null; suggestions?: unknown }
|
||||
|
||||
const suggestions = (Array.isArray(raw.suggestions) ? raw.suggestions : []).flatMap(item => {
|
||||
const s = item as Partial<Record<keyof UsageLimitSuggestion, unknown>>
|
||||
|
||||
return typeof s?.model === 'string' && s.model
|
||||
? [
|
||||
{
|
||||
model: s.model,
|
||||
label: typeof s.label === 'string' && s.label ? s.label : s.model,
|
||||
note: typeof s.note === 'string' ? s.note : '',
|
||||
provider: typeof s.provider === 'string' && s.provider ? s.provider : 'nous'
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
const action = raw.credits?.action
|
||||
const credits = action === 'use_credits' || action === 'add_credits' ? action : undefined
|
||||
|
||||
return suggestions.length || credits ? { suggestions, ...(credits ? { credits } : {}) } : undefined
|
||||
}
|
||||
|
||||
/** Validate a wire payload into an ErrorSurface, or null when absent/garbled. */
|
||||
@@ -117,6 +166,7 @@ export function parseErrorSurface(value: unknown): ErrorSurface | null {
|
||||
provider_label?: unknown
|
||||
resets_at?: unknown
|
||||
retryable?: unknown
|
||||
usage_limit?: unknown
|
||||
}
|
||||
|
||||
const layer = typeof raw.layer === 'string' ? (raw.layer as ErrorSurfaceLayer) : null
|
||||
@@ -125,6 +175,8 @@ export function parseErrorSurface(value: unknown): ErrorSurface | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const usageLimit = parseUsageLimit(raw.usage_limit)
|
||||
|
||||
return {
|
||||
layer,
|
||||
code: typeof raw.code === 'string' && raw.code ? raw.code : 'unknown',
|
||||
@@ -137,7 +189,8 @@ export function parseErrorSurface(value: unknown): ErrorSurface | null {
|
||||
...(typeof raw.message === 'string' && raw.message.trim() ? { message: raw.message.trim() } : {}),
|
||||
...(typeof raw.resets_at === 'number' && Number.isFinite(raw.resets_at) && raw.resets_at > 0
|
||||
? { resetsAt: raw.resets_at }
|
||||
: {})
|
||||
: {}),
|
||||
...(usageLimit ? { usageLimit } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +313,16 @@ export interface ErrorRecoveryPlan {
|
||||
* model.switch); Settings → Models deep link fallback when no chat surface
|
||||
* is on screen. */
|
||||
switchProvider: boolean
|
||||
/** Settings → Billing (usage_window: turn on Keep going, add credits). */
|
||||
openBilling: boolean
|
||||
/** usage_window: one-click "Switch to <recommended model>" (this session),
|
||||
* then re-run the turn. Shown only when the backend named a model. */
|
||||
switchToModel: boolean
|
||||
/** usage_window: "Use credits" — turns on Keep going, then re-runs the turn. */
|
||||
useCredits: boolean
|
||||
/** Open logs / Send diagnostics / Copy details. Off for a plan limit: it is
|
||||
* not a fault, and those buttons would bury the two that fix it. */
|
||||
diagnostics: boolean
|
||||
}
|
||||
|
||||
// Layers where the fix is provider/endpoint/auth config, not a retry.
|
||||
@@ -278,7 +341,12 @@ const CODE_PLANS: Partial<Record<ErrorCodeKey, Partial<ErrorRecoveryPlan>>> = {
|
||||
disk_full: { openHermesFolder: true, retry: true },
|
||||
loop_error: { startNewSession: true },
|
||||
model_not_found: { chooseModel: true, retry: false },
|
||||
payload_too_large: { compress: true, retry: false, startNewSession: true }
|
||||
payload_too_large: { compress: true, retry: false, startNewSession: true },
|
||||
// A window plan's limit: the two ways on, one click each — switch to the
|
||||
// plan's recommended model, or spend credits (Use credits when Keep going is
|
||||
// off and there is a balance, else Add credits via Billing). Retry at the
|
||||
// reset stays. Switch provider is not the fix (the provider is fine).
|
||||
usage_window: { diagnostics: false, openBilling: true, retry: true, switchProvider: false }
|
||||
}
|
||||
|
||||
export function errorRecoveryPlan(surface: ErrorSurface | null | undefined): ErrorRecoveryPlan {
|
||||
@@ -299,12 +367,27 @@ export function errorRecoveryPlan(surface: ErrorSurface | null | undefined): Err
|
||||
signInFreeTier: isFreeTierSurface(surface),
|
||||
startNewSession: false,
|
||||
switchProvider: surface != null && SWITCH_PROVIDER_LAYERS.includes(surface.layer),
|
||||
openBilling: false,
|
||||
switchToModel: false,
|
||||
useCredits: false,
|
||||
diagnostics: true,
|
||||
updateApiKey: apiKeyRejected
|
||||
}
|
||||
|
||||
const key = errorCardKey(surface)
|
||||
const plan = { ...base, ...('code' in key ? CODE_PLANS[key.code] : undefined) }
|
||||
|
||||
return { ...base, ...('code' in key ? CODE_PLANS[key.code] : undefined) }
|
||||
if (surface?.code === 'usage_window') {
|
||||
const limit = surface.usageLimit
|
||||
plan.switchToModel = Boolean(limit?.suggestions.length)
|
||||
plan.useCredits = limit?.credits === 'use_credits'
|
||||
// Use credits replaces Open billing; Add credits is Open billing.
|
||||
plan.openBilling = !plan.useCredits
|
||||
// An older backend (no structured limit): keep the menu so there is still a way on.
|
||||
plan.switchProvider = !limit
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
/** Plain-text error-details blob for the error card's "Copy error details". */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ModelOptionsResult } from '@hermes/shared'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { invalidateProfileScopedQueries, queryClient } from './query-client'
|
||||
import { invalidateProfileScopedQueries, keepWarmNousRow, queryClient } from './query-client'
|
||||
|
||||
function invalidated(key: unknown[]): boolean {
|
||||
return queryClient.getQueryState(key)?.isInvalidated ?? false
|
||||
@@ -56,3 +57,44 @@ describe('invalidateProfileScopedQueries', () => {
|
||||
expect(invalidated([{ scope: 'weird' }])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const warm: ModelOptionsResult = {
|
||||
providers: [
|
||||
{
|
||||
models: ['example/model-a', 'example/model-free:free'],
|
||||
name: 'Nous Portal',
|
||||
slug: 'nous',
|
||||
unavailable_models: [],
|
||||
usage_levels: { 'example/model-a': 'top_tier', 'example/model-free:free': 'free' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const pending: ModelOptionsResult = {
|
||||
providers: [
|
||||
{
|
||||
free_tier_pending: true,
|
||||
models: ['example/model-a', 'example/model-free:free'],
|
||||
name: 'Nous Portal',
|
||||
slug: 'nous',
|
||||
unavailable_models: ['example/model-a', 'example/model-free:free']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('keepWarmNousRow', () => {
|
||||
it('keeps the last warm Large / Free tags while a cache-only read is still pending', () => {
|
||||
const merged = keepWarmNousRow(warm, pending) as ModelOptionsResult
|
||||
const row = merged.providers?.[0]
|
||||
|
||||
expect(row?.usage_levels).toEqual(warm.providers?.[0].usage_levels)
|
||||
expect(row?.unavailable_models).toEqual([])
|
||||
// Still pending, so the menu keeps re-reading until the warm row lands.
|
||||
expect(row?.free_tier_pending).toBe(true)
|
||||
})
|
||||
|
||||
it('takes a warm reply as is, and leaves a first pending reply alone', () => {
|
||||
expect(keepWarmNousRow(pending, warm)).toEqual(warm)
|
||||
expect(keepWarmNousRow(undefined, pending)).toEqual(pending)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QueryClient, type QueryKey } from '@tanstack/react-query'
|
||||
import type { ModelOptionProvider, ModelOptionsResult } from '@hermes/shared'
|
||||
import { QueryClient, type QueryKey, replaceEqualDeep } from '@tanstack/react-query'
|
||||
|
||||
// Shared React Query client. Lives in its own module (not main.tsx) so non-React
|
||||
// code — e.g. the profile store on a gateway swap — can invalidate cached,
|
||||
@@ -12,6 +13,45 @@ export const queryClient = new QueryClient({
|
||||
}
|
||||
})
|
||||
|
||||
// A cache-only `model.options` read can answer for Nous before the backend has re-warmed its catalog /
|
||||
// entitlement caches (`pricing_pending` / `free_tier_pending`): no Large / Free tags and, while the
|
||||
// entitlement is unknown, every model locked. The model menu re-reads every 2s until a warm row lands,
|
||||
// so a warm reply followed by a pending one made the tags blink off and on. Keep what the last warm row
|
||||
// said until a warm one replaces it. Window plans only: only their rows carry `usage_levels`.
|
||||
const WARM_NOUS_FIELDS = ['usage_levels', 'pricing', 'unavailable_models', 'free_tier', 'warning'] as const
|
||||
|
||||
const isPendingNous = (row: ModelOptionProvider) =>
|
||||
row.slug === 'nous' && Boolean(row.pricing_pending || row.free_tier_pending)
|
||||
|
||||
export function keepWarmNousRow(oldData: unknown, newData: unknown): unknown {
|
||||
const prev = oldData as ModelOptionsResult | undefined
|
||||
const next = newData as ModelOptionsResult | undefined
|
||||
const warm = prev?.providers?.find(row => row.slug === 'nous' && row.usage_levels && !isPendingNous(row))
|
||||
|
||||
if (!warm || !next?.providers?.some(isPendingNous)) {
|
||||
return replaceEqualDeep(oldData, newData)
|
||||
}
|
||||
|
||||
const providers = next.providers.map(row => {
|
||||
if (!isPendingNous(row)) {
|
||||
return row
|
||||
}
|
||||
|
||||
// The pending flags stay, so the menu's bounded re-read still picks up the warm row.
|
||||
const kept: Record<string, unknown> = {}
|
||||
|
||||
for (const field of WARM_NOUS_FIELDS) {
|
||||
kept[field] = warm[field]
|
||||
}
|
||||
|
||||
return { ...row, ...kept } as ModelOptionProvider
|
||||
})
|
||||
|
||||
return replaceEqualDeep(oldData, { ...next, providers })
|
||||
}
|
||||
|
||||
queryClient.setQueryDefaults(['model-options'], { structuralSharing: keepWarmNousRow })
|
||||
|
||||
// Curried, setState-shaped cache writer for optimistic write-through: keeps
|
||||
// mutation sites terse (`setX(next)` or `setX(prev => …)`) over one query key.
|
||||
export const writeCache =
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatPercentUsed } from './usage-percent'
|
||||
|
||||
describe('formatPercentUsed', () => {
|
||||
it('floors, shows <1% for any sliver, and 100% only when exhausted', () => {
|
||||
expect(formatPercentUsed(0)).toBe('0%')
|
||||
expect(formatPercentUsed(0.0047)).toBe('<1%')
|
||||
expect(formatPercentUsed(1 - 0.99)).toBe('1%')
|
||||
expect(formatPercentUsed(0.996)).toBe('99%')
|
||||
expect(formatPercentUsed(0.9999)).toBe('99%')
|
||||
expect(formatPercentUsed(1)).toBe('100%')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Whole-percent used for a usage-window allowance. Same rule as the portal
|
||||
* (`formatUsedPercentLabel`) and the CLI (`format_percent_used`): floor, any
|
||||
* sliver reads `<1%` (never `0%`), and `100%` only once it is actually exhausted.
|
||||
*/
|
||||
export function formatPercentUsed(used: number): string {
|
||||
// Round to a micro-basis-point first: `1 - left` can land a hair under an exact bp.
|
||||
const bp = Math.floor(Math.round(Math.min(1, Math.max(0, used)) * 10_000 * 1e6) / 1e6)
|
||||
|
||||
if (bp <= 0) {
|
||||
return '0%'
|
||||
}
|
||||
|
||||
if (bp < 100) {
|
||||
return '<1%'
|
||||
}
|
||||
|
||||
return `${Math.floor(bp / 100)}%`
|
||||
}
|
||||
|
||||
/** `5h` / `2d 3h` / `12m` until a window allowance resets; null when unknown or under a minute away
|
||||
* (the caller words it). */
|
||||
export function formatResetsIn(resetsAtMs?: null | number, nowMs = Date.now()): null | string {
|
||||
if (typeof resetsAtMs !== 'number' || !Number.isFinite(resetsAtMs)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const seconds = Math.max(0, Math.floor((resetsAtMs - nowMs) / 1000))
|
||||
|
||||
if (seconds < 60) {
|
||||
return null
|
||||
}
|
||||
|
||||
const days = Math.floor(seconds / 86_400)
|
||||
const hours = Math.floor((seconds % 86_400) / 3_600)
|
||||
|
||||
if (days > 0) {
|
||||
return hours > 0 ? `${days}d ${hours}h` : `${days}d`
|
||||
}
|
||||
|
||||
const minutes = Math.floor((seconds % 3_600) / 60)
|
||||
|
||||
return hours > 0 ? `${hours}h` : `${minutes}m`
|
||||
}
|
||||
@@ -190,3 +190,39 @@ test('the urgent pair maps to a global native input carrying the text as its bod
|
||||
expect(restored?.kind).toBe('credits')
|
||||
expect(restored?.body).toBe('✓ Credit access restored')
|
||||
})
|
||||
|
||||
// ── a window plan's limit line: the error card says it, not a toast ──────────
|
||||
|
||||
test("a window plan's limit line is no toast, but still an OS notification", () => {
|
||||
const limit: AgentNoticePayload = {
|
||||
key: 'credits.window_limit',
|
||||
kind: 'sticky',
|
||||
level: 'error',
|
||||
text: "✕ You've hit your daily limit · resets 15:56 · /topup to add credits"
|
||||
}
|
||||
|
||||
expect(noticeToToast(limit)).toBeNull()
|
||||
showAgentNotice(limit)
|
||||
expect($notifications.get()).toHaveLength(0)
|
||||
expect(nativeNoticeInput(limit, 'Hermes credits')?.body).toBe(limit.text)
|
||||
})
|
||||
|
||||
// ── a window plan's 90% heads-up (credits.window.<id>) ───────────────────────
|
||||
|
||||
test('the 90% heads-up is an orange ttl toast, replaced in place per allowance', () => {
|
||||
const headsUp: AgentNoticePayload = {
|
||||
key: 'credits.window.week',
|
||||
kind: 'ttl',
|
||||
level: 'warn',
|
||||
text: '⚠ 91% of your weekly limit used · resets Thu 09:00',
|
||||
ttl_ms: 10_000
|
||||
}
|
||||
|
||||
const toast = noticeToToast(headsUp)
|
||||
|
||||
expect(toast?.accentColor).toBe('var(--ui-orange)')
|
||||
expect(toast?.message).toBe('91% of your weekly limit used')
|
||||
expect(toast?.id).toBe('credits.window.week')
|
||||
// A heads-up, not an alarm: no OS notification.
|
||||
expect(nativeNoticeInput(headsUp, 'Hermes credits')).toBeNull()
|
||||
})
|
||||
@@ -28,6 +28,14 @@ export interface AgentNoticePayload {
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/** `credits.window_limit` (agent/credits_tracker.py CREDITS_WINDOW_LIMIT_KEY). */
|
||||
const WINDOW_LIMIT_KEY = 'credits.window_limit'
|
||||
/** `model.large_notice` (tui_gateway/model_switch.py LARGE_MODEL_NOTICE_KEY): switched to a Large model. */
|
||||
const LARGE_MODEL_NOTICE_KEY = 'model.large_notice'
|
||||
|
||||
/** `credits.window.<gauge id>`: a window plan's 90% heads-up (CREDITS_WINDOW_APPROACH_PREFIX). */
|
||||
const WINDOW_APPROACH_PREFIX = 'credits.window.'
|
||||
|
||||
const LEVEL_TO_TOAST_KIND: Record<string, NotificationKind> = {
|
||||
error: 'error',
|
||||
info: 'info',
|
||||
@@ -82,6 +90,12 @@ export function noticeAccent(payload: AgentNoticePayload | undefined): string |
|
||||
return 'var(--ui-green)'
|
||||
}
|
||||
|
||||
// A window plan's 90% heads-up (`credits.window.<id>`, agent/credits_tracker.py): orange, like the
|
||||
// credits gauge past 75%. Same for the Large-model warning.
|
||||
if (payload?.key?.startsWith(WINDOW_APPROACH_PREFIX) || payload?.key === LARGE_MODEL_NOTICE_KEY) {
|
||||
return 'var(--ui-orange)'
|
||||
}
|
||||
|
||||
const frac = usageFraction(payload?.text)
|
||||
|
||||
if (frac === null) {
|
||||
@@ -113,7 +127,9 @@ export function noticeAccent(payload: AgentNoticePayload | undefined): string |
|
||||
export function noticeToToast(payload: AgentNoticePayload | undefined): NotificationInput | null {
|
||||
const text = payload?.text?.trim()
|
||||
|
||||
if (!text) {
|
||||
// A window plan's limit line is said by the refused turn's error card (same
|
||||
// sentence, with Open billing / Retry at reset); a toast would say it twice.
|
||||
if (!text || payload?.key === WINDOW_LIMIT_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -157,6 +173,11 @@ export function splitMeta(text: string): [primary: string, meta: string | undefi
|
||||
return [text.slice(0, at), text.slice(at + 3) || undefined]
|
||||
}
|
||||
|
||||
/** Settings → Model's default-model apply returns the Large-model warning on its response (no session event). */
|
||||
export function showLargeModelNotice(text: string): void {
|
||||
showAgentNotice({ key: LARGE_MODEL_NOTICE_KEY, kind: 'ttl', level: 'warn', text, ttl_ms: 8000 })
|
||||
}
|
||||
|
||||
/** Render a `notification.show` notice as a toast (no-op when it has no text). */
|
||||
export function showAgentNotice(payload: AgentNoticePayload | undefined): void {
|
||||
const toast = noticeToToast(payload)
|
||||
@@ -176,11 +197,12 @@ export function clearAgentNotice(key: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Only these two credit notices are urgent enough to break through as a native
|
||||
// OS notification (when Hermes is backgrounded). The escalating usage line
|
||||
// Only these credit notices are urgent enough to break through as a native OS
|
||||
// notification (when Hermes is backgrounded): access paused, a window plan's
|
||||
// limit reached, and access restored. The escalating usage line
|
||||
// (`credits.usage`) and the grant-spent notice stay in-app toasts only — they
|
||||
// aren't worth interrupting the user's OS for.
|
||||
const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', 'credits.restored'])
|
||||
const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', WINDOW_LIMIT_KEY, 'credits.restored'])
|
||||
|
||||
/**
|
||||
* Map a notice to a native OS notification input, or `null` when it isn't one of
|
||||
|
||||
@@ -47,6 +47,17 @@ describe('custom models', () => {
|
||||
expect(merged[1]).toBe(providers[1])
|
||||
expect(withCustomModels(providers, [])).toBe(providers)
|
||||
})
|
||||
|
||||
it('merges custom models into a usage-window Nous row too (window plans allow any model)', () => {
|
||||
const nous: ModelOptionProvider = {
|
||||
...provider('nous', ['example/model-b']),
|
||||
plan_usage: { kind: 'usage_window', gauges: [] }
|
||||
}
|
||||
|
||||
const merged = withCustomModels([nous], [{ model: 'acme/model-x', provider: 'nous' }])
|
||||
|
||||
expect(merged[0]?.models).toEqual(['example/model-b', 'acme/model-x'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetModelVisibilityKeepingCustoms', () => {
|
||||
|
||||
@@ -886,6 +886,8 @@ export interface UsageStats {
|
||||
cost_usd?: number
|
||||
input: number
|
||||
output: number
|
||||
/** Window plans only (tui_gateway `_get_usage`): today's allowance (else the binding one), for the status bar. */
|
||||
plan_gauge?: { id: string; label: string; resets_at_ms?: null | number; used_bp: number }
|
||||
total: number
|
||||
}
|
||||
|
||||
@@ -1886,6 +1888,8 @@ export interface ModelAssignmentResponse {
|
||||
confirm_message?: string
|
||||
confirm_required?: boolean
|
||||
model?: string
|
||||
/** Window plans, Large model: a warning to show (never a confirm). */
|
||||
notice?: string
|
||||
ok: boolean
|
||||
provider?: string
|
||||
reset?: boolean
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"./i18n": "./src/i18n.ts",
|
||||
"./json-rpc-channel": "./src/json-rpc-channel.ts",
|
||||
"./model-search-text": "./src/model-search-text.ts",
|
||||
"./plan-usage": "./src/plan-usage.ts",
|
||||
"./reasoning-effort": "./src/reasoning-effort.ts",
|
||||
"./reconnect-backoff": "./src/reconnect-backoff.ts",
|
||||
"./skin": "./src/skin.ts",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
* `/topup`), while third-party providers deep-link to `billing_url`.
|
||||
*/
|
||||
export type { BillingBlock } from './gateway-contract.generated.js'
|
||||
import type { PlanUsage } from './gateway-contract.generated.js'
|
||||
|
||||
// ── Remote Spending (Phase 2b) ───────────────────────────────────────
|
||||
|
||||
@@ -200,6 +201,9 @@ export interface BillingStateResponse {
|
||||
charge_presets_display: string[]
|
||||
cli_billing_enabled: boolean
|
||||
error?: string | null
|
||||
// Window plans: "Keep going on credits".
|
||||
// Absent/null = no control (not a billing admin, other plan, older gateway).
|
||||
keep_going_on_credits?: boolean | null
|
||||
is_admin: boolean
|
||||
logged_in: boolean
|
||||
max_usd: string | null
|
||||
@@ -212,6 +216,10 @@ export interface BillingStateResponse {
|
||||
// Shared dollar usage model (two-bar view), embedded by the gateway so /topup
|
||||
// renders the same bars as /usage and /subscription from this single fetch.
|
||||
usage?: UsageModelData
|
||||
// Usage-window gauges (today / week / month / large models). For window plans
|
||||
// `usage` is unavailable, so this is what the bars draw from.
|
||||
// Absent/null on credit-grant, free and logged-out accounts and older gateways.
|
||||
plan_usage?: PlanUsage | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,6 +315,7 @@ export interface SubscriptionStateResponse {
|
||||
// Shared dollar usage model (two-bar view), embedded by the gateway so the
|
||||
// overlay renders the same bars as /usage from this single fetch.
|
||||
usage?: UsageModelData
|
||||
plan_usage?: PlanUsage | null
|
||||
}
|
||||
|
||||
// A chargeless quote (POST /subscription/preview) of what a change would do.
|
||||
|
||||
@@ -55,9 +55,11 @@ export interface BillingStateResult {
|
||||
payment_method?: BillingPaymentMethod | null
|
||||
monthly_cap?: BillingMonthlyCap | null
|
||||
auto_reload?: BillingAutoReload | null
|
||||
keep_going_on_credits?: boolean | null
|
||||
portal_url?: string | null
|
||||
error?: string | null
|
||||
usage?: UsageModel | null
|
||||
plan_usage?: PlanUsage | null
|
||||
}
|
||||
export interface BillingCardInfo {
|
||||
brand: string
|
||||
@@ -100,6 +102,25 @@ export interface BillingAutoReloadCard {
|
||||
last4?: string | null
|
||||
}
|
||||
export type AutoReloadCardKind = 'canonical' | 'distinct' | 'none'
|
||||
/** ``hermes_cli/model_picker_usage.py::PickerUsage.to_payload`` — usage-window plan chrome. */
|
||||
export interface PlanUsage {
|
||||
kind: string
|
||||
plan?: string | null
|
||||
gauges?: PlanUsageGauge[]
|
||||
top_tier_model_ids?: string[]
|
||||
featured_model_ids?: string[]
|
||||
paused_reason?: string | null
|
||||
credits_pay?: boolean | null
|
||||
}
|
||||
/** One server allowance on ``ModelOptionProvider.plan_usage``, in the server's order. ``label`` is the server's id (``day`` / ``week`` / ``month`` / ``top_tier`` have client words; any other id shows ``title``). ``scope``: which models a full one stops (``all`` / ``top_tier``). */
|
||||
export interface PlanUsageGauge {
|
||||
label: string
|
||||
left_fraction: number
|
||||
style: string
|
||||
resets_at_ms?: number | null
|
||||
scope?: string
|
||||
title?: string
|
||||
}
|
||||
/** ``_serialize_subscription_state``; the view's fallback emits only ``ok / logged_in / error``. */
|
||||
export interface SubscriptionStateResult {
|
||||
ok: boolean
|
||||
@@ -115,6 +136,7 @@ export interface SubscriptionStateResult {
|
||||
portal_url?: string | null
|
||||
error?: string | null
|
||||
usage?: UsageModel | null
|
||||
plan_usage?: PlanUsage | null
|
||||
}
|
||||
export type SubscriptionContext = 'personal' | 'team'
|
||||
export interface CurrentSubscription {
|
||||
@@ -262,6 +284,10 @@ export interface BillingMutationResult {
|
||||
code?: string | null
|
||||
recovery?: string | null
|
||||
}
|
||||
export interface BillingKeepGoingOnCreditsParams {
|
||||
profile?: string | null
|
||||
enabled?: boolean
|
||||
}
|
||||
export interface BillingStepUpParams {
|
||||
profile?: string | null
|
||||
session_id?: string | null
|
||||
@@ -806,6 +832,8 @@ export interface ModelOptionProvider {
|
||||
free_tier_pending?: boolean | null
|
||||
free_tier_row?: boolean | null
|
||||
unavailable_models?: string[] | null
|
||||
plan_usage?: PlanUsage | null
|
||||
usage_levels?: Record<string, string> | null
|
||||
limit?: ProviderLimit | null
|
||||
usage?: ProviderUsage | null
|
||||
[key: string]: unknown
|
||||
@@ -3474,6 +3502,7 @@ export interface SessionUsageResult {
|
||||
cost_usd?: number | null
|
||||
cost_status?: string | null
|
||||
credits_lines?: string[] | null
|
||||
credits_usage_window?: boolean | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
export interface SessionContextBreakdownParams {
|
||||
@@ -5620,7 +5649,7 @@ export interface BillingBlock {
|
||||
message: string
|
||||
unverified?: boolean | null
|
||||
}
|
||||
/** ``agent/error_surface.py::_surface`` — advisory {layer, code, retryable} (+ identity, + auth hint, + ``resets_at`` epoch seconds when the provider named when its limit lifts). */
|
||||
/** ``agent/error_surface.py::_surface`` — advisory {layer, code, retryable} (+ identity, + auth hint, + ``resets_at`` epoch seconds when the provider named when its limit lifts, + ``usage_limit`` on a usage-window plan's limit). */
|
||||
export interface ErrorSurface {
|
||||
layer: string
|
||||
code: string
|
||||
@@ -5628,8 +5657,31 @@ export interface ErrorSurface {
|
||||
provider?: string | null
|
||||
model?: string | null
|
||||
resets_at?: number | null
|
||||
usage_limit?: UsageLimit | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
/** ``agent/plan_limits.py::LimitHit`` — a usage-window plan's limit: what ran out, when it resets, and the one-click ways on (switch to a recommended model, use / add credits). */
|
||||
export interface UsageLimit {
|
||||
gauge_id: string
|
||||
scope: string
|
||||
limit_name: string
|
||||
resets_at_ms?: number | null
|
||||
message: string
|
||||
suggestions?: UsageLimitSuggestion[]
|
||||
credits?: UsageLimitCredits | null
|
||||
current_model?: string
|
||||
}
|
||||
/** ``agent/plan_limits.py::ModelSuggestion`` — a recommended model that still works past the limit. */
|
||||
export interface UsageLimitSuggestion {
|
||||
model: string
|
||||
label: string
|
||||
note: string
|
||||
provider?: string
|
||||
}
|
||||
/** ``agent/plan_limits.py::CreditsOption`` — ``use_credits`` (turn on Keep going) or ``add_credits``. */
|
||||
export interface UsageLimitCredits {
|
||||
action: string
|
||||
}
|
||||
/** Committed SQLite row addresses for the agent's current-turn suffix. Missing ids are unproven, never negative acknowledgements. ``complete`` permits retiring the whole local turn only when the original turn boundary, every row and final body are still accounted for; compaction, redirects and partial writes conservatively leave it false. Row ids are scoped to the owning profile's store, as in ``SessionMessage.row_id``. */
|
||||
export interface PersistedTurn {
|
||||
row_ids: number[]
|
||||
@@ -5959,6 +6011,8 @@ export interface RpcMethods {
|
||||
'billing.charge': { params: BillingChargeParams; result: BillingChargeResult }
|
||||
/** Poll one charge by id. */
|
||||
'billing.charge_status': { params: BillingChargeStatusParams; result: BillingChargeStatusResult }
|
||||
/** Window plans: turn 'Keep going on credits' on/off (billing:manage). */
|
||||
'billing.keep_going_on_credits': { params: BillingKeepGoingOnCreditsParams; result: BillingMutationResult }
|
||||
/** Read-only billing view (no scope); the Nous free tier is answered locally without a portal call. */
|
||||
'billing.state': { params: ProfileParams; result: BillingStateResult }
|
||||
/** Run the billing:manage device flow; the URL/code arrive via billing.step_up.verification. */
|
||||
@@ -6469,6 +6523,7 @@ export const RPC_METHODS = [
|
||||
'billing.auto_reload',
|
||||
'billing.charge',
|
||||
'billing.charge_status',
|
||||
'billing.keep_going_on_credits',
|
||||
'billing.state',
|
||||
'billing.step_up',
|
||||
'bot_relay.deliver',
|
||||
|
||||
@@ -132,6 +132,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "billing.keep_going_on_credits",
|
||||
"summary": "Window plans: turn 'Keep going on credits' on/off (billing:manage).",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BillingKeepGoingOnCreditsParams"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BillingMutationResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "billing.state",
|
||||
"summary": "Read-only billing view (no scope); the Nous free tier is answered locally without a portal call.",
|
||||
@@ -6384,6 +6402,30 @@
|
||||
"title": "BillingChargeStatusResult",
|
||||
"type": "object"
|
||||
},
|
||||
"BillingKeepGoingOnCreditsParams": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profile": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Profile"
|
||||
},
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "BillingKeepGoingOnCreditsParams",
|
||||
"type": "object"
|
||||
},
|
||||
"BillingMonthlyCap": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -6983,6 +7025,18 @@
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"keep_going_on_credits": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Keep Going On Credits"
|
||||
},
|
||||
"portal_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -7017,6 +7071,17 @@
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"plan_usage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PlanUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18055,7 +18120,7 @@
|
||||
},
|
||||
"ErrorSurface": {
|
||||
"additionalProperties": true,
|
||||
"description": "``agent/error_surface.py::_surface`` \u2014 advisory {layer, code, retryable} (+ identity, + auth hint,\n+ ``resets_at`` epoch seconds when the provider named when its limit lifts).",
|
||||
"description": "``agent/error_surface.py::_surface`` \u2014 advisory {layer, code, retryable} (+ identity, + auth hint,\n+ ``resets_at`` epoch seconds when the provider named when its limit lifts, + ``usage_limit`` on a\nusage-window plan's limit).",
|
||||
"properties": {
|
||||
"layer": {
|
||||
"title": "Layer",
|
||||
@@ -18104,6 +18169,17 @@
|
||||
],
|
||||
"default": null,
|
||||
"title": "Resets At"
|
||||
},
|
||||
"usage_limit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageLimit"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -24339,6 +24415,32 @@
|
||||
"default": null,
|
||||
"title": "Unavailable Models"
|
||||
},
|
||||
"plan_usage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PlanUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"usage_levels": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Usage Levels"
|
||||
},
|
||||
"limit": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -27057,6 +27159,125 @@
|
||||
"title": "PingResult",
|
||||
"type": "object"
|
||||
},
|
||||
"PlanUsage": {
|
||||
"additionalProperties": false,
|
||||
"description": "``hermes_cli/model_picker_usage.py::PickerUsage.to_payload`` \u2014 usage-window plan chrome.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"title": "Kind",
|
||||
"type": "string"
|
||||
},
|
||||
"plan": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Plan"
|
||||
},
|
||||
"gauges": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlanUsageGauge"
|
||||
},
|
||||
"title": "Gauges",
|
||||
"type": "array"
|
||||
},
|
||||
"top_tier_model_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Top Tier Model Ids",
|
||||
"type": "array"
|
||||
},
|
||||
"featured_model_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Featured Model Ids",
|
||||
"type": "array"
|
||||
},
|
||||
"paused_reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Paused Reason"
|
||||
},
|
||||
"credits_pay": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Credits Pay"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"title": "PlanUsage",
|
||||
"type": "object"
|
||||
},
|
||||
"PlanUsageGauge": {
|
||||
"additionalProperties": false,
|
||||
"description": "One server allowance on ``ModelOptionProvider.plan_usage``, in the server's order. ``label`` is the\nserver's id (``day`` / ``week`` / ``month`` / ``top_tier`` have client words; any other id shows\n``title``). ``scope``: which models a full one stops (``all`` / ``top_tier``).",
|
||||
"properties": {
|
||||
"label": {
|
||||
"title": "Label",
|
||||
"type": "string"
|
||||
},
|
||||
"left_fraction": {
|
||||
"title": "Left Fraction",
|
||||
"type": "number"
|
||||
},
|
||||
"style": {
|
||||
"title": "Style",
|
||||
"type": "string"
|
||||
},
|
||||
"resets_at_ms": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Resets At Ms"
|
||||
},
|
||||
"scope": {
|
||||
"default": "all",
|
||||
"title": "Scope",
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"default": "",
|
||||
"title": "Title",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"left_fraction",
|
||||
"style"
|
||||
],
|
||||
"title": "PlanUsageGauge",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginActivation": {
|
||||
"additionalProperties": false,
|
||||
"description": "What a plugin loaded mid-run does NOW vs later (``hermes_cli.plugins_activation``). ``activated_now``\nkinds (``{kind: [names]}``): ``gateway_commands`` (slash names), ``locales`` (``<lang>.<surface>``\nlanguage-pack layers), ``gateway_transforms`` / ``hooks`` (hook\nnames), ``callbacks`` (platforms / ``slack:<action_id>``) \u2014 live in the running gateway once it reloaded\n(``gateway_reloaded``). ``live_now``: the plugin's MCP servers (connected, with their tools, or the\nerror) and skills, usable in every open chat of the profile from its next turn \u2014 the chats also get a\nnote listing them. ``deferred`` kinds: ``tools`` (Python tool names) and ``prompt`` (section ids)\napply from the next session.",
|
||||
@@ -39079,6 +39300,18 @@
|
||||
],
|
||||
"default": null,
|
||||
"title": "Credits Lines"
|
||||
},
|
||||
"credits_usage_window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Credits Usage Window"
|
||||
}
|
||||
},
|
||||
"title": "SessionUsageResult",
|
||||
@@ -42690,6 +42923,17 @@
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"plan_usage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PlanUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -44682,6 +44926,116 @@
|
||||
"title": "UsageBarKind",
|
||||
"type": "string"
|
||||
},
|
||||
"UsageLimit": {
|
||||
"additionalProperties": false,
|
||||
"description": "``agent/plan_limits.py::LimitHit`` \u2014 a usage-window plan's limit: what ran out, when it resets, and\nthe one-click ways on (switch to a recommended model, use / add credits).",
|
||||
"properties": {
|
||||
"gauge_id": {
|
||||
"title": "Gauge Id",
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"title": "Scope",
|
||||
"type": "string"
|
||||
},
|
||||
"limit_name": {
|
||||
"title": "Limit Name",
|
||||
"type": "string"
|
||||
},
|
||||
"resets_at_ms": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Resets At Ms"
|
||||
},
|
||||
"message": {
|
||||
"title": "Message",
|
||||
"type": "string"
|
||||
},
|
||||
"suggestions": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UsageLimitSuggestion"
|
||||
},
|
||||
"title": "Suggestions",
|
||||
"type": "array"
|
||||
},
|
||||
"credits": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageLimitCredits"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"current_model": {
|
||||
"default": "",
|
||||
"title": "Current Model",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"gauge_id",
|
||||
"scope",
|
||||
"limit_name",
|
||||
"message"
|
||||
],
|
||||
"title": "UsageLimit",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageLimitCredits": {
|
||||
"additionalProperties": false,
|
||||
"description": "``agent/plan_limits.py::CreditsOption`` \u2014 ``use_credits`` (turn on Keep going) or ``add_credits``.",
|
||||
"properties": {
|
||||
"action": {
|
||||
"title": "Action",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
],
|
||||
"title": "UsageLimitCredits",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageLimitSuggestion": {
|
||||
"additionalProperties": false,
|
||||
"description": "``agent/plan_limits.py::ModelSuggestion`` \u2014 a recommended model that still works past the limit.",
|
||||
"properties": {
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"title": "Label",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"title": "Note",
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"default": "nous",
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model",
|
||||
"label",
|
||||
"note"
|
||||
],
|
||||
"title": "UsageLimitSuggestion",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageModel": {
|
||||
"additionalProperties": false,
|
||||
"description": "``_serialize_usage_model`` \u2014 also embedded as ``usage`` in the billing / subscription states,\nwhere the fail-open form is a bare ``{available: false}`` (no ``ok``).",
|
||||
|
||||
@@ -99,6 +99,18 @@ export {
|
||||
type WebSocketLike
|
||||
} from './json-rpc-gateway'
|
||||
export { modelSearchText } from './model-search-text'
|
||||
export {
|
||||
gaugeScope,
|
||||
gaugeTitle,
|
||||
hasTopTier,
|
||||
planBlocks,
|
||||
planLimit,
|
||||
sectionAt,
|
||||
splitRecommended,
|
||||
type UsageLevel,
|
||||
usageLevel,
|
||||
usageTag
|
||||
} from './plan-usage'
|
||||
export {
|
||||
DEFAULT_REASONING_EFFORT,
|
||||
isReasoningEffort,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Usage-window plan chrome shared by every model picker (desktop dialog + composer menu, TUI).
|
||||
*
|
||||
* The gateway sends each model's word per caller (`usage_level` on `/v1/models`); the backend copies
|
||||
* it into `ModelOptionProvider.usage_levels`. Non-window providers have no `usage_levels` / `plan_usage`, so every helper
|
||||
* returns the empty answer and their pickers are unchanged.
|
||||
*
|
||||
* Words are each client's own translated copy (desktop `t.modelPicker`, TUI `pickers.model.plan`).
|
||||
* Python twin: hermes_cli/model_picker_usage.py.
|
||||
*/
|
||||
import type { ModelOptionProvider, PlanUsageGauge, UsageLimit } from './gateway-contract.generated.js'
|
||||
|
||||
/** A usage-window plan's limit on a refused turn (error_surface.usage_limit, agent/plan_limits.py). */
|
||||
export type { UsageLimit }
|
||||
|
||||
export type UsageLevel = 'free' | 'low' | 'medium' | 'top_tier'
|
||||
|
||||
const USAGE_LEVELS = new Set<string>(['free', 'low', 'medium', 'top_tier'])
|
||||
|
||||
type PlanProvider = Partial<Pick<ModelOptionProvider, 'plan_usage' | 'usage_levels'>> | null | undefined
|
||||
|
||||
export function usageLevel(provider: PlanProvider, model: string): UsageLevel | null {
|
||||
// Window plans only: any other account keeps its main-branch row even if the field were sent.
|
||||
if (provider?.plan_usage?.kind !== 'usage_window') {
|
||||
return null
|
||||
}
|
||||
|
||||
const level = provider.usage_levels?.[model]
|
||||
|
||||
return level && USAGE_LEVELS.has(level) ? (level as UsageLevel) : null
|
||||
}
|
||||
|
||||
/** The tag a picker row shows: only Large (it has its own monthly limit) and Free (never runs out). Small /
|
||||
* Medium change only how fast usage drains, so they carry no tag. */
|
||||
export function usageTag(provider: PlanProvider, model: string): 'free' | 'top_tier' | null {
|
||||
const level = usageLevel(provider, model)
|
||||
|
||||
return level === 'free' || level === 'top_tier' ? level : null
|
||||
}
|
||||
|
||||
/** True when any listed model is Large (the footnote shows only then). */
|
||||
export function hasTopTier(provider: PlanProvider, models: readonly string[]): boolean {
|
||||
return models.some(model => usageLevel(provider, model) === 'top_tier')
|
||||
}
|
||||
|
||||
/**
|
||||
* Window plans: `{ recommended, other }` — the plan's featured picks that are listed (plan order),
|
||||
* then every other listed model in its given order. `null` for every other account, and when the
|
||||
* plan has no featured pick in the list (nothing to split).
|
||||
*/
|
||||
export function splitRecommended(
|
||||
provider: PlanProvider,
|
||||
models: readonly string[]
|
||||
): { other: string[]; recommended: string[] } | null {
|
||||
if (provider?.plan_usage?.kind !== 'usage_window') {
|
||||
return null
|
||||
}
|
||||
|
||||
const listed = new Set(models)
|
||||
const recommended = [...new Set(provider.plan_usage.featured_model_ids ?? [])].filter(model => listed.has(model))
|
||||
|
||||
if (recommended.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const head = new Set(recommended)
|
||||
|
||||
return { other: models.filter(model => !head.has(model)), recommended }
|
||||
}
|
||||
|
||||
/** The section title a window-plan picker shows above row *idx* of *models* (no query): `recommended`
|
||||
* above the first featured pick, `other` above the first row after them, else null. */
|
||||
export function sectionAt(
|
||||
provider: PlanProvider,
|
||||
models: readonly string[],
|
||||
idx: number
|
||||
): 'other' | 'recommended' | null {
|
||||
const split = splitRecommended(provider, models)
|
||||
|
||||
if (!split) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
return 'recommended'
|
||||
}
|
||||
|
||||
return idx === split.recommended.length && split.other.length > 0 ? 'other' : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Server allowance ids with client words (translated per surface). The server sends the list and its
|
||||
* order; any other id renders with the gauge's own `title`, so a new allowance needs no client release.
|
||||
*/
|
||||
export type KnownGaugeId = 'day' | 'month' | 'top_tier' | 'week'
|
||||
|
||||
const KNOWN_GAUGE_IDS = new Set<string>(['day', 'month', 'top_tier', 'week'])
|
||||
|
||||
export function knownGaugeId(gauge: Pick<PlanUsageGauge, 'label'>): KnownGaugeId | null {
|
||||
return KNOWN_GAUGE_IDS.has(gauge.label) ? (gauge.label as KnownGaugeId) : null
|
||||
}
|
||||
|
||||
/** Which models a full gauge stops: `all` = every paid model, `top_tier` = top-tier models only. */
|
||||
export function gaugeScope(gauge: Pick<PlanUsageGauge, 'scope'>): 'all' | 'top_tier' {
|
||||
return gauge.scope === 'top_tier' ? 'top_tier' : 'all'
|
||||
}
|
||||
|
||||
/** The display name: the client word for a known id, else the server's title (else the id). */
|
||||
export function gaugeTitle(
|
||||
gauge: Pick<PlanUsageGauge, 'label' | 'title'>,
|
||||
words: Record<KnownGaugeId, string>
|
||||
): string {
|
||||
const known = knownGaugeId(gauge)
|
||||
|
||||
return known ? words[known] : gauge.title || gauge.label
|
||||
}
|
||||
|
||||
/**
|
||||
* The full allowance that stops *model* on a window plan, or undefined while it still has included
|
||||
* room. Same rule as agent/plan_limits.py `blocking_gauge`: a free
|
||||
* model (gateway word `free`) is never blocked; a full
|
||||
* `all` gauge blocks every paid model; a full `top_tier` gauge blocks the gateway's top-tier models. Of
|
||||
* several full gauges, the one that frees up last.
|
||||
*/
|
||||
export function blockingGauge(provider: PlanProvider | null | undefined, model: string): PlanUsageGauge | undefined {
|
||||
const usage = provider?.plan_usage
|
||||
|
||||
if (usage?.kind !== 'usage_window') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (usageLevel(provider, model) === 'free' || model.endsWith(':free')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const topTier = usageLevel(provider, model) === 'top_tier' || (usage.top_tier_model_ids ?? []).includes(model)
|
||||
|
||||
const full = (usage.gauges ?? []).filter(
|
||||
g => g.left_fraction <= 0 && (gaugeScope(g) === 'all' || (gaugeScope(g) === 'top_tier' && topTier))
|
||||
)
|
||||
|
||||
return full.reduce<PlanUsageGauge | undefined>(
|
||||
(last, g) => (!last || (g.resets_at_ms ?? 0) > (last.resets_at_ms ?? 0) ? g : last),
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** "5h", "2d 3h", "12m", "soon" until *resetsAtMs* — the picker's compact reset (Python twin:
|
||||
* hermes_cli/model_picker_usage.py `format_resets_in`, without the "resets in" prefix). */
|
||||
export function formatResetsIn(resetsAtMs: null | number | undefined, nowMs: number = Date.now()): string {
|
||||
if (typeof resetsAtMs !== 'number' || resetsAtMs <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const minutes = Math.max(0, Math.floor((resetsAtMs - nowMs) / 60_000))
|
||||
|
||||
if (minutes < 1) {
|
||||
return 'soon'
|
||||
}
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours < 24) {
|
||||
return `${hours}h`
|
||||
}
|
||||
|
||||
const rest = hours % 24
|
||||
|
||||
return rest ? `${Math.floor(hours / 24)}d ${rest}h` : `${Math.floor(hours / 24)}d`
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a window-plan picker row should look unavailable: a full allowance stops it and top-up
|
||||
* credits won't pay (pay as you go keeps it usable). The limit line above the list says why, once.
|
||||
*/
|
||||
export function planBlocks(provider: PlanProvider | null | undefined, model: string): boolean {
|
||||
return provider?.plan_usage?.credits_pay !== true && blockingGauge(provider, model) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The full allowance worth one line at the top of a window-plan picker, and what happens next. An
|
||||
* all-model allowance wins over the large-model one (it stops more). `then`: `credits` = paid models (or
|
||||
* Large models) now bill top-up credits, pay as you go; `wait` = they stop until the reset; `unknown` =
|
||||
* an older portal that does not say. `null` while nothing is full or the plan is paused.
|
||||
*/
|
||||
export function planLimit(
|
||||
provider: PlanProvider | null | undefined
|
||||
): { gauge: PlanUsageGauge; scope: 'all' | 'top_tier'; then: 'credits' | 'unknown' | 'wait' } | null {
|
||||
const usage = provider?.plan_usage
|
||||
|
||||
if (usage?.kind !== 'usage_window' || usage.paused_reason) {
|
||||
return null
|
||||
}
|
||||
|
||||
const full = (usage.gauges ?? []).filter(g => g.left_fraction <= 0)
|
||||
|
||||
const latest = (scope: 'all' | 'top_tier') =>
|
||||
full
|
||||
.filter(g => gaugeScope(g) === scope)
|
||||
.reduce<PlanUsageGauge | undefined>(
|
||||
(last, g) => (!last || (g.resets_at_ms ?? 0) > (last.resets_at_ms ?? 0) ? g : last),
|
||||
undefined
|
||||
)
|
||||
|
||||
const all = latest('all')
|
||||
const gauge = all ?? latest('top_tier')
|
||||
|
||||
if (!gauge) {
|
||||
return null
|
||||
}
|
||||
|
||||
const then = usage.credits_pay === true ? 'credits' : usage.credits_pay === false ? 'wait' : 'unknown'
|
||||
|
||||
return { gauge, scope: all ? 'all' : 'top_tier', then }
|
||||
}
|
||||
|
||||
/** The words a terminal picker's limit line is built from; English by default, the TUI passes its
|
||||
* translated copy (desktop renders its own from {@link planLimit}). */
|
||||
export interface PlanLimitWords {
|
||||
resetsIn: (duration: string) => string
|
||||
/** "Weekly limit reached" from a limit name ("weekly"). */
|
||||
limitReached: (name: string) => string
|
||||
limitNames: Record<KnownGaugeId, string>
|
||||
limitThenCredits: string
|
||||
limitThenCreditsLarge: string
|
||||
limitThenWait: string
|
||||
limitThenWaitLarge: string
|
||||
}
|
||||
|
||||
export const PLAN_LIMIT_WORDS_EN: PlanLimitWords = {
|
||||
resetsIn: duration => `resets in ${duration}`,
|
||||
limitReached: name => `${name.charAt(0).toUpperCase()}${name.slice(1)} limit reached`,
|
||||
// "You've hit your <name> limit" words (agent/usage_gauges.py `gauge_limit_name`).
|
||||
limitNames: { day: 'daily', month: 'monthly', top_tier: 'monthly large-model', week: 'weekly' },
|
||||
limitThenCredits: 'paid models now use your credits (pay as you go)',
|
||||
limitThenCreditsLarge: 'Large models now use your credits (pay as you go)',
|
||||
limitThenWait: 'only Free models until then',
|
||||
limitThenWaitLarge: 'Large models paused until then'
|
||||
}
|
||||
|
||||
/** One-line limit summary for terminal pickers:
|
||||
* "Weekly limit reached · resets in 21h · paid models now use your credits (pay as you go)". */
|
||||
export function planLimitLine(
|
||||
provider: PlanProvider | null | undefined,
|
||||
nowMs: number = Date.now(),
|
||||
words: PlanLimitWords = PLAN_LIMIT_WORDS_EN
|
||||
): string {
|
||||
const limit = planLimit(provider)
|
||||
|
||||
if (!limit) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const name = gaugeTitle(limit.gauge, words.limitNames)
|
||||
const when = formatResetsIn(limit.gauge.resets_at_ms, nowMs)
|
||||
// Under a minute: no "resets in soon"; the limit line alone reads right until the next refresh.
|
||||
const head = `${words.limitReached(name)}${when && when !== 'soon' ? ` · ${words.resetsIn(when)}` : ''}`
|
||||
const all = limit.scope === 'all'
|
||||
|
||||
if (limit.then === 'credits') {
|
||||
return `${head} · ${all ? words.limitThenCredits : words.limitThenCreditsLarge}`
|
||||
}
|
||||
|
||||
if (limit.then === 'wait') {
|
||||
return `${head} · ${all ? words.limitThenWait : words.limitThenWaitLarge}`
|
||||
}
|
||||
|
||||
return head
|
||||
}
|
||||
+10
-1
@@ -309,6 +309,10 @@ class GatewayTurnMixin(GatewayTurnPrepareMixin, GatewayTurnHygieneMixin, Gateway
|
||||
reply_expected=event.reply_expected,
|
||||
)
|
||||
response = self._hmwa_prepend_reasoning(agent_result, response, source, _intentional_silence)
|
||||
if agent_result.get("usage_window_message") and response:
|
||||
# A chat has no /topup Keep going control: the plan's credits settings live in the Portal.
|
||||
from agent.plan_limits import portal_wording
|
||||
response = portal_wording(response)
|
||||
_footer_line = self._hmwa_runtime_footer_line(agent_result, source, _turn_seconds)
|
||||
# Streaming already delivered the body: the footer goes out as a trailing send instead.
|
||||
if _footer_line and response and not agent_result.get("already_sent") and not _intentional_silence:
|
||||
@@ -330,10 +334,15 @@ class GatewayTurnMixin(GatewayTurnPrepareMixin, GatewayTurnHygieneMixin, Gateway
|
||||
hidden_reasoning_incomplete=hidden_reasoning_incomplete,
|
||||
is_context_overflow_failure=is_context_overflow_failure,
|
||||
)
|
||||
return await self._hmwa_deliver_turn_response(
|
||||
delivered = await self._hmwa_deliver_turn_response(
|
||||
event, source, session_entry, session_key, run_generation,
|
||||
agent_result, agent_messages, response, _footer_line, _intentional_silence,
|
||||
)
|
||||
if isinstance(agent_result.get("usage_limit"), dict):
|
||||
# After the reply lands: the limit sentence is the reply; the button follows it.
|
||||
self._schedule_usage_limit_offer(event, agent_result["usage_limit"], session_key=session_key,
|
||||
generation=run_generation)
|
||||
return delivered
|
||||
|
||||
except Exception as e:
|
||||
return await self._hmwa_agent_error_reply(e, event, source, session_entry, session_key, prepared)
|
||||
|
||||
@@ -326,6 +326,12 @@ class TurnRunner(GatewayTurnProgressMixin, GatewaySessionAgentMixin):
|
||||
from gateway.warning_notifications import is_diagnostic_notice, render_notification
|
||||
if self._ctx.mute_notification_reply or not self._status_live():
|
||||
return
|
||||
# A usage-window limit line repeats the turn's own reply, which carries the sentence and the
|
||||
# switch button (a chat message cannot be cleared, so it would only pile up). The 90% heads-up
|
||||
# is a desktop / terminal nudge; in a chat it is noise (/usage has the numbers).
|
||||
key = str(getattr(notice, "key", "") or "")
|
||||
if key == "credits.window_limit" or key.startswith("credits.window."):
|
||||
return
|
||||
diagnostic = is_diagnostic_notice(notice)
|
||||
def present():
|
||||
try:
|
||||
@@ -1330,6 +1336,8 @@ class TurnRunner(GatewayTurnProgressMixin, GatewaySessionAgentMixin):
|
||||
common = {
|
||||
"messages": result.get("messages", []), "api_calls": result.get("api_calls", 0),
|
||||
"failed": result.get("failed", False), "failure_reason": result.get("failure_reason"),
|
||||
# Usage-window limit (agent/plan_limits.py): the gateway offers the plan's model as a button.
|
||||
"usage_limit": result.get("usage_limit"),
|
||||
"partial": result.get("partial", False), "completed": result.get("completed"),
|
||||
"interrupted": result.get("interrupted", False), "interrupt_message": result.get("interrupt_message"),
|
||||
"error": result.get("error"),
|
||||
|
||||
@@ -343,6 +343,8 @@ class GatewayModelCommandsMixin:
|
||||
lines.append(t("gateway.model.prompt_caching_enabled"))
|
||||
if result.warning_message:
|
||||
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
|
||||
if getattr(result, "usage_notice", ""):
|
||||
lines.append(f"⚠️ {result.usage_notice}")
|
||||
if ctx.persist_global and global_error is not None:
|
||||
# Never claim a clean global commit the disk did not take (#100314).
|
||||
lines.append(t("gateway.model.warning_prefix", warning=global_error))
|
||||
@@ -622,6 +624,114 @@ class GatewayModelCommandsMixin:
|
||||
return guard_reply
|
||||
return await self._commit_model_switch_locked(result, ctx, source=source, picker=False)
|
||||
|
||||
# -------------------------------------------------- usage-window limit: one-tap switch
|
||||
|
||||
def _schedule_usage_limit_offer(self, event: MessageEvent, limit: dict, *, session_key: str = "",
|
||||
generation: Optional[int] = None) -> None:
|
||||
"""After a usage-window refusal's reply: offer the plan's recommended model as a button where the
|
||||
platform already has quick-choice buttons, else one line of text.
|
||||
|
||||
Runs as the adapter's post-delivery callback, so the offer always lands under the reply (the reply
|
||||
is sent after this turn returns). The tap switches this session only (never the default) and
|
||||
re-sends the refused message, like /retry. No credits button in a chat: anyone in the room could
|
||||
spend the owner's credits, so the Portal is named instead. Hermes never switches on its own."""
|
||||
try:
|
||||
adapter = self._delivery_adapter_for(event.source) if event.source is not None else None
|
||||
register = getattr(adapter, "register_post_delivery_callback", None)
|
||||
if session_key and callable(register):
|
||||
register(session_key, lambda: self._offer_usage_limit_switch(event, limit, generation),
|
||||
generation=generation)
|
||||
return
|
||||
self._track_background_task(self._offer_usage_limit_switch(event, limit, generation))
|
||||
except Exception:
|
||||
logger.debug("usage-limit offer scheduling failed", exc_info=True)
|
||||
|
||||
async def _offer_usage_limit_switch(self, event: MessageEvent, limit: dict,
|
||||
generation: Optional[int] = None) -> None:
|
||||
from agent.plan_limits import limit_next_steps_from_payload
|
||||
try:
|
||||
suggestions = [s for s in (limit.get("suggestions") or []) if isinstance(s, dict) and s.get("model")]
|
||||
source = event.source
|
||||
if source is None:
|
||||
return
|
||||
prefix = self._typed_command_prefix_for(source.platform)
|
||||
adapter = self._delivery_adapter_for(source)
|
||||
session_key = self._session_key_for_source(source)
|
||||
if suggestions and adapter is not None and getattr(type(adapter), "send_choice_picker", None) is not None:
|
||||
async def _on_choice(_chat_id: str, value: str) -> str:
|
||||
if generation is not None and self._usage_limit_offer_is_stale(session_key, generation):
|
||||
# A newer turn ran since the refusal: /retry would re-run that one, not the refused one.
|
||||
return t("platform.discord.picker.choice_expired")
|
||||
return await self._accept_usage_limit_switch(event, value)
|
||||
|
||||
choices = [{"value": str(s["model"]),
|
||||
"label": t("gateway.usage_limit.switch_choice", model=str(s.get("label") or s["model"]),
|
||||
note=str(s.get("note") or "")),
|
||||
"is_current": False} for s in suggestions]
|
||||
if await self._try_send_choice_picker(
|
||||
event, session_key, title=t("gateway.usage_limit.picker_title"), choices=choices,
|
||||
on_choice_selected=_on_choice):
|
||||
return
|
||||
text = limit_next_steps_from_payload(limit, model_command=f"{prefix}model", topup_command=None, portal=True)
|
||||
if adapter is not None and text:
|
||||
await adapter.send(source.chat_id, text, metadata=self._reply_metadata(event))
|
||||
except Exception:
|
||||
logger.debug("usage-limit offer failed", exc_info=True)
|
||||
|
||||
def _usage_limit_offer_is_stale(self, session_key: str, generation: int) -> bool:
|
||||
"""True once the session has started a newer run than the refused one. Fails open (not stale)."""
|
||||
try:
|
||||
return int(self._session_state(session_key).persistent.run_generation) != int(generation)
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
async def _accept_usage_limit_switch(self, event: MessageEvent, model_id: str) -> str:
|
||||
"""The tap: switch this session to *model_id* on Nous, then re-send the refused message."""
|
||||
from gateway.run import _hermes_home
|
||||
source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source)
|
||||
session_key = self._session_key_for_source(source)
|
||||
profile_home = None
|
||||
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
|
||||
profile_home = self._resolve_profile_home_for_source(event.source)
|
||||
ctx = _ModelSwitchContext(session_key=session_key, source=source,
|
||||
config_path=(profile_home or _hermes_home) / "config.yaml", persist_global=False)
|
||||
|
||||
async def _switch() -> str:
|
||||
ctx.read_config()
|
||||
ctx.apply_override(self._session_model_overrides.get(session_key, {}))
|
||||
result, error = await self._perform_model_switch(ctx, model_id, "nous", event.source)
|
||||
if error is not None:
|
||||
return error
|
||||
return await self._commit_model_switch(result, ctx, source=event.source, picker=True)
|
||||
|
||||
before = self._session_model_overrides.get(session_key)
|
||||
if profile_home is None:
|
||||
reply = await _switch()
|
||||
else:
|
||||
from gateway.run import _profile_runtime_scope
|
||||
with _profile_runtime_scope(profile_home):
|
||||
reply = await _switch()
|
||||
after = self._session_model_overrides.get(session_key)
|
||||
switched = after is not None and after is not before and after.get("provider") == "nous"
|
||||
if switched:
|
||||
# Only after the switch landed (an error reply leaves the override unchanged). Exactly a typed /retry from the same sender (rewinds the refused turn, runs the message
|
||||
# again, and the adapter delivers the reply). Called directly, the re-run's reply would be
|
||||
# returned to nobody.
|
||||
self._track_background_task(self._dispatch_usage_limit_retry(event))
|
||||
return reply
|
||||
|
||||
async def _dispatch_usage_limit_retry(self, event: MessageEvent) -> None:
|
||||
from gateway.platforms.event import MessageType
|
||||
adapter = self._delivery_adapter_for(event.source)
|
||||
if adapter is None:
|
||||
return
|
||||
try:
|
||||
await adapter.handle_message(MessageEvent(
|
||||
text="/retry", message_type=MessageType.TEXT, source=event.source,
|
||||
raw_message=event.raw_message, channel_prompt=event.channel_prompt))
|
||||
except Exception:
|
||||
logger.debug("usage-limit retry dispatch failed", exc_info=True)
|
||||
|
||||
# -------------------------------------------------- /codex-runtime, /personality
|
||||
|
||||
async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
|
||||
|
||||
@@ -602,7 +602,11 @@ class GatewayStatusCommandsMixin:
|
||||
# logged in" — NOT the inference provider — so a Nous user inferring elsewhere still sees
|
||||
# a balance. Fail-open: never break /usage.
|
||||
from agent.account_usage import nous_credits_lines
|
||||
credits_lines = await _quiet(lambda: asyncio.to_thread(nous_credits_lines, markdown=True), [])
|
||||
credits_state = getattr(agent, "_credits_state", None) if agent else None
|
||||
credits_lines = await _quiet(
|
||||
lambda: asyncio.to_thread(nous_credits_lines, markdown=True, credits_state=credits_state),
|
||||
[],
|
||||
)
|
||||
|
||||
def _with_account_blocks(lines: list[str]) -> str:
|
||||
# Each block is preceded by a blank divider only when something precedes it.
|
||||
|
||||
+116
-29
@@ -48,21 +48,50 @@ def _confirm_selection_guards(
|
||||
return response in {"y", "yes"}
|
||||
|
||||
|
||||
class _ModelPickerRows:
|
||||
"""Column-aligned picker rows (name + $/Mtok + Nous sale chrome).
|
||||
# Usage column on a window plan: "Large" / "Free" (other levels carry no tag).
|
||||
_COST_COL = len("Large")
|
||||
_GAUGE_GAP = " "
|
||||
# Style per gateway level: Free green, Large yellow (it draws on its own limit), the rest plain.
|
||||
_LEVEL_STYLE = {"free": "green", "top_tier": "yellow"}
|
||||
|
||||
Sale chrome is emitted as styled segments, not ANSI baked into one string — curses addnstr
|
||||
would render escape bytes literally.
|
||||
|
||||
class _ModelPickerRows:
|
||||
"""Column-aligned picker rows.
|
||||
|
||||
Usage-window plan: ``Large`` / ``Free`` on the rows the gateway marks, exactly as
|
||||
the gateway sent it on this caller's catalog. Every other account keeps ``$/Mtok``.
|
||||
|
||||
Chrome is emitted as styled segments, not ANSI baked into one string — curses addnstr would
|
||||
render escape bytes literally.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, all_models: list[str], pricing: Optional[dict[str, dict[str, str]]], *,
|
||||
current_model: str, sale_chrome: bool, notes: Optional[dict[str, str]] = None,
|
||||
current_model: str, sale_chrome: bool, usage=None,
|
||||
notes: Optional[dict[str, str]] = None,
|
||||
) -> None:
|
||||
from hermes_cli.models_pricing import _format_price_per_mtok, compute_sale_discount
|
||||
self.current_model = current_model
|
||||
# Per-model dim annotation (e.g. "usage credits"); the row stays selectable.
|
||||
self.notes = notes or {}
|
||||
self.window_mode = usage is not None
|
||||
if self.window_mode:
|
||||
# The gateway's ``top_tier`` marks decide which rows a full large-model limit dims.
|
||||
usage = usage.with_pricing(pricing)
|
||||
self.usage = usage
|
||||
if self.window_mode:
|
||||
from hermes_cli.model_picker_usage import usage_levels
|
||||
self.levels = usage_levels(all_models, pricing)
|
||||
self.has_pricing = False
|
||||
self.has_cache = False
|
||||
self.any_on_sale = False
|
||||
self.price_col = self.cache_col = 0
|
||||
self._price_cache = {}
|
||||
self.has_top_tier = "top_tier" in self.levels.values()
|
||||
self.name_col = max((len(m) for m in all_models), default=0) + 2
|
||||
return
|
||||
self.levels = {}
|
||||
self.has_top_tier = False
|
||||
self.has_pricing = bool(pricing and any(pricing.get(m) for m in all_models))
|
||||
# Leave room for a leading "★ " on sale rows (Nous only).
|
||||
name_pad = 3 if sale_chrome else 2
|
||||
@@ -105,10 +134,21 @@ class _ModelPickerRows:
|
||||
if self.has_cache:
|
||||
self.cache_col = max(self.cache_col, 5) # minimum: "Cache" header
|
||||
|
||||
def _cost_segments(self, mid: str) -> list[tuple[str, str | None]]:
|
||||
"""Window row: padded name (dim when a full allowance stops it) + Large / Free."""
|
||||
segs: list[tuple[str, str | None]] = [(f"{mid:<{self.name_col}}", "dim" if self.usage.blocks(mid) else None)]
|
||||
from hermes_cli.model_picker_usage import usage_level_label
|
||||
level = self.levels.get(mid)
|
||||
word = usage_level_label(level, "en")
|
||||
segs.extend(((_GAUGE_GAP, None), (f"{word:<{_COST_COL}}", _LEVEL_STYLE.get(level or ""))))
|
||||
return segs
|
||||
|
||||
def segments(self, mid: str) -> list[tuple[str, str | None]]:
|
||||
"""Build a rich radiolist row: yellow ★/% , dim was, plain prices."""
|
||||
"""Build a rich radiolist row: yellow ★/% , dim was, plain prices — or usage gauges on the window."""
|
||||
current = [(_CURRENT_SUFFIX, None)] if mid == self.current_model else []
|
||||
note = [(f" · {self.notes[mid]}", "dim")] if self.notes.get(mid) else []
|
||||
if self.window_mode:
|
||||
return self._cost_segments(mid) + current
|
||||
if not self.has_pricing:
|
||||
return [(mid, None), *note, *current]
|
||||
|
||||
@@ -133,21 +173,40 @@ class _ModelPickerRows:
|
||||
def label(self, mid: str) -> str:
|
||||
return "".join(text for text, _style in self.segments(mid))
|
||||
|
||||
def menu_title(self) -> str:
|
||||
"""``Select default model:`` plus an aligned pricing header hint when priced."""
|
||||
title = "Select default model:"
|
||||
def description_lines(self) -> list[str]:
|
||||
"""Lines between the title and the list: plan gauges (window plan), column headers, legend.
|
||||
Each choice is " {label}" (2 spaces) plus a 3-char cursor region ("-> " or " "), so column
|
||||
content starts at col 5."""
|
||||
pad = " " * 5
|
||||
lines: list[str] = []
|
||||
if self.window_mode:
|
||||
from hermes_cli.model_picker_usage import plan_gauge_lines
|
||||
for i, line in enumerate(plan_gauge_lines(self.usage)):
|
||||
suffix = f" — {self.usage.included_summary}" if i == 0 else ""
|
||||
lines.append(f"{pad}{line}{suffix}")
|
||||
lines.append(f"{pad}{'':>{self.name_col}}{_GAUGE_GAP}Usage")
|
||||
return lines
|
||||
if self.has_pricing:
|
||||
# Each choice is " {label}" (2 spaces) plus a 3-char cursor region ("-> " or " "),
|
||||
# so content starts at col 5.
|
||||
pad = " " * 5
|
||||
header = f"\n{pad}{'':>{self.name_col}} {'In':>{self.price_col}} {'Out':>{self.price_col}}"
|
||||
header = f"{pad}{'':>{self.name_col}} {'In':>{self.price_col}} {'Out':>{self.price_col}}"
|
||||
if self.has_cache:
|
||||
header += f" {'Cache':>{self.cache_col}}"
|
||||
# Legend lives on the column-header line so it reads as a key, not a fake menu row.
|
||||
title += header + " $/Mtok"
|
||||
header += " $/Mtok"
|
||||
if self.any_on_sale:
|
||||
title += " ★ = on sale"
|
||||
return title
|
||||
header += " ★ = on sale"
|
||||
lines.append(header)
|
||||
return lines
|
||||
|
||||
def menu_title(self) -> str:
|
||||
"""``Select default model:`` plus the description lines (headers / gauges), newline-joined."""
|
||||
lines = self.description_lines()
|
||||
return "Select default model:" + ("\n" + "\n".join(lines) if lines else "")
|
||||
|
||||
def footnote(self) -> str:
|
||||
"""Window plan with a Large row listed: the one line that says what Large means."""
|
||||
if not self.has_top_tier:
|
||||
return ""
|
||||
from hermes_cli.model_picker_usage import TOP_TIER_FOOTNOTE
|
||||
return TOP_TIER_FOOTNOTE
|
||||
|
||||
|
||||
def _prompt_model_selection(
|
||||
@@ -155,12 +214,15 @@ def _prompt_model_selection(
|
||||
pricing: Optional[dict[str, dict[str, str]]] = None,
|
||||
unavailable_models: Optional[list[str]] = None, portal_url: str = "",
|
||||
unavailable_message: str = "", confirm_provider: str = "", confirm_base_url: str = "",
|
||||
confirm_api_key: str = "", notes: Optional[dict[str, str]] = None,
|
||||
confirm_api_key: str = "", usage=None,
|
||||
notes: Optional[dict[str, str]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Interactive model picker; current_model listed first. Returns the chosen model ID or None.
|
||||
|
||||
With *pricing* (``{model_id: {prompt, completion}}``) a compact price column is shown; models in
|
||||
*unavailable_models* render grayed out and unselectable with an upgrade link to *portal_url*.
|
||||
*usage* (``model_picker_usage.PickerUsage``, Nous only) swaps ``$/Mtok`` for plan gauges and the
|
||||
gateway's per-model usage word on a usage-window plan.
|
||||
"""
|
||||
from hermes_cli.cli_output import line_input
|
||||
_unavailable = unavailable_models or []
|
||||
@@ -184,13 +246,29 @@ def _prompt_model_selection(
|
||||
return None
|
||||
return _confirmed_selection(custom) if custom else None
|
||||
|
||||
# Reorder: current model first, then the rest (deduplicated)
|
||||
ordered = list(dict.fromkeys(
|
||||
([current_model] if current_model and current_model in model_ids else []) + list(model_ids)
|
||||
))
|
||||
# All models for column-width computation (selectable + unavailable). Plan usage is Nous-only chrome.
|
||||
window_usage = usage if sale_chrome else None
|
||||
# Window plan: "Recommended" (the plan's featured picks) then "Other models", with the
|
||||
# current model left in its own section (the cursor starts on it). Everyone else: current first.
|
||||
sections: dict[int, str] = {}
|
||||
if window_usage is not None:
|
||||
from hermes_cli.model_picker_usage import SECTION_OTHER, SECTION_RECOMMENDED, split_recommended
|
||||
head, rest = split_recommended(list(dict.fromkeys(model_ids)), window_usage.featured_model_ids)
|
||||
ordered = head + rest
|
||||
if head:
|
||||
sections[0] = SECTION_RECOMMENDED
|
||||
if rest:
|
||||
sections[len(head)] = SECTION_OTHER
|
||||
else:
|
||||
# Reorder: current model first, then the rest (deduplicated)
|
||||
ordered = list(dict.fromkeys(
|
||||
([current_model] if current_model and current_model in model_ids else []) + list(model_ids)
|
||||
))
|
||||
start = ordered.index(current_model) if current_model in ordered else 0
|
||||
|
||||
# All models for column-width computation (selectable + unavailable)
|
||||
rows = _ModelPickerRows(ordered + list(_unavailable), pricing, current_model=current_model, sale_chrome=sale_chrome, notes=notes)
|
||||
rows = _ModelPickerRows(
|
||||
ordered + list(_unavailable), pricing, current_model=current_model, sale_chrome=sale_chrome,
|
||||
usage=window_usage, notes=notes)
|
||||
_DIM = "\033[2m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
@@ -207,11 +285,13 @@ def _prompt_model_selection(
|
||||
if not unavailable_footer and _unavailable:
|
||||
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
|
||||
|
||||
# Header/legend + unavailable block go in the description so they survive the curses clear.
|
||||
desc_lines: list[str] = menu_title.split("\n", 1)[1].splitlines() if rows.has_pricing else []
|
||||
# Header/legend/gauges + unavailable block go in the description so they survive the curses clear.
|
||||
desc_lines: list[str] = rows.description_lines()
|
||||
if _unavailable:
|
||||
desc_lines.extend(f" {rows.label(mid)}" for mid in _unavailable)
|
||||
desc_lines.append(f" ── {unavailable_footer} ──")
|
||||
if footnote := rows.footnote():
|
||||
desc_lines.append(f" {footnote}")
|
||||
|
||||
# Search haystack = label + aliases for brand-less wire ids (Kimi `k3` ↔ "kimi"); skip when
|
||||
# model_search_text adds nothing beyond the bare id.
|
||||
@@ -225,11 +305,12 @@ def _prompt_model_selection(
|
||||
idx = curses_radiolist(
|
||||
"Select default model:",
|
||||
choices,
|
||||
selected=0, # cursor on the current model (index 0 if it was reordered to top)
|
||||
selected=start, # cursor on the current model
|
||||
cancel_returns=-1,
|
||||
description="\n".join(desc_lines) if desc_lines else None,
|
||||
searchable=True,
|
||||
search_labels=model_search_labels,
|
||||
headings=sections or None,
|
||||
)
|
||||
if idx < 0:
|
||||
return None
|
||||
@@ -242,16 +323,22 @@ def _prompt_model_selection(
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# Fallback: numbered list (ANSI colors for sale chrome)
|
||||
from hermes_cli.curses_ui import format_radio_item_ansi
|
||||
# Fallback: numbered list (ANSI colors for sale chrome / usage gauges)
|
||||
from hermes_cli.curses_ui import colorize_gauge_bars_ansi, format_radio_item_ansi
|
||||
from hermes_cli.colors import Colors, color
|
||||
for line in menu_title.splitlines():
|
||||
if rows.window_mode:
|
||||
line = colorize_gauge_bars_ansi(line)
|
||||
print(line.replace("★", color("★", Colors.YELLOW), 1) if "★" in line else line)
|
||||
num_width = len(str(n + 2))
|
||||
for i, mid in enumerate(ordered, 1):
|
||||
if (i - 1) in sections:
|
||||
print(f" {_DIM}{sections[i - 1]}{_RESET}")
|
||||
print(f" {i:>{num_width}}. {format_radio_item_ansi(rows.segments(mid))}")
|
||||
print(f" {n + 1:>{num_width}}. {_CUSTOM_LABEL}")
|
||||
print(f" {n + 2:>{num_width}}. {_SKIP_LABEL}")
|
||||
if footnote := rows.footnote():
|
||||
print(f" {_DIM}{footnote}{_RESET}")
|
||||
|
||||
if _unavailable:
|
||||
unavailable_footer = unavailable_message.strip() or (
|
||||
|
||||
+17
-1
@@ -1490,6 +1490,13 @@ def step_up_nous_billing_scope(
|
||||
open_browser=open_browser, timeout_seconds=timeout_seconds, on_verification=on_verification)
|
||||
_save_active_provider_state("nous", auth_state)
|
||||
_mirror_nous_state_best_effort(auth_state)
|
||||
# Every caller replays the refused billing action next: drop the billing client's 30s token cache
|
||||
# here (not per caller) so that replay sends the new token, not the pre-approval one (403 again).
|
||||
try:
|
||||
from hermes_cli.nous_billing import invalidate_cached_token
|
||||
invalidate_cached_token()
|
||||
except Exception:
|
||||
pass
|
||||
granted = auth_state.get("scope")
|
||||
return isinstance(granted, str) and NOUS_BILLING_MANAGE_SCOPE in granted.split()
|
||||
|
||||
@@ -1553,13 +1560,19 @@ def _pick_nous_model_after_login(
|
||||
_policy_notice = nous_policy_notice(removed=_policy_narrowed)
|
||||
if _policy_notice:
|
||||
print(_policy_notice)
|
||||
from hermes_cli.model_picker_usage import featured_first, nous_picker_usage
|
||||
_usage = nous_picker_usage(warm=True)
|
||||
# Window plans: every model is usable; the plan's featured picks lead the list.
|
||||
if _usage is not None:
|
||||
model_ids = featured_first(model_ids, _usage.featured_model_ids)
|
||||
print(
|
||||
f"Showing {len(model_ids)} curated models — "
|
||||
"use \"Enter custom model name\" for others.")
|
||||
return _prompt_model_selection(
|
||||
model_ids, pricing=pricing, unavailable_models=unavailable_models, portal_url=_portal,
|
||||
unavailable_message=unavailable_message, confirm_provider="nous",
|
||||
confirm_base_url=inference_base_url, confirm_api_key=runtime_key)
|
||||
confirm_base_url=inference_base_url, confirm_api_key=runtime_key,
|
||||
usage=_usage)
|
||||
if unavailable_models:
|
||||
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
print("No free models currently available.")
|
||||
@@ -1663,6 +1676,9 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
"nous", inference_base_url, default_model=selected_model)
|
||||
_save_model_choice(selected_model)
|
||||
print(f"Default model set to: {selected_model}")
|
||||
from hermes_cli.model_picker_usage import large_model_notice
|
||||
if _notice := large_model_notice(selected_model, "nous", base_url=inference_base_url or ""):
|
||||
print(f" ⚠ {_notice}") # window plans only (a Large mark exists only on their catalog)
|
||||
print(f" Config updated: {config_path} (model.provider=nous)")
|
||||
except KeyboardInterrupt:
|
||||
print("\nLogin cancelled.")
|
||||
|
||||
@@ -103,6 +103,23 @@ def _cancel_sub_choices() -> list[tuple[str, str, str]]:
|
||||
("cancel", t("cli.billing.choice_go_back"), t("cli.subscription.desc_keep_your_plan"))]
|
||||
|
||||
|
||||
# Keep going on credits: window plans only (the portal sends the setting only there).
|
||||
def _keep_going_row() -> tuple[str, str, str]:
|
||||
return ("hermes", t("cli.billing.keep_going_choice"), t("cli.billing.keep_going_desc"))
|
||||
|
||||
|
||||
def _keep_going_agree_choices() -> list[tuple[str, str, str]]:
|
||||
return [
|
||||
("agree", t("cli.billing.keep_going_turn_on"), t("cli.billing.keep_going_desc")),
|
||||
_cancel_row(desc_key="cli.billing.keep_going_keep_off_desc")]
|
||||
|
||||
|
||||
def _keep_going_off_choices() -> list[tuple[str, str, str]]:
|
||||
return [
|
||||
("off", t("cli.billing.keep_going_turn_off"), t("cli.billing.keep_going_turn_off_desc")),
|
||||
_cancel_row(label_key="cli.billing.keep_going_leave_on")]
|
||||
|
||||
|
||||
class CLIBillingMixin:
|
||||
"""Mixin holding interactive-CLI billing and subscription handlers."""
|
||||
|
||||
@@ -263,7 +280,9 @@ class CLIBillingMixin:
|
||||
if head or tail:
|
||||
return True
|
||||
from agent.account_usage import nous_credits_lines
|
||||
lines = nous_credits_lines()
|
||||
lines = nous_credits_lines(
|
||||
credits_state=getattr(getattr(self, "agent", None), "_credits_state", None),
|
||||
)
|
||||
if not lines:
|
||||
return False
|
||||
print()
|
||||
@@ -708,6 +727,8 @@ class CLIBillingMixin:
|
||||
print(f" {t('cli.billing.auto_reload_on_line', threshold=format_money(ar.threshold_usd), reload_to=format_money(ar.reload_to_usd))}")
|
||||
else:
|
||||
print(f" {t('cli.billing.auto_reload_off_line')}")
|
||||
if state.keep_going_on_credits is not None:
|
||||
print(f" {t('cli.billing.keep_going_on_line' if state.keep_going_on_credits else 'cli.billing.keep_going_off_line')}")
|
||||
if state.can_change_plan and state.cli_billing_enabled: # card at a glance, full-menu case only
|
||||
if state.card is not None:
|
||||
print(f" {t('cli.billing.card_line', card=state.card.display)}")
|
||||
@@ -716,6 +737,7 @@ class CLIBillingMixin:
|
||||
print(f" {_RULE}")
|
||||
# Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal.
|
||||
if not self._billing_require_admin(state, icon="", off_reason_key="cli.billing.killswitch_reason_overview"):
|
||||
self._billing_offer_keep_going_off(state)
|
||||
return
|
||||
if not self._app: # non-interactive: no modal, just the portal funnel
|
||||
self._billing_portal_hint(state)
|
||||
@@ -732,11 +754,16 @@ class CLIBillingMixin:
|
||||
print(f" {_RULE}")
|
||||
# No "Allow Remote Spending" item — discovered at pay time. "Add funds" charges the org's
|
||||
# portal-saved card (server-held; no card ref leaves the client).
|
||||
# "hermes" only on window plans (the portal sends the setting only there).
|
||||
choices = _topup_menu_choices()
|
||||
if state.keep_going_on_credits is not None:
|
||||
choices.insert(2, _keep_going_row()) # after Auto-reload
|
||||
action = {
|
||||
"buy": self._billing_buy_flow,
|
||||
"auto": self._billing_auto_reload_flow,
|
||||
"hermes": self._billing_keep_going_on_credits_flow,
|
||||
"limit": self._billing_limit_screen,
|
||||
"portal": self._billing_open_portal}.get(self._modal_choice(t("cli.billing.topup_title"), "", _topup_menu_choices()))
|
||||
"portal": self._billing_open_portal}.get(self._modal_choice(t("cli.billing.topup_title"), "", choices))
|
||||
if action:
|
||||
action(state)
|
||||
else:
|
||||
@@ -1052,9 +1079,13 @@ class CLIBillingMixin:
|
||||
|
||||
def _billing_patch_auto_top_up(self, state, **kwargs) -> bool:
|
||||
"""PATCH auto-top-up; scope denials → step-up, other errors → renderer. True on success."""
|
||||
from hermes_cli.nous_billing import BillingError, BillingScopeRequired, patch_auto_top_up
|
||||
from hermes_cli.nous_billing import patch_auto_top_up
|
||||
return self._billing_patch(state, patch_auto_top_up, **kwargs)
|
||||
|
||||
def _billing_patch(self, state, call, **kwargs) -> bool:
|
||||
from hermes_cli.nous_billing import BillingError, BillingScopeRequired
|
||||
try:
|
||||
patch_auto_top_up(**kwargs)
|
||||
call(**kwargs)
|
||||
except BillingScopeRequired:
|
||||
self._billing_handle_scope_required(state)
|
||||
return False
|
||||
@@ -1063,6 +1094,47 @@ class CLIBillingMixin:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _billing_offer_keep_going_off(self, state):
|
||||
"""Turning "Keep going on credits" off only stops spending, so an admin can still do it with
|
||||
Remote Spending off (the portal and the server allow it too)."""
|
||||
if not (state.can_change_plan and state.keep_going_on_credits and getattr(self, "_app", None)):
|
||||
return
|
||||
if self._modal_choice(t("cli.billing.keep_going_is_on_title"), "", _keep_going_off_choices()) == "off":
|
||||
self._billing_keep_going_on_credits_flow(state)
|
||||
|
||||
def _billing_keep_going_on_credits_flow(self, state):
|
||||
"""Window plans: flip "Keep going on credits". Turning it on spends money later, so it asks
|
||||
first; turning it off does not."""
|
||||
turn_on = not state.keep_going_on_credits
|
||||
if turn_on and self._modal_choice(
|
||||
t("cli.billing.keep_going_confirm_title"), t("cli.billing.keep_going_confirm_body"),
|
||||
_keep_going_agree_choices()) != "agree":
|
||||
print(f" {t('cli.billing.cancelled_yellow')}")
|
||||
return
|
||||
self._billing_set_keep_going(state, turn_on, allow_stepup=True)
|
||||
|
||||
def _billing_set_keep_going(self, state, turn_on: bool, *, allow_stepup: bool) -> None:
|
||||
"""PATCH the setting. A scope denial runs the step-up, then sends the same change ONCE (no card or
|
||||
org Remote Spending check: the setting is not a charge, and turning it off must always work)."""
|
||||
from hermes_cli.nous_billing import BillingError, BillingScopeRequired, patch_keep_going_on_credits
|
||||
try:
|
||||
patch_keep_going_on_credits(enabled=turn_on)
|
||||
except BillingScopeRequired as exc:
|
||||
if not allow_stepup:
|
||||
self._billing_render_charge_error(state, exc)
|
||||
return
|
||||
if self._step_up_remote_spending(
|
||||
explain=t("cli.subscription.stepup_explain"),
|
||||
noninteractive_msg=f" {t('cli.subscription.stepup_noninteractive')}",
|
||||
declined_msg=f" {t('cli.subscription.stepup_declined')}",
|
||||
not_granted_msg=f" {t('cli.subscription.stepup_not_granted')}"):
|
||||
self._billing_set_keep_going(state, turn_on, allow_stepup=False)
|
||||
return
|
||||
except BillingError as exc:
|
||||
self._billing_render_charge_error(state, exc)
|
||||
return
|
||||
print(f" {t('cli.billing.keep_going_turned_on' if turn_on else 'cli.billing.keep_going_turned_off')}")
|
||||
|
||||
def _billing_auto_reload_disable(self, state):
|
||||
"""PATCH ``enabled:false``; the endpoint still requires threshold/topUpAmount → echo current (or 0)."""
|
||||
ar = state.auto_reload
|
||||
|
||||
@@ -710,7 +710,23 @@ class CLIChatTurnMixin:
|
||||
|
||||
# Billing CTA pins the single action (Nous → /topup, others → billing page) so it
|
||||
# stays visible instead of scrolling away inside the response prose.
|
||||
if turn.result and turn.result.get("failure_reason") == "billing":
|
||||
_limit = (turn.result or {}).get("usage_limit") if turn.result else None
|
||||
if isinstance(_limit, dict):
|
||||
# Usage-window plan: what to do next, in commands that always work here. Hermes never
|
||||
# switches on its own; the user picks.
|
||||
try:
|
||||
from agent.plan_limits import limit_next_steps_from_payload
|
||||
ChatConsole().print(Panel(
|
||||
limit_next_steps_from_payload(_limit, markup=True),
|
||||
title="[#CD7F32 bold]⚡ Limit reached[/]", title_align="left", border_style="#CD7F32",
|
||||
box=rich_box.HORIZONTALS, padding=(1, 4), width=self._scrollback_box_width(),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
elif (turn.result and turn.result.get("failure_reason") == "billing"
|
||||
and not turn.result.get("usage_window_message")):
|
||||
# (A window plan's refusal already reads as its own sentence with the way on; an
|
||||
# "Out of credits" box under it would contradict it.)
|
||||
_bb = turn.result.get("billing_block") or {}
|
||||
if _bb.get("is_nous"):
|
||||
_cta_lines = [t("cli.chat.billing_cta_nous")]
|
||||
|
||||
@@ -183,6 +183,8 @@ def _print_switch_summary(cli, result, old_model, *, one_turn: bool, strict_cont
|
||||
_cprint(f" {t('gateway.model.prompt_caching_enabled')}")
|
||||
if result.warning_message:
|
||||
_cprint(f" ⚠ {result.warning_message}")
|
||||
if getattr(result, "usage_notice", ""):
|
||||
_cprint(f" ⚠ {result.usage_notice}")
|
||||
|
||||
|
||||
def _switch_model_from(
|
||||
|
||||
@@ -540,7 +540,9 @@ class CLITuiMixin:
|
||||
(e.g. Ollama Cloud's 36+ models). ``state["_scroll_offset"]`` is updated in place.
|
||||
"""
|
||||
from cli import HermesCLI, _panel_box_width, _wrap_panel_text
|
||||
box_width = _panel_box_width(title, [hint] + labels, min_width=min_width, max_width=max_width)
|
||||
# ``hint`` may be several rows (a window plan's limit line above the usual hint).
|
||||
hints = list(hint) if isinstance(hint, (list, tuple)) else [hint]
|
||||
box_width = _panel_box_width(title, hints + labels, min_width=min_width, max_width=max_width)
|
||||
# ``_Panel.row`` pads every row to ``box_width - 2``, so that is the real
|
||||
# body width. Keep the wrap budget in sync with it and reserve the
|
||||
# leading cell for the cursor/indent applied below, rather than the old
|
||||
@@ -554,12 +556,14 @@ class CLITuiMixin:
|
||||
except Exception:
|
||||
term_rows = _term_rows()
|
||||
scroll_offset, visible = HermesCLI._compute_model_picker_viewport(
|
||||
selected, state.get("_scroll_offset", 0), len(labels), term_rows)
|
||||
selected, state.get("_scroll_offset", 0), len(labels), term_rows,
|
||||
panel_chrome=5 + len(hints))
|
||||
state["_scroll_offset"] = scroll_offset
|
||||
|
||||
panel = _Panel('class:clarify-border', box_width, title, 'class:clarify-title')
|
||||
panel.blank()
|
||||
panel.row('class:clarify-hint', hint)
|
||||
for line in hints:
|
||||
panel.row('class:clarify-hint', line)
|
||||
panel.blank()
|
||||
for idx in range(scroll_offset, min(scroll_offset + visible, len(labels))):
|
||||
style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice'
|
||||
@@ -574,6 +578,19 @@ class CLITuiMixin:
|
||||
panel.blank()
|
||||
return panel.close()
|
||||
|
||||
def _annotate_window_plan_rows(self, provider_data: dict, model_ids: list) -> tuple[list, Optional[str]]:
|
||||
"""``(labels, limit_line)`` for the /model picker's model stage. On a Nous usage-window plan rows carry
|
||||
the TUI's ``Large`` / ``Free`` tags, and with a full allowance ``limit_line`` (above the hint) says it
|
||||
once, with what happens next. Every other account: the bare ids and None. Display only."""
|
||||
if str((provider_data or {}).get("slug") or "").lower() != "nous":
|
||||
return list(model_ids), None
|
||||
try:
|
||||
from hermes_cli.model_picker_usage import nous_picker_usage, window_row_labels
|
||||
usage = nous_picker_usage() # fresh snapshot only: a stale percent would mislabel the line
|
||||
return window_row_labels(provider_data, model_ids), (usage.limit_line() if usage is not None else None)
|
||||
except Exception:
|
||||
return list(model_ids), None
|
||||
|
||||
def _get_model_picker_display_fragments(self):
|
||||
state = self._model_picker_state
|
||||
if not state:
|
||||
@@ -616,13 +633,17 @@ class CLITuiMixin:
|
||||
filtered_pairs = self._filter_model_picker_entries(model_list, _query)
|
||||
state["_filtered_pairs"] = filtered_pairs
|
||||
model_labels = [e for (_i, e) in filtered_pairs]
|
||||
choices = list(model_labels) + [t("cli.shared.back"), t("cli.shared.cancel")]
|
||||
row_labels, limit_line = self._annotate_window_plan_rows(provider_data, model_labels)
|
||||
choices = row_labels + [t("cli.shared.back"), t("cli.shared.cancel")]
|
||||
if _query:
|
||||
hint = t("cli.tui.picker_filter_hint", query=_query, shown=len(model_labels), total=len(model_list))
|
||||
elif model_list:
|
||||
hint = t("cli.tui.picker_select_hint", count=len(model_list))
|
||||
else:
|
||||
hint = t("cli.tui.picker_empty")
|
||||
# Window plan past an allowance: say once what happens next (pay as you go or wait), above the hint.
|
||||
if limit_line:
|
||||
hint = [limit_line, hint]
|
||||
return self._render_scroll_list_panel(
|
||||
state, title, hint, choices, min_width=46, max_width=84, indent=' ')
|
||||
|
||||
|
||||
+141
-15
@@ -1,5 +1,6 @@
|
||||
"""Curses checklist / radiolist / single-select menus with keyboard navigation and fuzzy ``/``
|
||||
search, plus a numbered text fallback for terminals without curses."""
|
||||
import re
|
||||
import sys
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
@@ -8,7 +9,7 @@ from typing import Callable, List, Optional, Sequence, Set, Tuple, Union
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
# Rich radiolist rows: (text, style). style is None | "yellow" | "dim". Plain ``str`` works too.
|
||||
# Rich radiolist rows: (text, style). style is None | "yellow" | "dim" | "green" | "red". Plain ``str`` works too.
|
||||
RadioItem = str | Sequence[tuple[str, str | None]]
|
||||
_NO_REPLAY = object()
|
||||
|
||||
@@ -119,6 +120,13 @@ def _curses_style_attr(curses, style: Optional[str], *, is_cursor: bool):
|
||||
return curses.A_BOLD | (curses.color_pair(1) if has_colors else 0)
|
||||
if style == "yellow" and has_colors:
|
||||
return curses.color_pair(2)
|
||||
if style == "green" and has_colors:
|
||||
return curses.color_pair(1)
|
||||
if style == "red" and has_colors:
|
||||
try:
|
||||
return curses.color_pair(4)
|
||||
except curses.error:
|
||||
return curses.A_BOLD
|
||||
if style != "dim":
|
||||
return curses.A_NORMAL
|
||||
attr = curses.A_DIM
|
||||
@@ -160,7 +168,7 @@ def _draw_segments(stdscr, y: int, x: int, segments, max_x: int) -> None:
|
||||
|
||||
|
||||
def _draw_description_line(stdscr, y: int, text: str, max_x: int) -> None:
|
||||
"""Draw a description line, highlighting ★ in yellow when colors exist."""
|
||||
"""Draw a description line, highlighting ★ in yellow and ``[|||| ]`` gauges by how much is used."""
|
||||
import curses
|
||||
star_attr = curses.color_pair(2) if curses.has_colors() else curses.A_NORMAL
|
||||
segments = []
|
||||
@@ -168,7 +176,10 @@ def _draw_description_line(stdscr, y: int, text: str, max_x: int) -> None:
|
||||
if i:
|
||||
segments.append(("★", star_attr))
|
||||
if part:
|
||||
segments.append((part, curses.A_NORMAL))
|
||||
segments.extend(
|
||||
(chunk, _curses_style_attr(curses, style, is_cursor=False))
|
||||
for chunk, style in _gauge_bar_segments(part)
|
||||
)
|
||||
_draw_segments(stdscr, y, 0, segments, max_x)
|
||||
|
||||
|
||||
@@ -442,6 +453,10 @@ def _init_colors(curses, extra_color_pairs: bool) -> None:
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
try:
|
||||
curses.init_pair(4, curses.COLOR_RED, -1) # "red" gauge segments (usage nearly gone)
|
||||
except curses.error:
|
||||
pass # a terminal with few colour pairs keeps the menu; red gauges just render plain
|
||||
if extra_color_pairs:
|
||||
curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
|
||||
|
||||
@@ -475,7 +490,7 @@ def _route_key(curses, stdscr, key: int, search: _SearchState, use_search: bool)
|
||||
def _run_curses_menu(
|
||||
*, initial_cursor, item_count, draw_header, draw_row, on_action, reserve_bottom=1,
|
||||
draw_footer=None, extra_color_pairs=False, fallback, cancel_value, searchable=False,
|
||||
search_labels=None):
|
||||
search_labels=None, selectable=None):
|
||||
"""Shared curses single-/multi-select event loop: non-TTY guard, ``curses.wrapper`` setup,
|
||||
clear/refresh cycle, scroll math, key dispatch with cursor wrap, KeyboardInterrupt /
|
||||
curses-unavailable fallback.
|
||||
@@ -483,7 +498,8 @@ def _run_curses_menu(
|
||||
``draw_row`` always receives the ORIGINAL item index; ``on_action`` returns ``_KEEP`` to
|
||||
continue or any other value to resolve; a ``draw_footer`` row budget must be included in
|
||||
``reserve_bottom``; with ``searchable``, ``/`` filters over ``search_labels`` (length ==
|
||||
``item_count``) and results are original indices.
|
||||
``item_count``) and results are original indices. ``selectable(i)`` (default: every row)
|
||||
marks rows the cursor skips, e.g. section headings: drawn, never landed on.
|
||||
"""
|
||||
navigation_handler = _MENU_NAVIGATION_HANDLER.get()
|
||||
|
||||
@@ -529,13 +545,16 @@ def _run_curses_menu(
|
||||
filtered = (
|
||||
_filter_indices(search_labels, search.query) if use_search
|
||||
else list(range(item_count)))
|
||||
cursor, cursor_pos = _reconcile_cursor(filtered, cursor)
|
||||
# The cursor only ever rests on selectable rows; every row is still drawn.
|
||||
nav = filtered if selectable is None else [i for i in filtered if selectable(i)]
|
||||
cursor, _nav_pos = _reconcile_cursor(nav, cursor)
|
||||
cursor_pos = filtered.index(cursor) if cursor in filtered else 0
|
||||
items_start = draw_header(
|
||||
stdscr, max_y, max_x, search=search, back_enabled=allow_back)
|
||||
visible_rows = max(1, max_y - items_start - reserve_bottom)
|
||||
scroll_offset = _scroll_for_cursor(
|
||||
scroll_offset, cursor_pos, visible_rows, len(filtered))
|
||||
if use_search and search.query and not filtered:
|
||||
if use_search and search.query and not nav:
|
||||
_addnstr(stdscr, items_start, 0, " No matches", max_x - 1, curses.A_DIM)
|
||||
for draw_i, i in enumerate(filtered[scroll_offset : scroll_offset + visible_rows]):
|
||||
y = draw_i + items_start
|
||||
@@ -549,15 +568,17 @@ def _run_curses_menu(
|
||||
if changed:
|
||||
scroll_offset = 0
|
||||
if search.active: # Esc-clear resets scroll only; the loop re-reconciles
|
||||
cursor, cursor_pos = _reconcile_cursor(
|
||||
_filter_indices(search_labels, search.query), cursor)
|
||||
refiltered = _filter_indices(search_labels, search.query)
|
||||
cursor, _ = _reconcile_cursor(
|
||||
refiltered if selectable is None else [i for i in refiltered if selectable(i)],
|
||||
cursor)
|
||||
if action in (NAV_UP, NAV_DOWN):
|
||||
delta = -1 if action == NAV_UP else 1
|
||||
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, delta)
|
||||
cursor = _move_filtered_cursor(nav, cursor, _nav_pos, delta)
|
||||
continue
|
||||
if action is _CONSUMED or action not in _RESOLVING or (
|
||||
action == NAV_BACK and not allow_back) or (
|
||||
action == NAV_SELECT and use_search and not filtered):
|
||||
action == NAV_SELECT and use_search and not nav):
|
||||
continue
|
||||
if action in (NAV_CANCEL, NAV_INTERRUPT):
|
||||
_notify(MenuNavigationEvent.CANCEL)
|
||||
@@ -642,14 +663,20 @@ def _simple_header(title: str, confirm: str, cancel: str, searchable: bool):
|
||||
def curses_radiolist(
|
||||
title: str, items: list[RadioItem], selected: int = 0, *, cancel_returns: int | None = None,
|
||||
description: str | None = None, searchable: bool = False,
|
||||
search_labels: list[str] | None = None) -> int:
|
||||
search_labels: list[str] | None = None, headings: Optional[dict[int, str]] = None) -> int:
|
||||
"""Curses single-select radio list -> selected index.
|
||||
|
||||
Items are plain strings or ``(text, style)`` segment sequences (``None``/``"yellow"``/
|
||||
``"dim"``); the cursor row is forced green. ``description`` is shown between title and list
|
||||
so context survives the curses screen clear. With ``searchable``, ``/`` filters over
|
||||
``search_labels`` (default: display labels); the return value is always the ORIGINAL index.
|
||||
``headings`` (``{item_index: text}``) draws a dim section title above that item; it is never
|
||||
selectable and is hidden while a search query is active.
|
||||
"""
|
||||
if headings:
|
||||
return _radiolist_with_headings(
|
||||
title, items, selected, headings, cancel_returns=cancel_returns, description=description,
|
||||
searchable=searchable, search_labels=search_labels)
|
||||
if cancel_returns is None:
|
||||
cancel_returns = selected
|
||||
desc_lines = description.splitlines() if description else []
|
||||
@@ -693,7 +720,103 @@ def curses_radiolist(
|
||||
search_labels=search_labels if searchable else None)
|
||||
|
||||
|
||||
_ANSI_STYLE = {"yellow": Colors.YELLOW, "dim": Colors.DIM}
|
||||
def _radiolist_with_headings(
|
||||
title: str, items: List[RadioItem], selected: int, headings: dict[int, str], *,
|
||||
cancel_returns: int | None, description: str | None, searchable: bool,
|
||||
search_labels: List[str] | None) -> int:
|
||||
"""``curses_radiolist`` with dim, unselectable section titles (``{item_index: text}``) drawn
|
||||
above the given items. Headings carry an empty search label, so any query hides them. Returns
|
||||
the ORIGINAL item index, exactly like the plain list."""
|
||||
if cancel_returns is None:
|
||||
cancel_returns = selected
|
||||
rows: list[tuple[str, int]] = [] # ("h", heading_key) or ("i", original item index)
|
||||
for i in range(len(items)):
|
||||
if i in headings:
|
||||
rows.append(("h", i))
|
||||
rows.append(("i", i))
|
||||
row_of_item = {idx: r for r, (kind, idx) in enumerate(rows) if kind == "i"}
|
||||
labels = (list(search_labels) if search_labels is not None
|
||||
else [radio_item_plain(item) for item in items])
|
||||
row_labels = ["" if kind == "h" else labels[idx] for kind, idx in rows]
|
||||
desc_lines = description.splitlines() if description else []
|
||||
|
||||
def _draw_header(stdscr, max_y, max_x, search=None, back_enabled=False):
|
||||
row = 1
|
||||
choice_rows = min(max(1, len(rows)), 5)
|
||||
description_rows = max(0, max_y - 4 - choice_rows)
|
||||
visible_description = desc_lines[:description_rows]
|
||||
if len(desc_lines) > description_rows and visible_description:
|
||||
hidden = len(desc_lines) - description_rows + 1
|
||||
visible_description[-1] = f" ... {hidden} more description lines (enlarge terminal)"
|
||||
for dline in visible_description:
|
||||
_draw_description_line(stdscr, row, dline, max_x)
|
||||
row += 1
|
||||
hint = _search_hint(search, searchable, "ENTER/SPACE select", "ESC cancel", back_enabled)
|
||||
_draw_title_and_hint(stdscr, title, hint, max_x, hint_row=row)
|
||||
return row + 2
|
||||
|
||||
def _draw_row(stdscr, y, r, is_cursor, max_x):
|
||||
import curses
|
||||
kind, idx = rows[r]
|
||||
if kind == "h":
|
||||
_addnstr(stdscr, y, 0, f" {headings[idx]}", max_x - 1, curses.A_DIM | curses.A_BOLD)
|
||||
return
|
||||
radio, arrow = "\u25cf" if idx == selected else "\u25cb", "\u2192" if is_cursor else " "
|
||||
prefix = f" {arrow} ({radio}) "
|
||||
_draw_plain_row(stdscr, y, prefix, max_x, is_cursor=is_cursor)
|
||||
_draw_radio_item(stdscr, y, len(prefix), items[idx], max_x, is_cursor=is_cursor)
|
||||
|
||||
def _on_action(action, cursor):
|
||||
if action in (NAV_SELECT, NAV_TOGGLE):
|
||||
return rows[cursor][1]
|
||||
return cancel_returns
|
||||
|
||||
return _run_curses_menu(
|
||||
initial_cursor=row_of_item.get(selected, 0), item_count=len(rows), draw_header=_draw_header,
|
||||
draw_row=_draw_row, on_action=_on_action, extra_color_pairs=True,
|
||||
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns, headings=headings),
|
||||
cancel_value=cancel_returns, searchable=searchable,
|
||||
search_labels=row_labels if searchable else None,
|
||||
selectable=lambda r: rows[r][0] == "i")
|
||||
|
||||
|
||||
_ANSI_STYLE = {"yellow": Colors.YELLOW, "dim": Colors.DIM, "green": Colors.GREEN, "red": Colors.RED}
|
||||
# Exactly the ``render_bar`` shape (``model_picker_usage.BAR_WIDTH`` = 10 cells of ``|`` then spaces), so
|
||||
# an unrelated ``[]`` / ``[ ]`` in a description is never painted as a gauge.
|
||||
_GAUGE_BAR_RE = re.compile(r"\[(?=[| ]{10}\])\|* *\]")
|
||||
|
||||
|
||||
def _gauge_bar_style(bar: str) -> str:
|
||||
"""Map a ``[|||| ]`` cell to green/yellow/red from how full it is (what is USED)."""
|
||||
inner = bar[1:-1]
|
||||
used = inner.count("|") / len(inner) if inner else 0.0
|
||||
if used > 0.8:
|
||||
return "red"
|
||||
if used > 0.5:
|
||||
return "yellow"
|
||||
return "green"
|
||||
|
||||
|
||||
def _gauge_bar_segments(text: str) -> list[tuple[str, str | None]]:
|
||||
"""Split *text* so each ``[|||| ]`` is its own styled segment."""
|
||||
segs: list[tuple[str, str | None]] = []
|
||||
last = 0
|
||||
for match in _GAUGE_BAR_RE.finditer(text):
|
||||
if match.start() > last:
|
||||
segs.append((text[last:match.start()], None))
|
||||
segs.append((match.group(), _gauge_bar_style(match.group())))
|
||||
last = match.end()
|
||||
if last < len(text):
|
||||
segs.append((text[last:], None))
|
||||
return segs or [(text, None)]
|
||||
|
||||
|
||||
def colorize_gauge_bars_ansi(text: str) -> str:
|
||||
"""Color ``[|||| ]`` runs in a header/legend line (numbered-fallback path)."""
|
||||
return "".join(
|
||||
color(chunk, _ANSI_STYLE[style]) if style in _ANSI_STYLE else chunk
|
||||
for chunk, style in _gauge_bar_segments(text)
|
||||
)
|
||||
|
||||
|
||||
def format_radio_item_ansi(item: RadioItem) -> str:
|
||||
@@ -705,11 +828,14 @@ def format_radio_item_ansi(item: RadioItem) -> str:
|
||||
|
||||
|
||||
def _radio_numbered_fallback(
|
||||
title: str, items: list[RadioItem], selected: int, cancel_returns: int) -> int:
|
||||
"""Text-based numbered fallback for radio selection."""
|
||||
title: str, items: list[RadioItem], selected: int, cancel_returns: int, *,
|
||||
headings: Optional[dict[int, str]] = None) -> int:
|
||||
"""Text-based numbered fallback for radio selection (section titles printed, never numbered)."""
|
||||
print(color(f"\n {title}", Colors.YELLOW))
|
||||
print(color(" Select by number, Enter to confirm.\n", Colors.DIM))
|
||||
for i, label in enumerate(items):
|
||||
if headings and i in headings:
|
||||
print(color(f" {headings[i]}", Colors.DIM))
|
||||
marker = color("(\u25cf)", Colors.GREEN) if i == selected else "(\u25cb)"
|
||||
print(f" {marker} {i + 1:>2}. {format_radio_item_ansi(label)}")
|
||||
print()
|
||||
|
||||
+50
-1
@@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from contextvars import copy_context
|
||||
from dataclasses import dataclass, replace
|
||||
from threading import Lock, Thread, current_thread
|
||||
@@ -271,7 +272,9 @@ def _apply_limits(rows: list[dict]) -> None:
|
||||
pooled = {slug for slug, entries in read_credential_pool().items() if entries}
|
||||
for row in rows:
|
||||
slug = str(row.get("slug") or "")
|
||||
if slug not in pooled or row.get("is_user_defined"):
|
||||
# Window plans say their limits in plan_usage (gauges + limit line); a credential cooldown chip
|
||||
# beside them would show a second, unrelated reset time.
|
||||
if slug not in pooled or row.get("is_user_defined") or row.get("plan_usage"):
|
||||
continue
|
||||
try:
|
||||
state = load_pool(slug).limit_state(row.get("models") or [])
|
||||
@@ -862,6 +865,41 @@ def _reorder_canonical(rows: list[dict]) -> list[dict]:
|
||||
return canon + extras
|
||||
|
||||
|
||||
def _attach_nous_plan_usage(row: dict, *, warm: bool):
|
||||
"""Stamp ``plan_usage`` from the portal snapshot (window plans only) and return the PickerUsage (or
|
||||
None). A cache-only open reads the last snapshot at any age: the 60s account cache has usually lapsed
|
||||
by the time someone opens the composer menu. Accounts without a window plan get nothing."""
|
||||
try:
|
||||
from hermes_cli.model_picker_usage import nous_picker_usage
|
||||
usage = nous_picker_usage(warm=warm, any_age=not warm)
|
||||
except Exception:
|
||||
return None
|
||||
if usage is not None:
|
||||
row["plan_usage"] = usage.to_payload()
|
||||
# Main's subscription-usage chip (`usage.windows`): the same gauge for a window
|
||||
# plan, so it warns near a limit in the existing slot. Once an allowance is full the picker's limit
|
||||
# line says it (with what happens next), so the chip would only repeat it; paused plans say so in
|
||||
# the plan line.
|
||||
if not usage.paused_reason and not row.get("usage") and not any(g.left_fraction <= 0.0 for g in usage.gauges):
|
||||
windows = [_plan_usage_window(g) for g in usage.gauges if g.resets_at_ms]
|
||||
if windows:
|
||||
row["usage"] = {"windows": windows}
|
||||
return usage
|
||||
|
||||
|
||||
def _plan_usage_window(g) -> dict:
|
||||
"""One window-plan allowance in main's ``ProviderUsageWindow`` shape. The label is Hermes's row title
|
||||
(``Today``), like the other providers' English window labels."""
|
||||
from datetime import datetime
|
||||
from agent.usage_gauges import WindowGauge, gauge_title
|
||||
return {
|
||||
"label": gauge_title(WindowGauge(g.label, g.scope, g.title or g.label, 0)),
|
||||
"used_percent": round(g.used_fraction * 100.0, 2),
|
||||
"resets_at": datetime.fromtimestamp(g.resets_at_ms / 1000, UTC).isoformat(),
|
||||
"scope": "account" if g.scope == "all" else "model",
|
||||
}
|
||||
|
||||
|
||||
def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cached_only: bool = False) -> None:
|
||||
"""Set ``row["pricing"] = {model_id: {input, output, cache | None, free}}``; for Nous also
|
||||
``free_tier`` (account is free-tier) and ``unavailable_models`` (paid models a free user can't pick).
|
||||
@@ -897,6 +935,7 @@ def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cac
|
||||
except Exception:
|
||||
raw_pricing = {}
|
||||
cached_nous_tier: Optional[bool] = None
|
||||
plan_usage = _attach_nous_plan_usage(row, warm=not cached_only) if slug == "nous" else None
|
||||
if slug == "nous" and cached_only:
|
||||
cached_nous_tier = get_cached_nous_free_tier()
|
||||
if cached_nous_tier is None:
|
||||
@@ -956,6 +995,16 @@ def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cac
|
||||
except Exception: # tier detection failed — fail open (no gating)
|
||||
row["free_tier"] = False
|
||||
row["unavailable_models"] = []
|
||||
if plan_usage is not None:
|
||||
from hermes_cli.model_picker_usage import usage_levels
|
||||
# The gateway's per-plan word per model (free / low / medium / top_tier).
|
||||
row["usage_levels"] = usage_levels(list(models), raw_pricing)
|
||||
# Top-tier marks from the gateway (it decides, not the client).
|
||||
with contextlib.suppress(Exception):
|
||||
row["plan_usage"] = plan_usage.with_pricing(
|
||||
{m: raw_pricing.get(m) for m in models}).to_payload()
|
||||
# Window plans: the picker shows the usage word, not per-token prices.
|
||||
row.pop("pricing", None)
|
||||
|
||||
|
||||
def _local_runtime_row(ctx: ConfigContext) -> dict | None:
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Plan-usage chrome for the model pickers (CLI radiolist and the gateway ``model.options`` inventory).
|
||||
|
||||
On a usage-window plan every listed model is included, so the picker shows how much of each allowance
|
||||
is used instead of per-token prices. Each model row carries one word, ``Small`` / ``Medium`` /
|
||||
``Large`` / ``Free``, that the gateway sends per caller (``usage_level`` on ``/v1/models``).
|
||||
|
||||
Every other account (credit-grant, pay-as-you-go, free tier) gets nothing from here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
BAR_WIDTH = 10
|
||||
BAR_FILL = "|"
|
||||
BAR_EMPTY = " "
|
||||
|
||||
GaugeStyle = Literal["green", "yellow", "red"]
|
||||
|
||||
# Remaining-fraction thresholds (allowance left). Green under half used, red at 80%+ used.
|
||||
_YELLOW_BELOW = 0.5
|
||||
_RED_BELOW = 0.2
|
||||
|
||||
# Per-model label on a window plan, as the gateway sends it (``usage_level``). Display words only.
|
||||
USAGE_LEVELS = ("free", "low", "medium", "top_tier")
|
||||
# Words, limit lines and switch notices come from ``plan_usage.*`` in locales/ (``usage_level_label``,
|
||||
# ``_t``); the `hermes model` setup picker passes ``lang="en"`` (its own text is English, as on main).
|
||||
# One line for that picker, shown only when a Large row is listed.
|
||||
TOP_TIER_FOOTNOTE = "Large models also use your monthly large-model limit."
|
||||
# Window pickers split the list here; messaging pickers just order recommended first.
|
||||
SECTION_RECOMMENDED = "Recommended"
|
||||
SECTION_OTHER = "Other models"
|
||||
|
||||
_BP_FULL = 10_000
|
||||
|
||||
# The server's allowance ids Hermes has its own words for (agent/usage_gauges.py); any other id
|
||||
# renders with the server's label.
|
||||
GAUGE_TODAY = "day"
|
||||
GAUGE_WEEK = "week"
|
||||
GAUGE_MONTH = "month"
|
||||
GAUGE_TOP_TIER = "top_tier"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageGauge:
|
||||
"""One server allowance and when it refills, in the server's order. The wire carries ``left_fraction``;
|
||||
every surface draws it as USED (bar fills 0 → 100, same as the portal's "N% used"). ``scope``: which
|
||||
models a full one stops (``all`` / ``top_tier``). ``title``: the server's display fallback."""
|
||||
|
||||
label: str # the server's id
|
||||
left_fraction: float
|
||||
resets_at_ms: Optional[int] = None
|
||||
scope: str = "all"
|
||||
title: str = ""
|
||||
|
||||
@property
|
||||
def used_fraction(self) -> float:
|
||||
return 1.0 - clamp_fraction(self.left_fraction)
|
||||
|
||||
@property
|
||||
def style(self) -> GaugeStyle:
|
||||
return gauge_style(self.left_fraction)
|
||||
|
||||
@property
|
||||
def bar(self) -> str:
|
||||
return render_bar(self.used_fraction)
|
||||
|
||||
@property
|
||||
def percent_used(self) -> str:
|
||||
return format_percent_used(self.used_fraction)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PickerUsage:
|
||||
"""What the pickers need to draw a usage-window plan, derived from the portal account snapshot.
|
||||
Window plans only: other accounts (credit-grant, pay-as-you-go, free) get no plan chrome."""
|
||||
|
||||
plan: Optional[str]
|
||||
gauges: tuple[UsageGauge, ...]
|
||||
paused_reason: Optional[str] = None
|
||||
# Window only: our picks, in order; pickers list them first. Every model is usable.
|
||||
featured_model_ids: tuple[str, ...] = ()
|
||||
# Window only: model ids the gateway labels Large in this caller's ``/v1/models``. Empty until applied.
|
||||
top_tier_ids: frozenset[str] = frozenset()
|
||||
# Window only: model ids that draw on nothing (the gateway's ``free`` word or a free catalog row). A full
|
||||
# allowance never blocks them. Empty until applied.
|
||||
free_ids: frozenset[str] = frozenset()
|
||||
# Window only: past a full allowance, does the turn bill top-up credits (pay as you go)? True: "Keep
|
||||
# going on credits" is on and there is a top-up balance. False: it stops. None: unknown (older portal).
|
||||
credits_pay: Optional[bool] = None
|
||||
|
||||
def gauge(self, label: str) -> Optional[UsageGauge]:
|
||||
return next((g for g in self.gauges if g.label == label), None)
|
||||
|
||||
def is_top_tier(self, model_id: str) -> bool:
|
||||
"""The gateway's ``top_tier`` mark. Unmarked: not top tier (fail open)."""
|
||||
return model_id in self.top_tier_ids
|
||||
|
||||
def with_pricing(self, pricing: Optional[dict[str, dict[str, Any]]]) -> "PickerUsage":
|
||||
"""A copy that reads the gateway's ``top_tier`` marks from *pricing* (picker pricing shape)."""
|
||||
if not pricing:
|
||||
return self
|
||||
from agent.plan_limits import is_free
|
||||
free = frozenset(m for m in pricing if is_free(m, pricing))
|
||||
marked = frozenset(m for m, p in pricing.items() if p and p.get("top_tier") is True and m not in free)
|
||||
from dataclasses import replace
|
||||
return replace(self, top_tier_ids=marked, free_ids=free)
|
||||
|
||||
def exhausted_gauge(self, model_id: str) -> Optional[UsageGauge]:
|
||||
"""Window only: the spent allowance that stops this model (an all-scope one, or the top-tier one for a
|
||||
top-tier model; of several, the one that frees up last), or None while it still has included room. A
|
||||
free model is never stopped. Same rule as agent/plan_limits.py ``blocking_gauge``."""
|
||||
if model_id in self.free_ids or model_id.endswith(":free"):
|
||||
return None
|
||||
spent = [g for g in self.gauges_for_model(model_id) if g.left_fraction <= 0.0]
|
||||
return max(spent, key=lambda g: g.resets_at_ms or 0) if spent else None
|
||||
|
||||
def blocks(self, model_id: str) -> bool:
|
||||
"""Window only: a full allowance stops this model and top-up credits won't pay for it, so a picker
|
||||
dims the row (the limit line says why, once). Same rule as apps/shared ``planBlocks``."""
|
||||
return self.credits_pay is not True and self.exhausted_gauge(model_id) is not None
|
||||
|
||||
def _full_all_gauge(self) -> Optional[UsageGauge]:
|
||||
spent = [g for g in self.gauges if g.scope == "all" and g.left_fraction <= 0.0]
|
||||
return max(spent, key=lambda g: g.resets_at_ms or 0) if spent else None
|
||||
|
||||
def limit_line(self, lang: Optional[str] = None) -> Optional[str]:
|
||||
"""One line for the top of a window-plan picker once an allowance is full, saying what happens
|
||||
next: ``Weekly limit reached · resets in 21h · paid models now use your credits (pay as you go)``.
|
||||
None while nothing is full (and while paused: the plan header already says so)."""
|
||||
if self.paused_reason:
|
||||
return None
|
||||
full = self._full_all_gauge()
|
||||
top = None if full is not None else next(
|
||||
(g for g in self.gauges if g.scope == GAUGE_TOP_TIER and g.left_fraction <= 0.0), None)
|
||||
g = full or top
|
||||
if g is None:
|
||||
return None
|
||||
head = _capitalize(_t("limit_reached", lang, name=_limit_name(g, lang), resets=_resets(g.resets_at_ms, lang)))
|
||||
large = "" if full is not None else "_large"
|
||||
if self.credits_pay:
|
||||
return f"{head} · {_t('then_credits' + large, lang)}"
|
||||
if self.credits_pay is False:
|
||||
return f"{head} · {_t('then_wait' + large, lang)}"
|
||||
return head
|
||||
|
||||
def large_model_notice(self, model_id: str, *, detail: bool = True, lang: Optional[str] = None) -> str:
|
||||
"""The warning shown when someone switches to a Large model (never a confirm). ``detail`` adds the
|
||||
allowance state; pass False for a stale snapshot, whose percents could mislead."""
|
||||
g = self.exhausted_gauge(model_id) if detail and not self.paused_reason else None
|
||||
if g is not None:
|
||||
if self.credits_pay:
|
||||
return _t("large_notice_credits", lang)
|
||||
key = "large_notice_limit_stops" if self.credits_pay is False else "large_notice_limit"
|
||||
return _t(key, lang, name=_limit_name(g, lang), resets=_resets(g.resets_at_ms, lang))
|
||||
top = next((g for g in self.gauges if g.scope == GAUGE_TOP_TIER), None) if detail else None
|
||||
if top is not None:
|
||||
return _t("large_notice_percent", lang, percent=top.percent_used)
|
||||
return _t("large_notice", lang)
|
||||
|
||||
@property
|
||||
def included_summary(self) -> str:
|
||||
"""Header suffix: every row is included, unless an allowance is full (then what happens next) or the
|
||||
plan is paused."""
|
||||
if self.paused_reason:
|
||||
return "included usage paused"
|
||||
# Only the `hermes model` setup picker shows this, and its own text is English.
|
||||
return self.limit_line(lang="en") or "every model below is included"
|
||||
|
||||
def gauges_for_model(self, model_id: str) -> tuple[UsageGauge, ...]:
|
||||
"""Every all-scope allowance, plus the top-tier ones when the model draws on them."""
|
||||
top = self.is_top_tier(model_id)
|
||||
return tuple(g for g in self.gauges if g.scope == "all" or (g.scope == "top_tier" and top))
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""Wire shape for ``model.options`` (``ModelOptionProvider.plan_usage``)."""
|
||||
return {
|
||||
"kind": "usage_window",
|
||||
"plan": self.plan,
|
||||
"gauges": [
|
||||
{"label": g.label, "left_fraction": round(g.left_fraction, 4), "style": g.style,
|
||||
"resets_at_ms": g.resets_at_ms, "scope": g.scope, "title": g.title}
|
||||
for g in self.gauges
|
||||
],
|
||||
# The gateway's marks (what the desktop's used-up notes read).
|
||||
"top_tier_model_ids": sorted(self.top_tier_ids),
|
||||
"featured_model_ids": list(self.featured_model_ids),
|
||||
"paused_reason": self.paused_reason,
|
||||
"credits_pay": self.credits_pay,
|
||||
}
|
||||
|
||||
|
||||
def clamp_fraction(value: float) -> float:
|
||||
return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value
|
||||
|
||||
|
||||
def gauge_style(left_fraction: float) -> GaugeStyle:
|
||||
"""Allowance color: green under half used, yellow from 50%, red once 80%+ is used."""
|
||||
left = clamp_fraction(left_fraction)
|
||||
if left < _RED_BELOW:
|
||||
return "red"
|
||||
if left < _YELLOW_BELOW:
|
||||
return "yellow"
|
||||
return "green"
|
||||
|
||||
|
||||
def _filled_cells(fraction: float, width: int) -> int:
|
||||
value = clamp_fraction(fraction)
|
||||
filled = round(value * width)
|
||||
if value > 0.0 and filled == 0:
|
||||
filled = 1
|
||||
if value < 1.0 and filled == width:
|
||||
filled = width - 1
|
||||
return filled
|
||||
|
||||
|
||||
def render_bar(fraction: float, width: int = BAR_WIDTH) -> str:
|
||||
"""``[|||| ]`` — filled cells are *fraction* (usage gauges pass the USED fraction; cost bars
|
||||
their intensity). Any non-zero value shows at least one cell and a non-full one always leaves one
|
||||
empty, so "barely used" never reads as untouched and "almost out" never reads as exhausted."""
|
||||
filled = _filled_cells(fraction, width)
|
||||
return f"[{BAR_FILL * filled}{BAR_EMPTY * (width - filled)}]"
|
||||
|
||||
|
||||
def featured_first(model_ids: list[str], featured: tuple[str, ...] | list[str]) -> list[str]:
|
||||
"""The plan's featured picks first (plan order, only those listed), then the rest unchanged."""
|
||||
head, rest = split_recommended(model_ids, featured)
|
||||
return head + rest
|
||||
|
||||
|
||||
def split_recommended(
|
||||
model_ids: list[str], featured: tuple[str, ...] | list[str],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""``(recommended, other)``: the plan's featured picks that are listed (plan order), then every
|
||||
other listed model in its catalog order. No featured picks: ``([], model_ids)``."""
|
||||
listed = set(model_ids)
|
||||
head = [m for m in dict.fromkeys(featured or ()) if m in listed]
|
||||
seen = set(head)
|
||||
return head, [m for m in model_ids if m not in seen]
|
||||
|
||||
|
||||
def usage_levels(model_ids: list[str], pricing: Optional[dict[str, dict[str, Any]]]) -> dict[str, str]:
|
||||
"""``{model: "free" | "low" | "medium" | "top_tier"}`` from what the gateway stamped on this
|
||||
caller's catalog. An older gateway sends no ``usage_level``: then only what it does send is used
|
||||
(its ``top_tier`` mark, and a $0/$0 row is ``free``); every other row gets no label."""
|
||||
from agent.plan_limits import is_free
|
||||
out: dict[str, str] = {}
|
||||
for mid in model_ids:
|
||||
row = (pricing or {}).get(mid) or {}
|
||||
level = row.get("usage_level")
|
||||
if is_free(mid, pricing):
|
||||
out[mid] = "free"
|
||||
elif level in USAGE_LEVELS:
|
||||
out[mid] = level
|
||||
elif row.get("top_tier") is True:
|
||||
out[mid] = "top_tier"
|
||||
return out
|
||||
|
||||
|
||||
def is_free_model(pricing_row: Optional[dict[str, Any]]) -> bool:
|
||||
"""True when the catalog lists the model at $0 in and $0 out (``:free`` / natively free). A row
|
||||
with no pricing is NOT free — unknown price is not a promise."""
|
||||
from hermes_cli.models_pricing import _price_float
|
||||
if not pricing_row:
|
||||
return False
|
||||
inp = _price_float(pricing_row.get("prompt"), positive=False)
|
||||
out = _price_float(pricing_row.get("completion"), positive=False)
|
||||
return inp is not None and out is not None and inp <= 0.0 and out <= 0.0
|
||||
|
||||
|
||||
def format_percent_used(used_fraction: float) -> str:
|
||||
"""Whole-percent used, same rule as the portal (``formatUsedPercentLabel``): floor, any sliver
|
||||
reads ``<1%`` (never ``0%``), and ``100%`` only once the allowance is actually exhausted."""
|
||||
# round() first: used = 1 - left can land a hair under an exact basis point (0.00999…).
|
||||
bp = int(round(clamp_fraction(used_fraction) * _BP_FULL, 6))
|
||||
if bp <= 0:
|
||||
return "0%"
|
||||
if bp < 100:
|
||||
return "<1%"
|
||||
return f"{bp // 100}%"
|
||||
|
||||
|
||||
def compact_duration(resets_at_ms: Optional[int], now_ms: Optional[int] = None) -> str:
|
||||
"""``"5h"`` / ``"2d 3h"`` / ``"12m"`` until *resets_at_ms*; ``""`` when past, under a minute or unknown."""
|
||||
if not isinstance(resets_at_ms, int):
|
||||
return ""
|
||||
now = int(time.time() * 1000) if now_ms is None else now_ms
|
||||
seconds = max(0, (resets_at_ms - now) // 1000)
|
||||
if seconds < 60:
|
||||
return ""
|
||||
days, rem = divmod(seconds, 86_400)
|
||||
hours, rem = divmod(rem, 3_600)
|
||||
minutes = rem // 60
|
||||
if days:
|
||||
return f"{days}d {hours}h" if hours else f"{days}d"
|
||||
return f"{hours}h" if hours else f"{minutes}m"
|
||||
|
||||
|
||||
def format_resets_in(resets_at_ms: Optional[int], now_ms: Optional[int] = None) -> str:
|
||||
"""``"resets in 5h"`` / ``"resets in 2d 3h"`` / ``"resets soon"`` (past or unknown → soon). English:
|
||||
``/usage`` (untranslated, like main's credits lines); pickers use the catalog's words."""
|
||||
d = compact_duration(resets_at_ms, now_ms)
|
||||
return f"resets in {d}" if d else "resets soon"
|
||||
|
||||
|
||||
def _t(key: str, lang: Optional[str] = None, **kwargs: Any) -> str:
|
||||
from agent.i18n import t
|
||||
return t(f"plan_usage.{key}", lang, **kwargs)
|
||||
|
||||
|
||||
def _resets(resets_at_ms: Optional[int], lang: Optional[str] = None) -> str:
|
||||
d = compact_duration(resets_at_ms)
|
||||
return _t("resets_in", lang, duration=d) if d else _t("resets_soon", lang)
|
||||
|
||||
|
||||
def _capitalize(text: str) -> str:
|
||||
return text[:1].upper() + text[1:]
|
||||
|
||||
|
||||
def _limit_name(g: "UsageGauge", lang: Optional[str] = None) -> str:
|
||||
"""``weekly`` for ``Weekly limit reached`` / ``your weekly limit``; an unknown id shows the server's title."""
|
||||
if g.label in (GAUGE_TODAY, GAUGE_WEEK, GAUGE_MONTH, GAUGE_TOP_TIER):
|
||||
return _t(f"limit_{g.label}", lang)
|
||||
return g.title or g.label
|
||||
|
||||
|
||||
# The levels a picker row names: Large (its own monthly limit) and Free (never runs out). Small / Medium
|
||||
# only change how fast usage drains, so they carry no tag.
|
||||
SHOWN_USAGE_LEVELS = ("free", "top_tier")
|
||||
|
||||
|
||||
def usage_level_label(level: Optional[str], lang: Optional[str] = None) -> str:
|
||||
"""The row tag in the catalog language (``Large`` / ``Free``); "" for every other level."""
|
||||
return _t(f"level_{level}", lang) if level in SHOWN_USAGE_LEVELS else ""
|
||||
|
||||
|
||||
def _get(obj: Any, name: str, default: Any = None) -> Any:
|
||||
if obj is None:
|
||||
return default
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(name, default)
|
||||
return getattr(obj, name, default)
|
||||
|
||||
|
||||
def picker_usage_from_account(info: Any) -> Optional[PickerUsage]:
|
||||
"""``NousPortalAccountInfo`` (or a dict shaped like one) → PickerUsage, or None when there is nothing to
|
||||
gauge: only a usage-window plan has plan chrome (credit-grant, pay-as-you-go and free tier get
|
||||
None, as does an unreadable snapshot). Never raises."""
|
||||
try:
|
||||
if info is None or not _get(info, "logged_in", True):
|
||||
return None
|
||||
sub = _get(info, "subscription")
|
||||
plan = _get(sub, "plan")
|
||||
plan = plan if isinstance(plan, str) and plan else None
|
||||
window = _get(info, "usage_window")
|
||||
if window is None:
|
||||
return None
|
||||
from agent.usage_gauges import all_scope, parse_gauges
|
||||
parsed = parse_gauges(_get(window, "gauges"))
|
||||
if not parsed or not all_scope(parsed):
|
||||
return None # same requirement as the tracker seed: an allowance that stops every model
|
||||
gauges = [UsageGauge(g.id, 1.0 - g.used_fraction, g.resets_at_ms, g.scope, g.label) for g in parsed]
|
||||
featured = tuple(m for m in (_get(window, "featured_model_ids") or ()) if isinstance(m, str) and m)
|
||||
paused = _get(window, "paused_reason")
|
||||
return PickerUsage(
|
||||
plan=plan, gauges=tuple(gauges),
|
||||
paused_reason=paused if isinstance(paused, str) and paused else None,
|
||||
featured_model_ids=featured,
|
||||
credits_pay=_credits_pay(info, window),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _credits_pay(info: Any, window: Any) -> Optional[bool]:
|
||||
"""Past a full allowance, top-up credits pay (pay as you go): "Keep going on credits" is not off AND a
|
||||
top-up balance is left. Same rule as ``CreditsState.credits_pay``. None when the balance is unknown."""
|
||||
if _get(window, "keep_going_on_credits") is False:
|
||||
return False
|
||||
purchased = _get(_get(info, "paid_service_access_info"), "purchased_credits_remaining")
|
||||
if not isinstance(purchased, (int, float)) or isinstance(purchased, bool):
|
||||
return None
|
||||
return purchased > 0
|
||||
|
||||
|
||||
def nous_picker_usage(*, warm: bool = False, any_age: bool = False) -> Optional[PickerUsage]:
|
||||
"""PickerUsage from the portal account snapshot. Cache-only by default so a picker never adds a
|
||||
round trip; ``warm=True`` fetches once (fills the 60s cache) so a window plan is known
|
||||
on a cold start. ``any_age`` reads the last snapshot past the 60s TTL (plan-shape callers only:
|
||||
its percents may be stale). Never raises."""
|
||||
try:
|
||||
from hermes_cli.nous_account import cached_nous_portal_account_info, get_nous_portal_account_info
|
||||
if warm and cached_nous_portal_account_info() is None:
|
||||
# A known window plan needs a real fetch: the JWT snapshot carries no gauges and never fills the cache.
|
||||
from agent.credits_tracker import _window_seed_fields
|
||||
on_window = _window_seed_fields(_get(cached_nous_portal_account_info(any_age=True), "usage_window")) is not None
|
||||
get_nous_portal_account_info(force_fresh=on_window)
|
||||
return picker_usage_from_account(cached_nous_portal_account_info(any_age=any_age))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _nous_catalog(base_url: str = "", *, fetch: bool = False) -> dict[str, dict[str, Any]]:
|
||||
"""The caller's Nous ``/v1/models`` rows: resident cache first, one fetch only when ``fetch``."""
|
||||
from hermes_cli.models_pricing import get_pricing_for_provider, peek_cached_pricing
|
||||
rows = get_pricing_for_provider("nous", cached_only=True) or {}
|
||||
if not rows:
|
||||
if not base_url:
|
||||
from hermes_cli.auth import DEFAULT_NOUS_INFERENCE_URL, _nous_inference_env_override
|
||||
base_url = _nous_inference_env_override() or DEFAULT_NOUS_INFERENCE_URL
|
||||
rows = peek_cached_pricing(base_url) or {}
|
||||
if not rows and fetch:
|
||||
rows = get_pricing_for_provider("nous") or {}
|
||||
return rows
|
||||
|
||||
|
||||
def large_model_notice(
|
||||
model_id: str, provider: str, *, base_url: str = "", fetch: bool = False, lang: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Warning line for a switch to a Large model, or None. Window plans only. The gateway decides "Large"
|
||||
(``usage_level: top_tier``; older gateways ``top_tier: true``) and stamps it ONLY on a usage-window
|
||||
caller's catalog, so a marked row is itself proof of a window plan. With a plan snapshot the catalog may
|
||||
be fetched once (``fetch``); without one only the resident cache is read, so any other account adds no
|
||||
network call. Never raises."""
|
||||
try:
|
||||
if str(provider or "").strip().lower() != "nous" or not model_id:
|
||||
return None
|
||||
fresh = nous_picker_usage()
|
||||
usage = fresh or nous_picker_usage(any_age=True)
|
||||
rows = _nous_catalog(base_url, fetch=fetch and usage is not None)
|
||||
row = rows.get(model_id)
|
||||
if not row:
|
||||
return None
|
||||
if usage_levels([model_id], {model_id: row}).get(model_id) != "top_tier":
|
||||
return None
|
||||
if usage is None:
|
||||
return _t("large_notice", lang)
|
||||
return usage.with_pricing({model_id: row}).large_model_notice(model_id, detail=fresh is not None, lang=lang)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def window_row_labels(provider_row: Any, model_ids: list) -> list[str]:
|
||||
"""CLI picker rows: ``model-b Large`` from the payload's ``usage_levels``, which only a window plan's
|
||||
row carries (the TUI's rule: Large / Free only). Any other row, or anything unexpected: the bare ids."""
|
||||
ids = [str(m) for m in model_ids]
|
||||
plan = provider_row.get("plan_usage") if isinstance(provider_row, dict) else None
|
||||
levels = provider_row.get("usage_levels") if isinstance(plan, dict) and plan.get("kind") == "usage_window" else None
|
||||
tags = [usage_level_label(levels.get(m)) for m in ids] if isinstance(levels, dict) else []
|
||||
if not any(tags):
|
||||
return ids
|
||||
width = min(max(len(m) for m in ids), 48)
|
||||
return [f"{m.ljust(width)} {tag}".rstrip() for m, tag in zip(ids, tags)]
|
||||
|
||||
|
||||
def picker_row_suffix(provider_row: Any, model_id: str) -> str:
|
||||
"""Messaging pickers: ``Large`` / ``Free`` for one row, from the labels
|
||||
``model_switch_providers._attach_window_model_notes`` stamps (window plans only); "" otherwise."""
|
||||
if not isinstance(provider_row, dict):
|
||||
return ""
|
||||
return str((provider_row.get("model_labels") or {}).get(model_id) or "")
|
||||
|
||||
|
||||
def _gauge_name(g: UsageGauge) -> str:
|
||||
from agent.usage_gauges import WindowGauge, gauge_short
|
||||
return gauge_short(WindowGauge(g.label, g.scope, g.title or g.label, 0))
|
||||
|
||||
|
||||
def plan_gauge_lines(usage: PickerUsage) -> list[str]:
|
||||
"""Stacked plan summary: one line per server allowance, in the server's order, so the picker header
|
||||
stays readable at a normal terminal width."""
|
||||
head = (usage.plan or "Hermes plan") + (" (paused: renewal unpaid)" if usage.paused_reason else "")
|
||||
label_w = max((len(_gauge_name(g)) for g in usage.gauges), default=4)
|
||||
return [head, *(
|
||||
f"{_gauge_name(g):<{label_w}} {g.bar} {g.percent_used + ' used':<9} {format_resets_in(g.resets_at_ms)}"
|
||||
for g in usage.gauges
|
||||
)]
|
||||
@@ -341,17 +341,27 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
_policy_notice = nous_policy_notice(removed=_policy_narrowed)
|
||||
if _policy_notice:
|
||||
print(_policy_notice)
|
||||
from hermes_cli.model_picker_usage import featured_first, nous_picker_usage
|
||||
_usage = nous_picker_usage(warm=True)
|
||||
# Window plans: every model is usable; the plan's featured picks lead the list.
|
||||
if _usage is not None:
|
||||
model_ids = featured_first(model_ids, _usage.featured_model_ids)
|
||||
print(f'Showing {len(model_ids)} curated models — use "Enter custom model name" for others.')
|
||||
|
||||
selected = _prompt_model_selection(
|
||||
model_ids, current_model=current_model, pricing=pricing, unavailable_models=unavailable_models,
|
||||
portal_url=_nous_portal_url, unavailable_message=unavailable_message, confirm_provider="nous",
|
||||
confirm_base_url=creds.get("base_url", ""), confirm_api_key=creds.get("api_key", ""))
|
||||
confirm_base_url=creds.get("base_url", ""), confirm_api_key=creds.get("api_key", ""),
|
||||
usage=_usage)
|
||||
if not selected:
|
||||
print("No change.")
|
||||
return
|
||||
config = _nous_persist_selection(selected, creds)
|
||||
print(f"Default model set to: {selected} (via Nous Portal)")
|
||||
if _usage is not None:
|
||||
from hermes_cli.model_picker_usage import large_model_notice
|
||||
if _notice := large_model_notice(selected, "nous", base_url=creds.get("base_url", "")):
|
||||
print(f"⚠ {_notice}")
|
||||
# Offer Tool Gateway enablement for paid subscribers
|
||||
prompt_enable_tool_gateway(config)
|
||||
|
||||
|
||||
@@ -456,6 +456,9 @@ class ModelSwitchResult:
|
||||
request_overrides: Optional[dict] = None
|
||||
error_message: str = ""
|
||||
warning_message: str = ""
|
||||
# Window plans only: the Large-model warning (display only, never a confirm). Empty for every other
|
||||
# account and model, so their switch output is unchanged.
|
||||
usage_notice: str = ""
|
||||
provider_label: str = ""
|
||||
resolved_via_alias: str = ""
|
||||
capabilities: Optional[ModelCapabilities] = None
|
||||
@@ -1727,6 +1730,9 @@ def _build_switch_result(st: _Switch) -> ModelSwitchResult:
|
||||
model_info = get_model_info(st.target_provider, st.new_model, allow_network=True)
|
||||
|
||||
warnings = [w for w in (st.validation.get("message"), _check_hermes_model_warning(st.new_model)) if w]
|
||||
from hermes_cli.model_picker_usage import large_model_notice
|
||||
usage_notice = large_model_notice(
|
||||
st.new_model, st.target_provider, base_url=str(st.base_url or ""), fetch=True) or ""
|
||||
|
||||
# Carry the switched provider's request_overrides (custom_providers ``extra_body`` such as
|
||||
# chat_template_kwargs) so the gateway applies them like the default-provider path does.
|
||||
@@ -1741,7 +1747,7 @@ def _build_switch_result(st: _Switch) -> ModelSwitchResult:
|
||||
success=True, new_model=st.new_model, target_provider=st.target_provider,
|
||||
provider_changed=st.provider_changed, api_key=st.api_key, base_url=st.base_url, api_mode=st.api_mode,
|
||||
request_overrides=dict(request_overrides or {}), warning_message=" | ".join(warnings) if warnings else "",
|
||||
provider_label=st.provider_label, resolved_via_alias=st.resolved_alias, capabilities=capabilities,
|
||||
usage_notice=usage_notice, provider_label=st.provider_label, resolved_via_alias=st.resolved_alias, capabilities=capabilities,
|
||||
runtime_capabilities={
|
||||
k: v for k, v in runtime_capabilities.items() if isinstance(k, str) and isinstance(v, bool)},
|
||||
model_info=model_info, is_global=st.is_global, alias_endpoint_applied=st.alias_endpoint_applied)
|
||||
|
||||
@@ -477,6 +477,16 @@ def _nous_picker_model_ids(curated: dict, force_fresh_nous_tier: bool) -> list:
|
||||
model_ids = restrict_to_nous_policy(model_ids, nous_policy_allowed_ids(), rescue_empty=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Window plans: the plan's featured picks lead the list. Plan shape only (it rarely changes), so
|
||||
# the last snapshot at any age answers — a long-running gateway's 60s cache has usually lapsed
|
||||
# by the time someone opens /model.
|
||||
from hermes_cli.model_picker_usage import featured_first, nous_picker_usage
|
||||
usage = nous_picker_usage(any_age=True)
|
||||
if usage is not None:
|
||||
model_ids = featured_first(model_ids, usage.featured_model_ids)
|
||||
except Exception:
|
||||
pass
|
||||
return model_ids
|
||||
|
||||
|
||||
@@ -1398,4 +1408,41 @@ def list_picker_providers(
|
||||
from hermes_cli.models_validate import drop_unofferable_model_ids
|
||||
|
||||
drop_unofferable_model_ids(filtered)
|
||||
_attach_window_model_notes(filtered)
|
||||
return filtered
|
||||
|
||||
|
||||
def _attach_window_model_notes(providers: List[dict]) -> None:
|
||||
"""Nous on a usage-window plan, for the messaging pickers (same words as the CLI/TUI/desktop pickers):
|
||||
``model_labels`` {id: "Large" | "Free"} (plan shape, any-age snapshot; other levels carry no tag), and
|
||||
from a fresh snapshot only (a stale percent would mislabel it) ``plan_line`` (the limit line: "Weekly
|
||||
limit reached · resets in 21h · paid models now use your credits (pay as you go)").
|
||||
Every field is omitted when empty and for any account not on a window plan. Never raises."""
|
||||
try:
|
||||
nous = next((p for p in providers if str(p.get("slug", "")).lower() == "nous"), None)
|
||||
if nous is None:
|
||||
return
|
||||
from hermes_cli.model_picker_usage import nous_picker_usage, usage_level_label, usage_levels
|
||||
fresh = nous_picker_usage()
|
||||
usage = fresh or nous_picker_usage(any_age=True)
|
||||
if usage is None:
|
||||
return
|
||||
from agent.plan_limits import cached_pricing
|
||||
base_url = str(nous.get("api_url") or "")
|
||||
if not base_url:
|
||||
# Cache-only: the env override, else the default URL; never a credential refresh here.
|
||||
from hermes_cli.auth import _nous_inference_env_override
|
||||
from hermes_cli.auth_constants import DEFAULT_NOUS_INFERENCE_URL
|
||||
base_url = _nous_inference_env_override() or DEFAULT_NOUS_INFERENCE_URL
|
||||
pricing = cached_pricing(base_url)
|
||||
models = [str(m) for m in nous.get("models", [])]
|
||||
labels = {m: label for m, lv in usage_levels(models, pricing).items() if (label := usage_level_label(lv))}
|
||||
if labels:
|
||||
nous["model_labels"] = labels
|
||||
if fresh is None:
|
||||
return
|
||||
line = usage.limit_line()
|
||||
if line:
|
||||
nous["plan_line"] = line
|
||||
except Exception:
|
||||
logger.debug("window model notes failed", exc_info=True)
|
||||
+20
-1
@@ -569,10 +569,29 @@ def recommended_nous_default_model() -> dict[str, Any]:
|
||||
model_ids, _unavailable = partition_nous_models_by_tier(model_ids, pricing, free_tier=True)
|
||||
# Never default onto a subscription-billed row: spending that plan is the user's call.
|
||||
model_ids = [mid for mid in model_ids if not _is_subscription_billed(pricing.get(mid))] or model_ids
|
||||
return {"provider": "nous", "model": pick_silent_default_model(model_ids, provider="nous"),
|
||||
model = _window_plan_default(model_ids, pricing) if not free_tier else ""
|
||||
return {"provider": "nous", "model": model or pick_silent_default_model(model_ids, provider="nous"),
|
||||
"free_tier": bool(free_tier)}
|
||||
|
||||
|
||||
def _window_plan_default(model_ids: list[str], pricing: Optional[dict] = None) -> str:
|
||||
"""A usage-window plan's first featured pick that is listed (the picker's top Recommended row), or
|
||||
"" for every other account. Cache-only: the caller's fresh tier read just filled the snapshot. Never raises."""
|
||||
try:
|
||||
from hermes_cli.model_picker_usage import nous_picker_usage, split_recommended
|
||||
usage = nous_picker_usage(any_age=True)
|
||||
if usage is None:
|
||||
return ""
|
||||
recommended, _other = split_recommended(model_ids, usage.featured_model_ids)
|
||||
rows = pricing or {}
|
||||
# A default must not spend the Large allowance: skip Large picks (a user can still choose one).
|
||||
usable = [mid for mid in recommended if not (rows.get(mid) or {}).get("top_tier")
|
||||
and (rows.get(mid) or {}).get("usage_level") != "top_tier"]
|
||||
return usable[0] if usable else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_default_model_for_provider(provider: str) -> str:
|
||||
"""Cost-safe default model for a provider, or "" — the NON-INTERACTIVE fallback when a provider
|
||||
is configured but no model was ever selected."""
|
||||
|
||||
@@ -260,6 +260,18 @@ def fetch_models_with_pricing(
|
||||
# Nous Portal-only: the gateway bills this row to a subscription the account holds, not to credits.
|
||||
if include_sale_original and item.get("billing_mode") == "subscription":
|
||||
entry["billing_mode"] = "subscription"
|
||||
# Usage-window plans only (only those callers ever see the mark): the gateway marks the
|
||||
# models that count against the plan's monthly large-model limit.
|
||||
if include_sale_original and item.get("top_tier") is True:
|
||||
entry["top_tier"] = True
|
||||
# Same callers: the gateway's per-plan picker label (free / low / medium / top_tier).
|
||||
if include_sale_original and isinstance(item.get("usage_level"), str):
|
||||
entry["usage_level"] = item["usage_level"]
|
||||
# Nous only: the catalog's display name, so a limit line can name a model the way the
|
||||
# picker does ("Model A", not the routed id).
|
||||
if include_sale_original and isinstance(item.get("name"), str) and item["name"].strip() \
|
||||
and item["name"].strip() != mid:
|
||||
entry["name"] = item["name"].strip()
|
||||
# Nous Portal-only: on-sale rows join the picker, which must not offer a tool-less model.
|
||||
if include_sale_original and not _openrouter_model_supports_tools(item):
|
||||
entry["tools"] = False
|
||||
|
||||
+112
-2
@@ -22,6 +22,9 @@ TOOL_COVERAGE_CATEGORIES = ("firecrawl", "fal", "fal-video", "openai-audio", "br
|
||||
_ACCOUNT_INFO_CACHE_TTL = 60
|
||||
_account_info_cache: tuple[str, float, NousPortalAccountInfo] | None = None
|
||||
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
|
||||
# Who ``_account_info_cache`` belongs to (portal + JWT user/org), for ``cached_nous_portal_account_info``:
|
||||
# that read must survive a token refresh, which changes the cache key above.
|
||||
_account_info_identity: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -55,6 +58,24 @@ class NousPaidServiceAccessInfo:
|
||||
member_spend_cap_remaining_usd: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousUsageWindowInfo:
|
||||
"""Portal ``usage_window`` block: a usage-window plan's allowances (``gauges``, the server's list in
|
||||
display order; see agent/usage_gauges.py), auto top-up, a paused reason while the plan's renewal is
|
||||
unpaid, and the plan's picks. Absent from the account body for every other account."""
|
||||
|
||||
# The server's allowance list (agent.usage_gauges.WindowGauge). None when the portal sends none.
|
||||
gauges: Optional[tuple[Any, ...]] = None
|
||||
# The plan's recommended picks, in order: pickers list them first, and they are the
|
||||
# models a limit line suggests. Every model is usable on the plan.
|
||||
featured_model_ids: Optional[tuple[str, ...]] = None
|
||||
auto_top_up_enabled: Optional[bool] = None
|
||||
paused_reason: Optional[str] = None
|
||||
# The account's "Keep going on credits" setting; None from a portal
|
||||
# that predates it (top-up is spent then, as before).
|
||||
keep_going_on_credits: Optional[bool] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousToolAccessInfo:
|
||||
"""Free tool-pool entitlement (Portal ``tool_access``), decoupled from paid/billing access.
|
||||
@@ -107,6 +128,8 @@ class NousPortalAccountInfo:
|
||||
paid_service_access: Optional[bool] = None
|
||||
paid_service_access_info: Optional[NousPaidServiceAccessInfo] = None
|
||||
tool_access: Optional[NousToolAccessInfo] = None
|
||||
# Portal ``usage_window`` (account API only): None for every account not on a usage-window plan.
|
||||
usage_window: Optional[NousUsageWindowInfo] = None
|
||||
raw_claims: Optional[dict[str, Any]] = None
|
||||
raw_account: Optional[dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
@@ -175,6 +198,28 @@ def nous_portal_topup_url(account_info: Optional[NousPortalAccountInfo] = None)
|
||||
return f"{base}/billing?topup=open"
|
||||
|
||||
|
||||
def format_usage_window_block_guidance(account_info: Optional[NousPortalAccountInfo]) -> Optional[str]:
|
||||
"""Next step after a usage-window 402 (included usage spent, no credits): the add-credits link,
|
||||
plus the auto top-up nudge when it is off. None for every account not on a usage-window plan, so
|
||||
the credit-grant guidance is unchanged. A paused plan points at the card, not at a top-up."""
|
||||
window = getattr(account_info, "usage_window", None) if account_info is not None else None
|
||||
# A partial/empty block is not a window plan (same completeness rule as the credits seed).
|
||||
from agent.credits_tracker import _window_seed_fields
|
||||
if _window_seed_fields(window) is None:
|
||||
return None
|
||||
if getattr(window, "paused_reason", None):
|
||||
# A paused plan bills credits whatever the setting says: no credits is why this 402 came.
|
||||
return ("Update your card to restore included usage, or add credits to keep going now: "
|
||||
f"{nous_portal_billing_url(account_info)}")
|
||||
if getattr(window, "keep_going_on_credits", None) is False:
|
||||
# A balance doesn't unlock Hermes here: the account setting does.
|
||||
return f"Turn on Keep going in /topup to use your credits now: {nous_portal_billing_url(account_info)}"
|
||||
lines = [f"Add credits to keep going now: {nous_portal_topup_url(account_info)}"]
|
||||
if getattr(window, "auto_top_up_enabled", None) is False:
|
||||
lines.append("Turn on auto top-up in the same place so this doesn't stop you next time.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_nous_portal_entitlement_message(
|
||||
account_info: Optional[NousPortalAccountInfo], *, capability: str = "this feature",
|
||||
include_refresh_hint: bool = True, coverage_category: Optional[str] = None,
|
||||
@@ -293,8 +338,9 @@ def _no_paid_access_message(
|
||||
|
||||
def reset_nous_portal_account_info_cache() -> None:
|
||||
"""Clear the short-lived account-info cache used by tests."""
|
||||
global _account_info_cache
|
||||
global _account_info_cache, _account_info_identity
|
||||
_account_info_cache = None
|
||||
_account_info_identity = None
|
||||
|
||||
|
||||
def get_nous_portal_account_info(*, force_fresh: bool = False, min_jwt_ttl_seconds: int = 60) -> NousPortalAccountInfo:
|
||||
@@ -346,6 +392,53 @@ def nous_policy_present() -> Optional[bool]:
|
||||
return None
|
||||
|
||||
|
||||
def cached_nous_portal_account_info(*, any_age: bool = False) -> Optional[NousPortalAccountInfo]:
|
||||
"""The account snapshot in the 60s cache, or None when empty/expired. Never a fresh fetch, never raises —
|
||||
for UI chrome that must not add a round trip (the pickers) after a flow has already fetched the account.
|
||||
``any_age`` returns the last snapshot even past the TTL, for callers that read only the plan's shape
|
||||
(window or not, featured models), which rarely changes."""
|
||||
try:
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
cached, identity = _account_info_cache, _account_info_identity
|
||||
if cached is None:
|
||||
return None
|
||||
_, cached_at, info = cached
|
||||
# Expiry first: it is free, while the identity check reads the auth store.
|
||||
if not any_age and (time.monotonic() - cached_at) >= _ACCOUNT_INFO_CACHE_TTL:
|
||||
return None
|
||||
# Another login, org or portal never reads this snapshot; a refreshed token for the same one does.
|
||||
if identity is None or identity != _current_account_identity():
|
||||
return None
|
||||
return info
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _account_identity(access_token: str, portal_base_url: Optional[str]) -> str:
|
||||
"""Portal + the token's user and org (stable across a refresh); an opaque token falls back to its hash."""
|
||||
from hermes_cli.auth_constants import _decode_jwt_claims
|
||||
|
||||
claims = _decode_jwt_claims(access_token)
|
||||
sub, org = claims.get("sub"), claims.get("org_id")
|
||||
if _nonblank(sub) and _nonblank(org):
|
||||
identity = f"jwt:{sub}:{org}"
|
||||
else:
|
||||
identity = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
|
||||
return f"{portal_base_url or ''}:{identity}"
|
||||
|
||||
|
||||
def _current_account_identity() -> Optional[str]:
|
||||
"""``_account_identity`` for the persisted Nous login, without a refresh or network call. None
|
||||
when logged out."""
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
access_token = state.get("access_token")
|
||||
if not _nonblank(access_token):
|
||||
return None
|
||||
return _account_identity(access_token, _portal_base_url(state))
|
||||
|
||||
|
||||
def nous_policy_notice(*, removed: bool) -> str:
|
||||
"""One-line notice for a list the org's policy narrowed, else ``""``.
|
||||
|
||||
@@ -362,7 +455,7 @@ def nous_policy_notice(*, removed: bool) -> str:
|
||||
|
||||
|
||||
def _fresh_account_info(state: dict[str, Any], force_fresh: bool, portal_base_url: Optional[str]) -> NousPortalAccountInfo:
|
||||
global _account_info_cache
|
||||
global _account_info_cache, _account_info_identity
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state, resolve_nous_access_token
|
||||
@@ -381,8 +474,10 @@ def _fresh_account_info(state: dict[str, Any], force_fresh: bool, portal_base_ur
|
||||
|
||||
info = _info_from_fetched_account(access_token, refreshed_state, portal_base_url)
|
||||
if info.source != "error":
|
||||
identity = _account_identity(access_token, portal_base_url)
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
_account_info_identity = identity
|
||||
return info
|
||||
except Exception as exc:
|
||||
return _error_info(error=exc, logged_in=bool(state.get("access_token")),
|
||||
@@ -555,6 +650,7 @@ def _info_from_account_payload(
|
||||
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")),
|
||||
usage_window=_coerced_dataclass(NousUsageWindowInfo, payload.get("usage_window")),
|
||||
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")),
|
||||
@@ -645,10 +741,24 @@ def _coerce_num(value: Any, cast):
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_str_tuple(value: Any) -> Optional[tuple[str, ...]]:
|
||||
"""A JSON array of non-empty strings → tuple (order kept, non-strings dropped); anything else → None."""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
return tuple(v for v in value if isinstance(v, str) and v)
|
||||
|
||||
|
||||
# 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),
|
||||
"Optional[tuple[str, ...]]": _coerce_str_tuple,
|
||||
"Optional[tuple[Any, ...]]": lambda v: _parse_gauges(v),
|
||||
}
|
||||
|
||||
|
||||
def _parse_gauges(value: Any):
|
||||
from agent.usage_gauges import parse_gauges
|
||||
return parse_gauges(value)
|
||||
@@ -319,6 +319,12 @@ def patch_auto_top_up(
|
||||
return _request("PATCH", "/api/billing/auto-top-up", body=body, timeout=timeout)
|
||||
|
||||
|
||||
def patch_keep_going_on_credits(*, enabled: bool, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
"""``PATCH /api/billing/keep-going-on-credits``: the window plan's "Keep going on credits" setting (scope
|
||||
required; strict ``{enabled}`` body). Turning it off skips the spending gates."""
|
||||
return _request("PATCH", "/api/billing/keep-going-on-credits", body={"enabled": bool(enabled)}, timeout=timeout)
|
||||
|
||||
|
||||
def post_charge(*, amount_usd: float | str, idempotency_key: str, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
"""``POST /api/billing/charge`` — buy credits (scope required). Reuse the UUID ``idempotency_key`` on retry.
|
||||
|
||||
|
||||
@@ -786,6 +786,8 @@ def _apply_main_assignment_sync(cfg: dict, provider: str, model: str, base_url:
|
||||
"base_url": model_cfg.get("base_url", ""),
|
||||
"gateway_tools": gateway_tools,
|
||||
"stale_aux": _stale_aux_pins(cfg, new_provider),
|
||||
# Window plans, Large model: the warning (never a confirm). Absent for everyone else.
|
||||
**({"notice": result.usage_notice} if getattr(result, "usage_notice", "") else {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,16 @@
|
||||
"billing.confirm.portalCardCharged",
|
||||
"billing.confirm.title",
|
||||
"billing.confirm.total",
|
||||
"billing.keepGoing.body",
|
||||
"billing.keepGoing.offLine",
|
||||
"billing.keepGoing.offNote",
|
||||
"billing.keepGoing.onLine",
|
||||
"billing.keepGoing.rowTurnOff",
|
||||
"billing.keepGoing.rowTurnOn",
|
||||
"billing.keepGoing.title",
|
||||
"billing.keepGoing.turnOn",
|
||||
"billing.keepGoing.turnedOff",
|
||||
"billing.keepGoing.turnedOn",
|
||||
"billing.limit.hint",
|
||||
"billing.limit.noCapVisible",
|
||||
"billing.limit.readOnly",
|
||||
@@ -643,6 +653,21 @@
|
||||
"pickers.model.persist.scopeGlobal",
|
||||
"pickers.model.persist.scopeSession",
|
||||
"pickers.model.persist.toggleHint",
|
||||
"pickers.model.plan.largeFootnote",
|
||||
"pickers.model.plan.levels.free",
|
||||
"pickers.model.plan.levels.top_tier",
|
||||
"pickers.model.plan.limitNames.day",
|
||||
"pickers.model.plan.limitNames.month",
|
||||
"pickers.model.plan.limitNames.top_tier",
|
||||
"pickers.model.plan.limitNames.week",
|
||||
"pickers.model.plan.limitReached",
|
||||
"pickers.model.plan.limitThenCredits",
|
||||
"pickers.model.plan.limitThenCreditsLarge",
|
||||
"pickers.model.plan.limitThenWait",
|
||||
"pickers.model.plan.limitThenWaitLarge",
|
||||
"pickers.model.plan.resetsIn",
|
||||
"pickers.model.plan.sectionOther",
|
||||
"pickers.model.plan.sectionRecommended",
|
||||
"pickers.model.provider.current",
|
||||
"pickers.model.provider.filterHint",
|
||||
"pickers.model.provider.hint",
|
||||
|
||||
@@ -50,6 +50,16 @@ billing.confirm.payNow: "Betaal nou {0}"
|
||||
billing.confirm.portalCardCharged: "Jou kaart wat op die portaal gestoor is, sal afgeskryf word."
|
||||
billing.confirm.title: "Bevestig aankoop"
|
||||
billing.confirm.total: "Totaal: {0}"
|
||||
billing.keepGoing.body: "Gebruik jou krediete wanneer ingeslote gebruik opraak."
|
||||
billing.keepGoing.offLine: "Gaan voort op krediete: af (Hermes wag vir die terugstelling)"
|
||||
billing.keepGoing.offNote: "Af: Hermes wag vir die terugstelling."
|
||||
billing.keepGoing.onLine: "Gaan voort op krediete: aan (gebruik jou krediete wanneer ingeslote gebruik opraak)"
|
||||
billing.keepGoing.rowTurnOff: "Gaan voort op krediete: skakel af"
|
||||
billing.keepGoing.rowTurnOn: "Gaan voort op krediete: skakel aan"
|
||||
billing.keepGoing.title: "Gaan voort op krediete?"
|
||||
billing.keepGoing.turnOn: "Skakel aan"
|
||||
billing.keepGoing.turnedOff: "✅ Gaan voort op krediete: af. Hermes wag vir die terugstelling."
|
||||
billing.keepGoing.turnedOn: "✅ Gaan voort op krediete: aan. Gebruik jou krediete wanneer ingeslote gebruik opraak."
|
||||
billing.limit.hint: "↑/↓ kies · 1-{0} vinnige keuse · Enter bevestig · Esc terug"
|
||||
billing.limit.noCapVisible: "Geen maandelikse perk sigbaar nie (word op die portaal bestuur)."
|
||||
billing.limit.readOnly: "Die maandelikse limiet word op die portaal gestel — hier slegs leesalleen gewys."
|
||||
@@ -673,6 +683,21 @@ pickers.model.provider.warning: "waarskuwing: {0}"
|
||||
pickers.model.reasoning.keepCurrent: "Behou huidige inspanning"
|
||||
pickers.model.reasoning.none: "geen (deaktiveer redenering)"
|
||||
pickers.model.runHermesModelToConfigure: "voer `hermes model` uit om op te stel"
|
||||
pickers.model.plan.levels.free: "Gratis"
|
||||
pickers.model.plan.levels.top_tier: "Groot"
|
||||
pickers.model.plan.sectionRecommended: "Aanbeveel"
|
||||
pickers.model.plan.sectionOther: "Ander modelle"
|
||||
pickers.model.plan.largeFootnote: "Groot modelle tel ook teen jou maandelikse limiet vir groot modelle."
|
||||
pickers.model.plan.resetsIn: "stel terug oor {0}"
|
||||
pickers.model.plan.limitReached: "{0} bereik"
|
||||
pickers.model.plan.limitNames.day: "Daaglikse limiet"
|
||||
pickers.model.plan.limitNames.month: "Maandelikse limiet"
|
||||
pickers.model.plan.limitNames.top_tier: "Maandelikse limiet vir groot modelle"
|
||||
pickers.model.plan.limitNames.week: "Weeklikse limiet"
|
||||
pickers.model.plan.limitThenCredits: "betaalde modelle gebruik nou jou krediete (betaal soos jy gebruik)"
|
||||
pickers.model.plan.limitThenCreditsLarge: "groot modelle gebruik nou jou krediete (betaal soos jy gebruik)"
|
||||
pickers.model.plan.limitThenWait: "net gratis modelle tot dan"
|
||||
pickers.model.plan.limitThenWaitLarge: "groot modelle tot dan gepouseer"
|
||||
pickers.pet.adopting: "neem aan…"
|
||||
pickers.pet.escCancelHint: "Esc kanselleer"
|
||||
pickers.pet.hint: "↑/↓ kies · Enter neem aan · tik om te filter · Esc kanselleer"
|
||||
|
||||
@@ -172,6 +172,10 @@ gateway:
|
||||
denied_plural: "❌ Opdragte geweier ({count} opdragte)."
|
||||
denied_reason_singular: "❌ Opdrag geweier. Rede aan die agent oorgedra: \"{reason}\""
|
||||
denied_reason_plural: "❌ Opdragte geweier ({count} opdragte). Rede aan die agent oorgedra: \"{reason}\""
|
||||
|
||||
usage_limit:
|
||||
picker_title: "Keep going on another model:"
|
||||
switch_choice: "Switch to {model} ({note})"
|
||||
fast:
|
||||
not_supported: "⚡ /fast is slegs beskikbaar vir OpenAI-modelle wat Priority Processing ondersteun."
|
||||
status: "⚡ Priority Processing\n\nHuidige modus: `{mode}`\n\n_Gebruik:_ `/fast <normal|fast|status>`"
|
||||
@@ -1767,6 +1771,21 @@ cli:
|
||||
desc_auto_reload: "vul outomaties aan wanneer jou saldo laag raak"
|
||||
choice_monthly_limit: "Maandelikse limiet"
|
||||
desc_monthly_limit: "wys die maandelikse bestedingsperk (leesalleen)"
|
||||
# Keep going on credits (window plans only; the portal sends the setting only there)
|
||||
keep_going_choice: "Gaan voort op krediete"
|
||||
keep_going_desc: "gebruik jou krediete wanneer ingeslote gebruik opraak"
|
||||
keep_going_on_line: "Gaan voort op krediete: aan (gebruik jou krediete wanneer ingeslote gebruik opraak)"
|
||||
keep_going_off_line: "Gaan voort op krediete: af (Hermes wag vir die terugstelling)"
|
||||
keep_going_confirm_title: "Gaan voort op krediete?"
|
||||
keep_going_confirm_body: "Gebruik jou krediete wanneer ingeslote gebruik opraak. Af: Hermes wag vir die terugstelling."
|
||||
keep_going_turn_on: "Skakel aan"
|
||||
keep_going_keep_off_desc: "hou dit af: Hermes wag vir die terugstelling"
|
||||
keep_going_is_on_title: "Gaan voort op krediete is aan"
|
||||
keep_going_turn_off: "Skakel dit af"
|
||||
keep_going_turn_off_desc: "Hermes wag vir die terugstelling"
|
||||
keep_going_leave_on: "Laat dit aan"
|
||||
keep_going_turned_on: "✅ Gaan voort op krediete: aan. Gebruik jou krediete wanneer ingeslote gebruik opraak."
|
||||
keep_going_turned_off: "✅ Gaan voort op krediete: af. Hermes wag vir die terugstelling."
|
||||
cancelled: "Gekanselleer."
|
||||
cancelled_yellow: "🟡 Gekanselleer."
|
||||
cancelled_no_funds: "Gekanselleer. Geen fondse bygevoeg nie."
|
||||
@@ -3913,3 +3932,24 @@ tips:
|
||||
p09: "Verduidelik hierdie fout en hoe om dit reg te maak"
|
||||
p10: "Stel 'n herinnering of skeduleer 'n herhalende taak"
|
||||
p11: "Tik / om deur opdragte te blaai, of Ctrl+P vir die palet"
|
||||
|
||||
# Window plans (Nous subscription with included usage): picker words, limit lines, Large-model notice.
|
||||
plan_usage:
|
||||
level_free: "Gratis"
|
||||
level_top_tier: "Groot"
|
||||
resets_in: "stel terug oor {duration}"
|
||||
resets_soon: "stel binnekort terug"
|
||||
limit_reached: "{name} bereik · {resets}"
|
||||
limit_day: "Daaglikse limiet"
|
||||
limit_week: "Weeklikse limiet"
|
||||
limit_month: "Maandelikse limiet"
|
||||
limit_top_tier: "Maandelikse limiet vir groot modelle"
|
||||
then_credits: "betaalde modelle gebruik nou jou krediete (betaal soos jy gebruik)"
|
||||
then_credits_large: "groot modelle gebruik nou jou krediete (betaal soos jy gebruik)"
|
||||
then_wait: "net gratis modelle tot dan"
|
||||
then_wait_large: "groot modelle tot dan gepouseer"
|
||||
large_notice: "Groot model: gebruik meer van jou plan per boodskap."
|
||||
large_notice_credits: "Groot model: gebruik meer krediete per boodskap (betaal soos jy gebruik)."
|
||||
large_notice_limit: "Groot model: {name} bereik ({resets})."
|
||||
large_notice_limit_stops: "Groot model: {name} bereik ({resets}), dit sal nie tot dan loop nie."
|
||||
large_notice_percent: "Groot model: gebruik meer van jou plan per boodskap. Maandelikse limiet vir groot modelle: {percent} gebruik."
|
||||
@@ -50,6 +50,16 @@ billing.confirm.payNow: "ادفع {0} الآن"
|
||||
billing.confirm.portalCardCharged: "سيُخصم المبلغ من بطاقتك المحفوظة في البوابة."
|
||||
billing.confirm.title: "تأكيد الشراء"
|
||||
billing.confirm.total: "الإجمالي: {0}"
|
||||
billing.keepGoing.body: "استخدم رصيدك عند نفاد الاستخدام المضمّن."
|
||||
billing.keepGoing.offLine: "الاستمرار بالرصيد: معطّل (ينتظر Hermes إعادة التعيين)"
|
||||
billing.keepGoing.offNote: "عند الإيقاف: ينتظر Hermes إعادة التعيين."
|
||||
billing.keepGoing.onLine: "الاستمرار بالرصيد: مفعّل (استخدام رصيدك عند نفاد الاستخدام المضمّن)"
|
||||
billing.keepGoing.rowTurnOff: "الاستمرار بالرصيد: إيقاف"
|
||||
billing.keepGoing.rowTurnOn: "الاستمرار بالرصيد: تفعيل"
|
||||
billing.keepGoing.title: "الاستمرار بالرصيد؟"
|
||||
billing.keepGoing.turnOn: "تفعيل"
|
||||
billing.keepGoing.turnedOff: "✅ الاستمرار بالرصيد: معطّل. ينتظر Hermes إعادة التعيين."
|
||||
billing.keepGoing.turnedOn: "✅ الاستمرار بالرصيد: مفعّل. استخدم رصيدك عند نفاد الاستخدام المضمّن."
|
||||
billing.limit.hint: "↑/↓ اختيار · 1-{0} اختيار سريع · Enter تأكيد · Esc رجوع"
|
||||
billing.limit.noCapVisible: "لا يظهر حد شهري (يُدار من البوابة)."
|
||||
billing.limit.readOnly: "يُحدَّد الحد الشهري من البوابة — ويُعرض هنا للقراءة فقط."
|
||||
@@ -673,6 +683,21 @@ pickers.model.provider.warning: "تحذير: {0}"
|
||||
pickers.model.reasoning.keepCurrent: "الإبقاء على الجهد الحالي"
|
||||
pickers.model.reasoning.none: "لا شيء (تعطيل التفكير)"
|
||||
pickers.model.runHermesModelToConfigure: "شغّل `hermes model` للإعداد"
|
||||
pickers.model.plan.levels.free: "مجاني"
|
||||
pickers.model.plan.levels.top_tier: "كبير"
|
||||
pickers.model.plan.sectionRecommended: "موصى بها"
|
||||
pickers.model.plan.sectionOther: "نماذج أخرى"
|
||||
pickers.model.plan.largeFootnote: "تستهلك النماذج الكبيرة أيضًا حدّك الشهري للنماذج الكبيرة."
|
||||
pickers.model.plan.resetsIn: "يُعاد التعيين خلال {0}"
|
||||
pickers.model.plan.limitReached: "تم بلوغ الحد {0}"
|
||||
pickers.model.plan.limitNames.day: "اليومي"
|
||||
pickers.model.plan.limitNames.month: "الشهري"
|
||||
pickers.model.plan.limitNames.top_tier: "الشهري للنماذج الكبيرة"
|
||||
pickers.model.plan.limitNames.week: "الأسبوعي"
|
||||
pickers.model.plan.limitThenCredits: "النماذج المدفوعة تستخدم رصيدك الآن (الدفع حسب الاستخدام)"
|
||||
pickers.model.plan.limitThenCreditsLarge: "النماذج الكبيرة تستخدم رصيدك الآن (الدفع حسب الاستخدام)"
|
||||
pickers.model.plan.limitThenWait: "النماذج المجانية فقط حتى ذلك الحين"
|
||||
pickers.model.plan.limitThenWaitLarge: "النماذج الكبيرة متوقفة مؤقتًا حتى ذلك الحين"
|
||||
pickers.pet.adopting: "جارٍ التبنّي…"
|
||||
pickers.pet.escCancelHint: "Esc إلغاء"
|
||||
pickers.pet.hint: "↑/↓ اختيار · Enter تبنّي · اكتب للتصفية · Esc إلغاء"
|
||||
|
||||
@@ -172,6 +172,10 @@ gateway:
|
||||
denied_plural: "❌ رُفضت الأوامر ({count} أوامر)."
|
||||
denied_reason_singular: "❌ رُفض الأمر. السبب المُبلّغ للوكيل: \"{reason}\""
|
||||
denied_reason_plural: "❌ رُفضت الأوامر ({count} أوامر). السبب المُبلّغ للوكيل: \"{reason}\""
|
||||
|
||||
usage_limit:
|
||||
picker_title: "Keep going on another model:"
|
||||
switch_choice: "Switch to {model} ({note})"
|
||||
fast:
|
||||
not_supported: "⚡ /fast متاح فقط لنماذج OpenAI التي تدعم المعالجة ذات الأولوية."
|
||||
status: "⚡ المعالجة ذات الأولوية\n\nالوضع الحالي: `{mode}`\n\n_الاستخدام:_ `/fast <normal|fast|status>`"
|
||||
@@ -1767,6 +1771,21 @@ cli:
|
||||
desc_auto_reload: "أعد التعبئة تلقائيًا عند انخفاض رصيدك"
|
||||
choice_monthly_limit: "الحد الشهري"
|
||||
desc_monthly_limit: "عرض سقف الإنفاق الشهري (للقراءة فقط)"
|
||||
# Keep going on credits (window plans only; the portal sends the setting only there)
|
||||
keep_going_choice: "الاستمرار بالرصيد"
|
||||
keep_going_desc: "استخدام رصيدك عند نفاد الاستخدام المضمّن"
|
||||
keep_going_on_line: "الاستمرار بالرصيد: مفعّل (استخدام رصيدك عند نفاد الاستخدام المضمّن)"
|
||||
keep_going_off_line: "الاستمرار بالرصيد: معطّل (ينتظر Hermes إعادة التعيين)"
|
||||
keep_going_confirm_title: "الاستمرار بالرصيد؟"
|
||||
keep_going_confirm_body: "استخدم رصيدك عند نفاد الاستخدام المضمّن. عند الإيقاف: ينتظر Hermes إعادة التعيين."
|
||||
keep_going_turn_on: "تفعيل"
|
||||
keep_going_keep_off_desc: "إبقاؤه معطّلًا: ينتظر Hermes إعادة التعيين"
|
||||
keep_going_is_on_title: "الاستمرار بالرصيد مفعّل"
|
||||
keep_going_turn_off: "إيقافه"
|
||||
keep_going_turn_off_desc: "ينتظر Hermes إعادة التعيين"
|
||||
keep_going_leave_on: "إبقاؤه مفعّلًا"
|
||||
keep_going_turned_on: "✅ الاستمرار بالرصيد: مفعّل. استخدم رصيدك عند نفاد الاستخدام المضمّن."
|
||||
keep_going_turned_off: "✅ الاستمرار بالرصيد: معطّل. ينتظر Hermes إعادة التعيين."
|
||||
cancelled: "أُلغي."
|
||||
cancelled_yellow: "🟡 أُلغي."
|
||||
cancelled_no_funds: "أُلغي. لم تُضَف أي أموال."
|
||||
@@ -3913,3 +3932,24 @@ tips:
|
||||
p09: "اشرح هذا الخطأ وكيفية إصلاحه"
|
||||
p10: "اضبط تذكيرًا أو جدولة مهمة متكررة"
|
||||
p11: "اكتب / لاستعراض الأوامر، أو Ctrl+P للوحة الأوامر"
|
||||
|
||||
# Window plans (Nous subscription with included usage): picker words, limit lines, Large-model notice.
|
||||
plan_usage:
|
||||
level_free: "مجاني"
|
||||
level_top_tier: "كبير"
|
||||
resets_in: "يُعاد التعيين خلال {duration}"
|
||||
resets_soon: "يُعاد التعيين قريبًا"
|
||||
limit_reached: "تم بلوغ الحد {name} · {resets}"
|
||||
limit_day: "اليومي"
|
||||
limit_week: "الأسبوعي"
|
||||
limit_month: "الشهري"
|
||||
limit_top_tier: "الشهري للنماذج الكبيرة"
|
||||
then_credits: "النماذج المدفوعة تستخدم رصيدك الآن (الدفع حسب الاستخدام)"
|
||||
then_credits_large: "النماذج الكبيرة تستخدم رصيدك الآن (الدفع حسب الاستخدام)"
|
||||
then_wait: "النماذج المجانية فقط حتى ذلك الحين"
|
||||
then_wait_large: "النماذج الكبيرة متوقفة مؤقتًا حتى ذلك الحين"
|
||||
large_notice: "نموذج كبير: يستهلك المزيد من استخدام خطتك لكل رسالة."
|
||||
large_notice_credits: "نموذج كبير: يستهلك المزيد من الرصيد لكل رسالة (الدفع حسب الاستخدام)."
|
||||
large_notice_limit: "نموذج كبير: تم بلوغ حدك {name} ({resets})."
|
||||
large_notice_limit_stops: "نموذج كبير: تم بلوغ حدك {name} ({resets})، لذا لن يعمل حتى ذلك الحين."
|
||||
large_notice_percent: "نموذج كبير: يستهلك المزيد من استخدام خطتك لكل رسالة. الحد الشهري للنماذج الكبيرة: {percent} مستخدم."
|
||||
@@ -50,6 +50,16 @@ billing.confirm.payNow: "{0} jetzt zahlen"
|
||||
billing.confirm.portalCardCharged: "Ihre im Portal hinterlegte Karte wird belastet."
|
||||
billing.confirm.title: "Kauf bestätigen"
|
||||
billing.confirm.total: "Gesamt: {0}"
|
||||
billing.keepGoing.body: "Ihre Credits nutzen, wenn die enthaltene Nutzung aufgebraucht ist."
|
||||
billing.keepGoing.offLine: "Mit Credits weitermachen: aus (Hermes wartet auf das Zurücksetzen)"
|
||||
billing.keepGoing.offNote: "Aus: Hermes wartet auf das Zurücksetzen."
|
||||
billing.keepGoing.onLine: "Mit Credits weitermachen: an (Ihre Credits nutzen, wenn die enthaltene Nutzung aufgebraucht ist)"
|
||||
billing.keepGoing.rowTurnOff: "Mit Credits weitermachen: deaktivieren"
|
||||
billing.keepGoing.rowTurnOn: "Mit Credits weitermachen: aktivieren"
|
||||
billing.keepGoing.title: "Mit Credits weitermachen?"
|
||||
billing.keepGoing.turnOn: "Aktivieren"
|
||||
billing.keepGoing.turnedOff: "✅ Mit Credits weitermachen: aus. Hermes wartet auf das Zurücksetzen."
|
||||
billing.keepGoing.turnedOn: "✅ Mit Credits weitermachen: an. Ihre Credits nutzen, wenn die enthaltene Nutzung aufgebraucht ist."
|
||||
billing.limit.hint: "↑/↓ auswählen · 1-{0} Schnellwahl · Enter bestätigen · Esc zurück"
|
||||
billing.limit.noCapVisible: "Keine monatliche Obergrenze sichtbar (wird im Portal verwaltet)."
|
||||
billing.limit.readOnly: "Das Monatslimit wird im Portal festgelegt — hier nur lesend angezeigt."
|
||||
@@ -673,6 +683,21 @@ pickers.model.provider.warning: "Warnung: {0}"
|
||||
pickers.model.reasoning.keepCurrent: "Aktuelle Stärke beibehalten"
|
||||
pickers.model.reasoning.none: "keine (Reasoning deaktivieren)"
|
||||
pickers.model.runHermesModelToConfigure: "zum Konfigurieren `hermes model` ausführen"
|
||||
pickers.model.plan.levels.free: "Kostenlos"
|
||||
pickers.model.plan.levels.top_tier: "Groß"
|
||||
pickers.model.plan.sectionRecommended: "Empfohlen"
|
||||
pickers.model.plan.sectionOther: "Weitere Modelle"
|
||||
pickers.model.plan.largeFootnote: "Große Modelle zählen auch zu deinem Monatslimit für große Modelle."
|
||||
pickers.model.plan.resetsIn: "Zurücksetzung in {0}"
|
||||
pickers.model.plan.limitReached: "{0} erreicht"
|
||||
pickers.model.plan.limitNames.day: "Tageslimit"
|
||||
pickers.model.plan.limitNames.month: "Monatslimit"
|
||||
pickers.model.plan.limitNames.top_tier: "Monatslimit für große Modelle"
|
||||
pickers.model.plan.limitNames.week: "Wochenlimit"
|
||||
pickers.model.plan.limitThenCredits: "kostenpflichtige Modelle nutzen jetzt dein Guthaben (Pay-as-you-go)"
|
||||
pickers.model.plan.limitThenCreditsLarge: "Große Modelle nutzen jetzt dein Guthaben (Pay-as-you-go)"
|
||||
pickers.model.plan.limitThenWait: "bis dahin nur kostenlose Modelle"
|
||||
pickers.model.plan.limitThenWaitLarge: "Große Modelle bis dahin pausiert"
|
||||
pickers.pet.adopting: "wird adoptiert…"
|
||||
pickers.pet.escCancelHint: "Esc abbrechen"
|
||||
pickers.pet.hint: "↑/↓ auswählen · Enter adoptieren · tippen zum Filtern · Esc abbrechen"
|
||||
|
||||
@@ -172,6 +172,10 @@ gateway:
|
||||
denied_plural: "❌ Befehle abgelehnt ({count} Befehle)."
|
||||
denied_reason_singular: "❌ Befehl abgelehnt. Grund an den Agenten weitergeleitet: \"{reason}\""
|
||||
denied_reason_plural: "❌ Befehle abgelehnt ({count} Befehle). Grund an den Agenten weitergeleitet: \"{reason}\""
|
||||
|
||||
usage_limit:
|
||||
picker_title: "Mit einem anderen Modell weitermachen:"
|
||||
switch_choice: "Zu {model} wechseln ({note})"
|
||||
fast:
|
||||
not_supported: "⚡ /fast ist nur für OpenAI-Modelle mit Priority Processing verfügbar."
|
||||
status: "⚡ Priority Processing\n\nAktueller Modus: `{mode}`\n\n_Verwendung:_ `/fast <normal|fast|status>`"
|
||||
@@ -1767,6 +1771,21 @@ cli:
|
||||
desc_auto_reload: "automatisch nachladen, wenn Ihr Guthaben knapp wird"
|
||||
choice_monthly_limit: "Monatslimit"
|
||||
desc_monthly_limit: "monatliches Ausgabenlimit anzeigen (nur lesend)"
|
||||
# Keep going on credits (window plans only; the portal sends the setting only there)
|
||||
keep_going_choice: "Mit Credits weitermachen"
|
||||
keep_going_desc: "Ihre Credits nutzen, wenn die enthaltene Nutzung aufgebraucht ist"
|
||||
keep_going_on_line: "Mit Credits weitermachen: an (Ihre Credits nutzen, wenn die enthaltene Nutzung aufgebraucht ist)"
|
||||
keep_going_off_line: "Mit Credits weitermachen: aus (Hermes wartet auf das Zurücksetzen)"
|
||||
keep_going_confirm_title: "Mit Credits weitermachen?"
|
||||
keep_going_confirm_body: "Ihre Credits nutzen, wenn die enthaltene Nutzung aufgebraucht ist. Aus: Hermes wartet auf das Zurücksetzen."
|
||||
keep_going_turn_on: "Aktivieren"
|
||||
keep_going_keep_off_desc: "aus lassen: Hermes wartet auf das Zurücksetzen"
|
||||
keep_going_is_on_title: "Mit Credits weitermachen ist an"
|
||||
keep_going_turn_off: "Deaktivieren"
|
||||
keep_going_turn_off_desc: "Hermes wartet auf das Zurücksetzen"
|
||||
keep_going_leave_on: "Aktiviert lassen"
|
||||
keep_going_turned_on: "✅ Mit Credits weitermachen: an. Ihre Credits nutzen, wenn die enthaltene Nutzung aufgebraucht ist."
|
||||
keep_going_turned_off: "✅ Mit Credits weitermachen: aus. Hermes wartet auf das Zurücksetzen."
|
||||
cancelled: "Abgebrochen."
|
||||
cancelled_yellow: "🟡 Abgebrochen."
|
||||
cancelled_no_funds: "Abgebrochen. Kein Guthaben hinzugefügt."
|
||||
@@ -3913,3 +3932,24 @@ tips:
|
||||
p09: "Erkläre diesen Fehler und wie ich ihn behebe"
|
||||
p10: "Richte eine Erinnerung ein oder plane eine wiederkehrende Aufgabe"
|
||||
p11: "Geben Sie / ein, um Befehle zu durchsuchen, oder Ctrl+P für die Palette"
|
||||
|
||||
# Window plans (Nous subscription with included usage): picker words, limit lines, Large-model notice.
|
||||
plan_usage:
|
||||
level_free: "Kostenlos"
|
||||
level_top_tier: "Groß"
|
||||
resets_in: "Zurücksetzung in {duration}"
|
||||
resets_soon: "Zurücksetzung bald"
|
||||
limit_reached: "{name} erreicht · {resets}"
|
||||
limit_day: "Tageslimit"
|
||||
limit_week: "Wochenlimit"
|
||||
limit_month: "Monatslimit"
|
||||
limit_top_tier: "Monatslimit für große Modelle"
|
||||
then_credits: "kostenpflichtige Modelle nutzen jetzt dein Guthaben (Pay-as-you-go)"
|
||||
then_credits_large: "Große Modelle nutzen jetzt dein Guthaben (Pay-as-you-go)"
|
||||
then_wait: "bis dahin nur kostenlose Modelle"
|
||||
then_wait_large: "Große Modelle bis dahin pausiert"
|
||||
large_notice: "Großes Modell: verbraucht pro Nachricht mehr von deinem Plan."
|
||||
large_notice_credits: "Großes Modell: verbraucht pro Nachricht mehr Guthaben (Pay-as-you-go)."
|
||||
large_notice_limit: "Großes Modell: {name} erreicht ({resets})."
|
||||
large_notice_limit_stops: "Großes Modell: {name} erreicht ({resets}), es läuft bis dahin nicht."
|
||||
large_notice_percent: "Großes Modell: verbraucht pro Nachricht mehr von deinem Plan. Monatslimit für große Modelle: {percent} genutzt."
|
||||
@@ -175,6 +175,10 @@ gateway:
|
||||
denied_plural: "❌ Commands denied ({count} commands)."
|
||||
denied_reason_singular: "❌ Command denied. Reason relayed to the agent: \"{reason}\""
|
||||
denied_reason_plural: "❌ Commands denied ({count} commands). Reason relayed to the agent: \"{reason}\""
|
||||
|
||||
usage_limit:
|
||||
picker_title: "Keep going on another model:"
|
||||
switch_choice: "Switch to {model} ({note})"
|
||||
fast:
|
||||
not_supported: "⚡ /fast is only available for OpenAI models that support Priority Processing."
|
||||
status: "⚡ Priority Processing\n\nCurrent mode: `{mode}`\n\n_Usage:_ `/fast <normal|fast|auto|cold|ultrafast|status>`"
|
||||
@@ -1842,6 +1846,21 @@ cli:
|
||||
desc_auto_reload: "refill automatically when your balance runs low"
|
||||
choice_monthly_limit: "Monthly limit"
|
||||
desc_monthly_limit: "show the monthly spend cap (read-only)"
|
||||
# Keep going on credits (window plans only; the portal sends the setting only there)
|
||||
keep_going_choice: "Keep going on credits"
|
||||
keep_going_desc: "use your credits when included usage runs out"
|
||||
keep_going_on_line: "Keep going on credits: on (uses your credits when included usage runs out)"
|
||||
keep_going_off_line: "Keep going on credits: off (Hermes waits for the reset)"
|
||||
keep_going_confirm_title: "Keep going on credits?"
|
||||
keep_going_confirm_body: "Use your credits when included usage runs out. Off: Hermes waits for the reset."
|
||||
keep_going_turn_on: "Turn on"
|
||||
keep_going_keep_off_desc: "keep it off: Hermes waits for the reset"
|
||||
keep_going_is_on_title: "Keep going on credits is on"
|
||||
keep_going_turn_off: "Turn it off"
|
||||
keep_going_turn_off_desc: "Hermes waits for the reset"
|
||||
keep_going_leave_on: "Leave it on"
|
||||
keep_going_turned_on: "✅ Keep going on credits: on. Uses your credits when included usage runs out."
|
||||
keep_going_turned_off: "✅ Keep going on credits: off. Hermes waits for the reset."
|
||||
cancelled: "Cancelled."
|
||||
cancelled_yellow: "🟡 Cancelled."
|
||||
cancelled_no_funds: "Cancelled. No funds added."
|
||||
@@ -4028,3 +4047,24 @@ tips:
|
||||
p09: "Explain this error and how to fix it"
|
||||
p10: "Set a reminder or schedule a recurring task"
|
||||
p11: "Type / to browse commands, or Ctrl+P for the palette"
|
||||
|
||||
# Window plans (Nous subscription with included usage): picker words, limit lines, Large-model notice.
|
||||
plan_usage:
|
||||
level_free: "Free"
|
||||
level_top_tier: "Large"
|
||||
resets_in: "resets in {duration}"
|
||||
resets_soon: "resets soon"
|
||||
limit_reached: "{name} limit reached · {resets}"
|
||||
limit_day: "daily"
|
||||
limit_week: "weekly"
|
||||
limit_month: "monthly"
|
||||
limit_top_tier: "monthly large-model"
|
||||
then_credits: "paid models now use your credits (pay as you go)"
|
||||
then_credits_large: "Large models now use your credits (pay as you go)"
|
||||
then_wait: "only Free models until then"
|
||||
then_wait_large: "Large models paused until then"
|
||||
large_notice: "Large model: uses more of your plan's usage per message."
|
||||
large_notice_credits: "Large model: uses more credits per message (pay as you go)."
|
||||
large_notice_limit: "Large model: your {name} limit is reached ({resets})."
|
||||
large_notice_limit_stops: "Large model: your {name} limit is reached ({resets}), so it won't run until then."
|
||||
large_notice_percent: "Large model: uses more of your plan's usage per message. Monthly large-model limit: {percent} used."
|
||||
@@ -50,6 +50,16 @@ billing.confirm.payNow: "Pagar {0} ahora"
|
||||
billing.confirm.portalCardCharged: "Se cobrará a la tarjeta guardada en el portal."
|
||||
billing.confirm.title: "Confirmar compra"
|
||||
billing.confirm.total: "Total: {0}"
|
||||
billing.keepGoing.body: "Usa tus créditos cuando se agote el uso incluido."
|
||||
billing.keepGoing.offLine: "Seguir con créditos: desactivado (Hermes espera al reinicio)"
|
||||
billing.keepGoing.offNote: "Desactivado: Hermes espera al reinicio."
|
||||
billing.keepGoing.onLine: "Seguir con créditos: activado (usa tus créditos cuando se agote el uso incluido)"
|
||||
billing.keepGoing.rowTurnOff: "Seguir con créditos: desactivar"
|
||||
billing.keepGoing.rowTurnOn: "Seguir con créditos: activar"
|
||||
billing.keepGoing.title: "¿Seguir con créditos?"
|
||||
billing.keepGoing.turnOn: "Activar"
|
||||
billing.keepGoing.turnedOff: "✅ Seguir con créditos: desactivado. Hermes espera al reinicio."
|
||||
billing.keepGoing.turnedOn: "✅ Seguir con créditos: activado. Usa tus créditos cuando se agote el uso incluido."
|
||||
billing.limit.hint: "↑/↓ seleccionar · 1-{0} selección rápida · Enter confirmar · Esc volver"
|
||||
billing.limit.noCapVisible: "No hay tope mensual visible (se gestiona en el portal)."
|
||||
billing.limit.readOnly: "El límite mensual se define en el portal — aquí se muestra en solo lectura."
|
||||
@@ -673,6 +683,21 @@ pickers.model.provider.warning: "aviso: {0}"
|
||||
pickers.model.reasoning.keepCurrent: "Mantener el esfuerzo actual"
|
||||
pickers.model.reasoning.none: "ninguno (desactivar razonamiento)"
|
||||
pickers.model.runHermesModelToConfigure: "ejecuta `hermes model` para configurarlo"
|
||||
pickers.model.plan.levels.free: "Gratis"
|
||||
pickers.model.plan.levels.top_tier: "Grande"
|
||||
pickers.model.plan.sectionRecommended: "Recomendados"
|
||||
pickers.model.plan.sectionOther: "Otros modelos"
|
||||
pickers.model.plan.largeFootnote: "Los modelos grandes también cuentan para tu límite mensual de modelos grandes."
|
||||
pickers.model.plan.resetsIn: "se restablece en {0}"
|
||||
pickers.model.plan.limitReached: "Límite {0} alcanzado"
|
||||
pickers.model.plan.limitNames.day: "diario"
|
||||
pickers.model.plan.limitNames.month: "mensual"
|
||||
pickers.model.plan.limitNames.top_tier: "mensual de modelos grandes"
|
||||
pickers.model.plan.limitNames.week: "semanal"
|
||||
pickers.model.plan.limitThenCredits: "los modelos de pago ahora usan tus créditos (pago por uso)"
|
||||
pickers.model.plan.limitThenCreditsLarge: "los modelos grandes ahora usan tus créditos (pago por uso)"
|
||||
pickers.model.plan.limitThenWait: "solo modelos gratuitos hasta entonces"
|
||||
pickers.model.plan.limitThenWaitLarge: "modelos grandes en pausa hasta entonces"
|
||||
pickers.pet.adopting: "adoptando…"
|
||||
pickers.pet.escCancelHint: "Esc cancelar"
|
||||
pickers.pet.hint: "↑/↓ seleccionar · Enter adoptar · escribe para filtrar · Esc cancelar"
|
||||
|
||||
@@ -172,6 +172,10 @@ gateway:
|
||||
denied_plural: "❌ Comandos denegados ({count} comandos)."
|
||||
denied_reason_singular: "❌ Comando denegado. Motivo transmitido al agente: \"{reason}\""
|
||||
denied_reason_plural: "❌ Comandos denegados ({count} comandos). Motivo transmitido al agente: \"{reason}\""
|
||||
|
||||
usage_limit:
|
||||
picker_title: "Continúa con otro modelo:"
|
||||
switch_choice: "Cambiar a {model} ({note})"
|
||||
fast:
|
||||
not_supported: "⚡ /fast solo está disponible para modelos de OpenAI que admiten Priority Processing."
|
||||
status: "⚡ Priority Processing\n\nModo actual: `{mode}`\n\n_Uso:_ `/fast <normal|fast|status>`"
|
||||
@@ -1767,6 +1771,21 @@ cli:
|
||||
desc_auto_reload: "recargar automáticamente cuando tu saldo esté bajo"
|
||||
choice_monthly_limit: "Límite mensual"
|
||||
desc_monthly_limit: "ver el tope de gasto mensual (solo lectura)"
|
||||
# Keep going on credits (window plans only; the portal sends the setting only there)
|
||||
keep_going_choice: "Seguir con créditos"
|
||||
keep_going_desc: "usa tus créditos cuando se agote el uso incluido"
|
||||
keep_going_on_line: "Seguir con créditos: activado (usa tus créditos cuando se agote el uso incluido)"
|
||||
keep_going_off_line: "Seguir con créditos: desactivado (Hermes espera al reinicio)"
|
||||
keep_going_confirm_title: "¿Seguir con créditos?"
|
||||
keep_going_confirm_body: "Usa tus créditos cuando se agote el uso incluido. Desactivado: Hermes espera al reinicio."
|
||||
keep_going_turn_on: "Activar"
|
||||
keep_going_keep_off_desc: "dejarlo desactivado: Hermes espera al reinicio"
|
||||
keep_going_is_on_title: "Seguir con créditos está activado"
|
||||
keep_going_turn_off: "Desactivarlo"
|
||||
keep_going_turn_off_desc: "Hermes espera al reinicio"
|
||||
keep_going_leave_on: "Dejarlo activado"
|
||||
keep_going_turned_on: "✅ Seguir con créditos: activado. Usa tus créditos cuando se agote el uso incluido."
|
||||
keep_going_turned_off: "✅ Seguir con créditos: desactivado. Hermes espera al reinicio."
|
||||
cancelled: "Cancelado."
|
||||
cancelled_yellow: "🟡 Cancelado."
|
||||
cancelled_no_funds: "Cancelado. No se añadieron fondos."
|
||||
@@ -3913,3 +3932,24 @@ tips:
|
||||
p09: "Explica este error y cómo solucionarlo"
|
||||
p10: "Crea un recordatorio o programa una tarea recurrente"
|
||||
p11: "Escribe / para explorar los comandos, o Ctrl+P para la paleta"
|
||||
|
||||
# Window plans (Nous subscription with included usage): picker words, limit lines, Large-model notice.
|
||||
plan_usage:
|
||||
level_free: "Gratis"
|
||||
level_top_tier: "Grande"
|
||||
resets_in: "se restablece en {duration}"
|
||||
resets_soon: "se restablece pronto"
|
||||
limit_reached: "Límite {name} alcanzado · {resets}"
|
||||
limit_day: "diario"
|
||||
limit_week: "semanal"
|
||||
limit_month: "mensual"
|
||||
limit_top_tier: "mensual de modelos grandes"
|
||||
then_credits: "los modelos de pago ahora usan tus créditos (pago por uso)"
|
||||
then_credits_large: "los modelos grandes ahora usan tus créditos (pago por uso)"
|
||||
then_wait: "solo modelos gratuitos hasta entonces"
|
||||
then_wait_large: "modelos grandes en pausa hasta entonces"
|
||||
large_notice: "Modelo grande: usa más del uso de tu plan por mensaje."
|
||||
large_notice_credits: "Modelo grande: usa más créditos por mensaje (pago por uso)."
|
||||
large_notice_limit: "Modelo grande: alcanzaste tu límite {name} ({resets})."
|
||||
large_notice_limit_stops: "Modelo grande: alcanzaste tu límite {name} ({resets}), así que no se ejecutará hasta entonces."
|
||||
large_notice_percent: "Modelo grande: usa más del uso de tu plan por mensaje. Límite mensual de modelos grandes: {percent} usado."
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user