From 4f2d4c91fa5565767ee17a072b7f2850a8c5643e Mon Sep 17 00:00:00 2001 From: abundantbeing Date: Sun, 28 Jun 2026 16:35:52 -0400 Subject: [PATCH 1/4] feat(api): expose provider-aware model options --- gateway/platforms/api_server.py | 36 +++++++++++++++++++ tests/gateway/test_api_server.py | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 2e7361c18b438..1f99b05bdb148 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1201,6 +1201,39 @@ async def _handle_models(self, request: "web.Request") -> "web.Response": ], }) + async def _handle_model_options(self, request: "web.Request") -> "web.Response": + """GET /api/model/options — provider-aware Hermes model inventory. + + Keep /v1/models OpenAI-compatible by advertising the synthetic Hermes + alias there. Browser and dashboard clients that need the real provider + registry use this route, which mirrors the TUI/dashboard picker payload. + """ + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + try: + from hermes_cli.inventory import build_models_payload, load_picker_context + + refresh = str(request.query.get("refresh", "")).lower() in {"1", "true", "yes", "on"} + payload = build_models_payload( + load_picker_context(), + include_unconfigured=True, + picker_hints=True, + canonical_order=True, + pricing=False, + capabilities=True, + refresh=refresh, + ) + payload.setdefault("object", "hermes.model_options") + return web.json_response(payload) + except Exception: + logger.exception("GET /api/model/options failed") + return web.json_response( + _openai_error("Failed to enumerate model options", err_type="server_error"), + status=500, + ) + async def _handle_capabilities(self, request: "web.Request") -> "web.Response": """GET /v1/capabilities — advertise the stable API surface. @@ -1250,6 +1283,7 @@ async def _handle_capabilities(self, request: "web.Request") -> "web.Response": "jobs_admin": False, "memory_write_api": False, "skills_api": True, + "model_options_api": True, "audio_api": False, "realtime_voice": False, "session_continuity_header": "X-Hermes-Session-Id", @@ -1260,6 +1294,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"}, @@ -4386,6 +4421,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self._app.router.add_get("/health/detailed", self._handle_health_detailed) self._app.router.add_get("/v1/health", self._handle_health) self._app.router.add_get("/v1/models", self._handle_models) + self._app.router.add_get("/api/model/options", self._handle_model_options) self._app.router.add_get("/v1/capabilities", self._handle_capabilities) self._app.router.add_get("/v1/skills", self._handle_skills) self._app.router.add_get("/v1/toolsets", self._handle_toolsets) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index a941d4afc9349..5013ff3815ded 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -502,6 +502,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) @@ -744,6 +745,65 @@ async def test_models_with_valid_auth(self, auth_adapter): assert resp.status == 200 + @pytest.mark.asyncio + async def test_model_options_returns_provider_inventory(self, adapter): + fake_payload = { + "providers": [ + {"slug": "openai-codex", "name": "OpenAI Codex", "models": ["gpt-5.5"], "authenticated": True}, + ], + "provider": "openai-codex", + "model": "gpt-5.5", + } + with patch("hermes_cli.inventory.load_picker_context", return_value=MagicMock()), patch( + "hermes_cli.inventory.build_models_payload", + return_value=dict(fake_payload), + ) as build_payload: + 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["object"] == "hermes.model_options" + assert data["providers"] == fake_payload["providers"] + assert data["provider"] == "openai-codex" + assert data["model"] == "gpt-5.5" + build_payload.assert_called_once() + kwargs = build_payload.call_args.kwargs + assert kwargs["include_unconfigured"] is True + assert kwargs["picker_hints"] is True + assert kwargs["canonical_order"] is True + assert kwargs["capabilities"] is True + assert kwargs["refresh"] is 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 + + with patch("hermes_cli.inventory.load_picker_context", return_value=MagicMock()), patch( + "hermes_cli.inventory.build_models_payload", + return_value={"providers": [], "provider": "", "model": ""}, + ): + authed = await cli.get( + "/api/model/options", + headers={"Authorization": "Bearer sk-secret"}, + ) + assert authed.status == 200 + + @pytest.mark.asyncio + async def test_model_options_handles_inventory_failure(self, adapter): + with patch("hermes_cli.inventory.load_picker_context", side_effect=RuntimeError("boom")): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/api/model/options") + assert resp.status == 500 + data = await resp.json() + assert "error" in data + + # --------------------------------------------------------------------------- # /v1/capabilities endpoint # --------------------------------------------------------------------------- @@ -770,7 +830,9 @@ async def test_capabilities_advertises_plugin_safe_contract(self, adapter): assert data["features"]["run_status"] is True assert data["features"]["run_events_sse"] is True assert data["features"]["session_continuity_header"] == "X-Hermes-Session-Id" + assert data["features"]["model_options_api"] is True 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"} From 27f6ba8fe902f2eb23a68f355110effa604d6924 Mon Sep 17 00:00:00 2001 From: abundantbeing Date: Sun, 28 Jun 2026 17:43:03 -0400 Subject: [PATCH 2/4] fix: honor API request model overrides --- gateway/platforms/api_server.py | 215 +++++++++++++++++++++++++++--- tests/gateway/test_api_server.py | 220 ++++++++++++++++++++++++++++++- 2 files changed, 414 insertions(+), 21 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 1f99b05bdb148..10cfd073ba886 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -131,6 +131,131 @@ def _coerce_request_bool(value: Any, default: bool = False) -> bool: return default +_REQUEST_OPTION_MISSING = object() +_REASONING_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"}) + + +def _clean_request_string(value: Any) -> Optional[str]: + """Return a stripped request string, or None for absent/non-string values.""" + if not isinstance(value, str): + return None + cleaned = value.strip() + return cleaned or None + + +def _request_reasoning_config(model_options: Any) -> Optional[Dict[str, Any]]: + """Translate browser/API model_options into AIAgent reasoning_config. + + The browser extension sends both a structured ``reasoning`` object and a + compatibility ``reasoning_effort`` scalar. Keep this parser permissive so + older clients can send either shape, but ignore unknown effort values rather + than raising on a chat request. + """ + if not isinstance(model_options, dict): + return None + + reasoning = model_options.get("reasoning") + enabled: Any = None + effort: Any = model_options.get("reasoning_effort") + if isinstance(reasoning, dict): + enabled = reasoning.get("enabled") + effort = reasoning.get("effort", effort) + + effort_norm = str(effort).strip().lower() if effort is not None else "" + if enabled is False or effort_norm == "none": + return {"enabled": False} + if effort_norm in _REASONING_EFFORTS and effort_norm != "none": + return {"enabled": True, "effort": effort_norm} + if enabled is True: + return {"enabled": True} + return None + + +def _request_service_tier(model_options: Any) -> Any: + """Return a per-request service_tier override or _REQUEST_OPTION_MISSING.""" + if not isinstance(model_options, dict): + return _REQUEST_OPTION_MISSING + if "service_tier" in model_options: + raw_tier = model_options.get("service_tier") + if raw_tier is None: + return None + if isinstance(raw_tier, str): + return raw_tier.strip() or None + return raw_tier + if "fast" in model_options: + return "priority" if _coerce_request_bool(model_options.get("fast"), default=False) else None + return _REQUEST_OPTION_MISSING + + +def _resolve_request_runtime_agent_kwargs(provider: str, target_model: Optional[str] = None) -> Dict[str, Any]: + """Resolve runtime kwargs for a one-request provider override. + + This mirrors gateway.run._resolve_runtime_agent_kwargs(), but accepts an + explicit provider/model so an API caller can use the same authenticated + provider catalog as the TUI without mutating config.yaml. + """ + from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error, _get_model_config + + try: + runtime = resolve_runtime_provider(requested=provider, target_model=target_model) + except Exception as exc: + raise RuntimeError(format_runtime_provider_error(exc)) from exc + + model_cfg = _get_model_config() + max_tokens = None + env_max_tokens = os.environ.get("HERMES_MAX_TOKENS") + if env_max_tokens: + try: + max_tokens = int(env_max_tokens) + except (ValueError, TypeError): + max_tokens = None + elif isinstance(model_cfg, dict): + cfg_max_tokens = model_cfg.get("max_tokens") + if isinstance(cfg_max_tokens, int): + max_tokens = cfg_max_tokens + if max_tokens is None: + runtime_max_tokens = runtime.get("max_output_tokens") + if isinstance(runtime_max_tokens, int) and runtime_max_tokens > 0: + max_tokens = runtime_max_tokens + + return { + "api_key": runtime.get("api_key"), + "base_url": runtime.get("base_url"), + "provider": runtime.get("provider"), + "api_mode": runtime.get("api_mode"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), + "credential_pool": runtime.get("credential_pool"), + "max_tokens": max_tokens, + } + + +def _request_agent_overrides(body: Any, *, virtual_model: Optional[str] = None) -> Dict[str, Any]: + """Extract per-request model/provider/options for _run_agent. + + ``/v1/models`` advertises a stable virtual model (usually ``hermes-agent``) + for OpenAI-compatible clients. Treat that alias as "use the gateway + default"; real model picker selections from the browser extension send the + raw provider model id plus a provider slug and should override this turn. + """ + if not isinstance(body, dict): + return {} + + overrides: Dict[str, Any] = {} + model = _clean_request_string(body.get("model")) + if model and model != virtual_model: + overrides["requested_model"] = model + + provider = _clean_request_string(body.get("provider")) + if provider: + overrides["requested_provider"] = provider + + model_options = body.get("model_options") + if isinstance(model_options, dict): + overrides["model_options"] = dict(model_options) + return overrides + + def _normalize_chat_content( content: Any, *, _max_depth: int = 10, _depth: int = 0, ) -> str: @@ -1069,6 +1194,9 @@ def _create_agent( tool_start_callback=None, tool_complete_callback=None, gateway_session_key: Optional[str] = None, + requested_model: Optional[str] = None, + requested_provider: Optional[str] = None, + model_options: Optional[Dict[str, Any]] = None, ) -> Any: """ Create an AIAgent instance using the gateway's runtime config. @@ -1099,6 +1227,21 @@ def _create_agent( reasoning_config = GatewayRunner._load_reasoning_config() model = _resolve_gateway_model() + request_model = _clean_request_string(requested_model) + request_provider = _clean_request_string(requested_provider) + if request_model: + model = request_model + if request_provider: + runtime_kwargs = _resolve_request_runtime_agent_kwargs( + request_provider, + target_model=model or None, + ) + + request_reasoning_config = _request_reasoning_config(model_options) + if request_reasoning_config is not None: + reasoning_config = request_reasoning_config + request_service_tier = _request_service_tier(model_options) + user_config = _load_gateway_config() enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server")) @@ -1108,25 +1251,29 @@ def _create_agent( # same fallback behaviour as Telegram/Discord/Slack (fixes #4954). fallback_model = GatewayRunner._load_fallback_model() - agent = AIAgent( - model=model, + agent_kwargs = { + "model": model, **runtime_kwargs, - max_iterations=max_iterations, - quiet_mode=True, - verbose_logging=False, - ephemeral_system_prompt=ephemeral_system_prompt or None, - enabled_toolsets=enabled_toolsets, - session_id=session_id, - platform="api_server", - stream_delta_callback=stream_delta_callback, - tool_progress_callback=tool_progress_callback, - tool_start_callback=tool_start_callback, - tool_complete_callback=tool_complete_callback, - session_db=self._ensure_session_db(), - fallback_model=fallback_model, - reasoning_config=reasoning_config, - gateway_session_key=gateway_session_key, - ) + "max_iterations": max_iterations, + "quiet_mode": True, + "verbose_logging": False, + "ephemeral_system_prompt": ephemeral_system_prompt or None, + "enabled_toolsets": enabled_toolsets, + "session_id": session_id, + "platform": "api_server", + "stream_delta_callback": stream_delta_callback, + "tool_progress_callback": tool_progress_callback, + "tool_start_callback": tool_start_callback, + "tool_complete_callback": tool_complete_callback, + "session_db": self._ensure_session_db(), + "fallback_model": fallback_model, + "reasoning_config": reasoning_config, + "gateway_session_key": gateway_session_key, + } + if request_service_tier is not _REQUEST_OPTION_MISSING: + agent_kwargs["service_tier"] = request_service_tier + + agent = AIAgent(**agent_kwargs) return agent # ------------------------------------------------------------------ @@ -1675,6 +1822,7 @@ async def _handle_session_chat(self, request: "web.Request") -> "web.Response": 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) + agent_overrides = _request_agent_overrides(body, virtual_model=self._model_name) history = self._conversation_history_for_session(session_id) result, usage = await self._run_agent( user_message=user_message, @@ -1682,6 +1830,7 @@ async def _handle_session_chat(self, request: "web.Request") -> "web.Response": ephemeral_system_prompt=system_prompt, session_id=session_id, gateway_session_key=gateway_session_key, + **agent_overrides, ) effective_session_id = result.get("session_id") if isinstance(result, dict) else session_id final_response = result.get("final_response", "") if isinstance(result, dict) else "" @@ -1719,6 +1868,7 @@ async def _handle_session_chat_stream(self, request: "web.Request") -> "web.Stre 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) + agent_overrides = _request_agent_overrides(body, virtual_model=self._model_name) loop = asyncio.get_running_loop() queue: "asyncio.Queue[Optional[tuple[str, Dict[str, Any]]]]" = asyncio.Queue() @@ -1773,6 +1923,7 @@ async def _run_and_signal() -> None: stream_delta_callback=_delta, tool_progress_callback=_tool_progress, gateway_session_key=gateway_session_key, + **agent_overrides, ) final_response = 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 @@ -1864,6 +2015,7 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons ) stream = _coerce_request_bool(body.get("stream"), default=False) + agent_overrides = _request_agent_overrides(body, virtual_model=self._model_name) # Extract system message (becomes ephemeral system prompt layered ON TOP of core) system_prompt = None @@ -2044,6 +2196,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul tool_complete_callback=_on_tool_complete, agent_ref=agent_ref, gateway_session_key=gateway_session_key, + **agent_overrides, )) # Ensure SSE drain loops can terminate without relying on polling # agent_task.done(), which can race with queue timeout checks. @@ -2063,11 +2216,15 @@ async def _compute_completion(): ephemeral_system_prompt=system_prompt, session_id=session_id, gateway_session_key=gateway_session_key, + **agent_overrides, ) idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key: - fp = _make_request_fingerprint(body, keys=["model", "messages", "tools", "tool_choice", "stream"]) + fp = _make_request_fingerprint( + body, + keys=["model", "provider", "model_options", "messages", "tools", "tool_choice", "stream"], + ) try: result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_completion) except Exception as e: @@ -3029,6 +3186,7 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response": session_id = stored_session_id or str(uuid.uuid4()) stream = _coerce_request_bool(body.get("stream"), default=False) + agent_overrides = _request_agent_overrides(body, virtual_model=self._model_name) if stream: # Streaming branch — emit OpenAI Responses SSE events as the # agent runs so frontends can render text deltas and tool @@ -3081,6 +3239,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul tool_complete_callback=_on_tool_complete, agent_ref=agent_ref, gateway_session_key=gateway_session_key, + **agent_overrides, )) # Ensure SSE drain loops can terminate without relying on polling # agent_task.done(), which can race with queue timeout checks. @@ -3114,13 +3273,23 @@ async def _compute_response(): ephemeral_system_prompt=instructions, session_id=session_id, gateway_session_key=gateway_session_key, + **agent_overrides, ) idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key: fp = _make_request_fingerprint( body, - keys=["input", "instructions", "previous_response_id", "conversation", "model", "tools"], + keys=[ + "input", + "instructions", + "previous_response_id", + "conversation", + "model", + "provider", + "model_options", + "tools", + ], ) try: result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_response) @@ -3744,6 +3913,9 @@ async def _run_agent( tool_complete_callback=None, agent_ref: Optional[list] = None, gateway_session_key: Optional[str] = None, + requested_model: Optional[str] = None, + requested_provider: Optional[str] = None, + model_options: Optional[Dict[str, Any]] = None, ) -> tuple: """ Create an agent and run a conversation in a thread executor. @@ -3775,6 +3947,9 @@ def _run(): tool_start_callback=tool_start_callback, tool_complete_callback=tool_complete_callback, gateway_session_key=gateway_session_key, + requested_model=requested_model, + requested_provider=requested_provider, + model_options=model_options, ) if agent_ref is not None: agent_ref[0] = agent diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 5013ff3815ded..882ff0ac158c6 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -16,7 +16,9 @@ import json import os import stat +import sys import time +import types import uuid from unittest.mock import AsyncMock, MagicMock, patch @@ -506,6 +508,8 @@ def _create_app(adapter: APIServerAdapter) -> web.Application: 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) + app.router.add_post("/api/sessions/{session_id}/chat", adapter._handle_session_chat) + app.router.add_post("/api/sessions/{session_id}/chat/stream", adapter._handle_session_chat_stream) app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions) app.router.add_post("/v1/responses", adapter._handle_responses) app.router.add_get("/v1/responses/{response_id}", adapter._handle_get_response) @@ -537,11 +541,15 @@ async def test_run_agent_uses_session_id_as_task_id(self, adapter): mock_agent.session_completion_tokens = 2 mock_agent.session_total_tokens = 3 - with patch.object(adapter, "_create_agent", return_value=mock_agent): + model_options = {"reasoning": {"enabled": False}, "fast": False} + with patch.object(adapter, "_create_agent", return_value=mock_agent) as mock_create_agent: result, usage = await adapter._run_agent( user_message="hello", conversation_history=[], session_id="session-123", + requested_model="MiniMax-M3", + requested_provider="minimax", + model_options=model_options, ) # _run_agent annotates result with the effective agent.session_id @@ -551,12 +559,93 @@ async def test_run_agent_uses_session_id_as_task_id(self, adapter): # the annotation — header will fall back to the provided session_id. assert result["final_response"] == "ok" assert usage == {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3} + create_kwargs = mock_create_agent.call_args.kwargs + assert create_kwargs["requested_model"] == "MiniMax-M3" + assert create_kwargs["requested_provider"] == "minimax" + assert create_kwargs["model_options"] == model_options mock_agent.run_conversation.assert_called_once_with( user_message="hello", conversation_history=[], task_id="session-123", ) + def test_create_agent_honors_request_model_provider_and_options(self, adapter, monkeypatch): + import gateway.run as gateway_run + import hermes_cli.runtime_provider as runtime_provider + import hermes_cli.tools_config as tools_config + + class _CapturingAgent: + last_kwargs = None + + def __init__(self, **kwargs): + type(self).last_kwargs = dict(kwargs) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = _CapturingAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + monkeypatch.setattr(gateway_run, "_current_max_iterations", lambda: 7) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda: "gpt-5.5") + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr( + gateway_run, + "_resolve_runtime_agent_kwargs", + lambda: { + "api_key": "codex-key", + "base_url": "https://chatgpt.com/backend-api/codex", + "provider": "openai-codex", + "api_mode": "codex_responses", + "command": None, + "args": [], + "credential_pool": None, + "max_tokens": None, + }, + ) + monkeypatch.setattr(gateway_run.GatewayRunner, "_load_reasoning_config", staticmethod(lambda: {"enabled": True, "effort": "medium"})) + monkeypatch.setattr(gateway_run.GatewayRunner, "_load_fallback_model", staticmethod(lambda: None)) + monkeypatch.setattr(tools_config, "_get_platform_tools", lambda _cfg, _platform: {"web"}) + monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) + + def _fake_resolve_runtime_provider(*, requested=None, target_model=None, **_kwargs): + assert requested == "minimax" + assert target_model == "MiniMax-M3" + return { + "api_key": "minimax-key", + "base_url": "https://api.minimax.io/v1", + "provider": "minimax", + "api_mode": "anthropic_messages", + "command": None, + "args": [], + "credential_pool": None, + "max_output_tokens": 32000, + } + + monkeypatch.setattr(runtime_provider, "resolve_runtime_provider", _fake_resolve_runtime_provider) + monkeypatch.setattr(runtime_provider, "_get_model_config", lambda: {}) + + adapter._create_agent( + session_id="session-123", + requested_model="MiniMax-M3", + requested_provider="minimax", + model_options={ + "reasoning": {"enabled": True, "effort": "high"}, + "reasoning_effort": "high", + "fast": True, + }, + ) + + kwargs = _CapturingAgent.last_kwargs + assert kwargs is not None + assert kwargs["model"] == "MiniMax-M3" + assert kwargs["provider"] == "minimax" + assert kwargs["api_mode"] == "anthropic_messages" + assert kwargs["base_url"] == "https://api.minimax.io/v1" + assert kwargs["api_key"] == "minimax-key" + assert kwargs["max_tokens"] == 32000 + assert kwargs["reasoning_config"] == {"enabled": True, "effort": "high"} + assert kwargs["service_tier"] == "priority" + assert kwargs["enabled_toolsets"] == ["web"] + # --------------------------------------------------------------------------- # /health endpoint @@ -1035,6 +1124,135 @@ async def test_empty_messages_returns_400(self, adapter): resp = await cli.post("/v1/chat/completions", json={"model": "test", "messages": []}) assert resp.status == 400 + @pytest.mark.asyncio + async def test_chat_completions_passes_request_model_provider_options(self, adapter): + app = _create_app(adapter) + model_options = { + "reasoning": {"enabled": True, "effort": "high"}, + "reasoning_effort": "high", + "service_tier": "priority", + "fast": True, + } + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + {"final_response": "ok", "messages": [], "api_calls": 1}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + resp = await cli.post( + "/v1/chat/completions", + json={ + "model": "MiniMax-M3", + "provider": "minimax", + "model_options": model_options, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + + assert resp.status == 200 + kwargs = mock_run.call_args.kwargs + assert kwargs["requested_model"] == "MiniMax-M3" + assert kwargs["requested_provider"] == "minimax" + assert kwargs["model_options"] == model_options + + @pytest.mark.asyncio + async def test_chat_completions_stream_passes_request_model_provider_options(self, adapter): + app = _create_app(adapter) + model_options = {"reasoning": {"enabled": False}, "reasoning_effort": "none", "fast": False} + + async def _mock_run_agent(**kwargs): + cb = kwargs.get("stream_delta_callback") + if cb: + cb("ok") + return ( + {"final_response": "ok", "messages": [], "api_calls": 1}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent) as mock_run: + resp = await cli.post( + "/v1/chat/completions", + json={ + "model": "MiniMax-M3", + "provider": "minimax", + "model_options": model_options, + "messages": [{"role": "user", "content": "hi"}], + "stream": True, + }, + ) + assert resp.status == 200 + body = await resp.text() + + assert "data: " in body + kwargs = mock_run.call_args.kwargs + assert kwargs["requested_model"] == "MiniMax-M3" + assert kwargs["requested_provider"] == "minimax" + assert kwargs["model_options"] == model_options + + @pytest.mark.asyncio + async def test_session_chat_passes_request_model_provider_options(self, adapter): + app = _create_app(adapter) + model_options = {"reasoning": {"enabled": True, "effort": "low"}, "fast": True} + async with TestClient(TestServer(app)) as cli: + with ( + patch.object(adapter, "_get_existing_session_or_404", return_value=({"id": "s1"}, None)), + patch.object(adapter, "_conversation_history_for_session", return_value=[]), + patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run, + ): + mock_run.return_value = ( + {"final_response": "ok", "messages": [], "api_calls": 1}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + resp = await cli.post( + "/api/sessions/s1/chat", + json={ + "message": "hi", + "model": "MiniMax-M3", + "provider": "minimax", + "model_options": model_options, + }, + ) + + assert resp.status == 200 + kwargs = mock_run.call_args.kwargs + assert kwargs["requested_model"] == "MiniMax-M3" + assert kwargs["requested_provider"] == "minimax" + assert kwargs["model_options"] == model_options + + @pytest.mark.asyncio + async def test_session_chat_stream_passes_request_model_provider_options(self, adapter): + app = _create_app(adapter) + model_options = {"reasoning_effort": "medium", "service_tier": "priority"} + async with TestClient(TestServer(app)) as cli: + with ( + patch.object(adapter, "_get_existing_session_or_404", return_value=({"id": "s1"}, None)), + patch.object(adapter, "_conversation_history_for_session", return_value=[]), + patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run, + ): + mock_run.return_value = ( + {"final_response": "ok", "messages": [], "api_calls": 1}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + resp = await cli.post( + "/api/sessions/s1/chat/stream", + json={ + "message": "hi", + "model": "MiniMax-M3", + "provider": "minimax", + "model_options": model_options, + }, + ) + assert resp.status == 200 + body = await resp.text() + + assert "event: run.completed" in body + kwargs = mock_run.call_args.kwargs + assert kwargs["requested_model"] == "MiniMax-M3" + assert kwargs["requested_provider"] == "minimax" + assert kwargs["model_options"] == model_options + @pytest.mark.asyncio async def test_stream_true_returns_sse(self, adapter): """stream=true returns SSE format with the full response.""" From 424842b52867e4aaf22b78ddd2e45297d1d6afa5 Mon Sep 17 00:00:00 2001 From: abundantbeing Date: Sun, 28 Jun 2026 18:40:49 -0400 Subject: [PATCH 3/4] fix: enrich model-options capabilities with context_length --- hermes_cli/inventory.py | 23 +++++++++++++++++++++-- tests/gateway/test_api_server.py | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index c74ebc288768b..8b2e76b04553f 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -231,13 +231,17 @@ def build_models_payload( def _apply_capabilities(rows: list[dict]) -> None: - """Attach a ``{model: {fast, reasoning}}`` map to each provider row. + """Attach a ``{model: {fast, reasoning, context_length}}`` map to each provider row. `fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime enforces). `reasoning` comes from the models.dev catalog when known and defaults to True otherwise — the effort dial is broadly accepted and a no-op on models that ignore it, whereas hiding it from a capable-but- uncatalogued model is the worse failure. + + `context_length` is resolved through ``get_model_context_length`` so + browser extensions and GUI pickers can display the real context window + instead of falling back to a tiny default. """ from hermes_cli.models import model_supports_fast_mode @@ -246,9 +250,14 @@ def _apply_capabilities(rows: list[dict]) -> None: except Exception: get_model_capabilities = None # type: ignore[assignment] + try: + from agent.model_metadata import get_model_context_length + except Exception: + get_model_context_length = None # type: ignore[assignment] + for row in rows: slug = row.get("slug") or "" - caps: dict[str, dict[str, bool]] = {} + caps: dict[str, dict[str, object]] = {} for model in row.get("models") or []: reasoning = True @@ -260,9 +269,19 @@ def _apply_capabilities(rows: list[dict]) -> None: except Exception: reasoning = True + context_length = 0 + if get_model_context_length is not None: + try: + ctx = get_model_context_length(model, provider=slug or None) + if ctx: + context_length = int(ctx) + except Exception: + context_length = 0 + caps[model] = { "fast": bool(model_supports_fast_mode(model)), "reasoning": reasoning, + "context_length": context_length, } row["capabilities"] = caps diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 882ff0ac158c6..0c0ef8270ca6e 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -865,6 +865,27 @@ async def test_model_options_returns_provider_inventory(self, adapter): assert kwargs["capabilities"] is True assert kwargs["refresh"] is True + @pytest.mark.asyncio + async def test_model_options_capabilities_include_context_length(self, adapter): + """Capabilities enrichment should include context_length per model.""" + from hermes_cli.inventory import _apply_capabilities + + rows = [ + { + "slug": "nous", + "models": ["xiaomi/mimo-v2.5-pro", "anthropic/claude-sonnet-4"], + } + ] + with patch("agent.model_metadata.get_model_context_length", side_effect=lambda m, **kw: { + "xiaomi/mimo-v2.5-pro": 1048576, + "anthropic/claude-sonnet-4": 200000, + }.get(m)) as mock_ctx, patch("hermes_cli.models.model_supports_fast_mode", return_value=False): + _apply_capabilities(rows) + + caps = rows[0]["capabilities"] + assert caps["xiaomi/mimo-v2.5-pro"]["context_length"] == 1048576 + assert caps["anthropic/claude-sonnet-4"]["context_length"] == 200000 + @pytest.mark.asyncio async def test_model_options_requires_auth(self, auth_adapter): app = _create_app(auth_adapter) From 731e52fb67a525f92396bbf9c1e1704b5fedd9be Mon Sep 17 00:00:00 2001 From: abundantbeing Date: Wed, 15 Jul 2026 16:35:56 +0200 Subject: [PATCH 4/4] refactor(api): separate inventory from request routing --- hermes_cli/inventory.py | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 5d52a83841cb9..cd90d2a9a9e77 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -260,17 +260,13 @@ def build_models_payload( def _apply_capabilities(rows: list[dict]) -> None: - """Attach a ``{model: {fast, reasoning, context_length}}`` map to each provider row. + """Attach a ``{model: {fast, reasoning}}`` map to each provider row. `fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime enforces). `reasoning` comes from the models.dev catalog when known and defaults to True otherwise — the effort dial is broadly accepted and a no-op on models that ignore it, whereas hiding it from a capable-but- uncatalogued model is the worse failure. - - `context_length` is resolved through ``get_model_context_length`` so - browser extensions and GUI pickers can display the real context window - instead of falling back to a tiny default. """ from hermes_cli.models import model_supports_fast_mode @@ -279,14 +275,9 @@ def _apply_capabilities(rows: list[dict]) -> None: except Exception: get_model_capabilities = None # type: ignore[assignment] - try: - from agent.model_metadata import get_model_context_length - except Exception: - get_model_context_length = None # type: ignore[assignment] - for row in rows: slug = row.get("slug") or "" - caps: dict[str, dict[str, object]] = {} + caps: dict[str, dict[str, bool]] = {} for model in row.get("models") or []: reasoning = True @@ -298,19 +289,9 @@ def _apply_capabilities(rows: list[dict]) -> None: except Exception: reasoning = True - context_length = 0 - if get_model_context_length is not None: - try: - ctx = get_model_context_length(model, provider=slug or None) - if ctx: - context_length = int(ctx) - except Exception: - context_length = 0 - caps[model] = { "fast": bool(model_supports_fast_mode(model)), "reasoning": reasoning, - "context_length": context_length, } row["capabilities"] = caps