feat(skills)!: add portable Agent Skills Hub API (#50159)
Some checks are pending
Cost map pins recheck / rerun-cost-map-guard (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust DB / db-queries (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Modules / fmt, validate, test (aws) (push) Waiting to run
Terraform Modules / fmt, validate, test (gcp) (push) Waiting to run
unit / rust-bridge (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.0.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.25.0 compatibility (push) Blocked by required conditions
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run

This commit is contained in:
tin-berri 2026-11-04 23:24:49 -08:00 • committed by GitHub
parent b4a8bc715f
commit 3f4dc93b40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 3310 additions and 91 deletions

View file

@ -1,8 +1,12 @@
from collections.abc import Mapping
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass
from typing import Protocol
def has_agent_skill_metadata(manifest: Mapping[str, object]) -> bool:
return isinstance(manifest.get("agent_skill"), dict)
@dataclass(frozen=True, slots=True)
class PluginUpload:
name: str
@ -19,6 +23,10 @@ class PluginArchiveMetadata:
enabled: bool
source: Mapping[str, str]
manifest_json: str | None
id: str | None = None
version: str | None = None
description: str | None = None
has_agent_skill_metadata: bool = False
class PluginUploadStore(Protocol):
@ -27,3 +35,5 @@ class PluginUploadStore(Protocol):
async def get_metadata(self, name: str) -> PluginArchiveMetadata | None: ...
async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None: ...
def public_metadata(self) -> AsyncIterator[PluginArchiveMetadata]: ...

View file

@ -283,6 +283,11 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
module_path="litellm.proxy.decisions_endpoints.endpoints",
path_prefixes=("/v1/decisions", "/decisions", "/v1/systemone", "/systemone", "/v1/extraction", "/extraction"),
),
LazyFeature(
name="skill_hub",
module_path="litellm.proxy.discovery_endpoints.skill_hub_endpoints",
path_prefixes=("/skill-hub",),
),
LazyFeature(
name="claude_code_marketplace",
module_path="litellm.proxy.anthropic_endpoints.claude_code_endpoints",

View file

@ -58849,6 +58849,871 @@
}
}
},
"skill_hub": {
"components": {
"schemas": {
"Body_upload_skill_skill_hub_skills_upload_post": {
"properties": {
"file": {
"contentMediaType": "application/octet-stream",
"description": "Agent Skills ZIP with SKILL.md and optional supporting files",
"title": "File",
"type": "string"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version"
}
},
"required": [
"file"
],
"title": "Body_upload_skill_skill_hub_skills_upload_post",
"type": "object"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"title": "Detail",
"type": "array"
}
},
"title": "HTTPValidationError",
"type": "object"
},
"ImportSkillRequest": {
"properties": {
"path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Repository-relative skill directory",
"title": "Path"
},
"ref": {
"default": "HEAD",
"description": "Git commit, tag or branch to import",
"title": "Ref",
"type": "string"
},
"skill": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Skill name to select when a repository contains several skills",
"title": "Skill"
},
"url": {
"description": "Public skills.sh skill URL or GitHub repository URL",
"title": "Url",
"type": "string"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Version label for the imported snapshot",
"title": "Version"
}
},
"required": [
"url"
],
"title": "ImportSkillRequest",
"type": "object"
},
"SkillHubList": {
"properties": {
"count": {
"title": "Count",
"type": "integer"
},
"next_offset": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Next Offset"
},
"skills": {
"items": {
"$ref": "#/components/schemas/SkillHubSkill"
},
"title": "Skills",
"type": "array"
}
},
"required": [
"skills",
"count",
"next_offset"
],
"title": "SkillHubList",
"type": "object"
},
"SkillHubMetadata": {
"additionalProperties": false,
"properties": {
"description": {
"anyOf": [
{
"maxLength": 1024,
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"domain": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Domain"
},
"keywords": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Keywords"
},
"namespace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Namespace"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version"
}
},
"title": "SkillHubMetadata",
"type": "object"
},
"SkillHubPublication": {
"additionalProperties": false,
"properties": {},
"title": "SkillHubPublication",
"type": "object"
},
"SkillHubSkill": {
"properties": {
"content_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Content Url"
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"download_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Download Url"
},
"id": {
"title": "Id",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"source": {
"additionalProperties": {
"type": "string"
},
"title": "Source",
"type": "object"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version"
},
"visibility": {
"enum": [
"public",
"private"
],
"title": "Visibility",
"type": "string"
}
},
"required": [
"id",
"name",
"visibility",
"source"
],
"title": "SkillHubSkill",
"type": "object"
},
"ValidationError": {
"properties": {
"ctx": {
"title": "Context",
"type": "object"
},
"input": {
"title": "Input"
},
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"title": "Location",
"type": "array"
},
"msg": {
"title": "Message",
"type": "string"
},
"type": {
"title": "Error Type",
"type": "string"
}
},
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError",
"type": "object"
}
}
},
"paths": {
"/skill-hub/skills": {
"get": {
"description": "Browse the caller's public and granted private skills.",
"operationId": "list_skills_skill_hub_skills_get",
"parameters": [
{
"in": "query",
"name": "query",
"required": false,
"schema": {
"default": "",
"title": "Query",
"type": "string"
}
},
{
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"minimum": 0,
"title": "Offset",
"type": "integer"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 50,
"maximum": 100,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubList"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "List Skills",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/skills/import": {
"post": {
"description": "Import a public skills.sh or GitHub skill as a portable snapshot.",
"operationId": "import_skill_skill_hub_skills_import_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImportSkillRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Import Skill",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/skills/upload": {
"post": {
"description": "Publish a portable skill while retaining Claude marketplace compatibility.",
"operationId": "upload_skill_skill_hub_skills_upload_post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_upload_skill_skill_hub_skills_upload_post"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Upload Skill",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/skills/{name}": {
"delete": {
"description": "Delete a skill and its managed archive.",
"operationId": "remove_skill_skill_hub_skills__name__delete",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Remove Skill",
"tags": [
"skill_hub"
]
},
"get": {
"description": "Read one visible skill and its portable download URL.",
"operationId": "get_skill_skill_hub_skills__name__get",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Get Skill",
"tags": [
"skill_hub"
]
},
"put": {
"description": "Replace registry metadata, clearing omitted fields and preserving skill files.",
"operationId": "replace_skill_metadata_skill_hub_skills__name__put",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubMetadata"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Replace Skill Metadata",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/skills/{name}/archive": {
"get": {
"description": "Download the complete portable skill, with SKILL.md at the ZIP root.",
"operationId": "download_skill_skill_hub_skills__name__archive_get",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
},
{
"in": "query",
"name": "digest",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Digest"
}
}
],
"responses": {
"200": {
"content": {
"application/zip": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Download Skill",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/skills/{name}/content": {
"get": {
"description": "Read the original SKILL.md without downloading supporting files.",
"operationId": "skill_content_skill_hub_skills__name__content_get",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"text/markdown": {}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Skill Content",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/skills/{name}/publish": {
"post": {
"description": "Make a skill public using the existing Hub publication policy.",
"operationId": "publish_skill_skill_hub_skills__name__publish_post",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubPublication"
},
{
"type": "null"
}
],
"title": " Body"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Publish Skill",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/skills/{name}/unpublish": {
"post": {
"description": "Remove a skill from public discovery while retaining explicit grants.",
"operationId": "unpublish_skill_skill_hub_skills__name__unpublish_post",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubPublication"
},
{
"type": "null"
}
],
"title": " Body"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Unpublish Skill",
"tags": [
"skill_hub"
]
}
}
}
},
"tools": {
"components": {
"schemas": {

View file

@ -450,6 +450,14 @@ class LiteLLMRoutes(enum.Enum):
)
litellm_native_routes = [
"/skill-hub/skills",
"/skill-hub/skills/upload",
"/skill-hub/skills/import",
"/skill-hub/skills/{name}",
"/skill-hub/skills/{name}/archive",
"/skill-hub/skills/{name}/content",
"/skill-hub/skills/{name}/publish",
"/skill-hub/skills/{name}/unpublish",
"/rag/ingest",
"/v1/rag/ingest",
"/rag/query",

View file

@ -34,7 +34,7 @@ from typing_extensions import ReadOnly
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.url_utils import is_https_url_with_host
from litellm.models.plugin_upload import PluginUpload
from litellm.models.plugin_upload import PluginUpload, has_agent_skill_metadata
from litellm.proxy._types import CommonProxyErrors, ProxyErrorTypes, ProxyException, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
SkillVisibility,
@ -125,7 +125,23 @@ async def get_prisma_client() -> object:
_get_prisma_client: Final = get_prisma_client
async def _header_key_caller(request: Request) -> UserAPIKeyAuth:
def plugin_archive_url(request: Request, name: str, digest: str) -> str:
from litellm.proxy.utils import get_custom_url
return get_custom_url(
request_base_url=str(request.base_url), route=f"claude-code/plugins/{name}/archive/{digest}.zip"
)
def plugin_archive_source_matches(
request: Request, name: str, source: Mapping[str, str], digest: str, *, has_agent_skill_metadata: bool
) -> bool:
if has_agent_skill_metadata:
return source.get("source") == "archive" and source.get("sha256") == digest
return source == {"source": "archive", "url": plugin_archive_url(request, name, digest), "sha256": digest}
async def header_key_caller(request: Request) -> UserAPIKeyAuth:
try:
caller: Final = await user_api_key_auth(
request=request,
@ -154,7 +170,7 @@ async def _header_key_caller(request: Request) -> UserAPIKeyAuth:
async def _marketplace_caller(request: Request, key: str | None) -> UserAPIKeyAuth | None:
if marketplace_auth_required():
return await _header_key_caller(request)
return await header_key_caller(request)
if key:
return await user_api_key_auth(request=request, api_key=f"Bearer {key}")
return None
@ -173,8 +189,6 @@ async def upload_plugin(
metadata: Annotated[Json[UploadPluginRequest], Form(description="JSON skill metadata without a source URL")],
) -> RegisterPluginResponse:
"""Create a skill and its uploaded ZIP atomically. Duplicate names return 409."""
from litellm.proxy.utils import get_custom_url
_require_proxy_admin(user_api_key_dict)
if uploads is None:
raise _error_response(500, CommonProxyErrors.db_not_connected_error.value)
@ -189,10 +203,7 @@ async def upload_plugin(
digest: Final = hashlib.sha256(archive).hexdigest()
source: Final = {
"source": "archive",
"url": get_custom_url(
request_base_url=str(request.base_url),
route=f"claude-code/plugins/{metadata.name}/archive/{digest}.zip",
),
"url": plugin_archive_url(request, metadata.name, digest),
"sha256": digest,
}
spec: Final = RegisterPluginRequest.model_validate({**metadata.model_dump(), "source": source})
@ -237,10 +248,8 @@ async def download_plugin_archive(
uploads: PluginUploadRepositoryDep,
) -> Response:
"""Download an uploaded ZIP under the same visibility policy as its skill."""
from litellm.proxy.utils import get_custom_url
caller: Final = (
await _header_key_caller(request)
await header_key_caller(request)
if marketplace_key_header_present(request)
else await _marketplace_caller(request, None)
)
@ -248,13 +257,12 @@ async def download_plugin_archive(
if uploads is None:
raise _error_response(500, CommonProxyErrors.db_not_connected_error.value)
archive: Final = await uploads.get_metadata(plugin_name)
expected_url: Final = get_custom_url(
request_base_url=str(request.base_url), route=f"claude-code/plugins/{plugin_name}/archive/{digest}.zip"
)
if (
archive is None
or not visibility.allows(archive)
or archive.source != {"source": "archive", "url": expected_url, "sha256": digest}
or not plugin_archive_source_matches(
request, plugin_name, archive.source, digest, has_agent_skill_metadata=archive.has_agent_skill_metadata
)
):
raise _error_response(404, "Skill archive not found")
content: Final = await uploads.get_content(archive)
@ -608,7 +616,7 @@ async def register_plugin(
async def list_plugins(
enabled_only: bool = False,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
) -> ListPluginsResponse:
"""
List all plugins in the marketplace.
@ -640,6 +648,7 @@ async def list_plugins(
version=p.version,
description=p.description,
source=manifest.get("source", {}),
has_agent_skill_metadata=has_agent_skill_metadata(MANIFEST_FIELDS.validate_python(manifest)),
author=manifest.get("author"),
homepage=manifest.get("homepage"),
keywords=manifest.get("keywords"),
@ -805,7 +814,14 @@ async def update_plugin(
_validate_plugin_source(request.source)
_validate_installation_preference(request)
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request)
manifest: Final[Mapping[str, object]] = {
**_build_plugin_manifest(plugin_name, request),
**(
{"agent_skill": current_manifest["agent_skill"]}
if "agent_skill" in current_manifest and request.source == current_manifest.get("source")
else {}
),
}
plugin: Final[_PluginRecord | None] = await repository.table.update(
where={"name": plugin_name},

View file

@ -11,26 +11,63 @@ import io
import lzma
import re
import stat
import struct
import zipfile
import zlib
from collections.abc import Mapping
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
from typing import ClassVar, Final
import yaml
from pydantic import TypeAdapter
from pydantic import ConfigDict, TypeAdapter, ValidationError
MAX_ARCHIVE_UNPACKED_BYTES: Final = 50 * 1024 * 1024
MAX_ARCHIVE_ENTRIES: Final = 1000
MAX_SKILL_FILE_BYTES: Final = 10 * 1024 * 1024
MAX_SKILL_MANIFEST_BYTES: Final = 1024 * 1024
SKILL_ARCHIVE_ERRORS: Final = (
zipfile.BadZipFile,
OSError,
RuntimeError,
EOFError,
lzma.LZMAError,
zlib.error,
struct.error,
UnicodeError,
)
SKILL_MANIFEST_FILENAME: Final = "SKILL.md"
_ZIP_ENTRY_TIMESTAMP: Final = (1980, 1, 1, 0, 0, 0)
_ZIP_ENTRY_MODE: Final = 0o644
_FRONTMATTER_PATTERN: Final = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL)
_AGENT_SKILL_FRONTMATTER_PATTERN: Final = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.DOTALL)
_AGENT_SKILL_YAML_SCALARS: Final = MappingProxyType(
{
"tag:yaml.org,2002:null": re.compile(r"^(?:null|Null|NULL|~|)$"),
"tag:yaml.org,2002:bool": re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"),
"tag:yaml.org,2002:int": re.compile(r"^(?:[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+)$"),
"tag:yaml.org,2002:float": re.compile(
r"^(?:[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?"
r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$"
),
}
)
_MAX_FRONTMATTER_DEPTH: Final = 32
_WINDOWS_DRIVE_PATTERN: Final = re.compile(r"^[A-Za-z]:")
_EMPTY_FRONTMATTER: Final[Mapping[str, object]] = MappingProxyType({})
_DECODED_YAML: Final = TypeAdapter(object)
_YAML_EVENTS: Final = TypeAdapter(Iterable[object])
_YAML_NODES: Final = TypeAdapter(tuple[yaml.Node, ...], config=ConfigDict(arbitrary_types_allowed=True))
_YAML_PAIRS: Final = TypeAdapter(
tuple[tuple[yaml.Node, yaml.Node], ...], config=ConfigDict(arbitrary_types_allowed=True)
)
class _AgentSkillFrontmatterLoader(yaml.BaseLoader):
yaml_implicit_resolvers: ClassVar[Mapping[str | None, Sequence[tuple[str, re.Pattern[str]]]]] = { # pyright: ignore[reportIncompatibleVariableOverride] # PyYAML resolver tables are class state
None: list(_AGENT_SKILL_YAML_SCALARS.items())
}
@dataclass(frozen=True, slots=True)
@ -39,37 +76,100 @@ class SkillArchive:
digest: str
declared_name: str | None
declared_description: str | None
manifest_content: bytes
def read_skill_archive_members(
stored_content: bytes, *, for_upload: bool = False
stored_content: bytes, *, for_upload: bool = False, skill_path: str | None = None
) -> tuple[tuple[str, bytes, int], ...] | None:
try:
with zipfile.ZipFile(io.BytesIO(stored_content)) as uploaded:
return _flattened_members(uploaded, for_upload=for_upload)
except (zipfile.BadZipFile, OSError, RuntimeError, EOFError, lzma.LZMAError, zlib.error):
return _flattened_members(uploaded, for_upload=for_upload, skill_path=skill_path)
except SKILL_ARCHIVE_ERRORS:
return None
def read_skill_archive_entry(uploaded: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
with uploaded.open(info) as member:
return member.read(info.file_size + 1)
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."""
members: Final = read_skill_archive_members(stored_content)
if members is None:
return None
frontmatter: Final = _manifest_frontmatter(
next(data for name, data, _ in members if name == SKILL_MANIFEST_FILENAME)
)
manifest_content: Final = next(data for name, data, _ in members if name == SKILL_MANIFEST_FILENAME)
frontmatter: Final = _manifest_frontmatter(manifest_content)
content: Final = pack_skill_archive(members)
return SkillArchive(
content=content,
digest=f"sha256:{hashlib.sha256(content).hexdigest()}",
declared_name=_frontmatter_text(frontmatter, "name"),
declared_description=_frontmatter_text(frontmatter, "description"),
manifest_content=manifest_content,
)
def _flattened_members(uploaded: zipfile.ZipFile, *, for_upload: bool) -> tuple[tuple[str, bytes, int], ...] | None:
def build_agent_skill_archive(
content: bytes, *, skill_path: str | None = None, plugin_name: str | None = None
) -> SkillArchive | None:
members: Final = read_skill_archive_members(content, for_upload=True, skill_path=skill_path)
selected: Final = (
members
if members is not None or plugin_name is None
else read_skill_archive_members(content, for_upload=True, skill_path=f"skills/{plugin_name}")
)
if selected is None:
return None
manifest_content: Final = next(data for path, data, _ in selected if path == SKILL_MANIFEST_FILENAME)
manifest: Final = agent_skill_frontmatter(manifest_content)
name: Final = _frontmatter_text(manifest, "name")
description: Final = _frontmatter_text(manifest, "description")
if (
name is None
or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name)
or len(name) > 64
or description is None
or len(description) > 1024
):
return None
archive: Final = pack_skill_archive(selected)
return SkillArchive(
content=archive,
digest=f"sha256:{hashlib.sha256(archive).hexdigest()}",
declared_name=name,
declared_description=description,
manifest_content=manifest_content,
)
def _flattened_members(
uploaded: zipfile.ZipFile, *, for_upload: bool, skill_path: str | None
) -> tuple[tuple[str, bytes, int], ...] | None:
kept: Final = skill_archive_entries(uploaded, for_upload=for_upload)
if kept is None:
return None
prefix: Final = f"{skill_path}/" if skill_path is not None else _common_root_prefix(tuple(path for _, path in kept))
flattened: Final = tuple((info, path[len(prefix) :]) for info, path in kept if path.startswith(prefix))
names: Final = frozenset(name for _, name in flattened)
if (
SKILL_MANIFEST_FILENAME not in names
or len(names) != len(flattened)
or (for_upload and any(not safe_skill_archive_entry(info) for info, _ in flattened))
):
return None
return tuple(
(name, read_skill_archive_entry(uploaded, info), _entry_mode(info, preserve_executable_bits=for_upload))
for info, name in sorted(flattened, key=lambda member: member[1])
)
def skill_archive_entries(
uploaded: zipfile.ZipFile, *, for_upload: bool
) -> tuple[tuple[zipfile.ZipInfo, str], ...] | None:
infos: Final = tuple(info for info in uploaded.infolist() if for_upload or not info.is_dir())
if not infos or len(infos) > MAX_ARCHIVE_ENTRIES:
return None
@ -81,26 +181,13 @@ def _flattened_members(uploaded: zipfile.ZipFile, *, for_upload: bool) -> tuple[
)
if any(path is None for _, path in normalized):
return None
if for_upload and (
len(frozenset(path for _, path in normalized)) != len(normalized)
or any(not _safe_upload_entry(info) for info in infos)
):
return None
kept: Final = tuple(
(info, path)
for info, path in normalized
if path is not None and not info.is_dir() and (not for_upload or not _is_macos_metadata(path))
)
prefix: Final = _common_root_prefix(tuple(path for _, path in kept))
flattened: Final = tuple((info, path[len(prefix) :]) for info, path in kept)
names: Final = frozenset(name for _, name in flattened)
if SKILL_MANIFEST_FILENAME not in names or len(names) != len(flattened):
if for_upload and len(frozenset(path for _, path in normalized)) != len(normalized):
return None
return tuple(
(name, uploaded.read(info), _entry_mode(info, preserve_executable_bits=for_upload))
for info, name in sorted(flattened, key=lambda member: member[1])
(info, path)
for info, path in normalized
if path is not None and not info.is_dir() and (not for_upload or not _is_macos_metadata(path))
)
@ -113,9 +200,11 @@ def _entry_mode(info: zipfile.ZipInfo, *, preserve_executable_bits: bool) -> int
return _ZIP_ENTRY_MODE | (mode & 0o111)
def _safe_upload_entry(info: zipfile.ZipInfo) -> bool:
return not info.flag_bits & 1 and (
info.create_system != 3 or stat.S_IFMT(info.external_attr >> 16) in (0, stat.S_IFREG, stat.S_IFDIR)
def safe_skill_archive_entry(info: zipfile.ZipInfo) -> bool:
return (
info.file_size <= MAX_SKILL_FILE_BYTES
and not info.flag_bits & 1
and (info.create_system != 3 or stat.S_IFMT(info.external_attr >> 16) in (0, stat.S_IFREG, stat.S_IFDIR))
)
@ -154,6 +243,65 @@ def _manifest_frontmatter(manifest: bytes) -> Mapping[str, object]:
return parsed
def agent_skill_frontmatter(manifest: bytes) -> Mapping[str, str]:
if len(manifest) > MAX_SKILL_MANIFEST_BYTES:
return MappingProxyType({})
try:
match: Final = _AGENT_SKILL_FRONTMATTER_PATTERN.match(manifest.decode("utf-8-sig"))
if match is None:
return MappingProxyType({})
frontmatter: Final = match.group(1)
events: Final = _YAML_EVENTS.validate_python(yaml.parse(frontmatter, Loader=yaml.BaseLoader)) # pyright: ignore[reportUnknownMemberType] # PyYAML parse stubs omit their event iterator return type
if any(isinstance(event, yaml.AliasEvent) for event in events):
return MappingProxyType({})
node: Final = _DECODED_YAML.validate_python(yaml.compose(frontmatter, Loader=_AgentSkillFrontmatterLoader)) # pyright: ignore[reportUnknownMemberType] # PyYAML compose stubs omit their return type
except (yaml.YAMLError, UnicodeError, RecursionError, ValidationError):
return MappingProxyType({})
if not isinstance(node, yaml.MappingNode) or not _valid_frontmatter_node(node):
return MappingProxyType({})
pairs: Final = _YAML_PAIRS.validate_python(node.value)
fields: Final = {
key_text: value_text
for key, value in pairs
if (key_text := _frontmatter_string(key)) is not None and (value_text := _frontmatter_string(value)) is not None
}
if "name" not in fields or "description" not in fields:
return MappingProxyType({})
return MappingProxyType(fields)
def _frontmatter_string(node: yaml.Node) -> str | None:
if not isinstance(node, yaml.ScalarNode) or node.tag != "tag:yaml.org,2002:str":
return None
value: Final = _DECODED_YAML.validate_python(node.value)
return value if isinstance(value, str) else None
def _valid_frontmatter_node(node: yaml.Node, depth: int = 0) -> bool:
if depth > _MAX_FRONTMATTER_DEPTH:
return False
if isinstance(node, yaml.ScalarNode):
pattern: Final = _AGENT_SKILL_YAML_SCALARS.get(node.tag)
scalar: Final = _DECODED_YAML.validate_python(node.value)
return isinstance(scalar, str) and (
node.tag == "tag:yaml.org,2002:str" or (pattern is not None and pattern.fullmatch(scalar) is not None)
)
if isinstance(node, yaml.SequenceNode):
values: Final = _YAML_NODES.validate_python(node.value)
return node.tag == "tag:yaml.org,2002:seq" and all(
_valid_frontmatter_node(value, depth + 1) for value in values
)
if not isinstance(node, yaml.MappingNode) or node.tag != "tag:yaml.org,2002:map":
return False
pairs: Final = _YAML_PAIRS.validate_python(node.value)
keys: Final = tuple(_frontmatter_string(key) for key, _ in pairs)
return (
None not in keys
and len(frozenset(keys)) == len(keys)
and all(_valid_frontmatter_node(value, depth + 1) for _, value in pairs)
)
def _frontmatter_text(frontmatter: Mapping[str, object], key: str) -> str | None:
value: Final = frontmatter.get(key)
if not isinstance(value, str):

View file

@ -9,7 +9,7 @@ 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 itertools import count
from types import MappingProxyType
from typing import Final
@ -19,6 +19,7 @@ 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.deps.repositories import PluginUploadRepositoryDep
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,
@ -83,28 +84,34 @@ async def stored_skill(skill_id: str) -> LiteLLM_SkillsTable | None:
)
async def agent_skills_index(
request: Request,
uploads: PluginUploadRepositoryDep,
skills: Sequence[LiteLLM_SkillsTable] = Depends(stored_skills),
) -> AgentSkillsIndex:
"""Agent Skills v0.2.0 discovery index over every skill stored on this proxy."""
"""Discover public Hub skills and the separately enabled hosted skill store."""
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import public_hub_entries
from litellm.proxy.utils import get_custom_url
installable: Final = await _installable(skills)
installable: Final = await _installable(skills[:MAX_INDEXED_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)
legacy: Final = 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)
)
remaining: Final = MAX_INDEXED_SKILLS - len(legacy)
hub: Final = (await public_hub_entries(request, uploads))[:remaining] if remaining else ()
hub_names: Final = _deduplicated(tuple(entry.name for entry in hub), reserved=frozenset(names))
return AgentSkillsIndex(
skills=legacy
+ tuple(entry.model_copy(update={"name": name}) for entry, name in zip(hub, hub_names, strict=True))
)
@ -153,7 +160,7 @@ async def _archive_for(skill: LiteLLM_SkillsTable) -> SkillArchive | None:
)
return None
if cache_key is not None and len(archive.content) <= MAX_CACHED_ARCHIVE_BYTES:
if cache_key is not None and len(archive.content) + len(archive.manifest_content) <= MAX_CACHED_ARCHIVE_BYTES:
_ARCHIVE_CACHE.set_cache(cache_key, archive)
return archive
@ -172,19 +179,25 @@ def _slugify(raw: str | None) -> str | None:
return _NON_SLUG_PATTERN.sub("-", raw.lower()).strip("-")[:MAX_SKILL_NAME_LENGTH].rstrip("-") or None
def _entry_name(entry: tuple[int, str]) -> str:
return entry[1]
def _deduplicated(names: Sequence[str], reserved: frozenset[str] = frozenset()) -> tuple[str, ...]:
natural_names: Final = frozenset(names) | reserved
ordinals: Final = MappingProxyType({name: count(1) for name in frozenset(names)})
used: Final[set[str]] = set(reserved) # mutable-ok: bounded local allocation avoids rebuilding the set
def allocate(name: str) -> str:
selected: Final = (
name
if name not in used
else next(
candidate
for candidate in (_with_ordinal(name, ordinal) for ordinal in ordinals[name])
if candidate not in natural_names and candidate not in used
)
)
used.add(selected)
return selected
def _deduplicated(names: Sequence[str]) -> tuple[str, ...]:
ordinals: Final = MappingProxyType(
{
position: ordinal
for _, duplicates in groupby(sorted(enumerate(names), key=_entry_name), key=_entry_name)
for ordinal, (position, _) in enumerate(duplicates)
}
)
return tuple(_with_ordinal(name, ordinals[position]) for position, name in enumerate(names))
return tuple(allocate(name) for name in names)
def _with_ordinal(name: str, ordinal: int) -> str:

View file

@ -0,0 +1,439 @@
import asyncio
import hashlib
from typing import Annotated, Final
from urllib.parse import quote
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile
import litellm
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # shared HTTP factory has untyped optional params
)
from litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload, PluginUploadStore
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
MANIFEST_FIELDS,
delete_plugin,
disable_plugin,
enable_plugin,
header_key_caller,
list_plugins,
plugin_archive_source_matches,
plugin_archive_url,
update_plugin,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import build_uploaded_skill_archive
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.claude_code_marketplace_auth import (
marketplace_auth_required,
marketplace_key_header_present,
)
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
SkillArchive,
build_agent_skill_archive,
)
from litellm.proxy.discovery_endpoints.skill_import import (
MAX_IMPORT_BYTES,
SkillImportError,
fetch_skill_source,
parse_skill_source,
select_imported_skill,
)
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
from litellm.types.proxy.claude_code_endpoints import PluginListItem, UpdatePluginRequest
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import AgentSkillsIndexEntry
from litellm.types.proxy.skill_hub import (
ImportSkillRequest,
SkillHubList,
SkillHubMetadata,
SkillHubPublication,
SkillHubSkill,
)
router: Final = APIRouter(prefix="/skill-hub/skills", tags=["Skills Hub"])
def _url(request: Request, route: str) -> str:
from litellm.proxy.utils import get_custom_url
return get_custom_url(request_base_url=str(request.base_url), route=route.lstrip("/"))
def _download_url(request: Request, name: str) -> str:
return _url(request, f"skill-hub/skills/{quote(name, safe='')}/archive")
def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill:
local_archive: Final = plugin_archive_source_matches(
request,
plugin.name,
plugin.source,
plugin.source.get("sha256", ""),
has_agent_skill_metadata=plugin.has_agent_skill_metadata,
)
return SkillHubSkill(
id=plugin.id,
name=plugin.name,
description=plugin.description,
version=plugin.version,
visibility="public" if plugin.enabled else "private",
source={"source": "archive", "url": _download_url(request, plugin.name)} if local_archive else plugin.source,
download_url=_download_url(request, plugin.name) if local_archive else None,
content_url=_url(request, f"skill-hub/skills/{quote(plugin.name, safe='')}/content") if local_archive else None,
)
@router.get("", response_model=SkillHubList)
async def list_skills(
request: Request,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
query: str = "",
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> SkillHubList:
"""Browse the caller's public and granted private skills."""
catalog: Final = await list_plugins(enabled_only=False, user_api_key_dict=caller)
needle: Final = query.casefold()
matched: Final = tuple(
plugin
for plugin in sorted(catalog.plugins, key=lambda item: item.name)
if needle in " ".join((plugin.name, plugin.description or "", *(plugin.keywords or ()))).casefold()
)
return SkillHubList(
skills=tuple(_skill_response(request, plugin) for plugin in matched[offset : offset + limit]),
count=len(matched),
next_offset=offset + limit if offset + limit < len(matched) else None,
)
@router.get("/{name}", response_model=SkillHubSkill)
async def get_skill(
request: Request,
name: str,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillHubSkill:
"""Read one visible skill and its portable download URL."""
store: Final = _require_store(uploads)
metadata: Final = await store.get_metadata(name)
if metadata is None or not (await resolve_skill_visibility(caller)).allows(metadata):
raise HTTPException(404, "Skill not found")
return _metadata_response(request, metadata)
def _metadata_response(request: Request, metadata: PluginArchiveMetadata) -> SkillHubSkill:
return _skill_response(
request,
PluginListItem(
id=metadata.id or metadata.name,
name=metadata.name,
description=metadata.description,
version=metadata.version,
source=dict(metadata.source),
has_agent_skill_metadata=metadata.has_agent_skill_metadata,
enabled=metadata.enabled,
created_at=None,
updated_at=None,
),
)
def _require_store(uploads: PluginUploadStore | None) -> PluginUploadStore:
if uploads is None:
raise HTTPException(503, "Skills Hub requires a connected database")
return uploads
def _require_admin(caller: UserAPIKeyAuth) -> None:
if not is_proxy_admin(caller):
raise HTTPException(403, "Only proxy admins may publish skills")
async def skill_publisher(
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> UserAPIKeyAuth:
from litellm.proxy.proxy_server import proxy_logging_obj
_require_admin(caller)
limiter: Final = proxy_logging_obj.get_proxy_hook("parallel_request_limiter")
if not isinstance(limiter, PROXY_MaxParallelRequestsHandler_v3):
raise HTTPException(503, "Skills Hub publishing requires the default v3 rate limiter")
key_identity: Final = caller.api_key or caller.token
identity: Final = (
f"user:{caller.user_id}" if caller.user_id else f"key:{key_identity}" if key_identity else "local-admin"
)
result: Final = await limiter.atomic_check_and_increment_by_n(
descriptors=[
{
"key": "skill_hub_publish",
"value": hashlib.sha256(identity.encode()).hexdigest(),
"rate_limit": {"requests_per_unit": 10, "window_size": 60},
}
],
increments=[{"requests": 1}],
parent_otel_span=caller.parent_otel_span,
)
if result["overall_code"] == "OVER_LIMIT":
raise HTTPException(
429, "Skills Hub allows 10 uploads or imports per minute per user", headers={"Retry-After": "60"}
)
return caller
@router.put("/{name}", response_model=SkillHubSkill)
async def replace_skill_metadata(
request: Request,
name: str,
body: SkillHubMetadata,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillHubSkill:
"""Replace registry metadata, clearing omitted fields and preserving skill files."""
_require_admin(caller)
store: Final = _require_store(uploads)
metadata: Final = await store.get_metadata(name)
if metadata is None:
raise HTTPException(404, "Skill not found")
manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}")
updated: Final = UpdatePluginRequest.model_validate(
{**manifest, **body.model_dump(), "source": dict(metadata.source)}
)
await update_plugin(plugin_name=name, request=updated, user_api_key_dict=caller)
return await get_skill(request, name, uploads, caller)
@router.post("/{name}/publish", response_model=SkillHubSkill)
async def publish_skill(
request: Request,
name: str,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
_body: SkillHubPublication | None = None,
) -> SkillHubSkill:
"""Make a skill public using the existing Hub publication policy."""
_require_admin(caller)
await enable_plugin(plugin_name=name, user_api_key_dict=caller)
return await get_skill(request, name, uploads, caller)
@router.post("/{name}/unpublish", response_model=SkillHubSkill)
async def unpublish_skill(
request: Request,
name: str,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
_body: SkillHubPublication | None = None,
) -> SkillHubSkill:
"""Remove a skill from public discovery while retaining explicit grants."""
_require_admin(caller)
await disable_plugin(plugin_name=name, user_api_key_dict=caller)
return await get_skill(request, name, uploads, caller)
@router.delete("/{name}", status_code=204, response_class=Response)
async def remove_skill(name: str, caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)]) -> Response:
"""Delete a skill and its managed archive."""
_require_admin(caller)
await delete_plugin(plugin_name=name, user_api_key_dict=caller)
return Response(status_code=204)
@router.post("/upload", response_model=SkillHubSkill)
async def upload_skill(
request: Request,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(skill_publisher)],
file: Annotated[UploadFile, File(description="Agent Skills ZIP with SKILL.md and optional supporting files")],
version: Annotated[str | None, Form()] = None,
) -> SkillHubSkill:
"""Publish a portable skill while retaining Claude marketplace compatibility."""
store: Final = _require_store(uploads)
content: Final = await file.read(MAX_IMPORT_BYTES + 1)
if len(content) > MAX_IMPORT_BYTES:
raise HTTPException(413, "Skill ZIP must be 10 MiB or smaller")
archive: Final = await asyncio.to_thread(build_agent_skill_archive, content)
if archive is None:
raise HTTPException(422, "Upload a valid Agent Skill with name and description in SKILL.md")
return await _publish(request, store, caller, archive, version, None)
def import_http_client() -> httpx.AsyncClient:
return get_async_httpx_client(llm_provider="skill_hub_import").client
@router.post("/import", response_model=SkillHubSkill)
async def import_skill(
request: Request,
body: ImportSkillRequest,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(skill_publisher)],
client: Annotated[httpx.AsyncClient, Depends(import_http_client)],
) -> SkillHubSkill:
"""Import a public skills.sh or GitHub skill as a portable snapshot."""
store: Final = _require_store(uploads)
source: Final = parse_skill_source(body.url, skill=body.skill, ref=body.ref, path=body.path)
if isinstance(source, SkillImportError):
raise HTTPException(source.status, source.message)
content: Final = await fetch_skill_source(source, client)
if isinstance(content, SkillImportError):
raise HTTPException(content.status, content.message)
archive: Final = await asyncio.to_thread(select_imported_skill, content, source)
if isinstance(archive, SkillImportError):
raise HTTPException(archive.status, archive.message)
return await _publish(request, store, caller, archive, body.version, body.url)
async def _publish(
request: Request,
store: PluginUploadStore,
caller: UserAPIKeyAuth,
archive: SkillArchive,
version: str | None,
origin: str | None,
) -> SkillHubSkill:
name: Final = archive.declared_name
if name is None:
raise HTTPException(422, "SKILL.md must declare a skill name")
plugin: Final = await asyncio.to_thread(build_uploaded_skill_archive, archive.content, name)
if plugin is None:
raise HTTPException(422, "Skill ZIP exceeds the supported archive limits")
portable: Final = await asyncio.to_thread(build_agent_skill_archive, plugin, plugin_name=name)
if portable is None or portable.digest != archive.digest:
raise HTTPException(422, "Skill ZIP exceeds the supported archive limits after compatibility packaging")
digest: Final = hashlib.sha256(plugin).hexdigest()
source: Final = {
"source": "archive",
"url": plugin_archive_url(request, name, digest),
"sha256": digest,
}
manifest: Final = {
"name": name,
"description": archive.declared_description,
"version": version,
"source": source,
"agent_skill": {
"name": name,
"description": archive.declared_description,
"digest": archive.digest,
"origin": origin,
},
}
identifier: Final = await store.create(
PluginUpload(name, version, archive.declared_description, manifest, plugin, caller.user_id)
)
if identifier is None:
raise HTTPException(409, "A skill with this name already exists")
return _metadata_response(
request,
PluginArchiveMetadata(
name, True, source, None, identifier, version, archive.declared_description, has_agent_skill_metadata=True
),
)
async def _visible_archive(request: Request, name: str, uploads: PluginUploadStore | None) -> SkillArchive:
anonymous_allowed: Final = litellm.public_skills_index is True and not marketplace_auth_required()
caller: Final = (
await header_key_caller(request) if marketplace_key_header_present(request) or not anonymous_allowed else None
)
store: Final = _require_store(uploads)
metadata: Final = await store.get_metadata(name)
if metadata is None or not (await resolve_skill_visibility(caller)).allows(metadata):
raise HTTPException(404, "Skill not found")
if not plugin_archive_source_matches(
request,
name,
metadata.source,
metadata.source.get("sha256", ""),
has_agent_skill_metadata=metadata.has_agent_skill_metadata,
):
raise HTTPException(404, "This skill no longer refers to a managed archive")
content: Final = await store.get_content(metadata)
archive: Final = (
await asyncio.to_thread(build_agent_skill_archive, content, plugin_name=name) if content is not None else None
)
if archive is None:
raise HTTPException(404, "This skill has no stored Agent Skills archive")
return archive
@router.get(
"/{name}/archive",
response_class=Response,
responses={200: {"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}}}},
)
async def download_skill(
request: Request, name: str, uploads: PluginUploadRepositoryDep, digest: str | None = None
) -> Response:
"""Download the complete portable skill, with SKILL.md at the ZIP root."""
archive: Final = await _visible_archive(request, name, uploads)
if digest is not None and digest != archive.digest:
raise HTTPException(404, "Skill archive version not found")
return Response(
archive.content,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{name}.zip"',
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"ETag": f'"{archive.digest}"',
},
)
@router.get("/{name}/content", response_class=Response, responses={200: {"content": {"text/markdown": {}}}})
async def skill_content(request: Request, name: str, uploads: PluginUploadRepositoryDep) -> Response:
"""Read the original SKILL.md without downloading supporting files."""
archive: Final = await _visible_archive(request, name, uploads)
return Response(
archive.manifest_content,
media_type="text/markdown",
headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"},
)
async def public_hub_entries(request: Request, store: PluginUploadStore | None) -> tuple[AgentSkillsIndexEntry, ...]:
if store is None or marketplace_auth_required():
return ()
entries: Final[list[AgentSkillsIndexEntry]] = [] # mutable-ok: bounded inventory avoids copying on each append
async for item in store.public_metadata():
entry: AgentSkillsIndexEntry | None = _index_entry(request, item)
if entry is None:
continue
entries.append(entry)
if len(entries) == 1000:
break
return tuple(entries)
def _index_entry(request: Request, item: PluginArchiveMetadata) -> AgentSkillsIndexEntry | None:
manifest: Final = MANIFEST_FIELDS.validate_json(item.manifest_json or "{}")
raw: Final = manifest.get("agent_skill")
if (
not isinstance(raw, dict)
or not item.enabled
or not plugin_archive_source_matches(
request,
item.name,
item.source,
item.source.get("sha256", ""),
has_agent_skill_metadata=item.has_agent_skill_metadata,
)
):
return None
skill: Final = MANIFEST_FIELDS.validate_python(raw)
digest: Final = skill.get("digest")
description: Final = item.description or skill.get("description")
if not isinstance(digest, str) or not isinstance(description, str):
return None
return AgentSkillsIndexEntry(
name=item.name,
type="archive",
description=description,
url=f"{_download_url(request, item.name)}?digest={quote(digest, safe=':')}",
digest=digest,
)

View file

@ -0,0 +1,126 @@
import io
import re
import zipfile
from dataclasses import dataclass
from typing import Final
from urllib.parse import quote, unquote, urlsplit
import httpx
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
MAX_ARCHIVE_ENTRIES,
MAX_ARCHIVE_UNPACKED_BYTES,
MAX_SKILL_MANIFEST_BYTES,
SKILL_ARCHIVE_ERRORS,
SkillArchive,
agent_skill_frontmatter,
build_agent_skill_archive,
read_skill_archive_entry,
safe_skill_archive_entry,
skill_archive_entries,
)
MAX_IMPORT_BYTES: Final = 10 * 1024 * 1024
@dataclass(frozen=True, slots=True)
class SkillImportError:
status: int
message: str
@dataclass(frozen=True, slots=True)
class GitHubSkillSource:
owner: str
repository: str
ref: str
skill: str | None
path: str | None
@property
def archive_url(self) -> str:
return f"https://codeload.github.com/{self.owner}/{self.repository}/zip/{quote(self.ref, safe='')}"
def parse_skill_source(
url: str, *, skill: str | None, ref: str, path: str | None
) -> GitHubSkillSource | SkillImportError:
try:
parsed: Final = urlsplit(url)
except ValueError:
return SkillImportError(400, "Invalid skill source URL")
if parsed.scheme != "https" or parsed.netloc not in ("skills.sh", "github.com") or parsed.query or parsed.fragment:
return SkillImportError(400, "Use an HTTPS skills.sh skill URL or a public github.com repository URL")
parts: Final = tuple(unquote(part) for part in parsed.path.strip("/").split("/"))
if len(parts) < 2 or any(re.fullmatch(r"[A-Za-z0-9_.-]+", part) is None for part in parts[:2]):
return SkillImportError(400, "The source URL must identify a GitHub owner and repository")
if parsed.netloc == "skills.sh":
if len(parts) != 3 or (skill is not None and skill != parts[2]):
return SkillImportError(400, "Use skills.sh/<owner>/<repository>/<skill> with a matching skill selection")
return GitHubSkillSource(parts[0], parts[1], ref, parts[2], path)
if len(parts) == 2:
return GitHubSkillSource(parts[0], parts[1].removesuffix(".git"), ref, skill, path)
if len(parts) < 5 or parts[2] != "tree" or path is not None or ref != "HEAD":
return SkillImportError(400, "Use a repository URL with ref and path, or a GitHub tree URL")
return GitHubSkillSource(parts[0], parts[1], parts[3], skill, "/".join(parts[4:]))
async def fetch_skill_source(source: GitHubSkillSource, client: httpx.AsyncClient) -> bytes | SkillImportError:
buffer: Final = io.BytesIO()
request: Final = httpx.Request(
"GET",
source.archive_url,
headers={"Accept": "application/zip"},
extensions={"timeout": httpx.Timeout(30).as_dict()},
)
try:
response: Final = await client.send(request, stream=True, follow_redirects=False, auth=None)
try:
if response.status_code != 200:
return SkillImportError(422, "Unable to download the public GitHub repository at the requested ref")
async for chunk in response.aiter_bytes():
if buffer.tell() + len(chunk) > MAX_IMPORT_BYTES:
return SkillImportError(413, "Repository ZIP must be 10 MiB or smaller")
buffer.write(chunk)
finally:
await response.aclose()
except httpx.HTTPError:
return SkillImportError(502, "The skill source could not be reached")
return buffer.getvalue()
def select_imported_skill(content: bytes, source: GitHubSkillSource) -> SkillArchive | SkillImportError:
try:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
infos: Final = archive.infolist()
if len(infos) > MAX_ARCHIVE_ENTRIES or sum(info.file_size for info in infos) > MAX_ARCHIVE_UNPACKED_BYTES:
return SkillImportError(413, "Repository exceeds the skill archive limits")
entries: Final = skill_archive_entries(archive, for_upload=True)
if entries is None:
return SkillImportError(422, "Repository contains unsafe archive entries")
candidates: Final = tuple(info for info, path in entries if path.endswith("/SKILL.md"))
if any(not safe_skill_archive_entry(info) for info in candidates):
return SkillImportError(422, "Repository contains unsafe skill manifests")
selected: Final = tuple(info for info in candidates if _matches_selection(info, archive, source))
if len(selected) != 1:
return SkillImportError(422, "Select exactly one skill using its name or repository-relative path")
root: Final = selected[0].filename.rsplit("/", 1)[0]
skill: Final = build_agent_skill_archive(content, skill_path=root)
return (
skill
if skill is not None
else SkillImportError(422, "The selected directory is not a valid Agent Skill")
)
except SKILL_ARCHIVE_ERRORS:
return SkillImportError(422, "The repository did not return a readable skill ZIP")
def _matches_selection(info: zipfile.ZipInfo, archive: zipfile.ZipFile, source: GitHubSkillSource) -> bool:
if info.file_size > MAX_SKILL_MANIFEST_BYTES:
return False
relative: Final = info.filename.split("/", 1)[1].rpartition("/")[0]
if source.path is not None and relative != source.path.strip("/"):
return False
if source.skill is None:
return True
return agent_skill_frontmatter(read_skill_archive_entry(archive, info)).get("name") == source.skill

View file

@ -2934,7 +2934,7 @@ def configured_max_request_size_mb() -> int | float | None:
_ROUTE_MAX_REQUEST_SIZE_MB: Final[Mapping[tuple[str, str], float]] = MappingProxyType(
{("POST", "/claude-code/plugins/upload"): 11}
{("POST", "/claude-code/plugins/upload"): 11, ("POST", "/skill-hub/skills/upload"): 11}
)

View file

@ -1,10 +1,11 @@
import json
from collections.abc import AsyncIterator, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # Prisma dynamically exposes its typed table actions
from pydantic import TypeAdapter
from pydantic import TypeAdapter, ValidationError
from litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload
from litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload, has_agent_skill_metadata
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
@ -15,6 +16,7 @@ if TYPE_CHECKING:
_MANIFEST: Final = TypeAdapter(dict[str, object])
_SOURCE: Final = TypeAdapter(dict[str, str])
PAGE_SIZE: Final = 100
class PluginUploadRepository:
@ -48,13 +50,27 @@ class PluginUploadRepository:
plugin: Final = await self._plugins.find_unique(where={"name": name})
if plugin is None:
return None
manifest: Final = _MANIFEST.validate_json(plugin.manifest_json or "{}")
return PluginArchiveMetadata(
name=plugin.name,
enabled=plugin.enabled,
source=MappingProxyType(_SOURCE.validate_python(manifest.get("source", {}))),
manifest_json=plugin.manifest_json,
)
return _archive_metadata(plugin)
async def public_metadata(self) -> AsyncIterator[PluginArchiveMetadata]:
after: str | None = None # rebind-ok: bounded keyset traversal advances after each raw page
while True:
plugins: Sequence[prisma_models.LiteLLM_ClaudeCodePluginTable] = await self._plugins.find_many(
where={"enabled": True} if after is None else {"enabled": True, "name": {"gt": after}},
take=PAGE_SIZE,
order={"name": "asc"},
)
if not plugins:
return
for plugin in plugins:
try:
metadata: PluginArchiveMetadata = _archive_metadata(plugin)
except ValidationError:
continue
yield metadata
if len(plugins) < PAGE_SIZE:
return
after = plugins[-1].name
async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None:
archive: Final = await self._archives.find_first(
@ -69,3 +85,17 @@ class PluginUploadRepository:
}
)
return None if archive is None else archive.content.decode()
def _archive_metadata(plugin: "prisma_models.LiteLLM_ClaudeCodePluginTable") -> PluginArchiveMetadata:
manifest: Final = _MANIFEST.validate_json(plugin.manifest_json or "{}")
return PluginArchiveMetadata(
name=plugin.name,
enabled=plugin.enabled,
source=MappingProxyType(_SOURCE.validate_python(manifest.get("source", {}))),
manifest_json=plugin.manifest_json,
id=plugin.id,
version=plugin.version,
description=plugin.description,
has_agent_skill_metadata=has_agent_skill_metadata(manifest),
)

View file

@ -150,6 +150,7 @@ class PluginListItem(LiteLLMBaseModel):
enabled: bool
created_at: str | None
updated_at: str | None
has_agent_skill_metadata: bool = Field(default=False, exclude=True)
class ListPluginsResponse(LiteLLMBaseModel):

View file

@ -0,0 +1,51 @@
from collections.abc import Mapping
from typing import Literal
from pydantic import ConfigDict, Field
from litellm.types.llms.base import LiteLLMBaseModel
class SkillHubSkill(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
id: str
name: str
description: str | None = None
version: str | None = None
visibility: Literal["public", "private"]
source: Mapping[str, str]
download_url: str | None = None
content_url: str | None = None
class SkillHubList(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
skills: tuple[SkillHubSkill, ...]
count: int
next_offset: int | None
class ImportSkillRequest(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
url: str = Field(description="Public skills.sh skill URL or GitHub repository URL")
skill: str | None = Field(None, description="Skill name to select when a repository contains several skills")
ref: str = Field("HEAD", description="Git commit, tag or branch to import")
path: str | None = Field(None, description="Repository-relative skill directory")
version: str | None = Field(None, description="Version label for the imported snapshot")
class SkillHubMetadata(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
description: str | None = Field(None, max_length=1024)
version: str | None = None
domain: str | None = None
namespace: str | None = None
keywords: tuple[str, ...] | None = None
class SkillHubPublication(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")

View file

@ -90,6 +90,7 @@ IGNORE_FUNCTIONS = [
"_json", # router_rust index views: max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by raising ValueError at the cap.
"to_wire", # router_rust model info: bounded by the nesting depth of a stored deployment or cost-map entry (finite JSON trees: pydantic refuses a cycle when it dumps a deployment).
"from_wire", # router_rust model info: walks only what router-core merged from to_wire's output, so it never goes deeper than to_wire did.
"_valid_frontmatter_node", # max depth 32, manifest size capped at 1 MiB, and YAML aliases rejected before traversal.
]

View file

@ -4,8 +4,12 @@ import stat
import zipfile
from typing import Final
import pytest
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
MAX_ARCHIVE_ENTRIES,
agent_skill_frontmatter,
build_agent_skill_archive,
build_skill_archive,
)
@ -118,3 +122,127 @@ def test_discovery_keeps_its_permission_and_finder_metadata_policy() -> None:
with zipfile.ZipFile(io.BytesIO(result.content)) as archive:
assert {entry.create_system for entry in archive.infolist()} == {3}
assert {entry.external_attr >> 16 for entry in archive.infolist()} == {0o644}
@pytest.mark.parametrize(
"manifest",
(
pytest.param(b"Instructions without frontmatter", id="missing-frontmatter"),
pytest.param(b"---\n- pdf-summarizer\n- description\n---\n", id="non-mapping"),
pytest.param(b"---\nname: [unterminated\ndescription: invalid\n---\n", id="invalid-yaml"),
pytest.param(b"---\nname: pdf-summarizer\ndescription: !!python/object:unsafe {}\n---\n", id="unsafe-yaml-tag"),
pytest.param(b"---\nname: Bad_Name\ndescription: invalid\n---\n", id="invalid-name"),
pytest.param(b"---\nname: " + b"a" * 65 + b"\ndescription: too long\n---\n", id="name-too-long"),
pytest.param(b"---\nname: pdf-summarizer\n---\n", id="missing-description"),
pytest.param(b"---\nname: pdf-summarizer\ndescription:\n - item\n---\n", id="non-string-description"),
pytest.param(b"---\nname: pdf-summarizer\ndescription: ' '\n---\n", id="empty-description"),
pytest.param(b"---\nname: pdf-summarizer\ndescription: " + b"a" * 1025 + b"\n---\n", id="description-too-long"),
pytest.param(b"---\nname: pdf-summarizer\nname: other-skill\ndescription: ok\n---\n", id="duplicate-name"),
pytest.param(b"---\nname: a\ndescription: ok\nlicense: MIT\nlicense: MIT\n---\n", id="duplicate-optional-key"),
),
)
def test_portable_upload_rejects_malformed_or_invalid_frontmatter(manifest: bytes) -> None:
assert build_agent_skill_archive(zip_bytes({"SKILL.md": manifest})) is None
@pytest.mark.parametrize(
"unsafe_path",
("../escape", "scripts/../../escape", "/absolute", r"scripts\escape", "C:/escape", "./scripts/run.sh"),
)
def test_portable_upload_rejects_unsafe_paths(unsafe_path: str) -> None:
assert build_agent_skill_archive(zip_bytes({"SKILL.md": MANIFEST, unsafe_path: b"unsafe"})) is None
@pytest.mark.parametrize("file_type", (stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR))
def test_portable_upload_rejects_links_and_special_files(file_type: int) -> None:
entry: Final = zipfile.ZipInfo("scripts/run.sh")
entry.create_system = 3
entry.external_attr = (file_type | 0o777) << 16
assert build_agent_skill_archive(zip_bytes({"SKILL.md": MANIFEST, entry: b"../../private"})) is None
@pytest.mark.parametrize("size, accepted", ((10 * 1024 * 1024, True), (10 * 1024 * 1024 + 1, False)))
def test_file_size_limit_applies_to_selected_skill_members(size: int, accepted: bool) -> None:
content: Final = zip_bytes(
{
"selected/SKILL.md": MANIFEST,
"selected/asset.bin": b"x" * size,
"unrelated.bin": b"x" * (size + 1),
}
)
assert (build_agent_skill_archive(content, skill_path="selected") is not None) == accepted
def test_portable_upload_rejects_entries_that_normalize_to_the_same_path() -> None:
content: Final = zip_bytes({"SKILL.md": MANIFEST, "scripts//run.sh": b"first", "scripts/run.sh": b"second"})
assert build_agent_skill_archive(content) is None
@pytest.mark.parametrize("bom", (b"", b"\xef\xbb\xbf"))
@pytest.mark.parametrize("newline", ("\n", "\r\n"))
@pytest.mark.parametrize(
("name", "description", "expected"),
(
("on", "yes", ("on", "yes")),
("yes", "on", ("yes", "on")),
('"123"', "'true'", ("123", "true")),
("!!str 123", "!!str null", ("123", "null")),
('"null"', '"false"', ("null", "false")),
("date-reader", "2026-11-05", ("date-reader", "2026-11-05")),
("block-reader", "|-\n yes", ("block-reader", "yes")),
),
)
def test_frontmatter_preserves_yaml12_strings_bom_newlines_and_extensions(
bom: bytes, newline: str, name: str, description: str, expected: tuple[str, str]
) -> None:
frontmatter: Final = (
f"---\nname: {name}\ndescription: {description}\n"
"metadata:\n"
" tags: [on, yes, 123]\n"
"x-extension:\n"
" nested: {count: 1e3, enabled: false}\n"
"---\nPreserve this body and its trailing spaces. \n"
)
manifest: Final = bom + frontmatter.replace("\n", newline).encode()
result: Final = build_agent_skill_archive(zip_bytes({"SKILL.md": manifest}))
assert result is not None
assert (result.declared_name, result.declared_description) == expected
assert result.manifest_content == manifest
@pytest.mark.parametrize("field", ("name", "description"))
@pytest.mark.parametrize("scalar", ("123", "1e3", "012", "0o12", "0x12", "true", "FALSE", "null", "~"))
def test_frontmatter_rejects_non_string_required_scalars(field: str, scalar: str) -> None:
manifest: Final = f"---\n{field}: {scalar}\n".encode() + (
b"description: Instructions\n---\n" if field == "name" else b"name: pdf-summarizer\n---\n"
)
assert build_agent_skill_archive(zip_bytes({"SKILL.md": manifest})) is None
@pytest.mark.parametrize(
"extension",
(
pytest.param("metadata:\n owner: first\n owner: second", id="duplicate-metadata-key"),
pytest.param("metadata:\n owners:\n - {owner: first, owner: second}", id="duplicate-key-in-sequence"),
pytest.param("metadata:\n owner: !!python/object:unsafe {}", id="unsafe-nested-tag"),
pytest.param("metadata:\n count: !!int invalid", id="invalid-explicit-scalar"),
pytest.param("metadata:\n ? [complex, key]\n : value", id="complex-key"),
pytest.param("metadata: &loop\n self: *loop", id="recursive-alias"),
pytest.param("metadata: " + "[" * 40 + "nested" + "]" * 40, id="excessive-depth"),
pytest.param("metadata: [unterminated", id="malformed-extension"),
),
)
def test_frontmatter_rejects_invalid_nested_metadata(extension: str) -> None:
manifest: Final = f"---\nname: pdf-summarizer\ndescription: Instructions\n{extension}\n---\n".encode()
assert build_agent_skill_archive(zip_bytes({"SKILL.md": manifest})) is None
@pytest.mark.parametrize("name", ("résumé", "мой-навык", "技能", "foo"))
def test_hub_registry_retains_its_ascii_name_policy(name: str) -> None:
manifest: Final = f"---\nname: {name}\ndescription: Instructions\n---\n".encode()
assert agent_skill_frontmatter(manifest) == {"name": name, "description": "Instructions"}
assert build_agent_skill_archive(zip_bytes({"SKILL.md": manifest})) is None

View file

@ -1,21 +1,36 @@
import hashlib
import io
import json
import zipfile
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from prisma import Base64
from prisma import models as prisma_models
import litellm
from litellm.models.skills import LiteLLM_SkillsTable
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import build_uploaded_skill_archive
from litellm.proxy.deps.repositories import get_plugin_upload_repository
from litellm.proxy.discovery_endpoints.agent_skills_archive import build_agent_skill_archive
from litellm.proxy.discovery_endpoints.agent_skills_endpoints import (
MAX_CACHED_ARCHIVE_BYTES,
MAX_INDEXED_SKILLS,
_ARCHIVE_CACHE,
router,
stored_skill,
stored_skills,
)
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import router as hub_router
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import (
AGENT_SKILLS_DISCOVERY_SCHEMA_URL,
MAX_SKILL_NAME_LENGTH,
)
WELL_KNOWN_PATHS = ("/.well-known/agent-skills/index.json", "/.well-known/skills/index.json")
@ -54,9 +69,10 @@ def skill(
)
def client_for(*skills: LiteLLM_SkillsTable) -> TestClient:
def client_for(*skills: LiteLLM_SkillsTable, uploads: PluginUploadRepository | None = None) -> TestClient:
app = FastAPI()
app.include_router(router)
app.include_router(hub_router)
def _skills() -> tuple[LiteLLM_SkillsTable, ...]:
return skills
@ -66,9 +82,50 @@ def client_for(*skills: LiteLLM_SkillsTable) -> TestClient:
app.dependency_overrides[stored_skills] = _skills
app.dependency_overrides[stored_skill] = _skill
app.dependency_overrides[get_plugin_upload_repository] = lambda: uploads
return TestClient(app)
def hub_record(
name: str,
) -> tuple[prisma_models.LiteLLM_ClaudeCodePluginTable, prisma_models.LiteLLM_ClaudeCodePluginUpload]:
manifest: Final = MANIFEST.replace(b"pdf-summarizer", name.encode())
portable: Final = build_agent_skill_archive(zip_bytes({"SKILL.md": manifest}))
assert portable is not None
wrapper: Final = build_uploaded_skill_archive(portable.content, name)
assert wrapper is not None
digest: Final = hashlib.sha256(wrapper).hexdigest()
return (
prisma_models.LiteLLM_ClaudeCodePluginTable(
id=f"hub-{name}", name=name, enabled=True, manifest_json=json.dumps({
"source": {
"source": "archive", "sha256": digest,
"url": f"http://testserver/claude-code/plugins/{name}/archive/{digest}.zip",
},
"agent_skill": {"name": name, "description": portable.declared_description, "digest": portable.digest},
}),
),
prisma_models.LiteLLM_ClaudeCodePluginUpload(plugin_name=name, content=Base64.encode(wrapper)),
)
def hub_repository(*names: str) -> PluginUploadRepository:
records: Final = tuple(hub_record(name) for name in names)
prisma: Final = MagicMock()
plugins: Final = prisma.writer_db.litellm_claudecodeplugintable
plugins.find_many = AsyncMock(return_value=[record for record, _ in records])
async def metadata(where: Mapping[str, str]) -> prisma_models.LiteLLM_ClaudeCodePluginTable | None:
return next((record for record, _ in records if record.name == where["name"]), None)
async def content(where: Mapping[str, object]) -> prisma_models.LiteLLM_ClaudeCodePluginUpload | None:
return next((archive for _, archive in records if archive.plugin_name == where["plugin_name"]), None)
plugins.find_unique = AsyncMock(side_effect=metadata)
prisma.writer_db.litellm_claudecodepluginupload.find_first = AsyncMock(side_effect=content)
return PluginUploadRepository(prisma)
@pytest.fixture
def index_enabled(monkeypatch):
monkeypatch.setattr(litellm, "public_skills_index", True)
@ -149,6 +206,55 @@ def test_skills_sharing_a_title_get_distinct_install_names(index_enabled):
assert names == ["pdf-summarizer", "pdf-summarizer-2"]
@pytest.mark.parametrize("hosted_names, hub_names, aliases", (
(("foo",), ("foo", "foo-2"), ("foo-3", "foo-2")),
(("a" * 64,), ("a" * 64, "a" * 62 + "-2"), ("a" * 62 + "-3", "a" * 62 + "-2")),
(("foo", "foo"), ("foo", "foo-2"), ("foo-3", "foo-2-2")),
(("foo", "foo", "foo-2"), ("foo", "foo-2", "foo-3"), ("foo-4", "foo-2-2", "foo-3-2")),
(("bar", "bar", "foo", "foo"), ("bar", "bar-2", "foo", "foo-2"), ("bar-3", "bar-2-2", "foo-3", "foo-2-2")),
))
def test_hub_collisions_keep_every_hosted_entry_and_only_alias_hub_install_names(
index_enabled: None, hosted_names: tuple[str, ...], hub_names: tuple[str, ...], aliases: tuple[str, ...],
) -> None:
hosted: Final = tuple(
skill(f"hosted-{position}", files={"s/SKILL.md": MANIFEST.replace(b"pdf-summarizer", name.encode())})
for position, name in enumerate(hosted_names)
)
before: Final = client_for(*reversed(hosted)).get(WELL_KNOWN_PATHS[0]).json()["skills"]
client: Final = client_for(*reversed(hosted), uploads=hub_repository(*hub_names))
response: Final = client.get(WELL_KNOWN_PATHS[0])
assert response.status_code == 200, response.text
entries: Final = response.json()["skills"]
assert entries[:len(hosted)] == before
assert len({entry["name"] for entry in entries}) == len(hosted) + len(hub_names)
assert all(len(entry["name"]) <= MAX_SKILL_NAME_LENGTH for entry in entries)
assert tuple(entry["name"] for entry in entries[len(hosted):]) == aliases
assert client.get(WELL_KNOWN_PATHS[1]).json() == response.json()
assert client.get(WELL_KNOWN_PATHS[0]).json() == response.json()
for entry, declared_name in zip(entries, hosted_names + hub_names, strict=True):
downloaded: Final = client.get(entry["url"])
assert downloaded.status_code == 200, downloaded.text
assert entry["digest"] == f"sha256:{hashlib.sha256(downloaded.content).hexdigest()}"
with zipfile.ZipFile(io.BytesIO(downloaded.content)) as archive:
assert archive.read("SKILL.md") == MANIFEST.replace(b"pdf-summarizer", declared_name.encode())
@pytest.mark.parametrize("hosted_count", (MAX_INDEXED_SKILLS - 1, MAX_INDEXED_SKILLS + 1))
def test_combined_index_cap_keeps_hosted_entries_before_hub_entries(index_enabled: None, hosted_count: int) -> None:
hosted: Final = tuple(skill(f"hosted-{position}") for position in range(hosted_count))
client: Final = client_for(*hosted, uploads=hub_repository("first", "second"))
response: Final = client.get(WELL_KNOWN_PATHS[0])
assert response.status_code == 200, response.text
entries: Final = response.json()["skills"]
retained: Final = min(hosted_count, MAX_INDEXED_SKILLS)
assert len(entries) == MAX_INDEXED_SKILLS
assert len({entry["name"] for entry in entries}) == MAX_INDEXED_SKILLS
assert [entry["url"] for entry in entries[:retained]] == [
f"http://testserver/v1/skills/{item.skill_id}/archive" for item in reversed(hosted[:MAX_INDEXED_SKILLS])
]
assert [entry["name"] for entry in entries[retained:]] == (["first"] if retained < MAX_INDEXED_SKILLS else [])
def test_uploads_without_a_root_manifest_are_left_out_of_the_index(index_enabled):
client = client_for(
skill("litellm_skill_1"),
@ -182,6 +288,24 @@ def test_a_stored_skill_is_repacked_once_per_version(index_enabled):
)
def test_retained_manifest_bytes_count_toward_the_archive_cache_limit(
index_enabled: None,
) -> None:
stamp: Final = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc)
name: Final = "litellm_skill_large_manifest"
manifest: Final = MANIFEST + b"x" * MAX_CACHED_ARCHIVE_BYTES
client: Final = client_for(skill(name, files={"s/SKILL.md": manifest}, updated_at=stamp))
response: Final = client.get(f"/v1/skills/{name}/archive")
assert response.status_code == 200
assert len(response.content) < MAX_CACHED_ARCHIVE_BYTES
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
stored: Final = archive.read("SKILL.md")
actual: Final = (len(stored), hashlib.sha256(stored).hexdigest())
expected: Final = (len(manifest), hashlib.sha256(manifest).hexdigest())
assert actual == expected
assert _ARCHIVE_CACHE.get_cache(f"{name}:{stamp.isoformat()}") is None
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))

View file

@ -0,0 +1,420 @@
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import stat
import threading
import zipfile
import zlib
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from prisma import models as prisma_models
from prisma.errors import UniqueViolationError
import litellm
from litellm.caching.caching import DualCache
from litellm.proxy import proxy_server
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
from litellm.proxy.deps.repositories import get_plugin_upload_repository
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
MAX_ARCHIVE_ENTRIES,
MAX_ARCHIVE_UNPACKED_BYTES,
)
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import import_http_client
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
from litellm.proxy.utils import InternalUsageCache
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
from tests._master_key import MASTER_KEY
NAME: Final = "portable-notes"
MANIFEST: Final = (
b"---\nname: portable-notes\ndescription: Summarize synthetic notes.\n---\n\nRead references/guide.md.\n"
)
FILES: Final = {
"SKILL.md": MANIFEST,
"references/guide.md": b"Use only the supplied notes.\n",
"assets/example.bin": b"\x00\x01\xff",
"scripts/check.sh": b"#!/bin/sh\nprintf 'synthetic notes\\n'\n",
}
HUB: Final = "/skill-hub/skills"
INDEX: Final = "/.well-known/agent-skills/index.json"
GRANTED_KEY: Final = "sk-hub-granted"
PLAIN_KEY: Final = "sk-hub-plain"
def _zip(files: Mapping[str, bytes], prefix: str = "") -> bytes:
buffer: Final = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for path, content in files.items():
entry: Final = zipfile.ZipInfo(f"{prefix}{path}", (2024, 1, 1, 0, 0, 0))
entry.create_system = 3
entry.external_attr = (stat.S_IFREG | (0o755 if path.endswith(".sh") else 0o644)) << 16
archive.writestr(entry, content, compress_type=zipfile.ZIP_DEFLATED)
return buffer.getvalue()
def _headers(key: str | None) -> dict[str, str]:
return {"Authorization": f"Bearer {key}"} if key is not None else {}
@dataclass(frozen=True, slots=True)
class HubClient:
client: httpx.AsyncClient
plugins: MagicMock
archives: MagicMock
upstream: MagicMock
clock: MagicMock
@pytest.fixture
async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]:
prisma: Final = MagicMock()
prisma.writer_db = prisma.db
prisma.get_data = AsyncMock(return_value=None)
prisma.db.litellm_skillstable.find_many = AsyncMock(return_value=[])
prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
plugins: Final = prisma.db.litellm_claudecodeplugintable
archives: Final = prisma.db.litellm_claudecodepluginupload
plugins.create = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable(
id="hub-id", name=NAME, enabled=True,
))
plugins.find_unique = AsyncMock(return_value=None)
plugins.find_many = AsyncMock(return_value=[])
async def update_row(where: Mapping[str, str], data: Mapping[str, object]) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
current: Final = plugins.find_unique.return_value.model_copy(update=dict(data))
plugins.find_unique.return_value = current
plugins.find_many.return_value = [current]
return current
plugins.update = AsyncMock(side_effect=update_row)
plugins.delete = AsyncMock()
archives.find_first = AsyncMock(return_value=None)
upstream: Final = MagicMock(return_value=httpx.Response(404))
clock: Final = MagicMock(return_value=datetime.now())
limiter: Final = PROXY_MaxParallelRequestsHandler_v3(InternalUsageCache(DualCache()), time_provider=clock)
monkeypatch.setattr(proxy_server.proxy_logging_obj, "proxy_hook_mapping", {"parallel_request_limiter": limiter})
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(proxy_server, "master_key", MASTER_KEY)
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
monkeypatch.setattr(litellm, "public_skills_index", True)
monkeypatch.setenv("PROXY_BASE_URL", "http://test")
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
for key, skills in ((GRANTED_KEY, [NAME]), (PLAIN_KEY, [])):
proxy_server.user_api_key_cache.set_cache(
key=proxy_server.hash_token(key),
value=UserAPIKeyAuth(
token=proxy_server.hash_token(key),
user_role=proxy_server.LitellmUserRoles.INTERNAL_USER,
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id=key, skills=skills),
),
)
async with (
httpx.AsyncClient(transport=httpx.MockTransport(upstream), trust_env=False) as source_client,
httpx.AsyncClient(transport=httpx.ASGITransport(app=proxy_server.app), base_url="http://test") as client,
):
monkeypatch.setattr(proxy_server.app, "dependency_overrides", {
get_plugin_upload_repository: lambda: PluginUploadRepository(prisma),
import_http_client: lambda: source_client,
})
yield HubClient(client, plugins, archives, upstream, clock)
async def _upload(hub: HubClient, content: bytes, key: str | None = MASTER_KEY) -> httpx.Response:
return await hub.client.post(
f"{HUB}/upload",
headers=_headers(key),
data={"version": "2.0.0"},
files={"file": ("notes.zip", content, "application/zip")},
)
def _stored_upload(hub: HubClient, enabled: bool = True) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
data: Final = hub.plugins.create.await_args.kwargs["data"]
record: Final = prisma_models.LiteLLM_ClaudeCodePluginTable(
id="hub-id", name=data["name"], version=data["version"], description=data["description"],
manifest_json=data["manifest_json"], enabled=enabled,
)
hub.plugins.find_unique.return_value = record
hub.plugins.find_many.return_value = [record]
hub.archives.find_first.return_value = prisma_models.LiteLLM_ClaudeCodePluginUpload(
plugin_name=record.name, content=data["uploaded_archive"]["create"]["content"],
)
return record
def _assert_portable(response: httpx.Response) -> None:
assert response.status_code == 200, response.text
assert response.headers["content-type"] == "application/zip"
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
assert {name: archive.read(name) for name in archive.namelist()} == FILES
assert stat.S_IMODE(archive.getinfo("scripts/check.sh").external_attr >> 16) == 0o755
@pytest.mark.parametrize("operation", ("upload", "import"))
@pytest.mark.parametrize("public_base, root_path", (
("http://test", ""), ("https://public.example", ""), ("https://public.example", "/gateway"),
))
async def test_publication_round_trips_both_formats_without_inflating_on_the_request_thread(
hub: HubClient, monkeypatch: pytest.MonkeyPatch, operation: str, public_base: str, root_path: str,
) -> None:
monkeypatch.delenv("PROXY_BASE_URL")
hub.upstream.return_value = httpx.Response(200, content=_zip(FILES, "repo-revision/"))
response: Final = (
await _upload(hub, _zip(FILES)) if operation == "upload" else await hub.client.post(
f"{HUB}/import", headers=_headers(MASTER_KEY),
json={"url": f"https://skills.sh/synthetic-author/notes-library/{NAME}"},
)
)
assert response.status_code == 200, response.text
record: Final = _stored_upload(hub)
manifest: Final = json.loads(record.manifest_json)
monkeypatch.setattr(proxy_server.app, "root_path", root_path)
monkeypatch.setenv("SERVER_ROOT_PATH", root_path)
serving_base: Final = public_base + root_path
hub.client.base_url = httpx.URL(serving_base)
details: Final = await hub.client.get(f"{HUB}/{NAME}", headers=_headers(PLAIN_KEY))
listed: Final = await hub.client.get(HUB, headers=_headers(PLAIN_KEY))
assert listed.json()["skills"] == [details.json()]
assert "has_agent_skill_metadata" not in listed.text + details.text
assert details.json()["download_url"] == f"{serving_base}{HUB}/{NAME}/archive"
assert details.json()["content_url"] == f"{serving_base}{HUB}/{NAME}/content"
catalog: Final = await hub.client.get("/claude-code/plugins", headers=_headers(PLAIN_KEY))
assert catalog.status_code == 200 and "has_agent_skill_metadata" not in catalog.text
request_thread: Final = threading.get_ident()
decompress: Final = zlib.decompressobj
def off_thread_decompressor(wbits: int) -> zlib.Decompress:
assert threading.get_ident() != request_thread
return decompress(wbits)
monkeypatch.setattr(zlib, "decompressobj", off_thread_decompressor)
portable: Final = await hub.client.get(details.json()["download_url"])
content: Final = await hub.client.get(details.json()["content_url"])
assert content.status_code == 200 and content.content == MANIFEST
digest: Final = "sha256:" + hashlib.sha256(portable.content).hexdigest()
assert manifest["agent_skill"]["digest"] == digest
entry: Final = (await hub.client.get(INDEX)).json()["skills"][0]
assert entry["digest"] == digest
assert entry["url"].startswith(f"{serving_base}{HUB}/{NAME}/archive?")
assert (await hub.client.get(entry["url"])).content == portable.content
assert (await hub.client.get(f"{HUB}/{NAME}/archive?digest=sha256:stale")).status_code == 404
legacy_path: Final = httpx.URL(manifest["source"]["url"]).path
legacy: Final = await hub.client.get(legacy_path)
assert legacy.status_code == 200
assert hashlib.sha256(legacy.content).hexdigest() == manifest["source"]["sha256"]
assert (await hub.client.get(legacy_path.replace(manifest["source"]["sha256"], "stale"))).status_code == 404
monkeypatch.setattr(zlib, "decompressobj", decompress)
_assert_portable(portable)
with zipfile.ZipFile(io.BytesIO(legacy.content)) as archive:
assert {path: archive.read(path) for path in archive.namelist()} == {
".claude-plugin/plugin.json": json.dumps({"name": NAME}).encode(),
**{f"skills/{NAME}/{path}": content for path, content in FILES.items()},
}
@pytest.mark.parametrize("operation", ("upload", "import"))
@pytest.mark.parametrize("boundary", ("entries", "bytes", "file"))
@pytest.mark.parametrize("fits", (True, False))
async def test_publication_reserves_wrapper_capacity_before_persisting(
hub: HubClient, operation: str, boundary: str, fits: bool,
) -> None:
file_limit: Final = 10 * 1024 * 1024
padding_size: Final = (
MAX_ARCHIVE_UNPACKED_BYTES - len(MANIFEST) - len(json.dumps({"name": NAME}).encode()) * int(fits)
if boundary == "bytes" else file_limit + int(not fits)
)
files: Final = (
{"SKILL.md": MANIFEST, **{
f"references/{index}.txt": b"x" for index in range(MAX_ARCHIVE_ENTRIES - 1 - int(fits))
}} if boundary == "entries" else {
"SKILL.md": MANIFEST,
**({
f"assets/{index}.bin": b"x" * min(file_limit, padding_size - index * file_limit)
for index in range(5)
} if boundary == "bytes" else {"assets/padding.bin": b"x" * padding_size}),
}
)
content: Final = _zip(files, prefix="repo-revision/" if operation == "import" else "")
hub.upstream.return_value = httpx.Response(200, content=content)
response: Final = (
await _upload(hub, content) if operation == "upload" else await hub.client.post(
f"{HUB}/import", headers=_headers(MASTER_KEY),
json={"url": "https://github.com/synthetic-author/notes-library"},
)
)
assert response.status_code == (200 if fits else 422), response.text
if not fits:
hub.plugins.create.assert_not_awaited()
return
_stored_upload(hub)
downloaded: Final = await hub.client.get(response.json()["download_url"])
assert downloaded.status_code == 200, downloaded.text
with zipfile.ZipFile(io.BytesIO(downloaded.content)) as archive:
actual: Final = {
path: (archive.getinfo(path).file_size, hashlib.sha256(archive.read(path)).hexdigest())
for path in archive.namelist()
}
expected: Final = {path: (len(content), hashlib.sha256(content).hexdigest()) for path, content in files.items()}
assert actual == expected
@pytest.mark.parametrize("enabled, public_index, auth_required, key, status", (
(True, True, False, None, 200), (False, True, False, None, 404),
(True, False, False, None, 401), (True, True, True, None, 401),
(False, True, False, GRANTED_KEY, 200), (False, True, False, PLAIN_KEY, 404),
(True, True, False, "", 401), (True, True, False, "sk-invalid", 401),
))
async def test_download_policy_requires_publication_or_a_valid_grant(
hub: HubClient, monkeypatch: pytest.MonkeyPatch, enabled: bool,
public_index: bool, auth_required: bool, key: str | None, status: int,
) -> None:
assert (await _upload(hub, _zip(FILES))).status_code == 200
_stored_upload(hub, enabled=enabled)
monkeypatch.setattr(litellm, "public_skills_index", public_index)
monkeypatch.setattr(proxy_server, "general_settings", {"claude_code_marketplace_auth_required": auth_required})
for suffix in ("archive", "content"):
response: Final = await hub.client.get(f"{HUB}/{NAME}/{suffix}", headers=_headers(key))
assert response.status_code == status, response.text
if status != 200:
hub.archives.find_first.assert_not_awaited()
index: Final = await hub.client.get(INDEX)
assert index.status_code == (200 if public_index else 404)
if public_index:
assert [entry["name"] for entry in index.json()["skills"]] == ([NAME] if enabled and not auth_required else [])
async def test_search_paginates_visible_matches(hub: HubClient) -> None:
assert (await _upload(hub, _zip(FILES))).status_code == 200
record: Final = _stored_upload(hub)
hub.plugins.find_many.return_value = [
record.model_copy(update={"name": "private-notes", "enabled": False}), record,
record.model_copy(update={"name": "other-notes"}),
record.model_copy(update={"name": "unmatched", "description": "Different topic"}),
]
for offset, name in enumerate(("other-notes", NAME)):
response: Final = await hub.client.get(
HUB, headers=_headers(PLAIN_KEY), params={"query": "SYNTHETIC", "limit": 1, "offset": offset},
)
assert response.status_code == 200
assert [item["name"] for item in response.json()["skills"]] == [name]
assert response.json()["count"] == 2
assert response.json()["next_offset"] == (1 if offset == 0 else None)
async def test_metadata_publication_and_deletion_preserve_files_and_grants(hub: HubClient) -> None:
assert (await _upload(hub, _zip(FILES))).status_code == 200
original: Final = _stored_upload(hub)
manifest: Final = json.loads(original.manifest_json)
for route, metadata in (
(f"/claude-code/plugins/{NAME}", {"source": manifest["source"], "description": "Legacy edit"}),
(f"{HUB}/{NAME}", {"description": "Neutral edit", "version": "3.0.0", "keywords": ["notes"]}),
(f"{HUB}/{NAME}", {}),
):
response: Final = await hub.client.put(route, headers=_headers(MASTER_KEY), json=metadata)
assert response.status_code == 200, response.text
assert json.loads(hub.plugins.find_unique.return_value.manifest_json)["agent_skill"] == manifest["agent_skill"]
details: Final = await hub.client.get(f"{HUB}/{NAME}", headers=_headers(PLAIN_KEY))
assert details.json()["description"] == metadata.get("description")
_assert_portable(await hub.client.get(details.json()["download_url"]))
for action, public in (("unpublish", False), ("publish", True)):
changed: Final = await hub.client.post(f"{HUB}/{NAME}/{action}", headers=_headers(MASTER_KEY))
assert changed.status_code == 200
assert changed.json()["visibility"] == ("public" if public else "private")
assert (await hub.client.get(f"{HUB}/{NAME}/archive")).status_code == (200 if public else 404)
_assert_portable(await hub.client.get(f"{HUB}/{NAME}/archive", headers=_headers(GRANTED_KEY)))
assert (await hub.client.get(f"{HUB}/{NAME}", headers=_headers(PLAIN_KEY))).status_code == (200 if public else 404)
assert bool((await hub.client.get(INDEX)).json()["skills"]) == public
assert (await hub.client.delete(f"{HUB}/{NAME}", headers=_headers(MASTER_KEY))).status_code == 204
hub.plugins.delete.assert_awaited_once_with(where={"name": NAME})
@pytest.mark.parametrize("method, suffix", (("PUT", ""), ("POST", "/publish"), ("POST", "/unpublish"), ("DELETE", "")))
async def test_granted_readers_cannot_manage_skills(hub: HubClient, method: str, suffix: str) -> None:
response: Final = await hub.client.request(
method, f"{HUB}/{NAME}{suffix}", headers=_headers(GRANTED_KEY), json={} if method == "PUT" else None,
)
assert response.status_code == 403
hub.plugins.update.assert_not_awaited()
hub.plugins.delete.assert_not_awaited()
@pytest.mark.parametrize("content, key, status", (
(b"not a zip", MASTER_KEY, 422),
(_zip(FILES), PLAIN_KEY, 403), (_zip(FILES), None, 401),
), ids=("invalid-zip", "forbidden", "unauthenticated"))
async def test_rejected_uploads_do_not_write(hub: HubClient, content: bytes, key: str | None, status: int) -> None:
assert (await _upload(hub, content, key)).status_code == status
hub.plugins.create.assert_not_awaited()
async def test_stored_content_survives_duplicate_upload_and_is_hidden_after_source_replacement(hub: HubClient) -> None:
assert (await _upload(hub, _zip(FILES))).status_code == 200
original: Final = _stored_upload(hub)
hub.plugins.create.side_effect = UniqueViolationError({"message": "duplicate", "error_code": "P2002"})
response: Final = await _upload(hub, _zip({**FILES, "references/guide.md": b"replacement"}))
assert response.status_code == 409
_assert_portable(await hub.client.get(f"{HUB}/{NAME}/archive"))
hub.plugins.update.assert_not_awaited()
hub.archives.find_first.reset_mock()
manifest: Final = json.loads(original.manifest_json)
external: Final = {
**manifest["source"],
"url": str(httpx.URL(manifest["source"]["url"]).copy_with(scheme="https", host="external.example")),
}
replaced: Final = await hub.client.put(
f"/claude-code/plugins/{NAME}", headers=_headers(MASTER_KEY),
json={"source": external, "agent_skill": manifest["agent_skill"]},
)
assert replaced.status_code == 200, replaced.text
assert "agent_skill" not in json.loads(hub.plugins.find_unique.return_value.manifest_json)
details: Final = await hub.client.get(f"{HUB}/{NAME}", headers=_headers(MASTER_KEY))
assert details.json()["source"] == external and details.json()["download_url"] is None
for suffix in ("archive", "content"):
assert (await hub.client.get(f"{HUB}/{NAME}/{suffix}")).status_code == 404
assert (await hub.client.get(httpx.URL(manifest["source"]["url"]).path)).status_code == 404
hub.archives.find_first.assert_not_awaited()
assert (await hub.client.get(INDEX)).json()["skills"] == []
@pytest.mark.parametrize("user_id, admitted", (("publisher", 10), (None, 20)))
async def test_publication_limit_is_shared_by_user_and_route_and_resets(
hub: HubClient, monkeypatch: pytest.MonkeyPatch, user_id: str | None, admitted: int,
) -> None:
for key in (PLAIN_KEY, GRANTED_KEY):
proxy_server.user_api_key_cache.set_cache(
key=proxy_server.hash_token(key),
value=UserAPIKeyAuth(
token=proxy_server.hash_token(key), user_id=user_id,
user_role=proxy_server.LitellmUserRoles.PROXY_ADMIN,
),
)
responses: Final = await asyncio.gather(*(
_upload(hub, b"invalid", PLAIN_KEY) if index % 2 else hub.client.post(
f"{HUB}/import", headers=_headers(GRANTED_KEY), json={"url": "https://example.com/skill"},
) for index in range(20)
))
assert sum(response.status_code in (400, 422) for response in responses) == admitted
assert sum(response.status_code == 429 for response in responses) == 20 - admitted
assert all(response.headers["retry-after"] == "60" for response in responses if response.status_code == 429)
hub.plugins.create.assert_not_awaited()
hub.upstream.assert_not_called()
assert (await _upload(hub, _zip(FILES))).status_code == 200
_stored_upload(hub)
for _ in range(11):
assert (await hub.client.get(INDEX)).status_code == 200
assert (await hub.client.get(f"{HUB}/{NAME}/archive", headers=_headers(PLAIN_KEY))).status_code == 200
hub.clock.return_value += timedelta(seconds=60)
assert (await _upload(hub, b"invalid", GRANTED_KEY)).status_code == 422
monkeypatch.setattr(proxy_server.proxy_logging_obj, "proxy_hook_mapping", {})
assert (await _upload(hub, b"invalid")).status_code == 503

View file

@ -0,0 +1,189 @@
import hashlib
import io
import stat
import struct
import zipfile
import zlib
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Final, NoReturn
import httpx
import pytest
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
MAX_ARCHIVE_ENTRIES,
MAX_ARCHIVE_UNPACKED_BYTES,
MAX_SKILL_MANIFEST_BYTES,
)
from litellm.proxy.discovery_endpoints.skill_import import (
MAX_IMPORT_BYTES,
GitHubSkillSource,
SkillImportError,
fetch_skill_source,
parse_skill_source,
select_imported_skill,
)
MANIFEST: Final = b"---\nname: pdf-summarizer\ndescription: Summarize PDFs.\n---\n\nRead the PDF.\n"
SOURCE: Final = GitHubSkillSource("author", "repo", "HEAD", "pdf-summarizer", None)
NESTED: Final = "skills/pdf-summarizer"
def repository_zip(entries: tuple[tuple[str | zipfile.ZipInfo, bytes], ...]) -> bytes:
buffer: Final = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name, content in entries:
entry: Final = zipfile.ZipInfo(name) if isinstance(name, str) else name
archive.writestr(entry, content, compress_type=zipfile.ZIP_DEFLATED)
return buffer.getvalue()
@pytest.mark.parametrize(("url", "skill", "ref", "path", "expected"), (
("https://skills.sh/author/repo/pdf-summarizer", None, "HEAD", None, SOURCE),
("https://github.com/author/repo.git/", "pdf-summarizer", "release/v2", NESTED,
GitHubSkillSource("author", "repo", "release/v2", "pdf-summarizer", NESTED)),
(f"https://github.com/author/repo/tree/release%2Fv2/{NESTED}", None, "HEAD", None,
GitHubSkillSource("author", "repo", "release/v2", None, NESTED)),
("http://github.com/author/repo", None, "HEAD", None, 400),
("https://github.com.example/author/repo", None, "HEAD", None, 400),
("https://user:secret@github.com/author/repo", None, "HEAD", None, 400),
("https://github.com/author/repo?token=secret", None, "HEAD", None, 400),
("https://github.com/author/repo#fragment", None, "HEAD", None, 400),
("https://github.com/author%2Fother/repo", None, "HEAD", None, 400),
("https://[invalid/author/repo", None, "HEAD", None, 400),
("https://skills.sh/author/repo", None, "HEAD", None, 400),
("https://skills.sh/author/repo/pdf-summarizer", "other", "HEAD", None, 400),
(f"https://github.com/author/repo/tree/main/{NESTED}", None, "other", None, 400),
(f"https://github.com/author/repo/tree/main/{NESTED}", None, "HEAD", "other", 400),
))
def test_source_urls(
url: str, skill: str | None, ref: str, path: str | None, expected: GitHubSkillSource | int
) -> None:
result: Final = parse_skill_source(url, skill=skill, ref=ref, path=path)
assert (result.status if isinstance(result, SkillImportError) else result) == expected
@dataclass(frozen=True, slots=True)
class DownloadStream(httpx.AsyncByteStream):
overflow: bool
async def __aiter__(self) -> AsyncIterator[bytes]:
yield b"a" * MAX_IMPORT_BYTES
if self.overflow:
yield b"!"
pytest.fail("The download kept reading after exceeding the byte limit")
@pytest.mark.asyncio
@pytest.mark.parametrize(("mode", "status"), (
("bounded", None), ("overflow", 413), ("redirect", 422), ("http-error", 422), ("timeout", 502),
))
async def test_public_download_transport(mode: str, status: int | None) -> None:
credentials: Final = {
"Authorization": "Bearer unit-secret",
"Proxy-Authorization": "Basic unit-secret",
"X-API-Key": "unit-secret",
"Cookie": "session=unit-secret",
}
def respond(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert str(request.url) == "https://codeload.github.com/author/repo/zip/release%2Fv2"
assert not frozenset(key.lower() for key in credentials).intersection(request.headers)
if mode == "timeout":
raise httpx.ReadTimeout("simulated timeout", request=request)
return httpx.Response(
307 if mode == "redirect" else 404 if mode == "http-error" else 200,
headers={"Location": "https://untrusted.example/private", "Content-Length": "1"},
stream=DownloadStream(mode == "overflow"),
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(respond), headers=credentials, auth=httpx.BasicAuth("unit-user", "unit-secret"),
follow_redirects=True, trust_env=False,
) as client:
result: Final = await fetch_skill_source(GitHubSkillSource("author", "repo", "release/v2", None, None), client)
if status is None:
assert isinstance(result, bytes)
actual: Final = (len(result), hashlib.sha256(result).hexdigest())
expected: Final = (MAX_IMPORT_BYTES, hashlib.sha256(b"a" * MAX_IMPORT_BYTES).hexdigest())
assert actual == expected
else:
assert isinstance(result, SkillImportError) and result.status == status
@pytest.mark.parametrize(("skill", "path", "root_name", "expected"), (
(None, "", b"root", "root"),
(None, "/", b"root", "root"),
(None, "SKILL.md", b"root", 422),
(None, NESTED, b"root", "pdf-summarizer"),
("pdf-summarizer", None, b"root", "pdf-summarizer"),
("pdf-summarizer", f"/{NESTED}/", b"pdf-summarizer", "pdf-summarizer"),
(None, None, b"root", 422),
("missing", None, b"root", 422),
("pdf-summarizer", "", b"root", 422),
("pdf-summarizer", None, b"pdf-summarizer", 422),
(None, "", None, 422),
))
def test_repository_selection(skill: str | None, path: str | None, root_name: bytes | None, expected: str | int) -> None:
root: Final = (
(("repo/SKILL.md", MANIFEST.replace(b"pdf-summarizer", root_name)),)
if root_name is not None else ()
)
result: Final = select_imported_skill(
repository_zip(root + ((f"repo/{NESTED}/SKILL.md", MANIFEST),)),
GitHubSkillSource("author", "repo", "HEAD", skill, path),
)
assert (result.status if isinstance(result, SkillImportError) else result.declared_name) == expected
def test_corrupt_manifest_compression_returns_an_import_error() -> None:
content: Final = repository_zip(((f"repo/{NESTED}/SKILL.md", MANIFEST),))
with zipfile.ZipFile(io.BytesIO(content)) as archive:
entry: Final = archive.infolist()[0]
filename_size, extra_size = struct.unpack_from("<HH", content, entry.header_offset + 26)
offset: Final = entry.header_offset + 30 + filename_size + extra_size
invalid: Final = content[:offset] + b"\xff" * entry.compress_size + content[offset + entry.compress_size:]
result: Final = select_imported_skill(invalid, SOURCE)
assert isinstance(result, SkillImportError) and result.status == 422
@pytest.mark.parametrize(("violation", "expected"), (
("path", 422), ("inside-link", 422), ("outside-link", "pdf-summarizer"), ("entries", 413), ("unpacked-bytes", 413),
))
def test_repository_validation_scope(violation: str, expected: str | int) -> None:
paths: Final = {"path": "repo/../escape", "inside-link": f"repo/{NESTED}/run.sh"}
entry: Final = zipfile.ZipInfo(paths.get(violation, "repo/CLAUDE.md"))
entry.create_system = 3
entry.external_attr = ((stat.S_IFLNK if violation.endswith("-link") else stat.S_IFREG) | 0o644) << 16
payload: Final = b"x" * (MAX_ARCHIVE_UNPACKED_BYTES if violation == "unpacked-bytes" else 1)
extra: Final = (
tuple((f"repo/unrelated/{index}", b"x") for index in range(MAX_ARCHIVE_ENTRIES))
if violation == "entries" else ((entry, payload),)
)
result: Final = select_imported_skill(
repository_zip(((f"repo/{NESTED}/SKILL.md", MANIFEST),) + extra), SOURCE
)
assert (result.status if isinstance(result, SkillImportError) else result.declared_name) == expected
@pytest.mark.parametrize(("violation", "status"), (("path", 422), ("symlink", 422), ("manifest-bytes", 422)))
def test_candidate_metadata_is_validated_before_manifest_decompression(
violation: str, status: int, monkeypatch: pytest.MonkeyPatch
) -> None:
entry: Final = zipfile.ZipInfo(
"repo/../pdf-summarizer/SKILL.md" if violation == "path" else f"repo/{NESTED}/SKILL.md"
)
entry.create_system = 3
entry.external_attr = ((stat.S_IFLNK if violation == "symlink" else stat.S_IFREG) | 0o644) << 16
content: Final = repository_zip(
((entry, b"x" * (MAX_SKILL_MANIFEST_BYTES + 1) if violation == "manifest-bytes" else MANIFEST),)
)
def reject_decompression(*args: object, **kwargs: object) -> NoReturn:
pytest.fail("Invalid candidate metadata reached the decompressor before rejection")
monkeypatch.setattr(zlib, "decompressobj", reject_decompression)
result: Final = select_imported_skill(content, SOURCE)
assert isinstance(result, SkillImportError) and result.status == status

View file

@ -0,0 +1,62 @@
import json
from collections.abc import Mapping
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import Request
from prisma import models as prisma_models
from litellm.proxy import proxy_server
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import public_hub_entries
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
def plugin(
name: str, manifest_json: str | None = None, *, host: str = "testserver",
) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
digest: Final = "a" * 64
return prisma_models.LiteLLM_ClaudeCodePluginTable(
id=name, name=name, enabled=True,
manifest_json=manifest_json if manifest_json is not None else json.dumps({
"source": {
"source": "archive", "url": f"http://{host}/claude-code/plugins/{name}/archive/{digest}.zip",
"sha256": digest,
},
"agent_skill": {"name": name, "description": f"Synthetic skill {name}", "digest": f"sha256:{digest}"},
}),
)
@pytest.mark.asyncio
async def test_public_discovery_pages_past_ineligible_and_invalid_rows_before_limit(monkeypatch: pytest.MonkeyPatch) -> None:
malformed: Final = ("{invalid-json", '{"source": {"url": 42}}')
rows: Final = (
tuple(plugin(f"a-invalid-{number:04}", malformed[number % len(malformed)]) for number in range(100))
+ tuple(plugin(f"b-legacy-{number:04}", "{}") for number in range(1000))
+ (plugin("c-external", '{"source": {"source": "archive", "url": "https://external.example/skill.zip"}}'),)
+ tuple(plugin(f"z-portable-{number:04}") for number in range(2000))
)
prisma: Final = MagicMock()
async def find_many(
*, where: Mapping[str, Mapping[str, str] | bool], take: int, order: Mapping[str, str],
) -> tuple[prisma_models.LiteLLM_ClaudeCodePluginTable, ...]:
name_filter: Final = where.get("name", {})
after: Final = name_filter.get("gt", "") if isinstance(name_filter, Mapping) else ""
assert after < "z-portable-0999"
ordered: Final = sorted(rows, key=lambda row: row.name, reverse=order.get("name") == "desc")
return tuple(row for row in ordered if row.enabled == where["enabled"] and row.name > after)[:take]
prisma.writer_db.litellm_claudecodeplugintable.find_many = AsyncMock(side_effect=find_many)
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setenv("PROXY_BASE_URL", "http://testserver")
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
request: Final = Request({
"type": "http", "scheme": "http", "server": ("testserver", 80),
"path": "/.well-known/agent-skills/index.json", "headers": [],
})
entries: Final = await public_hub_entries(request, PluginUploadRepository(prisma))
assert tuple(entry.name for entry in entries) == tuple(f"z-portable-{number:04}" for number in range(1000))

View file

@ -30,7 +30,7 @@ export interface paths {
};
/**
* Agent Skills Index
* @description Agent Skills v0.2.0 discovery index over every skill stored on this proxy.
* @description Discover public Hub skills and the separately enabled hosted skill store.
*/
get: operations["agent_skills_index__well_known_agent_skills_index_json_get"];
put?: never;
@ -339,7 +339,7 @@ export interface paths {
};
/**
* Agent Skills Index
* @description Agent Skills v0.2.0 discovery index over every skill stored on this proxy.
* @description Discover public Hub skills and the separately enabled hosted skill store.
*/
get: operations["agent_skills_index__well_known_skills_index_json_get"];
put?: never;
@ -15670,6 +15670,174 @@ export interface paths {
patch?: never;
trace?: never;
};
"/skill-hub/skills": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Skills
* @description Browse the caller's public and granted private skills.
*/
get: operations["list_skills_skill_hub_skills_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/skills/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Import Skill
* @description Import a public skills.sh or GitHub skill as a portable snapshot.
*/
post: operations["import_skill_skill_hub_skills_import_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/skills/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload Skill
* @description Publish a portable skill while retaining Claude marketplace compatibility.
*/
post: operations["upload_skill_skill_hub_skills_upload_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/skills/{name}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Skill
* @description Read one visible skill and its portable download URL.
*/
get: operations["get_skill_skill_hub_skills__name__get"];
/**
* Replace Skill Metadata
* @description Replace registry metadata, clearing omitted fields and preserving skill files.
*/
put: operations["replace_skill_metadata_skill_hub_skills__name__put"];
post?: never;
/**
* Remove Skill
* @description Delete a skill and its managed archive.
*/
delete: operations["remove_skill_skill_hub_skills__name__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/skills/{name}/archive": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Download Skill
* @description Download the complete portable skill, with SKILL.md at the ZIP root.
*/
get: operations["download_skill_skill_hub_skills__name__archive_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/skills/{name}/content": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Skill Content
* @description Read the original SKILL.md without downloading supporting files.
*/
get: operations["skill_content_skill_hub_skills__name__content_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/skills/{name}/publish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Publish Skill
* @description Make a skill public using the existing Hub publication policy.
*/
post: operations["publish_skill_skill_hub_skills__name__publish_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/skills/{name}/unpublish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Unpublish Skill
* @description Remove a skill from public discovery while retaining explicit grants.
*/
post: operations["unpublish_skill_skill_hub_skills__name__unpublish_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/spend/calculate": {
parameters: {
query?: never;
@ -28173,6 +28341,16 @@ export interface components {
*/
metadata: string;
};
/** Body_upload_skill_skill_hub_skills_upload_post */
Body_upload_skill_skill_hub_skills_upload_post: {
/**
* File
* @description Agent Skills ZIP with SKILL.md and optional supporting files
*/
file: string;
/** Version */
version?: string | null;
};
/** Body_video_create_character_v1_videos_characters_post */
Body_video_create_character_v1_videos_characters_post: {
/** Name */
@ -34197,6 +34375,35 @@ export interface components {
} & {
[key: string]: unknown;
};
/** ImportSkillRequest */
ImportSkillRequest: {
/**
* Path
* @description Repository-relative skill directory
*/
path?: string | null;
/**
* Ref
* @description Git commit, tag or branch to import
* @default HEAD
*/
ref: string;
/**
* Skill
* @description Skill name to select when a repository contains several skills
*/
skill?: string | null;
/**
* Url
* @description Public skills.sh skill URL or GitHub repository URL
*/
url: string;
/**
* Version
* @description Version label for the imported snapshot
*/
version?: string | null;
};
/**
* IncompleteDetails
* @description Details about why the response is incomplete.
@ -46392,6 +46599,54 @@ export interface components {
/** Updated At */
updated_at: string;
};
/** SkillHubList */
SkillHubList: {
/** Count */
count: number;
/** Next Offset */
next_offset: number | null;
/** Skills */
skills: components["schemas"]["SkillHubSkill"][];
};
/** SkillHubMetadata */
SkillHubMetadata: {
/** Description */
description?: string | null;
/** Domain */
domain?: string | null;
/** Keywords */
keywords?: string[] | null;
/** Namespace */
namespace?: string | null;
/** Version */
version?: string | null;
};
/** SkillHubPublication */
SkillHubPublication: Record<string, never>;
/** SkillHubSkill */
SkillHubSkill: {
/** Content Url */
content_url?: string | null;
/** Description */
description?: string | null;
/** Download Url */
download_url?: string | null;
/** Id */
id: string;
/** Name */
name: string;
/** Source */
source: {
[key: string]: string;
};
/** Version */
version?: string | null;
/**
* Visibility
* @enum {string}
*/
visibility: "public" | "private";
};
/** SkillReference */
SkillReference: {
/** Skill Id */
@ -73513,6 +73768,334 @@ export interface operations {
};
};
};
list_skills_skill_hub_skills_get: {
parameters: {
query?: {
query?: string;
offset?: number;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubList"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
import_skill_skill_hub_skills_import_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ImportSkillRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_skill_skill_hub_skills_upload_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_skill_skill_hub_skills_upload_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_skill_skill_hub_skills__name__get: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
replace_skill_metadata_skill_hub_skills__name__put: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SkillHubMetadata"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
remove_skill_skill_hub_skills__name__delete: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
download_skill_skill_hub_skills__name__archive_get: {
parameters: {
query?: {
digest?: string | null;
};
header?: never;
path: {
name: 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"];
};
};
};
};
skill_content_skill_hub_skills__name__content_get: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/markdown": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
publish_skill_skill_hub_skills__name__publish_post: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["SkillHubPublication"] | null;
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
unpublish_skill_skill_hub_skills__name__unpublish_post: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["SkillHubPublication"] | null;
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
calculate_spend_spend_calculate_post: {
parameters: {
query?: never;