mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-03 04:22:22 +00:00
[Feat] - Add self hosted Claude Code Plugin Marketplace (#19378)
* init schema * init endpoints * fix: claude_code_marketplace_router * refactor * fix: claude_code_marketplace_router * claude_code_marketplace_router
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Claude Code Endpoints
|
||||
|
||||
Provides endpoints for Claude Code plugin marketplace integration.
|
||||
"""
|
||||
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
router as claude_code_marketplace_router,
|
||||
)
|
||||
|
||||
__all__ = ["claude_code_marketplace_router"]
|
||||
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
CLAUDE CODE MARKETPLACE
|
||||
|
||||
Provides a registry/discovery layer for Claude Code plugins.
|
||||
Plugins are stored as metadata + git source references in LiteLLM database.
|
||||
Actual plugin files are hosted on GitHub/GitLab/Bitbucket.
|
||||
|
||||
Endpoints:
|
||||
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery
|
||||
/claude-code/plugins - POST - Register a plugin
|
||||
/claude-code/plugins - GET - List plugins (admin)
|
||||
/claude-code/plugins/{name} - GET - Get plugin details
|
||||
/claude-code/plugins/{name}/enable - POST - Enable a plugin
|
||||
/claude-code/plugins/{name}/disable - POST - Disable a plugin
|
||||
/claude-code/plugins/{name} - DELETE - Delete a plugin
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
ListPluginsResponse,
|
||||
PluginListItem,
|
||||
RegisterPluginRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _get_prisma_client():
|
||||
"""Get the prisma client from proxy_server."""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
return prisma_client
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claude-code/marketplace.json",
|
||||
tags=["Claude Code Marketplace"],
|
||||
)
|
||||
async def get_marketplace():
|
||||
"""
|
||||
Serve marketplace.json for Claude Code plugin discovery.
|
||||
|
||||
This endpoint is accessed by Claude Code CLI when users run:
|
||||
- claude plugin marketplace add <url>
|
||||
- claude plugin install <name>@<marketplace>
|
||||
|
||||
Returns:
|
||||
Marketplace catalog with list of available plugins and their git sources.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json
|
||||
claude plugin install my-plugin@litellm
|
||||
```
|
||||
"""
|
||||
try:
|
||||
prisma_client = await _get_prisma_client()
|
||||
|
||||
plugins = await prisma_client.db.litellm_claudecodeplugintable.find_many(
|
||||
where={"enabled": True}
|
||||
)
|
||||
|
||||
plugin_list = []
|
||||
for plugin in plugins:
|
||||
try:
|
||||
manifest = json.loads(plugin.manifest_json)
|
||||
except json.JSONDecodeError:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Plugin {plugin.name} has invalid manifest JSON, skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
# Source must be specified for URL-based marketplaces
|
||||
if "source" not in manifest:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Plugin {plugin.name} has no source field, skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"name": plugin.name,
|
||||
"source": manifest["source"],
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
marketplace = {
|
||||
"name": "litellm",
|
||||
"owner": {"name": "LiteLLM", "email": "support@litellm.ai"},
|
||||
"plugins": plugin_list,
|
||||
}
|
||||
|
||||
return JSONResponse(content=marketplace)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error generating marketplace: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": f"Failed to generate marketplace: {str(e)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claude-code/plugins",
|
||||
tags=["Claude Code Marketplace"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def register_plugin(
|
||||
request: RegisterPluginRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Register a plugin in the LiteLLM marketplace.
|
||||
|
||||
LiteLLM acts as a registry/discovery layer. Plugins are hosted on
|
||||
GitHub/GitLab/Bitbucket. Claude Code will clone from the git source
|
||||
when users install.
|
||||
|
||||
Parameters:
|
||||
- name: Plugin name (kebab-case)
|
||||
- source: Git source reference (github or url format)
|
||||
- version: Semantic version (optional)
|
||||
- description: Plugin description (optional)
|
||||
- author: Author information (optional)
|
||||
- homepage: Plugin homepage URL (optional)
|
||||
- keywords: Search keywords (optional)
|
||||
- category: Plugin category (optional)
|
||||
|
||||
Returns:
|
||||
Registration status and plugin information.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/claude-code/plugins \\
|
||||
-H "Authorization: Bearer sk-..." \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"name": "my-plugin",
|
||||
"source": {"source": "github", "repo": "org/my-plugin"},
|
||||
"version": "1.0.0",
|
||||
"description": "My awesome plugin"
|
||||
}'
|
||||
```
|
||||
"""
|
||||
try:
|
||||
prisma_client = await _get_prisma_client()
|
||||
|
||||
# Validate name format
|
||||
if not re.match(r"^[a-z0-9-]+$", request.name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Plugin name must be kebab-case (lowercase letters, numbers, hyphens)"
|
||||
},
|
||||
)
|
||||
|
||||
# Validate source format
|
||||
source = request.source
|
||||
source_type = source.get("source")
|
||||
|
||||
if source_type == "github":
|
||||
if "repo" not in source:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "GitHub source must include 'repo' field (e.g., 'org/repo')"
|
||||
},
|
||||
)
|
||||
elif source_type == "url":
|
||||
if "url" not in source:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "URL source must include 'url' field (e.g., 'https://github.com/org/repo.git')"
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "source.source must be 'github' or 'url'"},
|
||||
)
|
||||
|
||||
# Build manifest for storage
|
||||
manifest: Dict[str, Any] = {
|
||||
"name": request.name,
|
||||
"source": request.source,
|
||||
}
|
||||
if request.version:
|
||||
manifest["version"] = request.version
|
||||
if request.description:
|
||||
manifest["description"] = request.description
|
||||
if request.author:
|
||||
manifest["author"] = request.author.model_dump(exclude_none=True)
|
||||
if request.homepage:
|
||||
manifest["homepage"] = request.homepage
|
||||
if request.keywords:
|
||||
manifest["keywords"] = request.keywords
|
||||
if request.category:
|
||||
manifest["category"] = request.category
|
||||
|
||||
# Check if plugin exists
|
||||
existing = await prisma_client.db.litellm_claudecodeplugintable.find_unique(
|
||||
where={"name": request.name}
|
||||
)
|
||||
|
||||
if existing:
|
||||
plugin = await prisma_client.db.litellm_claudecodeplugintable.update(
|
||||
where={"name": request.name},
|
||||
data={
|
||||
"version": request.version,
|
||||
"description": request.description,
|
||||
"manifest_json": json.dumps(manifest),
|
||||
"files_json": "{}",
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
action = "updated"
|
||||
else:
|
||||
plugin = await prisma_client.db.litellm_claudecodeplugintable.create(
|
||||
data={
|
||||
"name": request.name,
|
||||
"version": request.version,
|
||||
"description": request.description,
|
||||
"manifest_json": json.dumps(manifest),
|
||||
"files_json": "{}",
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
"created_by": user_api_key_dict.user_id,
|
||||
}
|
||||
)
|
||||
action = "created"
|
||||
|
||||
verbose_proxy_logger.info(f"Plugin {request.name} {action} successfully")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"action": action,
|
||||
"plugin": {
|
||||
"id": plugin.id,
|
||||
"name": plugin.name,
|
||||
"version": plugin.version,
|
||||
"description": plugin.description,
|
||||
"source": request.source,
|
||||
"enabled": plugin.enabled,
|
||||
},
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error registering plugin: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": f"Registration failed: {str(e)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claude-code/plugins",
|
||||
tags=["Claude Code Marketplace"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=ListPluginsResponse,
|
||||
)
|
||||
async def list_plugins(
|
||||
enabled_only: bool = False,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
List all plugins in the marketplace.
|
||||
|
||||
Parameters:
|
||||
- enabled_only: If true, only return enabled plugins
|
||||
|
||||
Returns:
|
||||
List of plugins with their metadata.
|
||||
"""
|
||||
try:
|
||||
prisma_client = await _get_prisma_client()
|
||||
|
||||
where = {"enabled": True} if enabled_only else {}
|
||||
plugins = await prisma_client.db.litellm_claudecodeplugintable.find_many(
|
||||
where=where,
|
||||
order_by={"created_at": "desc"},
|
||||
)
|
||||
|
||||
return ListPluginsResponse(
|
||||
plugins=[
|
||||
PluginListItem(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
version=p.version,
|
||||
description=p.description,
|
||||
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,
|
||||
)
|
||||
for p in plugins
|
||||
],
|
||||
count=len(plugins),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error listing plugins: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claude-code/plugins/{plugin_name}",
|
||||
tags=["Claude Code Marketplace"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def get_plugin(
|
||||
plugin_name: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Get details of a specific plugin.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin
|
||||
|
||||
Returns:
|
||||
Plugin details including source and metadata.
|
||||
"""
|
||||
try:
|
||||
prisma_client = await _get_prisma_client()
|
||||
|
||||
plugin = await prisma_client.db.litellm_claudecodeplugintable.find_unique(
|
||||
where={"name": plugin_name}
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
)
|
||||
|
||||
manifest = json.loads(plugin.manifest_json) if plugin.manifest_json else {}
|
||||
|
||||
return {
|
||||
"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"),
|
||||
"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,
|
||||
"created_by": plugin.created_by,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error getting plugin: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claude-code/plugins/{plugin_name}/enable",
|
||||
tags=["Claude Code Marketplace"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def enable_plugin(
|
||||
plugin_name: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Enable a disabled plugin.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to enable
|
||||
"""
|
||||
try:
|
||||
prisma_client = await _get_prisma_client()
|
||||
|
||||
plugin = await prisma_client.db.litellm_claudecodeplugintable.find_unique(
|
||||
where={"name": plugin_name}
|
||||
)
|
||||
if not plugin:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
)
|
||||
|
||||
await prisma_client.db.litellm_claudecodeplugintable.update(
|
||||
where={"name": plugin_name},
|
||||
data={"enabled": True, "updated_at": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(f"Plugin {plugin_name} enabled")
|
||||
return {"status": "success", "message": f"Plugin '{plugin_name}' enabled"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error enabling plugin: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claude-code/plugins/{plugin_name}/disable",
|
||||
tags=["Claude Code Marketplace"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def disable_plugin(
|
||||
plugin_name: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Disable a plugin without deleting it.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to disable
|
||||
"""
|
||||
try:
|
||||
prisma_client = await _get_prisma_client()
|
||||
|
||||
plugin = await prisma_client.db.litellm_claudecodeplugintable.find_unique(
|
||||
where={"name": plugin_name}
|
||||
)
|
||||
if not plugin:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
)
|
||||
|
||||
await prisma_client.db.litellm_claudecodeplugintable.update(
|
||||
where={"name": plugin_name},
|
||||
data={"enabled": False, "updated_at": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(f"Plugin {plugin_name} disabled")
|
||||
return {"status": "success", "message": f"Plugin '{plugin_name}' disabled"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error disabling plugin: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/claude-code/plugins/{plugin_name}",
|
||||
tags=["Claude Code Marketplace"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def delete_plugin(
|
||||
plugin_name: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Delete a plugin from the marketplace.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to delete
|
||||
"""
|
||||
try:
|
||||
prisma_client = await _get_prisma_client()
|
||||
|
||||
plugin = await prisma_client.db.litellm_claudecodeplugintable.find_unique(
|
||||
where={"name": plugin_name}
|
||||
)
|
||||
if not plugin:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
)
|
||||
|
||||
await prisma_client.db.litellm_claudecodeplugintable.delete(
|
||||
where={"name": plugin_name}
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(f"Plugin {plugin_name} deleted")
|
||||
return {"status": "success", "message": f"Plugin '{plugin_name}' deleted"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error deleting plugin: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": str(e)},
|
||||
)
|
||||
@@ -2,7 +2,7 @@ model_list:
|
||||
- model_name: gemini/*
|
||||
litellm_params:
|
||||
model: gemini/*
|
||||
- model_name: claude-sonnet-4-5-20250929
|
||||
- model_name: -claude-sonnet-4-5-20250929
|
||||
litellm_params:
|
||||
model: bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
model_info:
|
||||
@@ -40,7 +40,7 @@ model_list:
|
||||
model_info:
|
||||
litellm_provider: bedrock_converse
|
||||
mode: chat
|
||||
- model_name: azure-claude-opus-4-5
|
||||
- model_name: claude-sonnet-4-5-20250929
|
||||
litellm_params:
|
||||
model: azure_ai/claude-opus-4-5
|
||||
api_base: https://krish-mh44t553-eastus2.services.ai.azure.com
|
||||
|
||||
@@ -207,6 +207,9 @@ from litellm.proxy.anthropic_endpoints.endpoints import router as anthropic_rout
|
||||
from litellm.proxy.anthropic_endpoints.skills_endpoints import (
|
||||
router as anthropic_skills_router,
|
||||
)
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import (
|
||||
claude_code_marketplace_router,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
ExperimentalUIJWTToken,
|
||||
get_team_object,
|
||||
@@ -10499,6 +10502,7 @@ app.include_router(llm_passthrough_router)
|
||||
app.include_router(mcp_management_router)
|
||||
app.include_router(anthropic_router)
|
||||
app.include_router(anthropic_skills_router)
|
||||
app.include_router(claude_code_marketplace_router)
|
||||
app.include_router(google_router)
|
||||
app.include_router(langfuse_router)
|
||||
app.include_router(pass_through_router)
|
||||
|
||||
@@ -863,3 +863,20 @@ model LiteLLM_SkillsTable {
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String?
|
||||
}
|
||||
|
||||
// Claude Code Marketplace - stores plugins for Claude Code integration
|
||||
model LiteLLM_ClaudeCodePluginTable {
|
||||
id String @id @default(uuid())
|
||||
name String @unique // Plugin name (kebab-case)
|
||||
version String? // Semantic version
|
||||
description String? // Plugin description
|
||||
manifest_json String // Full plugin.json as JSON string
|
||||
files_json String // All files as JSON: {"path": "content"}
|
||||
enabled Boolean @default(true)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
created_by String?
|
||||
|
||||
@@index([name])
|
||||
@@map("litellm_claudecodeplugin")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Claude Code Marketplace endpoint types for LiteLLM Proxy
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PluginAuthor(BaseModel):
|
||||
"""Plugin author information."""
|
||||
|
||||
name: str = Field(..., description="Author name")
|
||||
email: Optional[str] = Field(None, description="Author email")
|
||||
|
||||
|
||||
class PluginOwner(BaseModel):
|
||||
"""Marketplace owner information."""
|
||||
|
||||
name: str = Field(..., description="Owner name")
|
||||
email: Optional[str] = Field(None, description="Owner email")
|
||||
|
||||
|
||||
class RegisterPluginRequest(BaseModel):
|
||||
"""
|
||||
Request body for registering a plugin in the marketplace.
|
||||
|
||||
LiteLLM acts as a registry/discovery layer. Plugins are hosted on
|
||||
GitHub/GitLab/Bitbucket and referenced by their git source.
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Plugin name (kebab-case, e.g., 'my-plugin')",
|
||||
pattern=r"^[a-z0-9-]+$",
|
||||
)
|
||||
source: Dict[str, str] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Git source reference. Supported formats:\n"
|
||||
"- GitHub: {'source': 'github', 'repo': 'org/repo'}\n"
|
||||
"- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}"
|
||||
),
|
||||
)
|
||||
version: Optional[str] = Field("1.0.0", description="Semantic version")
|
||||
description: Optional[str] = Field(None, description="Plugin description")
|
||||
author: Optional[PluginAuthor] = Field(None, description="Plugin author")
|
||||
homepage: Optional[str] = Field(None, description="Plugin homepage URL")
|
||||
keywords: Optional[List[str]] = Field(None, description="Search keywords")
|
||||
category: Optional[str] = Field(None, description="Plugin category")
|
||||
|
||||
|
||||
class PluginResponse(BaseModel):
|
||||
"""Plugin information in API responses."""
|
||||
|
||||
id: str = Field(..., description="Plugin unique ID")
|
||||
name: str = Field(..., description="Plugin name")
|
||||
version: Optional[str] = Field(None, description="Plugin version")
|
||||
description: Optional[str] = Field(None, description="Plugin description")
|
||||
source: Dict[str, str] = Field(..., description="Git source reference")
|
||||
enabled: bool = Field(..., description="Whether plugin is enabled")
|
||||
|
||||
|
||||
class RegisterPluginResponse(BaseModel):
|
||||
"""Response from plugin registration."""
|
||||
|
||||
status: str = Field(..., description="Operation status")
|
||||
action: str = Field(..., description="Action taken (created/updated)")
|
||||
plugin: PluginResponse = Field(..., description="Plugin information")
|
||||
|
||||
|
||||
class PluginListItem(BaseModel):
|
||||
"""Plugin item in list responses."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
version: Optional[str]
|
||||
description: Optional[str]
|
||||
enabled: bool
|
||||
created_at: Optional[str]
|
||||
updated_at: Optional[str]
|
||||
|
||||
|
||||
class ListPluginsResponse(BaseModel):
|
||||
"""Response from listing plugins."""
|
||||
|
||||
plugins: List[PluginListItem]
|
||||
count: int
|
||||
|
||||
|
||||
class MarketplacePluginEntry(BaseModel):
|
||||
"""Plugin entry in marketplace.json."""
|
||||
|
||||
name: str
|
||||
source: Dict[str, str]
|
||||
version: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
author: Optional[PluginAuthor] = None
|
||||
homepage: Optional[str] = None
|
||||
keywords: Optional[List[str]] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class MarketplaceResponse(BaseModel):
|
||||
"""
|
||||
Marketplace catalog response.
|
||||
|
||||
This format is consumed by Claude Code CLI.
|
||||
See: https://docs.anthropic.com/en/docs/claude-code/plugins
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="Marketplace identifier")
|
||||
owner: PluginOwner = Field(..., description="Marketplace owner")
|
||||
plugins: List[MarketplacePluginEntry] = Field(
|
||||
default_factory=list, description="Available plugins"
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Tests for Claude Code Marketplace endpoints.
|
||||
|
||||
Tests:
|
||||
1. Register a plugin
|
||||
2. Get marketplace.json (list enabled plugins)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import LitellmUserRoles
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest
|
||||
|
||||
# Import the functions we're testing
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
register_plugin,
|
||||
get_marketplace,
|
||||
)
|
||||
|
||||
proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prisma_client():
|
||||
from litellm.proxy.proxy_cli import append_query_params
|
||||
|
||||
params = {"connection_limit": 100, "pool_timeout": 60}
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
modified_url = append_query_params(database_url, params)
|
||||
os.environ["DATABASE_URL"] = modified_url
|
||||
|
||||
prisma_client = PrismaClient(
|
||||
database_url=os.environ["DATABASE_URL"], proxy_logging_obj=proxy_logging_obj
|
||||
)
|
||||
|
||||
litellm.proxy.proxy_server.litellm_proxy_budget_name = (
|
||||
f"litellm-proxy-budget-{time.time()}"
|
||||
)
|
||||
|
||||
return prisma_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_plugin(prisma_client):
|
||||
"""Test registering a plugin in the marketplace."""
|
||||
setattr(litellm.proxy.proxy_server, "prisma_client", prisma_client)
|
||||
setattr(litellm.proxy.proxy_server, "master_key", "sk-1234")
|
||||
|
||||
await litellm.proxy.proxy_server.prisma_client.connect()
|
||||
|
||||
# Create a unique plugin name for this test
|
||||
plugin_name = f"test-plugin-{int(time.time())}"
|
||||
|
||||
request = RegisterPluginRequest(
|
||||
name=plugin_name,
|
||||
source={"source": "github", "repo": "test-org/test-repo"},
|
||||
version="1.0.0",
|
||||
description="Test plugin for unit tests",
|
||||
)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-1234",
|
||||
user_id="test-user",
|
||||
)
|
||||
|
||||
response = await register_plugin(
|
||||
request=request,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
assert response["status"] == "success"
|
||||
assert response["action"] == "created"
|
||||
assert response["plugin"]["name"] == plugin_name
|
||||
assert response["plugin"]["version"] == "1.0.0"
|
||||
assert response["plugin"]["enabled"] is True
|
||||
|
||||
# Cleanup - delete the plugin
|
||||
await prisma_client.db.litellm_claudecodeplugintable.delete(
|
||||
where={"name": plugin_name}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_marketplace(prisma_client):
|
||||
"""Test getting marketplace.json with registered plugins."""
|
||||
setattr(litellm.proxy.proxy_server, "prisma_client", prisma_client)
|
||||
setattr(litellm.proxy.proxy_server, "master_key", "sk-1234")
|
||||
|
||||
await litellm.proxy.proxy_server.prisma_client.connect()
|
||||
|
||||
# First register a plugin
|
||||
plugin_name = f"test-marketplace-plugin-{int(time.time())}"
|
||||
|
||||
request = RegisterPluginRequest(
|
||||
name=plugin_name,
|
||||
source={"source": "github", "repo": "test-org/marketplace-test"},
|
||||
version="2.0.0",
|
||||
description="Test plugin for marketplace test",
|
||||
)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-1234",
|
||||
user_id="test-user",
|
||||
)
|
||||
|
||||
await register_plugin(
|
||||
request=request,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
# Now get the marketplace
|
||||
response = await get_marketplace()
|
||||
|
||||
# Response is a JSONResponse, get the body
|
||||
import json
|
||||
body = json.loads(response.body.decode())
|
||||
|
||||
assert body["name"] == "litellm"
|
||||
assert "plugins" in body
|
||||
|
||||
# Find our plugin in the list
|
||||
our_plugin = next(
|
||||
(p for p in body["plugins"] if p["name"] == plugin_name),
|
||||
None
|
||||
)
|
||||
assert our_plugin is not None
|
||||
assert our_plugin["source"] == {"source": "github", "repo": "test-org/marketplace-test"}
|
||||
assert our_plugin["version"] == "2.0.0"
|
||||
|
||||
# Cleanup
|
||||
await prisma_client.db.litellm_claudecodeplugintable.delete(
|
||||
where={"name": plugin_name}
|
||||
)
|
||||
Reference in New Issue
Block a user