From a7f9f892be62591baf46b47eb1610353d0604b54 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Thu, 5 Nov 2026 16:57:33 -0800 Subject: [PATCH] feat(skills): add sortable Skills Hub catalog and publication audit (#50195) * feat(skills): add sortable Skills Hub catalog and publication audit * refactor(skills): remove redundant publisher table column * style(skills): apply dashboard formatting * fix(skills): preserve approval audit and complete hub sorting --- litellm/models/plugin_upload.py | 13 ++ litellm/proxy/_lazy_openapi_snapshot.json | 169 +++++++++++++++++- .../claude_code_marketplace.py | 18 ++ .../skill_hub_endpoints.py | 85 +++++++-- .../public_endpoints/public_endpoints.py | 16 +- litellm/repositories/plugin_publication.py | 72 ++++++-- .../repositories/plugin_upload_repository.py | 2 + .../skill_submission_repository.py | 26 ++- litellm/types/proxy/claude_code_endpoints.py | 5 + litellm/types/proxy/skill_hub.py | 21 ++- .../database/test_skill_submissions.py | 11 +- .../test_claude_code_marketplace.py | 11 +- .../test_skill_hub_endpoints.py | 119 ++++++++++-- .../public_endpoints/test_public_endpoints.py | 11 +- .../repositories/test_plugin_publication.py | 41 ++++- .../test_skill_submission_repository.py | 31 ++++ ...laudeCodePluginsPanel.integration.test.tsx | 50 ++++-- .../ClaudeCodePluginsPanel.test.tsx | 11 +- .../_components/ClaudeCodePluginsPanel.tsx | 34 ++-- .../skills/_components/PluginTable.test.tsx | 29 +-- .../skills/_components/PluginTable.tsx | 37 ++-- .../skills/_components/PluginTableColumns.tsx | 30 +--- .../add_plugin_form.integration.test.tsx | 2 +- .../SkillHubDashboard.integration.test.tsx | 31 ++++ .../components/AIHub/SkillHubDashboard.tsx | 63 ++----- .../components/AIHub/SkillHubTableColumns.tsx | 3 + .../components/CommandPalette/utils.test.ts | 2 +- .../claude_code_plugins/SkillFilters.tsx | 89 +++++++++ .../SkillMetadataColumns.tsx | 95 ++++++++++ .../claude_code_plugins/skillCatalog.ts | 99 ++++++++++ .../components/claude_code_plugins/types.ts | 4 +- .../src/components/leftnav.test.tsx | 2 +- .../PublicAiHub.integration.test.tsx | 47 ++++- .../publicModelHub/ResourcePanes.tsx | 79 +++----- .../publicModelHub/SkillInspector.tsx | 20 ++- ui/litellm-dashboard/src/i18n/locales/en.json | 86 +++++---- ui/litellm-dashboard/src/lib/http/schema.d.ts | 37 +++- 37 files changed, 1229 insertions(+), 272 deletions(-) create mode 100644 tests/unit/repositories/test_skill_submission_repository.py create mode 100644 ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/SkillFilters.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/SkillMetadataColumns.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/skillCatalog.ts diff --git a/litellm/models/plugin_upload.py b/litellm/models/plugin_upload.py index bd5e85c79cc..95db82dec79 100644 --- a/litellm/models/plugin_upload.py +++ b/litellm/models/plugin_upload.py @@ -41,6 +41,14 @@ class PluginArchiveMetadata: description: str | None = None has_agent_skill_metadata: bool = False updated_at: datetime | None = None + created_at: datetime | None = None + created_by: str | None = None + + +@dataclass(frozen=True, slots=True) +class PluginReview: + approved_by: str + security_scan_passed: bool @dataclass(frozen=True, slots=True) @@ -51,6 +59,7 @@ class PluginPublication: manifest: Mapping[str, object] created_by: str | None content: bytes | None = None + review: PluginReview | None = None @dataclass(frozen=True, slots=True) @@ -66,6 +75,10 @@ class PublishedPlugin: version: str enabled: bool updated_at: datetime + created_at: datetime | None = None + created_by: str | None = None + review: PluginReview | None = None + published_by: str | None = None @dataclass(frozen=True, slots=True) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 7d9e230f8ef..5cb63c98d56 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -7683,6 +7683,17 @@ "PluginListItem": { "description": "Plugin item in list responses.", "properties": { + "approved_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Approved By" + }, "author": { "anyOf": [ { @@ -7715,6 +7726,17 @@ ], "title": "Created At" }, + "created_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created By" + }, "description": { "anyOf": [ { @@ -7737,6 +7759,17 @@ ], "title": "Domain" }, + "download_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Download Count" + }, "enabled": { "title": "Enabled", "type": "boolean" @@ -7801,6 +7834,22 @@ ], "title": "Namespace" }, + "published_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Published By" + }, + "security_scan_passed": { + "default": false, + "title": "Security Scan Passed", + "type": "boolean" + }, "source": { "additionalProperties": { "type": "string" @@ -59415,6 +59464,14 @@ }, "title": "Skills", "type": "array" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" } }, "required": [ @@ -59690,6 +59747,17 @@ }, "SkillHubSkill": { "properties": { + "approved_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Approved By" + }, "content_url": { "anyOf": [ { @@ -59701,6 +59769,29 @@ ], "title": "Content Url" }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "created_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created By" + }, "description": { "anyOf": [ { @@ -59732,7 +59823,7 @@ "type": "null" } ], - "description": "Gateway ZIP downloads, included in proxy-admin catalog listings", + "description": "Gateway ZIP downloads, including repeat downloads", "title": "Download Count" }, "download_url": { @@ -59779,6 +59870,22 @@ ], "title": "Namespace" }, + "published_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Published By" + }, + "security_scan_passed": { + "default": false, + "title": "Security Scan Passed", + "type": "boolean" + }, "source": { "additionalProperties": { "type": "string" @@ -60434,6 +60541,66 @@ "title": "Limit", "type": "integer" } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "default": "", + "title": "Tag", + "type": "string" + } + }, + { + "in": "query", + "name": "security_scan_passed", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Security Scan Passed" + } + }, + { + "in": "query", + "name": "sort_by", + "required": false, + "schema": { + "default": "name", + "enum": [ + "name", + "download_count", + "updated_at", + "created_at", + "created_by", + "published_by", + "approved_by", + "security_scan_passed" + ], + "title": "Sort By", + "type": "string" + } + }, + { + "in": "query", + "name": "sort_order", + "required": false, + "schema": { + "default": "asc", + "enum": [ + "asc", + "desc" + ], + "title": "Sort Order", + "type": "string" + } } ], "responses": { diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index 26cb0a2f3e6..3534dd167de 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -60,6 +60,7 @@ from litellm.proxy.common_utils.claude_code_marketplace_auth import ( ) from litellm.proxy.common_utils.resource_ownership import is_proxy_admin from litellm.proxy.deps.repositories import PluginUploadRepositoryDep +from litellm.repositories.plugin_publication import read_plugin_publisher, read_plugin_review from litellm.repositories.table_repositories import ClaudeCodePluginRepository from litellm.types.proxy.auth.special_headers import SpecialHeaders from litellm.types.proxy.claude_code_endpoints import ( @@ -666,6 +667,7 @@ async def register_plugin( response_model=ListPluginsResponse, ) async def list_plugins( + uploads: PluginUploadRepositoryDep, enabled_only: bool = False, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> ListPluginsResponse: @@ -678,6 +680,17 @@ async def list_plugins( Returns: List of plugins with their metadata. """ + catalog: Final = await list_visible_plugins(enabled_only, user_api_key_dict) + if uploads is None: + raise _error_response(500, CommonProxyErrors.db_not_connected_error.value) + counts: Final = await uploads.download_counts(tuple(plugin.id for plugin in catalog.plugins)) + return ListPluginsResponse( + plugins=[plugin.model_copy(update={"download_count": counts.get(plugin.id, 0)}) for plugin in catalog.plugins], + count=catalog.count, + ) + + +async def list_visible_plugins(enabled_only: bool, user_api_key_dict: UserAPIKeyAuth) -> ListPluginsResponse: try: prisma_client: Final = await get_prisma_client() @@ -693,9 +706,14 @@ async def list_plugins( # Parse manifest to get additional fields manifest = json.loads(p.manifest_json) if p.manifest_json else {} + review = read_plugin_review(MANIFEST_FIELDS.validate_python(manifest)) plugin_list.append( PluginListItem( id=p.id, + created_by=p.created_by, + approved_by=review.approved_by if review else None, + published_by=read_plugin_publisher(MANIFEST_FIELDS.validate_python(manifest)), + security_scan_passed=review.security_scan_passed if review else False, name=p.name, version=p.version, description=p.description, diff --git a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py index 93d1ace435d..ffddbd31048 100644 --- a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py +++ b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py @@ -3,7 +3,8 @@ import hashlib from collections.abc import Mapping from dataclasses import dataclass, replace from datetime import datetime -from typing import Annotated, Final +from itertools import chain +from typing import Annotated, Final, Literal from urllib.parse import quote import httpx @@ -33,7 +34,7 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp enable_plugin, get_plugin_downloads, header_key_caller, - list_plugins, + list_visible_plugins, plugin_archive_source_matches, plugin_archive_url, plugin_downloader, @@ -65,6 +66,7 @@ from litellm.proxy.discovery_endpoints.skill_import import ( parse_skill_source, select_imported_skill, ) +from litellm.repositories.plugin_publication import read_plugin_publisher, read_plugin_review from litellm.types.proxy.claude_code_endpoints import PluginListItem from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import AgentSkillsIndexEntry from litellm.types.proxy.skill_hub import ( @@ -78,6 +80,7 @@ from litellm.types.proxy.skill_hub import ( SkillHubSkill, SkillHubVersion, SkillHubVersions, + SkillSortField, ) router: Final = APIRouter(prefix="/skill-hub/skills", tags=["Skills Hub"]) @@ -121,6 +124,11 @@ def _skill_response(request: Request, plugin: PluginListItem, download_count: in namespace=plugin.namespace, keywords=None if plugin.keywords is None else tuple(plugin.keywords), updated_at=None if plugin.updated_at is None else datetime.fromisoformat(plugin.updated_at), + created_at=None if plugin.created_at is None else datetime.fromisoformat(plugin.created_at), + created_by=plugin.created_by, + approved_by=plugin.approved_by, + published_by=plugin.published_by, + security_scan_passed=plugin.security_scan_passed, visibility="public" if plugin.enabled else "private", 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, @@ -137,28 +145,74 @@ async def list_skills( query: str = "", offset: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 50, + tag: str = "", + security_scan_passed: bool | None = None, + sort_by: SkillSortField = "name", + sort_order: Literal["asc", "desc"] = "asc", ) -> SkillHubList: """Browse the caller's public and granted private skills.""" - catalog: Final = await list_plugins(enabled_only=False, user_api_key_dict=caller) - needle: Final = query.casefold() + catalog: Final = await list_visible_plugins(enabled_only=False, user_api_key_dict=caller) + needle: Final = query.strip().casefold() + selected_tag: Final = tag.strip().casefold() matched: Final = tuple( plugin for plugin in sorted(catalog.plugins, key=lambda item: item.name) - if needle in " ".join((plugin.name, plugin.description or "", *(plugin.keywords or ()))).casefold() + if needle + in " ".join( + ( + plugin.name, + plugin.description or "", + plugin.domain or "", + plugin.namespace or "", + plugin.created_by or "", + plugin.approved_by or "", + plugin.published_by or "", + *(plugin.keywords or ()), + ) + ).casefold() + and (not selected_tag or selected_tag in {word.casefold() for word in plugin.keywords or ()}) + and (security_scan_passed is None or plugin.security_scan_passed == security_scan_passed) ) - 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) + await _require_store(uploads).download_counts(tuple(plugin.id for plugin in matched)) + if sort_by == "download_count" else {} ) + ordered: Final = sorted( + matched, key=lambda plugin: _skill_sort_key(plugin, sort_by, counts), reverse=sort_order == "desc" + ) + page: Final = ordered[offset : offset + limit] + page_counts: Final = ( + await _require_store(uploads).download_counts(tuple(plugin.id for plugin in page)) + if sort_by != "download_count" + else counts + ) return SkillHubList( - skills=tuple(_skill_response(request, plugin, counts.get(plugin.id)) for plugin in page), + skills=tuple(_skill_response(request, plugin, page_counts.get(plugin.id)) for plugin in page), count=len(matched), next_offset=offset + limit if offset + limit < len(matched) else None, + tags=tuple( + sorted(set(chain.from_iterable(plugin.keywords or () for plugin in catalog.plugins)), key=str.casefold) + ), ) +def _skill_sort_key(plugin: PluginListItem, field: SkillSortField, counts: Mapping[str, int]) -> str | float: + if field == "download_count": + return counts.get(plugin.id, 0) + if field == "security_scan_passed": + return int(plugin.security_scan_passed) + if field in {"created_at", "updated_at"}: + value: Final = plugin.created_at if field == "created_at" else plugin.updated_at + return datetime.fromisoformat(value).timestamp() if value else 0 + return { + "name": plugin.name, + "created_by": plugin.created_by or "", + "published_by": plugin.published_by or "", + "approved_by": plugin.approved_by or "", + }[field].casefold() + + @router.get("/{name}", response_model=SkillHubSkill) async def get_skill( request: Request, @@ -176,6 +230,7 @@ async def get_skill( def skill_metadata_response(request: Request, metadata: PluginArchiveMetadata) -> SkillHubSkill: manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}") + review: Final = read_plugin_review(manifest) return _skill_response( request, PluginListItem.model_validate( @@ -187,7 +242,11 @@ def skill_metadata_response(request: Request, metadata: PluginArchiveMetadata) - "source": dict(metadata.source), "has_agent_skill_metadata": metadata.has_agent_skill_metadata, "enabled": metadata.enabled, - "created_at": None, + "created_at": metadata.created_at.isoformat() if metadata.created_at is not None else None, + "created_by": metadata.created_by, + "approved_by": review.approved_by if review else None, + "published_by": read_plugin_publisher(manifest), + "security_scan_passed": review.security_scan_passed if review else False, "updated_at": metadata.updated_at.isoformat() if metadata.updated_at is not None else None, "domain": manifest.get("domain"), "namespace": manifest.get("namespace"), @@ -416,7 +475,11 @@ def skill_publication_response( "version": published.version, "enabled": published.enabled, "has_agent_skill_metadata": has_agent_skill_metadata(publication.manifest), - "created_at": None, + "created_at": published.created_at.isoformat() if published.created_at is not None else None, + "created_by": published.created_by, + "approved_by": published.review.approved_by if published.review else None, + "published_by": published.published_by, + "security_scan_passed": published.review.security_scan_passed if published.review else False, "updated_at": published.updated_at.isoformat(), "domain": publication.manifest.get("domain"), "namespace": publication.manifest.get("namespace"), diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index b8cdea0dea4..cb1e51d0deb 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -24,7 +24,9 @@ from litellm.proxy._types import ( CommonProxyErrors, ) from litellm.proxy.db.health_check_latest import latest_by_model_name +from litellm.proxy.deps.repositories import PluginUploadRepositoryDep from litellm.proxy.utils import get_custom_url +from litellm.repositories.plugin_publication import read_plugin_publisher, read_plugin_review from litellm.repositories.table_repositories import ClaudeCodePluginRepository from litellm.router_strategy.complexity_router.fuse_presets import FusePresetCatalog, get_fuse_presets from litellm.types.agents import AgentCard @@ -71,6 +73,9 @@ class _EndpointEntry(TypedDict): class _PluginRow(Protocol): + @property + def created_by(self) -> str | None: ... + @property def id(self) -> str: ... @@ -366,7 +371,7 @@ async def get_mcp_servers(): "/public/skill_hub", tags=["public", "Claude Code Marketplace"], ) -async def public_skill_hub(): +async def public_skill_hub(uploads: PluginUploadRepositoryDep): """Return enabled (public) Claude Code skills, or none when the marketplace requires a key.""" from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( MANIFEST_FIELDS, @@ -385,13 +390,22 @@ async def public_skill_hub(): raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) try: plugins: Final = await _plugin_table(prisma_client).find_many(where={"enabled": True}) + counts: Final[Mapping[str, int]] = ( + await uploads.download_counts(tuple(plugin.id for plugin in plugins)) if uploads else {} + ) items: Final = [] for plugin in plugins: raw = plugin.manifest_json or {} manifest = json.loads(raw) if isinstance(raw, str) else raw + review = read_plugin_review(MANIFEST_FIELDS.validate_python(manifest)) items.append( PluginListItem( id=plugin.id, + download_count=counts.get(plugin.id), + created_by=plugin.created_by, + approved_by=review.approved_by if review else None, + published_by=read_plugin_publisher(MANIFEST_FIELDS.validate_python(manifest)), + security_scan_passed=review.security_scan_passed if review else False, name=plugin.name, enabled=plugin.enabled, created_at=str(plugin.created_at) if plugin.created_at else None, diff --git a/litellm/repositories/plugin_publication.py b/litellm/repositories/plugin_publication.py index f5fab458c6b..6f94076c3e0 100644 --- a/litellm/repositories/plugin_publication.py +++ b/litellm/repositories/plugin_publication.py @@ -1,15 +1,18 @@ import hashlib import json +from collections.abc import Mapping +from dataclasses import asdict from datetime import datetime, timezone from typing import TYPE_CHECKING, Final, Protocol -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from semver import Version from litellm.models.plugin_upload import ( PluginPublication, PluginPublicationBase, PluginPublicationError, + PluginReview, PublishedPlugin, ) from litellm.repositories.prisma_protocols import TableActions @@ -23,6 +26,22 @@ if TYPE_CHECKING: _MANIFEST: Final = TypeAdapter(dict[str, object]) _SOURCE: Final = TypeAdapter(dict[str, str]) +_REVIEW: Final = TypeAdapter(PluginReview) +_PUBLISHER: Final = TypeAdapter[str | None](str | None) + + +def read_plugin_review(manifest: Mapping[str, object]) -> PluginReview | None: + try: + return _REVIEW.validate_python(manifest.get("_litellm_review")) + except ValidationError: + return None + + +def read_plugin_publisher(manifest: Mapping[str, object]) -> str | None: + try: + return _PUBLISHER.validate_python(manifest.get("_litellm_published_by")) + except ValidationError: + return None class PluginPublicationTransaction(Protocol): @@ -122,7 +141,22 @@ async def publish_plugin( ) if isinstance(version, PluginPublicationError): return version - manifest_json: Final = json.dumps({**publication.manifest, "version": version}) + current_manifest: Final = _MANIFEST.validate_json(current.manifest_json or "{}") if current is not None else {} + same_source: Final = current is not None and current_manifest.get("source") == publication.manifest.get("source") + review: Final = publication.review or (read_plugin_review(current_manifest) if same_source else None) + publisher: Final = publication.review.approved_by if publication.review is not None else publication.created_by + manifest_json: Final = json.dumps( + { + **{ + key: value + for key, value in publication.manifest.items() + if key not in {"_litellm_review", "_litellm_published_by"} + }, + "version": version, + **({"_litellm_published_by": publisher} if publisher is not None else {}), + **({"_litellm_review": asdict(review)} if review is not None else {}), + } + ) if publication.content is not None and verified_archive(publication.content, manifest_json) is None: return PluginPublicationError("conflict", "Uploaded archive does not match its published source digest") timestamp: Final = datetime.now(timezone.utc) @@ -141,8 +175,18 @@ async def publish_plugin( await tx.litellm_claudecodepluginupload.create( data={"plugin_name": publication.name, "content": Base64.encode(publication.content)} ) - await _snapshot(tx, created, publication.content, publication.created_by) - return PublishedPlugin(created.id, created.name, version, created.enabled, created.updated_at or timestamp) + await _snapshot(tx, created, publication.content, publisher) + return PublishedPlugin( + created.id, + created.name, + version, + created.enabled, + created.updated_at or timestamp, + created.created_at, + created.created_by, + review, + publisher, + ) previous_archive: Final = await tx.litellm_claudecodepluginupload.find_unique(where={"plugin_name": current.name}) previous_content: Final = ( verified_archive(previous_archive.content.decode(), current.manifest_json) @@ -152,11 +196,7 @@ async def publish_plugin( history_count: Final = await tx.litellm_claudecodepluginversion.count(where={"plugin_id": current.id}, take=1) if history_count == 0: await _snapshot(tx, current, previous_content, None) - unchanged_content: Final = ( - previous_content - if _MANIFEST.validate_json(current.manifest_json or "{}").get("source") == publication.manifest.get("source") - else None - ) + unchanged_content: Final = previous_content if same_source else None content: Final = publication.content if publication.content is not None else unchanged_content updated: Final = await tx.litellm_claudecodeplugintable.update(where={"id": current.id}, data=data) if updated is None: @@ -171,5 +211,15 @@ async def publish_plugin( "update": {"content": Base64.encode(content)}, }, ) - await _snapshot(tx, updated, content, publication.created_by) - return PublishedPlugin(updated.id, updated.name, version, updated.enabled, updated.updated_at or timestamp) + await _snapshot(tx, updated, content, publisher) + return PublishedPlugin( + updated.id, + updated.name, + version, + updated.enabled, + updated.updated_at or timestamp, + updated.created_at, + updated.created_by, + review, + publisher, + ) diff --git a/litellm/repositories/plugin_upload_repository.py b/litellm/repositories/plugin_upload_repository.py index 99e226e442b..8045abf9af9 100644 --- a/litellm/repositories/plugin_upload_repository.py +++ b/litellm/repositories/plugin_upload_repository.py @@ -287,4 +287,6 @@ def _archive_metadata(plugin: "prisma_models.LiteLLM_ClaudeCodePluginTable") -> description=plugin.description, has_agent_skill_metadata=has_agent_skill_metadata(manifest), updated_at=plugin.updated_at, + created_at=plugin.created_at, + created_by=plugin.created_by, ) diff --git a/litellm/repositories/skill_submission_repository.py b/litellm/repositories/skill_submission_repository.py index 829aa0aadbf..03821da042a 100644 --- a/litellm/repositories/skill_submission_repository.py +++ b/litellm/repositories/skill_submission_repository.py @@ -7,12 +7,13 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # Prisma's generated delegates are dynamic from urllib.parse import urlsplit -from pydantic import BaseModel, ConfigDict, TypeAdapter +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError from litellm.models.plugin_upload import ( PluginPublication, PluginPublicationBase, PluginPublicationError, + PluginReview, PublishedPlugin, uploaded_plugin_source, ) @@ -312,7 +313,9 @@ class SkillSubmissionRepository: if approve and content is not None and publication_base_url else _SOURCE.validate_python(submission.proposal.manifest.get("source")) ) - publication: Final = await _publish_submission(transaction, row, source, content) if approve else None + publication: Final = ( + await _publish_submission(transaction, row, source, content, reviewer) if approve else None + ) if isinstance(publication, PluginPublicationError): return publication reviewed_at: Final = datetime.now(timezone.utc) @@ -346,6 +349,7 @@ async def _publish_submission( row: "prisma_models.LiteLLM_SkillSubmission", source: Mapping[str, str], content: bytes | None, + reviewer: str, ) -> PublishedPlugin | PluginPublicationError: stored: Final = _StoredProposal.model_validate_json(row.proposal_json) proposal: Final = stored.proposal(row.plugin_name) @@ -363,6 +367,24 @@ async def _publish_submission( manifest={**stored.manifest, "source": dict(source)}, content=content, created_by=row.submitted_by, + review=PluginReview(reviewer, scan_passed_for_archive(row.scan_json, source.get("sha256"))), ), base, ) + + +def scan_passed_for_archive(scan_json: str | None, digest: str | None) -> bool: + if not scan_json or not digest: + return False + try: + scan: Final = SkillScan.model_validate_json(scan_json) + except ValidationError: + return False + return ( + scan.status == "completed" + and scan.archive_sha256 == digest + and scan.report is not None + and scan.report.complete + and scan.report.risk_score == 0 + and not scan.report.findings + ) diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 5d8479b8ce1..dfbf1de17af 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -154,6 +154,11 @@ class PluginListItem(LiteLLMBaseModel): enabled: bool created_at: str | None updated_at: str | None + created_by: str | None = None + approved_by: str | None = None + published_by: str | None = None + security_scan_passed: bool = False + download_count: int | None = None has_agent_skill_metadata: bool = Field(default=False, exclude=True) diff --git a/litellm/types/proxy/skill_hub.py b/litellm/types/proxy/skill_hub.py index d575defcda7..050c3cb7eb8 100644 --- a/litellm/types/proxy/skill_hub.py +++ b/litellm/types/proxy/skill_hub.py @@ -7,6 +7,17 @@ from pydantic import ConfigDict, Field from litellm.models.skill_submission import SubmissionOperation, SubmissionStatus from litellm.types.llms.base import LiteLLMBaseModel +SkillSortField: TypeAlias = Literal[ + "name", + "download_count", + "updated_at", + "created_at", + "created_by", + "published_by", + "approved_by", + "security_scan_passed", +] + class SkillHubSkill(LiteLLMBaseModel): model_config = ConfigDict(frozen=True) @@ -16,6 +27,11 @@ class SkillHubSkill(LiteLLMBaseModel): description: str | None = None version: str | None = None updated_at: datetime | None = None + created_at: datetime | None = None + created_by: str | None = None + approved_by: str | None = None + published_by: str | None = None + security_scan_passed: bool = False visibility: Literal["public", "private"] source: Mapping[str, str] download_url: str | None = None @@ -23,12 +39,11 @@ 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" - ) + download_count: int | None = Field(None, description="Gateway ZIP downloads, including repeat downloads") class SkillHubList(LiteLLMBaseModel): + tags: tuple[str, ...] = () model_config = ConfigDict(frozen=True) skills: tuple[SkillHubSkill, ...] diff --git a/tests/integration/database/test_skill_submissions.py b/tests/integration/database/test_skill_submissions.py index cc00bf6e359..e55e19fb110 100644 --- a/tests/integration/database/test_skill_submissions.py +++ b/tests/integration/database/test_skill_submissions.py @@ -134,6 +134,8 @@ async def test_approval_commits_version_archive_history_and_review(pending_updat assert current is not None published_manifest: Final = { **case.proposal.manifest, + "_litellm_review": {"approved_by": "integration-reviewer", "security_scan_passed": False}, + "_litellm_published_by": "integration-reviewer", "source": { **TypeAdapter(dict[str, str]).validate_python(case.submission.proposal.manifest["source"]), "url": f"https://skills.example.test/claude-code/plugins/{case.original.name}/archive/" @@ -148,17 +150,22 @@ async def test_approval_commits_version_archive_history_and_review(pending_updat ) metadata: Final = await case.publications.get_metadata(case.original.name) assert metadata is not None + assert metadata.created_by == before.created_by == case.original.created_by + assert metadata.created_at == before.created_at + assert metadata.updated_at is not None and before.updated_at is not None + assert metadata.updated_at > before.updated_at assert await case.publications.get_content(metadata) == case.proposal.content history: Final = await case.publications.versions(metadata, 10, 0) assert tuple((version.version, version.description, version.archive_available) for version in history) == ( ("1.0.1", case.proposal.description, True), ("1.0.0", case.original.description, True), ) + assert history[0].published_by == "integration-reviewer" + assert history[1].published_by == case.original.created_by for version, publication, manifest in zip( - history, (case.proposal, case.original), (published_manifest, case.original.manifest), strict=True + history, (case.proposal, case.original), (published_manifest, {**case.original.manifest, "_litellm_published_by": case.original.created_by}), strict=True ): assert version.manifest == {**manifest, "version": version.version} - assert version.published_by == publication.created_by assert await case.publications.version_content(metadata, version.id) == publication.content diff --git a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 7e5b3a7abd7..a1be416f4cb 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -74,6 +74,7 @@ def _make_mock_prisma(): record.description = data.get("description") record.manifest_json = data.get("manifest_json", "{}") record.enabled = data.get("enabled", True) + record.created_by = data.get("created_by") record.created_at = data.get("created_at") record.updated_at = data.get("updated_at") store[data["name"]] = record @@ -221,7 +222,7 @@ async def test_metadata_edits_preserve_uploaded_source_and_reject_a_changed_http await table.create(data={"name": _UPLOAD_NAME, "manifest_json": json.dumps({"source": _UPLOAD_SOURCE})}) request: Final = UpdatePluginRequest(source=_UPLOAD_SOURCE, description="Updated description", version="1.0.0") response: Final = await update_plugin(uploads=_uploads(), plugin_name=_UPLOAD_NAME, request=request, user_api_key_dict=_USER) - expected: Final = {"name": _UPLOAD_NAME, "source": _UPLOAD_SOURCE, "description": "Updated description", "version": "1.0.0"} + expected: Final = {"name": _UPLOAD_NAME, "source": _UPLOAD_SOURCE, "description": "Updated description", "version": "1.0.0", "_litellm_published_by": _USER.user_id} assert response.plugin.description == "Updated description" assert await _read_stored_manifest(_UPLOAD_NAME) == expected with pytest.raises(HTTPException) as error: @@ -354,7 +355,7 @@ async def _register_public_and_private_plugins() -> None: async def _listed_names(user: UserAPIKeyAuth) -> set[str]: - response = await list_plugins(user_api_key_dict=user) + response = await list_plugins(uploads=_uploads(), user_api_key_dict=user) return {plugin.name for plugin in response.plugins} @@ -474,7 +475,7 @@ async def test_update_plugin_propagates_installation_preference_to_get_and_list( ) plugin = await get_plugin(plugin_name=name, user_api_key_dict=_USER) - listed_plugin = (await list_plugins(user_api_key_dict=_USER)).plugins[0] + listed_plugin = (await list_plugins(uploads=_uploads(), user_api_key_dict=_USER)).plugins[0] assert response.plugin.installation_preference == "auto_install" assert plugin["installation_preference"] == "auto_install" @@ -514,7 +515,7 @@ async def test_stored_unknown_installation_preference_is_not_served(stored_value ) marketplace = json.loads((await get_marketplace(request=MagicMock())).body) - listed_plugin = (await list_plugins(user_api_key_dict=_USER)).plugins[0] + listed_plugin = (await list_plugins(uploads=_uploads(), user_api_key_dict=_USER)).plugins[0] plugin = await get_plugin(plugin_name=name, user_api_key_dict=_USER) assert marketplace["plugins"] == [{"name": name, "source": _ARCHIVE_SOURCE}] @@ -1288,7 +1289,7 @@ async def test_skill_upload_creates_metadata_and_bytes_atomically_and_rejects_du "url": f"http://test/claude-code/plugins/{_UPLOAD_NAME}/archive/{digest}.zip", "sha256": digest, } - assert json.loads(stored["manifest_json"]) == {**metadata, "source": source} + assert json.loads(stored["manifest_json"]) == {**metadata, "source": source, "_litellm_published_by": stored["created_by"]} assert stored == { "name": _UPLOAD_NAME, "description": "My notes", "version": "2.0.0", "manifest_json": stored["manifest_json"], "created_by": litellm.proxy.proxy_server.litellm_proxy_admin_name, diff --git a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py index 8127fc80bab..7912df3939b 100644 --- a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py @@ -196,25 +196,25 @@ 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, +@pytest.mark.parametrize("route", (f"{HUB}?limit=1", "/claude-code/plugins")) +@pytest.mark.parametrize("key", (MASTER_KEY, PLAIN_KEY)) +async def test_catalog_counts_are_batched_for_the_page( + hub: HubClient, key: str, route: str, ) -> 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)) + response: Final = await hub.client.get(route, 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 + items: Final = response.json()["plugins" if route == "/claude-code/plugins" else "skills"] + expected: Final = {record.id: 7, "other": 0} if route == "/claude-code/plugins" else {record.id: 7} + assert {item["id"]: item["download_count"] for item in items} == expected + assert hub.database.query_raw.await_count == 1 + assert set(hub.database.query_raw.await_args.args[1]) == set(expected) + 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")) @@ -806,3 +806,98 @@ async def test_configured_scan_persists_result_without_changing_manual_approval( assert (await hub.client.get(path, headers=_headers(MASTER_KEY))).json() == before approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY)) assert approved.status_code == 200 and approved.json()["submission"]["status"] == "approved" + + +async def test_catalog_filters_metadata_before_pagination_and_preserves_tag_facets(hub: HubClient) -> None: + hub.plugins.find_many.return_value = [ + prisma_models.LiteLLM_ClaudeCodePluginTable( + id=name, name=name, enabled=True, anonymous_download_count=0, created_by="author@example.test", + created_at=datetime(2026, 1, 2), updated_at=datetime(2026, 2, 3), + manifest_json=json.dumps({ + "source": {"source": "github", "repo": "example/skills"}, + "keywords": tags, + "_litellm_published_by": "publisher@example.test", + **({"_litellm_review": {"approved_by": "reviewer@example.test", "security_scan_passed": scanned}} if approved else {}), + }), + ) + for name, tags, approved, scanned in ( + ("a-other", ["support"], False, False), + ("b-review", ["engineering"], True, False), + ("c-review", ["engineering"], True, True), + ) + ] + response = await hub.client.get(HUB, headers=_headers(MASTER_KEY), params={ + "query": " REVIEWER ", "tag": "ENGINEERING", "limit": 1, "offset": 1, + }) + assert response.status_code == 200 + body = response.json() + assert body["count"] == 2 + assert body["tags"] == ["engineering", "support"] + assert [skill["name"] for skill in body["skills"]] == ["c-review"] + skill = body["skills"][0] + assert skill["created_by"] == "author@example.test" + assert skill["published_by"] == "publisher@example.test" + assert skill["approved_by"] == "reviewer@example.test" + assert skill["created_at"].startswith("2026-01-02") + assert skill["updated_at"].startswith("2026-02-03") + assert skill["security_scan_passed"] is True + scanned = await hub.client.get(HUB, headers=_headers(MASTER_KEY), params={"security_scan_passed": "true"}) + assert [skill["name"] for skill in scanned.json()["skills"]] == ["c-review"] + unscanned = await hub.client.get(HUB, headers=_headers(MASTER_KEY), params={"security_scan_passed": "false"}) + assert [skill["name"] for skill in unscanned.json()["skills"]] == ["a-other", "b-review"] + assert unscanned.json()["skills"][0]["approved_by"] is None + + +@pytest.mark.parametrize("has_issues", (False, True)) +async def test_admin_approval_does_not_override_scan_result(pending_hub: HubClient, has_issues: bool) -> None: + hub: Final = pending_hub + pending: Final = await _upload(hub, _zip(FILES), MEMBER_KEY, version=None) + receipt: Final = pending.json() + submission: Final = hub.database.litellm_skillsubmission.rows[0] + digest: Final = hashlib.sha256(submission.archive_content.decode()).hexdigest() + await hub.database.litellm_skillsubmission.update( + where={"id": submission.id}, data={"scan_json": json.dumps({ + "id": "scan", "requested_by": "admin", "started_at": "2026-01-01T00:00:00Z", + "expires_at": "2026-01-01T00:03:00Z", "status": "completed", "archive_sha256": digest, + "report": {"scanner_version": "test", "scan_mode": "static-only", "complete": True, + "risk_score": 0, "findings": [{"rule_id": "rule", "severity": "LOW", + "message": "Issue", "file": "SKILL.md"}] if has_issues else []}, + })}, + ) + approved: Final = await hub.client.put( + f"/skill-hub/submissions/{receipt['submission_id']}/approve", headers=_headers(MASTER_KEY), + ) + assert approved.status_code == 200 + skill: Final = approved.json()["skill"] + assert skill["security_scan_passed"] is not has_issues + assert skill["created_by"] == MEMBER_KEY + assert skill["approved_by"] == approved.json()["submission"]["reviewed_by"] + assert skill["published_by"] == skill["approved_by"] + _stored_upload(hub) + listed: Final = (await hub.client.get(HUB, headers=_headers(MASTER_KEY))).json()["skills"][0] + assert listed["security_scan_passed"] is not has_issues + assert listed["published_by"] == skill["published_by"] + + +@pytest.mark.parametrize("field,order,expected", [ + ("name", "asc", "a"), ("name", "desc", "c"), + ("download_count", "desc", "a"), ("download_count", "asc", "b"), + ("updated_at", "desc", "c"), ("updated_at", "asc", "b"), +]) +async def test_catalog_sorts_all_matches_before_pagination( + hub: HubClient, field: str, order: str, expected: str, +) -> None: + hub.plugins.find_many.return_value = [ + prisma_models.LiteLLM_ClaudeCodePluginTable( + id=name, name=name, enabled=True, anonymous_download_count=0, updated_at=datetime(2026, 1, day), + manifest_json=json.dumps({"source": {"source": "github", "repo": "example/skills"}}), + ) for name, day in (("a", 2), ("b", 1), ("c", 3)) + ] + hub.database.query_raw.return_value = [ + {"id": name, "download_count": count} for name, count in (("a", 8), ("b", 0), ("c", 3)) + ] + response = await hub.client.get(HUB, headers=_headers(PLAIN_KEY), params={ + "sort_by": field, "sort_order": order, "limit": 1, "offset": 0, + }) + assert response.status_code == 200, response.text + assert [skill["name"] for skill in response.json()["skills"]] == [expected] diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index dd3ffad4727..d1afcf80ec9 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -1,3 +1,5 @@ +from litellm.proxy.deps.repositories import get_plugin_upload_repository +from litellm.repositories.plugin_upload_repository import PluginUploadRepository import asyncio import json import re @@ -1804,7 +1806,6 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference: from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import register_plugin - from litellm.repositories.plugin_upload_repository import PluginUploadRepository from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest def create(data: Mapping[str, object]) -> LiteLLM_ClaudeCodePluginTable: @@ -1826,6 +1827,7 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference: prisma.db.litellm_claudecodepluginversion.create = AsyncMock() app: Final = FastAPI() app.include_router(router) + app.dependency_overrides[get_plugin_upload_repository] = lambda: PluginUploadRepository(prisma) with patch("litellm.proxy.proxy_server.prisma_client", prisma): asyncio.run( @@ -1843,8 +1845,10 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference: user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"), ) ) + prisma.db.query_raw.return_value = [{"id": "plugin-id", "download_count": 12}] response: Final = TestClient(app).get("/public/skill_hub") + assert response.json()["plugins"][0]["download_count"] == 12 assert response.status_code == 200 assert [(p["name"], p["installation_preference"]) for p in response.json()["plugins"]] == [ ("security-review", preference) @@ -1854,6 +1858,7 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference: _INTERNAL_PLUGIN: Final = SimpleNamespace( id="plugin-id", name="deploy-helper", + created_by=None, enabled=True, created_at=None, updated_at=None, @@ -1874,8 +1879,11 @@ def _skill_hub_response(find_many: AsyncMock, general_settings: Mapping[str, obj table.find_many = find_many prisma: Final = MagicMock() prisma.db.litellm_claudecodeplugintable = table + prisma.writer_db = prisma.db + prisma.db.query_raw = AsyncMock(return_value=[]) app: Final = FastAPI() app.include_router(router) + app.dependency_overrides[get_plugin_upload_repository] = lambda: PluginUploadRepository(prisma) with ( patch("litellm.proxy.proxy_server.prisma_client", prisma), @@ -1909,6 +1917,7 @@ def test_public_skill_hub_500_body_carries_no_exception_text() -> None: def test_public_skill_hub_without_a_database_answers_db_not_connected() -> None: app: Final = FastAPI() app.include_router(router) + app.dependency_overrides[get_plugin_upload_repository] = lambda: None with ( patch("litellm.proxy.proxy_server.prisma_client", None), diff --git a/tests/unit/repositories/test_plugin_publication.py b/tests/unit/repositories/test_plugin_publication.py index 0a784553d09..7a96b0fd5a7 100644 --- a/tests/unit/repositories/test_plugin_publication.py +++ b/tests/unit/repositories/test_plugin_publication.py @@ -16,6 +16,7 @@ from litellm.models.plugin_upload import ( PluginPublication, PluginPublicationBase, PluginPublicationError, + PluginReview, PublishedPlugin, ) from litellm.repositories.plugin_publication import ( @@ -144,6 +145,44 @@ async def test_first_legacy_update_preserves_its_original_snapshot( } assert replacement.kwargs["data"] == { "plugin_id": current.id, "version": result.version, "description": "replacement", - "manifest_json": json.dumps({"source": source, "version": result.version}), + "manifest_json": json.dumps({"source": source, "version": result.version, "_litellm_published_by": "admin"}), "published_at": result.updated_at, "published_by": "admin", "content": None, "archive_available": False, } + + +@pytest.mark.parametrize("same_source", (True, False)) +@pytest.mark.parametrize("review", [None, PluginReview("reviewer@example.test", False)]) +@pytest.mark.asyncio +async def test_publication_preserves_only_trusted_review_for_unchanged_source( + publication_tx: MagicMock, review: PluginReview | None, same_source: bool, +) -> None: + original = publication_tx.litellm_claudecodeplugintable.find_unique.return_value + stored_review = {"approved_by": "original-reviewer", "security_scan_passed": True} + stored_manifest = {**json.loads(original.manifest_json), "_litellm_review": stored_review} + current = original.model_copy(update={"manifest_json": json.dumps(stored_manifest)}) + publication_tx.litellm_claudecodeplugintable.find_unique.return_value = current + + async def update(where: Mapping[str, str], data: Mapping[str, object]) -> LiteLLM_ClaudeCodePluginTable: + return current.model_copy(update=dict(data)) + + publication_tx.litellm_claudecodeplugintable.update.side_effect = update + result = await publish_plugin( + cast(PluginPublicationTransaction, publication_tx), + PluginPublication("skill", None, "updated", { + "_litellm_review": {"approved_by": "forged", "security_scan_passed": True}, + "source": stored_manifest["source"] if same_source else {"source": "github", "repo": "example/skills"}, + }, "author", review=review), + PluginPublicationBase(current.id, current.updated_at), + ) + assert isinstance(result, PublishedPlugin) + manifest = json.loads(publication_tx.litellm_claudecodeplugintable.update.call_args.kwargs["data"]["manifest_json"]) + assert manifest["_litellm_published_by"] == (review.approved_by if review else "author") + expected = ( + {"approved_by": review.approved_by, "security_scan_passed": review.security_scan_passed} if review + else stored_review if same_source else None + ) + assert manifest.get("_litellm_review") == expected + assert (None if result.review is None else { + "approved_by": result.review.approved_by, "security_scan_passed": result.review.security_scan_passed, + }) == expected + assert json.loads(publication_tx.litellm_claudecodepluginversion.create.call_args.kwargs["data"]["manifest_json"]).get("_litellm_review") == expected diff --git a/tests/unit/repositories/test_skill_submission_repository.py b/tests/unit/repositories/test_skill_submission_repository.py new file mode 100644 index 00000000000..7187cdbe514 --- /dev/null +++ b/tests/unit/repositories/test_skill_submission_repository.py @@ -0,0 +1,31 @@ +import json + +import pytest + +from litellm.repositories.skill_submission_repository import scan_passed_for_archive + + +@pytest.mark.parametrize("status,digest,report,expected", [ + ("completed", "current", {"complete": True, "risk_score": 0, "findings": []}, True), + ("running", "current", {"complete": True, "risk_score": 0, "findings": []}, False), + ("failed", "current", {"complete": True, "risk_score": 0, "findings": []}, False), + ("completed", "previous", {"complete": True, "risk_score": 0, "findings": []}, False), + ("completed", "current", {"complete": False, "risk_score": 0, "findings": []}, False), + ("completed", "current", None, False), + ("completed", "current", {"complete": True, "risk_score": 90, "findings": []}, False), + ("completed", "current", {"complete": True, "risk_score": 0, "findings": [{"rule_id": "rule", "severity": "LOW", "message": "Issue", "file": "SKILL.md"}]}, False), + ("completed", "current", {"complete": True, "risk_score": 0}, False), +]) +def test_scan_pass_requires_clean_complete_report_for_published_archive( + status: str, digest: str, report: dict[str, object] | None, expected: bool, +) -> None: + assert scan_passed_for_archive(json.dumps({ + "id": "scan", "requested_by": "admin", "started_at": "2026-01-01T00:00:00Z", + "expires_at": "2026-01-01T00:03:00Z", "status": status, "archive_sha256": digest, + "report": None if report is None else {"scanner_version": "test", "scan_mode": "static-only", **report}, + }), "current") is expected + + +@pytest.mark.parametrize("record", [None, "", "{}", "null", "not-json"]) +def test_missing_or_invalid_scan_is_not_reported_as_passed(record: str | None) -> None: + assert scan_passed_for_archive(record, "current") is False diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx index 359fb12dd95..9a260bdfc65 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx @@ -1,7 +1,8 @@ +import userEvent from "@testing-library/user-event"; import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; -import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; +import { chooseSelectOption, renderWithProviders, testQueryClient } from "@/../tests/test-utils"; import { setGlobalLitellmHeaderName } from "@/components/networking"; import type { SkillHubSkill, SkillSubmission } from "@/components/claude_code_plugins/types"; import { toast } from "@/lib/toast"; @@ -61,6 +62,7 @@ const pending: SkillSubmission = { const fetchMock = vi.fn(); const queryDefaults = testQueryClient.getDefaultOptions(); let catalog: SkillHubSkill[]; +let catalogRequests: URL[]; let submissions: SkillSubmission[]; let pageRequests: { limit: number; offset: number }[]; let writes: { path: string; method: string; body: RequestInit["body"] }[]; @@ -161,6 +163,7 @@ beforeEach(() => { testQueryClient.clear(); vi.clearAllMocks(); catalog = [published]; + catalogRequests = []; submissions = [pending]; pageRequests = []; writes = []; @@ -180,14 +183,17 @@ beforeEach(() => { return respondToWrite(path, body); } if (path === "/skill-hub/skills") { + catalogRequests.push(url); const limit = Number(url.searchParams.get("limit")); const offset = Number(url.searchParams.get("offset")); const skills = catalog.slice(offset, offset + limit); - return Response.json({ + const result = { + tags: ["review", "engineering"], skills, count: skills.length, next_offset: offset + limit < catalog.length ? offset + limit : null, - }); + }; + return Response.json(result); } if (path === `/skill-hub/skills/${published.name}`) return Response.json(catalog.find((skill) => skill.name === published.name)); @@ -250,7 +256,7 @@ it.each(["current", "retained"])("refreshes published counts after a %s download catalog = [{ ...published, download_count: 1 }]; fireEvent.click(button); await waitFor(() => expect(button).toBeEnabled()); - fireEvent.click(screen.getByText("Skills")); + fireEvent.click(screen.getByText("Skills Hub")); expect(await screen.findByRole("cell", { name: "1" })).toBeInTheDocument(); }); @@ -266,7 +272,7 @@ it("paginates the neutral catalog and refreshes detail for a recreated skill", a renderWithProviders(); fireEvent.click(await screen.findByText(published.name)); expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument(); - fireEvent.click(screen.getByText("Skills")); + fireEvent.click(screen.getByText("Skills Hub")); catalog = [ { ...published, id: "filler", name: "catalog-0" }, ...catalog.slice(1), @@ -281,7 +287,7 @@ it("paginates the neutral catalog and refreshes detail for a recreated skill", a fireEvent.click(screen.getByText(published.name)); expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument(); expect(screen.getByText("Recreated instructions")).toBeInTheDocument(); - fireEvent.click(screen.getByText("Skills")); + fireEvent.click(screen.getByText("Skills Hub")); fireEvent.click(screen.getByRole("button", { name: "Previous" })); await waitFor(() => expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled()); }); @@ -394,7 +400,7 @@ it("allows a member submission, explains approval and refreshes their pending li await waitFor(() => expect(screen.getByRole("tab", { name: "Published skills" })).toHaveAttribute("aria-selected", "true"), ); - fireEvent.click(screen.getByRole("button", { name: "+ Add Skill" })); + fireEvent.click(screen.getByRole("button", { name: "+ Register your skill" })); fireEvent.click(screen.getByRole("radio", { name: "Import URL" })); fireEvent.change(screen.getByRole("textbox", { name: "Source URL" }), { target: { value: "https://github.com/example/new-skill" }, @@ -593,10 +599,10 @@ it("refreshes detail after a review conflict and removes actions when another re it("keeps an effective Admin viewer read-only while allowing full submission inspection", async () => { renderWithProviders(); - expect(screen.getByRole("button", { name: "+ Add Skill" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "+ Register your skill" })).toBeDisabled(); fireEvent.click(await screen.findByText(published.name)); expect(screen.queryByRole("button", { name: "Edit Skill" })).not.toBeInTheDocument(); - fireEvent.click(screen.getByText("Skills")); + fireEvent.click(screen.getByText("Skills Hub")); fireEvent.click(screen.getByRole("tab", { name: "Submissions" })); fireEvent.click(await screen.findByRole("button", { name: "View details" })); expect(await screen.findByText("Currently published")).toBeInTheDocument(); @@ -643,7 +649,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(); + expect(screen.getByRole("columnheader", { name: "Downloads" })).toBeInTheDocument(); }); it("does not reuse admin review metadata when the same account loses its role and detail refresh fails", async () => { @@ -762,3 +768,27 @@ it.each([200, 409])("refreshes the running scan after POST %s and ignores an old fireEvent.click(screen.getByRole("button", { name: "Approve and publish" })); expect(await screen.findByText("Approved")).toBeInTheDocument(); }); + +it("applies tag and scan filters across the catalog and resets pagination when searching", async () => { + const user = userEvent.setup(); + catalog = [published, { ...published, id: "second", name: "second-skill" }]; + renderWithProviders(, { + searchParams: { skills_page: "2", skills_page_size: "1" }, + }); + await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("offset")).toBe("1")); + await screen.findByRole("cell", { name: "Approved instructions" }); + await user.click(screen.getByRole("button", { name: "Downloads" })); + await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("sort_by")).toBe("download_count")); + expect(catalogRequests.at(-1)?.searchParams.get("offset")).toBe("0"); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by tag" }), "engineering"); + await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("tag")).toBe("engineering")); + expect(catalogRequests.at(-1)?.searchParams.get("offset")).toBe("0"); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "Security scan" }), "Security scan: Yes"); + await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("security_scan_passed")).toBe("true")); + fireEvent.change(screen.getByRole("textbox", { name: /Search skills/ }), { + target: { value: "reviewer@example.test" }, + }); + await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("query")).toBe("reviewer@example.test")); + expect(catalogRequests.at(-1)?.searchParams.get("tag")).toBe("engineering"); + expect(catalogRequests.at(-1)?.searchParams.get("security_scan_passed")).toBe("true"); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx index a0e785268ea..dbdf5ca86d7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -87,7 +87,16 @@ describe("ClaudeCodePluginsPanel loading state", () => { resolveFetch({ skills: [], count: 0, next_offset: null }); expect(await screen.findByText("table-loaded")).toBeInTheDocument(); - expect(mockListSkillHubSkills).toHaveBeenCalledWith("sk-test", { limit: 50, offset: 0 }); + const expectedQuery = { + limit: 50, + offset: 0, + query: "", + tag: "", + security_scan_passed: undefined, + sort_by: "name", + sort_order: "asc", + }; + expect(mockListSkillHubSkills).toHaveBeenCalledWith("sk-test", expectedQuery); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx index 6efa511ce49..6497c0922d2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -1,3 +1,6 @@ +import type { SkillListQuery } from "@/components/claude_code_plugins/types"; +import { SkillFilters } from "@/components/claude_code_plugins/SkillFilters"; +import { useSkillFilters, skillSortQuery } from "@/components/claude_code_plugins/skillCatalog"; import React, { useState } from "react"; import { useQuery, useQueryClient, type QueryKey } from "@tanstack/react-query"; import { Trans, useTranslation } from "react-i18next"; @@ -21,7 +24,6 @@ import { toast } from "@/lib/toast"; import { isPendingSkill, type SkillHubSkill, type SkillMutationResponse } from "@/components/claude_code_plugins/types"; import { useUrlTab } from "@/hooks/useUrlTab"; import SkillSubmissions from "./SkillSubmissions"; -import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState"; interface ClaudeCodePluginsPanelProps { accessToken: string | null; @@ -29,14 +31,7 @@ interface ClaudeCodePluginsPanelProps { isViewOnly?: boolean; } -const CATALOG_STATE: UrlTableStateOptions = { - keyPrefix: "skills_", - defaultPageSize: 50, - maxPageSize: 100, - sortFields: ["name"], - defaultSort: { id: "name", desc: false }, - filterColumns: [], -}; +const scanResultFilter = (scan: string): boolean | undefined => (scan === "all" ? undefined : scan === "yes"); const SKILLS_TABS = ["skills", "submissions"] as const; @@ -117,11 +112,20 @@ const SkillsPanelContent: React.FC = ({ const { readOnly, canWrite, isAdmin, adminView } = skillPermissions(accessToken, userRole, isViewOnly); const queryScope = ["skill-hub", accessToken, userRole, readOnly] as const; const showForm = Boolean(isAddModalVisible || editingSkill) && canWrite; - const { pagination, onPaginationChange } = useUrlTableState(CATALOG_STATE); + const filters = useSkillFilters(); + const { pagination, onPaginationChange, sorting, onSortingChange } = filters; const { pageIndex, pageSize } = pagination; + const catalogQuery: SkillListQuery & { limit: number; offset: number } = { + limit: pageSize, + offset: pageIndex * pageSize, + query: filters.search, + tag: filters.tag, + security_scan_passed: scanResultFilter(filters.scan), + ...skillSortQuery(sorting), + }; const plugins = useQuery({ - queryKey: [...queryScope, "published", pageIndex, pageSize], - queryFn: () => listSkillHubSkills(accessToken!, { limit: pageSize, offset: pageIndex * pageSize }), + queryKey: [...queryScope, "published", catalogQuery], + queryFn: () => listSkillHubSkills(accessToken!, catalogQuery), enabled: Boolean(accessToken), }); const pluginsList = plugins.data?.skills ?? []; @@ -193,6 +197,9 @@ const SkillsPanelContent: React.FC = ({ +
+ +
{plugins.isError && (
{t("skills.panel.loadFailed")} @@ -204,6 +211,9 @@ const SkillsPanelContent: React.FC = ({ { [12345, "12,345"], ] as const)("renders headers and download count %s", (download_count, display) => { render(); - for (const header of ["Skill Name", "Version", "Description", "Domain", "Public", "Updated at", "Downloads"]) { + for (const header of [ + "Skill Name", + "Tags", + "Created by", + "Approved by", + "Created", + "Last updated", + "Security scan", + "Downloads", + ]) { expect(screen.getByText(header)).toBeInTheDocument(); } - expect(within(screen.getByRole("row", { name: /newer-skill/ })).getByText(display)).toBeInTheDocument(); + expect(within(screen.getByRole("row", { name: /newer-skill/ })).getAllByText(display)[0]).toBeInTheDocument(); }); it("should display the empty state when data is empty", () => { render(); - expect(screen.getByText("No skills found")).toBeInTheDocument(); - }); - - it("should sort by name ascending by default", () => { - render(); - const rows = screen.getAllByRole("row").slice(1); - expect(within(rows[0]).getByText("newer-skill")).toBeInTheDocument(); - expect(within(rows[1]).getByText("older-skill")).toBeInTheDocument(); + expect(screen.getByText("No skills registered yet")).toBeInTheDocument(); }); it("should call onPluginClick with the plugin ID when the skill name is clicked", async () => { @@ -89,7 +93,8 @@ describe("PluginTable", () => { it("should show whether the skill is public", () => { render(); expect(screen.getByText("Yes")).toBeInTheDocument(); - expect(screen.getByText("No")).toBeInTheDocument(); + const privateRow = screen.getByRole("row", { name: /older-skill/ }); + expect(within(privateRow).getAllByText("No")).toHaveLength(2); }); it("should delete a skill through the actions menu when admin", async () => { @@ -111,7 +116,7 @@ describe("PluginTable", () => { it("should hide the delete action for non-admins but keep copy available", async () => { const user = userEvent.setup(); render(); - expect(screen.queryByRole("columnheader", { name: "Downloads" })).not.toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Downloads" })).toBeInTheDocument(); await user.click(screen.getByTestId("plugin-actions-newer-skill")); expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); expect(screen.queryByTestId("plugin-action-delete")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx index db61f80c690..943b34c7023 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx @@ -1,8 +1,8 @@ "use client"; -import { SortingState } from "@tanstack/react-table"; +import { type OnChangeFn, type SortingState } from "@tanstack/react-table"; import { Inbox } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { DataTable } from "@/components/shared/DataTable"; @@ -12,30 +12,43 @@ import { getPluginTableColumns } from "./PluginTableColumns"; interface PluginTableProps { pluginsList: SkillHubSkill[]; + sorting: SortingState; + onSortingChange: OnChangeFn; isLoading: boolean; + filtered?: boolean; onDeleteClick: (pluginName: string, displayName: string) => void; isAdmin: boolean; onPluginClick: (pluginId: string) => void; } -const DEFAULT_SORTING: SortingState = [{ id: "name", desc: false }]; - -function EmptyState() { +function EmptyState({ filtered }: { filtered: boolean }) { const { t } = useTranslation(); return (
-
{t("skills.table.empty.title")}
-
{t("skills.table.empty.description")}
+
+ {t(filtered ? "aiHub.skillHubDashboard.emptyState.noMatchTitle" : "skills.table.empty.title")} +
+
+ {t(filtered ? "aiHub.skillHubDashboard.emptyState.noMatchBody" : "skills.table.empty.description")} +
); } -const PluginTable: React.FC = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => { +const PluginTable: React.FC = ({ + pluginsList, + sorting, + onSortingChange, + isLoading, + filtered = false, + onDeleteClick, + isAdmin, + onPluginClick, +}) => { const { t } = useTranslation(); - const [sorting, setSorting] = useState(DEFAULT_SORTING); const columns = useMemo( () => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }), @@ -48,12 +61,12 @@ const PluginTable: React.FC = ({ pluginsList, isLoading, onDel paginationMode="none" columns={columns} getRowId={(plugin, index) => plugin.id || String(index)} - sortingMode="client" + sortingMode="server" sorting={sorting} - onSortingChange={setSorting} + onSortingChange={onSortingChange} isLoading={isLoading} loadingMessage={t("skills.table.loading")} - noDataMessage={} + noDataMessage={} size="compact" /> ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx index f2055b77969..c838ea5deda 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx @@ -1,11 +1,13 @@ "use client"; +import { getSkillMetadataColumns } from "@/components/claude_code_plugins/SkillMetadataColumns"; + import { ColumnDef } from "@tanstack/react-table"; import { Copy, MoreHorizontal, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { DataTableSortHeader } from "@/components/shared/DataTable"; -import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; +import { IdentityCell, StatusBadge } from "@/components/shared/table_cells"; import i18n from "@/i18n/i18n"; import { SkillHubSkill } from "@/components/claude_code_plugins/types"; import { buttonVariants } from "@/components/ui/button"; @@ -143,31 +145,7 @@ export const getPluginTableColumns = ({ /> ), }, - { - id: "updated_at", - accessorKey: "updated_at", - sortingFn: "datetime", - meta: { title: i18n.t("skills.table.columns.updatedAt") }, - header: ({ column }) => , - size: 160, - enableSorting: true, - cell: ({ row }) => , - }, - ...(isAdmin - ? ([ - { - id: "download_count", - accessorKey: "download_count", - meta: { title: i18n.t("skills.table.columns.downloads") }, - header: i18n.t("skills.table.columns.downloads"), - size: 120, - enableSorting: false, - cell: ({ row }) => ( - {row.original.download_count?.toLocaleString(i18n.language) ?? "—"} - ), - }, - ] satisfies ColumnDef[]) - : []), + ...getSkillMetadataColumns(), { id: "actions", meta: { className: "text-right", headerClassName: "text-right" }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.integration.test.tsx index 778bd8e309a..9547db39ca1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.integration.test.tsx @@ -15,7 +15,7 @@ const file = new File(["skill archive"], "personal-skill.zip", { type: "applicat const chooseFile = (selected = file) => fireEvent.change(screen.getByLabelText("Skill ZIP file"), { target: { files: [selected] } }); const change = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } }); -const submit = () => fireEvent.click(screen.getByRole("button", { name: "Add Skill" })); +const submit = () => fireEvent.click(screen.getByRole("button", { name: "Register skill" })); const openImport = () => { renderWithProviders(); fireEvent.click(screen.getByRole("radio", { name: "Import URL" })); diff --git a/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.integration.test.tsx b/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.integration.test.tsx new file mode 100644 index 00000000000..8f97f63cd2f --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.integration.test.tsx @@ -0,0 +1,31 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { renderWithProviders } from "@/../tests/test-utils"; +import type { Plugin } from "@/components/claude_code_plugins/types"; +import SkillHubDashboard from "./SkillHubDashboard"; + +const skills: Plugin[] = [ + { id: "alpha", name: "alpha", source: {}, category: "Writing", domain: "Support", enabled: false, download_count: 1 }, + { + id: "beta", + name: "beta", + source: {}, + category: "Analysis", + domain: "Engineering", + enabled: true, + download_count: 7, + }, +]; + +describe("Skills Hub sorting", () => { + it.each(["Category", "Domain", "Status", "Downloads"])("sorts by %s through the table header", async (header) => { + const user = userEvent.setup(); + renderWithProviders(); + expect(screen.getAllByRole("row")[1]).toHaveTextContent("alpha"); + await user.click(screen.getByRole("button", { name: header, exact: true })); + await waitFor(() => expect(screen.getAllByRole("row")[1]).toHaveTextContent("beta")); + await user.click(screen.getByRole("button", { name: header, exact: true })); + await waitFor(() => expect(screen.getAllByRole("row")[1]).toHaveTextContent("alpha")); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx b/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx index 00f2da9171d..3099da3476e 100644 --- a/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx @@ -1,15 +1,21 @@ import React, { useMemo, useState } from "react"; -import { SortingState } from "@tanstack/react-table"; -import { Inbox, Search, X } from "lucide-react"; +import { Inbox } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Plugin } from "@/components/claude_code_plugins/types"; import { DataTable } from "@/components/shared/DataTable"; import { getSkillHubTableColumns } from "@/components/AIHub/SkillHubTableColumns"; import SkillDetail, { legacySkillDetail } from "@/components/claude_code_plugins/skill_detail"; -import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import { SkillFilters } from "@/components/claude_code_plugins/SkillFilters"; +import { + useSkillFilters, + matchesSkillFilters, + skillTags, + SKILL_SORT_FIELDS, +} from "@/components/claude_code_plugins/skillCatalog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; const ALL_DOMAINS = "__all_domains__"; +const DASHBOARD_SORT_FIELDS = [...SKILL_SORT_FIELDS, "category", "domain", "enabled"]; interface SkillHubDashboardProps { skills: Plugin[]; @@ -50,10 +56,9 @@ const SkillHubDashboard: React.FC = ({ onPublishSuccess, }) => { const { t } = useTranslation(); - const [search, setSearch] = useState(""); + const filters = useSkillFilters(DASHBOARD_SORT_FIELDS); const [domainFilter, setDomainFilter] = useState(undefined); const [selectedSkill, setSelectedSkill] = useState(null); - const [sorting, setSorting] = useState([{ id: "name", desc: false }]); // Derived stats const totalSkills = skills.length; @@ -69,19 +74,10 @@ const SkillHubDashboard: React.FC = ({ if (domainFilter) { result = result.filter((s) => (s.domain || "General") === domainFilter); } - if (search.trim()) { - const q = search.toLowerCase(); - result = result.filter( - (s) => - s.name.toLowerCase().includes(q) || - s.description?.toLowerCase().includes(q) || - s.domain?.toLowerCase().includes(q) || - s.namespace?.toLowerCase().includes(q) || - s.keywords?.some((k) => k.toLowerCase().includes(q)), - ); - } - return result; - }, [skills, search, domainFilter]); + return result.filter((skill) => + matchesSkillFilters(skill, { search: filters.search, tag: filters.tag, scan: filters.scan }), + ); + }, [skills, filters.search, filters.tag, filters.scan, domainFilter]); const columns = useMemo(() => getSkillHubTableColumns({ onSkillClick: setSelectedSkill }), []); @@ -93,7 +89,7 @@ const SkillHubDashboard: React.FC = ({ [domains, t], ); - const hasActiveFilter = search.trim().length > 0 || domainFilter != null; + const hasActiveFilter = filters.active || domainFilter != null; if (selectedSkill) { return ( @@ -131,7 +127,7 @@ const SkillHubDashboard: React.FC = ({

{publicPage ? t("aiHub.skillHubDashboard.allPublicSkills") : t("aiHub.skillHubDashboard.allSkills")}

-
+
- - - - - setSearch(e.target.value)} - /> - {search !== "" && ( - - setSearch("")} - > - - - - )} - +
= ({ columns={columns} getRowId={(skill, index) => skill.id || String(index)} sortingMode="client" - sorting={sorting} - onSortingChange={setSorting} + sorting={filters.sorting} + onSortingChange={filters.onSortingChange} isLoading={isLoading} loadingMessage={t("aiHub.skillHubDashboard.loading")} noDataMessage={} diff --git a/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.tsx index 766c4243c45..76ba9f6e592 100644 --- a/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.tsx @@ -1,5 +1,7 @@ "use client"; +import { getSkillMetadataColumns } from "@/components/claude_code_plugins/SkillMetadataColumns"; + import { ColumnDef } from "@tanstack/react-table"; import { Copy, ExternalLink, Info, MoreHorizontal } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -159,6 +161,7 @@ export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDe /> ), }, + ...getSkillMetadataColumns(), { id: "actions", meta: { className: "text-right", headerClassName: "text-right" }, diff --git a/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts b/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts index fceee39e943..d1f14137577 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts +++ b/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts @@ -59,7 +59,7 @@ describe("visibleMenuGroups", () => { }); expect(flattenNavItems(visible).find(({ route }) => route === "skills")).toMatchObject({ - label: "Skills", + label: "Skills Hub", route: "skills", }); }, diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillFilters.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillFilters.tsx new file mode 100644 index 00000000000..49088ec1364 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillFilters.tsx @@ -0,0 +1,89 @@ +import { Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import type { ScanFilter } from "./skillCatalog"; + +interface SkillFiltersProps { + search: string; + tag: string; + scan: ScanFilter; + tags: string[]; + active: boolean; + setSearch: (value: string) => void; + setTag: (value: string) => void; + setScan: (value: ScanFilter) => void; + clear: () => void; +} + +export function SkillFilters({ + search, + tag, + scan, + tags, + active, + setSearch, + setTag, + setScan, + clear, +}: SkillFiltersProps) { + const { t } = useTranslation(); + const tagItems = [ + { value: "", label: t("skills.filters.allTags") }, + ...[...new Set([...tags, ...(tag ? [tag] : [])])].map((value) => ({ value, label: value })), + ]; + const scanItems = [ + { value: "all", label: t("skills.filters.allScans") }, + { value: "yes", label: t("skills.filters.scanned") }, + { value: "no", label: t("skills.filters.notScanned") }, + ]; + return ( +
+ + + + + setSearch(event.target.value)} + /> + + + + {active && ( + + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillMetadataColumns.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillMetadataColumns.tsx new file mode 100644 index 00000000000..649a1696a9e --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillMetadataColumns.tsx @@ -0,0 +1,95 @@ +import { DataTableSortHeader } from "@/components/shared/DataTable/DataTableSortHeader"; +import type { ColumnDef } from "@tanstack/react-table"; +import { DateCell, StatusBadge } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import i18n from "@/i18n/i18n"; +import type { SkillCatalogMetadata } from "./skillCatalog"; + +const IDENTITY_LABELS = { + created_by: "skills.table.columns.createdBy", + approved_by: "skills.table.columns.approvedBy", +} as const; + +export function getSkillMetadataColumns(): ColumnDef[] { + return [ + { + id: "download_count", + accessorKey: "download_count", + header: ({ column }) => , + size: 120, + sortUndefined: "last", + accessorFn: (skill) => skill.download_count ?? undefined, + cell: ({ row }) => ( + {row.original.download_count?.toLocaleString(i18n.language) ?? "—"} + ), + }, + { + id: "keywords", + header: i18n.t("skills.table.columns.tags"), + size: 180, + enableSorting: false, + cell: ({ row }) => ( +
+ {row.original.keywords?.length + ? row.original.keywords.map((tag) => ( + + {tag} + + )) + : "—"} +
+ ), + }, + ...(["created_by", "approved_by"] as const).map( + (field): ColumnDef => ({ + id: field, + accessorKey: field, + size: 190, + enableSorting: true, + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original[field] || i18n.t("skills.table.notRecorded")} + + ), + }), + ), + ...(["created_at", "updated_at"] as const).map( + (field): ColumnDef => ({ + id: field, + accessorKey: field, + size: 140, + enableSorting: true, + accessorFn: (skill) => (skill[field] ? new Date(skill[field]!).getTime() : undefined), + sortUndefined: "last", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }), + ), + { + id: "security_scan_passed", + accessorKey: "security_scan_passed", + size: 130, + enableSorting: true, + header: ({ column }) => ( + {i18n.t("skills.table.columns.securityScan")}} + /> + ), + cell: ({ row }) => ( + + + + ), + }, + ]; +} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skillCatalog.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/skillCatalog.ts new file mode 100644 index 00000000000..ec057a00984 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skillCatalog.ts @@ -0,0 +1,99 @@ +import type { SkillListQuery } from "./types"; +import type { SortingState } from "@tanstack/react-table"; +import { parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs"; +import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState"; +import { matchesSearchTerm } from "@/utils/searchUtils"; + +const SCAN_FILTERS = ["all", "yes", "no"] as const; +export type ScanFilter = (typeof SCAN_FILTERS)[number]; + +export interface SkillCatalogMetadata { + created_by?: string | null; + approved_by?: string | null; + published_by?: string | null; + created_at?: string | null; + updated_at?: string | null; + security_scan_passed?: boolean; + download_count?: number | null; + keywords?: string[] | null; +} + +export const SKILL_SORT_FIELDS = [ + "name", + "download_count", + "created_at", + "updated_at", + "created_by", + "approved_by", + "security_scan_passed", +] as const satisfies readonly NonNullable[]; + +export function skillSortQuery(sorting: SortingState): Pick { + return { + sort_by: SKILL_SORT_FIELDS.find((field) => field === sorting[0]?.id) ?? "name", + sort_order: sorting[0]?.desc ? "desc" : "asc", + }; +} + +const CATALOG_STATE: UrlTableStateOptions = { + keyPrefix: "skills_", + defaultPageSize: 50, + maxPageSize: 100, + sortFields: SKILL_SORT_FIELDS, + defaultSort: { id: "name", desc: false }, + filterColumns: [], +}; +const FILTER_PARSERS = { + tag: parseAsString.withDefault(""), + scan: parseAsStringLiteral(SCAN_FILTERS).withDefault("all"), +}; +const FILTER_OPTIONS = { urlKeys: { tag: "skills_filter_tag", scan: "skills_filter_scan" } }; + +export function useSkillFilters(sortFields: readonly string[] = SKILL_SORT_FIELDS) { + const table = useUrlTableState({ ...CATALOG_STATE, sortFields }); + const [filters, setFilters] = useQueryStates(FILTER_PARSERS, FILTER_OPTIONS); + const update = (next: { tag?: string; scan?: ScanFilter } | null) => { + void setFilters(next); + table.onPaginationChange({ ...table.pagination, pageIndex: 0 }); + }; + return { + ...table, + ...filters, + setTag: (tag: string) => update({ tag }), + setScan: (scan: ScanFilter) => update({ scan }), + clear: () => { + update(null); + table.setSearch(""); + }, + active: Boolean(table.search.trim() || filters.tag || filters.scan !== "all"), + }; +} + +export function skillTags(skills: SkillCatalogMetadata[]): string[] { + return [...new Set(skills.flatMap((skill) => skill.keywords ?? []))].sort((a, b) => a.localeCompare(b)); +} + +export function matchesSkillFilters( + skill: SkillCatalogMetadata & { + name: string; + description?: string | null; + domain?: string | null; + namespace?: string | null; + }, + filters: { search: string; tag: string; scan: ScanFilter }, +): boolean { + const matchesQuery = matchesSearchTerm(filters.search, [ + skill.name, + skill.description, + skill.domain, + skill.namespace, + skill.created_by, + skill.approved_by, + skill.published_by, + ...(skill.keywords ?? []), + ]); + const matchesTag = + !filters.tag || (skill.keywords ?? []).some((tag) => tag.toLowerCase() === filters.tag.toLowerCase()); + const matchesScan = filters.scan === "all" || Boolean(skill.security_scan_passed) === (filters.scan === "yes"); + return matchesQuery && matchesTag && matchesScan; +} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts index c0560c09951..7574500e693 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts @@ -3,7 +3,7 @@ * API request/response shapes are synced from the generated OpenAPI types in @/lib/http/schema. */ -import type { components } from "@/lib/http/schema"; +import type { components, operations } from "@/lib/http/schema"; export interface PluginSource { source: "github" | "url" | "git-subdir" | "archive"; @@ -25,6 +25,8 @@ export type SkillRegisterRequest = Omit; + export type SkillHubSkill = components["schemas"]["SkillHubSkill"]; export type SkillMetadata = components["schemas"]["SkillHubMetadata"]; export type SkillImport = Omit & diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index ef7c7511276..1ffe8f6eaa9 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -625,7 +625,7 @@ describe("Sidebar (leftnav)", () => { renderWithProviders(); - expect(screen.getByRole("link", { name: "Skills" })).toHaveAttribute("href", "/ui/skills"); + expect(screen.getByRole("link", { name: "Skills Hub" })).toHaveAttribute("href", "/ui/skills"); }); it.each([ diff --git a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx index 3fedeead150..e966836fa3a 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx @@ -82,6 +82,7 @@ const MODELS = [ const SKILLS: Plugin[] = [ { + download_count: 5, id: "skill-research", name: "research-assistant", description: "Answers research questions", @@ -92,11 +93,15 @@ const SKILLS: Plugin[] = [ category: "Analysis", keywords: ["evidence"], author: { name: "Research maintainers" }, + created_by: "writer@example.test", + approved_by: "reviewer@example.test", + security_scan_passed: true, enabled: true, created_at: null, updated_at: null, }, { + download_count: 9, id: "skill-review", name: "review-changes", description: "Checks pull requests", @@ -111,6 +116,7 @@ const SKILLS: Plugin[] = [ updated_at: null, }, { + download_count: 2, id: "skill-release", name: "release-notes", description: "Summarizes shipped changes", @@ -522,18 +528,18 @@ describe("PublicAiHub", () => { expect(screen.getByRole("link", { name: /release-notes/ })).toBeInTheDocument(); expect(screen.getByText("2 skills")).toBeInTheDocument(); - fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { target: { value: "changelog" } }); + fireEvent.change(screen.getByRole("textbox", { name: /Search skills/ }), { target: { value: "changelog" } }); expect(screen.getByRole("link", { name: /release-notes/ })).toBeInTheDocument(); expect(screen.queryByRole("link", { name: /review-changes/ })).not.toBeInTheDocument(); - fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { target: { value: "evidence" } }); + fireEvent.change(screen.getByRole("textbox", { name: /Search skills/ }), { target: { value: "evidence" } }); expect(screen.getByText('No skills match "evidence"')).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /^All domains/ })); expect(screen.getByRole("link", { name: /research-assistant/ })).toBeInTheDocument(); expect(screen.queryByRole("link", { name: /release-notes/ })).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Clear all" })); + await user.click(screen.getByRole("button", { name: "Clear filters" })); expect(screen.getByText("3 skills")).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Search skills" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: /Search skills/ })).toHaveValue(""); expect(screen.getByRole("checkbox", { name: "Engineering" })).not.toBeChecked(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); @@ -541,7 +547,7 @@ describe("PublicAiHub", () => { it("finds skills by name, description, namespace, domain, and keywords", async () => { renderWithProviders(, { searchParams: { tab: "skills" } }); await screen.findByRole("link", { name: /research-assistant/ }); - const search = screen.getByRole("textbox", { name: "Search skills" }); + const search = screen.getByRole("textbox", { name: /Search skills/ }); for (const value of ["research-assistant", "Answers research questions", "knowledge", "Research", "evidence"]) { fireEvent.change(search, { target: { value } }); @@ -586,3 +592,34 @@ describe("PublicAiHub", () => { expect(screen.queryByRole("heading", { level: 2, name: "release-notes" })).not.toBeInTheDocument(); }); }); + +it("filters skills by tag and scan status, searches people, and scopes filters to Skills", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: { tab: "skills" }, onUrlUpdate }); + await screen.findByRole("link", { name: /research-assistant/ }); + await user.click(screen.getByRole("button", { name: "Downloads" })); + expect(within(screen.getAllByRole("row")[1]).getByRole("link", { name: /review-changes/ })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Downloads" })); + expect(within(screen.getAllByRole("row")[1]).getByRole("link", { name: /release-notes/ })).toBeInTheDocument(); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by tag" }), "evidence"); + expect(screen.queryByRole("link", { name: /review-changes/ })).not.toBeInTheDocument(); + expect(screen.getByText("writer@example.test")).toBeInTheDocument(); + expect(screen.getByText("reviewer@example.test")).toBeInTheDocument(); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "Security scan" }), "Security scan: No"); + expect(screen.queryByRole("link", { name: /research-assistant/ })).not.toBeInTheDocument(); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "Security scan" }), "Security scan: Yes"); + fireEvent.change(screen.getByRole("textbox", { name: /Search skills/ }), { + target: { value: "reviewer@example.test" }, + }); + expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument(); + await waitFor(() => + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining("skills_filter_tag=evidence"), + }), + ), + ); + await user.click(screen.getByRole("tab", { name: "Model Hub" })); + expect(screen.queryByRole("combobox", { name: "Filter by tag" })).not.toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx index 4827a6fcc35..bcfade1849f 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx @@ -5,17 +5,18 @@ import { Puzzle } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { getSourceDisplayText } from "@/components/claude_code_plugins/helpers"; import type { Plugin } from "@/components/claude_code_plugins/types"; +import { DataTableSortHeader } from "@/components/shared/DataTable/DataTableSortHeader"; import { DataTable } from "@/components/shared/DataTable"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import { matchesSearchTerm } from "@/utils/searchUtils"; +import { SkillFilters } from "@/components/claude_code_plugins/SkillFilters"; +import { getSkillMetadataColumns } from "@/components/claude_code_plugins/SkillMetadataColumns"; +import { useSkillFilters, matchesSkillFilters, skillTags } from "@/components/claude_code_plugins/skillCatalog"; import { CatalogDetails } from "./CatalogDetails"; import { CatalogFilters } from "./CatalogFilters"; import { LoadFailed, NoResults, NotListed } from "./HubEmpty"; -import { SearchInput } from "./HubParts"; import { SkillInspector } from "./SkillInspector"; import { useOpenHref } from "./useHubUrlState"; @@ -63,7 +64,8 @@ function SkillLink({ export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }: SkillsPaneProps) { const { t } = useTranslation(); - const [query, setQuery] = React.useState(""); + const filters = useSkillFilters(); + const query = filters.search; const [domains, setDomains] = React.useState([]); const skills = React.useMemo(() => [...(rows ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [rows]); const options = React.useMemo( @@ -77,65 +79,34 @@ export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect } const shown = React.useMemo( () => skills.filter((skill) => { - const matchesQuery = matchesSearchTerm(query, [ - skill.name, - skill.description, - skill.domain, - skill.namespace, - ...(skill.keywords ?? []), - ]); + const matchesQuery = matchesSkillFilters(skill, { + search: filters.search, + tag: filters.tag, + scan: filters.scan, + }); const matchesDomain = domains.length === 0 || domains.includes(skill.domain ?? ""); return matchesQuery && matchesDomain; }), - [skills, query, domains], + [skills, filters.search, filters.tag, filters.scan, domains], ); const opened = skills.find((skill) => skill.name === selected); const missingName = opened === undefined && !loading ? selected : null; const showRefreshError = error && !loading && shown.length > 0; - const filtered = query.trim() !== "" || domains.length > 0; + const filtered = filters.active || domains.length > 0; const clear = () => { - setQuery(""); + filters.clear(); setDomains([]); }; const columns: ColumnDef[] = [ { accessorKey: "name", - header: t("publicModelHub.catalog.skill", { defaultValue: "Skill" }), + header: ({ column }) => ( + + ), meta: { skeleton: "twoLine", className: "min-w-48 whitespace-normal" }, cell: ({ row }) => , }, - { - id: "source", - header: t("publicModelHub.catalog.source", { defaultValue: "Source" }), - meta: { - className: "hidden max-w-64 text-xs whitespace-normal wrap-anywhere text-muted-foreground lg:table-cell", - }, - cell: ({ row }) => getSourceDisplayText(row.original.source), - }, - { - accessorKey: "namespace", - header: t("publicModelHub.catalog.namespace", { defaultValue: "Namespace" }), - meta: { - className: - "hidden max-w-40 font-mono text-xs whitespace-normal wrap-anywhere text-muted-foreground xl:table-cell", - }, - cell: ({ row }) => row.original.namespace || "-", - }, - { - accessorKey: "category", - header: t("publicModelHub.catalog.category", { defaultValue: "Category" }), - meta: { className: "max-w-40 text-xs whitespace-normal wrap-anywhere text-muted-foreground" }, - cell: ({ row }) => row.original.category || "-", - }, - { - accessorKey: "version", - header: t("publicModelHub.catalog.version", { defaultValue: "Version" }), - meta: { - className: - "hidden max-w-32 font-mono text-xs whitespace-normal wrap-anywhere text-muted-foreground sm:table-cell", - }, - cell: ({ row }) => row.original.version || "-", - }, + ...getSkillMetadataColumns(), ]; const countOptions = { count: shown.length, @@ -183,16 +154,7 @@ export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }
- - {filtered && ( - - )} +
{loading @@ -203,6 +165,9 @@ export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }
skill.name} isLoading={loading} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx index 121d352565a..df94b2ea4f8 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx @@ -1,5 +1,6 @@ "use client"; +import { DateCell } from "@/components/shared/table_cells"; import { ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -32,8 +33,6 @@ function sourceValue(source: Plugin["source"]) { export function SkillInspector({ skill }: { skill: Plugin }) { const { t } = useTranslation(); const keywords = skill.keywords ?? []; - const addedAt = skill.created_at ? new Date(skill.created_at) : null; - const added = addedAt && !Number.isNaN(addedAt.getTime()) ? addedAt.toLocaleDateString() : null; return (
}, + { label: t("skills.table.columns.updatedAt"), value: }, + { label: t("skills.table.columns.createdBy"), value: skill.created_by || t("skills.table.notRecorded") }, + { + label: t("skills.table.columns.publishedBy"), + value: skill.published_by || t("skills.table.notRecorded"), + }, + { label: t("skills.table.columns.approvedBy"), value: skill.approved_by || t("skills.table.notRecorded") }, + { + label: t("skills.table.columns.securityScan"), + value: ( + + {t(skill.security_scan_passed ? "skills.table.publicYes" : "skills.table.publicNo")} + + ), + }, ], [ { diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index ec67f4dc09c..f8886610e01 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -1693,9 +1693,9 @@ "unavailable": "An install command is unavailable for this gateway URL. Authenticated installs require HTTPS or a localhost gateway" }, "panel": { - "title": "Skills", - "intro": "Upload or import portable skills. Review proposed changes before publishing them to the Skills Hub.", - "addButton": "+ Add Skill", + "title": "Skills Hub", + "intro": "Register and share reusable skills for Codex, Claude Code, and other agents that support the open Agent Skills format.", + "addButton": "+ Register your skill", "deleteDialog": { "title": "Delete Skill", "confirm": "Are you sure you want to delete skill: ?", @@ -1718,8 +1718,8 @@ }, "table": { "empty": { - "title": "No skills found", - "description": "Add one to get started." + "title": "No skills registered yet", + "description": "Register your first skill to get started." }, "loading": "Loading skills…", "uncategorized": "Uncategorized", @@ -1739,15 +1739,22 @@ "description": "Description", "category": "Category", "public": "Public", - "createdAt": "Created At", + "createdAt": "Created", "actions": "Actions", "domain": "Domain", - "updatedAt": "Updated at", - "downloads": "Downloads" - } + "updatedAt": "Last updated", + "downloads": "Downloads", + "tags": "Tags", + "createdBy": "Created by", + "publishedBy": "Published by", + "approvedBy": "Approved by", + "securityScan": "Security scan" + }, + "notRecorded": "Not recorded", + "scanHelp": "Yes means this published version has a completed scan with no findings and a risk score of 0. No means it has not passed a recorded scan." }, "addForm": { - "title": "Add New Skill", + "title": "Register your skill", "sourceOptions": { "url": "Source URL", "upload": "Upload ZIP" @@ -1764,22 +1771,22 @@ "namespace": "Namespace (Optional)", "description": "Description (Optional)", "category": "Category (Optional)", - "keywords": "Keywords (Optional)", + "keywords": "Tags (Optional)", "version": "Version (Optional)", "authorName": "Author Name (Optional)", "authorEmail": "Author Email (Optional)" }, "hints": { "archive": "Choose a ZIP from your computer, up to 10 MiB. Include SKILL.md at the root or inside one skill folder, along with any supporting files. The skill will use the same visibility settings as skills added by URL.", - "sourceUrl": "Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.", - "subPath": "Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.", - "sha256": "Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.", + "sourceUrl": "Link to a Git repository, a skill folder, or a ZIP archive containing SKILL.md and any supporting files. For private repositories, use an SSH clone URL with access configured in your agent.", + "subPath": "Path within the repository where the skill lives (e.g., skills/my-skill). Leave empty if the skill is at the repo root.", + "sha256": "Optional SHA-256 digest for verifying the ZIP archive’s integrity.", "name": "Unique identifier in kebab-case format (e.g., my-skill)", - "domain": "Top-level grouping in the Skill Hub (e.g., Productivity)", + "domain": "Top-level grouping in the Skills Hub (e.g., Productivity)", "namespace": "Sub-grouping within domain (e.g., workflows)", "description": "Brief description of what the skill does", "category": "Select a category or enter a custom one", - "keywords": "Comma-separated list of keywords for search", + "keywords": "Comma-separated list of tags for search", "version": "Semantic version (e.g., 1.0.0)", "authorName": "Name of the skill author or organization", "authorEmail": "Contact email for the skill author" @@ -1800,13 +1807,13 @@ "detected": "Detected: {{source}}", "noCategories": "No matching categories", "cancel": "Cancel", - "adding": "Adding...", - "submit": "Add Skill", + "adding": "Registering...", + "submit": "Register skill", "errors": { "zipRequired": "Please choose a .zip file", "zipTooLarge": "The ZIP file must be 10 MiB or smaller", "urlRequired": "Please enter a repository or zip archive URL", - "subPathInvalid": "Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)", + "subPathInvalid": "Subfolder must be a relative path like skills/my-skill (letters, numbers, dots, hyphens, underscores)", "sha256Invalid": "SHA-256 must be a 64-character hex digest", "nameRequired": "Please enter skill name", "nameInvalid": "Name must be kebab-case (lowercase, numbers, hyphens only)", @@ -1844,7 +1851,7 @@ "path": "Path (optional)", "domain": "Domain (optional)", "namespace": "Namespace (optional)", - "keywords": "Keywords (optional)" + "keywords": "Tags (optional)" }, "uploadHint": "Choose a ZIP up to 10 MiB containing SKILL.md. Its frontmatter supplies the skill name and description.", "replacementHint": "The replacement SKILL.md supplies the description. Domain, namespace, and keywords are preserved.", @@ -1921,7 +1928,7 @@ "source": "Source", "author": "Author", "homepage": "Homepage", - "keywords": "Keywords", + "keywords": "Tags", "category": "Category", "domain": "Domain", "namespace": "Namespace", @@ -1934,6 +1941,15 @@ "detail": { "subtitle": "Published skill metadata and portable archive", "private": "Private" + }, + "filters": { + "allTags": "All tags", + "tag": "Filter by tag", + "allScans": "All scan statuses", + "scanned": "Security scan: Yes", + "notScanned": "Security scan: No", + "search": "Search skills, tags, or people…", + "clear": "Clear filters" } }, "claudeCodePlugins": { @@ -1962,7 +1978,7 @@ "newer": "Newer", "older": "Older" }, - "back": "Skills", + "back": "Skills Hub", "tabs": { "overview": "Overview", "usage": "How to Use", @@ -2016,14 +2032,14 @@ "copy": "Copy", "copied": "Copied", "usage": { - "title": "Using this skill", + "title": "Use with Claude Code", "intro": "Once your proxy is set as a marketplace, enable this skill in Claude Code with one command:", "staleCatalog": "If you see \"Plugin {{name}} not found in marketplace\", update the catalog first:", "notConfigured": "Don't have the marketplace configured yet?", "seeSetup": "See one-time setup →" }, "setup": { - "title": "One-time marketplace setup", + "title": "Claude Code marketplace setup", "removeFirst": "If you added this marketplace before without a key, remove it first in Claude Code. Removing it also uninstalls the plugins you installed from it, so install them again from /plugin after you add the snippet:", "requiresKey": "This proxy requires a LiteLLM key to read the marketplace. Add this to and replace with your key:", "runCommand": "Run this command in Claude Code to register the marketplace:", @@ -2031,7 +2047,7 @@ } }, "makePublic": { - "title": "Publish to Skill Hub", + "title": "Publish to Skills Hub", "steps": { "select": "Select Skills", "confirm": "Confirm" @@ -2039,15 +2055,15 @@ "step1": { "title": "Select Skills to Publish", "selectAll": "Select All ({{total}})", - "description": "Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished.", + "description": "Selected skills will be visible to all users in the Skills Hub. Deselected skills will be unpublished.", "empty": "No skills registered yet.", "public": "Public", "selectedCount_one": "{{count}} skill will be published", "selectedCount_other": "{{count}} skills will be published" }, "step2": { - "title": "Confirm Publish to Skill Hub", - "note": "Note: Published skills will be visible to all users in the Skill Hub tab. Skills not in the list below will be unpublished.", + "title": "Confirm Publish to Skills Hub", + "note": "Note: Published skills will be visible to all users in the Skills Hub tab. Skills not in the list below will be unpublished.", "listTitle": "Skills to be published:", "total_one": "Total: {{count}} skill will be published", "total_other": "Total: {{count}} skills will be published" @@ -2060,8 +2076,8 @@ }, "toasts": { "selectAtLeastOne": "Please select at least one skill", - "published_one": "Skill Hub updated — {{count}} skill(s) published", - "published_other": "Skill Hub updated — {{count}} skill(s) published", + "published_one": "Skills Hub updated — {{count}} skill(s) published", + "published_other": "Skills Hub updated — {{count}} skill(s) published", "publishFailed": "Failed to update skills. Please try again." } } @@ -7102,7 +7118,7 @@ "models": "Model Hub", "agents": "Agent Hub", "mcp": "MCP Hub", - "skills": "Skill Hub" + "skills": "Skills Hub" }, "models": { "title": "Available Models", @@ -7265,7 +7281,7 @@ "models": "Model Hub", "agents": "Agent Hub", "mcp": "MCP Hub", - "skills": "Skill Hub" + "skills": "Skills Hub" }, "models": { "chatTitle": "Models", @@ -7688,7 +7704,7 @@ "agents": "Agents", "mcpShort": "MCP", "mcpServers": "MCP servers", - "skills": "Skills" + "skills": "Skills Hub" }, "header": { "title": "AI Hub", @@ -7954,7 +7970,7 @@ "workflowRuns": "Workflow Runs", "memory": "Memory", "mcpServers": "MCP Servers", - "skills": "Skills", + "skills": "Skills Hub", "guardrails": "Guardrails", "policies": "Policies", "tools": "Tools", @@ -8029,7 +8045,7 @@ "uiTheme": "Customize dashboard appearance", "tagManagement": "Organize resources with tags", "prompts": "Manage and version prompt templates", - "skills": "Browse and manage Claude Code skills", + "skills": "Register and share reusable agent skills in the Skills Hub", "routerSettings": "Configure routing and load balancing settings", "telemetry": "Choose which anonymous usage telemetry this proxy collects and see the last report", "loggingAndAlerts": "Set up logging and alert configurations", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a9af3f2b719..f9dae53bd03 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -42114,15 +42114,21 @@ export interface components { * @description Plugin item in list responses. */ PluginListItem: { + /** Approved By */ + approved_by?: string | null; author?: components["schemas"]["PluginAuthor"] | null; /** Category */ category?: string | null; /** Created At */ created_at: string | null; + /** Created By */ + created_by?: string | null; /** Description */ description: string | null; /** Domain */ domain?: string | null; + /** Download Count */ + download_count?: number | null; /** Enabled */ enabled: boolean; /** Homepage */ @@ -42137,6 +42143,13 @@ export interface components { name: string; /** Namespace */ namespace?: string | null; + /** Published By */ + published_by?: string | null; + /** + * Security Scan Passed + * @default false + */ + security_scan_passed: boolean; /** Source */ source: { [key: string]: string; @@ -46920,6 +46933,11 @@ export interface components { next_offset: number | null; /** Skills */ skills: components["schemas"]["SkillHubSkill"][]; + /** + * Tags + * @default [] + */ + tags: string[]; }; /** SkillHubMetadata */ SkillHubMetadata: { @@ -47005,15 +47023,21 @@ export interface components { }; /** SkillHubSkill */ SkillHubSkill: { + /** Approved By */ + approved_by?: string | null; /** Content Url */ content_url?: string | null; + /** Created At */ + created_at?: string | null; + /** Created By */ + created_by?: string | null; /** Description */ description?: string | null; /** Domain */ domain?: string | null; /** * Download Count - * @description Gateway ZIP downloads, included in proxy-admin catalog listings + * @description Gateway ZIP downloads, including repeat downloads */ download_count?: number | null; /** Download Url */ @@ -47026,6 +47050,13 @@ export interface components { name: string; /** Namespace */ namespace?: string | null; + /** Published By */ + published_by?: string | null; + /** + * Security Scan Passed + * @default false + */ + security_scan_passed: boolean; /** Source */ source: { [key: string]: string; @@ -74391,6 +74422,10 @@ export interface operations { query?: string; offset?: number; limit?: number; + tag?: string; + security_scan_passed?: boolean | null; + sort_by?: "name" | "download_count" | "updated_at" | "created_at" | "created_by" | "published_by" | "approved_by" | "security_scan_passed"; + sort_order?: "asc" | "desc"; }; header?: never; path?: never;