diff --git a/litellm/models/plugin_upload.py b/litellm/models/plugin_upload.py index 41fbf901a03..21a55d44960 100644 --- a/litellm/models/plugin_upload.py +++ b/litellm/models/plugin_upload.py @@ -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]: ... diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index 2f613396413..c8fd9d860a1 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -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", diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index ab85b6f8842..6575552c6fd 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -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": { diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 1864c3ae82e..25849634764 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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", diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index b8c90fe7e93..0faefaeaa41 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -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}, diff --git a/litellm/proxy/discovery_endpoints/agent_skills_archive.py b/litellm/proxy/discovery_endpoints/agent_skills_archive.py index dd78896725f..e45ca7e10f2 100644 --- a/litellm/proxy/discovery_endpoints/agent_skills_archive.py +++ b/litellm/proxy/discovery_endpoints/agent_skills_archive.py @@ -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): diff --git a/litellm/proxy/discovery_endpoints/agent_skills_endpoints.py b/litellm/proxy/discovery_endpoints/agent_skills_endpoints.py index 830f7b0334e..3b589b1a4f0 100644 --- a/litellm/proxy/discovery_endpoints/agent_skills_endpoints.py +++ b/litellm/proxy/discovery_endpoints/agent_skills_endpoints.py @@ -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: diff --git a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py new file mode 100644 index 00000000000..faef3b9e4d4 --- /dev/null +++ b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py @@ -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, + ) diff --git a/litellm/proxy/discovery_endpoints/skill_import.py b/litellm/proxy/discovery_endpoints/skill_import.py new file mode 100644 index 00000000000..a74e3abef10 --- /dev/null +++ b/litellm/proxy/discovery_endpoints/skill_import.py @@ -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/// 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 diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9a80761b477..debefec4a30 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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} ) diff --git a/litellm/repositories/plugin_upload_repository.py b/litellm/repositories/plugin_upload_repository.py index 99f1f76abb5..f37b191e3d6 100644 --- a/litellm/repositories/plugin_upload_repository.py +++ b/litellm/repositories/plugin_upload_repository.py @@ -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), + ) diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 49dd8b9ef91..26c610a4f11 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -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): diff --git a/litellm/types/proxy/skill_hub.py b/litellm/types/proxy/skill_hub.py new file mode 100644 index 00000000000..bf28ce592b3 --- /dev/null +++ b/litellm/types/proxy/skill_hub.py @@ -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") diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index f0275c34c06..f4ca7fa2965 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -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. ] diff --git a/tests/unit/proxy/discovery_endpoints/test_agent_skills_archive.py b/tests/unit/proxy/discovery_endpoints/test_agent_skills_archive.py index a3d968263e5..81b901f6b9c 100644 --- a/tests/unit/proxy/discovery_endpoints/test_agent_skills_archive.py +++ b/tests/unit/proxy/discovery_endpoints/test_agent_skills_archive.py @@ -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 diff --git a/tests/unit/proxy/discovery_endpoints/test_agent_skills_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_agent_skills_endpoints.py index ea889e75ae8..89c31cbdf1d 100644 --- a/tests/unit/proxy/discovery_endpoints/test_agent_skills_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_agent_skills_endpoints.py @@ -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)) diff --git a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py new file mode 100644 index 00000000000..143d26a3ccb --- /dev/null +++ b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py @@ -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 diff --git a/tests/unit/proxy/discovery_endpoints/test_skill_import.py b/tests/unit/proxy/discovery_endpoints/test_skill_import.py new file mode 100644 index 00000000000..8df00ce1f5d --- /dev/null +++ b/tests/unit/proxy/discovery_endpoints/test_skill_import.py @@ -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(" 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 diff --git a/tests/unit/repositories/test_plugin_upload_repository.py b/tests/unit/repositories/test_plugin_upload_repository.py new file mode 100644 index 00000000000..dc35973a062 --- /dev/null +++ b/tests/unit/repositories/test_plugin_upload_repository.py @@ -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)) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index f3a9c12a235..11ce7f97ad8 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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; + /** 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;