From 48bd9b01f67df0eb6429a0e6bc8440ca8b14cd46 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 4 Nov 2026 00:56:40 -0800 Subject: [PATCH] feat(skills): upload local skill ZIPs from the dashboard (#50008) --- .../migration.sql | 9 + .../litellm_proxy_extras/schema.prisma | 9 + litellm/integrations/otel/model/spans.py | 1 + litellm/models/plugin_upload.py | 29 ++ litellm/proxy/_lazy_openapi_snapshot.json | 256 +++++++++++++++++ litellm/proxy/_types.py | 2 + .../claude_code_marketplace.py | 146 +++++++++- .../claude_code_skill_access.py | 7 +- .../skill_upload_archive.py | 30 ++ .../claude_code_marketplace_auth.py | 15 + .../proxy/common_utils/http_parsing_utils.py | 19 +- litellm/proxy/deps/repositories.py | 9 + .../agent_skills_archive.py | 84 ++++-- .../request_body_decompression_middleware.py | 13 +- .../request_size_limit_middleware.py | 39 ++- litellm/proxy/proxy_server.py | 7 + litellm/proxy/schema.prisma | 9 + .../repositories/plugin_upload_repository.py | 71 +++++ litellm/types/proxy/claude_code_endpoints.py | 35 ++- schema.prisma | 9 + scripts/check_openapi_docs.py | 2 +- .../test_claude_code_marketplace.py | 194 ++++++++++++- .../test_skill_upload_archive.py | 92 ++++++ .../common_utils/test_http_parsing_utils.py | 25 +- .../test_agent_skills_archive.py | 16 +- ...t_request_body_decompression_middleware.py | 56 +++- .../test_request_size_limit_middleware.py | 75 ++++- tests/unit/test_check_openapi_docs.py | 16 +- ...x => add_plugin_form.integration.test.tsx} | 77 ++++- .../skills/_components/add_plugin_form.tsx | 264 +++++++++++++----- .../claude_code_plugins/helpers.test.ts | 38 +++ .../components/claude_code_plugins/helpers.ts | 21 ++ .../skill_detail.integration.test.tsx | 68 +++++ .../claude_code_plugins/skill_detail.tsx | 72 +++-- .../src/components/networking.test.ts | 20 ++ .../src/components/networking.tsx | 11 + ui/litellm-dashboard/src/i18n/locales/en.json | 13 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 169 +++++++++++ 38 files changed, 1839 insertions(+), 189 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20261104000000_skill_zip_upload/migration.sql create mode 100644 litellm/models/plugin_upload.py create mode 100644 litellm/proxy/anthropic_endpoints/claude_code_endpoints/skill_upload_archive.py create mode 100644 litellm/repositories/plugin_upload_repository.py create mode 100644 tests/unit/proxy/anthropic_endpoints/test_skill_upload_archive.py rename ui/litellm-dashboard/src/app/(dashboard)/skills/_components/{add_plugin_form.test.tsx => add_plugin_form.integration.test.tsx} (74%) create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.integration.test.tsx diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261104000000_skill_zip_upload/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261104000000_skill_zip_upload/migration.sql new file mode 100644 index 00000000000..acbe87515d9 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261104000000_skill_zip_upload/migration.sql @@ -0,0 +1,9 @@ +SET LOCAL lock_timeout = '10s'; + +CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginUpload" ( + "plugin_name" TEXT NOT NULL, + "content" BYTEA NOT NULL, + CONSTRAINT "LiteLLM_ClaudeCodePluginUpload_pkey" PRIMARY KEY ("plugin_name"), + CONSTRAINT "LiteLLM_ClaudeCodePluginUpload_plugin_name_fkey" FOREIGN KEY ("plugin_name") + REFERENCES "LiteLLM_ClaudeCodePluginTable"("name") ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 179b741fe7c..c007b047087 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1615,9 +1615,18 @@ model LiteLLM_ClaudeCodePluginTable { updated_at DateTime? @default(now()) @updatedAt created_by String? + uploaded_archive LiteLLM_ClaudeCodePluginUpload? + @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginUpload { + plugin_name String @id + content Bytes + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_name], references: [name], onDelete: Cascade, onUpdate: Cascade) +} + + // User/team-scoped memory store with a GLOBAL unique key. // `value` is a string (typically markdown/text meant for LLM context); // `metadata` is an optional JSON envelope for structured tags without schema changes. diff --git a/litellm/integrations/otel/model/spans.py b/litellm/integrations/otel/model/spans.py index 320231430d2..ece85b09432 100644 --- a/litellm/integrations/otel/model/spans.py +++ b/litellm/integrations/otel/model/spans.py @@ -341,6 +341,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset( "LiteLLM_AccountingDelivery", "LiteLLM_AccountingEvent", "LiteLLM_ClaudeCodePluginTable", + "LiteLLM_ClaudeCodePluginUpload", "LiteLLM_MemoryTable", "LiteLLM_AdaptiveRouterState", "LiteLLM_AdaptiveRouterSession", diff --git a/litellm/models/plugin_upload.py b/litellm/models/plugin_upload.py new file mode 100644 index 00000000000..41fbf901a03 --- /dev/null +++ b/litellm/models/plugin_upload.py @@ -0,0 +1,29 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class PluginUpload: + name: str + version: str | None + description: str | None + manifest: Mapping[str, object] + content: bytes + created_by: str | None + + +@dataclass(frozen=True, slots=True) +class PluginArchiveMetadata: + name: str + enabled: bool + source: Mapping[str, str] + manifest_json: str | None + + +class PluginUploadStore(Protocol): + async def create(self, upload: PluginUpload) -> str | None: ... + + async def get_metadata(self, name: str) -> PluginArchiveMetadata | None: ... + + async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None: ... diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 5cc1c53d396..b290adc47ce 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -7454,6 +7454,31 @@ "claude_code_marketplace": { "components": { "schemas": { + "Body_upload_plugin_claude_code_plugins_upload_post": { + "properties": { + "file": { + "contentMediaType": "application/octet-stream", + "description": "Skill ZIP containing SKILL.md and supporting files, up to 10 MiB", + "title": "File", + "type": "string" + }, + "metadata": { + "contentMediaType": "application/json", + "contentSchema": { + "$ref": "#/components/schemas/UploadPluginRequest" + }, + "description": "JSON skill metadata without a source URL", + "title": "Metadata", + "type": "string" + } + }, + "required": [ + "file", + "metadata" + ], + "title": "Body_upload_plugin_claude_code_plugins_upload_post", + "type": "object" + }, "HTTPValidationError": { "properties": { "detail": { @@ -8057,6 +8082,137 @@ "title": "UpdatePluginRequest", "type": "object" }, + "UploadPluginRequest": { + "properties": { + "author": { + "anyOf": [ + { + "$ref": "#/components/schemas/PluginAuthor" + }, + { + "type": "null" + } + ], + "description": "Plugin author" + }, + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plugin category", + "title": "Category" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plugin description", + "title": "Description" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skill domain (e.g., 'Productivity')", + "title": "Domain" + }, + "homepage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plugin homepage URL", + "title": "Homepage" + }, + "installation_preference": { + "anyOf": [ + { + "enum": [ + "available", + "auto_install", + "required" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin", + "title": "Installation Preference" + }, + "keywords": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Search keywords", + "title": "Keywords" + }, + "name": { + "description": "Skill name (kebab-case)", + "pattern": "^[a-z0-9-]+$", + "title": "Name", + "type": "string" + }, + "namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skill namespace within domain (e.g., 'workflows')", + "title": "Namespace" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "1.0.0", + "description": "Semantic version", + "title": "Version" + } + }, + "required": [ + "name" + ], + "title": "UploadPluginRequest", + "type": "object" + }, "ValidationError": { "properties": { "ctx": { @@ -8242,6 +8398,53 @@ ] } }, + "/claude-code/plugins/upload": { + "post": { + "description": "Create a skill and its uploaded ZIP atomically. Duplicate names return 409.", + "operationId": "upload_plugin_claude_code_plugins_upload_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_plugin_claude_code_plugins_upload_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterPluginResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Upload Plugin", + "tags": [ + "claude_code_marketplace" + ] + } + }, "/claude-code/plugins/{plugin_name}": { "delete": { "description": "Delete a plugin from the marketplace.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: The name of the plugin to delete", @@ -8388,6 +8591,59 @@ ] } }, + "/claude-code/plugins/{plugin_name}/archive/{digest}.zip": { + "get": { + "description": "Download an uploaded ZIP under the same visibility policy as its skill.", + "operationId": "download_plugin_archive_claude_code_plugins__plugin_name__archive__digest__zip_get", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + }, + { + "in": "path", + "name": "digest", + "required": true, + "schema": { + "title": "Digest", + "type": "string" + } + } + ], + "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 Plugin Archive", + "tags": [ + "claude_code_marketplace" + ] + } + }, "/claude-code/plugins/{plugin_name}/disable": { "post": { "description": "Disable a plugin without deleting it.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: The name of the plugin to disable", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 2ef4c0089f9..de1096701af 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -467,6 +467,8 @@ class LiteLLMRoutes(enum.Enum): "/v1/skills/{skill_id}", "/claude-code/marketplace.json", "/claude-code/plugins", + "/claude-code/plugins/upload", + "/claude-code/plugins/{plugin_name}/archive/{digest}.zip", "/claude-code/plugins/{plugin_name}", ] 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 fc82686947d..5df73230b6c 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 @@ -18,6 +18,8 @@ Endpoints: /claude-code/plugins/{name} - DELETE - Delete a plugin (proxy admin only) """ +import asyncio +import hashlib import json import re from collections.abc import Mapping, Sequence @@ -26,21 +28,30 @@ from types import MappingProxyType from typing import Annotated, Final, Protocol, TypedDict, get_args from urllib.parse import urlsplit -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import JSONResponse -from pydantic import TypeAdapter +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse, Response +from pydantic import Json, TypeAdapter from typing_extensions import ReadOnly from litellm._logging import verbose_proxy_logger +from litellm.models.plugin_upload import PluginUpload from litellm.proxy._types import CommonProxyErrors, ProxyErrorTypes, ProxyException, UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import ( SkillVisibility, resolve_skill_visibility, ) +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import ( + MAX_SKILL_UPLOAD_BYTES, + build_uploaded_skill_archive, +) from litellm.proxy.auth.auth_exception_handler import DB_UNAVAILABLE_FALLBACK_USER_ID 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 +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.repositories.table_repositories import ClaudeCodePluginRepository from litellm.types.proxy.auth.special_headers import SpecialHeaders from litellm.types.proxy.claude_code_endpoints import ( @@ -52,6 +63,7 @@ from litellm.types.proxy.claude_code_endpoints import ( RegisterPluginRequest, RegisterPluginResponse, UpdatePluginRequest, + UploadPluginRequest, ) router: Final = APIRouter() @@ -148,6 +160,117 @@ async def _marketplace_caller(request: Request, key: str | None) -> UserAPIKeyAu return None +@router.post( + "/claude-code/plugins/upload", + tags=["Claude Code Marketplace"], + response_model=RegisterPluginResponse, +) +async def upload_plugin( + request: Request, + uploads: PluginUploadRepositoryDep, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + file: Annotated[UploadFile, File(description="Skill ZIP containing SKILL.md and supporting files, up to 10 MiB")], + 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) + content: Final = await file.read(MAX_SKILL_UPLOAD_BYTES + 1) + if len(content) > MAX_SKILL_UPLOAD_BYTES: + raise _error_response(413, "Skill ZIP must be 10 MiB or smaller") + archive: Final = await asyncio.to_thread(build_uploaded_skill_archive, content, metadata.name) + if archive is None: + raise _error_response( + 400, "Upload a valid ZIP with SKILL.md at the root or inside one skill folder, and safe supporting files" + ) + 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", + ), + "sha256": digest, + } + spec: Final = RegisterPluginRequest.model_validate({**metadata.model_dump(), "source": source}) + _validate_installation_preference(spec) + plugin_id: Final = await uploads.create( + PluginUpload( + name=spec.name, + version=spec.version, + description=spec.description, + manifest=_build_plugin_manifest(spec.name, spec), + content=archive, + created_by=user_api_key_dict.user_id, + ) + ) + if plugin_id is None: + raise _name_conflict_error(spec.name) + return RegisterPluginResponse( + status="success", + action="created", + plugin=PluginResponse( + id=plugin_id, + name=spec.name, + version=spec.version, + description=spec.description, + source=source, + installation_preference=spec.installation_preference, + enabled=True, + ), + ) + + +@router.get( + "/claude-code/plugins/{plugin_name}/archive/{digest}.zip", + tags=["Claude Code Marketplace"], + response_class=Response, + responses={200: {"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}}}}, +) +async def download_plugin_archive( + request: Request, + plugin_name: str, + digest: str, + 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) + if marketplace_key_header_present(request) + else await _marketplace_caller(request, None) + ) + visibility: Final = await resolve_skill_visibility(caller) + 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} + ): + raise _error_response(404, "Skill archive not found") + content: Final = await uploads.get_content(archive) + if content is None: + raise _error_response(404, "Skill archive not found") + return Response( + content=content, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{archive.name}.zip"', + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + }, + ) + + @router.get( "/claude-code/marketplace.json", tags=["Claude Code Marketplace"], @@ -680,18 +803,19 @@ async def update_plugin( prisma_client: Final = await get_prisma_client() - _validate_plugin_source(request.source) - _validate_installation_preference(request) - - existing: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique( - where={"name": plugin_name} - ) + repository: Final = ClaudeCodePluginRepository(prisma_client, use_writer=True) + existing: Final[_PluginRecord | None] = await repository.table.find_unique(where={"name": plugin_name}) if not existing: raise _error_response(404, f"Plugin '{plugin_name}' not found") + current_manifest: Final = MANIFEST_FIELDS.validate_json(existing.manifest_json or "{}") + if request.source != current_manifest.get("source"): + _validate_plugin_source(request.source) + _validate_installation_preference(request) + manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request) - plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.update( + plugin: Final[_PluginRecord | None] = await repository.table.update( where={"name": plugin_name}, data={ "version": request.version, diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py index 9e5b55e7e36..4775498dec3 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py @@ -19,8 +19,11 @@ if TYPE_CHECKING: class _SkillRecord(Protocol): - name: str - enabled: bool + @property + def name(self) -> str: ... + + @property + def enabled(self) -> bool: ... def _skills_of(permission: LiteLLM_ObjectPermissionTable | None) -> frozenset[str]: diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/skill_upload_archive.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/skill_upload_archive.py new file mode 100644 index 00000000000..48af819b673 --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/skill_upload_archive.py @@ -0,0 +1,30 @@ +import json +import re +import struct +import zipfile +from typing import Final + +from litellm.proxy.discovery_endpoints.agent_skills_archive import pack_skill_archive, read_skill_archive_members + +MAX_SKILL_UPLOAD_BYTES: Final = 10 * 1024 * 1024 +MAX_SKILL_ARCHIVE_COMPRESSION_RATIO: Final = 50 + + +def build_uploaded_skill_archive(content: bytes, name: str) -> bytes | None: + if len(content) > MAX_SKILL_UPLOAD_BYTES or re.fullmatch(r"[a-z0-9-]+", name) is None: + return None + try: + skill: Final = read_skill_archive_members(content, for_upload=True) + if skill is None: + return None + members: Final = ((".claude-plugin/plugin.json", json.dumps({"name": name}).encode(), 0o644),) + tuple( + (f"skills/{name}/{path}", data, mode) for path, data, mode in skill + ) + compressed: Final = pack_skill_archive(members) + return ( + pack_skill_archive(members, zipfile.ZIP_STORED) + if sum(len(data) for _, data, _ in members) > len(compressed) * MAX_SKILL_ARCHIVE_COMPRESSION_RATIO + else compressed + ) + except (struct.error, UnicodeEncodeError): + return None diff --git a/litellm/proxy/common_utils/claude_code_marketplace_auth.py b/litellm/proxy/common_utils/claude_code_marketplace_auth.py index 0cfb3061744..2cf9ae03327 100644 --- a/litellm/proxy/common_utils/claude_code_marketplace_auth.py +++ b/litellm/proxy/common_utils/claude_code_marketplace_auth.py @@ -1,10 +1,25 @@ from typing import Final +from fastapi import Request from pydantic import TypeAdapter, ValidationError +from litellm.types.proxy.auth.special_headers import SpecialHeaders + _AUTH_REQUIRED_SETTING: Final[TypeAdapter[bool | None]] = TypeAdapter(bool | None) +def marketplace_key_header_present(request: Request) -> bool: + """Recognize header credentials before an optional-auth archive chooses anonymity.""" + from litellm.proxy.proxy_server import general_settings_view + + configured_header: Final = general_settings_view().get("litellm_key_header_name") + return ( + SpecialHeaders.openai_authorization.value in request.headers + or SpecialHeaders.custom_litellm_api_key.value in request.headers + or (isinstance(configured_header, str) and configured_header in request.headers) + ) + + def marketplace_auth_required() -> bool: from litellm.proxy.proxy_server import general_settings_view diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index baf586acef6..e210c337922 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -207,10 +207,10 @@ def _parse_form_metadata(raw_metadata: str) -> object: """The JSON body path rejects a lone surrogate escape (``"\\ud800"``) with a 400 because no provider or database can encode it. ``json.loads`` accepts the escape, so the multipart ``metadata`` field gets the same check.""" - parsed: Final[object] = json.loads(raw_metadata) try: + parsed: Final[object] = json.loads(raw_metadata) json.dumps(parsed, ensure_ascii=False).encode("utf-8") - except UnicodeEncodeError as e: + except (json.JSONDecodeError, UnicodeEncodeError) as e: raise ProxyException( message=f"Invalid form payload: metadata {e}", type="invalid_request_error", @@ -220,6 +220,11 @@ def _parse_form_metadata(raw_metadata: str) -> object: return parsed +def _reraise_request_size_error(error: Exception) -> None: + if isinstance(error, HTTPException) and error.status_code == status.HTTP_413_CONTENT_TOO_LARGE: + raise error + + async def _read_form_data(request: Request) -> FormData: try: form_data: Final = await request.form() @@ -227,6 +232,7 @@ async def _read_form_data(request: Request) -> FormData: except ClientDisconnect: raise except Exception as e: + _reraise_request_size_error(e) # ``request.form()`` raises on malformed multipart (missing # boundary, malformed chunk encoding, …). Surface as 400 so # the auth-time pre-read does not silently cache ``{}`` while @@ -377,6 +383,7 @@ async def read_request_body(request: Request | None) -> dict: raise disconnected from e except Exception as e: # Catch unexpected errors to avoid crashes + _reraise_request_size_error(e) verbose_proxy_logger.exception("Unexpected error reading request body - %s", e) return {} @@ -814,10 +821,6 @@ def populate_request_with_path_params(request_data: dict, request: Request) -> d request_data["vector_store_ids"] = [value] continue request_data.setdefault(key, value) - verbose_proxy_logger.debug( - "populate_request_with_path_params: Found path_params, vector_store_ids=%s", - request_data.get("vector_store_ids"), - ) return request_data # Fallback: parse the URL path directly to extract vector_store_id @@ -853,7 +856,3 @@ def _add_vector_store_id_from_path(request_data: dict, request: Request) -> None existing_ids.append(vector_store_id) else: request_data["vector_store_ids"] = [vector_store_id] - verbose_proxy_logger.debug( - "populate_request_with_path_params: Updated request_data with vector_store_ids=%s", - request_data.get("vector_store_ids"), - ) diff --git a/litellm/proxy/deps/repositories.py b/litellm/proxy/deps/repositories.py index e37ecfd582a..8f929119dee 100644 --- a/litellm/proxy/deps/repositories.py +++ b/litellm/proxy/deps/repositories.py @@ -4,10 +4,12 @@ from fastapi import Depends from litellm.models.deployment_warning import DeploymentWarningIgnoreStore from litellm.models.gateway_activity import GatewayActivityStore +from litellm.models.plugin_upload import PluginUploadStore from litellm.models.user_banner import UserBannerStore from litellm.proxy.deps.database import DatabaseDep from litellm.repositories.deployment_warning_ignore_repository import DeploymentWarningIgnoreRepository from litellm.repositories.gateway_activity_repository import GatewayActivityRepository +from litellm.repositories.plugin_upload_repository import PluginUploadRepository from litellm.repositories.user_banner_repository import UserBannerRepository @@ -34,3 +36,10 @@ async def get_gateway_activity_repository(database: DatabaseDep) -> GatewayActiv GatewayActivityRepositoryDep: TypeAlias = Annotated[ GatewayActivityStore | None, Depends(get_gateway_activity_repository) ] + + +async def get_plugin_upload_repository(database: DatabaseDep) -> PluginUploadStore | None: + return None if database is None else PluginUploadRepository(database.prisma) + + +PluginUploadRepositoryDep: TypeAlias = Annotated[PluginUploadStore | None, Depends(get_plugin_upload_repository)] diff --git a/litellm/proxy/discovery_endpoints/agent_skills_archive.py b/litellm/proxy/discovery_endpoints/agent_skills_archive.py index 94a0c3e2cf1..dd78896725f 100644 --- a/litellm/proxy/discovery_endpoints/agent_skills_archive.py +++ b/litellm/proxy/discovery_endpoints/agent_skills_archive.py @@ -8,8 +8,11 @@ SHA-256 digest published in the index reproducible for identical uploads. import hashlib import io +import lzma import re +import stat import zipfile +import zlib from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType @@ -23,7 +26,7 @@ MAX_ARCHIVE_ENTRIES: Final = 1000 SKILL_MANIFEST_FILENAME: Final = "SKILL.md" _ZIP_ENTRY_TIMESTAMP: Final = (1980, 1, 1, 0, 0, 0) -_ZIP_ENTRY_PERMISSIONS: Final = 0o644 << 16 +_ZIP_ENTRY_MODE: Final = 0o644 _FRONTMATTER_PATTERN: Final = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL) _WINDOWS_DRIVE_PATTERN: Final = re.compile(r"^[A-Za-z]:") _EMPTY_FRONTMATTER: Final[Mapping[str, object]] = MappingProxyType({}) @@ -38,19 +41,26 @@ class SkillArchive: declared_description: str | None -def build_skill_archive(stored_content: bytes) -> SkillArchive | None: - """Return the installable archive for an upload, or None when it holds no root SKILL.md.""" +def read_skill_archive_members( + stored_content: bytes, *, for_upload: bool = False +) -> tuple[tuple[str, bytes, int], ...] | None: try: with zipfile.ZipFile(io.BytesIO(stored_content)) as uploaded: - members: Final = _flattened_members(uploaded) - except (zipfile.BadZipFile, OSError, RuntimeError): + return _flattened_members(uploaded, for_upload=for_upload) + except (zipfile.BadZipFile, OSError, RuntimeError, EOFError, lzma.LZMAError, zlib.error): return None + +def build_skill_archive(stored_content: bytes) -> SkillArchive | None: + """Return the installable archive for an upload, or None when it holds no root SKILL.md.""" + 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)) - content: Final = _repack(members) + frontmatter: Final = _manifest_frontmatter( + next(data for name, data, _ in members if name == SKILL_MANIFEST_FILENAME) + ) + content: Final = pack_skill_archive(members) return SkillArchive( content=content, digest=f"sha256:{hashlib.sha256(content).hexdigest()}", @@ -59,24 +69,58 @@ def build_skill_archive(stored_content: bytes) -> SkillArchive | None: ) -def _flattened_members(uploaded: zipfile.ZipFile) -> tuple[tuple[str, bytes], ...] | None: - infos: Final = tuple(info for info in uploaded.infolist() if not info.is_dir()) +def _flattened_members(uploaded: zipfile.ZipFile, *, for_upload: bool) -> tuple[tuple[str, bytes, int], ...] | 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 if sum(info.file_size for info in infos) > MAX_ARCHIVE_UNPACKED_BYTES: return None - normalized: Final = tuple((info, _normalized_path(info.filename)) for info in infos) + normalized: Final = tuple( + (info, _normalized_path(info.orig_filename if for_upload else info.filename)) for info in infos + ) 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 - prefix: Final = _common_root_prefix(tuple(path for _, path in normalized if path is not None)) - flattened: Final = tuple((info, path[len(prefix) :]) for info, path in normalized if path is not None) + 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): return None - return tuple((name, uploaded.read(info)) for info, name in sorted(flattened, key=lambda member: member[1])) + 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]) + ) + + +def _entry_mode(info: zipfile.ZipInfo, *, preserve_executable_bits: bool) -> int: + if not preserve_executable_bits or info.create_system != 3: + return _ZIP_ENTRY_MODE + mode: Final = info.external_attr >> 16 + if stat.S_IFMT(mode) not in (0, stat.S_IFREG): + return _ZIP_ENTRY_MODE + 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 _is_macos_metadata(path: str) -> bool: + return path.split("/", 1)[0] == "__MACOSX" or path.rsplit("/", 1)[-1] == ".DS_Store" def _common_root_prefix(paths: tuple[str, ...]) -> str: @@ -117,16 +161,16 @@ def _frontmatter_text(frontmatter: Mapping[str, object], key: str) -> str | None return value.strip() or None -def _zip_entry(name: str) -> zipfile.ZipInfo: +def _zip_entry(name: str, mode: int) -> zipfile.ZipInfo: entry: Final = zipfile.ZipInfo(filename=name, date_time=_ZIP_ENTRY_TIMESTAMP) - entry.compress_type = zipfile.ZIP_DEFLATED - entry.external_attr = _ZIP_ENTRY_PERMISSIONS + entry.create_system = 3 + entry.external_attr = mode << 16 return entry -def _repack(members: tuple[tuple[str, bytes], ...]) -> bytes: +def pack_skill_archive(members: tuple[tuple[str, bytes, int], ...], compression: int = zipfile.ZIP_DEFLATED) -> bytes: buffer: Final = io.BytesIO() - with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as repacked: - for name, data in members: - repacked.writestr(_zip_entry(name), data) + with zipfile.ZipFile(buffer, mode="w") as repacked: + for name, data, mode in members: + repacked.writestr(_zip_entry(name, mode), data, compress_type=compression) return buffer.getvalue() diff --git a/litellm/proxy/middleware/request_body_decompression_middleware.py b/litellm/proxy/middleware/request_body_decompression_middleware.py index 3e144d7bf4c..2c36a621387 100644 --- a/litellm/proxy/middleware/request_body_decompression_middleware.py +++ b/litellm/proxy/middleware/request_body_decompression_middleware.py @@ -20,7 +20,11 @@ from litellm.constants import ( ) from litellm.proxy.auth.auth_utils import get_request_route from litellm.proxy.common_utils.anthropic_error_payload import proxy_error_type, route_error_content -from litellm.proxy.middleware.request_size_limit_middleware import MaxRequestSizeGetter, send_request_too_large +from litellm.proxy.middleware.request_size_limit_middleware import ( + MaxRequestSizeGetter, + effective_max_request_size_mb, + send_request_too_large, +) RFC_9659_ZSTD_WINDOW_LOG_MAX: Final = 23 GZIP_MAGIC: Final = b"\x1f\x8b" @@ -337,6 +341,7 @@ class RequestBodyDecompressionMiddleware: max_decompression_ratio: int = MAX_REQUEST_BODY_DECOMPRESSION_RATIO, max_decoded_bodies_in_flight_mb: float = MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB, codecs: Mapping[str, Codec] = AVAILABLE_CODECS, + route_max_request_size_mb: Mapping[tuple[str, str], float] | None = None, ) -> None: self.app = app self.get_max_request_size_mb: Final = get_max_request_size_mb @@ -344,6 +349,7 @@ class RequestBodyDecompressionMiddleware: self.max_decompression_ratio: Final = max_decompression_ratio self.budget: Final = _DecodedBodyBudget(int(max_decoded_bodies_in_flight_mb * 1024 * 1024)) self.codecs: Final = codecs + self.route_max_request_size_mb: Final = MappingProxyType(dict(route_max_request_size_mb or {})) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: coding: Final = _coding_for(scope) @@ -378,7 +384,10 @@ class RequestBodyDecompressionMiddleware: async def _decode_then_call( self, scope: Scope, receive: Receive, send: Send, codec: Codec, prefix: _Prefix ) -> None: - max_mb: Final = _effective_max_mb(self.max_decompressed_body_size_mb, self.get_max_request_size_mb()) + request_max_mb: Final = effective_max_request_size_mb( + scope, self.get_max_request_size_mb(), self.route_max_request_size_mb + ) + max_mb: Final = _effective_max_mb(self.max_decompressed_body_size_mb, request_max_mb) limits: Final = _Limits(max_mb=max_mb, max_ratio=self.max_decompression_ratio) match await _receive_body(scope, receive, prefix, limits.max_bytes): case _Body(data=data): diff --git a/litellm/proxy/middleware/request_size_limit_middleware.py b/litellm/proxy/middleware/request_size_limit_middleware.py index 15bbf05bd01..f2883168d65 100644 --- a/litellm/proxy/middleware/request_size_limit_middleware.py +++ b/litellm/proxy/middleware/request_size_limit_middleware.py @@ -1,7 +1,10 @@ import json -from collections.abc import Callable +from collections.abc import Callable, Mapping +from types import MappingProxyType from typing import Final +from fastapi import HTTPException +from starlette._utils import get_route_path from starlette.types import ASGIApp, Message, Receive, Scope, Send from litellm.proxy.common_utils.anthropic_error_payload import route_error_content @@ -10,8 +13,22 @@ MaxRequestSizeGetter = Callable[[], int | float | None] RequestSizeLimitEnabledGetter = Callable[[], bool] -class RequestEntityTooLarge(Exception): - pass +class RequestEntityTooLarge(HTTPException): + def __init__(self, max_request_size_mb: float) -> None: + super().__init__(status_code=413, detail=f"Request size is too large. Max size is {max_request_size_mb} MB") + + +def effective_max_request_size_mb( + scope: Scope, + configured: float | None, + route_limits: Mapping[tuple[str, str], float], +) -> float | None: + global_limit: Final = configured if configured is not None and configured > 0 else None + method: Final = scope.get("method") + route_limit: Final = route_limits.get((method, get_route_path(scope))) if isinstance(method, str) else None + if route_limit is None: + return global_limit + return min(route_limit, global_limit) if global_limit is not None else route_limit class RequestSizeLimitMiddleware: @@ -28,19 +45,29 @@ class RequestSizeLimitMiddleware: app: ASGIApp, get_max_request_size_mb: MaxRequestSizeGetter, is_request_size_limit_enabled: RequestSizeLimitEnabledGetter, + route_max_request_size_mb: Mapping[tuple[str, str], float] | None = None, ) -> None: self.app = app self.get_max_request_size_mb = get_max_request_size_mb self.is_request_size_limit_enabled = is_request_size_limit_enabled + self.route_max_request_size_mb = MappingProxyType(dict(route_max_request_size_mb or {})) + + def _max_request_size_mb(self, scope: Scope) -> float | None: + configured: Final = self.get_max_request_size_mb() + return effective_max_request_size_mb( + scope, + configured if self.is_request_size_limit_enabled() else None, + self.route_max_request_size_mb, + ) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": await self.app(scope, receive, send) return - max_request_size_mb: Final = self.get_max_request_size_mb() + max_request_size_mb: Final = self._max_request_size_mb(scope) max_request_size_bytes: Final = _mb_to_bytes(max_request_size_mb) - if max_request_size_bytes is None or not self.is_request_size_limit_enabled(): + if max_request_size_bytes is None or max_request_size_mb is None: await self.app(scope, receive, send) return @@ -61,7 +88,7 @@ class RequestSizeLimitMiddleware: received_body_bytes += len(message.get("body", b"")) if received_body_bytes > max_request_size_bytes: - raise RequestEntityTooLarge + raise RequestEntityTooLarge(max_request_size_mb) return message async def tracking_send(message: Message) -> None: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 698c1e6e752..cfcec018f4d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2931,9 +2931,15 @@ def configured_max_request_size_mb() -> int | float | None: return MAX_REQUEST_SIZE_MB_SETTING.validate_python(general_settings.get("max_request_size_mb")) +_ROUTE_MAX_REQUEST_SIZE_MB: Final[Mapping[tuple[str, str], float]] = MappingProxyType( + {("POST", "/claude-code/plugins/upload"): 11} +) + + app.add_middleware( RequestBodyDecompressionMiddleware, get_max_request_size_mb=lambda: configured_max_request_size_mb() if premium_user is True else None, + route_max_request_size_mb=_ROUTE_MAX_REQUEST_SIZE_MB, ) app.add_middleware( CORSMiddleware, @@ -22420,6 +22426,7 @@ app.add_middleware( RequestSizeLimitMiddleware, get_max_request_size_mb=configured_max_request_size_mb, is_request_size_limit_enabled=lambda: premium_user is True, + route_max_request_size_mb=_ROUTE_MAX_REQUEST_SIZE_MB, ) app.add_middleware( AdmissionControlMiddleware, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 179b741fe7c..c007b047087 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1615,9 +1615,18 @@ model LiteLLM_ClaudeCodePluginTable { updated_at DateTime? @default(now()) @updatedAt created_by String? + uploaded_archive LiteLLM_ClaudeCodePluginUpload? + @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginUpload { + plugin_name String @id + content Bytes + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_name], references: [name], onDelete: Cascade, onUpdate: Cascade) +} + + // User/team-scoped memory store with a GLOBAL unique key. // `value` is a string (typically markdown/text meant for LLM context); // `metadata` is an optional JSON envelope for structured tags without schema changes. diff --git a/litellm/repositories/plugin_upload_repository.py b/litellm/repositories/plugin_upload_repository.py new file mode 100644 index 00000000000..99f1f76abb5 --- /dev/null +++ b/litellm/repositories/plugin_upload_repository.py @@ -0,0 +1,71 @@ +import json +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 litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload +from litellm.repositories.prisma_protocols import TableActions +from litellm.repositories.table_repositories import ClaudeCodePluginRepository + +if TYPE_CHECKING: + from prisma import models as prisma_models + + from litellm.proxy.utils import PrismaClient + +_MANIFEST: Final = TypeAdapter(dict[str, object]) +_SOURCE: Final = TypeAdapter(dict[str, str]) + + +class PluginUploadRepository: + def __init__(self, prisma_client: "PrismaClient") -> None: + self._plugins = ClaudeCodePluginRepository(prisma_client, use_writer=True).table + self._archives = cast( # cast-ok: Prisma exposes generated table actions through an untyped delegate + TableActions["prisma_models.LiteLLM_ClaudeCodePluginUpload"], + prisma_client.writer_db.litellm_claudecodepluginupload, + ) + + async def create(self, upload: PluginUpload) -> str | None: + from prisma import Base64 + from prisma.errors import UniqueViolationError + + try: + plugin: Final = await self._plugins.create( + data={ + "name": upload.name, + "version": upload.version, + "description": upload.description, + "manifest_json": json.dumps(dict(upload.manifest)), + "created_by": upload.created_by, + "uploaded_archive": {"create": {"content": Base64.encode(upload.content)}}, + } + ) + except UniqueViolationError: + return None + return plugin.id + + async def get_metadata(self, name: str) -> PluginArchiveMetadata | None: + 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, + ) + + async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None: + archive: Final = await self._archives.find_first( + where={ + "plugin_name": metadata.name, + "plugin": { + "is": { + "manifest_json": metadata.manifest_json, + "enabled": metadata.enabled, + } + }, + } + ) + return None if archive is None else archive.content.decode() diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 19e40aa5c5d..49dd8b9ef91 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -4,7 +4,7 @@ Claude Code Marketplace endpoint types for LiteLLM Proxy from typing import Final, Literal, TypeAlias -from pydantic import AliasChoices, Field, TypeAdapter, model_validator +from pydantic import AliasChoices, ConfigDict, Field, TypeAdapter, model_validator from litellm.types.llms.base import LiteLLMBaseModel @@ -27,20 +27,11 @@ class PluginOwner(LiteLLMBaseModel): email: str | None = Field(None, description="Owner email") -class PluginSpec(LiteLLMBaseModel): +class PluginMetadata(LiteLLMBaseModel): """Mutable fields shared by plugin create and update requests.""" - source: dict[str, str] = Field( - ..., - description=( - "Plugin source reference. Supported formats:\n" - "- GitHub: {'source': 'github', 'repo': 'org/repo'}\n" - "- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n" - "- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n" - "- Zip archive on any https host (e.g. S3): " - "{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': ''}" - ), - ) + model_config = ConfigDict(frozen=True) + version: str | None = Field("1.0.0", description="Semantic version") description: str | None = Field(None, description="Plugin description") author: PluginAuthor | None = Field(None, description="Plugin author") @@ -75,6 +66,20 @@ class PluginSpec(LiteLLMBaseModel): } +class PluginSpec(PluginMetadata): + source: dict[str, str] = Field( + ..., + description=( + "Plugin source reference. Supported formats:\n" + "- GitHub: {'source': 'github', 'repo': 'org/repo'}\n" + "- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n" + "- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n" + "- Zip archive on any https host (e.g. S3): " + "{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': ''}" + ), + ) + + class RegisterPluginRequest(PluginSpec): """ Request body for registering a plugin in the marketplace. @@ -90,6 +95,10 @@ class RegisterPluginRequest(PluginSpec): ) +class UploadPluginRequest(PluginMetadata): + name: str = Field(..., pattern=r"^[a-z0-9-]+$", description="Skill name (kebab-case)") + + class UpdatePluginRequest(PluginSpec): """ Request body for replacing an existing plugin. diff --git a/schema.prisma b/schema.prisma index 179b741fe7c..c007b047087 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1615,9 +1615,18 @@ model LiteLLM_ClaudeCodePluginTable { updated_at DateTime? @default(now()) @updatedAt created_by String? + uploaded_archive LiteLLM_ClaudeCodePluginUpload? + @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginUpload { + plugin_name String @id + content Bytes + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_name], references: [name], onDelete: Cascade, onUpdate: Cascade) +} + + // User/team-scoped memory store with a GLOBAL unique key. // `value` is a string (typically markdown/text meant for LLM context); // `metadata` is an optional JSON envelope for structured tags without schema changes. diff --git a/scripts/check_openapi_docs.py b/scripts/check_openapi_docs.py index e29cac29212..219ef912952 100755 --- a/scripts/check_openapi_docs.py +++ b/scripts/check_openapi_docs.py @@ -18,7 +18,7 @@ from collections.abc import Iterator, Mapping from typing import Final, NamedTuple GATED_METHODS: Final = frozenset({"post"}) -SCHEMA_MEDIA_TYPES: Final = ("application/json", "application/sdp") +SCHEMA_MEDIA_TYPES: Final = ("application/json", "application/sdp", "multipart/form-data") class Violation(NamedTuple): diff --git a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 9c693ff1c3e..ea2fe42a01f 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -4,15 +4,20 @@ Unit tests for claude_code_marketplace.py source validation. Covers the git-subdir and archive source types added alongside the existing github and url types. """ +import hashlib +import io import json -from collections.abc import Mapping +import zipfile +from collections.abc import AsyncIterator, Mapping from types import MappingProxyType -from typing import Final -from unittest.mock import AsyncMock, MagicMock +from typing import Final, TypeAlias +from unittest.mock import AsyncMock, MagicMock, call import httpx import pytest from fastapi import HTTPException +from prisma import Base64, models as prisma_models +from prisma.errors import UniqueViolationError from pydantic import ValidationError import litellm @@ -29,7 +34,9 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp register_plugin, update_plugin, ) +from litellm.proxy.deps.repositories import get_plugin_upload_repository from litellm.proxy.proxy_server import LitellmUserRoles, hash_token +from litellm.repositories.plugin_upload_repository import PluginUploadRepository from litellm.types.proxy.claude_code_endpoints import ( MarketplaceResponse, RegisterPluginRequest, @@ -83,6 +90,7 @@ def _make_mock_prisma(): mock_table.create = AsyncMock(side_effect=_create) mock_table.update = AsyncMock(side_effect=_update) mock_client.db.litellm_claudecodeplugintable = mock_table + mock_client.writer_db = mock_client.db return mock_client @@ -196,6 +204,25 @@ async def test_update_plugin_replaces_existing_source(): assert stored["version"] == "2.0.0" +@pytest.mark.asyncio +async def test_metadata_edits_preserve_uploaded_source_and_reject_a_changed_http_destination() -> None: + table: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugintable + await table.create(data={"name": _UPLOAD_NAME, "manifest_json": json.dumps({"source": _UPLOAD_SOURCE})}) + request: Final = UpdatePluginRequest(source=_UPLOAD_SOURCE, description="Updated description") + response: Final = await update_plugin(plugin_name=_UPLOAD_NAME, request=request, user_api_key_dict=_USER) + expected: Final = {"name": _UPLOAD_NAME, "source": _UPLOAD_SOURCE, "description": "Updated description"} + assert response.plugin.description == "Updated description" + assert await _read_stored_manifest(_UPLOAD_NAME) == expected + with pytest.raises(HTTPException) as error: + await update_plugin( + plugin_name=_UPLOAD_NAME, user_api_key_dict=_USER, + request=UpdatePluginRequest(source={**_UPLOAD_SOURCE, "url": "http://other.example/skill.zip"}), + ) + assert error.value.status_code == 400 + table.update.assert_awaited_once() + assert await _read_stored_manifest(_UPLOAD_NAME) == expected + + @pytest.mark.asyncio async def test_update_plugin_not_found(): """PUT on a name that does not exist raises HTTP 404.""" @@ -1171,3 +1198,164 @@ async def test_marketplace_without_a_database_answers_db_not_connected_for_a_que assert response.status_code == 500 assert CommonProxyErrors.db_not_connected_error.value in response.text + + +_UploadClient: TypeAlias = tuple[httpx.AsyncClient, MagicMock, MagicMock] +_UPLOAD_NAME: Final = "local-skill" +_UPLOAD_DIGEST: Final = hashlib.sha256(b"stored archive").hexdigest() +_UPLOAD_PATH: Final = f"/claude-code/plugins/{_UPLOAD_NAME}/archive/{_UPLOAD_DIGEST}.zip" +_UPLOAD_SOURCE: Final = {"source": "archive", "url": f"http://test{_UPLOAD_PATH}", "sha256": _UPLOAD_DIGEST} + + +@pytest.fixture +async def upload_storage(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[_UploadClient]: + prisma: Final = litellm.proxy.proxy_server.prisma_client + prisma.writer_db = MagicMock() + plugins: Final = prisma.writer_db.litellm_claudecodeplugintable + archives: Final = prisma.writer_db.litellm_claudecodepluginupload + plugins.create = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable( + id="uploaded-id", name=_UPLOAD_NAME, enabled=True, + )) + plugins.find_unique = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable( + id="uploaded-id", name=_UPLOAD_NAME, enabled=True, manifest_json=json.dumps({"source": _UPLOAD_SOURCE}), + )) + archives.find_first = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginUpload( + plugin_name=_UPLOAD_NAME, content=Base64.encode(b"stored archive"), + )) + monkeypatch.setattr(litellm.proxy.proxy_server.app, "dependency_overrides", { + get_plugin_upload_repository: lambda: PluginUploadRepository(prisma), + }) + monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {}) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", DualCache()) + monkeypatch.setenv("PROXY_BASE_URL", "http://test") + monkeypatch.delenv("SERVER_ROOT_PATH", raising=False) + for key, skills in ((_LIMITED_KEY, [_UPLOAD_NAME]), ("sk-plain", [])): + litellm.proxy.proxy_server.user_api_key_cache.set_cache( + key=hash_token(key), value=_granted_user(skills).model_copy(update={"token": hash_token(key)}), + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=litellm.proxy.proxy_server.app), base_url="http://test", + ) as client: + yield client, plugins, archives + + +async def _post_skill_upload( + client: httpx.AsyncClient, content: bytes, metadata: str, key: str = MASTER_KEY, +) -> httpx.Response: + return await client.post( + "/claude-code/plugins/upload", headers={"Authorization": f"Bearer {key}"}, + data={"metadata": metadata}, files={"file": ("local.zip", content, "application/zip")}, + ) + + +@pytest.mark.asyncio +async def test_skill_upload_creates_metadata_and_bytes_atomically_and_rejects_duplicates( + upload_storage: _UploadClient, +) -> None: + client, plugins, _ = upload_storage + buffer: Final = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("SKILL.md", "Read the personal instructions") + metadata: Final = {"name": _UPLOAD_NAME, "description": "My notes", "namespace": "personal", "version": "2.0.0"} + response: Final = await _post_skill_upload(client, buffer.getvalue(), json.dumps(metadata)) + assert response.status_code == 200, response.text + plugins.create.assert_awaited_once() + stored: Final = plugins.create.await_args.kwargs["data"] + content: Final = stored["uploaded_archive"]["create"]["content"].decode() + digest: Final = hashlib.sha256(content).hexdigest() + source: Final = { + "source": "archive", + "url": f"http://test/claude-code/plugins/{_UPLOAD_NAME}/archive/{digest}.zip", + "sha256": digest, + } + assert json.loads(stored["manifest_json"]) == {**metadata, "source": source} + assert stored == { + "name": _UPLOAD_NAME, "description": "My notes", "version": "2.0.0", + "manifest_json": stored["manifest_json"], "created_by": litellm.proxy.proxy_server.litellm_proxy_admin_name, + "uploaded_archive": {"create": {"content": Base64.encode(content)}}, + } + assert response.json() == {"status": "success", "action": "created", "plugin": { + "id": "uploaded-id", "name": _UPLOAD_NAME, "version": "2.0.0", "description": "My notes", + "source": source, "installation_preference": None, "enabled": True, + }} + plugins.create.side_effect = UniqueViolationError({"message": "duplicate", "error_code": "P2002"}) + duplicate: Final = await _post_skill_upload(client, buffer.getvalue(), json.dumps(metadata)) + assert duplicate.status_code == 409, duplicate.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("content, metadata, key, status", [ + (b"invalid", '{"name":"local-skill"}', MASTER_KEY, 400), + (b"x" * (10 * 1024 * 1024 + 1), '{"name":"local-skill"}', MASTER_KEY, 413), + (b"ignored", "not-json", MASTER_KEY, 400), + (b"ignored", '{"name":"../invalid"}', MASTER_KEY, 422), + (b"ignored", '{"name":"local-skill"}', _LIMITED_KEY, 403), +], ids=["invalid-zip", "file-over-cap", "invalid-metadata", "invalid-name", "non-admin"]) +async def test_skill_upload_rejects_invalid_or_unauthorized_input_without_writing( + upload_storage: _UploadClient, content: bytes, metadata: str, key: str, status: int, +) -> None: + client, plugins, _ = upload_storage + response: Final = await _post_skill_upload(client, content, metadata, key) + assert response.status_code == status, response.text + plugins.create.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled, key, source, blob, status, reads", [ + (True, None, _UPLOAD_SOURCE, True, 200, 1), + (False, None, _UPLOAD_SOURCE, True, 404, 0), + (False, "sk-plain", _UPLOAD_SOURCE, True, 404, 0), + (False, _LIMITED_KEY, _UPLOAD_SOURCE, True, 200, 1), + (True, None, None, True, 404, 0), + (True, None, {**_UPLOAD_SOURCE, "sha256": "0" * 64}, True, 404, 0), + (True, None, {**_UPLOAD_SOURCE, "url": "https://external.example/skill.zip"}, True, 404, 0), + (True, None, _UPLOAD_SOURCE, False, 404, 1), +]) +async def test_archive_reads_only_authorized_current_bytes( + upload_storage: _UploadClient, enabled: bool, key: str | None, + source: Mapping[str, str] | None, blob: bool, status: int, reads: int, +) -> None: + client, plugins, archives = upload_storage + manifest: Final = json.dumps({"source": source}) + plugins.find_unique.return_value = None if source is None else prisma_models.LiteLLM_ClaudeCodePluginTable( + id="uploaded-id", name=_UPLOAD_NAME, enabled=enabled, manifest_json=manifest, + ) + if not blob: + archives.find_first.return_value = None + response: Final = await client.get(_UPLOAD_PATH, headers={"Authorization": f"Bearer {key}"} if key else {}) + assert response.status_code == status, response.text + assert (response.content == b"stored archive") is (status == 200) + plugins.find_unique.assert_awaited_once_with(where={"name": _UPLOAD_NAME}) + assert archives.find_first.await_args_list == ([call(where={ + "plugin_name": _UPLOAD_NAME, "plugin": {"is": {"manifest_json": manifest, "enabled": enabled}}, + })] if reads else []) + litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable.find_unique.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("required, header, credential, query, status", [ + (True, "Authorization", _LIMITED_KEY, None, 200), + (False, "x-skill-key", _LIMITED_KEY, MASTER_KEY, 200), + (True, "x-skill-key", _LIMITED_KEY, MASTER_KEY, 200), + (False, "x-skill-key", "", MASTER_KEY, 401), + (False, None, None, MASTER_KEY, 404), + (False, None, None, "invalid-query-key", 404), + (True, None, None, MASTER_KEY, 401), + (True, None, None, None, 401), +]) +async def test_archive_requires_header_credentials_for_private_content( + upload_storage: _UploadClient, monkeypatch: pytest.MonkeyPatch, required: bool, + header: str | None, credential: str | None, query: str | None, status: int, +) -> None: + client, plugins, archives = upload_storage + plugins.find_unique.return_value.enabled = False + monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", { + "litellm_key_header_name": "X-Skill-Key", "claude_code_marketplace_auth_required": required, + }) + response: Final = await client.get( + _UPLOAD_PATH, headers={header: f"Bearer {credential}" if credential else ""} if header else {}, + params={"key": query} if query else {}, + ) + assert response.status_code == status, response.text + assert (response.content == b"stored archive") is (status == 200) + assert archives.find_first.await_count == int(status == 200) diff --git a/tests/unit/proxy/anthropic_endpoints/test_skill_upload_archive.py b/tests/unit/proxy/anthropic_endpoints/test_skill_upload_archive.py new file mode 100644 index 00000000000..531d97efe64 --- /dev/null +++ b/tests/unit/proxy/anthropic_endpoints/test_skill_upload_archive.py @@ -0,0 +1,92 @@ +import io +import json +import stat +import zipfile +from typing import Final + +import pytest + +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import ( + MAX_SKILL_ARCHIVE_COMPRESSION_RATIO, + MAX_SKILL_UPLOAD_BYTES, + build_uploaded_skill_archive, +) +from litellm.proxy.discovery_endpoints.agent_skills_archive import MAX_ARCHIVE_ENTRIES, MAX_ARCHIVE_UNPACKED_BYTES + +MANIFEST: Final = b"---\nname: skill\ndescription: Notes\n---\nRead guide.md" + + +def _zip(files: tuple[tuple[str | zipfile.ZipInfo, bytes], ...], compression: int = zipfile.ZIP_DEFLATED) -> bytes: + buffer: Final = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=compression) as archive: + for name, content in files: + archive.writestr(name, content) + return buffer.getvalue() + + +def _entry(name: str, mode: int, system: int = 3) -> zipfile.ZipInfo: + entry: Final = zipfile.ZipInfo(name) + entry.create_system = system + entry.external_attr = mode << 16 + return entry + + +@pytest.mark.parametrize("prefix, manifest", [ + ("", MANIFEST), ("folder/", MANIFEST), + ("folder/", b"---\nnotes: " + b"[" * 1000 + b"0" + b"]" * 1000 + b"\n---\nRead guide.md"), +]) +@pytest.mark.parametrize("script, compression", [ + (b"#!/bin/sh\necho ready\n", zipfile.ZIP_DEFLATED), (b"echo ready\n" * 10000, zipfile.ZIP_STORED), +]) +def test_installable_archive_preserves_files_and_safe_modes( + prefix: str, manifest: bytes, script: bytes, compression: int, +) -> None: + files: Final = ( + ("SKILL.md", manifest, 0o644, 3, 0o644), + ("guide.md", b"Keep filenames unchanged", 0o666, 3, 0o644), + ("scripts/run.sh", script, stat.S_IFREG | 0o7777, 3, 0o755), + ("scripts/owner.sh", script, stat.S_IFREG | 0o700, 3, 0o744), + ("scripts/no-type.sh", script, 0o755, 3, 0o755), + ("scripts/dos.sh", script, stat.S_IFREG | 0o7777, 0, 0o644), + ("scripts/dos-link.sh", script, stat.S_IFLNK | 0o777, 0, 0o644), + ) + upload: Final = _zip(tuple( + (_entry(prefix + name, mode, system), data) for name, data, mode, system, _ in files + ) + ((prefix + ".DS_Store", b"Finder"), ("__MACOSX/._SKILL.md", b"Finder"))) + result: Final = build_uploaded_skill_archive(upload, "registered-name") + expected: Final = {".claude-plugin/plugin.json": (json.dumps({"name": "registered-name"}).encode(), 0o644)} | { + f"skills/registered-name/{name}": (data, mode) for name, data, _, _, mode in files + } + assert result is not None + with zipfile.ZipFile(io.BytesIO(result)) as archive: + assert {entry.filename: (archive.read(entry), entry.external_attr >> 16) for entry in archive.infolist()} == expected + assert {entry.create_system for entry in archive.infolist()} == {3} + assert {entry.compress_type for entry in archive.infolist()} == {compression} + assert sum(entry.file_size for entry in archive.infolist()) <= len(result) * MAX_SKILL_ARCHIVE_COMPRESSION_RATIO + assert build_uploaded_skill_archive(upload, "registered-name") == result + + +_VALID: Final = _zip((("SKILL.md", MANIFEST),)) +_ENCRYPTED: Final = _zip((("__MACOSX/encrypted", b"secret"), ("SKILL.md", MANIFEST))) +_CENTRAL: Final = _ENCRYPTED.index(b"PK\x01\x02") + + +@pytest.mark.parametrize("content, name", [ + (b"not a zip", "skill"), + (_zip((("SKILL.md", MANIFEST),), zipfile.ZIP_STORED).replace(b"Read guide.md", b"Dead guide.md"), "skill"), + (_ENCRYPTED[:_CENTRAL + 8] + b"\x01\x00" + _ENCRYPTED[_CENTRAL + 10:], "skill"), + *[(_VALID, name) for name in ("../escape", "", "Not a slug", "x" * 65536)], + *[(_zip(((path, MANIFEST),)), "skill") for path in ("readme.md", "wrapper/nested/SKILL.md", "skills/example/SKILL.md")], + *[(_zip((("SKILL.md", MANIFEST), (path, b"unsafe"))), "skill") for path in ( + "../escape", "/absolute", "C:/escape", "a\\escape", "__MACOSX/../escape", "bad/./file", + )], + *[(_zip((("SKILL.md", MANIFEST), (_entry(path, stat.S_IFLNK | 0o777), b"../../outside"))), "skill") + for path in ("__MACOSX/link", "scripts/link")], + (_zip(((".claude-plugin/plugin.json", b'{"name":"plugin"}'), ("skills/example/SKILL.md", MANIFEST))), "skill"), + (_zip((("SKILL.md", MANIFEST), ("__MACOSX/a", b"one"), ("__MACOSX//a", b"two"))), "skill"), + (_zip((("SKILL.md", MANIFEST), ("asset.bin", bytes(MAX_SKILL_UPLOAD_BYTES))), zipfile.ZIP_STORED), "skill"), + (_zip((("SKILL.md", MANIFEST), ("__MACOSX/large", bytes(MAX_ARCHIVE_UNPACKED_BYTES)))), "skill"), + (_zip((("SKILL.md", MANIFEST),) + tuple((f"__MACOSX/{i}", b"") for i in range(MAX_ARCHIVE_ENTRIES))), "skill"), +]) +def test_rejects_unsafe_invalid_or_oversized_archives(content: bytes, name: str) -> None: + assert build_uploaded_skill_archive(content, name) is None diff --git a/tests/unit/proxy/common_utils/test_http_parsing_utils.py b/tests/unit/proxy/common_utils/test_http_parsing_utils.py index 84cb62f0426..0fc7bb15a32 100644 --- a/tests/unit/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/unit/proxy/common_utils/test_http_parsing_utils.py @@ -65,6 +65,24 @@ def _starlette_request( return Request(scope, receive) +@pytest.mark.asyncio +@pytest.mark.parametrize("path, path_params", [ + ("/claude-code/plugins/skill/archive/digest.zip", {"plugin_name": "skill"}), + ("/vector_stores/vs-safe/files", {}), +]) +async def test_request_vector_store_values_cannot_forge_debug_log_lines( + path: str, path_params: Mapping[str, str], caplog: pytest.LogCaptureFixture, +) -> None: + untrusted: Final = "private-value\r\nFORGED LOG LINE" + request: Final = _starlette_request(b"{}", "application/json", path) + request.scope["path_params"] = dict(path_params) + with caplog.at_level("DEBUG", logger=http_parsing_utils.verbose_proxy_logger.name): + result: Final = populate_request_with_path_params({"vector_store_ids": [untrusted]}, request) + assert untrusted in result["vector_store_ids"] + assert "private-value" not in caplog.text + assert "FORGED LOG LINE" not in caplog.text + + @pytest.mark.asyncio @pytest.mark.parametrize( "body", @@ -377,7 +395,7 @@ async def test_form_data_with_json_metadata(): @pytest.mark.asyncio -async def test_form_data_with_invalid_json_metadata(): +async def test_form_data_with_invalid_json_metadata() -> None: """ Test that form data with invalid JSON in metadata field raises an exception. @@ -398,9 +416,10 @@ async def test_form_data_with_invalid_json_metadata(): mock_request.scope = {} mock_request.state._cached_headers = None - # Should raise JSONDecodeError when trying to parse invalid JSON metadata - with pytest.raises(json.JSONDecodeError): + with pytest.raises(ProxyException) as exc_info: await read_request_body(mock_request) + assert exc_info.value.code == "400" + assert exc_info.value.param == "metadata" @pytest.mark.asyncio 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 dd5ac230aac..a3d968263e5 100644 --- a/tests/unit/proxy/discovery_endpoints/test_agent_skills_archive.py +++ b/tests/unit/proxy/discovery_endpoints/test_agent_skills_archive.py @@ -1,6 +1,8 @@ import hashlib import io +import stat import zipfile +from typing import Final from litellm.proxy.discovery_endpoints.agent_skills_archive import ( MAX_ARCHIVE_ENTRIES, @@ -16,7 +18,7 @@ Read the PDF, then write the brief. """ -def zip_bytes(files: dict[str, bytes]) -> bytes: +def zip_bytes(files: dict[str | zipfile.ZipInfo, bytes]) -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: for name, content in files.items(): @@ -104,3 +106,15 @@ def test_an_upload_over_the_entry_cap_is_not_installable(): } assert build_skill_archive(zip_bytes(files)) is None + + +def test_discovery_keeps_its_permission_and_finder_metadata_policy() -> None: + executable: Final = zipfile.ZipInfo("scripts/run.sh") + executable.create_system = 3 + executable.external_attr = (stat.S_IFREG | 0o7777) << 16 + result: Final = build_skill_archive(zip_bytes({"SKILL.md": MANIFEST, executable: b"echo ready\n"})) + assert build_skill_archive(zip_bytes({"folder/SKILL.md": MANIFEST, "__MACOSX/._SKILL.md": b"Finder"})) is None + assert result is not 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} diff --git a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py index 2727857c352..48981288d00 100644 --- a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py +++ b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py @@ -54,10 +54,11 @@ async def _echo_request(request: Request) -> Response: ) -def _client(max_decompressed_body_size_mb: float = CAP_MB, **options: object) -> TestClient: +def _client(max_decompressed_body_size_mb: float = CAP_MB, root_path: str = "", **options: object) -> TestClient: echo_app: Final = Starlette( routes=[ Route("/v1/systemone", _echo_request, methods=["POST"]), + Route("/claude-code/plugins/upload", _echo_request, methods=["POST"]), Route("/plugin-proxy/{path:path}", _echo_request, methods=["POST"]), Route("/claude_code_gateway/v1/{signal}", _echo_request, methods=["POST"]), ] @@ -67,7 +68,8 @@ def _client(max_decompressed_body_size_mb: float = CAP_MB, **options: object) -> echo_app, max_decompressed_body_size_mb=max_decompressed_body_size_mb, **options, # pyright: ignore[reportArgumentType] # each test passes one typed keyword - ) + ), + root_path=root_path, ) @@ -220,19 +222,51 @@ def test_body_decoding_past_the_cap_is_rejected(): @pytest.mark.parametrize( ("operator_max_mb", "effective_max_mb"), - [(0.25, 0.25), (CAP_MB * 4, CAP_MB), (None, CAP_MB), (0, CAP_MB)], + [(0.25, 0.25), (CAP_MB * 4, CAP_MB), (None, CAP_MB), (0, CAP_MB), (1 / 64, 1 / 64)], ) +@pytest.mark.parametrize(("route_max_mb", "root_path"), [(None, ""), (1 / 32, ""), (1 / 32, "/gateway")]) +@pytest.mark.parametrize("coding", ("gzip", "zstd")) def test_operator_request_size_limit_caps_the_decoded_body_when_it_is_lower( - operator_max_mb: float | None, effective_max_mb: float + operator_max_mb: float | None, effective_max_mb: float, route_max_mb: float | None, root_path: str, coding: str, ): - client: Final = _client(get_max_request_size_mb=lambda: operator_max_mb) - within: Final = zstd.compress(_low_entropy_text(int(effective_max_mb * MIB))) - over: Final = zstd.compress(_low_entropy_text(int(effective_max_mb * MIB) + 1)) - - assert _post(client, within, ("zstd",)).status_code == 200 - over_response: Final = _post(client, over, ("zstd",)) + path: Final = "/claude-code/plugins/upload" if route_max_mb else "/v1/systemone" + limit: Final = min(effective_max_mb, route_max_mb or effective_max_mb) + client: Final = _client( + root_path=root_path, + get_max_request_size_mb=lambda: operator_max_mb, + route_max_request_size_mb={("POST", path): route_max_mb} if route_max_mb else None, + ) + compress: Final = gzip.compress if coding == "gzip" else zstd.compress + within: Final = compress(_low_entropy_text(int(limit * MIB))) + over: Final = compress(_low_entropy_text(int(limit * MIB) + 1)) + assert _post(client, within, (coding,), f"{root_path}{path}").status_code == 200 + over_response: Final = _post(client, over, (coding,), f"{root_path}{path}") assert over_response.status_code == 413 - assert over_response.json() == _decoded_too_large(effective_max_mb) + assert over_response.json() == _decoded_too_large(limit) + + +@pytest.mark.parametrize(("coding", "compress"), [ + ("identity", bytes), ("gzip", gzip.compress), ("zstd", zstd.compress), +]) +def test_proxy_bounds_skill_uploads_before_authentication_or_body_parsing( + coding: str, compress: Callable[[bytes], bytes], +) -> None: + from litellm.proxy.proxy_server import app + + body: Final = random.Random(0).randbytes(6 * MIB) + bytes(6 * MIB) + encoded: Final = compress(body) + + response: Final = TestClient(app).post( + "/claude-code/plugins/upload", + content=encoded, + headers={"content-encoding": coding, "content-type": "multipart/form-data; boundary=upload"}, + ) + + assert (len(encoded) < 11 * MIB) == (coding != "identity") + assert response.status_code == 413 + assert response.json() == ( + {"error": "Request size is too large. Max size is 11 MB"} if coding == "identity" else _decoded_too_large(11) + ) @pytest.mark.parametrize(("signature", "content_encoding"), [(GZIP_MAGIC, "gzip"), (ZSTD_FRAME_MAGIC, "zstd")]) diff --git a/tests/unit/proxy/middleware/test_request_size_limit_middleware.py b/tests/unit/proxy/middleware/test_request_size_limit_middleware.py index 0a30b4cf9ef..7bb73b77000 100644 --- a/tests/unit/proxy/middleware/test_request_size_limit_middleware.py +++ b/tests/unit/proxy/middleware/test_request_size_limit_middleware.py @@ -1,5 +1,7 @@ +from collections.abc import AsyncIterator from typing import Final +import httpx import pytest from starlette.responses import JSONResponse from starlette.testclient import TestClient @@ -10,7 +12,16 @@ from litellm.proxy.middleware.request_size_limit_middleware import ( ) -def test_request_size_limit_middleware_rejects_content_length_before_body_read(): +@pytest.mark.parametrize(("path", "root_path", "operator_mb", "route_mb"), [ + ("/chat/completions", "", 1, None), + ("/claude-code/plugins/upload", "", None, 1), + ("/gateway/claude-code/plugins/upload", "/gateway", None, 1), + ("/claude-code/plugins/upload", "", 2, 1), + ("/claude-code/plugins/upload", "", 1, 2), +]) +def test_request_size_limit_middleware_rejects_content_length_before_body_read( + path: str, root_path: str, operator_mb: int | None, route_mb: int | None, +) -> None: downstream_called = False async def app(scope, receive, send): @@ -22,13 +33,15 @@ def test_request_size_limit_middleware_rejects_content_length_before_body_read() client = TestClient( RequestSizeLimitMiddleware( app, - get_max_request_size_mb=lambda: 1, - is_request_size_limit_enabled=lambda: True, - ) + get_max_request_size_mb=lambda: operator_mb, + is_request_size_limit_enabled=lambda: operator_mb is not None, + route_max_request_size_mb={("POST", "/claude-code/plugins/upload"): route_mb} if route_mb else None, + ), + root_path=root_path, ) response = client.post( - "/chat/completions", + path, content=b"x" * (1024 * 1024 + 1), headers={"content-type": "application/json"}, ) @@ -39,7 +52,16 @@ def test_request_size_limit_middleware_rejects_content_length_before_body_read() assert downstream_called is False -def test_request_size_limit_middleware_zero_limit_disables_guard(): +@pytest.mark.parametrize(("enabled", "operator_mb"), [(True, 0), (False, 1)]) +@pytest.mark.parametrize(("method", "path"), [ + ("POST", "/chat/completions"), + ("PUT", "/claude-code/plugins/upload"), + ("POST", "/claude-code/plugins/upload-other"), + ("POST", "/claude-code/plugins"), +]) +def test_request_size_limit_middleware_zero_or_disabled_global_limit_preserves_other_routes( + enabled: bool, operator_mb: int, method: str, path: str, +) -> None: downstream_called = False async def app(scope, receive, send): @@ -51,14 +73,15 @@ def test_request_size_limit_middleware_zero_limit_disables_guard(): client = TestClient( RequestSizeLimitMiddleware( app, - get_max_request_size_mb=lambda: 0, - is_request_size_limit_enabled=lambda: True, + get_max_request_size_mb=lambda: operator_mb, + is_request_size_limit_enabled=lambda: enabled, + route_max_request_size_mb={("POST", "/claude-code/plugins/upload"): 1}, ) ) - response = client.post( - "/chat/completions", - content=b"x", + response = client.request( + method, path, + content=b"x" * (1024 * 1024 + 1), headers={"content-type": "application/json"}, ) @@ -154,3 +177,33 @@ def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_erro "type": "error", "error": {"type": "request_too_large", "message": "Request size is too large. Max size is 1 MB"}, } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ("/claude-code/plugins/upload", "/chat/completions")) +async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing( + path: str, +) -> None: + from litellm.proxy.proxy_server import app + + limited: Final = RequestSizeLimitMiddleware( + app, + get_max_request_size_mb=lambda: None, + is_request_size_limit_enabled=lambda: False, + route_max_request_size_mb={("POST", path): 1 / 1024}, + ) + + async def chunks() -> AsyncIterator[bytes]: + yield b'--skill\r\nContent-Disposition: form-data; name="file"; filename="skill.zip"\r\n\r\n' + yield b"x" * 1024 + pytest.fail("The server consumed more of the upload after its byte limit") + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=limited), base_url="http://test") as client: + response: Final = await client.post( + path, + content=chunks(), + headers={"content-type": "multipart/form-data; boundary=skill"}, + ) + + assert response.status_code == 413 + assert response.json() == {"detail": "Request size is too large. Max size is 0.0009765625 MB"} diff --git a/tests/unit/test_check_openapi_docs.py b/tests/unit/test_check_openapi_docs.py index 5d8803c4118..c6ea0cd936e 100644 --- a/tests/unit/test_check_openapi_docs.py +++ b/tests/unit/test_check_openapi_docs.py @@ -20,14 +20,17 @@ def _post_with_media_types(request_media_type: str, response_media_type: str) -> } -@pytest.mark.parametrize("media_type", ("application/json", "application/sdp")) -def test_a_fully_documented_post_has_no_findings(media_type: str) -> None: - operation: Final = _post_with_media_types(media_type, media_type) +@pytest.mark.parametrize("request_type, response_type", [ + ("application/json", "application/json"), ("application/sdp", "application/sdp"), + ("multipart/form-data", "application/json"), +]) +def test_a_fully_documented_post_has_no_findings(request_type: str, response_type: str) -> None: + operation: Final = _post_with_media_types(request_type, response_type) assert checker.violations({"paths": {"/v1/thing": {"post": operation}}}) == () def test_a_schema_under_another_media_type_still_counts_as_undocumented() -> None: - operation: Final = _post_with_media_types("multipart/form-data", "audio/wav") + operation: Final = _post_with_media_types("application/octet-stream", "audio/wav") assert {v.code for v in checker.violations({"paths": {"/v1/thing": {"post": operation}}})} == {"OA001", "OA003"} @@ -37,8 +40,9 @@ def test_each_missing_piece_is_its_own_rule() -> None: assert codes == {"OA001", "OA002", "OA003"} -def test_only_the_missing_piece_is_reported() -> None: - no_body: Final = {**_DOCUMENTED, "requestBody": {}} +@pytest.mark.parametrize("body", [{}, {"content": {"multipart/form-data": {}}}]) +def test_only_the_missing_piece_is_reported(body: Mapping[str, object]) -> None: + no_body: Final = {**_DOCUMENTED, "requestBody": body} blank_description: Final = {**_DOCUMENTED, "description": " "} untyped_200: Final = {**_DOCUMENTED, "responses": {"200": {"content": {"application/json": {"schema": {}}}}}} spec: Final = { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.integration.test.tsx similarity index 74% rename from ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.integration.test.tsx index 6507855d0c7..b09b5f627be 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.integration.test.tsx @@ -3,14 +3,16 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderWithProviders } from "@/../tests/test-utils"; import AddPluginForm from "./add_plugin_form"; -import { registerClaudeCodePlugin } from "@/components/networking"; +import { registerClaudeCodePlugin, uploadClaudeCodeSkill } from "@/components/networking"; import { toast } from "@/lib/toast"; vi.mock("@/components/networking", () => ({ registerClaudeCodePlugin: vi.fn().mockResolvedValue({ status: "success" }), + uploadClaudeCodeSkill: vi.fn().mockResolvedValue({ status: "success" }), })); const mockRegister = vi.mocked(registerClaudeCodePlugin); +const mockUpload = vi.mocked(uploadClaudeCodeSkill); const mockMessageError = vi.mocked(toast.error); const DEFAULT_PROPS = { @@ -34,7 +36,7 @@ describe("AddPluginForm", () => { it("renders the host-agnostic source URL input and subfolder field, hiding the digest until a zip is entered", () => { renderWithProviders(); - expect(screen.getByText("Source URL")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Source URL" })).toBeChecked(); expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toBeInTheDocument(); expect(screen.getByText("Subfolder path (Optional)")).toBeInTheDocument(); expect(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER)).toBeInTheDocument(); @@ -364,4 +366,75 @@ describe("AddPluginForm", () => { }); expect(mockMessageError).toHaveBeenCalledTimes(1); }); + + const chooseUpload = () => fireEvent.click(screen.getByRole("radio", { name: "Upload ZIP" })); + const file = new File(["skill archive"], "personal-skill.zip", { type: "application/zip" }); + const chooseFile = (selected = file) => + fireEvent.change(screen.getByLabelText("Skill ZIP file"), { target: { files: [selected] } }); + + it("preserves a pending or failed ZIP upload and registers its metadata only after retry succeeds", async () => { + const pending = Promise.withResolvers(); + mockUpload.mockReturnValueOnce(pending.promise); + renderWithProviders(); + await typeUrl(S3_ZIP_URL); + fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), { target: { value: "invalid-for-url" } }); + chooseUpload(); + chooseFile(); + fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } }); + fireEvent.change(screen.getByPlaceholderText("A skill that helps with..."), { target: { value: "My workflow" } }); + await submit(); + expect(screen.getByRole("button", { name: "Adding..." })).toBeDisabled(); + expect(DEFAULT_PROPS.onClose).not.toHaveBeenCalled(); + await act(async () => pending.reject(new Error("Unable to save skill"))); + expect(mockMessageError).toHaveBeenCalledWith("Unable to save skill"); + expect(DEFAULT_PROPS.onSuccess).not.toHaveBeenCalled(); + expect(screen.getByPlaceholderText("my-skill")).toHaveValue("personal-skill"); + await submit(); + expect(mockUpload).toHaveBeenLastCalledWith("sk-test", file, { + name: "personal-skill", + description: "My workflow", + }); + expect(mockRegister).not.toHaveBeenCalled(); + expect(DEFAULT_PROPS.onSuccess).toHaveBeenCalledOnce(); + expect(DEFAULT_PROPS.onClose).toHaveBeenCalledOnce(); + }); + + it.each([ + [new File(["text"], "SKILL.md"), "Please choose a .zip file"], + [new File([new Uint8Array(10 * 1024 * 1024 + 1)], "large.zip"), "The ZIP file must be 10 MiB or smaller"], + ])("rejects invalid file case %#", async (invalidFile, message) => { + renderWithProviders(); + chooseUpload(); + fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } }); + chooseFile(invalidFile); + await submit(); + expect(screen.getByRole("alert")).toHaveTextContent(message); + expect(mockUpload).not.toHaveBeenCalled(); + }); + + it("clears file removal and cancellation, allows reselection, and restores URL validation", async () => { + const { rerender } = renderWithProviders(); + chooseUpload(); + chooseFile(); + fireEvent.click(screen.getByRole("button", { name: "Remove file" })); + expect((screen.getByLabelText("Skill ZIP file") as HTMLInputElement).files).toHaveLength(0); + await submit(); + expect(screen.getByRole("alert")).toHaveTextContent("Please choose a .zip file"); + chooseFile(); + expect(screen.getByRole("button", { name: "Remove file" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + rerender(); + rerender(); + expect(screen.getByRole("radio", { name: "Source URL" })).toBeChecked(); + expect(screen.getByPlaceholderText("my-skill")).toHaveValue(""); + chooseUpload(); + fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } }); + await submit(); + expect(screen.getByRole("alert")).toHaveTextContent("Please choose a .zip file"); + fireEvent.click(screen.getByRole("radio", { name: "Source URL" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await submit(); + expect(await screen.findByText("Please enter a repository or zip archive URL")).toBeInTheDocument(); + expect(mockUpload).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx index a2288123f91..f9efe2fc7bb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import i18n from "@/i18n/i18n"; import { z } from "zod"; import { toast } from "@/lib/toast"; -import { registerClaudeCodePlugin } from "@/components/networking"; +import { registerClaudeCodePlugin, uploadClaudeCodeSkill } from "@/components/networking"; import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; @@ -19,6 +19,7 @@ import { ComboboxList, } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useZodForm } from "@/lib/forms/useZodForm"; @@ -42,12 +43,20 @@ interface AddPluginFormProps { onSuccess: () => void; } +const MAX_SKILL_UPLOAD_BYTES = 10 * 1024 * 1024; + const addPluginShape = { - skillUrl: z.string().min(1, { error: () => i18n.t("skills.addForm.errors.urlRequired") }), - subPath: z.string().refine((value) => !value || isValidSubPath(value), { - error: () => i18n.t("skills.addForm.errors.subPathInvalid"), - }), - sha256: z.string().refine(isValidSha256, { error: () => i18n.t("skills.addForm.errors.sha256Invalid") }), + sourceType: z.enum(["url", "upload"]), + archiveFile: z + .file() + .refine((file) => file.name.toLowerCase().endsWith(".zip"), { + error: () => i18n.t("skills.addForm.errors.zipRequired"), + }) + .max(MAX_SKILL_UPLOAD_BYTES, { error: () => i18n.t("skills.addForm.errors.zipTooLarge") }) + .nullable(), + skillUrl: z.string(), + subPath: z.string(), + sha256: z.string(), name: z .string() .min(1, { error: () => i18n.t("skills.addForm.errors.nameRequired") }) @@ -64,11 +73,29 @@ const addPluginShape = { }), }; -const addPluginSchema = z.object(addPluginShape); +const addPluginSchema = z.object(addPluginShape).superRefine((values, context) => { + if (values.sourceType === "upload") { + if (!values.archiveFile) { + context.addIssue({ code: "custom", path: ["archiveFile"], message: i18n.t("skills.addForm.errors.zipRequired") }); + } + return; + } + if (!values.skillUrl) { + context.addIssue({ code: "custom", path: ["skillUrl"], message: i18n.t("skills.addForm.errors.urlRequired") }); + } + if (values.subPath && !isValidSubPath(values.subPath)) { + context.addIssue({ code: "custom", path: ["subPath"], message: i18n.t("skills.addForm.errors.subPathInvalid") }); + } + if (!isValidSha256(values.sha256)) { + context.addIssue({ code: "custom", path: ["sha256"], message: i18n.t("skills.addForm.errors.sha256Invalid") }); + } +}); type AddPluginFormValues = z.infer; const EMPTY_VALUES: AddPluginFormValues = { + sourceType: "url", + archiveFile: null, skillUrl: "", subPath: "", sha256: "", @@ -100,11 +127,10 @@ const withArchiveDigest = (source: PluginSource, sha256: string): PluginSource = return source.source === "archive" && digest ? { ...source, sha256: digest.toLowerCase() } : source; }; -const buildRegisterRequest = (values: AddPluginFormValues, source: PluginSource): SkillRegisterRequest => { +const buildSkillMetadata = (values: AddPluginFormValues): Omit => { const author = buildAuthor(values); return { name: values.name.trim(), - source: withArchiveDigest(source, values.sha256), ...(values.version ? { version: values.version.trim() } : {}), ...(values.description ? { description: values.description.trim() } : {}), ...(author ? { author } : {}), @@ -152,6 +178,43 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT const [isSubmitting, setIsSubmitting] = useState(false); const [urlPreview, setUrlPreview] = useState(null); const [subPathLock, setSubPathLock] = useState(null); + const [archiveInputKey, setArchiveInputKey] = useState(0); + const isUpload = form.watch("sourceType") === "upload"; + + const clearArchive = () => { + form.setValue("archiveFile", null); + form.clearErrors("archiveFile"); + setArchiveInputKey((key) => key + 1); + }; + + const resetDraft = () => { + form.reset(EMPTY_VALUES); + setArchiveInputKey((key) => key + 1); + setUrlPreview(null); + setSubPathLock(null); + }; + + const handleSourceChange = (value: unknown) => { + if (value !== "url" && value !== "upload") return; + form.setValue("sourceType", value); + clearArchive(); + form.clearErrors(["skillUrl", "subPath", "sha256"]); + }; + + const selectArchive = (file: File | undefined) => { + form.setValue("archiveFile", file ?? null, { shouldValidate: true }); + if (!file || !addPluginShape.archiveFile.safeParse(file).success) return; + if (!form.getValues("name")) { + form.setValue( + "name", + file.name + .slice(0, -4) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""), + ); + } + }; const recomputePreview = (skillUrl: string, subPath: string) => { const lock = subPathLockFor(parseSkillSource(skillUrl)?.parsed.source); @@ -175,7 +238,7 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT return; } - if (!urlPreview) { + if (values.sourceType === "url" && !urlPreview) { toast.error(t("skills.addForm.toasts.invalidUrl")); return; } @@ -197,11 +260,18 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT setIsSubmitting(true); try { - await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed)); + const metadata = buildSkillMetadata(values); + if (values.sourceType === "upload") { + if (!values.archiveFile) return; + await uploadClaudeCodeSkill(accessToken, values.archiveFile, metadata); + } else if (urlPreview) { + await registerClaudeCodePlugin(accessToken, { + ...metadata, + source: withArchiveDigest(urlPreview.parsed, values.sha256), + }); + } toast.success(t("skills.addForm.toasts.registered")); - form.reset(EMPTY_VALUES); - setUrlPreview(null); - setSubPathLock(null); + resetDraft(); onSuccess(); onClose(); } catch (error) { @@ -213,9 +283,8 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT }; const handleCancel = () => { - form.reset(EMPTY_VALUES); - setUrlPreview(null); - setSubPathLock(null); + if (isSubmitting) return; + resetDraft(); onClose(); }; @@ -228,67 +297,124 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT
- - {({ ref, onChange, ...field }) => ( - { - onChange(event); - recomputePreview(event.target.value, form.getValues("subPath")); - }} - /> + + {({ value }) => ( + + + + )} - - {({ ref, onChange, ...field }) => ( - { - onChange(event); - recomputePreview(form.getValues("skillUrl"), event.target.value); - }} - disabled={subPathLock !== null} - /> - )} - - - {urlPreview?.parsed.source === "archive" && ( + {isUpload ? ( - {({ ref, ...field }) => ( - + {({ value, ...field }) => ( + <> + selectArchive(event.target.files?.[0])} + /> + {value && ( + + )} + )} - )} + ) : ( + <> + + {({ ref, onChange, ...field }) => ( + { + onChange(event); + recomputePreview(event.target.value, form.getValues("subPath")); + }} + /> + )} + - {urlPreview && ( -
- {t("skills.addForm.detected", { source: urlPreview.label })} -
+ + {({ ref, onChange, ...field }) => ( + { + onChange(event); + recomputePreview(form.getValues("skillUrl"), event.target.value); + }} + disabled={subPathLock !== null} + /> + )} + + + {urlPreview?.parsed.source === "archive" && ( + + {({ ref, ...field }) => ( + + )} + + )} + + {urlPreview && ( +
+ {t("skills.addForm.detected", { source: urlPreview.label })} +
+ )} + )} { + const base = "https://proxy.example.com"; + const digest = "a".repeat(64); + const path = `/claude-code/plugins/my-skill/archive/${digest}.zip`; + const source = { source: "archive" as const, url: `${base}${path}`, sha256: digest }; + const identify = (overrides: Partial = {}, name = "my-skill") => + getManagedSkillArchivePath(name, { ...source, ...overrides }, base); + + it("recognizes only the gateway archive, including a mounted gateway", () => { + expect(identify()).toBe(path); + const mounted = { ...source, url: `${base}/gateway${path}` }; + expect(getManagedSkillArchivePath("my-skill", mounted, "/gateway", base)).toBe(path); + }); + + it.each([ + `https://other.example.com${path}`, + `http://proxy.example.com${path}`, + `https://proxy.example.com:8443${path}`, + `https://user@proxy.example.com${path}`, + `${base}/other${path}`, + `${base}${path}?redirect=other`, + `${base}${path}#fragment`, + `${base}${path.replace("my-skill", "another-skill")}`, + `${base}${path.replace(digest, "b".repeat(64))}`, + ])("rejects a different credential recipient or archive: %s", (url) => { + expect(identify({ url })).toBeNull(); + }); + + it("requires an archive type, safe skill name and full SHA256 digest", () => { + expect(identify({ source: "url" })).toBeNull(); + expect(identify({}, "../my-skill")).toBeNull(); + expect(identify({ sha256: undefined })).toBeNull(); + expect(identify({ sha256: "short" })).toBeNull(); + }); +}); + describe("buildMarketplaceSettingsSnippet", () => { it("sends the key placeholder as a bearer header when the proxy requires a key", () => { expect(JSON.parse(buildMarketplaceSettingsSnippet("https://proxy.example.com", true))).toEqual({ diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts index b8a32dfd774..6374db58c9b 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -305,6 +305,27 @@ export const getSourceLink = (source: Plugin["source"]): string | null => { return linksToUrl && source.url && BROWSABLE_URL_REGEX.test(source.url) ? source.url : null; }; +export const getManagedSkillArchivePath = ( + name: string, + source: Plugin["source"], + proxyBaseUrl: string, + pageOrigin?: string, +): string | null => { + if (source.source !== "archive" || !validatePluginName(name)) { + return null; + } + if (!source.sha256 || !SHA256_REGEX.test(source.sha256)) { + return null; + } + const path = `/claude-code/plugins/${name}/archive/${source.sha256}.zip`; + try { + const expectedUrl = new URL(`${proxyBaseUrl.replace(/\/+$/, "")}${path}`, pageOrigin); + return /^https?:$/.test(expectedUrl.protocol) && source.url === expectedUrl.href ? path : null; + } catch { + return null; + } +}; + /** * Get badge color based on category */ diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.integration.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.integration.test.tsx new file mode 100644 index 00000000000..5f3c6da23e0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.integration.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { getProxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking"; +import SkillDetail from "./skill_detail"; +import type { Plugin } from "./types"; + +const digest = "a".repeat(64); +const archivePath = `/claude-code/plugins/my-skill/archive/${digest}.zip`; +const url = `${getProxyBaseUrl()}${archivePath}`; +const skill: Plugin = { + id: "private-skill", + name: "my-skill", + source: { source: "archive", url, sha256: digest }, + enabled: true, + version: null, + description: null, + created_at: null, + updated_at: null, +}; +const fetchMock = vi.fn(); +const renderSkill = (source = skill.source) => + renderWithProviders(); + +beforeEach(() => { + testQueryClient.clear(); + fetchMock.mockReset().mockResolvedValue(Response.json({ claude_code_marketplace_auth_required: true })); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + setGlobalLitellmHeaderName(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it("retries a private ZIP download through the configured auth header and local route", async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, "click"); + URL.createObjectURL = vi.fn(() => "blob:skill-download"); + setGlobalLitellmHeaderName("X-LiteLLM-Key"); + renderSkill(); + fetchMock.mockResolvedValueOnce(Response.json({ detail: "Archive not found" }, { status: 404 })); + fireEvent.click(screen.getByRole("button", { name: "Download ZIP" })); + expect(screen.getByRole("button", { name: "Downloading…" })).toBeDisabled(); + expect(await screen.findByRole("alert")).toHaveTextContent("Archive not found"); + expect(click).not.toHaveBeenCalled(); + fetchMock.mockResolvedValueOnce(new Response("zip-bytes", { headers: { "Content-Type": "application/zip" } })); + fireEvent.click(screen.getByRole("button", { name: "Download ZIP" })); + await waitFor(() => expect(click).toHaveBeenCalledOnce()); + expect((click.mock.contexts[0] as HTMLAnchorElement).download).toBe("my-skill.zip"); + expect(fetchMock).toHaveBeenLastCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ "X-LiteLLM-Key": "Bearer dashboard-token" }), + redirect: "error", + }), + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); +}); + +it("keeps an external archive as an ordinary link even when its path matches the gateway", () => { + const externalUrl = `https://external.example.com${archivePath}`; + renderSkill({ ...skill.source, url: externalUrl }); + expect(screen.getByRole("link")).toHaveAttribute("href", externalUrl); + expect(screen.getByRole("link")).toHaveAttribute("target", "_blank"); + expect(screen.queryByRole("button", { name: "Download ZIP" })).not.toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index b46d1eaf632..8d247ff21b7 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -1,25 +1,64 @@ import React, { useState } from "react"; -import { ArrowLeft, Check, Copy, Link2 } from "lucide-react"; +import { ArrowLeft, Check, Copy, Download, Link2 } from "lucide-react"; import { Trans, useTranslation } from "react-i18next"; import { useClaudeCodeMarketplaceRequiresKey } from "@/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey"; import { cn } from "@/lib/cva.config"; +import { downloadBlob } from "@/components/EntityUsageExport/utils"; +import { apiClient, getProxyBaseUrl } from "@/components/networking"; +import { Button } from "@/components/ui/button"; import { buildMarketplaceSettingsSnippet, formatInstallCommand, + getManagedSkillArchivePath, getSourceDisplayText, getSourceLink, MARKETPLACE_KEY_PLACEHOLDER, } from "./helpers"; import { Plugin } from "./types"; -const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => { +const SkillSource: React.FC> = ({ skill, accessToken }) => { const { t } = useTranslation(); + const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); + const { source } = skill; + const archivePath = getManagedSkillArchivePath( + skill.name, + source, + getProxyBaseUrl(), + typeof window !== "undefined" ? window.location.origin : undefined, + ); + const downloadArchive = async () => { + if (!archivePath || downloading) return; + setDownloading(true); + setDownloadError(null); + try { + const blob = await apiClient.getBlob(archivePath, { accessToken, redirect: "error" }); + downloadBlob(blob, `${skill.name}.zip`); + } catch (error) { + setDownloadError( + error instanceof Error ? error.message : t("claudeCodePlugins.skillDetail.overview.downloadFailed"), + ); + } finally { + setDownloading(false); + } + }; const link = getSourceLink(source); const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link; - if (href) { - return ( -
-
{t("claudeCodePlugins.skillDetail.overview.source")}
+ if (!href && !source.url) return null; + return ( +
+
{t("claudeCodePlugins.skillDetail.overview.source")}
+ {archivePath && ( + + )} + {!archivePath && href && ( = ({ source }) => { {href.replace("https://", "")} -
- ); - } - if (!source.url) { - return null; - } - return ( -
-
{t("claudeCodePlugins.skillDetail.overview.source")}
-
{getSourceDisplayText(source)}
+ )} + {!href &&
{getSourceDisplayText(source)}
} + {downloadError && ( +

+ {downloadError} +

+ )}
); }; @@ -51,7 +87,7 @@ interface SkillDetailProps { onPublishClick?: () => void; } -const SkillDetail: React.FC = ({ skill, onBack }) => { +const SkillDetail: React.FC = ({ skill, onBack, accessToken }) => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("overview"); const [copiedKey, setCopiedKey] = useState(null); @@ -187,7 +223,7 @@ const SkillDetail: React.FC = ({ skill, onBack }) => {
- + {skill.keywords && skill.keywords.length > 0 && (
diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 874cdb52fad..eaac98b5d1e 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -1225,3 +1225,23 @@ describe("searchToolQueryCall", () => { await expect(Networking.searchToolQueryCall("token", "exa-search-2", "litellm")).rejects.toThrow(errorMessage); }); }); + +describe("uploadClaudeCodeSkill", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("sends the archive bytes and metadata as one authenticated multipart request", async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({ status: "success" })); + vi.stubGlobal("fetch", fetch); + const file = new File(["skill archive bytes"], "personal.zip", { type: "application/zip" }); + const metadata = { name: "personal", description: "Personal workflow", keywords: ["workflow"] }; + + await Networking.uploadClaudeCodeSkill("upload-token", file, metadata); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe(`${Networking.getProxyBaseUrl()}/claude-code/plugins/upload`); + expect(init?.method).toBe("POST"); + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer upload-token"); + expect(headers.has("Content-Type")).toBe(false); + expect(Object.fromEntries(init?.body as FormData)).toEqual({ file, metadata: JSON.stringify(metadata) }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 994c2948346..cc1358ce9b2 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -6942,6 +6942,17 @@ export const registerClaudeCodePlugin = async (accessToken: string, pluginData: } }; +export const uploadClaudeCodeSkill = async ( + accessToken: string, + file: File, + metadata: Omit, +): Promise => { + const body = new FormData(); + body.append("file", file); + body.append("metadata", JSON.stringify(metadata)); + return apiClient.post("/claude-code/plugins/upload", { accessToken, rawBody: body }); +}; + /** * Enable a Claude Code plugin (admin only) * @param accessToken - Admin access token diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index b4b78c53f38..e98c29e4343 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -1642,7 +1642,14 @@ }, "addForm": { "title": "Add New Skill", + "sourceOptions": { + "url": "Source URL", + "upload": "Upload ZIP" + }, + "removeFile": "Remove file", "labels": { + "source": "Skill source", + "archive": "Skill ZIP file", "sourceUrl": "Source URL", "subPath": "Subfolder path (Optional)", "sha256": "Archive SHA-256 (Optional)", @@ -1657,6 +1664,7 @@ "authorEmail": "Author Email (Optional)" }, "hints": { + "archive": "Choose a ZIP from your computer, up to 10 MiB. Include SKILL.md at the root or inside one skill folder, along with any supporting files. The skill will use the same visibility settings as skills added by URL.", "sourceUrl": "Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.", "subPath": "Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.", "sha256": "Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.", @@ -1689,6 +1697,8 @@ "adding": "Adding...", "submit": "Add Skill", "errors": { + "zipRequired": "Please choose a .zip file", + "zipTooLarge": "The ZIP file must be 10 MiB or smaller", "urlRequired": "Please enter a repository or zip archive URL", "subPathInvalid": "Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)", "sha256Invalid": "SHA-256 must be a 64-character hex digest", @@ -1739,6 +1749,9 @@ "public": "Public", "draft": "Draft", "source": "Source", + "downloadZip": "Download ZIP", + "downloading": "Downloading…", + "downloadFailed": "Could not download the skill. Try again.", "tags": "Tags", "skillId": "Skill ID" }, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e2e1210bc2a..068d48564ec 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2528,6 +2528,26 @@ export interface paths { patch?: never; trace?: never; }; + "/claude-code/plugins/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload Plugin + * @description Create a skill and its uploaded ZIP atomically. Duplicate names return 409. + */ + post: operations["upload_plugin_claude_code_plugins_upload_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/claude-code/plugins/{plugin_name}": { parameters: { query?: never; @@ -2604,6 +2624,26 @@ export interface paths { patch?: never; trace?: never; }; + "/claude-code/plugins/{plugin_name}/archive/{digest}.zip": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download Plugin Archive + * @description Download an uploaded ZIP under the same visibility policy as its skill. + */ + get: operations["download_plugin_archive_claude_code_plugins__plugin_name__archive__digest__zip_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/claude-code/plugins/{plugin_name}/disable": { parameters: { query?: never; @@ -28082,6 +28122,19 @@ export interface components { /** File */ file: string; }; + /** Body_upload_plugin_claude_code_plugins_upload_post */ + Body_upload_plugin_claude_code_plugins_upload_post: { + /** + * File + * @description Skill ZIP containing SKILL.md and supporting files, up to 10 MiB + */ + file: string; + /** + * Metadata + * @description JSON skill metadata without a source URL + */ + metadata: string; + }; /** Body_video_create_character_v1_videos_characters_post */ Body_video_create_character_v1_videos_characters_post: { /** Name */ @@ -49877,6 +49930,57 @@ export interface components { /** User Role */ user_role?: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null; }; + /** UploadPluginRequest */ + UploadPluginRequest: { + /** @description Plugin author */ + author?: components["schemas"]["PluginAuthor"] | null; + /** + * Category + * @description Plugin category + */ + category?: string | null; + /** + * Description + * @description Plugin description + */ + description?: string | null; + /** + * Domain + * @description Skill domain (e.g., 'Productivity') + */ + domain?: string | null; + /** + * Homepage + * @description Plugin homepage URL + */ + homepage?: string | null; + /** + * Installation Preference + * @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin + */ + installation_preference?: ("available" | "auto_install" | "required") | null; + /** + * Keywords + * @description Search keywords + */ + keywords?: string[] | null; + /** + * Name + * @description Skill name (kebab-case) + */ + name: string; + /** + * Namespace + * @description Skill namespace within domain (e.g., 'workflows') + */ + namespace?: string | null; + /** + * Version + * @description Semantic version + * @default 1.0.0 + */ + version: string | null; + }; /** UsageCaptureSettings */ UsageCaptureSettings: { /** @@ -55820,6 +55924,39 @@ export interface operations { }; }; }; + upload_plugin_claude_code_plugins_upload_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_plugin_claude_code_plugins_upload_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RegisterPluginResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_plugin_claude_code_plugins__plugin_name__get: { parameters: { query?: never; @@ -55917,6 +56054,38 @@ export interface operations { }; }; }; + download_plugin_archive_claude_code_plugins__plugin_name__archive__digest__zip_get: { + parameters: { + query?: never; + header?: never; + path: { + plugin_name: string; + digest: 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"]; + }; + }; + }; + }; disable_plugin_claude_code_plugins__plugin_name__disable_post: { parameters: { query?: never;