feat(proxy): add agent skills discovery endpoint

This commit is contained in:
Dávid Balatoni 2026-06-22 17:15:49 +02:00
parent 5ba4a4e5f9
commit 2f0ca013ae
No known key found for this signature in database
3 changed files with 194 additions and 0 deletions

View file

@ -11,6 +11,7 @@ from litellm.proxy._types import LiteLLM_SkillsTable, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
OPENCODE_SKILLS_DEFAULT_PATH = "/opencode/skills"
AGENT_SKILLS_PATHS = ("/.well-known/agent-skills", "/.well-known/skills")
_MAX_SKILLS = 1000
@ -25,6 +26,17 @@ def _opencode_config_enabled(skills_gateway_config: Optional[dict]) -> bool:
)
def _agent_skills_config_enabled(skills_gateway_config: Optional[dict]) -> bool:
if not isinstance(skills_gateway_config, dict):
return False
agent_skills_config = skills_gateway_config.get("agent_skills", {})
return (
skills_gateway_config.get("enabled") is True
and isinstance(agent_skills_config, dict)
and agent_skills_config.get("enabled") is True
)
def _opencode_path(skills_gateway_config: Optional[dict]) -> str:
if not isinstance(skills_gateway_config, dict):
return OPENCODE_SKILLS_DEFAULT_PATH
@ -47,6 +59,16 @@ def _opencode_skill_name(skill: LiteLLM_SkillsTable) -> str:
return skill.skill_id
def _agent_skill_name(skill: LiteLLM_SkillsTable) -> str:
return _slug(skill.skill_id)[:64].strip("-") or "litellm-skill"
def _agent_skill_description(skill: LiteLLM_SkillsTable) -> str:
return _one_line(
skill.description or skill.instructions or skill.display_title or skill.skill_id
)[:1024]
def _slug(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "litellm-skill"
@ -130,6 +152,43 @@ async def opencode_skill_file(
raise HTTPException(status_code=404, detail="Skill file not found")
async def agent_skills_index(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
skills = await _enabled_skills(user_api_key_dict)
return {
"skills": [
{
"name": _agent_skill_name(skill),
"description": _agent_skill_description(skill),
"files": _sorted_files(_skill_files(skill)),
}
for skill in skills
]
}
async def agent_skill_file(
skill_name: str,
file_path: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
for skill in await _enabled_skills(user_api_key_dict):
if _agent_skill_name(skill) != skill_name:
continue
files = _skill_files(skill)
content = files.get(file_path)
if content is None:
break
media_type = (
"text/markdown; charset=utf-8"
if file_path.endswith(".md")
else "application/octet-stream"
)
return Response(content=content, media_type=media_type)
raise HTTPException(status_code=404, detail="Skill file not found")
def _add_route(app: FastAPI, path: str, endpoint):
for route in app.routes:
if getattr(route, "path", None) == path and "GET" in getattr(
@ -150,3 +209,15 @@ def initialize_opencode_skills_endpoint(
_add_route(app, path, opencode_skills_index)
_add_route(app, f"{path}/index.json", opencode_skills_index)
_add_route(app, f"{path}/{{skill_name}}/{{file_path:path}}", opencode_skill_file)
def initialize_agent_skills_endpoint(
app: FastAPI,
skills_gateway_config: Optional[dict],
) -> None:
if not _agent_skills_config_enabled(skills_gateway_config):
return
for path in AGENT_SKILLS_PATHS:
_add_route(app, f"{path}/index.json", agent_skills_index)
_add_route(app, f"{path}/{{skill_name}}/{{file_path:path}}", agent_skill_file)

View file

@ -4552,6 +4552,7 @@ class ProxyConfig:
if general_settings is None:
general_settings = {}
from litellm.proxy.opencode_endpoints.skills_endpoints import (
initialize_agent_skills_endpoint,
initialize_opencode_skills_endpoint,
)
@ -4559,6 +4560,10 @@ class ProxyConfig:
app=app,
skills_gateway_config=config.get("skills_gateway"),
)
initialize_agent_skills_endpoint(
app=app,
skills_gateway_config=config.get("skills_gateway"),
)
_enable_hc_routing = False
_hc_staleness = None

View file

@ -11,6 +11,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
def _client(config: dict, auth: UserAPIKeyAuth | None = None) -> TestClient:
from litellm.proxy.opencode_endpoints.skills_endpoints import (
initialize_agent_skills_endpoint,
initialize_opencode_skills_endpoint,
)
@ -18,6 +19,7 @@ def _client(config: dict, auth: UserAPIKeyAuth | None = None) -> TestClient:
if auth is not None:
app.dependency_overrides[user_api_key_auth] = lambda: auth
initialize_opencode_skills_endpoint(app=app, skills_gateway_config=config)
initialize_agent_skills_endpoint(app=app, skills_gateway_config=config)
return TestClient(app)
@ -29,6 +31,14 @@ def test_should_not_register_opencode_skills_endpoint_when_disabled():
assert response.status_code == 404
def test_should_not_register_agent_skills_endpoint_when_disabled():
client = _client({"enabled": True})
response = client.get("/.well-known/agent-skills/index.json")
assert response.status_code == 404
def test_should_return_opencode_skills_index_when_enabled(monkeypatch):
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
@ -58,6 +68,114 @@ def test_should_return_opencode_skills_index_when_enabled(monkeypatch):
list_skills.assert_awaited_once_with(limit=1000, offset=0, user_api_key_dict=auth)
def test_should_return_agent_skills_well_known_index_when_enabled(monkeypatch):
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
list_skills = AsyncMock(
return_value=[
LiteLLM_SkillsTable(
skill_id="litellm_skill_writer",
display_title="Writer",
description="Draft clean release notes",
instructions="Use this skill to draft release notes.",
)
]
)
monkeypatch.setattr(LiteLLMSkillsHandler, "list_skills", list_skills)
auth = UserAPIKeyAuth(user_id="user-1")
client = _client(
{"enabled": True, "agent_skills": {"enabled": True}},
auth=auth,
)
response = client.get("/.well-known/agent-skills/index.json")
assert response.status_code == 200
assert response.json() == {
"skills": [
{
"name": "litellm-skill-writer",
"description": "Draft clean release notes",
"files": ["SKILL.md"],
}
]
}
list_skills.assert_awaited_once_with(limit=1000, offset=0, user_api_key_dict=auth)
def test_should_serve_agent_skills_markdown_from_well_known_path(monkeypatch):
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
monkeypatch.setattr(
LiteLLMSkillsHandler,
"list_skills",
AsyncMock(
return_value=[
LiteLLM_SkillsTable(
skill_id="litellm_skill_writer",
display_title="Writer",
description="Draft clean release notes",
instructions="Use this skill to draft release notes.",
)
]
),
)
client = _client(
{"enabled": True, "agent_skills": {"enabled": True}},
auth=UserAPIKeyAuth(user_id="user-1"),
)
response = client.get("/.well-known/agent-skills/litellm-skill-writer/SKILL.md")
assert response.status_code == 200
assert response.text == (
"---\n"
"name: litellm-skill-writer\n"
"description: Draft clean release notes\n"
"---\n\n"
"# Writer\n\n"
"Use this skill to draft release notes.\n"
)
def test_should_serve_legacy_agent_skills_well_known_alias(monkeypatch):
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
monkeypatch.setattr(
LiteLLMSkillsHandler,
"list_skills",
AsyncMock(
return_value=[
LiteLLM_SkillsTable(
skill_id="litellm_skill_enabled",
metadata={"enabled": True},
),
LiteLLM_SkillsTable(
skill_id="litellm_skill_disabled",
metadata={"enabled": False},
),
]
),
)
client = _client(
{"enabled": True, "agent_skills": {"enabled": True}},
auth=UserAPIKeyAuth(user_id="user-1"),
)
response = client.get("/.well-known/skills/index.json")
assert response.status_code == 200
assert response.json() == {
"skills": [
{
"name": "litellm-skill-enabled",
"description": "litellm_skill_enabled",
"files": ["SKILL.md"],
}
]
}
def test_should_omit_disabled_litellm_skills(monkeypatch):
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler