From 26c99ce2529aa05aa4d949ade387ab6bac6606d5 Mon Sep 17 00:00:00 2001 From: Louis Lippmann Date: Mon, 19 Oct 2026 11:26:25 +0200 Subject: [PATCH 1/2] feat(claude-code): accept Authorization header on marketplace.json The marketplace only authenticated through ?key=, which puts the virtual key in access logs and saved marketplace URLs. It now also reads Authorization: Bearer, which Claude Code can send through a marketplace url source's headers or headersHelper. ?key= still works and wins when both are sent. --- .../claude_code_marketplace.py | 20 ++++-- .../test_claude_code_marketplace.py | 70 ++++++++++++++----- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index 8e348423837..abc2905b8ef 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,7 @@ 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 (unauthenticated; `Authorization: Bearer` or `?key=` adds the key's 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) @@ -108,6 +108,12 @@ async def get_prisma_client() -> object: _get_prisma_client: Final = get_prisma_client +def _marketplace_credential(request: Request, key: str | None) -> str | None: + if key: + return f"Bearer {key}" + return request.headers.get("authorization") or None + + @router.get( "/claude-code/marketplace.json", tags=["Claude Code Marketplace"], @@ -120,9 +126,11 @@ 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-...` - 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. + Without a credential the catalog holds the enabled (public) plugins. With an + `Authorization: Bearer sk-...` header or `?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. The header keeps + the key out of URLs and access logs; `?key=` wins when both are sent. Returns: Marketplace catalog with list of available plugins and their git sources. @@ -131,14 +139,16 @@ async def get_marketplace(request: Request, key: str | None = None): ```bash claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json claude plugin marketplace add "http://localhost:4000/claude-code/marketplace.json?key=sk-..." + curl -H "Authorization: Bearer sk-..." http://localhost:4000/claude-code/marketplace.json claude plugin install my-plugin@litellm ``` """ try: prisma_client: Final = await get_prisma_client() + credential: Final = _marketplace_credential(request, key) caller: Final[UserAPIKeyAuth | None] = ( - await user_api_key_auth(request=request, api_key=f"Bearer {key}") if key else None + await user_api_key_auth(request=request, api_key=credential) if credential else None ) visibility: Final[SkillVisibility] = await resolve_skill_visibility(caller) plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many( 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..dca661ceba9 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -9,7 +9,7 @@ from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest -from fastapi import HTTPException +from fastapi import HTTPException, Request from pydantic import ValidationError import litellm @@ -34,6 +34,11 @@ from litellm.types.proxy.claude_code_endpoints import ( from tests._master_key import MASTER_KEY +def _request(authorization: str | None = None) -> Request: + headers: Final = [] if authorization is None else [(b"authorization", authorization.encode())] + return Request({"type": "http", "headers": headers}) + + def _make_mock_prisma(): """Stateful prisma mock that supports find_unique, find_many, create, and update.""" store: dict = {} @@ -284,7 +289,7 @@ async def test_get_marketplace_skips_plugin_with_null_manifest(): table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable await table.create(data={"name": "null-manifest-plugin", "manifest_json": None, "enabled": True}) - response = await get_marketplace(request=MagicMock()) + response = await get_marketplace(request=_request()) assert response.status_code == 200 body = json.loads(response.body) @@ -340,14 +345,12 @@ async def test_get_plugin_returns_403_for_disabled_plugin_the_key_is_not_granted async def _marketplace_names(key: str | None) -> list[str]: - response = await get_marketplace(request=MagicMock(), key=key) + response = await get_marketplace(request=_request(), key=key) assert response.status_code == 200 return sorted(plugin["name"] for plugin in json.loads(response.body)["plugins"]) -@pytest.mark.asyncio -async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(monkeypatch): - await _register_public_and_private_plugins() +def _install_fake_key_auth(monkeypatch) -> None: keys = {"sk-granted": _granted_user(["private-skill"]), "sk-plain": _NON_ADMIN_USER} async def _fake_auth(request, api_key: str) -> UserAPIKeyAuth: @@ -358,15 +361,50 @@ async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(mon monkeypatch.setattr(claude_code_marketplace, "user_api_key_auth", _fake_auth) + +@pytest.mark.asyncio +async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(monkeypatch): + await _register_public_and_private_plugins() + _install_fake_key_auth(monkeypatch) + assert await _marketplace_names(None) == ["public-skill"] assert await _marketplace_names("sk-plain") == ["public-skill"] assert await _marketplace_names("sk-granted") == ["private-skill", "public-skill"] with pytest.raises(ProxyException) as exc_info: - await get_marketplace(request=MagicMock(), key="sk-bogus") + await get_marketplace(request=_request(), key="sk-bogus") assert exc_info.value.code == "401" +async def _marketplace_names_via_header(authorization: str | None, key: str | None = None) -> list[str]: + response = await get_marketplace(request=_request(authorization), key=key) + assert response.status_code == 200 + return sorted(plugin["name"] for plugin in json.loads(response.body)["plugins"]) + + +@pytest.mark.asyncio +async def test_get_marketplace_authorization_header_adds_granted_disabled_plugins(monkeypatch): + await _register_public_and_private_plugins() + _install_fake_key_auth(monkeypatch) + + assert await _marketplace_names_via_header(None) == ["public-skill"] + assert await _marketplace_names_via_header("Bearer sk-plain") == ["public-skill"] + assert await _marketplace_names_via_header("Bearer sk-granted") == ["private-skill", "public-skill"] + + with pytest.raises(ProxyException) as exc_info: + await _marketplace_names_via_header("Bearer sk-bogus") + assert exc_info.value.code == "401" + + +@pytest.mark.asyncio +async def test_get_marketplace_key_query_param_wins_over_authorization_header(monkeypatch): + await _register_public_and_private_plugins() + _install_fake_key_auth(monkeypatch) + + assert await _marketplace_names_via_header("Bearer sk-plain", key="sk-granted") == ["private-skill", "public-skill"] + assert await _marketplace_names_via_header("Bearer sk-granted", key="sk-plain") == ["public-skill"] + + @pytest.mark.asyncio @pytest.mark.parametrize("preference", ["available", "auto_install", "required"]) async def test_get_marketplace_emits_installation_preference(preference): @@ -379,7 +417,7 @@ async def test_get_marketplace_emits_installation_preference(preference): user_api_key_dict=_USER, ) - marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + marketplace = json.loads((await get_marketplace(request=_request())).body) assert marketplace["plugins"] == [ {"name": "s3-skill", "source": _ARCHIVE_SOURCE, "version": "1.0.0", "installationPreference": preference} @@ -408,7 +446,7 @@ async def test_get_marketplace_omits_installation_preference_when_unset(): user_api_key_dict=_USER, ) - marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + marketplace = json.loads((await get_marketplace(request=_request())).body) assert "installationPreference" not in marketplace["plugins"][0] @@ -452,7 +490,7 @@ async def test_update_plugin_without_installation_preference_clears_it(): user_api_key_dict=_USER, ) - marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + marketplace = json.loads((await get_marketplace(request=_request())).body) assert "installationPreference" not in marketplace["plugins"][0] @@ -470,7 +508,7 @@ async def test_stored_unknown_installation_preference_is_not_served(stored_value } ) - marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + marketplace = json.loads((await get_marketplace(request=_request())).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) @@ -488,7 +526,7 @@ async def test_register_accepts_the_camel_case_installation_preference_key(): ) response = await register_plugin(request=request, user_api_key_dict=_USER) - marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + marketplace = json.loads((await get_marketplace(request=_request())).body) assert response.plugin.installation_preference == "auto_install" assert (await _read_stored_manifest("s3-skill"))["installation_preference"] == "auto_install" @@ -519,7 +557,7 @@ async def _assert_preference_refused(source, preference): assert register_error.value.status_code == 400 assert update_error.value.status_code == 400 - assert "installationPreference" not in json.loads((await get_marketplace(request=MagicMock())).body)["plugins"][0] + assert "installationPreference" not in json.loads((await get_marketplace(request=_request())).body)["plugins"][0] @pytest.mark.asyncio @@ -543,7 +581,7 @@ async def test_available_preference_is_accepted_on_an_unpinned_archive(): ), user_api_key_dict=_USER, ) - marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + marketplace = json.loads((await get_marketplace(request=_request())).body) assert response.plugin.installation_preference == "available" assert marketplace["plugins"][0]["installationPreference"] == "available" @@ -704,7 +742,7 @@ async def test_archive_source_registers_and_is_served_verbatim_in_marketplace(): assert response.action == "created" assert response.plugin.source == _ARCHIVE_SOURCE - marketplace = json.loads((await get_marketplace(request=MagicMock())).body) + marketplace = json.loads((await get_marketplace(request=_request())).body) assert marketplace["plugins"] == [{"name": "s3-skill", "source": _ARCHIVE_SOURCE, "version": "1.0.0"}] @@ -878,7 +916,7 @@ async def test_get_marketplace(): user_api_key_dict=user_api_key_dict, ) - response: Final = await get_marketplace(request=MagicMock()) + response: Final = await get_marketplace(request=_request()) body: Final = json.loads(response.body.decode()) From 8403329516028e80446bd955b946a9021540ff76 Mon Sep 17 00:00:00 2001 From: Louis Lippmann Date: Mon, 19 Oct 2026 11:35:49 +0200 Subject: [PATCH 2/2] chore(claude-code): regenerate OpenAPI snapshot and dashboard types The route docstring is served as the OpenAPI description, so documenting the Authorization header changes both generated files. --- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 7198d74909c..0abe1ded968 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -7741,7 +7741,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 credential the catalog holds the enabled (public) plugins. With an\n`Authorization: Bearer sk-...` header or `?key=sk-...` the key is authenticated and\nthe catalog also holds the disabled plugins granted to it through\n`object_permission.skills` or an access group on the key or its team. The header keeps\nthe key out of URLs and access logs; `?key=` wins when both are sent.\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 curl -H \"Authorization: Bearer sk-...\" http://localhost:4000/claude-code/marketplace.json\n claude plugin install my-plugin@litellm\n ```", "operationId": "get_marketplace_claude_code_marketplace_json_get", "parameters": [ { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a111ed1d773..6cd8742992a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2384,9 +2384,11 @@ export interface paths { * - claude plugin marketplace add * - claude plugin install @ * - * Without `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. + * Without a credential the catalog holds the enabled (public) plugins. With an + * `Authorization: Bearer sk-...` header or `?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. The header keeps + * the key out of URLs and access logs; `?key=` wins when both are sent. * * Returns: * Marketplace catalog with list of available plugins and their git sources. @@ -2395,6 +2397,7 @@ export interface paths { * ```bash * claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json * claude plugin marketplace add "http://localhost:4000/claude-code/marketplace.json?key=sk-..." + * curl -H "Authorization: Bearer sk-..." http://localhost:4000/claude-code/marketplace.json * claude plugin install my-plugin@litellm * ``` */