mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge pull request #40770 from BerriAI/litellm_lit6996_agent_skills_wellknown
feat(proxy): serve registered skills as an Agent Skills well-known index
This commit is contained in:
commit
fc053dae3e
10 changed files with 880 additions and 2 deletions
|
|
@ -501,6 +501,7 @@ disable_copilot_system_to_assistant: bool = False # If false (default), convert
|
|||
public_mcp_servers: Optional[List[str]] = None
|
||||
public_mcp_hub_strict_whitelist: bool = True
|
||||
public_model_groups: Optional[List[str]] = None
|
||||
public_skills_index: bool = False
|
||||
public_agent_groups: Optional[List[str]] = None
|
||||
agent_search_embedding_model: Optional[str] = None
|
||||
mcp_tool_search: Optional[Mapping[str, object]] = None
|
||||
|
|
|
|||
|
|
@ -5145,6 +5145,49 @@
|
|||
"anthropic_skills"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/skills/{skill_id}/archive": {
|
||||
"get": {
|
||||
"description": "Stored skill upload, repacked so SKILL.md sits at the archive root.",
|
||||
"operationId": "agent_skills_archive_v1_skills__skill_id__archive_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "skill_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Skill Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/zip": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Agent Skills Archive",
|
||||
"tags": [
|
||||
"anthropic_skills"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from .agent_skills_endpoints import router as agent_skills_discovery_router
|
||||
from .ui_discovery_endpoints import router as ui_discovery_endpoints_router
|
||||
|
||||
__all__ = ["ui_discovery_endpoints_router"]
|
||||
__all__ = ["agent_skills_discovery_router", "ui_discovery_endpoints_router"]
|
||||
|
|
|
|||
130
litellm/proxy/discovery_endpoints/agent_skills_archive.py
Normal file
130
litellm/proxy/discovery_endpoints/agent_skills_archive.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Repack a stored skill upload into the archive shape Agent Skills clients install from.
|
||||
|
||||
Uploads follow the Anthropic Skills API layout, where every file sits under a single
|
||||
top-level folder. Discovery clients read ``SKILL.md`` from the archive root, so that
|
||||
folder is stripped and the zip is rebuilt with fixed entry timestamps, which keeps the
|
||||
SHA-256 digest published in the index reproducible for identical uploads.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import yaml
|
||||
|
||||
MAX_ARCHIVE_UNPACKED_BYTES: Final = 50 * 1024 * 1024
|
||||
MAX_ARCHIVE_ENTRIES: Final = 1000
|
||||
SKILL_MANIFEST_FILENAME: Final = "SKILL.md"
|
||||
|
||||
_ZIP_ENTRY_TIMESTAMP: Final = (1980, 1, 1, 0, 0, 0)
|
||||
_ZIP_ENTRY_PERMISSIONS: Final = 0o644 << 16
|
||||
_FRONTMATTER_PATTERN: Final = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL)
|
||||
_WINDOWS_DRIVE_PATTERN: Final = re.compile(r"^[A-Za-z]:")
|
||||
_EMPTY_FRONTMATTER: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillArchive:
|
||||
content: bytes
|
||||
digest: str
|
||||
declared_name: str | None
|
||||
declared_description: str | None
|
||||
|
||||
|
||||
def build_skill_archive(stored_content: bytes) -> SkillArchive | None:
|
||||
"""Return the installable archive for an upload, or None when it holds no root SKILL.md."""
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(stored_content)) as uploaded:
|
||||
members: Final = _flattened_members(uploaded)
|
||||
except (zipfile.BadZipFile, OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
if members is None:
|
||||
return None
|
||||
|
||||
frontmatter: Final = _manifest_frontmatter(next(data for name, data in members if name == SKILL_MANIFEST_FILENAME))
|
||||
content: Final = _repack(members)
|
||||
return SkillArchive(
|
||||
content=content,
|
||||
digest=f"sha256:{hashlib.sha256(content).hexdigest()}",
|
||||
declared_name=_frontmatter_text(frontmatter, "name"),
|
||||
declared_description=_frontmatter_text(frontmatter, "description"),
|
||||
)
|
||||
|
||||
|
||||
def _flattened_members(uploaded: zipfile.ZipFile) -> tuple[tuple[str, bytes], ...] | None:
|
||||
infos: Final = tuple(info for info in uploaded.infolist() if not info.is_dir())
|
||||
if not infos or len(infos) > MAX_ARCHIVE_ENTRIES:
|
||||
return None
|
||||
if sum(info.file_size for info in infos) > MAX_ARCHIVE_UNPACKED_BYTES:
|
||||
return None
|
||||
|
||||
normalized: Final = tuple((info, _normalized_path(info.filename)) for info in infos)
|
||||
if any(path is None for _, path in normalized):
|
||||
return None
|
||||
|
||||
prefix: Final = _common_root_prefix(tuple(path for _, path in normalized if path is not None))
|
||||
flattened: Final = tuple((info, path[len(prefix) :]) for info, path in normalized if path is not None)
|
||||
names: Final = frozenset(name for _, name in flattened)
|
||||
if SKILL_MANIFEST_FILENAME not in names or len(names) != len(flattened):
|
||||
return None
|
||||
|
||||
return tuple((name, uploaded.read(info)) for info, name in sorted(flattened, key=lambda member: member[1]))
|
||||
|
||||
|
||||
def _common_root_prefix(paths: tuple[str, ...]) -> str:
|
||||
roots: Final = frozenset(path.split("/", 1)[0] for path in paths)
|
||||
if len(roots) != 1 or not all("/" in path for path in paths):
|
||||
return ""
|
||||
return f"{next(iter(roots))}/"
|
||||
|
||||
|
||||
def _normalized_path(raw_path: str) -> str | None:
|
||||
if not raw_path or "\0" in raw_path or "\\" in raw_path:
|
||||
return None
|
||||
if raw_path.startswith("/") or _WINDOWS_DRIVE_PATTERN.match(raw_path):
|
||||
return None
|
||||
parts: Final = tuple(part for part in raw_path.split("/") if part)
|
||||
if not parts or any(part in (".", "..") for part in parts):
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _manifest_frontmatter(manifest: bytes) -> Mapping[str, object]:
|
||||
match: Final = _FRONTMATTER_PATTERN.match(manifest.decode("utf-8", errors="replace"))
|
||||
if match is None:
|
||||
return _EMPTY_FRONTMATTER
|
||||
try:
|
||||
parsed: Final = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return _EMPTY_FRONTMATTER
|
||||
if not isinstance(parsed, dict):
|
||||
return _EMPTY_FRONTMATTER
|
||||
return parsed
|
||||
|
||||
|
||||
def _frontmatter_text(frontmatter: Mapping[str, object], key: str) -> str | None:
|
||||
value: Final = frontmatter.get(key)
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
def _zip_entry(name: str) -> zipfile.ZipInfo:
|
||||
entry: Final = zipfile.ZipInfo(filename=name, date_time=_ZIP_ENTRY_TIMESTAMP)
|
||||
entry.compress_type = zipfile.ZIP_DEFLATED
|
||||
entry.external_attr = _ZIP_ENTRY_PERMISSIONS
|
||||
return entry
|
||||
|
||||
|
||||
def _repack(members: tuple[tuple[str, bytes], ...]) -> bytes:
|
||||
buffer: Final = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as repacked:
|
||||
for name, data in members:
|
||||
repacked.writestr(_zip_entry(name), data)
|
||||
return buffer.getvalue()
|
||||
200
litellm/proxy/discovery_endpoints/agent_skills_endpoints.py
Normal file
200
litellm/proxy/discovery_endpoints/agent_skills_endpoints.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""Serve skills stored on the proxy as an Agent Skills well-known discovery index.
|
||||
|
||||
``npx skills add <proxy url> -a <agent>`` reads ``/.well-known/agent-skills/index.json``
|
||||
and downloads each entry's archive. Discovery clients send no credentials, so both
|
||||
routes are unauthenticated and stay off until ``litellm_settings.public_skills_index``
|
||||
is enabled, which publishes every stored skill to anyone who can reach the proxy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from itertools import groupby
|
||||
from operator import itemgetter
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.models.skills import LiteLLM_SkillsTable
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import SkillArchive, build_skill_archive
|
||||
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
MAX_SKILL_DESCRIPTION_LENGTH,
|
||||
MAX_SKILL_NAME_LENGTH,
|
||||
AgentSkillsIndex,
|
||||
AgentSkillsIndexEntry,
|
||||
)
|
||||
|
||||
MAX_INDEXED_SKILLS: Final = 1000
|
||||
MAX_CACHED_ARCHIVES: Final = 128
|
||||
MAX_CACHED_ARCHIVE_BYTES: Final = 512 * 1024
|
||||
ARCHIVE_CACHE_TTL_SECONDS: Final = 3600
|
||||
|
||||
_ARCHIVE_CACHE: Final = InMemoryCache(
|
||||
max_size_in_memory=MAX_CACHED_ARCHIVES,
|
||||
default_ttl=ARCHIVE_CACHE_TTL_SECONDS,
|
||||
max_size_per_item=MAX_CACHED_ARCHIVE_BYTES // 1024,
|
||||
)
|
||||
|
||||
_NON_SLUG_PATTERN: Final = re.compile(r"[^a-z0-9]+")
|
||||
_FALLBACK_SKILL_NAME: Final = "skill"
|
||||
|
||||
router: Final = APIRouter(tags=["public", "skills"]) # mutable-ok: fastapi types tags as list[str | Enum]
|
||||
|
||||
|
||||
class ZipArchiveResponse(Response):
|
||||
"""Response whose OpenAPI entry declares an application/zip download rather than JSON."""
|
||||
|
||||
media_type = "application/zip"
|
||||
|
||||
|
||||
def ensure_index_enabled() -> None:
|
||||
if litellm.public_skills_index is not True:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
|
||||
async def stored_skills() -> Sequence[LiteLLM_SkillsTable]:
|
||||
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
|
||||
|
||||
return await LiteLLMSkillsHandler.list_skills(limit=MAX_INDEXED_SKILLS)
|
||||
|
||||
|
||||
async def stored_skill(skill_id: str) -> LiteLLM_SkillsTable | None:
|
||||
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
|
||||
|
||||
try:
|
||||
return await LiteLLMSkillsHandler.get_skill(skill_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/.well-known/agent-skills/index.json",
|
||||
response_model=AgentSkillsIndex,
|
||||
dependencies=(Depends(ensure_index_enabled),),
|
||||
)
|
||||
@router.get(
|
||||
"/.well-known/skills/index.json",
|
||||
response_model=AgentSkillsIndex,
|
||||
dependencies=(Depends(ensure_index_enabled),),
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def agent_skills_index(
|
||||
request: Request,
|
||||
skills: Sequence[LiteLLM_SkillsTable] = Depends(stored_skills),
|
||||
) -> AgentSkillsIndex:
|
||||
"""Agent Skills v0.2.0 discovery index over every skill stored on this proxy."""
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
|
||||
installable: Final = await _installable(skills)
|
||||
names: Final = _deduplicated(tuple(_base_name(skill, archive) for skill, archive in installable))
|
||||
|
||||
return AgentSkillsIndex(
|
||||
skills=tuple(
|
||||
AgentSkillsIndexEntry(
|
||||
name=name,
|
||||
type="archive",
|
||||
description=_description(skill, archive, name),
|
||||
url=get_custom_url(
|
||||
request_base_url=str(request.base_url),
|
||||
route=f"v1/skills/{skill.skill_id}/archive",
|
||||
),
|
||||
digest=archive.digest,
|
||||
)
|
||||
for (skill, archive), name in zip(installable, names, strict=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/v1/skills/{skill_id}/archive",
|
||||
dependencies=(Depends(ensure_index_enabled),),
|
||||
response_class=ZipArchiveResponse,
|
||||
)
|
||||
async def agent_skills_archive(
|
||||
skill_id: str,
|
||||
skill: LiteLLM_SkillsTable | None = Depends(stored_skill),
|
||||
) -> ZipArchiveResponse:
|
||||
"""Stored skill upload, repacked so SKILL.md sits at the archive root."""
|
||||
archive: Final = await _archive_for(skill) if skill is not None else None
|
||||
if archive is None:
|
||||
raise HTTPException(status_code=404, detail=f"No installable skill archive for: {skill_id}")
|
||||
|
||||
return ZipArchiveResponse(
|
||||
content=archive.content,
|
||||
headers=MappingProxyType({"Content-Disposition": f'attachment; filename="{skill_id}.zip"'}),
|
||||
)
|
||||
|
||||
|
||||
async def _installable(
|
||||
skills: Sequence[LiteLLM_SkillsTable],
|
||||
) -> tuple[tuple[LiteLLM_SkillsTable, SkillArchive], ...]:
|
||||
built: Final = tuple([(skill, await _archive_for(skill)) for skill in reversed(skills)])
|
||||
return tuple((skill, archive) for skill, archive in built if archive is not None)
|
||||
|
||||
|
||||
async def _archive_for(skill: LiteLLM_SkillsTable) -> SkillArchive | None:
|
||||
if skill.file_content is None:
|
||||
return None
|
||||
|
||||
cache_key: Final = None if skill.updated_at is None else f"{skill.skill_id}:{skill.updated_at.isoformat()}"
|
||||
cached: Final = None if cache_key is None else _ARCHIVE_CACHE.get_cache(cache_key)
|
||||
if isinstance(cached, SkillArchive):
|
||||
return cached
|
||||
|
||||
archive: Final = await asyncio.to_thread(build_skill_archive, skill.file_content)
|
||||
if archive is None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Agent Skills index: skipping skill %s, its upload is not a zip holding SKILL.md at the root of a "
|
||||
"single top-level folder",
|
||||
skill.skill_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if cache_key is not None and len(archive.content) <= MAX_CACHED_ARCHIVE_BYTES:
|
||||
_ARCHIVE_CACHE.set_cache(cache_key, archive)
|
||||
return archive
|
||||
|
||||
|
||||
def _base_name(skill: LiteLLM_SkillsTable, archive: SkillArchive) -> str:
|
||||
candidates: Final = (archive.declared_name, skill.display_title, skill.skill_id)
|
||||
return next(
|
||||
(slug for slug in (_slugify(candidate) for candidate in candidates) if slug is not None),
|
||||
_FALLBACK_SKILL_NAME,
|
||||
)
|
||||
|
||||
|
||||
def _slugify(raw: str | None) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
return _NON_SLUG_PATTERN.sub("-", raw.lower()).strip("-")[:MAX_SKILL_NAME_LENGTH].rstrip("-") or None
|
||||
|
||||
|
||||
def _deduplicated(names: Sequence[str]) -> tuple[str, ...]:
|
||||
ordinals: Final = MappingProxyType(
|
||||
{
|
||||
position: ordinal
|
||||
for _, duplicates in groupby(sorted(enumerate(names), key=itemgetter(1)), key=itemgetter(1))
|
||||
for ordinal, (position, _) in enumerate(duplicates)
|
||||
}
|
||||
)
|
||||
return tuple(_with_ordinal(name, ordinals[position]) for position, name in enumerate(names))
|
||||
|
||||
|
||||
def _with_ordinal(name: str, ordinal: int) -> str:
|
||||
if ordinal == 0:
|
||||
return name
|
||||
suffix: Final = f"-{ordinal + 1}"
|
||||
return f"{name[: MAX_SKILL_NAME_LENGTH - len(suffix)].rstrip('-')}{suffix}"
|
||||
|
||||
|
||||
def _description(skill: LiteLLM_SkillsTable, archive: SkillArchive, name: str) -> str:
|
||||
candidates: Final = (archive.declared_description, skill.description, skill.display_title)
|
||||
chosen: Final = next(
|
||||
(candidate.strip() for candidate in candidates if candidate is not None and candidate.strip()),
|
||||
name,
|
||||
)
|
||||
return chosen[:MAX_SKILL_DESCRIPTION_LENGTH]
|
||||
|
|
@ -448,7 +448,10 @@ from litellm.proxy.db.proxy_worker_heartbeat import (
|
|||
ProxyWorkerHeartbeat,
|
||||
)
|
||||
from litellm.proxy.db.spend_counter_reseed import END_USER_COUNTER_PREFIX, SpendCounterReseed
|
||||
from litellm.proxy.discovery_endpoints import ui_discovery_endpoints_router
|
||||
from litellm.proxy.discovery_endpoints import (
|
||||
agent_skills_discovery_router,
|
||||
ui_discovery_endpoints_router,
|
||||
)
|
||||
from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router
|
||||
from litellm.proxy.fine_tuning_endpoints.endpoints import set_fine_tuning_config
|
||||
from litellm.proxy.google_endpoints.endpoints import router as google_router
|
||||
|
|
@ -18798,6 +18801,7 @@ app.include_router(user_agent_analytics_router)
|
|||
app.include_router(gateway_request_router)
|
||||
app.include_router(enterprise_router)
|
||||
app.include_router(ui_discovery_endpoints_router)
|
||||
app.include_router(agent_skills_discovery_router)
|
||||
# Eager: /models/{name}:method overlaps with the OpenAI /models endpoint.
|
||||
app.include_router(google_router)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
"""Agent Skills discovery index, version 0.2.0.
|
||||
|
||||
Schema: https://schemas.agentskills.io/discovery/0.2.0/schema.json
|
||||
"""
|
||||
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
AGENT_SKILLS_DISCOVERY_SCHEMA_URL: Final = "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
|
||||
MAX_SKILL_NAME_LENGTH: Final = 64
|
||||
MAX_SKILL_DESCRIPTION_LENGTH: Final = 1024
|
||||
|
||||
|
||||
class AgentSkillsIndexEntry(BaseModel):
|
||||
name: str
|
||||
type: Literal["archive"]
|
||||
description: str
|
||||
url: str
|
||||
digest: str
|
||||
|
||||
|
||||
class AgentSkillsIndex(BaseModel):
|
||||
discovery_schema: str = Field(default=AGENT_SKILLS_DISCOVERY_SCHEMA_URL, alias="$schema")
|
||||
skills: tuple[AgentSkillsIndexEntry, ...]
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import hashlib
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
|
||||
MAX_ARCHIVE_ENTRIES,
|
||||
build_skill_archive,
|
||||
)
|
||||
|
||||
MANIFEST = b"""---
|
||||
name: pdf-summarizer
|
||||
description: Summarize a PDF into an executive brief.
|
||||
---
|
||||
|
||||
Read the PDF, then write the brief.
|
||||
"""
|
||||
|
||||
|
||||
def zip_bytes(files: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, content in files.items():
|
||||
archive.writestr(name, content)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def entries_of(content: bytes) -> dict[str, bytes]:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
|
||||
def test_single_top_level_folder_is_stripped_so_skill_md_sits_at_the_root():
|
||||
archive = build_skill_archive(
|
||||
zip_bytes(
|
||||
{
|
||||
"pdf-summarizer/SKILL.md": MANIFEST,
|
||||
"pdf-summarizer/reference.md": b"page citations",
|
||||
"pdf-summarizer/scripts/extract.py": b"print('hi')",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert archive is not None
|
||||
assert entries_of(archive.content) == {
|
||||
"SKILL.md": MANIFEST,
|
||||
"reference.md": b"page citations",
|
||||
"scripts/extract.py": b"print('hi')",
|
||||
}
|
||||
|
||||
|
||||
def test_digest_covers_the_repacked_bytes_and_is_stable_across_builds():
|
||||
upload = zip_bytes({"pdf-summarizer/SKILL.md": MANIFEST, "pdf-summarizer/reference.md": b"page citations"})
|
||||
|
||||
first = build_skill_archive(upload)
|
||||
second = build_skill_archive(upload)
|
||||
|
||||
assert first is not None and second is not None
|
||||
assert first.digest == f"sha256:{hashlib.sha256(first.content).hexdigest()}"
|
||||
assert first.content == second.content
|
||||
|
||||
|
||||
def test_an_upload_that_is_already_flat_keeps_every_file_where_it_is():
|
||||
archive = build_skill_archive(zip_bytes({"SKILL.md": MANIFEST, "reference.md": b"page citations"}))
|
||||
|
||||
assert archive is not None
|
||||
assert sorted(entries_of(archive.content)) == ["SKILL.md", "reference.md"]
|
||||
|
||||
|
||||
def test_manifest_frontmatter_supplies_the_declared_name_and_description():
|
||||
archive = build_skill_archive(zip_bytes({"pdf-summarizer/SKILL.md": MANIFEST}))
|
||||
|
||||
assert archive is not None
|
||||
assert archive.declared_name == "pdf-summarizer"
|
||||
assert archive.declared_description == "Summarize a PDF into an executive brief."
|
||||
|
||||
|
||||
def test_a_manifest_without_frontmatter_declares_nothing():
|
||||
archive = build_skill_archive(zip_bytes({"pdf-summarizer/SKILL.md": b"just prose, no frontmatter"}))
|
||||
|
||||
assert archive is not None
|
||||
assert archive.declared_name is None
|
||||
assert archive.declared_description is None
|
||||
|
||||
|
||||
def test_a_manifest_buried_below_the_stripped_folder_is_not_installable():
|
||||
assert build_skill_archive(zip_bytes({"pdf-summarizer/nested/SKILL.md": MANIFEST})) is None
|
||||
|
||||
|
||||
def test_an_upload_with_no_manifest_is_not_installable():
|
||||
assert build_skill_archive(zip_bytes({"pdf-summarizer/reference.md": b"page citations"})) is None
|
||||
|
||||
|
||||
def test_a_non_zip_upload_is_not_installable():
|
||||
assert build_skill_archive(MANIFEST) is None
|
||||
|
||||
|
||||
def test_a_path_traversal_entry_is_not_installable():
|
||||
assert build_skill_archive(zip_bytes({"SKILL.md": MANIFEST, "../escape.md": b"nope"})) is None
|
||||
|
||||
|
||||
def test_an_upload_over_the_entry_cap_is_not_installable():
|
||||
files = {"pdf-summarizer/SKILL.md": MANIFEST} | {
|
||||
f"pdf-summarizer/file-{index}.md": b"x" for index in range(MAX_ARCHIVE_ENTRIES)
|
||||
}
|
||||
|
||||
assert build_skill_archive(zip_bytes(files)) is None
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import hashlib
|
||||
import io
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm
|
||||
from litellm.models.skills import LiteLLM_SkillsTable
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
router,
|
||||
stored_skill,
|
||||
stored_skills,
|
||||
)
|
||||
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
AGENT_SKILLS_DISCOVERY_SCHEMA_URL,
|
||||
)
|
||||
|
||||
WELL_KNOWN_PATHS = ("/.well-known/agent-skills/index.json", "/.well-known/skills/index.json")
|
||||
|
||||
MANIFEST = b"""---
|
||||
name: pdf-summarizer
|
||||
description: Summarize a PDF into an executive brief.
|
||||
---
|
||||
|
||||
Read the PDF, then write the brief.
|
||||
"""
|
||||
|
||||
|
||||
def zip_bytes(files: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, content in files.items():
|
||||
archive.writestr(name, content)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def skill(
|
||||
skill_id: str,
|
||||
*,
|
||||
display_title: str | None = "PDF Summarizer",
|
||||
description: str | None = None,
|
||||
files: dict[str, bytes] | None = None,
|
||||
updated_at: datetime | None = None,
|
||||
) -> LiteLLM_SkillsTable:
|
||||
return LiteLLM_SkillsTable(
|
||||
skill_id=skill_id,
|
||||
display_title=display_title,
|
||||
description=description,
|
||||
file_content=zip_bytes(files if files is not None else {"pdf-summarizer/SKILL.md": MANIFEST}),
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def client_for(*skills: LiteLLM_SkillsTable) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
def _skills() -> tuple[LiteLLM_SkillsTable, ...]:
|
||||
return skills
|
||||
|
||||
def _skill(skill_id: str) -> LiteLLM_SkillsTable | None:
|
||||
return next((candidate for candidate in skills if candidate.skill_id == skill_id), None)
|
||||
|
||||
app.dependency_overrides[stored_skills] = _skills
|
||||
app.dependency_overrides[stored_skill] = _skill
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_enabled(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "public_skills_index", True)
|
||||
|
||||
|
||||
def test_discovery_is_absent_until_public_skills_index_is_enabled(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "public_skills_index", False)
|
||||
client = client_for(skill("litellm_skill_1"))
|
||||
|
||||
for path in WELL_KNOWN_PATHS:
|
||||
assert client.get(path).status_code == 404
|
||||
assert client.get("/v1/skills/litellm_skill_1/archive").status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", WELL_KNOWN_PATHS)
|
||||
def test_index_publishes_each_stored_skill_in_the_v0_2_0_shape(index_enabled, path):
|
||||
client = client_for(skill("litellm_skill_1"))
|
||||
|
||||
body = client.get(path).json()
|
||||
|
||||
assert body["$schema"] == AGENT_SKILLS_DISCOVERY_SCHEMA_URL
|
||||
assert len(body["skills"]) == 1
|
||||
entry = body["skills"][0]
|
||||
assert entry["name"] == "pdf-summarizer"
|
||||
assert entry["type"] == "archive"
|
||||
assert entry["description"] == "Summarize a PDF into an executive brief."
|
||||
assert entry["url"].endswith("/v1/skills/litellm_skill_1/archive")
|
||||
assert entry["digest"].startswith("sha256:")
|
||||
|
||||
|
||||
def test_index_digest_matches_the_bytes_the_archive_route_serves(index_enabled):
|
||||
client = client_for(skill("litellm_skill_1"))
|
||||
|
||||
entry = client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]
|
||||
downloaded = client.get(entry["url"])
|
||||
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.headers["content-type"] == "application/zip"
|
||||
assert entry["digest"] == f"sha256:{hashlib.sha256(downloaded.content).hexdigest()}"
|
||||
|
||||
|
||||
def test_install_name_falls_back_to_the_manifest_name_without_a_display_title(index_enabled):
|
||||
client = client_for(skill("litellm_skill_1", display_title=None))
|
||||
|
||||
assert client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["name"] == "pdf-summarizer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest, stored_description, expected",
|
||||
[
|
||||
(MANIFEST, "registry copy", "Summarize a PDF into an executive brief."),
|
||||
(b"no frontmatter here", "registry copy", "registry copy"),
|
||||
(b"no frontmatter here", None, "PDF Summarizer"),
|
||||
],
|
||||
)
|
||||
def test_description_prefers_the_manifest_then_the_registry_then_the_title(
|
||||
index_enabled, manifest, stored_description, expected
|
||||
):
|
||||
client = client_for(
|
||||
skill(
|
||||
"litellm_skill_1",
|
||||
description=stored_description,
|
||||
files={"pdf-summarizer/SKILL.md": manifest},
|
||||
)
|
||||
)
|
||||
|
||||
assert client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["description"] == expected
|
||||
|
||||
|
||||
def test_skills_sharing_a_title_get_distinct_install_names(index_enabled):
|
||||
client = client_for(
|
||||
skill("litellm_skill_2", files={"pdf-summarizer/SKILL.md": b"second"}),
|
||||
skill("litellm_skill_1", files={"pdf-summarizer/SKILL.md": b"first"}),
|
||||
)
|
||||
|
||||
names = [entry["name"] for entry in client.get(WELL_KNOWN_PATHS[0]).json()["skills"]]
|
||||
|
||||
assert names == ["pdf-summarizer", "pdf-summarizer-2"]
|
||||
|
||||
|
||||
def test_uploads_without_a_root_manifest_are_left_out_of_the_index(index_enabled):
|
||||
client = client_for(
|
||||
skill("litellm_skill_1"),
|
||||
skill("litellm_skill_2", files={"pdf-summarizer/reference.md": b"no manifest"}),
|
||||
)
|
||||
|
||||
body = client.get(WELL_KNOWN_PATHS[0]).json()
|
||||
|
||||
assert [entry["url"].split("/")[-2] for entry in body["skills"]] == ["litellm_skill_1"]
|
||||
assert client.get("/v1/skills/litellm_skill_2/archive").status_code == 404
|
||||
|
||||
|
||||
def test_archive_route_404s_for_a_skill_that_does_not_exist(index_enabled):
|
||||
client = client_for(skill("litellm_skill_1"))
|
||||
|
||||
assert client.get("/v1/skills/litellm_skill_missing/archive").status_code == 404
|
||||
|
||||
|
||||
def test_a_stored_skill_is_repacked_once_per_version(index_enabled):
|
||||
stamp = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc)
|
||||
first = client_for(skill("litellm_skill_cached", files={"s/SKILL.md": MANIFEST}, updated_at=stamp))
|
||||
published = first.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"]
|
||||
|
||||
unchanged_row = client_for(
|
||||
skill("litellm_skill_cached", files={"s/SKILL.md": MANIFEST, "s/extra.md": b"rewritten"}, updated_at=stamp)
|
||||
)
|
||||
|
||||
assert unchanged_row.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"] == published
|
||||
assert hashlib.sha256(unchanged_row.get("/v1/skills/litellm_skill_cached/archive").content).hexdigest() == (
|
||||
published.removeprefix("sha256:")
|
||||
)
|
||||
|
||||
|
||||
def test_a_skill_edited_since_the_last_read_is_republished(index_enabled):
|
||||
stamp = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc)
|
||||
before = client_for(skill("litellm_skill_edited", files={"s/SKILL.md": MANIFEST}, updated_at=stamp))
|
||||
published = before.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"]
|
||||
|
||||
after = client_for(
|
||||
skill(
|
||||
"litellm_skill_edited",
|
||||
files={"s/SKILL.md": MANIFEST, "s/extra.md": b"rewritten"},
|
||||
updated_at=datetime(2026, 9, 6, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
)
|
||||
republished = after.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"]
|
||||
|
||||
assert republished != published
|
||||
assert hashlib.sha256(after.get("/v1/skills/litellm_skill_edited/archive").content).hexdigest() == (
|
||||
republished.removeprefix("sha256:")
|
||||
)
|
||||
|
||||
|
||||
def test_openapi_declares_the_archive_route_as_a_zip_download(index_enabled):
|
||||
schema = client_for(skill("litellm_skill_1")).get("/openapi.json").json()
|
||||
|
||||
content = schema["paths"]["/v1/skills/{skill_id}/archive"]["get"]["responses"]["200"]["content"]
|
||||
|
||||
assert "application/zip" in content
|
||||
assert "application/json" not in content
|
||||
157
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
157
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -21,6 +21,26 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/.well-known/agent-skills/index.json": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Agent Skills Index
|
||||
* @description Agent Skills v0.2.0 discovery index over every skill stored on this proxy.
|
||||
*/
|
||||
get: operations["agent_skills_index__well_known_agent_skills_index_json_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/.well-known/jwks.json": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -310,6 +330,26 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/.well-known/skills/index.json": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Agent Skills Index
|
||||
* @description Agent Skills v0.2.0 discovery index over every skill stored on this proxy.
|
||||
*/
|
||||
get: operations["agent_skills_index__well_known_skills_index_json_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/a2a/{agent_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -19982,6 +20022,26 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/skills/{skill_id}/archive": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Agent Skills Archive
|
||||
* @description Stored skill upload, repacked so SKILL.md sits at the archive root.
|
||||
*/
|
||||
get: operations["agent_skills_archive_v1_skills__skill_id__archive_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/threads": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -23138,6 +23198,32 @@ export interface components {
|
|||
/** Tags */
|
||||
tags?: string[];
|
||||
};
|
||||
/** AgentSkillsIndex */
|
||||
AgentSkillsIndex: {
|
||||
/**
|
||||
* $Schema
|
||||
* @default https://schemas.agentskills.io/discovery/0.2.0/schema.json
|
||||
*/
|
||||
$schema: string;
|
||||
/** Skills */
|
||||
skills: components["schemas"]["AgentSkillsIndexEntry"][];
|
||||
};
|
||||
/** AgentSkillsIndexEntry */
|
||||
AgentSkillsIndexEntry: {
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Digest */
|
||||
digest: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "archive";
|
||||
/** Url */
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* AlertType
|
||||
* @description Enum for alert types and management event types
|
||||
|
|
@ -40098,6 +40184,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
agent_skills_index__well_known_agent_skills_index_json_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AgentSkillsIndex"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
jwks_json__well_known_jwks_json_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -40426,6 +40532,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
agent_skills_index__well_known_skills_index_json_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AgentSkillsIndex"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
invoke_agent_a2a_a2a__agent_id__post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -65156,6 +65282,37 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
agent_skills_archive_v1_skills__skill_id__archive_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
skill_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/zip": string;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_threads_v1_threads_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue