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:
Teknium
2026-09-02 19:40:07 -07:00
parent f7c5b16fcc
commit 559714fb18
2 changed files with 283 additions and 556 deletions

View File

@@ -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