diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index a18630f85ce..82486d4ed9d 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1101,9 +1101,98 @@ class APIServerAdapter(BasePlatformAdapter): "run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"}, "run_approval": {"method": "POST", "path": "/v1/runs/{run_id}/approval"}, "run_stop": {"method": "POST", "path": "/v1/runs/{run_id}/stop"}, + "skills": {"method": "GET", "path": "/v1/skills"}, + "toolsets": {"method": "GET", "path": "/v1/toolsets"}, }, }) + async def _handle_skills(self, request: "web.Request") -> "web.Response": + """GET /v1/skills — list installed skills visible to the API-server agent. + + Read-only listing intended for external clients that need to know + which skills are available without sending a chat message and asking + the model. Mirrors what the gateway/CLI surfaces through + ``/skills list``, but as a deterministic JSON payload. + + Returns the same skill metadata (name, description, category) the + skills hub uses internally. Disabled skills are excluded so the + listing matches what the agent actually loads. + """ + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + try: + from tools.skills_tool import _find_all_skills, _sort_skills + skills = _sort_skills(_find_all_skills(skip_disabled=False)) + except Exception: + logger.exception("GET /v1/skills failed") + return web.json_response( + _openai_error("Failed to enumerate skills", err_type="server_error"), + status=500, + ) + + return web.json_response({ + "object": "list", + "data": skills, + }) + + async def _handle_toolsets(self, request: "web.Request") -> "web.Response": + """GET /v1/toolsets — list toolsets and their resolved tools. + + Returns the toolset surface the api_server platform actually exposes + to its agent: each toolset's enabled/configured state plus the + concrete tool names it expands to. This is the deterministic + equivalent of what a client would otherwise have to recover by + asking the model what tools it can call. + """ + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + try: + from hermes_cli.config import load_config + from hermes_cli.tools_config import ( + _get_effective_configurable_toolsets, + _get_platform_tools, + _toolset_has_keys, + ) + from toolsets import resolve_toolset + + config = load_config() + enabled_toolsets = _get_platform_tools( + config, + "api_server", + include_default_mcp_servers=False, + ) + data: List[Dict[str, Any]] = [] + for name, label, desc in _get_effective_configurable_toolsets(): + try: + tools = sorted(set(resolve_toolset(name))) + except Exception: + tools = [] + is_enabled = name in enabled_toolsets + data.append({ + "name": name, + "label": label, + "description": desc, + "enabled": is_enabled, + "configured": _toolset_has_keys(name, config), + "tools": tools, + }) + except Exception: + logger.exception("GET /v1/toolsets failed") + return web.json_response( + _openai_error("Failed to enumerate toolsets", err_type="server_error"), + status=500, + ) + + return web.json_response({ + "object": "list", + "platform": "api_server", + "data": data, + }) + async def _handle_chat_completions(self, request: "web.Request") -> "web.Response": """POST /v1/chat/completions — OpenAI Chat Completions format.""" auth_err = self._check_auth(request) @@ -3492,6 +3581,8 @@ class APIServerAdapter(BasePlatformAdapter): 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("/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) self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions) self._app.router.add_post("/v1/responses", self._handle_responses) self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 608385bef17..3b0a9b24b6f 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -413,6 +413,8 @@ def _create_app(adapter: APIServerAdapter) -> web.Application: app.router.add_get("/v1/health", adapter._handle_health) app.router.add_get("/v1/models", adapter._handle_models) 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("/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) @@ -657,6 +659,8 @@ class TestCapabilitiesEndpoint: assert data["features"]["run_events_sse"] 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"]["skills"] == {"method": "GET", "path": "/v1/skills"} + assert data["endpoints"]["toolsets"] == {"method": "GET", "path": "/v1/toolsets"} @pytest.mark.asyncio async def test_capabilities_requires_auth_when_key_configured(self, auth_adapter): @@ -674,6 +678,154 @@ class TestCapabilitiesEndpoint: assert data["auth"]["required"] is True +# --------------------------------------------------------------------------- +# /v1/skills and /v1/toolsets endpoints +# --------------------------------------------------------------------------- + + +class TestSkillsEndpoint: + @pytest.mark.asyncio + async def test_skills_returns_list_envelope(self, adapter): + fake_skills = [ + {"name": "github", "description": "GitHub workflow skill", "category": "github"}, + {"name": "ascii-art", "description": "ASCII art generation", "category": "creative"}, + ] + with patch( + "tools.skills_tool._find_all_skills", + return_value=list(fake_skills), + ): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/v1/skills") + assert resp.status == 200 + data = await resp.json() + assert data["object"] == "list" + names = sorted(s["name"] for s in data["data"]) + assert names == ["ascii-art", "github"] + for entry in data["data"]: + assert set(entry.keys()) >= {"name", "description", "category"} + + @pytest.mark.asyncio + async def test_skills_handles_enumeration_failure(self, adapter): + with patch( + "tools.skills_tool._find_all_skills", + side_effect=RuntimeError("boom"), + ): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/v1/skills") + assert resp.status == 500 + data = await resp.json() + assert "error" in data + + @pytest.mark.asyncio + async def test_skills_requires_auth_when_key_configured(self, auth_adapter): + with patch("tools.skills_tool._find_all_skills", return_value=[]): + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/v1/skills") + assert resp.status == 401 + + authed = await cli.get( + "/v1/skills", + headers={"Authorization": "Bearer sk-secret"}, + ) + assert authed.status == 200 + + +class TestToolsetsEndpoint: + @pytest.mark.asyncio + async def test_toolsets_returns_resolved_tools(self, adapter): + fake_toolsets = [ + ("default", "Default Tools", "Core tools"), + ("web", "Web Tools", "Search and extract"), + ] + with patch( + "hermes_cli.tools_config._get_effective_configurable_toolsets", + return_value=fake_toolsets, + ), patch( + "hermes_cli.tools_config._get_platform_tools", + return_value={"default"}, + ), patch( + "hermes_cli.tools_config._toolset_has_keys", + return_value=True, + ), patch( + "toolsets.resolve_toolset", + side_effect=lambda name: { + "default": ["terminal", "read_file"], + "web": ["web_search"], + }[name], + ): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/v1/toolsets") + assert resp.status == 200 + data = await resp.json() + assert data["object"] == "list" + assert data["platform"] == "api_server" + by_name = {ts["name"]: ts for ts in data["data"]} + assert by_name["default"]["enabled"] is True + assert by_name["default"]["tools"] == ["read_file", "terminal"] + assert by_name["web"]["enabled"] is False + assert by_name["web"]["tools"] == ["web_search"] + assert by_name["default"]["configured"] is True + + @pytest.mark.asyncio + async def test_toolsets_handles_resolution_failure_per_toolset(self, adapter): + """If one toolset fails to resolve, others still appear with empty tools.""" + fake_toolsets = [ + ("broken", "Broken", "fails"), + ("ok", "OK", "works"), + ] + + def _resolve(name): + if name == "broken": + raise RuntimeError("nope") + return ["some_tool"] + + with patch( + "hermes_cli.tools_config._get_effective_configurable_toolsets", + return_value=fake_toolsets, + ), patch( + "hermes_cli.tools_config._get_platform_tools", + return_value=set(), + ), patch( + "hermes_cli.tools_config._toolset_has_keys", + return_value=False, + ), patch( + "toolsets.resolve_toolset", + side_effect=_resolve, + ): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/v1/toolsets") + assert resp.status == 200 + data = await resp.json() + by_name = {ts["name"]: ts for ts in data["data"]} + assert by_name["broken"]["tools"] == [] + assert by_name["ok"]["tools"] == ["some_tool"] + + @pytest.mark.asyncio + async def test_toolsets_requires_auth_when_key_configured(self, auth_adapter): + with patch( + "hermes_cli.tools_config._get_effective_configurable_toolsets", + return_value=[], + ), patch( + "hermes_cli.tools_config._get_platform_tools", + return_value=set(), + ): + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/v1/toolsets") + assert resp.status == 401 + + authed = await cli.get( + "/v1/toolsets", + headers={"Authorization": "Bearer sk-secret"}, + ) + assert authed.status == 200 + + # --------------------------------------------------------------------------- # /v1/chat/completions endpoint # ---------------------------------------------------------------------------