mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
fix(claude-code): restrict installation_preference to the documented values
Accept only available, auto_install, or required so a typo returns 422 instead of being silently dropped, and cover the PUT full-replace clearing behavior with a test
This commit is contained in:
parent
ad3275106b
commit
d51f84cc9c
5 changed files with 104 additions and 36 deletions
|
|
@ -5707,6 +5707,11 @@
|
|||
"installation_preference": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"available",
|
||||
"auto_install",
|
||||
"required"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
|
|
@ -5912,13 +5917,18 @@
|
|||
"installation_preference": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"available",
|
||||
"auto_install",
|
||||
"required"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install')",
|
||||
"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": {
|
||||
|
|
@ -6074,13 +6084,18 @@
|
|||
"installation_preference": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"available",
|
||||
"auto_install",
|
||||
"required"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install')",
|
||||
"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": {
|
||||
|
|
@ -6277,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 - installation_preference: Marketplace installationPreference, e.g. 'auto_install' (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": {
|
||||
|
|
@ -6412,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 - installation_preference: Marketplace installationPreference, e.g. 'auto_install' (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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,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,
|
||||
|
|
@ -71,12 +72,18 @@ class _MarketplaceEntry(TypedDict, total=False):
|
|||
homepage: object
|
||||
keywords: object
|
||||
category: object
|
||||
installationPreference: ReadOnly[str]
|
||||
installationPreference: ReadOnly[InstallationPreference]
|
||||
|
||||
|
||||
def _get_manifest_string(manifest: Mapping[str, object], key: str) -> str | None:
|
||||
value: Final = manifest.get(key)
|
||||
return value if isinstance(value, str) else None
|
||||
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:
|
||||
|
|
@ -143,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
|
||||
|
|
@ -160,10 +173,6 @@ async def get_marketplace(request: Request, key: str | None = None):
|
|||
entry["keywords"] = manifest["keywords"]
|
||||
if "category" in manifest:
|
||||
entry["category"] = manifest["category"]
|
||||
if (installation_preference := _get_manifest_string(manifest, "installation_preference")) is not None:
|
||||
entry["installationPreference"] = ( # pyright: ignore[reportTypedDictNotRequiredAccess] # assembled incrementally
|
||||
installation_preference
|
||||
)
|
||||
|
||||
plugin_list.append(entry)
|
||||
|
||||
|
|
@ -317,7 +326,7 @@ async def register_plugin(
|
|||
- homepage: Plugin homepage URL (optional)
|
||||
- keywords: Search keywords (optional)
|
||||
- category: Plugin category (optional)
|
||||
- installation_preference: Marketplace installationPreference, e.g. 'auto_install' (optional)
|
||||
- installation_preference: 'available', 'auto_install', or 'required' (optional)
|
||||
|
||||
Returns:
|
||||
Registration status (action is always "created") and plugin information.
|
||||
|
|
@ -447,7 +456,7 @@ async def list_plugins(
|
|||
category=manifest.get("category"),
|
||||
domain=manifest.get("domain"),
|
||||
namespace=manifest.get("namespace"),
|
||||
installation_preference=_get_manifest_string(manifest, "installation_preference"),
|
||||
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,
|
||||
|
|
@ -521,7 +530,7 @@ async def get_plugin(
|
|||
"homepage": manifest.get("homepage"),
|
||||
"keywords": manifest.get("keywords"),
|
||||
"category": manifest.get("category"),
|
||||
"installation_preference": manifest.get("installation_preference"),
|
||||
"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,
|
||||
|
|
@ -572,7 +581,7 @@ async def update_plugin(
|
|||
- homepage: Plugin homepage URL (optional)
|
||||
- keywords: Search keywords (optional)
|
||||
- category: Plugin category (optional)
|
||||
- installation_preference: Marketplace installationPreference, e.g. 'auto_install' (optional)
|
||||
- installation_preference: 'available', 'auto_install', or 'required' (optional)
|
||||
|
||||
Returns:
|
||||
Update status (action is always "updated") and plugin information.
|
||||
|
|
|
|||
|
|
@ -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,9 +45,13 @@ 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: str | None = Field(
|
||||
installation_preference: InstallationPreference | None = Field(
|
||||
None,
|
||||
description="Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install')",
|
||||
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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -108,7 +116,7 @@ class PluginListItem(BaseModel):
|
|||
category: str | None = None
|
||||
domain: str | None = None
|
||||
namespace: str | None = None
|
||||
installation_preference: str | None = None
|
||||
installation_preference: InstallationPreference | None = None
|
||||
enabled: bool
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
|
@ -132,7 +140,6 @@ class MarketplacePluginEntry(BaseModel):
|
|||
homepage: str | None = None
|
||||
keywords: list[str] | None = None
|
||||
category: str | None = None
|
||||
installation_preference: str | None = None
|
||||
|
||||
|
||||
class MarketplaceResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth
|
||||
|
|
@ -364,19 +365,37 @@ async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(mon
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_marketplace_emits_installation_preference():
|
||||
@pytest.mark.parametrize("preference", ["available", "auto_install", "required"])
|
||||
async def test_get_marketplace_emits_installation_preference(preference):
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(
|
||||
name="auto-install-plugin",
|
||||
source=_GIT_SUBDIR_SOURCE,
|
||||
installation_preference="auto_install",
|
||||
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"][0]["installationPreference"] == "auto_install"
|
||||
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
|
||||
|
|
@ -415,6 +434,24 @@ async def test_update_plugin_propagates_installation_preference_to_get_and_list(
|
|||
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."""
|
||||
|
|
|
|||
14
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
14
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -2344,7 +2344,7 @@ export interface paths {
|
|||
* - homepage: Plugin homepage URL (optional)
|
||||
* - keywords: Search keywords (optional)
|
||||
* - category: Plugin category (optional)
|
||||
* - installation_preference: Marketplace installationPreference, e.g. 'auto_install' (optional)
|
||||
* - installation_preference: 'available', 'auto_install', or 'required' (optional)
|
||||
*
|
||||
* Returns:
|
||||
* Registration status (action is always "created") and plugin information.
|
||||
|
|
@ -2411,7 +2411,7 @@ export interface paths {
|
|||
* - homepage: Plugin homepage URL (optional)
|
||||
* - keywords: Search keywords (optional)
|
||||
* - category: Plugin category (optional)
|
||||
* - installation_preference: Marketplace installationPreference, e.g. 'auto_install' (optional)
|
||||
* - installation_preference: 'available', 'auto_install', or 'required' (optional)
|
||||
*
|
||||
* Returns:
|
||||
* Update status (action is always "updated") and plugin information.
|
||||
|
|
@ -38004,7 +38004,7 @@ export interface components {
|
|||
/** Id */
|
||||
id: string;
|
||||
/** Installation Preference */
|
||||
installation_preference?: string | null;
|
||||
installation_preference?: ("available" | "auto_install" | "required") | null;
|
||||
/** Keywords */
|
||||
keywords?: string[] | null;
|
||||
/** Name */
|
||||
|
|
@ -39458,9 +39458,9 @@ export interface components {
|
|||
homepage?: string | null;
|
||||
/**
|
||||
* Installation Preference
|
||||
* @description Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install')
|
||||
* @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?: string | null;
|
||||
installation_preference?: ("available" | "auto_install" | "required") | null;
|
||||
/**
|
||||
* Keywords
|
||||
* @description Search keywords
|
||||
|
|
@ -44882,9 +44882,9 @@ export interface components {
|
|||
homepage?: string | null;
|
||||
/**
|
||||
* Installation Preference
|
||||
* @description Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install')
|
||||
* @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?: string | null;
|
||||
installation_preference?: ("available" | "auto_install" | "required") | null;
|
||||
/**
|
||||
* Keywords
|
||||
* @description Search keywords
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue