feat(claude-code): emit installationPreference in marketplace.json entries (#41430)

* feat(claude-code): emit installationPreference in marketplace.json entries

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-code): satisfy marketplace CI checks

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-code): qualify marketplace entry as read only

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-code): suppress typed marketplace assembly warning

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* 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

* fix(claude-code): echo installation_preference on the public skill hub

* refactor(claude-code): read installation_preference with one membership check and cover hand-edited rows

* test(integration): audit installation_preference on the Claude Code marketplace

* test(integration): kill a serving worker in the marketplace chaos cell

* test(integration): give the marketplace worker-kill cell the two-boot timeout

* fix(proxy): keep installationPreference on the MarketplacePluginEntry model

* fix(proxy): refuse self-installing preferences off sha256 archives, accept the camelCase key, and echo the preference on POST and PUT

* fix(claude-code): serve installation_preference only on archive plugins and refuse both key spellings

Claude Code 2.1.75 and older reject a marketplace.json once any plugin carries installationPreference,
and every Claude Code release before archive support rejects a catalog with an archive plugin. Requiring
an archive source for every value, available included, keeps the field off catalogs those clients can read.
A request that sends installation_preference and installationPreference together now gets a 422.

* fix(claude-code): read the preference spelling that carries a value when the other is null

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-14 07:59:25 +00:00 • committed by GitHub
parent cfa98e2f40
commit 46a389c60b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1033 additions and 15 deletions

View file

