mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
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>
This commit is contained in:
parent
e8f0735d8a
commit
c87a25a357
19 changed files with 600 additions and 55 deletions
|
|
@ -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 <url>\n- claude plugin install <name>@<marketplace>\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 <url>\n- claude plugin install <name>@<marketplace>\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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 <url>
|
||||
- claude plugin install <name>@<marketplace>
|
||||
|
||||
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"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
17
litellm/proxy/common_utils/claude_code_marketplace_auth.py
Normal file
17
litellm/proxy/common_utils/claude_code_marketplace_auth.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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=[])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<Response>) => {
|
||||
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 }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
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<Response>(() => {}));
|
||||
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 <your-litellm-key>" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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 = "<your-litellm-key>";
|
||||
|
||||
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}` } } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(<SkillDetail skill={buildSkill({ source: "github", repo: "org/repo" })} onBack={vi.fn()} />);
|
||||
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 <your-litellm-key>"/)).toBeInTheDocument();
|
||||
expect(screen.getByText("/plugin marketplace remove litellm")).toBeInTheDocument();
|
||||
expect(screen.getByText(/uninstalls the plugins you installed from it/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SkillDetailProps> = ({ skill, onBack }) => {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const marketplaceRequiresKey = useClaudeCodeMarketplaceRequiresKey();
|
||||
|
||||
const copyToClipboard = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
|
|
@ -56,6 +64,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
|
||||
const settingsSnippet = buildMarketplaceSettingsSnippet(
|
||||
typeof window !== "undefined" ? window.location.origin : "<proxy-url>",
|
||||
marketplaceRequiresKey,
|
||||
);
|
||||
|
||||
const detailRows = [
|
||||
|
|
@ -226,38 +235,65 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
<div className="max-w-[640px]">
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">One-time marketplace setup</h2>
|
||||
|
||||
{/* Option 1: single command — fastest path for most users */}
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Run this command in Claude Code to register the marketplace:
|
||||
</p>
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-4 py-2.5">
|
||||
<span className="text-[13px] font-medium text-foreground">Run in Claude Code</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
copyToClipboard(`/plugin marketplace add ${origin}/claude-code/marketplace.json`, "marketplace-cmd");
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-xs",
|
||||
copiedKey === "marketplace-cmd" ? "text-success" : "text-info",
|
||||
)}
|
||||
>
|
||||
{copiedKey === "marketplace-cmd" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedKey === "marketplace-cmd" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-[13px] text-foreground">
|
||||
{`/plugin marketplace add ${typeof window !== "undefined" ? window.location.origin : "<proxy-url>"}/claude-code/marketplace.json`}
|
||||
</pre>
|
||||
</div>
|
||||
{marketplaceRequiresKey ? (
|
||||
<>
|
||||
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3">
|
||||
<p className="m-0 mb-2 text-[13px] leading-relaxed text-muted-foreground">
|
||||
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:
|
||||
</p>
|
||||
<pre className="m-0 bg-transparent font-mono text-[13px] text-foreground">
|
||||
/plugin marketplace remove litellm
|
||||
</pre>
|
||||
</div>
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
This proxy requires a LiteLLM key to read the marketplace. Add this to{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">~/.claude/settings.json</code> and replace{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">{MARKETPLACE_KEY_PLACEHOLDER}</code> with
|
||||
your key:
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Option 1: single command — fastest path for most users */}
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Run this command in Claude Code to register the marketplace:
|
||||
</p>
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-4 py-2.5">
|
||||
<span className="text-[13px] font-medium text-foreground">Run in Claude Code</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
copyToClipboard(
|
||||
`/plugin marketplace add ${origin}/claude-code/marketplace.json`,
|
||||
"marketplace-cmd",
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-xs",
|
||||
copiedKey === "marketplace-cmd" ? "text-success" : "text-info",
|
||||
)}
|
||||
>
|
||||
{copiedKey === "marketplace-cmd" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedKey === "marketplace-cmd" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-[13px] text-foreground">
|
||||
{`/plugin marketplace add ${typeof window !== "undefined" ? window.location.origin : "<proxy-url>"}/claude-code/marketplace.json`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Option 2: settings.json — for persistent config or managed deployments.
|
||||
extraKnownMarketplaces requires source to be a nested object, not a flat string. */}
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Or add this to <code className="rounded bg-muted px-1.5 py-px text-[13px]">~/.claude/settings.json</code>{" "}
|
||||
for a persistent configuration:
|
||||
</p>
|
||||
{/* Option 2: settings.json — for persistent config or managed deployments.
|
||||
extraKnownMarketplaces requires source to be a nested object, not a flat string. */}
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Or add this to{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">~/.claude/settings.json</code> for a
|
||||
persistent configuration:
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-4 py-2.5">
|
||||
<span className="text-[13px] font-medium text-foreground">~/.claude/settings.json</span>
|
||||
|
|
|
|||
20
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
20
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -2408,10 +2408,15 @@ export interface paths {
|
|||
* - claude plugin marketplace add <url>
|
||||
* - claude plugin install <name>@<marketplace>
|
||||
*
|
||||
* 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;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue