{"repo":"NousResearch/hermes-agent","public_id":"3155","display_label":"Per-client and per-request model routing support","summary":"These issues and pull requests concern adding model routing capabilities to the API server, allowing model selection to be configured per-client via model_routes config and per-request via the request-level model field.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":57028,"merged_at":"2026-07-02T12:23:29+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-02T12:23:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":3155,"pr_number":57028,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-02T12:23:29+00:00"}],"members":[{"kind":"issue","number":3155,"title":"[Feature]: Support per-client model routing in the API server","url":null,"state":"closed"},{"kind":"pr","number":3176,"title":"feat(api-server): per-client model routing via model_routes config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57028","number":57028}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10773,"title":"feat(api-server): honor request-level model field for per-request model selection","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#31139","number":31139},{"kind":"pr","value":"#27678","number":27678},{"kind":"pr","value":"#10773","number":10773}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":57028,"title":"feat(api-server): per-client model routing via model_routes (#3176 salvage)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli-config.yaml.example","gateway/platforms/api_server.py","scripts/release.py","tests/gateway/test_api_server.py"],"fix_hunks":[{"file":"cli-config.yaml.example","hunks":["@@ -571,6 +571,41 @@ max_concurrent_sessions: null\n # explicitly want one shared \"room brain\" per group/channel.\n group_sessions_per_user: true\n \n+# ─────────────────────────────────────────────────────────────────────────────\n+# API Server — per-client model routing\n+# ─────────────────────────────────────────────────────────────────────────────\n+# Route different API clients to different models/providers on a single\n+# Hermes deployment.  Clients choose a backend by sending a specific string\n+# as the OpenAI ``model`` field.  Unmapped model values fall back to the\n+# global model configured in the ``model:`` section above, and an explicit\n+# session /model override always wins over a route.\n+#\n+# Configure via the ``platforms.api_server.extra.model_routes`` gateway\n+# config block:\n+#\n+#   platforms:\n+#     api_server:\n+#       enabled: true\n+#       extra:\n+#         key: \"your-api-server-secret\"\n+#         model_routes:\n+#           # Xiaozhi clients send model=\"minimax-m2\" → routed to MiniMax via OpenRouter\n+#           minimax-m2:\n+#             model: \"minimax/minimax-m1\"\n+#             provider: \"openrouter\"          # optional — overrides global provider\n+#             # api_key: \"[redacted:secret-assignment]\"             # optional — per-route UPSTREAM provider\n+#             #                                 key (NOT caller auth; never logged)\n+#             # base_url: \"https://...\"       # optional — per-route base URL\n+#           # GPT clients keep their own alias\n+#           gpt-5:\n+#             model: \"openai/gpt-5\"\n+#             provider: \"openrouter\"\n+#\n+# Configured aliases are automatically listed by GET /v1/models so clients\n+# can discover them without manual coordination.  Caller authentication is\n+# unchanged: every request still authenticates with the global API server\n+# key (``extra.key`` / API_SERVER_KEY).\n+\n # ─────────────────────────────────────────────────────────────────────────────\n # Gateway Streaming\n # ─────────────────────────────────────────────────────────────────────────────"]},{"file":"gateway/platforms/api_server.py","hunks":["@@ -6,7 +6,7 @@\n - POST /v1/responses               — OpenAI Responses API format (stateful via previous_response_id; X-Hermes-Session-Key supported)\n - GET  /v1/responses/{response_id} — Retrieve a stored response\n - DELETE /v1/responses/{response_id} — Delete a stored response\n-- GET  /v1/models                  — lists hermes-agent as an available model\n+- GET  /v1/models                  — lists hermes-agent and any configured model_routes aliases\n - GET  /v1/capabilities            — machine-readable API capabilities for external UIs\n - GET  /api/sessions               — list client-visible Hermes sessions\n - POST /api/sessions               — create an empty Hermes session","@@ -832,6 +832,22 @@ def __init__(self, config: PlatformConfig):\n         self._model_name: str = self._resolve_model_name(\n             extra.get(\"model_name\", os.getenv(\"API_SERVER_MODEL_NAME\", \"\")),\n         )\n+        # model_routes: maps incoming ``model`` field values to specific\n+        # provider/model configs so one API server instance can serve\n+        # multiple clients on different backends.\n+        #\n+        # Config format (platforms.api_server.extra in the gateway config):\n+        #   model_routes:\n+        #     minimax-m2:          # alias the client sends as the \"model\" field\n+        #       model: \"minimax/minimax-m1\"\n+        #       provider: \"openrouter\"   # optional — resolved via the provider\n+        #                                # credential chain when set\n+        #       api_key: \"sk-…\"          # optional — per-route UPSTREAM provider\n+        #                                # key override (NOT caller auth; never logged)\n+        #       base_url: \"https://…\"    # optional — per-route base URL override\n+        self._model_routes: Dict[str, Dict[str, Any]] = self._parse_model_routes(\n+            extra.get(\"model_routes\"),\n+        )\n         self._app: Optional[\"web.Application\"] = None\n         self._runner: Optional[\"web.AppRunner\"] = None\n         self._site: Optional[\"web.TCPSite\"] = None","@@ -1118,6 +1134,78 @@ def _ensure_session_db(self):\n     # Agent creation helper\n     # ------------------------------------------------------------------\n \n+    @staticmethod\n+    def _parse_model_routes(raw: Any) -> Dict[str, Dict[str, Any]]:\n+        \"\"\"Validate and normalize the ``model_routes`` config block.\n+\n+        Accepts a mapping of ``alias -> {model, provider?, api_key?, base_url?}``.\n+        Invalid shapes are dropped (never raised) so a config typo can't take\n+        the whole API server down.  Route values are coerced to strings.\n+\n+        Security: per-route ``api_key`` values are UPSTREAM provider\n+        credentials (used to call the routed model's backend), not caller\n+        authentication — callers still authenticate with the global\n+        API_SERVER_KEY bearer token via ``_check_auth``.  Route api_keys must\n+        never be logged; only alias names and non-secret fields may appear in\n+        logs.\n+        \"\"\"\n+        if not isinstance(raw, dict):\n+            if raw:\n+                logger.warning(\n+                    \"api_server model_routes ignored: expected a mapping, got %s\",\n+                    type(raw).__name__,\n+                )\n+            return {}\n+\n+        allowed_keys = (\"model\", \"provider\", \"api_key\", \"base_url\")\n+        routes: Dict[str, Dict[str, Any]] = {}\n+        for alias, cfg in raw.items():\n+            alias_str = str(alias).strip()\n+            if not alias_str or not isinstance(cfg, dict):\n+                logger.warning(\n+                    \"api_server model_routes: dropping invalid route entry %r\", alias_str or alias\n+                )\n+                continue\n+            route = {\n+                key: str(cfg[key]).strip()\n+                for key in allowed_keys\n+                if cfg.get(key) is not None and str(cfg[key]).strip()\n+            }\n+            if not route.get(\"model\"):\n+                logger.warning(\n+                    \"api_server model_routes: route %r has no 'model'; dropping\", alias_str\n+                )\n+                continue\n+            routes[alias_str] = route\n+        return routes\n+\n+    def _resolve_route(self, model_alias: Any) -> Optional[Dict[str, Any]]:\n+        \"\"\"Return the model_routes entry for *model_alias*, or None.\"\"\"\n+        if not self._model_routes or not isinstance(model_alias, str):\n+            return None\n+        return self._model_routes.get(model_alias)\n+\n+    def _session_model_override_for(self, session_key: Optional[str]) -> Optional[Dict[str, Any]]:\n+        \"\"\"Return the gateway's session ``/model`` override for *session_key*, if any.\n+\n+        The gateway tracks per-session ``/model`` switches in\n+        ``GatewayRunner._session_model_overrides``.  API-server requests that\n+        share such a session key must keep honouring the explicit session\n+        override even when the request's ``model`` field matches a configured\n+        route — a user-issued ``/model`` always wins over static config.\n+        \"\"\"\n+        if not session_key:\n+            return None\n+        try:\n+            from gateway.run import _gateway_runner_ref\n+            runner = _gateway_runner_ref()\n+            if runner is None:\n+                return None\n+            override = runner._session_model_overrides.get(session_key)\n+            return dict(override) if isinstance(override, dict) else None\n+        except Exception:\n+            return None\n+\n     def _create_agent(\n         self,\n         ephemeral_system_prompt: Optional[str] = None,","@@ -1127,6 +1215,7 @@ def _create_agent(\n         tool_start_callback=None,\n         tool_complete_callback=None,\n         gateway_session_key: Optional[str] = None,\n+        route: Optional[Dict[str, Any]] = None,\n     ) -> Any:\n         \"\"\"\n         Create an AIAgent instance using the gateway's runtime config.","@@ -1142,6 +1231,11 @@ def _create_agent(\n         key is meant to persist across transcripts so long-term memory\n         providers (e.g. Honcho) can scope their per-chat state correctly\n         — matching the semantics of the native gateway's ``session_key``.\n+\n+        ``route`` is an optional ``model_routes`` entry (per-client model\n+        routing).  When set — and no session ``/model`` override exists for\n+        this session — its model/provider/api_key/base_url override the\n+        global defaults for this agent instance only.\n         \"\"\"\n         from run_agent import AIAgent\n         from gateway.run import (","@@ -1169,6 +1263,51 @@ def _create_agent(\n         if runtime_model:\n             model = runtime_model\n \n+        # Per-client model routing (model_routes config).  The route was\n+        # resolved from the request's ``model`` field by the HTTP handler.\n+        # Precedence (highest first): session ``/model`` override → model_routes\n+        # route → global config — an explicit user-issued ``/model`` on the\n+        # session always beats static per-client route config.\n+        session_override = self._session_model_override_for(\n+            gateway_session_key or session_id\n+        )\n+        if route and not session_override:\n+            if route.get(\"provider\"):\n+                # Resolve real credentials for the routed provider (mirrors\n+                # the channel_overrides path in gateway/run.py) so a route\n+                # without an explicit api_key/base_url still gets the right\n+                # provider auth instead of the default provider's key.\n+                try:\n+                    from gateway.run import _resolve_runtime_agent_kwargs_for_provider\n+                    provider_kwargs = _resolve_runtime_agent_kwargs_for_provider(\n+                        route[\"provider\"]\n+                    )\n+                    provider_kwargs.pop(\"model\", None)\n+                    runtime_kwargs.update(provider_kwargs)\n+                except Exception:\n+                    # Fall back to just switching the provider name; explicit\n+                    # per-route api_key/base_url below can still complete auth.\n+                    runtime_kwargs[\"provider\"] = route[\"provider\"]\n+            if route.get(\"model\"):\n+                model = route[\"model\"]\n+            # Per-route secrets are upstream provider credentials. Never log\n+            # them (compare _check_auth: caller auth stays the global bearer\n+            # key checked with hmac.compare_digest).\n+            if route.get(\"api_key\"):\n+                runtime_kwargs[\"api_key\"] = route[\"api_key\"]\n+            if route.get(\"base_url\"):\n+                runtime_kwargs[\"base_url\"] = route[\"base_url\"]\n+            logger.debug(\n+                \"api_server model route applied: model=%s provider=%s\",\n+                model,\n+                runtime_kwargs.get(\"provider\"),\n+            )\n+        elif route and session_override:\n+            logger.debug(\n+                \"api_server model route skipped: session /model override wins for %s\",\n+                gateway_session_key or session_id,\n+            )\n+\n         user_config = _load_gateway_config()\n         enabled_toolsets = sorted(_get_platform_tools(user_config, \"api_server\"))\n ","@@ -1255,25 +1394,40 @@ async def _handle_health_detailed(self, request: \"web.Request\") -> \"web.Response\n         })\n \n     async def _handle_models(self, request: \"web.Request\") -> \"web.Response\":\n-        \"\"\"GET /v1/models — return hermes-agent as an available model.\"\"\"\n+        \"\"\"GET /v1/models — list hermes-agent and any configured model_routes aliases.\"\"\"\n         auth_err = self._check_auth(request)\n         if auth_err:\n             return auth_err\n \n-        return web.json_response({\n-            \"object\": \"list\",\n-            \"data\": [\n-                {\n-                    \"id\": self._model_name,\n-                    \"object\": \"model\",\n-                    \"created\": int(time.time()),\n-                    \"owned_by\": \"hermes\",\n-                    \"permission\": [],\n-                    \"root\": self._model_name,\n-                    \"parent\": None,\n-                }\n-            ],\n-        })\n+        now = int(time.time())\n+        models = [\n+            {\n+                \"id\": self._model_name,\n+                \"object\": \"model\",\n+                \"created\": now,\n+                \"owned_by\": \"hermes\",\n+                \"permission\": [],\n+                \"root\": self._model_name,\n+                \"parent\": None,\n+            }\n+        ]\n+        # Expose configured model route aliases so clients can discover them.\n+        # Only the alias and resolved model name are exposed — never provider\n+        # credentials.\n+        for alias, route_cfg in self._model_routes.items():\n+            if alias == self._model_name:\n+                continue  # already listed above\n+            models.append({\n+                \"id\": alias,\n+                \"object\": \"model\",\n+                \"created\": now,\n+                \"owned_by\": \"hermes\",\n+                \"permission\": [],\n+                \"root\": route_cfg.get(\"model\", alias),\n+                \"parent\": self._model_name,\n+            })\n+\n+        return web.json_response({\"object\": \"list\", \"data\": models})\n \n     async def _handle_capabilities(self, request: \"web.Request\") -> \"web.Response\":\n         \"\"\"GET /v1/capabilities — advertise the stable API surface.","@@ -2012,6 +2166,11 @@ async def _handle_chat_completions(self, request: \"web.Request\") -> \"web.Respons\n         model_name = body.get(\"model\", self._model_name)\n         created = int(time.time())\n \n+        # Per-client model routing: if the requested model matches a\n+        # configured model_routes alias, this request's agent is created\n+        # with that route's model/provider instead of the global default.\n+        route = self._resolve_route(model_name)\n+\n         if stream:\n             import queue as _q\n             _stream_q: _q.Queue = _q.Queue()","@@ -2094,6 +2253,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul\n                 tool_complete_callback=_on_tool_complete,\n                 agent_ref=agent_ref,\n                 gateway_session_key=gateway_session_key,\n+                route=route,\n             ))\n             # Ensure SSE drain loops can terminate without relying on polling\n             # agent_task.done(), which can race with queue timeout checks.","@@ -2113,6 +2273,7 @@ async def _compute_completion():\n                 ephemeral_system_prompt=system_prompt,\n                 session_id=session_id,\n                 gateway_session_key=gateway_session_key,\n+                route=route,\n             )\n \n         idempotency_key = request.headers.get(\"Idempotency-Key\")","@@ -3123,6 +3284,9 @@ async def _handle_responses(self, request: \"web.Request\") -> \"web.Response\":\n         # groups the entire conversation under one session entry.\n         session_id = stored_session_id or str(uuid.uuid4())\n \n+        # Per-client model routing for /v1/responses (see model_routes).\n+        route = self._resolve_route(body.get(\"model\"))\n+\n         stream = _coerce_request_bool(body.get(\"stream\"), default=False)\n         if stream:\n             # Streaming branch — emit OpenAI Responses SSE events as the","@@ -3176,6 +3340,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul\n                 tool_complete_callback=_on_tool_complete,\n                 agent_ref=agent_ref,\n                 gateway_session_key=gateway_session_key,\n+                route=route,\n             ))\n             # Ensure SSE drain loops can terminate without relying on polling\n             # agent_task.done(), which can race with queue timeout checks.","@@ -3209,6 +3374,7 @@ async def _compute_response():\n                 ephemeral_system_prompt=instructions,\n                 session_id=session_id,\n                 gateway_session_key=gateway_session_key,\n+                route=route,\n             )\n \n         idempotency_key = request.headers.get(\"Idempotency-Key\")","@@ -3839,6 +4005,7 @@ async def _run_agent(\n         tool_complete_callback=None,\n         agent_ref: Optional[list] = None,\n         gateway_session_key: Optional[str] = None,\n+        route: Optional[Dict[str, Any]] = None,\n     ) -> tuple:\n         \"\"\"\n         Create an agent and run a conversation in a thread executor.","@@ -3846,6 +4013,10 @@ async def _run_agent(\n         Returns ``(result_dict, usage_dict)`` where *usage_dict* contains\n         ``input_tokens``, ``output_tokens`` and ``total_tokens``.\n \n+        *route* is an optional ``model_routes`` entry (resolved from the\n+        request's ``model`` field) that overrides the global model/provider\n+        for this specific request.\n+\n         If *agent_ref* is a one-element list, the AIAgent instance is stored\n         at ``agent_ref[0]`` before ``run_conversation`` begins.  This allows\n         callers (e.g. the SSE writer) to call ``agent.interrupt()`` from","@@ -3870,6 +4041,7 @@ def _run():\n                     tool_start_callback=tool_start_callback,\n                     tool_complete_callback=tool_complete_callback,\n                     gateway_session_key=gateway_session_key,\n+                    route=route,\n                 )\n                 if agent_ref is not None:\n                     agent_ref[0] = agent","@@ -4085,6 +4257,9 @@ def _text_cb(delta: Optional[str]) -> None:\n             model=body.get(\"model\", self._model_name),\n         )\n \n+        # Per-client model routing for /v1/runs (see model_routes).\n+        route = self._resolve_route(body.get(\"model\"))\n+\n         async def _run_and_close():\n             try:\n                 self._set_run_status(run_id, \"running\")","@@ -4094,6 +4269,7 @@ async def _run_and_close():\n                     stream_delta_callback=_text_cb,\n                     tool_progress_callback=event_cb,\n                     gateway_session_key=gateway_session_key,\n+                    route=route,\n                 )\n                 self._active_run_agents[run_id] = agent\n "]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,7 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"Mibayy\",  # PR #3243 salvage (/compact alias + preview/aggressive flags for /compress)\n+    \"[redacted:email]\": \"Mibayy\",  # PR #3176 salvage (api-server: per-client model routing via model_routes)\n     \"[redacted:email]\": \"CrazyBoyM\",  # PR #55828 salvage (image_gen openai-codex: wire image-to-image / reference-image editing via Codex Responses input_image parts; magic-byte + read-guard + 25MB-cap + clamp-to-16 hardening)\n     \"r0gersm1th@users.noreply.github.com\": \"r0gersm1th\",  # PR #3219 salvage (whatsapp bridge: resolve LID sender IDs to phone numbers in the message payload so phone-based allowlists match; commit authored by collaborator r0gersm1th, PR by @ajmeese7)\n     \"[redacted:email]\": \"Mibayy\",  # PR #3296 salvage (status: provider label honors config.yaml model.base_url, not just OPENAI_BASE_URL env)"]},{"file":"tests/gateway/test_api_server.py","hunks":["@@ -3798,3 +3798,278 @@ async def test_capabilities_advertises_session_key_header(self, adapter):\n             assert resp.status == 200\n             data = await resp.json()\n             assert data[\"features\"][\"session_key_header\"] == \"X-Hermes-Session-Key\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Per-client model routing (model_routes)\n+# ---------------------------------------------------------------------------\n+\n+\n+def _make_routing_adapter(routes) -> APIServerAdapter:\n+    \"\"\"Create an adapter with model_routes configured.\"\"\"\n+    config = PlatformConfig(enabled=True, extra={\"model_routes\": routes})\n+    return APIServerAdapter(config)\n+\n+\n+def _patch_create_agent_runtime(monkeypatch, captured: dict, fake_agent_cls):\n+    \"\"\"Stub out every external dependency of _create_agent.\"\"\"\n+    monkeypatch.setattr(\"run_agent.AIAgent\", fake_agent_cls)\n+    monkeypatch.setattr(\n+        \"gateway.run._resolve_runtime_agent_kwargs\",\n+        lambda: {\n+            \"provider\": \"openrouter\",\n+            \"api_key\": \"[redacted:secret-assignment]\",\n+            \"base_url\": \"https://openrouter.ai/api/v1\",\n+            \"api_mode\": \"chat_completions\",\n+        },\n+    )\n+    monkeypatch.setattr(\"gateway.run._resolve_gateway_model\", lambda: \"global/model\")\n+    monkeypatch.setattr(\"gateway.run._load_gateway_config\", lambda: {})\n+    monkeypatch.setattr(\n+        \"gateway.run.GatewayRunner._load_reasoning_config\", staticmethod(lambda: {})\n+    )\n+    monkeypatch.setattr(\n+        \"gateway.run.GatewayRunner._load_fallback_model\", staticmethod(lambda: None)\n+    )\n+    monkeypatch.setattr(\"gateway.run._current_max_iterations\", lambda: 90)\n+    monkeypatch.setattr(\"hermes_cli.tools_config._get_platform_tools\", lambda *_: set())\n+\n+\n+class TestModelRoutesParsing:\n+    def test_valid_routes_are_parsed(self):\n+        routes = {\"minimax-m2\": {\"model\": \"minimax/minimax-m1\", \"provider\": \"openrouter\"}}\n+        adapter = _make_routing_adapter(routes)\n+        assert adapter._model_routes == routes\n+\n+    def test_non_dict_routes_config_is_ignored(self):\n+        adapter = _make_routing_adapter(\"not-a-dict\")\n+        assert adapter._model_routes == {}\n+\n+    def test_route_without_model_is_dropped(self):\n+        adapter = _make_routing_adapter({\"bad\": {\"provider\": \"openrouter\"}})\n+        assert adapter._model_routes == {}\n+\n+    def test_route_with_non_dict_value_is_dropped(self):\n+        adapter = _make_routing_adapter({\"bad\": \"gpt-5\", \"good\": {\"model\": \"openai/gpt-5\"}})\n+        assert set(adapter._model_routes) == {\"good\"}\n+\n+    def test_unknown_route_keys_are_stripped(self):\n+        adapter = _make_routing_adapter(\n+            {\"a\": {\"model\": \"m\", \"provider\": \"p\", \"evil_extra\": \"x\"}}\n+        )\n+        assert adapter._model_routes[\"a\"] == {\"model\": \"m\", \"provider\": \"p\"}\n+\n+    def test_resolve_route_lookup(self):\n+        adapter = _make_routing_adapter({\"minimax-m2\": {\"model\": \"minimax/minimax-m1\"}})\n+        assert adapter._resolve_route(\"minimax-m2\") == {\"model\": \"minimax/minimax-m1\"}\n+        assert adapter._resolve_route(\"unknown-model\") is None\n+        assert adapter._resolve_route(None) is None\n+        assert adapter._resolve_route(123) is None\n+\n+    def test_no_routes_configured(self):\n+        adapter = _make_routing_adapter({})\n+        assert adapter._resolve_route(\"hermes-agent\") is None\n+\n+\n+class TestModelRoutesModelsEndpoint:\n+    @pytest.mark.asyncio\n+    async def test_models_endpoint_lists_route_aliases(self):\n+        routes = {\n+            \"minimax-m2\": {\"model\": \"minimax/minimax-m1\", \"provider\": \"openrouter\"},\n+            \"gpt-5\": {\"model\": \"openai/gpt-5\"},\n+        }\n+        adapter = _make_routing_adapter(routes)\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.get(\"/v1/models\")\n+            assert resp.status == 200\n+            data = await resp.json()\n+            ids = {m[\"id\"] for m in data[\"data\"]}\n+            assert adapter._model_name in ids\n+            assert \"minimax-m2\" in ids\n+            assert \"gpt-5\" in ids\n+\n+    @pytest.mark.asyncio\n+    async def test_models_endpoint_route_alias_fields_and_no_secrets(self):\n+        routes = {\"my-alias\": {\"model\": \"openai/gpt-5\", \"api_key\": \"[redacted:secret-assignment]\"}}\n+        adapter = _make_routing_adapter(routes)\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.get(\"/v1/models\")\n+            data = await resp.json()\n+            alias_entry = next(m for m in data[\"data\"] if m[\"id\"] == \"my-alias\")\n+            assert alias_entry[\"root\"] == \"openai/gpt-5\"\n+            assert alias_entry[\"parent\"] == adapter._model_name\n+            # per-route api_key must never leak through the discovery endpoint\n+            assert \"sk-route-secret\" not in json.dumps(data)\n+\n+\n+class TestModelRoutesHandlers:\n+    @pytest.mark.asyncio\n+    async def test_chat_completions_passes_route_to_run_agent(self):\n+        routes = {\"minimax-m2\": {\"model\": \"minimax/minimax-m1\", \"provider\": \"openrouter\"}}\n+        adapter = _make_routing_adapter(routes)\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (\n+                    {\"final_response\": \"hi\", \"messages\": [], \"api_calls\": 1},\n+                    {\"input_tokens\": 5, \"output_tokens\": 5, \"total_tokens\": 10},\n+                )\n+                resp = await cli.post(\"/v1/chat/completions\", json={\n+                    \"model\": \"minimax-m2\",\n+                    \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}],\n+                })\n+                assert resp.status == 200\n+                kwargs = mock_run.call_args.kwargs\n+                assert kwargs.get(\"route\") == {\n+                    \"model\": \"minimax/minimax-m1\", \"provider\": \"openrouter\",\n+                }\n+\n+    @pytest.mark.asyncio\n+    async def test_chat_completions_no_route_for_unknown_model(self):\n+        adapter = _make_routing_adapter({\"minimax-m2\": {\"model\": \"minimax/minimax-m1\"}})\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (\n+                    {\"final_response\": \"hi\", \"messages\": [], \"api_calls\": 1},\n+                    {\"input_tokens\": 5, \"output_tokens\": 5, \"total_tokens\": 10},\n+                )\n+                resp = await cli.post(\"/v1/chat/completions\", json={\n+                    \"model\": \"unknown-model\",\n+                    \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}],\n+                })\n+                assert resp.status == 200\n+                assert mock_run.call_args.kwargs.get(\"route\") is None\n+\n+    @pytest.mark.asyncio\n+    async def test_responses_api_passes_route_to_run_agent(self):\n+        routes = {\"xiaozhi\": {\"model\": \"minimax/minimax-m1\", \"provider\": \"openrouter\"}}\n+        adapter = _make_routing_adapter(routes)\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (\n+                    {\"final_response\": \"hi\", \"messages\": [], \"api_calls\": 1},\n+                    {\"input_tokens\": 5, \"output_tokens\": 5, \"total_tokens\": 10},\n+                )\n+                resp = await cli.post(\"/v1/responses\", json={\n+                    \"model\": \"xiaozhi\",\n+                    \"input\": \"hello\",\n+                })\n+                assert resp.status == 200\n+                assert mock_run.call_args.kwargs.get(\"route\") == {\n+                    \"model\": \"minimax/minimax-m1\", \"provider\": \"openrouter\",\n+                }\n+\n+\n+class TestModelRoutesAgentCreation:\n+    def test_route_overrides_model_and_credentials(self, monkeypatch):\n+        captured = {}\n+\n+        class FakeAgent:\n+            def __init__(self, **kwargs):\n+                captured.update(kwargs)\n+\n+        _patch_create_agent_runtime(monkeypatch, captured, FakeAgent)\n+        adapter = _make_routing_adapter(\n+            {\"alias\": {\n+                \"model\": \"minimax/minimax-m1\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+                \"base_url\": \"https://route.example/v1\",\n+            }}\n+        )\n+        monkeypatch.setattr(adapter, \"_ensure_session_db\", lambda: None)\n+        monkeypatch.setattr(adapter, \"_session_model_override_for\", lambda *_: None)\n+\n+        agent = adapter._create_agent(\n+            session_id=\"s1\", route=adapter._resolve_route(\"alias\")\n+        )\n+\n+        assert isinstance(agent, FakeAgent)\n+        assert captured[\"model\"] == \"minimax/minimax-m1\"\n+        assert captured[\"api_key\"] == \"sk-route\"\n+        assert captured[\"base_url\"] == \"https://route.example/v1\"\n+\n+    def test_route_provider_resolves_provider_credentials(self, monkeypatch):\n+        captured = {}\n+\n+        class FakeAgent:\n+            def __init__(self, **kwargs):\n+                captured.update(kwargs)\n+\n+        _patch_create_agent_runtime(monkeypatch, captured, FakeAgent)\n+        monkeypatch.setattr(\n+            \"gateway.run._resolve_runtime_agent_kwargs_for_provider\",\n+            lambda provider: {\n+                \"provider\": provider,\n+                \"api_key\": f\"sk-{provider}\",\n+                \"base_url\": f\"https://{provider}.example/v1\",\n+                \"api_mode\": \"chat_completions\",\n+            },\n+        )\n+        adapter = _make_routing_adapter(\n+            {\"alias\": {\"model\": \"other/model\", \"provider\": \"otherprov\"}}\n+        )\n+        monkeypatch.setattr(adapter, \"_ensure_session_db\", lambda: None)\n+        monkeypatch.setattr(adapter, \"_session_model_override_for\", lambda *_: None)\n+\n+        adapter._create_agent(session_id=\"s1\", route=adapter._resolve_route(\"alias\"))\n+\n+        assert captured[\"model\"] == \"other/model\"\n+        assert captured[\"provider\"] == \"otherprov\"\n+        assert captured[\"api_key\"] == \"sk-otherprov\"\n+\n+    def test_no_route_keeps_global_model(self, monkeypatch):\n+        captured = {}\n+\n+        class FakeAgent:\n+            def __init__(self, **kwargs):\n+                captured.update(kwargs)\n+\n+        _patch_create_agent_runtime(monkeypatch, captured, FakeAgent)\n+        adapter = _make_routing_adapter({\"alias\": {\"model\": \"other/model\"}})\n+        monkeypatch.setattr(adapter, \"_ensure_session_db\", lambda: None)\n+        monkeypatch.setattr(adapter, \"_session_model_override_for\", lambda *_: None)\n+\n+        adapter._create_agent(session_id=\"s1\", route=None)\n+\n+        assert captured[\"model\"] == \"global/model\"\n+        assert captured[\"api_key\"] == \"sk-global\"\n+\n+    def test_session_model_override_beats_route(self, monkeypatch):\n+        \"\"\"A user-issued /model on the session must win over static route config.\"\"\"\n+        captured = {}\n+\n+        class FakeAgent:\n+            def __init__(self, **kwargs):\n+                captured.update(kwargs)\n+\n+        _patch_create_agent_runtime(monkeypatch, captured, FakeAgent)\n+        adapter = _make_routing_adapter({\"alias\": {\"model\": \"route/model\", \"api_key\": \"[redacted:secret-assignment]\"}})\n+        monkeypatch.setattr(adapter, \"_ensure_session_db\", lambda: None)\n+        monkeypatch.setattr(\n+            adapter,\n+            \"_session_model_override_for\",\n+            lambda key: {\"model\": \"session/override-model\"},\n+        )\n+\n+        adapter._create_agent(session_id=\"s1\", route=adapter._resolve_route(\"alias\"))\n+\n+        # The route must NOT be applied — the session override path (global\n+        # runtime here, since the gateway applies /model separately) wins.\n+        assert captured[\"model\"] == \"global/model\"\n+        assert captured[\"api_key\"] == \"sk-global\"\n+\n+    def test_session_override_lookup_reads_gateway_runner(self, monkeypatch):\n+        \"\"\"_session_model_override_for consults GatewayRunner._session_model_overrides.\"\"\"\n+        adapter = _make_routing_adapter({})\n+\n+        class FakeRunner:\n+            _session_model_overrides = {\"chan-1\": {\"model\": \"user/model\"}}\n+\n+        monkeypatch.setattr(\"gateway.run._gateway_runner_ref\", lambda: FakeRunner())\n+        assert adapter._session_model_override_for(\"chan-1\") == {\"model\": \"user/model\"}\n+        assert adapter._session_model_override_for(\"chan-2\") is None\n+        assert adapter._session_model_override_for(None) is None\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}