Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/discovery_endpoints/agent_skills_endpoints.py: 42%
85 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""Serve skills stored on the proxy as an Agent Skills well-known discovery index.
3``npx skills add <proxy url> -a <agent>`` reads ``/.well-known/agent-skills/index.json``
4and downloads each entry's archive. Discovery clients send no credentials, so both
5routes are unauthenticated and stay off until ``litellm_settings.public_skills_index``
6is enabled, which publishes every stored skill to anyone who can reach the proxy.
7"""
9import asyncio
10import re
11from collections.abc import Sequence
12from itertools import groupby
13from operator import itemgetter
14from types import MappingProxyType
15from typing import Final
17from fastapi import APIRouter, Depends, HTTPException, Request, Response
19import litellm
20from litellm._logging import verbose_proxy_logger
21from litellm.caching.in_memory_cache import InMemoryCache
22from litellm.models.skills import LiteLLM_SkillsTable
23from litellm.proxy.discovery_endpoints.agent_skills_archive import SkillArchive, build_skill_archive
24from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import (
25 MAX_SKILL_DESCRIPTION_LENGTH,
26 MAX_SKILL_NAME_LENGTH,
27 AgentSkillsIndex,
28 AgentSkillsIndexEntry,
29)
31MAX_INDEXED_SKILLS: Final = 1000
32MAX_CACHED_ARCHIVES: Final = 128
33MAX_CACHED_ARCHIVE_BYTES: Final = 512 * 1024
34ARCHIVE_CACHE_TTL_SECONDS: Final = 3600
36_ARCHIVE_CACHE: Final = InMemoryCache(
37 max_size_in_memory=MAX_CACHED_ARCHIVES,
38 default_ttl=ARCHIVE_CACHE_TTL_SECONDS,
39 max_size_per_item=MAX_CACHED_ARCHIVE_BYTES // 1024,
40)
42_NON_SLUG_PATTERN: Final = re.compile(r"[^a-z0-9]+")
43_FALLBACK_SKILL_NAME: Final = "skill"
45router: Final = APIRouter(tags=["public", "skills"]) # mutable-ok: fastapi types tags as list[str | Enum]
48class ZipArchiveResponse(Response):
49 """Response whose OpenAPI entry declares an application/zip download rather than JSON."""
51 media_type = "application/zip"
54def ensure_index_enabled() -> None:
55 if litellm.public_skills_index is not True: 55 ↛ exitline 55 didn't return from function 'ensure_index_enabled' because the condition on line 55 was always true
56 raise HTTPException(status_code=404, detail="Not Found")
59async def stored_skills() -> Sequence[LiteLLM_SkillsTable]:
60 from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
62 return await LiteLLMSkillsHandler.list_skills(limit=MAX_INDEXED_SKILLS)
65async def stored_skill(skill_id: str) -> LiteLLM_SkillsTable | None:
66 from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
68 try:
69 return await LiteLLMSkillsHandler.get_skill(skill_id)
70 except ValueError:
71 return None
74@router.get(
75 "/.well-known/agent-skills/index.json",
76 response_model=AgentSkillsIndex,
77 dependencies=(Depends(ensure_index_enabled),),
78)
79@router.get(
80 "/.well-known/skills/index.json",
81 response_model=AgentSkillsIndex,
82 dependencies=(Depends(ensure_index_enabled),),
83 include_in_schema=False,
84)
85async def agent_skills_index(
86 request: Request,
87 skills: Sequence[LiteLLM_SkillsTable] = Depends(stored_skills),
88) -> AgentSkillsIndex:
89 """Agent Skills v0.2.0 discovery index over every skill stored on this proxy."""
90 from litellm.proxy.utils import get_custom_url
92 installable: Final = await _installable(skills)
93 names: Final = _deduplicated(tuple(_base_name(skill, archive) for skill, archive in installable))
95 return AgentSkillsIndex(
96 skills=tuple(
97 AgentSkillsIndexEntry(
98 name=name,
99 type="archive",
100 description=_description(skill, archive, name),
101 url=get_custom_url(
102 request_base_url=str(request.base_url),
103 route=f"v1/skills/{skill.skill_id}/archive",
104 ),
105 digest=archive.digest,
106 )
107 for (skill, archive), name in zip(installable, names, strict=True)
108 )
109 )
112@router.get(
113 "/v1/skills/{skill_id}/archive",
114 dependencies=(Depends(ensure_index_enabled),),
115 response_class=ZipArchiveResponse,
116)
117async def agent_skills_archive(
118 skill_id: str,
119 skill: LiteLLM_SkillsTable | None = Depends(stored_skill),
120) -> ZipArchiveResponse:
121 """Stored skill upload, repacked so SKILL.md sits at the archive root."""
122 archive: Final = await _archive_for(skill) if skill is not None else None
123 if archive is None:
124 raise HTTPException(status_code=404, detail=f"No installable skill archive for: {skill_id}")
126 return ZipArchiveResponse(
127 content=archive.content,
128 headers=MappingProxyType({"Content-Disposition": f'attachment; filename="{skill_id}.zip"'}),
129 )
132async def _installable(
133 skills: Sequence[LiteLLM_SkillsTable],
134) -> tuple[tuple[LiteLLM_SkillsTable, SkillArchive], ...]:
135 built: Final = tuple([(skill, await _archive_for(skill)) for skill in reversed(skills)])
136 return tuple((skill, archive) for skill, archive in built if archive is not None)
139async def _archive_for(skill: LiteLLM_SkillsTable) -> SkillArchive | None:
140 if skill.file_content is None:
141 return None
143 cache_key: Final = None if skill.updated_at is None else f"{skill.skill_id}:{skill.updated_at.isoformat()}"
144 cached: Final = None if cache_key is None else _ARCHIVE_CACHE.get_cache(cache_key)
145 if isinstance(cached, SkillArchive):
146 return cached
148 archive: Final = await asyncio.to_thread(build_skill_archive, skill.file_content)
149 if archive is None:
150 verbose_proxy_logger.warning(
151 "Agent Skills index: skipping skill %s, its upload is not a zip holding SKILL.md at the root of a "
152 "single top-level folder",
153 skill.skill_id,
154 )
155 return None
157 if cache_key is not None and len(archive.content) <= MAX_CACHED_ARCHIVE_BYTES:
158 _ARCHIVE_CACHE.set_cache(cache_key, archive)
159 return archive
162def _base_name(skill: LiteLLM_SkillsTable, archive: SkillArchive) -> str:
163 candidates: Final = (archive.declared_name, skill.display_title, skill.skill_id)
164 return next(
165 (slug for slug in (_slugify(candidate) for candidate in candidates) if slug is not None),
166 _FALLBACK_SKILL_NAME,
167 )
170def _slugify(raw: str | None) -> str | None:
171 if raw is None:
172 return None
173 return _NON_SLUG_PATTERN.sub("-", raw.lower()).strip("-")[:MAX_SKILL_NAME_LENGTH].rstrip("-") or None
176def _deduplicated(names: Sequence[str]) -> tuple[str, ...]:
177 ordinals: Final = MappingProxyType(
178 {
179 position: ordinal
180 for _, duplicates in groupby(sorted(enumerate(names), key=itemgetter(1)), key=itemgetter(1))
181 for ordinal, (position, _) in enumerate(duplicates)
182 }
183 )
184 return tuple(_with_ordinal(name, ordinals[position]) for position, name in enumerate(names))
187def _with_ordinal(name: str, ordinal: int) -> str:
188 if ordinal == 0:
189 return name
190 suffix: Final = f"-{ordinal + 1}"
191 return f"{name[: MAX_SKILL_NAME_LENGTH - len(suffix)].rstrip('-')}{suffix}"
194def _description(skill: LiteLLM_SkillsTable, archive: SkillArchive, name: str) -> str:
195 candidates: Final = (archive.declared_description, skill.description, skill.display_title)
196 chosen: Final = next(
197 (candidate.strip() for candidate in candidates if candidate is not None and candidate.strip()),
198 name,
199 )
200 return chosen[:MAX_SKILL_DESCRIPTION_LENGTH]