fix tests

This commit is contained in:
rob-maron committed 2026-10-20 22:18:49 -04:00
1 parent 83c5737be2
commit 0e29e8a25a
54 files changed
+519 -325

No files matched your search

+5 -2
View File
@@ -4,7 +4,7 @@ import logging
import math
import time
from dataclasses import dataclass, replace
from datetime import datetime, timezone, UTC
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Callable, Optional
import httpx
@@ -118,6 +118,7 @@ def _reset_phrase(dt: Optional[datetime]) -> str:
from hermes_cli.model_picker_usage import format_resets_in
return format_resets_in(int(dt.timestamp() * 1000))
except Exception:
logger.debug("Reset phrase formatting failed", exc_info=True)
return f"resets {_format_reset(dt)}"
@@ -164,6 +165,7 @@ def _render_usage_window_lines(snapshot: AccountUsageSnapshot) -> list[str]:
from hermes_cli.model_picker_usage import format_percent_used, render_bar
bar, pct = render_bar(used), format_percent_used(used)
except Exception:
logger.debug("Usage bar rendering failed", exc_info=True)
bar, pct = "", f"{int(used * 100)}%"
base = f"{label} {bar} {pct} used" if bar else f"{label} {pct} used"
if window.reset_at:
@@ -203,7 +205,7 @@ def _bp_window(label: str, used_bp: Any, resets_at_ms: Any) -> Optional[AccountU
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)
reset_at = datetime.fromtimestamp(resets_at_ms / 1000, tz=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)),
@@ -411,6 +413,7 @@ def nous_credits_view(*, markdown: bool = False, timeout: float = 10.0, credits_
from agent.credits_tracker import dev_fixture_credits_state
fixture = dev_fixture_credits_state()
except Exception:
logger.debug("Dev credits fixture unavailable", exc_info=True)
fixture = None
if fixture is not None:
snapshot = _snapshot_from_credits_state(fixture)
+2 -5
View File
@@ -1054,11 +1054,8 @@ def recover_with_credential_pool(
# Pass classified semantics, not just the status: a billing 403 and an edge-throttle 403
# need opposite cooldowns.
if effective_reason is not None:
failure_reason = effective_reason.value
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:
failure_reason = "usage_window" if (error_context or {}).get("usage_window") else effective_reason.value
if 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
+42
View File
@@ -0,0 +1,42 @@
"""Non-streaming chat completions that still feed response headers to the credits capture."""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
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()
+2 -37
View File
@@ -32,6 +32,7 @@ from agent.error_classifier import (
from agent.sdk_transform_bypass import bypass_chat_sdk_request_transform
from agent.errors import EmptyStreamError
from agent.chat_completion_local import _streams_llamacpp, stale_giveup
from agent.chat_completion_headers import _chat_completion_capturing_headers
from agent.chat_completion_stream_monitor import StreamingWaitMonitor
from agent.transports.chat_completions import is_router_timeout_shim, router_timeout_shim_may_follow
from agent.fast_mode import effective_request_overrides
@@ -48,9 +49,7 @@ from agent.message_sanitization import (
normalize_finish_reason as _normalize_finish_reason, sanitize_outbound_kwargs, strip_images_for_rejecting_model,
)
from agent.reasoning_summaries import (
append_streamed_reasoning_detail, separate_glued_reasoning_blocks,
streamed_reasoning_detail_text,
)
append_streamed_reasoning_detail, separate_glued_reasoning_blocks, streamed_reasoning_detail_text)
from agent.local_prompt_progress import prompt_progress, with_prompt_progress
from agent.repetition_guard import RunawayStreamWatch, is_repetition_dominated
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
@@ -795,40 +794,6 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
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:
"""Whether an OpenAI-wire request should skip the interrupt worker.
+2
View File
@@ -650,9 +650,11 @@ def _usage_window_refusal(agent, classified, api_messages=None) -> tuple[str, Op
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:
logger.debug("Usage-window limit payload unavailable", exc_info=True)
limit = None
return (message, resets_ms / 1000 if isinstance(resets_ms, int) and resets_ms > 0 else None, limit)
except Exception:
logger.debug("Usage-window refusal parse failed", exc_info=True)
return "", None, None
+2 -4
View File
@@ -621,6 +621,7 @@ def response_is_usage_window(http_response: Any) -> bool:
state = parse_credits_headers(headers)
return state is not None and state.is_usage_window
except Exception:
logger.debug("Usage-window header parse failed", exc_info=True)
return False
@@ -774,10 +775,7 @@ def restore_window_notices(session_id, latch: dict) -> None:
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
_seen_window_notices.pop(session_id, None)
def remember_window_notices(session_id, latch: dict) -> None:
+1 -1
View File
@@ -1129,7 +1129,7 @@ def _status_5xx(c: _Ctx) -> Verdict:
return _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_SERVER_ERROR
def _is_nous_route(c: "_Ctx") -> bool:
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
+6
View File
@@ -85,6 +85,7 @@ def cached_pricing(base_url: str) -> dict:
from hermes_cli.models_pricing import peek_cached_pricing
return peek_cached_pricing(base_url) or {}
except Exception:
logger.debug("Cached pricing unavailable", exc_info=True)
return {}
@@ -93,6 +94,7 @@ def _billed_elsewhere(model: str, pricing: Optional[dict]) -> bool:
from hermes_cli.models import _is_subscription_billed
return _is_subscription_billed((pricing or {}).get(model))
except Exception:
logger.debug("Subscription billing lookup failed", exc_info=True)
return False
@@ -106,6 +108,7 @@ def is_free(model: str, pricing: Optional[dict]) -> bool:
from hermes_cli.models import _is_model_free
return bool(pricing) and _is_model_free(model, pricing)
except Exception:
logger.debug("Free-model lookup failed", exc_info=True)
return False
@@ -150,6 +153,7 @@ def _plan_usage() -> Any:
from hermes_cli.model_picker_usage import nous_picker_usage
return nous_picker_usage(any_age=True)
except Exception:
logger.debug("Plan usage unavailable", exc_info=True)
return None
@@ -165,6 +169,7 @@ def _portal_free_models() -> list[str]:
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:
logger.debug("Portal free models unavailable", exc_info=True)
return []
@@ -176,6 +181,7 @@ def _fits_context(model: str, needed_tokens: Optional[int], base_url: str) -> bo
ctx = get_model_context_length(model, base_url=base_url, provider=PROVIDER)
return not ctx or ctx >= needed_tokens + _CONTEXT_HEADROOM_TOKENS
except Exception:
logger.debug("Context length lookup failed", exc_info=True)
return True
+1 -1
View File
@@ -137,7 +137,7 @@ class RateLimitCreditsMixin:
from agent.plan_snapshot_sync import sync_plan_snapshot
sync_plan_snapshot(state)
except Exception:
pass
logger.debug("Plan snapshot sync failed", exc_info=True)
if dev:
# HERMES_DEV_CREDITS streams each capture to agent.log (`hermes logs -f`, grep 'credits ▸').
spent = self.get_credits_spent_micros()
+1
View File
@@ -617,6 +617,7 @@ def _reject_all_turns(agent: Any, api_error: Exception) -> bool:
return bool(reject(api_error)) if callable(reject) else False
# health: allow CC -- usage-window 402: skip the credential refresh and hand the pool a model-scoped bench context; this recovery ladder was over target on main
def recover_after_classification(
agent: Any, api_error: Exception, classified: Any, _retry: TurnRetryState, *,
status_code: Optional[int], error_context: Any, messages: list[dict[str, Any]],
@@ -124,6 +124,7 @@ function lightOwnMessagingTurn(runtimeId: string, admissionId: string | undefine
lightDesktopMessagingCompletion(storedId, admissionId, storedId === $focusedStoredSessionId.get(), profile)
}
// health: allow CC -- usage-window limit: one guard so the error card, not a second billing toast, says it; this event switch was over target on main
export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, sessionId, isActiveEvent, occurredAt } = ctx
@@ -24,11 +24,11 @@ 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'
import { StepUpInlineAction } from './inline-feedback'
import { KeepGoingOnCreditsRow } from './keep-going-on-credits-row'
import { openExternal } from './open-external'
import { BillingPlansView } from './plans-view'
import { createSimulatedBillingApi } from './simulated-api'
@@ -767,6 +767,7 @@ function autoReloadRow(billing: BillingStateResponse, b: Translations['settings'
}
}
// health: allow CC -- usage-window plans: allowance rows beside the dollar rows; this row builder was over target on main
function deriveUsageRows(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse,
@@ -124,6 +124,7 @@ function versionItem(
}
}
// health: allow CC FUNC_LINES -- usage-window plans: one status-bar gauge item, hidden for every other account; this hook was over both targets on main
export function useStatusbarItems({
agentsOpen,
changeSessionCwd,
@@ -233,6 +233,7 @@ function useDownloadRows(owner: LocalModelsOwner, localModelsEnabled: boolean) {
* plugin surfaces that pick a model without a session behind it — so the two
* can never drift apart.
*/
// health: allow CC -- usage-window plans: section headings and the limit line in the existing list render; this component was over target on main
export function ModelCatalogMenu({
controller,
footer,
@@ -273,20 +274,8 @@ export function ModelCatalogMenu({
// 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 }),
// 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
refetchOnMount: query => nousRefetchOnMount(query.state.data),
refetchInterval: query => nousPricingPoll(query.state.data, query.state.dataUpdateCount)
})
const loading = modelOptions.isPending && !modelOptions.data
@@ -1016,6 +1005,7 @@ interface ModelFamilyRowProps {
* model, plus the hover-revealed options submenu. Shared by the Favorites
* section and the provider groups, so the two can never paint a model
* differently. */
// health: allow CC -- usage-window plans: the Large/Free tag replaces the price on tagged rows; this row was over target on main
function ModelFamilyRow({
controller,
current,
@@ -1433,6 +1423,23 @@ function isNous(provider: ModelOptionProvider): boolean {
return provider.slug === 'nous'
}
// 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.
function nousRefetchOnMount(data: ModelOptionsResult | undefined): 'always' | true {
return 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.
function nousPricingPoll(data: ModelOptionsResult | undefined, updates: number): false | number {
return updates < 6 && data?.providers?.some(p => isNous(p) && p.pricing_pending) ? 2_000 : false
}
// 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(
@@ -833,6 +833,28 @@ const UseCreditsAction: FC = () => {
)
}
// usage_window's own buttons, ahead of the generic ones: switch to the plan's suggested model, or turn
// on credits. Nothing for any other failure.
const UsageWindowLeadActions: FC<{ plan: ReturnType<typeof errorRecoveryPlan>; surface?: ErrorSurface }> = ({
plan,
surface
}) => {
const suggestion = surface?.usageLimit?.suggestions[0]
return (
<>
{plan.switchToModel && suggestion && <SwitchToModelAction suggestion={suggestion} />}
{plan.useCredits && <UseCreditsAction />}
</>
)
}
// A plan limit names the fix as choosing another model; every other failure keeps "switch provider".
const switchProviderLabel = (
copy: { errorChooseModel: string; errorSwitchProvider: string },
surface?: ErrorSurface
) => (surface?.code === 'usage_window' ? copy.errorChooseModel : copy.errorSwitchProvider)
// 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
@@ -914,6 +936,7 @@ const ScheduledRetryAction: FC<{ resetsAt: number }> = ({ resetsAt }) => {
)
}
// health: allow CC -- usage-window limit card: an Open Billing button, and diagnostics hidden for plan limits; this action bar was over target on main
const ErrorRecoveryActions: FC = () => {
const { t } = useI18n()
const copy = t.assistant.thread
@@ -1027,12 +1050,9 @@ 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 />}
<UsageWindowLeadActions plan={plan} surface={surface} />
{plan.editMessage && <EditPreviousMessageAction label={copy.editMessage} />}
{plan.compress && <CompressConversationAction label={copy.errorCompressConversation} />}
{plan.chooseModel && (
@@ -1095,29 +1115,25 @@ const ErrorRecoveryActions: FC = () => {
{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>
)}
{plan.switchProvider && inRouter && <SwitchProviderAction label={switchProviderLabel(copy, surface)} />}
{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}
/>
<>
{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}
/>
</>
)}
</div>
)
+1
View File
@@ -1,3 +1,4 @@
// health: allow FILE_LINES -- locale data: translated copy for the usage-window plan UI (allowance gauges, Large/Free tags, the limit card), one key per string
import { defineFieldCopy } from '@/app/settings/field-copy'
import { deBoot } from './de_boot'
+1
View File
@@ -1,3 +1,4 @@
// health: allow FILE_LINES -- locale data: translated copy for the usage-window plan UI (allowance gauges, Large/Free tags, the limit card), one key per string
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import { enAppTour } from './en_app_tour'
+1
View File
@@ -1,3 +1,4 @@
// health: allow FILE_LINES -- locale data: translated copy for the usage-window plan UI (allowance gauges, Large/Free tags, the limit card), one key per string
import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale, type TranslationOverrides } from './define-locale'
+1
View File
@@ -1,3 +1,4 @@
// health: allow FILE_LINES -- locale data: translated copy for the usage-window plan UI (allowance gauges, Large/Free tags, the limit card), one key per string
import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale, type TranslationOverrides } from './define-locale'
+1
View File
@@ -1,3 +1,4 @@
// health: allow FILE_LINES -- locale data: translated copy for the usage-window plan UI (allowance gauges, Large/Free tags, the limit card), one key per string
import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale, type TranslationOverrides } from './define-locale'
+1
View File
@@ -1,3 +1,4 @@
// health: allow FILE_LINES -- locale data: translated copy for the usage-window plan UI (allowance gauges, Large/Free tags, the limit card), one key per string
import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale, type TranslationOverrides } from './define-locale'
+1
View File
@@ -1,3 +1,4 @@
// health: allow FILE_LINES -- the copy contract for the usage-window plan keys every locale adds (allowance gauges, Large/Free tags, the limit card)
// Desktop i18n type contract.
//
// `Translations` is the single source of truth for every translatable string
+1
View File
@@ -1,3 +1,4 @@
// health: allow FILE_LINES -- locale data: translated copy for the usage-window plan UI (allowance gauges, Large/Free tags, the limit card), one key per string
import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale, type TranslationOverrides } from './define-locale'
+1
View File
@@ -152,6 +152,7 @@ function parseUsageLimit(value: unknown): UsageLimitSurface | undefined {
}
/** Validate a wire payload into an ErrorSurface, or null when absent/garbled. */
// health: allow CC -- usage-window: one branch for the optional usageLimit payload (parsed in parseUsageLimit); over target on main
export function parseErrorSurface(value: unknown): ErrorSurface | null {
if (!value || typeof value !== 'object') {
return null
+1
View File
@@ -1,3 +1,4 @@
# health: allow FILE_LINES -- usage-window plans: two delivery hooks only (Portal wording on chat platforms, then the limit offer after the reply); the logic lives in agent/plan_limits.py and gateway/slash_commands_model.py
"""Agent-turn execution for GatewayRunner: _handle_message_with_agent, _run_agent*, proxy path,
background tasks, MCP reload. Bound onto ``GatewayRunner`` via the MRO; ``gateway.run`` internals
are imported lazily inside method bodies (import cycle) so ``patch("gateway.run.X")`` still works.
+46 -32
View File
@@ -209,6 +209,50 @@ class _ModelPickerRows:
return TOP_TIER_FOOTNOTE
def _picker_order(model_ids: list[str], current_model: str, window_usage) -> tuple[list[str], dict[int, str], int]:
"""``(ordered ids, {row index: section heading}, cursor start)`` for the picker.
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)
))
return ordered, sections, (ordered.index(current_model) if current_model in ordered else 0)
def _print_numbered_rows(rows: _ModelPickerRows, menu_title: str, ordered: list[str], sections: dict[int, str]) -> int:
"""The no-curses fallback's title, numbered rows (+ custom / skip) and footnote; returns the number width."""
from hermes_cli.curses_ui import colorize_gauge_bars_ansi, format_radio_item_ansi
from hermes_cli.colors import Colors, color
n, _DIM, _RESET = len(ordered), "\033[2m", "\033[0m"
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}")
return num_width
def _prompt_model_selection(
model_ids: list[str], current_model: str = "",
pricing: Optional[dict[str, dict[str, str]]] = None,
@@ -248,23 +292,7 @@ def _prompt_model_selection(
# 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
ordered, sections, start = _picker_order(model_ids, current_model, window_usage)
rows = _ModelPickerRows(
ordered + list(_unavailable), pricing, current_model=current_model, sale_chrome=sale_chrome,
@@ -324,21 +352,7 @@ def _prompt_model_selection(
pass
# 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}")
num_width = _print_numbered_rows(rows, menu_title, ordered, sections)
if _unavailable:
unavailable_footer = unavailable_message.strip() or (
+1 -1
View File
@@ -1496,7 +1496,7 @@ def step_up_nous_billing_scope(
from hermes_cli.nous_billing import invalidate_cached_token
invalidate_cached_token()
except Exception:
pass
logger.debug("Billing token cache invalidation failed", exc_info=True)
granted = auth_state.get("scope")
return isinstance(granted, str) and NOUS_BILLING_MANAGE_SCOPE in granted.split()
+1 -1
View File
@@ -725,7 +725,7 @@ class CLIChatTurnMixin:
box=rich_box.HORIZONTALS, padding=(1, 4), width=self._scrollback_box_width(),
))
except Exception:
pass
logging.getLogger(__name__).debug("Usage-limit panel rendering failed", exc_info=True)
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
+3 -14
View File
@@ -1,3 +1,4 @@
# health: allow FILE_LINES -- usage-window plans: /model picker hooks only (a multi-row hint, Large/Free row labels); the logic lives in hermes_cli/model_picker_usage.py
"""prompt_toolkit TUI construction, key-binding handlers, and overlay display fragments for the
interactive CLI. Mixin bound onto ``HermesCLI`` via the MRO; cli.py-internal symbols are imported
LAZILY inside each method (``from cli import ...``) — never at module load (import cycle)."""
@@ -562,19 +563,6 @@ 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:
@@ -617,7 +605,8 @@ 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]
row_labels, limit_line = self._annotate_window_plan_rows(provider_data, model_labels)
from hermes_cli.model_picker_usage import annotate_window_plan_rows
row_labels, limit_line = 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))
+2 -2
View File
@@ -721,9 +721,9 @@ def curses_radiolist(
def _radiolist_with_headings(
title: str, items: List[RadioItem], selected: int, headings: dict[int, str], *,
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:
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."""
+3
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import contextlib
import logging
from contextvars import copy_context
from dataclasses import dataclass, replace
from threading import Lock, Thread, current_thread
@@ -873,6 +874,7 @@ def _attach_nous_plan_usage(row: dict, *, warm: bool):
from hermes_cli.model_picker_usage import nous_picker_usage
usage = nous_picker_usage(warm=warm, any_age=not warm)
except Exception:
logging.getLogger(__name__).debug("Nous plan usage unavailable", exc_info=True)
return None
if usage is not None:
row["plan_usage"] = usage.to_payload()
@@ -900,6 +902,7 @@ def _plan_usage_window(g) -> dict:
}
# health: allow CC -- usage-window plans: one branch attaches plan usage to the Nous row (built in _attach_nous_plan_usage); over target on main
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).
+39 -3
View File
@@ -9,6 +9,7 @@ Every other account (credit-grant, pay-as-you-go, free tier) gets nothing from h
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any, Literal, Optional
@@ -98,7 +99,7 @@ class PickerUsage:
"""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":
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
@@ -193,7 +194,7 @@ class PickerUsage:
def clamp_fraction(value: float) -> float:
return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value
return 0.0 if value < 0.0 else min(value, 1.0)
def gauge_style(left_fraction: float) -> GaugeStyle:
@@ -319,7 +320,7 @@ def _capitalize(text: str) -> str:
return text[:1].upper() + text[1:]
def _limit_name(g: "UsageGauge", lang: Optional[str] = None) -> str:
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)
@@ -371,6 +372,7 @@ def picker_usage_from_account(info: Any) -> Optional[PickerUsage]:
credits_pay=_credits_pay(info, window),
)
except Exception:
logging.getLogger(__name__).debug("Plan usage parse failed", exc_info=True)
return None
@@ -399,6 +401,7 @@ def nous_picker_usage(*, warm: bool = False, any_age: bool = False) -> Optional[
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:
logging.getLogger(__name__).debug("Nous plan usage unavailable", exc_info=True)
return None
@@ -439,6 +442,7 @@ def large_model_notice(
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:
logging.getLogger(__name__).debug("Large-model notice unavailable", exc_info=True)
return None
@@ -477,3 +481,35 @@ def plan_gauge_lines(usage: PickerUsage) -> list[str]:
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
)]
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:
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:
logging.getLogger(__name__).debug("Window plan default unavailable", exc_info=True)
return ""
def annotate_window_plan_rows(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:
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:
logging.getLogger(__name__).debug("Window plan row labels unavailable", exc_info=True)
return list(model_ids), None
+2 -2
View File
@@ -476,7 +476,7 @@ def _nous_picker_model_ids(curated: dict, force_fresh_nous_tier: bool) -> list:
from hermes_cli.models_pricing import nous_policy_allowed_ids, restrict_to_nous_policy
model_ids = restrict_to_nous_policy(model_ids, nous_policy_allowed_ids(), rescue_empty=True)
except Exception:
pass
logger.debug("Nous model policy filter failed", exc_info=True)
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
@@ -1412,7 +1412,7 @@ def list_picker_providers(
return filtered
def _attach_window_model_notes(providers: List[dict]) -> None:
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
+2 -21
View File
@@ -550,9 +550,8 @@ def recommended_nous_default_model() -> dict[str, Any]:
is selectable (callers degrade). Shared by ``GET /api/model/recommended-default`` and the
sign-in completion in ``hermes_cli.anon_auth`` so both land on the same model.
"""
from hermes_cli import models_pricing as mp
from hermes_cli import model_picker_usage, models_pricing as mp
from hermes_cli.auth import get_provider_auth_state
model_ids = get_curated_nous_model_ids()
pricing = mp.get_pricing_for_provider("nous") or {}
free_tier = check_nous_free_tier(force_fresh=True)
@@ -569,29 +568,11 @@ 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
model = _window_plan_default(model_ids, pricing) if not free_tier else ""
model = "" if free_tier else model_picker_usage.window_plan_default(model_ids, pricing)
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."""
+2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import threading
import time
import urllib.request
@@ -411,6 +412,7 @@ def cached_nous_portal_account_info(*, any_age: bool = False) -> Optional[NousPo
return None
return info
except Exception:
logging.getLogger(__name__).debug("Cached Nous account info unreadable", exc_info=True)
return None
+51
View File
@@ -200,6 +200,7 @@
"assistant.thread.copyFullResponse",
"assistant.thread.dismissError",
"assistant.thread.editMessage",
"assistant.thread.errorAddCredits",
"assistant.thread.errorAuthKinds.api_key.body",
"assistant.thread.errorAuthKinds.api_key.title",
"assistant.thread.errorAuthKinds.oauth.title",
@@ -272,6 +273,8 @@
"assistant.thread.errorCodes.upstream_blocked.title",
"assistant.thread.errorCodes.upstream_rate_limit.body",
"assistant.thread.errorCodes.upstream_rate_limit.title",
"assistant.thread.errorCodes.usage_window.body",
"assistant.thread.errorCodes.usage_window.title",
"assistant.thread.errorCompressConversation",
"assistant.thread.errorCompressFailed",
"assistant.thread.errorCopyDiagnostics",
@@ -297,6 +300,7 @@
"assistant.thread.errorLayers.streaming",
"assistant.thread.errorLimitResets",
"assistant.thread.errorOauthExpired",
"assistant.thread.errorOpenBillingSettings",
"assistant.thread.errorOpenDesktopLogs",
"assistant.thread.errorOpenHermesFolder",
"assistant.thread.errorOpenHermesFolderFailed",
@@ -310,9 +314,13 @@
"assistant.thread.errorSignInAgain",
"assistant.thread.errorSignInFreeTier",
"assistant.thread.errorStartNewSession",
"assistant.thread.errorSwitchModelFailed",
"assistant.thread.errorSwitchProvider",
"assistant.thread.errorSwitchToModel",
"assistant.thread.errorToastTitle",
"assistant.thread.errorUpdateApiKey",
"assistant.thread.errorUseCredits",
"assistant.thread.errorUseCreditsFailed",
"assistant.thread.expandMessage",
"assistant.thread.filesChanged",
"assistant.thread.goForward",
@@ -2109,18 +2117,41 @@
"modelPicker.downloading",
"modelPicker.free",
"modelPicker.freeTier",
"modelPicker.included",
"modelPicker.includedUsedUp",
"modelPicker.largeFootnote",
"modelPicker.limitNames.day",
"modelPicker.limitNames.month",
"modelPicker.limitNames.top_tier",
"modelPicker.limitNames.week",
"modelPicker.limitReached",
"modelPicker.limitThenCredits",
"modelPicker.limitThenCreditsLarge",
"modelPicker.limitThenWait",
"modelPicker.limitThenWaitLarge",
"modelPicker.loadFailed",
"modelPicker.loadingIntoMemory",
"modelPicker.localDownloadsHeading",
"modelPicker.month",
"modelPicker.noAuthenticatedProviders",
"modelPicker.noModels",
"modelPicker.pausedRenewal",
"modelPicker.percentUsed",
"modelPicker.priceTitle",
"modelPicker.pro",
"modelPicker.proNeedsSubscription",
"modelPicker.resetsIn",
"modelPicker.search",
"modelPicker.sectionOther",
"modelPicker.sectionRecommended",
"modelPicker.title",
"modelPicker.today",
"modelPicker.topTier",
"modelPicker.unknown",
"modelPicker.usageLevels.free",
"modelPicker.usageLevels.top_tier",
"modelPicker.wasPrice",
"modelPicker.week",
"modelVisibility.addCustomModel",
"modelVisibility.addProvider",
"modelVisibility.noAuthenticatedProviders",
@@ -2969,6 +3000,13 @@
"settings.billing.freeTier.plan",
"settings.billing.freeTier.signIn",
"settings.billing.freeTier.title",
"settings.billing.keepGoingOnCredits.approveInBrowser",
"settings.billing.keepGoingOnCredits.approvedRetry",
"settings.billing.keepGoingOnCredits.cancel",
"settings.billing.keepGoingOnCredits.description",
"settings.billing.keepGoingOnCredits.openAgain",
"settings.billing.keepGoingOnCredits.title",
"settings.billing.keepGoingOnCredits.tryAgain",
"settings.billing.perMonth",
"settings.billing.plan.alreadyOn",
"settings.billing.plan.backAria",
@@ -3041,6 +3079,17 @@
"settings.billing.state.usage.subscriptionCredits.valueOver",
"settings.billing.state.usage.topupCredits.caption",
"settings.billing.state.usage.topupCredits.title",
"settings.billing.state.usage.window.barLabel",
"settings.billing.state.usage.window.captionIncluded",
"settings.billing.state.usage.window.captionPaused",
"settings.billing.state.usage.window.captionResetsIn",
"settings.billing.state.usage.window.captionTopTierUsedUp",
"settings.billing.state.usage.window.captionTopTierUsedUpKeepGoingOff",
"settings.billing.state.usage.window.month",
"settings.billing.state.usage.window.today",
"settings.billing.state.usage.window.topTier",
"settings.billing.state.usage.window.valueUsed",
"settings.billing.state.usage.window.week",
"settings.billing.stepUp.deniedBody",
"settings.billing.stepUp.deniedTitle",
"settings.billing.stepUp.dismiss",
@@ -4378,6 +4427,7 @@
"shell.statusbar.openModelPicker",
"shell.statusbar.openStarmap",
"shell.statusbar.openWebhooks",
"shell.statusbar.planUsageTitle",
"shell.statusbar.providerModelTitle",
"shell.statusbar.releaseAvailable",
"shell.statusbar.resetStatusbar",
@@ -4400,6 +4450,7 @@
"shell.statusbar.toggleCommandCenter",
"shell.statusbar.toggleContextUsage",
"shell.statusbar.toggleFreeTier",
"shell.statusbar.togglePlanUsage",
"shell.statusbar.toggleRunningTimer",
"shell.statusbar.toggleSessionTimer",
"shell.statusbar.toggleTerminal",
+8 -8
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
@@ -412,7 +412,7 @@ class TestHardStops:
latch = new_credits_latch()
show, _ = _run(_state(window_day_used_bp=10_000, window_week_used_bp=6_000, spend_source="included"), latch)
assert _keys(show)[-1] == CREDITS_WINDOW_LIMIT_KEY
key, level, text = show[-1]
_key, level, text = show[-1]
assert level == "error"
assert text == f"✕ You've hit your daily limit · resets {format_window_reset(DAY_RESET)} · /topup to add credits"
assert CREDITS_WINDOW_PAYG_KEY not in _keys(show)
@@ -706,8 +706,8 @@ class TestUsageCommand:
assert snap is not None and snap.plan == "Window Plan"
assert [w.label for w in snap.windows] == ["Today", "This week", "Large models (monthly)"]
# The monthly row resets on the large-model allowance's own clock, and the used-up line names that date.
top_local = datetime.fromtimestamp(top_reset_ms / 1000, tz=timezone.utc).astimezone()
assert snap.windows[2].reset_at == datetime.fromtimestamp(top_reset_ms / 1000, tz=timezone.utc)
top_local = datetime.fromtimestamp(top_reset_ms / 1000, tz=UTC).astimezone()
assert snap.windows[2].reset_at == datetime.fromtimestamp(top_reset_ms / 1000, tz=UTC)
assert (f"Monthly large-model usage is used up. Resets {top_local:%b} {top_local.day}. "
"Past this, large models use your credits.") in snap.details
assert [round(w.used_percent) for w in snap.windows] == [76, 40, 100]
@@ -739,7 +739,7 @@ class TestUsageCommand:
# A new allowance renders with the server's label, where the server put it.
window.gauges = [{"id": "five_hour", "scope": "all", "label": "Next 5 hours", "used_bp": 5_000,
"resets_at_ms": now + 3600_000}, *_gauges(100, 200, **kw)]
assert [w.label for w in account_usage.build_nous_credits_snapshot(info).windows][0] == "Next 5 hours"
assert next(w.label for w in account_usage.build_nous_credits_snapshot(info).windows) == "Next 5 hours"
def test_header_gauge_raises_stale_portal_percents(self, monkeypatch):
from agent import account_usage
@@ -979,7 +979,7 @@ class TestBlockedResponseCapture:
(spend_source / window gauges otherwise go stale until the next stream or 402)."""
from unittest.mock import MagicMock
from agent.chat_completion_helpers import _chat_completion_capturing_headers
from agent.chat_completion_headers import _chat_completion_capturing_headers
seen = []
agent = SimpleNamespace(
@@ -1024,7 +1024,7 @@ class TestBlockedResponseCapture:
def test_non_streaming_non_window_keeps_mains_behaviour(self):
"""Not a usage-window response: nothing is captured. A session not known to be on a window
plan (no state yet, or a credit-grant state) keeps main's plain call."""
from agent.chat_completion_helpers import _chat_completion_capturing_headers
from agent.chat_completion_headers import _chat_completion_capturing_headers
seen = []
agent = SimpleNamespace(
@@ -1113,7 +1113,7 @@ class TestRefusalReadsAsTheStopLine:
def _no_plan_caches(self, monkeypatch):
from agent import plan_limits as pl
monkeypatch.setattr(pl, "_plan_usage", lambda: None)
monkeypatch.setattr(pl, "_portal_free_models", lambda: [])
monkeypatch.setattr(pl, "_portal_free_models", list)
monkeypatch.setattr(pl, "cached_pricing", lambda base_url: {})
def _agent(self, **state_kw):
+1 -1
View File
@@ -37,7 +37,7 @@ HEALTHY = (_g("day", 3_000), _g("week", 2_000), _g("top_tier", 2_000, scope=SCOP
@pytest.fixture(autouse=True)
def _no_caches(monkeypatch):
"""Every test names its own catalog and plan; nothing reads the user's disk caches."""
monkeypatch.setattr(pl, "_portal_free_models", lambda: [])
monkeypatch.setattr(pl, "_portal_free_models", list)
monkeypatch.setattr(pl, "_plan_usage", lambda: SimpleNamespace(featured_model_ids=FEATURED))
monkeypatch.setattr(pl, "cached_pricing", lambda base_url: PRICING)
monkeypatch.setattr(pl, "_fits_context", lambda model, needed, base_url: True)
+5 -2
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace as NS
import pytest
from agent import plan_snapshot_sync as sync
from agent.usage_gauges import BP_FULL, SCOPE_ALL, WindowGauge
def _state(window, full=()):
@@ -12,7 +13,9 @@ def _state(window, full=()):
def _snap(gauges=None, source="network"):
window = None if gauges is None else NS(gauges=[NS(id=g, full=f) for g, f in gauges])
# The portal snapshot carries parsed gauges (agent.usage_gauges.WindowGauge), as nous_account stores them.
window = None if gauges is None else NS(gauges=tuple(WindowGauge(g, SCOPE_ALL, g, BP_FULL if f else 0)
for g, f in gauges))
return NS(usage_window=window, source=source)
@@ -75,7 +78,7 @@ def test_a_limit_change_refreshes_only_the_account(wired):
def test_back_in_step_rearms(wired):
calls, box = wired
_calls, box = wired
assert sync.sync_plan_snapshot(_state(True)) is True
box["snap"] = ON_PLAN
assert sync.sync_plan_snapshot(_state(True)) is False # in step: clears the guard
@@ -502,6 +502,9 @@ class TestLoginNousSkipKeepsCurrent:
lambda ids, p, free_tier=False: (ids, []),
)
monkeypatch.setattr(ns, "prompt_enable_tool_gateway", lambda cfg: None)
# Not a usage-window account, and no live portal read of the fake token.
import hermes_cli.model_picker_usage as mpu
monkeypatch.setattr(mpu, "nous_picker_usage", lambda **kw: None)
return free_tier_calls
def test_skip_keep_current_preserves_provider_and_model(self, tmp_path, monkeypatch):
@@ -14,7 +14,7 @@ class _Cli(CLIBillingMixin):
def __init__(self, choice: str = "agree"):
self.choice, self.asked = choice, []
def _modal_choice(self, title, detail, choices): # noqa: ARG002 - signature mirrors the mixin
def _modal_choice(self, title, detail, choices):
self.asked.append((title, [c[0] for c in choices]))
return self.choice
+6 -9
View File
@@ -182,7 +182,7 @@ def test_picker_draws_whatever_allowances_the_server_lists():
*usage_window()["gauges"],
], featured_model_ids=()),
))
assert [g.label for g in usage.gauges][0] == "five_hour"
assert next(g.label for g in usage.gauges) == "five_hour"
assert plan_gauge_lines(usage)[1].startswith("Next 5 hours")
assert usage.included_summary.startswith("Next 5 hours limit reached")
assert usage.blocks("any/model")
@@ -424,31 +424,28 @@ def test_warm_picker_never_forces_a_fetch_for_a_non_window_account(monkeypatch):
def test_the_in_chat_cli_picker_says_the_limit_once_on_nous_only(monkeypatch):
from hermes_cli import model_picker_usage as mpu
from hermes_cli.cli_tui_mixin import CLITuiMixin
monkeypatch.setattr(mpu, "nous_picker_usage", lambda **kw: _window_usage(day_used_bp=10_000))
cli = SimpleNamespace(base_url="https://inference-api.nousresearch.com/v1")
rows, line = CLITuiMixin._annotate_window_plan_rows(cli, {"slug": "nous"}, ["paid/m", "gift/m:free"])
rows, line = mpu.annotate_window_plan_rows({"slug": "nous"}, ["paid/m", "gift/m:free"])
# Rows stay the bare ids; the limit is said once, above the list.
assert rows == ["paid/m", "gift/m:free"] and line.startswith("Daily limit reached")
# Any other provider, or no fresh snapshot: the bare ids and no line.
assert CLITuiMixin._annotate_window_plan_rows(cli, {"slug": "example"}, ["paid/m"]) == (["paid/m"], None)
assert mpu.annotate_window_plan_rows({"slug": "example"}, ["paid/m"]) == (["paid/m"], None)
monkeypatch.setattr(mpu, "nous_picker_usage", lambda **kw: None)
assert CLITuiMixin._annotate_window_plan_rows(cli, {"slug": "nous"}, ["paid/m"]) == (["paid/m"], None)
assert mpu.annotate_window_plan_rows({"slug": "nous"}, ["paid/m"]) == (["paid/m"], None)
def test_the_in_chat_cli_picker_tags_large_and_free_rows_on_window_plans_only(monkeypatch):
from hermes_cli import model_picker_usage as mpu
from hermes_cli.cli_tui_mixin import CLITuiMixin
monkeypatch.setattr(mpu, "nous_picker_usage", lambda **kw: None)
window = {"slug": "nous", "plan_usage": {"kind": "usage_window"},
"usage_levels": {"example/model-b": "top_tier", "example/model-a": "low", "example/model-free:free": "free"}}
ids = ["example/model-b", "example/model-a", "example/model-free:free"]
rows, _line = CLITuiMixin._annotate_window_plan_rows(None, window, ids)
rows, _line = mpu.annotate_window_plan_rows(window, ids)
assert [r.split() for r in rows] == [["example/model-b", "Large"], ["example/model-a"], ["example/model-free:free", "Free"]]
# Same payload without a window plan (or a stray levels map): the bare ids.
assert CLITuiMixin._annotate_window_plan_rows(None, {**window, "plan_usage": None}, ids)[0] == ids
assert mpu.annotate_window_plan_rows({**window, "plan_usage": None}, ids)[0] == ids
_LEVELS = {"example/model-b": {"usage_level": "top_tier", "top_tier": True},
@@ -9,7 +9,7 @@ from types import SimpleNamespace
import pytest
from tui_gateway import methods_slash
from tui_gateway import server
@pytest.fixture
@@ -22,10 +22,11 @@ def calls(monkeypatch):
monkeypatch.setattr("agent.account_usage.nous_credits_lines", _lines)
monkeypatch.setattr("agent.account_usage.fetch_account_usage", lambda *a, **kw: None)
monkeypatch.setattr(methods_slash, "_session_profile_runtime_scope", lambda session, **kw: contextlib.nullcontext())
monkeypatch.setattr(methods_slash, "_config_model_target", lambda: ("", ""))
monkeypatch.setattr(methods_slash, "_session_usage_snapshot", lambda session: {"model": "m"}, raising=False)
monkeypatch.setattr(methods_slash, "_metadata_mirror", lambda session: {}, raising=False)
# The handlers run against server.py's globals (method_ctx.bind_module), so patch them there.
monkeypatch.setattr(server, "_session_profile_runtime_scope", lambda session, **kw: contextlib.nullcontext())
monkeypatch.setattr(server, "_config_model_target", lambda: ("", ""))
monkeypatch.setattr(server, "_session_usage_snapshot", lambda session: {"model": "m"})
monkeypatch.setattr(server, "_metadata_mirror", lambda session: {})
return seen
@@ -35,23 +36,23 @@ def _session(state):
def test_no_credits_state_makes_mains_call(calls):
out = methods_slash._format_live_usage_output("sid", _session(None), "")
out = server._format_live_usage_output("sid", _session(None), "")
assert calls == [{}]
assert out.count("Nous credits block") == 1
def test_non_window_credits_state_makes_mains_call(calls):
methods_slash._format_live_usage_output("sid", _session(SimpleNamespace(is_usage_window=False)), "")
server._format_live_usage_output("sid", _session(SimpleNamespace(is_usage_window=False)), "")
assert calls == [{}]
def test_a_mock_agent_attribute_is_not_a_window_plan(calls):
methods_slash._format_live_usage_output("sid", _session(SimpleNamespace(is_usage_window=object())), "")
server._format_live_usage_output("sid", _session(SimpleNamespace(is_usage_window=object())), "")
assert calls == [{}]
def test_window_session_passes_the_reply_gauges_once(calls):
state = SimpleNamespace(is_usage_window=True)
out = methods_slash._format_live_usage_output("sid", _session(state), "")
out = server._format_live_usage_output("sid", _session(state), "")
assert calls == [{"credits_state": state}]
assert out.count("Nous credits block") == 1 # one Nous block, never a second plan block
+2
View File
@@ -9,6 +9,7 @@ so tests may still monkeypatch e.g. ``server._usage_payload``.
from __future__ import annotations
import logging
from typing import Optional
from .method_ctx import bind_module
@@ -130,6 +131,7 @@ def _plan_usage_payload(state) -> Optional[dict]:
usage = nous_picker_usage(warm=True)
return usage.to_payload() if usage is not None else None
except Exception:
logging.getLogger(__name__).debug("Plan usage payload unavailable", exc_info=True)
return None
+1
View File
@@ -1,3 +1,4 @@
# health: allow FILE_LINES -- usage-window plans: /usage marks the window view, plus one billing write route (keep going on credits) beside its siblings; data building lives in agent/account_usage.py
"""Session / delegation / spawn-tree / billing / pet JSON-RPC handlers.
Bodies are rebound onto server.py's globals at install time (method_ctx.py), so they use server
+2 -2
View File
@@ -383,11 +383,11 @@ def _apply_model_switch(
from_provider=_switch_away_provider(agent, explicit_provider, current_provider),
to_provider=result.target_provider, surface=_session_source(session), from_model=current_model,
session_id=getattr(agent, "session_id", None))
if result.usage_notice and sid:
if (notice := getattr(result, "usage_notice", "")) and sid:
# Window plans, Large model: a warning, never a confirm. The existing notice event, so desktop
# (toast) and TUI (status line) show it for every switch path, live or applied at turn start.
_emit("notification.show", sid, {
"text": f"⚠ {result.usage_notice}", "level": "warn", "kind": "ttl", "ttl_ms": 8000,
"text": f"⚠ {notice}", "level": "warn", "kind": "ttl", "ttl_ms": 8000,
"key": LARGE_MODEL_NOTICE_KEY, "id": LARGE_MODEL_NOTICE_KEY})
return {
"value": result.new_model, "warning": result.warning_message or "",
+88 -68
View File
@@ -695,6 +695,93 @@ function applySessionInfo(payload: unknown, { appendMessage, setHistoryItems, se
setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info } : m)))
}
/** Billing wall (out of credits / payment required) or a window plan's limit (`error_surface.usage_limit`). */
function confirmBillingWall(
payload: Extract<AnyGatewayEvent, { type: 'message.complete' }>['payload'],
ctx: GatewayEventHandlerContext
): void {
const { submitRef } = ctx.submission
const surface = payload?.error_surface as { code?: string; usage_limit?: UsageLimit | null } | null | undefined
const usageLimit = surface?.usage_limit
if (usageLimit) {
return confirmUsageLimit(usageLimit, ctx)
}
// A window plan's limit is not an empty wallet: its sentence is in the transcript already.
if (!payload?.billing || surface?.code === 'usage_window') {
return
}
const block = payload.billing
const copy = billingDialogCopy(block)
patchOverlayState({
confirm: {
cancelLabel: copy.cancelLabel,
confirmLabel: copy.confirmLabel,
detail: copy.detail,
onConfirm: () => {
if (block.is_nous) {
submitRef.current('/topup')
} else if (block.billing_url) {
openExternalUrl(block.billing_url)
} else {
submitRef.current('/model')
}
},
title: copy.title
}
})
}
/** A window plan's limit: one ask, switch to the plan's recommended model (this session) and re-send,
* or dismiss. The sentence is already in the transcript. */
function confirmUsageLimit(usageLimit: UsageLimit, ctx: GatewayEventHandlerContext): void {
const { rpc } = ctx.gateway
const { sys } = ctx.system
const { submitRef } = ctx.submission
const dialog = usageLimitDialog(usageLimit)
patchOverlayState({
confirm: {
cancelLabel: dialog.cancelLabel,
confirmLabel: dialog.confirmLabel,
detail: dialog.detail,
onConfirm: () => {
if (!dialog.switchValue) {
submitRef.current('/topup')
return
}
// Switch first; re-send only once it landed (or was queued for the next turn).
void rpc<{ deferred?: boolean; value?: string }>('config.set', {
key: 'model',
session_id: getUiState().sid,
value: dialog.switchValue
})
.then(r => {
if (!r?.value) {
return sys('error: model switch failed')
}
sys(`model → ${r.value}`)
patchUiState(state => ({
...state,
info: state.info ? { ...state.info, model: r.value! } : state.info
}))
submitRef.current('/retry')
})
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
},
title: dialog.title
}
})
}
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: AnyGatewayEvent) => void {
syncThemeToTerminalBackground()
@@ -1792,74 +1879,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// this is the actionable layer. Set AFTER recordMessageComplete() so the
// turn-idle resetFlowOverlays() (which clears `confirm`) can't wipe it;
// the top-of-loop guard already scopes this to the active session.
const surface = ev.payload?.error_surface as
{ code?: string; usage_limit?: UsageLimit | null } | null | undefined
const usageLimit = surface?.usage_limit
if (usageLimit) {
// A window plan's limit: one ask, switch to the plan's recommended model (this session)
// and re-send — or dismiss. The sentence is already in the transcript.
const dialog = usageLimitDialog(usageLimit)
patchOverlayState({
confirm: {
cancelLabel: dialog.cancelLabel,
confirmLabel: dialog.confirmLabel,
detail: dialog.detail,
onConfirm: () => {
if (!dialog.switchValue) {
submitRef.current('/topup')
return
}
// Switch first; re-send only once it landed (or was queued for the next turn).
void rpc<{ deferred?: boolean; value?: string }>('config.set', {
key: 'model',
session_id: getUiState().sid,
value: dialog.switchValue
})
.then(r => {
if (!r?.value) {
return sys('error: model switch failed')
}
sys(`model → ${r.value}`)
patchUiState(state => ({
...state,
info: state.info ? { ...state.info, model: r.value! } : state.info
}))
submitRef.current('/retry')
})
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
},
title: dialog.title
}
})
} else if (ev.payload?.billing && surface?.code !== 'usage_window') {
// A window plan's limit is not an empty wallet: its sentence is in the transcript already.
const block = ev.payload.billing
const copy = billingDialogCopy(block)
patchOverlayState({
confirm: {
cancelLabel: copy.cancelLabel,
confirmLabel: copy.confirmLabel,
detail: copy.detail,
onConfirm: () => {
if (block.is_nous) {
submitRef.current('/topup')
} else if (block.billing_url) {
openExternalUrl(block.billing_url)
} else {
submitRef.current('/model')
}
},
title: copy.title
}
})
}
confirmBillingWall(ev.payload, ctx)
return
}
+1
View File
@@ -829,6 +829,7 @@ export const sessionCommands: SlashCommand[] = [
return canonicalUsage(ctx)
}
// health: allow CC -- /usage: appends the plan allowance bars from billing.state for usage-window plans only; this handler was over target on main
ctx.gateway.rpc<SessionUsageResponse>('session.usage', { session_id: ctx.sid }).then(r => {
if (ctx.stale()) {
return
+18 -8
View File
@@ -111,12 +111,7 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
// instead of an amount picker that would 403 no_payment_method.
// Window plans: turning it on spends money later, so it confirms first (like
// the terminal /topup); turning it off happens at once.
const hermesRow =
s.keep_going_on_credits == null ? null : s.keep_going_on_credits ? B.keepGoing.rowTurnOff : B.keepGoing.rowTurnOn
// Turning it off only stops spending, so an admin keeps that one row with
// Remote Spending off (the portal and the server allow it too).
const offOnly = !full && s.is_admin && s.keep_going_on_credits === true
const { line: hermesLine, offOnly, row: hermesRow } = keepGoingChrome(s, B, full)
const items = full
? [
@@ -158,8 +153,6 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const sel = useMenu(rows, onClose)
const auto = autoReloadLine(B, s)
const hermesLine =
s.keep_going_on_credits == null ? null : s.keep_going_on_credits ? B.keepGoing.onLine : B.keepGoing.offLine
// Balance leads, in the title — the first thing seen (review feedback).
const title = B.overview.title(s.balance_display)
@@ -974,6 +967,23 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
)
}
/** Keep going on credits chrome (window plans; nulls elsewhere): the overview row, its status line, and
* whether an admin with Remote Spending off still gets the row (turning it off only stops spending, and
* the portal and the server allow that too). */
function keepGoingChrome(s: BillingStateResponse, B: BillingMessages, full: boolean) {
if (s.keep_going_on_credits == null) {
return { line: null, offOnly: false, row: null }
}
const on = s.keep_going_on_credits
return {
line: on ? B.keepGoing.onLine : B.keepGoing.offLine,
offOnly: !full && s.is_admin && on,
row: on ? B.keepGoing.rowTurnOff : B.keepGoing.rowTurnOn
}
}
/** Flip the account setting; the ✅ line lands once the gateway confirms (errors render there). */
function applyKeepGoingOnCredits(ctx: BillingOverlayState['ctx'], B: Translations['billing'], enabled: boolean) {
void ctx.applyKeepGoingOnCredits(enabled).then(ok => {
+19 -12
View File
@@ -179,18 +179,8 @@ export const MessageLine = memo(function MessageLine({
// ── `/usage` gauge row: one line with a `[|||| ]` bar, colored like the
// model picker. Only these rows take this path; every other system message
// keeps its collapse / ANSI / truncation handling below.
if (msg.role === 'system' && !msg.kind && !msg.text.includes('\n') && hasUsageGauge(msg.text)) {
// wrap-ansi treats a colored `[|||||||| ]` as a broken CSI sequence and
// prints `?[32m`, so each segment is its own <Text> with no wrapping.
return (
<Box>
{splitUsageGaugeLine(stripAnsi(msg.text)).map((seg, j) => (
<Text color={seg.style ? gaugeThemeColor(t, seg.style) : t.color.muted} key={j}>
{seg.text}
</Text>
))}
</Box>
)
if (isUsageGaugeRow(msg)) {
return <UsageGaugeRow t={t} text={msg.text} />
}
// ── Collapsible long system message (system prompt, AGENTS.md, etc.) ──
@@ -341,6 +331,23 @@ export const MessageLine = memo(function MessageLine({
const gaugeThemeColor = (t: Theme, style: GaugeStyle): string =>
style === 'red' ? t.color.error : style === 'yellow' ? t.color.warn : t.color.ok
const isUsageGaugeRow = (msg: Msg): boolean =>
msg.role === 'system' && !msg.kind && !msg.text.includes('\n') && hasUsageGauge(msg.text)
// wrap-ansi treats a colored `[|||||||| ]` as a broken CSI sequence and
// prints `?[32m`, so each segment is its own <Text> with no wrapping.
function UsageGaugeRow({ t, text }: { t: Theme; text: string }) {
return (
<Box>
{splitUsageGaugeLine(stripAnsi(text)).map((seg, j) => (
<Text color={seg.style ? gaugeThemeColor(t, seg.style) : t.color.muted} key={j}>
{seg.text}
</Text>
))}
</Box>
)
}
export const shouldShowResponseSeparator = (msg: Msg, showDetails: boolean): boolean =>
msg.role === 'assistant' && showDetails && /\S/.test(msg.text)
+34 -11
View File
@@ -70,6 +70,32 @@ export function providerIndexAfterClearingFilter(
return providerRows.findIndex(row => row.provider.slug === provider.slug)
}
/** Window-plan layout for the model list; every other provider gets plain rows at full height.
* Up to two section titles ("Recommended" / "Other models") ride inside the list, so the window shrinks
* by two rows to keep the overlay's height fixed; a query is its own order, so no sections while
* filtering. Past an allowance, one line in the footnote row says what happens next (pay as you go, or
* wait); rows it stops are dimmed rather than annotated. */
function planListLayout(
provider: ModelOptionProvider | undefined,
models: readonly string[],
filtering: boolean,
width: number,
M: ReturnType<typeof useT>['pickers']['model']
) {
const sectioned = !filtering && sectionAt(provider, models, 0) !== null
const levelled = models.some(m => usageLevel(provider, m))
const limitLine = levelled ? planLimitLine(provider, Date.now(), M.plan) : ''
return {
footnote: limitLine || (hasTopTier(provider, models) ? M.plan.largeFootnote : ''),
levelled,
nameWidth: levelled ? Math.min(Math.max(...models.map(m => m.length), 0), Math.max(10, width - 22)) : 0,
sectioned,
visibleModels: sectioned ? VISIBLE - 2 : VISIBLE
}
}
// health: allow CC -- usage-window plans: Large/Free tags, section titles and dimmed rows (layout in planListLayout); this component was over target on main
export function ModelPicker({
allowPersistGlobal = true,
gw,
@@ -253,6 +279,7 @@ export function ModelPicker({
const listStage = stage === 'provider' || stage === 'model' || stage === 'reasoning'
useOverlayKeys({ disabled: listStage, onBack: back, onClose: onCancel })
// health: allow CC -- usage-window plans: the cursor starts on the current model inside its section; this key handler was over target on main
useInput((ch, key) => {
// Key entry stage handles its own input
if (stage === 'key') {
@@ -779,20 +806,16 @@ export function ModelPicker({
}
// ── Model selection stage ────────────────────────────────────────────
// Window plans: up to two section titles ("Recommended" / "Other models") ride inside the list, so
// the window shrinks by two rows to keep the overlay's height fixed.
const filtering = !!filter.trim()
// A query is its own order, so no sections while filtering.
const sectioned = !filtering && sectionAt(provider, models, 0) !== null
const visibleModels = sectioned ? VISIBLE - 2 : VISIBLE
const { footnote, levelled, nameWidth, sectioned, visibleModels } = planListLayout(
provider,
models,
filtering,
width,
M
)
const { items, offset } = windowItems(models, modelIdx, visibleModels)
const noModelMatches = filtering && models.length === 0
const levelled = models.some(m => usageLevel(provider, m))
const nameWidth = levelled ? Math.min(Math.max(...models.map(m => m.length), 0), Math.max(10, width - 22)) : 0
// Window plans past an allowance: one line saying what happens next (pay as you go, or wait), in the
// footnote row; rows it stops are dimmed rather than annotated. Filtering hides nothing here (fixed row).
const limitLine = levelled ? planLimitLine(provider, Date.now(), M.plan) : ''
const footnote = limitLine || (hasTopTier(provider, models) ? M.plan.largeFootnote : '')
let sectionLines = 0
return (
+26
View File
@@ -21,3 +21,29 @@ export interface AuxiliaryModelsResponse {
tasks: AuxiliaryTaskAssignment[];
main: { provider: string; model: string };
}
/** An auxiliary task still pinned to a provider that differs from the
* newly-selected main provider after a main-model switch. */
export interface StaleAuxAssignment {
task: string;
provider: string;
model: string;
}
export interface ModelAssignmentResponse {
confirm_message?: string;
confirm_required?: boolean;
ok: boolean;
scope?: string;
provider?: string;
model?: string;
tasks?: string[];
reset?: boolean;
/** Auxiliary slots still pinned to a different provider than the new main.
* Switching main never clears aux pins; this lets the UI warn the user
* their helper tasks aren't following the switch. Only set on scope:'main'. */
stale_aux?: StaleAuxAssignment[];
/** Nous window plans, Large model: a one-line warning (never a confirm). Absent for every other
* plan, provider and model. Only set on scope:'main'. */
notice?: string;
}
+1 -27
View File
@@ -7,7 +7,7 @@ import { createDashboardSessionMutations } from "./session-mutations";
const mutateSessionHttp = createDashboardSessionMutations(fetchJSON);
import type { AuxiliaryModelsResponse } from "./api-aux";
import type { AuxiliaryModelsResponse, ModelAssignmentResponse } from "./api-aux";
import type { AgentPluginUpdateResponse } from "./api-plugins";
import type {
ManagedFileReadResponse,
@@ -2583,32 +2583,6 @@ export interface ModelAssignmentRequest {
task?: string;
}
/** An auxiliary task still pinned to a provider that differs from the
* newly-selected main provider after a main-model switch. */
export interface StaleAuxAssignment {
task: string;
provider: string;
model: string;
}
export interface ModelAssignmentResponse {
confirm_message?: string;
confirm_required?: boolean;
ok: boolean;
scope?: string;
provider?: string;
model?: string;
tasks?: string[];
reset?: boolean;
/** Auxiliary slots still pinned to a different provider than the new main.
* Switching main never clears aux pins; this lets the UI warn the user
* their helper tasks aren't following the switch. Only set on scope:'main'. */
stale_aux?: StaleAuxAssignment[];
/** Nous window plans, Large model: a one-line warning (never a confirm). Absent for every other
* plan, provider and model. Only set on scope:'main'. */
notice?: string;
}
// ── OAuth provider types ────────────────────────────────────────────────
export interface OAuthProviderStatus {