This commit is contained in:
devin-ai-integration[bot] 2026-09-29 16:35:25 +00:00 • committed by GitHub
commit bff7df6016
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 246 additions and 12 deletions

View file

@ -5704,6 +5704,22 @@
"title": "Id",
"type": "string"
},
"installation_preference": {
"anyOf": [
{
"enum": [
"available",
"auto_install",
"required"
],
"type": "string"
},
{
"type": "null"
}
],
"title": "Installation Preference"
},
"keywords": {
"anyOf": [
{
@ -5898,6 +5914,23 @@
"description": "Plugin homepage URL",
"title": "Homepage"
},
"installation_preference": {
"anyOf": [
{
"enum": [
"available",
"auto_install",
"required"
],
"type": "string"
},
{
"type": "null"
}
],
"description": "Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the plugin is an archive source with a sha256",
"title": "Installation Preference"
},
"keywords": {
"anyOf": [
{
@ -6048,6 +6081,23 @@
"description": "Plugin homepage URL",
"title": "Homepage"
},
"installation_preference": {
"anyOf": [
{
"enum": [
"available",
"auto_install",
"required"
],
"type": "string"
},
{
"type": "null"
}
],
"description": "Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the plugin is an archive source with a sha256",
"title": "Installation Preference"
},
"keywords": {
"anyOf": [
{
@ -6242,7 +6292,7 @@
]
},
"post": {
"description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket or as a zip archive on any https host (e.g. S3).\nClaude Code clones the git source or downloads the archive when 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\nRequires a proxy admin API key.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Plugin source reference (github, url, git-subdir, or archive 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 ```",
"description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket or as a zip archive on any https host (e.g. S3).\nClaude Code clones the git source or downloads the archive when 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\nRequires a proxy admin API key.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Plugin source reference (github, url, git-subdir, or archive 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 - installation_preference: 'available', 'auto_install', or 'required' (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": {
@ -6377,7 +6427,7 @@
]
},
"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\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Plugin source reference (github, url, git-subdir, or archive 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 ```",
"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\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Plugin source reference (github, url, git-subdir, or archive 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 - installation_preference: 'available', 'auto_install', or 'required' (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": [
{

View file

@ -26,6 +26,7 @@ from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from typing_extensions import ReadOnly
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth
@ -37,6 +38,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
from litellm.types.proxy.claude_code_endpoints import (
InstallationPreference,
ListPluginsResponse,
PluginListItem,
PluginResponse,
@ -70,6 +72,18 @@ class _MarketplaceEntry(TypedDict, total=False):
homepage: object
keywords: object
category: object
installationPreference: ReadOnly[InstallationPreference]
def _get_installation_preference(manifest: Mapping[str, object]) -> InstallationPreference | None:
value: Final = manifest.get("installation_preference")
if not isinstance(value, str):
return None
match value:
case "available" | "auto_install" | "required":
return value
case _:
return None
async def _get_prisma_client() -> object:
@ -136,10 +150,16 @@ async def get_marketplace(request: Request, key: str | None = None):
verbose_proxy_logger.warning("Plugin %s has no source field, skipping", plugin.name)
continue
entry: _MarketplaceEntry = {
"name": plugin.name,
"source": manifest["source"],
}
installation_preference = _get_installation_preference(manifest)
entry: _MarketplaceEntry = (
{"name": plugin.name, "source": manifest["source"]}
if installation_preference is None
else {
"name": plugin.name,
"source": manifest["source"],
"installationPreference": installation_preference,
}
)
if plugin.version:
entry["version"] = plugin.version
@ -306,6 +326,7 @@ async def register_plugin(
- homepage: Plugin homepage URL (optional)
- keywords: Search keywords (optional)
- category: Plugin category (optional)
- installation_preference: 'available', 'auto_install', or 'required' (optional)
Returns:
Registration status (action is always "created") and plugin information.
@ -435,6 +456,7 @@ async def list_plugins(
category=manifest.get("category"),
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
installation_preference=_get_installation_preference(manifest),
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,
@ -508,6 +530,7 @@ async def get_plugin(
"homepage": manifest.get("homepage"),
"keywords": manifest.get("keywords"),
"category": manifest.get("category"),
"installation_preference": _get_installation_preference(manifest),
"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,
@ -558,6 +581,7 @@ async def update_plugin(
- homepage: Plugin homepage URL (optional)
- keywords: Search keywords (optional)
- category: Plugin category (optional)
- installation_preference: 'available', 'auto_install', or 'required' (optional)
Returns:
Update status (action is always "updated") and plugin information.

View file

@ -322,6 +322,7 @@ async def get_mcp_servers():
async def public_skill_hub():
"""Return enabled (public) Claude Code skills — no auth required."""
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
_get_installation_preference,
_get_prisma_client,
)
from litellm.types.proxy.claude_code_endpoints import (
@ -352,6 +353,7 @@ async def public_skill_hub():
homepage=manifest.get("homepage"),
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
installation_preference=_get_installation_preference(manifest),
)
)
return ListPluginsResponse(plugins=items, count=len(items))

View file

@ -2,8 +2,12 @@
Claude Code Marketplace endpoint types for LiteLLM Proxy
"""
from typing import Literal, TypeAlias
from pydantic import BaseModel, Field
InstallationPreference: TypeAlias = Literal["available", "auto_install", "required"]
class PluginAuthor(BaseModel):
"""Plugin author information."""
@ -41,6 +45,14 @@ class PluginSpec(BaseModel):
category: str | None = Field(None, description="Plugin category")
domain: str | None = Field(None, description="Skill domain (e.g., 'Productivity')")
namespace: str | None = Field(None, description="Skill namespace within domain (e.g., 'workflows')")
installation_preference: InstallationPreference | None = Field(
None,
description=(
"Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs "
"an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the "
"plugin is an archive source with a sha256"
),
)
class RegisterPluginRequest(PluginSpec):
@ -104,6 +116,7 @@ class PluginListItem(BaseModel):
category: str | None = None
domain: str | None = None
namespace: str | None = None
installation_preference: InstallationPreference | None = None
enabled: bool
created_at: str | None
updated_at: str | None

View file

@ -5,18 +5,14 @@ Covers the git-subdir and archive source types added alongside the existing gith
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from unittest.mock import AsyncMock, MagicMock
from pydantic import ValidationError
import litellm
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth
from litellm.proxy.proxy_server import LitellmUserRoles
from litellm.types.proxy.claude_code_endpoints import (
RegisterPluginRequest,
UpdatePluginRequest,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import claude_code_marketplace
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
delete_plugin,
@ -28,6 +24,11 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp
register_plugin,
update_plugin,
)
from litellm.proxy.proxy_server import LitellmUserRoles
from litellm.types.proxy.claude_code_endpoints import (
RegisterPluginRequest,
UpdatePluginRequest,
)
def _make_mock_prisma():
@ -363,6 +364,94 @@ async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(mon
assert exc_info.value.code == "401"
@pytest.mark.asyncio
@pytest.mark.parametrize("preference", ["available", "auto_install", "required"])
async def test_get_marketplace_emits_installation_preference(preference):
await register_plugin(
request=RegisterPluginRequest(
name="s3-skill",
source=_ARCHIVE_SOURCE,
installation_preference=preference,
),
user_api_key_dict=_USER,
)
marketplace = json.loads((await get_marketplace(request=MagicMock())).body)
assert marketplace["plugins"] == [
{"name": "s3-skill", "source": _ARCHIVE_SOURCE, "version": "1.0.0", "installationPreference": preference}
]
@pytest.mark.parametrize(
"build_request",
[
lambda preference: RegisterPluginRequest(
name="s3-skill", source=_ARCHIVE_SOURCE, installation_preference=preference
),
lambda preference: UpdatePluginRequest(source=_ARCHIVE_SOURCE, installation_preference=preference),
],
ids=["register", "update"],
)
def test_plugin_request_rejects_unknown_installation_preference(build_request):
with pytest.raises(ValidationError, match="installation_preference"):
build_request("auto-install")
@pytest.mark.asyncio
async def test_get_marketplace_omits_installation_preference_when_unset():
await register_plugin(
request=RegisterPluginRequest(name="manual-install-plugin", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
marketplace = json.loads((await get_marketplace(request=MagicMock())).body)
assert "installationPreference" not in marketplace["plugins"][0]
@pytest.mark.asyncio
async def test_update_plugin_propagates_installation_preference_to_get_and_list():
name = "updated-install-plugin"
await register_plugin(
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(
source=_GIT_SUBDIR_SOURCE,
installation_preference="auto_install",
),
user_api_key_dict=_USER,
)
plugin = await get_plugin(plugin_name=name, user_api_key_dict=_USER)
listed_plugin = (await list_plugins(user_api_key_dict=_USER)).plugins[0]
assert plugin["installation_preference"] == "auto_install"
assert listed_plugin.installation_preference == "auto_install"
@pytest.mark.asyncio
async def test_update_plugin_without_installation_preference_clears_it():
name = "cleared-install-plugin"
await register_plugin(
request=RegisterPluginRequest(name=name, source=_ARCHIVE_SOURCE, installation_preference="auto_install"),
user_api_key_dict=_USER,
)
await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source=_ARCHIVE_SOURCE),
user_api_key_dict=_USER,
)
marketplace = json.loads((await get_marketplace(request=MagicMock())).body)
assert "installationPreference" not in marketplace["plugins"][0]
@pytest.mark.asyncio
async def test_register_plugin_git_subdir_missing_url():
"""git-subdir without url field raises HTTP 400."""

View file

@ -1,6 +1,8 @@
import asyncio
import json
import re
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -1469,3 +1471,43 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa
response.json = MagicMock(return_value={})
with pytest.raises(ValueError, match="empty"):
await _fetch_remote_autorouter_presets("https://example.test/presets.json")
@pytest.mark.parametrize("preference", ["auto_install", None])
def test_public_skill_hub_echoes_registered_installation_preference(preference):
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import register_plugin
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest
table: Final = MagicMock()
table.find_unique = AsyncMock(return_value=None)
table.create = AsyncMock(side_effect=lambda data: SimpleNamespace(id="plugin-id", **data))
table.find_many = AsyncMock(
side_effect=lambda where: [SimpleNamespace(id="plugin-id", **table.create.call_args.kwargs["data"])]
)
prisma: Final = MagicMock()
prisma.db.litellm_claudecodeplugintable = table
app: Final = FastAPI()
app.include_router(router)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
asyncio.run(
register_plugin(
request=RegisterPluginRequest(
name="security-review",
source={
"source": "archive",
"url": "https://plugins.example.com/security-review-1.0.0.zip",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
installation_preference=preference,
),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
)
)
response: Final = TestClient(app).get("/public/skill_hub")
assert response.status_code == 200
assert [(p["name"], p["installation_preference"]) for p in response.json()["plugins"]] == [
("security-review", preference)
]

View file

@ -2344,6 +2344,7 @@ export interface paths {
* - homepage: Plugin homepage URL (optional)
* - keywords: Search keywords (optional)
* - category: Plugin category (optional)
* - installation_preference: 'available', 'auto_install', or 'required' (optional)
*
* Returns:
* Registration status (action is always "created") and plugin information.
@ -2410,6 +2411,7 @@ export interface paths {
* - homepage: Plugin homepage URL (optional)
* - keywords: Search keywords (optional)
* - category: Plugin category (optional)
* - installation_preference: 'available', 'auto_install', or 'required' (optional)
*
* Returns:
* Update status (action is always "updated") and plugin information.
@ -38001,6 +38003,8 @@ export interface components {
homepage?: string | null;
/** Id */
id: string;
/** Installation Preference */
installation_preference?: ("available" | "auto_install" | "required") | null;
/** Keywords */
keywords?: string[] | null;
/** Name */
@ -39452,6 +39456,11 @@ export interface components {
* @description Plugin homepage URL
*/
homepage?: string | null;
/**
* Installation Preference
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the plugin is an archive source with a sha256
*/
installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords
@ -44871,6 +44880,11 @@ export interface components {
* @description Plugin homepage URL
*/
homepage?: string | null;
/**
* Installation Preference
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the plugin is an archive source with a sha256
*/
installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords