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}
+
+
+