mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
feat(skills): show download counts and downloader history (#50165)
This commit is contained in:
parent
79e63d3e06
commit
aa7917a3d2
35 changed files with 1708 additions and 89 deletions
|
|
@ -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 $$;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -343,6 +343,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
|
|||
"LiteLLM_ClaudeCodePluginUpload",
|
||||
"LiteLLM_ClaudeCodePluginVersion",
|
||||
"LiteLLM_SkillSubmission",
|
||||
"LiteLLM_ClaudeCodePluginDownload",
|
||||
"LiteLLM_MemoryTable",
|
||||
"LiteLLM_AdaptiveRouterState",
|
||||
"LiteLLM_AdaptiveRouterSession",
|
||||
|
|
|
|||
|
|
@ -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]: ...
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "{}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
163
tests/integration/management/test_skill_download_history.py
Normal file
163
tests/integration/management/test_skill_download_history.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"])]
|
||||
|
|
|
|||
|
|
@ -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()}}),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(<ClaudeCodePluginsPanel accessToken="admin-token" userRole="Admin" />);
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) => (
|
||||
<div data-testid="plugin-table">
|
||||
{isLoading ? "table-loading" : "table-loaded"}
|
||||
|
|
@ -30,6 +33,7 @@ vi.mock("./PluginTable", () => ({
|
|||
<button
|
||||
key={plugin.id}
|
||||
data-testid={`row-delete-${plugin.id}`}
|
||||
onDoubleClick={() => onPluginClick(plugin.id)}
|
||||
onClick={() => onDeleteClick(plugin.name, plugin.name)}
|
||||
>
|
||||
row delete
|
||||
|
|
@ -41,7 +45,13 @@ vi.mock("./PluginTable", () => ({
|
|||
|
||||
vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null }));
|
||||
vi.mock("./SkillSubmissions", () => ({ __esModule: true, default: () => null }));
|
||||
vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null }));
|
||||
vi.mock("@/components/claude_code_plugins/skill_detail", () => ({
|
||||
__esModule: true,
|
||||
skillHubDetail: (skill: SkillHubSkill) => skill,
|
||||
default: ({ skill, isAdmin }: { skill: SkillHubSkill; isAdmin?: boolean }) => (
|
||||
<section aria-label={`Details for ${skill.name}`}>{isAdmin && <button>Downloads</button>}</section>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<ClaudeCodePluginsPanel accessToken="sk-test" userRole={userRole} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ClaudeCodePluginsPanelProps> = ({
|
|||
{selectedSkill ? (
|
||||
<CatalogSkillDetail
|
||||
skill={selectedSkill}
|
||||
isAdmin={isAdmin}
|
||||
accessToken={accessToken}
|
||||
queryScope={queryScope}
|
||||
canWrite={canWrite}
|
||||
|
|
|
|||
|
|
@ -42,11 +42,16 @@ describe("PluginTable", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render every column header", () => {
|
||||
render(<PluginTable {...defaultProps} />);
|
||||
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(<PluginTable {...defaultProps} pluginsList={[{ ...mockPlugins[0], download_count }]} />);
|
||||
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(<PluginTable {...defaultProps} isAdmin={false} />);
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -153,6 +153,21 @@ export const getPluginTableColumns = ({
|
|||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
|
||||
},
|
||||
...(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 }) => (
|
||||
<span className="tabular-nums">{row.original.download_count?.toLocaleString(i18n.language) ?? "—"}</span>
|
||||
),
|
||||
},
|
||||
] satisfies ColumnDef<SkillHubSkill>[])
|
||||
: []),
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
|
|
|
|||
|
|
@ -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<Plugin, "id" | "name">, accessToken?: string | null) =>
|
||||
["skill-downloads", skill.id, skill.name, accessToken] as const;
|
||||
|
||||
interface SkillDownloadsProps {
|
||||
skill: Pick<Plugin, "id" | "name">;
|
||||
accessToken?: string | null;
|
||||
}
|
||||
|
||||
export function SkillDownloads({ skill, accessToken }: SkillDownloadsProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryOptions = {
|
||||
queryKey: skillDownloadsQueryKey(skill, accessToken),
|
||||
queryFn: ({ pageParam, signal }: QueryFunctionContext<ReturnType<typeof skillDownloadsQueryKey>, string | null>) =>
|
||||
apiClient.get<PluginDownloadsResponse>(`/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 (
|
||||
<section aria-label={t("claudeCodePlugins.skillDetail.downloads.title")} className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-normal">{t("claudeCodePlugins.skillDetail.downloads.title")}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.downloads.description")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.downloads.attributionNote")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled={isFetching} onClick={() => void refetch()}>
|
||||
<RefreshCw className="size-4" />
|
||||
{t("claudeCodePlugins.skillDetail.downloads.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{data && (
|
||||
<dl className="flex flex-wrap gap-x-8 gap-y-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{t("claudeCodePlugins.skillDetail.downloads.total")}</dt>
|
||||
<dd aria-label={t("claudeCodePlugins.skillDetail.downloads.total")} className="text-3xl tabular-nums">
|
||||
{data.pages[0].total_downloads.toLocaleString(i18n.language)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.downloads.anonymousTotal")}
|
||||
</dt>
|
||||
<dd
|
||||
aria-label={t("claudeCodePlugins.skillDetail.downloads.anonymousTotal")}
|
||||
className="text-3xl tabular-nums"
|
||||
>
|
||||
{data.pages[0].anonymous_downloads.toLocaleString(i18n.language)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
{isPending && <p role="status">{t("claudeCodePlugins.skillDetail.downloads.loading")}</p>}
|
||||
{isError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t("claudeCodePlugins.skillDetail.downloads.loadFailed")}
|
||||
</p>
|
||||
)}
|
||||
{isFetching && !isPending && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
isFetchingNextPage
|
||||
? "claudeCodePlugins.skillDetail.downloads.loadingMore"
|
||||
: "claudeCodePlugins.skillDetail.downloads.refreshing",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{isEmpty && <p className="text-sm text-muted-foreground">{t("claudeCodePlugins.skillDetail.downloads.empty")}</p>}
|
||||
{downloads.length > 0 && (
|
||||
<Table aria-label={t("claudeCodePlugins.skillDetail.downloads.title")}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.downloadedAt")}</TableHead>
|
||||
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.downloader")}</TableHead>
|
||||
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.key")}</TableHead>
|
||||
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.team")}</TableHead>
|
||||
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.version")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{downloads.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell>
|
||||
<time dateTime={event.downloaded_at}>
|
||||
{new Date(event.downloaded_at).toLocaleString(i18n.language)}
|
||||
</time>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{event.authenticated
|
||||
? event.user_email || event.user_id || t("claudeCodePlugins.skillDetail.downloads.unidentified")
|
||||
: t("claudeCodePlugins.skillDetail.downloads.anonymous")}
|
||||
</TableCell>
|
||||
<TableCell>{event.key_alias || unavailable}</TableCell>
|
||||
<TableCell>{event.team_alias || event.team_id || unavailable}</TableCell>
|
||||
<TableCell>{event.version || unavailable}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{hasNextPage && (
|
||||
<Button variant="outline" disabled={isFetching} onClick={() => void fetchNextPage()}>
|
||||
{t("claudeCodePlugins.skillDetail.downloads.loadMore")}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(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 {
|
||||
|
|
|
|||
|
|
@ -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<PluginSource> = {}, 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<typeof fetch>();
|
||||
const renderSkill = (source = skill.source) =>
|
||||
renderWithProviders(<SkillDetail skill={{ ...skill, source }} onBack={vi.fn()} accessToken="dashboard-token" />);
|
||||
const skillRequestMock = vi.fn<typeof fetch>();
|
||||
const renderSkill = (source = skill.source, isAdmin = false) =>
|
||||
renderWithProviders(
|
||||
<SkillDetail skill={{ ...skill, source }} onBack={vi.fn()} accessToken="dashboard-token" isAdmin={isAdmin} />,
|
||||
);
|
||||
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(<SkillDetail skill={detail} onBack={vi.fn()} accessToken="dashboard-token" />);
|
||||
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(<SkillDetail skill={skill} onBack={vi.fn()} accessToken="dashboard-token" isAdmin={false} />);
|
||||
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<Response>();
|
||||
skillRequestMock.mockReturnValueOnce(nextPage.promise);
|
||||
view.rerender(<SkillDetail skill={nextSkill} onBack={vi.fn()} accessToken={token} isAdmin />);
|
||||
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();
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<Pick<SkillDetailProps, "skill" | "accessToken">> = ({ skill, accessToken }) => {
|
||||
const SkillSource: React.FC<
|
||||
Pick<SkillDetailProps, "skill" | "accessToken" | "isAdmin"> & { archivePath: string | null }
|
||||
> = ({ skill, accessToken, isAdmin, archivePath }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(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<Pick<SkillDetailProps, "skill" | "accessToken">> = (
|
|||
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<SkillDetailProps> = ({ skill, onBack, accessToken, onEditClick }) => {
|
||||
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, onEditClick, isAdmin = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(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<SkillDetailProps> = ({ 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 (
|
||||
<div className="py-6 pl-0 pr-8">
|
||||
<Tabs value={visibleTab} onValueChange={setActiveTab} className="block py-6 pl-0 pr-8">
|
||||
{/* Back link */}
|
||||
<div
|
||||
onClick={onBack}
|
||||
|
|
@ -198,30 +214,32 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
|
|||
|
||||
{/* Tab bar */}
|
||||
<div className="mb-7 mt-6 border-b border-border">
|
||||
<div className="flex">
|
||||
<TabsList variant="line" className="h-auto p-0">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
<TabsTrigger
|
||||
key={tab.key}
|
||||
onClick={() => 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}
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</div>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Overview tab */}
|
||||
{activeTab === "history" && accessToken && (
|
||||
<SkillVersionHistory key={`${skill.id}:${accessToken}`} skill={skill} accessToken={accessToken} />
|
||||
{visibleTab === "history" && accessToken && (
|
||||
<TabsContent value="history">
|
||||
<SkillVersionHistory key={`${skill.id}:${accessToken}`} skill={skill} accessToken={accessToken} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex gap-16">
|
||||
{visibleTab === "overview" && (
|
||||
<TabsContent value="overview" className="flex gap-16">
|
||||
{/* Left column */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="m-0 mb-1 text-lg font-normal text-foreground">
|
||||
|
|
@ -268,7 +286,13 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<SkillSource skill={skill} accessToken={accessToken} />
|
||||
<SkillSource
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
archivePath={archivePath}
|
||||
/>
|
||||
|
||||
{skill.keywords && skill.keywords.length > 0 && (
|
||||
<div className="mb-6">
|
||||
|
|
@ -295,12 +319,12 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
|
|||
<div className="break-all font-mono text-xs text-foreground">{skill.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* How to Use tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div className="max-w-[640px]">
|
||||
{visibleTab === "usage" && (
|
||||
<TabsContent value="usage" className="max-w-[640px]">
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.usage.title")}
|
||||
</h2>
|
||||
|
|
@ -346,12 +370,12 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
|
|||
{t("claudeCodePlugins.skillDetail.usage.seeSetup")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Setup tab (linked from usage) */}
|
||||
{activeTab === "setup" && (
|
||||
<div className="max-w-[640px]">
|
||||
{visibleTab === "setup" && (
|
||||
<TabsContent value="setup" className="max-w-[640px]">
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.setup.title")}
|
||||
</h2>
|
||||
|
|
@ -447,9 +471,14 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
|
|||
</div>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-[13px] text-foreground">{settingsSnippet}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && visibleTab === "downloads" && (
|
||||
<TabsContent value="downloads">
|
||||
<SkillDownloads skill={skill} accessToken={accessToken} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<components["schemas"]["RegisterPluginRequest"], "version"> & {
|
||||
version?: components["schemas"]["RegisterPluginRequest"]["version"];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
160
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
160
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue