Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9059,7 +9059,7 @@ def _handle_model_switch(self, cmd_original: str):
"""
from hermes_cli.model_switch import (
switch_model,
parse_model_flags_detailed,
parse_model_switch_args,
resolve_persist_behavior,
)
from hermes_cli.providers import get_label
Expand All @@ -9069,18 +9069,17 @@ def _handle_model_switch(self, cmd_original: str):
raw_args = parts[1].strip() if len(parts) > 1 else ""

# 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.")
# via the shared single-owner parser (hermes_cli.model_switch).
request = parse_model_switch_args(raw_args)
model_input = request.target
explicit_provider = request.explicit_provider
is_global_flag = request.is_global
force_refresh = request.force_refresh
is_session = request.is_session
one_turn = request.is_once
if request.errors:
# CLI decoration: " ✗ " prefix over the canonical error copy.
_cprint(f" ✗ {request.error_messages()[0]}")
return
# Resolve the effective persistence once: --global forces persist,
# --session/--once force session-scope, otherwise defer to
Expand Down
22 changes: 16 additions & 6 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1410,17 +1410,22 @@ def _resolve_model_name(explicit: str) -> str:
1. Explicit override (config extra or API_SERVER_MODEL_NAME env var)
2. Active profile name (so each profile advertises a distinct model)
3. Fallback: "hermes-agent"

Delegates the tiered fallthrough to
:func:`hermes_cli.model_switch.resolve_effective_model` (the shared
override > mid-tier > default precedence owner).
"""
if explicit and explicit.strip():
return explicit.strip()
from hermes_cli.model_switch import resolve_effective_model

profile_name = ""
try:
from hermes_cli.profiles import get_active_profile_name
profile = get_active_profile_name()
if profile and profile not in {"default", "custom"}:
return profile
profile_name = profile
except Exception:
pass
return "hermes-agent"
return resolve_effective_model(explicit, profile_name, "hermes-agent")

