diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 75dce43c84a8..4d5a69bd41b9 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -5704,6 +5704,22 @@ "title": "Id", "type": "string" }, + "installation_preference": { + "anyOf": [ + { + "enum": [ + "available", + "auto_install", + "required" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Installation Preference" + }, "keywords": { "anyOf": [ { @@ -5898,6 +5914,23 @@ "description": "Plugin homepage URL", "title": "Homepage" }, + "installation_preference": { + "anyOf": [ + { + "enum": [ + "available", + "auto_install", + "required" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the plugin is an archive source with a sha256", + "title": "Installation Preference" + }, "keywords": { "anyOf": [ { @@ -6048,6 +6081,23 @@ "description": "Plugin homepage URL", "title": "Homepage" }, + "installation_preference": { + "anyOf": [ + { + "enum": [ + "available", + "auto_install", + "required" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the plugin is an archive source with a sha256", + "title": "Installation Preference" + }, "keywords": { "anyOf": [ { @@ -6242,7 +6292,7 @@ ] }, "post": { - "description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket or as a zip archive on any https host (e.g. S3).\nClaude Code clones the git source or downloads the archive when users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Plugin source reference (github, url, git-subdir, or archive format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket or as a zip archive on any https host (e.g. S3).\nClaude Code clones the git source or downloads the archive when users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Plugin source reference (github, url, git-subdir, or archive format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n - installation_preference: 'available', 'auto_install', or 'required' (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", "operationId": "register_plugin_claude_code_plugins_post", "requestBody": { "content": { @@ -6377,7 +6427,7 @@ ] }, "put": { - "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Plugin source reference (github, url, git-subdir, or archive format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Plugin source reference (github, url, git-subdir, or archive format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n - installation_preference: 'available', 'auto_install', or 'required' (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", "operationId": "update_plugin_claude_code_plugins__plugin_name__put", "parameters": [ { 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 7c6a4571948e..1bfe3c3d3cd4 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,11 +21,12 @@ 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 typing_extensions import ReadOnly from litellm._logging import verbose_proxy_logger from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth @@ -37,6 +38,7 @@ from litellm.proxy.common_utils.resource_ownership import is_proxy_admin from litellm.repositories.table_repositories import ClaudeCodePluginRepository from litellm.types.proxy.claude_code_endpoints import ( + InstallationPreference, ListPluginsResponse, PluginListItem, PluginResponse, @@ -70,6 +72,15 @@ class _MarketplaceEntry(TypedDict, total=False): homepage: object keywords: object category: object + installationPreference: ReadOnly[InstallationPreference] + + +_INSTALLATION_PREFERENCES: Final[tuple[InstallationPreference, ...]] = get_args(InstallationPreference) + + +def _get_installation_preference(manifest: Mapping[str, object]) -> InstallationPreference | None: + value: Final = manifest.get("installation_preference") + return value if value in _INSTALLATION_PREFERENCES else None async def _get_prisma_client() -> object: @@ -136,10 +147,16 @@ async def get_marketplace(request: Request, key: str | None = None): verbose_proxy_logger.warning("Plugin %s has no source field, skipping", plugin.name) continue - entry: _MarketplaceEntry = { - "name": plugin.name, - "source": manifest["source"], - } + installation_preference = _get_installation_preference(manifest) + entry: _MarketplaceEntry = ( + {"name": plugin.name, "source": manifest["source"]} + if installation_preference is None + else { + "name": plugin.name, + "source": manifest["source"], + "installationPreference": installation_preference, + } + ) if plugin.version: entry["version"] = plugin.version @@ -306,6 +323,7 @@ async def register_plugin( - homepage: Plugin homepage URL (optional) - keywords: Search keywords (optional) - category: Plugin category (optional) + - installation_preference: 'available', 'auto_install', or 'required' (optional) Returns: Registration status (action is always "created") and plugin information. @@ -435,6 +453,7 @@ async def list_plugins( category=manifest.get("category"), domain=manifest.get("domain"), namespace=manifest.get("namespace"), + installation_preference=_get_installation_preference(manifest), enabled=p.enabled, created_at=p.created_at.isoformat() if p.created_at else None, updated_at=p.updated_at.isoformat() if p.updated_at else None, @@ -508,6 +527,7 @@ async def get_plugin( "homepage": manifest.get("homepage"), "keywords": manifest.get("keywords"), "category": manifest.get("category"), + "installation_preference": _get_installation_preference(manifest), "enabled": plugin.enabled, "created_at": plugin.created_at.isoformat() if plugin.created_at else None, "updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None, @@ -558,6 +578,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. diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index bba5ef681d02..5f56f2ef4abe 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/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index dcb5561cfeb2..5ba629d11a87 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,6 +45,14 @@ class PluginSpec(BaseModel): category: str | None = Field(None, description="Plugin category") domain: str | None = Field(None, description="Skill domain (e.g., 'Productivity')") namespace: str | None = Field(None, description="Skill namespace within domain (e.g., 'workflows')") + installation_preference: InstallationPreference | None = Field( + None, + description=( + "Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs " + "an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the " + "plugin is an archive source with a sha256" + ), + ) class RegisterPluginRequest(PluginSpec): @@ -104,6 +116,7 @@ class PluginListItem(BaseModel): category: str | None = None domain: str | None = None namespace: str | None = None + installation_preference: InstallationPreference | None = None enabled: bool created_at: str | None updated_at: str | None diff --git a/tests/integration/management/test_claude_code_plugin_installation_preference.py b/tests/integration/management/test_claude_code_plugin_installation_preference.py new file mode 100644 index 000000000000..37ffc05d4136 --- /dev/null +++ b/tests/integration/management/test_claude_code_plugin_installation_preference.py @@ -0,0 +1,529 @@ +import json +import signal +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 psutil +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 serving_workers(owned: OwnedProxy) -> tuple[psutil.Process, ...]: + port: Final = owned.gateway.client.base_url.port + return tuple( + child + for child in psutil.Process(owned.process.pid).children(recursive=True) + if any(conn.status == psutil.CONN_LISTEN and conn.laddr.port == port for conn in child.net_connections("inet")) + ) + + +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" + + +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(lambda: serving_workers(owned), 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) + workers[0].send_signal(signal.SIGKILL) + eventually(lambda: workers[0].is_running(), lambda alive: not alive, seconds=10) + statuses: Final = tuple(future.result() for future in futures) + assert set(statuses) <= {200, -1}, statuses + assert statuses.count(200) >= 3, statuses + eventually( + lambda: owned.gateway.client.get("/health/readiness").status_code, lambda code: code == 200, seconds=30 + ) + 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) 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 a585666743ff..5c8b0297b3ed 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,14 @@ """ import json +from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException -from unittest.mock import AsyncMock, MagicMock +from pydantic import ValidationError import litellm from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth -from litellm.proxy.proxy_server import LitellmUserRoles -from litellm.types.proxy.claude_code_endpoints import ( - RegisterPluginRequest, - UpdatePluginRequest, -) from litellm.proxy.anthropic_endpoints.claude_code_endpoints import claude_code_marketplace from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( delete_plugin, @@ -28,6 +24,11 @@ register_plugin, update_plugin, ) +from litellm.proxy.proxy_server import LitellmUserRoles +from litellm.types.proxy.claude_code_endpoints import ( + RegisterPluginRequest, + UpdatePluginRequest, +) def _make_mock_prisma(): @@ -363,6 +364,117 @@ async def _fake_auth(request, api_key: str) -> UserAPIKeyAuth: assert exc_info.value.code == "401" +@pytest.mark.asyncio +@pytest.mark.parametrize("preference", ["available", "auto_install", "required"]) +async def test_get_marketplace_emits_installation_preference(preference): + await register_plugin( + request=RegisterPluginRequest( + name="s3-skill", + source=_ARCHIVE_SOURCE, + installation_preference=preference, + ), + user_api_key_dict=_USER, + ) + + marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + + assert marketplace["plugins"] == [ + {"name": "s3-skill", "source": _ARCHIVE_SOURCE, "version": "1.0.0", "installationPreference": preference} + ] + + +@pytest.mark.parametrize( + "build_request", + [ + lambda preference: RegisterPluginRequest( + name="s3-skill", source=_ARCHIVE_SOURCE, installation_preference=preference + ), + lambda preference: UpdatePluginRequest(source=_ARCHIVE_SOURCE, installation_preference=preference), + ], + ids=["register", "update"], +) +def test_plugin_request_rejects_unknown_installation_preference(build_request): + with pytest.raises(ValidationError, match="installation_preference"): + build_request("auto-install") + + +@pytest.mark.asyncio +async def test_get_marketplace_omits_installation_preference_when_unset(): + await register_plugin( + request=RegisterPluginRequest(name="manual-install-plugin", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + + assert "installationPreference" not in marketplace["plugins"][0] + + +@pytest.mark.asyncio +async def test_update_plugin_propagates_installation_preference_to_get_and_list(): + name = "updated-install-plugin" + await register_plugin( + request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + await update_plugin( + plugin_name=name, + request=UpdatePluginRequest( + source=_GIT_SUBDIR_SOURCE, + installation_preference="auto_install", + ), + user_api_key_dict=_USER, + ) + + plugin = await get_plugin(plugin_name=name, user_api_key_dict=_USER) + listed_plugin = (await list_plugins(user_api_key_dict=_USER)).plugins[0] + + assert plugin["installation_preference"] == "auto_install" + assert listed_plugin.installation_preference == "auto_install" + + +@pytest.mark.asyncio +async def test_update_plugin_without_installation_preference_clears_it(): + name = "cleared-install-plugin" + await register_plugin( + request=RegisterPluginRequest(name=name, source=_ARCHIVE_SOURCE, installation_preference="auto_install"), + user_api_key_dict=_USER, + ) + + await update_plugin( + plugin_name=name, + request=UpdatePluginRequest(source=_ARCHIVE_SOURCE), + user_api_key_dict=_USER, + ) + + marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + assert "installationPreference" not in marketplace["plugins"][0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stored_value", ["auto-install", 1, True, None]) +async def test_stored_unknown_installation_preference_is_not_served(stored_value): + 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 + + @pytest.mark.asyncio async def test_register_plugin_git_subdir_missing_url(): """git-subdir without url field raises HTTP 400.""" 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 18839a65d623..b05a9948bcf5 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) + ] diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 93f4718a5864..70002792c972 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2344,6 +2344,7 @@ export interface paths { * - homepage: Plugin homepage URL (optional) * - keywords: Search keywords (optional) * - category: Plugin category (optional) + * - installation_preference: 'available', 'auto_install', or 'required' (optional) * * Returns: * Registration status (action is always "created") and plugin information. @@ -2410,6 +2411,7 @@ export interface paths { * - homepage: Plugin homepage URL (optional) * - keywords: Search keywords (optional) * - category: Plugin category (optional) + * - installation_preference: 'available', 'auto_install', or 'required' (optional) * * Returns: * Update status (action is always "updated") and plugin information. @@ -38001,6 +38003,8 @@ export interface components { homepage?: string | null; /** Id */ id: string; + /** Installation Preference */ + installation_preference?: ("available" | "auto_install" | "required") | null; /** Keywords */ keywords?: string[] | null; /** Name */ @@ -39452,6 +39456,11 @@ export interface components { * @description Plugin homepage URL */ homepage?: string | null; + /** + * Installation Preference + * @description Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the plugin is an archive source with a sha256 + */ + installation_preference?: ("available" | "auto_install" | "required") | null; /** * Keywords * @description Search keywords @@ -44871,6 +44880,11 @@ export interface components { * @description Plugin homepage URL */ homepage?: string | null; + /** + * Installation Preference + * @description Emitted as installationPreference on this plugin's marketplace.json entry. Claude Desktop auto-installs an 'auto_install' plugin when the marketplace is served from its inference gateway origin and the plugin is an archive source with a sha256 + */ + installation_preference?: ("available" | "auto_install" | "required") | null; /** * Keywords * @description Search keywords