From 65fffa413690445a17144b5ec73ed4cd63f52821 Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 14 Oct 2026 11:18:34 +0000 Subject: [PATCH] feat(claude-code): serve gateway-hosted plugin archives for Claude Desktop marketplaces Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../migration.sql | 27 + .../litellm_proxy_extras/schema.prisma | 12 + litellm/constants.py | 1 + litellm/integrations/otel/model/spans.py | 1 + litellm/proxy/_lazy_openapi_snapshot.json | 166 ++++- litellm/proxy/_types.py | 2 + .../claude_code_marketplace.py | 483 ++++++++++--- .../public_endpoints/public_endpoints.py | 4 + litellm/proxy/schema.prisma | 12 + litellm/repositories/table_repositories.py | 4 + litellm/types/proxy/claude_code_endpoints.py | 21 +- schema.prisma | 12 + .../test_claude_code_marketplace.py | 632 +++++++++++++++++- .../ClaudeCodePluginsPanel.test.tsx | 44 +- .../_components/ClaudeCodePluginsPanel.tsx | 40 +- .../_components/add_plugin_form.test.tsx | 47 +- .../skills/_components/add_plugin_form.tsx | 216 ++++-- .../claude_code_plugins/skill_detail.test.tsx | 6 + .../claude_code_plugins/skill_detail.tsx | 8 + .../components/claude_code_plugins/types.ts | 2 +- .../src/components/networking.test.ts | 25 + .../src/components/networking.tsx | 9 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 132 +++- 23 files changed, 1714 insertions(+), 192 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20261014000000_claude_code_plugin_archives/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261014000000_claude_code_plugin_archives/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261014000000_claude_code_plugin_archives/migration.sql new file mode 100644 index 00000000000..648c990d2dd --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261014000000_claude_code_plugin_archives/migration.sql @@ -0,0 +1,27 @@ +SET LOCAL lock_timeout = '10s'; + +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "archive_sha256" TEXT; + +CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginArchiveTable" ( + "plugin_id" TEXT NOT NULL, + "content" BYTEA NOT NULL, + "size_bytes" INTEGER NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LiteLLM_ClaudeCodePluginArchiveTable_pkey" PRIMARY KEY ("plugin_id") +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'LiteLLM_ClaudeCodePluginArchiveTable_plugin_id_fkey' + AND conrelid = '"LiteLLM_ClaudeCodePluginArchiveTable"'::regclass + ) THEN + ALTER TABLE "LiteLLM_ClaudeCodePluginArchiveTable" + ADD CONSTRAINT "LiteLLM_ClaudeCodePluginArchiveTable_plugin_id_fkey" + FOREIGN KEY ("plugin_id") REFERENCES "LiteLLM_ClaudeCodePluginTable"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 12438614755..4a8be17cd22 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1608,14 +1608,26 @@ model LiteLLM_ClaudeCodePluginTable { description String? manifest_json String? files_json String? @default("{}") + archive_sha256 String? enabled Boolean @default(true) created_at DateTime? @default(now()) updated_at DateTime? @default(now()) @updatedAt created_by String? + archive LiteLLM_ClaudeCodePluginArchiveTable? @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginArchiveTable { + plugin_id String @id + content Bytes + size_bytes Int + updated_at DateTime @default(now()) @updatedAt + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade) + + @@map("LiteLLM_ClaudeCodePluginArchiveTable") +} + // User/team-scoped memory store with a GLOBAL unique key. // `value` is a string (typically markdown/text meant for LLM context); // `metadata` is an optional JSON envelope for structured tags without schema changes. diff --git a/litellm/constants.py b/litellm/constants.py index e78e3e36364..351736268d3 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -91,6 +91,7 @@ OTLP_MAX_ATTRIBUTE_VALUE_BYTES: Final = get_env_int("OTLP_MAX_ATTRIBUTE_VALUE_BY OTLP_RETRY_AFTER_SECONDS: Final = get_env_int("OTLP_RETRY_AFTER_SECONDS", 2) TRACE_READ_RETRY_AFTER_SECONDS: Final = get_env_int("TRACE_READ_RETRY_AFTER_SECONDS", 2) OTLP_MAX_CONCURRENT_INGESTS: Final = get_env_int("OTLP_MAX_CONCURRENT_INGESTS", 2) +CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES: Final = get_env_int("CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES", 50 * 1024 * 1024) AGENT_TRACING_INPUT_PREVIEW_CHARS: Final = get_env_int("AGENT_TRACING_INPUT_PREVIEW_CHARS", 240) AGENT_TRACING_LIST_PAGE_SIZE: Final = get_env_int("AGENT_TRACING_LIST_PAGE_SIZE", 50) AGENT_TRACING_AGENT_LIST_LIMIT: Final = get_env_int("AGENT_TRACING_AGENT_LIST_LIMIT", 500) diff --git a/litellm/integrations/otel/model/spans.py b/litellm/integrations/otel/model/spans.py index 76fc969cfc0..9ea33da9b0b 100644 --- a/litellm/integrations/otel/model/spans.py +++ b/litellm/integrations/otel/model/spans.py @@ -338,6 +338,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset( "LiteLLM_TelemetryReport", "LiteLLM_AccessGroupTable", "LiteLLM_ClaudeCodePluginTable", + "LiteLLM_ClaudeCodePluginArchiveTable", "LiteLLM_MemoryTable", "LiteLLM_AdaptiveRouterState", "LiteLLM_AdaptiveRouterSession", diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 9086c8417ae..0f224f621ee 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -7148,6 +7148,17 @@ "PluginListItem": { "description": "Plugin item in list responses.", "properties": { + "archive_sha256": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archive Sha256" + }, "author": { "anyOf": [ { @@ -7462,7 +7473,7 @@ "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", + "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. LiteLLM supplies the sha256 for gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin", "title": "Installation Preference" }, "keywords": { @@ -7502,7 +7513,7 @@ "additionalProperties": { "type": "string" }, - "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': ''}", + "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': ''}\n- Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin", "title": "Source", "type": "object" }, @@ -7629,7 +7640,7 @@ "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", + "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. LiteLLM supplies the sha256 for gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin", "title": "Installation Preference" }, "keywords": { @@ -7663,7 +7674,7 @@ "additionalProperties": { "type": "string" }, - "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': ''}", + "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': ''}\n- Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin", "title": "Source", "type": "object" }, @@ -7686,6 +7697,31 @@ "title": "UpdatePluginRequest", "type": "object" }, + "UploadPluginArchiveResponse": { + "properties": { + "plugin": { + "$ref": "#/components/schemas/PluginResponse", + "description": "Updated plugin information" + }, + "sha256": { + "description": "SHA-256 digest of the uploaded archive", + "title": "Sha256", + "type": "string" + }, + "size_bytes": { + "description": "Size of the uploaded archive in bytes", + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "plugin", + "sha256", + "size_bytes" + ], + "title": "UploadPluginArchiveResponse", + "type": "object" + }, "ValidationError": { "properties": { "ctx": { @@ -8017,6 +8053,128 @@ ] } }, + "/claude-code/plugins/{plugin_name}/archive": { + "put": { + "description": "Upload and validate a zip archive for an existing hosted plugin.", + "operationId": "upload_plugin_archive_claude_code_plugins__plugin_name__archive_put", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + }, + "application/zip": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadPluginArchiveResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Upload Plugin Archive", + "tags": [ + "claude_code_marketplace" + ] + } + }, + "/claude-code/plugins/{plugin_name}/archive.zip": { + "get": { + "description": "Download a hosted plugin archive when it is visible to the caller.", + "operationId": "download_plugin_archive_claude_code_plugins__plugin_name__archive_zip_get", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + }, + { + "in": "query", + "name": "key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Download Plugin Archive", + "tags": [ + "claude_code_marketplace" + ] + } + }, "/claude-code/plugins/{plugin_name}/disable": { "post": { "description": "Disable a plugin without deleting it.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: The name of the plugin to disable", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 5d089d5be36..409fcdafc69 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -451,6 +451,8 @@ class LiteLLMRoutes(enum.Enum): "/claude-code/marketplace.json", "/claude-code/plugins", "/claude-code/plugins/{plugin_name}", + "/claude-code/plugins/{plugin_name}/archive", + "/claude-code/plugins/{plugin_name}/archive.zip", ] # MCP tool-call / passthrough routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS. diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index 8e348423837..a910520630f 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -17,19 +17,28 @@ Endpoints: /claude-code/plugins/{name} - DELETE - Delete a plugin (proxy admin only) """ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import io import json import re -from collections.abc import Mapping, Sequence +import zipfile +from collections.abc import Callable, Mapping, Sequence from datetime import datetime, timezone -from typing import Annotated, Final, Protocol, TypedDict, get_args -from urllib.parse import urlsplit +from pathlib import PurePosixPath, PureWindowsPath +from typing import TYPE_CHECKING, Annotated, Final, Protocol, TypedDict, TypeVar, get_args +from urllib.parse import quote, urlsplit from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import JSONResponse -from pydantic import TypeAdapter +from fastapi.responses import JSONResponse, Response +from pydantic import TypeAdapter, ValidationError from typing_extensions import ReadOnly from litellm._logging import verbose_proxy_logger +from litellm.constants import CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import ( SkillVisibility, @@ -37,7 +46,7 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_a ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.resource_ownership import is_proxy_admin -from litellm.repositories.table_repositories import ClaudeCodePluginRepository +from litellm.repositories.table_repositories import ClaudeCodePluginArchiveRepository, ClaudeCodePluginRepository from litellm.types.proxy.claude_code_endpoints import ( InstallationPreference, ListPluginsResponse, @@ -47,8 +56,13 @@ from litellm.types.proxy.claude_code_endpoints import ( RegisterPluginRequest, RegisterPluginResponse, UpdatePluginRequest, + UploadPluginArchiveResponse, ) +if TYPE_CHECKING: + from litellm.proxy.utils import PrismaClient + + router: Final = APIRouter() @@ -62,25 +76,33 @@ class _PluginRecord(Protocol): created_at: datetime | None updated_at: datetime | None created_by: str | None + archive_sha256: str | None + + +class _PluginArchiveRecord(Protocol): + def model_dump(self) -> Mapping[str, object]: ... class _MarketplaceEntry(TypedDict, total=False): - name: str - source: object - version: str - description: str - author: object - homepage: object - keywords: object - category: object + name: ReadOnly[str] + source: ReadOnly[object] installationPreference: ReadOnly[InstallationPreference] + version: ReadOnly[str] + description: ReadOnly[str] + author: ReadOnly[object] + homepage: ReadOnly[object] + keywords: ReadOnly[object] + category: ReadOnly[object] _INSTALLATION_PREFERENCES: Final[tuple[InstallationPreference, ...]] = get_args(InstallationPreference) +MARKETPLACE_ENTRY_FIELDS: Final = TypeAdapter(_MarketplaceEntry) MANIFEST_FIELDS: Final = TypeAdapter(dict[str, object]) +PLUGIN_LIST_ITEM_FIELDS: Final = TypeAdapter(PluginListItem) _SELF_INSTALLING_PREFERENCES: Final[frozenset[InstallationPreference]] = frozenset({"auto_install", "required"}) +_EncodedArchive = TypeVar("_EncodedArchive") # rebind-ok: generic encoder type must be module scoped def get_installation_preference(manifest: Mapping[str, object]) -> InstallationPreference | None: @@ -93,7 +115,7 @@ def get_installation_preference(manifest: Mapping[str, object]) -> InstallationP return None -async def get_prisma_client() -> object: +async def get_prisma_client() -> PrismaClient: """Get the prisma client from proxy_server.""" from litellm.proxy.proxy_server import prisma_client @@ -105,6 +127,75 @@ async def get_prisma_client() -> object: return prisma_client +def _hosted_marketplace_source( + plugin: _PluginRecord, + marketplace_base_url: str, +) -> object | None: + if plugin.archive_sha256 is None: + verbose_proxy_logger.warning("Hosted plugin %s has no archive, skipping", plugin.name) + return None + + return { + "source": "archive", + "url": f"{marketplace_base_url}/claude-code/plugins/{quote(plugin.name, safe='')}/archive.zip", + "sha256": plugin.archive_sha256, + } + + +def _marketplace_entry( + plugin: _PluginRecord, + marketplace_base_url: str, + visibility: SkillVisibility, +) -> _MarketplaceEntry | None: + if not visibility.allows(plugin): + return None + try: + manifest: Final[Mapping[str, object]] = MANIFEST_FIELDS.validate_json(plugin.manifest_json or "{}") + except ValidationError: + verbose_proxy_logger.warning("Plugin %s has invalid manifest JSON, skipping", plugin.name) + return None + + if "source" not in manifest: + verbose_proxy_logger.warning("Plugin %s has no source field, skipping", plugin.name) + return None + + source: Final = manifest["source"] + marketplace_source: Final[object] = ( + _hosted_marketplace_source(plugin, marketplace_base_url) if source == {"source": "hosted"} else source + ) + if marketplace_source is None: + return None + + installation_preference: Final = get_installation_preference(manifest) + return MARKETPLACE_ENTRY_FIELDS.validate_python( + { + "name": plugin.name, + "source": marketplace_source, + **({"installationPreference": installation_preference} if installation_preference is not None else {}), + **({"version": plugin.version} if plugin.version else {}), + **({"description": plugin.description} if plugin.description else {}), + **({"author": manifest["author"]} if "author" in manifest else {}), + **({"homepage": manifest["homepage"]} if "homepage" in manifest else {}), + **({"keywords": manifest["keywords"]} if "keywords" in manifest else {}), + **({"category": manifest["category"]} if "category" in manifest else {}), + } + ) + + +async def _update_plugin_and_delete_archive( + prisma_client: PrismaClient, + plugin_id: str, + plugin_name: str, + update_data: Mapping[str, object], +) -> _PluginRecord | None: + async with prisma_client.db.tx() as transaction: + await transaction.litellm_claudecodepluginarchivetable.delete_many(where={"plugin_id": plugin_id}) + return await transaction.litellm_claudecodeplugintable.update( + where={"name": plugin_name}, + data=update_data, + ) + + _get_prisma_client: Final = get_prisma_client @@ -136,55 +227,19 @@ async def get_marketplace(request: Request, key: str | None = None): """ try: prisma_client: Final = await get_prisma_client() + from litellm.proxy._experimental.mcp_server.oauth_utils import get_request_base_url - caller: Final[UserAPIKeyAuth | None] = ( - await user_api_key_auth(request=request, api_key=f"Bearer {key}") if key else None - ) + marketplace_base_url: Final = get_request_base_url(request) + + caller: Final[UserAPIKeyAuth | None] = await _resolve_marketplace_caller(request, key) visibility: Final[SkillVisibility] = await resolve_skill_visibility(caller) plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many( where=visibility.where() ) - - plugin_list: Final = [] - for plugin in plugins: - if not visibility.allows(plugin): - continue - try: - manifest: Mapping[str, object] = json.loads(plugin.manifest_json or "{}") - except json.JSONDecodeError: - verbose_proxy_logger.warning("Plugin %s has invalid manifest JSON, skipping", plugin.name) - continue - - # Source must be specified for URL-based marketplaces - if "source" not in manifest: - verbose_proxy_logger.warning("Plugin %s has no source field, skipping", plugin.name) - continue - - installation_preference = get_installation_preference(manifest) - entry: _MarketplaceEntry = ( - {"name": plugin.name, "source": manifest["source"]} - if installation_preference is None - else { - "name": plugin.name, - "source": manifest["source"], - "installationPreference": installation_preference, - } - ) - - if plugin.version: - entry["version"] = plugin.version - if plugin.description: - entry["description"] = plugin.description - if "author" in manifest: - entry["author"] = manifest["author"] - if "homepage" in manifest: - entry["homepage"] = manifest["homepage"] - if "keywords" in manifest: - entry["keywords"] = manifest["keywords"] - if "category" in manifest: - entry["category"] = manifest["category"] - - plugin_list.append(entry) + plugin_entries: Final = tuple( + _marketplace_entry(plugin, marketplace_base_url, visibility) for plugin in plugins + ) + plugin_list: Final = tuple(entry for entry in plugin_entries if entry is not None) marketplace: Final = { "name": "litellm", @@ -204,6 +259,145 @@ async def get_marketplace(request: Request, key: str | None = None): ) +async def _resolve_marketplace_caller(request: Request, key: str | None) -> UserAPIKeyAuth | None: + if key: + return await user_api_key_auth(request=request, api_key=f"Bearer {key}") + authorization: Final = request.headers.get("Authorization") + if isinstance(authorization, str) and authorization: + return await user_api_key_auth(request=request, api_key=authorization) + return None + + +def _archive_bytes(archive: _PluginArchiveRecord) -> bytes: + content: Final = archive.model_dump().get("content") + if isinstance(content, str): + return base64.b64decode(content, validate=True) + if isinstance(content, bytes): + return content + raise ValueError("Stored plugin archive content is not bytes") + + +def _archive_not_found() -> HTTPException: + return _error_response(404, "Plugin not found") + + +def _validate_and_hash_plugin_archive(content: bytes) -> str: + try: + with zipfile.ZipFile(io.BytesIO(content)) as archive: + paths: Final = tuple(PurePosixPath(info.filename.replace("\\", "/")) for info in archive.infolist()) + except (OSError, RuntimeError, zipfile.BadZipFile) as exc: + raise ValueError("Uploaded file is not a valid zip archive") from exc + + if any( + path.is_absolute() + or ".." in path.parts + or PureWindowsPath(path.as_posix()).is_absolute() + or bool(PureWindowsPath(path.as_posix()).drive) + for path in paths + ): + raise ValueError("Archive paths must not be absolute or contain '..' segments") + + if not any( + path.parts == (".claude-plugin", "plugin.json") + or (len(path.parts) == 3 and path.parts[1:] == (".claude-plugin", "plugin.json")) + for path in paths + ): + raise ValueError( + "Archive must contain .claude-plugin/plugin.json at the root or under exactly one top-level folder" + ) + + return hashlib.sha256(content).hexdigest() + + +def _validate_and_encode_plugin_archive( + content: bytes, + encode: Callable[[bytes], _EncodedArchive], +) -> tuple[str, _EncodedArchive]: + return _validate_and_hash_plugin_archive(content), encode(content) + + +async def _read_plugin_archive_body(request: Request) -> bytes: + chunks: tuple[bytes, ...] = () # rebind-ok: request bytes must be accumulated within the size limit + total_bytes: int = 0 # rebind-ok: request bytes must be counted while streaming + async for chunk in request.stream(): + if total_bytes + len(chunk) > CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES: + raise _error_response(413, "Plugin archive exceeds the maximum allowed size") + total_bytes += len(chunk) + chunks += (chunk,) + return b"".join(chunks) + + +def _plugin_list_item(plugin: _PluginRecord) -> PluginListItem: + manifest: Final[Mapping[str, object]] = json.loads(plugin.manifest_json) if plugin.manifest_json else {} + return PLUGIN_LIST_ITEM_FIELDS.validate_python( + { + "id": plugin.id, + "name": plugin.name, + "version": plugin.version, + "description": plugin.description, + "source": manifest.get("source", {}), + "author": manifest.get("author"), + "homepage": manifest.get("homepage"), + "keywords": manifest.get("keywords"), + "category": manifest.get("category"), + "domain": manifest.get("domain"), + "namespace": manifest.get("namespace"), + "installation_preference": get_installation_preference(manifest), + "archive_sha256": (plugin.archive_sha256 if manifest.get("source") == {"source": "hosted"} else None), + "enabled": plugin.enabled, + "created_at": plugin.created_at.isoformat() if plugin.created_at else None, + "updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None, + } + ) + + +@router.get( + "/claude-code/plugins/{plugin_name}/archive.zip", + tags=["Claude Code Marketplace"], +) +async def download_plugin_archive( + plugin_name: str, + request: Request, + key: str | None = None, +) -> Response: + """Download a hosted plugin archive when it is visible to the caller.""" + try: + prisma_client: Final = await get_prisma_client() + caller: Final = await _resolve_marketplace_caller(request, key) + visibility: Final = await resolve_skill_visibility(caller) + plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique( + where={"name": plugin_name} + ) + if plugin is None or not visibility.allows(plugin): + raise _archive_not_found() + + try: + manifest_value: Final[Mapping[str, object]] = MANIFEST_FIELDS.validate_json(plugin.manifest_json or "{}") + except ValidationError: + raise _archive_not_found() + if manifest_value.get("source") != {"source": "hosted"} or plugin.archive_sha256 is None: + raise _archive_not_found() + + archive: Final[_PluginArchiveRecord | None] = await ClaudeCodePluginArchiveRepository( + prisma_client + ).table.find_unique(where={"plugin_id": plugin.id}) + if archive is None: + raise _archive_not_found() + + return Response( + content=_archive_bytes(archive), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{plugin.name}.zip"'}, + ) + except HTTPException: + raise + except ProxyException: + raise + except Exception as e: # noqa: BLE001 # unexpected download errors map to HTTP 500 + verbose_proxy_logger.exception("Error downloading plugin archive: %s", e) + raise HTTPException(status_code=500, detail={"error": str(e)}) + + # Allowlist for git-subdir paths: one or more segments separated by '/'. # Each segment must start with an alphanumeric character and contain only # alphanumeric characters, dots, hyphens, and underscores. @@ -269,17 +463,21 @@ def _validate_plugin_source(source: Mapping[str, str]) -> None: status_code=400, detail={"error": "archive 'sha256' must be a 64-character hex digest"}, ) + elif source_type == "hosted": + if set(source) != {"source"}: + raise HTTPException(status_code=400, detail={"error": "hosted source must contain only 'source'"}) else: raise HTTPException( status_code=400, - detail={"error": "source.source must be 'github', 'url', 'git-subdir', or 'archive'"}, + detail={"error": "source.source must be 'github', 'url', 'git-subdir', 'archive', or 'hosted'"}, ) def _validate_installation_preference(spec: PluginSpec) -> None: if spec.installation_preference is None: return - if spec.source.get("source") != "archive": + source_type: Final = spec.source.get("source") + if source_type not in {"archive", "hosted"}: raise HTTPException( status_code=400, detail={ @@ -287,6 +485,8 @@ def _validate_installation_preference(spec: PluginSpec) -> None: "from a marketplace.json URL" }, ) + if source_type == "hosted": + return if spec.installation_preference in _SELF_INSTALLING_PREFERENCES and not spec.source.get("sha256"): raise HTTPException( status_code=400, @@ -469,32 +669,7 @@ async def list_plugins( where={"enabled": True} if enabled_only else visibility.where() ) - plugin_list: Final = [] - for p in plugins: - if not visibility.allows(p): - continue - # Parse manifest to get additional fields - manifest = json.loads(p.manifest_json) if p.manifest_json else {} - - plugin_list.append( - PluginListItem( - id=p.id, - name=p.name, - version=p.version, - description=p.description, - source=manifest.get("source", {}), - author=manifest.get("author"), - homepage=manifest.get("homepage"), - keywords=manifest.get("keywords"), - category=manifest.get("category"), - domain=manifest.get("domain"), - namespace=manifest.get("namespace"), - installation_preference=get_installation_preference(MANIFEST_FIELDS.validate_python(manifest)), - enabled=p.enabled, - created_at=p.created_at.isoformat() if p.created_at else None, - updated_at=p.updated_at.isoformat() if p.updated_at else None, - ) - ) + plugin_list: Final = [_plugin_list_item(p) for p in plugins if visibility.allows(p)] # Sort by created_at descending (newest first) plugin_list.sort(key=lambda x: x.created_at or "", reverse=True) @@ -564,6 +739,7 @@ async def get_plugin( "keywords": manifest.get("keywords"), "category": manifest.get("category"), "installation_preference": get_installation_preference(manifest), + "archive_sha256": plugin.archive_sha256 if manifest.get("source") == {"source": "hosted"} else None, "enabled": plugin.enabled, "created_at": plugin.created_at.isoformat() if plugin.created_at else None, "updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None, @@ -647,17 +823,26 @@ async def update_plugin( if not existing: raise _error_response(404, f"Plugin '{plugin_name}' not found") + existing_manifest: Final[Mapping[str, object]] = MANIFEST_FIELDS.validate_json(existing.manifest_json or "{}") + clear_archive: Final = existing_manifest.get("source") == {"source": "hosted"} and request.source != { + "source": "hosted" + } manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request) - - plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.update( - where={"name": plugin_name}, - data={ - "version": request.version, - "description": request.description, - "manifest_json": json.dumps(manifest), - "files_json": "{}", - "updated_at": datetime.now(timezone.utc), - }, + update_data: Final = { + "version": request.version, + "description": request.description, + "manifest_json": json.dumps(manifest), + "files_json": "{}", + "updated_at": datetime.now(timezone.utc), + **({"archive_sha256": None} if clear_archive else {}), + } + plugin: Final[_PluginRecord | None] = ( + await _update_plugin_and_delete_archive(prisma_client, existing.id, plugin_name, update_data) + if clear_archive + else await ClaudeCodePluginRepository(prisma_client).table.update( + where={"name": plugin_name}, + data=update_data, + ) ) if plugin is None: raise _error_response(404, f"Plugin '{plugin_name}' not found") @@ -685,6 +870,110 @@ async def update_plugin( raise _error_response(500, f"Update failed: {e}") +@router.put( + "/claude-code/plugins/{plugin_name}/archive", + tags=["Claude Code Marketplace"], + response_model=UploadPluginArchiveResponse, + openapi_extra={ + "requestBody": { + "required": True, + "content": { + "application/zip": {"schema": {"type": "string", "format": "binary"}}, + "application/octet-stream": {"schema": {"type": "string", "format": "binary"}}, + }, + } + }, +) +async def upload_plugin_archive( + plugin_name: str, + request: Request, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> UploadPluginArchiveResponse: + """Upload and validate a zip archive for an existing hosted plugin.""" + try: + _require_proxy_admin(user_api_key_dict) + prisma_client: Final = await get_prisma_client() + plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique( + where={"name": plugin_name} + ) + if plugin is None: + raise _error_response(404, f"Plugin '{plugin_name}' not found") + + if (content_length := request.headers.get("content-length")) is not None: + try: + declared_length: Final = int(content_length) + except ValueError: + raise _error_response(400, "Invalid Content-Length") from None + if declared_length < 0: + raise _error_response(400, "Invalid Content-Length") + if declared_length > CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES: + raise _error_response(413, "Plugin archive exceeds the maximum allowed size") + + content_type: Final = request.headers.get("content-type", "").split(";", maxsplit=1)[0].strip().lower() + if content_type not in ("application/zip", "application/octet-stream"): + raise _error_response(415, "Content-Type must be application/zip or application/octet-stream") + + content: Final[bytes] = await _read_plugin_archive_body(request) + from prisma.fields import Base64 + + try: + sha256, encoded_content = await asyncio.to_thread( + _validate_and_encode_plugin_archive, content, Base64.encode + ) + except ValueError as exc: + raise _error_response(400, str(exc)) from exc + + manifest: Final[Mapping[str, object]] = MANIFEST_FIELDS.validate_json(plugin.manifest_json or "{}") + hosted_manifest: Final = {**manifest, "source": {"source": "hosted"}} + updated_at: Final = datetime.now(timezone.utc) + async with prisma_client.db.tx() as transaction: + await transaction.litellm_claudecodepluginarchivetable.upsert( + where={"plugin_id": plugin.id}, + data={ + "create": { + "plugin_id": plugin.id, + "content": encoded_content, + "size_bytes": len(content), + "updated_at": updated_at, + }, + "update": { + "content": encoded_content, + "size_bytes": len(content), + "updated_at": updated_at, + }, + }, + ) + updated_plugin: Final[_PluginRecord | None] = await transaction.litellm_claudecodeplugintable.update( + where={"name": plugin_name}, + data={ + "manifest_json": json.dumps(hosted_manifest), + "archive_sha256": sha256, + "updated_at": updated_at, + }, + ) + if updated_plugin is None: + raise _error_response(404, f"Plugin '{plugin_name}' not found") + + return UploadPluginArchiveResponse( + plugin=PluginResponse( + id=updated_plugin.id, + name=updated_plugin.name, + version=updated_plugin.version, + description=updated_plugin.description, + source={"source": "hosted"}, + installation_preference=get_installation_preference(hosted_manifest), + enabled=updated_plugin.enabled, + ), + sha256=sha256, + size_bytes=len(content), + ) + except HTTPException: + raise + except Exception as e: # noqa: BLE001 # unexpected upload errors map to HTTP 500 + verbose_proxy_logger.exception("Error uploading plugin archive: %s", e) + raise HTTPException(status_code=500, detail={"error": str(e)}) + + @router.post( "/claude-code/plugins/{plugin_name}/enable", tags=["Claude Code Marketplace"], diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 137f91f9b6e..ddf9990bb2d 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -85,6 +85,9 @@ class _PluginRow(Protocol): @property def manifest_json(self) -> str | None: ... + @property + def archive_sha256(self) -> str | None: ... + class _PluginTableActions(Protocol): def find_many(self, *, where: Mapping[str, bool]) -> Awaitable[Sequence[_PluginRow]]: ... @@ -355,6 +358,7 @@ async def public_skill_hub(): domain=manifest.get("domain"), namespace=manifest.get("namespace"), installation_preference=get_installation_preference(MANIFEST_FIELDS.validate_python(manifest)), + archive_sha256=plugin.archive_sha256 if manifest.get("source") == {"source": "hosted"} else None, ) ) return ListPluginsResponse(plugins=items, count=len(items)) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 12438614755..4a8be17cd22 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1608,14 +1608,26 @@ model LiteLLM_ClaudeCodePluginTable { description String? manifest_json String? files_json String? @default("{}") + archive_sha256 String? enabled Boolean @default(true) created_at DateTime? @default(now()) updated_at DateTime? @default(now()) @updatedAt created_by String? + archive LiteLLM_ClaudeCodePluginArchiveTable? @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginArchiveTable { + plugin_id String @id + content Bytes + size_bytes Int + updated_at DateTime @default(now()) @updatedAt + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade) + + @@map("LiteLLM_ClaudeCodePluginArchiveTable") +} + // User/team-scoped memory store with a GLOBAL unique key. // `value` is a string (typically markdown/text meant for LLM context); // `metadata` is an optional JSON envelope for structured tags without schema changes. diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index 2d3139f7a2c..26a610d3b67 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -92,6 +92,10 @@ class ClaudeCodePluginRepository(PrismaTableRepository["prisma_models.LiteLLM_Cl table_name = "litellm_claudecodeplugintable" +class ClaudeCodePluginArchiveRepository(PrismaTableRepository["prisma_models.LiteLLM_ClaudeCodePluginArchiveTable"]): + table_name = "litellm_claudecodepluginarchivetable" + + class TeamMembershipRepository(PrismaTableRepository["prisma_models.LiteLLM_TeamMembership"]): table_name = "litellm_teammembership" diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 19e40aa5c5d..e3420baed7f 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -4,7 +4,7 @@ Claude Code Marketplace endpoint types for LiteLLM Proxy from typing import Final, Literal, TypeAlias -from pydantic import AliasChoices, Field, TypeAdapter, model_validator +from pydantic import AliasChoices, ConfigDict, Field, TypeAdapter, model_validator from litellm.types.llms.base import LiteLLMBaseModel @@ -38,7 +38,9 @@ class PluginSpec(LiteLLMBaseModel): "- 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': ''}" + "{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', " + "'sha256': ''}\n" + "- Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin" ), ) version: str | None = Field("1.0.0", description="Semantic version") @@ -54,9 +56,9 @@ class PluginSpec(LiteLLMBaseModel): validation_alias=AliasChoices("installation_preference", "installationPreference"), 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" + "the only plugins Claude Desktop lists from a marketplace.json URL. LiteLLM supplies the sha256 for " + "gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json " + "share its inference gateway origin" ), ) @@ -115,6 +117,14 @@ class PluginResponse(LiteLLMBaseModel): enabled: bool = Field(..., description="Whether plugin is enabled") +class UploadPluginArchiveResponse(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + plugin: PluginResponse = Field(..., description="Updated plugin information") + sha256: str = Field(..., description="SHA-256 digest of the uploaded archive") + size_bytes: int = Field(..., description="Size of the uploaded archive in bytes") + + class RegisterPluginResponse(LiteLLMBaseModel): """Response from plugin registration.""" @@ -138,6 +148,7 @@ class PluginListItem(LiteLLMBaseModel): domain: str | None = None namespace: str | None = None installation_preference: InstallationPreference | None = None + archive_sha256: str | None = None enabled: bool created_at: str | None updated_at: str | None diff --git a/schema.prisma b/schema.prisma index 12438614755..4a8be17cd22 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1608,14 +1608,26 @@ model LiteLLM_ClaudeCodePluginTable { description String? manifest_json String? files_json String? @default("{}") + archive_sha256 String? enabled Boolean @default(true) created_at DateTime? @default(now()) updated_at DateTime? @default(now()) @updatedAt created_by String? + archive LiteLLM_ClaudeCodePluginArchiveTable? @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginArchiveTable { + plugin_id String @id + content Bytes + size_bytes Int + updated_at DateTime @default(now()) @updatedAt + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade) + + @@map("LiteLLM_ClaudeCodePluginArchiveTable") +} + // User/team-scoped memory store with a GLOBAL unique key. // `value` is a string (typically markdown/text meant for LLM context); // `metadata` is an optional JSON envelope for structured tags without schema changes. diff --git a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 6f426a4a3a7..bf0e9265b17 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -4,29 +4,42 @@ 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 base64 +import hashlib +import io import json -from typing import Final +import zipfile +from contextlib import asynccontextmanager +from typing import Final, cast from unittest.mock import AsyncMock, MagicMock import pytest -from fastapi import HTTPException +from fastapi import FastAPI, HTTPException, Request +from fastapi.testclient import TestClient from pydantic import ValidationError +from starlette.types import Message, Receive, Scope, Send import litellm +from litellm.proxy import proxy_server from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.claude_code_endpoints import claude_code_marketplace from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( delete_plugin, disable_plugin, + download_plugin_archive, enable_plugin, get_marketplace, get_plugin, list_plugins, register_plugin, update_plugin, + upload_plugin_archive, ) +from litellm.proxy.auth import auth_checks +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import LitellmUserRoles from litellm.types.proxy.claude_code_endpoints import ( + InstallationPreference, MarketplaceResponse, RegisterPluginRequest, UpdatePluginRequest, @@ -36,7 +49,8 @@ from tests._master_key import MASTER_KEY def _make_mock_prisma(): """Stateful prisma mock that supports find_unique, find_many, create, and update.""" - store: dict = {} + store: dict[str, MagicMock] = {} + archive_store: dict[str, MagicMock] = {} mock_client = MagicMock() mock_client.proxy_logging_obj = MagicMock() @@ -65,6 +79,7 @@ def _make_mock_prisma(): record.enabled = data.get("enabled", True) record.created_at = data.get("created_at") record.updated_at = data.get("updated_at") + record.archive_sha256 = data.get("archive_sha256") store[data["name"]] = record return record @@ -79,6 +94,36 @@ 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 + + archive_table = MagicMock() + + async def _find_archive(where: dict[str, str]): + return archive_store.get(where.get("plugin_id")) + + async def _upsert_archive(where: dict[str, str], data: dict[str, object]): + archive_data = data["update"] if where["plugin_id"] in archive_store else data["create"] + record = MagicMock() + record.plugin_id = archive_data["plugin_id"] + record.content = archive_data["content"] + record.size_bytes = archive_data["size_bytes"] + record.updated_at = archive_data["updated_at"] + record.model_dump = MagicMock(return_value={"content": base64.b64encode(record.content.decode()).decode()}) + archive_store[where["plugin_id"]] = record + return record + + async def _delete_archive(where: dict[str, str]): + archive_store.pop(where["plugin_id"], None) + + archive_table.find_unique = AsyncMock(side_effect=_find_archive) + archive_table.upsert = AsyncMock(side_effect=_upsert_archive) + archive_table.delete_many = AsyncMock(side_effect=_delete_archive) + mock_client.db.litellm_claudecodepluginarchivetable = archive_table + + @asynccontextmanager + async def _transaction(): + yield mock_client.db + + mock_client.db.tx = _transaction return mock_client @@ -325,6 +370,21 @@ async def test_list_plugins_shows_disabled_plugin_only_to_granted_key_or_admin() assert await _listed_names(_USER) == {"public-skill", "private-skill"} +@pytest.mark.asyncio +async def test_list_plugins_returns_500_for_invalid_manifest_json(): + await register_plugin( + request=RegisterPluginRequest(name="invalid-manifest", source=_GIT_SUBDIR_SOURCE, version="1.0.0"), + user_api_key_dict=_USER, + ) + table: Final = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable + await table.update(where={"name": "invalid-manifest"}, data={"manifest_json": "{"}) + + with pytest.raises(HTTPException) as exc_info: + await list_plugins(user_api_key_dict=_USER) + + assert exc_info.value.status_code == 500 + + @pytest.mark.asyncio async def test_get_plugin_returns_403_for_disabled_plugin_the_key_is_not_granted(): await _register_public_and_private_plugins() @@ -362,9 +422,529 @@ async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(mon assert await _marketplace_names("sk-plain") == ["public-skill"] assert await _marketplace_names("sk-granted") == ["private-skill", "public-skill"] + header_request: Final = MagicMock(headers={"Authorization": "Bearer sk-granted"}) + header_response: Final = await get_marketplace(request=header_request) + assert sorted(plugin["name"] for plugin in json.loads(header_response.body)["plugins"]) == [ + "private-skill", + "public-skill", + ] + with pytest.raises(ProxyException) as exc_info: await get_marketplace(request=MagicMock(), key="sk-bogus") assert exc_info.value.code == "401" + with pytest.raises(ProxyException) as header_error: + await get_marketplace(request=MagicMock(headers={"Authorization": "Bearer sk-bogus"})) + assert header_error.value.code == "401" + + +def _plugin_archive(manifest_path: str = ".claude-plugin/plugin.json") -> bytes: + archive_bytes: Final = io.BytesIO() + with zipfile.ZipFile(archive_bytes, "w") as archive: + archive.writestr(manifest_path, '{"name":"hosted-plugin"}') + return archive_bytes.getvalue() + + +def _archive_request( + content: bytes, + *, + content_type: str = "application/zip", + content_length: str | None = None, + receive: Receive | None = None, +) -> Request: + content_headers: Final = ((b"content-type", content_type.encode()),) + length_headers: Final = () if content_length is None else ((b"content-length", content_length.encode()),) + headers: Final = (*content_headers, *length_headers) + + async def receive_body() -> Message: + return {"type": "http.request", "body": content, "more_body": False} + + scope: Final[Scope] = { + "type": "http", + "http_version": "1.1", + "method": "PUT", + "scheme": "http", + "path": "/claude-code/plugins/hosted-plugin/archive", + "raw_path": b"/claude-code/plugins/hosted-plugin/archive", + "query_string": b"", + "headers": list(headers), + "server": ("testserver", 80), + "client": ("127.0.0.1", 1234), + } + return Request(scope, receive or receive_body) + + +def _register_request( + name: str, + source: dict[str, str], + installation_preference: InstallationPreference | None = None, +) -> RegisterPluginRequest: + return RegisterPluginRequest( + name=name, + source=source, + version="1.0.0", + description=None, + author=None, + homepage=None, + keywords=None, + category=None, + domain=None, + namespace=None, + installation_preference=installation_preference, + ) + + +def _update_request(source: dict[str, str]) -> UpdatePluginRequest: + return UpdatePluginRequest( + source=source, + version=None, + description=None, + author=None, + homepage=None, + keywords=None, + category=None, + domain=None, + namespace=None, + installation_preference=None, + ) + + +async def _register_hosted_plugin(name: str = "hosted-plugin") -> None: + await register_plugin( + request=_register_request(name, {"source": "hosted"}), + user_api_key_dict=_USER, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "manifest_path", + [".claude-plugin/plugin.json", "bundle/.claude-plugin/plugin.json"], +) +async def test_hosted_plugin_archive_upload_and_download(manifest_path: str): + await _register_hosted_plugin() + content: Final = _plugin_archive(manifest_path) + result: Final = await upload_plugin_archive( + plugin_name="hosted-plugin", + request=_archive_request(content), + user_api_key_dict=_USER, + ) + assert result.sha256 == hashlib.sha256(content).hexdigest() + + downloaded: Final = await download_plugin_archive( + plugin_name="hosted-plugin", + request=MagicMock(headers={}), + ) + assert downloaded.body == content + assert downloaded.media_type == "application/zip" + assert downloaded.headers["content-disposition"] == 'attachment; filename="hosted-plugin.zip"' + listed: Final = await list_plugins(user_api_key_dict=_USER) + stored_plugin: Final = await get_plugin(plugin_name="hosted-plugin", user_api_key_dict=_USER) + assert next(plugin for plugin in listed.plugins if plugin.name == "hosted-plugin").archive_sha256 == result.sha256 + assert stored_plugin["archive_sha256"] == result.sha256 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("content_type", ["application/zip", "application/octet-stream"]) +async def test_upload_plugin_archive_accepts_raw_body_route( + monkeypatch: pytest.MonkeyPatch, + content_type: str, +): + await _register_hosted_plugin() + prisma_client: Final = litellm.proxy.proxy_server.prisma_client + monkeypatch.setattr(claude_code_marketplace, "get_prisma_client", AsyncMock(return_value=prisma_client)) + + app: Final = FastAPI() + app.include_router(claude_code_marketplace.router) + app.dependency_overrides[user_api_key_auth] = lambda: _USER + content: Final = _plugin_archive() + + with TestClient(app) as client: + response = client.put( + "/claude-code/plugins/hosted-plugin/archive", + content=content, + headers={"Content-Type": content_type}, + ) + + assert response.status_code == 200 + assert response.json()["sha256"] == hashlib.sha256(content).hexdigest() + + +@pytest.mark.asyncio +async def test_upload_plugin_archive_rejects_unauthenticated_request_before_storage(monkeypatch: pytest.MonkeyPatch): + await _register_hosted_plugin() + prisma_client: Final = litellm.proxy.proxy_server.prisma_client + monkeypatch.setattr(claude_code_marketplace, "get_prisma_client", AsyncMock(return_value=prisma_client)) + app: Final = FastAPI() + app.include_router(claude_code_marketplace.router) + + def reject_unauthenticated_request() -> UserAPIKeyAuth: + raise HTTPException(status_code=401, detail="Unauthorized") + + app.dependency_overrides[user_api_key_auth] = reject_unauthenticated_request + content: Final = _plugin_archive() + receive_spy: Final = MagicMock() + + async def track_body_reads(scope: Scope, receive: Receive, send: Send) -> None: + async def tracked_receive() -> Message: + message: Final = await receive() + if message["type"] == "http.request" and message.get("body"): + receive_spy(message) + return message + + await app(scope, tracked_receive, send) + + with TestClient(track_body_reads) as client: + response = client.put( + "/claude-code/plugins/hosted-plugin/archive", + content=content, + headers={"Content-Type": "application/zip"}, + ) + + archive: Final = await prisma_client.db.litellm_claudecodepluginarchivetable.find_unique( + where={"plugin_id": "test-id"} + ) + assert response.status_code == 401 + assert receive_spy.call_count == 0 + assert archive is None + + +@pytest.mark.asyncio +async def test_private_hosted_archive_uses_inference_key_visibility(monkeypatch: pytest.MonkeyPatch): + await _register_hosted_plugin("private-plugin") + await disable_plugin(plugin_name="private-plugin", user_api_key_dict=_USER) + content: Final = _plugin_archive() + await upload_plugin_archive( + plugin_name="private-plugin", + request=_archive_request(content), + user_api_key_dict=_USER, + ) + keys: dict[str, UserAPIKeyAuth] = {"sk-granted": _granted_user(["private-plugin"]), "sk-plain": _NON_ADMIN_USER} + + async def _fake_auth(request: Request, api_key: str) -> UserAPIKeyAuth: + token = api_key.removeprefix("Bearer ") + if token not in keys: + raise ProxyException(message="invalid key", type="auth_error", param="key", code=401) + return keys[token] + + monkeypatch.setattr(claude_code_marketplace, "user_api_key_auth", _fake_auth) + hidden_request = MagicMock(headers={"Authorization": "Bearer sk-plain"}) + with pytest.raises(HTTPException) as hidden_error: + await download_plugin_archive("private-plugin", hidden_request) + assert hidden_error.value.status_code == 404 + + granted_request = MagicMock(headers={"Authorization": "Bearer sk-granted"}) + visible_archive: Final = await download_plugin_archive("private-plugin", granted_request) + assert visible_archive.body == content + + +@pytest.mark.asyncio +async def test_access_group_grant_exposes_disabled_hosted_archive_and_marketplace(monkeypatch: pytest.MonkeyPatch): + await _register_hosted_plugin("group-only-plugin") + await disable_plugin(plugin_name="group-only-plugin", user_api_key_dict=_USER) + content: Final = _plugin_archive() + await upload_plugin_archive( + plugin_name="group-only-plugin", + request=_archive_request(content), + user_api_key_dict=_USER, + ) + + group_key: Final = UserAPIKeyAuth( + api_key="sk-group-only", + token="sk-group-only", + user_role=LitellmUserRoles.INTERNAL_USER, + access_group_ids=["plugin-access-group"], + ) + access_group: Final = MagicMock() + access_group.access_group_id = "plugin-access-group" + access_group.access_skill_names = ["group-only-plugin"] + access_group.assigned_team_ids = [] + access_group.assigned_key_ids = [group_key.token] + keys: Final[dict[str, UserAPIKeyAuth]] = { + "sk-group-only": group_key, + "sk-no-group": _NON_ADMIN_USER, + } + + async def _fake_auth(request: Request, api_key: str) -> UserAPIKeyAuth: + assert request.headers.get("Authorization") == api_key + return keys[api_key.removeprefix("Bearer ")] + + monkeypatch.setattr(claude_code_marketplace, "user_api_key_auth", _fake_auth) + monkeypatch.setattr(auth_checks, "get_access_object", AsyncMock(return_value=access_group)) + monkeypatch.setattr(auth_checks, "get_skill_names_from_access_groups", AsyncMock(return_value=frozenset())) + monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock()) + monkeypatch.setenv("PROXY_BASE_URL", "https://marketplace.example.com") + + grouped_request: Final = cast(Request, MagicMock(headers={"Authorization": "Bearer sk-group-only"})) + marketplace_response: Final = await get_marketplace(request=grouped_request) + marketplace_plugins: Final = json.loads(bytes(marketplace_response.body))["plugins"] + assert marketplace_plugins == [ + { + "name": "group-only-plugin", + "source": { + "source": "archive", + "url": "https://marketplace.example.com/claude-code/plugins/group-only-plugin/archive.zip", + "sha256": hashlib.sha256(content).hexdigest(), + }, + "version": "1.0.0", + } + ] + + archive: Final = await download_plugin_archive("group-only-plugin", grouped_request) + assert archive.status_code == 200 + assert archive.body == content + + without_group_request: Final = cast(Request, MagicMock(headers={"Authorization": "Bearer sk-no-group"})) + with pytest.raises(HTTPException) as hidden_error: + await download_plugin_archive("group-only-plugin", without_group_request) + assert hidden_error.value.status_code == 404 + assert hidden_error.value.detail == {"error": "Plugin not found"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "source", + [{"source": "hosted", "url": "https://example.com/archive.zip"}, {"source": "hosted", "sha256": "abc"}], +) +async def test_register_rejects_hosted_source_extra_keys(source: dict[str, str]): + with pytest.raises(HTTPException) as exc_info: + await register_plugin( + request=_register_request("hosted-plugin", source), + user_api_key_dict=_USER, + ) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "source", + [{"source": "hosted", "url": "https://example.com/archive.zip"}, {"source": "hosted", "sha256": "abc"}], +) +async def test_update_rejects_hosted_source_extra_keys(source: dict[str, str]): + await register_plugin( + request=_register_request("existing-plugin", _GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + with pytest.raises(HTTPException) as exc_info: + await update_plugin( + plugin_name="existing-plugin", + request=_update_request(source), + user_api_key_dict=_USER, + ) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_hosted_marketplace_uses_request_origin_and_digest(monkeypatch: pytest.MonkeyPatch): + await _register_hosted_plugin() + content: Final = _plugin_archive() + await upload_plugin_archive( + plugin_name="hosted-plugin", + request=_archive_request(content), + user_api_key_dict=_USER, + ) + monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.example.com/root") + + response: Final = await get_marketplace(request=MagicMock(headers={})) + hosted_entry: Final = next( + plugin for plugin in json.loads(bytes(response.body))["plugins"] if plugin["name"] == "hosted-plugin" + ) + assert hosted_entry["source"] == { + "source": "archive", + "url": "https://proxy.example.com/root/claude-code/plugins/hosted-plugin/archive.zip", + "sha256": hashlib.sha256(content).hexdigest(), + } + + +@pytest.mark.asyncio +async def test_hosted_marketplace_uses_request_origin_without_configured_proxy_url(monkeypatch: pytest.MonkeyPatch): + await _register_hosted_plugin() + content: Final = _plugin_archive() + await upload_plugin_archive( + plugin_name="hosted-plugin", + request=_archive_request(content), + user_api_key_dict=_USER, + ) + monkeypatch.delenv("PROXY_BASE_URL", raising=False) + request: Final = Request( + { + "type": "http", + "http_version": "1.1", + "method": "GET", + "scheme": "https", + "path": "/claude-code/marketplace.json", + "raw_path": b"/claude-code/marketplace.json", + "query_string": b"", + "headers": [], + "server": ("marketplace.example.com", 443), + "client": ("127.0.0.1", 1234), + } + ) + + response: Final = await get_marketplace(request=request) + hosted_entry: Final = next( + plugin for plugin in json.loads(bytes(response.body))["plugins"] if plugin["name"] == "hosted-plugin" + ) + assert hosted_entry["source"]["url"] == ( + "https://marketplace.example.com/claude-code/plugins/hosted-plugin/archive.zip" + ) + + +@pytest.mark.asyncio +async def test_hosted_plugin_without_archive_is_omitted_from_marketplace(caplog: pytest.LogCaptureFixture): + await _register_hosted_plugin() + + response: Final = await get_marketplace(request=MagicMock(headers={})) + + assert "hosted-plugin" not in {plugin["name"] for plugin in json.loads(bytes(response.body))["plugins"]} + assert "has no archive" in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "plugin_name", + ["missing-plugin", "non-hosted-plugin", "hosted-plugin"], +) +async def test_archive_download_hides_missing_non_hosted_and_archive_less_plugins(plugin_name: str): + await register_plugin( + request=_register_request("non-hosted-plugin", _GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + await _register_hosted_plugin() + + with pytest.raises(HTTPException) as exc_info: + await download_plugin_archive(plugin_name=plugin_name, request=MagicMock(headers={})) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == {"error": "Plugin not found"} + + +@pytest.mark.asyncio +async def test_upload_archive_requires_admin_and_rejects_invalid_zip(): + await _register_hosted_plugin() + with pytest.raises(HTTPException) as auth_error: + await upload_plugin_archive("hosted-plugin", _archive_request(b"not a zip"), _NON_ADMIN_USER) + assert auth_error.value.status_code == 403 + + with pytest.raises(HTTPException) as zip_error: + await upload_plugin_archive( + "hosted-plugin", + _archive_request(b"not a zip"), + _USER, + ) + assert zip_error.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_upload_archive_enforces_stream_size_limit(monkeypatch: pytest.MonkeyPatch): + await _register_hosted_plugin() + monkeypatch.setattr(claude_code_marketplace, "CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES", 4) + + with pytest.raises(HTTPException) as exc_info: + await upload_plugin_archive( + "hosted-plugin", + _archive_request(b"12345"), + _USER, + ) + + assert exc_info.value.status_code == 413 + archive: Final = await litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodepluginarchivetable.find_unique( + where={"plugin_id": "test-id"} + ) + assert archive is None + + +@pytest.mark.asyncio +async def test_upload_archive_rejects_over_limit_content_length_without_reading_body( + monkeypatch: pytest.MonkeyPatch, +): + await _register_hosted_plugin() + monkeypatch.setattr(claude_code_marketplace, "CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES", 4) + receive: Final = AsyncMock(return_value={"type": "http.request", "body": _plugin_archive(), "more_body": False}) + request: Final = _archive_request( + _plugin_archive(), + content_length="5", + receive=cast(Receive, receive), + ) + + with pytest.raises(HTTPException) as exc_info: + await upload_plugin_archive("hosted-plugin", request, _USER) + + archive: Final = await litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodepluginarchivetable.find_unique( + where={"plugin_id": "test-id"} + ) + assert exc_info.value.status_code == 413 + assert receive.await_count == 0 + assert archive is None + + +@pytest.mark.asyncio +async def test_upload_archive_rejects_unsupported_content_type(): + await _register_hosted_plugin() + + with pytest.raises(HTTPException) as exc_info: + await upload_plugin_archive( + "hosted-plugin", + _archive_request(_plugin_archive(), content_type="application/json"), + _USER, + ) + + assert exc_info.value.status_code == 415 + + +@pytest.mark.asyncio +async def test_hosted_plugin_accepts_auto_install_preference(): + response: Final = await register_plugin( + request=_register_request("hosted-auto-install", {"source": "hosted"}, "auto_install"), + user_api_key_dict=_USER, + ) + assert response.plugin.source == {"source": "hosted"} + assert response.plugin.installation_preference == "auto_install" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "manifest_path", + ["../.claude-plugin/plugin.json", "/.claude-plugin/plugin.json", "folder/extra/.claude-plugin/plugin.json"], +) +async def test_upload_archive_rejects_unsafe_or_deep_manifest_paths(manifest_path: str): + await _register_hosted_plugin() + + with pytest.raises(HTTPException) as exc_info: + await upload_plugin_archive( + "hosted-plugin", + _archive_request(_plugin_archive(manifest_path)), + _USER, + ) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_update_away_from_hosted_source_clears_archive(): + await _register_hosted_plugin() + await upload_plugin_archive( + "hosted-plugin", + _archive_request(_plugin_archive()), + _USER, + ) + + await update_plugin( + plugin_name="hosted-plugin", + request=_update_request(_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + plugin: Final = await litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable.find_unique( + where={"name": "hosted-plugin"} + ) + archive: Final = await litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodepluginarchivetable.find_unique( + where={"plugin_id": plugin.id} + ) + assert plugin.archive_sha256 is None + assert archive is None @pytest.mark.asyncio @@ -386,6 +966,52 @@ async def test_get_marketplace_emits_installation_preference(preference): ] +@pytest.mark.asyncio +async def test_get_marketplace_preserves_non_hosted_entry_key_order_and_content(): + await register_plugin( + request=RegisterPluginRequest( + name="marketplace-shape", + source=_ARCHIVE_SOURCE, + version="2.3.4", + description="Marketplace compatibility", + author={"name": "Ada Lovelace", "email": "ada@example.com"}, + homepage="https://example.com/plugin", + keywords=["analysis", "tools"], + category="development", + installation_preference="available", + ), + user_api_key_dict=_USER, + ) + + request = Request( + { + "type": "http", + "method": "GET", + "scheme": "https", + "server": ("gateway.example", 443), + "client": ("127.0.0.1", 12345), + "headers": [], + "path": "/claude-code/marketplace.json", + "query_string": b"", + } + ) + marketplace = json.loads((await get_marketplace(request=request)).body) + entry = marketplace["plugins"][0] + expected = { + "name": "marketplace-shape", + "source": _ARCHIVE_SOURCE, + "installationPreference": "available", + "version": "2.3.4", + "description": "Marketplace compatibility", + "author": {"name": "Ada Lovelace", "email": "ada@example.com"}, + "homepage": "https://example.com/plugin", + "keywords": ["analysis", "tools"], + "category": "development", + } + + assert tuple(entry.items()) == tuple(expected.items()) + + @pytest.mark.parametrize( "build_request", [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx index 67bab398bd2..65158daec7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,6 +10,7 @@ import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; vi.mock("@/components/networking", () => ({ getClaudeCodePluginsList: vi.fn(), deleteClaudeCodePlugin: vi.fn(), + getProxyBaseUrl: vi.fn(() => "https://proxy.example.com/root/"), })); vi.mock("./PluginTable", () => ({ @@ -18,28 +19,30 @@ vi.mock("./PluginTable", () => ({ isLoading, pluginsList, onDeleteClick, + onPluginClick, }: { isLoading: boolean; pluginsList: Plugin[]; onDeleteClick: (pluginName: string, displayName: string) => void; + onPluginClick: (id: string) => void; }) => (
{isLoading ? "table-loading" : "table-loaded"} {pluginsList.map((plugin) => ( - +
+ + +
))}
), })); vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null })); -vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null })); const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin); @@ -50,6 +53,7 @@ const skill: Plugin = { source: { source: "github", repo: "acme/my-skill" }, enabled: true, }; +const hostedSkill: Plugin = { ...skill, source: { source: "hosted" } }; describe("ClaudeCodePluginsPanel loading state", () => { beforeEach(() => { @@ -76,6 +80,28 @@ describe("ClaudeCodePluginsPanel loading state", () => { expect(await screen.findByText("table-loaded")).toBeInTheDocument(); expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false); }); + + it("shows the Claude Desktop marketplace URL and configuration", async () => { + mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [], count: 0 }); + render(); + + expect(await screen.findByText("https://proxy.example.com/root/claude-code/marketplace.json")).toBeInTheDocument(); + expect(screen.getByText("Connect Claude Desktop")).toBeInTheDocument(); + expect(screen.getByText((_, element) => element?.tagName === "PRE")).toHaveTextContent( + '"credentialKind": "inferenceCredential"', + ); + expect(screen.getByRole("button", { name: "Copy JSON" })).toBeInTheDocument(); + }); + + it("shows hosted plugins as hosted rather than a URL", async () => { + mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [hostedSkill], count: 1 }); + render(); + + fireEvent.click(await screen.findByTestId("row-open-plugin-1")); + + expect(await screen.findByText("Hosted on LiteLLM")).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); }); describe("ClaudeCodePluginsPanel delete confirmation", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx index f79b9a405cc..e00e2adf234 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect, useState } from "react"; +import { Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { AlertDialog, @@ -9,7 +10,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import { getClaudeCodePluginsList, deleteClaudeCodePlugin, getProxyBaseUrl } from "@/components/networking"; import AddPluginForm from "./add_plugin_form"; import PluginTable from "./PluginTable"; import SkillDetail from "@/components/claude_code_plugins/skill_detail"; @@ -27,6 +28,7 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isDeleting, setIsDeleting] = useState(false); + const marketplaceUrl = `${getProxyBaseUrl().replace(/\/+$/, "")}/claude-code/marketplace.json`; const [pluginToDelete, setPluginToDelete] = useState<{ name: string; displayName: string; @@ -56,6 +58,28 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT fetchPlugins(); }, [accessToken]); + const marketplaceConfig = JSON.stringify( + [ + { + source: "url", + url: marketplaceUrl, + credentialKind: "inferenceCredential", + installationPreference: "available", + }, + ], + null, + 2, + ); + + const handleCopyMarketplaceConfig = async () => { + try { + await navigator.clipboard.writeText(marketplaceConfig); + toast.success("Claude Desktop configuration copied"); + } catch { + toast.error("Could not copy Claude Desktop configuration"); + } + }; + const handleDeleteClick = (pluginName: string, displayName: string) => { setPluginToDelete({ name: pluginName, displayName }); }; @@ -95,6 +119,18 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "} /claude-code/marketplace.json.

+
+

Connect Claude Desktop

+

Marketplace URL

+ {marketplaceUrl} +
+
{marketplaceConfig}
+ +
+
diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx index 92e18514f66..8b179a1c45f 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx @@ -54,4 +54,10 @@ describe("SkillDetail source", () => { expect(screen.getByText("git@ghe.example.com:org/repo.git @ plugins/x")).toBeInTheDocument(); expect(screen.queryByRole("link")).not.toBeInTheDocument(); }); + + it("labels a hosted source without rendering it as a URL", () => { + render(); + expect(screen.getByText("Hosted on LiteLLM")).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index 9b03c25aade..b69765f211e 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -5,6 +5,14 @@ import { buildMarketplaceSettingsSnippet, formatInstallCommand, getSourceDisplay import { Plugin } from "./types"; const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => { + if (source.source === "hosted") { + return ( +
+
Source
+
Hosted on LiteLLM
+
+ ); + } const link = getSourceLink(source); const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link; if (href) { diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts index 29552c0392b..4475e980c81 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts @@ -6,7 +6,7 @@ import type { components } from "@/lib/http/schema"; export interface PluginSource { - source: "github" | "url" | "git-subdir" | "archive"; + source: "github" | "url" | "git-subdir" | "archive" | "hosted"; repo?: string; // Format: "org/repo" for GitHub url?: string; // Full URL for other sources path?: string; // Subdirectory path for git-subdir diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index dc582da6833..706b2ae198f 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -152,6 +152,31 @@ describe("exchangeLoginCode - storeLoginToken integration", () => { }); }); +describe("uploadClaudeCodePluginArchive", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("sends the zip bytes as a raw application/zip body", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + global.fetch = fetchMock as unknown as typeof fetch; + const file = new File(["zip bytes"], "plugin.zip", { type: "application/zip" }); + + await Networking.uploadClaudeCodePluginArchive("sk-test", "hosted plugin", file); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain("/claude-code/plugins/hosted%20plugin/archive"); + expect(init.method).toBe("PUT"); + expect(init.body).toBe(file); + expect(init.headers).toMatchObject({ + Authorization: "Bearer sk-test", + "Content-Type": "application/zip", + }); + }); +}); + describe("modelInfoCall", () => { let currentFetch: typeof global.fetch; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d5e81af4652..5ed9a99e10e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -7301,6 +7301,15 @@ export const registerClaudeCodePlugin = async (accessToken: string, pluginData: } }; +export const uploadClaudeCodePluginArchive = async (accessToken: string, pluginName: string, file: File) => { + const path = `/claude-code/plugins/${encodeURIComponent(pluginName)}/archive`; + return apiClient.put(path, { + accessToken, + rawBody: file, + headers: { "Content-Type": "application/zip" }, + }); +}; + /** * Enable a Claude Code plugin (admin only) * @param accessToken - Admin access token diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 500848e08a4..fa832795e10 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2550,6 +2550,46 @@ export interface paths { patch?: never; trace?: never; }; + "/claude-code/plugins/{plugin_name}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upload Plugin Archive + * @description Upload and validate a zip archive for an existing hosted plugin. + */ + put: operations["upload_plugin_archive_claude_code_plugins__plugin_name__archive_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/claude-code/plugins/{plugin_name}/archive.zip": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download Plugin Archive + * @description Download a hosted plugin archive when it is visible to the caller. + */ + get: operations["download_plugin_archive_claude_code_plugins__plugin_name__archive_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; @@ -40366,6 +40406,8 @@ export interface components { * @description Plugin item in list responses. */ PluginListItem: { + /** Archive Sha256 */ + archive_sha256?: string | null; author?: components["schemas"]["PluginAuthor"] | null; /** Category */ category?: string | null; @@ -41843,7 +41885,7 @@ export interface components { 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 + * @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. LiteLLM supplies the sha256 for gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin */ installation_preference?: ("available" | "auto_install" | "required") | null; /** @@ -41868,6 +41910,7 @@ export interface components { * - Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'} * - Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'} * - Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': ''} + * - Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin */ source: { [key: string]: string; @@ -48009,7 +48052,7 @@ export interface components { 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 + * @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. LiteLLM supplies the sha256 for gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin */ installation_preference?: ("available" | "auto_install" | "required") | null; /** @@ -48029,6 +48072,7 @@ export interface components { * - Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'} * - Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'} * - Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': ''} + * - Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin */ source: { [key: string]: string; @@ -48553,6 +48597,21 @@ export interface components { /** User Role */ user_role?: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null; }; + /** UploadPluginArchiveResponse */ + UploadPluginArchiveResponse: { + /** @description Updated plugin information */ + plugin: components["schemas"]["PluginResponse"]; + /** + * Sha256 + * @description SHA-256 digest of the uploaded archive + */ + sha256: string; + /** + * Size Bytes + * @description Size of the uploaded archive in bytes + */ + size_bytes: number; + }; /** UsageAIChatRequest */ UsageAIChatRequest: { /** @@ -54310,6 +54369,75 @@ export interface operations { }; }; }; + upload_plugin_archive_claude_code_plugins__plugin_name__archive_put: { + parameters: { + query?: never; + header?: never; + path: { + plugin_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/octet-stream": string; + "application/zip": string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadPluginArchiveResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_plugin_archive_claude_code_plugins__plugin_name__archive_zip_get: { + parameters: { + query?: { + key?: string | null; + }; + header?: never; + path: { + plugin_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; disable_plugin_claude_code_plugins__plugin_name__disable_post: { parameters: { query?: never;