From 0a421148470d3aa108874177bbe97aa34d45bc8b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 4 Aug 2026 18:44:56 -0700 Subject: [PATCH] fix(claude-code): create-only skill registration with a PUT update route (LIT-4110) (#31752) * fix(claude-code): make skill registration create-only with a PUT update route POST /claude-code/plugins upserted by name, so re-registering an existing name silently overwrote the stored skill's source and metadata. The "Add New Skill" UI button posts here, so a name collision clobbered a different skill with no signal to the user. Make POST create-only: it returns 409 if the name already exists, with a unique-violation guard mapping the find-then-create race to the same 409. Add an explicit PUT /claude-code/plugins/{plugin_name} for updates (404 if the name is missing). PUT is a full replace and documents that omitted fields reset to their defaults, so UpdatePluginRequest defaults version to None instead of fabricating the create-time 1.0.0. The shared mutable fields move to a PluginSpec base; RegisterPluginRequest keeps its name and its generated schema unchanged, UpdatePluginRequest carries no name. Regenerated the dashboard types and the lazy openapi snapshot for the new route. Resolves LIT-4110 * fix(ui): surface the proxy error detail so the skill 409 conflict is legible The add-skill form rendered the raw HTTPException envelope on failure because deriveErrorMessage did not unwrap an object-shaped detail ({"detail": {"error": ...}}), so the new create-only 409 reached the user as a JSON blob. Unwrap object-shaped detail at the client layer, which covers every handler that returns detail={"error": ...}, and surface the resulting message verbatim on the form instead of burying it under a generic prefix. * refactor(claude-code): replace blind excepts in plugin mutations with typed handling Narrow register_plugin's create-conflict guard from a broad 'except Exception' + isinstance dance to a direct 'except UniqueViolationError', using an Exception subclass sentinel (not None) as the prisma-absent fallback so the sentinel can be caught directly. Drop update_plugin's outer 'except Exception -> 500' wrapper so HTTPExceptions propagate on their own and unexpected DB errors surface as FastAPI's default 500 rather than echoing str(e). Keeps the BLE001 strict-rule budget green. * fix(claude-code): restore structured 500 handling on update_plugin via typed PrismaError catch Flattening update_plugin to satisfy the no-blind-except rule dropped its error wrapper entirely, so a data-layer failure (e.g. a dropped DB connection) would skip the intentional verbose_proxy_logger.exception call and degrade the response from the endpoint's structured {"error": ...} body to FastAPI's default {"detail": "Internal Server Error"}, inconsistent with every sibling route. Wrap update_plugin in 'except PrismaError' instead of the blind 'except Exception' the other routes use: it logs and returns the structured 500 for real DB failures while letting genuine code bugs surface rather than masking them as 'Update failed', and stays off the BLE001 budget. Add a regression test that a PrismaError during the update maps to a structured 500. * fix(claude-code): import prisma error types at function level to satisfy LIT009 * refactor(claude-code): typed plugin mutation responses and lint gate fixes Return RegisterPluginResponse models from POST and PUT instead of ad-hoc dicts, declare them as response_model so the OpenAPI schema and dashboard types carry the real response shape, build the stored manifest via model_dump, and drop update_plugin's unused auth parameter (the route dependency already enforces auth). Keeps the LIT002/B008/UP045 budgets at their ratcheted ceilings after merging litellm_internal_staging --- litellm/proxy/_lazy_openapi_snapshot.json | 275 +++++++++++++++++- .../claude_code_marketplace.py | 204 +++++++++---- litellm/types/proxy/claude_code_endpoints.py | 42 ++- .../test_claude_code_marketplace.py | 24 +- .../test_claude_code_marketplace.py | 147 ++++++++-- .../_components/add_plugin_form.test.tsx | 15 + .../skills/_components/add_plugin_form.tsx | 3 +- .../src/components/networking.tsx | 3 +- .../src/lib/http/client.test.ts | 12 + ui/litellm-dashboard/src/lib/http/client.ts | 1 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 201 ++++++++++++- 11 files changed, 813 insertions(+), 114 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 12da0a26708..7fe02c6d8bc 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -4525,6 +4525,66 @@ "title": "PluginListItem", "type": "object" }, + "PluginResponse": { + "description": "Plugin information in API responses.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plugin description", + "title": "Description" + }, + "enabled": { + "description": "Whether plugin is enabled", + "title": "Enabled", + "type": "boolean" + }, + "id": { + "description": "Plugin unique ID", + "title": "Id", + "type": "string" + }, + "name": { + "description": "Plugin name", + "title": "Name", + "type": "string" + }, + "source": { + "additionalProperties": { + "type": "string" + }, + "description": "Git source reference", + "title": "Source", + "type": "object" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plugin version", + "title": "Version" + } + }, + "required": [ + "id", + "name", + "source", + "enabled" + ], + "title": "PluginResponse", + "type": "object" + }, "RegisterPluginRequest": { "description": "Request body for registering a plugin in the marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket and referenced by their git source.", "properties": { @@ -4643,14 +4703,163 @@ } }, "required": [ - "name", - "source" + "source", + "name" ], "title": "RegisterPluginRequest", "type": "object" }, + "RegisterPluginResponse": { + "description": "Response from plugin registration.", + "properties": { + "action": { + "description": "Action taken (created/updated)", + "title": "Action", + "type": "string" + }, + "plugin": { + "$ref": "#/components/schemas/PluginResponse", + "description": "Plugin information" + }, + "status": { + "description": "Operation status", + "title": "Status", + "type": "string" + } + }, + "required": [ + "status", + "action", + "plugin" + ], + "title": "RegisterPluginResponse", + "type": "object" + }, + "UpdatePluginRequest": { + "description": "Request body for replacing an existing plugin.\n\nThe plugin name is the resource identity and is supplied as the path\nparameter, so it cannot be changed here. This is a full replace: omitted\nfields reset to their defaults, so version is cleared rather than\ndefaulting to the create-time \"1.0.0\".", + "properties": { + "author": { + "anyOf": [ + { + "$ref": "#/components/schemas/PluginAuthor" + }, + { + "type": "null" + } + ], + "description": "Plugin author" + }, + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plugin category", + "title": "Category" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plugin description", + "title": "Description" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skill domain (e.g., 'Productivity')", + "title": "Domain" + }, + "homepage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plugin homepage URL", + "title": "Homepage" + }, + "keywords": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Search keywords", + "title": "Keywords" + }, + "namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skill namespace within domain (e.g., 'workflows')", + "title": "Namespace" + }, + "source": { + "additionalProperties": { + "type": "string" + }, + "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'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}", + "title": "Source", + "type": "object" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Semantic version; cleared if omitted", + "title": "Version" + } + }, + "required": [ + "source" + ], + "title": "UpdatePluginRequest", + "type": "object" + }, "ValidationError": { "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, "loc": { "items": { "anyOf": [ @@ -4754,7 +4963,7 @@ ] }, "post": { - "description": "Register a plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", "operationId": "register_plugin_claude_code_plugins_post", "requestBody": { "content": { @@ -4770,7 +4979,9 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/RegisterPluginResponse" + } } }, "description": "Successful Response" @@ -4885,6 +5096,62 @@ "tags": [ "claude_code_marketplace" ] + }, + "put": { + "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "operationId": "update_plugin_claude_code_plugins__plugin_name__put", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePluginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterPluginResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Update Plugin", + "tags": [ + "claude_code_marketplace" + ] } }, "/claude-code/plugins/{plugin_name}/disable": { diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index 11e5169bf30..579c735b180 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -7,9 +7,10 @@ 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 - POST - Register a new plugin (create-only) /claude-code/plugins - GET - List plugins (admin) /claude-code/plugins/{name} - GET - Get plugin details +/claude-code/plugins/{name} - PUT - Update an existing plugin /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 @@ -30,7 +31,11 @@ from litellm.repositories.table_repositories import ClaudeCodePluginRepository from litellm.types.proxy.claude_code_endpoints import ( ListPluginsResponse, PluginListItem, + PluginResponse, + PluginSpec, RegisterPluginRequest, + RegisterPluginResponse, + UpdatePluginRequest, ) router: Final = APIRouter() @@ -174,22 +179,43 @@ def _validate_plugin_source(source: dict[str, Any]) -> None: ) +def _build_plugin_manifest(name: str, spec: PluginSpec) -> dict[str, Any]: + """Build the stored manifest dict shared by plugin create and update.""" + dumped = spec.model_dump(exclude_none=True) + return {"name": name, **{key: value for key, value in dumped.items() if value and key != "name"}} + + +def _error_response(status_code: int, message: str) -> HTTPException: + return HTTPException(status_code=status_code, detail={"error": message}) + + +def _name_conflict_error(name: str) -> HTTPException: + return _error_response( + 409, f"A skill named '{name}' already exists. Update the existing skill instead of adding it again." + ) + + @router.post( "/claude-code/plugins", tags=["Claude Code Marketplace"], dependencies=[Depends(user_api_key_auth)], + response_model=RegisterPluginResponse, ) async def register_plugin( request: RegisterPluginRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Register a plugin in the LiteLLM marketplace. + Register a new 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. + This endpoint is create-only and never overwrites. If a plugin with + the same name already exists it returns 409 Conflict; use + PUT /claude-code/plugins/{plugin_name} to update an existing plugin. + Parameters: - name: Plugin name (kebab-case) - source: Git source reference (github, url, or git-subdir format) @@ -201,7 +227,7 @@ async def register_plugin( - category: Plugin category (optional) Returns: - Registration status and plugin information. + Registration status (action is always "created") and plugin information. Example: ```bash @@ -216,58 +242,26 @@ async def register_plugin( }' ``` """ + from prisma.errors import UniqueViolationError + try: prisma_client: Final = 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: Final = request.source - _validate_plugin_source(source) + _validate_plugin_source(request.source) - # Build manifest for storage - manifest: Final[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 - if request.domain: - manifest["domain"] = request.domain - if request.namespace: - manifest["namespace"] = request.namespace - - # Check if plugin exists existing = await ClaudeCodePluginRepository(prisma_client).table.find_unique(where={"name": request.name}) - if existing: - plugin = await ClaudeCodePluginRepository(prisma_client).table.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: + raise _name_conflict_error(request.name) + + manifest = _build_plugin_manifest(request.name, request) + + try: plugin = await ClaudeCodePluginRepository(prisma_client).table.create( data={ "name": request.name, @@ -281,22 +275,23 @@ async def register_plugin( "created_by": user_api_key_dict.user_id, } ) - action = "created" + except UniqueViolationError: + raise _name_conflict_error(request.name) - verbose_proxy_logger.info("Plugin %s %s successfully", request.name, action) + verbose_proxy_logger.info("Plugin %s created successfully", request.name) - return { - "status": "success", - "action": action, - "plugin": { - "id": plugin.id, - "name": plugin.name, - "version": plugin.version, - "description": plugin.description, - "source": request.source, - "enabled": plugin.enabled, - }, - } + return RegisterPluginResponse( + status="success", + action="created", + plugin=PluginResponse( + id=plugin.id, + name=plugin.name, + version=plugin.version, + description=plugin.description, + source=request.source, + enabled=plugin.enabled, + ), + ) except HTTPException: raise @@ -432,6 +427,101 @@ async def get_plugin( ) +@router.put( + "/claude-code/plugins/{plugin_name}", + tags=["Claude Code Marketplace"], + dependencies=[Depends(user_api_key_auth)], + response_model=RegisterPluginResponse, +) +async def update_plugin( + plugin_name: str, + request: UpdatePluginRequest, +): + """ + Update an existing plugin in the LiteLLM marketplace. + + The plugin is identified by its name in the path, which is the resource + identity and cannot be changed here. This is a full replace, not a merge: + the manifest is rebuilt from the request body, so any optional field left + out is reset to its default (e.g. an omitted version is cleared, not kept). + Send the full desired state. + + Returns 404 if no plugin with the given name exists; use + POST /claude-code/plugins to create a new plugin. + + Parameters: + - plugin_name: Name of the plugin to update (path parameter) + - source: Git source reference (github, url, or git-subdir 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: + Update status (action is always "updated") and plugin information. + + Example: + ```bash + curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\ + -H "Authorization: Bearer sk-..." \\ + -H "Content-Type: application/json" \\ + -d '{ + "source": {"source": "github", "repo": "org/my-plugin"}, + "version": "2.0.0", + "description": "My awesome plugin" + }' + ``` + """ + from prisma.errors import PrismaError + + try: + prisma_client = await _get_prisma_client() + + _validate_plugin_source(request.source) + + existing = await ClaudeCodePluginRepository(prisma_client).table.find_unique( + where={"name": plugin_name} # mutable-ok: prisma query arguments must be plain dicts + ) + if not existing: + raise _error_response(404, f"Plugin '{plugin_name}' not found") + + manifest = _build_plugin_manifest(plugin_name, request) + + plugin = await ClaudeCodePluginRepository(prisma_client).table.update( + where={"name": plugin_name}, # mutable-ok: prisma query arguments must be plain dicts + data={ # mutable-ok: prisma query arguments must be plain dicts + "version": request.version, + "description": request.description, + "manifest_json": json.dumps(manifest), + "files_json": "{}", + "updated_at": datetime.now(timezone.utc), + }, + ) + + verbose_proxy_logger.info("Plugin %s updated successfully", plugin_name) + + return RegisterPluginResponse( + status="success", + action="updated", + plugin=PluginResponse( + id=plugin.id, + name=plugin.name, + version=plugin.version, + description=plugin.description, + source=request.source, + enabled=plugin.enabled, + ), + ) + + except HTTPException: + raise + except PrismaError as e: + verbose_proxy_logger.exception("Error updating plugin: %s", e) + raise _error_response(500, f"Update failed: {e}") + + @router.post( "/claude-code/plugins/{plugin_name}/enable", tags=["Claude Code Marketplace"], diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 47dd40df694..af15e205d42 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -21,19 +21,9 @@ class PluginOwner(BaseModel): email: Optional[str] = Field(None, description="Owner email") -class RegisterPluginRequest(BaseModel): - """ - Request body for registering a plugin in the marketplace. +class PluginSpec(BaseModel): + """Mutable fields shared by plugin create and update requests.""" - 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=( @@ -53,6 +43,34 @@ class RegisterPluginRequest(BaseModel): namespace: Optional[str] = Field(None, description="Skill namespace within domain (e.g., 'workflows')") +class RegisterPluginRequest(PluginSpec): + """ + 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-]+$", + ) + + +class UpdatePluginRequest(PluginSpec): + """ + Request body for replacing an existing plugin. + + The plugin name is the resource identity and is supplied as the path + parameter, so it cannot be changed here. This is a full replace: omitted + fields reset to their defaults, so version is cleared rather than + defaulting to the create-time "1.0.0". + """ + + version: str | None = Field(None, description="Semantic version; cleared if omitted") + + class PluginResponse(BaseModel): """Plugin information in API responses.""" diff --git a/tests/pass_through_unit_tests/test_claude_code_marketplace.py b/tests/pass_through_unit_tests/test_claude_code_marketplace.py index 2f51394ae68..1a225b44b50 100644 --- a/tests/pass_through_unit_tests/test_claude_code_marketplace.py +++ b/tests/pass_through_unit_tests/test_claude_code_marketplace.py @@ -168,11 +168,11 @@ async def test_register_plugin(mock_prisma_client): 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 + 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 # Verify the plugin was stored in the mock stored_plugin = ( @@ -274,16 +274,16 @@ async def test_register_plugin_git_subdir(mock_prisma_client): 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"]["source"]["source"] == "git-subdir" + assert response.status == "success" + assert response.action == "created" + assert response.plugin.name == plugin_name + assert response.plugin.source["source"] == "git-subdir" assert ( - response["plugin"]["source"]["url"] + response.plugin.source["url"] == "https://github.com/test-org/monorepo.git" ) - assert response["plugin"]["source"]["path"] == "plugins/my-plugin" - assert response["plugin"]["enabled"] is True + assert response.plugin.source["path"] == "plugins/my-plugin" + assert response.plugin.enabled is True # Cleanup await mock_prisma_client.db.litellm_claudecodeplugintable.delete( diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 1bdba166120..f94cd471a01 100644 --- a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -4,6 +4,8 @@ Unit tests for claude_code_marketplace.py source validation. Covers the git-subdir source type added alongside the existing github and url types. """ +import json + import pytest from fastapi import HTTPException from unittest.mock import AsyncMock, MagicMock @@ -11,9 +13,13 @@ from unittest.mock import AsyncMock, MagicMock import litellm from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.proxy_server import LitellmUserRoles -from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest +from litellm.types.proxy.claude_code_endpoints import ( + RegisterPluginRequest, + UpdatePluginRequest, +) from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( register_plugin, + update_plugin, ) @@ -68,42 +74,141 @@ _GIT_SUBDIR_SOURCE = { @pytest.fixture(autouse=True) def _patch_proxy_globals(monkeypatch): """Scope prisma_client/master_key mutations to each test via monkeypatch.""" - monkeypatch.setattr( - litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma() - ) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") @pytest.mark.asyncio async def test_register_plugin_git_subdir_success(): """git-subdir with both url and path fields registers successfully.""" - request = RegisterPluginRequest( - name="my-monorepo-plugin", source=_GIT_SUBDIR_SOURCE - ) + request = RegisterPluginRequest(name="my-monorepo-plugin", source=_GIT_SUBDIR_SOURCE) response = await register_plugin(request=request, user_api_key_dict=_USER) - assert response["status"] == "success" - assert response["action"] == "created" - assert response["plugin"]["source"]["source"] == "git-subdir" - assert response["plugin"]["source"]["path"] == "plugins/my-plugin" + assert response.status == "success" + assert response.action == "created" + assert response.plugin.source["source"] == "git-subdir" + assert response.plugin.source["path"] == "plugins/my-plugin" + + +async def _read_stored_manifest(name: str) -> dict: + table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable + record = await table.find_unique(where={"name": name}) + return json.loads(record.manifest_json) @pytest.mark.asyncio -async def test_register_plugin_git_subdir_update(): - """Registering the same git-subdir plugin twice returns action=updated.""" - request = RegisterPluginRequest( - name="my-monorepo-plugin", source=_GIT_SUBDIR_SOURCE, version="1.0.0" +async def test_register_plugin_duplicate_name_conflicts(): + """A second POST with an existing name returns 409 and leaves the stored plugin untouched.""" + name = "my-monorepo-plugin" + await register_plugin( + request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), + user_api_key_dict=_USER, ) - await register_plugin(request=request, user_api_key_dict=_USER) - request2 = RegisterPluginRequest( - name="my-monorepo-plugin", source=_GIT_SUBDIR_SOURCE, version="2.0.0" + stored_before = await _read_stored_manifest(name) + assert stored_before["version"] == "1.0.0" + + conflicting = RegisterPluginRequest( + name=name, + source={ + "source": "git-subdir", + "url": "https://github.com/org/other.git", + "path": "plugins/other-plugin", + }, + version="2.0.0", ) - response = await register_plugin(request=request2, user_api_key_dict=_USER) + with pytest.raises(HTTPException) as exc_info: + await register_plugin(request=conflicting, user_api_key_dict=_USER) - assert response["status"] == "success" - assert response["action"] == "updated" + assert exc_info.value.status_code == 409 + assert "already exists" in exc_info.value.detail["error"] + + stored_after = await _read_stored_manifest(name) + assert stored_after == stored_before + assert stored_after["version"] == "1.0.0" + assert stored_after["source"]["url"] == "https://github.com/org/monorepo.git" + + +@pytest.mark.asyncio +async def test_update_plugin_replaces_existing_source(): + """PUT updates an existing plugin: action=updated and the stored source is replaced.""" + name = "my-monorepo-plugin" + await register_plugin( + request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), + user_api_key_dict=_USER, + ) + + new_source = {"source": "github", "repo": "org/replacement"} + response = await update_plugin( + plugin_name=name, + request=UpdatePluginRequest(source=new_source, version="2.0.0", description="updated"), + ) + + assert response.status == "success" + assert response.action == "updated" + assert response.plugin.version == "2.0.0" + assert response.plugin.source == new_source + + stored = await _read_stored_manifest(name) + assert stored["source"] == new_source + assert stored["version"] == "2.0.0" + + +@pytest.mark.asyncio +async def test_update_plugin_not_found(): + """PUT on a name that does not exist raises HTTP 404.""" + with pytest.raises(HTTPException) as exc_info: + await update_plugin( + plugin_name="does-not-exist", + request=UpdatePluginRequest(source=_GIT_SUBDIR_SOURCE), + ) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_register_plugin_create_race_maps_unique_violation_to_409(): + """A concurrent insert that slips past the find_unique pre-check (create raises + the unique-constraint error) is mapped to 409, not surfaced as a 500.""" + from prisma.errors import UniqueViolationError + + table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable + table.create = AsyncMock(side_effect=UniqueViolationError({}, message="duplicate name")) + + with pytest.raises(HTTPException) as exc_info: + await register_plugin( + request=RegisterPluginRequest(name="racy-plugin", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + assert exc_info.value.status_code == 409 + assert "already exists" in exc_info.value.detail["error"] + + +@pytest.mark.asyncio +async def test_update_plugin_db_error_maps_to_structured_500(): + """A data-layer failure during the update (e.g. a dropped DB connection) is caught and + returned as a structured 500, not swallowed silently or leaked as an unhandled error.""" + from prisma.errors import PrismaError + + name = "my-monorepo-plugin" + await register_plugin( + request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), + user_api_key_dict=_USER, + ) + + table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable + table.update = AsyncMock(side_effect=PrismaError("connection lost")) + + with pytest.raises(HTTPException) as exc_info: + await update_plugin( + plugin_name=name, + request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}), + ) + + assert exc_info.value.status_code == 500 + assert "connection lost" in exc_info.value.detail["error"] @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx index 8ea4dfac32c..74d1fb5facf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx @@ -270,4 +270,19 @@ describe("AddPluginForm", () => { expect(mockMessageError).toHaveBeenCalledWith(expect.stringContaining("Plugin 'claude-code' already exists")); }); }); + + it("surfaces the 409 name-conflict reason verbatim without burying it under a generic failure prefix", async () => { + const conflictMessage = + "A skill named 'gitlab' already exists. Update the existing skill instead of adding it again."; + mockRegister.mockRejectedValueOnce(new Error(conflictMessage)); + renderWithProviders(); + + await typeUrl("https://github.com/anthropics/claude-code"); + await submit(); + + await waitFor(() => { + expect(mockMessageError).toHaveBeenCalledWith(conflictMessage); + }); + expect(mockMessageError).toHaveBeenCalledTimes(1); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx index 04b8c88ae8d..686f7130024 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx @@ -145,8 +145,7 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT onClose(); } catch (error) { console.error("Error registering skill:", error); - const reason = error instanceof Error && error.message ? error.message : "Failed to register skill"; - MessageManager.error(`Failed to register skill: ${reason}`); + MessageManager.error(error instanceof Error && error.message ? error.message : "Failed to register skill"); } finally { setIsSubmitting(false); } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index e234e19d02e..558484e8f92 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -7236,7 +7236,8 @@ export const getClaudeCodePluginDetails = async (accessToken: string, pluginName }; /** - * Register or update a Claude Code plugin (admin only) + * Register a new Claude Code plugin (admin only). Create-only: the proxy returns + * 409 if a plugin with the same name already exists. * @param accessToken - Admin access token * @param pluginData - Plugin registration data */ diff --git a/ui/litellm-dashboard/src/lib/http/client.test.ts b/ui/litellm-dashboard/src/lib/http/client.test.ts index 72d72557112..c3be1d48b42 100644 --- a/ui/litellm-dashboard/src/lib/http/client.test.ts +++ b/ui/litellm-dashboard/src/lib/http/client.test.ts @@ -64,6 +64,18 @@ describe("createApiClient", () => { expect(onError).toHaveBeenCalledWith("no access"); }); + it("unwraps an object-shaped detail ({detail:{error}}) rather than dumping the JSON envelope (FastAPI HTTPException shape)", async () => { + const conflict = "A skill named 'gitlab' already exists. Update the existing skill instead of adding it again."; + const fetchImpl = vi.fn(async () => errorResponse(409, { detail: { error: conflict } })); + const onError = vi.fn(); + const client = createApiClient({ getBaseUrl: () => "", onError, fetchImpl }); + + const promise = client.get("/claude-code/plugins", { accessToken: "sk" }); + + await expect(promise).rejects.toMatchObject({ message: conflict, status: 409 }); + expect(onError).toHaveBeenCalledWith(conflict); + }); + it("falls back to the raw text body when a non-2xx response is not JSON (e.g. an HTML 502)", async () => { const fetchImpl = vi.fn(async () => rawErrorResponse(502, "Bad Gateway")); const onError = vi.fn(); diff --git a/ui/litellm-dashboard/src/lib/http/client.ts b/ui/litellm-dashboard/src/lib/http/client.ts index 8a1a8b2f43d..b8cfb81211a 100644 --- a/ui/litellm-dashboard/src/lib/http/client.ts +++ b/ui/litellm-dashboard/src/lib/http/client.ts @@ -48,6 +48,7 @@ const deriveDetailMessage = (detail: any): string | undefined => { if (Array.isArray(detail)) return detail.map((d: any) => d?.msg || JSON.stringify(d)).join("; "); if (typeof detail === "string") return detail; if (typeof detail?.error === "string") return detail.error; + if (detail && typeof detail === "object") return detail.error?.message || detail.message; return undefined; }; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d3f162b150d..9230f694c7b 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -1451,12 +1451,16 @@ export interface paths { put?: never; /** * Register Plugin - * @description Register a plugin in the LiteLLM marketplace. + * @description Register a new 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. * + * This endpoint is create-only and never overwrites. If a plugin with + * the same name already exists it returns 409 Conflict; use + * PUT /claude-code/plugins/{plugin_name} to update an existing plugin. + * * Parameters: * - name: Plugin name (kebab-case) * - source: Git source reference (github, url, or git-subdir format) @@ -1468,7 +1472,7 @@ export interface paths { * - category: Plugin category (optional) * * Returns: - * Registration status and plugin information. + * Registration status (action is always "created") and plugin information. * * Example: * ```bash @@ -1508,7 +1512,45 @@ export interface paths { * Plugin details including source and metadata. */ get: operations["get_plugin_claude_code_plugins__plugin_name__get"]; - put?: never; + /** + * Update Plugin + * @description Update an existing plugin in the LiteLLM marketplace. + * + * The plugin is identified by its name in the path, which is the resource + * identity and cannot be changed here. This is a full replace, not a merge: + * the manifest is rebuilt from the request body, so any optional field left + * out is reset to its default (e.g. an omitted version is cleared, not kept). + * Send the full desired state. + * + * Returns 404 if no plugin with the given name exists; use + * POST /claude-code/plugins to create a new plugin. + * + * Parameters: + * - plugin_name: Name of the plugin to update (path parameter) + * - source: Git source reference (github, url, or git-subdir 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: + * Update status (action is always "updated") and plugin information. + * + * Example: + * ```bash + * curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \ + * -H "Authorization: Bearer sk-..." \ + * -H "Content-Type: application/json" \ + * -d '{ + * "source": {"source": "github", "repo": "org/my-plugin"}, + * "version": "2.0.0", + * "description": "My awesome plugin" + * }' + * ``` + */ + put: operations["update_plugin_claude_code_plugins__plugin_name__put"]; post?: never; /** * Delete Plugin @@ -23841,7 +23883,7 @@ export interface components { * @description Default role assigned to new users created * @default internal_user_viewer */ - user_role: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null; + user_role: ("internal_user" | "internal_user_viewer" | "proxy_admin" | "proxy_admin_viewer") | null; }; /** * DefaultTeamSSOParams @@ -29717,6 +29759,44 @@ export interface components { /** Version */ version: string | null; }; + /** + * PluginResponse + * @description Plugin information in API responses. + */ + PluginResponse: { + /** + * Description + * @description Plugin description + */ + description?: string | null; + /** + * Enabled + * @description Whether plugin is enabled + */ + enabled: boolean; + /** + * Id + * @description Plugin unique ID + */ + id: string; + /** + * Name + * @description Plugin name + */ + name: string; + /** + * Source + * @description Git source reference + */ + source: { + [key: string]: string; + }; + /** + * Version + * @description Plugin version + */ + version?: string | null; + }; /** * PolicyAttachmentCreateRequest * @description Request body for creating a policy attachment. @@ -30989,6 +31069,24 @@ export interface components { */ version: string | null; }; + /** + * RegisterPluginResponse + * @description Response from plugin registration. + */ + RegisterPluginResponse: { + /** + * Action + * @description Action taken (created/updated) + */ + action: string; + /** @description Plugin information */ + plugin: components["schemas"]["PluginResponse"]; + /** + * Status + * @description Operation status + */ + status: string; + }; /** RejectMCPServerRequest */ RejectMCPServerRequest: { /** Review Notes */ @@ -33077,6 +33175,64 @@ export interface components { /** Model Names */ model_names?: string[] | null; }; + /** + * UpdatePluginRequest + * @description Request body for replacing an existing plugin. + * + * The plugin name is the resource identity and is supplied as the path + * parameter, so it cannot be changed here. This is a full replace: omitted + * fields reset to their defaults, so version is cleared rather than + * defaulting to the create-time "1.0.0". + */ + UpdatePluginRequest: { + /** @description Plugin author */ + author?: components["schemas"]["PluginAuthor"] | null; + /** + * Category + * @description Plugin category + */ + category?: string | null; + /** + * Description + * @description Plugin description + */ + description?: string | null; + /** + * Domain + * @description Skill domain (e.g., 'Productivity') + */ + domain?: string | null; + /** + * Homepage + * @description Plugin homepage URL + */ + homepage?: string | null; + /** + * Keywords + * @description Search keywords + */ + keywords?: string[] | null; + /** + * Namespace + * @description Skill namespace within domain (e.g., 'workflows') + */ + namespace?: string | null; + /** + * Source + * @description Git source reference. Supported formats: + * - GitHub: {'source': 'github', 'repo': 'org/repo'} + * - 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'} + */ + source: { + [key: string]: string; + }; + /** + * Version + * @description Semantic version; cleared if omitted + */ + version?: string | null; + }; /** * UpdateProjectRequest * @description Request model for POST /project/update @@ -37053,7 +37209,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["RegisterPluginResponse"]; }; }; /** @description Validation Error */ @@ -37098,6 +37254,41 @@ export interface operations { }; }; }; + update_plugin_claude_code_plugins__plugin_name__put: { + parameters: { + query?: never; + header?: never; + path: { + plugin_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdatePluginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RegisterPluginResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_plugin_claude_code_plugins__plugin_name__delete: { parameters: { query?: never;