mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
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>
This commit is contained in:
parent
7d7e28829f
commit
65fffa4136
23 changed files with 1714 additions and 192 deletions
|
|
@ -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 $$;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -338,6 +338,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
|
|||
"LiteLLM_TelemetryReport",
|
||||
"LiteLLM_AccessGroupTable",
|
||||
"LiteLLM_ClaudeCodePluginTable",
|
||||
"LiteLLM_ClaudeCodePluginArchiveTable",
|
||||
"LiteLLM_MemoryTable",
|
||||
"LiteLLM_AdaptiveRouterState",
|
||||
"LiteLLM_AdaptiveRouterSession",
|
||||
|
|
|
|||
|
|
@ -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': '<optional hex digest>'}",
|
||||
"description": "Plugin source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n- Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}\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': '<optional hex digest>'}",
|
||||
"description": "Plugin source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n- Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}\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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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': '<optional hex digest>'}"
|
||||
"{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', "
|
||||
"'sha256': '<optional hex digest>'}\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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) => (
|
||||
<div data-testid="plugin-table">
|
||||
{isLoading ? "table-loading" : "table-loaded"}
|
||||
{pluginsList.map((plugin) => (
|
||||
<button
|
||||
key={plugin.id}
|
||||
data-testid={`row-delete-${plugin.id}`}
|
||||
onClick={() => onDeleteClick(plugin.name, plugin.name)}
|
||||
>
|
||||
row delete
|
||||
</button>
|
||||
<div key={plugin.id}>
|
||||
<button data-testid={`row-delete-${plugin.id}`} onClick={() => onDeleteClick(plugin.name, plugin.name)}>
|
||||
row delete
|
||||
</button>
|
||||
<button data-testid={`row-open-${plugin.id}`} onClick={() => onPluginClick(plugin.id)}>
|
||||
Open plugin
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
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(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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<ClaudeCodePluginsPanelProps> = ({ 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<ClaudeCodePluginsPanelProps> = ({ 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<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "}
|
||||
<code className="bg-muted px-1 rounded-sm">/claude-code/marketplace.json</code>.
|
||||
</p>
|
||||
<section className="mt-2 rounded-lg border p-4">
|
||||
<h2 className="text-sm font-semibold">Connect Claude Desktop</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Marketplace URL</p>
|
||||
<code className="break-all text-xs">{marketplaceUrl}</code>
|
||||
<div className="mt-3 flex items-start justify-between gap-3">
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">{marketplaceConfig}</pre>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyMarketplaceConfig}>
|
||||
<Copy className="size-4" />
|
||||
Copy JSON
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
|
||||
+ Add Skill
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import AddPluginForm from "./add_plugin_form";
|
||||
import { registerClaudeCodePlugin } from "@/components/networking";
|
||||
import { registerClaudeCodePlugin, uploadClaudeCodePluginArchive } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
registerClaudeCodePlugin: vi.fn().mockResolvedValue({ status: "success" }),
|
||||
uploadClaudeCodePluginArchive: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const mockRegister = vi.mocked(registerClaudeCodePlugin);
|
||||
const mockUpload = vi.mocked(uploadClaudeCodePluginArchive);
|
||||
const mockMessageError = vi.mocked(toast.error);
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
|
|
@ -182,6 +184,49 @@ describe("AddPluginForm", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("creates hosted plugins before uploading their zip archive", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
const file = new File(["zip"], "hosted-plugin.zip", { type: "application/zip" });
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Upload a zip to LiteLLM"));
|
||||
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "hosted-plugin" } });
|
||||
fireEvent.change(screen.getByLabelText("Plugin archive (.zip)"), { target: { files: [file] } });
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith(
|
||||
"sk-test",
|
||||
expect.objectContaining({ name: "hosted-plugin", source: { source: "hosted" } }),
|
||||
);
|
||||
expect(mockUpload).toHaveBeenCalledWith("sk-test", "hosted-plugin", file);
|
||||
});
|
||||
expect(mockRegister.mock.invocationCallOrder[0]).toBeLessThan(mockUpload.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("keeps a created hosted plugin available and retries only the failed upload", async () => {
|
||||
mockUpload.mockRejectedValueOnce(new Error("Archive upload failed"));
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
const file = new File(["zip"], "hosted-plugin.zip", { type: "application/zip" });
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Upload a zip to LiteLLM"));
|
||||
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "hosted-plugin" } });
|
||||
fireEvent.change(screen.getByLabelText("Plugin archive (.zip)"), { target: { files: [file] } });
|
||||
await submit();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Retry Upload" })).toBeInTheDocument();
|
||||
expect(mockMessageError).toHaveBeenCalledWith("Archive upload failed");
|
||||
expect(DEFAULT_PROPS.onSuccess).toHaveBeenCalled();
|
||||
expect(DEFAULT_PROPS.onClose).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry Upload" }));
|
||||
});
|
||||
|
||||
expect(mockRegister).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpload).toHaveBeenCalledTimes(2);
|
||||
expect(DEFAULT_PROPS.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits a github subdir source from a tree URL", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { registerClaudeCodePlugin } from "@/components/networking";
|
||||
import { registerClaudeCodePlugin, uploadClaudeCodePluginArchive } from "@/components/networking";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -40,7 +40,7 @@ interface AddPluginFormProps {
|
|||
}
|
||||
|
||||
const addPluginShape = {
|
||||
skillUrl: z.string().min(1, "Please enter a repository or zip archive URL"),
|
||||
skillUrl: z.string(),
|
||||
subPath: z
|
||||
.string()
|
||||
.refine(
|
||||
|
|
@ -151,6 +151,9 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
|
||||
const [subPathLock, setSubPathLock] = useState<SubPathLock | null>(null);
|
||||
const [isHostedUpload, setIsHostedUpload] = useState(false);
|
||||
const [archiveFile, setArchiveFile] = useState<File | null>(null);
|
||||
const [createdHostedPluginName, setCreatedHostedPluginName] = useState<string | null>(null);
|
||||
|
||||
const recomputePreview = (skillUrl: string, subPath: string) => {
|
||||
const lock = subPathLockFor(parseSkillSource(skillUrl)?.parsed.source);
|
||||
|
|
@ -169,12 +172,18 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
};
|
||||
|
||||
const handleSubmit = async (values: AddPluginFormValues) => {
|
||||
const selectedArchiveFile = archiveFile;
|
||||
if (!accessToken) {
|
||||
toast.error("No access token available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!urlPreview) {
|
||||
if (isHostedUpload && selectedArchiveFile === null) {
|
||||
toast.error("Please choose a .zip archive");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isHostedUpload && !urlPreview) {
|
||||
toast.error("Please enter a valid repository or zip archive URL");
|
||||
return;
|
||||
}
|
||||
|
|
@ -196,12 +205,30 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed));
|
||||
if (isHostedUpload) {
|
||||
const pluginName = values.name.trim();
|
||||
if (createdHostedPluginName !== pluginName) {
|
||||
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, { source: "hosted" }));
|
||||
setCreatedHostedPluginName(pluginName);
|
||||
onSuccess();
|
||||
}
|
||||
if (selectedArchiveFile === null) {
|
||||
return;
|
||||
}
|
||||
await uploadClaudeCodePluginArchive(accessToken, pluginName, selectedArchiveFile);
|
||||
} else if (urlPreview) {
|
||||
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed));
|
||||
}
|
||||
toast.success("Skill registered successfully");
|
||||
form.reset(EMPTY_VALUES);
|
||||
setUrlPreview(null);
|
||||
setSubPathLock(null);
|
||||
onSuccess();
|
||||
setIsHostedUpload(false);
|
||||
setArchiveFile(null);
|
||||
setCreatedHostedPluginName(null);
|
||||
if (!isHostedUpload) {
|
||||
onSuccess();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error registering skill:", error);
|
||||
|
|
@ -215,9 +242,14 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
form.reset(EMPTY_VALUES);
|
||||
setUrlPreview(null);
|
||||
setSubPathLock(null);
|
||||
setIsHostedUpload(false);
|
||||
setArchiveFile(null);
|
||||
setCreatedHostedPluginName(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submitButtonText = createdHostedPluginName ? "Retry Upload" : "Add Skill";
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[700px]">
|
||||
|
|
@ -227,71 +259,111 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate className="mt-4">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skillUrl"
|
||||
label={labelWithHint(
|
||||
"Source URL",
|
||||
"Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.",
|
||||
)}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="https://github.com/org/repo or https://bucket.s3.amazonaws.com/my-skill.zip"
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
recomputePreview(event.target.value, form.getValues("subPath"));
|
||||
}}
|
||||
<div role="group" aria-label="Plugin source" className="flex gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="plugin-source"
|
||||
checked={!isHostedUpload}
|
||||
disabled={createdHostedPluginName !== null}
|
||||
onChange={() => setIsHostedUpload(false)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subPath"
|
||||
label={labelWithHint(
|
||||
"Subfolder path (Optional)",
|
||||
"Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
|
||||
)}
|
||||
description={subPathLock ? SUB_PATH_LOCK_REASON[subPathLock] : undefined}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="plugins/my-skill"
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
recomputePreview(form.getValues("skillUrl"), event.target.value);
|
||||
}}
|
||||
disabled={subPathLock !== null}
|
||||
Use a URL
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="plugin-source"
|
||||
checked={isHostedUpload}
|
||||
disabled={createdHostedPluginName !== null}
|
||||
onChange={() => setIsHostedUpload(true)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
Upload a zip to LiteLLM
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{urlPreview?.parsed.source === "archive" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sha256"
|
||||
label={labelWithHint(
|
||||
"Archive SHA-256 (Optional)",
|
||||
"Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.",
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="64 hex characters" className="rounded-lg font-mono" />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{urlPreview && (
|
||||
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
|
||||
Detected: {urlPreview.label}
|
||||
{isHostedUpload ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="plugin-archive-file" className="text-sm font-medium">
|
||||
Plugin archive (.zip)
|
||||
</label>
|
||||
<Input
|
||||
id="plugin-archive-file"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
aria-label="Plugin archive (.zip)"
|
||||
onChange={(event) => setArchiveFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skillUrl"
|
||||
label={labelWithHint(
|
||||
"Source URL",
|
||||
"Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.",
|
||||
)}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="https://github.com/org/repo or https://bucket.s3.amazonaws.com/my-skill.zip"
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
recomputePreview(event.target.value, form.getValues("subPath"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subPath"
|
||||
label={labelWithHint(
|
||||
"Subfolder path (Optional)",
|
||||
"Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
|
||||
)}
|
||||
description={subPathLock ? SUB_PATH_LOCK_REASON[subPathLock] : undefined}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="plugins/my-skill"
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
recomputePreview(form.getValues("skillUrl"), event.target.value);
|
||||
}}
|
||||
disabled={subPathLock !== null}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{urlPreview?.parsed.source === "archive" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sha256"
|
||||
label={labelWithHint(
|
||||
"Archive SHA-256 (Optional)",
|
||||
"Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.",
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="64 hex characters" className="rounded-lg font-mono" />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{urlPreview && (
|
||||
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
|
||||
Detected: {urlPreview.label}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
|
|
@ -299,7 +371,15 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
name="name"
|
||||
label={labelWithHint("Skill Name", "Unique identifier in kebab-case format (e.g., my-skill)")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="my-skill" className="rounded-lg" />}
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="my-skill"
|
||||
className="rounded-lg"
|
||||
disabled={createdHostedPluginName !== null}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className="flex gap-4">
|
||||
|
|
@ -414,7 +494,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
|
||||
{isSubmitting && <UiLoadingSpinner className="size-4" />}
|
||||
{isSubmitting ? "Adding..." : "Add Skill"}
|
||||
{isSubmitting ? "Adding..." : submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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(<SkillDetail skill={buildSkill({ source: "hosted" })} onBack={vi.fn()} />);
|
||||
expect(screen.getByText("Hosted on LiteLLM")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">Source</div>
|
||||
<div className="break-all text-[13px] text-foreground">Hosted on LiteLLM</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const link = getSourceLink(source);
|
||||
const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link;
|
||||
if (href) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>(path, {
|
||||
accessToken,
|
||||
rawBody: file,
|
||||
headers: { "Content-Type": "application/zip" },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable a Claude Code plugin (admin only)
|
||||
* @param accessToken - Admin access token
|
||||
|
|
|
|||
132
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
132
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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': '<optional hex digest>'}
|
||||
* - 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': '<optional hex digest>'}
|
||||
* - 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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue