diff --git a/litellm/models/object_permission.py b/litellm/models/object_permission.py index 3052a2af459..7ab70d86762 100644 --- a/litellm/models/object_permission.py +++ b/litellm/models/object_permission.py @@ -25,3 +25,4 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase): blocked_tools: Optional[List[str]] = [] search_tools: Optional[List[str]] = [] mcp_tool_search_enabled: Optional[bool] = None + allowed_skills: Optional[List[str]] = None diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index f441a1d3f84..ab66e2b98bc 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1008,6 +1008,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase): models: Optional[List[str]] = None search_tools: Optional[List[str]] = None mcp_tool_search_enabled: Optional[bool] = None + allowed_skills: Optional[List[str]] = None from litellm.types.object_permission import ( # noqa: E402 diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/__init__.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/__init__.py index 0d1a5a20836..54ee0b8284b 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/__init__.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/__init__.py @@ -7,5 +7,10 @@ Provides endpoints for Claude Code plugin marketplace integration. from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( router as claude_code_marketplace_router, ) +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sources import ( + router as claude_code_marketplace_sources_router, +) + +claude_code_marketplace_router.include_router(claude_code_marketplace_sources_router) __all__ = ["claude_code_marketplace_router"] 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 7d69e09b8da..5341bb97b2a 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 @@ -18,14 +18,25 @@ Endpoints: import json import re from datetime import datetime, timezone -from typing import Any, Dict +from typing import Any, Dict, FrozenSet, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request, Security from fastapi.responses import JSONResponse from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_authz import ( + get_allowed_skills, +) +from litellm.proxy.auth.user_api_key_auth import ( + anthropic_api_key_header, + api_key_header, + azure_api_key_header, + azure_apim_header, + custom_litellm_key_header, + google_ai_studio_api_key_header, + user_api_key_auth, +) from litellm.repositories.table_repositories import ClaudeCodePluginRepository from litellm.types.proxy.claude_code_endpoints import ( ListPluginsResponse, @@ -36,6 +47,34 @@ from litellm.types.proxy.claude_code_endpoints import ( router = APIRouter() +async def _optional_user_api_key_auth( + request: Request, + api_key: str = Security(api_key_header), + azure_api_key: str = Security(azure_api_key_header), + anthropic_api_key: Optional[str] = Security(anthropic_api_key_header), + google_ai_studio_api_key: Optional[str] = Security(google_ai_studio_api_key_header), + azure_apim_key: Optional[str] = Security(azure_apim_header), + custom_litellm_key: Optional[str] = Security(custom_litellm_key_header), +) -> Optional[UserAPIKeyAuth]: + """ + Resolve UserAPIKeyAuth if a key is present (header or, for this route, + the `key` query param wired up via RouteChecks.is_claude_code_marketplace_route), + but never raise - this route must stay accessible with no key at all. + """ + try: + return await user_api_key_auth( + request=request, + api_key=api_key, + azure_api_key_header=azure_api_key, + anthropic_api_key_header=anthropic_api_key, + google_ai_studio_api_key_header=google_ai_studio_api_key, + azure_apim_header=azure_apim_key, + custom_litellm_key_header=custom_litellm_key, + ) + except (HTTPException, ProxyException): + return None + + async def _get_prisma_client(): """Get the prisma client from proxy_server.""" from litellm.proxy.proxy_server import prisma_client @@ -52,7 +91,9 @@ async def _get_prisma_client(): "/claude-code/marketplace.json", tags=["Claude Code Marketplace"], ) -async def get_marketplace(): +async def get_marketplace( + user_api_key_dict: Optional[UserAPIKeyAuth] = Depends(_optional_user_api_key_auth), # noqa: B008 # DI idiom +): """ Serve marketplace.json for Claude Code plugin discovery. @@ -60,6 +101,10 @@ async def get_marketplace(): - claude plugin marketplace add - claude plugin install @ + No key is required - unauthenticated requests see every enabled plugin. + A valid key (header, or `?key=` query param for the CLI) additionally + unlocks any plugin whose name is in that key/team/org's allowed_skills. + Returns: Marketplace catalog with list of available plugins and their git sources. @@ -72,7 +117,18 @@ async def get_marketplace(): try: prisma_client = await _get_prisma_client() - plugins = await ClaudeCodePluginRepository(prisma_client).table.find_many(where={"enabled": True}) + allowed_skills: FrozenSet[str] = ( + await get_allowed_skills(user_api_key_dict, prisma_client) + if user_api_key_dict is not None + else frozenset() + ) + where = ( + {"OR": [{"enabled": True}, {"name": {"in": list(allowed_skills)}}]} + if allowed_skills + else {"enabled": True} + ) + + plugins = await ClaudeCodePluginRepository(prisma_client).table.find_many(where=where) plugin_list = [] for plugin in plugins: diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_backfill.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_backfill.py new file mode 100644 index 00000000000..e5580f75d42 --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_backfill.py @@ -0,0 +1,47 @@ +"""Startup backfill for the default Claude Code skill marketplace. + +Ensures a ``LiteLLM_SkillMarketplaceTable`` row named ``litellm`` (source_type +``managed``) exists, then attaches every pre-existing ``LiteLLM_ClaudeCodePluginTable`` +row with a null ``marketplace_id`` to it. Those rows predate the multi-marketplace +feature and were hand-registered directly, so they belong to the default marketplace +by definition - this only stamps the FK, it never renames or re-enables anything. +Idempotent: a healed fleet has no null-``marketplace_id`` rows and the backfill exits +after two queries. +""" + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.utils import PrismaClient + +DEFAULT_MARKETPLACE_NAME = "litellm" + + +async def backfill_default_skill_marketplace(prisma_client: PrismaClient) -> int: + """Upsert the default marketplace row and backfill orphaned plugin rows onto it. + + Returns the number of plugin rows backfilled. + """ + default_marketplace = await prisma_client.db.litellm_skillmarketplacetable.upsert( + where={"name": DEFAULT_MARKETPLACE_NAME}, + data={ + "create": { + "name": DEFAULT_MARKETPLACE_NAME, + "display_name": "LiteLLM (default)", + "source_type": "managed", + }, + "update": {}, + }, + ) + + orphaned = await prisma_client.db.litellm_claudecodeplugintable.update_many( + where={"marketplace_id": None}, + data={"marketplace_id": default_marketplace.id}, + ) + orphaned_count = orphaned if isinstance(orphaned, int) else getattr(orphaned, "count", 0) + + if orphaned_count: + verbose_proxy_logger.info( + "skill marketplace backfill: attached %d pre-existing plugin row(s) to the default '%s' marketplace", + orphaned_count, + DEFAULT_MARKETPLACE_NAME, + ) + return orphaned_count diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_sources.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_sources.py new file mode 100644 index 00000000000..d954e648273 --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_sources.py @@ -0,0 +1,314 @@ +""" +CLAUDE CODE MARKETPLACE SOURCES + +Manages external Claude Code marketplace sources (git repos / marketplace.json URLs) +that LiteLLM syncs plugin listings from into LiteLLM_ClaudeCodePluginTable. + +Endpoints: +/claude-code/marketplaces - POST - Register an external marketplace source +/claude-code/marketplaces - GET - List registered marketplace sources +/claude-code/marketplaces/{name} - GET - Get a marketplace source's details +/claude-code/marketplaces/{name}/sync - POST - Re-sync a marketplace source +/claude-code/marketplaces/{name} - DELETE - Disable a marketplace source and its plugins +""" + +import re +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sync import ( + MarketplaceRow, + resolve_and_sync, +) +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.repositories.table_repositories import ( + ClaudeCodePluginRepository, + SkillMarketplaceRepository, +) +from litellm.types.proxy.claude_code_endpoints import ( + ListMarketplacesResponse, + MarketplaceSourceResponse, + RegisterMarketplaceRequest, + SyncMarketplaceResponse, +) + +router = APIRouter() + +_MARKETPLACE_NAME_RE = re.compile(r"^[a-z0-9-]+$") + + +async def _get_prisma_client(): + """Get the prisma client from proxy_server.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + return prisma_client + + +def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None: + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail={"error": "Only proxy admins can manage Claude Code marketplace sources"}, + ) + + +def _derive_marketplace_slug(source: str) -> str: + trimmed = source.rstrip("/") + trimmed = trimmed[: -len(".git")] if trimmed.endswith(".git") else trimmed + last_segment = trimmed.rsplit("/", 1)[-1] + return re.sub(r"[^a-z0-9]+", "-", last_segment.lower()).strip("-") + + +async def _resolve_marketplace_name(prisma_client, request: RegisterMarketplaceRequest) -> str: + slug = request.name if request.name else _derive_marketplace_slug(request.source) + + if not slug or not _MARKETPLACE_NAME_RE.match(slug): + raise HTTPException( + status_code=400, + detail={"error": "Marketplace name must be kebab-case (lowercase letters, numbers, hyphens)"}, + ) + + existing = await SkillMarketplaceRepository(prisma_client).table.find_unique(where={"name": slug}) + if existing: + raise HTTPException( + status_code=400, + detail={"error": f"Marketplace '{slug}' already exists"}, + ) + + return slug + + +async def _get_marketplace_or_404(prisma_client, name: str): + marketplace = await SkillMarketplaceRepository(prisma_client).table.find_unique(where={"name": name}) + if not marketplace: + raise HTTPException( + status_code=404, + detail={"error": f"Marketplace '{name}' not found"}, + ) + return marketplace + + +async def _count_plugins(prisma_client, marketplace_id: str) -> int: + return await ClaudeCodePluginRepository(prisma_client).table.count(where={"marketplace_id": marketplace_id}) + + +def _to_marketplace_source_response(marketplace, plugin_count: Optional[int]) -> MarketplaceSourceResponse: + return MarketplaceSourceResponse( + id=marketplace.id, + name=marketplace.name, + display_name=marketplace.display_name, + source_type=marketplace.source_type, + source_ref=marketplace.source_ref, + branch=marketplace.branch, + enabled=marketplace.enabled, + sync_status=marketplace.sync_status, + sync_error=marketplace.sync_error, + last_synced_at=marketplace.last_synced_at.isoformat() if marketplace.last_synced_at else None, + plugin_count=plugin_count, + created_at=marketplace.created_at.isoformat() if marketplace.created_at else None, + updated_at=marketplace.updated_at.isoformat() if marketplace.updated_at else None, + ) + + +async def _sync_and_build_response(prisma_client, marketplace) -> SyncMarketplaceResponse: + marketplace_row = MarketplaceRow( + id=marketplace.id, + name=marketplace.name, + source_ref=marketplace.source_ref, + branch=marketplace.branch, + ) + result = await resolve_and_sync(prisma_client, marketplace_row) + refreshed = await SkillMarketplaceRepository(prisma_client).table.find_unique(where={"id": marketplace.id}) + return SyncMarketplaceResponse( + status=result.status, + marketplace=_to_marketplace_source_response(refreshed, result.plugin_count), + ) + + +@router.post( + "/claude-code/marketplaces", + tags=["Claude Code Marketplace"], + dependencies=[Depends(user_api_key_auth)], + response_model=SyncMarketplaceResponse, +) +async def register_marketplace( + request: RegisterMarketplaceRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom +): + """ + Register and sync an external Claude Code marketplace source. + + Parameters: + - source: 'org/repo' shorthand, a git URL, or a direct URL to a marketplace.json file + - name: Marketplace slug to register under (optional, derived from source if omitted) + + Returns: + Sync status and the registered marketplace's details. + """ + try: + prisma_client = await _get_prisma_client() + _require_proxy_admin(user_api_key_dict) + + name = await _resolve_marketplace_name(prisma_client, request) + + marketplace = await SkillMarketplaceRepository(prisma_client).table.create( + data={ + "name": name, + "display_name": name, + "source_type": "claude_marketplace_json", + "source_ref": request.source, + "sync_status": "pending", + "created_by": user_api_key_dict.user_id, + } + ) + + verbose_proxy_logger.info(f"Marketplace {name} registered, syncing now") + return await _sync_and_build_response(prisma_client, marketplace) + + except HTTPException: + raise + except Exception as e: # noqa: BLE001 # top-level endpoint boundary + verbose_proxy_logger.exception(f"Error registering marketplace: {e}") + raise HTTPException( + status_code=500, + detail={"error": f"Registration failed: {str(e)}"}, + ) + + +@router.get( + "/claude-code/marketplaces", + tags=["Claude Code Marketplace"], + dependencies=[Depends(user_api_key_auth)], + response_model=ListMarketplacesResponse, +) +async def list_marketplaces( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom +): + """List all registered marketplace sources.""" + try: + prisma_client = await _get_prisma_client() + _require_proxy_admin(user_api_key_dict) + + marketplaces = await SkillMarketplaceRepository(prisma_client).table.find_many() + + marketplace_list = [ + _to_marketplace_source_response(marketplace, await _count_plugins(prisma_client, marketplace.id)) + for marketplace in marketplaces + ] + + return ListMarketplacesResponse(marketplaces=marketplace_list, count=len(marketplace_list)) + + except HTTPException: + raise + except Exception as e: # noqa: BLE001 # top-level endpoint boundary + verbose_proxy_logger.exception(f"Error listing marketplaces: {e}") + raise HTTPException( + status_code=500, + detail={"error": str(e)}, + ) + + +@router.get( + "/claude-code/marketplaces/{marketplace_name}", + tags=["Claude Code Marketplace"], + dependencies=[Depends(user_api_key_auth)], + response_model=MarketplaceSourceResponse, +) +async def get_marketplace_source( + marketplace_name: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom +): + """Get details of a registered marketplace source.""" + try: + prisma_client = await _get_prisma_client() + _require_proxy_admin(user_api_key_dict) + + marketplace = await _get_marketplace_or_404(prisma_client, marketplace_name) + plugin_count = await _count_plugins(prisma_client, marketplace.id) + + return _to_marketplace_source_response(marketplace, plugin_count) + + except HTTPException: + raise + except Exception as e: # noqa: BLE001 # top-level endpoint boundary + verbose_proxy_logger.exception(f"Error getting marketplace: {e}") + raise HTTPException( + status_code=500, + detail={"error": str(e)}, + ) + + +@router.post( + "/claude-code/marketplaces/{marketplace_name}/sync", + tags=["Claude Code Marketplace"], + dependencies=[Depends(user_api_key_auth)], + response_model=SyncMarketplaceResponse, +) +async def sync_marketplace( + marketplace_name: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom +): + """Re-sync a registered marketplace source.""" + try: + prisma_client = await _get_prisma_client() + _require_proxy_admin(user_api_key_dict) + + marketplace = await _get_marketplace_or_404(prisma_client, marketplace_name) + + verbose_proxy_logger.info(f"Re-syncing marketplace {marketplace_name}") + return await _sync_and_build_response(prisma_client, marketplace) + + except HTTPException: + raise + except Exception as e: # noqa: BLE001 # top-level endpoint boundary + verbose_proxy_logger.exception(f"Error syncing marketplace: {e}") + raise HTTPException( + status_code=500, + detail={"error": str(e)}, + ) + + +@router.delete( + "/claude-code/marketplaces/{marketplace_name}", + tags=["Claude Code Marketplace"], + dependencies=[Depends(user_api_key_auth)], +) +async def delete_marketplace( + marketplace_name: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom +): + """Disable a marketplace source and all plugins it registered.""" + try: + prisma_client = await _get_prisma_client() + _require_proxy_admin(user_api_key_dict) + + marketplace = await _get_marketplace_or_404(prisma_client, marketplace_name) + + await SkillMarketplaceRepository(prisma_client).table.update( + where={"name": marketplace_name}, + data={"enabled": False}, + ) + await ClaudeCodePluginRepository(prisma_client).table.update_many( + where={"marketplace_id": marketplace.id}, + data={"enabled": False}, + ) + + verbose_proxy_logger.info(f"Marketplace {marketplace_name} disabled") + return {"status": "success", "message": f"Marketplace '{marketplace_name}' disabled"} + + except HTTPException: + raise + except Exception as e: # noqa: BLE001 # top-level endpoint boundary + verbose_proxy_logger.exception(f"Error deleting marketplace: {e}") + raise HTTPException( + status_code=500, + detail={"error": str(e)}, + ) diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_sync.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_sync.py new file mode 100644 index 00000000000..b1ebec2b6bb --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace_sync.py @@ -0,0 +1,607 @@ +""" +Sync logic for externally-hosted Claude Code marketplaces. + +Given a ``LiteLLM_SkillMarketplaceTable`` row pointing at a git host (or a +direct URL), this module fetches the marketplace's plugin/skill catalog and +upserts the resolved entries into ``LiteLLM_ClaudeCodePluginTable`` so they +can be served through the existing marketplace/plugin endpoints. + +Two upstream catalog shapes are supported: + +- ``.claude-plugin/marketplace.json`` at the repo root (the Claude Code + plugin marketplace spec). +- a bare ``skills/`` directory of ``SKILL.md`` files (one or two levels + deep), for repos that don't publish a marketplace.json. +""" + +import asyncio +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import ( + Any, # noqa: TID251 # prisma_client crosses the untyped prisma ORM boundary; see MarketplaceRow + Literal, +) +from urllib.parse import urlparse + +import httpx +import yaml +from pydantic import BaseModel, Field, TypeAdapter, ValidationError + +from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client +from litellm.repositories.table_repositories import ( + ClaudeCodePluginRepository, + SkillMarketplaceRepository, +) +from litellm.types.llms.custom_http import httpxSpecialProvider +from litellm.types.proxy.claude_code_endpoints import ( + GithubSource, + GitSubdirSource, + MarketplaceSourceType, + PluginSourceConfig, + UrlSource, +) + +DEFAULT_SYNC_TIMEOUT_SECONDS = 10.0 + +SourceHost = Literal["github", "gitlab", "bitbucket", "url"] +SyncErrorReason = Literal["unreachable", "http_error", "invalid_json", "invalid_schema"] + + +@dataclass(frozen=True, slots=True) +class MarketplaceRow: + """The subset of ``LiteLLM_SkillMarketplaceTable`` this module needs. + + Callers construct this from the Prisma row before calling + ``resolve_and_sync`` - keeps the untyped ORM boundary to a single + conversion point instead of threading ``Any`` through every helper. + """ + + id: str + name: str + source_ref: str | None + branch: str | None + + +@dataclass(frozen=True, slots=True) +class ResolvedSource: + host: SourceHost + repo_or_url: str + branch: str = "main" + + +@dataclass(frozen=True, slots=True) +class MarketplaceSyncError(Exception): + """Raised internally for any failure while syncing one marketplace. + + Always caught by ``resolve_and_sync`` and turned into a ``SyncResult`` - + it never escapes this module. + """ + + reason: SyncErrorReason + detail: str + + def __str__(self) -> str: + return f"{self.reason}: {self.detail}" + + +@dataclass(frozen=True, slots=True) +class SyncResult: + status: Literal["success", "error"] + error: str | None + plugin_count: int + + +@dataclass(frozen=True, slots=True) +class ResolvedPluginEntry: + stored_name: str + display_name: str + description: str | None + source: PluginSourceConfig + + +# --- source_ref parsing ----------------------------------------------------- + +_BARE_SHORTHAND_RE = re.compile(r"^[\w.-]+/[\w.-]+$") + +_HOST_MARKERS: tuple[tuple[str, Literal["github", "gitlab", "bitbucket"]], ...] = ( + ("github.com", "github"), + ("gitlab.com", "gitlab"), + ("bitbucket.org", "bitbucket"), +) + + +def _extract_repo_path(raw: str) -> str: + normalized = raw if "://" in raw else f"https://{raw}" + path = urlparse(normalized).path.strip("/") + path = path.removesuffix(".git") + segments = path.split("/") + return "/".join(segments[:2]) + + +def _parse_source_ref(raw: str) -> ResolvedSource: + stripped = raw.strip() + if _BARE_SHORTHAND_RE.match(stripped): + return ResolvedSource(host="github", repo_or_url=stripped) + for marker, host in _HOST_MARKERS: + if marker in stripped: + return ResolvedSource(host=host, repo_or_url=_extract_repo_path(stripped)) + return ResolvedSource(host="url", repo_or_url=stripped) + + +def _build_manifest_url(resolved: ResolvedSource, branch: str) -> str: + match resolved.host: + case "github": + return f"https://raw.githubusercontent.com/{resolved.repo_or_url}/{branch}/.claude-plugin/marketplace.json" + case "gitlab": + return f"https://gitlab.com/{resolved.repo_or_url}/-/raw/{branch}/.claude-plugin/marketplace.json" + case "bitbucket": + return f"https://bitbucket.org/{resolved.repo_or_url}/raw/{branch}/.claude-plugin/marketplace.json" + case "url": + return resolved.repo_or_url + + +def _git_clone_url(resolved: ResolvedSource) -> str: + match resolved.host: + case "github": + return f"https://github.com/{resolved.repo_or_url}.git" + case "gitlab": + return f"https://gitlab.com/{resolved.repo_or_url}.git" + case "bitbucket": + return f"https://bitbucket.org/{resolved.repo_or_url}.git" + case "url": + return resolved.repo_or_url + + +# --- HTTP ----------------------------------------------------------------- + + +async def _http_get(client: AsyncHTTPHandler, url: str, *, timeout: float) -> httpx.Response: + try: + # async_safe_get is SSRF-guarded (resolves + validates every redirect + # hop) - required here because the target URL is admin-supplied. + return await async_safe_get(client, url, headers={}, timeout=timeout) + except SSRFError as exc: + raise MarketplaceSyncError(reason="unreachable", detail=str(exc)) from exc + except httpx.HTTPError as exc: + raise MarketplaceSyncError(reason="unreachable", detail=str(exc)) from exc + + +def _parse_json_body(response: httpx.Response) -> object: + try: + return response.json() + except json.JSONDecodeError as exc: + raise MarketplaceSyncError(reason="invalid_json", detail=str(exc)) from exc + + +# --- marketplace.json parsing ---------------------------------------------- + + +class _ExternalMarketplaceOwner(BaseModel): + name: str + email: str | None = None + + +class _ExternalMarketplacePluginEntry(BaseModel): + name: str + description: str | None = None + source: str | dict[str, str] + skills: list[str] | None = None + + +class _ExternalMarketplaceManifest(BaseModel): + name: str + owner: _ExternalMarketplaceOwner | None = None + plugins: list[_ExternalMarketplacePluginEntry] = Field(default_factory=list) + + +def _parse_marketplace_manifest(response: httpx.Response) -> _ExternalMarketplaceManifest: + body = _parse_json_body(response) + try: + return _ExternalMarketplaceManifest.model_validate(body) + except ValidationError as exc: + raise MarketplaceSyncError(reason="invalid_schema", detail=str(exc)) from exc + + +def _normalize_relative_path(raw: str) -> str | None: + """Normalize a marketplace.json ``source`` relative path, rejecting traversal. + + Returns None (rather than raising) for anything unsafe so the caller can + skip just that one plugin entry instead of failing the whole sync. + """ + stripped = raw.strip() + if stripped in ("", ".", "./"): + return "" + trimmed = stripped.removeprefix("./") + trimmed = trimmed.strip("/") + if not trimmed: + return "" + segments = trimmed.split("/") + if any(segment in ("", "..") for segment in segments): + return None + return "/".join(segments) + + +def _resolve_relative_plugin_source(resolved: ResolvedSource, raw_path: str) -> PluginSourceConfig | None: + if resolved.host == "url": + # A raw marketplace.json URL isn't necessarily backed by a git repo we + # can derive a clone URL from, so a relative plugin source can't be + # resolved against it. + return None + + normalized = _normalize_relative_path(raw_path) + if normalized is None: + return None + if normalized == "": + if resolved.host == "github": + return GithubSource(repo=resolved.repo_or_url) + return UrlSource(url=_git_clone_url(resolved)) + return GitSubdirSource(url=_git_clone_url(resolved), path=normalized) + + +def _resolve_manifest_entry_source( + resolved: ResolvedSource, raw_source: str | dict[str, str] +) -> PluginSourceConfig | None: + if isinstance(raw_source, str): + return _resolve_relative_plugin_source(resolved, raw_source) + try: + return TypeAdapter(PluginSourceConfig).validate_python(raw_source) + except ValidationError: + return None + + +def _build_single_manifest_entry( + marketplace_name: str, + resolved: ResolvedSource, + raw_entry: _ExternalMarketplacePluginEntry, +) -> ResolvedPluginEntry | None: + plugin_source = _resolve_manifest_entry_source(resolved, raw_entry.source) + if plugin_source is None: + verbose_proxy_logger.warning( + "skill-marketplace-sync: skipping plugin %r in marketplace %r, unresolvable source %r", + raw_entry.name, + marketplace_name, + raw_entry.source, + ) + return None + return ResolvedPluginEntry( + stored_name=f"{marketplace_name}--{raw_entry.name}", + display_name=raw_entry.name, + description=raw_entry.description, + source=plugin_source, + ) + + +def _build_manifest_plugin_entries( + marketplace_name: str, + resolved: ResolvedSource, + manifest: _ExternalMarketplaceManifest, +) -> tuple[ResolvedPluginEntry, ...]: + resolved_entries = ( + _build_single_manifest_entry(marketplace_name, resolved, raw_entry) for raw_entry in manifest.plugins + ) + return tuple(entry for entry in resolved_entries if entry is not None) + + +# --- skills/ directory discovery (GitHub only) ------------------------------ + + +class _GithubContentsEntry(BaseModel): + name: str + path: str + type: Literal["file", "dir", "symlink", "submodule"] + + +@dataclass(frozen=True, slots=True) +class _DiscoveredSkillDoc: + skill_md_path: str + plugin_source_path: str + + +class _SkillFrontmatter(BaseModel): + name: str | None = None + description: str | None = None + + +_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL) + + +def _parse_skill_frontmatter(content: str) -> _SkillFrontmatter: + match = _FRONTMATTER_RE.match(content) + if not match: + return _SkillFrontmatter() + try: + raw = yaml.safe_load(match.group(1)) + except yaml.YAMLError: + return _SkillFrontmatter() + if not isinstance(raw, dict): + return _SkillFrontmatter() + try: + return _SkillFrontmatter.model_validate(raw) + except ValidationError: + return _SkillFrontmatter() + + +async def _list_github_contents( + client: AsyncHTTPHandler, repo: str, path: str, branch: str, *, timeout: float +) -> tuple[_GithubContentsEntry, ...]: + # NOTE: unauthenticated GitHub Contents API calls are capped at 60 req/hr + # per source IP. No auth-token support yet - known v1 limitation. + url = f"https://api.github.com/repos/{repo}/contents/{path}?ref={branch}" + response = await _http_get(client, url, timeout=timeout) + if response.status_code == 404: + return () + if response.status_code >= 400: + raise MarketplaceSyncError( + reason="http_error", + detail=f"GitHub contents API returned {response.status_code} for {url}", + ) + body = _parse_json_body(response) + try: + return tuple(TypeAdapter(list[_GithubContentsEntry]).validate_python(body)) + except ValidationError as exc: + raise MarketplaceSyncError(reason="invalid_schema", detail=str(exc)) from exc + + +async def _skill_md_exists(client: AsyncHTTPHandler, repo: str, branch: str, dir_path: str, *, timeout: float) -> bool: + url = f"https://raw.githubusercontent.com/{repo}/{branch}/{dir_path}/SKILL.md" + response = await _http_get(client, url, timeout=timeout) + return response.status_code == 200 + + +async def _check_nested_skill_doc( + client: AsyncHTTPHandler, + repo: str, + branch: str, + category_path: str, + entry: _GithubContentsEntry, + *, + timeout: float, +) -> _DiscoveredSkillDoc | None: + nested_path = f"{category_path}/{entry.name}" + if await _skill_md_exists(client, repo, branch, nested_path, timeout=timeout): + return _DiscoveredSkillDoc(skill_md_path=f"{nested_path}/SKILL.md", plugin_source_path=nested_path) + return None + + +async def _discover_skill_docs_under( + client: AsyncHTTPHandler, + repo: str, + branch: str, + entry: _GithubContentsEntry, + *, + timeout: float, +) -> tuple[_DiscoveredSkillDoc, ...]: + flat_path = f"skills/{entry.name}" + if await _skill_md_exists(client, repo, branch, flat_path, timeout=timeout): + return (_DiscoveredSkillDoc(skill_md_path=f"{flat_path}/SKILL.md", plugin_source_path=flat_path),) + + # Not a flat skill - try one level of catalog nesting (skills///SKILL.md). + nested_entries = await _list_github_contents(client, repo, flat_path, branch, timeout=timeout) + nested_docs = await asyncio.gather( + *( + _check_nested_skill_doc(client, repo, branch, flat_path, sub_entry, timeout=timeout) + for sub_entry in nested_entries + if sub_entry.type == "dir" + ) + ) + return tuple(doc for doc in nested_docs if doc is not None) + + +async def _discover_github_skill_docs( + client: AsyncHTTPHandler, repo: str, branch: str, *, timeout: float +) -> tuple[_DiscoveredSkillDoc, ...]: + top_level = await _list_github_contents(client, repo, "skills", branch, timeout=timeout) + grouped = await asyncio.gather( + *( + _discover_skill_docs_under(client, repo, branch, entry, timeout=timeout) + for entry in top_level + if entry.type == "dir" + ) + ) + return tuple(doc for docs in grouped for doc in docs) + + +async def _fetch_skill_entry( + client: AsyncHTTPHandler, + repo: str, + branch: str, + marketplace_name: str, + doc: _DiscoveredSkillDoc, + *, + timeout: float, +) -> ResolvedPluginEntry | None: + url = f"https://raw.githubusercontent.com/{repo}/{branch}/{doc.skill_md_path}" + response = await _http_get(client, url, timeout=timeout) + if response.status_code != 200: + return None + + frontmatter = _parse_skill_frontmatter(response.text) + folder_name = doc.plugin_source_path.rsplit("/", 1)[-1] + skill_name = frontmatter.name or folder_name + return ResolvedPluginEntry( + stored_name=f"{marketplace_name}--{skill_name}", + display_name=skill_name, + description=frontmatter.description, + source=GitSubdirSource(url=f"https://github.com/{repo}.git", path=doc.plugin_source_path), + ) + + +# --- top-level fetch orchestration ----------------------------------------- + + +async def _fetch_marketplace_entries( + marketplace_row: MarketplaceRow, +) -> tuple[tuple[ResolvedPluginEntry, ...], MarketplaceSourceType]: + if not marketplace_row.source_ref: + raise MarketplaceSyncError(reason="invalid_schema", detail="marketplace has no source_ref to sync from") + + resolved = _parse_source_ref(marketplace_row.source_ref) + branch = marketplace_row.branch or resolved.branch + client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.PassThroughEndpoint, + params={"timeout": DEFAULT_SYNC_TIMEOUT_SECONDS}, + ) + + manifest_url = _build_manifest_url(resolved, branch) + manifest_response = await _http_get(client, manifest_url, timeout=DEFAULT_SYNC_TIMEOUT_SECONDS) + + if manifest_response.status_code == 200: + manifest = _parse_marketplace_manifest(manifest_response) + entries = _build_manifest_plugin_entries(marketplace_row.name, resolved, manifest) + return entries, "claude_marketplace_json" + + if manifest_response.status_code == 404 and resolved.host == "github": + docs = await _discover_github_skill_docs( + client, resolved.repo_or_url, branch, timeout=DEFAULT_SYNC_TIMEOUT_SECONDS + ) + fetched = await asyncio.gather( + *( + _fetch_skill_entry( + client, + resolved.repo_or_url, + branch, + marketplace_row.name, + doc, + timeout=DEFAULT_SYNC_TIMEOUT_SECONDS, + ) + for doc in docs + ) + ) + entries = tuple(entry for entry in fetched if entry is not None) + return entries, "skills_dir" + + if manifest_response.status_code == 404: + raise MarketplaceSyncError( + reason="http_error", + detail=( + f"no .claude-plugin/marketplace.json found for host={resolved.host!r} and " + "directory-scan fallback is only supported for GitHub" + ), + ) + + raise MarketplaceSyncError( + reason="http_error", + detail=f"manifest fetch returned HTTP {manifest_response.status_code} for {manifest_url}", + ) + + +# --- persistence ------------------------------------------------------------- + + +def _build_plugin_manifest_json(entry: ResolvedPluginEntry) -> str: + return json.dumps( + { + "name": entry.display_name, + "source": entry.source.model_dump(), + "description": entry.description, + } + ) + + +async def _upsert_single_plugin( + repository: ClaudeCodePluginRepository, marketplace_id: str, entry: ResolvedPluginEntry +) -> None: + now = datetime.now(timezone.utc) + manifest_json = _build_plugin_manifest_json(entry) + await repository.table.upsert( + where={"name": entry.stored_name}, + data={ + "create": { + "name": entry.stored_name, + "description": entry.description, + "manifest_json": manifest_json, + "files_json": "{}", + "enabled": False, + "marketplace_id": marketplace_id, + "created_at": now, + "updated_at": now, + }, + "update": { + "description": entry.description, + "manifest_json": manifest_json, + "marketplace_id": marketplace_id, + "updated_at": now, + }, + }, + ) + + +# prisma_client has no importable type stubs in this codebase (generated at +# runtime, never imported by name) - every repository in +# litellm/repositories/ types it as Any for the same reason. +async def _upsert_plugin_entries( + prisma_client: Any, # noqa: ANN401 # see comment above + marketplace_id: str, + entries: tuple[ResolvedPluginEntry, ...], +) -> None: + repository = ClaudeCodePluginRepository(prisma_client) + await asyncio.gather(*(_upsert_single_plugin(repository, marketplace_id, entry) for entry in entries)) + + +async def _soft_disable_stale_plugins( + prisma_client: Any, # noqa: ANN401 # prisma_client has no importable type stubs, see _upsert_plugin_entries + marketplace_id: str, + entries: tuple[ResolvedPluginEntry, ...], +) -> None: + repository = ClaudeCodePluginRepository(prisma_client) + existing = await repository.table.find_many(where={"marketplace_id": marketplace_id}) + fresh_names = frozenset(entry.stored_name for entry in entries) + stale = tuple(row for row in existing if row.name not in fresh_names and row.enabled) + await asyncio.gather(*(repository.table.update(where={"name": row.name}, data={"enabled": False}) for row in stale)) + + +async def _record_sync_success( + prisma_client: Any, # noqa: ANN401 # prisma_client has no importable type stubs, see _upsert_plugin_entries + marketplace_row: MarketplaceRow, + source_type: MarketplaceSourceType, +) -> None: + await SkillMarketplaceRepository(prisma_client).table.update( + where={"id": marketplace_row.id}, + data={ + "sync_status": "success", + "sync_error": None, + "source_type": source_type, + "last_synced_at": datetime.now(timezone.utc), + }, + ) + + +async def _record_sync_failure( + prisma_client: Any, # noqa: ANN401 # prisma_client has no importable type stubs, see _upsert_plugin_entries + marketplace_row: MarketplaceRow, + detail: str, +) -> None: + await SkillMarketplaceRepository(prisma_client).table.update( + where={"id": marketplace_row.id}, + data={ + "sync_status": "error", + "sync_error": detail, + "last_synced_at": datetime.now(timezone.utc), + }, + ) + + +async def resolve_and_sync( + prisma_client: Any, # noqa: ANN401 # prisma_client has no importable type stubs, see _upsert_plugin_entries + marketplace_row: MarketplaceRow, +) -> SyncResult: + """Fetch ``marketplace_row``'s upstream catalog and sync it into the plugin table. + + Never raises - any failure is recorded on the marketplace row + (``sync_status="error"``) and reflected in the returned ``SyncResult``. + """ + try: + entries, source_type = await _fetch_marketplace_entries(marketplace_row) + except MarketplaceSyncError as exc: + verbose_proxy_logger.warning("skill-marketplace-sync: failed to sync %r: %s", marketplace_row.name, exc) + await _record_sync_failure(prisma_client, marketplace_row, str(exc)) + return SyncResult(status="error", error=str(exc), plugin_count=0) + + await _upsert_plugin_entries(prisma_client, marketplace_row.id, entries) + await _soft_disable_stale_plugins(prisma_client, marketplace_row.id, entries) + await _record_sync_success(prisma_client, marketplace_row, source_type) + return SyncResult(status="success", error=None, plugin_count=len(entries)) diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_authz.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_authz.py new file mode 100644 index 00000000000..8acb66d0184 --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_authz.py @@ -0,0 +1,137 @@ +from typing import Optional + +from litellm._logging import verbose_logger +from litellm.proxy._types import UI_TEAM_ID, UserAPIKeyAuth +from litellm.proxy.utils import PrismaClient + + +async def _get_allowed_skills_for_key( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: Optional[PrismaClient], +) -> frozenset[str]: + """Key's own allowed_skills ceiling from its object_permission. + + object_permission is normally already loaded onto the auth dict by + get_key_object() in the main auth flow; fall back to a DB lookup by + object_permission_id for the rare case it wasn't. + """ + from litellm.proxy.auth.auth_checks import get_object_permission + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + key_object_permission = user_api_key_dict.object_permission + if key_object_permission is None and user_api_key_dict.object_permission_id and prisma_client is not None: + key_object_permission = await get_object_permission( + object_permission_id=user_api_key_dict.object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_dict.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + + if key_object_permission is None: + return frozenset() + + return frozenset(key_object_permission.allowed_skills or []) + + +async def _get_allowed_skills_for_team( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: Optional[PrismaClient], +) -> frozenset[str]: + """Team's allowed_skills ceiling from team.object_permission.""" + from litellm.proxy.auth.auth_checks import get_team_object + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + if not user_api_key_dict.team_id or prisma_client is None: + return frozenset() + + if user_api_key_dict.team_id == UI_TEAM_ID: + return frozenset() + + team_obj = await get_team_object( + team_id=user_api_key_dict.team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_dict.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + if team_obj.object_permission is None: + return frozenset() + + return frozenset(team_obj.object_permission.allowed_skills or []) + + +async def _get_allowed_skills_for_org( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: Optional[PrismaClient], +) -> frozenset[str]: + """Org's allowed_skills ceiling from org.object_permission.""" + from litellm.proxy.auth.auth_checks import get_object_permission, get_org_object + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + if not user_api_key_dict.org_id or prisma_client is None: + return frozenset() + + org_obj = await get_org_object( + org_id=user_api_key_dict.org_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_dict.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + if org_obj is None or not org_obj.object_permission_id: + return frozenset() + + org_object_permission = await get_object_permission( + object_permission_id=org_obj.object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_dict.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + if org_object_permission is None: + return frozenset() + + return frozenset(org_object_permission.allowed_skills or []) + + +async def get_allowed_skills( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: Optional[PrismaClient], +) -> frozenset[str]: + """ + Resolve the set of Claude Code skill names (already-namespaced, e.g. + "anthropic-agent-skills--document-skills") this key is allowed to see, + beyond whatever the default/managed marketplace already exposes. + + Permission hierarchy (all rules are intersections, mirrors + MCPRequestHandler.get_allowed_mcp_servers for key/team/org): + - An empty/missing allowed_skills list at a given level means that level + places no restriction and is skipped from the intersection. + - key/team: if both restrict, intersect; if only one restricts, use it. + - org: acts as a ceiling. If the org has an explicit list, the key/team + result is capped to it; if there's no lower-level restriction, the org + list becomes the result outright. + """ + try: + key_skills = await _get_allowed_skills_for_key(user_api_key_dict, prisma_client) + team_skills = await _get_allowed_skills_for_team(user_api_key_dict, prisma_client) + + if not team_skills: + allowed_skills = key_skills + elif not key_skills: + allowed_skills = team_skills + else: + allowed_skills = key_skills & team_skills + + has_lower_level_restrictions = bool(key_skills or team_skills) + + if user_api_key_dict.org_id: + org_skills = await _get_allowed_skills_for_org(user_api_key_dict, prisma_client) + if org_skills: + allowed_skills = allowed_skills & org_skills if has_lower_level_restrictions else org_skills + except Exception as e: # noqa: BLE001 # never let an authz lookup crash the caller + verbose_logger.warning(f"Failed to get allowed skills: {e!s}") + return frozenset() + else: + return allowed_skills diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index dd0a34a7898..346bed9ab0a 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -714,6 +714,17 @@ class RouteChecks: return True return False + @staticmethod + def is_claude_code_marketplace_route(route: str) -> bool: + """ + Returns True if this is the Claude Code plugin marketplace discovery route. + + The Claude Code CLI fetches this route without custom headers, so it + allows passing key=api_key in the query params (mirrors the Google + generateContent carve-out above). + """ + return route == "/claude-code/marketplace.json" + # HTTP methods that are intrinsically read-only and therefore safe to # default-allow for PROXY_ADMIN_VIEW_ONLY. Anything else (POST/PUT/PATCH/ # DELETE) is treated as a write attempt and goes through the explicit diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 2613510bd0c..3059fb8ce04 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -595,13 +595,16 @@ def get_api_key( passed_in_key = azure_apim_header api_key = azure_apim_header elif ( - RouteChecks.is_generate_content_route(route=route) + ( + RouteChecks.is_generate_content_route(route=route) + or RouteChecks.is_claude_code_marketplace_route(route=route) + ) and request is not None and _safe_get_request_query_params(request).get("key") ): - google_auth_key: str = _safe_get_request_query_params(request).get("key") or "" - passed_in_key = google_auth_key - api_key = google_auth_key + query_param_key: str = _safe_get_request_query_params(request).get("key") or "" + passed_in_key = query_param_key + api_key = query_param_key elif pass_through_endpoints is not None: for endpoint in pass_through_endpoints: if endpoint.get("path", "") == route: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f0ca1f6396f..036b433ce09 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -5960,6 +5960,8 @@ class ProxyConfig: if self._should_load_db_object(object_type="policies"): await self._init_policies_in_db(prisma_client=prisma_client) + await self._init_skill_marketplace_backfill_in_db(prisma_client=prisma_client) + if self._should_load_db_object(object_type="vector_stores"): await self._init_vector_stores_in_db(prisma_client=prisma_client) @@ -6447,6 +6449,20 @@ class ProxyConfig: "litellm.proxy.proxy_server.py::ProxyConfig:_init_tool_policy_in_db - {}".format(str(e)) ) + async def _init_skill_marketplace_backfill_in_db(self, prisma_client: PrismaClient): + from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_backfill import ( + backfill_default_skill_marketplace, + ) + + try: + await backfill_default_skill_marketplace(prisma_client=prisma_client) + except Exception as e: # noqa: BLE001 # startup backfill must never block proxy boot + verbose_proxy_logger.exception( + "litellm.proxy.proxy_server.py::ProxyConfig:_init_skill_marketplace_backfill_in_db - {}".format( + str(e) + ) + ) + async def _init_vector_stores_in_db(self, prisma_client: PrismaClient): from litellm.vector_stores.vector_store_registry import VectorStoreRegistry diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index fb4d8d0b5a3..1439861ee09 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -280,6 +280,7 @@ model LiteLLM_ObjectPermissionTable { mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user search_tools String[] @default([]) // search_tool_name values this key/team/user may call mcp_tool_search_enabled Boolean? + allowed_skills String[] @default([]) // Namespaced Claude Code skill names ("marketplace--skill") granted to this key/team/org teams LiteLLM_TeamTable[] projects LiteLLM_ProjectTable[] verification_tokens LiteLLM_VerificationToken[] @@ -1261,6 +1262,7 @@ model LiteLLM_ClaudeCodePluginTable { manifest_json String? files_json String? @default("{}") enabled Boolean @default(true) + marketplace_id String? created_at DateTime? @default(now()) updated_at DateTime? @default(now()) @updatedAt created_by String? @@ -1268,6 +1270,28 @@ model LiteLLM_ClaudeCodePluginTable { @@map("LiteLLM_ClaudeCodePluginTable") } +// A registered external Claude Code marketplace source (git repo or URL) that +// litellm syncs skill/plugin listings from into LiteLLM_ClaudeCodePluginTable. +model LiteLLM_SkillMarketplaceTable { + id String @id @default(uuid()) + name String @unique + display_name String? + source_type String // "claude_marketplace_json" | "skills_dir" | "managed" + source_ref String? + branch String? @default("main") + owner_name String? + owner_email String? + enabled Boolean @default(true) + sync_status String @default("pending") // "pending" | "success" | "error" + sync_error String? + last_synced_at DateTime? + created_at DateTime? @default(now()) + updated_at DateTime? @default(now()) @updatedAt + created_by String? + + @@map("LiteLLM_SkillMarketplaceTable") +} + // User/team-scoped memory store with a GLOBAL unique key. // `value` is a string (typically markdown/text meant for LLM context); // `metadata` is an optional JSON envelope for structured tags without schema changes. diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index 7ce4607e1ca..6f098f54a9b 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -61,6 +61,10 @@ class ClaudeCodePluginRepository(PrismaTableRepository): table_name = "litellm_claudecodeplugintable" +class SkillMarketplaceRepository(PrismaTableRepository): + table_name = "litellm_skillmarketplacetable" + + class TeamMembershipRepository(PrismaTableRepository): table_name = "litellm_teammembership" diff --git a/litellm/types/object_permission.py b/litellm/types/object_permission.py index d0458173fbf..50fd7573f95 100644 --- a/litellm/types/object_permission.py +++ b/litellm/types/object_permission.py @@ -25,3 +25,4 @@ class ObjectPermissionDict(TypedDict, total=False): models: Optional[list[str]] search_tools: Optional[list[str]] mcp_tool_search_enabled: Optional[bool] + allowed_skills: Optional[list[str]] diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 47dd40df694..1b2fb4486f6 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -2,9 +2,10 @@ Claude Code Marketplace endpoint types for LiteLLM Proxy """ -from typing import Dict, List, Optional +from typing import Dict, List, Literal, Optional, Union from pydantic import BaseModel, Field +from typing_extensions import Annotated class PluginAuthor(BaseModel): @@ -122,3 +123,72 @@ class MarketplaceResponse(BaseModel): name: str = Field(..., description="Marketplace identifier") owner: PluginOwner = Field(..., description="Marketplace owner") plugins: List[MarketplacePluginEntry] = Field(default_factory=list, description="Available plugins") + + +# --- Multi-marketplace import (LiteLLM_SkillMarketplaceTable) --- + +MarketplaceSourceType = Literal["claude_marketplace_json", "skills_dir", "managed"] + + +class GithubSource(BaseModel): + source: Literal["github"] = "github" + repo: str = Field(..., description="'org/repo'") + + +class UrlSource(BaseModel): + source: Literal["url"] = "url" + url: str + + +class GitSubdirSource(BaseModel): + source: Literal["git-subdir"] = "git-subdir" + url: str + path: str + + +PluginSourceConfig = Annotated[ + Union[GithubSource, UrlSource, GitSubdirSource], + Field(discriminator="source"), +] + + +class RegisterMarketplaceRequest(BaseModel): + """Request body for importing an external Claude Code marketplace.""" + + source: str = Field( + ..., + description=( + "'org/repo' shorthand, a github/gitlab/bitbucket URL, or a direct URL to a marketplace.json file" + ), + ) + name: Optional[str] = Field( + None, description="Marketplace slug to register under. Defaults to a slug derived from the source." + ) + + +class MarketplaceSourceResponse(BaseModel): + """A registered marketplace source and its current sync state.""" + + id: str + name: str + display_name: Optional[str] = None + source_type: MarketplaceSourceType + source_ref: Optional[str] = None + branch: Optional[str] = None + enabled: bool + sync_status: str + sync_error: Optional[str] = None + last_synced_at: Optional[str] = None + plugin_count: Optional[int] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +class ListMarketplacesResponse(BaseModel): + marketplaces: List[MarketplaceSourceResponse] + count: int + + +class SyncMarketplaceResponse(BaseModel): + status: str + marketplace: MarketplaceSourceResponse diff --git a/ruff.toml b/ruff.toml index 2ea9d7260fb..6adc749f358 100644 --- a/ruff.toml +++ b/ruff.toml @@ -6,7 +6,7 @@ lint.extend-select = ["T20", "PGH004", "RUF008", "RUF009", "RUF100"] # litellm's own ruff config both rely on suppressions this config can't see. lint.external = [ # Enforced by the strict-rule gate (scripts/ruff_strict_gate.py + ruff-strict.toml) - "C901", + "C901", "ANN401", "TID251", "B008", # Enforced by upstream litellm's ruff config, but not run in this repo's CI "PLC0415", "E402", "BLE001", "ARG002", "S102", "S324", "S606", "D401", "F403", "F405", ] diff --git a/tests/pass_through_unit_tests/test_claude_code_marketplace.py b/tests/pass_through_unit_tests/test_claude_code_marketplace.py index 2f51394ae68..25822f21082 100644 --- a/tests/pass_through_unit_tests/test_claude_code_marketplace.py +++ b/tests/pass_through_unit_tests/test_claude_code_marketplace.py @@ -69,14 +69,22 @@ def mock_prisma_client(): plugin_name = where.get("name") return plugins_store.get(plugin_name) + def _matches_clause(plugin, clause): + """Support the small subset of Prisma where-clause shapes get_marketplace + actually issues: {"enabled": bool} and {"name": {"in": [...]}}.""" + if "enabled" in clause: + return plugin.enabled == clause["enabled"] + if "name" in clause: + return plugin.name in clause["name"].get("in", []) + return False + async def find_many(where=None): """Mock find_many - returns list of plugins matching where clause.""" if where is None or where == {}: return list(plugins_store.values()) - enabled = where.get("enabled") - if enabled is not None: - return [p for p in plugins_store.values() if p.enabled == enabled] - return list(plugins_store.values()) + if "OR" in where: + return [p for p in plugins_store.values() if any(_matches_clause(p, c) for c in where["OR"])] + return [p for p in plugins_store.values() if _matches_clause(p, where)] async def create(data): """Mock create - creates a new plugin.""" @@ -242,6 +250,121 @@ async def test_get_marketplace(mock_prisma_client): ) +@pytest.mark.asyncio +async def test_get_marketplace_no_key_unaffected_by_imported_disabled_skills( + mock_prisma_client, +): + """Backward-compat regression test: an unauthenticated request to + marketplace.json must be byte-for-byte the same as before the + marketplace-import feature existed - it only ever sees enabled=True rows, + even once a marketplace sync has imported (and left disabled) new skills. + """ + setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma_client) + setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") + await litellm.proxy.proxy_server.prisma_client.connect() + + public_plugin_name = f"public-plugin-{int(time.time())}" + imported_disabled_name = f"imported-private-skill-{int(time.time())}" + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + user_id="test-user", + ) + await register_plugin( + request=RegisterPluginRequest( + name=public_plugin_name, + source={"source": "github", "repo": "test-org/public-plugin"}, + version="1.0.0", + description="Always-public plugin", + ), + user_api_key_dict=user_api_key_dict, + ) + # Simulate a marketplace sync having imported a skill, left disabled + # pending admin opt-in (mirrors resolve_and_sync's upsert semantics). + await mock_prisma_client.db.litellm_claudecodeplugintable.create( + data={ + "name": imported_disabled_name, + "version": "1.0.0", + "description": "Imported but not yet enabled", + "manifest_json": json.dumps( + {"source": {"source": "github", "repo": "org/private-skill"}} + ), + "enabled": False, + } + ) + + response_before_key_check = await get_marketplace(user_api_key_dict=None) + body_before = json.loads(response_before_key_check.body.decode()) + + response_no_key = await get_marketplace(user_api_key_dict=None) + body_no_key = json.loads(response_no_key.body.decode()) + + # No-key responses must be deterministic/identical across calls, and must + # never include the imported-but-disabled skill. + assert body_no_key == body_before + names = {p["name"] for p in body_no_key["plugins"]} + assert public_plugin_name in names + assert imported_disabled_name not in names + + # Cleanup + await mock_prisma_client.db.litellm_claudecodeplugintable.delete( + where={"name": public_plugin_name} + ) + await mock_prisma_client.db.litellm_claudecodeplugintable.delete( + where={"name": imported_disabled_name} + ) + + +@pytest.mark.asyncio +async def test_get_marketplace_with_key_unlocks_allowed_imported_skill( + mock_prisma_client, +): + """A key whose object_permission.allowed_skills grants an imported + (still disabled) skill sees that skill in marketplace.json alongside the + always-public entries - without a key, that same skill stays hidden.""" + from litellm.models.object_permission import LiteLLM_ObjectPermissionTable + + setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma_client) + setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") + await litellm.proxy.proxy_server.prisma_client.connect() + + imported_disabled_name = f"imported-scoped-skill-{int(time.time())}" + await mock_prisma_client.db.litellm_claudecodeplugintable.create( + data={ + "name": imported_disabled_name, + "version": "1.0.0", + "description": "Imported, granted to one key only", + "manifest_json": json.dumps( + {"source": {"source": "github", "repo": "org/scoped-skill"}} + ), + "enabled": False, + } + ) + + response_no_key = await get_marketplace(user_api_key_dict=None) + body_no_key = json.loads(response_no_key.body.decode()) + assert imported_disabled_name not in {p["name"] for p in body_no_key["plugins"]} + + scoped_key = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-scoped", + user_id="scoped-user", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="perm-1", allowed_skills=[imported_disabled_name] + ), + ) + response_with_key = await get_marketplace(user_api_key_dict=scoped_key) + body_with_key = json.loads(response_with_key.body.decode()) + names_with_key = {p["name"] for p in body_with_key["plugins"]} + assert imported_disabled_name in names_with_key + + # Cleanup + await mock_prisma_client.db.litellm_claudecodeplugintable.delete( + where={"name": imported_disabled_name} + ) + + @pytest.mark.asyncio async def test_register_plugin_git_subdir(mock_prisma_client): """Test registering a plugin with git-subdir source type.""" diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace_sources_endpoints.py b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace_sources_endpoints.py new file mode 100644 index 00000000000..5a880837150 --- /dev/null +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace_sources_endpoints.py @@ -0,0 +1,299 @@ +""" +Unit tests for claude_code_marketplace_sources.py. + +Covers the register/list/get/sync/delete marketplace-source routes and their +proxy-admin gating. resolve_and_sync itself is exercised separately in +test_claude_code_marketplace_sync.py, so here it's replaced with a stub that +reports success - these tests are only about the endpoint/DB-orchestration +layer built on top of it. +""" + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +import litellm +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.anthropic_endpoints.claude_code_endpoints import ( + claude_code_marketplace_sources as sources_module, +) +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sources import ( + delete_marketplace, + get_marketplace_source, + list_marketplaces, + register_marketplace, + sync_marketplace, +) +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sync import ( + SyncResult, +) +from litellm.types.proxy.claude_code_endpoints import RegisterMarketplaceRequest + + +class _FakeTable: + def __init__(self): + self._rows: dict = {} + + @staticmethod + def _matches(record, where): + return all(getattr(record, k, None) == v for k, v in where.items()) + + async def find_unique(self, where): + for record in self._rows.values(): + if self._matches(record, where): + return record + return None + + async def find_many(self, where=None): + if not where: + return list(self._rows.values()) + return [r for r in self._rows.values() if self._matches(r, where)] + + async def count(self, where=None): + return len(await self.find_many(where)) + + async def create(self, data): + # Mirrors the LiteLLM_SkillMarketplaceTable / LiteLLM_ClaudeCodePluginTable + # prisma schema defaults, since this fake has no DB layer to apply them. + record_data = { + "display_name": None, + "branch": "main", + "enabled": True, + "sync_error": None, + "last_synced_at": None, + "created_at": None, + "updated_at": None, + "description": None, + "version": None, + "marketplace_id": None, + **data, + } + record_data.setdefault("id", str(uuid.uuid4())) + record = SimpleNamespace(**record_data) + self._rows[record.id] = record + return record + + async def update(self, where, data): + record = await self.find_unique(where) + if record is None: + raise ValueError(f"no record matching {where}") + for k, v in data.items(): + setattr(record, k, v) + return record + + async def update_many(self, where, data): + matched = await self.find_many(where) + for record in matched: + for k, v in data.items(): + setattr(record, k, v) + return len(matched) + + +def _make_fake_prisma_client(): + client = SimpleNamespace() + client.db = SimpleNamespace( + litellm_skillmarketplacetable=_FakeTable(), + litellm_claudecodeplugintable=_FakeTable(), + ) + return client + + +_ADMIN = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-admin", + user_id="admin-user", +) + +_NON_ADMIN = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-regular", + user_id="regular-user", +) + + +@pytest.fixture(autouse=True) +def prisma_client(monkeypatch): + client = _make_fake_prisma_client() + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", client) + monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", "sk-admin") + monkeypatch.setattr( + sources_module, + "resolve_and_sync", + AsyncMock(return_value=SyncResult(status="success", error=None, plugin_count=3)), + ) + return client + + +@pytest.mark.asyncio +async def test_register_marketplace_success(prisma_client): + request = RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills") + + response = await register_marketplace(request=request, user_api_key_dict=_ADMIN) + + assert response.status == "success" + assert response.marketplace.name == "anthropic-skills" + assert response.marketplace.source_ref == "anthropics/skills" + assert response.marketplace.plugin_count == 3 + + stored = await prisma_client.db.litellm_skillmarketplacetable.find_unique(where={"name": "anthropic-skills"}) + assert stored is not None + + +@pytest.mark.asyncio +async def test_register_marketplace_derives_name_from_source(prisma_client): + request = RegisterMarketplaceRequest(source="anthropics/skills") + + response = await register_marketplace(request=request, user_api_key_dict=_ADMIN) + + assert response.marketplace.name == "skills" + + +@pytest.mark.asyncio +async def test_register_marketplace_duplicate_name_rejected(prisma_client): + request = RegisterMarketplaceRequest(source="anthropics/skills", name="dup-skills") + await register_marketplace(request=request, user_api_key_dict=_ADMIN) + + with pytest.raises(HTTPException) as exc_info: + await register_marketplace(request=request, user_api_key_dict=_ADMIN) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_list_marketplaces(prisma_client): + await register_marketplace( + request=RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills"), + user_api_key_dict=_ADMIN, + ) + await register_marketplace( + request=RegisterMarketplaceRequest(source="vercel-labs/skills", name="vercel-skills"), + user_api_key_dict=_ADMIN, + ) + + response = await list_marketplaces(user_api_key_dict=_ADMIN) + + assert response.count == 2 + assert {m.name for m in response.marketplaces} == {"anthropic-skills", "vercel-skills"} + + +@pytest.mark.asyncio +async def test_get_marketplace_source(prisma_client): + await register_marketplace( + request=RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills"), + user_api_key_dict=_ADMIN, + ) + marketplace = await prisma_client.db.litellm_skillmarketplacetable.find_unique(where={"name": "anthropic-skills"}) + for plugin_name in ["anthropic-skills--doc", "anthropic-skills--example", "anthropic-skills--api"]: + await prisma_client.db.litellm_claudecodeplugintable.create( + data={"name": plugin_name, "marketplace_id": marketplace.id} + ) + + response = await get_marketplace_source(marketplace_name="anthropic-skills", user_api_key_dict=_ADMIN) + + assert response.name == "anthropic-skills" + assert response.source_ref == "anthropics/skills" + assert response.plugin_count == 3 + + +@pytest.mark.asyncio +async def test_get_marketplace_source_not_found(prisma_client): + with pytest.raises(HTTPException) as exc_info: + await get_marketplace_source(marketplace_name="does-not-exist", user_api_key_dict=_ADMIN) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_sync_marketplace_reinvokes_resolve_and_sync(prisma_client): + await register_marketplace( + request=RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills"), + user_api_key_dict=_ADMIN, + ) + assert sources_module.resolve_and_sync.call_count == 1 + + response = await sync_marketplace(marketplace_name="anthropic-skills", user_api_key_dict=_ADMIN) + + assert response.status == "success" + assert sources_module.resolve_and_sync.call_count == 2 + + +@pytest.mark.asyncio +async def test_sync_marketplace_not_found(prisma_client): + with pytest.raises(HTTPException) as exc_info: + await sync_marketplace(marketplace_name="does-not-exist", user_api_key_dict=_ADMIN) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_marketplace_disables_marketplace_and_its_plugins(prisma_client): + await register_marketplace( + request=RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills"), + user_api_key_dict=_ADMIN, + ) + marketplace = await prisma_client.db.litellm_skillmarketplacetable.find_unique(where={"name": "anthropic-skills"}) + await prisma_client.db.litellm_claudecodeplugintable.create( + data={"name": "anthropic-skills--doc", "enabled": True, "marketplace_id": marketplace.id} + ) + + response = await delete_marketplace(marketplace_name="anthropic-skills", user_api_key_dict=_ADMIN) + + assert response["status"] == "success" + + refreshed_marketplace = await prisma_client.db.litellm_skillmarketplacetable.find_unique( + where={"name": "anthropic-skills"} + ) + assert refreshed_marketplace.enabled is False + + refreshed_plugin = await prisma_client.db.litellm_claudecodeplugintable.find_unique( + where={"name": "anthropic-skills--doc"} + ) + assert refreshed_plugin.enabled is False + + +@pytest.mark.asyncio +async def test_delete_marketplace_not_found(prisma_client): + with pytest.raises(HTTPException) as exc_info: + await delete_marketplace(marketplace_name="does-not-exist", user_api_key_dict=_ADMIN) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_non_admin_forbidden_on_all_marketplace_routes(prisma_client): + """Regression test for the admin-gating decision: a real non-admin + UserAPIKeyAuth (not a mock) must be rejected by every marketplace-source + route, both before and after a marketplace exists.""" + request = RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills") + + with pytest.raises(HTTPException) as exc_info: + await register_marketplace(request=request, user_api_key_dict=_NON_ADMIN) + assert exc_info.value.status_code == 403 + + # Seed a marketplace (as an admin) so the remaining routes have something + # to act on - a 404 short-circuit before the role check would falsely + # pass this test. + await register_marketplace(request=request, user_api_key_dict=_ADMIN) + + with pytest.raises(HTTPException) as exc_info: + await list_marketplaces(user_api_key_dict=_NON_ADMIN) + assert exc_info.value.status_code == 403 + + with pytest.raises(HTTPException) as exc_info: + await get_marketplace_source(marketplace_name="anthropic-skills", user_api_key_dict=_NON_ADMIN) + assert exc_info.value.status_code == 403 + + with pytest.raises(HTTPException) as exc_info: + await sync_marketplace(marketplace_name="anthropic-skills", user_api_key_dict=_NON_ADMIN) + assert exc_info.value.status_code == 403 + + with pytest.raises(HTTPException) as exc_info: + await delete_marketplace(marketplace_name="anthropic-skills", user_api_key_dict=_NON_ADMIN) + assert exc_info.value.status_code == 403 + + # None of the non-admin calls should have mutated anything. + stored = await prisma_client.db.litellm_skillmarketplacetable.find_unique(where={"name": "anthropic-skills"}) + assert stored.enabled is True diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace_sync.py b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace_sync.py new file mode 100644 index 00000000000..4c465000296 --- /dev/null +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace_sync.py @@ -0,0 +1,424 @@ +""" +Unit tests for claude_code_marketplace_sync.py. + +Covers source-ref host detection, syncing an external marketplace.json into +LiteLLM_ClaudeCodePluginTable (including relative-source rewriting and the +GitHub-only skills/ directory-scan fallback), error classification, and the +idempotent/soft-disable upsert semantics. +""" + +import json +import uuid +from datetime import datetime, timedelta +from types import SimpleNamespace + +import httpx +import pytest + +from litellm.proxy.anthropic_endpoints.claude_code_endpoints import ( + claude_code_marketplace_sync as sync_module, +) +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sync import ( + MarketplaceSyncError, + _parse_source_ref, + resolve_and_sync, +) + + +class _FakeTable: + def __init__(self): + self._rows: dict = {} + + @staticmethod + def _matches(record, where): + return all(getattr(record, k, None) == v for k, v in where.items()) + + async def find_unique(self, where): + for record in self._rows.values(): + if self._matches(record, where): + return record + return None + + async def find_many(self, where=None): + if not where: + return list(self._rows.values()) + return [r for r in self._rows.values() if self._matches(r, where)] + + async def count(self, where=None): + return len(await self.find_many(where)) + + async def create(self, data): + record_data = dict(data) + record_data.setdefault("id", str(uuid.uuid4())) + record = SimpleNamespace(**record_data) + self._rows[record.id] = record + return record + + async def update(self, where, data): + record = await self.find_unique(where) + if record is None: + raise ValueError(f"no record matching {where}") + for k, v in data.items(): + setattr(record, k, v) + return record + + async def update_many(self, where, data): + matched = await self.find_many(where) + for record in matched: + for k, v in data.items(): + setattr(record, k, v) + return len(matched) + + async def upsert(self, where, data): + existing = await self.find_unique(where) + if existing is not None: + for k, v in data["update"].items(): + setattr(existing, k, v) + return existing + return await self.create(data["create"]) + + async def delete(self, where): + record = await self.find_unique(where) + if record is not None: + del self._rows[record.id] + return record + + +def _make_fake_prisma_client(): + client = SimpleNamespace() + client.db = SimpleNamespace( + litellm_skillmarketplacetable=_FakeTable(), + litellm_claudecodeplugintable=_FakeTable(), + ) + return client + + +class _FakeClock: + def __init__(self): + self._counter = 0 + + def now(self, tz=None): + self._counter += 1 + return datetime(2024, 1, 1, tzinfo=tz) + timedelta(seconds=self._counter) + + +_ANTHROPIC_SKILLS_MANIFEST = { + "name": "anthropic-agent-skills", + "owner": {"name": "Keith Lazuka", "email": "klazuka@anthropic.com"}, + "metadata": {"description": "Anthropic example skills", "version": "1.0.0"}, + "plugins": [ + { + "name": "document-skills", + "description": ( + "Collection of document processing suite including Excel, Word, PowerPoint, and PDF capabilities" + ), + "source": "./", + "strict": False, + "skills": ["./skills/xlsx", "./skills/docx", "./skills/pptx", "./skills/pdf"], + }, + { + "name": "example-skills", + "description": "Collection of example skills demonstrating various capabilities", + "source": "./", + "strict": False, + "skills": ["./skills/algorithmic-art"], + }, + { + "name": "claude-api", + "description": "Claude API and SDK documentation skill", + "source": "./", + "strict": False, + "skills": ["./skills/claude-api"], + }, + ], +} + +_FIND_SKILLS_SKILL_MD = """--- +name: find-skills +description: Helps users discover and install agent skills across GitHub repositories. +--- + +# Find Skills + +Body content describing how to discover and install skills. +""" + +_ANTHROPIC_MANIFEST_URL = "https://raw.githubusercontent.com/anthropics/skills/main/.claude-plugin/marketplace.json" + + +async def _create_marketplace(client, *, name, source_ref, branch=None): + return await client.db.litellm_skillmarketplacetable.create( + data={ + "name": name, + "source_type": "claude_marketplace_json", + "source_ref": source_ref, + "branch": branch, + "sync_status": "pending", + } + ) + + +@pytest.mark.parametrize( + "raw,expected_host,expected_repo", + [ + ("anthropics/skills", "github", "anthropics/skills"), + ("https://github.com/anthropics/skills", "github", "anthropics/skills"), + ("https://gitlab.com/foo/bar", "gitlab", "foo/bar"), + ("https://bitbucket.org/foo/bar", "bitbucket", "foo/bar"), + ("https://example.com/marketplace.json", "url", "https://example.com/marketplace.json"), + ], +) +def test_parse_source_ref(raw, expected_host, expected_repo): + resolved = _parse_source_ref(raw) + assert resolved.host == expected_host + assert resolved.repo_or_url == expected_repo + + +def test_marketplace_sync_error_str_includes_reason_and_detail(): + err = MarketplaceSyncError(reason="unreachable", detail="boom") + assert str(err) == "unreachable: boom" + assert err.reason == "unreachable" + assert err.detail == "boom" + + +@pytest.mark.asyncio +async def test_resolve_and_sync_rewrites_relative_sources(monkeypatch): + """Regression test: a '.claude-plugin/marketplace.json' plugin entry whose + ``source`` is the relative shorthand './' must be rewritten into a resolvable + github reference, never left as the bare './' string Claude Code can't install.""" + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="anthropic-agent-skills", source_ref="anthropics/skills") + + async def _get(http_client, url, **kwargs): + assert url == _ANTHROPIC_MANIFEST_URL + return httpx.Response(200, json=_ANTHROPIC_SKILLS_MANIFEST) + + monkeypatch.setattr(sync_module, "async_safe_get", _get) + + result = await resolve_and_sync(client, marketplace) + + assert result.status == "success" + assert result.plugin_count == 3 + + plugins = await client.db.litellm_claudecodeplugintable.find_many(where={"marketplace_id": marketplace.id}) + plugins_by_name = {p.name: p for p in plugins} + assert set(plugins_by_name) == { + "anthropic-agent-skills--document-skills", + "anthropic-agent-skills--example-skills", + "anthropic-agent-skills--claude-api", + } + for plugin in plugins_by_name.values(): + assert plugin.enabled is False + source = json.loads(plugin.manifest_json)["source"] + assert source == {"source": "github", "repo": "anthropics/skills"} + assert source != "./" + + refreshed_marketplace = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id}) + assert refreshed_marketplace.sync_status == "success" + + +@pytest.mark.asyncio +async def test_resolve_and_sync_falls_back_to_skills_directory_scan(monkeypatch): + """A repo with no marketplace.json (404) falls back to scanning skills/*/SKILL.md + frontmatter (github-only), pulling name/description from the frontmatter.""" + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="vercel-skills", source_ref="vercel-labs/skills") + + manifest_url = "https://raw.githubusercontent.com/vercel-labs/skills/main/.claude-plugin/marketplace.json" + contents_url = "https://api.github.com/repos/vercel-labs/skills/contents/skills?ref=main" + skill_md_url = "https://raw.githubusercontent.com/vercel-labs/skills/main/skills/find-skills/SKILL.md" + + async def _get(http_client, url, **kwargs): + if url == manifest_url: + return httpx.Response(404) + if url == contents_url: + return httpx.Response( + 200, + json=[{"name": "find-skills", "path": "skills/find-skills", "type": "dir"}], + ) + if url == skill_md_url: + return httpx.Response(200, text=_FIND_SKILLS_SKILL_MD) + raise AssertionError(f"unexpected url requested: {url}") + + monkeypatch.setattr(sync_module, "async_safe_get", _get) + + result = await resolve_and_sync(client, marketplace) + + assert result.status == "success" + assert result.plugin_count == 1 + + plugins = await client.db.litellm_claudecodeplugintable.find_many(where={"marketplace_id": marketplace.id}) + assert len(plugins) == 1 + plugin = plugins[0] + assert plugin.name == "vercel-skills--find-skills" + assert plugin.description == "Helps users discover and install agent skills across GitHub repositories." + source = json.loads(plugin.manifest_json)["source"] + assert source == { + "source": "git-subdir", + "url": "https://github.com/vercel-labs/skills.git", + "path": "skills/find-skills", + } + + +@pytest.mark.asyncio +async def test_resolve_and_sync_connection_failure_is_unreachable(monkeypatch): + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="flaky-marketplace", source_ref="org/flaky-repo") + + async def _get(http_client, url, **kwargs): + raise httpx.ConnectError("connection refused", request=httpx.Request("GET", url)) + + monkeypatch.setattr(sync_module, "async_safe_get", _get) + + result = await resolve_and_sync(client, marketplace) + + assert result.status == "error" + refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id}) + assert refreshed.sync_status == "error" + assert refreshed.sync_error.startswith("unreachable:") + + +@pytest.mark.asyncio +async def test_resolve_and_sync_non_200_is_http_error(monkeypatch): + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="broken-marketplace", source_ref="org/broken-repo") + + async def _get(http_client, url, **kwargs): + return httpx.Response(500) + + monkeypatch.setattr(sync_module, "async_safe_get", _get) + + result = await resolve_and_sync(client, marketplace) + + assert result.status == "error" + refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id}) + assert refreshed.sync_status == "error" + assert refreshed.sync_error.startswith("http_error:") + + +@pytest.mark.asyncio +async def test_resolve_and_sync_malformed_json_is_invalid_json(monkeypatch): + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="badjson-marketplace", source_ref="org/badjson-repo") + + async def _get(http_client, url, **kwargs): + return httpx.Response(200, text="{not valid json") + + monkeypatch.setattr(sync_module, "async_safe_get", _get) + + result = await resolve_and_sync(client, marketplace) + + assert result.status == "error" + refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id}) + assert refreshed.sync_status == "error" + assert refreshed.sync_error.startswith("invalid_json:") + + +@pytest.mark.asyncio +async def test_resolve_and_sync_schema_violation_is_invalid_schema(monkeypatch): + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="badschema-marketplace", source_ref="org/badschema-repo") + + async def _get(http_client, url, **kwargs): + # Valid JSON, but missing the required top-level "name" field. + return httpx.Response(200, json={"foo": "bar"}) + + monkeypatch.setattr(sync_module, "async_safe_get", _get) + + result = await resolve_and_sync(client, marketplace) + + assert result.status == "error" + refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id}) + assert refreshed.sync_status == "error" + assert refreshed.sync_error.startswith("invalid_schema:") + + +@pytest.mark.asyncio +async def test_resolve_and_sync_non_github_404_does_not_attempt_dir_scan(monkeypatch): + """Regression test: the skills/ directory-scan fallback is GitHub-only. A + gitlab/bitbucket/plain-url source that 404s on its manifest path must fail + the sync outright and must never hit the GitHub contents API.""" + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="gitlab-marketplace", source_ref="https://gitlab.com/foo/bar") + + manifest_url = "https://gitlab.com/foo/bar/-/raw/main/.claude-plugin/marketplace.json" + calls = [] + + async def _get(http_client, url, **kwargs): + calls.append(url) + return httpx.Response(404) + + monkeypatch.setattr(sync_module, "async_safe_get", _get) + + result = await resolve_and_sync(client, marketplace) + + assert result.status == "error" + refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id}) + assert refreshed.sync_status == "error" + assert calls == [manifest_url] + assert all("api.github.com" not in url for url in calls) + + +@pytest.mark.asyncio +async def test_resolve_and_sync_is_idempotent(monkeypatch): + monkeypatch.setattr(sync_module, "datetime", _FakeClock()) + + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="anthropic-agent-skills", source_ref="anthropics/skills") + + async def _get(http_client, url, **kwargs): + return httpx.Response(200, json=_ANTHROPIC_SKILLS_MANIFEST) + + monkeypatch.setattr(sync_module, "async_safe_get", _get) + + await resolve_and_sync(client, marketplace) + first_pass = await client.db.litellm_claudecodeplugintable.find_many(where={"marketplace_id": marketplace.id}) + assert len(first_pass) == 3 + # Extract the value now - the stored record is a live, mutable object that + # the second sync will update in place, so keeping a reference to the + # record itself (rather than its updated_at value) would falsely "advance" + # this too. + first_updated_at = next(p for p in first_pass if p.name == "anthropic-agent-skills--claude-api").updated_at + + await resolve_and_sync(client, marketplace) + second_pass = await client.db.litellm_claudecodeplugintable.find_many(where={"marketplace_id": marketplace.id}) + assert len(second_pass) == 3 + + target_after = next(p for p in second_pass if p.name == "anthropic-agent-skills--claude-api") + assert target_after.updated_at > first_updated_at + + +@pytest.mark.asyncio +async def test_resolve_and_sync_soft_disables_stale_plugin(monkeypatch): + client = _make_fake_prisma_client() + marketplace = await _create_marketplace(client, name="anthropic-agent-skills", source_ref="anthropics/skills") + + async def _get_full(http_client, url, **kwargs): + return httpx.Response(200, json=_ANTHROPIC_SKILLS_MANIFEST) + + monkeypatch.setattr(sync_module, "async_safe_get", _get_full) + await resolve_and_sync(client, marketplace) + + stale_name = "anthropic-agent-skills--claude-api" + stale_plugin = await client.db.litellm_claudecodeplugintable.find_unique(where={"name": stale_name}) + assert stale_plugin is not None + # Simulate an admin having manually enabled this skill before the next sync. + stale_plugin.enabled = True + + reduced_manifest = { + **_ANTHROPIC_SKILLS_MANIFEST, + "plugins": [p for p in _ANTHROPIC_SKILLS_MANIFEST["plugins"] if p["name"] != "claude-api"], + } + + async def _get_reduced(http_client, url, **kwargs): + return httpx.Response(200, json=reduced_manifest) + + monkeypatch.setattr(sync_module, "async_safe_get", _get_reduced) + result = await resolve_and_sync(client, marketplace) + + assert result.status == "success" + assert result.plugin_count == 2 + + refreshed_stale = await client.db.litellm_claudecodeplugintable.find_unique(where={"name": stale_name}) + assert refreshed_stale is not None + assert refreshed_stale.enabled is False diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_skill_authz.py b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_skill_authz.py new file mode 100644 index 00000000000..898acde9c71 --- /dev/null +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_skill_authz.py @@ -0,0 +1,168 @@ +""" +Unit tests for claude_code_skill_authz.get_allowed_skills. + +This is the authorization gate that decides which non-public (disabled) +Claude Code skills a key/team/org can additionally see via the +`GET /claude-code/marketplace.json?key=...` endpoint. The intersection/ceiling +rules deliberately mirror MCPRequestHandler.get_allowed_mcp_servers: +- an empty/missing allowed_skills list at a given level means that level + places no restriction and is skipped from the intersection +- key/team: if both restrict, intersect; if only one restricts, use it +- org: acts as a ceiling - if the org has an explicit list, it caps whatever + the key/team level resolved to; if nothing lower restricts, the org list + becomes the result outright + +Team/org lookups are stubbed at their real collaborator functions +(auth_checks.get_team_object / get_org_object / get_object_permission) rather +than by faking the underlying prisma/cache stack those already have their own +tests for - this keeps these tests focused on get_allowed_skills's own +aggregation logic. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from litellm.models.object_permission import LiteLLM_ObjectPermissionTable +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_authz import ( + get_allowed_skills, +) +from litellm.proxy.auth import auth_checks + + +@pytest.fixture(autouse=True) +def _default_team_org_lookups(monkeypatch): + """By default, nobody has a team/org - team_id/org_id being unset on the + key is what actually short-circuits these, but stub them anyway so a test + that forgets to override a lookup fails loudly instead of hitting a real DB.""" + monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=None)) + monkeypatch.setattr(auth_checks, "get_org_object", AsyncMock(return_value=None)) + monkeypatch.setattr(auth_checks, "get_object_permission", AsyncMock(return_value=None)) + + +def _object_permission(allowed_skills): + return LiteLLM_ObjectPermissionTable(object_permission_id="perm-1", allowed_skills=allowed_skills) + + +def _key_auth(*, allowed_skills=None, team_id=None, org_id=None): + object_permission = _object_permission(allowed_skills) if allowed_skills is not None else None + return UserAPIKeyAuth( + api_key="sk-test", + user_id="test-user", + team_id=team_id, + org_id=org_id, + object_permission=object_permission, + ) + + +def _mock_org(monkeypatch, *, allowed_skills): + monkeypatch.setattr( + auth_checks, "get_org_object", AsyncMock(return_value=SimpleNamespace(object_permission_id="org-perm-1")) + ) + monkeypatch.setattr( + auth_checks, + "get_object_permission", + AsyncMock(return_value=_object_permission(allowed_skills)), + ) + + +def _mock_team(monkeypatch, *, allowed_skills): + monkeypatch.setattr( + auth_checks, + "get_team_object", + AsyncMock(return_value=SimpleNamespace(object_permission=_object_permission(allowed_skills))), + ) + + +@pytest.mark.asyncio +async def test_org_grants_with_no_team_or_key_restriction_becomes_the_effective_set(monkeypatch): + """Org narrows, nothing else does -> org's list becomes the ceiling outright.""" + key_auth = _key_auth(allowed_skills=None, org_id="org-1") + _mock_org(monkeypatch, allowed_skills=["a--x"]) + + result = await get_allowed_skills(key_auth, prisma_client=object()) + + assert result == frozenset({"a--x"}) + + +@pytest.mark.asyncio +async def test_org_grants_nothing_key_grants_becomes_the_effective_set(monkeypatch): + """Key narrows, org places no restriction -> key's list wins as-is.""" + key_auth = _key_auth(allowed_skills=["a--x"], org_id=None) + + result = await get_allowed_skills(key_auth, prisma_client=object()) + + assert result == frozenset({"a--x"}) + + +@pytest.mark.asyncio +async def test_org_grants_superset_of_key_intersects_to_key(monkeypatch): + """Both org and key restrict -> org caps the key's (tighter) list via intersection.""" + key_auth = _key_auth(allowed_skills=["a--x"], org_id="org-1") + _mock_org(monkeypatch, allowed_skills=["a--x", "a--y"]) + + result = await get_allowed_skills(key_auth, prisma_client=object()) + + assert result == frozenset({"a--x"}) + + +@pytest.mark.asyncio +async def test_disjoint_org_and_key_grants_intersect_to_empty(monkeypatch): + """Org and key each restrict to disjoint sets -> no overlap, effective set is empty.""" + key_auth = _key_auth(allowed_skills=["a--y"], org_id="org-1") + _mock_org(monkeypatch, allowed_skills=["a--x"]) + + result = await get_allowed_skills(key_auth, prisma_client=object()) + + assert result == frozenset() + + +@pytest.mark.asyncio +async def test_no_permissions_anywhere_returns_empty_set(monkeypatch): + """Nobody (key/team/org) has any object_permission at all. + + Mirrors MCPRequestHandler.get_allowed_mcp_servers's fallback: with zero + restrictions defined at any level, the function returns an empty + collection rather than "everything" - callers (get_marketplace) treat an + empty allowed_skills result as "nothing extra beyond the public catalog", + which is the correct, safe default. + """ + key_auth = _key_auth(allowed_skills=None, team_id=None, org_id=None) + + result = await get_allowed_skills(key_auth, prisma_client=object()) + + assert result == frozenset() + + +@pytest.mark.asyncio +async def test_team_grants_with_no_key_restriction_becomes_the_effective_set(monkeypatch): + key_auth = _key_auth(allowed_skills=None, team_id="team-1") + _mock_team(monkeypatch, allowed_skills=["a--x"]) + + result = await get_allowed_skills(key_auth, prisma_client=object()) + + assert result == frozenset({"a--x"}) + + +@pytest.mark.asyncio +async def test_key_and_team_grants_intersect(monkeypatch): + key_auth = _key_auth(allowed_skills=["a--x", "a--z"], team_id="team-1") + _mock_team(monkeypatch, allowed_skills=["a--x", "a--y"]) + + result = await get_allowed_skills(key_auth, prisma_client=object()) + + assert result == frozenset({"a--x"}) + + +@pytest.mark.asyncio +async def test_org_ceiling_applies_on_top_of_key_team_intersection(monkeypatch): + """Full chain: key/team intersect first, then org caps the result further.""" + key_auth = _key_auth(allowed_skills=["a--x", "a--y"], team_id="team-1", org_id="org-1") + _mock_team(monkeypatch, allowed_skills=["a--x", "a--y", "a--z"]) + _mock_org(monkeypatch, allowed_skills=["a--x"]) + + result = await get_allowed_skills(key_auth, prisma_client=object()) + + assert result == frozenset({"a--x"}) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index dac15a21620..7a8db3350b9 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { - "@typescript-eslint/no-explicit-any": 1977, + "@typescript-eslint/no-explicit-any": 1985, "complexity": 129, - "local/no-large-inline-object-arg": 509, + "local/no-large-inline-object-arg": 510, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 16 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces.ts new file mode 100644 index 00000000000..f906cebaed6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { getClaudeCodeMarketplaces } from "@/components/networking"; +import { ListMarketplacesResponse, MarketplaceSource } from "@/components/claude_code_plugins/types"; +import useAuthorized from "../useAuthorized"; + +const claudeCodeMarketplaceKeys = createQueryKeys("claudeCodeMarketplaces"); + +export const useClaudeCodeMarketplaces = () => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: claudeCodeMarketplaceKeys.list(), + queryFn: async () => { + const response: ListMarketplacesResponse = await getClaudeCodeMarketplaces(accessToken!); + return response.marketplaces; + }, + enabled: !!accessToken, + }); +}; + +export { claudeCodeMarketplaceKeys }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins.ts new file mode 100644 index 00000000000..c097ac29f68 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { getClaudeCodePluginsList } from "@/components/networking"; +import { ListPluginsResponse, PluginListItem } from "@/components/claude_code_plugins/types"; +import useAuthorized from "../useAuthorized"; + +const claudeCodePluginKeys = createQueryKeys("claudeCodePlugins"); + +export const useClaudeCodePlugins = () => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: claudeCodePluginKeys.list(), + queryFn: async () => { + const response: ListPluginsResponse = await getClaudeCodePluginsList(accessToken!, false); + return response.plugins; + }, + enabled: !!accessToken, + }); +}; + +export { claudeCodePluginKeys }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx index 178cd36857f..1b43b26c101 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -1,13 +1,21 @@ import React, { useState, useEffect } from "react"; import { Button } from "@tremor/react"; import { Modal } from "antd"; -import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import { + getClaudeCodePluginsList, + deleteClaudeCodePlugin, + getClaudeCodeMarketplaces, + syncClaudeCodeMarketplace, + deleteClaudeCodeMarketplace, +} from "@/components/networking"; import AddPluginForm from "./add_plugin_form"; +import AddMarketplaceForm from "./add_marketplace_form"; +import MarketplaceTable from "./marketplace_table"; import PluginTable from "./plugin_table"; import SkillDetail from "@/components/claude_code_plugins/skill_detail"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "@/components/molecules/notifications_manager"; -import { Plugin, ListPluginsResponse } from "@/components/claude_code_plugins/types"; +import { Plugin, ListPluginsResponse, MarketplaceSource, ListMarketplacesResponse } from "@/components/claude_code_plugins/types"; interface ClaudeCodePluginsPanelProps { accessToken: string | null; @@ -25,6 +33,16 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT } | null>(null); const [selectedSkill, setSelectedSkill] = useState(null); + const [marketplacesList, setMarketplacesList] = useState([]); + const [isAddMarketplaceModalVisible, setIsAddMarketplaceModalVisible] = useState(false); + const [isMarketplacesLoading, setIsMarketplacesLoading] = useState(false); + const [isDeletingMarketplace, setIsDeletingMarketplace] = useState(false); + const [syncingMarketplaceName, setSyncingMarketplaceName] = useState(null); + const [marketplaceToDelete, setMarketplaceToDelete] = useState<{ + name: string; + displayName: string; + } | null>(null); + const isAdmin = userRole ? isAdminRole(userRole) : false; const fetchPlugins = async () => { @@ -41,8 +59,23 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT } }; + const fetchMarketplaces = async () => { + if (!accessToken) return; + + setIsMarketplacesLoading(true); + try { + const response: ListMarketplacesResponse = await getClaudeCodeMarketplaces(accessToken); + setMarketplacesList(response.marketplaces); + } catch (error) { + console.error("Error fetching marketplaces:", error); + } finally { + setIsMarketplacesLoading(false); + } + }; + useEffect(() => { fetchPlugins(); + fetchMarketplaces(); }, [accessToken]); const handleDeleteClick = (pluginName: string, displayName: string) => { @@ -66,6 +99,45 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT } }; + const handleSyncMarketplace = async (marketplaceName: string) => { + if (!accessToken) return; + + setSyncingMarketplaceName(marketplaceName); + try { + await syncClaudeCodeMarketplace(accessToken, marketplaceName); + NotificationsManager.success(`Marketplace "${marketplaceName}" synced successfully`); + fetchMarketplaces(); + fetchPlugins(); + } catch (error) { + console.error("Error syncing marketplace:", error); + NotificationsManager.error("Failed to sync marketplace"); + } finally { + setSyncingMarketplaceName(null); + } + }; + + const handleDeleteMarketplaceClick = (marketplaceName: string, displayName: string) => { + setMarketplaceToDelete({ name: marketplaceName, displayName }); + }; + + const handleDeleteMarketplaceConfirm = async () => { + if (!marketplaceToDelete || !accessToken) return; + + setIsDeletingMarketplace(true); + try { + await deleteClaudeCodeMarketplace(accessToken, marketplaceToDelete.name); + NotificationsManager.success(`Marketplace "${marketplaceToDelete.displayName}" deleted successfully`); + fetchMarketplaces(); + fetchPlugins(); + } catch (error) { + console.error("Error deleting marketplace:", error); + NotificationsManager.error("Failed to delete marketplace"); + } finally { + setIsDeletingMarketplace(false); + setMarketplaceToDelete(null); + } + }; + return (
{selectedSkill ? ( @@ -79,10 +151,34 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT ) : ( <>
+

Marketplaces

+

+ Import external Claude Code plugin marketplaces. Their skills are namespaced (e.g.{" "} + marketplace-name--skill-name) and can be granted to + orgs, teams, or keys. +

+
+ +
+
+ + + +

Skills

- Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "} - /claude-code/marketplace.json. + Register Claude Code skills. Public skills appear in the Skill Hub for all users and are served via{" "} + /claude-code/marketplace.json. Non-public skills + require a per-skill grant on an org, team, or key.

); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_marketplace_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_marketplace_form.tsx new file mode 100644 index 00000000000..b13908d4b0c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_marketplace_form.tsx @@ -0,0 +1,87 @@ +import React, { useState } from "react"; +import { Modal, Form, Input, Button } from "antd"; +import { registerClaudeCodeMarketplace } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +interface AddMarketplaceFormProps { + visible: boolean; + onClose: () => void; + accessToken: string | null; + onSuccess: () => void; +} + +interface AddMarketplaceFormValues { + source: string; + name?: string; +} + +const AddMarketplaceForm: React.FC = ({ visible, onClose, accessToken, onSuccess }) => { + const [form] = Form.useForm(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleCancel = () => { + form.resetFields(); + onClose(); + }; + + const handleSubmit = async (values: AddMarketplaceFormValues) => { + if (!accessToken) { + NotificationsManager.error("No access token available"); + return; + } + + setIsSubmitting(true); + try { + await registerClaudeCodeMarketplace(accessToken, { + source: values.source.trim(), + ...(values.name?.trim() ? { name: values.name.trim() } : {}), + }); + NotificationsManager.success("Marketplace imported successfully"); + form.resetFields(); + onSuccess(); + onClose(); + } catch (error) { + console.error("Error registering marketplace:", error); + const reason = error instanceof Error && error.message ? error.message : "Failed to import marketplace"; + NotificationsManager.error(`Failed to import marketplace: ${reason}`); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ + + + + + + + + +
+ + +
+
+
+
+ ); +}; + +export default AddMarketplaceForm; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/marketplace_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/marketplace_table.tsx new file mode 100644 index 00000000000..d9338a40494 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/marketplace_table.tsx @@ -0,0 +1,180 @@ +import { RefreshIcon, TrashIcon } from "@heroicons/react/outline"; +import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { Button, Tooltip } from "antd"; +import React from "react"; +import { DateCell, StatusBadge, StatusTone } from "@/components/shared/table_cells"; +import { MarketplaceSource } from "@/components/claude_code_plugins/types"; + +interface MarketplaceTableProps { + marketplacesList: MarketplaceSource[]; + isLoading: boolean; + isAdmin: boolean; + syncingName: string | null; + onSyncClick: (marketplaceName: string) => void; + onDeleteClick: (marketplaceName: string, displayName: string) => void; +} + +const SYNC_STATUS_TONE: Record = { + synced: "success", + syncing: "info", + pending: "warning", + error: "error", +}; + +const syncStatusTone = (status: string): StatusTone => SYNC_STATUS_TONE[status] ?? "neutral"; + +const MarketplaceTable: React.FC = ({ + marketplacesList, + isLoading, + isAdmin, + syncingName, + onSyncClick, + onDeleteClick, +}) => { + const columns: ColumnDef[] = [ + { + header: "Marketplace", + accessorKey: "name", + cell: ({ row }) => { + const marketplace = row.original; + return ( +
+ {marketplace.name} + {marketplace.display_name && marketplace.display_name !== marketplace.name && ( + {marketplace.display_name} + )} +
+ ); + }, + }, + { + header: "Source", + accessorKey: "source_type", + cell: ({ row }) => { + const marketplace = row.original; + return ( + + {marketplace.source_type} + + ); + }, + }, + { + header: "Sync Status", + accessorKey: "sync_status", + cell: ({ row }) => { + const marketplace = row.original; + return ( + + ); + }, + }, + { + header: "Skills", + accessorKey: "plugin_count", + cell: ({ row }) => {row.original.plugin_count}, + }, + { + header: "Last Synced", + accessorKey: "last_synced_at", + cell: ({ row }) => , + }, + ...(isAdmin + ? [ + { + header: "Actions", + id: "actions", + cell: ({ row }: { row: { original: MarketplaceSource } }) => { + const marketplace = row.original; + return ( +
+ +
+ ); + }, + }, + ] + : []), + ]; + + const table = useReactTable({ + data: marketplacesList, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {isLoading && ( + + + + )} + {!isLoading && marketplacesList.length === 0 && ( + + + + )} + {!isLoading && + marketplacesList.length > 0 && + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
+
+

Loading...

+
+
+
+

No marketplaces imported. Add one to get started.

+
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ ); +}; + +export default MarketplaceTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/plugin_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/plugin_table.tsx index eb1c495374a..7f306541189 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/plugin_table.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/plugin_table.tsx @@ -107,7 +107,13 @@ const PluginTable: React.FC = ({ accessorKey: "enabled", cell: ({ row }) => { const plugin = row.original; - return ; + return ( + + ); }, }, { diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/MakeSkillPublicForm.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/MakeSkillPublicForm.tsx index 7abe67f3a4f..eb14d3a9e0e 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/MakeSkillPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/MakeSkillPublicForm.tsx @@ -118,7 +118,8 @@ const MakeSkillPublicForm: React.FC = ({
- Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished. + Selected skills are visible without a key. Deselected skills require an assigned grant ( + allowed_skills) on the org, team, or key.
diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillPermissionsPicker.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillPermissionsPicker.test.tsx new file mode 100644 index 00000000000..8d2683ab293 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillPermissionsPicker.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import SkillPermissionsPicker from "./SkillPermissionsPicker"; + +vi.mock("@/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces", () => ({ + useClaudeCodeMarketplaces: vi.fn(), +})); +vi.mock("@/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins", () => ({ + useClaudeCodePlugins: vi.fn(), +})); + +import { useClaudeCodeMarketplaces } from "@/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces"; +import { useClaudeCodePlugins } from "@/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins"; + +const mockUseMarketplaces = vi.mocked(useClaudeCodeMarketplaces); +const mockUsePlugins = vi.mocked(useClaudeCodePlugins); + +const marketplaceFixture = (overrides: Partial> = {}) => ({ + id: "mkt-1", + name: "anthropic-agent-skills", + display_name: "Anthropic Agent Skills", + source_type: "github", + source_ref: "anthropics/agent-skills", + enabled: true, + sync_status: "synced", + plugin_count: 2, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + ...overrides, +}); + +const otherMarketplaceFixture = marketplaceFixture({ + id: "mkt-2", + name: "other-marketplace", + display_name: "Other Marketplace", + plugin_count: 1, +}); + +const pluginFixture = (name: string) => ({ + id: name, + name, + enabled: false, + source: { source: "github" as const }, +}); + +describe("SkillPermissionsPicker", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("bulk-adds every skill belonging to a marketplace when its checkbox is checked", async () => { + mockUseMarketplaces.mockReturnValue({ data: [marketplaceFixture()], isLoading: false } as any); + mockUsePlugins.mockReturnValue({ + data: [ + pluginFixture("anthropic-agent-skills--document-skills"), + pluginFixture("anthropic-agent-skills--pdf-skills"), + ], + isLoading: false, + } as any); + + const onChange = vi.fn(); + renderWithProviders(); + + const marketplaceCheckbox = screen.getByRole("checkbox", { name: /Anthropic Agent Skills/ }); + await userEvent.click(marketplaceCheckbox); + + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining(["anthropic-agent-skills--document-skills", "anthropic-agent-skills--pdf-skills"]), + ); + expect(onChange.mock.calls[0][0]).toHaveLength(2); + }); + + it("removes only that marketplace's skills when its checkbox is unchecked, leaving other marketplaces untouched", async () => { + mockUseMarketplaces.mockReturnValue({ + data: [marketplaceFixture(), otherMarketplaceFixture], + isLoading: false, + } as any); + mockUsePlugins.mockReturnValue({ + data: [ + pluginFixture("anthropic-agent-skills--document-skills"), + pluginFixture("anthropic-agent-skills--pdf-skills"), + pluginFixture("other-marketplace--some-skill"), + ], + isLoading: false, + } as any); + + const onChange = vi.fn(); + renderWithProviders( + , + ); + + // Both marketplace checkboxes render fully checked given the value above. + const firstMarketplaceCheckbox = screen.getByRole("checkbox", { name: /Anthropic Agent Skills/ }); + await userEvent.click(firstMarketplaceCheckbox); + + expect(onChange).toHaveBeenCalledWith(["other-marketplace--some-skill"]); + }); + + it("removes a single individually-unchecked skill without touching its marketplace siblings", async () => { + mockUseMarketplaces.mockReturnValue({ data: [marketplaceFixture()], isLoading: false } as any); + mockUsePlugins.mockReturnValue({ + data: [ + pluginFixture("anthropic-agent-skills--document-skills"), + pluginFixture("anthropic-agent-skills--pdf-skills"), + ], + isLoading: false, + } as any); + + const onChange = vi.fn(); + renderWithProviders( + , + ); + + const skillCheckbox = screen.getByRole("checkbox", { name: /document-skills/ }); + await userEvent.click(skillCheckbox); + + expect(onChange).toHaveBeenCalledWith(["anthropic-agent-skills--pdf-skills"]); + }); + + it("adds a single individually-checked skill without requiring the marketplace checkbox", async () => { + mockUseMarketplaces.mockReturnValue({ data: [marketplaceFixture()], isLoading: false } as any); + mockUsePlugins.mockReturnValue({ + data: [ + pluginFixture("anthropic-agent-skills--document-skills"), + pluginFixture("anthropic-agent-skills--pdf-skills"), + ], + isLoading: false, + } as any); + + const onChange = vi.fn(); + renderWithProviders(); + + const skillCheckbox = screen.getByRole("checkbox", { name: /pdf-skills/ }); + await userEvent.click(skillCheckbox); + + expect(onChange).toHaveBeenCalledWith(["anthropic-agent-skills--pdf-skills"]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillPermissionsPicker.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillPermissionsPicker.tsx new file mode 100644 index 00000000000..5dd694b6910 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillPermissionsPicker.tsx @@ -0,0 +1,127 @@ +import React, { useMemo } from "react"; +import { Spin, Checkbox } from "antd"; +import { useClaudeCodeMarketplaces } from "@/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces"; +import { useClaudeCodePlugins } from "@/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins"; +import { PluginListItem } from "./types"; + +interface SkillPermissionsPickerProps { + accessToken: string; + value?: string[]; + onChange: (skills: string[]) => void; + disabled?: boolean; +} + +const marketplacePrefix = (marketplaceName: string) => `${marketplaceName}--`; + +const SkillPermissionsPicker: React.FC = ({ value, onChange, disabled = false }) => { + const { data: marketplaces = [], isLoading: marketplacesLoading } = useClaudeCodeMarketplaces(); + const { data: allSkills = [], isLoading: skillsLoading } = useClaudeCodePlugins(); + + const selected = useMemo(() => value ?? [], [value]); + const selectedSet = useMemo(() => new Set(selected), [selected]); + + const skillsByMarketplace = useMemo(() => { + return marketplaces.reduce>((acc, marketplace) => { + const prefix = marketplacePrefix(marketplace.name); + return { ...acc, [marketplace.name]: allSkills.filter((skill) => skill.name.startsWith(prefix)) }; + }, {}); + }, [marketplaces, allSkills]); + + const handleToggleMarketplace = (marketplaceName: string, checked: boolean) => { + if (disabled) return; + const prefix = marketplacePrefix(marketplaceName); + if (checked) { + const skillNames = (skillsByMarketplace[marketplaceName] || []).map((skill) => skill.name); + onChange(Array.from(new Set([...selected, ...skillNames]))); + } else { + onChange(selected.filter((name) => !name.startsWith(prefix))); + } + }; + + const handleToggleSkill = (skillName: string, checked: boolean) => { + if (disabled) return; + onChange( + checked ? Array.from(new Set([...selected, skillName])) : selected.filter((name) => name !== skillName), + ); + }; + + if (marketplacesLoading) { + return ( +
+ + Loading marketplaces... +
+ ); + } + + if (marketplaces.length === 0) { + return

No marketplaces imported yet. Add one from the Skills page.

; + } + + return ( +
+ {marketplaces.map((marketplace) => { + const skills = skillsByMarketplace[marketplace.name] || []; + const selectedCount = skills.filter((skill) => selectedSet.has(skill.name)).length; + const checked = skills.length > 0 && selectedCount === skills.length; + const indeterminate = selectedCount > 0 && selectedCount < skills.length; + + return ( +
+
+ handleToggleMarketplace(marketplace.name, e.target.checked)} + > +

{marketplace.display_name || marketplace.name}

+

+ {marketplace.source_type} · {selectedCount}/{skills.length} skills selected +

+
+
+ +
+ {skillsLoading && ( +
+ + Loading skills... +
+ )} + + {!skillsLoading && skills.length === 0 && ( +

No skills found for this marketplace

+ )} + + {!skillsLoading && skills.length > 0 && ( +
+ {skills.map((skill) => { + const isSelected = selectedSet.has(skill.name); + return ( + + ); + })} +
+ )} +
+
+ ); + })} +
+ ); +}; + +export default SkillPermissionsPicker; diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts index d16c880749b..1fc10206710 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts @@ -87,6 +87,45 @@ export interface MarketplaceResponse { plugins: MarketplacePluginEntry[]; } +// Marketplace management types +// Hand-written (not generated from schema.d.ts): the /claude-code/marketplaces admin endpoints +// are not yet reflected in the OpenAPI spec this dashboard was built against. +export interface MarketplaceSource { + id: string; + name: string; + display_name?: string; + source_type: string; + source_ref: string; + branch?: string; + enabled: boolean; + sync_status: string; + sync_error?: string; + last_synced_at?: string; + plugin_count: number; + created_at: string; + updated_at: string; +} + +export interface RegisterMarketplaceRequest { + source: string; + name?: string; +} + +export interface RegisterMarketplaceResponse { + status: string; + marketplace: MarketplaceSource; +} + +export interface ListMarketplacesResponse { + marketplaces: MarketplaceSource[]; + count: number; +} + +export interface DeleteMarketplaceResponse { + status: string; + message: string; +} + // UI-specific types export interface CategoryTab { key: string; diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 285daa8f156..05481205aa6 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -95,6 +95,7 @@ export interface KeyResponse { vector_stores: string[]; agents?: string[]; agent_access_groups?: string[]; + allowed_skills?: string[]; }; access_group_ids?: string[]; budget_fallbacks?: Record; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 781799286e9..871152c68ad 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -26,7 +26,7 @@ import { clearTokenCookies, storeLoginToken } from "@/utils/cookieUtils"; import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } from "./tag_management/types"; import { Team } from "./key_team_helpers/key_list"; import { EmailEventSettingsResponse, EmailEventSettingsUpdateRequest } from "./email_events/types"; -import type { SkillRegisterRequest } from "./claude_code_plugins/types"; +import type { RegisterMarketplaceRequest, SkillRegisterRequest } from "./claude_code_plugins/types"; import { jsonFields } from "./common_components/check_openapi_schema"; import NotificationsManager from "./molecules/notifications_manager"; import type { MCPUserEnvVarsStatus } from "./mcp_tools/types"; @@ -206,6 +206,7 @@ export interface Organization { mcp_servers: string[]; mcp_access_groups?: string[]; vector_stores: string[]; + allowed_skills?: string[]; }; } @@ -7391,6 +7392,78 @@ export const deleteClaudeCodePlugin = async (accessToken: string, pluginName: st } }; +/** + * Register (and sync) a new external Claude Code marketplace (admin only) + * @param accessToken - Admin access token + * @param marketplaceData - Marketplace source to import + */ +export const registerClaudeCodeMarketplace = async ( + accessToken: string, + marketplaceData: RegisterMarketplaceRequest, +) => { + try { + return await apiClient.post(`/claude-code/marketplaces`, { accessToken, body: marketplaceData }); + } catch (error) { + console.error("Failed to register Claude Code marketplace:", error); + throw error; + } +}; + +/** + * List all imported Claude Code marketplaces (admin only) + * @param accessToken - Admin access token + */ +export const getClaudeCodeMarketplaces = async (accessToken: string) => { + try { + return await apiClient.get(`/claude-code/marketplaces`, { accessToken }); + } catch (error) { + console.error("Failed to fetch Claude Code marketplaces:", error); + throw error; + } +}; + +/** + * Get a single Claude Code marketplace by name (admin only) + * @param accessToken - Admin access token + * @param marketplaceName - Name of the marketplace + */ +export const getClaudeCodeMarketplaceByName = async (accessToken: string, marketplaceName: string) => { + try { + return await apiClient.get(`/claude-code/marketplaces/${marketplaceName}`, { accessToken }); + } catch (error) { + console.error(`Failed to fetch marketplace "${marketplaceName}":`, error); + throw error; + } +}; + +/** + * Re-sync a Claude Code marketplace (admin only) + * @param accessToken - Admin access token + * @param marketplaceName - Name of the marketplace to sync + */ +export const syncClaudeCodeMarketplace = async (accessToken: string, marketplaceName: string) => { + try { + return await apiClient.post(`/claude-code/marketplaces/${marketplaceName}/sync`, { accessToken }); + } catch (error) { + console.error(`Failed to sync marketplace "${marketplaceName}":`, error); + throw error; + } +}; + +/** + * Delete a Claude Code marketplace (admin only) + * @param accessToken - Admin access token + * @param marketplaceName - Name of the marketplace to delete + */ +export const deleteClaudeCodeMarketplace = async (accessToken: string, marketplaceName: string) => { + try { + return await apiClient.delete(`/claude-code/marketplaces/${marketplaceName}`, { accessToken }); + } catch (error) { + console.error(`Failed to delete marketplace "${marketplaceName}":`, error); + throw error; + } +}; + // Compliance check types and functions export interface ComplianceCheckResult { diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index be021a5b59d..f3426c5aedd 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -14,6 +14,7 @@ interface ObjectPermission { agents?: string[]; agent_access_groups?: string[]; search_tools?: string[]; + allowed_skills?: string[]; } interface ObjectPermissionsViewProps { @@ -37,6 +38,7 @@ export function ObjectPermissionsView({ const agents = objectPermission?.agents || []; const agentAccessGroups = objectPermission?.agent_access_groups || []; const searchTools = objectPermission?.search_tools || []; + const allowedSkills = objectPermission?.allowed_skills || []; const content = (
@@ -59,6 +61,14 @@ export function ObjectPermissionsView({ {searchTools.join(", ")} )}
+
+ Skills + {allowedSkills.length === 0 ? ( + No skill grants — only public skills are accessible. + ) : ( + {allowedSkills.join(", ")} + )} +
); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx index 0fe8adb70e1..1e1120f4a14 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx @@ -286,6 +286,7 @@ vi.mock("../common_components/team_dropdown", () => ({ ), })); vi.mock("../CreateUserButton", () => ({ CreateUserButton: () => null })); +vi.mock("../claude_code_plugins/SkillPermissionsPicker", () => ({ default: () => null })); vi.mock("../mcp_server_management/MCPServerSelector", () => ({ default: () => null })); vi.mock("../mcp_server_management/MCPToolPermissions", () => ({ default: () => null })); vi.mock("../shared/numerical_input", () => ({ default: () => null })); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index ef2ddab70ed..c56308c01cd 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -37,6 +37,7 @@ import { hasAllModelsSentinel, } from "../key_team_helpers/fetch_available_models_team_key"; import { Team } from "../key_team_helpers/key_list"; +import SkillPermissionsPicker from "../claude_code_plugins/SkillPermissionsPicker"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; @@ -523,6 +524,16 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp delete formValues.allowed_agents_and_groups; } + // Transform allowed_skills into object_permission format + if (formValues.allowed_skills && formValues.allowed_skills.length > 0) { + if (!formValues.object_permission) { + formValues.object_permission = {}; + } + formValues.object_permission.allowed_skills = formValues.allowed_skills; + // Remove the original field as it's now part of object_permission + delete formValues.allowed_skills; + } + // Add model_aliases if any are defined if (Object.keys(modelAliases).length > 0) { formValues.aliases = JSON.stringify(modelAliases); @@ -1536,6 +1547,32 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp + + + Skills + + + + Allowed Skills{" "} + + + + + } + name="allowed_skills" + help="Select skills this key can access. Public skills are always accessible without a grant" + > + form.setFieldValue("allowed_skills", skills)} + value={form.getFieldValue("allowed_skills")} + accessToken={accessToken} + /> + + + + {premiumUser ? ( diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 402cc33902f..1a564a8d69a 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -12,6 +12,7 @@ import { CheckIcon, CopyIcon } from "lucide-react"; import React, { useMemo, useState } from "react"; import MemberTable from "../common_components/MemberTable"; import UserSearchModal from "../common_components/user_search_modal"; +import SkillPermissionsPicker from "../claude_code_plugins/SkillPermissionsPicker"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import { ModelSelect } from "../ModelSelect/ModelSelect"; import NotificationsManager from "../molecules/notifications_manager"; @@ -139,7 +140,11 @@ const OrganizationInfoView: React.FC = ({ }; // Handle object_permission updates - if (values.vector_stores !== undefined || values.mcp_servers_and_groups !== undefined) { + if ( + values.vector_stores !== undefined || + values.mcp_servers_and_groups !== undefined || + values.allowed_skills !== undefined + ) { updateData.object_permission = { ...orgData?.object_permission, vector_stores: values.vector_stores || [], @@ -157,6 +162,10 @@ const OrganizationInfoView: React.FC = ({ updateData.object_permission.mcp_access_groups = accessGroups; } } + + if (values.allowed_skills !== undefined) { + updateData.object_permission.allowed_skills = values.allowed_skills || []; + } } const response = await organizationUpdateCall(accessToken, updateData); @@ -372,6 +381,7 @@ const OrganizationInfoView: React.FC = ({ servers: orgData.object_permission?.mcp_servers || [], accessGroups: orgData.object_permission?.mcp_access_groups || [], }, + allowed_skills: orgData.object_permission?.allowed_skills || [], }} layout="vertical" > @@ -438,6 +448,14 @@ const OrganizationInfoView: React.FC = ({ /> + + form.setFieldValue("allowed_skills", skills)} + value={form.getFieldValue("allowed_skills")} + accessToken={accessToken || ""} + /> + + diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 3b80c598d34..95a0173cad9 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -41,6 +41,7 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; import GuardrailSettingsView from "../GuardrailSettingsView"; import LoggingSettingsView from "../logging_settings_view"; +import SkillPermissionsPicker from "../claude_code_plugins/SkillPermissionsPicker"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; import { ModelSelect } from "../ModelSelect/ModelSelect"; @@ -127,6 +128,7 @@ export interface TeamData { agents?: string[]; agent_access_groups?: string[]; search_tools?: string[]; + allowed_skills?: string[]; }; team_member_budget_table: { max_budget: number; @@ -609,6 +611,12 @@ const TeamInfoView: React.FC = ({ updateData.object_permission.search_tools = values.object_permission_search_tools; } + // Handle skill grants + if (values.allowed_skills !== undefined) { + updateData.object_permission.allowed_skills = values.allowed_skills || []; + } + delete values.allowed_skills; + // Pass access_group_ids to the update request if (values.access_group_ids !== undefined) { updateData.access_group_ids = values.access_group_ids; @@ -994,6 +1002,7 @@ const TeamInfoView: React.FC = ({ access_group_ids: info.access_group_ids || [], default_team_member_models: info.default_team_member_models || [], allowed_passthrough_routes: info.metadata?.allowed_passthrough_routes || [], + allowed_skills: info.object_permission?.allowed_skills || [], }} layout="vertical" > @@ -1393,6 +1402,14 @@ const TeamInfoView: React.FC = ({ /> + + form.setFieldValue("allowed_skills", skills)} + value={form.getFieldValue("allowed_skills")} + accessToken={accessToken || ""} + /> + + Search Tool Settings diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index dce7b9bd949..67dc3cdb19b 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -26,6 +26,7 @@ import { } from "../key_team_helpers/TagRateLimitEditor"; import { excludeProxyWideSentinel, hasAllModelsSentinel } from "../key_team_helpers/fetch_available_models_team_key"; import { KeyResponse } from "../key_team_helpers/key_list"; +import SkillPermissionsPicker from "../claude_code_plugins/SkillPermissionsPicker"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; @@ -204,6 +205,7 @@ export function KeyEditView({ agents: keyData.object_permission?.agents || [], accessGroups: keyData.object_permission?.agent_access_groups || [], }, + allowed_skills: keyData.object_permission?.allowed_skills || [], logging_settings: extractLoggingSettings(keyData.metadata), disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks) ? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks) @@ -233,6 +235,7 @@ export function KeyEditView({ accessGroups: keyData.object_permission?.mcp_access_groups || [], }, mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {}, + allowed_skills: keyData.object_permission?.allowed_skills || [], throttle_on_budget_exceeded: keyData.metadata?.throttle_on_budget_exceeded || false, logging_settings: extractLoggingSettings(keyData.metadata), disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks) @@ -750,6 +753,14 @@ export function KeyEditView({ /> + + form.setFieldValue("allowed_skills", skills)} + value={form.getFieldValue("allowed_skills")} + accessToken={accessToken || ""} + /> + + diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index cd97f6b851d..a68b5d19ed5 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -237,6 +237,16 @@ export default function KeyInfoView({ delete formValues.agents_and_groups; } + // Handle skill grants + if (formValues.allowed_skills !== undefined) { + formValues.object_permission = { + ...currentKeyData.object_permission, + ...formValues.object_permission, + allowed_skills: formValues.allowed_skills || [], + }; + delete formValues.allowed_skills; + } + formValues.max_budget = mapEmptyStringToNull(formValues.max_budget); formValues.tpm_limit = mapEmptyStringToNull(formValues.tpm_limit); formValues.rpm_limit = mapEmptyStringToNull(formValues.rpm_limit);