refactor(gateway): compact slash_commands_session/model — fold calls, inline single-use helpers, tighten docs
- Fold parenthesised multi-line calls that fit in 100 cols; inline _switch_and_commit, _reasoning_picker_choices, _configured_display_context, listing_kwargs (single-use). - Collapse getattr-guarded calls on always-present mixin methods (_is_user_authorized, _schedule_telegram_topic_title_rename); next()-based lookups; contextlib.suppress. - Docstrings/comments compacted by hand — every WHY (IDOR guards, cache/rotation invariants, off-loop rationale) kept; narrative and issue-number history dropped. - Differential parity vs base: 9216 traced cases for /new,/title,/undo,/branch; 12000 for the resume ownership guards; /fast + /reasoning tables; listing goldens.
This commit is contained in:
@@ -19,8 +19,7 @@ from gateway.platforms.base import MessageEvent
|
||||
from hermes_cli.config import atomic_config_write, clear_model_endpoint_credentials
|
||||
from utils import base_url_host_matches
|
||||
|
||||
# Log-record parity with gateway/run.py and the origin module.
|
||||
logger = logging.getLogger("gateway.run")
|
||||
logger = logging.getLogger("gateway.run") # log-record parity with gateway/run.py
|
||||
|
||||
# /fast argument -> (service tier, persisted value, i18n label key; None = value.upper()).
|
||||
_FAST_SELECTIONS = {
|
||||
@@ -37,12 +36,8 @@ _REASONING_DISPLAY_TOGGLES = {"show": True, "on": True, "hide": False, "off": Fa
|
||||
|
||||
|
||||
def _model_switch_skew_guard() -> Optional[str]:
|
||||
"""Refuse a model switch when the gateway is running stale code.
|
||||
|
||||
A long-lived gateway keeps boot-time modules in memory; if the checkout changed underneath it,
|
||||
a first-time lazy import on a new code path can crash on a stale cached dependency. Scoped to
|
||||
model switching only (the highest-risk trigger).
|
||||
"""
|
||||
"""Refuse a model switch when the gateway is running stale code: a first-time lazy import on
|
||||
a new code path can crash on a stale cached dependency. Scoped to the highest-risk trigger."""
|
||||
from gateway.code_skew import detect_code_skew
|
||||
|
||||
skew = detect_code_skew()
|
||||
@@ -62,10 +57,9 @@ def _model_switch_skew_guard() -> Optional[str]:
|
||||
async def _persist_model_switch_to_config(result, config_path) -> None:
|
||||
"""Write-through a resolved /model switch to ``config_path`` (model.default/provider/base_url).
|
||||
|
||||
Raw read is correct (merged defaults must not be persisted back). A scalar/None ``model:`` is
|
||||
coerced into a dict first or the assignments below raise ``TypeError``. Named providers
|
||||
re-resolve base_url/api_mode fresh, so leftovers are cleared; custom providers have no registry
|
||||
entry to re-derive from, so they need an explicit set-or-clear.
|
||||
Raw read: merged defaults must not be persisted back. A scalar/None ``model:`` is coerced to a
|
||||
dict first. Named providers re-resolve base_url/api_mode, so leftovers are cleared; custom
|
||||
providers have no registry entry to re-derive from and need an explicit set-or-clear.
|
||||
"""
|
||||
from hermes_cli.config import read_user_config_raw, save_config
|
||||
|
||||
@@ -79,17 +73,13 @@ async def _persist_model_switch_to_config(result, config_path) -> None:
|
||||
model_cfg = cfg["model"] = {}
|
||||
try:
|
||||
from hermes_cli.route_identity import should_clear_context_pin_async
|
||||
|
||||
if await should_clear_context_pin_async(
|
||||
model_cfg.get("default") or model_cfg.get("model"),
|
||||
result.new_model,
|
||||
model_cfg.get("base_url"),
|
||||
result.base_url,
|
||||
model_cfg.get("provider"),
|
||||
result.target_provider,
|
||||
):
|
||||
model_cfg.pop("context_length", None)
|
||||
clear_pin = await should_clear_context_pin_async(
|
||||
model_cfg.get("default") or model_cfg.get("model"), result.new_model,
|
||||
model_cfg.get("base_url"), result.base_url, model_cfg.get("provider"), result.target_provider,
|
||||
)
|
||||
except Exception:
|
||||
clear_pin = True
|
||||
if clear_pin:
|
||||
model_cfg.pop("context_length", None)
|
||||
model_cfg["default"] = result.new_model
|
||||
model_cfg["provider"] = result.target_provider
|
||||
@@ -128,7 +118,6 @@ class _ModelSwitchContext:
|
||||
def read_config(self) -> None:
|
||||
"""Fill the current route from ``config_path``; fail-open to the defaults."""
|
||||
from gateway.run import _load_gateway_config
|
||||
|
||||
try:
|
||||
cfg = _load_gateway_config(config_path=self.config_path)
|
||||
if not cfg:
|
||||
@@ -158,15 +147,6 @@ class _ModelSwitchContext:
|
||||
self.current_base_url = override.get("base_url", self.current_base_url)
|
||||
self.current_api_key = override.get("api_key", self.current_api_key)
|
||||
|
||||
def listing_kwargs(self) -> dict:
|
||||
return dict(
|
||||
current_provider=self.current_provider,
|
||||
current_base_url=self.current_base_url,
|
||||
current_model=self.current_model,
|
||||
user_providers=self.user_provs,
|
||||
custom_providers=self.custom_provs,
|
||||
excluded_providers=self.excluded_provs,
|
||||
)
|
||||
|
||||
|
||||
def _model_provider_listing_lines(providers) -> list[str]:
|
||||
@@ -186,23 +166,6 @@ def _model_provider_listing_lines(providers) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
async def _configured_display_context() -> tuple[dict, Optional[int]]:
|
||||
"""(model config section, its ``context_length`` as int) for the switch confirmation; fail-open."""
|
||||
from gateway.run import _load_gateway_config
|
||||
|
||||
model_cfg: dict = {}
|
||||
config_ctx = None
|
||||
with contextlib.suppress(Exception):
|
||||
model_cfg = _load_gateway_config().get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
raw = model_cfg.get("context_length")
|
||||
if raw is not None:
|
||||
config_ctx = int(raw)
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
return model_cfg, config_ctx
|
||||
|
||||
|
||||
class GatewayModelCommandsMixin:
|
||||
"""Model-route slash commands (/model, /codex-runtime, /reasoning, /fast, /personality)."""
|
||||
|
||||
@@ -218,33 +181,22 @@ class GatewayModelCommandsMixin:
|
||||
skew_error = _model_switch_skew_guard()
|
||||
if skew_error:
|
||||
return None, skew_error
|
||||
# Off the event loop: switch_model() can fall through to a synchronous models.dev HTTP
|
||||
# fetch (15s timeout) on a cold/expired cache, which would freeze the gateway.
|
||||
# Off-loop: switch_model() can hit a synchronous models.dev fetch (15s) on a cold cache.
|
||||
result = await asyncio.to_thread(
|
||||
switch_model,
|
||||
raw_input=raw_input,
|
||||
current_provider=ctx.current_provider,
|
||||
current_model=ctx.current_model,
|
||||
current_base_url=ctx.current_base_url,
|
||||
current_api_key=ctx.current_api_key,
|
||||
is_global=ctx.persist_global,
|
||||
explicit_provider=explicit_provider,
|
||||
user_providers=ctx.user_provs,
|
||||
switch_model, raw_input=raw_input, current_provider=ctx.current_provider,
|
||||
current_model=ctx.current_model, current_base_url=ctx.current_base_url,
|
||||
current_api_key=ctx.current_api_key, is_global=ctx.persist_global,
|
||||
explicit_provider=explicit_provider, user_providers=ctx.user_provs,
|
||||
custom_providers=ctx.custom_provs,
|
||||
)
|
||||
if not result.success:
|
||||
return None, t("gateway.model.error_prefix", error=result.error_message)
|
||||
try:
|
||||
from hermes_cli.context_switch_guard import enrich_model_switch_warnings_for_gateway
|
||||
|
||||
# Off-loop: merge_preflight_compression_warning() runs the sync provider probe ladder.
|
||||
await asyncio.to_thread(
|
||||
enrich_model_switch_warnings_for_gateway,
|
||||
result,
|
||||
self,
|
||||
session_key=ctx.session_key,
|
||||
source=source,
|
||||
custom_providers=ctx.custom_provs,
|
||||
enrich_model_switch_warnings_for_gateway, result, self, session_key=ctx.session_key,
|
||||
source=source, custom_providers=ctx.custom_provs,
|
||||
load_gateway_config=_load_gateway_config,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -254,20 +206,16 @@ class GatewayModelCommandsMixin:
|
||||
def _switch_cached_agent_model(self, result, ctx: _ModelSwitchContext, picker: bool) -> Optional[str]:
|
||||
"""In-place swap on the cached agent; returns the error reply when it failed.
|
||||
|
||||
The agent rolls back to the OLD working model/client and re-raises. Abort the commit (DB
|
||||
persist, session override, cache eviction, config write) so a failed switch is a no-op —
|
||||
otherwise the next message rebuilds a broken agent from the override.
|
||||
The agent rolls back to the OLD model/client and re-raises; the commit (DB, override,
|
||||
eviction, config) is aborted so the next message doesn't rebuild a broken agent.
|
||||
"""
|
||||
cached_agent = self._cached_agent_for(ctx.session_key)
|
||||
if cached_agent is None:
|
||||
return None
|
||||
try:
|
||||
cached_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,
|
||||
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:
|
||||
@@ -286,13 +234,12 @@ class GatewayModelCommandsMixin:
|
||||
"""Persist a committed switch: session DB, next-turn note, override map, config write-through."""
|
||||
from hermes_cli.model_switch import format_model_for_display
|
||||
|
||||
# Session DB so the dashboard shows the updated model.
|
||||
_sess_db = getattr(self, "_session_db", None)
|
||||
if _sess_db is not None:
|
||||
if _sess_db is not None: # so the dashboard shows the updated model
|
||||
try:
|
||||
_sess_entry = await self.async_session_store.get_or_create_session(source)
|
||||
# Typed path: consume an auto-reset flag so the next regular message's cleanup does
|
||||
# not wipe the override stored below.
|
||||
# Typed path: consume the auto-reset flag so the next message's cleanup does not
|
||||
# wipe the override stored below.
|
||||
if not picker and getattr(_sess_entry, "was_auto_reset", False):
|
||||
_sess_entry.was_auto_reset = False
|
||||
await _sess_db.update_session_model(
|
||||
@@ -300,9 +247,8 @@ class GatewayModelCommandsMixin:
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to persist model switch to DB: %s", exc)
|
||||
|
||||
# Note prepended to the next user message (avoids system messages mid-history). Display
|
||||
# form strips opaque Palantir RID prefixes; the override map keeps the full ID for the wire.
|
||||
# Prepended to the next user message (no system messages mid-history). Display form strips
|
||||
# opaque Palantir RID prefixes; the override map keeps the full ID for the wire.
|
||||
if not hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes = {}
|
||||
self._pending_model_notes[ctx.session_key] = (
|
||||
@@ -329,10 +275,8 @@ class GatewayModelCommandsMixin:
|
||||
)
|
||||
elif not picker and hasattr(self, "_pending_one_turn_model_restores"):
|
||||
self._pending_one_turn_model_restores.pop(ctx.session_key, None)
|
||||
|
||||
# Non-secret write-through so the override survives a restart (api_key/api_mode are
|
||||
# re-resolved on rehydration). /model --once is EXCLUDED: a one-turn override must not
|
||||
# outlive a restart.
|
||||
# re-resolved on rehydration); a --once override must NOT outlive a restart.
|
||||
if not one_turn:
|
||||
try:
|
||||
await self.async_session_store.set_model_override(
|
||||
@@ -340,10 +284,7 @@ class GatewayModelCommandsMixin:
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to persist session model override", exc_info=True)
|
||||
|
||||
# Evict so the next turn builds a fresh agent from the override.
|
||||
self._evict_cached_agent(ctx.session_key)
|
||||
|
||||
self._evict_cached_agent(ctx.session_key) # next turn builds fresh from the override
|
||||
if ctx.persist_global:
|
||||
try:
|
||||
await _persist_model_switch_to_config(result, ctx.config_path)
|
||||
@@ -354,24 +295,28 @@ class GatewayModelCommandsMixin:
|
||||
self, result, ctx: _ModelSwitchContext, *, one_turn: bool, picker: bool
|
||||
) -> str:
|
||||
"""Confirmation text with full metadata (display form shortens opaque Palantir IDs)."""
|
||||
from gateway.run import _load_gateway_config
|
||||
from hermes_cli.model_switch import format_model_for_display, resolve_display_context_length_async
|
||||
|
||||
lines = [
|
||||
t("gateway.model.switched", model=format_model_for_display(result.new_model)),
|
||||
t("gateway.model.provider_label", provider=result.provider_label or result.target_provider),
|
||||
]
|
||||
# Context: the provider-aware chain so Codex OAuth, Copilot and Nous-enforced caps win over
|
||||
# the raw models.dev entry.
|
||||
# Provider-aware chain: Codex OAuth, Copilot and Nous caps win over the raw models.dev entry.
|
||||
mi = result.model_info
|
||||
model_cfg, config_ctx = await _configured_display_context()
|
||||
model_cfg: dict = {}
|
||||
config_ctx = None
|
||||
with contextlib.suppress(Exception): # fail-open on config read errors
|
||||
model_cfg = _load_gateway_config().get("model", {})
|
||||
if isinstance(model_cfg, dict) and model_cfg.get("context_length") is not None:
|
||||
config_ctx = int(model_cfg["context_length"])
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
ctx_len = await resolve_display_context_length_async(
|
||||
result.new_model,
|
||||
result.target_provider,
|
||||
result.new_model, result.target_provider,
|
||||
base_url=result.base_url or ctx.current_base_url or "",
|
||||
api_key=result.api_key or ctx.current_api_key or "",
|
||||
model_info=mi,
|
||||
custom_providers=ctx.custom_provs,
|
||||
config_context_length=config_ctx,
|
||||
api_key=result.api_key or ctx.current_api_key or "", model_info=mi,
|
||||
custom_providers=ctx.custom_provs, config_context_length=config_ctx,
|
||||
configured_model=model_cfg.get("default") or model_cfg.get("model"),
|
||||
configured_provider=model_cfg.get("provider"),
|
||||
configured_base_url=model_cfg.get("base_url"),
|
||||
@@ -400,11 +345,8 @@ class GatewayModelCommandsMixin:
|
||||
async def _commit_model_switch(
|
||||
self, result, ctx: _ModelSwitchContext, *, source, picker: bool = False
|
||||
) -> str:
|
||||
"""Apply a resolved switch (cached agent, session, config) and build the confirmation.
|
||||
|
||||
Shared by the typed ``/model <name>`` path and the picker callback (``picker=True``, which
|
||||
never carries a one-turn override).
|
||||
"""
|
||||
"""Apply a resolved switch (cached agent, session, config) and build the confirmation; shared
|
||||
by the typed path and the picker callback (``picker=True`` never carries --once)."""
|
||||
one_turn = False if picker else ctx.one_turn
|
||||
error = self._switch_cached_agent_model(result, ctx, picker)
|
||||
if error is not None:
|
||||
@@ -412,24 +354,11 @@ class GatewayModelCommandsMixin:
|
||||
await self._record_model_switch(result, ctx, source=source, one_turn=one_turn, picker=picker)
|
||||
return await self._model_switch_confirmation(result, ctx, one_turn=one_turn, picker=picker)
|
||||
|
||||
async def _switch_and_commit(
|
||||
self, ctx: _ModelSwitchContext, model_id: str, provider_slug, *, source, picker: bool = False
|
||||
) -> str:
|
||||
result, error = await self._perform_model_switch(ctx, model_id, provider_slug, source)
|
||||
if error is not None:
|
||||
return error
|
||||
return await self._commit_model_switch(result, ctx, source=source, picker=picker)
|
||||
|
||||
async def _send_model_picker(self, event: MessageEvent, source, adapter, session_key: str, listing_kwargs: dict, on_model_selected) -> bool:
|
||||
"""Send the interactive /model picker; False when nothing was sent (text fallback).
|
||||
|
||||
*source* is the session-key-normalized source (Telegram topic recovery), so the picker's
|
||||
thread metadata lands where the next turn reads.
|
||||
"""
|
||||
"""Send the interactive /model picker; False when nothing was sent (text fallback). *source*
|
||||
is session-key-normalized so the picker's thread metadata lands where the next turn reads."""
|
||||
from hermes_cli.model_switch import list_picker_providers
|
||||
|
||||
try:
|
||||
# Off-loop: provider listing can fall through to a synchronous HTTP fetch on a stale cache.
|
||||
try: # off-loop: listing can hit a synchronous HTTP fetch on a stale cache
|
||||
providers = await asyncio.to_thread(
|
||||
list_picker_providers, max_models=50, include_moa=True, **listing_kwargs
|
||||
)
|
||||
@@ -438,11 +367,9 @@ class GatewayModelCommandsMixin:
|
||||
if not providers:
|
||||
return False
|
||||
result = await adapter.send_model_picker(
|
||||
chat_id=source.chat_id,
|
||||
providers=providers,
|
||||
chat_id=source.chat_id, providers=providers,
|
||||
current_model=listing_kwargs["current_model"],
|
||||
current_provider=listing_kwargs["current_provider"],
|
||||
session_key=session_key,
|
||||
current_provider=listing_kwargs["current_provider"], session_key=session_key,
|
||||
on_model_selected=on_model_selected,
|
||||
metadata=self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)),
|
||||
)
|
||||
@@ -455,25 +382,32 @@ class GatewayModelCommandsMixin:
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
from hermes_cli.providers import get_label
|
||||
|
||||
listing_kwargs = ctx.listing_kwargs()
|
||||
listing_kwargs = dict(
|
||||
current_provider=ctx.current_provider, current_base_url=ctx.current_base_url,
|
||||
current_model=ctx.current_model, user_providers=ctx.user_provs,
|
||||
custom_providers=ctx.custom_provs, excluded_providers=ctx.excluded_provs,
|
||||
)
|
||||
adapter = self._adapter_for_source(ctx.source)
|
||||
if adapter is not None and getattr(type(adapter), "send_model_picker", None) is not None:
|
||||
async def _on_model_selected(_chat_id: str, model_id: str, provider_slug: str) -> str:
|
||||
"""Perform the model switch and return confirmation text."""
|
||||
async def _picker_switch(model_id: str, provider_slug: str) -> str:
|
||||
# The picker callback binds the raw event source (pre-normalization).
|
||||
if profile_home is None:
|
||||
return await self._switch_and_commit(ctx, model_id, provider_slug, source=event.source, picker=True)
|
||||
from gateway.run import _profile_runtime_scope
|
||||
result, error = await self._perform_model_switch(ctx, model_id, provider_slug, event.source)
|
||||
if error is not None:
|
||||
return error
|
||||
return await self._commit_model_switch(result, ctx, source=event.source, picker=True)
|
||||
|
||||
async def _on_model_selected(_chat_id: str, model_id: str, provider_slug: str) -> str:
|
||||
if profile_home is None:
|
||||
return await _picker_switch(model_id, provider_slug)
|
||||
from gateway.run import _profile_runtime_scope
|
||||
with _profile_runtime_scope(profile_home):
|
||||
return await self._switch_and_commit(ctx, model_id, provider_slug, source=event.source, picker=True)
|
||||
return await _picker_switch(model_id, provider_slug)
|
||||
|
||||
if await self._send_model_picker(event, ctx.source, adapter, ctx.session_key, listing_kwargs, _on_model_selected):
|
||||
return None # Picker sent — adapter handles the response
|
||||
|
||||
lines = [t("gateway.model.current_label", model=ctx.current_model or "unknown", provider=get_label(ctx.current_provider)), ""]
|
||||
try:
|
||||
# Off-loop: provider listing can fall through to a stale-cache HTTP fetch.
|
||||
try: # off-loop: listing can hit a stale-cache HTTP fetch
|
||||
providers = await asyncio.to_thread(list_authenticated_providers, max_models=5, **listing_kwargs)
|
||||
lines.extend(_model_provider_listing_lines(providers))
|
||||
except Exception:
|
||||
@@ -488,20 +422,16 @@ class GatewayModelCommandsMixin:
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Selection-guard confirmation for the typed path (pickers confirm via their own UI).
|
||||
|
||||
Runs the unified registry (cost + data-policy guards) off the event loop — pricing lookups
|
||||
may hit models.dev or a /models endpoint on a cache miss. Returns ``(fired, reply)``; the
|
||||
reply may be None when the platform rendered confirm buttons itself.
|
||||
The unified registry (cost + data-policy guards) runs off-loop — pricing lookups may hit
|
||||
models.dev on a cache miss. Returns ``(fired, reply)``; the reply is None when the platform
|
||||
rendered confirm buttons itself.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.model_selection_guards import combined_selection_warning
|
||||
|
||||
warning = await asyncio.to_thread(
|
||||
combined_selection_warning,
|
||||
result.new_model,
|
||||
provider=result.target_provider,
|
||||
combined_selection_warning, result.new_model, provider=result.target_provider,
|
||||
base_url=result.base_url or ctx.current_base_url or "",
|
||||
api_key=result.api_key or ctx.current_api_key or "",
|
||||
model_info=result.model_info,
|
||||
api_key=result.api_key or ctx.current_api_key or "", model_info=result.model_info,
|
||||
)
|
||||
except Exception:
|
||||
warning = None
|
||||
@@ -538,20 +468,15 @@ class GatewayModelCommandsMixin:
|
||||
profile_home = None
|
||||
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
|
||||
profile_home = self._resolve_profile_home_for_source(event.source)
|
||||
|
||||
# --provider/--global/--session/--once/--refresh via the single-owner parser.
|
||||
request = parse_model_switch_args(event.get_command_args().strip())
|
||||
request = parse_model_switch_args(event.get_command_args().strip()) # single-owner parser
|
||||
if request.errors:
|
||||
# Gateway decoration: "❌ " prefix over the canonical error copy.
|
||||
return f"❌ {request.error_messages()[0]}"
|
||||
if request.force_refresh:
|
||||
# Bust the disk cache so the picker shows live data.
|
||||
return f"❌ {request.error_messages()[0]}" # gateway decoration over canonical copy
|
||||
if request.force_refresh: # bust the disk cache so the picker shows live data
|
||||
with contextlib.suppress(Exception):
|
||||
from hermes_cli.models import clear_provider_models_cache
|
||||
clear_provider_models_cache()
|
||||
|
||||
# Normalize the source the same way a message turn does (Telegram DM topic recovery) before
|
||||
# deriving the override key, so the override is stored under the key the next turn reads.
|
||||
# Normalize like a message turn (Telegram DM topic recovery) before deriving the override
|
||||
# key, so the override lands under the key the next turn reads.
|
||||
source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source)
|
||||
session_key = self._session_key_for_source(source)
|
||||
ctx = _ModelSwitchContext(
|
||||
@@ -559,9 +484,7 @@ class GatewayModelCommandsMixin:
|
||||
source=source,
|
||||
config_path=(profile_home or _hermes_home) / "config.yaml",
|
||||
persist_global=resolve_persist_behavior(
|
||||
request.is_global,
|
||||
request.is_session,
|
||||
is_once=request.is_once,
|
||||
request.is_global, request.is_session, is_once=request.is_once,
|
||||
explicit_provider=request.explicit_provider,
|
||||
),
|
||||
one_turn=request.is_once,
|
||||
@@ -569,10 +492,8 @@ class GatewayModelCommandsMixin:
|
||||
)
|
||||
ctx.read_config()
|
||||
ctx.apply_override(self._session_model_overrides.get(session_key, {}))
|
||||
|
||||
if not request.target and not request.explicit_provider:
|
||||
return await self._model_listing_reply(event, ctx, profile_home)
|
||||
|
||||
result, error = await self._perform_model_switch(ctx, request.target, request.explicit_provider, source)
|
||||
if error is not None:
|
||||
return error
|
||||
@@ -588,8 +509,7 @@ class GatewayModelCommandsMixin:
|
||||
on the next message (avoids prompt-cache invalidation mid-session)."""
|
||||
from hermes_cli import codex_runtime_switch as crs
|
||||
|
||||
raw_args = event.get_command_args().strip() if event else ""
|
||||
new_value, errors = crs.parse_args(raw_args)
|
||||
new_value, errors = crs.parse_args(event.get_command_args().strip() if event else "")
|
||||
if errors:
|
||||
return "❌ " + "\n❌ ".join(errors)
|
||||
try:
|
||||
@@ -603,10 +523,8 @@ class GatewayModelCommandsMixin:
|
||||
try:
|
||||
self._evict_cached_agent(self._session_key_for_source(event.source))
|
||||
except Exception:
|
||||
logger.debug("could not evict cached agent after codex-runtime change",
|
||||
exc_info=True)
|
||||
prefix = "✓" if result.success else "✗"
|
||||
return f"{prefix} {result.message}"
|
||||
logger.debug("could not evict cached agent after codex-runtime change", exc_info=True)
|
||||
return f"{'✓' if result.success else '✗'} {result.message}"
|
||||
|
||||
async def _handle_personality_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /personality — list or set a personality (hermes_cli.personality owns the state)."""
|
||||
@@ -625,7 +543,6 @@ class GatewayModelCommandsMixin:
|
||||
except Exception:
|
||||
config = {}
|
||||
personalities = available_personalities(config)
|
||||
|
||||
if not args:
|
||||
current = active_personality_name(config)
|
||||
lines = [t("gateway.personality.header"), t("gateway.personality.none_option")]
|
||||
@@ -636,15 +553,13 @@ class GatewayModelCommandsMixin:
|
||||
)
|
||||
lines.append(t("gateway.personality.usage"))
|
||||
return "\n".join(lines)
|
||||
|
||||
try:
|
||||
name, _new_prompt = resolve_personality(args, config)
|
||||
except ValueError:
|
||||
available = "`none`, " + ", ".join(f"`{n}`" for n in personalities)
|
||||
return t("gateway.personality.unknown", name=args.lower(), available=available)
|
||||
# Persist the selection only — never agent.system_prompt (user-owned overlay). It lands in
|
||||
# get_hermes_home()/config.yaml (the routed profile under multiplex) and the next turn
|
||||
# re-resolves the prompt from it: no process-global state.
|
||||
# Persists the selection only (never agent.system_prompt, a user-owned overlay) into the
|
||||
# routed profile's config.yaml; the next turn re-resolves the prompt — no process-global state.
|
||||
if not persist_personality(name):
|
||||
return t("gateway.personality.save_failed", error="config write failed")
|
||||
if not name:
|
||||
@@ -660,8 +575,7 @@ class GatewayModelCommandsMixin:
|
||||
from hermes_cli.config import read_user_config_raw
|
||||
config_path = _gateway_config_home() / "config.yaml"
|
||||
try:
|
||||
# Raw read: merged defaults must not be persisted back to the user's file.
|
||||
user_config = read_user_config_raw(config_path)
|
||||
user_config = read_user_config_raw(config_path) # raw: never persist merged defaults
|
||||
*parents, leaf = key_path.split(".")
|
||||
_nested_dict(user_config, *parents)[leaf] = value
|
||||
atomic_config_write(config_path, user_config)
|
||||
@@ -678,10 +592,7 @@ class GatewayModelCommandsMixin:
|
||||
def _apply_reasoning_selection(
|
||||
self, session_key: str, platform_key: str, value: str, persist_global: bool = False,
|
||||
) -> str:
|
||||
"""Apply a /reasoning argument (typed or picked) and return the reply.
|
||||
|
||||
Single path shared by `/reasoning <arg>` and the choice picker so both match the parser.
|
||||
"""
|
||||
"""Apply a /reasoning argument (typed or picked) and return the reply."""
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
|
||||
value = (value or "").strip().lower()
|
||||
@@ -712,37 +623,18 @@ class GatewayModelCommandsMixin:
|
||||
self._set_reasoning_override(session_key, parsed)
|
||||
return t("gateway.reasoning.set_session", effort=value)
|
||||
|
||||
def _reasoning_picker_choices(self, current_effort: str) -> list:
|
||||
"""Build the choice list for the interactive /reasoning picker."""
|
||||
from hermes_constants import VALID_REASONING_EFFORTS
|
||||
|
||||
choices = [{"value": "none", "label": t("gateway.reasoning.choice_none"), "is_current": current_effort == "none"}]
|
||||
choices.extend({"value": level, "label": level, "is_current": level == current_effort} for level in VALID_REASONING_EFFORTS)
|
||||
choices.extend(
|
||||
{"value": v, "label": t(f"gateway.reasoning.choice_{v}"), "is_current": False}
|
||||
for v in ("reset", "show", "hide")
|
||||
)
|
||||
return choices
|
||||
|
||||
async def _try_send_choice_picker(
|
||||
self, event: MessageEvent, session_key: str, title: str, choices: list, on_choice_selected,
|
||||
) -> bool:
|
||||
"""Send an interactive choice picker when the platform supports it.
|
||||
|
||||
Mirrors the `/model` gate: capability is detected on the adapter *type*
|
||||
(``send_choice_picker``); a failed send returns False (text fallback) instead of erroring.
|
||||
"""
|
||||
"""Send an interactive choice picker when the adapter *type* supports it (the /model gate);
|
||||
a failed send returns False (text fallback) instead of erroring."""
|
||||
adapter = self._adapter_for_source(event.source)
|
||||
if adapter is None or getattr(type(adapter), "send_choice_picker", None) is None:
|
||||
return False
|
||||
try:
|
||||
result = await adapter.send_choice_picker(
|
||||
chat_id=event.source.chat_id,
|
||||
title=title,
|
||||
choices=choices,
|
||||
session_key=session_key,
|
||||
on_choice_selected=on_choice_selected,
|
||||
metadata=self._reply_metadata(event),
|
||||
chat_id=event.source.chat_id, title=title, choices=choices, session_key=session_key,
|
||||
on_choice_selected=on_choice_selected, metadata=self._reply_metadata(event),
|
||||
)
|
||||
return bool(getattr(result, "success", False))
|
||||
except Exception as e:
|
||||
@@ -752,16 +644,15 @@ class GatewayModelCommandsMixin:
|
||||
async def _handle_reasoning_command(self, event: MessageEvent) -> Optional[str]:
|
||||
"""Handle /reasoning command — manage reasoning effort and display toggle."""
|
||||
from gateway.run import _platform_config_key
|
||||
from hermes_constants import VALID_REASONING_EFFORTS
|
||||
|
||||
raw_args = event.get_command_args().strip()
|
||||
args, persist_global = self._parse_reasoning_command_args(raw_args)
|
||||
# Normalize the source (Telegram DM topic recovery) before deriving the override key so
|
||||
# storage matches the key the next message turn reads — same as /model.
|
||||
# Normalize (Telegram DM topic recovery) so the override key matches the next turn's.
|
||||
_reasoning_source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source)
|
||||
session_key = self._session_key_for_source(_reasoning_source)
|
||||
self._show_reasoning = self._load_show_reasoning()
|
||||
# The session's effective model (session /model override wins over config default) so
|
||||
# per-model reasoning_overrides display correctly.
|
||||
# Effective model (session /model override wins) so per-model reasoning_overrides display.
|
||||
_session_model = str(
|
||||
((getattr(self, "_session_model_overrides", {}) or {}).get(session_key) or {}).get("model") or ""
|
||||
)
|
||||
@@ -769,10 +660,8 @@ class GatewayModelCommandsMixin:
|
||||
source=event.source, session_key=session_key, model=_session_model,
|
||||
)
|
||||
platform_key = _platform_config_key(event.source.platform)
|
||||
if raw_args:
|
||||
# Typed argument path — same applier the picker uses.
|
||||
if raw_args: # typed path — same applier the picker uses
|
||||
return self._apply_reasoning_selection(session_key, platform_key, args, persist_global=persist_global)
|
||||
|
||||
rc = self._reasoning_config
|
||||
if rc is None:
|
||||
level, current_effort = t("gateway.reasoning.level_default"), "medium"
|
||||
@@ -787,12 +676,16 @@ class GatewayModelCommandsMixin:
|
||||
async def _on_reasoning_choice(_chat_id: str, value: str) -> str:
|
||||
return self._apply_reasoning_selection(session_key, platform_key, value)
|
||||
|
||||
# Interactive picker where supported (parity with /model); else the text status card.
|
||||
picker_sent = await self._try_send_choice_picker(
|
||||
event,
|
||||
session_key,
|
||||
title=t("gateway.reasoning.picker_title", level=level, scope=scope, display=display_state),
|
||||
choices=self._reasoning_picker_choices(current_effort),
|
||||
choices=[
|
||||
{"value": "none", "label": t("gateway.reasoning.choice_none"), "is_current": current_effort == "none"},
|
||||
*({"value": lv, "label": lv, "is_current": lv == current_effort} for lv in VALID_REASONING_EFFORTS),
|
||||
*({"value": v, "label": t(f"gateway.reasoning.choice_{v}"), "is_current": False}
|
||||
for v in ("reset", "show", "hide")),
|
||||
],
|
||||
on_choice_selected=_on_reasoning_choice,
|
||||
)
|
||||
if picker_sent:
|
||||
@@ -808,35 +701,28 @@ class GatewayModelCommandsMixin:
|
||||
label = t(label_key) if label_key else value.upper()
|
||||
self._service_tier = tier
|
||||
if persist and self._save_gateway_config_key("agent.service_tier", saved_value):
|
||||
# Global write supersedes any session override.
|
||||
self._set_session_service_tier_override(session_key, None, clear=True)
|
||||
self._set_session_service_tier_override(session_key, None, clear=True) # global wins
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.fast.saved", label=label)
|
||||
# Session override; also the fallback when the config write failed so the user's choice
|
||||
# still applies (mirrors /reasoning --global).
|
||||
# Session override — also the fallback after a failed config write (as /reasoning --global).
|
||||
self._set_session_service_tier_override(session_key, tier)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.fast.session_only", label=label)
|
||||
|
||||
async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]:
|
||||
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.
|
||||
|
||||
Session-scoped by default; ``--global`` persists agent.service_tier (parity with /model).
|
||||
"""
|
||||
"""Handle /fast — the CLI Priority Processing toggle; session-scoped unless ``--global``
|
||||
(persists agent.service_tier, parity with /model)."""
|
||||
from gateway.run import _load_gateway_config, _resolve_gateway_model
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
raw_args = event.get_command_args().strip().lower()
|
||||
# The /reasoning arg parser strips --global (any position) and normalizes unicode dashes.
|
||||
args, persist_global = self._parse_reasoning_command_args(raw_args)
|
||||
# The /reasoning parser strips --global (any position) and normalizes unicode dashes.
|
||||
args, persist_global = self._parse_reasoning_command_args(event.get_command_args().strip().lower())
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
self._service_tier = self._resolve_session_service_tier(session_key=session_key)
|
||||
|
||||
if not model_supports_fast_mode(_resolve_gateway_model(_load_gateway_config())):
|
||||
return t("gateway.fast.not_supported")
|
||||
if args and args != "status":
|
||||
return self._apply_fast_selection(session_key, args, persist=persist_global)
|
||||
|
||||
mode = "fast" if self._service_tier == "priority" else (self._service_tier or "normal")
|
||||
status = {"fast": t("gateway.fast.status_fast"), "normal": t("gateway.fast.status_normal")}.get(mode, mode)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user