feat(api): backend-acknowledged session model lock with runtime routing

Add a persisted, backend-confirmed provider/model lock for Hermes
Browser and other session API clients. A confirmed lock is an
execution contract rather than response metadata:

- POST /api/sessions/{session_id}/model validates and persists a
  confirmed browser_model_lock (advertised in /v1/capabilities)
- session chat + chat/stream consume the persisted lock on body-only
  follow-up turns; a confirmed lock wins over an older gateway session
  /model override and the session-persisted model
- a later successful session /model switch explicitly clears and
  replaces the lock while preserving lineage markers (_branched_from)
  and invalidating cached system-prompt model/provider metadata
- ordinary one-off request overrides never replace a confirmed lock
- provider-resolution failure fails closed as a typed provider-auth
  error (controlled response, never global-credential reuse)
- confirmed locks disable the global fallback model chain
- the completed agent's actual provider/model must match the locked
  route or the turn fails with a runtime-mismatch error
- responses carry sanitized runtime metadata reporting actual vs
  requested provider/model and lock state

Rebased onto the provider-aware request routing (#70853) and
session-model parity (#70931) that landed since the original branch;
the lock now slots into that precedence chain as the top rung.

Salvaged from PR #61236 by @abundantbeing.
This commit is contained in:
abundantbeing 2026-07-24 12:31:53 -07:00 committed by Teknium
parent 306c9f7661
commit 7cd48733db
5 changed files with 1236 additions and 54 deletions

View file

@ -1772,6 +1772,7 @@ class APIServerAdapter(BasePlatformAdapter):
("POST", "/api/sessions/{session_id}/fork", self._handle_fork_session),
("POST", "/api/sessions/{session_id}/chat", self._handle_session_chat),
("POST", "/api/sessions/{session_id}/chat/stream", self._handle_session_chat_stream),
("POST", "/api/sessions/{session_id}/model", self._handle_session_model_lock),
("POST", "/v1/chat/completions", self._handle_chat_completions),
("POST", "/v1/responses", self._handle_responses),
("GET", "/v1/responses/{response_id}", self._handle_get_response),
@ -2000,6 +2001,232 @@ class APIServerAdapter(BasePlatformAdapter):
return None
return self._model_routes.get(model_alias)
@staticmethod
def _clean_runtime_id(value: Any, *, max_len: int = 200) -> str:
if value is None:
return ""
text = str(value).strip()
if not text or len(text) > max_len:
return ""
if re.search(r"[\r\n\x00]", text):
return ""
return text
@classmethod
def _split_provider_prefixed_model(cls, model: str) -> tuple[str, str]:
text = cls._clean_runtime_id(model)
if "::" in text:
provider, raw = text.split("::", 1)
if re.match(r"^[a-zA-Z0-9_.-]{2,64}$", provider) and raw.strip():
return provider, raw.strip()
return "", text
@classmethod
def _runtime_options_from_model_options(cls, model_options: Any) -> Dict[str, Any]:
if not isinstance(model_options, dict):
return {}
runtime_options: Dict[str, Any] = {}
reasoning = model_options.get("reasoning")
if isinstance(reasoning, dict):
enabled = reasoning.get("enabled")
effort = cls._clean_runtime_id(reasoning.get("effort"), max_len=32)
if enabled is False:
runtime_options["reasoning_config"] = {"enabled": False}
elif effort:
runtime_options["reasoning_config"] = {"enabled": True, "effort": effort}
elif enabled is True:
runtime_options["reasoning_config"] = {"enabled": True}
service_tier = cls._clean_runtime_id(model_options.get("service_tier"), max_len=32)
if service_tier:
runtime_options["service_tier"] = service_tier
elif _coerce_request_bool(model_options.get("fast"), default=False):
runtime_options["service_tier"] = "priority"
return runtime_options
def _session_runtime_request_from_body(self, body: Dict[str, Any]) -> Dict[str, Any]:
raw_model = self._clean_runtime_id(body.get("model") or body.get("model_id"))
raw_provider = self._clean_runtime_id(body.get("provider") or body.get("provider_id"), max_len=80)
prefixed_provider, split_model = self._split_provider_prefixed_model(raw_model)
provider = raw_provider or prefixed_provider
model = split_model or raw_model
alias_route = self._resolve_route(raw_model) or self._resolve_route(model)
route = dict(alias_route) if isinstance(alias_route, dict) else None
route_source = "model_routes" if route else "global"
if not route and model and model != self._model_name:
route = {"model": model}
if provider:
route["provider"] = provider
route_source = "raw_request"
elif not route and provider and model:
route = {"model": model, "provider": provider}
route_source = "raw_request"
runtime_options = self._runtime_options_from_model_options(body.get("model_options"))
requested = {"provider": provider, "model": model, "raw_model": raw_model}
return {
"requested": requested,
"route": route,
"route_source": route_source,
"runtime_options": runtime_options,
"require_model_lock": _coerce_request_bool(body.get("require_model_lock"), default=False),
"model_options": body.get("model_options") if isinstance(body.get("model_options"), dict) else {},
}
def _runtime_lock_error(self, runtime_request: Dict[str, Any]) -> Optional["web.Response"]:
if not runtime_request.get("require_model_lock"):
return None
requested = runtime_request.get("requested") or {}
model = self._clean_runtime_id(requested.get("model"))
provider = self._clean_runtime_id(requested.get("provider"), max_len=80)
route = runtime_request.get("route")
if not model and not provider:
return web.json_response(
_openai_error("require_model_lock was set but no model/provider was provided", code="missing_model"),
status=400,
)
if not route or runtime_request.get("route_source") == "global":
return web.json_response(
_openai_error("Requested Browser model lock cannot be routed; refusing silent global fallback", code="model_lock_unavailable"),
status=409,
)
return None
def _persist_session_runtime_lock(self, session_id: str, runtime_request: Dict[str, Any]) -> bool:
# Persist only a newly confirmed lock. Reusing a stored lock should not
# rewrite its timestamp/prompt state on every turn, and an ordinary
# one-off request override must not erase a previously confirmed lock.
if runtime_request.get("persisted_lock") or not runtime_request.get("require_model_lock"):
return True
requested = runtime_request.get("requested") or {}
model = self._clean_runtime_id(requested.get("model"))
provider = self._clean_runtime_id(requested.get("provider"), max_len=80)
if not model and not provider:
return False
db = self._ensure_session_db()
if db is None:
return False
try:
db.update_session_runtime_lock(
session_id,
model=model or None,
provider=provider or None,
model_options=runtime_request.get("model_options") or {},
route_source=runtime_request.get("route_source") or "",
confirmed=bool(runtime_request.get("require_model_lock")),
)
return True
except Exception:
logger.warning("[%s] failed to persist session runtime lock for %s", self.name, session_id, exc_info=True)
return False
@staticmethod
def _parse_session_model_config(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return dict(raw)
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
except Exception:
return {}
if isinstance(parsed, dict):
return parsed
return {}
def _runtime_request_from_persisted_session_lock(
self,
session: Optional[Dict[str, Any]],
body: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
if not isinstance(session, dict):
return None
model_config = self._parse_session_model_config(session.get("model_config"))
lock = model_config.get("browser_model_lock")
if not isinstance(lock, dict) or not _coerce_request_bool(lock.get("confirmed"), default=False):
return None
model = self._clean_runtime_id(lock.get("model"))
provider = self._clean_runtime_id(lock.get("provider"), max_len=80)
if not model and not provider:
return None
persisted_route_source = self._clean_runtime_id(
lock.get("route_source"),
max_len=64,
).lower()
route: Optional[Dict[str, Any]] = None
if persisted_route_source == "model_routes":
route = self._resolve_route(model) if model else None
else:
route = {"model": model} if model else {}
if provider:
route["provider"] = provider
model_options = (
body.get("model_options")
if isinstance(body.get("model_options"), dict)
else lock.get("model_options")
)
return {
"requested": {
"provider": provider,
"model": model,
"raw_model": model,
},
"route": route or None,
"route_source": "session_model_lock",
"runtime_options": self._runtime_options_from_model_options(model_options),
"require_model_lock": True,
"model_options": model_options if isinstance(model_options, dict) else {},
"persisted_lock": True,
}
def _effective_session_runtime_request(
self,
*,
session: Optional[Dict[str, Any]],
body: Dict[str, Any],
) -> Dict[str, Any]:
runtime_request = self._session_runtime_request_from_body(body)
requested = runtime_request.get("requested") or {}
if requested.get("model") or requested.get("provider"):
return runtime_request
persisted = self._runtime_request_from_persisted_session_lock(session, body)
return persisted or runtime_request
@classmethod
def _sanitize_runtime_metadata(
cls,
*,
runtime: Optional[Dict[str, Any]] = None,
requested_runtime: Optional[Dict[str, Any]] = None,
route_source: str = "global",
model_lock: str = "",
) -> Dict[str, Any]:
payload = dict(runtime or {})
provider = cls._clean_runtime_id(
payload.get("provider") or payload.get("provider_id") or payload.get("effective_provider"),
max_len=80,
)
model = cls._clean_runtime_id(payload.get("model") or payload.get("model_id") or payload.get("effective_model"))
result: Dict[str, Any] = {
"provider": provider,
"model": model,
"route_source": cls._clean_runtime_id(payload.get("route_source") or route_source, max_len=64) or "global",
}
if requested_runtime or payload.get("requested"):
req = requested_runtime or payload.get("requested") or {}
result["requested"] = {
"provider": cls._clean_runtime_id(req.get("provider"), max_len=80),
"model": cls._clean_runtime_id(req.get("model")),
}
if model_lock or payload.get("model_lock"):
result["model_lock"] = cls._clean_runtime_id(model_lock or payload.get("model_lock"), max_len=32)
return result
@staticmethod
def _normalize_session_source(value: Any) -> str:
text = str(value or "").strip().lower()
allowed = {"api_server", "hermes_browser", "browser", "cli", "telegram", "discord", "slack", "desktop", "dashboard"}
if text in allowed:
return "hermes_browser" if text == "browser" else text
return "api_server"
def _session_model_override_for(self, session_key: Optional[str]) -> Optional[Dict[str, Any]]:
"""Return the gateway's session ``/model`` override for *session_key*, if any.
@ -2080,6 +2307,7 @@ class APIServerAdapter(BasePlatformAdapter):
model_options: Optional[Dict[str, Any]] = None,
route: Optional[Dict[str, Any]] = None,
session_model: Optional[str] = None,
confirmed_runtime_lock: bool = False,
) -> Any:
"""
Create an AIAgent instance using the gateway's runtime config.
@ -2107,6 +2335,12 @@ class APIServerAdapter(BasePlatformAdapter):
handlers pass either ``route`` (alias hit) or ``session_model`` (raw
model), never both. Precedence: session ``/model`` override
``session_model`` route alias / per-request selection global.
``confirmed_runtime_lock`` marks a backend-acknowledged Browser model
lock (POST /api/sessions/{id}/model). A confirmed lock beats the
session ``/model`` override, disables the global fallback model
chain, and fails closed if the locked provider's credentials cannot
be resolved.
"""
from run_agent import AIAgent
from gateway.run import (
@ -2172,7 +2406,7 @@ class APIServerAdapter(BasePlatformAdapter):
provider_name,
target_model=target_model or None,
)
except Exception:
except Exception as exc:
try:
from gateway.run import _resolve_runtime_agent_kwargs_for_provider
@ -2180,7 +2414,10 @@ class APIServerAdapter(BasePlatformAdapter):
except Exception:
pass
if required:
raise
# Surface as the typed provider-auth failure so
# _run_agent()/_handle_runs() return the controlled
# response shape instead of a raw 500.
raise _ProviderAuthResolutionError(str(exc)) from exc
logger.debug(
"api_server provider-runtime refresh failed for provider=%s model=%s",
provider_name,
@ -2190,14 +2427,19 @@ class APIServerAdapter(BasePlatformAdapter):
return None
# Final precedence mirrors the gateway contract:
# session /model override → session-persisted model (POST
# /api/sessions {"model": ...}) → model_routes mapping selected by
# the request model alias → direct per-request provider/model →
# global defaults. model_options stay request-scoped regardless
# of which selection wins.
# confirmed Browser model lock → session /model override →
# session-persisted model (POST /api/sessions {"model": ...}) →
# model_routes mapping selected by the request model alias → direct
# per-request provider/model → global defaults. model_options stay
# request-scoped regardless of which selection wins. A confirmed
# lock is an execution contract: it bypasses the session /model
# override and fails closed (never reuses global credentials) if
# its provider cannot be resolved.
session_key = gateway_session_key or session_id
session_row_model = _clean_request_string(session_model)
session_override = self._session_model_override_for(session_key)
session_override = None
if not confirmed_runtime_lock:
session_override = self._session_model_override_for(session_key)
if session_override:
override_model = _clean_request_string(session_override.get("model")) or model
session_provider = _clean_request_string(session_override.get("provider"))
@ -2216,7 +2458,7 @@ class APIServerAdapter(BasePlatformAdapter):
"api_server request selection skipped: session /model override wins for %s",
session_key or "",
)
elif session_row_model:
elif session_row_model and not confirmed_runtime_lock:
# Session-persisted model (raw string that resolved to no route
# alias). Pins this session's turns ahead of per-request body
# values — a session's chosen model is a standing selection,
@ -2253,7 +2495,10 @@ class APIServerAdapter(BasePlatformAdapter):
provider_runtime = _resolve_provider_runtime(
effective_provider,
target_model=effective_model,
required=bool(request_provider),
# A confirmed Browser lock fails closed: if the locked
# provider cannot be resolved, never fall through to
# the previous global provider's credentials.
required=bool(request_provider) or confirmed_runtime_lock,
)
if provider_runtime:
_apply_runtime_agent_overrides(runtime_kwargs, provider_runtime)
@ -2331,7 +2576,11 @@ class APIServerAdapter(BasePlatformAdapter):
# Load fallback provider chain so the API server platform has the
# same fallback behaviour as Telegram/Discord/Slack (fixes #4954).
fallback_model = GatewayRunner._load_fallback_model()
fallback_model = (
None
if confirmed_runtime_lock
else GatewayRunner._load_fallback_model()
)
agent_kwargs = {
"model": model,
@ -2357,6 +2606,19 @@ class APIServerAdapter(BasePlatformAdapter):
agent_kwargs["service_tier"] = request_service_tier
agent = AIAgent(**agent_kwargs)
agent._hermes_api_runtime = {
"provider": runtime_kwargs.get("provider") or getattr(agent, "provider", "") or "",
"model": getattr(agent, "model", None) or model,
"route_source": (
"session_model_lock"
if confirmed_runtime_lock
else "session_model_override"
if session_override
else "raw_request"
if route or request_model or request_provider
else "global"
),
}
return agent
# ------------------------------------------------------------------
@ -2559,6 +2821,7 @@ class APIServerAdapter(BasePlatformAdapter):
"session_chat": True,
"session_chat_streaming": True,
"session_fork": True,
"session_model_lock": True,
"admin_config_rw": False,
"jobs_admin": False,
"memory_write_api": False,
@ -2592,6 +2855,7 @@ class APIServerAdapter(BasePlatformAdapter):
"session_fork": {"method": "POST", "path": "/api/sessions/{session_id}/fork"},
"session_chat": {"method": "POST", "path": "/api/sessions/{session_id}/chat"},
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
"session_model_lock": {"method": "POST", "path": "/api/sessions/{session_id}/model"},
},
})
@ -2816,6 +3080,25 @@ class APIServerAdapter(BasePlatformAdapter):
system_prompt = body.get("system_prompt")
if system_prompt is not None and not isinstance(system_prompt, str):
return web.json_response(_openai_error("system_prompt must be a string", code="invalid_system_prompt"), status=400)
source = self._normalize_session_source(body.get("source") or "api_server")
runtime_request = self._session_runtime_request_from_body(body)
lock_error = self._runtime_lock_error(runtime_request)
if lock_error is not None:
return lock_error
requested = runtime_request.get("requested") or {}
model_name = self._clean_runtime_id(requested.get("model")) or (str(model) if model else None)
model_config = None
if requested.get("model") or requested.get("provider"):
model_config = {
"browser_model_lock": {
"provider": requested.get("provider") or "",
"model": requested.get("model") or "",
"model_options": runtime_request.get("model_options") or {},
"route_source": runtime_request.get("route_source") or "",
"confirmed": bool(runtime_request.get("require_model_lock")),
"updated_at": time.time(),
}
}
title = body.get("title")
# Run the entire check-insert-title sequence inside a single
@ -2833,12 +3116,13 @@ class APIServerAdapter(BasePlatformAdapter):
import time as _time
conn.execute(
"""INSERT INTO sessions (
id, source, model, system_prompt, started_at
) VALUES (?, ?, ?, ?, ?)""",
id, source, model, model_config, system_prompt, started_at
) VALUES (?, ?, ?, ?, ?, ?)""",
(
session_id,
"api_server",
str(model) if model else None,
source,
model_name,
json.dumps(model_config) if model_config else None,
system_prompt,
_time.time(),
),
@ -2863,8 +3147,8 @@ class APIServerAdapter(BasePlatformAdapter):
"SELECT * FROM sessions WHERE id = ?", (session_id,)
).fetchone()
return (dict(session_row) if session_row else {
"id": session_id, "source": "api_server",
"model": model, "title": title,
"id": session_id, "source": source,
"model": model_name, "title": title,
}), None
return db._execute_write(_atomic)
@ -3010,26 +3294,57 @@ class APIServerAdapter(BasePlatformAdapter):
system_prompt = body.get("system_message") or body.get("instructions")
if system_prompt is not None and not isinstance(system_prompt, str):
return web.json_response(_openai_error("system_message must be a string", code="invalid_system_message"), status=400)
# Session-persisted model (POST /api/sessions {"model": ...}) —
# previously fetched and discarded here, so a session's chosen model
# silently had no effect on any chat turn. If the stored value is a
# model_routes alias, apply it through the route path so route
# provider/credentials come along; a raw model string threads
# through as session_model.
stored_model = session.get("model") if isinstance(session, dict) else None
stored_route = self._resolve_route(stored_model)
route = stored_route or self._resolve_route(body.get("model"))
session_model = stored_model if (stored_model and stored_route is None) else None
agent_overrides = _request_agent_overrides(body, virtual_model=self._model_name)
selection_error = self._request_route_conflict_error(
session_id=session_id,
gateway_session_key=gateway_session_key,
requested_model=agent_overrides.get("requested_model"),
requested_provider=agent_overrides.get("requested_provider"),
route=route,
# Runtime selection. A backend-acknowledged Browser model lock
# (require_model_lock in the body, or a previously confirmed lock
# persisted on the session row) is an execution contract and wins.
# Otherwise: session-persisted model (POST /api/sessions
# {"model": ...}) — previously fetched and discarded here — routes
# through model_routes when it is an alias (route
# provider/credentials come along) or threads through as
# session_model when it is a raw string; per-request body values
# come after that.
runtime_request = self._effective_session_runtime_request(
session=session,
body=body,
)
if selection_error:
return web.json_response(_openai_error(selection_error), status=400)
lock_error = self._runtime_lock_error(runtime_request)
if lock_error is not None:
return lock_error
if not self._persist_session_runtime_lock(session_id, runtime_request):
return web.json_response(
_openai_error(
"Could not persist the requested session model lock",
code="model_lock_persistence_failed",
),
status=500,
)
lock_active = bool(runtime_request.get("require_model_lock"))
if lock_active:
route = runtime_request.get("route")
session_model = None
requested = runtime_request.get("requested") or {}
agent_overrides: Dict[str, Any] = {}
if requested.get("model"):
agent_overrides["requested_model"] = requested["model"]
if requested.get("provider"):
agent_overrides["requested_provider"] = requested["provider"]
if runtime_request.get("model_options"):
agent_overrides["model_options"] = runtime_request["model_options"]
else:
stored_model = session.get("model") if isinstance(session, dict) else None
stored_route = self._resolve_route(stored_model)
route = stored_route or self._resolve_route(body.get("model"))
session_model = stored_model if (stored_model and stored_route is None) else None
agent_overrides = _request_agent_overrides(body, virtual_model=self._model_name)
selection_error = self._request_route_conflict_error(
session_id=session_id,
gateway_session_key=gateway_session_key,
requested_model=agent_overrides.get("requested_model"),
requested_provider=agent_overrides.get("requested_provider"),
route=route,
)
if selection_error:
return web.json_response(_openai_error(selection_error), status=400)
history = await self._conversation_history_for_session(session_id)
result, usage = await self._run_agent(
user_message=user_message,
@ -3039,6 +3354,9 @@ class APIServerAdapter(BasePlatformAdapter):
gateway_session_key=gateway_session_key,
route=route,
session_model=session_model,
requested_runtime=runtime_request.get("requested") or {},
route_source=runtime_request.get("route_source") or "global",
confirmed_runtime_lock=lock_active,
**agent_overrides,
)
effective_session_id = result.get("session_id") if isinstance(result, dict) else session_id
@ -3046,12 +3364,30 @@ class APIServerAdapter(BasePlatformAdapter):
headers = {"X-Hermes-Session-Id": effective_session_id or session_id}
if gateway_session_key:
headers["X-Hermes-Session-Key"] = gateway_session_key
runtime = {}
if isinstance(result, dict):
runtime = result.get("runtime") or {}
if not runtime and isinstance(usage, dict):
runtime = usage.get("runtime") or {}
runtime = self._sanitize_runtime_metadata(
runtime=runtime,
requested_runtime=runtime_request.get("requested"),
route_source=runtime_request.get("route_source") or "global",
model_lock=(
"confirmed"
if runtime and runtime_request.get("require_model_lock")
else "accepted"
if runtime_request.get("require_model_lock")
else ""
),
)
return web.json_response(
{
"object": "hermes.session.chat.completion",
"session_id": effective_session_id or session_id,
"message": {"role": "assistant", "content": final_response},
"usage": usage,
"runtime": runtime,
},
headers=headers,
)
@ -3075,22 +3411,55 @@ class APIServerAdapter(BasePlatformAdapter):
system_prompt = body.get("system_message") or body.get("instructions")
if system_prompt is not None and not isinstance(system_prompt, str):
return web.json_response(_openai_error("system_message must be a string", code="invalid_system_message"), status=400)
# Session-persisted model — see _handle_session_chat for the
# non-streaming twin of this resolution.
stored_model = session.get("model") if isinstance(session, dict) else None
stored_route = self._resolve_route(stored_model)
route = stored_route or self._resolve_route(body.get("model"))
session_model = stored_model if (stored_model and stored_route is None) else None
agent_overrides = _request_agent_overrides(body, virtual_model=self._model_name)
selection_error = self._request_route_conflict_error(
session_id=session_id,
gateway_session_key=gateway_session_key,
requested_model=agent_overrides.get("requested_model"),
requested_provider=agent_overrides.get("requested_provider"),
route=route,
# Runtime selection — mirrors _handle_session_chat (lock wins,
# otherwise session-persisted model then per-request values).
runtime_request = self._effective_session_runtime_request(
session=session,
body=body,
)
lock_error = self._runtime_lock_error(runtime_request)
if lock_error is not None:
return lock_error
if not self._persist_session_runtime_lock(session_id, runtime_request):
return web.json_response(
_openai_error(
"Could not persist the requested session model lock",
code="model_lock_persistence_failed",
),
status=500,
)
lock_active = bool(runtime_request.get("require_model_lock"))
if lock_active:
route = runtime_request.get("route")
session_model = None
requested = runtime_request.get("requested") or {}
agent_overrides: Dict[str, Any] = {}
if requested.get("model"):
agent_overrides["requested_model"] = requested["model"]
if requested.get("provider"):
agent_overrides["requested_provider"] = requested["provider"]
if runtime_request.get("model_options"):
agent_overrides["model_options"] = runtime_request["model_options"]
else:
stored_model = session.get("model") if isinstance(session, dict) else None
stored_route = self._resolve_route(stored_model)
route = stored_route or self._resolve_route(body.get("model"))
session_model = stored_model if (stored_model and stored_route is None) else None
agent_overrides = _request_agent_overrides(body, virtual_model=self._model_name)
selection_error = self._request_route_conflict_error(
session_id=session_id,
gateway_session_key=gateway_session_key,
requested_model=agent_overrides.get("requested_model"),
requested_provider=agent_overrides.get("requested_provider"),
route=route,
)
if selection_error:
return web.json_response(_openai_error(selection_error), status=400)
runtime_meta = self._sanitize_runtime_metadata(
requested_runtime=runtime_request.get("requested"),
route_source=runtime_request.get("route_source") or "global",
model_lock=("accepted" if lock_active else ""),
)
if selection_error:
return web.json_response(_openai_error(selection_error), status=400)
loop = asyncio.get_running_loop()
queue: "asyncio.Queue[Optional[tuple[str, Dict[str, Any]]]]" = asyncio.Queue()
@ -3134,7 +3503,10 @@ class APIServerAdapter(BasePlatformAdapter):
async def _run_and_signal() -> None:
try:
await queue.put(_event_payload("run.started", {"user_message": {"role": "user", "content": user_message}}))
await queue.put(_event_payload("run.started", {
"user_message": {"role": "user", "content": user_message},
"runtime": runtime_meta,
}))
await queue.put(_event_payload("message.started", {"message": {"id": message_id, "role": "assistant"}}))
history = await self._conversation_history_for_session(session_id)
result, usage = await self._run_agent(
@ -3147,11 +3519,31 @@ class APIServerAdapter(BasePlatformAdapter):
gateway_session_key=gateway_session_key,
route=route,
session_model=session_model,
requested_runtime=runtime_request.get("requested") or {},
route_source=runtime_request.get("route_source") or "global",
confirmed_runtime_lock=lock_active,
**agent_overrides,
)
final_response = _resolve_media_to_data_urls(result.get("final_response", "") if isinstance(result, dict) else "")
effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
effective_runtime = {}
if isinstance(result, dict):
effective_runtime = result.get("runtime") or {}
if not effective_runtime and isinstance(usage, dict):
effective_runtime = usage.get("runtime") or {}
effective_runtime = self._sanitize_runtime_metadata(
runtime=effective_runtime,
requested_runtime=runtime_request.get("requested"),
route_source=runtime_request.get("route_source") or "global",
model_lock=(
"confirmed"
if effective_runtime and runtime_request.get("require_model_lock")
else "accepted"
if runtime_request.get("require_model_lock")
else ""
),
)
await queue.put(_event_payload("assistant.completed", {
"session_id": effective_session_id,
"message_id": message_id,
@ -3159,6 +3551,7 @@ class APIServerAdapter(BasePlatformAdapter):
"completed": True,
"partial": False,
"interrupted": False,
"runtime": effective_runtime,
}))
await queue.put(_event_payload("run.completed", {
"session_id": effective_session_id,
@ -3166,6 +3559,7 @@ class APIServerAdapter(BasePlatformAdapter):
"completed": True,
"messages": turn_messages,
"usage": usage,
"runtime": effective_runtime,
}))
except Exception as exc:
logger.exception("[api_server] session chat stream failed")
@ -3214,6 +3608,48 @@ class APIServerAdapter(BasePlatformAdapter):
logger.debug("[api_server] session SSE stream error: %s", exc)
return response
async def _handle_session_model_lock(self, request: "web.Request") -> "web.Response":
"""POST /api/sessions/{session_id}/model — backend-ack a Browser model lock."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err
session_id = request.match_info["session_id"]
_, err = await self._get_existing_session_or_404(session_id)
if err:
return err
body, err = await self._read_json_body(request)
if err:
return err
runtime_request = self._session_runtime_request_from_body(body)
runtime_request["require_model_lock"] = True
lock_error = self._runtime_lock_error(runtime_request)
if lock_error is not None:
return lock_error
if not self._persist_session_runtime_lock(session_id, runtime_request):
return web.json_response(
_openai_error(
"Could not persist the requested session model lock",
code="model_lock_persistence_failed",
),
status=500,
)
requested = runtime_request.get("requested") or {}
route = runtime_request.get("route") or {}
runtime = self._sanitize_runtime_metadata(
runtime={
"provider": route.get("provider") or requested.get("provider") or "",
"model": route.get("model") or requested.get("model") or "",
"route_source": runtime_request.get("route_source") or "raw_request",
},
requested_runtime=requested,
route_source=runtime_request.get("route_source") or "raw_request",
model_lock="accepted",
)
return web.json_response({
"object": "hermes.session.model_lock",
"session_id": session_id,
"runtime": runtime,
})
@_admit_api_agent_request
async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
@ -5281,6 +5717,9 @@ class APIServerAdapter(BasePlatformAdapter):
model_options: Optional[Dict[str, Any]] = None,
route: Optional[Dict[str, Any]] = None,
session_model: Optional[str] = None,
requested_runtime: Optional[Dict[str, Any]] = None,
route_source: str = "global",
confirmed_runtime_lock: bool = False,
) -> tuple:
"""
Create an agent and run a conversation in a thread executor.
@ -5296,6 +5735,12 @@ class APIServerAdapter(BasePlatformAdapter):
row. It is used only when the persisted value did not resolve to a
``model_routes`` alias see ``_create_agent`` for precedence.
*requested_runtime* / *route_source* / *confirmed_runtime_lock*
carry the Browser model-lock contract: when a confirmed lock is
active the completed agent's actual provider/model must match the
locked selection or the turn fails, and the response carries
sanitized ``runtime`` metadata reporting actual vs requested.
If *agent_ref* is a one-element list, the AIAgent instance is stored
at ``agent_ref[0]`` before ``run_conversation`` begins. This allows
callers (e.g. the SSE writer) to call ``agent.interrupt()`` from
@ -5330,6 +5775,7 @@ class APIServerAdapter(BasePlatformAdapter):
model_options=model_options,
route=route,
session_model=session_model,
confirmed_runtime_lock=confirmed_runtime_lock,
)
if agent_ref is not None:
agent_ref[0] = agent
@ -5362,6 +5808,71 @@ class APIServerAdapter(BasePlatformAdapter):
)
if _compacted_in_place or _session_rotated:
result["_compressed"] = True
include_runtime = bool(
requested_runtime
or route
or confirmed_runtime_lock
or (route_source and route_source != "global")
)
if include_runtime:
runtime = dict(getattr(agent, "_hermes_api_runtime", {}) or {})
raw_provider = getattr(agent, "provider", "")
raw_model = getattr(agent, "model", "")
actual_provider = (
self._clean_runtime_id(raw_provider, max_len=80)
if isinstance(raw_provider, str)
else ""
)
actual_model = (
self._clean_runtime_id(raw_model)
if isinstance(raw_model, str)
else ""
)
if actual_provider:
runtime["provider"] = actual_provider
else:
runtime.setdefault("provider", "")
if actual_model:
runtime["model"] = actual_model
else:
runtime.setdefault("model", "")
if confirmed_runtime_lock:
expected_provider = self._clean_runtime_id(
(route or {}).get("provider")
or (requested_runtime or {}).get("provider"),
max_len=80,
)
expected_model = self._clean_runtime_id(
(route or {}).get("model")
or (requested_runtime or {}).get("model")
)
mismatched = (
(expected_provider and actual_provider != expected_provider)
or (expected_model and actual_model != expected_model)
)
if mismatched:
raise RuntimeError(
"confirmed model lock runtime mismatch: "
f"expected provider={expected_provider or '<unspecified>'} "
f"model={expected_model or '<unspecified>'}; "
f"actual provider={actual_provider or '<unknown>'} "
f"model={actual_model or '<unknown>'}"
)
if requested_runtime:
runtime["requested"] = {
"provider": self._clean_runtime_id((requested_runtime or {}).get("provider"), max_len=80),
"model": self._clean_runtime_id((requested_runtime or {}).get("model")),
}
runtime["route_source"] = route_source or runtime.get("route_source") or "global"
runtime = self._sanitize_runtime_metadata(
runtime=runtime,
requested_runtime=requested_runtime,
route_source=route_source or "global",
model_lock=("confirmed" if confirmed_runtime_lock else ""),
)
if isinstance(result, dict):
result["runtime"] = runtime
usage["runtime"] = runtime
return result, usage
except _ProviderAuthResolutionError as exc:
# Only _ProviderAuthResolutionError — raised exclusively

View file

@ -4366,14 +4366,81 @@ class SessionDB:
Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``
(only filling in NULL), this unconditionally sets the model column
so that the dashboard reflects the user's latest /model choice.
Also nulls ``system_prompt`` so stale ``Model:`` / ``Provider:``
footer metadata is rebuilt on the next turn. A successful /model
switch explicitly replaces any confirmed Browser runtime lock while
preserving unrelated lineage markers in ``model_config``.
"""
def _do(conn):
conn.execute(
"UPDATE sessions SET model = ? WHERE id = ?",
"""UPDATE sessions SET
model = ?,
model_config = CASE
WHEN model_config IS NULL THEN NULL
WHEN json_valid(model_config)
THEN json_remove(model_config, '$.browser_model_lock')
ELSE model_config
END,
system_prompt = NULL
WHERE id = ?""",
(model, session_id),
)
self._execute_write(_do)
def update_session_runtime_lock(
self,
session_id: str,
*,
model: Optional[str] = None,
provider: Optional[str] = None,
model_options: Optional[Dict[str, Any]] = None,
route_source: Optional[str] = None,
confirmed: bool = False,
) -> None:
"""Persist a Browser / API client runtime lock without clobbering lineage markers.
Merges ``browser_model_lock`` into the existing ``model_config`` JSON so
``_branched_from`` / ``_delegate_from`` survive. Nulls ``system_prompt``
so cached ``Model:`` / ``Provider:`` footers cannot lie after a switch.
"""
lock = {
"provider": provider or "",
"model": model or "",
"model_options": model_options or {},
"route_source": route_source or "",
"confirmed": bool(confirmed),
"updated_at": time.time(),
}
def _do(conn):
row = conn.execute(
"SELECT model_config FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
return
raw = row["model_config"] if isinstance(row, sqlite3.Row) else row[0]
config: Dict[str, Any] = {}
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
config = parsed
except Exception:
config = {}
elif isinstance(raw, dict):
config = dict(raw)
config["browser_model_lock"] = lock
conn.execute(
"""UPDATE sessions SET
model_config = ?,
model = COALESCE(?, model),
system_prompt = NULL
WHERE id = ?""",
(json.dumps(config), model, session_id),
)
self._execute_write(_do)
def update_session_billing_route(
self,
session_id: str,

View file

@ -65,11 +65,13 @@ class TestApiServerRouteTable:
assert "/v1/models" in paths
assert "/api/model/options" in paths
assert "/v1/chat/completions" in paths
assert "/api/sessions/{session_id}/model" in paths
# connect() mirrors every native path under /p/{profile}/…
mirrored = {f"/p/{{profile}}{path}" for path in paths}
assert "/p/{profile}/v1/models" in mirrored
assert "/p/{profile}/api/model/options" in mirrored
assert "/p/{profile}/v1/chat/completions" in mirrored
assert "/p/{profile}/api/sessions/{session_id}/model" in mirrored
class TestApiServerModelsUnderProfile:

View file

@ -113,7 +113,10 @@ async def test_run_agent_binds_api_session_context_for_tool_env(adapter, monkeyp
)
assert result["session_id"] == "request-session"
assert usage == {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
assert usage["input_tokens"] == 0
assert usage["output_tokens"] == 0
assert usage["total_tokens"] == 0
assert "runtime" not in usage
assert observed == {
"task_id": "request-session",
"context_session_id": "request-session",
@ -617,3 +620,578 @@ async def test_session_chat_surfaces_controlled_response_on_provider_auth_failur
payload = await resp.json()
assert payload["message"]["content"] == "⚠️ Provider authentication failed: Auth failed: token expired"
def _register_session_model_route(app, adapter):
app.router.add_post("/api/sessions/{session_id}/model", adapter._handle_session_model_lock)
def _patch_api_server_runtime(monkeypatch):
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: {
"provider": "openrouter",
"api_key": "sk-global",
"base_url": "https://openrouter.example/v1",
"api_mode": "chat_completions",
},
)
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "global/model")
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {})
monkeypatch.setattr(
"gateway.run.GatewayRunner._load_reasoning_config",
staticmethod(lambda model="": {}),
)
monkeypatch.setattr(
"gateway.run.GatewayRunner._load_fallback_model",
staticmethod(lambda: None),
)
monkeypatch.setattr("gateway.run._current_max_iterations", lambda: 90)
monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set())
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs_for_provider",
lambda provider: {
"provider": provider,
"api_key": f"sk-{provider}",
"base_url": f"https://{provider}.example/v1",
"api_mode": "chat_completions",
},
)
@pytest.mark.asyncio
async def test_session_chat_builds_raw_provider_model_route_when_alias_missing(adapter, session_db):
session_id = session_db.create_session("route-session", "api_server")
mock_run = AsyncMock(
return_value=(
{
"final_response": "ok",
"session_id": session_id,
"runtime": {"provider": "nous", "model": "x-ai/grok-4.5", "route_source": "raw_request"},
},
{"total_tokens": 2, "runtime": {"provider": "nous", "model": "x-ai/grok-4.5"}},
)
)
app = _create_session_app(adapter)
with patch.object(adapter, "_resolve_route", return_value=None), patch.object(adapter, "_run_agent", mock_run):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat",
json={
"message": "hello",
"provider": "nous",
"model": "x-ai/grok-4.5",
"require_model_lock": True,
},
)
assert resp.status == 200, await resp.text()
payload = await resp.json()
kwargs = mock_run.call_args.kwargs
assert kwargs["route"] == {"provider": "nous", "model": "x-ai/grok-4.5"}
assert payload["runtime"]["provider"] == "nous"
assert payload["runtime"]["model"] == "x-ai/grok-4.5"
assert payload["runtime"]["requested"]["model"] == "x-ai/grok-4.5"
@pytest.mark.asyncio
async def test_session_chat_passes_runtime_options_to_run_agent(adapter, session_db):
session_id = session_db.create_session("options-session", "api_server")
mock_run = AsyncMock(return_value=({"final_response": "ok", "session_id": session_id}, {}))
app = _create_session_app(adapter)
with patch.object(adapter, "_resolve_route", return_value=None), patch.object(adapter, "_run_agent", mock_run):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat",
json={
"message": "hello",
"provider": "nous",
"model": "x-ai/grok-4.5",
"model_options": {
"reasoning": {"enabled": True, "effort": "xhigh"},
"service_tier": "priority",
"fast": True,
},
},
)
assert resp.status == 200, await resp.text()
kwargs = mock_run.call_args.kwargs
# In the merged design model_options travel raw to _create_agent, which
# parses reasoning/service-tier itself (see _request_reasoning_config /
# _request_service_tier) — there is no separate runtime_options kwarg.
assert kwargs["model_options"] == {
"reasoning": {"enabled": True, "effort": "xhigh"},
"service_tier": "priority",
"fast": True,
}
@pytest.mark.asyncio
async def test_session_chat_stream_uses_same_runtime_lock(adapter, session_db):
session_id = session_db.create_session("stream-lock-session", "api_server")
captured = {}
async def fake_run(**kwargs):
captured.update(kwargs)
kwargs["stream_delta_callback"]("hi")
return (
{
"final_response": "hi",
"session_id": session_id,
"runtime": {
"provider": "nous",
"model": "x-ai/grok-4.5",
"requested": {"provider": "nous", "model": "x-ai/grok-4.5"},
"route_source": "raw_request",
},
},
{
"total_tokens": 1,
"runtime": {
"provider": "nous",
"model": "x-ai/grok-4.5",
"requested": {"provider": "nous", "model": "x-ai/grok-4.5"},
},
},
)
app = _create_session_app(adapter)
with patch.object(adapter, "_resolve_route", return_value=None), patch.object(adapter, "_run_agent", side_effect=fake_run):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat/stream",
json={
"message": "stream",
"provider": "nous",
"model": "x-ai/grok-4.5",
"model_options": {"reasoning": {"enabled": False}},
"require_model_lock": True,
},
)
assert resp.status == 200, await resp.text()
body = await resp.text()
assert captured["route"] == {"provider": "nous", "model": "x-ai/grok-4.5"}
assert captured["model_options"] == {"reasoning": {"enabled": False}}
assert captured["confirmed_runtime_lock"] is True
assert "x-ai/grok-4.5" in body
assert "run.started" in body or "event: run.started" in body
@pytest.mark.asyncio
async def test_create_session_respects_browser_source_and_model_lock(adapter, session_db):
app = _create_session_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/api/sessions",
json={
"id": "browser-lock-session",
"source": "hermes_browser",
"provider": "nous",
"model": "x-ai/grok-4.5",
"require_model_lock": True,
"title": "Browser lock",
"system_prompt": "browser prompt",
},
)
assert resp.status == 201, await resp.text()
payload = await resp.json()
assert payload["session"]["source"] == "hermes_browser"
assert payload["session"]["model"] == "x-ai/grok-4.5"
row = session_db.get_session("browser-lock-session")
assert row["source"] == "hermes_browser"
assert row["model"] == "x-ai/grok-4.5"
import json as _json
model_config = row.get("model_config")
if isinstance(model_config, str):
model_config = _json.loads(model_config)
assert model_config["browser_model_lock"]["provider"] == "nous"
assert model_config["browser_model_lock"]["model"] == "x-ai/grok-4.5"
assert model_config["browser_model_lock"]["confirmed"] is True
@pytest.mark.asyncio
async def test_session_model_lock_endpoint_persists_and_invalidates_prompt(adapter, session_db):
session_id = session_db.create_session(
"lock-endpoint-session",
"api_server",
model="gpt-5.5",
model_config={"_branched_from": "parent-session"},
system_prompt="Conversation started:\nModel: gpt-5.5\nProvider: openai-codex\n",
)
app = _create_session_app(adapter)
_register_session_model_route(app, adapter)
with patch.object(adapter, "_resolve_route", return_value=None):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/model",
json={
"provider": "nous",
"model": "x-ai/grok-4.5",
"model_options": {"reasoning": {"enabled": True, "effort": "high"}},
"require_model_lock": True,
},
)
assert resp.status == 200, await resp.text()
payload = await resp.json()
assert payload["object"] == "hermes.session.model_lock"
assert payload["runtime"]["requested"]["provider"] == "nous"
assert payload["runtime"]["model"] == "x-ai/grok-4.5"
assert payload["runtime"]["model_lock"] in {"accepted", "confirmed"}
row = session_db.get_session(session_id)
assert row["model"] == "x-ai/grok-4.5"
assert row["system_prompt"] is None
import json as _json
model_config = row.get("model_config")
if isinstance(model_config, str):
model_config = _json.loads(model_config)
assert model_config["_branched_from"] == "parent-session"
assert model_config["browser_model_lock"]["provider"] == "nous"
@pytest.mark.asyncio
async def test_session_model_lock_endpoint_then_chat_reuses_persisted_lock_and_provider_credentials(
adapter,
session_db,
monkeypatch,
):
session_id = session_db.create_session(
"endpoint-lock-chat",
"api_server",
model="gpt-5.5",
system_prompt="Conversation started:\nModel: gpt-5.5\nProvider: openai-codex\n",
)
captured = {}
class FakeAgent:
session_prompt_tokens = 0
session_completion_tokens = 0
session_total_tokens = 0
def __init__(self, **kwargs):
captured.update(kwargs)
self.session_id = kwargs["session_id"]
self.provider = kwargs.get("provider") or ""
self.model = kwargs.get("model") or ""
def run_conversation(self, user_message, conversation_history, task_id):
return {"final_response": "locked", "session_id": self.session_id}
_patch_api_server_runtime(monkeypatch)
monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
monkeypatch.setattr(
adapter,
"_session_model_override_for",
lambda *_: {
"model": "session/override-model",
"provider": "openai-codex",
"api_key": "sk-session-override",
"base_url": "https://override.example/v1",
"api_mode": "codex_responses",
},
)
app = _create_session_app(adapter)
_register_session_model_route(app, adapter)
with patch.object(adapter, "_resolve_route", return_value=None):
async with TestClient(TestServer(app)) as cli:
lock_resp = await cli.post(
f"/api/sessions/{session_id}/model",
json={
"provider": "nous",
"model": "x-ai/grok-4.5",
"require_model_lock": True,
},
)
assert lock_resp.status == 200, await lock_resp.text()
resp = await cli.post(
f"/api/sessions/{session_id}/chat",
json={"message": "use the stored lock"},
)
assert resp.status == 200, await resp.text()
payload = await resp.json()
assert captured["provider"] == "nous"
assert captured["model"] == "x-ai/grok-4.5"
assert captured["api_key"] == "sk-nous"
assert captured["base_url"] == "https://nous.example/v1"
assert payload["runtime"]["provider"] == "nous"
assert payload["runtime"]["model"] == "x-ai/grok-4.5"
assert payload["runtime"]["requested"] == {
"provider": "nous",
"model": "x-ai/grok-4.5",
}
assert payload["runtime"]["route_source"] == "session_model_lock"
@pytest.mark.asyncio
async def test_session_model_lock_endpoint_then_chat_stream_reuses_persisted_lock(
adapter,
session_db,
):
session_id = session_db.create_session("endpoint-lock-stream", "api_server")
captured = {}
async def fake_run(**kwargs):
captured.update(kwargs)
kwargs["stream_delta_callback"]("hi")
return (
{
"final_response": "hi",
"session_id": session_id,
"runtime": {
"provider": "nous",
"model": "x-ai/grok-4.5",
"requested": {"provider": "nous", "model": "x-ai/grok-4.5"},
"route_source": "session_model_lock",
},
},
{
"total_tokens": 1,
"runtime": {
"provider": "nous",
"model": "x-ai/grok-4.5",
"requested": {"provider": "nous", "model": "x-ai/grok-4.5"},
"route_source": "session_model_lock",
},
},
)
app = _create_session_app(adapter)
_register_session_model_route(app, adapter)
with patch.object(adapter, "_resolve_route", return_value=None), patch.object(
adapter,
"_run_agent",
side_effect=fake_run,
):
async with TestClient(TestServer(app)) as cli:
lock_resp = await cli.post(
f"/api/sessions/{session_id}/model",
json={
"provider": "nous",
"model": "x-ai/grok-4.5",
"require_model_lock": True,
},
)
assert lock_resp.status == 200, await lock_resp.text()
resp = await cli.post(
f"/api/sessions/{session_id}/chat/stream",
json={"message": "stream with stored lock"},
)
assert resp.status == 200, await resp.text()
body = await resp.text()
assert captured["route"] == {"provider": "nous", "model": "x-ai/grok-4.5"}
assert captured["requested_runtime"]["provider"] == "nous"
assert captured["requested_runtime"]["model"] == "x-ai/grok-4.5"
assert captured["route_source"] == "session_model_lock"
assert "x-ai/grok-4.5" in body
@pytest.mark.asyncio
async def test_run_agent_reports_actual_agent_runtime_not_requested_metadata(adapter, monkeypatch):
class FakeAgent:
session_prompt_tokens = 0
session_completion_tokens = 0
session_total_tokens = 0
def __init__(self):
self.session_id = "runtime-session"
self.provider = "actual-provider"
self.model = "actual-model"
self._hermes_api_runtime = {
"provider": "requested-provider",
"model": "requested-model",
"route_source": "raw_request",
}
def run_conversation(self, user_message, conversation_history, task_id):
return {"final_response": "ok", "session_id": self.session_id}
monkeypatch.setattr(adapter, "_create_agent", lambda **kwargs: FakeAgent())
result, usage = await adapter._run_agent(
user_message="hello",
conversation_history=[],
session_id="runtime-session",
route={"provider": "requested-provider", "model": "requested-model"},
requested_runtime={
"provider": "requested-provider",
"model": "requested-model",
},
route_source="session_model_lock",
)
assert result["runtime"]["provider"] == "actual-provider"
assert result["runtime"]["model"] == "actual-model"
assert result["runtime"]["requested"] == {
"provider": "requested-provider",
"model": "requested-model",
}
assert usage["runtime"]["provider"] == "actual-provider"
assert usage["runtime"]["model"] == "actual-model"
@pytest.mark.asyncio
async def test_confirmed_runtime_lock_rejects_actual_runtime_mismatch(adapter, monkeypatch):
class FakeAgent:
session_prompt_tokens = 0
session_completion_tokens = 0
session_total_tokens = 0
session_id = "mismatch-session"
provider = "fallback-provider"
model = "fallback-model"
def run_conversation(self, user_message, conversation_history, task_id):
return {"final_response": "wrong runtime", "session_id": self.session_id}
monkeypatch.setattr(adapter, "_create_agent", lambda **kwargs: FakeAgent())
with pytest.raises(RuntimeError, match="confirmed model lock runtime mismatch"):
await adapter._run_agent(
user_message="hello",
conversation_history=[],
session_id="mismatch-session",
route={"provider": "nous", "model": "x-ai/grok-4.5"},
requested_runtime={"provider": "nous", "model": "x-ai/grok-4.5"},
route_source="session_model_lock",
confirmed_runtime_lock=True,
)
def test_confirmed_runtime_lock_fails_closed_on_provider_resolution_error(adapter, monkeypatch):
_patch_api_server_runtime(monkeypatch)
# Break BOTH resolution paths (primary picker-based resolver + the
# gateway fallback) — a confirmed lock must propagate the failure
# instead of constructing an agent on the previous global credentials.
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("provider unavailable")),
)
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs_for_provider",
lambda provider: (_ for _ in ()).throw(RuntimeError("provider unavailable")),
)
agent_ctor = patch("run_agent.AIAgent")
with agent_ctor as mocked_agent:
with pytest.raises(RuntimeError, match="provider unavailable"):
adapter._create_agent(
session_id="locked-session",
route={"provider": "nous", "model": "x-ai/grok-4.5"},
confirmed_runtime_lock=True,
)
mocked_agent.assert_not_called()
def test_confirmed_runtime_lock_disables_global_fallback_model(adapter, monkeypatch):
_patch_api_server_runtime(monkeypatch)
monkeypatch.setattr(
"gateway.run.GatewayRunner._load_fallback_model",
staticmethod(lambda: "openrouter/fallback-model"),
)
captured = {}
class FakeAgent:
provider = "nous"
model = "x-ai/grok-4.5"
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
adapter._create_agent(
session_id="locked-session",
route={"provider": "nous", "model": "x-ai/grok-4.5"},
confirmed_runtime_lock=True,
)
assert captured["fallback_model"] is None
@pytest.mark.asyncio
async def test_unconfirmed_request_does_not_replace_confirmed_session_lock(adapter, session_db):
session_id = session_db.create_session("one-off-override", "api_server")
session_db.update_session_runtime_lock(
session_id,
provider="nous",
model="x-ai/grok-4.5",
route_source="raw_request",
confirmed=True,
)
mock_run = AsyncMock(
return_value=(
{
"final_response": "ok",
"session_id": session_id,
"runtime": {"provider": "openrouter", "model": "anthropic/claude-sonnet"},
},
{"total_tokens": 1},
)
)
app = _create_session_app(adapter)
with patch.object(adapter, "_resolve_route", return_value=None), patch.object(
adapter,
"_run_agent",
mock_run,
):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat",
json={
"message": "one turn only",
"provider": "openrouter",
"model": "anthropic/claude-sonnet",
},
)
assert resp.status == 200, await resp.text()
import json as _json
row = session_db.get_session(session_id)
config = row["model_config"]
if isinstance(config, str):
config = _json.loads(config)
assert config["browser_model_lock"]["provider"] == "nous"
assert config["browser_model_lock"]["model"] == "x-ai/grok-4.5"
assert config["browser_model_lock"]["confirmed"] is True
@pytest.mark.asyncio
async def test_require_model_lock_hard_fails_when_global_default_would_be_used(adapter, session_db, monkeypatch):
session_id = session_db.create_session("lock-fail-session", "api_server")
monkeypatch.setattr(adapter, "_model_name", "gpt-5.5")
app = _create_session_app(adapter)
with patch.object(adapter, "_resolve_route", return_value=None), patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
async with TestClient(TestServer(app)) as cli:
# empty model + require_model_lock must not silently fall through
resp = await cli.post(
f"/api/sessions/{session_id}/chat",
json={
"message": "hello",
"provider": "nous",
"model": "",
"require_model_lock": True,
},
)
assert resp.status in (400, 409), await resp.text()
body = await resp.json()
assert body["error"]["code"] in {"model_lock_unavailable", "invalid_model_lock", "missing_model"}
mock_run.assert_not_called()
@pytest.mark.asyncio
async def test_capabilities_advertises_session_model_lock(adapter):
app = _create_session_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/v1/capabilities")
assert resp.status == 200
data = await resp.json()
assert data["features"]["session_model_lock"] is True
assert data["endpoints"]["session_model_lock"] == {
"method": "POST",
"path": "/api/sessions/{session_id}/model",
}

View file

@ -543,6 +543,30 @@ class TestSessionLifecycle:
model="xiaomi/mimo-v2.5-pro")
assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5"
def test_update_session_model_clears_browser_lock_and_preserves_lineage(self, db):
"""A later /model switch must replace, not compete with, a Browser lock."""
db.create_session(
session_id="s1",
source="hermes_browser",
model="x-ai/grok-4.5",
model_config={
"_branched_from": "parent-session",
"browser_model_lock": {
"provider": "nous",
"model": "x-ai/grok-4.5",
"confirmed": True,
},
},
)
db.update_session_model("s1", "anthropic/claude-opus-4.8")
session = db.get_session("s1")
model_config = json.loads(session["model_config"])
assert session["model"] == "anthropic/claude-opus-4.8"
assert "browser_model_lock" not in model_config
assert model_config["_branched_from"] == "parent-session"
def test_update_session_billing_route_overwrites_after_switch(self, db):
"""A mid-session provider switch must overwrite the billing route.