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(