mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(proxy): add agent skills discovery endpoint
This commit is contained in:
parent
5ba4a4e5f9
commit
2f0ca013ae
3 changed files with 194 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue