feat(api-server): add GET /v1/skills and /v1/toolsets (#33016)

Lets external clients enumerate the agent's skills and resolved toolsets
deterministically over the OpenAI-compatible API server, without standing
up the dashboard web server or sending a chat message and asking the model
to list them.

- GET /v1/skills — list installed skills (name, description, category)
- GET /v1/toolsets — list toolsets resolved for the api_server platform,
  with enabled/configured state and the concrete tool names each expands
  to
- Both gated by API_SERVER_KEY (same Bearer scheme as every other /v1/*
  endpoint)
- /v1/capabilities advertises both new endpoints

Closes the gap a community user just hit asking how to list skills over
REST when only the OpenAI-compatible server is running.

Test plan
- python -m pytest tests/gateway/test_api_server.py -k "Skills or Toolsets or Capabilities" -o 'addopts=' -q
  → 9/9 pass
- python -m pytest tests/gateway/test_api_server.py -o 'addopts=' -q
  → 156/156 pass, no regressions
- E2E: started a real adapter on an isolated HERMES_HOME with a fake
  skill installed; curl-equivalent calls to /v1/capabilities,
  /v1/skills, /v1/toolsets returned the expected JSON; unauthenticated
  calls returned 401 with the configured API_SERVER_KEY.
This commit is contained in:
Teknium 2026-05-27 01:27:26 -07:00 committed by GitHub
parent febc4cfec0
commit 25f43d38de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 243 additions and 0 deletions

View file

@ -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)

View file

@ -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
# ---------------------------------------------------------------------------