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;