From a82467d679671c98348ecd4277907fa0bc3324a2 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 19 Jan 2026 14:05:47 -0800 Subject: [PATCH] [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 --- .../claude_code_endpoints/__init__.py | 11 + .../claude_code_marketplace.py | 533 ++++++++++++++++++ litellm/proxy/proxy_config.yaml | 4 +- litellm/proxy/proxy_server.py | 4 + litellm/proxy/schema.prisma | 17 + litellm/types/proxy/claude_code_endpoints.py | 116 ++++ .../test_claude_code_marketplace.py | 145 +++++ 7 files changed, 828 insertions(+), 2 deletions(-) create mode 100644 litellm/proxy/anthropic_endpoints/claude_code_endpoints/__init__.py create mode 100644 litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py create mode 100644 litellm/types/proxy/claude_code_endpoints.py create mode 100644 tests/pass_through_unit_tests/test_claude_code_marketplace.py 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 0000000000..0d1a5a2083 --- /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 0000000000..7c212020a3 --- /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/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index cf852805f8..646a062b72 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -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 diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f4e68c481a..c3a4de314e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 71b398c59a..22888f6d3a 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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") +} diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py new file mode 100644 index 0000000000..663b182b80 --- /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" + ) diff --git a/tests/pass_through_unit_tests/test_claude_code_marketplace.py b/tests/pass_through_unit_tests/test_claude_code_marketplace.py new file mode 100644 index 0000000000..b4ba30e9c7 --- /dev/null +++ b/tests/pass_through_unit_tests/test_claude_code_marketplace.py @@ -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} + )