mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
feat(skills): upload local skill ZIPs from the dashboard (#50008)
This commit is contained in:
parent
664241188f
commit
48bd9b01f6
38 changed files with 1839 additions and 189 deletions
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
|
|||
"LiteLLM_AccountingDelivery",
|
||||
"LiteLLM_AccountingEvent",
|
||||
"LiteLLM_ClaudeCodePluginTable",
|
||||
"LiteLLM_ClaudeCodePluginUpload",
|
||||
"LiteLLM_MemoryTable",
|
||||
"LiteLLM_AdaptiveRouterState",
|
||||
"LiteLLM_AdaptiveRouterSession",
|
||||
|
|
|
|||
29
litellm/models/plugin_upload.py
Normal file
29
litellm/models/plugin_upload.py
Normal file
|
|
@ -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: ...
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
71
litellm/repositories/plugin_upload_repository.py
Normal file
71
litellm/repositories/plugin_upload_repository.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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': '<optional hex digest>'}"
|
||||
),
|
||||
)
|
||||
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': '<optional hex digest>'}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")])
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
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<never>();
|
||||
mockUpload.mockReturnValueOnce(pending.promise);
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
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(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
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(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
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(<AddPluginForm {...DEFAULT_PROPS} visible={false} />);
|
||||
rerender(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof addPluginSchema>;
|
||||
|
||||
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<SkillRegisterRequest, "source"> => {
|
||||
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<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
|
||||
const [subPathLock, setSubPathLock] = useState<SubPathLock | null>(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<AddPluginFormProps> = ({ 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<AddPluginFormProps> = ({ 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<AddPluginFormProps> = ({ 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<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate className="mt-4">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skillUrl"
|
||||
label={labelWithHint(t("skills.addForm.labels.sourceUrl"), t("skills.addForm.hints.sourceUrl"))}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("skills.addForm.placeholders.sourceUrl")}
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
recomputePreview(event.target.value, form.getValues("subPath"));
|
||||
}}
|
||||
/>
|
||||
<FormField control={form.control} name="sourceType" label={t("skills.addForm.labels.source")}>
|
||||
{({ value }) => (
|
||||
<RadioGroup
|
||||
value={value}
|
||||
disabled={isSubmitting}
|
||||
onValueChange={handleSourceChange}
|
||||
aria-label={t("skills.addForm.labels.source")}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="url" />
|
||||
{t("skills.addForm.sourceOptions.url")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="upload" />
|
||||
{t("skills.addForm.sourceOptions.upload")}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subPath"
|
||||
label={labelWithHint(t("skills.addForm.labels.subPath"), t("skills.addForm.hints.subPath"))}
|
||||
description={subPathLock ? t(SUB_PATH_LOCK_REASON_KEY[subPathLock]) : undefined}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="plugins/my-skill"
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
recomputePreview(form.getValues("skillUrl"), event.target.value);
|
||||
}}
|
||||
disabled={subPathLock !== null}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{urlPreview?.parsed.source === "archive" && (
|
||||
{isUpload ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sha256"
|
||||
label={labelWithHint(t("skills.addForm.labels.sha256"), t("skills.addForm.hints.sha256"))}
|
||||
name="archiveFile"
|
||||
label={t("skills.addForm.labels.archive")}
|
||||
description={t("skills.addForm.hints.archive")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("skills.addForm.placeholders.sha256")}
|
||||
className="rounded-lg font-mono"
|
||||
/>
|
||||
{({ value, ...field }) => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
key={archiveInputKey}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => selectArchive(event.target.files?.[0])}
|
||||
/>
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={clearArchive}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("skills.addForm.removeFile")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skillUrl"
|
||||
label={labelWithHint(t("skills.addForm.labels.sourceUrl"), t("skills.addForm.hints.sourceUrl"))}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("skills.addForm.placeholders.sourceUrl")}
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
recomputePreview(event.target.value, form.getValues("subPath"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{urlPreview && (
|
||||
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
|
||||
{t("skills.addForm.detected", { source: urlPreview.label })}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subPath"
|
||||
label={labelWithHint(t("skills.addForm.labels.subPath"), t("skills.addForm.hints.subPath"))}
|
||||
description={subPathLock ? t(SUB_PATH_LOCK_REASON_KEY[subPathLock]) : undefined}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="plugins/my-skill"
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
recomputePreview(form.getValues("skillUrl"), event.target.value);
|
||||
}}
|
||||
disabled={subPathLock !== null}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{urlPreview?.parsed.source === "archive" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sha256"
|
||||
label={labelWithHint(t("skills.addForm.labels.sha256"), t("skills.addForm.hints.sha256"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("skills.addForm.placeholders.sha256")}
|
||||
className="rounded-lg font-mono"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{urlPreview && (
|
||||
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
|
||||
{t("skills.addForm.detected", { source: urlPreview.label })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginSource } from "./types";
|
||||
import {
|
||||
formatInstallCommand,
|
||||
validatePluginName,
|
||||
getSourceDisplayText,
|
||||
getSourceLink,
|
||||
getManagedSkillArchivePath,
|
||||
getCategoryBadgeColor,
|
||||
isValidSemanticVersion,
|
||||
isValidEmail,
|
||||
|
|
@ -15,6 +17,42 @@ import {
|
|||
buildMarketplaceSettingsSnippet,
|
||||
} from "./helpers";
|
||||
|
||||
describe("getManagedSkillArchivePath", () => {
|
||||
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<PluginSource> = {}, 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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<typeof fetch>();
|
||||
const renderSkill = (source = skill.source) =>
|
||||
renderWithProviders(<SkillDetail skill={{ ...skill, source }} onBack={vi.fn()} accessToken="dashboard-token" />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
@ -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<Pick<SkillDetailProps, "skill" | "accessToken">> = ({ skill, accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(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 (
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
|
||||
if (!href && !source.url) return null;
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
|
||||
{archivePath && (
|
||||
<Button variant="link" className="h-auto p-0 text-[13px]" onClick={downloadArchive} disabled={downloading}>
|
||||
<Download className="size-3 shrink-0" />
|
||||
{t(
|
||||
downloading
|
||||
? "claudeCodePlugins.skillDetail.overview.downloading"
|
||||
: "claudeCodePlugins.skillDetail.overview.downloadZip",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!archivePath && href && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
|
|
@ -29,16 +68,13 @@ const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => {
|
|||
{href.replace("https://", "")}
|
||||
<Link2 className="size-3 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!source.url) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
|
||||
<div className="break-all text-[13px] text-foreground">{getSourceDisplayText(source)}</div>
|
||||
)}
|
||||
{!href && <div className="break-all text-[13px] text-foreground">{getSourceDisplayText(source)}</div>}
|
||||
{downloadError && (
|
||||
<p role="alert" className="mt-2 text-xs text-destructive">
|
||||
{downloadError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -51,7 +87,7 @@ interface SkillDetailProps {
|
|||
onPublishClick?: () => void;
|
||||
}
|
||||
|
||||
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
||||
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
|
@ -187,7 +223,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<SkillSource source={skill.source} />
|
||||
<SkillSource skill={skill} accessToken={accessToken} />
|
||||
|
||||
{skill.keywords && skill.keywords.length > 0 && (
|
||||
<div className="mb-6">
|
||||
|
|
|
|||
|
|
@ -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<typeof globalThis.fetch>().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) });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6942,6 +6942,17 @@ export const registerClaudeCodePlugin = async (accessToken: string, pluginData:
|
|||
}
|
||||
};
|
||||
|
||||
export const uploadClaudeCodeSkill = async (
|
||||
accessToken: string,
|
||||
file: File,
|
||||
metadata: Omit<SkillRegisterRequest, "source">,
|
||||
): Promise<components["schemas"]["RegisterPluginResponse"]> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
169
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
169
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue