mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(proxy): serve registered skills as an Agent Skills well-known index
Adds an opt-in Agent Skills discovery index at
/.well-known/agent-skills/index.json (and the /.well-known/skills/index.json
alias) plus GET /v1/skills/{skill_id}/archive, so `npx skills add <proxy-url>`
installs skills uploaded through the Skills Gateway into any agent the CLI
supports.
The archive route repacks the stored upload so SKILL.md sits at the archive
root, with fixed entry timestamps so the SHA-256 digest published in the index
reproduces. Both routes are unauthenticated, since discovery clients send no
credentials, and stay 404 until an admin sets
`litellm_settings.public_skills_index: true`.
This commit is contained in:
parent
02522a5441
commit
6d993b5093
10 changed files with 802 additions and 2 deletions
|
|
@ -492,6 +492,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
|
||||
|
|
|
|||
|
|
@ -5065,6 +5065,47 @@
|
|||
"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/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"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()
|
||||
171
litellm/proxy/discovery_endpoints/agent_skills_endpoints.py
Normal file
171
litellm/proxy/discovery_endpoints/agent_skills_endpoints.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""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 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.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
|
||||
|
||||
_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]
|
||||
|
||||
|
||||
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 = tuple(
|
||||
(skill, archive)
|
||||
for skill, archive in ((skill, _archive_for(skill)) for skill in reversed(skills))
|
||||
if archive is not None
|
||||
)
|
||||
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),),
|
||||
)
|
||||
async def agent_skills_archive(
|
||||
skill_id: str,
|
||||
skill: LiteLLM_SkillsTable | None = Depends(stored_skill),
|
||||
) -> Response:
|
||||
"""Stored skill upload, repacked so SKILL.md sits at the archive root."""
|
||||
archive: Final = _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 Response(
|
||||
content=archive.content,
|
||||
media_type="application/zip",
|
||||
headers=MappingProxyType({"Content-Disposition": f'attachment; filename="{skill_id}.zip"'}),
|
||||
)
|
||||
|
||||
|
||||
def _archive_for(skill: LiteLLM_SkillsTable) -> SkillArchive | None:
|
||||
if skill.file_content is None:
|
||||
return None
|
||||
|
||||
archive: Final = 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 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]
|
||||
|
|
@ -431,7 +431,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
|
||||
|
|
@ -18370,6 +18373,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,164 @@
|
|||
import hashlib
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
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,
|
||||
) -> 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}),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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;
|
||||
|
|
@ -319,6 +339,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;
|
||||
|
|
@ -19962,6 +20002,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;
|
||||
|
|
@ -23162,6 +23222,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
|
||||
|
|
@ -39831,6 +39917,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;
|
||||
|
|
@ -40168,6 +40274,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;
|
||||
|
|
@ -64677,6 +64803,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/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @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