@ -7134,6 +7134,22 @@
"title": "Id",
"type": "string"
},
"installation_preference": {
"anyOf": [
{
"enum": [
"available",
"auto_install",
"required"
],
"type": "string"
},
{
"type": "null"
}
],
"title": "Installation Preference"
},
"keywords": {
"anyOf": [
{
@ -7231,6 +7247,23 @@
"title": "Id",
"type": "string"
},
"installation_preference": {
"anyOf": [
{
"enum": [
"available",
"auto_install",
"required"
],
"type": "string"
},
{
"type": "null"
}
],
"description": "Plugin installation preference",
"title": "Installation Preference"
},
"name": {
"description": "Plugin name",
"title": "Name",
@ -7328,6 +7361,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. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin",
"title": "Installation Preference"
},
"keywords": {
"anyOf": [
{
@ -7478,6 +7528,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. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin",
"title": "Installation Preference"
},
"keywords": {
"anyOf": [
{
@ -7672,7 +7739,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": {
@ -7807,7 +7874,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

@ -21,11 +21,13 @@ 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, get_args
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import TypeAdapter
from typing_extensions import ReadOnly
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth
@ -37,6 +39,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 +73,24 @@ class _MarketplaceEntry(TypedDict, total=False):
homepage: object
keywords: object
category: object
installationPreference: ReadOnly[InstallationPreference]
_INSTALLATION_PREFERENCES: Final[tuple[InstallationPreference, ...]] = get_args(InstallationPreference)
MANIFEST_FIELDS: Final = TypeAdapter(dict[str, object])
_SELF_INSTALLING_PREFERENCES: Final[frozenset[InstallationPreference]] = frozenset({"auto_install", "required"})
def get_installation_preference(manifest: Mapping[str, object]) -> InstallationPreference | None:
value: Final = manifest.get("installation_preference")
if value is None or value in _INSTALLATION_PREFERENCES:
return value
verbose_proxy_logger.warning(
"Plugin %s has unknown installation_preference %r, serving it as unset", manifest.get("name"), value
)
return None
async def get_prisma_client() -> object:
@ -139,10 +160,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
@ -249,6 +276,27 @@ def _validate_plugin_source(source: Mapping[str, str]) -> None:
)
def _validate_installation_preference(spec: PluginSpec) -> None:
if spec.installation_preference is None:
return
if spec.source.get("source") != "archive":
raise HTTPException(
status_code=400,
detail={
"error": "installation_preference needs an archive source, the only plugins Claude Desktop lists "
"from a marketplace.json URL"
},
)
if spec.installation_preference in _SELF_INSTALLING_PREFERENCES and not spec.source.get("sha256"):
raise HTTPException(
status_code=400,
detail={
"error": f"installation_preference '{spec.installation_preference}' needs the archive's sha256, "
"since Claude Desktop installs only pinned archives on its own"
},
)
def _build_plugin_manifest(name: str, spec: PluginSpec) -> Mapping[str, object]:
"""Build the stored manifest dict shared by plugin create and update."""
dumped: Final[Mapping[str, object]] = spec.model_dump(exclude_none=True)
@ -309,6 +357,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.
@ -340,6 +389,7 @@ async def register_plugin(
)
_validate_plugin_source(request.source)
_validate_installation_preference(request)
existing: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
where={"name": request.name}
@ -377,6 +427,7 @@ async def register_plugin(
version=plugin.version,
description=plugin.description,
source=request.source,
installation_preference=request.installation_preference,
enabled=plugin.enabled,
),
)
@ -438,6 +489,7 @@ async def list_plugins(
category=manifest.get("category"),
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
installation_preference=get_installation_preference(MANIFEST_FIELDS.validate_python(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,
@ -511,6 +563,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,
@ -561,6 +614,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.
@ -585,6 +639,7 @@ async def update_plugin(
prisma_client: Final = await get_prisma_client()
_validate_plugin_source(request.source)
_validate_installation_preference(request)
existing: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
where={"name": plugin_name}
@ -618,6 +673,7 @@ async def update_plugin(
version=plugin.version,
description=plugin.description,
source=request.source,
installation_preference=request.installation_preference,
enabled=plugin.enabled,
),
)

View file

@ -322,6 +322,8 @@ 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 (
MANIFEST_FIELDS,
get_installation_preference,
get_prisma_client,
)
from litellm.types.proxy.claude_code_endpoints import (
@ -352,6 +354,7 @@ async def public_skill_hub():
homepage=manifest.get("homepage"),
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
installation_preference=get_installation_preference(MANIFEST_FIELDS.validate_python(manifest)),
)
)
return ListPluginsResponse(plugins=items, count=len(items))

View file

@ -2,10 +2,16 @@
Claude Code Marketplace endpoint types for LiteLLM Proxy
"""
from pydantic import Field
from typing import Final, Literal, TypeAlias
from pydantic import AliasChoices, Field, TypeAdapter, model_validator
from litellm.types.llms.base import LiteLLMBaseModel
InstallationPreference: TypeAlias = Literal["available", "auto_install", "required"]
_BOTH_PREFERENCE_SPELLINGS: Final = frozenset({"installation_preference", "installationPreference"})
_REQUEST_KEYS: Final = TypeAdapter(dict[str, object])
class PluginAuthor(LiteLLMBaseModel):
"""Plugin author information."""
@ -43,6 +49,30 @@ class PluginSpec(LiteLLMBaseModel):
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,
validation_alias=AliasChoices("installation_preference", "installationPreference"),
description=(
"Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, "
"the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' "
"also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share "
"its inference gateway origin"
),
)
@model_validator(mode="before")
@classmethod
def reject_both_preference_spellings(cls, data: object) -> object:
if not isinstance(data, dict):
return data
fields: Final = _REQUEST_KEYS.validate_python(data)
if not _BOTH_PREFERENCE_SPELLINGS.issubset(fields):
return fields
if all(fields[key] is not None for key in _BOTH_PREFERENCE_SPELLINGS):
raise ValueError("send installation_preference or installationPreference, not both")
return {
key: value for key, value in fields.items() if key not in _BOTH_PREFERENCE_SPELLINGS or value is not None
}
class RegisterPluginRequest(PluginSpec):
@ -81,6 +111,7 @@ class PluginResponse(LiteLLMBaseModel):
version: str | None = Field(None, description="Plugin version")
description: str | None = Field(None, description="Plugin description")
source: dict[str, str] = Field(..., description="Plugin source reference")
installation_preference: InstallationPreference | None = Field(None, description="Plugin installation preference")
enabled: bool = Field(..., description="Whether plugin is enabled")
@ -106,6 +137,7 @@ class PluginListItem(LiteLLMBaseModel):
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
@ -129,6 +161,7 @@ class MarketplacePluginEntry(LiteLLMBaseModel):
homepage: str | None = None
keywords: list[str] | None = None
category: str | None = None
installationPreference: InstallationPreference | None = None
class MarketplaceResponse(LiteLLMBaseModel):

View file

@ -0,0 +1,540 @@
import json
import uuid
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from pathlib import Path
from typing import Final
import httpx
import pytest
from pydantic import JsonValue
from tests.integration._support.client import JSON_OBJECT, Gateway, Scenario, eventually, object_value, string_value
from tests.integration._support.database import read_rows, write_rows
from tests.integration._support.process import OwnedProxy, owned_proxy_process
PREFERENCES: Final = ("available", "auto_install", "required")
INVALID_PREFERENCES: Final[tuple[JsonValue, ...]] = (
"",
0,
["auto_install"],
"x" * 5000,
"auto-install",
"AUTO_INSTALL",
)
INVALID_IDS: Final = ("empty-string", "int", "list", "5kb-string", "hyphenated", "upper-case")
SCHEMAS_WITH_PREFERENCE: Final = ("RegisterPluginRequest", "UpdatePluginRequest", "PluginListItem")
MARKETPLACE: Final = "/claude-code/marketplace.json"
PLUGINS: Final = "/claude-code/plugins"
SKILL_HUB: Final = "/public/skill_hub"
PLUGIN_ROWS: Final = 'SELECT manifest_json, enabled, files_json FROM "LiteLLM_ClaudeCodePluginTable" WHERE name = %s'
def plugin_name() -> str:
return f"audit-{uuid.uuid4().hex[:12]}"
def archive_source(name: str, digest: str = "a" * 64) -> dict[str, JsonValue]:
return {"source": "archive", "url": f"https://files.example.com/{name}.zip", "sha256": digest}
def registration(name: str, **fields: JsonValue) -> dict[str, JsonValue]:
return {"name": name, "source": archive_source(name), **fields}
def replacement(name: str, **fields: JsonValue) -> dict[str, JsonValue]:
return {"source": archive_source(name), **fields}
def delete_plugin(gateway: Gateway, name: str) -> None:
response: Final = gateway.request("DELETE", f"{PLUGINS}/{name}")
assert response.status_code in {200, 404}, response.text
def register(scenario: Scenario, body: Mapping[str, JsonValue]) -> httpx.Response:
scenario.cleanups.callback(delete_plugin, scenario.gateway, string_value(body["name"]))
return scenario.gateway.request("POST", PLUGINS, body)
def registered(scenario: Scenario, name: str, **fields: JsonValue) -> None:
response: Final = register(scenario, registration(name, **fields))
assert response.status_code == 200, response.text
def replace(gateway: Gateway, name: str, body: Mapping[str, JsonValue], *, key: str | None = None) -> httpx.Response:
return gateway.request("PUT", f"{PLUGINS}/{name}", body, key=key)
def replaced(gateway: Gateway, name: str, **fields: JsonValue) -> None:
response: Final = replace(gateway, name, replacement(name, **fields))
assert response.status_code == 200, response.text
def entries(response: httpx.Response, name: str) -> tuple[dict[str, JsonValue], ...]:
assert response.status_code == 200, response.text
plugins: Final = JSON_OBJECT.validate_json(response.content)["plugins"]
assert isinstance(plugins, list), response.text
return tuple(object_value(plugin) for plugin in plugins if object_value(plugin)["name"] == name)
def marketplace_entries(gateway: Gateway, name: str, *, key: str | None = None) -> tuple[dict[str, JsonValue], ...]:
return entries(gateway.client.get(MARKETPLACE, params=None if key is None else {"key": key}), name)
def marketplace_entry(gateway: Gateway, name: str, *, key: str | None = None) -> dict[str, JsonValue]:
found: Final = marketplace_entries(gateway, name, key=key)
assert len(found) == 1, found
return found[0]
def listed_item(gateway: Gateway, name: str) -> dict[str, JsonValue]:
found: Final = entries(gateway.request("GET", PLUGINS), name)
assert len(found) == 1, found
return found[0]
def skill_hub_entries(gateway: Gateway, name: str) -> tuple[dict[str, JsonValue], ...]:
return entries(gateway.client.get(SKILL_HUB), name)
def detail(gateway: Gateway, name: str, *, key: str | None = None) -> httpx.Response:
return gateway.request("GET", f"{PLUGINS}/{name}", key=key)
def detail_body(gateway: Gateway, name: str, *, key: str | None = None) -> dict[str, JsonValue]:
response: Final = detail(gateway, name, key=key)
assert response.status_code == 200, response.text
return JSON_OBJECT.validate_json(response.content)
def stored_rows(name: str) -> list[dict[str, JsonValue]]:
return read_rows(PLUGIN_ROWS, (name,))
def stored_manifest(name: str) -> dict[str, JsonValue]:
rows: Final = stored_rows(name)
assert len(rows) == 1, rows
return JSON_OBJECT.validate_json(string_value(rows[0]["manifest_json"]))
def assert_preference_served(gateway: Gateway, name: str, preference: str) -> None:
assert marketplace_entry(gateway, name)["installationPreference"] == preference
assert listed_item(gateway, name)["installation_preference"] == preference
assert detail_body(gateway, name)["installation_preference"] == preference
hub: Final = skill_hub_entries(gateway, name)
assert len(hub) == 1, hub
assert hub[0]["installation_preference"] == preference
def assert_preference_unset(gateway: Gateway, name: str) -> None:
assert "installationPreference" not in marketplace_entry(gateway, name)
assert listed_item(gateway, name)["installation_preference"] is None
assert detail_body(gateway, name)["installation_preference"] is None
hub: Final = skill_hub_entries(gateway, name)
assert len(hub) == 1, hub
assert hub[0]["installation_preference"] is None
@pytest.mark.parametrize("preference", PREFERENCES)
def test_registered_preference_is_served_on_the_marketplace(gateway: Gateway, preference: str) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference=preference)
assert marketplace_entry(gateway, name) == {
"name": name,
"source": archive_source(name),
"installationPreference": preference,
"version": "1.0.0",
}
assert stored_manifest(name)["installation_preference"] == preference
def test_preference_echoes_on_list_detail_and_skill_hub(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="required")
assert_preference_served(gateway, name, "required")
def test_peer_process_serves_the_stored_preference(gateway: Gateway, peer: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="auto_install")
assert_preference_served(peer, name, "auto_install")
def test_omitted_preference_is_unset(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name)
assert_preference_unset(gateway, name)
assert stored_manifest(name).get("installation_preference") is None
def test_explicit_null_preference_is_unset(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference=None)
assert_preference_unset(gateway, name)
assert stored_manifest(name).get("installation_preference") is None
@pytest.mark.parametrize("preference", INVALID_PREFERENCES, ids=INVALID_IDS)
def test_invalid_preference_is_refused_on_register(gateway: Gateway, preference: JsonValue) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
response: Final = register(scenario, registration(name, installation_preference=preference))
assert response.status_code == 422, response.text
assert "installation_preference" in response.text
assert stored_rows(name) == []
@pytest.mark.parametrize("preference", INVALID_PREFERENCES, ids=INVALID_IDS)
def test_invalid_preference_is_refused_on_update(gateway: Gateway, preference: JsonValue) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="available")
response: Final = replace(gateway, name, replacement(name, installation_preference=preference))
assert response.status_code == 422, response.text
assert "installation_preference" in response.text
assert stored_manifest(name)["installation_preference"] == "available"
assert marketplace_entry(gateway, name)["installationPreference"] == "available"
@pytest.mark.parametrize("preference", PREFERENCES)
def test_preference_on_a_git_source_is_refused(gateway: Gateway, preference: str) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
body: Final = {
"name": name,
"source": {"source": "github", "repo": "example-org/example-plugin"},
"installation_preference": preference,
}
response: Final = register(scenario, body)
assert response.status_code == 400, response.text
assert "archive source" in response.text
assert stored_rows(name) == []
def test_both_preference_spellings_are_refused(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
response: Final = register(
scenario, registration(name, installation_preference="required", installationPreference="available")
)
assert response.status_code == 422, response.text
assert "not both" in response.text
assert stored_rows(name) == []
def test_duplicate_json_key_keeps_the_last_value(gateway: Gateway) -> None:
name: Final = plugin_name()
raw: Final = (
f'{{"name": "{name}", "source": {json.dumps(archive_source(name))}, '
'"installation_preference": "required", "installation_preference": "available"}'
)
with gateway.scenario() as scenario:
scenario.cleanups.callback(delete_plugin, gateway, name)
response: Final = gateway.client.post(
PLUGINS,
content=raw.encode(),
headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"},
)
assert response.status_code == 200, response.text
assert marketplace_entry(gateway, name)["installationPreference"] == "available"
assert stored_manifest(name)["installation_preference"] == "available"
def test_repeated_identical_update_is_idempotent(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="required")
replaced(gateway, name, installation_preference="required")
first: Final = stored_rows(name)
replaced(gateway, name, installation_preference="required")
second: Final = stored_rows(name)
assert len(first) == 1, first
assert second == first
assert_preference_served(gateway, name, "required")
def test_duplicate_name_is_refused_and_keeps_the_first_preference(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="required")
response: Final = register(scenario, registration(name, installation_preference="available"))
assert response.status_code == 409, response.text
assert len(stored_rows(name)) == 1
assert_preference_served(gateway, name, "required")
def test_update_changes_and_clears_the_preference(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="available")
replaced(gateway, name, installation_preference="required")
assert_preference_served(gateway, name, "required")
replaced(gateway, name)
assert_preference_unset(gateway, name)
replaced(gateway, name, installation_preference="auto_install")
assert_preference_served(gateway, name, "auto_install")
replaced(gateway, name, installation_preference=None)
assert_preference_unset(gateway, name)
assert stored_manifest(name).get("installation_preference") is None
def test_update_replaces_the_whole_manifest(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(
scenario,
name,
description="first",
keywords=["one"],
author={"name": "audit"},
homepage="https://example.com/first",
category="tools",
installation_preference="required",
)
before: Final = detail_body(gateway, name)
assert before["description"] == "first"
assert before["installation_preference"] == "required"
replaced(gateway, name, version="2.0.0", installation_preference="auto_install")
after: Final = detail_body(gateway, name)
assert after["updated_at"] != before["updated_at"]
assert {field: value for field, value in after.items() if field != "updated_at"} == {
**{field: value for field, value in before.items() if field != "updated_at"},
"version": "2.0.0",
"description": None,
"author": None,
"homepage": None,
"keywords": None,
"category": None,
"installation_preference": "auto_install",
}
assert marketplace_entry(gateway, name) == {
"name": name,
"source": archive_source(name),
"installationPreference": "auto_install",
"version": "2.0.0",
}
assert stored_rows(name)[0]["files_json"] == "{}"
def test_unauthenticated_requests_are_refused(gateway: Gateway) -> None:
name: Final = plugin_name()
unauthenticated: Final = gateway.client
with gateway.scenario() as scenario:
anonymous_registration: Final = unauthenticated.post(
PLUGINS, json=registration(name, installation_preference="required")
)
assert anonymous_registration.status_code == 401, anonymous_registration.text
assert stored_rows(name) == []
registered(scenario, name, installation_preference="required")
refused: Final = (
unauthenticated.put(f"{PLUGINS}/{name}", json=replacement(name, installation_preference="available")),
unauthenticated.post(f"{PLUGINS}/{name}/disable"),
unauthenticated.post(f"{PLUGINS}/{name}/enable"),
unauthenticated.delete(f"{PLUGINS}/{name}"),
unauthenticated.get(PLUGINS),
unauthenticated.get(f"{PLUGINS}/{name}"),
)
assert [response.status_code for response in refused] == [401] * len(refused), [r.text for r in refused]
assert stored_manifest(name)["installation_preference"] == "required"
assert stored_rows(name)[0]["enabled"] is True
assert marketplace_entry(gateway, name)["installationPreference"] == "required"
def test_non_admin_key_cannot_modify_the_catalog(gateway: Gateway) -> None:
name: Final = plugin_name()
other: Final = plugin_name()
with gateway.scenario() as scenario:
key: Final = scenario.key()
registered(scenario, name, installation_preference="required")
scenario.cleanups.callback(delete_plugin, gateway, other)
refused: Final = (
gateway.request("POST", PLUGINS, registration(other, installation_preference="available"), key=key),
replace(gateway, name, replacement(name, installation_preference="available"), key=key),
gateway.request("DELETE", f"{PLUGINS}/{name}", key=key),
)
assert [response.status_code for response in refused] == [403] * len(refused), [r.text for r in refused]
flips: Final = (
gateway.request("POST", f"{PLUGINS}/{name}/disable", key=key),
gateway.request("POST", f"{PLUGINS}/{name}/enable", key=key),
)
assert all(response.status_code in {401, 403} for response in flips), [r.text for r in flips]
assert stored_rows(other) == []
assert stored_manifest(name)["installation_preference"] == "required"
assert stored_rows(name)[0]["enabled"] is True
assert detail_body(gateway, name, key=key)["installation_preference"] == "required"
def test_disabled_plugin_keeps_its_preference_for_granted_keys(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="auto_install")
granted: Final = scenario.key(object_permission={"skills": [name]})
disabled: Final = gateway.request("POST", f"{PLUGINS}/{name}/disable")
assert disabled.status_code == 200, disabled.text
assert marketplace_entries(gateway, name) == ()
assert skill_hub_entries(gateway, name) == ()
assert marketplace_entry(gateway, name, key=granted)["installationPreference"] == "auto_install"
assert detail_body(gateway, name, key=granted)["installation_preference"] == "auto_install"
assert stored_rows(name)[0]["enabled"] is False
enabled: Final = gateway.request("POST", f"{PLUGINS}/{name}/enable")
assert enabled.status_code == 200, enabled.text
assert_preference_served(gateway, name, "auto_install")
assert stored_manifest(name)["installation_preference"] == "auto_install"
def test_non_granted_key_cannot_read_a_disabled_plugin(gateway: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="required")
plain: Final = scenario.key()
assert detail_body(gateway, name, key=plain)["installation_preference"] == "required"
disabled: Final = gateway.request("POST", f"{PLUGINS}/{name}/disable")
assert disabled.status_code == 200, disabled.text
refused: Final = detail(gateway, name, key=plain)
assert refused.status_code == 403, refused.text
assert marketplace_entries(gateway, name, key=plain) == ()
assert skill_hub_entries(gateway, name) == ()
def test_hand_edited_unknown_preference_is_not_served(gateway: Gateway, peer: Gateway) -> None:
name: Final = plugin_name()
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="required")
edited: Final = {**stored_manifest(name), "installation_preference": "someday"}
write_rows(
'UPDATE "LiteLLM_ClaudeCodePluginTable" SET manifest_json = %s WHERE name = %s',
(json.dumps(edited), name),
)
assert stored_manifest(name)["installation_preference"] == "someday"
assert_preference_unset(gateway, name)
assert_preference_unset(peer, name)
liveliness: Final = gateway.client.get("/health/liveliness")
assert liveliness.status_code == 200, liveliness.text
def preference_shapes(schemas: Mapping[str, JsonValue], schema: str) -> list[JsonValue]:
properties: Final = object_value(object_value(schemas[schema])["properties"])
assert "installation_preference" in properties, (schema, sorted(properties))
shapes: Final = object_value(properties["installation_preference"])["anyOf"]
assert isinstance(shapes, list), (schema, shapes)
return shapes
@pytest.mark.parametrize("schema", SCHEMAS_WITH_PREFERENCE)
def test_openapi_declares_the_preference_enum(gateway: Gateway, schema: str) -> None:
response: Final = gateway.client.get("/openapi.json")
assert response.status_code == 200, response.text
schemas: Final = object_value(object_value(JSON_OBJECT.validate_json(response.content)["components"])["schemas"])
shapes: Final = preference_shapes(schemas, schema)
assert {"type": "string", "enum": list(PREFERENCES)} in shapes, (schema, shapes)
assert {"type": "null"} in shapes, (schema, shapes)
def test_burst_reads_across_processes_while_the_preference_flips(gateway: Gateway, peer: Gateway) -> None:
name: Final = plugin_name()
targets: Final = (gateway, peer)
flips: Final = tuple(PREFERENCES[(index + 1) % len(PREFERENCES)] for index in range(13))
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="available")
def job(index: int) -> tuple[str, JsonValue]:
if index % 4 == 3:
return "flip", replace(
gateway, name, replacement(name, installation_preference=flips[index // 4])
).status_code
return "read", marketplace_entry(targets[index % 2], name).get("installationPreference")
with ThreadPoolExecutor(max_workers=8) as pool:
outcomes: Final = tuple(pool.map(job, range(53)))
assert [result for kind, result in outcomes if kind == "flip"] == [200] * len(flips), outcomes
observed: Final = tuple(result for kind, result in outcomes if kind == "read")
assert len(observed) == 40, outcomes
assert all(value in PREFERENCES for value in observed), observed
settled: Final = stored_manifest(name)["installation_preference"]
assert settled in PREFERENCES, settled
assert marketplace_entry(gateway, name)["installationPreference"] == settled
assert marketplace_entry(peer, name)["installationPreference"] == settled
assert len(stored_rows(name)) == 1
replaced(gateway, name, installation_preference="required")
assert_preference_served(gateway, name, "required")
assert_preference_served(peer, name, "required")
assert stored_manifest(name)["installation_preference"] == "required"
def test_repeated_identical_reads_are_stable(gateway: Gateway, peer: Gateway) -> None:
name: Final = plugin_name()
targets: Final = (gateway, peer)
with gateway.scenario() as scenario:
registered(scenario, name, installation_preference="required")
before: Final = listed_item(gateway, name)
bodies: Final = tuple(marketplace_entry(targets[index % 2], name) for index in range(10))
assert all(body == bodies[0] for body in bodies), bodies
assert bodies[0]["installationPreference"] == "required"
assert listed_item(peer, name) == before
assert len(stored_rows(name)) == 1
@pytest.mark.timeout(240)
def test_registration_burst_survives_a_worker_kill(gateway: Gateway, tmp_path: Path) -> None:
names: Final = tuple(plugin_name() for _ in range(20))
preferences: Final = tuple(PREFERENCES[index % len(PREFERENCES)] for index in range(len(names)))
with gateway.scenario() as scenario, owned_proxy_process(gateway, tmp_path, {}, workers=2) as owned:
for name in names:
scenario.cleanups.callback(delete_plugin, gateway, name)
workers: Final = eventually(owned.worker_pids, lambda found: len(found) == 2, seconds=30)
def submit(index: int) -> int:
try:
return owned.gateway.request(
"POST", PLUGINS, registration(names[index], installation_preference=preferences[index])
).status_code
except httpx.TransportError:
return -1
with ThreadPoolExecutor(max_workers=8) as pool:
futures: Final = tuple(pool.submit(submit, index) for index in range(len(names)))
eventually(lambda: sum(1 for future in futures if future.done()), lambda done: done >= 3, seconds=30)
owned.kill_worker(min(workers))
statuses: Final = tuple(future.result() for future in futures)
assert set(statuses) <= {200, -1}, statuses
assert statuses.count(200) >= 3, statuses
served: Final = tuple(marketplace_entries(owned.gateway, name) for name in names)
for index, found in enumerate(served):
assert len(found) == (1 if statuses[index] == 200 else len(found)), (names[index], statuses[index], found)
assert len(found) <= 1, (names[index], found)
assert all(entry["installationPreference"] == preferences[index] for entry in found), (names[index], found)
assert [marketplace_entries(gateway, name) for name in names] == list(served)
assert sum(len(stored_rows(name)) for name in names) == sum(len(found) for found in served)
@pytest.mark.timeout(240)
def test_registrations_survive_a_proxy_restart(gateway: Gateway, tmp_path: Path) -> None:
names: Final = tuple(plugin_name() for _ in range(10))
preferences: Final = tuple(PREFERENCES[index % len(PREFERENCES)] for index in range(len(names)))
with gateway.scenario() as scenario:
for name in names:
scenario.cleanups.callback(delete_plugin, gateway, name)
def submit(target: Gateway, index: int) -> int:
return target.request(
"POST", PLUGINS, registration(names[index], installation_preference=preferences[index])
).status_code
with owned_proxy_process(gateway, tmp_path, {}) as first, ThreadPoolExecutor(max_workers=8) as pool:
statuses: Final = tuple(pool.map(partial(submit, first.gateway), range(len(names))))
assert statuses == (200,) * len(names), statuses
with owned_proxy_process(gateway, tmp_path, {}) as second:
served: Final = tuple(marketplace_entry(second.gateway, name)["installationPreference"] for name in names)
assert served == preferences
listed: Final = second.gateway.request("GET", PLUGINS)
assert listed.status_code == 200, listed.text
plugins: Final = JSON_OBJECT.validate_json(listed.content)["plugins"]
assert isinstance(plugins, list), listed.text
assert sum(1 for plugin in plugins if object_value(plugin)["name"] in names) == len(names), listed.text
assert sum(len(stored_rows(name)) for name in names) == len(names)

View file

@ -4,20 +4,16 @@ Unit tests for claude_code_marketplace.py source validation.
Covers the git-subdir and archive source types added alongside the existing github and url types.
"""
from typing import Final
import json
from typing import Final
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,
@ -29,6 +25,12 @@ 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 (
MarketplaceResponse,
RegisterPluginRequest,
UpdatePluginRequest,
)
from tests._master_key import MASTER_KEY
@ -365,6 +367,225 @@ 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=_ARCHIVE_SOURCE),
user_api_key_dict=_USER,
)
response = await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(
source=_ARCHIVE_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 response.plugin.installation_preference == "auto_install"
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
@pytest.mark.parametrize("stored_value", ["auto-install", 1, True, None])
async def test_stored_unknown_installation_preference_is_not_served(stored_value, caplog):
name = "hand-edited-plugin"
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
await table.create(
data={
"name": name,
"manifest_json": json.dumps(
{"name": name, "source": _ARCHIVE_SOURCE, "installation_preference": stored_value}
),
}
)
marketplace = json.loads((await get_marketplace(request=MagicMock())).body)
listed_plugin = (await list_plugins(user_api_key_dict=_USER)).plugins[0]
plugin = await get_plugin(plugin_name=name, user_api_key_dict=_USER)
assert marketplace["plugins"] == [{"name": name, "source": _ARCHIVE_SOURCE}]
assert listed_plugin.installation_preference is None
assert plugin["installation_preference"] is None
unknown_value_warnings = [r for r in caplog.records if "unknown installation_preference" in r.getMessage()]
assert len(unknown_value_warnings) == (0 if stored_value is None else 3)
@pytest.mark.asyncio
async def test_register_accepts_the_camel_case_installation_preference_key():
request = RegisterPluginRequest.model_validate(
{"name": "s3-skill", "source": _ARCHIVE_SOURCE, "installationPreference": "auto_install"}
)
response = await register_plugin(request=request, user_api_key_dict=_USER)
marketplace = json.loads((await get_marketplace(request=MagicMock())).body)
assert response.plugin.installation_preference == "auto_install"
assert (await _read_stored_manifest("s3-skill"))["installation_preference"] == "auto_install"
assert marketplace["plugins"][0]["installationPreference"] == "auto_install"
_NON_ARCHIVE_SOURCES = [
{"source": "github", "repo": "org/repo"},
{"source": "url", "url": "https://github.com/org/repo.git"},
_GIT_SUBDIR_SOURCE,
]
_UNPINNED_ARCHIVE_SOURCE = {"source": "archive", "url": "https://artifacts.example.com/plugin.zip"}
async def _assert_preference_refused(source, preference):
with pytest.raises(HTTPException) as register_error:
await register_plugin(
request=RegisterPluginRequest(name="no-op-plugin", source=source, installation_preference=preference),
user_api_key_dict=_USER,
)
await register_plugin(request=RegisterPluginRequest(name="no-op-plugin", source=source), user_api_key_dict=_USER)
with pytest.raises(HTTPException) as update_error:
await update_plugin(
plugin_name="no-op-plugin",
request=UpdatePluginRequest(source=source, installation_preference=preference),
user_api_key_dict=_USER,
)
assert register_error.value.status_code == 400
assert update_error.value.status_code == 400
assert "installationPreference" not in json.loads((await get_marketplace(request=MagicMock())).body)["plugins"][0]
@pytest.mark.asyncio
@pytest.mark.parametrize("preference", ["available", "auto_install", "required"])
@pytest.mark.parametrize("source", _NON_ARCHIVE_SOURCES)
async def test_every_preference_needs_an_archive_source(preference, source):
await _assert_preference_refused(source, preference)
@pytest.mark.asyncio
@pytest.mark.parametrize("preference", ["auto_install", "required"])
async def test_self_installing_preference_needs_the_archive_sha256(preference):
await _assert_preference_refused(_UNPINNED_ARCHIVE_SOURCE, preference)
@pytest.mark.asyncio
async def test_available_preference_is_accepted_on_an_unpinned_archive():
response = await register_plugin(
request=RegisterPluginRequest(
name="listed-plugin", source=_UNPINNED_ARCHIVE_SOURCE, installation_preference="available"
),
user_api_key_dict=_USER,
)
marketplace = json.loads((await get_marketplace(request=MagicMock())).body)
assert response.plugin.installation_preference == "available"
assert marketplace["plugins"][0]["installationPreference"] == "available"
@pytest.mark.parametrize(
"build_request",
[
lambda body: RegisterPluginRequest.model_validate({"name": "s3-skill", **body}),
UpdatePluginRequest.model_validate,
],
ids=["register", "update"],
)
def test_plugin_request_rejects_both_preference_spellings(build_request):
with pytest.raises(ValidationError, match="not both"):
build_request(
{
"source": _ARCHIVE_SOURCE,
"installation_preference": "auto_install",
"installationPreference": "available",
}
)
@pytest.mark.parametrize(
"build_request",
[
lambda body: RegisterPluginRequest.model_validate({"name": "s3-skill", **body}),
UpdatePluginRequest.model_validate,
],
ids=["register", "update"],
)
@pytest.mark.parametrize(
"null_key, value_key",
[("installation_preference", "installationPreference"), ("installationPreference", "installation_preference")],
)
def test_plugin_request_reads_the_spelling_that_carries_a_value(build_request, null_key, value_key):
request = build_request({"source": _ARCHIVE_SOURCE, null_key: None, value_key: "auto_install"})
assert request.installation_preference == "auto_install"
@pytest.mark.asyncio
async def test_register_plugin_git_subdir_missing_url():
"""git-subdir without url field raises HTTP 400."""
@ -706,3 +927,40 @@ async def test_register_plugin_git_subdir():
assert response.plugin.source["url"] == "https://github.com/test-org/monorepo.git"
assert response.plugin.source["path"] == "plugins/my-plugin"
assert response.plugin.enabled is True
def test_marketplace_response_round_trips_installation_preference():
catalog: Final = {
"name": "litellm",
"owner": {"name": "LiteLLM", "email": "support@litellm.ai"},
"plugins": [
{
"name": "auto",
"source": {"source": "url", "url": "https://example.com/auto.tgz"},
"installationPreference": "auto_install",
},
{"name": "manual", "source": {"source": "url", "url": "https://example.com/manual.tgz"}},
],
}
round_tripped: Final = MarketplaceResponse.model_validate(catalog).model_dump(exclude_none=True)
assert round_tripped["plugins"][0]["installationPreference"] == "auto_install"
assert "installationPreference" not in round_tripped["plugins"][1]
def test_marketplace_response_rejects_unknown_installation_preference():
catalog: Final = {
"name": "litellm",
"owner": {"name": "LiteLLM", "email": "support@litellm.ai"},
"plugins": [
{
"name": "auto",
"source": {"source": "url", "url": "https://example.com/auto.tgz"},
"installationPreference": "sometimes",
}
],
}
with pytest.raises(ValidationError):
MarketplaceResponse.model_validate(catalog)

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
@ -1534,3 +1536,43 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa
response.content = b"{}"
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._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import register_plugin
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

@ -2449,6 +2449,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.
@ -2515,6 +2516,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.
@ -40238,6 +40240,8 @@ export interface components {
homepage?: string | null;
/** Id */
id: string;
/** Installation Preference */
installation_preference?: ("available" | "auto_install" | "required") | null;
/** Keywords */
keywords?: string[] | null;
/** Name */
@ -40273,6 +40277,11 @@ export interface components {
* @description Plugin unique ID
*/
id: string;
/**
* Installation Preference
* @description Plugin installation preference
*/
installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Name
* @description Plugin name
@ -41691,6 +41700,11 @@ export interface components {
* @description Plugin homepage URL
*/
homepage?: string | null;
/**
* Installation Preference
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
*/
installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords
@ -47848,6 +47862,11 @@ export interface components {
* @description Plugin homepage URL
*/
homepage?: string | null;
/**
* Installation Preference
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
*/
installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords