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

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