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

1"""Serve skills stored on the proxy as an Agent Skills well-known discovery index. 

2 

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""" 

8 

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 

16 

17from fastapi import APIRouter, Depends, HTTPException, Request, Response 

18 

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) 

30 

31MAX_INDEXED_SKILLS: Final = 1000 

32MAX_CACHED_ARCHIVES: Final = 128 

33MAX_CACHED_ARCHIVE_BYTES: Final = 512 * 1024 

34ARCHIVE_CACHE_TTL_SECONDS: Final = 3600 

35 

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) 

41 

42_NON_SLUG_PATTERN: Final = re.compile(r"[^a-z0-9]+") 

43_FALLBACK_SKILL_NAME: Final = "skill" 

44 

45router: Final = APIRouter(tags=["public", "skills"]) # mutable-ok: fastapi types tags as list[str | Enum] 

46 

47 

48class ZipArchiveResponse(Response): 

49 """Response whose OpenAPI entry declares an application/zip download rather than JSON.""" 

50 

51 media_type = "application/zip" 

52 

53 

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") 

57 

58 

59async def stored_skills() -> Sequence[LiteLLM_SkillsTable]: 

60 from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler 

61 

62 return await LiteLLMSkillsHandler.list_skills(limit=MAX_INDEXED_SKILLS) 

63 

64 

65async def stored_skill(skill_id: str) -> LiteLLM_SkillsTable | None: 

66 from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler 

67 

68 try: 

69 return await LiteLLMSkillsHandler.get_skill(skill_id) 

70 except ValueError: 

71 return None 

72 

73 

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 

91 

92 installable: Final = await _installable(skills) 

93 names: Final = _deduplicated(tuple(_base_name(skill, archive) for skill, archive in installable)) 

94 

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 ) 

110 

111 

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}") 

125 

126 return ZipArchiveResponse( 

127 content=archive.content, 

128 headers=MappingProxyType({"Content-Disposition": f'attachment; filename="{skill_id}.zip"'}), 

129 ) 

130 

131 

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) 

137 

138 

139async def _archive_for(skill: LiteLLM_SkillsTable) -> SkillArchive | None: 

140 if skill.file_content is None: 

141 return None 

142 

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 

147 

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 

156 

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 

160 

161 

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 ) 

168 

169 

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 

174 

175 

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)) 

185 

186 

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}" 

192 

193 

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]