From 7a2e475d51314a3c9d13c03507623e9631f67745 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:41:15 +0000 Subject: [PATCH 1/6] feat(claude-code): emit installationPreference in marketplace.json entries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 39 ++++++++++- .../claude_code_marketplace.py | 12 +++- litellm/types/proxy/claude_code_endpoints.py | 6 ++ .../test_claude_code_marketplace.py | 64 +++++++++++++++++-- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 74f38b3ca6d..c43af8cc8ac 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -5352,6 +5352,17 @@ "title": "Id", "type": "string" }, + "installation_preference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Installation Preference" + }, "keywords": { "anyOf": [ { @@ -5546,6 +5557,18 @@ "description": "Plugin homepage URL", "title": "Homepage" }, + "installation_preference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install')", + "title": "Installation Preference" + }, "keywords": { "anyOf": [ { @@ -5696,6 +5719,18 @@ "description": "Plugin homepage URL", "title": "Homepage" }, + "installation_preference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install')", + "title": "Installation Preference" + }, "keywords": { "anyOf": [ { @@ -5890,7 +5925,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: 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 ```", "operationId": "register_plugin_claude_code_plugins_post", "requestBody": { "content": { @@ -6025,7 +6060,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: 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 ```", "operationId": "update_plugin_claude_code_plugins__plugin_name__put", "parameters": [ { 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 7c6a4571948..871ca31eb42 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 @@ -21,7 +21,7 @@ import json import re from collections.abc import Mapping, Sequence from datetime import datetime, timezone -from typing import Annotated, Final, Protocol, TypedDict +from typing import Annotated, Final, Protocol, TypedDict, cast from urllib.parse import urlsplit from fastapi import APIRouter, Depends, HTTPException, Request @@ -70,6 +70,7 @@ class _MarketplaceEntry(TypedDict, total=False): homepage: object keywords: object category: object + installationPreference: str async def _get_prisma_client() -> object: @@ -153,6 +154,8 @@ 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" in manifest: + entry["installationPreference"] = cast(str, manifest["installation_preference"]) plugin_list.append(entry) @@ -306,6 +309,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) Returns: Registration status (action is always "created") and plugin information. @@ -435,6 +439,10 @@ async def list_plugins( category=manifest.get("category"), domain=manifest.get("domain"), namespace=manifest.get("namespace"), + installation_preference=cast( + str | None, + cast(Mapping[str, object], manifest).get("installation_preference"), + ), 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 +516,7 @@ async def get_plugin( "homepage": manifest.get("homepage"), "keywords": manifest.get("keywords"), "category": manifest.get("category"), + "installation_preference": manifest.get("installation_preference"), "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 +567,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) Returns: Update status (action is always "updated") and plugin information. diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index dcb5561cfeb..aff39a36a10 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -41,6 +41,10 @@ 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( + None, + description="Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install')", + ) class RegisterPluginRequest(PluginSpec): @@ -104,6 +108,7 @@ class PluginListItem(BaseModel): category: str | None = None domain: str | None = None namespace: str | None = None + installation_preference: str | None = None enabled: bool created_at: str | None updated_at: str | None @@ -127,6 +132,7 @@ class MarketplacePluginEntry(BaseModel): homepage: str | None = None keywords: list[str] | None = None category: str | None = None + installation_preference: str | None = None class MarketplaceResponse(BaseModel): 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 a585666743f..c91b77c2669 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 @@ -5,18 +5,13 @@ 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 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 +23,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 +363,58 @@ async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(mon assert exc_info.value.code == "401" +@pytest.mark.asyncio +async def test_get_marketplace_emits_installation_preference(): + await register_plugin( + request=RegisterPluginRequest( + name="auto-install-plugin", + source=_GIT_SUBDIR_SOURCE, + installation_preference="auto_install", + ), + user_api_key_dict=_USER, + ) + + marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + + assert marketplace["plugins"][0]["installationPreference"] == "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_register_plugin_git_subdir_missing_url(): """git-subdir without url field raises HTTP 400.""" From 7415cd3ff3d7fac0923d867081019dd4a4ee23cb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:10:51 +0000 Subject: [PATCH 2/6] fix(claude-code): satisfy marketplace CI checks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../claude_code_marketplace.py | 17 ++++++++++------- ui/litellm-dashboard/src/lib/http/schema.d.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) 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 871ca31eb42..c03339adf29 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 @@ -21,7 +21,7 @@ import json import re from collections.abc import Mapping, Sequence from datetime import datetime, timezone -from typing import Annotated, Final, Protocol, TypedDict, cast +from typing import Annotated, Final, Protocol, TypedDict from urllib.parse import urlsplit from fastapi import APIRouter, Depends, HTTPException, Request @@ -73,6 +73,11 @@ class _MarketplaceEntry(TypedDict, total=False): installationPreference: str +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 + + async def _get_prisma_client() -> object: """Get the prisma client from proxy_server.""" from litellm.proxy.proxy_server import prisma_client @@ -154,8 +159,9 @@ 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" in manifest: - entry["installationPreference"] = cast(str, manifest["installation_preference"]) + installation_preference: Final[str | None] = _get_manifest_string(manifest, "installation_preference") + if installation_preference is not None: + entry["installationPreference"] = installation_preference plugin_list.append(entry) @@ -439,10 +445,7 @@ async def list_plugins( category=manifest.get("category"), domain=manifest.get("domain"), namespace=manifest.get("namespace"), - installation_preference=cast( - str | None, - cast(Mapping[str, object], manifest).get("installation_preference"), - ), + installation_preference=_get_manifest_string(manifest, "installation_preference"), 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, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 17ec8367324..0b44a51040f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2250,6 +2250,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) * * Returns: * Registration status (action is always "created") and plugin information. @@ -2316,6 +2317,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) * * Returns: * Update status (action is always "updated") and plugin information. @@ -34381,6 +34383,8 @@ export interface components { homepage?: string | null; /** Id */ id: string; + /** Installation Preference */ + installation_preference?: string | null; /** Keywords */ keywords?: string[] | null; /** Name */ @@ -35699,6 +35703,11 @@ export interface components { * @description Plugin homepage URL */ homepage?: string | null; + /** + * Installation Preference + * @description Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install') + */ + installation_preference?: string | null; /** * Keywords * @description Search keywords @@ -39079,6 +39088,11 @@ export interface components { * @description Plugin homepage URL */ homepage?: string | null; + /** + * Installation Preference + * @description Claude Code marketplace installationPreference for this plugin (e.g. 'auto_install') + */ + installation_preference?: string | null; /** * Keywords * @description Search keywords From bff08db63834b4f5c9a639b985fac3a8be0989f3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:22:27 +0000 Subject: [PATCH 3/6] fix(claude-code): qualify marketplace entry as read only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../claude_code_endpoints/claude_code_marketplace.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 c03339adf29..fdd37d6f7d1 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 @@ -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 @@ -70,7 +71,7 @@ class _MarketplaceEntry(TypedDict, total=False): homepage: object keywords: object category: object - installationPreference: str + installationPreference: ReadOnly[str] def _get_manifest_string(manifest: Mapping[str, object], key: str) -> str | None: From e90116e42e4e801db88b1192aa48156cfd6126a4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:34:26 +0000 Subject: [PATCH 4/6] fix(claude-code): suppress typed marketplace assembly warning Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../claude_code_endpoints/claude_code_marketplace.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 fdd37d6f7d1..8832cfdc9ce 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 @@ -160,9 +160,10 @@ async def get_marketplace(request: Request, key: str | None = None): entry["keywords"] = manifest["keywords"] if "category" in manifest: entry["category"] = manifest["category"] - installation_preference: Final[str | None] = _get_manifest_string(manifest, "installation_preference") - if installation_preference is not None: - entry["installationPreference"] = installation_preference + 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) From d51f84cc9ca137cb476723d60472adfbc212d14d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:43:03 -0700 Subject: [PATCH 5/6] 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 --- litellm/proxy/_lazy_openapi_snapshot.json | 23 +++++++-- .../claude_code_marketplace.py | 41 +++++++++------- litellm/types/proxy/claude_code_endpoints.py | 15 ++++-- .../test_claude_code_marketplace.py | 47 +++++++++++++++++-- ui/litellm-dashboard/src/lib/http/schema.d.ts | 14 +++--- 5 files changed, 104 insertions(+), 36 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 2c60c05ef7d..4d5a69bd41b 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -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": [ { 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 8832cfdc9ce..ec84bed8000 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 @@ -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. diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index aff39a36a10..5ba629d11a8 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -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): 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 c91b77c2669..4ec435cf7cd 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 @@ -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.""" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 8f9fbe95b89..70002792c97 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 From 4caf39425dc2e3e4ca917d2f33936a45b0f68132 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:35:11 -0700 Subject: [PATCH 6/6] fix(claude-code): echo installation_preference on the public skill hub --- .../public_endpoints/public_endpoints.py | 2 + .../public_endpoints/test_public_endpoints.py | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index bba5ef681d0..5f56f2ef4ab 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -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)) diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 18839a65d62..b05a9948bcf 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -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) + ]