diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/__init__.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/__init__.py new file mode 100644 index 00000000000..0d1a5a20836 --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/__init__.py @@ -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"] diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py new file mode 100644 index 00000000000..7c212020a3d --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -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 + - claude plugin install @ + + 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)}, + ) diff --git a/litellm/proxy/anthropic_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_marketplace.py deleted file mode 100644 index 7830332345d..00000000000 --- a/litellm/proxy/anthropic_endpoints/claude_code_marketplace.py +++ /dev/null @@ -1,440 +0,0 @@ -""" -Claude Code Marketplace Endpoints - -Provides a marketplace for Claude Code plugins stored in LiteLLM database. -Enables multi-instance compatible plugin distribution. - -Endpoints: -- GET /claude-code/marketplace.json - List all plugins for Claude Code discovery -- GET /claude-code/plugins/{name}/{file} - Serve plugin files -- POST /claude-code/plugins/upload - Upload a plugin (ZIP) -- GET /claude-code/plugins - List plugins (admin) -- POST /claude-code/plugins/{name}/enable - Enable a plugin -- POST /claude-code/plugins/{name}/disable - Disable a plugin -- DELETE /claude-code/plugins/{name} - Delete a plugin -""" - -import io -import json -import zipfile -from datetime import datetime, timezone - -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from fastapi.responses import JSONResponse, Response - -from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import UserAPIKeyAuth -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth - -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="Database not connected. Please set DATABASE_URL." - ) - 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 marketplace add - - claude plugin install @ - - Example: - claude marketplace add litellm http://localhost:4000 - claude plugin install hello-world@litellm - """ - try: - prisma_client = await get_prisma_client() - - # Get all enabled plugins from database - plugins = await prisma_client.db.litellm_claudecodeplugintable.find_many( - where={"enabled": True} - ) - - marketplace = [] - for plugin in plugins: - # Parse manifest - try: - manifest = json.loads(plugin.manifest_json) - except json.JSONDecodeError: - continue - - entry = { - "name": plugin.name, - "source": f"./plugins/{plugin.name}", - } - - # Add fields from manifest/plugin - 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"] - - marketplace.append(entry) - - 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=str(e)) - - -@router.get( - "/claude-code/plugins/{plugin_name}/{file_path:path}", - tags=["Claude Code Marketplace"], -) -async def get_plugin_file(plugin_name: str, file_path: str): - """ - Serve plugin files from database. - - Claude Code will request files like: - - /claude-code/plugins/my-plugin/.claude-plugin/plugin.json - - /claude-code/plugins/my-plugin/commands/hello.md - """ - try: - prisma_client = await get_prisma_client() - - # Get plugin from database - plugin = await prisma_client.db.litellm_claudecodeplugintable.find_unique( - where={"name": plugin_name} - ) - - if not plugin or not plugin.enabled: - raise HTTPException(status_code=404, detail="Plugin not found") - - # Parse files JSON - try: - files = json.loads(plugin.files_json) - except json.JSONDecodeError: - raise HTTPException(status_code=500, detail="Invalid plugin files data") - - # Get requested file - if file_path not in files: - raise HTTPException(status_code=404, detail=f"File not found: {file_path}") - - content = files[file_path] - - # Determine media type - media_type = "text/plain" - if file_path.endswith(".json"): - media_type = "application/json" - elif file_path.endswith(".md"): - media_type = "text/markdown" - elif file_path.endswith(".sh"): - media_type = "application/x-sh" - elif file_path.endswith(".py"): - media_type = "text/x-python" - elif file_path.endswith(".js"): - media_type = "text/javascript" - - return Response( - content=content, - media_type=media_type, - headers={ - "Content-Disposition": f'inline; filename="{file_path.split("/")[-1]}"' - } - ) - - except HTTPException: - raise - except Exception as e: - verbose_proxy_logger.exception(f"Error serving plugin file: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post( - "/claude-code/plugins/upload", - tags=["Claude Code Marketplace"], - dependencies=[Depends(user_api_key_auth)], -) -async def upload_plugin( - file: UploadFile = File(...), - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): - """ - Upload a Claude Code plugin as a ZIP file. - - The ZIP should contain: - - .claude-plugin/plugin.json (required) - - commands/*.md (optional) - - agents/*.md (optional) - - skills/*/SKILL.md (optional) - - hooks/hooks.json (optional) - - Example: - curl -X POST \\ - -H "Authorization: Bearer sk-..." \\ - -F "file=@my-plugin.zip" \\ - http://localhost:4000/claude-code/plugins/upload - """ - if not file.filename or not file.filename.endswith('.zip'): - raise HTTPException(status_code=400, detail="File must be a ZIP archive") - - try: - prisma_client = await get_prisma_client() - - # Read ZIP - content = await file.read() - zip_buffer = io.BytesIO(content) - - files = {} - with zipfile.ZipFile(zip_buffer, 'r') as zip_ref: - for file_info in zip_ref.filelist: - if not file_info.is_dir(): - file_path = file_info.filename - # Security: block path traversal - if ".." in file_path or file_path.startswith("/"): - raise HTTPException( - status_code=400, - detail=f"Invalid path in ZIP: {file_path}" - ) - file_content = zip_ref.read(file_info).decode('utf-8', errors='ignore') - files[file_path] = file_content - - # Validate manifest exists - manifest_path = ".claude-plugin/plugin.json" - if manifest_path not in files: - raise HTTPException( - status_code=400, - detail="ZIP must contain .claude-plugin/plugin.json" - ) - - # Parse manifest - try: - manifest = json.loads(files[manifest_path]) - except json.JSONDecodeError as e: - raise HTTPException( - status_code=400, - detail=f"Invalid plugin.json: {e}" - ) - - # Validate required fields - if "name" not in manifest: - raise HTTPException( - status_code=400, - detail="plugin.json must contain 'name' field" - ) - - plugin_name = manifest["name"] - plugin_version = manifest.get("version", "1.0.0") - plugin_description = manifest.get("description") - - # Validate name format (kebab-case) - import re - if not re.match(r"^[a-z0-9-]+$", plugin_name): - raise HTTPException( - status_code=400, - detail="Plugin name must be kebab-case (lowercase letters, numbers, hyphens)" - ) - - # Check if plugin exists - existing = await prisma_client.db.litellm_claudecodeplugintable.find_unique( - where={"name": plugin_name} - ) - - if existing: - # Update existing plugin - plugin = await prisma_client.db.litellm_claudecodeplugintable.update( - where={"name": plugin_name}, - data={ - "version": plugin_version, - "description": plugin_description, - "manifest_json": json.dumps(manifest), - "files_json": json.dumps(files), - "updated_at": datetime.now(timezone.utc) - } - ) - action = "updated" - else: - # Create new plugin - plugin = await prisma_client.db.litellm_claudecodeplugintable.create( - data={ - "name": plugin_name, - "version": plugin_version, - "description": plugin_description, - "manifest_json": json.dumps(manifest), - "files_json": json.dumps(files), - "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 {plugin_name} {action} successfully") - - return { - "status": "success", - "action": action, - "plugin_id": plugin.id, - "plugin_name": plugin.name, - "version": plugin.version, - "files_count": len(files) - } - - except HTTPException: - raise - except Exception as e: - verbose_proxy_logger.exception(f"Error uploading plugin: {e}") - raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") - - -@router.get( - "/claude-code/plugins", - tags=["Claude Code Marketplace"], - dependencies=[Depends(user_api_key_auth)], -) -async def list_plugins( - enabled_only: bool = False, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): - """List all plugins in the marketplace""" - 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 { - "plugins": [ - { - "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=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""" - 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="Plugin 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=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""" - 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="Plugin 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=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""" - 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="Plugin 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=str(e)) diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py new file mode 100644 index 00000000000..663b182b805 --- /dev/null +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -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" + )