From 2f0ca013ae313751ef30132a690579d098dc0447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 22 Jun 2026 17:15:49 +0200 Subject: [PATCH] feat(proxy): add agent skills discovery endpoint --- .../opencode_endpoints/skills_endpoints.py | 71 +++++++++++ litellm/proxy/proxy_server.py | 5 + .../proxy/test_opencode_skills_endpoints.py | 118 ++++++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/litellm/proxy/opencode_endpoints/skills_endpoints.py b/litellm/proxy/opencode_endpoints/skills_endpoints.py index 916c05c844c..f4b7eca46ca 100644 --- a/litellm/proxy/opencode_endpoints/skills_endpoints.py +++ b/litellm/proxy/opencode_endpoints/skills_endpoints.py @@ -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) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 73136f20224..f2ca8b30523 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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 diff --git a/tests/test_litellm/proxy/test_opencode_skills_endpoints.py b/tests/test_litellm/proxy/test_opencode_skills_endpoints.py index 46d55f17063..d39c54b5b75 100644 --- a/tests/test_litellm/proxy/test_opencode_skills_endpoints.py +++ b/tests/test_litellm/proxy/test_opencode_skills_endpoints.py @@ -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