From 7cd48733db464471cf7da9501c12748ed9b570dc Mon Sep 17 00:00:00 2001 From: abundantbeing Date: Fri, 24 Jul 2026 12:31:53 -0700 Subject: [PATCH] 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. --- gateway/platforms/api_server.py | 615 ++++++++++++++++-- hermes_state.py | 69 +- .../test_multiplex_api_server_routing.py | 2 + tests/gateway/test_session_api.py | 580 ++++++++++++++++- tests/test_hermes_state.py | 24 + 5 files changed, 1236 insertions(+), 54 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 15760f25f868..a1b066a85b4a 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -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 ''} " + f"model={expected_model or ''}; " + f"actual provider={actual_provider or ''} " + f"model={actual_model or ''}" + ) + 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 diff --git a/hermes_state.py b/hermes_state.py index 468d9adf8b2c..6f35d59afa47 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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, diff --git a/tests/gateway/test_multiplex_api_server_routing.py b/tests/gateway/test_multiplex_api_server_routing.py index 4c3a3979deea..2d8a17a4b430 100644 --- a/tests/gateway/test_multiplex_api_server_routing.py +++ b/tests/gateway/test_multiplex_api_server_routing.py @@ -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: diff --git a/tests/gateway/test_session_api.py b/tests/gateway/test_session_api.py index c2a98c6012fa..f9989e18e004 100644 --- a/tests/gateway/test_session_api.py +++ b/tests/gateway/test_session_api.py @@ -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", + } diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 65dd4299a201..657938e327e2 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -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.