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."""