diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index b783f34db468..4ded157d694c 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1558,6 +1558,7 @@ def _http_route_table(self) -> List[tuple]: ("GET", "/health/detailed", self._handle_health_detailed), ("GET", "/v1/health", self._handle_health), ("GET", "/v1/models", self._handle_models), + ("GET", "/api/model/options", self._handle_model_options), ("GET", "/v1/capabilities", self._handle_capabilities), ("GET", "/v1/skills", self._handle_skills), ("GET", "/v1/toolsets", self._handle_toolsets), @@ -2070,6 +2071,43 @@ async def _handle_models(self, request: "web.Request") -> "web.Response": return web.json_response({"object": "list", "data": models}) + async def _handle_model_options(self, request: "web.Request") -> "web.Response": + """GET /api/model/options — return Hermes provider/model inventory. + + This mirrors the dashboard/TUI model picker inventory endpoint so + external clients using the API server can sync to the user's configured + Hermes provider catalog instead of scraping the single OpenAI-compatible + `/v1/models` alias. + """ + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + refresh = _coerce_request_bool(request.query.get("refresh"), default=False) + try: + from hermes_cli.inventory import build_model_options_payload, load_picker_context + + def _build_payload() -> Dict[str, Any]: + return build_model_options_payload( + load_picker_context(), + include_unconfigured=True, + refresh=refresh, + ) + + # Inventory enrichment can fetch pricing and provider catalogs. + # Keep all synchronous picker work off aiohttp's event loop. + payload = await asyncio.to_thread(_build_payload) + return web.json_response(payload) + except Exception: + logger.exception("[%s] GET /api/model/options failed", self.name) + return web.json_response( + _openai_error( + "Failed to list model options.", + code="model_options_failed", + ), + status=500, + ) + async def _handle_capabilities(self, request: "web.Request") -> "web.Response": """GET /v1/capabilities — advertise the stable API surface. @@ -2112,6 +2150,7 @@ async def _handle_capabilities(self, request: "web.Request") -> "web.Response": "tool_progress_events": True, "approval_events": True, "session_resources": True, + "model_options": True, "session_chat": True, "session_chat_streaming": True, "session_fork": True, @@ -2129,6 +2168,7 @@ async def _handle_capabilities(self, request: "web.Request") -> "web.Response": "health": {"method": "GET", "path": "/health"}, "health_detailed": {"method": "GET", "path": "/health/detailed"}, "models": {"method": "GET", "path": "/v1/models"}, + "model_options": {"method": "GET", "path": "/api/model/options"}, "chat_completions": {"method": "POST", "path": "/v1/chat/completions"}, "responses": {"method": "POST", "path": "/v1/responses"}, "runs": {"method": "POST", "path": "/v1/runs"}, diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index ff6b49ed505b..9eae39abbfce 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -263,6 +263,38 @@ def build_models_payload( } +def build_model_options_payload( + ctx: ConfigContext, + *, + explicit_only: bool = False, + include_unconfigured: bool = False, + refresh: bool = False, +) -> dict: + """Build the shared API-server/dashboard/TUI model-options payload. + + This wraps ``build_models_payload`` with the stable picker shape and the + safe custom-provider probe policy used for normal GUI/TUI opens: + + - normal open: probe only the current custom provider so offline saved + endpoints do not block the picker + - explicit refresh: probe every custom provider while busting the model + cache so live catalogs repopulate fully + """ + refresh = bool(refresh) + return build_models_payload( + ctx, + explicit_only=bool(explicit_only), + include_unconfigured=bool(include_unconfigured), + picker_hints=True, + canonical_order=True, + pricing=True, + capabilities=True, + refresh=refresh, + probe_custom_providers=refresh, + probe_current_custom_provider=not refresh, + ) + + def _apply_capabilities(rows: list[dict]) -> None: """Attach a ``{model: {fast, reasoning}}`` map to each provider row. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f73a77bb655d..565862720cf9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6574,7 +6574,7 @@ def get_model_info(profile: Optional[str] = None): @app.get("/api/model/options") -def get_model_options( +async def get_model_options( profile: Optional[str] = None, refresh: bool = False, include_unconfigured: bool = False, @@ -6596,25 +6596,21 @@ def get_model_options( Models" control. Normal opens leave it false to stay on the 1h cache. """ try: - from hermes_cli.inventory import build_models_payload, load_picker_context + from hermes_cli.inventory import build_model_options_payload, load_picker_context + + def _build_payload_scoped() -> dict: + # Keep the profile override inside the worker thread so the full + # sync picker build (config load, pricing, refresh probes) runs + # off the event loop under the requested profile. + with _profile_scope(profile): + return build_model_options_payload( + load_picker_context(), + explicit_only=bool(explicit_only), + include_unconfigured=bool(include_unconfigured), + refresh=bool(refresh), + ) - # Most desktop surfaces should only list providers the user has already - # configured. Onboarding opts into the full provider universe via - # include_unconfigured=1 so it can still render setup affordances for - # providers that are not yet authenticated. - with _profile_scope(profile): - return build_models_payload( - load_picker_context(), - explicit_only=bool(explicit_only), - include_unconfigured=bool(include_unconfigured), - picker_hints=True, - canonical_order=True, - pricing=True, - capabilities=True, - refresh=bool(refresh), - probe_custom_providers=bool(refresh), - probe_current_custom_provider=not bool(refresh), - ) + return await run_in_threadpool(_build_payload_scoped) except HTTPException: raise except Exception: diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 9a5981923553..7edbe457ba16 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -658,6 +658,7 @@ def _create_app(adapter: APIServerAdapter) -> web.Application: app.router.add_get("/health/detailed", adapter._handle_health_detailed) app.router.add_get("/v1/health", adapter._handle_health) app.router.add_get("/v1/models", adapter._handle_models) + app.router.add_get("/api/model/options", adapter._handle_model_options) app.router.add_get("/v1/capabilities", adapter._handle_capabilities) app.router.add_get("/v1/skills", adapter._handle_skills) app.router.add_get("/v1/toolsets", adapter._handle_toolsets) @@ -1016,6 +1017,61 @@ async def test_models_with_valid_auth(self, auth_adapter): ) assert resp.status == 200 + @pytest.mark.asyncio + async def test_model_options_returns_shared_inventory(self, adapter, monkeypatch): + """GET /api/model/options builds the shared picker payload off-loop.""" + from hermes_cli import inventory + + ctx = object() + payload = { + "providers": [{"slug": "nous", "name": "Nous Portal", "models": ["gpt-5.5"]}], + "model": "gpt-5.5", + "provider": "nous", + } + seen = {"thread_calls": 0} + + monkeypatch.setattr(inventory, "load_picker_context", lambda: ctx) + + def fake_build_model_options_payload(received_ctx, **kwargs): + seen["ctx"] = received_ctx + seen["kwargs"] = kwargs + return payload + + async def fake_to_thread(func, *args, **kwargs): + seen["thread_calls"] += 1 + return func(*args, **kwargs) + + monkeypatch.setattr( + inventory, + "build_model_options_payload", + fake_build_model_options_payload, + ) + monkeypatch.setattr( + "gateway.platforms.api_server.asyncio.to_thread", + fake_to_thread, + ) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/api/model/options?refresh=true") + assert resp.status == 200 + data = await resp.json() + + assert data == payload + assert seen["thread_calls"] == 1 + assert seen["ctx"] is ctx + assert seen["kwargs"] == { + "include_unconfigured": True, + "refresh": True, + } + + @pytest.mark.asyncio + async def test_model_options_requires_auth(self, auth_adapter): + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/api/model/options") + assert resp.status == 401 + # --------------------------------------------------------------------------- # /v1/capabilities endpoint @@ -1042,8 +1098,10 @@ async def test_capabilities_advertises_plugin_safe_contract(self, adapter): assert data["features"]["chat_completions"] is True assert data["features"]["run_status"] is True assert data["features"]["run_events_sse"] is True + assert data["features"]["model_options"] is True assert data["features"]["session_continuity_header"] == "X-Hermes-Session-Id" assert data["endpoints"]["run_status"]["path"] == "/v1/runs/{run_id}" + assert data["endpoints"]["model_options"] == {"method": "GET", "path": "/api/model/options"} assert data["endpoints"]["skills"] == {"method": "GET", "path": "/v1/skills"} assert data["endpoints"]["toolsets"] == {"method": "GET", "path": "/v1/toolsets"} diff --git a/tests/gateway/test_multiplex_api_server_routing.py b/tests/gateway/test_multiplex_api_server_routing.py index 5c6f7f5ee6aa..4c3a3979deea 100644 --- a/tests/gateway/test_multiplex_api_server_routing.py +++ b/tests/gateway/test_multiplex_api_server_routing.py @@ -58,15 +58,17 @@ def test_unknown_profile_rejected(self, monkeypatch): class TestApiServerRouteTable: - def test_route_table_includes_models_and_chat(self): - """ /p/{profile}/v1/models must be registered — this is the 404 Fadeway hit. """ + def test_route_table_includes_models_options_and_chat(self): + """Model discovery and chat routes must survive profile multiplexing.""" adapter = _make_adapter(multiplex=True) paths = {path for _method, path, _handler in adapter._http_route_table()} assert "/v1/models" in paths + assert "/api/model/options" in paths assert "/v1/chat/completions" 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 diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index 14141a815362..ffe2adc710da 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -340,6 +340,54 @@ def test_model_options_unknown_profile_404(self, client, isolated_profiles): resp = client.get("/api/model/options", params={"profile": "ghost"}) assert resp.status_code == 404 + def test_model_options_offloads_payload_build_to_threadpool(self, client, monkeypatch): + import hermes_cli.web_server as web_server + + calls = [] + + async def _fake_run_in_threadpool(func, *args, **kwargs): + calls.append((func, args, kwargs)) + return func(*args, **kwargs) + + monkeypatch.setattr( + web_server, + "run_in_threadpool", + _fake_run_in_threadpool, + ) + + resp = client.get("/api/model/options") + assert resp.status_code == 200 + assert len(calls) == 1 + + def test_model_options_matches_tui_safe_probe_flags(self, client, monkeypatch): + calls = [] + + monkeypatch.setattr( + "hermes_cli.inventory.load_picker_context", + lambda: object(), + ) + + def _fake_build_models_payload(_ctx, **kwargs): + calls.append(kwargs) + return {"providers": [], "model": "", "provider": ""} + + monkeypatch.setattr( + "hermes_cli.inventory.build_models_payload", + _fake_build_models_payload, + ) + + resp = client.get("/api/model/options") + assert resp.status_code == 200 + assert calls[-1]["refresh"] is False + assert calls[-1]["probe_custom_providers"] is False + assert calls[-1]["probe_current_custom_provider"] is True + + resp = client.get("/api/model/options", params={"refresh": "1"}) + assert resp.status_code == 200 + assert calls[-1]["refresh"] is True + assert calls[-1]["probe_custom_providers"] is True + assert calls[-1]["probe_current_custom_provider"] is False + def test_model_options_hides_unconfigured_providers_by_default(self, client, monkeypatch): calls = [] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 176239b2ba52..edf7c04e873f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -15055,7 +15055,7 @@ def _model_picker_context(agent): @method("model.options") def _(rid, params: dict) -> dict: try: - from hermes_cli.inventory import build_models_payload + from hermes_cli.inventory import build_model_options_payload session = _sessions.get(params.get("session_id", "")) agent = session.get("agent") if session else None @@ -15064,25 +15064,11 @@ def _(rid, params: dict) -> dict: # agent attributes must NOT clobber disk config (with_overrides # is truthy-only). ctx = _model_picker_context(agent) - # picker_hints + canonical_order produce the TUI/desktop picker shape: - # `authenticated`/`auth_type`/`key_env`/`warning` per row, in - # CANONICAL_PROVIDERS declaration order. Desktop pickers default to the - # configured subset; callers that need setup affordances can pass - # include_unconfigured=true explicitly. - # Curated model lists are preserved — list_authenticated_providers - # populates `models` from the curated catalog, not provider_model_ids - # (which would pull non-agentic models like TTS/embeddings/etc.). - payload = build_models_payload( + payload = build_model_options_payload( ctx, explicit_only=bool(params.get("explicit_only")), include_unconfigured=bool(params.get("include_unconfigured")), - picker_hints=True, - canonical_order=True, - pricing=True, - capabilities=True, refresh=bool(params.get("refresh")), - probe_custom_providers=bool(params.get("refresh")), - probe_current_custom_provider=not bool(params.get("refresh")), ) return _ok(rid, payload) except Exception as e: diff --git a/website/docs/developer-guide/programmatic-integration.md b/website/docs/developer-guide/programmatic-integration.md index 39a3bae2ff48..0feeb3b508ed 100644 --- a/website/docs/developer-guide/programmatic-integration.md +++ b/website/docs/developer-guide/programmatic-integration.md @@ -95,11 +95,36 @@ POST /v1/runs/{id}/approval Resolve a pending approval POST /v1/runs/{id}/stop Interrupt the run GET /v1/capabilities Machine-readable feature flags GET /v1/models Lists hermes-agent +GET /api/model/options Provider-aware picker inventory GET /health, /health/detailed ``` Setup, headers (`X-Hermes-Session-Id`, `X-Hermes-Session-Key`), and frontend wiring: [API Server](../user-guide/features/api-server). +### Model catalog surfaces + +The OpenAI-compatible API intentionally keeps `GET /v1/models` minimal: it is +the compatibility endpoint frontends expect, not the full Hermes provider/model +picker catalog. + +If an external control plane needs Hermes' curated provider rows, per-model +pricing, or capability hints, use one of the authenticated picker surfaces: + +- API server REST: `GET /api/model/options` with the API-server bearer key +- Dashboard backend REST: `GET /api/model/options` with `X-Hermes-Session-Token` +- TUI gateway RPC: `model.options` + +Those surfaces share the same payload builder and the same custom-provider +probe policy: + +- Normal open: probe only the current custom provider so offline saved + endpoints do not stall the picker. +- Explicit refresh (`refresh=1` or `refresh: true`): bust the provider-model + cache and probe all saved custom providers so live catalogs repopulate fully. + +Use `/v1/models` for OpenAI-client compatibility. Use `/api/model/options` or +`model.options` when you are building a Hermes-aware model picker. + --- ## Which one should I use? diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index cbcb1f954d5b..987bd9bafbad 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -198,6 +198,42 @@ Delete a stored response. Lists the agent as an available model. The advertised model name defaults to the [profile](/user-guide/profiles) name (or `hermes-agent` for the default profile). Required by most frontends for model discovery. +`/v1/models` is intentionally the cheap OpenAI-compat surface. It does **not** +enumerate every authenticated provider/model combination Hermes can route to, +and it does not do pricing or capability enrichment. + +### GET /api/model/options + +Hermes-aware clients can request the same curated provider/model inventory used +by the dashboard and TUI. This route uses the API server's normal bearer +authentication and returns provider rows, model capability hints, and pricing +metadata that do not belong in the OpenAI-compatible `/v1/models` response: + +```bash +curl \ + -H "Authorization: Bearer $API_SERVER_KEY" \ + "http://127.0.0.1:8642/api/model/options" +``` + +That payload is the same substrate the dashboard Models page and the TUI +`model.options` RPC use. It returns authenticated providers, curated model +lists, per-model pricing, and model capability hints. + +Normal opens are intentionally conservative for custom providers: Hermes probes +only the **currently selected** custom endpoint so a stale or offline saved +endpoint does not block the picker. An explicit refresh flips to full probing +and busts the provider model cache: + +```bash +curl \ + -H "Authorization: Bearer $API_SERVER_KEY" \ + "http://127.0.0.1:8642/api/model/options?refresh=1" +``` + +Use `/v1/models` when an OpenAI-compatible client only needs a model name to +send back in chat/responses requests. Use `/api/model/options` when an +authenticated UI needs the richer Hermes-specific picker metadata. + ### GET /v1/capabilities Returns a machine-readable description of the API server's stable surface for external UIs, orchestrators, and plugin bridges.