From c87a25a3571d5e9f4f146bd6cc69d5b74d65ec0d Mon Sep 17 00:00:00 2001 From: zoveki <349028329+zoveki@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:48:35 -0700 Subject: [PATCH] feat(proxy)!: general_settings.claude_code_marketplace_auth_required puts the Claude Code marketplace behind a LiteLLM key (#48980) * test(proxy): require a key for the Claude Code marketplace when configured and keep exception text out of its 500s * feat(proxy): general_settings.claude_code_marketplace_auth_required requires a LiteLLM key for the Claude Code marketplace When true, GET /claude-code/marketplace.json answers 401 without a key in Authorization, x-litellm-api-key, the litellm_key_header_name header, or ?key=, and GET /public/skill_hub returns an empty list. Both routes' 500 bodies carry a fixed message instead of the exception text. * fix(proxy): refuse ?key= and send WWW-Authenticate when the Claude Code marketplace requires a key * fix(proxy): close the marketplace key gate's outage, quoted-setting, and dashboard gaps Refuse the database-outage fallback identity with a 401, read a quoted "true" as on and an unreadable value as on, keep the skill hub's 500 detail a fixed string, and have the dashboard setup tab show a settings snippet with a key header when the marketplace requires a key * fix(proxy): keep the no-database 500, validate the marketplace key setting at boot, and show the key snippet until the probe answers * fix(claude-code): read the marketplace key setting from the UI config and answer database outages with 503 * fix(claude-code): keep the no-database hint on skill_hub and warn that the remove step uninstalls plugins --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- litellm/proxy/_types.py | 4 + .../claude_code_marketplace.py | 57 ++++- .../claude_code_marketplace_auth.py | 17 ++ .../ui_discovery_endpoints.py | 2 + litellm/proxy/proxy_server.py | 1 + .../public_endpoints/public_endpoints.py | 13 +- .../ui_discovery_endpoints.py | 1 + .../test_claude_code_marketplace.py | 211 +++++++++++++++++- .../test_ui_discovery_endpoints.py | 30 +++ .../proxy/proxy_server/test_proxy_config.py | 8 +- .../public_endpoints/test_public_endpoints.py | 72 ++++++ ...eClaudeCodeMarketplaceRequiresKey.test.tsx | 54 +++++ .../useClaudeCodeMarketplaceRequiresKey.ts | 4 + .../claude_code_plugins/helpers.test.ts | 16 +- .../components/claude_code_plugins/helpers.ts | 5 +- .../claude_code_plugins/skill_detail.test.tsx | 38 +++- .../claude_code_plugins/skill_detail.tsx | 100 ++++++--- ui/litellm-dashboard/src/lib/http/schema.d.ts | 20 +- 19 files changed, 600 insertions(+), 55 deletions(-) create mode 100644 litellm/proxy/common_utils/claude_code_marketplace_auth.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey.ts diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 01aa69ba5d2..dd964a74735 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -7816,7 +7816,7 @@ "paths": { "/claude-code/marketplace.json": { "get": { - "description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add \n- claude plugin install @\n\nWithout `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`\nthe key is authenticated and the catalog also holds the disabled plugins granted\nto it through `object_permission.skills` or an access group on the key or its team.\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin marketplace add \"http://localhost:4000/claude-code/marketplace.json?key=sk-...\"\n claude plugin install my-plugin@litellm\n ```", + "description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add \n- claude plugin install @\n\nWithout a key the catalog holds the enabled (public) plugins. With `?key=sk-...`\nthe key is authenticated and the catalog also holds the disabled plugins granted\nto it through `object_permission.skills` or an access group on the key or its team.\n\nWith `general_settings.claude_code_marketplace_auth_required: true` the key must come\nin the `Authorization` or `x-litellm-api-key` header (Claude Code sends them from the\nmarketplace source's `headers`), `?key=` is ignored, and a caller with no valid key\ngets a 401 with `WWW-Authenticate: Bearer`.\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin marketplace add \"http://localhost:4000/claude-code/marketplace.json?key=sk-...\"\n claude plugin install my-plugin@litellm\n ```", "operationId": "get_marketplace_claude_code_marketplace_json_get", "parameters": [ { diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index df139a9b84d..dd873ae49dd 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2833,6 +2833,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="If True, stores request messages and responses in spend logs. Default is False.", ) + claude_code_marketplace_auth_required: bool = Field( + False, + description="If True, GET /claude-code/marketplace.json requires a LiteLLM key in the Authorization or x-litellm-api-key header (?key= is not accepted) and answers 401 with WWW-Authenticate: Bearer without one, and GET /public/skill_hub returns an empty list. Default is False, which serves the enabled plugins to anyone.", + ) disable_requester_ip_address_logging: bool | None = Field( None, description="If True, new spend log rows store requester_ip_address as null in the column, the metadata and the stored request. The stored request also drops client-IP forwarding headers such as x-forwarded-for, and IP addresses in its other header values and in stored error messages are masked. Guardrails, metrics and callbacks that read the standard logging payload still receive the IP. Default is False.", 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 b0088d93112..fc82686947d 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 @@ -7,7 +7,8 @@ Actual plugin files are hosted on GitHub/GitLab/Bitbucket or as a zip archive on any HTTPS host (S3, Artifactory, a static file server). Endpoints: -/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (unauthenticated; `?key=` adds the key's granted skills) +/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (anonymous unless + general_settings.claude_code_marketplace_auth_required; a key adds its granted skills) /claude-code/plugins - POST - Register a new plugin (create-only, proxy admin only) /claude-code/plugins - GET - List plugins visible to the key (enabled, plus granted disabled ones) /claude-code/plugins/{name} - GET - Get plugin details (403 on a disabled plugin the key is not granted) @@ -31,14 +32,17 @@ from pydantic import TypeAdapter from typing_extensions import ReadOnly from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth +from litellm.proxy._types import CommonProxyErrors, ProxyErrorTypes, ProxyException, UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import ( SkillVisibility, resolve_skill_visibility, ) +from litellm.proxy.auth.auth_exception_handler import DB_UNAVAILABLE_FALLBACK_USER_ID from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.claude_code_marketplace_auth import marketplace_auth_required from litellm.proxy.common_utils.resource_ownership import is_proxy_admin from litellm.repositories.table_repositories import ClaudeCodePluginRepository +from litellm.types.proxy.auth.special_headers import SpecialHeaders from litellm.types.proxy.claude_code_endpoints import ( InstallationPreference, ListPluginsResponse, @@ -109,6 +113,41 @@ async def get_prisma_client() -> object: _get_prisma_client: Final = get_prisma_client +async def _header_key_caller(request: Request) -> UserAPIKeyAuth: + try: + caller: Final = await user_api_key_auth( + request=request, + api_key=request.headers.get(SpecialHeaders.openai_authorization.value) or "", + custom_litellm_key_header=request.headers.get(SpecialHeaders.custom_litellm_api_key.value), + ) + except ProxyException as e: + if e.code != "401": + raise + raise ProxyException( + message=e.message, + type=e.type, + param=e.param, + code=401, + headers={**e.headers, "WWW-Authenticate": "Bearer"}, + ) from e + if caller.user_id == DB_UNAVAILABLE_FALLBACK_USER_ID: + raise ProxyException( + message="The LiteLLM key could not be verified because the database is unavailable", + type=ProxyErrorTypes.no_db_connection, + param=None, + code=503, + ) + return caller + + +async def _marketplace_caller(request: Request, key: str | None) -> UserAPIKeyAuth | None: + if marketplace_auth_required(): + return await _header_key_caller(request) + if key: + return await user_api_key_auth(request=request, api_key=f"Bearer {key}") + return None + + @router.get( "/claude-code/marketplace.json", tags=["Claude Code Marketplace"], @@ -121,10 +160,15 @@ async def get_marketplace(request: Request, key: str | None = None): - claude plugin marketplace add - claude plugin install @ - Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...` + Without a key the catalog holds the enabled (public) plugins. With `?key=sk-...` the key is authenticated and the catalog also holds the disabled plugins granted to it through `object_permission.skills` or an access group on the key or its team. + With `general_settings.claude_code_marketplace_auth_required: true` the key must come + in the `Authorization` or `x-litellm-api-key` header (Claude Code sends them from the + marketplace source's `headers`), `?key=` is ignored, and a caller with no valid key + gets a 401 with `WWW-Authenticate: Bearer`. + Returns: Marketplace catalog with list of available plugins and their git sources. @@ -137,10 +181,7 @@ async def get_marketplace(request: Request, key: str | None = None): """ try: prisma_client: Final = await get_prisma_client() - - caller: Final[UserAPIKeyAuth | None] = ( - await user_api_key_auth(request=request, api_key=f"Bearer {key}") if key else None - ) + caller: Final = await _marketplace_caller(request, key) visibility: Final[SkillVisibility] = await resolve_skill_visibility(caller) plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many( where=visibility.where() @@ -201,7 +242,7 @@ async def get_marketplace(request: Request, key: str | None = None): verbose_proxy_logger.exception("Error generating marketplace: %s", e) raise HTTPException( status_code=500, - detail={"error": f"Failed to generate marketplace: {e}"}, + detail={"error": "Failed to generate marketplace"}, ) diff --git a/litellm/proxy/common_utils/claude_code_marketplace_auth.py b/litellm/proxy/common_utils/claude_code_marketplace_auth.py new file mode 100644 index 00000000000..0cfb3061744 --- /dev/null +++ b/litellm/proxy/common_utils/claude_code_marketplace_auth.py @@ -0,0 +1,17 @@ +from typing import Final + +from pydantic import TypeAdapter, ValidationError + +_AUTH_REQUIRED_SETTING: Final[TypeAdapter[bool | None]] = TypeAdapter(bool | None) + + +def marketplace_auth_required() -> bool: + from litellm.proxy.proxy_server import general_settings_view + + try: + return ( + _AUTH_REQUIRED_SETTING.validate_python(general_settings_view().get("claude_code_marketplace_auth_required")) + is True + ) + except ValidationError: + return True diff --git a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py index 792ef8a3f8c..36f424507d7 100644 --- a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -4,6 +4,7 @@ from typing import Final from fastapi import APIRouter +from litellm.proxy.common_utils.claude_code_marketplace_auth import marketplace_auth_required from litellm.proxy.common_utils.html_forms.default_credentials_hint import should_hide_default_credentials_hint from litellm.proxy.mcp_server.stdio_gate import is_mcp_stdio_enabled from litellm.types.proxy.discovery_endpoints.ui_discovery_endpoints import ( @@ -51,4 +52,5 @@ async def get_ui_config(): workers=proxy_config.worker_registry if is_control_plane else [], mcp_stdio_enabled=is_mcp_stdio_enabled(), use_si_terminology=use_si_terminology, + claude_code_marketplace_auth_required=marketplace_auth_required(), ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 5d034a72934..091e05610ba 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6978,6 +6978,7 @@ class ProxyConfig: for name in ( "vector_store_deny_by_default", "search_tool_deny_by_default", + "claude_code_marketplace_auth_required", "vertex_ai_pass_through_drop_anthropic_betas", "request_tag_mappings", ) diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 9f33e0d2adc..b8cdea0dea4 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -367,19 +367,23 @@ async def get_mcp_servers(): tags=["public", "Claude Code Marketplace"], ) async def public_skill_hub(): - """Return enabled (public) Claude Code skills — no auth required.""" + """Return enabled (public) Claude Code skills, or none when the marketplace requires a key.""" from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( MANIFEST_FIELDS, get_installation_preference, - get_prisma_client, ) + from litellm.proxy.common_utils.claude_code_marketplace_auth import marketplace_auth_required + from litellm.proxy.proxy_server import prisma_client from litellm.types.proxy.claude_code_endpoints import ( ListPluginsResponse, PluginListItem, ) + if marketplace_auth_required(): + return ListPluginsResponse(plugins=[], count=0) + if prisma_client is None: + raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) try: - prisma_client: Final = await get_prisma_client() plugins: Final = await _plugin_table(prisma_client).find_many(where={"enabled": True}) items: Final = [] for plugin in plugins: @@ -406,7 +410,8 @@ async def public_skill_hub(): ) return ListPluginsResponse(plugins=items, count=len(items)) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + verbose_logger.exception("Error listing public skills: %s", e) + raise HTTPException(status_code=500, detail="Failed to list skills") @router.get( diff --git a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py index d396c79b844..ca6ada0d1ec 100644 --- a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -15,4 +15,5 @@ class UiDiscoveryEndpoints(LiteLLMBaseModel): is_control_plane: bool = False mcp_stdio_enabled: bool = False use_si_terminology: bool = False + claude_code_marketplace_auth_required: bool = False workers: list[WorkerRegistryEntry] = Field(default=[]) diff --git a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 6f426a4a3a7..9c693ff1c3e 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -5,15 +5,19 @@ Covers the git-subdir and archive source types added alongside the existing gith """ import json +from collections.abc import Mapping +from types import MappingProxyType from typing import Final from unittest.mock import AsyncMock, MagicMock +import httpx import pytest from fastapi import HTTPException from pydantic import ValidationError import litellm -from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth +from litellm.caching.caching import DualCache +from litellm.proxy._types import CommonProxyErrors, LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth 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, @@ -25,7 +29,7 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp register_plugin, update_plugin, ) -from litellm.proxy.proxy_server import LitellmUserRoles +from litellm.proxy.proxy_server import LitellmUserRoles, hash_token from litellm.types.proxy.claude_code_endpoints import ( MarketplaceResponse, RegisterPluginRequest, @@ -964,3 +968,206 @@ def test_marketplace_response_rejects_unknown_installation_preference(): with pytest.raises(ValidationError): MarketplaceResponse.model_validate(catalog) + + +def _require_marketplace_auth(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + litellm.proxy.proxy_server, "general_settings", {"claude_code_marketplace_auth_required": True} + ) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", DualCache()) + + +async def _get_marketplace_over_http( + headers: Mapping[str, str] = MappingProxyType({}), params: Mapping[str, str] = MappingProxyType({}) +) -> httpx.Response: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=litellm.proxy.proxy_server.app), base_url="http://test" + ) as client: + return await client.get("/claude-code/marketplace.json", headers=dict(headers), params=dict(params)) + + +@pytest.mark.asyncio +async def test_marketplace_auth_required_refuses_an_anonymous_caller(monkeypatch: pytest.MonkeyPatch) -> None: + _require_marketplace_auth(monkeypatch) + await _register_public_and_private_plugins() + + response: Final = await _get_marketplace_over_http() + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert "public-skill" not in response.text + + +@pytest.mark.asyncio +async def test_marketplace_auth_required_refuses_a_key_in_the_query_string(monkeypatch: pytest.MonkeyPatch) -> None: + _require_marketplace_auth(monkeypatch) + await _register_public_and_private_plugins() + + response: Final = await _get_marketplace_over_http(params={"key": MASTER_KEY}) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert "public-skill" not in response.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "headers", + [ + {"Authorization": f"Bearer {MASTER_KEY}"}, + {"x-litellm-api-key": f"Bearer {MASTER_KEY}"}, + ], +) +async def test_marketplace_auth_required_serves_a_caller_with_a_valid_key( + monkeypatch: pytest.MonkeyPatch, headers: Mapping[str, str] +) -> None: + _require_marketplace_auth(monkeypatch) + await _register_public_and_private_plugins() + + response: Final = await _get_marketplace_over_http(headers) + + assert response.status_code == 200 + assert sorted(plugin["name"] for plugin in response.json()["plugins"]) == ["private-skill", "public-skill"] + + +@pytest.mark.asyncio +async def test_marketplace_auth_required_refuses_an_unknown_bearer_key(monkeypatch: pytest.MonkeyPatch) -> None: + _require_marketplace_auth(monkeypatch) + litellm.proxy.proxy_server.prisma_client.get_data = AsyncMock(return_value=None) + await _register_public_and_private_plugins() + + response: Final = await _get_marketplace_over_http({"Authorization": "Bearer sk-not-a-litellm-key"}) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert "public-skill" not in response.text + + +_LIMITED_KEY: Final = "sk-limited" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "limited_key_auth, status_code", + [ + ( + UserAPIKeyAuth( + token=hash_token(_LIMITED_KEY), + user_role=LitellmUserRoles.INTERNAL_USER, + allowed_routes=["/chat/completions"], + ), + 403, + ), + ( + UserAPIKeyAuth( + token=hash_token(_LIMITED_KEY), user_role=LitellmUserRoles.INTERNAL_USER, spend=2.0, max_budget=1.0 + ), + 422, + ), + ], + ids=["route-not-allowed", "over-budget"], +) +async def test_marketplace_auth_required_keeps_a_valid_key_refusal_status( + monkeypatch: pytest.MonkeyPatch, limited_key_auth: UserAPIKeyAuth, status_code: int +) -> None: + _require_marketplace_auth(monkeypatch) + litellm.proxy.proxy_server.user_api_key_cache.set_cache(key=hash_token(_LIMITED_KEY), value=limited_key_auth) + await _register_public_and_private_plugins() + + response: Final = await _get_marketplace_over_http({"Authorization": f"Bearer {_LIMITED_KEY}"}) + + assert response.status_code == status_code + assert "www-authenticate" not in response.headers + assert "public-skill" not in response.text + + +@pytest.mark.asyncio +async def test_marketplace_without_auth_required_ignores_an_unrelated_authorization_header(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {}) + await _register_public_and_private_plugins() + + response: Final = await _get_marketplace_over_http({"Authorization": "Bearer an-identity-aware-proxy-token"}) + + assert response.status_code == 200 + assert [plugin["name"] for plugin in response.json()["plugins"]] == ["public-skill"] + + +@pytest.mark.asyncio +async def test_marketplace_500_body_carries_no_exception_text(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {}) + table: Final = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable + table.find_many = AsyncMock(side_effect=RuntimeError("could not reach postgres://admin:hunter2@db.internal")) + + response: Final = await _get_marketplace_over_http() + + assert response.status_code == 500 + assert "hunter2" not in response.text + assert "db.internal" not in response.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("setting", ["true", "True", 1, "a typo"]) +async def test_marketplace_auth_required_treats_a_quoted_or_unreadable_setting_as_on( + monkeypatch: pytest.MonkeyPatch, setting: object +) -> None: + monkeypatch.setattr( + litellm.proxy.proxy_server, "general_settings", {"claude_code_marketplace_auth_required": setting} + ) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", DualCache()) + await _register_public_and_private_plugins() + + response: Final = await _get_marketplace_over_http() + + assert response.status_code == 401 + assert "public-skill" not in response.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("setting", ["false", False, 0, None]) +async def test_marketplace_auth_required_off_values_keep_the_public_catalog( + monkeypatch: pytest.MonkeyPatch, setting: object +) -> None: + monkeypatch.setattr( + litellm.proxy.proxy_server, "general_settings", {"claude_code_marketplace_auth_required": setting} + ) + await _register_public_and_private_plugins() + + response: Final = await _get_marketplace_over_http() + + assert response.status_code == 200 + assert [plugin["name"] for plugin in response.json()["plugins"]] == ["public-skill"] + + +@pytest.mark.asyncio +async def test_marketplace_auth_required_refuses_the_database_outage_fallback_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + litellm.proxy.proxy_server, + "general_settings", + {"claude_code_marketplace_auth_required": True, "allow_requests_on_db_unavailable": True}, + ) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", DualCache()) + await _register_public_and_private_plugins() + litellm.proxy.proxy_server.prisma_client.get_data = AsyncMock(side_effect=httpx.ConnectError("connection refused")) + litellm.proxy.proxy_server.prisma_client.attempt_db_reconnect = AsyncMock(return_value=False) + + response: Final = await _get_marketplace_over_http({"Authorization": "Bearer sk-not-a-litellm-key"}) + + assert response.status_code == 503 + assert "www-authenticate" not in response.headers + assert "public-skill" not in response.text + + +@pytest.mark.asyncio +async def test_marketplace_without_a_database_answers_db_not_connected_for_a_query_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {}) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", None) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", DualCache()) + + response: Final = await _get_marketplace_over_http(params={"key": "sk-not-the-master-key"}) + + assert response.status_code == 500 + assert CommonProxyErrors.db_not_connected_error.value in response.text diff --git a/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py index d338f38d862..6cdfd8139dc 100644 --- a/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py @@ -523,3 +523,33 @@ def test_ui_config_tells_the_dashboard_whether_to_use_si_terminology( assert response.status_code == 200 assert response.json()["use_si_terminology"] is expected + + +@pytest.mark.parametrize( + ("settings", "expected"), + [ + ({}, False), + ({"claude_code_marketplace_auth_required": False}, False), + ({"claude_code_marketplace_auth_required": True}, True), + ({"claude_code_marketplace_auth_required": "true"}, True), + ({"claude_code_marketplace_auth_required": "maybe"}, True), + ], +) +def test_ui_config_reports_whether_the_marketplace_requires_a_key_as_the_marketplace_reads_it( + monkeypatch: pytest.MonkeyPatch, settings: dict[str, object], expected: bool +) -> None: + import litellm.proxy.proxy_server + + monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", settings) + app: Final[FastAPI] = FastAPI() + app.include_router(router) + + with ( + patch("litellm.proxy.utils.get_server_root_path", return_value="/"), + patch("litellm.proxy.utils.get_proxy_base_url", return_value=None), + patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False), + ): + response: Final = TestClient(app).get("/.well-known/litellm-ui-config") + + assert response.status_code == 200 + assert response.json()["claude_code_marketplace_auth_required"] is expected diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index 20a4ca87d42..23dbe86ee5f 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -2660,7 +2660,9 @@ async def test_load_config_logs_disabled_budget_reservation_once(tmp_path, monke @pytest.mark.asyncio -@pytest.mark.parametrize("setting", ["vector_store_deny_by_default", "search_tool_deny_by_default"]) +@pytest.mark.parametrize( + "setting", ["vector_store_deny_by_default", "search_tool_deny_by_default", "claude_code_marketplace_auth_required"] +) @pytest.mark.parametrize(("yaml_value", "expected"), [("true", True), ("false", False)]) async def test_load_config_yaml_deny_by_default_is_boolean( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, setting: str, yaml_value: str, expected: bool @@ -2678,7 +2680,9 @@ async def test_load_config_yaml_deny_by_default_is_boolean( @pytest.mark.asyncio -@pytest.mark.parametrize("setting", ["vector_store_deny_by_default", "search_tool_deny_by_default"]) +@pytest.mark.parametrize( + "setting", ["vector_store_deny_by_default", "search_tool_deny_by_default", "claude_code_marketplace_auth_required"] +) @pytest.mark.parametrize("yaml_value", ["", "enabled"], ids=["null", "string"]) async def test_load_config_rejects_non_boolean_deny_by_default( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, setting: str, yaml_value: str diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index 072ef838e78..e694bca3c8d 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -1,6 +1,7 @@ import asyncio import json import re +from collections.abc import Mapping from datetime import datetime, timezone from importlib.resources import files from itertools import chain @@ -8,6 +9,7 @@ from types import SimpleNamespace from typing import Final from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -18,6 +20,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.health_check_latest import LatestHealthCheckRow from litellm.proxy.public_endpoints import router from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_presets +from litellm.types.proxy.common_proxy_errors import CommonProxyErrors from litellm.types.proxy.management_endpoints.model_management_endpoints import ( ModelGroupInfoProxy, ) @@ -1782,3 +1785,72 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference): assert [(p["name"], p["installation_preference"]) for p in response.json()["plugins"]] == [ ("security-review", preference) ] + + +_INTERNAL_PLUGIN: Final = SimpleNamespace( + id="plugin-id", + name="deploy-helper", + enabled=True, + created_at=None, + updated_at=None, + manifest_json=json.dumps( + { + "source": { + "source": "git-subdir", + "url": "https://gitlab.internal.example.com/platform/claude-skills.git", + "path": "plugins/deploy-helper", + } + } + ), +) + + +def _skill_hub_response(find_many: AsyncMock, general_settings: Mapping[str, object]) -> httpx.Response: + table: Final = MagicMock() + table.find_many = find_many + prisma: Final = MagicMock() + prisma.db.litellm_claudecodeplugintable = table + app: Final = FastAPI() + app.include_router(router) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", prisma), + patch("litellm.proxy.proxy_server.general_settings", dict(general_settings)), + ): + return TestClient(app).get("/public/skill_hub") + + +def test_public_skill_hub_is_empty_when_marketplace_auth_is_required() -> None: + find_many: Final = AsyncMock(return_value=[_INTERNAL_PLUGIN]) + + public: Final = _skill_hub_response(find_many, {}) + gated: Final = _skill_hub_response(find_many, {"claude_code_marketplace_auth_required": True}) + + assert [plugin["name"] for plugin in public.json()["plugins"]] == ["deploy-helper"] + assert gated.status_code == 200 + assert gated.json()["plugins"] == [] + assert "gitlab.internal.example.com" not in gated.text + + +def test_public_skill_hub_500_body_carries_no_exception_text() -> None: + response: Final = _skill_hub_response( + AsyncMock(side_effect=RuntimeError("could not reach postgres://admin:hunter2@db.internal")), {} + ) + + assert response.status_code == 500 + assert "hunter2" not in response.text + assert "db.internal" not in response.text + + +def test_public_skill_hub_without_a_database_answers_db_not_connected() -> None: + app: Final = FastAPI() + app.include_router(router) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch("litellm.proxy.proxy_server.general_settings", {}), + ): + response: Final = TestClient(app).get("/public/skill_hub") + + assert response.status_code == 500 + assert response.json() == {"detail": CommonProxyErrors.db_not_connected_error.value} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey.test.tsx new file mode 100644 index 00000000000..0cffcb82644 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useClaudeCodeMarketplaceRequiresKey } from "./useClaudeCodeMarketplaceRequiresKey"; + +const renderRequiresKey = (respond: () => Promise) => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(respond); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useClaudeCodeMarketplaceRequiresKey(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }); + const settled = () => + waitFor(() => + expect( + client + .getQueryCache() + .getAll() + .map((query) => query.state.status), + ).toEqual(["success"]), + ); + return { result, fetchSpy, settled }; +}; + +describe("useClaudeCodeMarketplaceRequiresKey", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + [{ claude_code_marketplace_auth_required: false }, false], + [{ claude_code_marketplace_auth_required: true }, true], + [{}, true], + ])("reads %o from the UI config as requires key=%s", async (config, requiresKey) => { + const { result, fetchSpy, settled } = renderRequiresKey( + async () => new Response(JSON.stringify(config), { status: 200 }), + ); + + await settled(); + expect(result.current).toBe(requiresKey); + expect(fetchSpy.mock.calls.map(([request]) => new URL((request as Request).url).pathname)).toEqual([ + "/.well-known/litellm-ui-config", + ]); + }); + + it("assumes a key is needed while the proxy has not answered yet", async () => { + const { result, fetchSpy } = renderRequiresKey(() => new Promise(() => {})); + + await waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + expect(result.current).toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey.ts new file mode 100644 index 00000000000..dec7098bf35 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey.ts @@ -0,0 +1,4 @@ +import { $api } from "@/lib/http/api"; + +export const useClaudeCodeMarketplaceRequiresKey = (): boolean => + $api.useQuery("get", "/.well-known/litellm-ui-config").data?.claude_code_marketplace_auth_required !== false; diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts index 5a4b8840c3e..ca1ee99cc27 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts @@ -16,8 +16,22 @@ import { } from "./helpers"; describe("buildMarketplaceSettingsSnippet", () => { + it("sends the key placeholder as a bearer header when the proxy requires a key", () => { + expect(JSON.parse(buildMarketplaceSettingsSnippet("https://proxy.example.com", true))).toEqual({ + extraKnownMarketplaces: { + litellm: { + source: { + source: "url", + url: "https://proxy.example.com/claude-code/marketplace.json", + headers: { Authorization: "Bearer " }, + }, + }, + }, + }); + }); + it("nests the url under a source object so Claude Code accepts the marketplace", () => { - expect(JSON.parse(buildMarketplaceSettingsSnippet("https://proxy.example.com"))).toEqual({ + expect(JSON.parse(buildMarketplaceSettingsSnippet("https://proxy.example.com", false))).toEqual({ extraKnownMarketplaces: { litellm: { source: { diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts index 52f860dc0ec..519d49ca704 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -240,7 +240,9 @@ export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourceP * bare `"url"` string, so the url/source pair is nested one level deeper. The key must be * "litellm" to match the name the proxy returns in marketplace.json. */ -export const buildMarketplaceSettingsSnippet = (proxyOrigin: string): string => +export const MARKETPLACE_KEY_PLACEHOLDER = ""; + +export const buildMarketplaceSettingsSnippet = (proxyOrigin: string, requiresKey: boolean): string => JSON.stringify( { extraKnownMarketplaces: { @@ -248,6 +250,7 @@ export const buildMarketplaceSettingsSnippet = (proxyOrigin: string): string => source: { source: "url", url: `${proxyOrigin}/claude-code/marketplace.json`, + ...(requiresKey ? { headers: { Authorization: `Bearer ${MARKETPLACE_KEY_PLACEHOLDER}` } } : {}), }, }, }, diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx index 92e18514f66..951f30fc9cc 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx @@ -1,10 +1,20 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { Plugin } from "./types"; import SkillDetail from "./skill_detail"; +const { mockUseMarketplaceRequiresKey } = vi.hoisted(() => ({ mockUseMarketplaceRequiresKey: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey", () => ({ + useClaudeCodeMarketplaceRequiresKey: () => mockUseMarketplaceRequiresKey(), +})); + +beforeEach(() => { + mockUseMarketplaceRequiresKey.mockReturnValue(false); +}); + const buildSkill = (source: Plugin["source"]): Plugin => ({ id: "plugin-id", name: "my-skill", @@ -55,3 +65,27 @@ describe("SkillDetail source", () => { expect(screen.queryByRole("link")).not.toBeInTheDocument(); }); }); + +describe("SkillDetail marketplace setup", () => { + const renderSetupTab = () => { + render(); + fireEvent.click(screen.getByText("How to Use")); + fireEvent.click(screen.getByText(/See one-time setup/)); + }; + + it("offers the add command and a keyless snippet when the marketplace is open", () => { + renderSetupTab(); + expect(screen.getByText(/^\/plugin marketplace add .*\/claude-code\/marketplace\.json$/)).toBeInTheDocument(); + expect(screen.queryByText(/Bearer/)).not.toBeInTheDocument(); + expect(screen.queryByText(/\/plugin marketplace remove/)).not.toBeInTheDocument(); + }); + + it("replaces the add command with a snippet carrying the key header when the marketplace requires a key", () => { + mockUseMarketplaceRequiresKey.mockReturnValue(true); + renderSetupTab(); + expect(screen.queryByText(/\/plugin marketplace add/)).not.toBeInTheDocument(); + expect(screen.getByText(/"Authorization": "Bearer "/)).toBeInTheDocument(); + expect(screen.getByText("/plugin marketplace remove litellm")).toBeInTheDocument(); + expect(screen.getByText(/uninstalls the plugins you installed from it/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index 9b03c25aade..658a0472699 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -1,7 +1,14 @@ import React, { useState } from "react"; import { ArrowLeft, Check, Copy, Link2 } from "lucide-react"; +import { useClaudeCodeMarketplaceRequiresKey } from "@/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey"; import { cn } from "@/lib/cva.config"; -import { buildMarketplaceSettingsSnippet, formatInstallCommand, getSourceDisplayText, getSourceLink } from "./helpers"; +import { + buildMarketplaceSettingsSnippet, + formatInstallCommand, + getSourceDisplayText, + getSourceLink, + MARKETPLACE_KEY_PLACEHOLDER, +} from "./helpers"; import { Plugin } from "./types"; const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => { @@ -45,6 +52,7 @@ interface SkillDetailProps { const SkillDetail: React.FC = ({ skill, onBack }) => { const [activeTab, setActiveTab] = useState("overview"); const [copiedKey, setCopiedKey] = useState(null); + const marketplaceRequiresKey = useClaudeCodeMarketplaceRequiresKey(); const copyToClipboard = (text: string, key: string) => { navigator.clipboard.writeText(text); @@ -56,6 +64,7 @@ const SkillDetail: React.FC = ({ skill, onBack }) => { const settingsSnippet = buildMarketplaceSettingsSnippet( typeof window !== "undefined" ? window.location.origin : "", + marketplaceRequiresKey, ); const detailRows = [ @@ -226,38 +235,65 @@ const SkillDetail: React.FC = ({ skill, onBack }) => {

One-time marketplace setup

- {/* Option 1: single command — fastest path for most users */} -

- Run this command in Claude Code to register the marketplace: -

-
-
- Run in Claude Code - -
-
-              {`/plugin marketplace add ${typeof window !== "undefined" ? window.location.origin : ""}/claude-code/marketplace.json`}
-            
-
+ {marketplaceRequiresKey ? ( + <> +
+

+ If you added this marketplace before without a key, remove it first in Claude Code. Removing it also + uninstalls the plugins you installed from it, so install them again from /plugin after you add the + snippet: +

+
+                  /plugin marketplace remove litellm
+                
+
+

+ This proxy requires a LiteLLM key to read the marketplace. Add this to{" "} + ~/.claude/settings.json and replace{" "} + {MARKETPLACE_KEY_PLACEHOLDER} with + your key: +

+ + ) : ( + <> + {/* Option 1: single command — fastest path for most users */} +

+ Run this command in Claude Code to register the marketplace: +

+
+
+ Run in Claude Code + +
+
+                  {`/plugin marketplace add ${typeof window !== "undefined" ? window.location.origin : ""}/claude-code/marketplace.json`}
+                
+
- {/* Option 2: settings.json — for persistent config or managed deployments. - extraKnownMarketplaces requires source to be a nested object, not a flat string. */} -

- Or add this to ~/.claude/settings.json{" "} - for a persistent configuration: -

+ {/* Option 2: settings.json — for persistent config or managed deployments. + extraKnownMarketplaces requires source to be a nested object, not a flat string. */} +

+ Or add this to{" "} + ~/.claude/settings.json for a + persistent configuration: +

+ + )}
~/.claude/settings.json diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 2147afc4931..f114c1c73d2 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2408,10 +2408,15 @@ export interface paths { * - claude plugin marketplace add * - claude plugin install @ * - * Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...` + * Without a key the catalog holds the enabled (public) plugins. With `?key=sk-...` * the key is authenticated and the catalog also holds the disabled plugins granted * to it through `object_permission.skills` or an access group on the key or its team. * + * With `general_settings.claude_code_marketplace_auth_required: true` the key must come + * in the `Authorization` or `x-litellm-api-key` header (Claude Code sends them from the + * marketplace source's `headers`), `?key=` is ignored, and a caller with no valid key + * gets a 401 with `WWW-Authenticate: Bearer`. + * * Returns: * Marketplace catalog with list of available plugins and their git sources. * @@ -13786,7 +13791,7 @@ export interface paths { }; /** * Public Skill Hub - * @description Return enabled (public) Claude Code skills — no auth required. + * @description Return enabled (public) Claude Code skills, or none when the marketplace requires a key. */ get: operations["public_skill_hub_public_skill_hub_get"]; put?: never; @@ -29890,6 +29895,12 @@ export interface components { claude_code_gateway_managed_settings?: { [key: string]: unknown; } | null; + /** + * Claude Code Marketplace Auth Required + * @description If True, GET /claude-code/marketplace.json requires a LiteLLM key in the Authorization or x-litellm-api-key header (?key= is not accepted) and answers 401 with WWW-Authenticate: Bearer without one, and GET /public/skill_hub returns an empty list. Default is False, which serves the enabled plugins to anyone. + * @default false + */ + claude_code_marketplace_auth_required: boolean; /** * Completion Model * @description proxy level default model for all chat completion calls @@ -48183,6 +48194,11 @@ export interface components { admin_ui_disabled: boolean; /** Auto Redirect To Sso */ auto_redirect_to_sso: boolean; + /** + * Claude Code Marketplace Auth Required + * @default false + */ + claude_code_marketplace_auth_required: boolean; /** Gateway Base Url */ gateway_base_url?: string | null; /**