def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
"""Return CORS headers for an allowed browser origin."""
Expand Down Expand Up @@ -2487,8 +2492,13 @@ def _resolve_provider_runtime(
session_override = None
if not confirmed_runtime_lock:
session_override = self._session_model_override_for(session_key)
# Model-string precedence delegates to the shared owner
# hermes_cli.model_switch.resolve_effective_model (session /model
# override > session-persisted model > global) — the rule 7dd00bb47d
# had to re-fix here after it diverged from gateway/run.py.
from hermes_cli.model_switch import resolve_effective_model
if session_override:
override_model = _clean_request_string(session_override.get("model")) or model
override_model = resolve_effective_model(session_override, None, model)
session_provider = _clean_request_string(session_override.get("provider"))
current_provider = _clean_request_string(runtime_kwargs.get("provider"))
provider_runtime = _resolve_provider_runtime(
Expand Down Expand Up @@ -2518,7 +2528,7 @@ def _resolve_provider_runtime(
)
if provider_runtime:
_apply_runtime_agent_overrides(runtime_kwargs, provider_runtime)
model = session_row_model
model = resolve_effective_model(None, session_row_model, model)
if request_model or request_provider:
logger.debug(
"api_server request selection skipped: session-persisted model wins for %s",
Expand Down
22 changes: 18 additions & 4 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5567,7 +5567,19 @@ def _resolve_model_for_channel(
thread_id: Optional[str] = None,
parent_id: Optional[str] = None,
) -> str:
"""Resolve model for this channel: channel_overrides else global default."""
"""Resolve model for this channel: channel_overrides else global default.

Delegates the precedence rule to
:func:`hermes_cli.model_switch.resolve_effective_model` (session
override > channel override > global default) — the single owner
shared with the API server, so the two surfaces cannot diverge
again (see 7dd00bb47d). This call site has no session tier: session
/model overrides are applied later by
``_apply_session_model_override`` on the resolved runtime.
"""
from hermes_cli.model_switch import resolve_effective_model

override = None
config = getattr(self, "config", None)
if config:
override = _get_channel_override(
Expand All @@ -5577,9 +5589,11 @@ def _resolve_model_for_channel(
thread_id=thread_id,
parent_id=parent_id,
)
if override and override.model:
return override.model
return _resolve_gateway_model(user_config)
return resolve_effective_model(
None, # session tier applied downstream (_apply_session_model_override)
override,
_resolve_gateway_model(user_config),
)

def _get_system_prompt_for_channel(
self,
Expand Down
24 changes: 12 additions & 12 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1724,7 +1724,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
"""
from gateway.run import _hermes_home, _load_gateway_config
from hermes_cli.model_switch import (
switch_model as _switch_model, parse_model_flags_detailed,
switch_model as _switch_model, parse_model_switch_args,
resolve_persist_behavior,
list_authenticated_providers,
list_picker_providers,
Expand All @@ -1740,17 +1740,17 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
)(source)

# 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:
return "❌ /model --once cannot be combined with --global"
if one_turn and not model_input and not explicit_provider:
return "❌ /model --once requires a model or provider."
# via the shared single-owner parser (hermes_cli.model_switch).
request = parse_model_switch_args(raw_args)
model_input = request.target
explicit_provider = request.explicit_provider
is_global_flag = request.is_global
force_refresh = request.force_refresh
is_session = request.is_session
one_turn = request.is_once
if request.errors:
# Gateway decoration: "❌ " prefix over the canonical error copy.
return f"❌ {request.error_messages()[0]}"
persist_global = resolve_persist_behavior(
is_global_flag,
is_session,
Expand Down
161 changes: 161 additions & 0 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,167 @@ def resolve_persist_behavior(
return False


# ---------------------------------------------------------------------------
# Single-owner /model request parsing + effective-model resolution
# ---------------------------------------------------------------------------
#
# Historically each surface (cli.py, gateway/slash_commands.py,
# tui_gateway/server.py) re-implemented flag parsing + conflict checks, and
# each resolution surface (gateway/run.py, gateway/platforms/api_server.py)
# re-implemented the session-override > channel/session > global precedence.
# Commit 7dd00bb47d had to re-fix the api_server discarding session-persisted
# models precisely because the precedence rule lived in two places. The
# helpers below are the ONE owner; surfaces map error codes to their own
# user-facing copy but never re-derive the semantics.

# Error codes emitted by parse_model_switch_args().
MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL = "once_with_global"
MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET = "once_requires_target"

# Canonical (surface-neutral) error copy. Surfaces prepend their own
# decoration (" ✗ " in the CLI, "❌ " in the gateway) but MUST NOT change
# the core sentence — it is shared user-visible copy.
MODEL_SWITCH_ERROR_TEXT = {
MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL: "/model --once cannot be combined with --global",
MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET: "/model --once requires a model or provider.",
}


@dataclass(frozen=True)
class ModelSwitchRequest:
"""A fully parsed /model command request.

``scope`` is the *requested* persistence scope derived purely from the
flags: ``"once"`` | ``"session"`` | ``"global"`` | ``"default"`` (no
explicit scope flag; the effective decision then belongs to
:func:`resolve_persist_behavior`, which also reads config).

``errors`` carries error *codes* (see ``MODEL_SWITCH_ERR_*``); surfaces
render them via :data:`MODEL_SWITCH_ERROR_TEXT` plus their own prefix.
"""

raw: str
target: str
explicit_provider: str = ""
is_global: bool = False
is_session: bool = False
is_once: bool = False
force_refresh: bool = False
scope: str = "default"
errors: tuple = ()

# Compat properties so a ModelSwitchRequest can be passed anywhere a
# ModelFlagParseResult was accepted (e.g. tui_gateway._apply_model_switch).
@property
def model_input(self) -> str:
return self.target

@property
def flags(self) -> "ModelFlagParseResult":
return ModelFlagParseResult(
model_input=self.target,
explicit_provider=self.explicit_provider,
is_global=self.is_global,
force_refresh=self.force_refresh,
is_session=self.is_session,
is_once=self.is_once,
)

def error_messages(self) -> list:
"""Canonical (undercorated) error strings for this request."""
return [MODEL_SWITCH_ERROR_TEXT[code] for code in self.errors]


def parse_model_switch_args(raw: str) -> ModelSwitchRequest:
"""Parse a raw /model argument string into a :class:`ModelSwitchRequest`.

The ONE parser for every /model surface. Wraps
:func:`parse_model_flags_detailed` (tokenization + Unicode-dash
normalization) and layers on the flag-conflict validation that cli.py,
gateway/slash_commands.py, and tui_gateway/server.py each used to
re-implement:

* ``--once`` + ``--global`` → ``MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL``
* ``--once`` with no model and no ``--provider``
→ ``MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET``

Model targets pass through untouched: bare names (``sonnet``),
aggregator slugs (``vendor/model``), and colon forms (``vendor:model``)
are all resolved later by :func:`switch_model` (aggregator-aware — bare
names resolve WITHIN the current aggregator first).
"""
raw = str(raw or "")
parsed = parse_model_flags_detailed(raw)

errors: list = []
if parsed.is_once and parsed.is_global:
errors.append(MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL)
if parsed.is_once and not parsed.model_input and not parsed.explicit_provider:
errors.append(MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET)

if parsed.is_once:
scope = "once"
elif parsed.is_session:
scope = "session"
elif parsed.is_global:
scope = "global"
else:
scope = "default"

return ModelSwitchRequest(
raw=raw,
target=parsed.model_input,
explicit_provider=parsed.explicit_provider,
is_global=parsed.is_global,
is_session=parsed.is_session,
is_once=parsed.is_once,
force_refresh=parsed.force_refresh,
scope=scope,
errors=tuple(errors),
)


def _effective_model_candidate(value: Any) -> str:
"""Extract a model-name candidate from a str / dict / attr-object."""
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, dict):
return str(value.get("model") or "").strip()
model_attr = getattr(value, "model", None)
if model_attr is not None:
return str(model_attr or "").strip()
return ""


def resolve_effective_model(
session_overrides: Any = None,
channel_config: Any = None,
global_config: Any = "",
) -> str:
"""Resolve the effective model: session override > channel > global.

The single owner of the precedence rule that gateway/run.py
(``_resolve_model_for_channel`` / ``_apply_session_model_override``) and
gateway/platforms/api_server.py (``_create_agent``'s session-override /
session-persisted-model branches) each encoded independently — the
divergence commit 7dd00bb47d had to close. A user-issued ``/model``
(session override) always wins over per-channel/session-persisted
configuration, which wins over the global default.

Each argument may be a plain model string, a dict with a ``"model"``
key (a gateway ``_session_model_overrides`` entry), or an object with a
``.model`` attribute (a ``ChannelOverride``). Empty/None entries fall
through to the next tier.
"""
for tier in (session_overrides, channel_config, global_config):
candidate = _effective_model_candidate(tier)
if candidate:
return candidate
return ""


# ---------------------------------------------------------------------------
# Alias resolution
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading