Files
hermes-agent/hermes_cli/cli_model_switch_mixin.py
Teknium 9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* 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>
2026-09-28 14:16:18 -07:00

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)}")