Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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"},
Expand Down
32 changes: 32 additions & 0 deletions hermes_cli/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
34 changes: 15 additions & 19 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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"}

Expand Down
6 changes: 4 additions & 2 deletions tests/gateway/test_multiplex_api_server_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
48 changes: 48 additions & 0 deletions tests/hermes_cli/test_web_server_profile_unification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down
18 changes: 2 additions & 16 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions website/docs/developer-guide/programmatic-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading