* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
951 lines
49 KiB
Python
951 lines
49 KiB
Python
"""Model picker, /model switch application, runtime snapshot/restore, and codex runtime handling for the interactive CLI
|
|
|
|
Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal
|
|
symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin
|
|
never imports ``cli`` at module load time (import cycle).
|
|
|
|
Tests drive the switch paths with bare stubs lacking most mixin methods, so shared steps are
|
|
module-level functions taking ``cli`` and siblings are called as ``HermesCLI.<name>(self, ...)``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import sys
|
|
import threading
|
|
|
|
from rich.markup import escape as _escape
|
|
from agent.i18n import t
|
|
from utils import base_url_host_matches
|
|
from hermes_cli.cli_agent_setup_mixin import _retire_agent
|
|
|
|
# CLI-level fields describing the active model route; snapshotted before a switch / one-turn
|
|
# override and restored wholesale on rollback. ``reasoning_config`` rides along because it is
|
|
# resolved per model: a failed swap or `/model X --reasoning high --once` must not leave the
|
|
# new model's effort behind with the old route.
|
|
_RUNTIME_FIELDS = (
|
|
"model", "provider", "requested_provider", "_explicit_api_key", "_explicit_base_url",
|
|
"api_key", "base_url", "api_mode", "reasoning_config")
|
|
|
|
|
|
def _runtime_fields(cli) -> dict:
|
|
return {key: getattr(cli, key, None) for key in _RUNTIME_FIELDS}
|
|
|
|
|
|
def _resolve_cli_reasoning(cli) -> None:
|
|
"""Re-resolve the CLI-level ``reasoning_config`` for ``cli.model`` through the shared chokepoint
|
|
(per-model ``reasoning_overrides`` > global ``agent.reasoning_effort``). Startup resolves it once
|
|
for the launch model; every path that moves ``cli.model`` must call this BEFORE the agent branch,
|
|
because a lazily built agent inherits this field — an always-thinking model then goes out with
|
|
the launch model's effort and 400s (#112921, #96012). ``agent.switch_model`` re-resolves its own
|
|
copy for the live-agent path."""
|
|
from cli import CLI_CONFIG
|
|
from hermes_constants import resolve_reasoning_config
|
|
# getattr: tests drive /new unbound on a SimpleNamespace without ``model`` (blank -> config default).
|
|
cli.reasoning_config = resolve_reasoning_config(CLI_CONFIG, getattr(cli, "model", None) or "")
|
|
|
|
|
|
def stored_session_route(session_meta, *, current_model, current_provider):
|
|
"""The route a resumed session should run on, or ``None`` when the stored one is absent or
|
|
already current. Returns ``(model, provider, base_url, api_mode, provider_changed)``; the
|
|
canonical row reader is ``SessionDB.session_gateway_runtime`` (``model_config.gateway_runtime``,
|
|
else the TUI's top-level keys). Bare ``custom`` is healed because the CLI resolve path
|
|
hard-fails on it (the TUI gateway keeps it when a base_url exists)."""
|
|
stored_model = str((session_meta or {}).get("model") or "").strip()
|
|
if not stored_model:
|
|
return None
|
|
from hermes_state import SessionDB as _SessionDB
|
|
runtime = _SessionDB.session_gateway_runtime(session_meta)
|
|
base_url = runtime.get("base_url") or None
|
|
provider = _heal_bare_custom_provider(runtime.get("provider") or None, base_url=base_url, model=stored_model)
|
|
provider_changed = bool(provider) and provider != current_provider
|
|
if stored_model == current_model and not provider_changed:
|
|
return None
|
|
api_mode = runtime.get("api_mode") or None
|
|
from hermes_cli.runtime_provider import is_foreign_provider_endpoint
|
|
if is_foreign_provider_endpoint(provider, base_url):
|
|
# The endpoint and its wire belong to the provider this chat left; resolve the stored one's own.
|
|
base_url = api_mode = None
|
|
# A row's api_mode/base_url were written for whichever model the session last ran. Providers that
|
|
# pick the wire per model (OpenCode Zen/Go, Copilot, Nous) re-derive both from the stored model, or a
|
|
# resumed opencode-go session keeps a MiniMax-era anthropic_messages route for a chat_completions
|
|
# model (#96066) — the CLI/oneshot twin of tui_gateway's _rederive_per_model_route.
|
|
from hermes_cli.model_switch import model_derived_api_mode
|
|
derived = model_derived_api_mode(provider or "", stored_model)
|
|
if derived is not None:
|
|
from hermes_cli.models import normalize_opencode_base_url
|
|
api_mode = derived
|
|
base_url = normalize_opencode_base_url(provider, api_mode, base_url) or None
|
|
return stored_model, provider, base_url, api_mode, provider_changed
|
|
|
|
|
|
def _heal_bare_custom_provider(provider, *, base_url, model):
|
|
"""Bare ``custom`` is a billing class, not a routable identity: persisting/restoring it makes a
|
|
later resume hard-fail once the config default leaves the custom endpoint. Recover the durable
|
|
``custom:<name>`` menu key from the endpoint, else drop the provider (None)."""
|
|
if str(provider or "").strip().lower() != "custom":
|
|
return provider
|
|
try:
|
|
# Heal bare "custom" persisted by older builds / gateway turns: it's the resolved billing class, not
|
|
# a routable identity. (Stricter than the TUI gateway's recovery, which keeps bare "custom" when a
|
|
# base_url exists — the CLI's resolve path would hard-fail on it, #14676.)
|
|
from hermes_cli.runtime_provider import canonical_custom_identity
|
|
return canonical_custom_identity(base_url=base_url or None, model=model or None) or None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _merge_preflight_warning(cli, result, custom_providers) -> None:
|
|
"""Fold the context-compression preflight warning into ``result`` (fail-soft)."""
|
|
from cli import logger
|
|
if cli.agent is None:
|
|
return
|
|
try:
|
|
from hermes_cli.context_switch_guard import merge_preflight_compression_warning
|
|
# Prefer the fresh inventory list (same source as switch_model / TUI); fall back
|
|
# to the agent-init snapshot.
|
|
merge_preflight_compression_warning(
|
|
result,
|
|
agent=cli.agent,
|
|
messages=list(cli.conversation_history or []),
|
|
custom_providers=custom_providers if custom_providers is not None
|
|
else getattr(cli.agent, "_custom_providers", None),
|
|
config_context_length=getattr(cli.agent, "_config_context_length", None))
|
|
except Exception as exc:
|
|
logger.debug("preflight-compression switch warning failed: %s", exc)
|
|
|
|
|
|
def _print_switch_summary(cli, result, old_model, *, one_turn: bool, strict_context: bool) -> None:
|
|
"""Record the next-turn switch note and print the "Model switched" block.
|
|
|
|
The note is prepended to the next user message (a mid-history system message would break
|
|
providers and prompt caching). ``strict_context``: the typed /model path lets
|
|
context-resolution errors propagate; the picker path swallows them.
|
|
"""
|
|
from cli import _cprint
|
|
from hermes_cli.model_switch import format_model_for_display, resolve_display_context_length
|
|
_display_old = format_model_for_display(old_model)
|
|
_display_new = format_model_for_display(result.new_model)
|
|
cli._pending_model_switch_note = (
|
|
f"[Note: model was just switched from {_display_old} to {_display_new} "
|
|
f"via {result.provider_label or result.target_provider}. "
|
|
f"{'This override applies to the next turn only. ' if one_turn else ''}"
|
|
f"Adjust your self-identification accordingly.]")
|
|
_cprint(f" {t('cli.model.switched', model=_display_new)}")
|
|
_cprint(f" {t('gateway.model.provider_label', provider=result.provider_label or result.target_provider)}")
|
|
if result.target_provider == "moa":
|
|
# The preset name hides who pays: the aggregator runs every tool-loop step (#112359).
|
|
from hermes_cli.moa_config import normalize_moa_config
|
|
moa_cfg = cli.config.get("moa") if isinstance(cli.config, dict) else {}
|
|
agg = normalize_moa_config(moa_cfg)["presets"].get(result.new_model, {}).get("aggregator") or {}
|
|
if agg:
|
|
_acting = f"{agg.get('provider')}:{agg.get('model')}"
|
|
_cprint(f" {t('cli.model.acting_model', model=_acting)}")
|
|
|
|
# Provider-aware context chain: Codex OAuth / Copilot / Nous caps win over the raw
|
|
# models.dev entry (gpt-5.5 is 1.05M on openai but 272K on Codex OAuth).
|
|
mi = result.model_info
|
|
agent = cli.agent
|
|
try:
|
|
ctx = resolve_display_context_length(
|
|
result.new_model, result.target_provider,
|
|
base_url=result.base_url or cli.base_url or "",
|
|
api_key=result.api_key or cli.api_key or "", model_info=mi,
|
|
config_context_length=getattr(agent, "_config_context_length", None) if agent else None,
|
|
custom_providers=getattr(agent, "_custom_providers", None) if agent else None)
|
|
except Exception:
|
|
if strict_context:
|
|
raise
|
|
ctx = None
|
|
if ctx:
|
|
from agent.context_pin import context_pin_suffix
|
|
_cprint(f" {t('gateway.model.context_label', tokens=f'{ctx:,}')}"
|
|
f"{context_pin_suffix(ctx, getattr(agent, '_config_context_length', None) if agent else None)}")
|
|
if mi:
|
|
if mi.max_output:
|
|
_cprint(f" {t('gateway.model.max_output_label', tokens=f'{mi.max_output:,}')}")
|
|
_cprint(f" {t('gateway.model.capabilities_label', capabilities=mi.format_capabilities())}")
|
|
cache_enabled = (
|
|
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
|
|
or result.api_mode == "anthropic_messages")
|
|
if cache_enabled:
|
|
_cprint(f" {t('gateway.model.prompt_caching_enabled')}")
|
|
if result.warning_message:
|
|
_cprint(f" ⚠ {result.warning_message}")
|
|
|
|
|
|
def _switch_model_from(
|
|
cli, raw_input, *, is_global, explicit_provider, user_providers, custom_providers):
|
|
"""``switch_model`` seeded with this CLI's live route."""
|
|
from hermes_cli.model_switch import switch_model
|
|
return switch_model(
|
|
raw_input=raw_input, current_provider=cli.provider or "", current_model=cli.model or "",
|
|
current_base_url=cli.base_url or "", current_api_key=cli.api_key or "", is_global=is_global,
|
|
explicit_provider=explicit_provider, user_providers=user_providers,
|
|
custom_providers=custom_providers)
|
|
|
|
|
|
def _run_confirm_and_apply(cli, target, *args) -> None:
|
|
"""Run a confirm+apply sequence off the UI thread when the TUI is live.
|
|
|
|
The expensive-model modal blocks its thread on a response queue (_prompt_text_input_modal);
|
|
on the prompt_toolkit main thread that freezes rendering, so the modal never appears and the
|
|
switch silently cancels after the 120s timeout.
|
|
"""
|
|
if getattr(cli, "_app", None):
|
|
threading.Thread(target=target, args=args, daemon=True).start()
|
|
else:
|
|
target(*args)
|
|
|
|
|
|
# `/model` usage block: (invocation form, description key). Forms are command syntax (never
|
|
# translated); descriptions are padded to one column at render time.
|
|
_MODEL_USAGE_ROWS = (
|
|
("/model <name>", "cli.model.usage_switch"),
|
|
("/model <name> --global", "cli.model.usage_switch_global"),
|
|
("/model <name> --once", "cli.model.usage_switch_once"),
|
|
("/model <name> --session", "cli.model.usage_switch_session"),
|
|
("/model <name> --provider <slug>", "cli.model.usage_switch_provider_model"),
|
|
("/model <name> --reasoning <level>", "cli.model.usage_switch_reasoning"),
|
|
("/model --provider <slug>", "cli.model.usage_switch_provider"),
|
|
("/model --refresh", "cli.model.usage_refresh"))
|
|
_MODEL_USAGE_COL = 36
|
|
|
|
|
|
def _picker_reasoning_rows() -> list[tuple[str, str]]:
|
|
"""``(value, label)`` rows for the picker's effort step: the canonical ladder, the off state,
|
|
then a keep-current row (empty value = leave the effort alone)."""
|
|
from hermes_constants import VALID_REASONING_EFFORTS
|
|
rows = [(lvl, lvl) for lvl in VALID_REASONING_EFFORTS]
|
|
rows.append(("none", t("cli.model.effort_none")))
|
|
rows.append(("", t("cli.model.effort_keep_current")))
|
|
return rows
|
|
|
|
|
|
def _picker_offers_reasoning(provider_data: dict, model: str) -> bool:
|
|
"""False only when the inventory's capability map says the picked model has no reasoning
|
|
control; unknown capabilities keep the step (a no-op dial beats hiding a real one)."""
|
|
caps = (provider_data or {}).get("capabilities")
|
|
entry = caps.get(model) if isinstance(caps, dict) else None
|
|
return not (isinstance(entry, dict) and entry.get("reasoning") is False)
|
|
|
|
|
|
def _apply_reasoning_after_switch(cli, effort: str, *, persist_global: bool) -> None:
|
|
"""Apply a ``--reasoning <level>`` that rode along with a model pick. Runs AFTER the swap: the
|
|
agent's ``switch_model`` re-resolves ``reasoning_config`` from config.yaml, so an earlier write
|
|
would be clobbered. Session-scoped unless the pick itself persists (``--global``)."""
|
|
from cli import CLI_CONFIG, _cprint, _parse_reasoning_config, save_config_value
|
|
parsed = _parse_reasoning_config(effort)
|
|
if parsed is None:
|
|
return
|
|
cli.reasoning_config = parsed
|
|
if cli.agent is not None:
|
|
cli.agent.reasoning_config = parsed
|
|
saved = persist_global and save_config_value("agent.reasoning_effort", effort)
|
|
if saved:
|
|
CLI_CONFIG.setdefault("agent", {})["reasoning_effort"] = effort
|
|
_cprint(f" {t('cli.model.reasoning_effort_saved' if saved else 'cli.model.reasoning_effort', effort=effort)}")
|
|
|
|
|
|
def _commit_model_switch(
|
|
cli, result, *, persist_global: bool, one_turn: bool = False, picker: bool = False,
|
|
reasoning_effort: str = "") -> None:
|
|
"""Stage + swap, print the summary, persist (session row unless --once; config on --global).
|
|
``picker``: tolerate context-resolution errors and label the config write "(--global)"; the
|
|
typed path additionally records the one-turn restore snapshot. ``reasoning_effort`` (from
|
|
``--reasoning`` or the picker's effort step) is applied after the swap."""
|
|
from cli import HermesCLI, _cprint
|
|
old_model, old_provider = cli.model, getattr(cli, "provider", None)
|
|
snapshot = cli._snapshot_model_runtime() if one_turn else None
|
|
if not cli._stage_and_swap_model(result, old_model):
|
|
return
|
|
# A TUI slash worker replays /model on its shadow CLI; the tui_gateway mirror counts the real switch.
|
|
if not getattr(cli, "is_slash_worker", False):
|
|
from hermes_cli.observability.shared_metrics_events import record_model_switch
|
|
record_model_switch(
|
|
from_provider=old_provider, to_provider=result.target_provider, surface="cli", from_model=old_model,
|
|
session_id=getattr(getattr(cli, "agent", None), "session_id", None) or getattr(cli, "session_id", None))
|
|
if not picker:
|
|
cli._pending_one_turn_model_restore = snapshot
|
|
_print_switch_summary(cli, result, old_model, one_turn=one_turn, strict_context=not picker)
|
|
if reasoning_effort:
|
|
_apply_reasoning_after_switch(cli, reasoning_effort, persist_global=persist_global and not one_turn)
|
|
if persist_global:
|
|
from hermes_cli.model_switch import persist_model_selection
|
|
persist_model_selection(result)
|
|
_cprint(f" {t('cli.model.saved_global' if picker else 'cli.model.saved')}")
|
|
elif one_turn:
|
|
_cprint(f" {t('cli.model.next_turn_only')}")
|
|
else:
|
|
_cprint(f" {t('cli.model.session_only')}")
|
|
# The row records what THIS session runs even on --global (else a later resume restores the
|
|
# stale creation-time model); --once is restored after one turn and never touches the row.
|
|
if not one_turn:
|
|
HermesCLI._persist_model_switch_to_session(cli, result)
|
|
|
|
|
|
def _show_model_picker(cli, ctx, force_refresh: bool) -> None:
|
|
"""``/model`` with no args: open the picker, or print usage when nothing is authed."""
|
|
from cli import _cprint
|
|
from hermes_cli.inventory import build_models_payload
|
|
from hermes_cli.providers import get_label
|
|
try:
|
|
if ctx is None:
|
|
raise RuntimeError("inventory context unavailable")
|
|
providers = build_models_payload(
|
|
ctx, probe_custom_providers=force_refresh,
|
|
probe_current_custom_provider=not force_refresh,
|
|
capabilities=True, # the effort step hides itself on reasoning-free routes
|
|
)["providers"]
|
|
except Exception:
|
|
providers = []
|
|
if not providers:
|
|
_cprint(f" {t('cli.model.no_authenticated_providers')}")
|
|
_cprint("")
|
|
for form, desc_key in _MODEL_USAGE_ROWS:
|
|
_cprint(f" {form.ljust(_MODEL_USAGE_COL)} {t(desc_key)}")
|
|
return
|
|
cli._open_model_picker(
|
|
providers, cli.model or "unknown", get_label(cli.provider) if cli.provider else "unknown",
|
|
user_provs=ctx.user_providers if ctx is not None else None,
|
|
custom_provs=ctx.custom_providers if ctx is not None else None)
|
|
|
|
|
|
class CLIModelSwitchMixin:
|
|
"""Model picker, /model switch application, runtime snapshot/restore, and codex runtime handling for the interactive CLI"""
|
|
|
|
def _normalize_model_for_provider(self, resolved_provider: str) -> bool:
|
|
"""Normalize provider-specific model IDs and routing."""
|
|
from cli import _split_model_config_default
|
|
current_model = str(self.model or "").strip()
|
|
if isinstance(self.model, dict):
|
|
current_model, _ = _split_model_config_default(self.model)
|
|
changed = False
|
|
|
|
def _adopt(canonical, notice) -> None:
|
|
"""Adopt ``canonical`` when it differs; ``notice(new)`` builds the warning text."""
|
|
nonlocal current_model, changed
|
|
if canonical and canonical != current_model:
|
|
if not self._model_is_default:
|
|
self._console_print(f"[yellow]⚠️ {notice(canonical)}[/]")
|
|
self.model = canonical
|
|
current_model = canonical
|
|
changed = True
|
|
|
|
def _adopt_with_mode(normalize, api_mode_of, notice) -> bool:
|
|
"""Provider families that also own the wire protocol: adopt id, then sync api_mode."""
|
|
nonlocal changed
|
|
try:
|
|
_adopt(normalize(current_model), notice)
|
|
resolved_mode = api_mode_of(current_model)
|
|
if resolved_mode != self.api_mode:
|
|
self.api_mode = resolved_mode
|
|
changed = True
|
|
except Exception:
|
|
pass
|
|
return changed
|
|
|
|
try:
|
|
from hermes_cli.model_normalize import (
|
|
_AGGREGATOR_PROVIDERS, normalize_model_for_provider)
|
|
if resolved_provider not in _AGGREGATOR_PROVIDERS:
|
|
_adopt(
|
|
normalize_model_for_provider(current_model, resolved_provider),
|
|
lambda new: t("cli.model.normalized_model", old=current_model, new=new, provider=resolved_provider))
|
|
except Exception:
|
|
pass
|
|
|
|
if resolved_provider == "copilot":
|
|
from hermes_cli.models import copilot_model_api_mode, normalize_copilot_model_id
|
|
return _adopt_with_mode(
|
|
lambda m: normalize_copilot_model_id(m, api_key=self.api_key),
|
|
lambda m: copilot_model_api_mode(m, api_key=self.api_key),
|
|
lambda new: t("cli.model.normalized_copilot_model", old=current_model, new=new))
|
|
|
|
from hermes_cli.models import opencode_provider_family
|
|
if opencode_provider_family(resolved_provider) is not None:
|
|
from hermes_cli.models import normalize_opencode_model_id, opencode_model_api_mode
|
|
return _adopt_with_mode(
|
|
lambda m: normalize_opencode_model_id(resolved_provider, m),
|
|
lambda m: opencode_model_api_mode(resolved_provider, m),
|
|
lambda new: t("cli.model.stripped_provider_prefix", old=current_model, new=new, provider=resolved_provider))
|
|
|
|
if resolved_provider != "openai-codex":
|
|
return changed
|
|
|
|
# 1. Strip provider prefix ("openai/gpt-5.4" → "gpt-5.4")
|
|
if "/" in current_model:
|
|
slug = current_model.split("/", 1)[1]
|
|
if not self._model_is_default:
|
|
self._console_print(
|
|
"[yellow]⚠️ " + _escape(t("cli.model.stripped_provider_prefix", old=current_model, new=slug, provider="OpenAI Codex")) + "[/]")
|
|
self.model = slug
|
|
current_model = slug
|
|
changed = True
|
|
|
|
# 2. Replace untouched default with a Codex model
|
|
if self._model_is_default:
|
|
from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, get_codex_model_ids
|
|
|
|
fallback_model = DEFAULT_CODEX_MODELS[0]
|
|
try:
|
|
# self.base_url is the route resolved with self.api_key (#121486).
|
|
available = get_codex_model_ids(
|
|
access_token=self.api_key if self.api_key else None, base_url=self.base_url or None)
|
|
if available:
|
|
fallback_model = available[0]
|
|
except Exception:
|
|
pass
|
|
if current_model != fallback_model:
|
|
self.model = fallback_model
|
|
changed = True
|
|
return changed
|
|
|
|
def _persist_model_switch_to_session(self, result) -> None:
|
|
"""Persist a session-scoped /model switch to the session DB row.
|
|
|
|
Writes the model column plus the route in both shapes readers use — nested
|
|
``gateway_runtime`` (CLI --resume) and top-level keys (TUI session.resume) — from one
|
|
or-None dict so stale keys are DELETED (``_merge_model_config_json`` only deletes on
|
|
explicit None) and the shapes never diverge.
|
|
|
|
Writes the model column plus the runtime route so ``--resume`` (CLI, reads ``gateway_runtime``) and
|
|
``session.resume`` (TUI/desktop, reads top-level ``model_config`` keys via
|
|
``_stored_session_runtime_overrides``) both restore the switched provider instead of recombining the
|
|
model with the ambient default (#79536). Mirrors the gateway's ``update_session_model()`` call.
|
|
getattr: tests drive the switch paths with ``object.__new__`` stubs.
|
|
"""
|
|
from cli import logger
|
|
db = getattr(self, "_session_db", None)
|
|
sid = getattr(self, "session_id", None)
|
|
if not db or not sid:
|
|
return
|
|
route = {
|
|
"provider": _heal_bare_custom_provider(
|
|
result.target_provider, base_url=result.base_url, model=result.new_model,
|
|
) or None,
|
|
# Both shapes use the same or-None discipline so stale keys from a previous switch are deleted
|
|
# (not merely omitted) in BOTH the nested gateway_runtime dict (CLI reader) and the top-level
|
|
# keys (TUI gateway reader). _merge_model_config_json only deletes on explicit None, so falsy
|
|
# values must be converted, not filtered. Deriving the top-level from **route guarantees the two
|
|
# shapes can never diverge — the asymmetry that caused the original stale-key bug (#85261
|
|
# simplify-code review).
|
|
"base_url": result.base_url or None,
|
|
"api_mode": result.api_mode or None}
|
|
try:
|
|
db.update_session_model(sid, result.new_model)
|
|
db.patch_session_model_config(sid, {"gateway_runtime": route, **route})
|
|
except Exception:
|
|
logger.debug("Failed to persist model switch to session DB", exc_info=True)
|
|
|
|
def _restore_session_model(self, session_meta: dict, *, quiet: bool = False) -> None:
|
|
"""Restore model/provider from the session DB row on every resume path.
|
|
|
|
Skips when no model is recorded or the CLI got an explicit ``-m`` (user intent wins).
|
|
A different stored provider gets its credentials re-resolved — the ambient ``api_key``
|
|
must not be sent to the session's endpoint; on failure the ambient credentials are kept
|
|
so the session still opens (the first turn surfaces the auth error).
|
|
"""
|
|
from cli import logger
|
|
if not (session_meta or {}).get("model") or getattr(self, "_explicit_model_override", False):
|
|
return
|
|
route = stored_session_route(session_meta, current_model=self.model, current_provider=self.provider)
|
|
if route is None:
|
|
return
|
|
stored_model, stored_provider, stored_base_url, stored_api_mode, provider_changed = route
|
|
from hermes_cli.local_runtime.endpoint import LLAMACPP_ALIASES
|
|
managed = str(stored_provider or "").strip().lower() in LLAMACPP_ALIASES
|
|
self.model = stored_model
|
|
if stored_provider:
|
|
self.provider = stored_provider
|
|
self.requested_provider = stored_provider
|
|
if stored_base_url and not managed:
|
|
self.base_url = stored_base_url
|
|
if stored_api_mode:
|
|
self.api_mode = stored_api_mode
|
|
if managed and not (getattr(self, "_explicit_base_url", None) and not provider_changed):
|
|
# The supervisor owns the live port: last boot's loopback URL (an ephemeral fallback when
|
|
# 18434 was busy) must not pin the resume onto a dead endpoint. A launch-time --base-url
|
|
# for this same provider is user intent and keeps winning.
|
|
self._explicit_api_key = None
|
|
self._explicit_base_url = None
|
|
try:
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
resolved = resolve_runtime_provider(requested=stored_provider, target_model=self.model or None)
|
|
if resolved.get("api_key"):
|
|
self.api_key = resolved["api_key"]
|
|
self._credential_pool = resolved.get("credential_pool")
|
|
if resolved.get("base_url"):
|
|
self.base_url = resolved["base_url"]
|
|
if not stored_api_mode and resolved.get("api_mode"):
|
|
self.api_mode = resolved["api_mode"]
|
|
except Exception:
|
|
if stored_base_url:
|
|
self.base_url = stored_base_url
|
|
logger.debug(
|
|
"Credential re-resolution for resumed session provider "
|
|
"%s failed; keeping ambient credentials",
|
|
stored_provider, exc_info=True)
|
|
elif provider_changed:
|
|
# Launch-time explicit overrides belong to the AMBIENT provider and would poison
|
|
# _ensure_runtime_credentials for the restored one. api_key is never persisted to
|
|
# the session DB — runtime provider resolution owns credentials.
|
|
self._explicit_api_key = None
|
|
self._explicit_base_url = stored_base_url
|
|
try:
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
resolved = resolve_runtime_provider(requested=stored_provider, target_model=self.model or None)
|
|
if resolved.get("api_key"):
|
|
self.api_key = resolved["api_key"]
|
|
self._credential_pool = resolved.get("credential_pool")
|
|
if not stored_base_url and resolved.get("base_url"):
|
|
self.base_url = resolved["base_url"]
|
|
if not stored_api_mode and resolved.get("api_mode"):
|
|
self.api_mode = resolved["api_mode"]
|
|
except Exception:
|
|
logger.debug(
|
|
"Credential re-resolution for resumed session provider "
|
|
"%s failed; keeping ambient credentials",
|
|
stored_provider, exc_info=True)
|
|
_resolve_cli_reasoning(self)
|
|
# Mid-chat /resume swaps the live agent; on startup --resume _init_agent picks up
|
|
# self.model / self.provider / self.reasoning_config.
|
|
if self.agent is not None:
|
|
try:
|
|
self.agent.switch_model(
|
|
new_model=self.model, new_provider=self.provider, api_key=self.api_key or "",
|
|
base_url=self.base_url or "", api_mode=self.api_mode or "")
|
|
except Exception:
|
|
logger.debug("In-place agent model swap on resume failed", exc_info=True)
|
|
msg = t("cli.model.restored_from_session", model=stored_model)
|
|
if stored_provider:
|
|
msg += f" ({stored_provider})"
|
|
if quiet:
|
|
print(msg, file=sys.stderr)
|
|
else:
|
|
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
|
|
|
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
|
|
"""Open prompt_toolkit-native /model picker modal."""
|
|
self._capture_modal_input_snapshot()
|
|
self._model_picker_state = {
|
|
"stage": "provider",
|
|
"providers": providers,
|
|
"selected": next((i for i, p in enumerate(providers) if p.get("is_current")), 0),
|
|
"current_model": current_model,
|
|
"current_provider": current_provider,
|
|
"user_provs": user_provs,
|
|
"custom_provs": custom_provs,
|
|
"filter": ""}
|
|
self._invalidate(min_interval=0.0)
|
|
|
|
def _confirm_expensive_model_switch(self, result) -> bool:
|
|
"""Ask for explicit confirmation before applying costly model switches."""
|
|
if not getattr(result, "success", False):
|
|
return True
|
|
try:
|
|
from hermes_cli.model_selection_guards import (
|
|
combined_selection_warning, selection_context_for_agent)
|
|
warning = combined_selection_warning(
|
|
result.new_model, provider=result.target_provider,
|
|
base_url=result.base_url or self.base_url or "",
|
|
api_key=result.api_key or self.api_key or "", model_info=result.model_info,
|
|
selection_context=selection_context_for_agent(getattr(self, "agent", None)))
|
|
except Exception:
|
|
warning = None
|
|
if warning is None:
|
|
return True
|
|
choices = [
|
|
("once", t("cli.model.choice_switch_anyway"), t("cli.model.desc_switch_anyway")),
|
|
("cancel", t("cli.model.choice_cancel"), t("cli.model.desc_keep_current_model"))]
|
|
raw = self._prompt_text_input_modal(
|
|
title=f"!!! {warning.title} !!!", detail=warning.message, choices=choices, timeout=120)
|
|
return self._normalize_slash_confirm_choice(raw, choices) == "once"
|
|
|
|
def _confirm_and_apply_model_switch_result(
|
|
self, result, persist_global: bool, custom_providers=None, reasoning_effort: str = "") -> None:
|
|
from cli import _cprint
|
|
try:
|
|
if result.success and not self._confirm_expensive_model_switch(result):
|
|
_cprint(f" {t('cli.model.switch_cancelled')}")
|
|
return
|
|
self._apply_model_switch_result(
|
|
result, persist_global, custom_providers=custom_providers, reasoning_effort=reasoning_effort)
|
|
except Exception as exc:
|
|
_cprint(f" {t('cli.model.selection_failed', error=exc)}")
|
|
|
|
def _close_model_picker(self) -> None:
|
|
self._model_picker_state = None
|
|
self._restore_modal_input_snapshot()
|
|
self._invalidate(min_interval=0.0)
|
|
|
|
def _snapshot_model_runtime(self) -> dict:
|
|
"""Capture current CLI and agent model runtime for one-turn restore."""
|
|
agent = getattr(self, "agent", None)
|
|
# ``reasoning_config`` is a mutable dict: deepcopy it so a later in-place edit cannot alias the snapshot.
|
|
return {
|
|
**_runtime_fields(self),
|
|
"reasoning_config": copy.deepcopy(getattr(self, "reasoning_config", None)),
|
|
"agent_primary_runtime": copy.deepcopy(
|
|
getattr(agent, "_primary_runtime", None)
|
|
) if agent is not None else None}
|
|
|
|
def _restore_model_runtime_snapshot(self, snapshot: dict | None) -> None:
|
|
"""Restore a model runtime captured before a one-turn override."""
|
|
from cli import logger
|
|
if not snapshot:
|
|
return
|
|
for key in _RUNTIME_FIELDS:
|
|
if key in snapshot:
|
|
setattr(self, key, snapshot.get(key))
|
|
|
|
agent = getattr(self, "agent", None)
|
|
if agent is None:
|
|
return
|
|
if "reasoning_config" in snapshot:
|
|
agent.reasoning_config = snapshot["reasoning_config"]
|
|
primary = snapshot.get("agent_primary_runtime")
|
|
if primary and hasattr(agent, "_restore_primary_runtime"):
|
|
try:
|
|
agent._primary_runtime = copy.deepcopy(primary)
|
|
agent._fallback_activated = True
|
|
agent._rate_limited_until = 0
|
|
if agent._restore_primary_runtime():
|
|
return
|
|
except Exception:
|
|
logger.debug("CLI one-turn model restore via primary runtime failed", exc_info=True)
|
|
if hasattr(agent, "switch_model"):
|
|
try:
|
|
agent.switch_model(
|
|
new_model=snapshot.get("model", ""), new_provider=snapshot.get("provider", ""),
|
|
api_key=snapshot.get("api_key", ""), base_url=snapshot.get("base_url", ""),
|
|
api_mode=snapshot.get("api_mode", ""),
|
|
capabilities=snapshot.get("capabilities"))
|
|
if "reasoning_config" in snapshot:
|
|
agent.reasoning_config = snapshot["reasoning_config"]
|
|
except Exception as exc:
|
|
logger.warning("CLI one-turn model restore failed: %s", exc)
|
|
|
|
@staticmethod
|
|
def _filter_model_picker_entries(entries: list, query: str) -> list:
|
|
"""Return (original_index, label) pairs matching ``query`` (case-insensitive subsequence;
|
|
empty matches all). The ORIGINAL index keeps a filtered selection resolving to exactly one
|
|
concrete model — filtering never introduces fuzzy *resolution*."""
|
|
pairs = list(enumerate(entries))
|
|
q = (query or "").strip().lower()
|
|
if not q:
|
|
return pairs
|
|
|
|
def _subseq(needle: str, hay: str) -> bool:
|
|
it = iter(hay)
|
|
return all(ch in it for ch in needle)
|
|
|
|
return [(i, e) for (i, e) in pairs if _subseq(q, str(e).lower())]
|
|
|
|
@staticmethod
|
|
def _compute_model_picker_viewport(
|
|
selected: int, scroll_offset: int, n: int, term_rows: int, reserved_below: int = 6,
|
|
panel_chrome: int = 6, min_visible: int = 3) -> tuple[int, int]:
|
|
"""Resolve (scroll_offset, visible) for the /model picker viewport. ``reserved_below``
|
|
matches the approval/clarify panels (input, status bar, separators); ``panel_chrome`` is
|
|
borders + blanks + hint row. The offset slides to keep ``selected`` on screen."""
|
|
max_visible = max(min_visible, term_rows - reserved_below - panel_chrome)
|
|
if n <= max_visible:
|
|
return 0, n
|
|
visible = max_visible
|
|
if selected < scroll_offset:
|
|
scroll_offset = selected
|
|
elif selected >= scroll_offset + visible:
|
|
scroll_offset = selected - visible + 1
|
|
return max(0, min(scroll_offset, n - visible)), visible
|
|
|
|
def _stage_and_swap_model(self, result, old_model) -> bool:
|
|
"""Stage ``result`` onto the CLI fields, then swap the live agent in place.
|
|
|
|
CLI fields are snapshotted first so a failed agent swap rolls the whole CLI back —
|
|
otherwise the staged broken credentials leak into the next turn even though the agent
|
|
rolled back. Returns False after printing the failure (a failed switch is a no-op).
|
|
"""
|
|
from cli import _cprint
|
|
_cli_snapshot = _runtime_fields(self)
|
|
self.model = result.new_model
|
|
self.provider = result.target_provider
|
|
self.requested_provider = result.target_provider
|
|
# Always overwrite explicit overrides so stale credentials from the previous provider
|
|
# (e.g. Ollama api_key/base_url) don't leak into the next resolution.
|
|
self._explicit_api_key = result.api_key
|
|
self._explicit_base_url = result.base_url
|
|
if result.api_key:
|
|
self.api_key = result.api_key
|
|
if result.base_url:
|
|
self.base_url = result.base_url
|
|
if result.api_mode:
|
|
self.api_mode = result.api_mode
|
|
_resolve_cli_reasoning(self)
|
|
|
|
if self.agent is not None:
|
|
try:
|
|
self.agent.switch_model(
|
|
new_model=result.new_model, new_provider=result.target_provider,
|
|
api_key=result.api_key, base_url=result.base_url, api_mode=result.api_mode,
|
|
capabilities=getattr(result, "runtime_capabilities", None))
|
|
except Exception as exc:
|
|
# The agent rolled itself back to the old working model/client. Roll the CLI's own staged
|
|
# fields back too and abort the rest of the commit (note + success print) so a failed switch
|
|
# is a no-op rather than a dead session (#50163).
|
|
# Agent rolled itself back; roll the CLI back too and abort so a failed switch is a no-op
|
|
# rather than a dead session (#50163).
|
|
for _k, _v in _cli_snapshot.items():
|
|
setattr(self, _k, _v)
|
|
_cprint(f" {t('cli.model.switch_failed', model=result.new_model, error=exc, old_model=old_model)}")
|
|
return False
|
|
return True
|
|
|
|
def _apply_model_switch_result(
|
|
self, result, persist_global: bool, custom_providers=None, reasoning_effort: str = "") -> None:
|
|
"""Picker-path commit (see _commit_model_switch)."""
|
|
from cli import _cprint
|
|
if not result.success:
|
|
_cprint(f" ✗ {result.error_message}")
|
|
return
|
|
_merge_preflight_warning(self, result, custom_providers)
|
|
_commit_model_switch(self, result, persist_global=persist_global, picker=True,
|
|
reasoning_effort=reasoning_effort)
|
|
|
|
def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
|
|
state = self._model_picker_state
|
|
if not state:
|
|
return
|
|
selected = state.get("selected", 0)
|
|
stage = state.get("stage")
|
|
if stage == "provider":
|
|
providers = state.get("providers") or []
|
|
if selected >= len(providers):
|
|
self._close_model_picker()
|
|
return
|
|
provider_data = providers[selected]
|
|
# Curated list (same as `hermes model` / gateway pickers); live catalog only when
|
|
# it is empty (user-defined endpoints, per-resource providers such as azure-foundry).
|
|
# Disk-cached like the gateway pickers: the live probe can walk several api-version
|
|
# fallbacks with a 6 s timeout each, which must not block the REPL on every select.
|
|
model_list = provider_data.get("models", [])
|
|
if not model_list:
|
|
try:
|
|
from hermes_cli.models import cached_provider_model_ids
|
|
model_list = cached_provider_model_ids(provider_data["slug"]) or model_list
|
|
except Exception:
|
|
pass
|
|
from hermes_cli.models_validate import offered_model_ids
|
|
model_list = offered_model_ids(
|
|
model_list, provider_data.get("slug"), provider_data.get("api_url"))
|
|
state.update(
|
|
stage="model", provider_data=provider_data, model_list=model_list,
|
|
selected=0, filter="", _filtered_pairs=None)
|
|
self._invalidate(min_interval=0.0)
|
|
return
|
|
if stage == "model":
|
|
provider_data = state.get("provider_data") or {}
|
|
model_list = state.get("model_list") or []
|
|
# Map the row through the active fuzzy filter; pairs carry the ORIGINAL index.
|
|
filtered_pairs = state.get("_filtered_pairs")
|
|
if filtered_pairs is None:
|
|
filtered_pairs = list(enumerate(model_list))
|
|
visible_labels = [e for (_i, e) in filtered_pairs]
|
|
back_idx = len(visible_labels)
|
|
if selected == back_idx:
|
|
state.update(
|
|
stage="provider", filter="", _filtered_pairs=None,
|
|
selected=next((i for i, p in enumerate(state.get("providers") or [])
|
|
if p.get("slug") == provider_data.get("slug")), 0))
|
|
self._invalidate(min_interval=0.0)
|
|
return
|
|
if selected > back_idx: # cancel row (and anything past it)
|
|
self._close_model_picker()
|
|
return
|
|
if 0 <= selected < back_idx:
|
|
result = _switch_model_from(
|
|
self, visible_labels[selected], is_global=persist_global,
|
|
explicit_provider=provider_data.get("slug"),
|
|
user_providers=state.get("user_provs"),
|
|
custom_providers=state.get("custom_provs"))
|
|
if result.success and _picker_offers_reasoning(provider_data, result.new_model):
|
|
# Third step: effort for the picked model (skipped for routes the catalog
|
|
# marks reasoning-free). Rows come from the canonical level set.
|
|
state.update(stage="reasoning", switch_result=result, selected=0, _scroll_offset=0)
|
|
self._invalidate(min_interval=0.0)
|
|
return
|
|
self._commit_picker_result(result, persist_global)
|
|
return
|
|
self._close_model_picker()
|
|
if stage == "reasoning":
|
|
rows = _picker_reasoning_rows()
|
|
result = state.get("switch_result")
|
|
if selected == len(rows): # ← Back to the model list
|
|
state.update(stage="model", selected=0, _scroll_offset=0, switch_result=None)
|
|
self._invalidate(min_interval=0.0)
|
|
return
|
|
if selected > len(rows) or result is None:
|
|
self._close_model_picker()
|
|
return
|
|
self._commit_picker_result(result, persist_global, reasoning_effort=rows[selected][0])
|
|
|
|
def _commit_picker_result(self, result, persist_global: bool, reasoning_effort: str = "") -> None:
|
|
"""Close the picker and run the confirm+apply sequence for ``result``."""
|
|
state = self._model_picker_state or {}
|
|
# Capture before close — picker state is cleared on close.
|
|
_picker_custom_provs = state.get("custom_provs")
|
|
self._close_model_picker()
|
|
# The effort is appended only when picked: stubs/tests pin the historical arity.
|
|
extra = (reasoning_effort,) if reasoning_effort else ()
|
|
_run_confirm_and_apply(
|
|
self, self._confirm_and_apply_model_switch_result,
|
|
result, persist_global, _picker_custom_provs, *extra)
|
|
|
|
def _handle_model_switch(self, cmd_original: str):
|
|
"""Handle /model command — switch model.
|
|
|
|
Supports:
|
|
/model — show current model + usage hints
|
|
/model <name> — switch model (this session only)
|
|
/model <name> --once — switch for the next turn only
|
|
/model <name> --session — switch for this session only (explicit)
|
|
/model <name> --global — switch and persist to config.yaml
|
|
/model <name> --provider <provider> — switch provider + model
|
|
/model <name> --reasoning <level> — switch and set reasoning effort in one step
|
|
/model --provider <provider> — switch to provider, auto-detect model
|
|
|
|
Switches are session-scoped unless ``model.persist_switch_by_default`` or ``--global``.
|
|
"""
|
|
from cli import _cprint
|
|
from hermes_cli.model_switch import parse_model_switch_args, resolve_persist_behavior
|
|
|
|
parts = cmd_original.split(None, 1) # split off '/model'
|
|
request = parse_model_switch_args(parts[1].strip() if len(parts) > 1 else "")
|
|
if request.errors:
|
|
# CLI decoration: " ✗ " prefix over the canonical error copy.
|
|
_cprint(f" ✗ {request.error_messages()[0]}")
|
|
return
|
|
one_turn = request.is_once
|
|
persist_global = resolve_persist_behavior(
|
|
request.is_global, request.is_session, is_once=one_turn,
|
|
explicit_provider=request.explicit_provider)
|
|
|
|
# --refresh: wipe the picker cache so every authed provider's /v1/models is re-fetched.
|
|
if request.force_refresh:
|
|
try:
|
|
from hermes_cli.models import clear_provider_models_cache
|
|
clear_provider_models_cache()
|
|
_cprint(f" {t('cli.model.cleared_picker_cache')}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Live session state is overlaid truthy-only so empty self.* attrs don't clobber config.
|
|
from hermes_cli.inventory import load_picker_context
|
|
try:
|
|
ctx = load_picker_context().with_overrides(
|
|
current_provider=self.provider or "", current_model=self.model or "",
|
|
current_base_url=self.base_url or "")
|
|
except Exception:
|
|
ctx = None
|
|
# switch_model() + _open_model_picker still need the raw provider dicts.
|
|
user_provs = ctx.user_providers if ctx is not None else None
|
|
custom_provs = ctx.custom_providers if ctx is not None else None
|
|
|
|
if not request.target and not request.explicit_provider:
|
|
return _show_model_picker(self, ctx, request.force_refresh)
|
|
|
|
result = _switch_model_from(
|
|
self, request.target, is_global=persist_global,
|
|
explicit_provider=request.explicit_provider,
|
|
user_providers=user_provs, custom_providers=custom_provs)
|
|
if not result.success:
|
|
_cprint(f" ✗ {result.error_message}")
|
|
return
|
|
_merge_preflight_warning(self, result, custom_provs)
|
|
extra = (request.reasoning_effort,) if request.reasoning_effort else ()
|
|
_run_confirm_and_apply(
|
|
self, self._confirm_and_apply_cli_model_switch,
|
|
result, persist_global, one_turn, custom_provs, *extra)
|
|
|
|
def _confirm_and_apply_cli_model_switch(
|
|
self, result, persist_global: bool, one_turn: bool, custom_provs=None, reasoning_effort: str = "") -> None:
|
|
"""Confirm an expensive model switch and apply it (typed /model path). Runs on a worker
|
|
thread when the TUI is active (see _run_confirm_and_apply) so the modal can render."""
|
|
from cli import _cprint
|
|
if not self._confirm_expensive_model_switch(result):
|
|
_cprint(f" {t('cli.model.switch_cancelled')}")
|
|
return
|
|
_commit_model_switch(self, result, persist_global=persist_global, one_turn=one_turn,
|
|
reasoning_effort=reasoning_effort)
|
|
|
|
def _handle_codex_runtime(self, cmd_original: str) -> None:
|
|
"""Handle /codex-runtime — toggle the codex app-server runtime opt-in.
|
|
|
|
Usage:
|
|
/codex-runtime — show current state
|
|
/codex-runtime auto — Hermes default (chat_completions)
|
|
/codex-runtime codex_app_server — hand turns to codex subprocess
|
|
/codex-runtime on / off — synonyms for the above
|
|
"""
|
|
from cli import _cprint
|
|
from hermes_cli import codex_runtime_switch as crs
|
|
|
|
parts = cmd_original.split(None, 1)
|
|
new_value, errors = crs.parse_args(parts[1].strip() if len(parts) > 1 else "")
|
|
if errors:
|
|
for err in errors:
|
|
_cprint(f"❌ {err}")
|
|
return
|
|
try:
|
|
from hermes_cli.config import load_config, save_config
|
|
except Exception as exc:
|
|
_cprint(t("cli.model.could_not_load_config", error=exc))
|
|
return
|
|
result = crs.apply(
|
|
load_config(), new_value,
|
|
persist_callback=(save_config if new_value is not None else None))
|
|
prefix = "✓" if result.success else "✗"
|
|
for line in result.message.splitlines():
|
|
_cprint(f" {prefix} {line}" if line.startswith("openai_runtime") else f" {line}")
|
|
if result.success and result.requires_new_session:
|
|
_cprint(f" {t('cli.model.tip_reset_new_session')}")
|
|
|
|
def _should_handle_model_command_inline(self, text: str, has_images: bool = False) -> bool:
|
|
"""Return True when /model should be handled immediately on the UI thread."""
|
|
from cli import _looks_like_slash_command
|
|
if not text or has_images or not _looks_like_slash_command(text):
|
|
return False
|
|
try:
|
|
from hermes_cli.commands import resolve_command
|
|
cmd = resolve_command(text.split(None, 1)[0].lower().lstrip('/'))
|
|
return bool(cmd and cmd.name == "model")
|
|
except Exception:
|
|
return False
|
|
|
|
def _cmd_moa(self, cmd_original: str):
|
|
"""/moa one-shot: run one prompt through the default MoA preset, then restore the prior
|
|
model (a session-long MoA switch goes through the picker's virtual MoA provider)."""
|
|
from cli import _cprint, _slash_args
|
|
from hermes_cli.moa_config import moa_usage, normalize_moa_config
|
|
|
|
payload = _slash_args(cmd_original)
|
|
if not payload:
|
|
_cprint(f" {moa_usage()}")
|
|
return True
|
|
moa_cfg = self.config.get("moa") if isinstance(self.config, dict) else {}
|
|
preset = normalize_moa_config(moa_cfg)["default_preset"]
|
|
self._pending_moa_restore_model = {
|
|
key: getattr(self, key, None)
|
|
for key in (
|
|
"requested_provider", "provider", "model", "api_key", "base_url", "api_mode")}
|
|
self.requested_provider = "moa"
|
|
self.provider = "moa"
|
|
self.model = preset
|
|
self.api_key = "moa-virtual-provider"
|
|
self.base_url = "moa://local"
|
|
self.api_mode = "chat_completions"
|
|
_retire_agent(self)
|
|
self._pending_moa_disable_after_turn = True
|
|
self._pending_agent_seed = payload
|
|
_cprint(f" {t('cli.model.moa_one_shot_queued', preset=preset)}")
|