mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix tests
This commit is contained in:
1 parent
83c5737be2
commit
0e29e8a25a
54 files changed
+519
-325
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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'
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user