feat: add /model --once one-turn model override (#29914)

Adds --once to /model across CLI, TUI, and gateway: switch model for the
next turn only, restoring the previous model in a finally block so
success, exception, and interrupt all revert. Parsing extends
parse_model_flags_detailed(); resolve_persist_behavior() treats --once
as a persistence opt-out; --global + --once is rejected.

Salvaged from PR #29923 (image-generation lane split to #59815 per
review; conflict resolution against current main by the maintainers).
This commit is contained in:
deusyu 2026-07-18 12:59:40 -07:00 committed by Teknium
parent 7ab95b4c9c
commit 3f84b7a163
9 changed files with 683 additions and 63 deletions

109
cli.py
View file

@ -24,6 +24,7 @@ except ModuleNotFoundError:
pass
import logging
import copy
import os
import shutil
import sys
@ -6163,7 +6164,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
pass
def _show_security_advisories(self):
"""Show a startup banner if any unacked security advisories match.
@ -7835,6 +7836,67 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
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)
return {
"model": self.model,
"provider": self.provider,
"requested_provider": self.requested_provider,
"_explicit_api_key": getattr(self, "_explicit_api_key", None),
"_explicit_base_url": getattr(self, "_explicit_base_url", None),
"api_key": self.api_key,
"base_url": self.base_url,
"api_mode": self.api_mode,
"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."""
if not snapshot:
return
for key in (
"model",
"provider",
"requested_provider",
"_explicit_api_key",
"_explicit_base_url",
"api_key",
"base_url",
"api_mode",
):
if key in snapshot:
setattr(self, key, snapshot.get(key))
agent = getattr(self, "agent", None)
if agent is None:
return
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", ""),
)
except Exception as exc:
logger.warning("CLI one-turn model restore failed: %s", exc)
@staticmethod
def _compute_model_picker_viewport(
selected: int,
@ -8068,17 +8130,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
Supports:
/model show current model + usage hints
/model <name> switch model (persists by default)
/model <name> --once switch for the next turn only
/model <name> --session switch for this session only
/model <name> --global switch and persist (explicit)
/model <name> --provider <provider> switch provider + model
/model --provider <provider> switch to provider, auto-detect model
Persistence defaults to on (``model.persist_switch_by_default`` in
config.yaml, default True). Use ``--session`` for a one-off switch.
config.yaml, default True). Use ``--session`` for this CLI session or
``--once`` for the next turn only.
"""
from hermes_cli.model_switch import (
switch_model,
parse_model_flags,
parse_model_flags_detailed,
resolve_persist_behavior,
)
from hermes_cli.providers import get_label
@ -8087,19 +8151,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
parts = cmd_original.split(None, 1) # split off '/model'
raw_args = parts[1].strip() if len(parts) > 1 else ""
# Parse --provider, --global, --session, and --refresh flags
(
model_input,
explicit_provider,
is_global_flag,
force_refresh,
is_session,
) = parse_model_flags(raw_args)
# Parse --provider, --global, --session, --once, and --refresh flags
parsed_flags = parse_model_flags_detailed(raw_args)
model_input = parsed_flags.model_input
explicit_provider = parsed_flags.explicit_provider
is_global_flag = parsed_flags.is_global
force_refresh = parsed_flags.force_refresh
is_session = parsed_flags.is_session
one_turn = parsed_flags.is_once
if is_global_flag and one_turn:
_cprint(" ✗ /model --once cannot be combined with --global")
return
if one_turn and not model_input and not explicit_provider:
_cprint(" ✗ /model --once requires a model or provider.")
return
# Resolve the effective persistence once: --session overrides the
# config-gated default, --global forces persist, otherwise defer to
# model.persist_switch_by_default (defaults to True so /model survives
# across sessions).
persist_global = resolve_persist_behavior(is_global_flag, is_session)
persist_global = resolve_persist_behavior(is_global_flag, is_session, is_once=one_turn)
# --refresh: wipe the on-disk picker cache before building the
# provider list. Forces a live re-fetch of every authed provider's
@ -8148,6 +8218,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(" No authenticated providers found.")
_cprint("")
_cprint(" /model <name> switch model (persists)")
_cprint(" /model <name> --once switch for the next turn only")
_cprint(" /model <name> --session switch for this session only")
_cprint(" /model --provider <slug> switch provider")
_cprint(" /model --refresh re-fetch live model lists")
@ -8200,6 +8271,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Update requested_provider so _ensure_runtime_credentials() doesn't
# overwrite the switch on the next turn (it re-resolves from this).
old_model = self.model
_one_turn_restore_snapshot = self._snapshot_model_runtime() if one_turn else None
# Snapshot CLI-level fields before mutation so a failed in-place swap
# rolls the whole CLI back to the old working model (#50163).
_cli_snapshot = {
@ -8254,8 +8326,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._pending_model_switch_note = (
f"[Note: model was just switched from {old_model} to {result.new_model} "
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.]"
)
if one_turn:
self._pending_one_turn_model_restore = _one_turn_restore_snapshot
else:
self._pending_one_turn_model_restore = None
# Display confirmation with full metadata
provider_label = result.provider_label or result.target_provider
@ -8305,6 +8382,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
save_config_value("model.base_url", result.base_url or None)
save_config_value("model.api_mode", result.api_mode or None)
_cprint(" Saved to config.yaml")
elif one_turn:
_cprint(" (next turn only — restores after one response)")
else:
_cprint(" (session only — add --global to persist)")
@ -11809,6 +11888,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_persist_clean_user_message = (
message if (_voice_prefix or agent_message != message) else None
)
_one_turn_model_restore = getattr(
self, "_pending_one_turn_model_restore", None
)
self._pending_one_turn_model_restore = None
try:
result = self.agent.run_conversation(
user_message=agent_message,
@ -11838,6 +11921,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"error": _summary,
}
finally:
if _one_turn_model_restore:
self._restore_model_runtime_snapshot(_one_turn_model_restore)
# Surface any credit notices queued during the turn (cold-start
# seed / per-turn capture) now that the response is done — printing
# at this boundary paints cleanly above the prompt instead of being