Files
hermes-agent/gateway/session_mutation_model.py
Teknium 65effb1e9f fix: /model reset on the session owner (canonical TUI, Desktop, attached CLI)
The Ink TUI, Desktop and the gateway-attached CLI send /model as a
session.mutate model mutation straight to the session owner, so the
earlier handlers never saw it and `reset` was frozen into the policy as
a model name (the provider then got `model: reset`).

prepare_model now treats `reset` as a pick of the owning profile's
configured model, read fresh from config.yaml (session-scoped, never
written back, not held behind the cost ask). A session already on that
default gets a `noop` with an "already on the configured default" note
and nothing is committed. `reset` with any other selection field is
refused like on every other surface. A messaging chat viewed through
the owner clears its override through the gateway's own reset; a reset
queued behind a running turn shows the default it will adopt. The TUI,
Desktop and the attached CLI print the owner's note.
2026-10-31 22:08:49 -07:00

379 lines
23 KiB
Python

"""Resolve explicit model changes using the existing source model switch pipeline."""
import asyncio
import json
from dataclasses import asdict, replace
from typing import NamedTuple
from gateway.session_route_identity import ROUTE_BOUND_KEYS
from hermes_state_runtime import RuntimeStoreError
class _Route(NamedTuple):
provider: str
model: str
base_url: str
api_key: object # a callable bearer (Azure Entra) stays callable
def _current_route(authority, live, old):
"""``(config, route)`` the session runs now, both from ONE read of the owning profile.
A following session runs the profile's current route, so its frozen creation endpoint beside
the current key would send that key to a host the profile left. Main switched from the live
runtime's whole tuple (``tui_gateway.model_switch._current_model_runtime``). The provider is
the configured name: a named ``providers:`` entry resolves to the bare ``custom`` class, and a
pick on the same entry must not read as a route change. Only the session's own route counts:
a fallback is where a turn lands, not what the user switches from. A route that cannot
authenticate here must not block leaving it: like main's agent-less ``--provider`` path, the
pick then switches from the configured provider alone and resolves its own target.
"""
from gateway.session_policy import profile_read_scope
from hermes_cli.auth import AuthError
with profile_read_scope(old, authority):
config = old.config(authority)
block = config.get('model') or {}
try:
model, runtime = authority.runner._resolve_session_agent_runtime(
source=live.source, session_key=live.route, fallback=False)
except AuthError:
return config, _Route(block.get('provider') or '', old.current_model(authority) or '',
block.get('base_url') or '', '')
return config, _Route(block.get('provider') or runtime.get('provider') or '', model or '',
runtime.get('base_url') or '', runtime.get('api_key') or '')
async def prepare_model(authority, live, payload, prepared):
from gateway.session_policy import restore_policy, bind_launch_key, bound_route_holds, route_launch_key
from gateway.session_policy_credentials import recover_config_secrets
from hermes_cli.model_switch import apply_model_selection, switch_model
from hermes_cli.config import get_compatible_custom_providers
old = restore_policy(prepared['snapshot']['receipt']['policy'])
config, current = _current_route(authority, live, old)
reset = is_model_reset(payload)
if reset:
payload = await asyncio.to_thread(_reset_payload, authority, old, current)
if 'status' in payload:
return payload
# A pick is an explicit user action: it resolves against the owning profile's CURRENT named
# providers, as CLI/TUI ``/model`` do (#139740). A following session's ``config`` already is
# the profile's current read; every other session keeps ``config`` for its own route below.
resolution = config if old.follows_profile else await asyncio.to_thread(
_provider_resolution_config, authority, config, old.ignore_user_config)
def switch(**kwargs):
result = switch_model(**kwargs)
return result, _alias_credential(authority, result)
result, alias_credential = await asyncio.to_thread(switch, raw_input=payload['model'],
current_provider=current.provider, current_model=current.model, current_base_url=current.base_url,
current_api_key=current.api_key, explicit_provider=payload.get('provider', ''),
is_global=False, user_providers=resolution.get('providers'),
custom_providers=get_compatible_custom_providers(resolution))
if not result.success:
raise RuntimeStoreError('model_resolution_failed')
# The configured default is the user's own choice: a reset is never held behind a cost ask.
refusal = None if reset else await _selection_guard_refusal(
authority, live, payload, prepared['snapshot'], current, result)
if refusal is not None:
return refusal
frozen = old.config()
# The committed selection freezes the ONE picked endpoint (its key stays a private secret).
provider_secrets, retired, moved = ({}, (), {}) if old.follows_profile else _freeze_selected_provider(
resolution, frozen, result.target_provider)
if old.follows_profile:
# A following session switches FROM the profile's current route: measured from its creation
# endpoint, a pick on the current one read as a route change and dropped its key_env pointer.
followed = config.get('model') or {}
# An inline key (or an alias key's pointer) authenticates the route it was picked on: once the
# followed route moved, the same-route cleanup below would keep it for the new endpoint.
held = bound_route_holds(frozen.get('model'), frozen, followed, config)
# Every other route-bound key follows the profile's current route.
kept = ('api_key', 'api', 'api_key_from') if held else ()
route_keys = tuple(k for k in ROUTE_BOUND_KEYS if k not in kept)
frozen['model'] = {k: v for k, v in (frozen.get('model') or {}).items() if k not in route_keys}
# The profile's inline key and its private-secret pointer are not copied into the receipt:
# its secrets stay at the profile's own path, never re-committed under this session.
frozen['model'].update({k: followed[k] for k in route_keys
if k in followed and k not in ('api_key', 'api', 'api_key_from')})
# The one selection shape config.yaml gets (#25106): api_mode follows the target, a context pin
# and the endpoint-bound credential fields (inline key, key_env/api_key_env) drop on a route change.
# Off-loop: the context-pin check can do cold-start disk I/O.
frozen['model'] = await asyncio.to_thread(apply_model_selection, frozen.get('model'), result)
if 'reasoning' in payload:
# The level picked with the model lands in the same commit (as prepare_reasoning writes it).
from gateway.session_policy import pin_reasoning_effort
pin_reasoning_effort(frozen, payload['reasoning'], session=True)
if old.follows_profile:
# A committed /model holds while the profile still targets what it targets now (main's
# composer_override_profile); a later profile edit supersedes it.
from pathlib import Path
from gateway.run import _load_gateway_config
from gateway.session_policy import present_sections, profile_target
live = await asyncio.to_thread(
lambda: _load_gateway_config(Path(authority.db.db_path).resolve().parent / 'config.yaml'))
frozen['model']['selection_profile'] = list(profile_target(present_sections(live)))
# A direct alias's own credential is part of the selection (never ``result.api_key``, which the
# switch resolved for its probe): its env pointer is committed and re-read every turn; a literal
# key is a private config secret at the alias entry's own path (what a restarted owner re-reads)
# and the receipt keeps only a pointer to it, which config() projects into ``model.api_key``.
key_env, source, alias_key = alias_credential
key_env = key_env or _selected_provider_key_env(resolution, result.target_provider)
if key_env:
frozen['model']['key_env'] = key_env
if source:
frozen['model'].update(api_key=None, api_key_from=list(source))
elif 'api_key' not in frozen['model']:
frozen['model'].pop('api_key_from', None)
# Creation identity (request_json) remains immutable; runtime selection lives in the policy.
policy = replace(old, model=result.new_model, config_json=json.dumps(frozen))
secrets = recover_config_secrets(authority, old) if old.config_secret_ref else {}
# The picked entry's private subtree is replaced whole: a key or header its current declaration
# withdrew must not follow it to the new endpoint.
# An unrelated entry the pick displaced carries its own secrets to its new index.
secrets = {moved.get(path[:2], path[:2]) + path[2:]: value for path, value in secrets.items()}
secrets = {path: value for path, value in secrets.items() if path[:2] not in retired}
secrets.update(provider_secrets)
# A cleared field's private value must not be re-hydrated into the new route by config().
secrets = {path: value for path, value in secrets.items()
if not (len(path) == 2 and path[0] == 'model' and path[1] not in frozen['model'])}
if source:
secrets.pop(('model', 'api_key'), None)
secrets[source] = alias_key
# Re-fingerprint frozen config references because policy identity includes model.
# An explicit launch key belongs to the endpoint it authenticated, not to a provider name:
# custom -> custom on another base_url must not carry it (same route identity as config keys).
# Measured on resolved routes (a respelled provider on one endpoint is one route; a named entry
# whose base_url moved is another): the key survives only while it still authenticates the route
# the session runs now and the pick stays on that route.
key = route_launch_key(authority, old, config)
if key is None or not bound_route_holds(config.get('model'), config, frozen['model'], config):
policy = replace(policy, credential_ref=None)
key = None
policy = bind_launch_key(authority, prepared['snapshot']['receipt']['session_id'], policy, key,
config_secrets=secrets)
return dict(prepared, policy=asdict(policy))
def is_model_reset(payload):
"""``/model reset`` as a ``model`` mutation; with any other selection field it is refused, as on
every surface (``parse_model_switch_args``)."""
from hermes_cli.model_switch import (
MODEL_SWITCH_ERR_RESET_WITH_FLAGS, MODEL_SWITCH_ERROR_TEXT, MODEL_SWITCH_RESET_TOKEN)
if payload['model'].strip().lower() != MODEL_SWITCH_RESET_TOKEN:
return False
if set(payload) - {'model', 'confirm'}:
raise RuntimeStoreError('invalid_params', field='payload',
detail=MODEL_SWITCH_ERROR_TEXT[MODEL_SWITCH_ERR_RESET_WITH_FLAGS])
return True
def configured_default(authority):
"""``(model, provider)`` the owning profile's config.yaml names now (``auto`` reads as no provider)."""
from gateway.run import _resolve_gateway_model
from gateway.session_policy import profile_config
profile = profile_config(authority)
block = profile.get('model')
if not block:
return '', ''
provider = str(block.get('provider') or '') if isinstance(block, dict) else ''
return _resolve_gateway_model(profile), '' if provider.lower() == 'auto' else provider
def _reset_payload(authority, old, current):
"""``/model reset``: the pick becomes the owning profile's configured model, read fresh, so the
session runs (and, when following, keeps following) its profile again; history is kept. Returns a
``noop`` report when the session already runs that model. A safe-mode session never read the
profile, so it has no configured default to return to."""
from agent.i18n import t
model, provider = ('', '') if old.ignore_user_config else configured_default(authority)
if not model:
raise RuntimeStoreError('invalid_params', field='payload.model', detail=t('cli.model.reset_no_default'))
if model == current.model and provider in ('', current.provider):
return {'status': 'noop', 'model': model, 'note': t('cli.model.reset_none', model=model)}
return {'model': model, **({'provider': provider} if provider else {})}
def _provider_resolution_config(authority, config, bypass=False):
"""*config* with the owning profile's CURRENT named providers over its frozen ones, used only to
resolve and freeze a pick. Reads this owner's own config.yaml (never another profile's); a
frozen entry the profile no longer declares stays resolvable. A session that bypasses the
profile (``ignore_user_config``, implied by safe mode) resolves only its own frozen config.
``${VAR}`` refs expand against the profile's CURRENT ``.env``, not the daemon's start env."""
from pathlib import Path
from gateway.session_local import load_profile_config
if bypass:
return config
current = load_profile_config(Path(authority.db.db_path).resolve().parent)
if not isinstance(current, dict):
return config
resolved = dict(config)
frozen_providers, current_providers = config.get('providers'), current.get('providers')
if isinstance(frozen_providers, dict) or isinstance(current_providers, dict):
current_providers = current_providers if isinstance(current_providers, dict) else {}
slugs = {_provider_key_slug(key) for key in current_providers}
# A respelled key is the same provider: the profile's spelling replaces the frozen one.
resolved['providers'] = {**{key: entry for key, entry in (
frozen_providers if isinstance(frozen_providers, dict) else {}).items()
if _provider_key_slug(key) not in slugs}, **current_providers}
frozen_custom, current_custom = config.get('custom_providers'), current.get('custom_providers')
if isinstance(frozen_custom, list) or isinstance(current_custom, list):
current_custom = list(current_custom) if isinstance(current_custom, list) else []
names = {_custom_provider_name(entry) for entry in current_custom if isinstance(entry, dict)}
# Current entries keep their profile positions (the path cold recovery re-reads).
resolved['custom_providers'] = current_custom + [
entry for entry in (frozen_custom if isinstance(frozen_custom, list) else [])
if not isinstance(entry, dict) or _custom_provider_name(entry) not in names]
resolved['_profile_custom_count'] = len(current_custom)
return resolved
def _custom_provider_name(entry):
"""The runtime's canonical identity: ``My Provider`` and ``my-provider`` are one provider."""
from hermes_cli.providers import custom_provider_slug
return custom_provider_slug(str(entry.get('name') or ''), str(entry.get('provider_key') or ''))
def _selected_provider_key_env(config, provider):
"""Return a selected ``providers.<slug>`` credential pointer, never its secret value."""
providers = config.get('providers')
entry = providers.get(provider) if isinstance(providers, dict) else None
if not isinstance(entry, dict):
return None
return str(entry.get('key_env') or entry.get('api_key_env') or '').strip() or None
def _freeze_selected_provider(config, frozen, provider):
"""Copy only the picked named provider's declaration into the frozen session route.
The next turn resolves from the session's own configuration, not a later profile edit. Private
fields move to the policy's secret store, as policy creation does; nothing else moves. Returns
``(secrets, retired)``: the entry's private values and every ``(section, key)`` whose previous
private subtree they replace, plus ``moved``: ``{old (section, index): new}`` for an unrelated entry
the pick displaced. A legacy entry from the profile is frozen at its profile index (the path cold
recovery re-reads); an equivalent earlier spelling is blanked, never left to win.
"""
from copy import deepcopy
from gateway.session_policy import _extract_config_secrets
from hermes_cli.runtime_provider_custom import named_custom_provider_config_path
path = named_custom_provider_config_path(provider, config)
if path is None:
return {}, (), {}
section, index = path
entries = config.get(section)
if not isinstance(entries, (dict, list)) or not isinstance(entries[index], dict):
return {}, (), {}
copied = deepcopy(entries[index])
retired, moved = {(section, index)}, {}
if section == 'providers':
previous = frozen.get(section) if isinstance(frozen.get(section), dict) else {}
stale = {key for key in previous if _provider_key_slug(key) == _provider_key_slug(index)}
frozen[section] = {**{k: v for k, v in previous.items() if k not in stale}, index: copied}
retired |= {(section, key) for key in stale}
else:
frozen_entries = list(frozen.get(section) or [])
name = _custom_provider_name(copied)
same = [i for i, entry in enumerate(frozen_entries)
if isinstance(entry, dict) and _custom_provider_name(entry) == name]
if index >= config.get('_profile_custom_count', len(entries)):
index = same[0] # a frozen-only entry stays where it is
frozen_entries += [{}] * (index + 1 - len(frozen_entries))
displaced = frozen_entries[index]
if isinstance(displaced, dict) and displaced and _custom_provider_name(displaced) != name:
moved[(section, index)] = (section, len(frozen_entries))
frozen_entries.append(displaced) # still resolvable by name, with its own secrets
for i in same:
frozen_entries[i] = {}
frozen_entries[index] = copied
retired |= {(section, i) for i in same} | {(section, index)}
frozen[section] = frozen_entries
secrets = {}
_extract_config_secrets(frozen[section][index], secrets, (section, index))
return secrets, retired, moved
def _provider_key_slug(key):
"""A ``providers:`` key's canonical identity: ``Review Prov`` and ``review-prov`` are one."""
from hermes_cli.providers import custom_provider_slug
return custom_provider_slug('', str(key))
def _alias_credential(authority, result):
"""``(key_env, source_path, literal_key)`` the direct alias a switch resolved declares for its own
endpoint, read from THIS owner's config.yaml: the file a restarted owner re-reads, never the
process-global alias cache another served profile may refill after the switch. A raw
``api_key: "${VAR}"`` is the pointer ``VAR``; ``(None, None, None)`` when it declares none."""
from pathlib import Path
from gateway.run import _load_gateway_config
from hermes_cli.config import _env_ref_var_name, read_user_config_raw
name = result.resolved_via_alias if result.success else ''
path = Path(authority.db.db_path).resolve().parent / 'config.yaml'
raw = read_user_config_raw(path) if name else {}
for section in (('model_aliases',), ('model', 'aliases')):
model = raw.get('model') # `model: name` (a scalar) is valid config and declares no aliases
entries = raw.get(section[0]) if len(section) == 1 else (model.get('aliases') if isinstance(model, dict) else None)
for written, entry in (entries.items() if isinstance(entries, dict) else ()):
if not isinstance(entry, dict) or not entry.get('model') or str(written).strip().lower() != name:
continue
key = str(entry.get('api_key') or '').strip()
pointer = _env_ref_var_name(key[2:-1]) if key.startswith('${') and key.endswith('}') else None
if pointer:
return pointer, None, None
if not key:
return str(entry.get('key_env') or '').strip() or None, None, None
value = _load_gateway_config(path)
for part in (*section, written, 'api_key'):
value = value.get(part) if isinstance(value, dict) else None
if not value:
raise RuntimeStoreError('model_resolution_failed')
return None, (*section, written, 'api_key'), value
return None, None, None
def _persisted_selection_context(authority, snapshot, current):
from agent.usage_anchor import persisted_anchor_tokens
from hermes_cli.model_selection_guards import SelectionContext
target = snapshot.get('target') or snapshot['receipt']['session_id']
tokens = persisted_anchor_tokens(authority.db, target, authority.db.get_messages_as_conversation(target))
return SelectionContext(context_tokens=tokens, current_model=current.model or None) if tokens else None
async def _selection_guard_refusal(authority, live, payload, snapshot, current, result):
"""The shared selection-guard registry (cost, data-policy, large-context switch) runs before the
commit. A flagged target is refused with ``status: confirmation_required`` and a ``confirm``
token bound to the resolved target, the selection it replaces and the session revision; nothing
is written. Only that exact token in ``payload['confirm']`` applies it: a stale or foreign
token (target re-resolved elsewhere, another switch landed, a turn settled) gets a fresh
refusal, never a silent apply."""
import hashlib
from hermes_cli.model_selection_guards import combined_message, selection_context_for_agent, selection_warnings
from hermes_cli.route_identity import normalize_route_base_url
lookup = (getattr(authority.runner, '_resident_agent_for', None)
or getattr(authority.runner, '_cached_agent_for', None))
agent = lookup(live.route) if live is not None and callable(lookup) else None
context = selection_context_for_agent(agent)
if context is None:
# No resident agent (evicted by the previous switch, or an owner restart): the session's
# persisted usage anchor still measures the context at stake, so the large-context guard
# does not go quiet on exactly the switch that follows another one.
context = await asyncio.to_thread(_persisted_selection_context, authority, snapshot, current)
# Off-loop: pricing lookups may hit models.dev on a cache miss.
warnings = await asyncio.to_thread(
selection_warnings, result.new_model, provider=result.target_provider,
base_url=result.base_url or current.base_url, api_key=result.api_key or current.api_key,
model_info=result.model_info, selection_context=context)
if not warnings:
return None
binding = {'target': [result.new_model, result.target_provider, normalize_route_base_url(result.base_url),
result.api_mode or ''],
'current': [current.model, current.provider, normalize_route_base_url(current.base_url)],
'session': [snapshot['target'], snapshot['target_revision']],
'guards': sorted(w.kind for w in warnings)}
token = hashlib.sha256(json.dumps(binding, sort_keys=True).encode()).hexdigest()
if payload.get('confirm') == token:
return None
# A fresh dict: the prepared snapshot carries the frozen policy and never leaves the owner. No
# ``model`` key: a client that predates the exchange must not paint the target as applied.
return {'status': 'confirmation_required', 'confirm_required': True, 'confirm': token,
'target_model': result.new_model, 'target_provider': result.target_provider,
'confirm_message': combined_message(warnings),
'warnings': [{'kind': w.kind, 'title': w.title, 'message': w.message} for w in warnings]}