diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105094500_add_skill_download_history/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105094500_add_skill_download_history/migration.sql new file mode 100644 index 00000000000..43e26b324a0 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105094500_add_skill_download_history/migration.sql @@ -0,0 +1,37 @@ +SET LOCAL lock_timeout = '10s'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = to_regclass('"LiteLLM_ClaudeCodePluginTable"') + AND attname = 'anonymous_download_count' AND NOT attisdropped + ) THEN + ALTER TABLE "LiteLLM_ClaudeCodePluginTable" + ADD COLUMN IF NOT EXISTS "anonymous_download_count" BIGINT NOT NULL DEFAULT 0; + END IF; + + IF to_regclass('"LiteLLM_ClaudeCodePluginDownload"') IS NULL THEN + CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginDownload" ( + "id" BIGSERIAL NOT NULL, + "plugin_id" TEXT NOT NULL, + "downloaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "version" TEXT, + "digest" TEXT NOT NULL, + "authenticated" BOOLEAN NOT NULL, + "user_id" TEXT, + "user_email" TEXT, + "key_alias" TEXT, + "team_id" TEXT, + "team_alias" TEXT, + CONSTRAINT "LiteLLM_ClaudeCodePluginDownload_pkey" PRIMARY KEY ("id"), + CONSTRAINT "LiteLLM_ClaudeCodePluginDownload_plugin_id_fkey" FOREIGN KEY ("plugin_id") + REFERENCES "LiteLLM_ClaudeCodePluginTable"("id") ON DELETE CASCADE ON UPDATE CASCADE + ); + END IF; + + IF to_regclass('"LiteLLM_ClaudeCodePluginDownload_plugin_id_id_idx"') IS NULL THEN + CREATE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginDownload_plugin_id_id_idx" + ON "LiteLLM_ClaudeCodePluginDownload"("plugin_id", "id"); + END IF; +END $$; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 5563414b23b..818f2dc4858 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1617,6 +1617,8 @@ model LiteLLM_ClaudeCodePluginTable { uploaded_archive LiteLLM_ClaudeCodePluginUpload? versions LiteLLM_ClaudeCodePluginVersion[] + downloads LiteLLM_ClaudeCodePluginDownload[] + anonymous_download_count BigInt @default(0) @@map("LiteLLM_ClaudeCodePluginTable") } @@ -1660,6 +1662,23 @@ model LiteLLM_SkillSubmission { @@index([created_at(sort: Desc), id(sort: Desc)]) } +model LiteLLM_ClaudeCodePluginDownload { + id BigInt @id @default(autoincrement()) + plugin_id String + downloaded_at DateTime @default(now()) + version String? + digest String + authenticated Boolean + user_id String? + user_email String? + key_alias String? + team_id String? + team_alias String? + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@index([plugin_id, id]) +} + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/litellm/integrations/otel/model/spans.py b/litellm/integrations/otel/model/spans.py index d4ee974d1a4..0f98a8fce0e 100644 --- a/litellm/integrations/otel/model/spans.py +++ b/litellm/integrations/otel/model/spans.py @@ -343,6 +343,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset( "LiteLLM_ClaudeCodePluginUpload", "LiteLLM_ClaudeCodePluginVersion", "LiteLLM_SkillSubmission", + "LiteLLM_ClaudeCodePluginDownload", "LiteLLM_MemoryTable", "LiteLLM_AdaptiveRouterState", "LiteLLM_AdaptiveRouterSession", diff --git a/litellm/models/plugin_upload.py b/litellm/models/plugin_upload.py index 647d9d02016..bd5e85c79cc 100644 --- a/litellm/models/plugin_upload.py +++ b/litellm/models/plugin_upload.py @@ -1,6 +1,7 @@ from collections.abc import AsyncIterator, Mapping from dataclasses import dataclass from datetime import datetime +from enum import Enum from types import MappingProxyType from typing import Literal, Protocol @@ -97,6 +98,43 @@ class PluginSnapshot: created_by: str | None +@dataclass(frozen=True, slots=True) +class PluginDownloader: + authenticated: bool + user_id: str | None + user_email: str | None + key_alias: str | None + team_id: str | None + team_alias: str | None + + +class PluginDownloadResult(Enum): + RECORDED = "recorded" + SKILL_NOT_FOUND = "skill_not_found" + + +@dataclass(frozen=True, slots=True) +class PluginDownloadEvent: + id: str + downloaded_at: datetime + version: str | None + digest: str + authenticated: bool + user_id: str | None + user_email: str | None + key_alias: str | None + team_id: str | None + team_alias: str | None + + +@dataclass(frozen=True, slots=True) +class PluginDownloadsResponse: + total_downloads: int + anonymous_downloads: int + downloads: tuple[PluginDownloadEvent, ...] + next_cursor: str | None + + class PluginUploadStore(Protocol): async def create(self, upload: PluginUpload) -> str | None: ... @@ -117,3 +155,11 @@ class PluginUploadStore(Protocol): async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None: ... def public_metadata(self) -> AsyncIterator[PluginArchiveMetadata]: ... + + async def record_download( + self, metadata: PluginArchiveMetadata, digest: str, downloader: PluginDownloader + ) -> PluginDownloadResult: ... + + async def downloads(self, name: str, limit: int, cursor: int | None) -> PluginDownloadsResponse | None: ... + + async def download_counts(self, plugin_ids: tuple[str, ...]) -> Mapping[str, int]: ... diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index c3110d30c38..7d9e230f8ef 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -7541,6 +7541,145 @@ "title": "PluginAuthor", "type": "object" }, + "PluginDownloadEvent": { + "properties": { + "authenticated": { + "title": "Authenticated", + "type": "boolean" + }, + "digest": { + "title": "Digest", + "type": "string" + }, + "downloaded_at": { + "format": "date-time", + "title": "Downloaded At", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "key_alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key Alias" + }, + "team_alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Alias" + }, + "team_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Id" + }, + "user_email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Email" + }, + "user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Id" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "id", + "downloaded_at", + "version", + "digest", + "authenticated", + "user_id", + "user_email", + "key_alias", + "team_id", + "team_alias" + ], + "title": "PluginDownloadEvent", + "type": "object" + }, + "PluginDownloadsResponse": { + "properties": { + "anonymous_downloads": { + "title": "Anonymous Downloads", + "type": "integer" + }, + "downloads": { + "items": { + "$ref": "#/components/schemas/PluginDownloadEvent" + }, + "title": "Downloads", + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + }, + "total_downloads": { + "title": "Total Downloads", + "type": "integer" + } + }, + "required": [ + "total_downloads", + "anonymous_downloads", + "downloads", + "next_cursor" + ], + "title": "PluginDownloadsResponse", + "type": "object" + }, "PluginListItem": { "description": "Plugin item in list responses.", "properties": { @@ -8703,6 +8842,87 @@ ] } }, + "/claude-code/plugins/{plugin_name}/downloads": { + "get": { + "description": "Admin-only ZIP download totals and authenticated downloader history.\n\nAnonymous downloads contribute only to totals. Counts include repeat requests and client prefetches,\nstarting when tracking is deployed. External sources and installation completion are not observed.", + "operationId": "get_plugin_downloads_claude_code_plugins__plugin_name__downloads_get", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + }, + { + "description": "Maximum number of download events", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 25, + "description": "Maximum number of download events", + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "description": "Previous next_cursor", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "pattern": "^[1-9][0-9]{0,18}$", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Previous next_cursor", + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginDownloadsResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Plugin Downloads", + "tags": [ + "claude_code_marketplace" + ] + } + }, "/claude-code/plugins/{plugin_name}/enable": { "post": { "description": "Enable a disabled plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: The name of the plugin to enable", @@ -58998,6 +59218,145 @@ "title": "ImportSkillRequest", "type": "object" }, + "PluginDownloadEvent": { + "properties": { + "authenticated": { + "title": "Authenticated", + "type": "boolean" + }, + "digest": { + "title": "Digest", + "type": "string" + }, + "downloaded_at": { + "format": "date-time", + "title": "Downloaded At", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "key_alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key Alias" + }, + "team_alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Alias" + }, + "team_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Id" + }, + "user_email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Email" + }, + "user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Id" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "id", + "downloaded_at", + "version", + "digest", + "authenticated", + "user_id", + "user_email", + "key_alias", + "team_id", + "team_alias" + ], + "title": "PluginDownloadEvent", + "type": "object" + }, + "PluginDownloadsResponse": { + "properties": { + "anonymous_downloads": { + "title": "Anonymous Downloads", + "type": "integer" + }, + "downloads": { + "items": { + "$ref": "#/components/schemas/PluginDownloadEvent" + }, + "title": "Downloads", + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + }, + "total_downloads": { + "title": "Total Downloads", + "type": "integer" + } + }, + "required": [ + "total_downloads", + "anonymous_downloads", + "downloads", + "next_cursor" + ], + "title": "PluginDownloadsResponse", + "type": "object" + }, "SkillHubAgentSkill": { "properties": { "description": { @@ -59364,6 +59723,18 @@ ], "title": "Domain" }, + "download_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Gateway ZIP downloads, included in proxy-admin catalog listings", + "title": "Download Count" + }, "download_url": { "anyOf": [ { @@ -60840,6 +61211,87 @@ ] } }, + "/skill-hub/skills/{plugin_name}/downloads": { + "get": { + "description": "Admin-only ZIP download totals and authenticated downloader history.\n\nAnonymous downloads contribute only to totals. Counts include repeat requests and client prefetches,\nstarting when tracking is deployed. External sources and installation completion are not observed.", + "operationId": "get_plugin_downloads_skill_hub_skills__plugin_name__downloads_get", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + }, + { + "description": "Maximum number of download events", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 25, + "description": "Maximum number of download events", + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "description": "Previous next_cursor", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "pattern": "^[1-9][0-9]{0,18}$", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Previous next_cursor", + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginDownloadsResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Plugin Downloads", + "tags": [ + "skill_hub" + ] + } + }, "/skill-hub/submissions": { "get": { "description": "Read a page of owned proposals. Proxy admins and admin viewers can read every owner's proposals.", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ca43e384b0d..5b5c0c5f8d3 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -457,6 +457,7 @@ class LiteLLMRoutes(enum.Enum): "/skill-hub/skills/{name}", "/skill-hub/skills/{name}/archive", "/skill-hub/skills/{name}/content", + "/skill-hub/skills/{plugin_name}/downloads", "/skill-hub/skills/{name}/versions", "/skill-hub/skills/{name}/versions/{version_id}", "/skill-hub/skills/{name}/versions/{version_id}/archive", @@ -490,6 +491,7 @@ class LiteLLMRoutes(enum.Enum): "/claude-code/plugins", "/claude-code/plugins/upload", "/claude-code/plugins/{plugin_name}/archive/{digest}.zip", + "/claude-code/plugins/{plugin_name}/downloads", "/claude-code/plugins/{plugin_name}", ] 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 d383d5f349a..26cb0a2f3e6 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 @@ -27,14 +27,17 @@ from datetime import datetime, timezone from types import MappingProxyType from typing import Annotated, Final, Protocol, TypedDict, get_args -from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile from fastapi.responses import JSONResponse, Response from pydantic import Json, TypeAdapter -from typing_extensions import ReadOnly +from typing_extensions import ReadOnly, assert_never from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.url_utils import is_https_url_with_host from litellm.models.plugin_upload import ( + PluginDownloader, + PluginDownloadResult, + PluginDownloadsResponse, PluginPublication, PluginPublicationBase, PluginPublicationError, @@ -240,6 +243,27 @@ async def upload_plugin( ) +def require_download_recorded(result: PluginDownloadResult) -> None: + match result: + case PluginDownloadResult.RECORDED: + return + case PluginDownloadResult.SKILL_NOT_FOUND: + raise _error_response(404, "Skill archive not found") + case _: + assert_never(result) + + +def plugin_downloader(caller: UserAPIKeyAuth | None) -> PluginDownloader: + return PluginDownloader( + authenticated=caller is not None, + user_id=caller.user_id if caller is not None else None, + user_email=caller.user_email if caller is not None else None, + key_alias=caller.key_alias if caller is not None else None, + team_id=caller.team_id if caller is not None else None, + team_alias=caller.team_alias if caller is not None else None, + ) + + @router.get( "/claude-code/plugins/{plugin_name}/archive/{digest}.zip", tags=["Claude Code Marketplace"], @@ -273,6 +297,7 @@ async def download_plugin_archive( content: Final = await uploads.get_content(archive) if content is None: raise _error_response(404, "Skill archive not found") + require_download_recorded(await uploads.record_download(archive, digest, plugin_downloader(caller))) return Response( content=content, media_type="application/zip", @@ -284,6 +309,35 @@ async def download_plugin_archive( ) +@router.get( + "/claude-code/plugins/{plugin_name}/downloads", + tags=["Claude Code Marketplace"], + response_model=PluginDownloadsResponse, +) +async def get_plugin_downloads( + plugin_name: str, + uploads: PluginUploadRepositoryDep, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + limit: Annotated[int, Query(ge=1, le=100, description="Maximum number of download events")] = 25, + cursor: Annotated[str | None, Query(pattern=r"^[1-9][0-9]{0,18}$", description="Previous next_cursor")] = None, +) -> PluginDownloadsResponse: + """Admin-only ZIP download totals and authenticated downloader history. + + Anonymous downloads contribute only to totals. Counts include repeat requests and client prefetches, + starting when tracking is deployed. External sources and installation completion are not observed. + """ + _require_proxy_admin(user_api_key_dict) + if uploads is None: + raise _error_response(500, CommonProxyErrors.db_not_connected_error.value) + before: Final = int(cursor) if cursor is not None else None + if before is not None and before > 9223372036854775807: + raise _error_response(422, "Invalid download cursor") + result: Final = await uploads.downloads(plugin_name, limit, before) + if result is None: + raise _error_response(404, "Plugin not found") + return result + + @router.get( "/claude-code/marketplace.json", tags=["Claude Code Marketplace"], diff --git a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py index 174158a0506..93d1ace435d 100644 --- a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py +++ b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py @@ -1,6 +1,7 @@ import asyncio import hashlib -from dataclasses import replace +from collections.abc import Mapping +from dataclasses import dataclass, replace from datetime import datetime from typing import Annotated, Final from urllib.parse import quote @@ -14,6 +15,8 @@ from litellm.llms.custom_httpx.http_handler import ( ) from litellm.models.plugin_upload import ( PluginArchiveMetadata, + PluginDownloader, + PluginDownloadsResponse, PluginPublication, PluginSnapshot, PluginUploadStore, @@ -28,10 +31,13 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp delete_plugin, disable_plugin, enable_plugin, + get_plugin_downloads, header_key_caller, list_plugins, plugin_archive_source_matches, plugin_archive_url, + plugin_downloader, + require_download_recorded, ) from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import build_uploaded_skill_archive @@ -76,6 +82,16 @@ from litellm.types.proxy.skill_hub import ( router: Final = APIRouter(prefix="/skill-hub/skills", tags=["Skills Hub"]) _EDITABLE_METADATA: Final = frozenset(SkillHubMetadata.model_fields) +router.add_api_route( + "/{plugin_name}/downloads", get_plugin_downloads, methods=["GET"], response_model=PluginDownloadsResponse +) + + +@dataclass(frozen=True, slots=True) +class VisibleSkillArchive: + archive: SkillArchive + metadata: PluginArchiveMetadata + downloader: PluginDownloader def _url(request: Request, route: str) -> str: @@ -88,7 +104,7 @@ def _download_url(request: Request, name: str) -> str: return _url(request, f"skill-hub/skills/{quote(name, safe='')}/archive") -def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill: +def _skill_response(request: Request, plugin: PluginListItem, download_count: int | None = None) -> SkillHubSkill: local_archive: Final = plugin_archive_source_matches( request, plugin.name, @@ -109,12 +125,14 @@ def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill: source={"source": "archive", "url": _download_url(request, plugin.name)} if local_archive else plugin.source, download_url=_download_url(request, plugin.name) if local_archive else None, content_url=_url(request, f"skill-hub/skills/{quote(plugin.name, safe='')}/content") if local_archive else None, + download_count=download_count, ) @router.get("", response_model=SkillHubList) async def list_skills( request: Request, + uploads: PluginUploadRepositoryDep, caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], query: str = "", offset: Annotated[int, Query(ge=0)] = 0, @@ -128,8 +146,14 @@ async def list_skills( for plugin in sorted(catalog.plugins, key=lambda item: item.name) if needle in " ".join((plugin.name, plugin.description or "", *(plugin.keywords or ()))).casefold() ) + page: Final = matched[offset : offset + limit] + counts: Final[Mapping[str, int]] = ( + await _require_store(uploads).download_counts(tuple(plugin.id for plugin in page)) + if is_proxy_admin(caller) + else {} + ) return SkillHubList( - skills=tuple(_skill_response(request, plugin) for plugin in matched[offset : offset + limit]), + skills=tuple(_skill_response(request, plugin, counts.get(plugin.id)) for plugin in page), count=len(matched), next_offset=offset + limit if offset + limit < len(matched) else None, ) @@ -520,14 +544,22 @@ async def download_skill_version( """Download a retained publication as a portable ZIP with SKILL.md at its root.""" store: Final = _require_store(uploads) metadata: Final = await _version_metadata(name, store, caller) - if metadata.id is None: - raise HTTPException(404, "Skill not found") + version: Final = await store.version(metadata, version_id) + if version is None: + raise HTTPException(404, "Skill version not found") content: Final = await store.version_content(metadata, version_id) archive: Final = ( await asyncio.to_thread(build_agent_skill_archive, content, plugin_name=name) if content is not None else None ) if archive is None: raise HTTPException(404, "This version has no retained Agent Skills archive") + require_download_recorded( + await store.record_download( + replace(metadata, version=version.version), + archive.digest.removeprefix("sha256:"), + plugin_downloader(caller), + ) + ) return Response( archive.content, media_type="application/zip", @@ -540,7 +572,7 @@ async def download_skill_version( ) -async def _visible_archive(request: Request, name: str, uploads: PluginUploadStore | None) -> SkillArchive: +async def _visible_archive(request: Request, name: str, uploads: PluginUploadStore | None) -> VisibleSkillArchive: anonymous_allowed: Final = litellm.public_skills_index is True and not marketplace_auth_required() caller: Final = ( await header_key_caller(request) if marketplace_key_header_present(request) or not anonymous_allowed else None @@ -563,7 +595,7 @@ async def _visible_archive(request: Request, name: str, uploads: PluginUploadSto ) if archive is None: raise HTTPException(404, "This skill has no stored Agent Skills archive") - return archive + return VisibleSkillArchive(archive, metadata, plugin_downloader(caller)) @router.get( @@ -575,9 +607,15 @@ async def download_skill( request: Request, name: str, uploads: PluginUploadRepositoryDep, digest: str | None = None ) -> Response: """Download the complete portable skill, with SKILL.md at the ZIP root.""" - archive: Final = await _visible_archive(request, name, uploads) + visible: Final = await _visible_archive(request, name, uploads) + archive: Final = visible.archive if digest is not None and digest != archive.digest: raise HTTPException(404, "Skill archive version not found") + require_download_recorded( + await _require_store(uploads).record_download( + visible.metadata, archive.digest.removeprefix("sha256:"), visible.downloader + ) + ) return Response( archive.content, media_type="application/zip", @@ -593,9 +631,9 @@ async def download_skill( @router.get("/{name}/content", response_class=Response, responses={200: {"content": {"text/markdown": {}}}}) async def skill_content(request: Request, name: str, uploads: PluginUploadRepositoryDep) -> Response: """Read the original SKILL.md without downloading supporting files.""" - archive: Final = await _visible_archive(request, name, uploads) + visible: Final = await _visible_archive(request, name, uploads) return Response( - archive.manifest_content, + visible.archive.manifest_content, media_type="text/markdown", headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"}, ) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 5563414b23b..818f2dc4858 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1617,6 +1617,8 @@ model LiteLLM_ClaudeCodePluginTable { uploaded_archive LiteLLM_ClaudeCodePluginUpload? versions LiteLLM_ClaudeCodePluginVersion[] + downloads LiteLLM_ClaudeCodePluginDownload[] + anonymous_download_count BigInt @default(0) @@map("LiteLLM_ClaudeCodePluginTable") } @@ -1660,6 +1662,23 @@ model LiteLLM_SkillSubmission { @@index([created_at(sort: Desc), id(sort: Desc)]) } +model LiteLLM_ClaudeCodePluginDownload { + id BigInt @id @default(autoincrement()) + plugin_id String + downloaded_at DateTime @default(now()) + version String? + digest String + authenticated Boolean + user_id String? + user_email String? + key_alias String? + team_id String? + team_alias String? + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@index([plugin_id, id]) +} + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/litellm/repositories/plugin_upload_repository.py b/litellm/repositories/plugin_upload_repository.py index 8fa981e5a99..99e226e442b 100644 --- a/litellm/repositories/plugin_upload_repository.py +++ b/litellm/repositories/plugin_upload_repository.py @@ -1,5 +1,6 @@ -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Mapping, Sequence from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass from types import MappingProxyType from typing import ( TYPE_CHECKING, @@ -12,6 +13,10 @@ from pydantic import TypeAdapter, ValidationError from litellm.models.plugin_upload import ( PluginArchiveMetadata, + PluginDownloader, + PluginDownloadEvent, + PluginDownloadResult, + PluginDownloadsResponse, PluginPublication, PluginPublicationBase, PluginPublicationError, @@ -28,11 +33,33 @@ from litellm.repositories.table_repositories import ClaudeCodePluginRepository if TYPE_CHECKING: from prisma import models as prisma_models + from litellm.proxy.db.create_views import SupportsExecuteRaw from litellm.proxy.utils import PrismaClient _MANIFEST: Final = TypeAdapter(dict[str, object]) _SOURCE: Final = TypeAdapter(dict[str, str]) PAGE_SIZE: Final = 100 +_INCREMENT_ANONYMOUS_DOWNLOADS: Final = """ +UPDATE "LiteLLM_ClaudeCodePluginTable" +SET "anonymous_download_count" = "anonymous_download_count" + 1 +WHERE "id" = $1 +""" +_DOWNLOAD_COUNTS_QUERY: Final = """ +SELECT p.id, p.anonymous_download_count + COUNT(d.id) AS download_count +FROM "LiteLLM_ClaudeCodePluginTable" p +LEFT JOIN "LiteLLM_ClaudeCodePluginDownload" d ON d.plugin_id = p.id +WHERE p.id = ANY($1::text[]) +GROUP BY p.id +""" + + +@dataclass(frozen=True, slots=True) +class _DownloadCount: + id: str + download_count: int + + +_DOWNLOAD_COUNTS: Final = TypeAdapter(tuple[_DownloadCount, ...]) _VERSIONS: Final = TypeAdapter(tuple[PluginVersion, ...]) _VERSION_QUERY: Final = """ SELECT v.id, v.version, v.description, COALESCE(v.manifest_json, '{}')::jsonb AS manifest, @@ -51,11 +78,18 @@ class PluginUploadRepository: self._db = cast( # cast-ok: the generated Prisma transaction delegate is untyped at this boundary _PublicationDatabase, prisma_client.writer_db ) + self._writer = cast( # cast-ok: PrismaWrapper dynamically delegates raw statements to its generated client + "SupportsExecuteRaw", prisma_client.writer_db + ) self._plugins = ClaudeCodePluginRepository(prisma_client, use_writer=True).table self._archives = cast( # cast-ok: Prisma exposes generated table actions through an untyped delegate TableActions["prisma_models.LiteLLM_ClaudeCodePluginUpload"], prisma_client.writer_db.litellm_claudecodepluginupload, ) + self._downloads = cast( # cast-ok: Prisma exposes generated table actions through an untyped delegate + TableActions["prisma_models.LiteLLM_ClaudeCodePluginDownload"], + prisma_client.writer_db.litellm_claudecodeplugindownload, + ) async def create(self, upload: PluginUpload) -> str | None: result: Final = await self.publish( @@ -165,6 +199,7 @@ class PluginUploadRepository: "plugin_name": metadata.name, "plugin": { "is": { + "id": metadata.id, "manifest_json": metadata.manifest_json, "enabled": metadata.enabled, } @@ -173,6 +208,72 @@ class PluginUploadRepository: ) return None if archive is None else archive.content.decode() + async def record_download( + self, metadata: PluginArchiveMetadata, digest: str, downloader: PluginDownloader + ) -> PluginDownloadResult: + from prisma.errors import ForeignKeyViolationError + + if metadata.id is None: + return PluginDownloadResult.SKILL_NOT_FOUND + if not downloader.authenticated: + # A counter bounds anonymous storage; raw SQL preserves Prisma's @updatedAt metadata. + affected: Final = await self._writer.execute_raw(_INCREMENT_ANONYMOUS_DOWNLOADS, metadata.id) + return PluginDownloadResult.RECORDED if affected == 1 else PluginDownloadResult.SKILL_NOT_FOUND + try: + await self._downloads.create( + data={ + "plugin_id": metadata.id, + "version": metadata.version, + "digest": digest, + "authenticated": downloader.authenticated, + "user_id": downloader.user_id, + "user_email": downloader.user_email, + "key_alias": downloader.key_alias, + "team_id": downloader.team_id, + "team_alias": downloader.team_alias, + } + ) + except ForeignKeyViolationError: + return PluginDownloadResult.SKILL_NOT_FOUND + return PluginDownloadResult.RECORDED + + async def downloads(self, name: str, limit: int, cursor: int | None) -> PluginDownloadsResponse | None: + plugin: Final = await self._plugins.find_unique(where={"name": name}) + if plugin is None: + return None + total: Final = await self._downloads.count(where={"plugin_id": plugin.id}) + rows: Final = await self._downloads.find_many( + where={"plugin_id": plugin.id, **({"id": {"lt": cursor}} if cursor is not None else {})}, + order={"id": "desc"}, + take=limit + 1, + ) + return PluginDownloadsResponse( + total_downloads=total + plugin.anonymous_download_count, + anonymous_downloads=plugin.anonymous_download_count, + downloads=tuple( + PluginDownloadEvent( + id=str(row.id), + downloaded_at=row.downloaded_at, + version=row.version, + digest=row.digest, + authenticated=row.authenticated, + user_id=row.user_id, + user_email=row.user_email, + key_alias=row.key_alias, + team_id=row.team_id, + team_alias=row.team_alias, + ) + for row in rows[:limit] + ), + next_cursor=str(rows[limit - 1].id) if len(rows) > limit else None, + ) + + async def download_counts(self, plugin_ids: tuple[str, ...]) -> Mapping[str, int]: + if not plugin_ids: + return MappingProxyType({}) + rows: Final = _DOWNLOAD_COUNTS.validate_python(await self._db.query_raw(_DOWNLOAD_COUNTS_QUERY, plugin_ids)) + return MappingProxyType({row.id: row.download_count for row in rows}) + def _archive_metadata(plugin: "prisma_models.LiteLLM_ClaudeCodePluginTable") -> PluginArchiveMetadata: manifest: Final = _MANIFEST.validate_json(plugin.manifest_json or "{}") diff --git a/litellm/types/proxy/skill_hub.py b/litellm/types/proxy/skill_hub.py index f679002cf1d..d575defcda7 100644 --- a/litellm/types/proxy/skill_hub.py +++ b/litellm/types/proxy/skill_hub.py @@ -23,6 +23,9 @@ class SkillHubSkill(LiteLLMBaseModel): domain: str | None = None namespace: str | None = None keywords: tuple[str, ...] | None = None + download_count: int | None = Field( + None, description="Gateway ZIP downloads, included in proxy-admin catalog listings" + ) class SkillHubList(LiteLLMBaseModel): diff --git a/schema.prisma b/schema.prisma index 5563414b23b..818f2dc4858 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1617,6 +1617,8 @@ model LiteLLM_ClaudeCodePluginTable { uploaded_archive LiteLLM_ClaudeCodePluginUpload? versions LiteLLM_ClaudeCodePluginVersion[] + downloads LiteLLM_ClaudeCodePluginDownload[] + anonymous_download_count BigInt @default(0) @@map("LiteLLM_ClaudeCodePluginTable") } @@ -1660,6 +1662,23 @@ model LiteLLM_SkillSubmission { @@index([created_at(sort: Desc), id(sort: Desc)]) } +model LiteLLM_ClaudeCodePluginDownload { + id BigInt @id @default(autoincrement()) + plugin_id String + downloaded_at DateTime @default(now()) + version String? + digest String + authenticated Boolean + user_id String? + user_email String? + key_alias String? + team_id String? + team_alias String? + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@index([plugin_id, id]) +} + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/tests/integration/management/test_skill_download_history.py b/tests/integration/management/test_skill_download_history.py new file mode 100644 index 00000000000..00445dd7e27 --- /dev/null +++ b/tests/integration/management/test_skill_download_history.py @@ -0,0 +1,163 @@ +import hashlib +import io +import os +import uuid +import zipfile +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from functools import partial +from pathlib import Path +from typing import Final + +import psycopg +import pytest +from pydantic import TypeAdapter + +from litellm.models.plugin_upload import PluginDownloadsResponse +from litellm.types.proxy.skill_hub import SkillHubList +from tests.integration._support.actors import GatewayClient, owned_settings_client +from tests.integration._support.client import Gateway, Scenario, object_value, string_value +from tests.integration._support.database import write_rows + +PLUGINS: Final = "/claude-code/plugins" +SKILLS: Final = "/skill-hub/skills" +HISTORY: Final = TypeAdapter(PluginDownloadsResponse) + + +@pytest.fixture(scope="module") +def gateway(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]: + directory: Final = tmp_path_factory.mktemp("skill-downloads") + with owned_settings_client(GatewayClient(session_gateway, {}), directory, {"public_skills_index": True}) as client: + with client.gateway.scenario() as publisher: + yield replace(client.gateway, key=publisher.key(user_id=publisher.user(user_role="proxy_admin"))) + + +def _delete(gateway: Gateway, name: str) -> None: + assert gateway.request("DELETE", f"{PLUGINS}/{name}").status_code in {200, 404} + + +def _upload(scenario: Scenario, name: str) -> str: + scenario.cleanups.callback(_delete, scenario.gateway, name) + content: Final = io.BytesIO() + with zipfile.ZipFile(content, "w") as archive: + archive.writestr("SKILL.md", f"---\nname: {name}\ndescription: Synthetic download regression\n---\n") + response: Final = scenario.gateway.request_multipart( + f"{SKILLS}/upload", {"version": "1.2.3"}, {"file": ("skill.zip", content.getvalue(), "application/zip")} + ) + assert response.status_code == 200, response.text + return string_value(object_value(scenario.gateway.get(f"{PLUGINS}/{name}")["source"])["url"]) + + +def _download(gateway: Gateway, key: str | None, url: str) -> str: + response: Final = gateway.client.get( + url, headers={"Authorization": f"Bearer {key}"} if key else {}, params={"user_id": "forged-user"} + ) + assert response.status_code == 200, response.text + return hashlib.sha256(response.content).hexdigest() + + +def _history(gateway: Gateway, name: str, **params: str) -> PluginDownloadsResponse: + history: Final = gateway.get(f"{SKILLS}/{name}/downloads", params) + assert gateway.get(f"{PLUGINS}/{name}/downloads", params) == history + return HISTORY.validate_python(history) + + +def test_attribution_survives_actor_deletion_and_name_reuse_starts_empty(gateway: Gateway) -> None: + with gateway.scenario() as skills: + name: Final = f"download-{uuid.uuid4().hex}" + legacy: Final = _upload(skills, name) + portable: Final = f"{SKILLS}/{name}/archive" + original_id: Final = string_value(gateway.get(f"{SKILLS}/{name}")["id"]) + assert gateway.client.get(f"{SKILLS}/{name}/content").status_code == 200 + assert _history(gateway, name).total_downloads == 0 + assert SkillHubList.model_validate(gateway.get(SKILLS, {"query": name})).skills[0].download_count == 0 + with gateway.scenario() as actors: + team: Final = actors.team(team_alias=name) + email: Final = f"{name}@example.test" + user: Final = actors.user(user_role="internal_user", user_email=email) + gateway.post("/team/member_add", {"team_id": team, "member": {"role": "user", "user_id": user}}) + key: Final = actors.key(user_id=user, team_id=team, key_alias=name) + digests: Final = tuple(_download(gateway, key, url) for url in (legacy, portable)) + for url in (legacy, portable): + _download(gateway, None, url) + assert gateway.request("GET", url, key="sk-invalid").status_code == 401 + recorded: Final = _history(gateway, name) + assert (recorded.total_downloads, recorded.anonymous_downloads) == (4, 2) + assert SkillHubList.model_validate(gateway.get(SKILLS, {"query": name})).skills[0].download_count == 4 + assert tuple((row.version, row.authenticated, row.user_id, row.user_email, row.key_alias, + row.team_id, row.team_alias) for row in recorded.downloads) == ( + ("1.2.3", True, user, email, name, team, name), + ) * 2 + assert tuple(row.digest for row in recorded.downloads) == digests[::-1] + for prefix in (PLUGINS, SKILLS): + assert gateway.request("GET", f"{prefix}/{name}/downloads", key=key).status_code == 403 + assert gateway.client.get(f"{prefix}/{name}/downloads").status_code == 401 + assert _history(gateway, name) == recorded + _delete(gateway, name) + assert gateway.rows('SELECT id FROM "LiteLLM_ClaudeCodePluginDownload" WHERE plugin_id = %s', (original_id,)) == [] + _upload(skills, name) + assert gateway.get(f"{SKILLS}/{name}")["id"] != original_id + assert _history(gateway, name) == PluginDownloadsResponse(0, 0, (), None) + + +def test_concurrent_counts_and_keyset_pages_preserve_scope_and_deleted_cursors(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + name: Final = f"download-{uuid.uuid4().hex}" + legacy: Final = _upload(scenario, name) + portable: Final = f"{SKILLS}/{name}/archive" + key: Final = scenario.key() + with ThreadPoolExecutor(max_workers=4) as pool: + tuple(pool.map(partial(_download, gateway, key), (legacy, portable) * 4)) + initial: Final = _history(gateway, name) + ordered: Final = tuple(row.id for row in initial.downloads) + assert initial.total_downloads == len(frozenset(ordered)) == 8 + assert ordered == tuple(sorted(ordered, key=int, reverse=True)) + timestamp: Final = gateway.get(f"{PLUGINS}/{name}")["updated_at"] + with ThreadPoolExecutor(max_workers=4) as pool: + tuple(pool.map(partial(_download, gateway, None), (legacy, portable) * 8)) + assert gateway.get(f"{PLUGINS}/{name}")["updated_at"] == timestamp + assert _history(gateway, name) == replace(initial, total_downloads=24, anonymous_downloads=16) + first: Final = _history(gateway, name, limit="3") + assert tuple(row.id for row in first.downloads) == ordered[:3] + assert first.next_cursor == ordered[2] + _download(gateway, key, portable) + other: Final = f"{name}-other" + _download(gateway, key, _upload(scenario, other)) + write_rows( + 'DELETE FROM "LiteLLM_ClaudeCodePluginDownload" WHERE id IN (%s::bigint, %s::bigint)', + (ordered[2], ordered[4]), database_url=gateway.database_url, + ) + assert first.next_cursor is not None + second: Final = _history(gateway, name, limit="3", cursor=first.next_cursor) + assert tuple(row.id for row in second.downloads) == (ordered[3], ordered[5], ordered[6]) + assert (second.total_downloads, second.anonymous_downloads) == (23, 16) + assert second.next_cursor is not None + last: Final = _history(gateway, name, limit="3", cursor=second.next_cursor) + assert tuple(row.id for row in last.downloads) == (ordered[7],) + assert last.next_cursor is None + assert _history(gateway, other, cursor=first.next_cursor) == PluginDownloadsResponse(1, 0, (), None) + catalog: Final = SkillHubList.model_validate(gateway.get(SKILLS, {"query": name})) + assert {skill.name: skill.download_count for skill in catalog.skills} == {name: 23, other: 1} + + +def test_repeat_migration_is_catalog_only_while_download_tables_are_locked(gateway: Gateway) -> None: + migration: Final = (Path(__file__).resolve().parents[3] / + "litellm-proxy-extras/litellm_proxy_extras/migrations/20261105094500_add_skill_download_history/migration.sql" + ).read_bytes() + with gateway.scenario() as scenario: + name: Final = f"download-{uuid.uuid4().hex}" + url: Final = _upload(scenario, name) + for key in (None, gateway.key): + _download(gateway, key, url) + before: Final = _history(gateway, name) + database_url: Final = gateway.database_url or os.environ["DATABASE_URL"] + with psycopg.connect(database_url) as locker, psycopg.connect(database_url, autocommit=True) as migrator: + locker.execute( + 'LOCK TABLE "LiteLLM_ClaudeCodePluginTable", "LiteLLM_ClaudeCodePluginDownload" IN ACCESS EXCLUSIVE MODE' + ) + for _ in range(2): + with migrator.transaction(): + migrator.execute("SET LOCAL statement_timeout = '500ms'") + migrator.execute(migration) + assert _history(gateway, name) == before diff --git a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py index b4a61a2b3e0..7e5b3a7abd7 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -855,6 +855,7 @@ async def test_enable_disable_delete_plugin_reject_non_admin(): enable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER), disable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER), delete_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER), + claude_code_marketplace.get_plugin_downloads(plugin_name=name, uploads=None, user_api_key_dict=_NON_ADMIN_USER), ): with pytest.raises(HTTPException) as exc_info: await coro @@ -1223,13 +1224,15 @@ _UPLOAD_SOURCE: Final = {"source": "archive", "url": f"http://test{_UPLOAD_PATH} async def upload_storage(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[_UploadClient]: prisma: Final = litellm.proxy.proxy_server.prisma_client prisma.writer_db = MagicMock() + prisma.writer_db.execute_raw = AsyncMock(return_value=1) plugins: Final = prisma.writer_db.litellm_claudecodeplugintable archives: Final = prisma.writer_db.litellm_claudecodepluginupload + prisma.writer_db.litellm_claudecodeplugindownload.create = AsyncMock(return_value=None) plugins.create = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable( - id="uploaded-id", name=_UPLOAD_NAME, enabled=True, + id="uploaded-id", name=_UPLOAD_NAME, enabled=True, anonymous_download_count=0, )) plugins.find_unique = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable( - id="uploaded-id", name=_UPLOAD_NAME, enabled=True, manifest_json=json.dumps({"source": _UPLOAD_SOURCE}), + id="uploaded-id", name=_UPLOAD_NAME, enabled=True, anonymous_download_count=0, manifest_json=json.dumps({"source": _UPLOAD_SOURCE}), )) prisma.writer_db.query_raw = AsyncMock(return_value=[]) prisma.writer_db.tx.return_value.__aenter__.return_value = prisma.writer_db @@ -1335,7 +1338,7 @@ async def test_archive_reads_only_authorized_current_bytes( client, plugins, archives = upload_storage manifest: Final = json.dumps({"source": source}) plugins.find_unique.return_value = None if source is None else prisma_models.LiteLLM_ClaudeCodePluginTable( - id="uploaded-id", name=_UPLOAD_NAME, enabled=enabled, manifest_json=manifest, + id="uploaded-id", name=_UPLOAD_NAME, enabled=enabled, anonymous_download_count=0, manifest_json=manifest, ) if not blob: archives.find_first.return_value = None @@ -1344,9 +1347,16 @@ async def test_archive_reads_only_authorized_current_bytes( assert (response.content == b"stored archive") is (status == 200) plugins.find_unique.assert_awaited_once_with(where={"name": _UPLOAD_NAME}) assert archives.find_first.await_args_list == ([call(where={ - "plugin_name": _UPLOAD_NAME, "plugin": {"is": {"manifest_json": manifest, "enabled": enabled}}, + "plugin_name": _UPLOAD_NAME, "plugin": {"is": { + "id": "uploaded-id", "manifest_json": manifest, "enabled": enabled, + }}, })] if reads else []) litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable.find_unique.assert_not_awaited() + events: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugindownload + assert events.create.await_count == int(status == 200 and key is not None) + assert litellm.proxy.proxy_server.prisma_client.writer_db.execute_raw.await_count == int( + status == 200 and key is None + ) @pytest.mark.asyncio @@ -1376,6 +1386,32 @@ async def test_archive_requires_header_credentials_for_private_content( assert response.status_code == status, response.text assert (response.content == b"stored archive") is (status == 200) assert archives.find_first.await_count == int(status == 200) + events: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugindownload + assert events.create.await_count == int(status == 200) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("key, query, exists, status", [ + (_LIMITED_KEY, {}, True, 403), + (None, {}, True, 401), + (MASTER_KEY, {"cursor": "9223372036854775808"}, True, 422), + (MASTER_KEY, {"cursor": "0"}, True, 422), + (MASTER_KEY, {"limit": 101}, True, 422), + (MASTER_KEY, {}, False, 404), +]) +async def test_download_history_rejects_unauthorized_invalid_and_unknown_requests( + upload_storage: _UploadClient, key: str | None, query: Mapping[str, str | int], exists: bool, status: int, +) -> None: + client, plugins, _ = upload_storage + if not exists: + plugins.find_unique.return_value = None + response: Final = await client.get( + f"/claude-code/plugins/{_UPLOAD_NAME}/downloads", + headers={"Authorization": f"Bearer {key}"} if key else {}, params=query, + ) + assert response.status_code == status, response.text + events: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugindownload + events.find_many.assert_not_called() @pytest.mark.asyncio diff --git a/tests/unit/proxy/auth/test_route_checks.py b/tests/unit/proxy/auth/test_route_checks.py index 3edf1e1a0ad..c1011310af3 100644 --- a/tests/unit/proxy/auth/test_route_checks.py +++ b/tests/unit/proxy/auth/test_route_checks.py @@ -363,18 +363,24 @@ def test_proxy_admin_viewer_allowed_management_reads(allowed_read_route): ) -def test_virtual_key_allowed_routes_with_litellm_routes_member_name_allowed(): +@pytest.mark.parametrize("group, route", ( + ("openai_routes", "/chat/completions"), + ("skill_hub_routes", "/skill-hub/skills/release-notes/downloads"), + ("management_routes", "/skill-hub/skills/release-notes/downloads"), + ("anthropic_routes", "/claude-code/plugins/release-notes/downloads"), +)) +def test_virtual_key_allowed_routes_with_litellm_routes_member_name_allowed(group: str, route: str) -> None: """Test that virtual key is allowed to call routes when allowed_routes contains LiteLLMRoutes member name""" # Create a UserAPIKeyAuth with allowed_routes containing a LiteLLMRoutes member name valid_token = UserAPIKeyAuth( user_id="test_user", - allowed_routes=["openai_routes"], # This is a member name in LiteLLMRoutes enum + allowed_routes=[group], ) # Test that a route from the openai_routes group is allowed result = RouteChecks.is_virtual_key_allowed_to_call_route( - route="/chat/completions", # This is in LiteLLMRoutes.openai_routes.value + route=route, valid_token=valid_token, ) diff --git a/tests/unit/proxy/discovery_endpoints/test_agent_skills_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_agent_skills_endpoints.py index 89c31cbdf1d..16eeedab0be 100644 --- a/tests/unit/proxy/discovery_endpoints/test_agent_skills_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_agent_skills_endpoints.py @@ -97,7 +97,7 @@ def hub_record( digest: Final = hashlib.sha256(wrapper).hexdigest() return ( prisma_models.LiteLLM_ClaudeCodePluginTable( - id=f"hub-{name}", name=name, enabled=True, manifest_json=json.dumps({ + id=f"hub-{name}", name=name, enabled=True, anonymous_download_count=0, manifest_json=json.dumps({ "source": { "source": "archive", "sha256": digest, "url": f"http://testserver/claude-code/plugins/{name}/archive/{digest}.zip", @@ -112,6 +112,7 @@ def hub_record( def hub_repository(*names: str) -> PluginUploadRepository: records: Final = tuple(hub_record(name) for name in names) prisma: Final = MagicMock() + prisma.writer_db.execute_raw = AsyncMock(return_value=1) plugins: Final = prisma.writer_db.litellm_claudecodeplugintable plugins.find_many = AsyncMock(return_value=[record for record, _ in records]) diff --git a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py index d0238aa83cd..8127fc80bab 100644 --- a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py @@ -17,7 +17,7 @@ from unittest.mock import AsyncMock, MagicMock import httpx import pytest from prisma import models as prisma_models -from prisma.errors import UniqueViolationError +from prisma.errors import ForeignKeyViolationError, UniqueViolationError import litellm from litellm.caching.caching import DualCache @@ -75,6 +75,8 @@ class HubClient: client: httpx.AsyncClient plugins: MagicMock archives: MagicMock + downloads: MagicMock + anonymous_counter: AsyncMock upstream: MagicMock clock: MagicMock database: MagicMock @@ -84,15 +86,18 @@ class HubClient: async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]: prisma: Final = MagicMock() prisma.writer_db = prisma.db + prisma.db.execute_raw = AsyncMock(return_value=1) prisma.get_data = AsyncMock(return_value=None) prisma.db.litellm_skillstable.find_many = AsyncMock(return_value=[]) prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None) plugins: Final = prisma.db.litellm_claudecodeplugintable archives: Final = prisma.db.litellm_claudecodepluginupload async def create_row(data: Mapping[str, object]) -> prisma_models.LiteLLM_ClaudeCodePluginTable: - return prisma_models.LiteLLM_ClaudeCodePluginTable.model_validate({"id": "hub-id", "enabled": True, **data}) + return prisma_models.LiteLLM_ClaudeCodePluginTable.model_validate({"id": "hub-id", "enabled": True, "anonymous_download_count": 0, **data}) plugins.create = AsyncMock(side_effect=create_row) + downloads: Final = prisma.db.litellm_claudecodeplugindownload + downloads.create = AsyncMock() plugins.find_unique = AsyncMock(return_value=None) plugins.find_many = AsyncMock(return_value=[]) @@ -154,7 +159,7 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]: get_skill_submission_repository: lambda: SkillSubmissionRepository(prisma), import_http_client: lambda: source_client, }) - yield HubClient(client, plugins, archives, upstream, clock, prisma.db) + yield HubClient(client, plugins, archives, downloads, prisma.db.execute_raw, upstream, clock, prisma.db) async def _upload( @@ -171,7 +176,7 @@ async def _upload( def _stored_upload(hub: HubClient, enabled: bool = True) -> prisma_models.LiteLLM_ClaudeCodePluginTable: data: Final = hub.plugins.create.await_args.kwargs["data"] record: Final = prisma_models.LiteLLM_ClaudeCodePluginTable( - id="hub-id", name=data["name"], version=data["version"], description=data["description"], + id="hub-id", name=data["name"], version=data["version"], description=data["description"], anonymous_download_count=0, manifest_json=data["manifest_json"], enabled=enabled, updated_at=data["updated_at"], ) hub.plugins.find_unique.return_value = record @@ -191,6 +196,27 @@ def _assert_portable(response: httpx.Response) -> None: assert stat.S_IMODE(archive.getinfo("scripts/check.sh").external_attr >> 16) == 0o755 +@pytest.mark.parametrize("key, expected", ((MASTER_KEY, 7), (PLAIN_KEY, None))) +async def test_catalog_counts_are_admin_only_and_batched_for_the_page( + hub: HubClient, key: str, expected: int | None, +) -> None: + assert (await _upload(hub, _zip(FILES))).status_code == 200 + record: Final = _stored_upload(hub) + hub.plugins.find_many.return_value = [record, record.model_copy(update={"id": "other", "name": "zzz-other"})] + hub.database.query_raw.reset_mock() + hub.database.query_raw.return_value = [{"id": record.id, "download_count": 7}] + response: Final = await hub.client.get(f"{HUB}?limit=1", headers=_headers(key)) + assert response.status_code == 200, response.text + assert [(item["id"], item["download_count"]) for item in response.json()["skills"]] == [(record.id, expected)] + if expected is None: + hub.database.query_raw.assert_not_awaited() + else: + assert hub.database.query_raw.await_count == 1 + assert hub.database.query_raw.await_args.args[1] == (record.id,) + assert (await hub.client.get(f"{HUB}?offset=2", headers=_headers(key))).json()["skills"] == [] + assert hub.database.query_raw.await_count == 1 + + @pytest.mark.parametrize("operation", ("upload", "import")) @pytest.mark.parametrize("public_base, root_path", ( ("http://test", ""), ("https://public.example", ""), ("https://public.example", "/gateway"), @@ -222,6 +248,8 @@ async def test_publication_round_trips_both_formats_without_inflating_on_the_req assert details.json()["content_url"] == f"{serving_base}{HUB}/{NAME}/content" catalog: Final = await hub.client.get("/claude-code/plugins", headers=_headers(PLAIN_KEY)) assert catalog.status_code == 200 and "has_agent_skill_metadata" not in catalog.text + hub.downloads.create.assert_not_awaited() + hub.anonymous_counter.assert_not_awaited() request_thread: Final = threading.get_ident() decompress: Final = zlib.decompressobj @@ -241,10 +269,13 @@ async def test_publication_round_trips_both_formats_without_inflating_on_the_req assert (await hub.client.get(entry["url"])).content == portable.content assert (await hub.client.get(f"{HUB}/{NAME}/archive?digest=sha256:stale")).status_code == 404 legacy_path: Final = httpx.URL(manifest["source"]["url"]).path - legacy: Final = await hub.client.get(legacy_path) + legacy: Final = await hub.client.get(legacy_path, headers=_headers(GRANTED_KEY)) assert legacy.status_code == 200 assert hashlib.sha256(legacy.content).hexdigest() == manifest["source"]["sha256"] assert (await hub.client.get(legacy_path.replace(manifest["source"]["sha256"], "stale"))).status_code == 404 + assert hub.anonymous_counter.await_count == 2 + assert hub.anonymous_counter.await_args.args[1:] == (record.id,) + hub.downloads.create.assert_awaited_once() monkeypatch.setattr(zlib, "decompressobj", decompress) _assert_portable(portable) with zipfile.ZipFile(io.BytesIO(legacy.content)) as archive: @@ -317,6 +348,8 @@ async def test_download_policy_requires_publication_or_a_valid_grant( for suffix in ("archive", "content"): response: Final = await hub.client.get(f"{HUB}/{NAME}/{suffix}", headers=_headers(key)) assert response.status_code == status, response.text + assert hub.downloads.create.await_count == int(status == 200 and key is not None) + assert hub.anonymous_counter.await_count == int(status == 200 and key is None) if status != 200: hub.archives.find_first.assert_not_awaited() index: Final = await hub.client.get(INDEX) @@ -325,6 +358,21 @@ async def test_download_policy_requires_publication_or_a_valid_grant( assert [entry["name"] for entry in index.json()["skills"]] == ([NAME] if enabled and not auth_required else []) +@pytest.mark.parametrize("key", (None, GRANTED_KEY)) +async def test_deleted_skill_is_not_served_when_download_cannot_be_recorded(hub: HubClient, key: str | None) -> None: + assert (await _upload(hub, _zip(FILES))).status_code == 200 + record: Final = _stored_upload(hub) + legacy_url: Final = json.loads(record.manifest_json)["source"]["url"] + hub.anonymous_counter.return_value = 0 + hub.downloads.create.side_effect = ForeignKeyViolationError(data={}) + for path in (f"{HUB}/{NAME}/archive", legacy_url): + response: Final = await hub.client.get(path, headers=_headers(key)) + assert response.status_code == 404, response.text + assert "Skill archive not found" in response.text + assert hub.anonymous_counter.await_count == (2 if key is None else 0) + assert hub.downloads.create.await_count == (0 if key is None else 2) + + async def test_search_paginates_visible_matches(hub: HubClient) -> None: assert (await _upload(hub, _zip(FILES))).status_code == 200 record: Final = _stored_upload(hub) @@ -516,6 +564,7 @@ async def test_neutral_history_list_detail_and_portable_download_share_current_v original: Final = _stored_upload(hub, enabled=False) data: Final = hub.database.litellm_claudecodepluginversion.create.await_args.kwargs["data"] version: Final = prisma_models.LiteLLM_ClaudeCodePluginVersion.model_validate({"id": "retained", **data}) + hub.plugins.find_unique.return_value = original.model_copy(update={"version": "3.0.0"}) hub.database.litellm_claudecodepluginversion.find_first.return_value = version hub.database.query_raw.return_value = [{ "id": version.id, "version": version.version, "description": version.description, @@ -526,7 +575,11 @@ async def test_neutral_history_list_detail_and_portable_download_share_current_v detail: Final = await hub.client.get(f"{HUB}/{NAME}/versions/retained", headers=_headers(key)) downloaded: Final = await hub.client.get(f"{HUB}/{NAME}/versions/retained/archive", headers=_headers(key)) assert listed.status_code == detail.status_code == downloaded.status_code == status + assert hub.downloads.create.await_count == int(status == 200) if status == 200: + event: Final = hub.downloads.create.await_args.kwargs["data"] + assert event["version"] == version.version != "3.0.0" + assert event["digest"] == hashlib.sha256(downloaded.content).hexdigest() assert listed.json() == {"versions": [detail.json()], "next_offset": None} assert detail.json()["manifest"]["agent_skill"] == json.loads(original.manifest_json)["agent_skill"] assert detail.json()["download_url"] == f"http://test{HUB}/{NAME}/versions/retained/archive" diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index 6591f7184d3..dd3ffad4727 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -1808,7 +1808,8 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference: from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest def create(data: Mapping[str, object]) -> LiteLLM_ClaudeCodePluginTable: - return LiteLLM_ClaudeCodePluginTable.model_validate({"id": "plugin-id", "enabled": True, **data}) + defaults: Final = {"id": "plugin-id", "enabled": True, "anonymous_download_count": 0} + return LiteLLM_ClaudeCodePluginTable.model_validate({**defaults, **data}) def find_many(where: Mapping[str, object]) -> list[LiteLLM_ClaudeCodePluginTable]: return [create(table.create.call_args.kwargs["data"])] diff --git a/tests/unit/repositories/test_plugin_publication.py b/tests/unit/repositories/test_plugin_publication.py index 6bd4b179810..0a784553d09 100644 --- a/tests/unit/repositories/test_plugin_publication.py +++ b/tests/unit/repositories/test_plugin_publication.py @@ -65,7 +65,7 @@ def publication_tx() -> MagicMock: tx: Final = MagicMock() tx.query_raw = AsyncMock(return_value=()) tx.litellm_claudecodeplugintable.find_unique = AsyncMock(return_value=LiteLLM_ClaudeCodePluginTable( - id="skill-id", name="skill", version="1.0.0", enabled=False, + id="skill-id", name="skill", version="1.0.0", enabled=False, anonymous_download_count=0, updated_at=datetime(2025, 1, 1, tzinfo=timezone.utc), manifest_json=json.dumps({"source": {"source": "archive", "sha256": hashlib.sha256(content).hexdigest()}}), )) diff --git a/tests/unit/repositories/test_plugin_upload_repository.py b/tests/unit/repositories/test_plugin_upload_repository.py index dc35973a062..8d11afc2518 100644 --- a/tests/unit/repositories/test_plugin_upload_repository.py +++ b/tests/unit/repositories/test_plugin_upload_repository.py @@ -7,17 +7,28 @@ import pytest from fastapi import Request from prisma import models as prisma_models +from litellm.models.plugin_upload import PluginArchiveMetadata, PluginDownloader, PluginDownloadResult from litellm.proxy import proxy_server from litellm.proxy.discovery_endpoints.skill_hub_endpoints import public_hub_entries from litellm.repositories.plugin_upload_repository import PluginUploadRepository +@pytest.mark.asyncio +async def test_download_without_stored_identity_returns_missing_skill() -> None: + repository: Final = PluginUploadRepository(MagicMock()) + result: Final = await repository.record_download( + PluginArchiveMetadata("missing", True, {}, None), "digest", + PluginDownloader(True, None, None, None, None, None), + ) + assert result is PluginDownloadResult.SKILL_NOT_FOUND + + def plugin( name: str, manifest_json: str | None = None, *, host: str = "testserver", ) -> prisma_models.LiteLLM_ClaudeCodePluginTable: digest: Final = "a" * 64 return prisma_models.LiteLLM_ClaudeCodePluginTable( - id=name, name=name, enabled=True, + id=name, name=name, enabled=True, anonymous_download_count=0, manifest_json=manifest_json if manifest_json is not None else json.dumps({ "source": { "source": "archive", "url": f"http://{host}/claude-code/plugins/{name}/archive/{digest}.zip", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx index 02f0b4eadb4..359fb12dd95 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx @@ -25,6 +25,7 @@ const published: SkillHubSkill = { download_url: "/skill-hub/skills/shared-skill/archive", content_url: "/skill-hub/skills/shared-skill/content", updated_at: "2026-10-20T00:00:00Z", + download_count: 0, }; const pending: SkillSubmission = { @@ -235,6 +236,24 @@ const openEdit = async () => { return screen.getByRole("dialog"); }; +it.each(["current", "retained"])("refreshes published counts after a %s download", async (kind) => { + URL.createObjectURL = vi.fn(() => "blob:skill-download"); + renderWithProviders(); + expect(await screen.findByRole("cell", { name: "0" })).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByText(published.name)); + await screen.findByRole("button", { name: "Edit Skill" }); + if (kind === "retained") fireEvent.click(screen.getByRole("tab", { name: "Version history" })); + const button = await screen.findByRole("button", { + name: kind === "retained" ? "Download retained ZIP" : "Download ZIP", + }); + catalog = [{ ...published, download_count: 1 }]; + fireEvent.click(button); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(screen.getByText("Skills")); + expect(await screen.findByRole("cell", { name: "1" })).toBeInTheDocument(); +}); + it("paginates the neutral catalog and refreshes detail for a recreated skill", async () => { catalog = [ published, @@ -624,6 +643,7 @@ it.each(["role", "read-only"])("does not reuse a published admin snapshot after expect(screen.queryByText("Approved instructions")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Edit Skill" })).not.toBeInTheDocument(); expect(await screen.findByRole("alert")).toHaveTextContent("Could not refresh published skills"); + expect(screen.queryByRole("columnheader", { name: "Downloads" })).not.toBeInTheDocument(); }); it("does not reuse admin review metadata when the same account loses its role and detail refresh fails", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx index 168787fb379..a0e785268ea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -1,15 +1,16 @@ -import { screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { renderWithProviders as render, testQueryClient } from "@/../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { listSkillHubSkills, deleteSkillHubSkill } from "@/components/networking"; +import { listSkillHubSkills, getSkillHubSkill, deleteSkillHubSkill } from "@/components/networking"; import type { SkillHubSkill } from "@/components/claude_code_plugins/types"; import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; vi.mock("@/components/networking", () => ({ listSkillHubSkills: vi.fn(), + getSkillHubSkill: vi.fn(), deleteSkillHubSkill: vi.fn(), })); @@ -19,10 +20,12 @@ vi.mock("./PluginTable", () => ({ isLoading, pluginsList, onDeleteClick, + onPluginClick, }: { isLoading: boolean; pluginsList: SkillHubSkill[]; onDeleteClick: (pluginName: string, displayName: string) => void; + onPluginClick: (id: string) => void; }) => (
{isLoading ? "table-loading" : "table-loaded"} @@ -30,6 +33,7 @@ vi.mock("./PluginTable", () => ({ } + ), +})); const mockListSkillHubSkills = vi.mocked(listSkillHubSkills); const mockDeleteSkillHubSkill = vi.mocked(deleteSkillHubSkill); @@ -126,3 +136,28 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => { expect(mockDeleteSkillHubSkill).not.toHaveBeenCalled(); }); }); + +describe("ClaudeCodePluginsPanel detail access", () => { + beforeEach(() => { + vi.clearAllMocks(); + testQueryClient.clear(); + mockListSkillHubSkills.mockResolvedValue({ skills: [skill], count: 1, next_offset: null }); + vi.mocked(getSkillHubSkill).mockResolvedValue(skill); + }); + + it.each([ + ["Admin", true], + ["proxy_admin", true], + ["org_admin", false], + ["Admin Viewer", false], + ["proxy_admin_viewer", false], + ["Internal User", false], + ["internal_user", false], + ] as const)("limits selected skill download access for %s", async (userRole, canViewDownloads) => { + render(); + fireEvent.doubleClick(await screen.findByTestId(`row-delete-${skill.id}`)); + expect(screen.getByRole("region", { name: `Details for ${skill.name}` })).toBeInTheDocument(); + const downloads = screen.queryByRole("button", { name: "Downloads" }); + expect(downloads !== null).toBe(canViewDownloads); + }); +}); 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 531163d558e..6efa511ce49 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -57,11 +57,20 @@ interface CatalogSkillDetailProps { accessToken: string | null; queryScope: QueryKey; canWrite: boolean; + isAdmin: boolean; onBack: () => void; onEdit: (skill: SkillHubSkill) => void; } -function CatalogSkillDetail({ skill, accessToken, queryScope, canWrite, onBack, onEdit }: CatalogSkillDetailProps) { +function CatalogSkillDetail({ + skill, + accessToken, + queryScope, + canWrite, + isAdmin, + onBack, + onEdit, +}: CatalogSkillDetailProps) { const { t } = useTranslation(); const detail = useQuery({ queryKey: [...queryScope, "detail", skill.id, skill.name], @@ -82,6 +91,7 @@ function CatalogSkillDetail({ skill, accessToken, queryScope, canWrite, onBack, skill={skillHubDetail(detail.data ?? skill)} onBack={onBack} accessToken={accessToken} + isAdmin={isAdmin} onEditClick={canWrite && detail.isSuccess && !detail.isFetching ? () => onEdit(detail.data) : undefined} /> @@ -156,6 +166,7 @@ const SkillsPanelContent: React.FC = ({ {selectedSkill ? ( { vi.clearAllMocks(); }); - it("should render every column header", () => { - render(); - for (const header of ["Skill Name", "Version", "Description", "Domain", "Public", "Updated at"]) { + it.each([ + [null, "—"], + [0, "0"], + [12345, "12,345"], + ] as const)("renders headers and download count %s", (download_count, display) => { + render(); + for (const header of ["Skill Name", "Version", "Description", "Domain", "Public", "Updated at", "Downloads"]) { expect(screen.getByText(header)).toBeInTheDocument(); } + expect(within(screen.getByRole("row", { name: /newer-skill/ })).getByText(display)).toBeInTheDocument(); }); it("should display the empty state when data is empty", () => { @@ -106,6 +111,7 @@ describe("PluginTable", () => { it("should hide the delete action for non-admins but keep copy available", async () => { const user = userEvent.setup(); render(); + expect(screen.queryByRole("columnheader", { name: "Downloads" })).not.toBeInTheDocument(); await user.click(screen.getByTestId("plugin-actions-newer-skill")); expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); expect(screen.queryByTestId("plugin-action-delete")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx index 4c9b134050f..f2055b77969 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx @@ -153,6 +153,21 @@ export const getPluginTableColumns = ({ enableSorting: true, cell: ({ row }) => , }, + ...(isAdmin + ? ([ + { + id: "download_count", + accessorKey: "download_count", + meta: { title: i18n.t("skills.table.columns.downloads") }, + header: i18n.t("skills.table.columns.downloads"), + size: 120, + enableSorting: false, + cell: ({ row }) => ( + {row.original.download_count?.toLocaleString(i18n.language) ?? "—"} + ), + }, + ] satisfies ColumnDef[]) + : []), { id: "actions", meta: { className: "text-right", headerClassName: "text-right" }, diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillDownloads.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillDownloads.tsx new file mode 100644 index 00000000000..b482b03b42f --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillDownloads.tsx @@ -0,0 +1,132 @@ +import { useInfiniteQuery, type QueryFunctionContext } from "@tanstack/react-query"; +import { RefreshCw } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { apiClient } from "@/components/networking"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { Plugin, PluginDownloadsResponse } from "./types"; + +export const skillDownloadsQueryKey = (skill: Pick, accessToken?: string | null) => + ["skill-downloads", skill.id, skill.name, accessToken] as const; + +interface SkillDownloadsProps { + skill: Pick; + accessToken?: string | null; +} + +export function SkillDownloads({ skill, accessToken }: SkillDownloadsProps) { + const { t, i18n } = useTranslation(); + const queryOptions = { + queryKey: skillDownloadsQueryKey(skill, accessToken), + queryFn: ({ pageParam, signal }: QueryFunctionContext, string | null>) => + apiClient.get(`/skill-hub/skills/${encodeURIComponent(skill.name)}/downloads`, { + accessToken, + query: { limit: 25, cursor: pageParam }, + signal, + }), + initialPageParam: null as string | null, + getNextPageParam: (page: PluginDownloadsResponse) => page.next_cursor, + staleTime: 0, + refetchOnMount: true, + retry: false, + }; + const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage, refetch } = + useInfiniteQuery(queryOptions); + const downloads = data?.pages.flatMap((page) => page.downloads) ?? []; + const isEmpty = data && !isError && downloads.length === 0; + const unavailable = t("claudeCodePlugins.skillDetail.downloads.unavailable"); + + return ( +
+
+
+

{t("claudeCodePlugins.skillDetail.downloads.title")}

+

+ {t("claudeCodePlugins.skillDetail.downloads.description")} +

+

+ {t("claudeCodePlugins.skillDetail.downloads.attributionNote")} +

+
+ +
+ {data && ( +
+
+
{t("claudeCodePlugins.skillDetail.downloads.total")}
+
+ {data.pages[0].total_downloads.toLocaleString(i18n.language)} +
+
+
+
+ {t("claudeCodePlugins.skillDetail.downloads.anonymousTotal")} +
+
+ {data.pages[0].anonymous_downloads.toLocaleString(i18n.language)} +
+
+
+ )} + {isPending &&

{t("claudeCodePlugins.skillDetail.downloads.loading")}

} + {isError && ( +

+ {t("claudeCodePlugins.skillDetail.downloads.loadFailed")} +

+ )} + {isFetching && !isPending && ( +

+ {t( + isFetchingNextPage + ? "claudeCodePlugins.skillDetail.downloads.loadingMore" + : "claudeCodePlugins.skillDetail.downloads.refreshing", + )} +

+ )} + {isEmpty &&

{t("claudeCodePlugins.skillDetail.downloads.empty")}

} + {downloads.length > 0 && ( + + + + {t("claudeCodePlugins.skillDetail.downloads.downloadedAt")} + {t("claudeCodePlugins.skillDetail.downloads.downloader")} + {t("claudeCodePlugins.skillDetail.downloads.key")} + {t("claudeCodePlugins.skillDetail.downloads.team")} + {t("claudeCodePlugins.skillDetail.downloads.version")} + + + + {downloads.map((event) => ( + + + + + + {event.authenticated + ? event.user_email || event.user_id || t("claudeCodePlugins.skillDetail.downloads.unidentified") + : t("claudeCodePlugins.skillDetail.downloads.anonymous")} + + {event.key_alias || unavailable} + {event.team_alias || event.team_id || unavailable} + {event.version || unavailable} + + ))} + +
+ )} + {hasNextPage && ( + + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx index 37e677355d3..ebfc9d8f525 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { downloadBlob } from "@/components/EntityUsageExport/utils"; @@ -20,6 +20,7 @@ function VersionArchive({ accessToken: string; }) { const { t } = useTranslation(); + const queryClient = useQueryClient(); const [error, setError] = useState(null); const [downloading, setDownloading] = useState(false); const download = async () => { @@ -32,6 +33,7 @@ function VersionArchive({ { accessToken, redirect: "error" }, ); downloadBlob(content, `skill-${version.version ?? "legacy"}.zip`); + await queryClient.invalidateQueries({ queryKey: ["skill-hub", accessToken] }); } catch { setError(t("claudeCodePlugins.skillDetail.history.downloadFailed")); } finally { diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts index e933ab41ed4..52a9cf43608 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts @@ -17,11 +17,16 @@ import { buildMarketplaceSettingsSnippet, } from "./helpers"; -describe("getManagedSkillArchivePath", () => { +describe.each(["legacy", "neutral"])("getManagedSkillArchivePath %s", (format) => { const base = "https://proxy.example.com"; const digest = "a".repeat(64); - const path = `/claude-code/plugins/my-skill/archive/${digest}.zip`; - const source = { source: "archive" as const, url: `${base}${path}`, sha256: digest }; + const path = + format === "legacy" ? `/claude-code/plugins/my-skill/archive/${digest}.zip` : "/skill-hub/skills/my-skill/archive"; + const source = { + source: "archive" as const, + url: `${base}${path}`, + sha256: format === "legacy" ? digest : undefined, + }; const identify = (overrides: Partial = {}, name = "my-skill") => getManagedSkillArchivePath(name, { ...source, ...overrides }, base); @@ -40,7 +45,7 @@ describe("getManagedSkillArchivePath", () => { `${base}${path}?redirect=other`, `${base}${path}#fragment`, `${base}${path.replace("my-skill", "another-skill")}`, - `${base}${path.replace(digest, "b".repeat(64))}`, + ...(format === "legacy" ? [`${base}${path.replace(digest, "b".repeat(64))}`] : []), ])("rejects a different credential recipient or archive: %s", (url) => { expect(identify({ url })).toBeNull(); }); @@ -48,6 +53,7 @@ describe("getManagedSkillArchivePath", () => { it("requires an archive type, safe skill name and full SHA256 digest", () => { expect(identify({ source: "url" })).toBeNull(); expect(identify({}, "../my-skill")).toBeNull(); + if (format !== "legacy") return; expect(identify({ sha256: undefined })).toBeNull(); expect(identify({ sha256: "short" })).toBeNull(); }); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts index 6374db58c9b..902e6ea7e88 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -314,10 +314,12 @@ export const getManagedSkillArchivePath = ( if (source.source !== "archive" || !validatePluginName(name)) { return null; } - if (!source.sha256 || !SHA256_REGEX.test(source.sha256)) { + if (source.sha256 && !SHA256_REGEX.test(source.sha256)) { return null; } - const path = `/claude-code/plugins/${name}/archive/${source.sha256}.zip`; + const path = source.sha256 + ? `/claude-code/plugins/${name}/archive/${source.sha256}.zip` + : `/skill-hub/skills/${name}/archive`; try { const expectedUrl = new URL(`${proxyBaseUrl.replace(/\/+$/, "")}${path}`, pageOrigin); return /^https?:$/.test(expectedUrl.protocol) && source.url === expectedUrl.href ? path : null; diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.integration.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.integration.test.tsx index a3bd2380cc7..7001ed5ec7f 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.integration.test.tsx @@ -3,7 +3,7 @@ import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { getProxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking"; import SkillDetail, { skillHubDetail } from "./skill_detail"; -import type { Plugin } from "./types"; +import type { Plugin, PluginDownloadEvent, PluginDownloadsResponse } from "./types"; const digest = "a".repeat(64); const archivePath = `/claude-code/plugins/my-skill/archive/${digest}.zip`; @@ -19,13 +19,42 @@ const skill: Plugin = { updated_at: null, }; const fetchMock = vi.fn(); -const renderSkill = (source = skill.source) => - renderWithProviders(); +const skillRequestMock = vi.fn(); +const renderSkill = (source = skill.source, isAdmin = false) => + renderWithProviders( + , + ); +const downloadsUrl = `${getProxyBaseUrl()}/skill-hub/skills/my-skill/downloads?limit=25`; +const event = { + id: "9007199254740993", + downloaded_at: "2026-10-01T10:00:00Z", + version: "1.2.0", + digest, + authenticated: true, + user_id: "reader-id", + user_email: "reader@example.test", + key_alias: "development", + team_id: "team-id", + team_alias: "Engineering", +} satisfies PluginDownloadEvent; +const page = ( + downloads: PluginDownloadEvent[] = [], + total_downloads = downloads.length, + next_cursor: string | null = null, + anonymous_downloads = 0, +): PluginDownloadsResponse => ({ downloads, total_downloads, next_cursor, anonymous_downloads }); beforeEach(() => { vi.clearAllMocks(); testQueryClient.clear(); - fetchMock.mockReset().mockResolvedValue(Response.json({ claude_code_marketplace_auth_required: true })); + skillRequestMock.mockReset().mockRejectedValue(new Error("Unexpected skill request")); + fetchMock.mockReset().mockImplementation((input, init) => { + const requestUrl = input instanceof Request ? input.url : String(input); + if (requestUrl.includes("/.well-known/litellm-ui-config")) { + return Promise.resolve(Response.json({ claude_code_marketplace_auth_required: true })); + } + return skillRequestMock(input, init); + }); vi.stubGlobal("fetch", fetchMock); }); @@ -47,16 +76,18 @@ it.each(["legacy", "portable"])( ? skillHubDetail({ ...skill, visibility: "private", download_url: "/skill-hub/skills/my-skill/archive" }) : skill; renderWithProviders(); - fetchMock.mockResolvedValueOnce(Response.json({ detail: "Archive not found" }, { status: 404 })); + skillRequestMock.mockResolvedValueOnce(Response.json({ detail: "Archive not found" }, { status: 404 })); fireEvent.click(screen.getByRole("button", { name: "Download ZIP" })); expect(screen.getByRole("button", { name: "Downloading…" })).toBeDisabled(); expect(await screen.findByRole("alert")).toHaveTextContent("Archive not found"); expect(click).not.toHaveBeenCalled(); - fetchMock.mockResolvedValueOnce(new Response("zip-bytes", { headers: { "Content-Type": "application/zip" } })); + skillRequestMock.mockResolvedValueOnce( + new Response("zip-bytes", { headers: { "Content-Type": "application/zip" } }), + ); fireEvent.click(screen.getByRole("button", { name: "Download ZIP" })); await waitFor(() => expect(click).toHaveBeenCalledOnce()); expect((click.mock.contexts[0] as HTMLAnchorElement).download).toBe("my-skill.zip"); - expect(fetchMock).toHaveBeenLastCalledWith( + expect(skillRequestMock).toHaveBeenLastCalledWith( expectedUrl, expect.objectContaining({ method: "GET", @@ -68,10 +99,91 @@ it.each(["legacy", "portable"])( }, ); -it("keeps an external archive as an ordinary link even when its path matches the gateway", () => { +it("keeps an external archive as a link while retaining stored download history", async () => { const externalUrl = `https://external.example.com${archivePath}`; - renderSkill({ ...skill.source, url: externalUrl }); + renderSkill({ ...skill.source, url: externalUrl }, true); expect(screen.getByRole("link")).toHaveAttribute("href", externalUrl); expect(screen.getByRole("link")).toHaveAttribute("target", "_blank"); expect(screen.queryByRole("button", { name: "Download ZIP" })).not.toBeInTheDocument(); + skillRequestMock.mockResolvedValueOnce(Response.json(page([event]))); + fireEvent.click(screen.getByRole("tab", { name: "Downloads" })); + expect(await screen.findByRole("cell", { name: event.user_email })).toBeInTheDocument(); + expect(screen.getByText(/External downloads are not tracked/)).toBeInTheDocument(); }); + +it("shows attribution and string cursors, retains rows on failure, and hides populated history on role loss", async () => { + setGlobalLitellmHeaderName("X-LiteLLM-Key"); + const view = renderSkill(skill.source, true); + skillRequestMock.mockResolvedValueOnce(Response.json(page([event], 7, event.id, 5))); + fireEvent.click(screen.getByRole("tab", { name: "Downloads" })); + const row = await screen.findByRole("row", { name: /reader@example.test/ }); + for (const value of ["development", "Engineering", "1.2.0", new Date(event.downloaded_at).toLocaleString("en")]) { + expect(row).toHaveTextContent(value); + } + expect(screen.getByRole("definition", { name: "Total downloads" })).toHaveTextContent("7"); + expect(screen.getByRole("definition", { name: "Anonymous downloads" })).toHaveTextContent("5"); + expect(skillRequestMock).toHaveBeenLastCalledWith( + downloadsUrl, + expect.objectContaining({ + headers: expect.objectContaining({ "X-LiteLLM-Key": "Bearer dashboard-token" }), + }), + ); + skillRequestMock.mockResolvedValueOnce(Response.json({ detail: "Unavailable" }, { status: 500 })); + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not load download history"); + expect(screen.getByRole("cell", { name: event.user_email })).toBeInTheDocument(); + skillRequestMock.mockResolvedValueOnce(Response.json(page([{ ...event, id: "2", user_email: null }], 7, null, 5))); + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + expect(await screen.findByRole("cell", { name: event.user_id })).toBeInTheDocument(); + expect(skillRequestMock).toHaveBeenLastCalledWith(`${downloadsUrl}&cursor=${event.id}`, expect.anything()); + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument(); + view.rerender(); + expect(screen.queryByRole("tab", { name: "Downloads" })).not.toBeInTheDocument(); + expect(screen.queryByRole("table", { name: "Download history" })).not.toBeInTheDocument(); +}); + +it.each([ + { nextSkill: { ...skill, id: "recreated-skill" }, token: "dashboard-token" }, + { nextSkill: skill, token: "another-admin-token" }, +])("clears old history while a new scope fails to load", async ({ nextSkill, token }) => { + const view = renderSkill(skill.source, true); + skillRequestMock.mockResolvedValueOnce(Response.json(page([event]))); + fireEvent.click(screen.getByRole("tab", { name: "Downloads" })); + expect(await screen.findByRole("cell", { name: event.user_email })).toBeInTheDocument(); + const nextPage = Promise.withResolvers(); + skillRequestMock.mockReturnValueOnce(nextPage.promise); + view.rerender(); + expect(screen.getByRole("status")).toHaveTextContent("Loading download history"); + expect(screen.queryByRole("table", { name: "Download history" })).not.toBeInTheDocument(); + nextPage.resolve(Response.json({ detail: "Unavailable" }, { status: 500 })); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not load download history"); + expect(screen.queryByText("Total downloads")).not.toBeInTheDocument(); +}); + +it.each([skill.source, { source: "archive", url: `${getProxyBaseUrl()}/skill-hub/skills/my-skill/archive` }])( + "refreshes canonical history after a successful ZIP download and refreshes anonymous-only totals", + async (source) => { + URL.createObjectURL = vi.fn(() => "blob:skill-download"); + renderSkill(source, true); + skillRequestMock.mockResolvedValueOnce(Response.json(page())); + fireEvent.click(screen.getByRole("tab", { name: "Downloads" })); + expect(await screen.findByText("No authenticated download history yet.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Overview" })); + skillRequestMock + .mockResolvedValueOnce(new Response("zip-bytes")) + .mockImplementation(() => Promise.resolve(Response.json(page([event])))); + fireEvent.click(screen.getByRole("button", { name: "Download ZIP" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Download ZIP" })).toBeEnabled()); + expect(skillRequestMock).toHaveBeenLastCalledWith(downloadsUrl, expect.anything()); + fireEvent.click(screen.getByRole("tab", { name: "Downloads" })); + expect(await screen.findByRole("cell", { name: event.user_email })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled()); + skillRequestMock.mockResolvedValue(Response.json(page([], 5, null, 5))); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(await screen.findByText("No authenticated download history yet.")).toBeInTheDocument(); + expect(screen.getByRole("definition", { name: "Total downloads" })).toHaveTextContent("5"); + skillRequestMock.mockResolvedValueOnce(Response.json({ versions: [], next_offset: null })); + fireEvent.click(screen.getByRole("tab", { name: "Version history" })); + expect(await screen.findByText(/No recorded releases yet/)).toBeInTheDocument(); + }, +); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx index 951f30fc9cc..9eb3f9ee8f1 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; +import { renderWithProviders as render } from "@/../tests/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { Plugin } from "./types"; diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index 4c0050e8e30..efdf4757c11 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Check, Copy, Download, Link2 } from "lucide-react"; import { Trans, useTranslation } from "react-i18next"; import { useClaudeCodeMarketplaceRequiresKey } from "@/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey"; @@ -6,6 +7,8 @@ import { cn } from "@/lib/cva.config"; import { downloadBlob } from "@/components/EntityUsageExport/utils"; import { apiClient, getProxyBaseUrl } from "@/components/networking"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { SkillDownloads, skillDownloadsQueryKey } from "./SkillDownloads"; import { buildMarketplaceSettingsSnippet, formatInstallCommand, @@ -44,22 +47,14 @@ export const skillHubDetail = (skill: SkillHubSkill): SkillDetailData => ({ downloadAvailable: Boolean(skill.download_url), }); -const SkillSource: React.FC> = ({ skill, accessToken }) => { +const SkillSource: React.FC< + Pick & { archivePath: string | null } +> = ({ skill, accessToken, isAdmin, archivePath }) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); const [downloading, setDownloading] = useState(false); const [downloadError, setDownloadError] = useState(null); const { source } = skill; - const portableArchivePath = skill.downloadAvailable - ? `/skill-hub/skills/${encodeURIComponent(skill.name)}/archive` - : null; - const archivePath = skill.portable - ? portableArchivePath - : getManagedSkillArchivePath( - skill.name, - source, - getProxyBaseUrl(), - typeof window !== "undefined" ? window.location.origin : undefined, - ); const downloadArchive = async () => { if (!archivePath || downloading) return; setDownloading(true); @@ -67,6 +62,14 @@ const SkillSource: React.FC> = ( try { const blob = await apiClient.getBlob(archivePath, { accessToken, redirect: "error" }); downloadBlob(blob, `${skill.name}.zip`); + if (isAdmin) { + await queryClient.invalidateQueries({ + queryKey: skillDownloadsQueryKey(skill, accessToken), + exact: true, + refetchType: "all", + }); + await queryClient.invalidateQueries({ queryKey: ["skill-hub", accessToken] }); + } } catch (error) { setDownloadError( error instanceof Error ? error.message : t("claudeCodePlugins.skillDetail.overview.downloadFailed"), @@ -121,11 +124,23 @@ interface SkillDetailProps { onEditClick?: () => void; } -const SkillDetail: React.FC = ({ skill, onBack, accessToken, onEditClick }) => { +const SkillDetail: React.FC = ({ skill, onBack, accessToken, onEditClick, isAdmin = false }) => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("overview"); const [copiedKey, setCopiedKey] = useState(null); const marketplaceRequiresKey = useClaudeCodeMarketplaceRequiresKey(); + const visibleTab = activeTab === "downloads" && !isAdmin ? "overview" : activeTab; + const portableArchivePath = skill.downloadAvailable + ? `/skill-hub/skills/${encodeURIComponent(skill.name)}/archive` + : null; + const archivePath = skill.portable + ? portableArchivePath + : getManagedSkillArchivePath( + skill.name, + skill.source, + getProxyBaseUrl(), + typeof window !== "undefined" ? window.location.origin : undefined, + ); const copyToClipboard = (text: string, key: string) => { navigator.clipboard.writeText(text); @@ -168,10 +183,11 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o { key: "overview", label: t("claudeCodePlugins.skillDetail.tabs.overview") }, ...(!skill.portable ? [{ key: "usage", label: t("claudeCodePlugins.skillDetail.tabs.usage") }] : []), ...(accessToken ? [{ key: "history", label: t("claudeCodePlugins.skillDetail.history.title") }] : []), + ...(isAdmin ? [{ key: "downloads", label: t("claudeCodePlugins.skillDetail.tabs.downloads") }] : []), ]; return ( -
+ {/* Back link */}
= ({ skill, onBack, accessToken, o {/* Tab bar */}
-
+ {tabs.map((tab) => ( -
setActiveTab(tab.key)} + value={tab.key} className={cn( - "-mb-px cursor-pointer border-b-[3px] px-5 py-3 text-sm", - activeTab === tab.key + "-mb-px h-auto cursor-pointer rounded-none border-b-[3px] px-5 py-3 text-sm after:hidden", + visibleTab === tab.key ? "border-info font-medium text-info" : "border-transparent font-normal text-muted-foreground", )} > {tab.label} -
+ ))} -
+
{/* Overview tab */} - {activeTab === "history" && accessToken && ( - + {visibleTab === "history" && accessToken && ( + + + )} - {activeTab === "overview" && ( -
+ {visibleTab === "overview" && ( + {/* Left column */}

@@ -268,7 +286,13 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o

- + {skill.keywords && skill.keywords.length > 0 && (
@@ -295,12 +319,12 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o
{skill.id}
-
+ )} {/* How to Use tab */} - {activeTab === "usage" && ( -
+ {visibleTab === "usage" && ( +

{t("claudeCodePlugins.skillDetail.usage.title")}

@@ -346,12 +370,12 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o {t("claudeCodePlugins.skillDetail.usage.seeSetup")}

-
+ )} {/* Setup tab (linked from usage) */} - {activeTab === "setup" && ( -
+ {visibleTab === "setup" && ( +

{t("claudeCodePlugins.skillDetail.setup.title")}

@@ -447,9 +471,14 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o
{settingsSnippet}
-
+ )} - + {isAdmin && visibleTab === "downloads" && ( + + + + )} + ); }; 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 82614816483..c0560c09951 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts @@ -18,6 +18,8 @@ export type PluginAuthor = components["schemas"]["PluginAuthor"]; export type Plugin = components["schemas"]["PluginListItem"]; export type PluginListItem = Plugin; export type ListPluginsResponse = components["schemas"]["ListPluginsResponse"]; +export type PluginDownloadEvent = components["schemas"]["PluginDownloadEvent"]; +export type PluginDownloadsResponse = components["schemas"]["PluginDownloadsResponse"]; export type SkillRegisterRequest = Omit & { version?: components["schemas"]["RegisterPluginRequest"]["version"]; diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 1b93a3ea05f..f9a7882b2de 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -1647,7 +1647,8 @@ "createdAt": "Created At", "actions": "Actions", "domain": "Domain", - "updatedAt": "Updated at" + "updatedAt": "Updated at", + "downloads": "Downloads" } }, "addForm": { @@ -1869,7 +1870,30 @@ "back": "Skills", "tabs": { "overview": "Overview", - "usage": "How to Use" + "usage": "How to Use", + "downloads": "Downloads" + }, + "downloads": { + "title": "Download history", + "description": "ZIP downloads served by this gateway, including repeat downloads and client prefetches. Counts do not confirm installations. External downloads are not tracked.", + "attributionNote": "History identifies authenticated callers or shared-key owners. Anonymous downloads are included in totals without individual history.", + "total": "Total downloads", + "anonymousTotal": "Anonymous downloads", + "downloadedAt": "Downloaded at", + "downloader": "User or key owner", + "key": "Key alias", + "team": "Team", + "version": "Version", + "anonymous": "Anonymous", + "unidentified": "Authenticated, no user identity", + "unavailable": "Not recorded", + "loading": "Loading download history…", + "loadFailed": "Could not load download history. Try refreshing.", + "empty": "No authenticated download history yet.", + "refresh": "Refresh", + "refreshing": "Refreshing download history…", + "loadMore": "Load more", + "loadingMore": "Loading more downloads…" }, "properties": { "category": "Category", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index db62f16d5ea..f3c465bcf85 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2708,6 +2708,29 @@ export interface paths { patch?: never; trace?: never; }; + "/claude-code/plugins/{plugin_name}/downloads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Plugin Downloads + * @description Admin-only ZIP download totals and authenticated downloader history. + * + * Anonymous downloads contribute only to totals. Counts include repeat requests and client prefetches, + * starting when tracking is deployed. External sources and installation completion are not observed. + */ + get: operations["get_plugin_downloads_claude_code_plugins__plugin_name__downloads_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/claude-code/plugins/{plugin_name}/enable": { parameters: { query?: never; @@ -15919,6 +15942,29 @@ export interface paths { patch?: never; trace?: never; }; + "/skill-hub/skills/{plugin_name}/downloads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Plugin Downloads + * @description Admin-only ZIP download totals and authenticated downloader history. + * + * Anonymous downloads contribute only to totals. Counts include repeat requests and client prefetches, + * starting when tracking is deployed. External sources and installation completion are not observed. + */ + get: operations["get_plugin_downloads_skill_hub_skills__plugin_name__downloads_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/skill-hub/submissions": { parameters: { query?: never; @@ -42026,6 +42072,43 @@ export interface components { */ url: string; }; + /** PluginDownloadEvent */ + PluginDownloadEvent: { + /** Authenticated */ + authenticated: boolean; + /** Digest */ + digest: string; + /** + * Downloaded At + * Format: date-time + */ + downloaded_at: string; + /** Id */ + id: string; + /** Key Alias */ + key_alias: string | null; + /** Team Alias */ + team_alias: string | null; + /** Team Id */ + team_id: string | null; + /** User Email */ + user_email: string | null; + /** User Id */ + user_id: string | null; + /** Version */ + version: string | null; + }; + /** PluginDownloadsResponse */ + PluginDownloadsResponse: { + /** Anonymous Downloads */ + anonymous_downloads: number; + /** Downloads */ + downloads: components["schemas"]["PluginDownloadEvent"][]; + /** Next Cursor */ + next_cursor: string | null; + /** Total Downloads */ + total_downloads: number; + }; /** * PluginListItem * @description Plugin item in list responses. @@ -46928,6 +47011,11 @@ export interface components { description?: string | null; /** Domain */ domain?: string | null; + /** + * Download Count + * @description Gateway ZIP downloads, included in proxy-admin catalog listings + */ + download_count?: number | null; /** Download Url */ download_url?: string | null; /** Id */ @@ -57038,6 +57126,42 @@ export interface operations { }; }; }; + get_plugin_downloads_claude_code_plugins__plugin_name__downloads_get: { + parameters: { + query?: { + /** @description Maximum number of download events */ + limit?: number; + /** @description Previous next_cursor */ + cursor?: string | null; + }; + header?: never; + path: { + plugin_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PluginDownloadsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; enable_plugin_claude_code_plugins__plugin_name__enable_post: { parameters: { query?: never; @@ -74722,6 +74846,42 @@ export interface operations { }; }; }; + get_plugin_downloads_skill_hub_skills__plugin_name__downloads_get: { + parameters: { + query?: { + /** @description Maximum number of download events */ + limit?: number; + /** @description Previous next_cursor */ + cursor?: string | null; + }; + header?: never; + path: { + plugin_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PluginDownloadsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_submissions_skill_hub_submissions_get: { parameters: { query?: {