From 0ad5d750d42ec0d51a2f8f5324078747dfa9cd5e Mon Sep 17 00:00:00 2001 From: tin-berri Date: Thu, 5 Nov 2026 00:19:38 -0800 Subject: [PATCH 01/47] feat(skills)!: version published Skills Hub updates (#50152) --- .../migration.sql | 16 + .../litellm_proxy_extras/schema.prisma | 18 + litellm/integrations/otel/model/spans.py | 1 + litellm/models/plugin_upload.py | 56 ++- litellm/proxy/_lazy_openapi_snapshot.json | 354 +++++++++++++++++- litellm/proxy/_types.py | 3 + .../claude_code_marketplace.py | 102 ++--- .../skill_hub_endpoints.py | 190 +++++++++- litellm/proxy/schema.prisma | 18 + litellm/repositories/plugin_publication.py | 175 +++++++++ .../repositories/plugin_upload_repository.py | 101 ++++- litellm/types/proxy/claude_code_endpoints.py | 12 +- litellm/types/proxy/skill_hub.py | 29 +- pyproject.toml | 2 + schema.prisma | 18 + .../database/test_skill_publication.py | 201 ++++++++++ .../test_claude_code_marketplace.py | 149 +++++--- tests/unit/proxy/auth/test_route_checks.py | 22 ++ .../test_skill_hub_endpoints.py | 128 ++++++- .../public_endpoints/test_public_endpoints.py | 24 +- .../repositories/test_plugin_publication.py | 149 ++++++++ .../SkillVersionHistory.integration.test.tsx | 85 +++++ .../SkillVersionHistory.tsx | 109 ++++++ .../claude_code_plugins/skill_detail.tsx | 5 + ui/litellm-dashboard/src/i18n/locales/en.json | 15 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 221 ++++++++++- uv.lock | 13 + 27 files changed, 2050 insertions(+), 166 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20261105000000_skill_version_history/migration.sql create mode 100644 litellm/repositories/plugin_publication.py create mode 100644 tests/integration/database/test_skill_publication.py create mode 100644 tests/unit/repositories/test_plugin_publication.py create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105000000_skill_version_history/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105000000_skill_version_history/migration.sql new file mode 100644 index 00000000000..ecfa7321d87 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105000000_skill_version_history/migration.sql @@ -0,0 +1,16 @@ +SET LOCAL lock_timeout = '10s'; +CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginVersion" ( + "id" TEXT NOT NULL, + "plugin_id" TEXT NOT NULL, + "version" TEXT, + "description" TEXT, + "manifest_json" TEXT, + "published_at" TIMESTAMP(3), + "published_by" TEXT, + "archive_available" BOOLEAN NOT NULL DEFAULT false, + "content" BYTEA, + CONSTRAINT "LiteLLM_ClaudeCodePluginVersion_pkey" PRIMARY KEY ("id"), + CONSTRAINT "LiteLLM_ClaudeCodePluginVersion_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "LiteLLM_ClaudeCodePluginTable"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginVersion_plugin_id_version_key" ON "LiteLLM_ClaudeCodePluginVersion"("plugin_id", "version"); +CREATE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginVersion_plugin_id_published_at_idx" ON "LiteLLM_ClaudeCodePluginVersion"("plugin_id", "published_at"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 78f16d9f1fd..7a03193f708 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1616,10 +1616,28 @@ model LiteLLM_ClaudeCodePluginTable { created_by String? uploaded_archive LiteLLM_ClaudeCodePluginUpload? + versions LiteLLM_ClaudeCodePluginVersion[] @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginVersion { + id String @id @default(uuid()) + plugin_id String + version String? + description String? + manifest_json String? + published_at DateTime? + published_by String? + archive_available Boolean @default(false) + content Bytes? + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@unique([plugin_id, version]) + @@index([plugin_id, published_at]) +} + + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/litellm/integrations/otel/model/spans.py b/litellm/integrations/otel/model/spans.py index 5b81fd9a331..9d5b1c66395 100644 --- a/litellm/integrations/otel/model/spans.py +++ b/litellm/integrations/otel/model/spans.py @@ -341,6 +341,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset( "LiteLLM_SpendLogDelivery", "LiteLLM_ClaudeCodePluginTable", "LiteLLM_ClaudeCodePluginUpload", + "LiteLLM_ClaudeCodePluginVersion", "LiteLLM_MemoryTable", "LiteLLM_AdaptiveRouterState", "LiteLLM_AdaptiveRouterSession", diff --git a/litellm/models/plugin_upload.py b/litellm/models/plugin_upload.py index 21a55d44960..0116401290d 100644 --- a/litellm/models/plugin_upload.py +++ b/litellm/models/plugin_upload.py @@ -1,6 +1,7 @@ from collections.abc import AsyncIterator, Mapping from dataclasses import dataclass -from typing import Protocol +from datetime import datetime +from typing import Literal, Protocol def has_agent_skill_metadata(manifest: Mapping[str, object]) -> bool: @@ -27,11 +28,64 @@ class PluginArchiveMetadata: version: str | None = None description: str | None = None has_agent_skill_metadata: bool = False + updated_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class PluginPublication: + name: str + version: str | None + description: str | None + manifest: Mapping[str, object] + created_by: str | None + content: bytes | None = None + + +@dataclass(frozen=True, slots=True) +class PluginPublicationBase: + id: str + updated_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class PublishedPlugin: + id: str + name: str + version: str + enabled: bool + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class PluginPublicationError: + kind: Literal["invalid_version", "conflict", "not_found"] + message: str + + +@dataclass(frozen=True, slots=True) +class PluginVersion: + id: str + version: str | None + description: str | None + manifest: Mapping[str, object] + published_at: datetime | None + published_by: str | None + archive_available: bool class PluginUploadStore(Protocol): async def create(self, upload: PluginUpload) -> str | None: ... + async def publish( + self, publication: PluginPublication, expected: PluginPublicationBase | None = None + ) -> PublishedPlugin | PluginPublicationError: ... + + async def versions(self, metadata: PluginArchiveMetadata, limit: int, offset: int) -> tuple[PluginVersion, ...]: ... + + async def version(self, metadata: PluginArchiveMetadata, version_id: str) -> PluginVersion | None: ... + + async def version_content(self, metadata: PluginArchiveMetadata, version_id: str) -> bytes | None: ... + async def get_metadata(self, name: str) -> PluginArchiveMetadata | None: ... async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None: ... diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 6575552c6fd..d8be3f65d55 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -7912,7 +7912,7 @@ } ], "default": "1.0.0", - "description": "Semantic version", + "description": "Semantic version, defaults to 1.0.0 on creation", "title": "Version" } }, @@ -7950,7 +7950,7 @@ "type": "object" }, "UpdatePluginRequest": { - "description": "Request body for replacing an existing plugin.\n\nThe plugin name is the resource identity and is supplied as the path\nparameter, so it cannot be changed here. This is a full replace: omitted\nfields reset to their defaults, so version is cleared rather than\ndefaulting to the create-time \"1.0.0\".", + "description": "Request body for replacing an existing plugin.\n\nThe plugin name is the resource identity and is supplied as the path\nparameter, so it cannot be changed here. This is a full replace: omitted\nfields reset to their defaults, except version which automatically increments\nthe published patch version when omitted or null.", "properties": { "author": { "anyOf": [ @@ -7999,6 +7999,19 @@ "description": "Skill domain (e.g., 'Productivity')", "title": "Domain" }, + "expected_updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Published updated_at read by the editor. A stale value returns 409", + "title": "Expected Updated At" + }, "homepage": { "anyOf": [ { @@ -8072,7 +8085,7 @@ "type": "null" } ], - "description": "Semantic version; cleared if omitted", + "description": "Explicit higher SemVer, or omit/null for an automatic patch bump", "title": "Version" } }, @@ -8203,7 +8216,7 @@ } ], "default": "1.0.0", - "description": "Semantic version", + "description": "Semantic version, defaults to 1.0.0 on creation", "title": "Version" } }, @@ -8535,7 +8548,7 @@ ] }, "put": { - "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Plugin source reference (github, url, git-subdir, or archive format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n - installation_preference: 'available', 'auto_install', or 'required' (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default, except version which automatically increments\nthe published patch version when omitted or null.\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Plugin source reference (github, url, git-subdir, or archive format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n - installation_preference: 'available', 'auto_install', or 'required' (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", "operationId": "update_plugin_claude_code_plugins__plugin_name__put", "parameters": [ { @@ -58937,7 +58950,7 @@ "type": "null" } ], - "description": "Version label for the imported snapshot", + "description": "Semantic version, or omit/null to start at 1.0.0", "title": "Version" } }, @@ -59006,6 +59019,19 @@ ], "title": "Domain" }, + "expected_updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Published updated_at read by the editor. A stale value returns 409", + "title": "Expected Updated At" + }, "keywords": { "anyOf": [ { @@ -59040,6 +59066,7 @@ "type": "null" } ], + "description": "Explicit higher SemVer, or omit/null for an automatic patch bump", "title": "Version" } }, @@ -59102,6 +59129,18 @@ "title": "Source", "type": "object" }, + "updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + }, "version": { "anyOf": [ { @@ -59131,6 +59170,123 @@ "title": "SkillHubSkill", "type": "object" }, + "SkillHubVersion": { + "properties": { + "content_kind": { + "enum": [ + "stored_archive", + "source_reference" + ], + "title": "Content Kind", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "download_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Download Url" + }, + "id": { + "title": "Id", + "type": "string" + }, + "manifest": { + "additionalProperties": true, + "title": "Manifest", + "type": "object" + }, + "published_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Published At" + }, + "published_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Published By" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "id", + "version", + "description", + "manifest", + "published_at", + "published_by", + "content_kind", + "download_url" + ], + "title": "SkillHubVersion", + "type": "object" + }, + "SkillHubVersions": { + "properties": { + "next_offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Next Offset" + }, + "versions": { + "items": { + "$ref": "#/components/schemas/SkillHubVersion" + }, + "title": "Versions", + "type": "array" + } + }, + "required": [ + "versions", + "next_offset" + ], + "title": "SkillHubVersions", + "type": "object" + }, "ValidationError": { "properties": { "ctx": { @@ -59711,6 +59867,192 @@ "skill_hub" ] } + }, + "/skill-hub/skills/{name}/versions": { + "get": { + "description": "Read published metadata history. External references are not retained artifacts.", + "operationId": "list_skill_versions_skill_hub_skills__name__versions_get", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillHubVersions" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "List Skill Versions", + "tags": [ + "skill_hub" + ] + } + }, + "/skill-hub/skills/{name}/versions/{version_id}": { + "get": { + "description": "Read a retained publication under the current skill's visibility policy.", + "operationId": "get_skill_version_skill_hub_skills__name__versions__version_id__get", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + }, + { + "in": "path", + "name": "version_id", + "required": true, + "schema": { + "title": "Version Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillHubVersion" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Skill Version", + "tags": [ + "skill_hub" + ] + } + }, + "/skill-hub/skills/{name}/versions/{version_id}/archive": { + "get": { + "description": "Download a retained publication as a portable ZIP with SKILL.md at its root.", + "operationId": "download_skill_version_skill_hub_skills__name__versions__version_id__archive_get", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + }, + { + "in": "path", + "name": "version_id", + "required": true, + "schema": { + "title": "Version Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/zip": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Download Skill Version", + "tags": [ + "skill_hub" + ] + } } } }, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 25849634764..ce014bccb5d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -456,6 +456,9 @@ class LiteLLMRoutes(enum.Enum): "/skill-hub/skills/{name}", "/skill-hub/skills/{name}/archive", "/skill-hub/skills/{name}/content", + "/skill-hub/skills/{name}/versions", + "/skill-hub/skills/{name}/versions/{version_id}", + "/skill-hub/skills/{name}/versions/{version_id}/archive", "/skill-hub/skills/{name}/publish", "/skill-hub/skills/{name}/unpublish", "/rag/ingest", 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 0faefaeaa41..d383d5f349a 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 @@ -34,7 +34,12 @@ from typing_extensions import ReadOnly from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.url_utils import is_https_url_with_host -from litellm.models.plugin_upload import PluginUpload, has_agent_skill_metadata +from litellm.models.plugin_upload import ( + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + has_agent_skill_metadata, +) from litellm.proxy._types import CommonProxyErrors, ProxyErrorTypes, ProxyException, UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import ( SkillVisibility, @@ -208,8 +213,8 @@ async def upload_plugin( } spec: Final = RegisterPluginRequest.model_validate({**metadata.model_dump(), "source": source}) _validate_installation_preference(spec) - plugin_id: Final = await uploads.create( - PluginUpload( + published: Final = await uploads.publish( + PluginPublication( name=spec.name, version=spec.version, description=spec.description, @@ -218,15 +223,15 @@ async def upload_plugin( created_by=user_api_key_dict.user_id, ) ) - if plugin_id is None: - raise _name_conflict_error(spec.name) + if isinstance(published, PluginPublicationError): + raise _publication_error(published) return RegisterPluginResponse( status="success", action="created", plugin=PluginResponse( - id=plugin_id, + id=published.id, name=spec.name, - version=spec.version, + version=published.version, description=spec.description, source=source, installation_preference=spec.installation_preference, @@ -472,10 +477,14 @@ def _error_response(status_code: int, message: str) -> HTTPException: return HTTPException(status_code=status_code, detail={"error": message}) -def _name_conflict_error(name: str) -> HTTPException: - return _error_response( - 409, f"A skill named '{name}' already exists. Update the existing skill instead of adding it again." - ) +def _publication_error(error: PluginPublicationError) -> HTTPException: + match error.kind: + case "invalid_version": + return _error_response(400, error.message) + case "not_found": + return _error_response(404, error.message) + case "conflict": + return _error_response(409, error.message) def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None: @@ -498,6 +507,7 @@ def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None: ) async def register_plugin( request: RegisterPluginRequest, + uploads: PluginUploadRepositoryDep, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ @@ -540,12 +550,11 @@ async def register_plugin( }' ``` """ - from prisma.errors import UniqueViolationError - try: _require_proxy_admin(user_api_key_dict) - prisma_client: Final = await get_prisma_client() + if uploads is None: + raise _error_response(500, CommonProxyErrors.db_not_connected_error.value) if not re.match(r"^[a-z0-9-]+$", request.name): raise HTTPException( @@ -556,30 +565,19 @@ async def register_plugin( _validate_plugin_source(request.source) _validate_installation_preference(request) - existing: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique( - where={"name": request.name} - ) - if existing: - raise _name_conflict_error(request.name) - manifest: Final[Mapping[str, object]] = _build_plugin_manifest(request.name, request) - try: - plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.create( - data={ - "name": request.name, - "version": request.version, - "description": request.description, - "manifest_json": json.dumps(manifest), - "files_json": "{}", - "enabled": True, - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), - "created_by": user_api_key_dict.user_id, - } + plugin: Final = await uploads.publish( + PluginPublication( + name=request.name, + version=request.version, + description=request.description, + manifest=manifest, + created_by=user_api_key_dict.user_id, ) - except UniqueViolationError: - raise _name_conflict_error(request.name) + ) + if isinstance(plugin, PluginPublicationError): + raise _publication_error(plugin) verbose_proxy_logger.info("Plugin %s created successfully", request.name) @@ -590,7 +588,7 @@ async def register_plugin( id=plugin.id, name=plugin.name, version=plugin.version, - description=plugin.description, + description=request.description, source=request.source, installation_preference=request.installation_preference, enabled=plugin.enabled, @@ -755,6 +753,7 @@ async def get_plugin( async def update_plugin( plugin_name: str, request: UpdatePluginRequest, + uploads: PluginUploadRepositoryDep, user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], ): """ @@ -763,7 +762,8 @@ async def update_plugin( The plugin is identified by its name in the path, which is the resource identity and cannot be changed here. This is a full replace, not a merge: the manifest is rebuilt from the request body, so any optional field left - out is reset to its default (e.g. an omitted version is cleared, not kept). + out is reset to its default, except version which automatically increments + the published patch version when omitted or null. Send the full desired state. Returns 404 if no plugin with the given name exists; use @@ -801,6 +801,8 @@ async def update_plugin( try: _require_proxy_admin(user_api_key_dict) + if uploads is None: + raise _error_response(500, CommonProxyErrors.db_not_connected_error.value) prisma_client: Final = await get_prisma_client() @@ -808,6 +810,8 @@ async def update_plugin( existing: Final[_PluginRecord | None] = await repository.table.find_unique(where={"name": plugin_name}) if not existing: raise _error_response(404, f"Plugin '{plugin_name}' not found") + if request.expected_updated_at is not None and request.expected_updated_at != existing.updated_at: + raise _error_response(409, "The published skill changed. Reload it before publishing an update") current_manifest: Final = MANIFEST_FIELDS.validate_json(existing.manifest_json or "{}") if request.source != current_manifest.get("source"): @@ -823,18 +827,18 @@ async def update_plugin( ), } - plugin: Final[_PluginRecord | None] = await repository.table.update( - where={"name": plugin_name}, - data={ - "version": request.version, - "description": request.description, - "manifest_json": json.dumps(manifest), - "files_json": "{}", - "updated_at": datetime.now(timezone.utc), - }, + plugin: Final = await uploads.publish( + PluginPublication( + name=plugin_name, + version=request.version, + description=request.description, + manifest=manifest, + created_by=user_api_key_dict.user_id, + ), + PluginPublicationBase(existing.id, existing.updated_at), ) - if plugin is None: - raise _error_response(404, f"Plugin '{plugin_name}' not found") + if isinstance(plugin, PluginPublicationError): + raise _publication_error(plugin) verbose_proxy_logger.info("Plugin %s updated successfully", plugin_name) @@ -845,7 +849,7 @@ async def update_plugin( id=plugin.id, name=plugin.name, version=plugin.version, - description=plugin.description, + description=request.description, source=request.source, installation_preference=request.installation_preference, enabled=plugin.enabled, diff --git a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py index faef3b9e4d4..27a4bd70738 100644 --- a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py +++ b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py @@ -1,5 +1,6 @@ import asyncio import hashlib +from datetime import datetime from typing import Annotated, Final from urllib.parse import quote @@ -10,7 +11,16 @@ import litellm from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # shared HTTP factory has untyped optional params ) -from litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload, PluginUploadStore +from litellm.models.plugin_upload import ( + PluginArchiveMetadata, + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + PluginUploadStore, + PluginVersion, + PublishedPlugin, + has_agent_skill_metadata, +) from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( MANIFEST_FIELDS, @@ -21,7 +31,6 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp list_plugins, plugin_archive_source_matches, plugin_archive_url, - update_plugin, ) from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import build_uploaded_skill_archive @@ -44,7 +53,7 @@ from litellm.proxy.discovery_endpoints.skill_import import ( select_imported_skill, ) from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3 -from litellm.types.proxy.claude_code_endpoints import PluginListItem, UpdatePluginRequest +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 ( ImportSkillRequest, @@ -52,9 +61,12 @@ from litellm.types.proxy.skill_hub import ( SkillHubMetadata, SkillHubPublication, SkillHubSkill, + SkillHubVersion, + SkillHubVersions, ) router: Final = APIRouter(prefix="/skill-hub/skills", tags=["Skills Hub"]) +_EDITABLE_METADATA: Final = frozenset(SkillHubMetadata.model_fields) def _url(request: Request, route: str) -> str: @@ -80,6 +92,7 @@ def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill: name=plugin.name, description=plugin.description, version=plugin.version, + updated_at=None if plugin.updated_at is None else datetime.fromisoformat(plugin.updated_at), 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,7 +150,7 @@ def _metadata_response(request: Request, metadata: PluginArchiveMetadata) -> Ski has_agent_skill_metadata=metadata.has_agent_skill_metadata, enabled=metadata.enabled, created_at=None, - updated_at=None, + updated_at=metadata.updated_at.isoformat() if metadata.updated_at is not None else None, ), ) @@ -196,14 +209,27 @@ async def replace_skill_metadata( _require_admin(caller) store: Final = _require_store(uploads) metadata: Final = await store.get_metadata(name) - if metadata is None: + if metadata is None or metadata.id is None: raise HTTPException(404, "Skill not found") + if body.expected_updated_at is not None and body.expected_updated_at != metadata.updated_at: + raise HTTPException(409, "The published skill changed. Reload it before publishing an update") manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}") - updated: Final = UpdatePluginRequest.model_validate( - {**manifest, **body.model_dump(), "source": dict(metadata.source)} + publication: Final = PluginPublication( + name=name, + version=body.version, + description=body.description, + manifest={ + **{key: value for key, value in manifest.items() if key not in _EDITABLE_METADATA}, + **body.model_dump(exclude_none=True), + "name": name, + "source": dict(metadata.source), + }, + created_by=caller.user_id, ) - await update_plugin(plugin_name=name, request=updated, user_api_key_dict=caller) - return await get_skill(request, name, uploads, caller) + published: Final = await store.publish(publication, PluginPublicationBase(metadata.id, metadata.updated_at)) + if isinstance(published, PluginPublicationError): + raise _publication_error(published) + return skill_publication_response(request, publication, published) @router.post("/{name}/publish", response_model=SkillHubSkill) @@ -295,6 +321,45 @@ async def _publish( version: str | None, origin: str | None, ) -> SkillHubSkill: + publication: Final = await prepare_skill_publication(request, archive, version, origin, caller.user_id) + published: Final = await store.publish(publication) + if isinstance(published, PluginPublicationError): + raise _publication_error(published) + return skill_publication_response(request, publication, published) + + +def _publication_error(error: PluginPublicationError) -> HTTPException: + return HTTPException({"invalid_version": 400, "conflict": 409, "not_found": 404}[error.kind], error.message) + + +def skill_publication_response( + request: Request, publication: PluginPublication, published: PublishedPlugin +) -> SkillHubSkill: + return _skill_response( + request, + PluginListItem.model_validate( + { + "source": publication.manifest.get("source", {}), + "id": published.id, + "name": published.name, + "description": publication.description, + "version": published.version, + "enabled": published.enabled, + "has_agent_skill_metadata": has_agent_skill_metadata(publication.manifest), + "created_at": None, + "updated_at": published.updated_at.isoformat(), + } + ), + ) + + +async def prepare_skill_publication( + request: Request, + archive: SkillArchive, + version: str | None, + origin: str | None, + created_by: str | None, +) -> PluginPublication: name: Final = archive.declared_name if name is None: raise HTTPException(422, "SKILL.md must declare a skill name") @@ -322,19 +387,110 @@ async def _publish( "origin": origin, }, } - identifier: Final = await store.create( - PluginUpload(name, version, archive.declared_description, manifest, plugin, caller.user_id) + return PluginPublication( + name=name, + version=version, + description=archive.declared_description, + manifest=manifest, + content=plugin, + created_by=created_by, ) - if identifier is None: - raise HTTPException(409, "A skill with this name already exists") - return _metadata_response( - request, - PluginArchiveMetadata( - name, True, source, None, identifier, version, archive.declared_description, has_agent_skill_metadata=True + + +async def _version_metadata(name: str, store: PluginUploadStore, caller: UserAPIKeyAuth) -> PluginArchiveMetadata: + metadata: Final = await store.get_metadata(name) + if metadata is None or metadata.id is None or not (await resolve_skill_visibility(caller)).allows(metadata): + raise HTTPException(404, "Skill not found") + return metadata + + +def _version_response(request: Request, name: str, version: PluginVersion) -> SkillHubVersion: + return SkillHubVersion( + id=version.id, + version=version.version, + description=version.description, + manifest=version.manifest, + published_at=version.published_at, + published_by=version.published_by, + content_kind="stored_archive" if version.archive_available else "source_reference", + download_url=( + _url(request, f"skill-hub/skills/{quote(name, safe='')}/versions/{quote(version.id, safe='')}/archive") + if version.archive_available + else None ), ) +@router.get("/{name}/versions", response_model=SkillHubVersions) +async def list_skill_versions( + request: Request, + name: str, + uploads: PluginUploadRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + limit: Annotated[int, Query(ge=1, le=100)] = 20, + offset: Annotated[int, Query(ge=0)] = 0, +) -> SkillHubVersions: + """Read published metadata history. External references are not retained artifacts.""" + store: Final = _require_store(uploads) + metadata: Final = await _version_metadata(name, store, caller) + versions: Final = await store.versions(metadata, limit + 1, offset) + return SkillHubVersions( + versions=tuple(_version_response(request, name, version) for version in versions[:limit]), + next_offset=offset + limit if len(versions) > limit else None, + ) + + +@router.get("/{name}/versions/{version_id}", response_model=SkillHubVersion) +async def get_skill_version( + request: Request, + name: str, + version_id: str, + uploads: PluginUploadRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> SkillHubVersion: + """Read a retained publication under the current skill's visibility policy.""" + store: Final = _require_store(uploads) + metadata: Final = await _version_metadata(name, store, caller) + version: Final = await store.version(metadata, version_id) + if version is None: + raise HTTPException(404, "Skill version not found") + return _version_response(request, name, version) + + +@router.get( + "/{name}/versions/{version_id}/archive", + response_class=Response, + responses={200: {"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}}}}, +) +async def download_skill_version( + name: str, + version_id: str, + uploads: PluginUploadRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> Response: + """Download a retained publication as a portable ZIP with SKILL.md at its root.""" + store: Final = _require_store(uploads) + metadata: Final = await _version_metadata(name, store, caller) + if metadata.id is None: + raise HTTPException(404, "Skill not found") + content: Final = await store.version_content(metadata, version_id) + archive: Final = ( + await asyncio.to_thread(build_agent_skill_archive, content, plugin_name=name) if content is not None else None + ) + if archive is None: + raise HTTPException(404, "This version has no retained Agent Skills archive") + return Response( + archive.content, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{name}.zip"', + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + "ETag": f'"{archive.digest}"', + }, + ) + + async def _visible_archive(request: Request, name: str, uploads: PluginUploadStore | None) -> SkillArchive: anonymous_allowed: Final = litellm.public_skills_index is True and not marketplace_auth_required() caller: Final = ( diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 78f16d9f1fd..7a03193f708 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1616,10 +1616,28 @@ model LiteLLM_ClaudeCodePluginTable { created_by String? uploaded_archive LiteLLM_ClaudeCodePluginUpload? + versions LiteLLM_ClaudeCodePluginVersion[] @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginVersion { + id String @id @default(uuid()) + plugin_id String + version String? + description String? + manifest_json String? + published_at DateTime? + published_by String? + archive_available Boolean @default(false) + content Bytes? + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@unique([plugin_id, version]) + @@index([plugin_id, published_at]) +} + + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/litellm/repositories/plugin_publication.py b/litellm/repositories/plugin_publication.py new file mode 100644 index 00000000000..f5fab458c6b --- /dev/null +++ b/litellm/repositories/plugin_publication.py @@ -0,0 +1,175 @@ +import hashlib +import json +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Final, Protocol + +from pydantic import TypeAdapter +from semver import Version + +from litellm.models.plugin_upload import ( + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + PublishedPlugin, +) +from litellm.repositories.prisma_protocols import TableActions + +if TYPE_CHECKING: + from prisma.models import ( + LiteLLM_ClaudeCodePluginTable, + LiteLLM_ClaudeCodePluginUpload, + LiteLLM_ClaudeCodePluginVersion, + ) + +_MANIFEST: Final = TypeAdapter(dict[str, object]) +_SOURCE: Final = TypeAdapter(dict[str, str]) + + +class PluginPublicationTransaction(Protocol): + @property + def litellm_claudecodeplugintable(self) -> TableActions["LiteLLM_ClaudeCodePluginTable"]: ... + + @property + def litellm_claudecodepluginupload(self) -> TableActions["LiteLLM_ClaudeCodePluginUpload"]: ... + + @property + def litellm_claudecodepluginversion(self) -> TableActions["LiteLLM_ClaudeCodePluginVersion"]: ... + + async def query_raw(self, query: str, *args: object) -> object: ... + + +def resolve_plugin_version( + requested: str | None, current: str | None = None, *, updating: bool = False +) -> str | PluginPublicationError: + try: + previous: Final = Version.parse(current) if current is not None else None + except ValueError: + return _legacy_version(requested) + if updating and previous is None: + return _legacy_version(requested) + try: + candidate: Final = Version.parse(requested) if requested is not None else None + except ValueError: + return PluginPublicationError("invalid_version", "Version must be a valid semantic version, for example 1.0.1") + if candidate is None: + return str(previous.bump_patch()) if previous is not None else "1.0.0" + if previous is not None and candidate.compare(previous) <= 0: + return PluginPublicationError("invalid_version", f"Version must be strictly higher than {current}") + return str(candidate) + + +def _legacy_version(requested: str | None) -> str | PluginPublicationError: + if requested is None: + return PluginPublicationError( + "conflict", + "The published version is missing or is not SemVer. Supply an explicit semantic version to update it", + ) + return resolve_plugin_version(requested) + + +def verified_archive(content: bytes | None, manifest_json: str | None) -> bytes | None: + if content is None: + return None + manifest: Final = _MANIFEST.validate_json(manifest_json or "{}") + source: Final = _SOURCE.validate_python(manifest.get("source", {})) + if source.get("source") != "archive" or source.get("sha256", "").lower() != hashlib.sha256(content).hexdigest(): + return None + return content + + +async def _snapshot( + tx: PluginPublicationTransaction, + plugin: "LiteLLM_ClaudeCodePluginTable", + content: bytes | None, + published_by: str | None, +) -> None: + from prisma import Base64 + + archive: Final = verified_archive(content, plugin.manifest_json) + await tx.litellm_claudecodepluginversion.create( + data={ + "plugin_id": plugin.id, + "version": plugin.version, + "description": plugin.description, + "manifest_json": plugin.manifest_json, + "published_at": plugin.updated_at, + "published_by": published_by, + "content": Base64.encode(archive) if archive is not None else None, + "archive_available": archive is not None, + } + ) + + +async def publish_plugin( + tx: PluginPublicationTransaction, + publication: PluginPublication, + expected: PluginPublicationBase | None = None, +) -> PublishedPlugin | PluginPublicationError: + from prisma import Base64 + + await tx.query_raw('SELECT id FROM "LiteLLM_ClaudeCodePluginTable" WHERE name = $1 FOR UPDATE', publication.name) + current: Final = await tx.litellm_claudecodeplugintable.find_unique(where={"name": publication.name}) + if current is not None and expected is None: + return PluginPublicationError("conflict", f"A skill named '{publication.name}' already exists") + if expected is not None and current is None: + return PluginPublicationError("not_found", f"Plugin '{publication.name}' not found") + if current is not None and ( + expected is not None and (current.id != expected.id or current.updated_at != expected.updated_at) + ): + return PluginPublicationError("conflict", "The published skill changed. Reload it before publishing an update") + version: Final = resolve_plugin_version( + publication.version, current.version if current is not None else None, updating=expected is not None + ) + if isinstance(version, PluginPublicationError): + return version + manifest_json: Final = json.dumps({**publication.manifest, "version": version}) + 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) + data: Final = { + "version": version, + "description": publication.description, + "manifest_json": manifest_json, + "files_json": "{}", + "updated_at": timestamp, + } + if current is None: + created: Final = await tx.litellm_claudecodeplugintable.create( + data={**data, "name": publication.name, "created_by": publication.created_by} + ) + if publication.content is not None: + 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) + 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) + if previous_archive is not None + else None + ) + 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 + ) + 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: + raise RuntimeError("Locked plugin disappeared during publication") + if content is None: + await tx.litellm_claudecodepluginupload.delete_many(where={"plugin_name": current.name}) + else: + await tx.litellm_claudecodepluginupload.upsert( + where={"plugin_name": current.name}, + data={ + "create": {"plugin_name": current.name, "content": Base64.encode(content)}, + "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) diff --git a/litellm/repositories/plugin_upload_repository.py b/litellm/repositories/plugin_upload_repository.py index f37b191e3d6..239a2a1537b 100644 --- a/litellm/repositories/plugin_upload_repository.py +++ b/litellm/repositories/plugin_upload_repository.py @@ -1,11 +1,26 @@ -import json from collections.abc import AsyncIterator, Sequence +from contextlib import AbstractAsyncContextManager from types import MappingProxyType -from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # Prisma dynamically exposes its typed table actions +from typing import ( + TYPE_CHECKING, + Final, + Protocol, + cast, # noqa: TID251 # Prisma dynamically exposes its typed table actions +) from pydantic import TypeAdapter, ValidationError -from litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload, has_agent_skill_metadata +from litellm.models.plugin_upload import ( + PluginArchiveMetadata, + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + PluginUpload, + PluginVersion, + PublishedPlugin, + has_agent_skill_metadata, +) +from litellm.repositories.plugin_publication import PluginPublicationTransaction, publish_plugin, verified_archive from litellm.repositories.prisma_protocols import TableActions from litellm.repositories.table_repositories import ClaudeCodePluginRepository @@ -17,10 +32,24 @@ if TYPE_CHECKING: _MANIFEST: Final = TypeAdapter(dict[str, object]) _SOURCE: Final = TypeAdapter(dict[str, str]) PAGE_SIZE: Final = 100 +_VERSIONS: Final = TypeAdapter(tuple[PluginVersion, ...]) +_VERSION_QUERY: Final = """ + SELECT v.id, v.version, v.description, COALESCE(v.manifest_json, '{}')::jsonb AS manifest, + v.published_at, v.published_by, v.archive_available + FROM "LiteLLM_ClaudeCodePluginVersion" v JOIN "LiteLLM_ClaudeCodePluginTable" p ON p.id = v.plugin_id + WHERE p.id = $1 AND p.enabled = $2 +""" + + +class _PublicationDatabase(PluginPublicationTransaction, Protocol): + def tx(self) -> AbstractAsyncContextManager[PluginPublicationTransaction]: ... class PluginUploadRepository: def __init__(self, prisma_client: "PrismaClient") -> None: + self._db = cast( # cast-ok: the generated Prisma transaction delegate is untyped at this boundary + _PublicationDatabase, prisma_client.writer_db + ) self._plugins = ClaudeCodePluginRepository(prisma_client, use_writer=True).table self._archives = cast( # cast-ok: Prisma exposes generated table actions through an untyped delegate TableActions["prisma_models.LiteLLM_ClaudeCodePluginUpload"], @@ -28,23 +57,64 @@ class PluginUploadRepository: ) async def create(self, upload: PluginUpload) -> str | None: - from prisma import Base64 + result: Final = await self.publish( + PluginPublication( + name=upload.name, + version=upload.version, + description=upload.description, + manifest=upload.manifest, + content=upload.content, + created_by=upload.created_by, + ) + ) + return result.id if isinstance(result, PublishedPlugin) else None + + async def publish( + self, publication: PluginPublication, expected: PluginPublicationBase | None = None + ) -> PublishedPlugin | PluginPublicationError: from prisma.errors import UniqueViolationError try: - plugin: Final = await self._plugins.create( - data={ - "name": upload.name, - "version": upload.version, - "description": upload.description, - "manifest_json": json.dumps(dict(upload.manifest)), - "created_by": upload.created_by, - "uploaded_archive": {"create": {"content": Base64.encode(upload.content)}}, - } - ) + async with self._db.tx() as tx: + return await publish_plugin(tx, publication, expected) except UniqueViolationError: + return PluginPublicationError("conflict", "A skill with this name or version already exists") + + async def versions(self, metadata: PluginArchiveMetadata, limit: int, offset: int) -> tuple[PluginVersion, ...]: + rows: Final = await self._db.query_raw( + _VERSION_QUERY + " ORDER BY v.published_at DESC NULLS LAST, v.id DESC LIMIT $3 OFFSET $4", + metadata.id, + metadata.enabled, + limit, + offset, + ) + return _VERSIONS.validate_python(rows) + + async def version(self, metadata: PluginArchiveMetadata, version_id: str) -> PluginVersion | None: + rows: Final = await self._db.query_raw( + _VERSION_QUERY + " AND v.id = $3", + metadata.id, + metadata.enabled, + version_id, + ) + versions: Final = _VERSIONS.validate_python(rows) + return versions[0] if versions else None + + async def version_content(self, metadata: PluginArchiveMetadata, version_id: str) -> bytes | None: + version: Final = await self._db.litellm_claudecodepluginversion.find_first( + where={ + "id": version_id, + "plugin_id": metadata.id, + "plugin": { + "is": { + "enabled": metadata.enabled, + } + }, + } + ) + if version is None or version.content is None: return None - return plugin.id + return verified_archive(version.content.decode(), version.manifest_json) async def get_metadata(self, name: str) -> PluginArchiveMetadata | None: plugin: Final = await self._plugins.find_unique(where={"name": name}) @@ -98,4 +168,5 @@ def _archive_metadata(plugin: "prisma_models.LiteLLM_ClaudeCodePluginTable") -> version=plugin.version, description=plugin.description, has_agent_skill_metadata=has_agent_skill_metadata(manifest), + updated_at=plugin.updated_at, ) diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 26c610a4f11..5d8479b8ce1 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -2,6 +2,7 @@ Claude Code Marketplace endpoint types for LiteLLM Proxy """ +from datetime import datetime from typing import Final, Literal, TypeAlias from pydantic import AliasChoices, ConfigDict, Field, TypeAdapter, model_validator @@ -32,7 +33,7 @@ class PluginMetadata(LiteLLMBaseModel): model_config = ConfigDict(frozen=True) - version: str | None = Field("1.0.0", description="Semantic version") + version: str | None = Field("1.0.0", description="Semantic version, defaults to 1.0.0 on creation") description: str | None = Field(None, description="Plugin description") author: PluginAuthor | None = Field(None, description="Plugin author") homepage: str | None = Field(None, description="Plugin homepage URL") @@ -105,11 +106,14 @@ class UpdatePluginRequest(PluginSpec): The plugin name is the resource identity and is supplied as the path parameter, so it cannot be changed here. This is a full replace: omitted - fields reset to their defaults, so version is cleared rather than - defaulting to the create-time "1.0.0". + fields reset to their defaults, except version which automatically increments + the published patch version when omitted or null. """ - version: str | None = Field(None, description="Semantic version; cleared if omitted") + version: str | None = Field(None, description="Explicit higher SemVer, or omit/null for an automatic patch bump") + expected_updated_at: datetime | None = Field( + None, description="Published updated_at read by the editor. A stale value returns 409", exclude=True + ) class PluginResponse(LiteLLMBaseModel): diff --git a/litellm/types/proxy/skill_hub.py b/litellm/types/proxy/skill_hub.py index bf28ce592b3..5a214a12872 100644 --- a/litellm/types/proxy/skill_hub.py +++ b/litellm/types/proxy/skill_hub.py @@ -1,4 +1,5 @@ from collections.abc import Mapping +from datetime import datetime from typing import Literal from pydantic import ConfigDict, Field @@ -13,6 +14,7 @@ class SkillHubSkill(LiteLLMBaseModel): name: str description: str | None = None version: str | None = None + updated_at: datetime | None = None visibility: Literal["public", "private"] source: Mapping[str, str] download_url: str | None = None @@ -34,14 +36,17 @@ class ImportSkillRequest(LiteLLMBaseModel): skill: str | None = Field(None, description="Skill name to select when a repository contains several skills") ref: str = Field("HEAD", description="Git commit, tag or branch to import") path: str | None = Field(None, description="Repository-relative skill directory") - version: str | None = Field(None, description="Version label for the imported snapshot") + version: str | None = Field(None, description="Semantic version, or omit/null to start at 1.0.0") class SkillHubMetadata(LiteLLMBaseModel): model_config = ConfigDict(frozen=True, extra="forbid") description: str | None = Field(None, max_length=1024) - version: str | None = None + version: str | None = Field(None, description="Explicit higher SemVer, or omit/null for an automatic patch bump") + expected_updated_at: datetime | None = Field( + None, description="Published updated_at read by the editor. A stale value returns 409", exclude=True + ) domain: str | None = None namespace: str | None = None keywords: tuple[str, ...] | None = None @@ -49,3 +54,23 @@ class SkillHubMetadata(LiteLLMBaseModel): class SkillHubPublication(LiteLLMBaseModel): model_config = ConfigDict(frozen=True, extra="forbid") + + +class SkillHubVersion(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + id: str + version: str | None + description: str | None + manifest: Mapping[str, object] + published_at: datetime | None + published_by: str | None + content_kind: Literal["stored_archive", "source_reference"] + download_url: str | None + + +class SkillHubVersions(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + versions: tuple[SkillHubVersion, ...] + next_offset: int | None diff --git a/pyproject.toml b/pyproject.toml index 2cc2d0f70a3..ee6cb6c1dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ proxy = [ "pyroscope-io>=0.8.16,<1.0; sys_platform != 'win32'", "expression>=5.6.0,<6.0", "backports-zstd>=1.0.0,<2.0; python_version < '3.14'", + "semver>=3.0.4,<4", ] # Thin client install for the `lite` CLI on developer laptops. The CLI's heavy # imports are all guarded, so it runs on the base SDK plus these packages, and @@ -269,6 +270,7 @@ proxy-dev = [ "a2a-sdk==1.1.0", "backports-zstd==1.8.0; python_version < '3.14'", "httptools>=0.7.1,<0.8", + "semver>=3.0.4,<4", ] ci = [ "psutil==7.2.2", diff --git a/schema.prisma b/schema.prisma index 78f16d9f1fd..7a03193f708 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1616,10 +1616,28 @@ model LiteLLM_ClaudeCodePluginTable { created_by String? uploaded_archive LiteLLM_ClaudeCodePluginUpload? + versions LiteLLM_ClaudeCodePluginVersion[] @@map("LiteLLM_ClaudeCodePluginTable") } +model LiteLLM_ClaudeCodePluginVersion { + id String @id @default(uuid()) + plugin_id String + version String? + description String? + manifest_json String? + published_at DateTime? + published_by String? + archive_available Boolean @default(false) + content Bytes? + plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@unique([plugin_id, version]) + @@index([plugin_id, published_at]) +} + + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/tests/integration/database/test_skill_publication.py b/tests/integration/database/test_skill_publication.py new file mode 100644 index 00000000000..476c6bd4399 --- /dev/null +++ b/tests/integration/database/test_skill_publication.py @@ -0,0 +1,201 @@ +import io +import json +import os +import stat +import uuid +import zipfile +from concurrent.futures import ThreadPoolExecutor +from contextlib import AsyncExitStack, ExitStack +from typing import Final + +import psycopg +import pytest +from psycopg import sql + +from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache +from litellm.proxy.utils import PrismaClient, ProxyLogging +from litellm.repositories.plugin_upload_repository import PluginUploadRepository +from tests.integration._support.client import Gateway, list_value, object_value, string_value +from tests.integration._support.database import read_rows + + +def _archive(name: str) -> bytes: + buffer: Final = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr( + "SKILL.md", f"---\nname: {name}\ndescription: Published instructions\n---\nRetain these exact bytes.\n", + ) + return buffer.getvalue() + + +def _delete(gateway: Gateway, name: str) -> None: + response: Final = gateway.request("DELETE", f"/claude-code/plugins/{name}") + assert response.status_code == 200, response.text + + +@pytest.mark.asyncio +async def test_snapshot_failure_rolls_back_catalog_archive_and_history_then_source_replacement_preserves_bytes( + gateway: Gateway, +) -> None: + name: Final = f"integration-version-{uuid.uuid4().hex}" + path: Final = f"/claude-code/plugins/{name}" + history_path: Final = f"/skill-hub/skills/{name}/versions" + content: Final = _archive(name) + async with AsyncExitStack() as cleanup: + created: Final = gateway.request_multipart( + "/claude-code/plugins/upload", + {"metadata": json.dumps({"name": name})}, + {"file": ("skill.zip", content, "application/zip")}, + ) + assert created.status_code == 200, created.text + cleanup.callback(_delete, gateway, name) + client: Final = PrismaClient(os.environ["DATABASE_URL"], ProxyLogging(UserApiKeyCache())) + await client.connect() + cleanup.push_async_callback(client.disconnect) + repository: Final = PluginUploadRepository(client) + metadata: Final = await repository.get_metadata(name) + assert metadata is not None + snapshot: Final = (await repository.versions(metadata, 100, 0))[0] + snapshot_bytes: Final = await repository.version_content(metadata, snapshot.id) + assert snapshot_bytes is not None + before: Final = gateway.get(path) + history: Final = gateway.get(history_path) + original: Final = object_value(list_value(history["versions"])[0]) + archive_path: Final = string_value(original["download_url"]) + original_response: Final = gateway.request("GET", archive_path) + assert original_response.status_code == 200, original_response.text + published_content: Final = original_response.content + with zipfile.ZipFile(io.BytesIO(published_content)) as published, zipfile.ZipFile(io.BytesIO(content)) as uploaded: + assert published.read("SKILL.md") == uploaded.read("SKILL.md") + current_path: Final = string_value(object_value(before["source"])["url"]) + original_current: Final = gateway.request("GET", current_path) + assert original_current.status_code == 200, original_current.text + witness: Final = f"publication_{uuid.uuid4().hex}" + trigger: Final = f"{witness}_trigger" + function: Final = f"{witness}_function" + with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection, ExitStack() as failure: + connection.execute(sql.SQL("CREATE SEQUENCE {}").format(sql.Identifier(witness))) + failure.callback(connection.execute, sql.SQL("DROP SEQUENCE {}").format(sql.Identifier(witness))) + connection.execute(sql.SQL("GRANT USAGE ON SEQUENCE {} TO PUBLIC").format(sql.Identifier(witness))) + connection.execute( + sql.SQL( + "CREATE FUNCTION {}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN " + "IF EXISTS (SELECT 1 FROM \"LiteLLM_ClaudeCodePluginTable\" " + "WHERE id = NEW.plugin_id AND name = {}) THEN PERFORM nextval({}); " + "RAISE EXCEPTION 'publication snapshot failure'; END IF; RETURN NEW; END $$" + ).format(sql.Identifier(function), sql.Literal(name), sql.Literal(witness)) + ) + failure.callback(connection.execute, sql.SQL("DROP FUNCTION {}()").format(sql.Identifier(function))) + connection.execute( + sql.SQL('CREATE TRIGGER {} BEFORE INSERT ON "LiteLLM_ClaudeCodePluginVersion" ' + "FOR EACH ROW EXECUTE FUNCTION {}()").format(sql.Identifier(trigger), sql.Identifier(function)) + ) + failure.callback( + connection.execute, + sql.SQL('DROP TRIGGER {} ON "LiteLLM_ClaudeCodePluginVersion"').format(sql.Identifier(trigger)), + ) + rejected: Final = gateway.request( + "PUT", path, {"source": {"source": "github", "repo": "example/changed"}, "description": "must roll back"}, + ) + assert rejected.status_code == 500, rejected.text + assert connection.execute( + sql.SQL("SELECT is_called FROM {}").format(sql.Identifier(witness)) + ).fetchone() == (True,) + assert gateway.get(path) == before + assert gateway.get(history_path) == history + current_archive: Final = gateway.request("GET", current_path) + assert current_archive.status_code == 200 and current_archive.content == original_current.content + updated: Final = gateway.request( + "PUT", path, {"source": {"source": "github", "repo": "example/changed"}}, + ) + assert updated.status_code == 200, updated.text + after: Final = gateway.get(history_path) + versions: Final = tuple(object_value(value) for value in list_value(after["versions"])) + assert [version["version"] for version in versions] == ["1.0.1", "1.0.0"] + assert versions[0]["content_kind"] == "source_reference" + assert versions[1]["content_kind"] == "stored_archive" + retained: Final = gateway.request("GET", archive_path) + assert retained.status_code == 200 and retained.content == published_content + assert read_rows( + 'SELECT plugin_name FROM "LiteLLM_ClaudeCodePluginUpload" WHERE plugin_name=%s', (name,) + ) == [] + assert snapshot in await repository.versions(metadata, 100, 0) + assert await repository.version(metadata, snapshot.id) == snapshot + assert await repository.version_content(metadata, snapshot.id) == snapshot_bytes + hidden: Final = gateway.request("POST", f"{path}/disable") + assert hidden.status_code == 200, hidden.text + assert await repository.versions(metadata, 100, 0) == () + assert await repository.version(metadata, snapshot.id) is None + assert await repository.version_content(metadata, snapshot.id) is None + + +def test_neutral_publication_preserves_portable_files_and_atomic_metadata_history(gateway: Gateway) -> None: + name: Final = f"integration-neutral-{uuid.uuid4().hex}" + path: Final = f"/skill-hub/skills/{name}" + files: Final = { + "SKILL.md": f"---\nname: {name}\ndescription: Portable integration fixture.\n---\n\nRead references/notes.md.\n", + "references/notes.md": "Preserve the original supporting file.\n", + "scripts/check.sh": "#!/bin/sh\nprintf 'portable\\n'\n", + } + buffer: Final = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for filename, content in files.items(): + entry: Final = zipfile.ZipInfo(filename) + entry.create_system = 3 + entry.external_attr = (stat.S_IFREG | (0o755 if filename.endswith(".sh") else 0o644)) << 16 + archive.writestr(entry, content) + with ExitStack() as cleanup: + created: Final = gateway.request_multipart( + "/skill-hub/skills/upload", {}, {"file": ("skill.zip", buffer.getvalue(), "application/zip")}, + ) + assert created.status_code == 200, created.text + cleanup.callback(_delete, gateway, name) + assert created.json()["version"] == "1.0.0" + before: Final = gateway.get(path) + assert string_value(before["updated_at"]) == created.json()["updated_at"] + history_before: Final = gateway.get(f"{path}/versions") + first: Final = object_value(list_value(history_before["versions"])[0]) + version_id: Final = string_value(first["id"]) + archive_path: Final = f"{path}/versions/{version_id}/archive" + downloaded: Final = gateway.request("GET", archive_path) + assert downloaded.status_code == 200, downloaded.text + with zipfile.ZipFile(io.BytesIO(downloaded.content)) as portable: + assert {member: portable.read(member).decode() for member in portable.namelist()} == files + assert stat.S_IMODE(portable.getinfo("scripts/check.sh").external_attr >> 16) == 0o755 + assert gateway.get(f"{path}/versions/{version_id}") == first + + def publish(index: int) -> int: + response: Final = gateway.request( + "PUT", path, + {"description": f"Registry edit {index}", "expected_updated_at": before["updated_at"]}, + ) + if response.status_code == 200: + assert string_value(response.json()["updated_at"]) != before["updated_at"] + return response.status_code + + with ThreadPoolExecutor(max_workers=2) as pool: + statuses: Final = tuple(pool.map(publish, range(2))) + assert sorted(statuses) == [200, 409] + assert publish(2) == 409 + current: Final = gateway.get(path) + history_after: Final = gateway.get(f"{path}/versions") + versions: Final = tuple(object_value(value) for value in list_value(history_after["versions"])) + assert [version["version"] for version in versions] == ["1.0.1", "1.0.0"] + latest: Final = versions[0] + manifest: Final = object_value(latest["manifest"]) + assert current["version"] == manifest["version"] == latest["version"] + assert current["description"] == manifest["description"] == latest["description"] + assert manifest["agent_skill"] == object_value(first["manifest"])["agent_skill"] + stored: Final = read_rows( + 'SELECT version, description, manifest_json FROM "LiteLLM_ClaudeCodePluginTable" WHERE name=%s', (name,) + ) + assert len(stored) == 1 + assert stored[0]["version"] == current["version"] + assert json.loads(string_value(stored[0]["manifest_json"])) == manifest + retained: Final = gateway.request("GET", archive_path) + latest_archive: Final = gateway.request("GET", f"{path}/versions/{latest['id']}/archive") + assert retained.content == latest_archive.content == downloaded.content + rejected: Final = gateway.request("PUT", path, {"version": "1.0.0", "description": "rejected"}) + assert rejected.status_code == 400, rejected.text + assert gateway.get(path) == current + assert gateway.get(f"{path}/versions") == history_after 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 ea2fe42a01f..b4a61a2b3e0 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -80,7 +80,7 @@ def _make_mock_prisma(): return record async def _update(where, data): - record = store[where["name"]] + record = store[where["name"]] if "name" in where else next(r for r in store.values() if r.id == where["id"]) for k, v in data.items(): setattr(record, k, v) return record @@ -91,9 +91,20 @@ def _make_mock_prisma(): mock_table.update = AsyncMock(side_effect=_update) mock_client.db.litellm_claudecodeplugintable = mock_table mock_client.writer_db = mock_client.db + mock_client.db.query_raw = AsyncMock(return_value=[]) + mock_client.db.tx.return_value.__aenter__.return_value = mock_client.db + mock_client.db.litellm_claudecodepluginversion.count = AsyncMock(return_value=0) + mock_client.db.litellm_claudecodepluginversion.create = AsyncMock() + mock_client.db.litellm_claudecodepluginupload.find_unique = AsyncMock(return_value=None) + mock_client.db.litellm_claudecodepluginupload.delete_many = AsyncMock(return_value=0) + mock_client.db.litellm_claudecodepluginupload.upsert = AsyncMock() return mock_client +def _uploads() -> PluginUploadRepository: + return PluginUploadRepository(litellm.proxy.proxy_server.prisma_client) + + _USER = UserAPIKeyAuth( user_role=LitellmUserRoles.PROXY_ADMIN, api_key=MASTER_KEY, @@ -131,7 +142,7 @@ async def test_register_plugin_git_subdir_success(): """git-subdir with both url and path fields registers successfully.""" request = RegisterPluginRequest(name="my-monorepo-plugin", source=_GIT_SUBDIR_SOURCE) - response = await register_plugin(request=request, user_api_key_dict=_USER) + response = await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_USER) assert response.status == "success" assert response.action == "created" @@ -149,7 +160,7 @@ async def _read_stored_manifest(name: str) -> dict: async def test_register_plugin_duplicate_name_conflicts(): """A second POST with an existing name returns 409 and leaves the stored plugin untouched.""" name = "my-monorepo-plugin" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), user_api_key_dict=_USER, ) @@ -167,7 +178,7 @@ async def test_register_plugin_duplicate_name_conflicts(): version="2.0.0", ) with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=conflicting, user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=conflicting, user_api_key_dict=_USER) assert exc_info.value.status_code == 409 assert "already exists" in exc_info.value.detail["error"] @@ -182,13 +193,13 @@ async def test_register_plugin_duplicate_name_conflicts(): async def test_update_plugin_replaces_existing_source(): """PUT updates an existing plugin: action=updated and the stored source is replaced.""" name = "my-monorepo-plugin" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), user_api_key_dict=_USER, ) new_source = {"source": "github", "repo": "org/replacement"} - response = await update_plugin( + response = await update_plugin(uploads=_uploads(), plugin_name=name, request=UpdatePluginRequest(source=new_source, version="2.0.0", description="updated"), user_api_key_dict=_USER, @@ -208,13 +219,13 @@ async def test_update_plugin_replaces_existing_source(): async def test_metadata_edits_preserve_uploaded_source_and_reject_a_changed_http_destination() -> None: table: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugintable await table.create(data={"name": _UPLOAD_NAME, "manifest_json": json.dumps({"source": _UPLOAD_SOURCE})}) - request: Final = UpdatePluginRequest(source=_UPLOAD_SOURCE, description="Updated description") - response: Final = await update_plugin(plugin_name=_UPLOAD_NAME, request=request, user_api_key_dict=_USER) - expected: Final = {"name": _UPLOAD_NAME, "source": _UPLOAD_SOURCE, "description": "Updated description"} + 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"} assert response.plugin.description == "Updated description" assert await _read_stored_manifest(_UPLOAD_NAME) == expected with pytest.raises(HTTPException) as error: - await update_plugin( + await update_plugin(uploads=_uploads(), plugin_name=_UPLOAD_NAME, user_api_key_dict=_USER, request=UpdatePluginRequest(source={**_UPLOAD_SOURCE, "url": "http://other.example/skill.zip"}), ) @@ -227,7 +238,7 @@ async def test_metadata_edits_preserve_uploaded_source_and_reject_a_changed_http async def test_update_plugin_not_found(): """PUT on a name that does not exist raises HTTP 404.""" with pytest.raises(HTTPException) as exc_info: - await update_plugin( + await update_plugin(uploads=_uploads(), plugin_name="does-not-exist", request=UpdatePluginRequest(source=_GIT_SUBDIR_SOURCE), user_api_key_dict=_USER, @@ -246,7 +257,7 @@ async def test_register_plugin_create_race_maps_unique_violation_to_409(): table.create = AsyncMock(side_effect=UniqueViolationError({}, message="duplicate name")) with pytest.raises(HTTPException) as exc_info: - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name="racy-plugin", source=_GIT_SUBDIR_SOURCE), user_api_key_dict=_USER, ) @@ -262,7 +273,7 @@ async def test_update_plugin_db_error_maps_to_structured_500(): from prisma.errors import PrismaError name = "my-monorepo-plugin" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), user_api_key_dict=_USER, ) @@ -271,7 +282,7 @@ async def test_update_plugin_db_error_maps_to_structured_500(): table.update = AsyncMock(side_effect=PrismaError("connection lost")) with pytest.raises(HTTPException) as exc_info: - await update_plugin( + await update_plugin(uploads=_uploads(), plugin_name=name, request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}), user_api_key_dict=_USER, @@ -286,16 +297,17 @@ async def test_update_plugin_deleted_mid_update_returns_404(): """A concurrent delete between the find_unique pre-check and the update makes prisma's update return None; that must surface the same 404 as a plain miss, not an AttributeError.""" name = "my-monorepo-plugin" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), user_api_key_dict=_USER, ) table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable - table.update = AsyncMock(return_value=None) + existing: Final = await table.find_unique(where={"name": name}) + table.find_unique = AsyncMock(side_effect=[existing, None]) with pytest.raises(HTTPException) as exc_info: - await update_plugin( + await update_plugin(uploads=_uploads(), plugin_name=name, request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}), user_api_key_dict=_USER, @@ -307,7 +319,7 @@ async def test_update_plugin_deleted_mid_update_returns_404(): @pytest.mark.asyncio async def test_get_marketplace_skips_plugin_with_null_manifest(): - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name="good-plugin", source=_GIT_SUBDIR_SOURCE, version="1.0.0"), user_api_key_dict=_USER, ) @@ -333,7 +345,7 @@ def _granted_user(skills: list[str]) -> UserAPIKeyAuth: async def _register_public_and_private_plugins() -> None: for name, enabled in (("public-skill", True), ("private-skill", False)): - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), user_api_key_dict=_USER, ) @@ -401,7 +413,7 @@ async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(mon @pytest.mark.asyncio @pytest.mark.parametrize("preference", ["available", "auto_install", "required"]) async def test_get_marketplace_emits_installation_preference(preference): - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest( name="s3-skill", source=_ARCHIVE_SOURCE, @@ -434,7 +446,7 @@ def test_plugin_request_rejects_unknown_installation_preference(build_request): @pytest.mark.asyncio async def test_get_marketplace_omits_installation_preference_when_unset(): - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name="manual-install-plugin", source=_GIT_SUBDIR_SOURCE), user_api_key_dict=_USER, ) @@ -447,12 +459,12 @@ async def test_get_marketplace_omits_installation_preference_when_unset(): @pytest.mark.asyncio async def test_update_plugin_propagates_installation_preference_to_get_and_list(): name = "updated-install-plugin" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_ARCHIVE_SOURCE), user_api_key_dict=_USER, ) - response = await update_plugin( + response = await update_plugin(uploads=_uploads(), plugin_name=name, request=UpdatePluginRequest( source=_ARCHIVE_SOURCE, @@ -472,12 +484,12 @@ async def test_update_plugin_propagates_installation_preference_to_get_and_list( @pytest.mark.asyncio async def test_update_plugin_without_installation_preference_clears_it(): name = "cleared-install-plugin" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_ARCHIVE_SOURCE, installation_preference="auto_install"), user_api_key_dict=_USER, ) - await update_plugin( + await update_plugin(uploads=_uploads(), plugin_name=name, request=UpdatePluginRequest(source=_ARCHIVE_SOURCE), user_api_key_dict=_USER, @@ -518,7 +530,7 @@ async def test_register_accepts_the_camel_case_installation_preference_key(): {"name": "s3-skill", "source": _ARCHIVE_SOURCE, "installationPreference": "auto_install"} ) - response = await register_plugin(request=request, user_api_key_dict=_USER) + response = await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_USER) marketplace = json.loads((await get_marketplace(request=MagicMock())).body) assert response.plugin.installation_preference == "auto_install" @@ -536,13 +548,13 @@ _UNPINNED_ARCHIVE_SOURCE = {"source": "archive", "url": "https://artifacts.examp async def _assert_preference_refused(source, preference): with pytest.raises(HTTPException) as register_error: - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name="no-op-plugin", source=source, installation_preference=preference), user_api_key_dict=_USER, ) - await register_plugin(request=RegisterPluginRequest(name="no-op-plugin", source=source), user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name="no-op-plugin", source=source), user_api_key_dict=_USER) with pytest.raises(HTTPException) as update_error: - await update_plugin( + await update_plugin(uploads=_uploads(), plugin_name="no-op-plugin", request=UpdatePluginRequest(source=source, installation_preference=preference), user_api_key_dict=_USER, @@ -568,7 +580,7 @@ async def test_self_installing_preference_needs_the_archive_sha256(preference): @pytest.mark.asyncio async def test_available_preference_is_accepted_on_an_unpinned_archive(): - response = await register_plugin( + response = await register_plugin(uploads=_uploads(), request=RegisterPluginRequest( name="listed-plugin", source=_UNPINNED_ARCHIVE_SOURCE, installation_preference="available" ), @@ -626,7 +638,7 @@ async def test_register_plugin_git_subdir_missing_url(): ) with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=request, user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_USER) assert exc_info.value.status_code == 400 assert "url" in exc_info.value.detail["error"] @@ -641,7 +653,7 @@ async def test_register_plugin_git_subdir_empty_url(): ) with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=request, user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_USER) assert exc_info.value.status_code == 400 assert "url" in exc_info.value.detail["error"] @@ -656,7 +668,7 @@ async def test_register_plugin_git_subdir_missing_path(): ) with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=request, user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_USER) assert exc_info.value.status_code == 400 assert "path" in exc_info.value.detail["error"] @@ -675,7 +687,7 @@ async def test_register_plugin_git_subdir_empty_path(): ) with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=request, user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_USER) assert exc_info.value.status_code == 400 assert "path" in exc_info.value.detail["error"] @@ -703,7 +715,7 @@ async def test_register_plugin_git_subdir_path_traversal(): ) with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=request, user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_USER) assert exc_info.value.status_code == 400 assert "relative" in exc_info.value.detail["error"] @@ -718,7 +730,7 @@ async def test_register_plugin_unknown_source_type(): ) with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=request, user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_USER) assert exc_info.value.status_code == 400 assert "git-subdir" in exc_info.value.detail["error"] @@ -727,7 +739,7 @@ async def test_register_plugin_unknown_source_type(): @pytest.mark.asyncio async def test_archive_source_registers_and_is_served_verbatim_in_marketplace(): - response = await register_plugin( + response = await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER, ) @@ -743,7 +755,7 @@ async def test_archive_source_registers_and_is_served_verbatim_in_marketplace(): async def test_archive_source_without_sha256_is_accepted(): source = {"source": "archive", "url": "https://artifacts.example.com/plugin.zip"} - response = await register_plugin( + response = await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name="unpinned-skill", source=source), user_api_key_dict=_USER, ) @@ -754,12 +766,12 @@ async def test_archive_source_without_sha256_is_accepted(): @pytest.mark.asyncio async def test_update_plugin_to_archive_source(): name = "my-monorepo-plugin" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE), user_api_key_dict=_USER, ) - response = await update_plugin( + response = await update_plugin(uploads=_uploads(), plugin_name=name, request=UpdatePluginRequest(source=_ARCHIVE_SOURCE), user_api_key_dict=_USER, @@ -787,7 +799,7 @@ async def test_update_plugin_to_archive_source(): ) async def test_register_plugin_archive_rejects_malformed_source(source, expected_fragment): with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=RegisterPluginRequest(name="bad-plugin", source=source), user_api_key_dict=_USER) + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name="bad-plugin", source=source), user_api_key_dict=_USER) assert exc_info.value.status_code == 400 assert expected_fragment in exc_info.value.detail["error"] @@ -799,7 +811,7 @@ async def test_register_plugin_rejects_non_admin(): request = RegisterPluginRequest(name="attacker-plugin", source=_GIT_SUBDIR_SOURCE) with pytest.raises(HTTPException) as exc_info: - await register_plugin(request=request, user_api_key_dict=_NON_ADMIN_USER) + await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=_NON_ADMIN_USER) assert exc_info.value.status_code == 403 @@ -811,14 +823,14 @@ async def test_register_plugin_rejects_non_admin(): async def test_update_plugin_rejects_non_admin_overwrite(): """A non-admin key cannot overwrite an existing plugin's source.""" name = "trusted-plugin" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), user_api_key_dict=_USER, ) malicious_source = {"source": "github", "repo": "attacker/malicious-repo"} with pytest.raises(HTTPException) as exc_info: - await update_plugin( + await update_plugin(uploads=_uploads(), plugin_name=name, request=UpdatePluginRequest(source=malicious_source), user_api_key_dict=_NON_ADMIN_USER, @@ -834,7 +846,7 @@ async def test_update_plugin_rejects_non_admin_overwrite(): async def test_enable_disable_delete_plugin_reject_non_admin(): """Non-admin keys cannot enable, disable, or delete catalog entries.""" name = "trusted-plugin-2" - await register_plugin( + await register_plugin(uploads=_uploads(), request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"), user_api_key_dict=_USER, ) @@ -869,7 +881,7 @@ async def test_register_plugin(): user_id="test-user", ) - response: Final = await register_plugin( + response: Final = await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=user_api_key_dict, ) @@ -904,7 +916,7 @@ async def test_get_marketplace(): user_id="test-user", ) - await register_plugin( + await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=user_api_key_dict, ) @@ -946,7 +958,7 @@ async def test_register_plugin_git_subdir(): user_id="test-user", ) - response: Final = await register_plugin( + response: Final = await register_plugin(uploads=_uploads(), request=request, user_api_key_dict=user_api_key_dict, ) @@ -1219,6 +1231,10 @@ async def upload_storage(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[_Uplo plugins.find_unique = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable( id="uploaded-id", name=_UPLOAD_NAME, enabled=True, manifest_json=json.dumps({"source": _UPLOAD_SOURCE}), )) + prisma.writer_db.query_raw = AsyncMock(return_value=[]) + prisma.writer_db.tx.return_value.__aenter__.return_value = prisma.writer_db + prisma.writer_db.litellm_claudecodepluginversion.create = AsyncMock() + archives.create = AsyncMock() archives.find_first = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginUpload( plugin_name=_UPLOAD_NAME, content=Base64.encode(b"stored archive"), )) @@ -1252,7 +1268,8 @@ async def _post_skill_upload( async def test_skill_upload_creates_metadata_and_bytes_atomically_and_rejects_duplicates( upload_storage: _UploadClient, ) -> None: - client, plugins, _ = upload_storage + client, plugins, archives = upload_storage + plugins.find_unique = AsyncMock(return_value=None) buffer: Final = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: archive.writestr("SKILL.md", "Read the personal instructions") @@ -1261,7 +1278,7 @@ async def test_skill_upload_creates_metadata_and_bytes_atomically_and_rejects_du assert response.status_code == 200, response.text plugins.create.assert_awaited_once() stored: Final = plugins.create.await_args.kwargs["data"] - content: Final = stored["uploaded_archive"]["create"]["content"].decode() + content: Final = archives.create.await_args.kwargs["data"]["content"].decode() digest: Final = hashlib.sha256(content).hexdigest() source: Final = { "source": "archive", @@ -1272,7 +1289,7 @@ async def test_skill_upload_creates_metadata_and_bytes_atomically_and_rejects_du 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, - "uploaded_archive": {"create": {"content": Base64.encode(content)}}, + "files_json": "{}", "updated_at": stored["updated_at"], } assert response.json() == {"status": "success", "action": "created", "plugin": { "id": "uploaded-id", "name": _UPLOAD_NAME, "version": "2.0.0", "description": "My notes", @@ -1359,3 +1376,33 @@ async def test_archive_requires_header_credentials_for_private_content( assert response.status_code == status, response.text assert (response.content == b"stored archive") is (status == 200) assert archives.find_first.await_count == int(status == 200) + + +@pytest.mark.asyncio +async def test_published_updates_auto_bump_and_reject_stale_editors() -> None: + from datetime import datetime, timezone + + source: Final = {"source": "github", "repo": "example/skill"} + created: Final = await register_plugin( + uploads=_uploads(), request=RegisterPluginRequest(name="versioned", source=source, version=None), + user_api_key_dict=_USER, + ) + updated: Final = await update_plugin( + uploads=_uploads(), plugin_name="versioned", request=UpdatePluginRequest(source=source), + user_api_key_dict=_USER, + ) + assert (created.plugin.version, updated.plugin.version) == ("1.0.0", "1.0.1") + before: Final = await _read_stored_manifest("versioned") + with pytest.raises(HTTPException) as stale: + await update_plugin( + uploads=_uploads(), plugin_name="versioned", user_api_key_dict=_USER, + request=UpdatePluginRequest(source=source, expected_updated_at=datetime(2000, 1, 1, tzinfo=timezone.utc)), + ) + assert stale.value.status_code == 409 + with pytest.raises(HTTPException) as error: + await update_plugin( + uploads=_uploads(), plugin_name="versioned", user_api_key_dict=_USER, + request=UpdatePluginRequest(source=source, version="1.0.0", description="rejected edit"), + ) + assert error.value.status_code == 400 + assert await _read_stored_manifest("versioned") == before diff --git a/tests/unit/proxy/auth/test_route_checks.py b/tests/unit/proxy/auth/test_route_checks.py index efb4451f2fc..3edf1e1a0ad 100644 --- a/tests/unit/proxy/auth/test_route_checks.py +++ b/tests/unit/proxy/auth/test_route_checks.py @@ -5102,3 +5102,25 @@ def test_every_dashboard_role_can_send_telemetry_ui_events( ) except HTTPException as exc: pytest.fail(f"{user_role.value} was denied {method} {route}: {exc.detail}") + + +@pytest.mark.parametrize("route", [ + "/skill-hub/skills/{name}/versions", + "/skill-hub/skills/{name}/versions/{version_id}", + "/skill-hub/skills/{name}/versions/{version_id}/archive", +]) +def test_member_can_reach_skill_history_visibility_checks(route: str) -> None: + request: Final = MagicMock(spec=Request) + request.method = "GET" + request.query_params = {} + try: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=LiteLLM_UserTable(user_id="reader", user_role=LitellmUserRoles.INTERNAL_USER.value), + _user_role=LitellmUserRoles.INTERNAL_USER, + route=route, + request=request, + valid_token=UserAPIKeyAuth(user_id="reader", user_role=LitellmUserRoles.INTERNAL_USER), + request_data={}, + ) + except HTTPException as exc: + pytest.fail(f"History visibility could not run: {exc.detail}") 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 143d26a3ccb..6d43e65e6f7 100644 --- a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py @@ -72,6 +72,7 @@ class HubClient: archives: MagicMock upstream: MagicMock clock: MagicMock + database: MagicMock @pytest.fixture @@ -83,9 +84,10 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]: prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None) plugins: Final = prisma.db.litellm_claudecodeplugintable archives: Final = prisma.db.litellm_claudecodepluginupload - plugins.create = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable( - id="hub-id", name=NAME, enabled=True, - )) + async def create_row(data: Mapping[str, object]) -> prisma_models.LiteLLM_ClaudeCodePluginTable: + return prisma_models.LiteLLM_ClaudeCodePluginTable.model_validate({"id": "hub-id", "enabled": True, **data}) + + plugins.create = AsyncMock(side_effect=create_row) plugins.find_unique = AsyncMock(return_value=None) plugins.find_many = AsyncMock(return_value=[]) @@ -98,6 +100,15 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]: plugins.update = AsyncMock(side_effect=update_row) plugins.delete = AsyncMock() archives.find_first = AsyncMock(return_value=None) + archives.find_unique = AsyncMock(return_value=None) + archives.create = AsyncMock() + archives.upsert = AsyncMock() + archives.delete_many = AsyncMock() + prisma.db.tx.return_value.__aenter__.return_value = prisma.db + prisma.db.query_raw = AsyncMock(return_value=[]) + prisma.db.litellm_claudecodepluginversion.create = AsyncMock() + prisma.db.litellm_claudecodepluginversion.count = AsyncMock(return_value=0) + prisma.db.litellm_claudecodepluginversion.find_first = AsyncMock(return_value=None) upstream: Final = MagicMock(return_value=httpx.Response(404)) clock: Final = MagicMock(return_value=datetime.now()) limiter: Final = PROXY_MaxParallelRequestsHandler_v3(InternalUsageCache(DualCache()), time_provider=clock) @@ -126,14 +137,16 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]: get_plugin_upload_repository: lambda: PluginUploadRepository(prisma), import_http_client: lambda: source_client, }) - yield HubClient(client, plugins, archives, upstream, clock) + yield HubClient(client, plugins, archives, upstream, clock, prisma.db) -async def _upload(hub: HubClient, content: bytes, key: str | None = MASTER_KEY) -> httpx.Response: +async def _upload( + hub: HubClient, content: bytes, key: str | None = MASTER_KEY, version: str | None = "2.0.0", +) -> httpx.Response: return await hub.client.post( f"{HUB}/upload", headers=_headers(key), - data={"version": "2.0.0"}, + data={"version": version} if version is not None else {}, files={"file": ("notes.zip", content, "application/zip")}, ) @@ -142,13 +155,14 @@ def _stored_upload(hub: HubClient, enabled: bool = True) -> prisma_models.LiteLL data: Final = hub.plugins.create.await_args.kwargs["data"] record: Final = prisma_models.LiteLLM_ClaudeCodePluginTable( id="hub-id", name=data["name"], version=data["version"], description=data["description"], - manifest_json=data["manifest_json"], enabled=enabled, + manifest_json=data["manifest_json"], enabled=enabled, updated_at=data["updated_at"], ) hub.plugins.find_unique.return_value = record hub.plugins.find_many.return_value = [record] hub.archives.find_first.return_value = prisma_models.LiteLLM_ClaudeCodePluginUpload( - plugin_name=record.name, content=data["uploaded_archive"]["create"]["content"], + plugin_name=record.name, content=hub.archives.create.await_args.kwargs["data"]["content"], ) + hub.archives.find_unique.return_value = hub.archives.find_first.return_value return record @@ -176,6 +190,7 @@ async def test_publication_round_trips_both_formats_without_inflating_on_the_req ) ) assert response.status_code == 200, response.text + assert response.json()["version"] == ("2.0.0" if operation == "upload" else "1.0.0") record: Final = _stored_upload(hub) manifest: Final = json.loads(record.manifest_json) monkeypatch.setattr(proxy_server.app, "root_path", root_path) @@ -418,3 +433,100 @@ async def test_publication_limit_is_shared_by_user_and_route_and_resets( assert (await _upload(hub, b"invalid", GRANTED_KEY)).status_code == 422 monkeypatch.setattr(proxy_server.proxy_logging_obj, "proxy_hook_mapping", {}) assert (await _upload(hub, b"invalid")).status_code == 503 + + +@pytest.mark.asyncio +async def test_neutral_metadata_publication_pairs_version_and_preserved_skill_snapshot( + hub: HubClient, monkeypatch: pytest.MonkeyPatch, +) -> None: + created: Final = await _upload(hub, _zip(FILES), version=None) + assert created.status_code == 200, created.text + assert created.json()["version"] == "1.0.0" + original: Final = _stored_upload(hub) + hub.plugins.find_unique.return_value = original.model_copy(update={ + "manifest_json": json.dumps({**json.loads(original.manifest_json), "author": "legacy author"}), + }) + assert (await hub.client.get(f"{HUB}/{NAME}", headers=_headers(MASTER_KEY))).status_code == 200 + monkeypatch.delenv("PROXY_BASE_URL") + hub.client.base_url = httpx.URL("https://public.example") + first: Final = hub.database.litellm_claudecodepluginversion.create.await_args.kwargs["data"] + hub.database.litellm_claudecodepluginversion.count.return_value = 1 + + async def replace_row( + where: Mapping[str, str], data: Mapping[str, object], + ) -> prisma_models.LiteLLM_ClaudeCodePluginTable: + current: Final = original.model_copy(update=dict(data)) + hub.plugins.find_unique.return_value = current + return current + + hub.plugins.update.side_effect = replace_row + updated: Final = await hub.client.put( + f"{HUB}/{NAME}", headers=_headers(MASTER_KEY), + json={"description": "Registry description", "domain": "notes", "namespace": "team", "keywords": ["notes"]}, + ) + assert updated.status_code == 200, updated.text + assert updated.json()["version"] == "1.0.1" + assert updated.json()["download_url"] == f"https://public.example{HUB}/{NAME}/archive" + latest: Final = hub.database.litellm_claudecodepluginversion.create.await_args.kwargs["data"] + manifest: Final = json.loads(latest["manifest_json"]) + assert latest["version"] == manifest["version"] == updated.json()["version"] + assert latest["manifest_json"] == hub.plugins.find_unique.return_value.manifest_json + assert manifest["agent_skill"] == json.loads(original.manifest_json)["agent_skill"] + assert (manifest["domain"], manifest["namespace"], manifest["keywords"]) == ("notes", "team", ["notes"]) + assert first["version"] == "1.0.0" + assert latest["content"].decode() == first["content"].decode() + _assert_portable(await hub.client.get(updated.json()["download_url"])) + + +@pytest.mark.parametrize("key,status", ((MASTER_KEY, 200), (GRANTED_KEY, 200), (PLAIN_KEY, 404))) +@pytest.mark.asyncio +async def test_neutral_history_list_detail_and_portable_download_share_current_visibility( + hub: HubClient, key: str, status: int, +) -> None: + assert (await _upload(hub, _zip(FILES), version=None)).status_code == 200 + original: Final = _stored_upload(hub, enabled=False) + data: Final = hub.database.litellm_claudecodepluginversion.create.await_args.kwargs["data"] + version: Final = prisma_models.LiteLLM_ClaudeCodePluginVersion.model_validate({"id": "retained", **data}) + hub.database.litellm_claudecodepluginversion.find_first.return_value = version + hub.database.query_raw.return_value = [{ + "id": version.id, "version": version.version, "description": version.description, + "manifest": json.loads(version.manifest_json), "published_at": version.published_at, + "published_by": version.published_by, "archive_available": version.archive_available, + }] + listed: Final = await hub.client.get(f"{HUB}/{NAME}/versions", headers=_headers(key)) + detail: Final = await hub.client.get(f"{HUB}/{NAME}/versions/retained", headers=_headers(key)) + downloaded: Final = await hub.client.get(f"{HUB}/{NAME}/versions/retained/archive", headers=_headers(key)) + assert listed.status_code == detail.status_code == downloaded.status_code == status + if status == 200: + assert listed.json() == {"versions": [detail.json()], "next_offset": None} + assert detail.json()["manifest"]["agent_skill"] == json.loads(original.manifest_json)["agent_skill"] + assert detail.json()["download_url"] == f"http://test{HUB}/{NAME}/versions/retained/archive" + _assert_portable(downloaded) + assert downloaded.headers["etag"] == f'"sha256:{hashlib.sha256(downloaded.content).hexdigest()}"' + hub.database.query_raw.return_value = [] + assert (await hub.client.get(f"{HUB}/{NAME}/versions/missing", headers=_headers(key))).status_code == 404 + hub.database.litellm_claudecodepluginversion.find_first.return_value = None + assert (await hub.client.get( + f"{HUB}/{NAME}/versions/missing/archive", headers=_headers(key), + )).status_code == 404 + + +@pytest.mark.asyncio +async def test_neutral_history_labels_external_sources_and_bounds_pagination(hub: HubClient) -> None: + assert (await _upload(hub, _zip(FILES))).status_code == 200 + _stored_upload(hub) + row: Final = { + "id": "reference", "version": "3.0.0", "description": None, + "manifest": {"source": {"source": "github", "repo": "example/skill"}}, + "published_at": None, "published_by": None, "archive_available": False, + } + hub.database.query_raw.return_value = [row, {**row, "id": "older"}] + listed: Final = await hub.client.get(f"{HUB}/{NAME}/versions?limit=1", headers=_headers(MASTER_KEY)) + assert listed.status_code == 200, listed.text + assert listed.json()["next_offset"] == 1 + assert len(listed.json()["versions"]) == 1 + assert listed.json()["versions"][0]["content_kind"] == "source_reference" + assert listed.json()["versions"][0]["download_url"] is None + assert (await hub.client.get( + f"{HUB}/{NAME}/versions?limit=101", headers=_headers(MASTER_KEY), + )).status_code == 422 diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index c17a9ba9371..6591f7184d3 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -1797,25 +1797,39 @@ def test_get_agent_fields_appends_inherited_provider_credential_fields(): @pytest.mark.parametrize("preference", ["auto_install", None]) -def test_public_skill_hub_echoes_registered_installation_preference(preference): +def test_public_skill_hub_echoes_registered_installation_preference(preference: str | None) -> None: + from collections.abc import Mapping + + from prisma.models import LiteLLM_ClaudeCodePluginTable + 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: + return LiteLLM_ClaudeCodePluginTable.model_validate({"id": "plugin-id", "enabled": True, **data}) + + def find_many(where: Mapping[str, object]) -> list[LiteLLM_ClaudeCodePluginTable]: + return [create(table.create.call_args.kwargs["data"])] + table: Final = MagicMock() table.find_unique = AsyncMock(return_value=None) - table.create = AsyncMock(side_effect=lambda data: SimpleNamespace(id="plugin-id", **data)) - table.find_many = AsyncMock( - side_effect=lambda where: [SimpleNamespace(id="plugin-id", **table.create.call_args.kwargs["data"])] - ) + table.create = AsyncMock(side_effect=create) + table.find_many = AsyncMock(side_effect=find_many) prisma: Final = MagicMock() prisma.db.litellm_claudecodeplugintable = table + prisma.writer_db = prisma.db + prisma.db.tx.return_value.__aenter__.return_value = prisma.db + prisma.db.query_raw = AsyncMock(return_value=[]) + prisma.db.litellm_claudecodepluginversion.create = AsyncMock() app: Final = FastAPI() app.include_router(router) with patch("litellm.proxy.proxy_server.prisma_client", prisma): asyncio.run( register_plugin( + uploads=PluginUploadRepository(prisma), request=RegisterPluginRequest( name="security-review", source={ diff --git a/tests/unit/repositories/test_plugin_publication.py b/tests/unit/repositories/test_plugin_publication.py new file mode 100644 index 00000000000..6bd4b179810 --- /dev/null +++ b/tests/unit/repositories/test_plugin_publication.py @@ -0,0 +1,149 @@ +import hashlib +import json +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from typing import Final, cast +from unittest.mock import AsyncMock, MagicMock + +import pytest +from prisma import Base64 +from prisma.models import ( + LiteLLM_ClaudeCodePluginTable, + LiteLLM_ClaudeCodePluginUpload, +) + +from litellm.models.plugin_upload import ( + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + PublishedPlugin, +) +from litellm.repositories.plugin_publication import ( + PluginPublicationTransaction, + publish_plugin, + resolve_plugin_version, +) + + +@pytest.mark.parametrize("requested,current,updating,expected", [ + (None, None, False, "1.0.0"), + (None, "1.0.0", True, "1.0.1"), + (None, "1.2.3-rc.1+build", True, "1.2.4"), + ("2.0.0", "1.9.9", True, "2.0.0"), + ("1.0.0-rc.10", "1.0.0-rc.9", True, "1.0.0-rc.10"), + ("1.0.0", "1.0.0-rc.1", True, "1.0.0"), + ("1.0.0", None, True, "1.0.0"), + ("2.0.0", "legacy", True, "2.0.0"), +]) +def test_published_version_resolution( + requested: str | None, current: str | None, updating: bool, expected: str, +) -> None: + assert resolve_plugin_version(requested, current, updating=updating) == expected + + +@pytest.mark.parametrize("requested,current,kind", [ + ("1.0.0", "1.0.0", "invalid_version"), + ("0.9.9", "1.0.0", "invalid_version"), + ("1.0.0+new", "1.0.0+old", "invalid_version"), + ("1.0.0-rc.1", "1.0.0", "invalid_version"), + ("1.0.0-rc.9", "1.0.0-rc.10", "invalid_version"), + ("1.2", "1.0.0", "invalid_version"), + (None, None, "conflict"), + (None, "legacy", "conflict"), +]) +def test_invalid_or_non_increasing_versions_cannot_publish( + requested: str | None, current: str | None, kind: str, +) -> None: + result: Final = resolve_plugin_version(requested, current, updating=True) + assert isinstance(result, PluginPublicationError) + assert result.kind == kind + + +@pytest.fixture +def publication_tx() -> MagicMock: + content: Final = b"original uploaded bytes" + tx: Final = MagicMock() + tx.query_raw = AsyncMock(return_value=()) + tx.litellm_claudecodeplugintable.find_unique = AsyncMock(return_value=LiteLLM_ClaudeCodePluginTable( + id="skill-id", name="skill", version="1.0.0", enabled=False, + updated_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + manifest_json=json.dumps({"source": {"source": "archive", "sha256": hashlib.sha256(content).hexdigest()}}), + )) + tx.litellm_claudecodepluginupload.find_unique = AsyncMock(return_value=LiteLLM_ClaudeCodePluginUpload( + plugin_name="skill", content=Base64.encode(content), + )) + tx.litellm_claudecodepluginversion.count = AsyncMock(return_value=0) + for table in (tx.litellm_claudecodeplugintable, tx.litellm_claudecodepluginupload, tx.litellm_claudecodepluginversion): + for operation in ("create", "update", "upsert", "delete_many"): + setattr(table, operation, AsyncMock()) + return tx + + +@pytest.mark.parametrize("failure,kind", [ + ("duplicate", "conflict"), ("missing", "not_found"), ("invalid", "invalid_version"), + ("digest", "conflict"), ("identity", "conflict"), ("timestamp", "conflict"), +]) +@pytest.mark.asyncio +async def test_rejected_publication_never_writes_catalog_archive_or_history( + publication_tx: MagicMock, failure: str, kind: str, +) -> None: + current: Final = LiteLLM_ClaudeCodePluginTable.model_validate( + publication_tx.litellm_claudecodeplugintable.find_unique.return_value, + ) + publication_tx.litellm_claudecodeplugintable.find_unique.return_value = None if failure == "missing" else current + expected: Final = None if failure == "duplicate" else PluginPublicationBase( + "old-id" if failure == "identity" else current.id, + current.updated_at - timedelta(seconds=1) if failure == "timestamp" else current.updated_at, + ) + result: Final = await publish_plugin( + cast(PluginPublicationTransaction, publication_tx), + PluginPublication("skill", "0.0.1" if failure == "invalid" else None, "rejected", + json.loads(current.manifest_json), "admin", b"mismatched" if failure == "digest" else None), + expected, + ) + assert isinstance(result, PluginPublicationError) and result.kind == kind + for table in (publication_tx.litellm_claudecodeplugintable, publication_tx.litellm_claudecodepluginupload, + publication_tx.litellm_claudecodepluginversion): + for operation in ("create", "update", "upsert", "delete_many"): + getattr(table, operation).assert_not_awaited() + + +@pytest.mark.parametrize("valid_archive", (True, False)) +@pytest.mark.asyncio +async def test_first_legacy_update_preserves_its_original_snapshot( + publication_tx: MagicMock, valid_archive: bool, +) -> None: + current: Final = LiteLLM_ClaudeCodePluginTable.model_validate( + publication_tx.litellm_claudecodeplugintable.find_unique.return_value, + ).model_copy(update={"version": None}) + publication_tx.litellm_claudecodeplugintable.find_unique.return_value = current + content: Final = b"original uploaded bytes" if valid_archive else b"corrupt legacy bytes" + publication_tx.litellm_claudecodepluginupload.find_unique.return_value = LiteLLM_ClaudeCodePluginUpload( + plugin_name="skill", content=Base64.encode(content), + ) + + 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 + source: Final = { + "source": "archive", "url": "https://external.example.test/skill.zip", + "sha256": hashlib.sha256(b"original uploaded bytes").hexdigest(), + } + result: Final = await publish_plugin( + cast(PluginPublicationTransaction, publication_tx), + PluginPublication("skill", "1.0.0", "replacement", {"source": source}, "admin"), + PluginPublicationBase(current.id, current.updated_at), + ) + assert isinstance(result, PublishedPlugin) and result.version == "1.0.0" and result.enabled is False + original, replacement = publication_tx.litellm_claudecodepluginversion.create.call_args_list + assert original.kwargs["data"] == { + "plugin_id": current.id, "version": None, "description": current.description, + "manifest_json": current.manifest_json, "published_at": current.updated_at, "published_by": None, + "content": Base64.encode(content) if valid_archive else None, "archive_available": valid_archive, + } + assert replacement.kwargs["data"] == { + "plugin_id": current.id, "version": result.version, "description": "replacement", + "manifest_json": json.dumps({"source": source, "version": result.version}), + "published_at": result.updated_at, "published_by": "admin", "content": None, "archive_available": False, + } diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.integration.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.integration.test.tsx new file mode 100644 index 00000000000..7833ea65db6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.integration.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import SkillVersionHistory from "./SkillVersionHistory"; + +const skill = { + id: "versioned-id", + name: "versioned", +}; +const fetchMock = vi.fn(); +beforeEach(() => { + testQueryClient.clear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +it("distinguishes retained bytes from external metadata and paginates publications", async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ + versions: [ + { + id: "new", + version: "1.0.1", + description: "Revised instructions", + manifest: { source: { source: "github", repo: "example/skill" } }, + published_at: null, + published_by: null, + content_kind: "source_reference", + download_url: null, + }, + { + id: "old", + version: "1.0.0", + description: null, + manifest: {}, + published_at: null, + published_by: null, + content_kind: "stored_archive", + download_url: "/skill-hub/skills/versioned/versions/old/archive", + }, + ], + next_offset: 20, + }), + ); + renderWithProviders(); + expect(await screen.findByText("1.0.1")).toBeInTheDocument(); + expect(String(fetchMock.mock.calls[0][0])).toContain("/skill-hub/skills/versioned/versions"); + expect(screen.getByText(/External files may change/)).toBeInTheDocument(); + expect(screen.getByText(/Uploaded ZIP retained/)).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Download retained ZIP" })).toHaveLength(1); + fetchMock.mockResolvedValueOnce(Response.json({ versions: [], next_offset: null })); + fireEvent.click(screen.getByRole("button", { name: "Older" })); + expect(await screen.findByText(/No recorded releases yet/)).toBeInTheDocument(); + expect(screen.queryByText("1.0.1")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Older" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Newer" })).toBeEnabled(); +}); + +it("downloads through the neutral API client and reports a failed retained download", async () => { + fetchMock + .mockResolvedValueOnce( + Response.json({ + versions: [ + { + id: "retained", + version: "1.0.0", + description: null, + manifest: {}, + published_at: null, + published_by: null, + content_kind: "stored_archive", + download_url: "https://proxy.example/skill-hub/skills/versioned/versions/retained/archive", + }, + ], + next_offset: null, + }), + ) + .mockResolvedValueOnce(new Response("Archive unavailable", { status: 503 })); + renderWithProviders(); + fireEvent.click(await screen.findByRole("button", { name: "Download retained ZIP" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not download this version."); + expect(String(fetchMock.mock.calls[1][0])).toContain("/skill-hub/skills/versioned/versions/retained/archive"); + expect(String(fetchMock.mock.calls[1][0])).not.toContain("https://proxy.example"); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx new file mode 100644 index 00000000000..37e677355d3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx @@ -0,0 +1,109 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { downloadBlob } from "@/components/EntityUsageExport/utils"; +import { apiClient } from "@/components/networking"; +import { Button } from "@/components/ui/button"; +import type { components } from "@/lib/http/schema"; + +type Versions = components["schemas"]["SkillHubVersions"]; +type PublishedVersion = components["schemas"]["SkillHubVersion"]; +type VersionedSkill = { id: string; name: string; updated_at?: string | null }; + +function VersionArchive({ + skillName, + version, + accessToken, +}: { + skillName: string; + version: PublishedVersion; + accessToken: string; +}) { + const { t } = useTranslation(); + const [error, setError] = useState(null); + const [downloading, setDownloading] = useState(false); + const download = async () => { + if (!version.download_url || downloading) return; + setDownloading(true); + setError(null); + try { + const content = await apiClient.getBlob( + `/skill-hub/skills/${encodeURIComponent(skillName)}/versions/${encodeURIComponent(version.id)}/archive`, + { accessToken, redirect: "error" }, + ); + downloadBlob(content, `skill-${version.version ?? "legacy"}.zip`); + } catch { + setError(t("claudeCodePlugins.skillDetail.history.downloadFailed")); + } finally { + setDownloading(false); + } + }; + return ( + <> + + {error &&

{error}

} + + ); +} + +export default function SkillVersionHistory({ skill, accessToken }: { skill: VersionedSkill; accessToken: string }) { + const { t } = useTranslation(); + const [offset, setOffset] = useState(0); + const history = useQuery({ + queryKey: ["skill-versions", skill.id, skill.updated_at, accessToken, offset], + queryFn: () => + apiClient.get(`/skill-hub/skills/${encodeURIComponent(skill.name)}/versions`, { + accessToken, + query: { limit: 20, offset }, + }), + }); + return ( +
+

{t("claudeCodePlugins.skillDetail.history.explanation")}

+ {history.isPending &&

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

} + {history.isError &&

{t("claudeCodePlugins.skillDetail.history.failed")}

} + {history.data?.versions.length === 0 &&

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

} + {history.data?.versions.map((version) => ( +
+
+

{version.version ?? t("claudeCodePlugins.skillDetail.history.legacy")}

+ {version.published_at && ( + + )} +
+ {version.description &&

{version.description}

} +

+ {t( + version.content_kind === "stored_archive" + ? "claudeCodePlugins.skillDetail.history.retained" + : "claudeCodePlugins.skillDetail.history.reference", + )} +

+ {version.content_kind === "stored_archive" && ( + + )} +
+ {t("claudeCodePlugins.skillDetail.history.metadata")} +
+              {JSON.stringify(version.manifest, null, 2)}
+            
+
+
+ ))} +
+ + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index 8d247ff21b7..c6e49e0b187 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -15,6 +15,7 @@ import { MARKETPLACE_KEY_PLACEHOLDER, } from "./helpers"; import { Plugin } from "./types"; +import SkillVersionHistory from "./SkillVersionHistory"; const SkillSource: React.FC> = ({ skill, accessToken }) => { const { t } = useTranslation(); @@ -133,6 +134,7 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken }) const tabs = [ { key: "overview", label: t("claudeCodePlugins.skillDetail.tabs.overview") }, { key: "usage", label: t("claudeCodePlugins.skillDetail.tabs.usage") }, + ...(accessToken ? [{ key: "history", label: t("claudeCodePlugins.skillDetail.history.title") }] : []), ]; return ( @@ -175,6 +177,9 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken }) {/* Overview tab */} + {activeTab === "history" && accessToken && ( + + )} {activeTab === "overview" && (
{/* Left column */} diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 89847982082..68472d413eb 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -1728,6 +1728,21 @@ "unknown": "Unknown source" }, "skillDetail": { + "history": { + "title": "Version history", + "explanation": "Published metadata and retained uploads. Updates automatically increment the patch version unless a higher version is specified.", + "loading": "Loading versions…", + "failed": "Could not load version history.", + "empty": "No recorded releases yet. Existing skills start recording history on their next published update.", + "legacy": "Legacy version", + "retained": "Uploaded ZIP retained and verified against its source digest.", + "reference": "Source reference only. External files may change and are not retained by LiteLLM.", + "metadata": "Published metadata", + "download": "Download retained ZIP", + "downloadFailed": "Could not download this version.", + "newer": "Newer", + "older": "Older" + }, "back": "Skills", "tabs": { "overview": "Overview", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index da96e9ffa01..041429c047d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2611,7 +2611,8 @@ export interface paths { * The plugin is identified by its name in the path, which is the resource * identity and cannot be changed here. This is a full replace, not a merge: * the manifest is rebuilt from the request body, so any optional field left - * out is reset to its default (e.g. an omitted version is cleared, not kept). + * out is reset to its default, except version which automatically increments + * the published patch version when omitted or null. * Send the full desired state. * * Returns 404 if no plugin with the given name exists; use @@ -15838,6 +15839,66 @@ export interface paths { patch?: never; trace?: never; }; + "/skill-hub/skills/{name}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Skill Versions + * @description Read published metadata history. External references are not retained artifacts. + */ + get: operations["list_skill_versions_skill_hub_skills__name__versions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/skill-hub/skills/{name}/versions/{version_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Skill Version + * @description Read a retained publication under the current skill's visibility policy. + */ + get: operations["get_skill_version_skill_hub_skills__name__versions__version_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/skill-hub/skills/{name}/versions/{version_id}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download Skill Version + * @description Download a retained publication as a portable ZIP with SKILL.md at its root. + */ + get: operations["download_skill_version_skill_hub_skills__name__versions__version_id__archive_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/spend/calculate": { parameters: { query?: never; @@ -34400,7 +34461,7 @@ export interface components { url: string; /** * Version - * @description Version label for the imported snapshot + * @description Semantic version, or omit/null to start at 1.0.0 */ version?: string | null; }; @@ -43322,7 +43383,7 @@ export interface components { }; /** * Version - * @description Semantic version + * @description Semantic version, defaults to 1.0.0 on creation * @default 1.0.0 */ version: string | null; @@ -46614,11 +46675,19 @@ export interface components { description?: string | null; /** Domain */ domain?: string | null; + /** + * Expected Updated At + * @description Published updated_at read by the editor. A stale value returns 409 + */ + expected_updated_at?: string | null; /** Keywords */ keywords?: string[] | null; /** Namespace */ namespace?: string | null; - /** Version */ + /** + * Version + * @description Explicit higher SemVer, or omit/null for an automatic patch bump + */ version?: string | null; }; /** SkillHubPublication */ @@ -46639,6 +46708,8 @@ export interface components { source: { [key: string]: string; }; + /** Updated At */ + updated_at?: string | null; /** Version */ version?: string | null; /** @@ -46647,6 +46718,37 @@ export interface components { */ visibility: "public" | "private"; }; + /** SkillHubVersion */ + SkillHubVersion: { + /** + * Content Kind + * @enum {string} + */ + content_kind: "stored_archive" | "source_reference"; + /** Description */ + description: string | null; + /** Download Url */ + download_url: string | null; + /** Id */ + id: string; + /** Manifest */ + manifest: { + [key: string]: unknown; + }; + /** Published At */ + published_at: string | null; + /** Published By */ + published_by: string | null; + /** Version */ + version: string | null; + }; + /** SkillHubVersions */ + SkillHubVersions: { + /** Next Offset */ + next_offset: number | null; + /** Versions */ + versions: components["schemas"]["SkillHubVersion"][]; + }; /** SkillReference */ SkillReference: { /** Skill Id */ @@ -49689,8 +49791,8 @@ export interface components { * * The plugin name is the resource identity and is supplied as the path * parameter, so it cannot be changed here. This is a full replace: omitted - * fields reset to their defaults, so version is cleared rather than - * defaulting to the create-time "1.0.0". + * fields reset to their defaults, except version which automatically increments + * the published patch version when omitted or null. */ UpdatePluginRequest: { /** @description Plugin author */ @@ -49710,6 +49812,11 @@ export interface components { * @description Skill domain (e.g., 'Productivity') */ domain?: string | null; + /** + * Expected Updated At + * @description Published updated_at read by the editor. A stale value returns 409 + */ + expected_updated_at?: string | null; /** * Homepage * @description Plugin homepage URL @@ -49743,7 +49850,7 @@ export interface components { }; /** * Version - * @description Semantic version; cleared if omitted + * @description Explicit higher SemVer, or omit/null for an automatic patch bump */ version?: string | null; }; @@ -50326,7 +50433,7 @@ export interface components { namespace?: string | null; /** * Version - * @description Semantic version + * @description Semantic version, defaults to 1.0.0 on creation * @default 1.0.0 */ version: string | null; @@ -74096,6 +74203,104 @@ export interface operations { }; }; }; + list_skill_versions_skill_hub_skills__name__versions_get: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SkillHubVersions"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_skill_version_skill_hub_skills__name__versions__version_id__get: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + version_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SkillHubVersion"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_skill_version_skill_hub_skills__name__versions__version_id__archive_get: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + version_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/zip": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; calculate_spend_spend_calculate_post: { parameters: { query?: never; diff --git a/uv.lock b/uv.lock index 4f36ef20bfb..954b9f90a75 100644 --- a/uv.lock +++ b/uv.lock @@ -5097,6 +5097,7 @@ proxy = [ { name = "restrictedpython" }, { name = "rich" }, { name = "rq" }, + { name = "semver" }, { name = "soundfile" }, { name = "starlette" }, { name = "tomlkit" }, @@ -5258,6 +5259,7 @@ proxy-dev = [ { name = "opentelemetry-sdk" }, { name = "prisma" }, { name = "prometheus-client" }, + { name = "semver" }, { name = "sentry-sdk" }, ] @@ -5365,6 +5367,7 @@ requires-dist = [ { name = "rich", marker = "extra == 'proxy'", specifier = ">=13.9.4,<14.0" }, { name = "rq", marker = "extra == 'proxy'", specifier = ">=2.7.0,<3.0" }, { name = "semantic-router", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.1.15,<1.0" }, + { name = "semver", marker = "extra == 'proxy'", specifier = ">=3.0.4,<4" }, { name = "sentry-sdk", marker = "extra == 'proxy-runtime'", specifier = ">=2.21.0,<3.0" }, { name = "soundfile", marker = "extra == 'proxy'", specifier = ">=0.12.1,<1.0" }, { name = "soundfile", marker = "extra == 'stt-nvidia-riva'", specifier = ">=0.12.1" }, @@ -5490,6 +5493,7 @@ proxy-dev = [ { name = "opentelemetry-sdk", specifier = "==1.45.1" }, { name = "prisma", specifier = "==0.11.0" }, { name = "prometheus-client", specifier = "==0.20.0" }, + { name = "semver", specifier = ">=3.0.4,<4" }, { name = "sentry-sdk", specifier = "==2.21.0" }, ] @@ -10281,6 +10285,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/c7/f4a20292aef9badd277efbb24d697c5b934693fb21e8e490d3ecb0fc83f0/semantic_router-0.1.15-py3-none-any.whl", hash = "sha256:c08978584c73c5ff8e75005202007ac8ee6593d77deaf8c7ec53f71e01e7f757", size = 128102, upload-time = "2026-05-23T12:58:14.295Z" }, ] +[[package]] +name = "semver" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/f5/e1dfe8e1d91c54ce212fd93916eb01fd1c590f413be0a0978c39a97aa1bb/semver-3.1.0.tar.gz", hash = "sha256:14bc073439513d7773662a338f4db9829cf16c12b74b9568e2d2689975fbd7fc", size = 304162, upload-time = "2026-09-12T18:38:14.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl", hash = "sha256:6e4998f8b4762acdfea85f9bfd5b4032e0bb50038dc04bf82011b42d5d1145c5", size = 18783, upload-time = "2026-09-12T18:38:12.457Z" }, +] + [[package]] name = "sentry-sdk" version = "2.21.0" From 7e4ad6eb333071d67c2eb03ce6468f89a0855027 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Thu, 5 Nov 2026 00:56:51 -0800 Subject: [PATCH 02/47] feat(skills)!: add portable submissions and admin review (#50160) --- .../migration.sql | 27 + .../litellm_proxy_extras/schema.prisma | 21 + litellm/integrations/otel/model/spans.py | 1 + litellm/models/plugin_upload.py | 26 + litellm/models/skill_submission.py | 94 ++ litellm/proxy/_lazy_features.py | 8 + litellm/proxy/_lazy_openapi_snapshot.json | 845 +++++++++++++++++- litellm/proxy/_types.py | 13 +- litellm/proxy/deps/repositories.py | 11 + .../skill_hub_endpoints.py | 205 +++-- .../skill_hub_mutations.py | 172 ++++ .../skill_hub_submissions.py | 192 ++++ .../request_size_limit_middleware.py | 16 +- litellm/proxy/proxy_server.py | 6 +- litellm/proxy/schema.prisma | 21 + .../repositories/plugin_upload_repository.py | 17 + .../skill_submission_repository.py | 288 ++++++ litellm/types/proxy/skill_hub.py | 88 +- schema.prisma | 21 + .../database/test_skill_submissions.py | 373 ++++++++ .../test_skill_hub_endpoints.py | 174 +++- ...t_request_body_decompression_middleware.py | 40 +- .../test_request_size_limit_middleware.py | 91 +- tests/unit/proxy/skill_submission_rows.py | 74 ++ ui/litellm-dashboard/eslint-suppressions.json | 7 +- ...laudeCodePluginsPanel.integration.test.tsx | 632 +++++++++++++ .../ClaudeCodePluginsPanel.test.tsx | 46 +- .../_components/ClaudeCodePluginsPanel.tsx | 243 +++-- .../skills/_components/PluginTable.test.tsx | 22 +- .../skills/_components/PluginTable.tsx | 8 +- .../skills/_components/PluginTableColumns.tsx | 61 +- .../skills/_components/SkillSubmissions.tsx | 358 ++++++++ .../add_plugin_form.integration.test.tsx | 502 ++--------- .../skills/_components/add_plugin_form.tsx | 634 +++---------- .../src/app/(dashboard)/skills/page.tsx | 4 +- .../components/AIHub/SkillHubDashboard.tsx | 4 +- .../components/CommandPalette/utils.test.ts | 30 + .../claude_code_plugins/skillForm.test.ts | 62 ++ .../claude_code_plugins/skillForm.ts | 48 + .../skill_detail.integration.test.tsx | 59 +- .../claude_code_plugins/skill_detail.tsx | 68 +- .../components/claude_code_plugins/types.ts | 19 +- .../src/components/leftnav.test.tsx | 8 + .../src/components/leftnav.tsx | 9 +- .../src/components/networking.tsx | 114 ++- ui/litellm-dashboard/src/i18n/locales/en.json | 113 ++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 463 +++++++++- 47 files changed, 5058 insertions(+), 1280 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20261105010000_skill_submissions/migration.sql create mode 100644 litellm/models/skill_submission.py create mode 100644 litellm/proxy/discovery_endpoints/skill_hub_mutations.py create mode 100644 litellm/proxy/discovery_endpoints/skill_hub_submissions.py create mode 100644 litellm/repositories/skill_submission_repository.py create mode 100644 tests/integration/database/test_skill_submissions.py create mode 100644 tests/unit/proxy/skill_submission_rows.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/skills/_components/SkillSubmissions.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/skillForm.test.ts create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/skillForm.ts diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105010000_skill_submissions/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105010000_skill_submissions/migration.sql new file mode 100644 index 00000000000..e7975258ca9 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105010000_skill_submissions/migration.sql @@ -0,0 +1,27 @@ +SET LOCAL lock_timeout = '10s'; + +CREATE TABLE IF NOT EXISTS "LiteLLM_SkillSubmission" ( + "id" TEXT NOT NULL, + "plugin_name" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending_review', + "proposal_json" TEXT NOT NULL, + "archive_content" BYTEA, + "submitted_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "base_plugin_id" TEXT, + "base_updated_at" TIMESTAMP(3), + "reviewed_by" TEXT, + "reviewed_at" TIMESTAMP(3), + "rejection_reason" TEXT, + CONSTRAINT "LiteLLM_SkillSubmission_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_submitted_by_created_at_id_idx" + ON "LiteLLM_SkillSubmission"("submitted_by", "created_at" DESC, "id" DESC); +CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_submitted_by_status_created_at_id_idx" + ON "LiteLLM_SkillSubmission"("submitted_by", "status", "created_at" DESC, "id" DESC); +CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_status_created_at_id_idx" + ON "LiteLLM_SkillSubmission"("status", "created_at" DESC, "id" DESC); +CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_created_at_id_idx" + ON "LiteLLM_SkillSubmission"("created_at" DESC, "id" DESC); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 7a03193f708..f274af70cff 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1638,6 +1638,27 @@ model LiteLLM_ClaudeCodePluginVersion { } +model LiteLLM_SkillSubmission { + id String @id @default(uuid()) + plugin_name String + operation String + status String @default("pending_review") + proposal_json String + archive_content Bytes? + submitted_by String + created_at DateTime @default(now()) + base_plugin_id String? + base_updated_at DateTime? + reviewed_by String? + reviewed_at DateTime? + rejection_reason String? + + @@index([submitted_by, created_at(sort: Desc), id(sort: Desc)]) + @@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)]) + @@index([status, created_at(sort: Desc), id(sort: Desc)]) + @@index([created_at(sort: Desc), id(sort: Desc)]) +} + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/litellm/integrations/otel/model/spans.py b/litellm/integrations/otel/model/spans.py index 9d5b1c66395..d4ee974d1a4 100644 --- a/litellm/integrations/otel/model/spans.py +++ b/litellm/integrations/otel/model/spans.py @@ -342,6 +342,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset( "LiteLLM_ClaudeCodePluginTable", "LiteLLM_ClaudeCodePluginUpload", "LiteLLM_ClaudeCodePluginVersion", + "LiteLLM_SkillSubmission", "LiteLLM_MemoryTable", "LiteLLM_AdaptiveRouterState", "LiteLLM_AdaptiveRouterSession", diff --git a/litellm/models/plugin_upload.py b/litellm/models/plugin_upload.py index 0116401290d..647d9d02016 100644 --- a/litellm/models/plugin_upload.py +++ b/litellm/models/plugin_upload.py @@ -1,6 +1,7 @@ from collections.abc import AsyncIterator, Mapping from dataclasses import dataclass from datetime import datetime +from types import MappingProxyType from typing import Literal, Protocol @@ -8,6 +9,16 @@ def has_agent_skill_metadata(manifest: Mapping[str, object]) -> bool: return isinstance(manifest.get("agent_skill"), dict) +def uploaded_plugin_source(base_url: str, name: str, digest: str) -> Mapping[str, str]: + return MappingProxyType( + { + "source": "archive", + "url": f"{base_url.rstrip('/')}/claude-code/plugins/{name}/archive/{digest}.zip", + "sha256": digest, + } + ) + + @dataclass(frozen=True, slots=True) class PluginUpload: name: str @@ -73,9 +84,24 @@ class PluginVersion: archive_available: bool +@dataclass(frozen=True, slots=True) +class PluginSnapshot: + id: str + name: str + version: str | None + description: str | None + manifest: Mapping[str, object] + enabled: bool + created_at: datetime | None + updated_at: datetime | None + created_by: str | None + + class PluginUploadStore(Protocol): async def create(self, upload: PluginUpload) -> str | None: ... + async def get_plugin(self, name: str) -> PluginSnapshot | None: ... + async def publish( self, publication: PluginPublication, expected: PluginPublicationBase | None = None ) -> PublishedPlugin | PluginPublicationError: ... diff --git a/litellm/models/skill_submission.py b/litellm/models/skill_submission.py new file mode 100644 index 00000000000..af31c48efe5 --- /dev/null +++ b/litellm/models/skill_submission.py @@ -0,0 +1,94 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, Protocol, TypeAlias + +from litellm.models.plugin_upload import ( + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + PublishedPlugin, +) + +SubmissionStatus: TypeAlias = Literal["pending_review", "approved", "rejected"] +SubmissionOperation: TypeAlias = Literal["create", "update"] + + +@dataclass(frozen=True, slots=True) +class SkillProposal: + name: str + version: str | None + description: str | None + manifest: Mapping[str, object] + + +@dataclass(frozen=True, slots=True) +class SkillSubmission: + id: str + plugin_name: str + operation: SubmissionOperation + status: SubmissionStatus + proposal: SkillProposal + has_archive: bool + submitted_by: str + created_at: datetime + base_plugin_id: str | None + base_updated_at: datetime | None + reviewed_by: str | None + reviewed_at: datetime | None + rejection_reason: str | None + + +@dataclass(frozen=True, slots=True) +class SubmissionPage: + submissions: tuple[SkillSubmission, ...] + has_more: bool + + +@dataclass(frozen=True, slots=True) +class SubmissionLimitExceeded: + message: str + + +@dataclass(frozen=True, slots=True) +class SubmissionMissing: + pass + + +@dataclass(frozen=True, slots=True) +class SubmissionConflict: + message: str + + +@dataclass(frozen=True, slots=True) +class SubmissionReviewed: + submission: SkillSubmission + plugin: PublishedPlugin | None + published_source: Mapping[str, str] | None = None + + +SubmissionReviewResult: TypeAlias = SubmissionReviewed | SubmissionMissing | SubmissionConflict | PluginPublicationError + + +class SkillSubmissionStore(Protocol): + async def submit( + self, upload: PluginPublication, submitted_by: str, base: PluginPublicationBase | None + ) -> SkillSubmission | SubmissionLimitExceeded: ... + + async def list( + self, submitted_by: str | None, status: SubmissionStatus | None, limit: int, offset: int + ) -> SubmissionPage: ... + + async def get(self, submission_id: str, submitted_by: str | None) -> SkillSubmission | None: ... + + async def get_archive(self, submission_id: str, submitted_by: str | None) -> bytes | None: ... + + async def review( + self, + submission_id: str, + reviewer: str, + approve: bool, + reason: str | None, + *, + publication_base_url: str | None = None, + ) -> SubmissionReviewResult: ... diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index c8fd9d860a1..51550d4c101 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -48,6 +48,13 @@ def _mount_app(prefix: str, attr_name: str = "app") -> Callable[["FastAPI", obje return _register +def _include_skill_hub(app: "FastAPI", module: object) -> None: + from litellm.proxy.discovery_endpoints.skill_hub_submissions import router + + _include_router()(app, module) + app.include_router(router) + + @dataclass(frozen=True) class LazyFeature: name: str @@ -287,6 +294,7 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = ( name="skill_hub", module_path="litellm.proxy.discovery_endpoints.skill_hub_endpoints", path_prefixes=("/skill-hub",), + register_fn=_include_skill_hub, ), LazyFeature( name="claude_code_marketplace", diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index d8be3f65d55..184439024ec 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -58865,6 +58865,44 @@ "skill_hub": { "components": { "schemas": { + "Body_replace_skill_archive_skill_hub_skills__name__upload_put": { + "properties": { + "expected_updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expected Updated At" + }, + "file": { + "contentMediaType": "application/octet-stream", + "description": "Replacement Agent Skills ZIP with the same declared skill name", + "title": "File", + "type": "string" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "file" + ], + "title": "Body_replace_skill_archive_skill_hub_skills__name__upload_put", + "type": "object" + }, "Body_upload_skill_skill_hub_skills_upload_post": { "properties": { "file": { @@ -58960,6 +58998,41 @@ "title": "ImportSkillRequest", "type": "object" }, + "SkillHubAgentSkill": { + "properties": { + "description": { + "title": "Description", + "type": "string" + }, + "digest": { + "title": "Digest", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Origin" + } + }, + "required": [ + "name", + "description", + "digest", + "origin" + ], + "title": "SkillHubAgentSkill", + "type": "object" + }, "SkillHubList": { "properties": { "count": { @@ -59073,12 +59146,189 @@ "title": "SkillHubMetadata", "type": "object" }, + "SkillHubPendingSkill": { + "properties": { + "action": { + "const": "submitted", + "default": "submitted", + "title": "Action", + "type": "string" + }, + "proposal": { + "$ref": "#/components/schemas/SkillHubProposal" + }, + "status": { + "const": "pending_review", + "default": "pending_review", + "title": "Status", + "type": "string" + }, + "submission_id": { + "title": "Submission Id", + "type": "string" + } + }, + "required": [ + "submission_id", + "proposal" + ], + "title": "SkillHubPendingSkill", + "type": "object" + }, + "SkillHubProposal": { + "properties": { + "agent_skill": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillHubAgentSkill" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + }, + "keywords": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Keywords" + }, + "name": { + "title": "Name", + "type": "string" + }, + "namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Namespace" + }, + "source": { + "additionalProperties": { + "type": "string" + }, + "title": "Source", + "type": "object" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "name", + "description", + "version", + "source" + ], + "title": "SkillHubProposal", + "type": "object" + }, "SkillHubPublication": { "additionalProperties": false, "properties": {}, "title": "SkillHubPublication", "type": "object" }, + "SkillHubRejection": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "maxLength": 2000, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "title": "SkillHubRejection", + "type": "object" + }, + "SkillHubReviewResponse": { + "properties": { + "action": { + "enum": [ + "approved", + "rejected" + ], + "title": "Action", + "type": "string" + }, + "skill": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillHubSkill" + }, + { + "type": "null" + } + ] + }, + "status": { + "const": "success", + "default": "success", + "title": "Status", + "type": "string" + }, + "submission": { + "$ref": "#/components/schemas/SkillHubSubmission" + } + }, + "required": [ + "action", + "submission", + "skill" + ], + "title": "SkillHubReviewResponse", + "type": "object" + }, "SkillHubSkill": { "properties": { "content_url": { @@ -59103,6 +59353,17 @@ ], "title": "Description" }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + }, "download_url": { "anyOf": [ { @@ -59118,10 +59379,35 @@ "title": "Id", "type": "string" }, + "keywords": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Keywords" + }, "name": { "title": "Name", "type": "string" }, + "namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Namespace" + }, "source": { "additionalProperties": { "type": "string" @@ -59170,6 +59456,174 @@ "title": "SkillHubSkill", "type": "object" }, + "SkillHubSubmission": { + "properties": { + "base_skill_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Skill Id" + }, + "base_updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Updated At" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "has_archive": { + "title": "Has Archive", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "operation": { + "enum": [ + "create", + "update" + ], + "title": "Operation", + "type": "string" + }, + "proposal": { + "$ref": "#/components/schemas/SkillHubProposal" + }, + "rejection_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rejection Reason" + }, + "reviewed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reviewed At" + }, + "reviewed_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reviewed By" + }, + "skill_name": { + "title": "Skill Name", + "type": "string" + }, + "status": { + "enum": [ + "pending_review", + "approved", + "rejected" + ], + "title": "Status", + "type": "string" + }, + "submitted_by": { + "title": "Submitted By", + "type": "string" + } + }, + "required": [ + "id", + "skill_name", + "operation", + "status", + "proposal", + "has_archive", + "submitted_by", + "created_at", + "base_skill_id", + "base_updated_at", + "reviewed_by", + "reviewed_at", + "rejection_reason" + ], + "title": "SkillHubSubmission", + "type": "object" + }, + "SkillHubSubmissionDetail": { + "properties": { + "current_skill": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillHubSkill" + }, + { + "type": "null" + } + ] + }, + "submission": { + "$ref": "#/components/schemas/SkillHubSubmission" + } + }, + "required": [ + "submission", + "current_skill" + ], + "title": "SkillHubSubmissionDetail", + "type": "object" + }, + "SkillHubSubmissionList": { + "properties": { + "count": { + "title": "Count", + "type": "integer" + }, + "has_more": { + "title": "Has More", + "type": "boolean" + }, + "submissions": { + "items": { + "$ref": "#/components/schemas/SkillHubSubmission" + }, + "title": "Submissions", + "type": "array" + } + }, + "required": [ + "submissions", + "count", + "has_more" + ], + "title": "SkillHubSubmissionList", + "type": "object" + }, "SkillHubVersion": { "properties": { "content_kind": { @@ -59421,7 +59875,15 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SkillHubSkill" + "anyOf": [ + { + "$ref": "#/components/schemas/SkillHubSkill" + }, + { + "$ref": "#/components/schemas/SkillHubPendingSkill" + } + ], + "title": "Response Import Skill Skill Hub Skills Import Post" } } }, @@ -59468,7 +59930,15 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SkillHubSkill" + "anyOf": [ + { + "$ref": "#/components/schemas/SkillHubSkill" + }, + { + "$ref": "#/components/schemas/SkillHubPendingSkill" + } + ], + "title": "Response Upload Skill Skill Hub Skills Upload Post" } } }, @@ -59583,7 +60053,7 @@ ] }, "put": { - "description": "Replace registry metadata, clearing omitted fields and preserving skill files.", + "description": "Replace registry metadata, preserving files. Members submit an immutable proposal for admin review.", "operationId": "replace_skill_metadata_skill_hub_skills__name__put", "parameters": [ { @@ -59611,7 +60081,15 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SkillHubSkill" + "anyOf": [ + { + "$ref": "#/components/schemas/SkillHubSkill" + }, + { + "$ref": "#/components/schemas/SkillHubPendingSkill" + } + ], + "title": "Response Replace Skill Metadata Skill Hub Skills Name Put" } } }, @@ -59868,6 +60346,72 @@ ] } }, + "/skill-hub/skills/{name}/upload": { + "put": { + "description": "Replace skill files, retaining registry fields. Members propose an update against the original editor timestamp.", + "operationId": "replace_skill_archive_skill_hub_skills__name__upload_put", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_replace_skill_archive_skill_hub_skills__name__upload_put" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillHubSkill" + }, + { + "$ref": "#/components/schemas/SkillHubPendingSkill" + } + ], + "title": "Response Replace Skill Archive Skill Hub Skills Name Upload Put" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Replace Skill Archive", + "tags": [ + "skill_hub" + ] + } + }, "/skill-hub/skills/{name}/versions": { "get": { "description": "Read published metadata history. External references are not retained artifacts.", @@ -60053,6 +60597,299 @@ "skill_hub" ] } + }, + "/skill-hub/submissions": { + "get": { + "description": "Read a page of owned proposals. Proxy admins and admin viewers can read every owner's proposals.", + "operationId": "list_submissions_skill_hub_submissions_get", + "parameters": [ + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "pending_review", + "approved", + "rejected" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillHubSubmissionList" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "List Submissions", + "tags": [ + "skill_hub" + ] + } + }, + "/skill-hub/submissions/{submission_id}": { + "get": { + "description": "Read an immutable proposal and its currently visible published skill.", + "operationId": "get_submission_skill_hub_submissions__submission_id__get", + "parameters": [ + { + "in": "path", + "name": "submission_id", + "required": true, + "schema": { + "title": "Submission Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillHubSubmissionDetail" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Submission", + "tags": [ + "skill_hub" + ] + } + }, + "/skill-hub/submissions/{submission_id}/approve": { + "put": { + "description": "Publish a pending proposal and record its approval atomically. Only proxy admins may review.", + "operationId": "approve_submission_skill_hub_submissions__submission_id__approve_put", + "parameters": [ + { + "in": "path", + "name": "submission_id", + "required": true, + "schema": { + "title": "Submission Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillHubReviewResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Approve Submission", + "tags": [ + "skill_hub" + ] + } + }, + "/skill-hub/submissions/{submission_id}/archive": { + "get": { + "description": "Download an owned proposal as a private portable ZIP. Legacy nonportable archives return 404.", + "operationId": "download_submission_skill_hub_submissions__submission_id__archive_get", + "parameters": [ + { + "in": "path", + "name": "submission_id", + "required": true, + "schema": { + "title": "Submission Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/zip": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Download Submission", + "tags": [ + "skill_hub" + ] + } + }, + "/skill-hub/submissions/{submission_id}/reject": { + "put": { + "description": "Reject a pending proposal without changing the published skill. Only proxy admins may review.", + "operationId": "reject_submission_skill_hub_submissions__submission_id__reject_put", + "parameters": [ + { + "in": "path", + "name": "submission_id", + "required": true, + "schema": { + "title": "Submission Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillHubRejection" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillHubReviewResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Reject Submission", + "tags": [ + "skill_hub" + ] + } } } }, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ce014bccb5d..6c0633fae88 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -449,7 +449,7 @@ class LiteLLMRoutes(enum.Enum): "/v1/traces/{trace_id}/spans/{span_id}/error", ) - litellm_native_routes = [ + skill_hub_routes = ( "/skill-hub/skills", "/skill-hub/skills/upload", "/skill-hub/skills/import", @@ -461,6 +461,15 @@ class LiteLLMRoutes(enum.Enum): "/skill-hub/skills/{name}/versions/{version_id}/archive", "/skill-hub/skills/{name}/publish", "/skill-hub/skills/{name}/unpublish", + "/skill-hub/skills/{name}/upload", + "/skill-hub/submissions", + "/skill-hub/submissions/{submission_id}", + "/skill-hub/submissions/{submission_id}/archive", + "/skill-hub/submissions/{submission_id}/approve", + "/skill-hub/submissions/{submission_id}/reject", + ) + + litellm_native_routes = [ "/rag/ingest", "/v1/rag/ingest", "/rag/query", @@ -709,6 +718,7 @@ class LiteLLMRoutes(enum.Enum): + key_management_routes + mcp_management_routes + list(agent_management_routes) + + list(skill_hub_routes) ) spend_tracking_routes = [ @@ -951,6 +961,7 @@ class LiteLLMRoutes(enum.Enum): # Agent registry - reads are role-scoped and writes are proxy-admin-gated # inside agent_endpoints/endpoints.py *agent_management_routes, + *skill_hub_routes, ] # routes that manage their own allowed/disallowed logic ## Org Admin Routes ## diff --git a/litellm/proxy/deps/repositories.py b/litellm/proxy/deps/repositories.py index 8f929119dee..323cf57dfec 100644 --- a/litellm/proxy/deps/repositories.py +++ b/litellm/proxy/deps/repositories.py @@ -5,11 +5,13 @@ from fastapi import Depends from litellm.models.deployment_warning import DeploymentWarningIgnoreStore from litellm.models.gateway_activity import GatewayActivityStore from litellm.models.plugin_upload import PluginUploadStore +from litellm.models.skill_submission import SkillSubmissionStore from litellm.models.user_banner import UserBannerStore from litellm.proxy.deps.database import DatabaseDep from litellm.repositories.deployment_warning_ignore_repository import DeploymentWarningIgnoreRepository from litellm.repositories.gateway_activity_repository import GatewayActivityRepository from litellm.repositories.plugin_upload_repository import PluginUploadRepository +from litellm.repositories.skill_submission_repository import SkillSubmissionRepository from litellm.repositories.user_banner_repository import UserBannerRepository @@ -43,3 +45,12 @@ async def get_plugin_upload_repository(database: DatabaseDep) -> PluginUploadSto PluginUploadRepositoryDep: TypeAlias = Annotated[PluginUploadStore | None, Depends(get_plugin_upload_repository)] + + +async def get_skill_submission_repository(database: DatabaseDep) -> SkillSubmissionStore | None: + return None if database is None else SkillSubmissionRepository(database.prisma) + + +SkillSubmissionRepositoryDep: TypeAlias = Annotated[ + SkillSubmissionStore | None, Depends(get_skill_submission_repository) +] diff --git a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py index 27a4bd70738..174158a0506 100644 --- a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py +++ b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py @@ -1,5 +1,6 @@ import asyncio import hashlib +from dataclasses import replace from datetime import datetime from typing import Annotated, Final from urllib.parse import quote @@ -14,13 +15,13 @@ from litellm.llms.custom_httpx.http_handler import ( from litellm.models.plugin_upload import ( PluginArchiveMetadata, PluginPublication, - PluginPublicationBase, - PluginPublicationError, + PluginSnapshot, PluginUploadStore, PluginVersion, PublishedPlugin, has_agent_skill_metadata, ) +from litellm.models.skill_submission import SkillSubmission, SkillSubmissionStore from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( MANIFEST_FIELDS, @@ -40,11 +41,17 @@ from litellm.proxy.common_utils.claude_code_marketplace_auth import ( marketplace_key_header_present, ) from litellm.proxy.common_utils.resource_ownership import is_proxy_admin -from litellm.proxy.deps.repositories import PluginUploadRepositoryDep +from litellm.proxy.deps.repositories import PluginUploadRepositoryDep, SkillSubmissionRepositoryDep from litellm.proxy.discovery_endpoints.agent_skills_archive import ( SkillArchive, build_agent_skill_archive, ) +from litellm.proxy.discovery_endpoints.skill_hub_mutations import ( + SkillMutationError, + archive_writer, + write_skill, + write_target, +) from litellm.proxy.discovery_endpoints.skill_import import ( MAX_IMPORT_BYTES, SkillImportError, @@ -52,13 +59,15 @@ from litellm.proxy.discovery_endpoints.skill_import import ( parse_skill_source, select_imported_skill, ) -from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3 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 ( ImportSkillRequest, SkillHubList, SkillHubMetadata, + SkillHubMutationResponse, + SkillHubPendingSkill, + SkillHubProposal, SkillHubPublication, SkillHubSkill, SkillHubVersion, @@ -92,6 +101,9 @@ def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill: name=plugin.name, description=plugin.description, version=plugin.version, + domain=plugin.domain, + 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), visibility="public" if plugin.enabled else "private", source={"source": "archive", "url": _download_url(request, plugin.name)} if local_archive else plugin.source, @@ -135,22 +147,28 @@ async def get_skill( metadata: Final = await store.get_metadata(name) if metadata is None or not (await resolve_skill_visibility(caller)).allows(metadata): raise HTTPException(404, "Skill not found") - return _metadata_response(request, metadata) + return skill_metadata_response(request, metadata) -def _metadata_response(request: Request, metadata: PluginArchiveMetadata) -> SkillHubSkill: +def skill_metadata_response(request: Request, metadata: PluginArchiveMetadata) -> SkillHubSkill: + manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}") return _skill_response( request, - PluginListItem( - id=metadata.id or metadata.name, - name=metadata.name, - description=metadata.description, - version=metadata.version, - source=dict(metadata.source), - has_agent_skill_metadata=metadata.has_agent_skill_metadata, - enabled=metadata.enabled, - created_at=None, - updated_at=metadata.updated_at.isoformat() if metadata.updated_at is not None else None, + PluginListItem.model_validate( + { + "id": metadata.id or metadata.name, + "name": metadata.name, + "description": metadata.description, + "version": metadata.version, + "source": dict(metadata.source), + "has_agent_skill_metadata": metadata.has_agent_skill_metadata, + "enabled": metadata.enabled, + "created_at": None, + "updated_at": metadata.updated_at.isoformat() if metadata.updated_at is not None else None, + "domain": manifest.get("domain"), + "namespace": manifest.get("namespace"), + "keywords": manifest.get("keywords"), + } ), ) @@ -166,70 +184,33 @@ def _require_admin(caller: UserAPIKeyAuth) -> None: raise HTTPException(403, "Only proxy admins may publish skills") -async def skill_publisher( - caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], -) -> UserAPIKeyAuth: - from litellm.proxy.proxy_server import proxy_logging_obj - - _require_admin(caller) - limiter: Final = proxy_logging_obj.get_proxy_hook("parallel_request_limiter") - if not isinstance(limiter, PROXY_MaxParallelRequestsHandler_v3): - raise HTTPException(503, "Skills Hub publishing requires the default v3 rate limiter") - key_identity: Final = caller.api_key or caller.token - identity: Final = ( - f"user:{caller.user_id}" if caller.user_id else f"key:{key_identity}" if key_identity else "local-admin" - ) - result: Final = await limiter.atomic_check_and_increment_by_n( - descriptors=[ - { - "key": "skill_hub_publish", - "value": hashlib.sha256(identity.encode()).hexdigest(), - "rate_limit": {"requests_per_unit": 10, "window_size": 60}, - } - ], - increments=[{"requests": 1}], - parent_otel_span=caller.parent_otel_span, - ) - if result["overall_code"] == "OVER_LIMIT": - raise HTTPException( - 429, "Skills Hub allows 10 uploads or imports per minute per user", headers={"Retry-After": "60"} - ) - return caller - - -@router.put("/{name}", response_model=SkillHubSkill) +@router.put("/{name}", response_model=SkillHubMutationResponse) async def replace_skill_metadata( request: Request, name: str, body: SkillHubMetadata, uploads: PluginUploadRepositoryDep, + submissions: SkillSubmissionRepositoryDep, caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], -) -> SkillHubSkill: - """Replace registry metadata, clearing omitted fields and preserving skill files.""" - _require_admin(caller) - store: Final = _require_store(uploads) - metadata: Final = await store.get_metadata(name) - if metadata is None or metadata.id is None: +) -> SkillHubMutationResponse: + """Replace registry metadata, preserving files. Members submit an immutable proposal for admin review.""" + existing: Final = await write_target(name, True, uploads, caller, body.expected_updated_at) + if isinstance(existing, SkillMutationError): + raise HTTPException(existing.status, existing.message) + if existing is None: raise HTTPException(404, "Skill not found") - if body.expected_updated_at is not None and body.expected_updated_at != metadata.updated_at: - raise HTTPException(409, "The published skill changed. Reload it before publishing an update") - manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}") publication: Final = PluginPublication( name=name, version=body.version, description=body.description, manifest={ - **{key: value for key, value in manifest.items() if key not in _EDITABLE_METADATA}, + **{key: value for key, value in existing.manifest.items() if key not in _EDITABLE_METADATA}, **body.model_dump(exclude_none=True), "name": name, - "source": dict(metadata.source), }, created_by=caller.user_id, ) - published: Final = await store.publish(publication, PluginPublicationBase(metadata.id, metadata.updated_at)) - if isinstance(published, PluginPublicationError): - raise _publication_error(published) - return skill_publication_response(request, publication, published) + return await _mutate(request, publication, existing, _require_store(uploads), submissions, caller) @router.post("/{name}/publish", response_model=SkillHubSkill) @@ -268,14 +249,15 @@ async def remove_skill(name: str, caller: Annotated[UserAPIKeyAuth, Depends(user return Response(status_code=204) -@router.post("/upload", response_model=SkillHubSkill) +@router.post("/upload", response_model=SkillHubMutationResponse) async def upload_skill( request: Request, uploads: PluginUploadRepositoryDep, - caller: Annotated[UserAPIKeyAuth, Depends(skill_publisher)], + submissions: SkillSubmissionRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(archive_writer)], file: Annotated[UploadFile, File(description="Agent Skills ZIP with SKILL.md and optional supporting files")], version: Annotated[str | None, Form()] = None, -) -> SkillHubSkill: +) -> SkillHubMutationResponse: """Publish a portable skill while retaining Claude marketplace compatibility.""" store: Final = _require_store(uploads) content: Final = await file.read(MAX_IMPORT_BYTES + 1) @@ -284,21 +266,22 @@ async def upload_skill( archive: Final = await asyncio.to_thread(build_agent_skill_archive, content) if archive is None: raise HTTPException(422, "Upload a valid Agent Skill with name and description in SKILL.md") - return await _publish(request, store, caller, archive, version, None) + return await _publish(request, store, submissions, caller, archive, version, None) def import_http_client() -> httpx.AsyncClient: return get_async_httpx_client(llm_provider="skill_hub_import").client -@router.post("/import", response_model=SkillHubSkill) +@router.post("/import", response_model=SkillHubMutationResponse) async def import_skill( request: Request, body: ImportSkillRequest, uploads: PluginUploadRepositoryDep, - caller: Annotated[UserAPIKeyAuth, Depends(skill_publisher)], + submissions: SkillSubmissionRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(archive_writer)], client: Annotated[httpx.AsyncClient, Depends(import_http_client)], -) -> SkillHubSkill: +) -> SkillHubMutationResponse: """Import a public skills.sh or GitHub skill as a portable snapshot.""" store: Final = _require_store(uploads) source: Final = parse_skill_source(body.url, skill=body.skill, ref=body.ref, path=body.path) @@ -310,26 +293,89 @@ async def import_skill( archive: Final = await asyncio.to_thread(select_imported_skill, content, source) if isinstance(archive, SkillImportError): raise HTTPException(archive.status, archive.message) - return await _publish(request, store, caller, archive, body.version, body.url) + return await _publish(request, store, submissions, caller, archive, body.version, body.url) async def _publish( request: Request, store: PluginUploadStore, + submissions: SkillSubmissionStore | None, caller: UserAPIKeyAuth, archive: SkillArchive, version: str | None, origin: str | None, -) -> SkillHubSkill: +) -> SkillHubMutationResponse: + existing: Final = await write_target(archive.declared_name or "", False, store, caller) + if isinstance(existing, SkillMutationError): + raise HTTPException(existing.status, existing.message) publication: Final = await prepare_skill_publication(request, archive, version, origin, caller.user_id) - published: Final = await store.publish(publication) - if isinstance(published, PluginPublicationError): - raise _publication_error(published) - return skill_publication_response(request, publication, published) + return await _mutate(request, publication, existing, store, submissions, caller) -def _publication_error(error: PluginPublicationError) -> HTTPException: - return HTTPException({"invalid_version": 400, "conflict": 409, "not_found": 404}[error.kind], error.message) +async def _mutate( + request: Request, + publication: PluginPublication, + existing: PluginSnapshot | None, + store: PluginUploadStore, + submissions: SkillSubmissionStore | None, + caller: UserAPIKeyAuth, +) -> SkillHubMutationResponse: + result: Final = await write_skill(publication, existing, store, submissions, caller) + if isinstance(result, SkillMutationError): + raise HTTPException(result.status, result.message) + if isinstance(result, SkillSubmission): + return SkillHubPendingSkill(submission_id=result.id, proposal=skill_proposal_response(request, result)) + return skill_publication_response(request, publication, result) + + +def skill_proposal_response(request: Request, submission: SkillSubmission) -> SkillHubProposal: + proposal: Final = submission.proposal + return SkillHubProposal.model_validate( + { + **proposal.manifest, + "name": proposal.name, + "description": proposal.description, + "version": proposal.version, + **( + { + "source": { + "source": "archive", + "url": _url(request, f"skill-hub/submissions/{quote(submission.id, safe='')}/archive"), + } + } + if submission.has_archive + else {} + ), + } + ) + + +@router.put("/{name}/upload", response_model=SkillHubMutationResponse) +async def replace_skill_archive( + request: Request, + name: str, + uploads: PluginUploadRepositoryDep, + submissions: SkillSubmissionRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(archive_writer)], + file: Annotated[UploadFile, File(description="Replacement Agent Skills ZIP with the same declared skill name")], + version: Annotated[str | None, Form()] = None, + expected_updated_at: Annotated[datetime | None, Form()] = None, +) -> SkillHubMutationResponse: + """Replace skill files, retaining registry fields. Members propose an update against the original editor timestamp.""" + existing: Final = await write_target(name, True, uploads, caller, expected_updated_at) + if isinstance(existing, SkillMutationError): + raise HTTPException(existing.status, existing.message) + if existing is None: + raise HTTPException(404, "Skill not found") + content: Final = await file.read(MAX_IMPORT_BYTES + 1) + if len(content) > MAX_IMPORT_BYTES: + raise HTTPException(413, "Skill ZIP must be 10 MiB or smaller") + archive: Final = await asyncio.to_thread(build_agent_skill_archive, content) + if archive is None or archive.declared_name != name: + raise HTTPException(422, "Replacement SKILL.md must declare the existing skill name and a description") + prepared: Final = await prepare_skill_publication(request, archive, version, None, caller.user_id) + publication: Final = replace(prepared, manifest={**existing.manifest, **prepared.manifest}) + return await _mutate(request, publication, existing, _require_store(uploads), submissions, caller) def skill_publication_response( @@ -348,6 +394,9 @@ def skill_publication_response( "has_agent_skill_metadata": has_agent_skill_metadata(publication.manifest), "created_at": None, "updated_at": published.updated_at.isoformat(), + "domain": publication.manifest.get("domain"), + "namespace": publication.manifest.get("namespace"), + "keywords": publication.manifest.get("keywords"), } ), ) diff --git a/litellm/proxy/discovery_endpoints/skill_hub_mutations.py b/litellm/proxy/discovery_endpoints/skill_hub_mutations.py new file mode 100644 index 00000000000..63c519923c6 --- /dev/null +++ b/litellm/proxy/discovery_endpoints/skill_hub_mutations.py @@ -0,0 +1,172 @@ +import hashlib +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated, Final + +from fastapi import Depends, HTTPException +from typing_extensions import assert_never + +from litellm.models.plugin_upload import ( + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + PluginSnapshot, + PluginUploadStore, + PublishedPlugin, +) +from litellm.models.skill_submission import ( + SkillSubmission, + SkillSubmissionStore, + SubmissionConflict, + SubmissionLimitExceeded, + SubmissionMissing, + SubmissionReviewed, +) +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility +from litellm.proxy.auth.auth_exception_handler import DB_UNAVAILABLE_FALLBACK_USER_ID +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.resource_ownership import is_proxy_admin +from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3 +from litellm.repositories.plugin_publication import resolve_plugin_version +from litellm.types.proxy.auth.user_roles import LitellmUserRoles + + +@dataclass(frozen=True, slots=True) +class SkillMutationError: + status: int + message: str + + +def writer_error(caller: UserAPIKeyAuth) -> SkillMutationError | None: + if is_proxy_admin(caller): + return None + if ( + not caller.user_id + or caller.user_id == DB_UNAVAILABLE_FALLBACK_USER_ID + or caller.user_role in (None, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY) + ): + return SkillMutationError(403, "Submitting a skill requires a writable user identity.") + return None + + +def submission_owner(caller: UserAPIKeyAuth) -> str | None | SkillMutationError: + if is_proxy_admin(caller) or caller.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY: + return None + if not caller.user_id or caller.user_id == DB_UNAVAILABLE_FALLBACK_USER_ID: + return SkillMutationError(403, "Reading submissions requires a user identity.") + return caller.user_id + + +async def archive_writer(caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)]) -> UserAPIKeyAuth: + from litellm.proxy.proxy_server import proxy_logging_obj + + error: Final = writer_error(caller) + if error is not None: + raise HTTPException(error.status, error.message) + limiter: Final = proxy_logging_obj.get_proxy_hook("parallel_request_limiter") + if not isinstance(limiter, PROXY_MaxParallelRequestsHandler_v3): + raise HTTPException(503, "Skills Hub publishing requires the default v3 rate limiter") + key_identity: Final = caller.api_key or caller.token + identity: Final = ( + f"user:{caller.user_id}" if caller.user_id else f"key:{key_identity}" if key_identity else "local-admin" + ) + result: Final = await limiter.atomic_check_and_increment_by_n( + descriptors=[ + { + "key": "skill_hub_publish", + "value": hashlib.sha256(identity.encode()).hexdigest(), + "rate_limit": {"requests_per_unit": 10, "window_size": 60}, + } + ], + increments=[{"requests": 1}], + parent_otel_span=caller.parent_otel_span, + ) + if result["overall_code"] == "OVER_LIMIT": + raise HTTPException( + 429, "Skills Hub allows 10 uploads or imports per minute per user", headers={"Retry-After": "60"} + ) + return caller + + +async def write_target( + name: str, + updating: bool, + uploads: PluginUploadStore | None, + caller: UserAPIKeyAuth, + expected_updated_at: datetime | None = None, +) -> PluginSnapshot | SkillMutationError | None: + error: Final = writer_error(caller) + if error is not None: + return error + if uploads is None: + return SkillMutationError(503, "Skills Hub requires a connected database") + existing: Final = await uploads.get_plugin(name) + if updating and (existing is None or not (await resolve_skill_visibility(caller)).allows(existing)): + return SkillMutationError(404, "Skill not found") + if existing is not None and expected_updated_at is not None and existing.updated_at != expected_updated_at: + return SkillMutationError(409, "The published skill changed. Reload it before submitting.") + if not updating and existing is not None: + return SkillMutationError(409, f"A skill named '{name}' already exists. Update the existing skill instead.") + return existing + + +def publication_error(error: PluginPublicationError) -> SkillMutationError: + return SkillMutationError({"invalid_version": 400, "conflict": 409, "not_found": 404}[error.kind], error.message) + + +async def write_skill( + publication: PluginPublication, + existing: PluginSnapshot | None, + uploads: PluginUploadStore, + submissions: SkillSubmissionStore | None, + caller: UserAPIKeyAuth, +) -> PublishedPlugin | SkillSubmission | SkillMutationError: + error: Final = writer_error(caller) + if error is not None: + return error + validation: Final = resolve_plugin_version( + publication.version, None if existing is None else existing.version, updating=existing is not None + ) + if isinstance(validation, PluginPublicationError): + return publication_error(validation) + expected: Final = None if existing is None else PluginPublicationBase(existing.id, existing.updated_at) + if is_proxy_admin(caller): + published: Final = await uploads.publish(publication, expected) + return publication_error(published) if isinstance(published, PluginPublicationError) else published + if submissions is None or not caller.user_id: + return SkillMutationError(503, "Skills Hub requires a connected database") + submission: Final = await submissions.submit(publication, caller.user_id, expected) + return ( + SkillMutationError(429, submission.message) if isinstance(submission, SubmissionLimitExceeded) else submission + ) + + +async def review_skill( + submission_id: str, + submissions: SkillSubmissionStore | None, + caller: UserAPIKeyAuth, + approve: bool, + reason: str | None, + publication_base_url: str | None = None, +) -> SubmissionReviewed | SkillMutationError: + if not is_proxy_admin(caller): + return SkillMutationError(403, "Only proxy admins may review skill submissions.") + if submissions is None: + return SkillMutationError(503, "Skills Hub requires a connected database") + result: Final = await submissions.review( + submission_id, caller.user_id or "proxy-admin", approve, reason, publication_base_url=publication_base_url + ) + match result: + case SubmissionMissing(): + return SkillMutationError(404, "Skill submission not found") + case SubmissionConflict(message=message): + return SkillMutationError(409, message) + case PluginPublicationError(kind="invalid_version") as error: + return publication_error(error) + case PluginPublicationError(message=message): + return SkillMutationError(409, message) + case SubmissionReviewed(): + return result + case _: + assert_never(result) diff --git a/litellm/proxy/discovery_endpoints/skill_hub_submissions.py b/litellm/proxy/discovery_endpoints/skill_hub_submissions.py new file mode 100644 index 00000000000..c1bb422b2a0 --- /dev/null +++ b/litellm/proxy/discovery_endpoints/skill_hub_submissions.py @@ -0,0 +1,192 @@ +import asyncio +from typing import Annotated, Final + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response + +from litellm.models.plugin_upload import PluginPublication +from litellm.models.skill_submission import SkillSubmission, SkillSubmissionStore, SubmissionStatus +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.deps.repositories import PluginUploadRepositoryDep, SkillSubmissionRepositoryDep +from litellm.proxy.discovery_endpoints.agent_skills_archive import build_agent_skill_archive +from litellm.proxy.discovery_endpoints.skill_hub_endpoints import ( + skill_metadata_response, + skill_proposal_response, + skill_publication_response, +) +from litellm.proxy.discovery_endpoints.skill_hub_mutations import SkillMutationError, review_skill, submission_owner +from litellm.types.proxy.skill_hub import ( + SkillHubRejection, + SkillHubReviewResponse, + SkillHubSubmission, + SkillHubSubmissionDetail, + SkillHubSubmissionList, +) + +router: Final = APIRouter(prefix="/skill-hub/submissions", tags=["Skills Hub"]) + + +def _store(submissions: SkillSubmissionStore | None) -> SkillSubmissionStore: + if submissions is None: + raise HTTPException(503, "Skills Hub requires a connected database") + return submissions + + +def _owner(caller: UserAPIKeyAuth) -> str | None: + owner: Final = submission_owner(caller) + if isinstance(owner, SkillMutationError): + raise HTTPException(owner.status, owner.message) + return owner + + +def _response(request: Request, submission: SkillSubmission) -> SkillHubSubmission: + return SkillHubSubmission( + id=submission.id, + skill_name=submission.plugin_name, + operation=submission.operation, + status=submission.status, + proposal=skill_proposal_response(request, submission), + has_archive=submission.has_archive, + submitted_by=submission.submitted_by, + created_at=submission.created_at, + base_skill_id=submission.base_plugin_id, + base_updated_at=submission.base_updated_at, + reviewed_by=submission.reviewed_by, + reviewed_at=submission.reviewed_at, + rejection_reason=submission.rejection_reason, + ) + + +@router.get("", response_model=SkillHubSubmissionList) +async def list_submissions( + request: Request, + submissions: SkillSubmissionRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + status: SubmissionStatus | None = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + offset: Annotated[int, Query(ge=0)] = 0, +) -> SkillHubSubmissionList: + """Read a page of owned proposals. Proxy admins and admin viewers can read every owner's proposals.""" + owner: Final = _owner(caller) + page: Final = await _store(submissions).list(owner, status, limit, offset) + return SkillHubSubmissionList( + submissions=tuple(_response(request, row) for row in page.submissions), + count=len(page.submissions), + has_more=page.has_more, + ) + + +@router.get("/{submission_id}", response_model=SkillHubSubmissionDetail) +async def get_submission( + request: Request, + submission_id: str, + submissions: SkillSubmissionRepositoryDep, + uploads: PluginUploadRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> SkillHubSubmissionDetail: + """Read an immutable proposal and its currently visible published skill.""" + owner: Final = _owner(caller) + submission: Final = await _store(submissions).get(submission_id, owner) + if submission is None: + raise HTTPException(404, "Skill submission not found") + if uploads is None: + raise HTTPException(503, "Skills Hub requires a connected database") + current: Final = await uploads.get_metadata(submission.plugin_name) + visible: Final = current is not None and (owner is None or (await resolve_skill_visibility(caller)).allows(current)) + return SkillHubSubmissionDetail( + submission=_response(request, submission), + current_skill=skill_metadata_response(request, current) if current is not None and visible else None, + ) + + +@router.get( + "/{submission_id}/archive", + response_class=Response, + responses={200: {"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}}}}, +) +async def download_submission( + submission_id: str, + submissions: SkillSubmissionRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> Response: + """Download an owned proposal as a private portable ZIP. Legacy nonportable archives return 404.""" + owner: Final = _owner(caller) + store: Final = _store(submissions) + submission: Final = await store.get(submission_id, owner) + if submission is None: + raise HTTPException(404, "Skill submission not found") + content: Final = await store.get_archive(submission_id, owner) + archive: Final = ( + await asyncio.to_thread(build_agent_skill_archive, content, plugin_name=submission.plugin_name) + if content is not None + else None + ) + if archive is None: + raise HTTPException(404, "This submission has no portable Agent Skills archive") + return Response( + archive.content, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{submission.plugin_name}.zip"', + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + "ETag": f'"{archive.digest}"', + }, + ) + + +async def _review( + request: Request, + submission_id: str, + submissions: SkillSubmissionStore | None, + caller: UserAPIKeyAuth, + approve: bool, + reason: str | None, +) -> SkillHubReviewResponse: + from litellm.proxy.utils import get_custom_url + + result: Final = await review_skill( + submission_id, submissions, caller, approve, reason, get_custom_url(str(request.base_url)) + ) + if isinstance(result, SkillMutationError): + raise HTTPException(result.status, result.message) + proposal: Final = result.submission.proposal + publication: Final = PluginPublication( + name=proposal.name, + version=proposal.version, + description=proposal.description, + manifest={ + **proposal.manifest, + **({"source": dict(result.published_source)} if result.published_source else {}), + }, + created_by=result.submission.submitted_by, + ) + return SkillHubReviewResponse( + action="approved" if approve else "rejected", + submission=_response(request, result.submission), + skill=None if result.plugin is None else skill_publication_response(request, publication, result.plugin), + ) + + +@router.put("/{submission_id}/approve", response_model=SkillHubReviewResponse) +async def approve_submission( + request: Request, + submission_id: str, + submissions: SkillSubmissionRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> SkillHubReviewResponse: + """Publish a pending proposal and record its approval atomically. Only proxy admins may review.""" + return await _review(request, submission_id, submissions, caller, True, None) + + +@router.put("/{submission_id}/reject", response_model=SkillHubReviewResponse) +async def reject_submission( + request: Request, + submission_id: str, + submissions: SkillSubmissionRepositoryDep, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + body: SkillHubRejection | None = None, +) -> SkillHubReviewResponse: + """Reject a pending proposal without changing the published skill. Only proxy admins may review.""" + return await _review(request, submission_id, submissions, caller, False, None if body is None else body.reason) diff --git a/litellm/proxy/middleware/request_size_limit_middleware.py b/litellm/proxy/middleware/request_size_limit_middleware.py index f2883168d65..329a3c38d91 100644 --- a/litellm/proxy/middleware/request_size_limit_middleware.py +++ b/litellm/proxy/middleware/request_size_limit_middleware.py @@ -5,6 +5,7 @@ from typing import Final from fastapi import HTTPException from starlette._utils import get_route_path +from starlette.routing import compile_path from starlette.types import ASGIApp, Message, Receive, Scope, Send from litellm.proxy.common_utils.anthropic_error_payload import route_error_content @@ -25,7 +26,20 @@ def effective_max_request_size_mb( ) -> float | None: global_limit: Final = configured if configured is not None and configured > 0 else None method: Final = scope.get("method") - route_limit: Final = route_limits.get((method, get_route_path(scope))) if isinstance(method, str) else None + path: Final = get_route_path(scope) + exact_limit: Final = route_limits.get((method, path)) if isinstance(method, str) else None + route_limit: Final = ( + exact_limit + if exact_limit is not None + else next( + ( + limit + for (route_method, route), limit in route_limits.items() + if route_method == method and "{" in route and compile_path(route)[0].fullmatch(path) is not None + ), + None, + ) + ) if route_limit is None: return global_limit return min(route_limit, global_limit) if global_limit is not None else route_limit diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index debefec4a30..c1af5d6f980 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2934,7 +2934,11 @@ def configured_max_request_size_mb() -> int | float | None: _ROUTE_MAX_REQUEST_SIZE_MB: Final[Mapping[tuple[str, str], float]] = MappingProxyType( - {("POST", "/claude-code/plugins/upload"): 11, ("POST", "/skill-hub/skills/upload"): 11} + { + ("POST", "/claude-code/plugins/upload"): 11, + ("POST", "/skill-hub/skills/upload"): 11, + ("PUT", "/skill-hub/skills/{name}/upload"): 11, + } ) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 7a03193f708..f274af70cff 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1638,6 +1638,27 @@ model LiteLLM_ClaudeCodePluginVersion { } +model LiteLLM_SkillSubmission { + id String @id @default(uuid()) + plugin_name String + operation String + status String @default("pending_review") + proposal_json String + archive_content Bytes? + submitted_by String + created_at DateTime @default(now()) + base_plugin_id String? + base_updated_at DateTime? + reviewed_by String? + reviewed_at DateTime? + rejection_reason String? + + @@index([submitted_by, created_at(sort: Desc), id(sort: Desc)]) + @@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)]) + @@index([status, created_at(sort: Desc), id(sort: Desc)]) + @@index([created_at(sort: Desc), id(sort: Desc)]) +} + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/litellm/repositories/plugin_upload_repository.py b/litellm/repositories/plugin_upload_repository.py index 239a2a1537b..8fa981e5a99 100644 --- a/litellm/repositories/plugin_upload_repository.py +++ b/litellm/repositories/plugin_upload_repository.py @@ -15,6 +15,7 @@ from litellm.models.plugin_upload import ( PluginPublication, PluginPublicationBase, PluginPublicationError, + PluginSnapshot, PluginUpload, PluginVersion, PublishedPlugin, @@ -116,6 +117,22 @@ class PluginUploadRepository: return None return verified_archive(version.content.decode(), version.manifest_json) + async def get_plugin(self, name: str) -> PluginSnapshot | None: + row: Final = await self._plugins.find_unique(where={"name": name}) + if row is None: + return None + return PluginSnapshot( + id=row.id, + name=row.name, + version=row.version, + description=row.description, + manifest=MappingProxyType(_MANIFEST.validate_json(row.manifest_json or "{}")), + enabled=row.enabled, + created_at=row.created_at, + updated_at=row.updated_at, + created_by=row.created_by, + ) + async def get_metadata(self, name: str) -> PluginArchiveMetadata | None: plugin: Final = await self._plugins.find_unique(where={"name": name}) if plugin is None: diff --git a/litellm/repositories/skill_submission_repository.py b/litellm/repositories/skill_submission_repository.py new file mode 100644 index 00000000000..67df032ebae --- /dev/null +++ b/litellm/repositories/skill_submission_repository.py @@ -0,0 +1,288 @@ +import hashlib +from collections.abc import Mapping +from contextlib import AbstractAsyncContextManager +from dataclasses import replace +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # Prisma's generated delegates are dynamic + +from pydantic import BaseModel, ConfigDict, TypeAdapter + +from litellm.models.plugin_upload import ( + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + PublishedPlugin, + uploaded_plugin_source, +) +from litellm.models.skill_submission import ( + SkillProposal, + SkillSubmission, + SubmissionConflict, + SubmissionLimitExceeded, + SubmissionMissing, + SubmissionOperation, + SubmissionPage, + SubmissionReviewed, + SubmissionReviewResult, + SubmissionStatus, +) +from litellm.repositories.plugin_publication import PluginPublicationTransaction, publish_plugin +from litellm.repositories.prisma_protocols import TableActions + +if TYPE_CHECKING: + from prisma import models as prisma_models + + from litellm.proxy.utils import PrismaClient + + +class _StoredProposal(BaseModel): + model_config = ConfigDict(frozen=True) + + manifest: Mapping[str, object] + version: str | None + + def proposal(self, name: str) -> SkillProposal: + return SkillProposal( + name, self.version, _DESCRIPTION.validate_python(self.manifest.get("description")), self.manifest + ) + + +class _PendingUsage(BaseModel): + model_config = ConfigDict(frozen=True) + + count: int + storage_bytes: int + + +class _SubmissionRow(BaseModel): + model_config = ConfigDict(frozen=True) + + id: str + plugin_name: str + operation: SubmissionOperation + status: SubmissionStatus + proposal_json: str + has_archive: bool + submitted_by: str + created_at: datetime + base_plugin_id: str | None + base_updated_at: datetime | None + reviewed_by: str | None + reviewed_at: datetime | None + rejection_reason: str | None + + def submission(self) -> SkillSubmission: + stored: Final = _StoredProposal.model_validate_json(self.proposal_json) + return SkillSubmission( + id=self.id, + plugin_name=self.plugin_name, + operation=self.operation, + status=self.status, + proposal=stored.proposal(self.plugin_name), + has_archive=self.has_archive, + submitted_by=self.submitted_by, + created_at=self.created_at, + base_plugin_id=self.base_plugin_id, + base_updated_at=self.base_updated_at, + reviewed_by=self.reviewed_by, + reviewed_at=self.reviewed_at, + rejection_reason=self.rejection_reason, + ) + + +_ROWS: Final = TypeAdapter(tuple[_SubmissionRow, ...]) +_DESCRIPTION: Final = TypeAdapter[str | None](str | None) +_SOURCE: Final = TypeAdapter(dict[str, str]) +_USAGE: Final = TypeAdapter(tuple[_PendingUsage, ...]) +_MAX_PENDING_SUBMISSIONS: Final = 20 +_MAX_PENDING_BYTES: Final = 50 * 1024 * 1024 +_SELECT: Final = """ +SELECT id, plugin_name, operation, status, proposal_json, + archive_content IS NOT NULL AS has_archive, submitted_by, created_at, + base_plugin_id, base_updated_at, reviewed_by, reviewed_at, rejection_reason +FROM "LiteLLM_SkillSubmission" +""" + + +class SubmissionTransaction(PluginPublicationTransaction, Protocol): + @property + def litellm_skillsubmission(self) -> TableActions["prisma_models.LiteLLM_SkillSubmission"]: ... + + +class SubmissionDatabase(SubmissionTransaction, Protocol): + def tx(self) -> AbstractAsyncContextManager[SubmissionTransaction]: ... + + +def _submission(row: "prisma_models.LiteLLM_SkillSubmission") -> SkillSubmission: + return _SubmissionRow.model_validate( + {**row.model_dump(), "has_archive": row.archive_content is not None} + ).submission() + + +class SkillSubmissionRepository: + def __init__(self, prisma_client: "PrismaClient") -> None: + self._database = cast( # cast-ok: Prisma dynamically exposes generated delegates and transactions + SubmissionDatabase, prisma_client.writer_db + ) + + async def submit( + self, upload: PluginPublication, submitted_by: str, base: PluginPublicationBase | None + ) -> SkillSubmission | SubmissionLimitExceeded: + from prisma import Base64 + + proposal_json: Final = _StoredProposal(manifest=dict(upload.manifest), version=upload.version).model_dump_json() + incoming_bytes: Final = len(proposal_json.encode("utf-8")) + len(upload.content or b"") + async with self._database.tx() as transaction: + await transaction.query_raw( + "SELECT 1 AS locked FROM pg_advisory_xact_lock(hashtext('litellm.skill_submissions'), hashtext($1))", + submitted_by, + ) + usage: Final = _USAGE.validate_python( + await transaction.query_raw( + """SELECT COUNT(*) AS count, + COALESCE(SUM(octet_length(proposal_json) + + COALESCE(octet_length(archive_content), 0)), 0) AS storage_bytes + FROM "LiteLLM_SkillSubmission" WHERE submitted_by = $1 AND status = 'pending_review'""", + submitted_by, + ) + )[0] + if usage.count >= _MAX_PENDING_SUBMISSIONS: + return SubmissionLimitExceeded( + "You already have 20 pending skill submissions. " + "Wait for an admin to review one before submitting another." + ) + if usage.storage_bytes + incoming_bytes > _MAX_PENDING_BYTES: + return SubmissionLimitExceeded( + "Pending skill submissions cannot exceed 50 MiB of stored metadata and archives per user. " + "Wait for an admin review or submit less data." + ) + row: Final = await transaction.litellm_skillsubmission.create( + data={ + "plugin_name": upload.name, + "operation": "create" if base is None else "update", + "proposal_json": proposal_json, + "archive_content": None if upload.content is None else Base64.encode(upload.content), + "submitted_by": submitted_by, + "base_plugin_id": None if base is None else base.id, + "base_updated_at": None if base is None else base.updated_at, + } + ) + return _submission(row) + + async def list( + self, submitted_by: str | None, status: SubmissionStatus | None, limit: int, offset: int + ) -> SubmissionPage: + rows: Final = _ROWS.validate_python( + await self._database.query_raw( + _SELECT + + """WHERE ($1::text IS NULL OR submitted_by = $1) + AND ($2::text IS NULL OR status = $2) + ORDER BY created_at DESC, id DESC LIMIT $3 OFFSET $4""", + submitted_by, + status, + limit + 1, + offset, + ) + ) + return SubmissionPage(tuple(row.submission() for row in rows[:limit]), has_more=len(rows) > limit) + + async def get(self, submission_id: str, submitted_by: str | None) -> SkillSubmission | None: + rows: Final = _ROWS.validate_python( + await self._database.query_raw( + _SELECT + "WHERE id = $1 AND ($2::text IS NULL OR submitted_by = $2)", + submission_id, + submitted_by, + ) + ) + return None if not rows else rows[0].submission() + + async def get_archive(self, submission_id: str, submitted_by: str | None) -> bytes | None: + row: Final = await self._database.litellm_skillsubmission.find_first( + where={"id": submission_id, **({"submitted_by": submitted_by} if submitted_by is not None else {})} + ) + return None if row is None or row.archive_content is None else row.archive_content.decode() + + async def review( + self, + submission_id: str, + reviewer: str, + approve: bool, + reason: str | None, + *, + publication_base_url: str | None = None, + ) -> SubmissionReviewResult: + from prisma.errors import UniqueViolationError + + try: + async with self._database.tx() as transaction: + await transaction.query_raw( + 'SELECT id FROM "LiteLLM_SkillSubmission" WHERE id = $1 FOR UPDATE', submission_id + ) + row: Final = await transaction.litellm_skillsubmission.find_unique(where={"id": submission_id}) + if row is None: + return SubmissionMissing() + submission: Final = _submission(row) + if submission.status != "pending_review": + return SubmissionConflict("This submission has already been reviewed.") + content: Final = None if row.archive_content is None else row.archive_content.decode() + if approve and content is not None and not publication_base_url: + return SubmissionConflict("Uploaded skill approval requires a trusted publication URL.") + source: Final = ( + uploaded_plugin_source(publication_base_url, row.plugin_name, hashlib.sha256(content).hexdigest()) + 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 + if isinstance(publication, PluginPublicationError): + return publication + reviewed_at: Final = datetime.now(timezone.utc) + status: Final[SubmissionStatus] = "approved" if approve else "rejected" + await transaction.litellm_skillsubmission.update( + where={"id": submission_id}, + data={ + "status": status, + "reviewed_by": reviewer, + "reviewed_at": reviewed_at, + "rejection_reason": None if approve else reason, + }, + ) + return SubmissionReviewed( + submission=replace( + submission, + status=status, + reviewed_by=reviewer, + reviewed_at=reviewed_at, + rejection_reason=None if approve else reason, + ), + plugin=publication, + published_source=source if approve else None, + ) + except UniqueViolationError: + return SubmissionConflict("A skill with this name is already published. Submit a new update proposal.") + + +async def _publish_submission( + transaction: PluginPublicationTransaction, + row: "prisma_models.LiteLLM_SkillSubmission", + source: Mapping[str, str], + content: bytes | None, +) -> PublishedPlugin | PluginPublicationError: + stored: Final = _StoredProposal.model_validate_json(row.proposal_json) + proposal: Final = stored.proposal(row.plugin_name) + base: Final = ( + None + if row.operation == "create" + else PluginPublicationBase(id=row.base_plugin_id or "", updated_at=row.base_updated_at) + ) + return await publish_plugin( + transaction, + PluginPublication( + name=row.plugin_name, + version=stored.version, + description=proposal.description, + manifest={**stored.manifest, "source": dict(source)}, + content=content, + created_by=row.submitted_by, + ), + base, + ) diff --git a/litellm/types/proxy/skill_hub.py b/litellm/types/proxy/skill_hub.py index 5a214a12872..f679002cf1d 100644 --- a/litellm/types/proxy/skill_hub.py +++ b/litellm/types/proxy/skill_hub.py @@ -1,9 +1,10 @@ from collections.abc import Mapping from datetime import datetime -from typing import Literal +from typing import Literal, TypeAlias from pydantic import ConfigDict, Field +from litellm.models.skill_submission import SubmissionOperation, SubmissionStatus from litellm.types.llms.base import LiteLLMBaseModel @@ -19,6 +20,9 @@ class SkillHubSkill(LiteLLMBaseModel): source: Mapping[str, str] download_url: str | None = None content_url: str | None = None + domain: str | None = None + namespace: str | None = None + keywords: tuple[str, ...] | None = None class SkillHubList(LiteLLMBaseModel): @@ -74,3 +78,85 @@ class SkillHubVersions(LiteLLMBaseModel): versions: tuple[SkillHubVersion, ...] next_offset: int | None + + +class SkillHubAgentSkill(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + name: str + description: str + digest: str + origin: str | None + + +class SkillHubProposal(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + name: str + description: str | None + version: str | None + domain: str | None = None + namespace: str | None = None + keywords: tuple[str, ...] | None = None + source: Mapping[str, str] + agent_skill: SkillHubAgentSkill | None = None + + +class SkillHubPendingSkill(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + status: Literal["pending_review"] = "pending_review" + action: Literal["submitted"] = "submitted" + submission_id: str + proposal: SkillHubProposal + + +SkillHubMutationResponse: TypeAlias = SkillHubSkill | SkillHubPendingSkill + + +class SkillHubSubmission(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + id: str + skill_name: str + operation: SubmissionOperation + status: SubmissionStatus + proposal: SkillHubProposal + has_archive: bool + submitted_by: str + created_at: datetime + base_skill_id: str | None + base_updated_at: datetime | None + reviewed_by: str | None + reviewed_at: datetime | None + rejection_reason: str | None + + +class SkillHubSubmissionList(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + submissions: tuple[SkillHubSubmission, ...] + count: int + has_more: bool + + +class SkillHubSubmissionDetail(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + submission: SkillHubSubmission + current_skill: SkillHubSkill | None + + +class SkillHubReviewResponse(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + status: Literal["success"] = "success" + action: Literal["approved", "rejected"] + submission: SkillHubSubmission + skill: SkillHubSkill | None + + +class SkillHubRejection(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + reason: str | None = Field(None, max_length=2000) diff --git a/schema.prisma b/schema.prisma index 7a03193f708..f274af70cff 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1638,6 +1638,27 @@ model LiteLLM_ClaudeCodePluginVersion { } +model LiteLLM_SkillSubmission { + id String @id @default(uuid()) + plugin_name String + operation String + status String @default("pending_review") + proposal_json String + archive_content Bytes? + submitted_by String + created_at DateTime @default(now()) + base_plugin_id String? + base_updated_at DateTime? + reviewed_by String? + reviewed_at DateTime? + rejection_reason String? + + @@index([submitted_by, created_at(sort: Desc), id(sort: Desc)]) + @@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)]) + @@index([status, created_at(sort: Desc), id(sort: Desc)]) + @@index([created_at(sort: Desc), id(sort: Desc)]) +} + model LiteLLM_ClaudeCodePluginUpload { plugin_name String @id content Bytes diff --git a/tests/integration/database/test_skill_submissions.py b/tests/integration/database/test_skill_submissions.py new file mode 100644 index 00000000000..ae5daedef0d --- /dev/null +++ b/tests/integration/database/test_skill_submissions.py @@ -0,0 +1,373 @@ +import asyncio +import hashlib +import io +import os +import uuid +import zipfile +from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import dataclass +from typing import Final, Literal + +import psycopg +import pytest +import pytest_asyncio +from pydantic import JsonValue, TypeAdapter + +from litellm.models.plugin_upload import ( + PluginPublication, + PluginPublicationBase, + PluginPublicationError, + PluginUploadStore, + PublishedPlugin, +) +from litellm.models.skill_submission import ( + SkillSubmission, + SkillSubmissionStore, + SubmissionConflict, + SubmissionLimitExceeded, + SubmissionReviewed, +) +from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache +from litellm.proxy.utils import PrismaClient, ProxyLogging +from litellm.repositories.plugin_upload_repository import PluginUploadRepository +from litellm.repositories.skill_submission_repository import SkillSubmissionRepository +from tests.integration._support.client import eventually +from tests.integration._support.database import read_rows, write_rows + + +@dataclass(frozen=True, slots=True) +class PendingUpdate: + publications: PluginUploadStore + submissions: SkillSubmissionStore + original: PluginPublication + proposal: PluginPublication + base: PublishedPlugin + submission: SkillSubmission + + +def _publication(name: str, description: str, base_url: str = "https://skills.example.test") -> PluginPublication: + buffer: Final = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("SKILL.md", f"---\nname: {name}\ndescription: {description}\n---\n{description}\n") + content: Final = buffer.getvalue() + digest: Final = hashlib.sha256(content).hexdigest() + return PluginPublication( + name=name, + version=None, + description=description, + manifest={ + "name": name, + "description": description, + "extension": {"retained": True}, + "source": { + "source": "archive", + "url": f"{base_url}/claude-code/plugins/{name}/archive/{digest}.zip", + "sha256": digest, + }, + }, + content=content, + created_by=f"{name}-owner", + ) + + +def _published_rows(name: str) -> Sequence[Mapping[str, JsonValue]]: + return read_rows( + """SELECT 'catalog' AS storage, to_jsonb(p) AS value + FROM "LiteLLM_ClaudeCodePluginTable" p WHERE name=%s + UNION ALL + SELECT 'archive', to_jsonb(a) FROM "LiteLLM_ClaudeCodePluginUpload" a WHERE plugin_name=%s + UNION ALL + SELECT 'history', to_jsonb(v) FROM "LiteLLM_ClaudeCodePluginVersion" v + JOIN "LiteLLM_ClaudeCodePluginTable" p ON p.id=v.plugin_id WHERE p.name=%s + ORDER BY storage, value""", + (name, name, name), + ) + + +@pytest_asyncio.fixture(loop_scope="function") +async def pending_update() -> AsyncIterator[PendingUpdate]: + name: Final = f"integration-submission-{uuid.uuid4().hex}" + client: Final = PrismaClient(os.environ["DATABASE_URL"], ProxyLogging(UserApiKeyCache())) + await client.connect() + try: + publications: Final = PluginUploadRepository(client) + submissions: Final = SkillSubmissionRepository(client) + original: Final = _publication(name, "Published instructions") + base: Final = await publications.publish(original) + assert isinstance(base, PublishedPlugin) + proposal: Final = _publication(name, "Proposed instructions", "https://member.example.test") + submission: Final = await submissions.submit( + proposal, f"{name}-owner", PluginPublicationBase(base.id, base.updated_at) + ) + assert isinstance(submission, SkillSubmission) + yield PendingUpdate(publications, submissions, original, proposal, base, submission) + finally: + try: + write_rows('DELETE FROM "LiteLLM_SkillSubmission" WHERE plugin_name=%s', (name,)) + write_rows('DELETE FROM "LiteLLM_ClaudeCodePluginTable" WHERE name=%s', (name,)) + finally: + await client.disconnect() + + +async def test_approval_commits_version_archive_history_and_review(pending_update: PendingUpdate) -> None: + case: Final = pending_update + before: Final = await case.publications.get_metadata(case.original.name) + assert before is not None + assert await case.publications.get_content(before) == case.original.content + assert case.submission.proposal.version is None + reviewed: Final = await case.submissions.review( + case.submission.id, "integration-reviewer", True, None, publication_base_url="https://skills.example.test" + ) + assert isinstance(reviewed, SubmissionReviewed) + assert reviewed.plugin is not None and reviewed.plugin.version == "1.0.1" + stored: Final = await case.submissions.get(case.submission.id, None) + assert stored is not None + assert (stored.status, stored.reviewed_by, stored.rejection_reason) == ( + "approved", + "integration-reviewer", + None, + ) + assert stored.reviewed_at is not None + assert stored.proposal == case.submission.proposal + assert await case.submissions.get_archive(stored.id, None) == case.proposal.content + current: Final = await case.publications.get_plugin(case.original.name) + assert current is not None + published_manifest: Final = { + **case.proposal.manifest, + "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/" + f"{hashlib.sha256(case.proposal.content or b'').hexdigest()}.zip", + }, + } + assert (current.id, current.version, current.description, current.manifest) == ( + case.base.id, + reviewed.plugin.version, + case.proposal.description, + {**published_manifest, "version": reviewed.plugin.version}, + ) + metadata: Final = await case.publications.get_metadata(case.original.name) + assert metadata is not None + 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), + ) + for version, publication, manifest in zip( + history, (case.proposal, case.original), (published_manifest, case.original.manifest), 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 + + +async def test_simultaneous_approve_reject_has_one_winner(pending_update: PendingUpdate) -> None: + case: Final = pending_update + before: Final = _published_rows(case.original.name) + with psycopg.connect(os.environ["DATABASE_URL"]) as blocker: + blocker.execute('SELECT id FROM "LiteLLM_SkillSubmission" WHERE id=%s FOR UPDATE', (case.submission.id,)) + blocker_pid: Final = str(blocker.info.backend_pid) + reviews: Final = ( + asyncio.create_task( + case.submissions.review( + case.submission.id, + "integration-approver", + True, + None, + publication_base_url="https://skills.example.test", + ) + ), + asyncio.create_task( + case.submissions.review(case.submission.id, "integration-rejector", False, "Needs revision") + ), + ) + try: + await asyncio.to_thread( + eventually, + lambda: read_rows( + """WITH RECURSIVE blocked AS ( + SELECT %s::integer AS pid + UNION + SELECT a.pid FROM pg_stat_activity a + JOIN blocked b ON b.pid = ANY(pg_blocking_pids(a.pid)) + ) SELECT pid FROM blocked WHERE pid<>%s::integer""", + (blocker_pid, blocker_pid), + ), + lambda rows: len(rows) == 2, + seconds=3, + ) + finally: + blocker.rollback() + results: Final = await asyncio.gather(*reviews) + winners: Final = tuple(result for result in results if isinstance(result, SubmissionReviewed)) + assert len(winners) == 1 + assert sum(isinstance(result, SubmissionConflict) for result in results) == 1 + stored: Final = await case.submissions.get(case.submission.id, None) + assert stored is not None and stored.reviewed_at is not None + winner: Final = winners[0] + assert (stored.status, stored.reviewed_by, stored.rejection_reason) == ( + winner.submission.status, + winner.submission.reviewed_by, + winner.submission.rejection_reason, + ) + if isinstance(results[0], SubmissionConflict): + assert (stored.status, stored.reviewed_by, stored.rejection_reason) == ( + "rejected", + "integration-rejector", + "Needs revision", + ) + assert _published_rows(case.original.name) == before + return + assert (stored.status, stored.reviewed_by, stored.rejection_reason) == ( + "approved", + "integration-approver", + None, + ) + metadata: Final = await case.publications.get_metadata(case.original.name) + assert metadata is not None + assert await case.publications.get_content(metadata) == case.proposal.content + assert tuple(version.version for version in await case.publications.versions(metadata, 10, 0)) == ( + "1.0.1", + "1.0.0", + ) + + +async def test_publication_failure_rolls_back_and_leaves_submission_pending(pending_update: PendingUpdate) -> None: + case: Final = pending_update + collision_id: Final = str(uuid.uuid4()) + write_rows( + """INSERT INTO "LiteLLM_ClaudeCodePluginVersion" (id, plugin_id, version) + VALUES (%s, %s, '1.0.1')""", + (collision_id, case.base.id), + ) + before: Final = _published_rows(case.original.name) + failed: Final = await case.submissions.review( + case.submission.id, "integration-reviewer", True, None, publication_base_url="https://skills.example.test" + ) + assert isinstance(failed, SubmissionConflict) + assert _published_rows(case.original.name) == before + assert await case.submissions.get(case.submission.id, None) == case.submission + assert await case.submissions.get_archive(case.submission.id, None) == case.proposal.content + write_rows('DELETE FROM "LiteLLM_ClaudeCodePluginVersion" WHERE id=%s', (collision_id,)) + retried: Final = await case.submissions.review( + case.submission.id, "integration-reviewer", True, None, publication_base_url="https://skills.example.test" + ) + assert isinstance(retried, SubmissionReviewed) + assert retried.plugin is not None and retried.plugin.version == "1.0.1" + stored: Final = await case.submissions.get(case.submission.id, None) + assert stored is not None and stored.status == "approved" + + +@pytest.mark.parametrize("change", ("update", "recreate")) +async def test_stale_target_cannot_publish( + pending_update: PendingUpdate, change: Literal["update", "recreate"] +) -> None: + case: Final = pending_update + intervening: Final = _publication(case.original.name, "Intervening publication") + if change == "recreate": + write_rows('DELETE FROM "LiteLLM_ClaudeCodePluginTable" WHERE name=%s', (case.original.name,)) + replacement: Final = await case.publications.publish( + intervening, + PluginPublicationBase(case.base.id, case.base.updated_at) if change == "update" else None, + ) + assert isinstance(replacement, PublishedPlugin) + before: Final = _published_rows(case.original.name) + reviewed: Final = await case.submissions.review( + case.submission.id, "integration-reviewer", True, None, publication_base_url="https://skills.example.test" + ) + assert isinstance(reviewed, PluginPublicationError) and reviewed.kind == "conflict" + assert _published_rows(case.original.name) == before + assert await case.submissions.get(case.submission.id, None) == case.submission + + +async def test_concurrent_submissions_share_user_limit_and_review_releases_capacity( + pending_update: PendingUpdate, +) -> None: + case: Final = pending_update + owner: Final = case.submission.submitted_by + base: Final = PluginPublicationBase(case.base.id, case.base.updated_at) + for _ in range(18): + assert isinstance(await case.submissions.submit(case.proposal, owner, base), SkillSubmission) + peer: Final = PrismaClient(os.environ["DATABASE_URL"], ProxyLogging(UserApiKeyCache())) + await peer.connect() + try: + with psycopg.connect(os.environ["DATABASE_URL"]) as blocker: + blocker.execute( + "SELECT pg_advisory_xact_lock(hashtext('litellm.skill_submissions'), hashtext(%s))", (owner,) + ) + blocker_pid: Final = str(blocker.info.backend_pid) + requests: Final = ( + asyncio.create_task(case.submissions.submit(case.proposal, owner, base)), + asyncio.create_task(SkillSubmissionRepository(peer).submit(case.proposal, owner, base)), + ) + try: + await asyncio.to_thread( + eventually, + lambda: read_rows( + "SELECT pid FROM pg_stat_activity WHERE %s::integer = ANY(pg_blocking_pids(pid))", + (blocker_pid,), + ), + lambda rows: len(rows) == 2, + seconds=3, + ) + finally: + blocker.rollback() + results: Final = await asyncio.gather(*requests) + finally: + await peer.disconnect() + assert sum(isinstance(result, SkillSubmission) for result in results) == 1 + assert sum(isinstance(result, SubmissionLimitExceeded) for result in results) == 1 + full: Final = await case.submissions.list(owner, "pending_review", 20, 0) + assert (len(full.submissions), full.has_more) == (20, False) + other: Final = await case.submissions.submit(case.proposal, f"{owner}-other", base) + assert isinstance(other, SkillSubmission) + rejected: Final = await case.submissions.review( + case.submission.id, "integration-reviewer", False, "Review complete" + ) + assert isinstance(rejected, SubmissionReviewed) + assert await case.submissions.get_archive(case.submission.id, owner) == case.proposal.content + replacement: Final = await case.submissions.submit(case.proposal, owner, base) + assert isinstance(replacement, SkillSubmission) + after: Final = await case.submissions.list(owner, "pending_review", 20, 0) + assert len(after.submissions) == 20 + assert all(row.id != case.submission.id and row.submitted_by == owner for row in after.submissions) + + +async def test_submission_pages_filter_owner_and_status_before_limit(pending_update: PendingUpdate) -> None: + case: Final = pending_update + owner: Final = case.submission.submitted_by + other: Final = f"{owner}-other" + generated: Final = await asyncio.gather( + *( + case.submissions.submit(case.proposal, identity, PluginPublicationBase(case.base.id, case.base.updated_at)) + for identity in (owner, other, owner, other) + ) + ) + submitted: Final = tuple(result for result in generated if isinstance(result, SkillSubmission)) + assert len(submitted) == 4 + rejected: Final = await case.submissions.review(submitted[0].id, "integration-reviewer", False, None) + assert isinstance(rejected, SubmissionReviewed) + write_rows( + """UPDATE "LiteLLM_SkillSubmission" SET created_at=TIMESTAMP '9999-01-01' + WHERE plugin_name=%s""", + (case.original.name,), + ) + own_pending: Final = tuple(sorted((case.submission.id, submitted[2].id), reverse=True)) + first: Final = await case.submissions.list(owner, "pending_review", 1, 0) + second: Final = await case.submissions.list(owner, "pending_review", 1, 1) + empty: Final = await case.submissions.list(owner, "pending_review", 1, 2) + assert (tuple(row.id for row in first.submissions), first.has_more) == (own_pending[:1], True) + assert (tuple(row.id for row in second.submissions), second.has_more) == (own_pending[1:], False) + assert (empty.submissions, empty.has_more) == ((), False) + reviewed: Final = await case.submissions.list(owner, "rejected", 20, 0) + assert (tuple(row.id for row in reviewed.submissions), reviewed.has_more) == ((submitted[0].id,), False) + own_all: Final = await case.submissions.list(owner, None, 20, 0) + assert tuple(row.id for row in own_all.submissions) == tuple(sorted((*own_pending, submitted[0].id), reverse=True)) + other_page: Final = await case.submissions.list(other, None, 20, 0) + assert tuple(row.id for row in other_page.submissions) == tuple( + sorted((submitted[1].id, submitted[3].id), reverse=True) + ) + global_pending: Final = await case.submissions.list(None, "pending_review", 3, 0) + all_pending: Final = tuple(sorted((*own_pending, submitted[1].id, submitted[3].id), reverse=True)) + assert (tuple(row.id for row in global_pending.submissions), global_pending.has_more) == (all_pending[:3], True) 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 6d43e65e6f7..50e1ab04511 100644 --- a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py @@ -23,7 +23,7 @@ import litellm from litellm.caching.caching import DualCache from litellm.proxy import proxy_server from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth -from litellm.proxy.deps.repositories import get_plugin_upload_repository +from litellm.proxy.deps.repositories import get_plugin_upload_repository, get_skill_submission_repository from litellm.proxy.discovery_endpoints.agent_skills_archive import ( MAX_ARCHIVE_ENTRIES, MAX_ARCHIVE_UNPACKED_BYTES, @@ -32,7 +32,9 @@ from litellm.proxy.discovery_endpoints.skill_hub_endpoints import import_http_cl from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3 from litellm.proxy.utils import InternalUsageCache from litellm.repositories.plugin_upload_repository import PluginUploadRepository +from litellm.repositories.skill_submission_repository import SkillSubmissionRepository from tests._master_key import MASTER_KEY +from tests.unit.proxy.skill_submission_rows import SubmissionRows NAME: Final = "portable-notes" MANIFEST: Final = ( @@ -48,6 +50,9 @@ HUB: Final = "/skill-hub/skills" INDEX: Final = "/.well-known/agent-skills/index.json" GRANTED_KEY: Final = "sk-hub-granted" PLAIN_KEY: Final = "sk-hub-plain" +MEMBER_KEY: Final = "sk-hub-member" +OTHER_KEY: Final = "sk-hub-other" +VIEWER_KEY: Final = "sk-hub-viewer" def _zip(files: Mapping[str, bytes], prefix: str = "") -> bytes: @@ -105,6 +110,8 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]: archives.upsert = AsyncMock() archives.delete_many = AsyncMock() prisma.db.tx.return_value.__aenter__.return_value = prisma.db + submissions: Final = SubmissionRows() + prisma.db.litellm_skillsubmission = submissions prisma.db.query_raw = AsyncMock(return_value=[]) prisma.db.litellm_claudecodepluginversion.create = AsyncMock() prisma.db.litellm_claudecodepluginversion.count = AsyncMock(return_value=0) @@ -129,12 +136,22 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]: object_permission=LiteLLM_ObjectPermissionTable(object_permission_id=key, skills=skills), ), ) + for key, role in ( + (MEMBER_KEY, proxy_server.LitellmUserRoles.INTERNAL_USER), + (OTHER_KEY, proxy_server.LitellmUserRoles.INTERNAL_USER), + (VIEWER_KEY, proxy_server.LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), + ): + proxy_server.user_api_key_cache.set_cache( + key=proxy_server.hash_token(key), + value=UserAPIKeyAuth(token=proxy_server.hash_token(key), user_role=role, user_id=key), + ) async with ( httpx.AsyncClient(transport=httpx.MockTransport(upstream), trust_env=False) as source_client, httpx.AsyncClient(transport=httpx.ASGITransport(app=proxy_server.app), base_url="http://test") as client, ): monkeypatch.setattr(proxy_server.app, "dependency_overrides", { get_plugin_upload_repository: lambda: PluginUploadRepository(prisma), + get_skill_submission_repository: lambda: SkillSubmissionRepository(prisma), import_http_client: lambda: source_client, }) yield HubClient(client, plugins, archives, upstream, clock, prisma.db) @@ -402,24 +419,36 @@ async def test_stored_content_survives_duplicate_upload_and_is_hidden_after_sour assert (await hub.client.get(INDEX)).json()["skills"] == [] -@pytest.mark.parametrize("user_id, admitted", (("publisher", 10), (None, 20))) +@pytest.mark.parametrize("user_id, role, admitted", ( + ("publisher", proxy_server.LitellmUserRoles.PROXY_ADMIN, 10), + (None, proxy_server.LitellmUserRoles.PROXY_ADMIN, 20), + ("publisher", proxy_server.LitellmUserRoles.INTERNAL_USER, 10), +), ids=("admin-user", "admin-key", "member-user")) async def test_publication_limit_is_shared_by_user_and_route_and_resets( - hub: HubClient, monkeypatch: pytest.MonkeyPatch, user_id: str | None, admitted: int, + hub: HubClient, monkeypatch: pytest.MonkeyPatch, user_id: str | None, role: str, admitted: int, ) -> None: for key in (PLAIN_KEY, GRANTED_KEY): proxy_server.user_api_key_cache.set_cache( key=proxy_server.hash_token(key), value=UserAPIKeyAuth( token=proxy_server.hash_token(key), user_id=user_id, - user_role=proxy_server.LitellmUserRoles.PROXY_ADMIN, + user_role=role, ), ) - responses: Final = await asyncio.gather(*( - _upload(hub, b"invalid", PLAIN_KEY) if index % 2 else hub.client.post( - f"{HUB}/import", headers=_headers(GRANTED_KEY), json={"url": "https://example.com/skill"}, - ) for index in range(20) - )) - assert sum(response.status_code in (400, 422) for response in responses) == admitted + async def attempt(index: int) -> httpx.Response: + key: Final = PLAIN_KEY if index % 2 else GRANTED_KEY + if index % 3 == 0: + return await _upload(hub, b"invalid", key) + if index % 3 == 1: + return await hub.client.post( + f"{HUB}/import", headers=_headers(key), json={"url": "https://example.com/skill"}, + ) + return await hub.client.put( + f"{HUB}/{NAME}/upload", headers=_headers(key), files={"file": ("invalid.zip", b"invalid")}, + ) + + responses: Final = await asyncio.gather(*(attempt(index) for index in range(20))) + assert sum(response.status_code in (400, 404, 422) for response in responses) == admitted assert sum(response.status_code == 429 for response in responses) == 20 - admitted assert all(response.headers["retry-after"] == "60" for response in responses if response.status_code == 429) hub.plugins.create.assert_not_awaited() @@ -530,3 +559,128 @@ async def test_neutral_history_labels_external_sources_and_bounds_pagination(hub assert (await hub.client.get( f"{HUB}/{NAME}/versions?limit=101", headers=_headers(MASTER_KEY), )).status_code == 422 + + +@pytest.fixture +async def pending_hub(hub: HubClient) -> HubClient: + hub.database.query_raw.side_effect = hub.database.litellm_skillsubmission.query_raw + return hub + + +@pytest.mark.parametrize("operation,approve", (("upload", True), ("import", True), ("upload", False)), + ids=("approve-upload", "approve-import", "reject-upload")) +async def test_member_archive_stays_private_until_admin_review( + pending_hub: HubClient, operation: str, approve: bool, +) -> None: + hub: Final = pending_hub + hub.upstream.return_value = httpx.Response(200, content=_zip(FILES, "repository/")) + pending: Final = ( + await _upload(hub, _zip(FILES), MEMBER_KEY, version=None) if operation == "upload" + else await hub.client.post( + f"{HUB}/import", headers=_headers(MEMBER_KEY), + json={"url": f"https://skills.sh/synthetic-author/notes-library/{NAME}"}, + ) + ) + assert pending.status_code == 200, pending.text + receipt: Final = pending.json() + assert receipt["status"] == "pending_review" and receipt["proposal"]["version"] is None + assert receipt["proposal"]["agent_skill"]["name"] == NAME + hub.plugins.create.assert_not_awaited() + hub.database.litellm_claudecodepluginversion.create.assert_not_awaited() + path: Final = f"/skill-hub/submissions/{receipt['submission_id']}" + assert receipt["proposal"]["source"]["url"] == f"http://test{path}/archive" + own: Final = await hub.client.get(path, headers=_headers(MEMBER_KEY)) + assert own.json()["current_skill"] is None + assert own.json()["submission"]["proposal"] == receipt["proposal"] + for suffix in ("", "/archive"): + assert (await hub.client.get(path + suffix, headers=_headers(OTHER_KEY))).status_code == 404 + _assert_portable(await hub.client.get(path + "/archive", headers=_headers(MEMBER_KEY))) + assert (await hub.client.get(path, headers=_headers(VIEWER_KEY))).status_code == 200 + denied: Final = await hub.client.put(path + "/approve", headers=_headers(VIEWER_KEY)) + assert denied.status_code == 403, denied.text + assert (await hub.client.get(f"{HUB}/{NAME}/archive")).status_code == 404 + if not approve: + rejected: Final = await hub.client.put( + path + "/reject", headers=_headers(MASTER_KEY), json={"reason": "Needs revision"}, + ) + assert rejected.status_code == 200, rejected.text + assert rejected.json()["skill"] is None and rejected.json()["action"] == "rejected" + assert rejected.json()["submission"]["rejection_reason"] == "Needs revision" + assert rejected.json()["submission"]["proposal"] == receipt["proposal"] + hub.plugins.create.assert_not_awaited() + return + approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY)) + assert approved.status_code == 200, approved.text + assert approved.json()["action"] == "approved" + assert approved.json()["skill"]["version"] == "1.0.0" + assert approved.json()["submission"]["proposal"] == receipt["proposal"] + _stored_upload(hub) + _assert_portable(await hub.client.get(approved.json()["skill"]["download_url"])) + assert (await hub.client.put(path + "/reject", headers=_headers(MASTER_KEY))).status_code == 409 + listed: Final = await hub.client.get("/skill-hub/submissions?status=approved", headers=_headers(MEMBER_KEY)) + assert (listed.json()["count"], listed.json()["has_more"]) == (1, False) + + +@pytest.mark.parametrize("replacement", (False, True), ids=("metadata", "archive")) +async def test_member_updates_preserve_manifest_and_original_editor_base(pending_hub: HubClient, replacement: bool) -> None: + hub: Final = pending_hub + assert (await _upload(hub, _zip(FILES))).status_code == 200 + original: Final = _stored_upload(hub) + manifest: Final = { + **json.loads(original.manifest_json), "domain": "Tools", "namespace": "research", + "keywords": ["notes"], "extension": {"retained": True}, "author": "legacy author", + } + hub.plugins.find_unique.return_value = original.model_copy(update={"manifest_json": json.dumps(manifest)}) + before: Final = hub.database.litellm_claudecodepluginversion.create.await_count + updated_files: Final = {**FILES, "references/guide.md": b"Updated supporting instructions.\n"} + expected: Final = original.updated_at.isoformat() + response: Final = ( + await hub.client.put( + f"{HUB}/{NAME}/upload", headers=_headers(MEMBER_KEY), + data={"expected_updated_at": expected}, files={"file": ("new.zip", _zip(updated_files), "application/zip")}, + ) if replacement else await hub.client.put( + f"{HUB}/{NAME}", headers=_headers(MEMBER_KEY), + json={"description": "Reviewed description", "expected_updated_at": expected, + "domain": "Tools", "namespace": "research", "keywords": ["notes"]}, + ) + ) + assert response.status_code == 200, response.text + path: Final = f"/skill-hub/submissions/{response.json()['submission_id']}" + detail: Final = (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json() + assert detail["submission"]["base_updated_at"] == original.updated_at.isoformat().replace("+00:00", "Z") + assert detail["submission"]["proposal"]["domain"] == "Tools" + assert detail["submission"]["proposal"]["keywords"] == ["notes"] + assert detail["current_skill"]["namespace"] == "research" + assert hub.database.litellm_claudecodepluginversion.create.await_count == before + hub.plugins.update.assert_not_awaited() + approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY)) + assert approved.status_code == 200, approved.text + stored: Final = json.loads(hub.plugins.find_unique.return_value.manifest_json) + assert (stored["extension"], stored["author"]) == (manifest["extension"], manifest["author"]) + assert stored["agent_skill"]["digest"] == response.json()["proposal"]["agent_skill"]["digest"] + assert approved.json()["skill"]["version"] == "2.0.1" + assert approved.json()["submission"]["proposal"]["version"] is None + wrapper: Final = hub.archives.upsert.await_args.kwargs["data"]["update"]["content"].decode() + with zipfile.ZipFile(io.BytesIO(wrapper)) as archive: + assert archive.read(f"skills/{NAME}/references/guide.md") == ( + updated_files["references/guide.md"] if replacement else FILES["references/guide.md"] + ) + if not replacement: + assert stored["agent_skill"] == manifest["agent_skill"] + + +@pytest.mark.parametrize("failure,status", (("stale", 409), ("wrong-name", 422), ("viewer", 403)), + ids=("stale", "wrong-name", "viewer")) +async def test_replacement_rejections_do_not_stage_or_change_files(pending_hub: HubClient, failure: str, status: int) -> None: + hub: Final = pending_hub + assert (await _upload(hub, _zip(FILES))).status_code == 200 + _stored_upload(hub) + response: Final = await hub.client.put( + f"{HUB}/{NAME}/upload", headers=_headers(VIEWER_KEY if failure == "viewer" else MEMBER_KEY), + data={"expected_updated_at": "2020-01-01T00:00:00Z"} if failure == "stale" else {}, + files={"file": ("new.zip", _zip({"SKILL.md": MANIFEST.replace(NAME.encode(), b"different-name")}), "application/zip")}, + ) + assert response.status_code == status, response.text + assert hub.database.litellm_skillsubmission.rows == () + hub.plugins.update.assert_not_awaited() + hub.archives.upsert.assert_not_awaited() diff --git a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py index 48981288d00..dbe01804151 100644 --- a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py +++ b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py @@ -1,6 +1,7 @@ import asyncio import base64 import gzip +import hashlib import json import math import random @@ -59,6 +60,8 @@ def _client(max_decompressed_body_size_mb: float = CAP_MB, root_path: str = "", routes=[ Route("/v1/systemone", _echo_request, methods=["POST"]), Route("/claude-code/plugins/upload", _echo_request, methods=["POST"]), + Route("/skill-hub/skills/upload", _echo_request, methods=["POST"]), + Route("/skill-hub/skills/{name}/upload", _echo_request, methods=["PUT"]), Route("/plugin-proxy/{path:path}", _echo_request, methods=["POST"]), Route("/claude_code_gateway/v1/{signal}", _echo_request, methods=["POST"]), ] @@ -248,16 +251,21 @@ def test_operator_request_size_limit_caps_the_decoded_body_when_it_is_lower( @pytest.mark.parametrize(("coding", "compress"), [ ("identity", bytes), ("gzip", gzip.compress), ("zstd", zstd.compress), ]) +@pytest.mark.parametrize(("method", "path"), [ + ("POST", "/claude-code/plugins/upload"), + ("POST", "/skill-hub/skills/upload"), + ("PUT", "/skill-hub/skills/example/upload"), +]) def test_proxy_bounds_skill_uploads_before_authentication_or_body_parsing( - coding: str, compress: Callable[[bytes], bytes], + coding: str, compress: Callable[[bytes], bytes], method: str, path: str, ) -> None: from litellm.proxy.proxy_server import app body: Final = random.Random(0).randbytes(6 * MIB) + bytes(6 * MIB) encoded: Final = compress(body) - response: Final = TestClient(app).post( - "/claude-code/plugins/upload", + response: Final = TestClient(app).request( + method, path, content=encoded, headers={"content-encoding": coding, "content-type": "multipart/form-data; boundary=upload"}, ) @@ -269,6 +277,32 @@ def test_proxy_bounds_skill_uploads_before_authentication_or_body_parsing( ) +@pytest.mark.parametrize("root_path", ("", "/gateway")) +@pytest.mark.parametrize(("coding", "compress"), [("gzip", gzip.compress), ("zstd", zstd.compress)]) +def test_replacement_upload_route_limit_caps_decoded_bytes( + root_path: str, coding: str, compress: Callable[[bytes], bytes], +) -> None: + limit: Final = 1 / 32 + client: Final = _client( + root_path=root_path, + route_max_request_size_mb={("PUT", "/skill-hub/skills/{name}/upload"): limit}, + ) + body: Final = _low_entropy_text(int(limit * MIB)) + path: Final = f"{root_path}/skill-hub/skills/example/upload" + headers: Final = {"content-encoding": coding, "content-type": "application/zip"} + + within: Final = client.put(path, content=compress(body), headers=headers) + over: Final = client.put(path, content=compress(body + b"x"), headers=headers) + + assert within.status_code == 200 + decoded: Final = bytes.fromhex(within.json()["body_hex"]) + actual: Final = (len(decoded), hashlib.sha256(decoded).hexdigest()) + expected: Final = (len(body), hashlib.sha256(body).hexdigest()) + assert actual == expected + assert over.status_code == 413 + assert over.json() == _decoded_too_large(limit) + + @pytest.mark.parametrize(("signature", "content_encoding"), [(GZIP_MAGIC, "gzip"), (ZSTD_FRAME_MAGIC, "zstd")]) def test_compressed_body_over_the_cap_is_rejected_before_decoding(signature: bytes, content_encoding: str): over_cap_on_the_wire: Final = signature + bytes(int(CAP_MB * MIB)) diff --git a/tests/unit/proxy/middleware/test_request_size_limit_middleware.py b/tests/unit/proxy/middleware/test_request_size_limit_middleware.py index 7bb73b77000..6d8d8363220 100644 --- a/tests/unit/proxy/middleware/test_request_size_limit_middleware.py +++ b/tests/unit/proxy/middleware/test_request_size_limit_middleware.py @@ -12,15 +12,20 @@ from litellm.proxy.middleware.request_size_limit_middleware import ( ) -@pytest.mark.parametrize(("path", "root_path", "operator_mb", "route_mb"), [ - ("/chat/completions", "", 1, None), - ("/claude-code/plugins/upload", "", None, 1), - ("/gateway/claude-code/plugins/upload", "/gateway", None, 1), - ("/claude-code/plugins/upload", "", 2, 1), - ("/claude-code/plugins/upload", "", 1, 2), +@pytest.mark.parametrize(("method", "path", "root_path", "operator_mb", "route_mb"), [ + ("POST", "/chat/completions", "", 1, None), + ("POST", "/claude-code/plugins/upload", "", None, 1), + ("POST", "/skill-hub/skills/upload", "", None, 1), + ("POST", "/gateway/claude-code/plugins/upload", "/gateway", None, 1), + ("POST", "/claude-code/plugins/upload", "", 2, 1), + ("POST", "/claude-code/plugins/upload", "", 1, 2), + ("PUT", "/skill-hub/skills/example/upload", "", None, 1), + ("PUT", "/gateway/skill-hub/skills/example/upload", "/gateway", None, 1), + ("PUT", "/skill-hub/skills/example/upload", "/gateway", None, 1), + ("PUT", "/skill-hub/skills/example/upload", "", 1, 2), ]) def test_request_size_limit_middleware_rejects_content_length_before_body_read( - path: str, root_path: str, operator_mb: int | None, route_mb: int | None, + method: str, path: str, root_path: str, operator_mb: int | None, route_mb: int | None, ) -> None: downstream_called = False @@ -35,13 +40,17 @@ def test_request_size_limit_middleware_rejects_content_length_before_body_read( app, get_max_request_size_mb=lambda: operator_mb, is_request_size_limit_enabled=lambda: operator_mb is not None, - route_max_request_size_mb={("POST", "/claude-code/plugins/upload"): route_mb} if route_mb else None, + route_max_request_size_mb={ + ("POST", "/claude-code/plugins/upload"): route_mb, + ("POST", "/skill-hub/skills/upload"): route_mb, + ("PUT", "/skill-hub/skills/{name}/upload"): route_mb, + } if route_mb else None, ), root_path=root_path, ) - response = client.post( - path, + response = client.request( + method, path, content=b"x" * (1024 * 1024 + 1), headers={"content-type": "application/json"}, ) @@ -53,14 +62,19 @@ def test_request_size_limit_middleware_rejects_content_length_before_body_read( @pytest.mark.parametrize(("enabled", "operator_mb"), [(True, 0), (False, 1)]) -@pytest.mark.parametrize(("method", "path"), [ - ("POST", "/chat/completions"), - ("PUT", "/claude-code/plugins/upload"), - ("POST", "/claude-code/plugins/upload-other"), - ("POST", "/claude-code/plugins"), +@pytest.mark.parametrize(("method", "path", "root_path"), [ + ("POST", "/chat/completions", ""), + ("PUT", "/claude-code/plugins/upload", ""), + ("POST", "/claude-code/plugins/upload-other", ""), + ("POST", "/claude-code/plugins", ""), + ("POST", "/skill-hub/skills/example/upload", ""), + ("PUT", "/skill-hub/skills/example/upload-other", ""), + ("PUT", "/skill-hub/skills/example/nested/upload", ""), + ("PUT", "/skill-hub/skills//upload", ""), + ("PUT", "/gateway-other/skill-hub/skills/example/upload", "/gateway"), ]) def test_request_size_limit_middleware_zero_or_disabled_global_limit_preserves_other_routes( - enabled: bool, operator_mb: int, method: str, path: str, + enabled: bool, operator_mb: int, method: str, path: str, root_path: str, ) -> None: downstream_called = False @@ -75,8 +89,12 @@ def test_request_size_limit_middleware_zero_or_disabled_global_limit_preserves_o app, get_max_request_size_mb=lambda: operator_mb, is_request_size_limit_enabled=lambda: enabled, - route_max_request_size_mb={("POST", "/claude-code/plugins/upload"): 1}, - ) + route_max_request_size_mb={ + ("POST", "/claude-code/plugins/upload"): 1, + ("PUT", "/skill-hub/skills/{name}/upload"): 1, + }, + ), + root_path=root_path, ) response = client.request( @@ -91,7 +109,15 @@ def test_request_size_limit_middleware_zero_or_disabled_global_limit_preserves_o @pytest.mark.asyncio -async def test_request_size_limit_middleware_rejects_streamed_body_without_content_length(): +@pytest.mark.parametrize(("method", "path", "root_path", "operator_mb"), [ + ("POST", "/chat/completions", "", 1), + ("POST", "/skill-hub/skills/upload", "", None), + ("PUT", "/skill-hub/skills/example/upload", "", None), + ("PUT", "/gateway/skill-hub/skills/example/upload", "/gateway", None), +]) +async def test_request_size_limit_middleware_rejects_streamed_body_without_content_length( + method: str, path: str, root_path: str, operator_mb: int | None, +) -> None: received_body_bytes = 0 async def app(scope, receive, send): @@ -109,8 +135,11 @@ async def test_request_size_limit_middleware_rejects_streamed_body_without_conte middleware = RequestSizeLimitMiddleware( app, - get_max_request_size_mb=lambda: 1, - is_request_size_limit_enabled=lambda: True, + get_max_request_size_mb=lambda: operator_mb, + is_request_size_limit_enabled=lambda: operator_mb is not None, + route_max_request_size_mb={ + ("POST", "/skill-hub/skills/upload"): 1, ("PUT", "/skill-hub/skills/{name}/upload"): 1, + }, ) sent_messages: list[Message] = [] receive_messages: list[Message] = [ @@ -135,8 +164,9 @@ async def test_request_size_limit_middleware_rejects_streamed_body_without_conte await middleware( { "type": "http", - "method": "POST", - "path": "/chat/completions", + "method": method, + "path": path, + "root_path": root_path, "headers": [(b"content-type", b"application/json")], }, receive, @@ -180,9 +210,14 @@ def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_erro @pytest.mark.asyncio -@pytest.mark.parametrize("path", ("/claude-code/plugins/upload", "/chat/completions")) +@pytest.mark.parametrize(("method", "path", "route"), [ + ("POST", "/claude-code/plugins/upload", "/claude-code/plugins/upload"), + ("POST", "/skill-hub/skills/upload", "/skill-hub/skills/upload"), + ("POST", "/chat/completions", "/chat/completions"), + ("PUT", "/skill-hub/skills/example/upload", "/skill-hub/skills/{name}/upload"), +]) async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing( - path: str, + method: str, path: str, route: str, ) -> None: from litellm.proxy.proxy_server import app @@ -190,7 +225,7 @@ async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing( app, get_max_request_size_mb=lambda: None, is_request_size_limit_enabled=lambda: False, - route_max_request_size_mb={("POST", path): 1 / 1024}, + route_max_request_size_mb={(method, route): 1 / 1024}, ) async def chunks() -> AsyncIterator[bytes]: @@ -199,8 +234,8 @@ async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing( pytest.fail("The server consumed more of the upload after its byte limit") async with httpx.AsyncClient(transport=httpx.ASGITransport(app=limited), base_url="http://test") as client: - response: Final = await client.post( - path, + response: Final = await client.request( + method, path, content=chunks(), headers={"content-type": "multipart/form-data; boundary=skill"}, ) diff --git a/tests/unit/proxy/skill_submission_rows.py b/tests/unit/proxy/skill_submission_rows.py new file mode 100644 index 00000000000..99e65a91344 --- /dev/null +++ b/tests/unit/proxy/skill_submission_rows.py @@ -0,0 +1,74 @@ +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Final + +from prisma import models as prisma_models + + +class SubmissionRows: + def __init__(self) -> None: + self.rows: tuple[prisma_models.LiteLLM_SkillSubmission, ...] = () + + async def create(self, data: Mapping[str, object]) -> prisma_models.LiteLLM_SkillSubmission: + row: Final = prisma_models.LiteLLM_SkillSubmission.model_validate( + { + "id": f"submission-{len(self.rows)}", + "status": "pending_review", + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + **data, + } + ) + self.rows = (*self.rows, row) + return row + + async def find_unique(self, where: Mapping[str, object]) -> prisma_models.LiteLLM_SkillSubmission | None: + return next((row for row in self.rows if row.id == where["id"]), None) + + async def find_first(self, where: Mapping[str, object]) -> prisma_models.LiteLLM_SkillSubmission | None: + row: Final = await self.find_unique(where) + return None if row is None or where.get("submitted_by", row.submitted_by) != row.submitted_by else row + + async def update( + self, + where: Mapping[str, object], + data: Mapping[str, object], + ) -> prisma_models.LiteLLM_SkillSubmission | None: + current: Final = await self.find_unique(where) + if current is None: + return None + updated: Final = prisma_models.LiteLLM_SkillSubmission.model_validate({**current.model_dump(), **data}) + self.rows = tuple(updated if row.id == current.id else row for row in self.rows) + return updated + + async def query_raw(self, query: str, *args: object) -> tuple[Mapping[str, object], ...]: + if 'FROM "LiteLLM_SkillSubmission"' not in query or "FOR UPDATE" in query: + return () + if "COUNT(*) AS count" in query: + pending: Final = tuple( + row for row in self.rows if row.submitted_by == args[0] and row.status == "pending_review" + ) + return ( + { + "count": len(pending), + "storage_bytes": sum( + len(row.proposal_json.encode("utf-8")) + + (0 if row.archive_content is None else len(row.archive_content.decode())) + for row in pending + ), + }, + ) + records: Final = ( + tuple(row for row in self.rows if row.id == args[0] and (args[1] is None or row.submitted_by == args[1])) + if "WHERE id = $1" in query + else tuple(row for row in self.rows if args[0] is None or row.submitted_by == args[0]) + ) + if "WHERE id = $1" in query: + return tuple({**row.model_dump(), "has_archive": row.archive_content is not None} for row in records) + limit, offset = args[2:] + assert isinstance(limit, int) and isinstance(offset, int) + filtered: Final = tuple(row for row in records if args[1] is None or row.status == args[1]) + ordered: Final = sorted(filtered, key=lambda row: (row.created_at, row.id), reverse=True) + return tuple( + {**row.model_dump(), "has_archive": row.archive_content is not None} + for row in ordered[offset : offset + limit] + ) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index dd428b286bd..e9bbeb94355 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1037,11 +1037,6 @@ "count": 1 } }, - "src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/skills/_components/add_plugin_form.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1745,7 +1740,7 @@ "count": 5 }, "no-restricted-syntax": { - "count": 133 + "count": 132 }, "prefer-const": { "count": 26 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 new file mode 100644 index 00000000000..7d586d2fb2d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.integration.test.tsx @@ -0,0 +1,632 @@ +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 { setGlobalLitellmHeaderName } from "@/components/networking"; +import type { SkillHubSkill, SkillSubmission } from "@/components/claude_code_plugins/types"; +import { toast } from "@/lib/toast"; +import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; + +const published: SkillHubSkill = { + id: "published-skill", + name: "shared-skill", + source: { + source: "archive", + url: "http://localhost:4000/skill-hub/skills/shared-skill/archive/approved.zip", + sha256: "a".repeat(64), + ref: "approved", + }, + version: "2.3.4", + description: "Approved instructions", + keywords: ["sales, finance", "review"], + domain: "Engineering", + namespace: "workflows", + visibility: "public", + download_url: "/skill-hub/skills/shared-skill/archive", + content_url: "/skill-hub/skills/shared-skill/content", + updated_at: "2026-10-20T00:00:00Z", +}; + +const pending: SkillSubmission = { + id: "submission-1", + skill_name: published.name, + operation: "update", + status: "pending_review", + proposal: { + name: published.name, + agent_skill: { + name: published.name, + description: "Portable instructions", + digest: "sha256:reviewed", + origin: "SKILL.md", + }, + source: { source: "github", repo: "example/skills", ref: "proposed-ref" }, + version: null, + description: "Proposed instructions", + keywords: published.keywords, + domain: published.domain, + namespace: published.namespace, + }, + has_archive: true, + submitted_by: "member@example.org", + created_at: "2026-10-21T00:00:00Z", + base_skill_id: published.id, + base_updated_at: published.updated_at ?? null, + reviewed_by: null, + reviewed_at: null, + rejection_reason: null, +}; + +const fetchMock = vi.fn(); +const queryDefaults = testQueryClient.getDefaultOptions(); +let catalog: SkillHubSkill[]; +let submissions: SkillSubmission[]; +let pageRequests: { limit: number; offset: number }[]; +let writes: { path: string; method: string; body: RequestInit["body"] }[]; +let reviewFailure: string | null; +let directPublication: boolean; + +const respondToWrite = (path: string, body: RequestInit["body"]) => { + if (path.endsWith("/approve") || path.endsWith("/reject")) { + if (reviewFailure) return Response.json({ detail: { error: reviewFailure } }, { status: 409 }); + const approved = path.endsWith("/approve"); + const reason = typeof body === "string" ? JSON.parse(body).reason : null; + submissions = submissions.map((submission) => + submission.id === pending.id + ? { ...submission, status: approved ? "approved" : "rejected", rejection_reason: reason } + : submission, + ); + if (approved) catalog = [{ ...published, ...pending.proposal, version: "2.3.5" }]; + const result = { + status: "success", + action: approved ? "approved" : "rejected", + submission: submissions[0], + skill: approved ? catalog[0] : null, + }; + return Response.json(result); + } + if (directPublication) { + catalog = [{ ...published, version: "3.0.0", description: "Admin-published instructions" }]; + return Response.json(catalog[0]); + } + submissions = [pending]; + const result = { + status: "pending_review", + action: "submitted", + submission_id: pending.id, + proposal: pending.proposal, + }; + return Response.json(result); +}; + +const respondToSubmissionsPage = (url: URL) => { + const limit = Number(url.searchParams.get("limit") ?? "20"); + const offset = Number(url.searchParams.get("offset") ?? "0"); + pageRequests.push({ limit, offset }); + const page = submissions.slice(offset, offset + limit); + return Response.json({ submissions: page, count: page.length, has_more: offset + limit < submissions.length }); +}; + +const makeSubmissions = (count: number): SkillSubmission[] => + Array.from({ length: count }, (_, index) => ({ + ...pending, + id: `submission-${index + 1}`, + skill_name: `skill-${index + 1}`, + proposal: { ...pending.proposal, description: `Instructions ${index + 1}` }, + })); + +beforeEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + catalog = [published]; + submissions = [pending]; + pageRequests = []; + writes = []; + reviewFailure = null; + directPublication = false; + fetchMock.mockReset().mockImplementation(async (input, options) => { + const request = input instanceof Request ? input : null; + const url = new URL(request?.url ?? String(input), "http://localhost"); + const path = url.pathname; + const method = request?.method ?? options?.method ?? "GET"; + if (method !== "GET") { + const body = request ? await request.text() : options?.body; + writes.push({ path, method, body }); + return respondToWrite(path, body); + } + if (path === "/skill-hub/skills") { + const limit = Number(url.searchParams.get("limit")); + const offset = Number(url.searchParams.get("offset")); + const skills = catalog.slice(offset, offset + limit); + return Response.json({ + skills, + count: skills.length, + next_offset: offset + limit < catalog.length ? offset + limit : null, + }); + } + if (path === `/skill-hub/skills/${published.name}`) + return Response.json(catalog.find((skill) => skill.name === published.name)); + if (path === `/skill-hub/skills/${published.name}/versions`) + return Response.json({ + versions: [ + { + id: "published-version", + version: published.version, + description: "Recorded release", + manifest: { source: published.source }, + published_at: published.updated_at, + published_by: "maintainer@example.org", + content_kind: "stored_archive", + download_url: "/skill-hub/skills/shared-skill/versions/published-version/archive", + }, + ], + next_offset: null, + }); + if (path === "/skill-hub/submissions") return respondToSubmissionsPage(url); + if (path.endsWith("/archive")) return new Response("zip-bytes", { headers: { "Content-Type": "application/zip" } }); + if (path === `/skill-hub/submissions/${pending.id}`) + return Response.json({ submission: submissions[0], current_skill: catalog[0] }); + if (path === "/.well-known/litellm-ui-config") + return Response.json({ claude_code_marketplace_auth_required: true }); + return Response.json({ detail: "Unexpected request" }, { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + testQueryClient.setDefaultOptions(queryDefaults); + setGlobalLitellmHeaderName(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const renderSubmissions = (accessToken = "admin-token") => + renderWithProviders(, { + searchParams: "tab=submissions", + }); + +const openEdit = async () => { + fireEvent.click(await screen.findByText(published.name)); + fireEvent.click(await screen.findByRole("button", { name: "Edit Skill" })); + return screen.getByRole("dialog"); +}; + +it("paginates the neutral catalog and refreshes detail for a recreated skill", async () => { + catalog = [ + published, + ...Array.from({ length: 50 }, (_, index) => ({ + ...published, + id: `skill-${index + 1}`, + name: `catalog-${index + 1}`, + })), + ]; + renderWithProviders(); + fireEvent.click(await screen.findByText(published.name)); + expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument(); + fireEvent.click(screen.getByText("Skills")); + catalog = [ + { ...published, id: "filler", name: "catalog-0" }, + ...catalog.slice(1), + { ...published, id: "recreated", description: "Recreated instructions" }, + ]; + expect(await screen.findByText("catalog-49")).toBeInTheDocument(); + expect(screen.queryByText("catalog-50")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(await screen.findByText("catalog-50")).toBeInTheDocument(); + expect(screen.queryByText("catalog-49")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + 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.getByRole("button", { name: "Previous" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled()); +}); + +it.each(["descriptor", "archive"])("requires a portable %s for a submitted ZIP preview", async (missing) => { + submissions = [ + { + ...pending, + has_archive: missing !== "archive", + proposal: { ...pending.proposal, agent_skill: missing === "descriptor" ? null : pending.proposal.agent_skill }, + }, + ]; + renderSubmissions(); + fireEvent.click(await screen.findByRole("button", { name: "Review" })); + expect(await screen.findByText("Currently published")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Download submitted ZIP" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Approve and publish" })).toBeEnabled(); +}); + +it("paginates more than 20 submissions without showing the prior page while the next request loads or fails", async () => { + submissions = makeSubmissions(21); + const defaults = { ...queryDefaults, queries: { ...queryDefaults.queries, staleTime: 0 } }; + testQueryClient.setDefaultOptions(defaults); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "tab=submissions", + onUrlUpdate, + }); + expect(await screen.findByText("Instructions 20")).toBeInTheDocument(); + expect(screen.queryByText("Instructions 21")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); + const fetchStub = fetchMock.getMockImplementation()!; + const response = Promise.withResolvers(); + fetchMock.mockImplementationOnce(async () => response.promise); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(await screen.findByText("Page 2")).toBeInTheDocument(); + expect(screen.getByText("Loading submissions...")).toBeInTheDocument(); + expect(screen.queryByText("Instructions 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Instructions 20")).not.toBeInTheDocument(); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("submissions_page")).toBe("2"); + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("tab")).toBe("submissions"); + await act(async () => response.resolve(Response.json({ detail: "Unavailable" }, { status: 500 }))); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not refresh submissions"); + expect(screen.queryByText("Instructions 20")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + fetchMock.mockImplementation(fetchStub); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("Instructions 21")).toBeInTheDocument(); + expect(screen.queryByText("Instructions 20")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Previous" })); + expect(await screen.findByText("Instructions 1")).toBeInTheDocument(); + expect(screen.queryByText("Instructions 21")).not.toBeInTheDocument(); + await waitFor(() => expect(pageRequests.at(-1)).toEqual({ limit: 20, offset: 0 })); + expect(pageRequests).toEqual([ + { limit: 20, offset: 0 }, + { limit: 20, offset: 20 }, + { limit: 20, offset: 0 }, + ]); +}); + +it("keeps differently sized deep-linked pages in separate caches", async () => { + submissions = makeSubmissions(25); + const first = renderWithProviders(, { + searchParams: "tab=submissions&submissions_page=2", + }); + expect(await screen.findByText("Instructions 21")).toBeInTheDocument(); + first.unmount(); + renderWithProviders(, { + searchParams: "tab=submissions&submissions_page=2&submissions_page_size=5", + }); + expect(await screen.findByText("Instructions 6")).toBeInTheDocument(); + expect(screen.queryByText("Instructions 21")).not.toBeInTheDocument(); + expect(pageRequests).toEqual([ + { limit: 20, offset: 20 }, + { limit: 5, offset: 5 }, + ]); +}); + +it("refreshes inactive submission pages after a review", async () => { + submissions = makeSubmissions(21); + renderSubmissions(); + await screen.findByText("Instructions 1"); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(await screen.findByText("Instructions 21")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Previous" })); + await screen.findByText("Instructions 1"); + submissions = submissions.map((submission) => + submission.id === "submission-21" + ? { ...submission, status: "rejected", rejection_reason: "Reviewed by another admin" } + : submission, + ); + fireEvent.click(screen.getAllByRole("button", { name: "Review" })[0]); + fireEvent.click(await screen.findByRole("button", { name: "Approve and publish" })); + await screen.findByText("Approved"); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(await screen.findByText("Rejection reason: Reviewed by another admin")).toBeInTheDocument(); + expect(screen.getByText("Rejected")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Review" })).not.toBeInTheDocument(); +}); + +it("allows a member submission, explains approval and refreshes their pending list", async () => { + submissions = []; + renderWithProviders(); + await screen.findByText("Approved instructions"); + fireEvent.click(screen.getByRole("tab", { name: "My submissions" })); + await screen.findByText("No submissions yet"); + fireEvent.click(screen.getByRole("tab", { name: "Published skills" })); + 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("radio", { name: "Import URL" })); + fireEvent.change(screen.getByRole("textbox", { name: "Source URL" }), { + target: { value: "https://github.com/example/new-skill" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit for approval" })); + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith("Submitted for admin approval. The published skill is unchanged"), + ); + expect(await screen.findByText("Approved instructions")).toBeInTheDocument(); + expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument(); + expect(writes[0]).toMatchObject({ + path: "/skill-hub/skills/import", + method: "POST", + }); + fireEvent.click(screen.getByRole("tab", { name: "My submissions" })); + expect(await screen.findByText("Pending review")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "View details" })); + expect(await screen.findByText("Awaiting admin approval. The published skill remains unchanged")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Approve and publish" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument(); +}); + +it("retries failed detail loading before enabling edits", async () => { + const fetchStub = fetchMock.getMockImplementation()!; + fetchMock.mockImplementation((input, options) => + String(input).endsWith(`/skill-hub/skills/${published.name}`) + ? Promise.resolve(Response.json({ detail: "Unavailable" }, { status: 500 })) + : fetchStub(input, options), + ); + renderWithProviders(); + fireEvent.click(await screen.findByText(published.name)); + expect(await screen.findByRole("alert")).toHaveTextContent("Retry before editing"); + expect(screen.queryByRole("button", { name: "Edit Skill" })).not.toBeInTheDocument(); + fetchMock.mockImplementation(fetchStub); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument(); +}); + +it("edits the loaded detail with automatic version intent and preserves the approved skill while review is pending", async () => { + const loaded = { ...published, updated_at: "2026-10-21T12:00:00Z" }; + const fetchStub = fetchMock.getMockImplementation()!; + fetchMock.mockImplementation((input, options) => + options?.method === "GET" && String(input).endsWith(`/skill-hub/skills/${published.name}`) + ? Promise.resolve(Response.json(loaded)) + : fetchStub(input, options), + ); + renderWithProviders(); + fireEvent.click(await screen.findByText(published.name)); + fireEvent.click(screen.getByText("Version history")); + expect(await screen.findByText("Recorded release")).toBeInTheDocument(); + fireEvent.click(await screen.findByRole("button", { name: "Edit Skill" })); + const form = screen.getByRole("dialog"); + expect(within(form).getByLabelText("Version override (optional)")).toHaveValue(""); + expect(within(form).getByLabelText("Namespace (optional)")).toHaveValue(published.namespace); + fireEvent.change(within(form).getByLabelText("Description (Optional)"), { + target: { value: "Proposed instructions" }, + }); + fireEvent.click(within(form).getByRole("button", { name: "Submit for approval" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(writes[0]).toMatchObject({ path: "/skill-hub/skills/shared-skill", method: "PUT" }); + const expectedMetadata = { + version: null, + expected_updated_at: loaded.updated_at, + domain: published.domain, + namespace: published.namespace, + keywords: published.keywords, + description: "Proposed instructions", + }; + expect(JSON.parse(String(writes[0].body))).toMatchObject(expectedMetadata); + expect(await screen.findByText("Recorded release")).toBeInTheDocument(); + expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument(); +}); + +it("sends ZIP replacement through PUT upload with automatic version intent and a concurrency timestamp", async () => { + renderWithProviders(); + const form = await openEdit(); + fireEvent.click(within(form).getByRole("radio", { name: "Upload ZIP" })); + const file = new File(["new instructions"], "replacement.zip", { type: "application/zip" }); + fireEvent.change(within(form).getByLabelText("Skill ZIP file"), { target: { files: [file] } }); + fireEvent.click(within(form).getByRole("button", { name: "Submit for approval" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(writes[0]).toMatchObject({ path: "/skill-hub/skills/shared-skill/upload", method: "PUT" }); + const body = writes[0].body as FormData; + expect(body.get("file")).toBe(file); + expect(body.get("version")).toBeNull(); + expect(body.get("expected_updated_at")).toBe(published.updated_at); + expect(body.get("metadata")).toBeNull(); + expect(screen.getByText("Approved instructions")).toBeInTheDocument(); +}); + +it.each(["metadata", "upload"])( + "keeps direct admin %s publication and wires an explicit version override", + async (sourceType) => { + directPublication = true; + renderWithProviders(); + const form = await openEdit(); + fireEvent.change(within(form).getByLabelText("Version override (optional)"), { target: { value: "3.0.0" } }); + fireEvent.change(within(form).getByLabelText("Description (Optional)"), { + target: { value: "Admin-published instructions" }, + }); + if (sourceType === "upload") { + fireEvent.click(within(form).getByRole("radio", { name: "Upload ZIP" })); + fireEvent.change(within(form).getByLabelText("Skill ZIP file"), { + target: { files: [new File(["skill"], "replacement.zip", { type: "application/zip" })] }, + }); + } + fireEvent.click(within(form).getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(await screen.findByText("Admin-published instructions")).toBeInTheDocument(); + expect(toast.success).toHaveBeenCalledWith("Skill updated successfully"); + expect(screen.queryByText("Submitted for admin approval")).not.toBeInTheDocument(); + const body = writes[0].body; + if (body instanceof FormData) { + expect(body.get("version")).toBe("3.0.0"); + expect(body.get("expected_updated_at")).toBe(published.updated_at); + } else { + expect(JSON.parse(String(body))).toMatchObject({ version: "3.0.0", expected_updated_at: published.updated_at }); + } + }, +); + +it("lets an admin inspect full proposed values, download the private ZIP, approve and see refreshed published details", async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + vi.stubGlobal( + "URL", + Object.assign(URL, { createObjectURL: vi.fn(() => "blob:submission"), revokeObjectURL: vi.fn() }), + ); + setGlobalLitellmHeaderName("X-LiteLLM-Key"); + renderSubmissions(); + fireEvent.click(await screen.findByRole("button", { name: "Review" })); + expect(await screen.findByText("Currently published")).toBeInTheDocument(); + const dialog = within(screen.getByRole("dialog")); + expect(dialog.getByText("Approved instructions")).toBeInTheDocument(); + expect(dialog.getByText("Proposed instructions")).toBeInTheDocument(); + expect(dialog.getByText("Next patch version on approval")).toBeInTheDocument(); + expect(dialog.getByText(/proposed-ref/)).toBeInTheDocument(); + expect(dialog.getByText(/sha256:reviewed/)).toBeInTheDocument(); + expect(dialog.getAllByText(published.namespace!)).toHaveLength(2); + fireEvent.click(dialog.getByRole("button", { name: "Download submitted ZIP" })); + await waitFor(() => expect(click).toHaveBeenCalledOnce()); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining(`/submissions/${pending.id}/archive`), + expect.objectContaining({ + headers: expect.objectContaining({ "X-LiteLLM-Key": "Bearer admin-token" }), + redirect: "error", + }), + ); + fireEvent.click(dialog.getByRole("button", { name: "Approve and publish" })); + expect(await screen.findByText("Approved")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Published skills" })); + await waitFor(() => + expect(screen.getByRole("tab", { name: "Published skills" })).toHaveAttribute("aria-selected", "true"), + ); + expect(await screen.findByRole("cell", { name: "Proposed instructions" })).toBeInTheDocument(); + expect(screen.queryByText("Approved instructions")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(published.name)); + expect(screen.getByText("Proposed instructions")).toBeInTheDocument(); +}); + +it("saves an admin rejection reason for the author and keeps the published metadata", async () => { + renderSubmissions(); + fireEvent.click(await screen.findByRole("button", { name: "Review" })); + fireEvent.change(await screen.findByLabelText("Rejection reason (optional)"), { + target: { value: "Please include setup instructions" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Reject" })); + expect(await screen.findByText("Rejection reason: Please include setup instructions")).toBeInTheDocument(); + expect(screen.getByText("Rejected")).toBeInTheDocument(); + expect(writes[0]).toMatchObject({ + path: `/skill-hub/submissions/${pending.id}/reject`, + method: "PUT", + body: JSON.stringify({ reason: "Please include setup instructions" }), + }); + fireEvent.click(screen.getByRole("tab", { name: "Published skills" })); + await waitFor(() => + expect(screen.getByRole("tab", { name: "Published skills" })).toHaveAttribute("aria-selected", "true"), + ); + expect(await screen.findByRole("cell", { name: "Approved instructions" })).toBeInTheDocument(); +}); + +it("refreshes detail after a review conflict and removes actions when another reviewer has already acted", async () => { + renderSubmissions(); + fireEvent.click(await screen.findByRole("button", { name: "Review" })); + await screen.findByRole("button", { name: "Approve and publish" }); + reviewFailure = "Submission has already been reviewed"; + submissions = [{ ...pending, status: "rejected", rejection_reason: "Already reviewed elsewhere" }]; + fireEvent.click(screen.getByRole("button", { name: "Approve and publish" })); + expect(await screen.findByRole("alert")).toHaveTextContent(reviewFailure); + await waitFor(() => expect(screen.queryByRole("button", { name: "Approve and publish" })).not.toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument(); + expect( + within(screen.getByRole("dialog")).getByText("Rejection reason: Already reviewed elsewhere"), + ).toBeInTheDocument(); +}); + +it("keeps an effective Admin viewer read-only while allowing full submission inspection", async () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: "+ Add 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.getByRole("tab", { name: "Submissions" })); + fireEvent.click(await screen.findByRole("button", { name: "View details" })); + expect(await screen.findByText("Currently published")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Approve and publish" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument(); + expect(writes).toEqual([]); +}); + +it.each(["account", "role", "read-only"])( + "does not show cached submissions after a change of %s when refresh fails", + async (change) => { + const { rerender } = renderSubmissions("first-token"); + expect(await screen.findByText("Proposed instructions")).toBeInTheDocument(); + fetchMock.mockImplementation(async () => Response.json({ detail: "Unavailable" }, { status: 500 })); + await act(async () => + rerender( + , + ), + ); + expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument(); + expect(screen.queryByText("Submitted by member@example.org")).not.toBeInTheDocument(); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not refresh submissions"); + }, +); + +it.each(["role", "read-only"])("does not reuse a published admin snapshot after a change of %s", async (change) => { + const { rerender } = renderWithProviders(); + fireEvent.click(await screen.findByText(published.name)); + expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument(); + fetchMock.mockImplementation(async () => Response.json({ detail: "Unavailable" }, { status: 500 })); + await act(async () => + rerender( + , + ), + ); + 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"); +}); + +it("does not reuse admin review metadata when the same account loses its role and detail refresh fails", async () => { + const { rerender } = renderSubmissions("same-token"); + fireEvent.click(await screen.findByRole("button", { name: "Review" })); + expect(await screen.findByText("Currently published")).toBeInTheDocument(); + expect(within(screen.getByRole("dialog")).getByText("Approved instructions")).toBeInTheDocument(); + const fetchStub = fetchMock.getMockImplementation()!; + fetchMock.mockImplementation(async (input, options) => { + const path = new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname; + if (path === `/skill-hub/submissions/${pending.id}`) + return Response.json({ detail: "Unavailable" }, { status: 500 }); + return fetchStub(input, options); + }); + await act(async () => rerender()); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + fireEvent.click(await screen.findByRole("button", { name: "View details" })); + const dialog = within(screen.getByRole("dialog")); + expect(dialog.queryByText("Approved instructions")).not.toBeInTheDocument(); + expect(dialog.queryByText("Proposed instructions")).not.toBeInTheDocument(); + expect(dialog.queryByRole("button", { name: "Approve and publish" })).not.toBeInTheDocument(); + expect(await dialog.findByRole("alert")).toHaveTextContent("Could not refresh submissions"); +}); + +it("ignores an in-flight admin submission list that resolves after the same account loses its role", async () => { + const response = Promise.withResolvers(); + const requested = Promise.withResolvers(); + const fetchStub = fetchMock.getMockImplementation()!; + fetchMock.mockImplementation(async (input, options) => { + const path = new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname; + if (path === "/skill-hub/submissions") { + requested.resolve(); + return response.promise; + } + return fetchStub(input, options); + }); + const { rerender } = renderSubmissions("same-token"); + await requested.promise; + submissions = []; + fetchMock.mockImplementation(fetchStub); + await act(async () => rerender()); + expect(await screen.findByText("No submissions yet")).toBeInTheDocument(); + await act(async () => { + response.resolve(Response.json({ submissions: [pending], count: 1, has_more: false })); + await response.promise; + }); + expect(screen.getByText("No submissions yet")).toBeInTheDocument(); + expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Review" })).not.toBeInTheDocument(); +}); 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 67bab398bd2..168787fb379 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -1,15 +1,16 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders as render, testQueryClient } from "@/../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; -import type { Plugin } from "@/components/claude_code_plugins/types"; +import { listSkillHubSkills, deleteSkillHubSkill } from "@/components/networking"; +import type { SkillHubSkill } from "@/components/claude_code_plugins/types"; import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; vi.mock("@/components/networking", () => ({ - getClaudeCodePluginsList: vi.fn(), - deleteClaudeCodePlugin: vi.fn(), + listSkillHubSkills: vi.fn(), + deleteSkillHubSkill: vi.fn(), })); vi.mock("./PluginTable", () => ({ @@ -20,7 +21,7 @@ vi.mock("./PluginTable", () => ({ onDeleteClick, }: { isLoading: boolean; - pluginsList: Plugin[]; + pluginsList: SkillHubSkill[]; onDeleteClick: (pluginName: string, displayName: string) => void; }) => (
@@ -39,32 +40,34 @@ vi.mock("./PluginTable", () => ({ })); vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null })); +vi.mock("./SkillSubmissions", () => ({ __esModule: true, default: () => null })); vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null })); -const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); -const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin); +const mockListSkillHubSkills = vi.mocked(listSkillHubSkills); +const mockDeleteSkillHubSkill = vi.mocked(deleteSkillHubSkill); -const skill: Plugin = { +const skill: SkillHubSkill = { id: "plugin-1", name: "my-skill", source: { source: "github", repo: "acme/my-skill" }, - enabled: true, + visibility: "public", }; describe("ClaudeCodePluginsPanel loading state", () => { beforeEach(() => { vi.clearAllMocks(); + testQueryClient.clear(); }); it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { render(); expect(await screen.findByText("table-loaded")).toBeInTheDocument(); - expect(mockGetClaudeCodePluginsList).not.toHaveBeenCalled(); + expect(mockListSkillHubSkills).not.toHaveBeenCalled(); }); it("should show the loading state until the skills fetch settles", async () => { - let resolveFetch: (value: { plugins: never[]; count: number }) => void = () => {}; - mockGetClaudeCodePluginsList.mockReturnValue( + let resolveFetch: (value: { skills: never[]; count: number; next_offset: number | null }) => void = () => {}; + mockListSkillHubSkills.mockReturnValue( new Promise((resolve) => { resolveFetch = resolve; }), @@ -72,16 +75,17 @@ describe("ClaudeCodePluginsPanel loading state", () => { render(); expect(screen.getByText("table-loading")).toBeInTheDocument(); - resolveFetch({ plugins: [], count: 0 }); + resolveFetch({ skills: [], count: 0, next_offset: null }); expect(await screen.findByText("table-loaded")).toBeInTheDocument(); - expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false); + expect(mockListSkillHubSkills).toHaveBeenCalledWith("sk-test", { limit: 50, offset: 0 }); }); }); describe("ClaudeCodePluginsPanel delete confirmation", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill], count: 1 }); + testQueryClient.clear(); + mockListSkillHubSkills.mockResolvedValue({ skills: [skill], count: 1, next_offset: null }); }); it("should ask for confirmation before deleting and name the skill", async () => { @@ -93,20 +97,20 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => { expect(await screen.findByText(/are you sure you want to delete skill/i)).toBeInTheDocument(); expect(screen.getByText("my-skill")).toBeInTheDocument(); expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument(); - expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); + expect(mockDeleteSkillHubSkill).not.toHaveBeenCalled(); }); it("should delete the skill and refresh the list once confirmed", async () => { const user = userEvent.setup(); - mockDeleteClaudeCodePlugin.mockResolvedValue({}); + mockDeleteSkillHubSkill.mockResolvedValue({}); render(); await user.click(await screen.findByTestId("row-delete-plugin-1")); await screen.findByText(/are you sure you want to delete skill/i); await user.click(screen.getByRole("button", { name: "Delete" })); - await waitFor(() => expect(mockDeleteClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "my-skill")); - await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(mockDeleteSkillHubSkill).toHaveBeenCalledWith("sk-test", "my-skill")); + await waitFor(() => expect(mockListSkillHubSkills).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument()); }); @@ -119,6 +123,6 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => { await user.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument()); - expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); + expect(mockDeleteSkillHubSkill).not.toHaveBeenCalled(); }); }); 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 45023541f78..531163d558e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -1,6 +1,8 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; +import { useQuery, useQueryClient, type QueryKey } from "@tanstack/react-query"; import { Trans, useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { AlertDialog, AlertDialogCancel, @@ -10,66 +12,136 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import { listSkillHubSkills, getSkillHubSkill, deleteSkillHubSkill } from "@/components/networking"; import AddPluginForm from "./add_plugin_form"; import PluginTable from "./PluginTable"; -import SkillDetail from "@/components/claude_code_plugins/skill_detail"; -import { isAdminRole } from "@/utils/roles"; +import SkillDetail, { skillHubDetail } from "@/components/claude_code_plugins/skill_detail"; +import { isProxyAdminRole, isProxyAdminTierRole, isViewOnlySessionRole } from "@/utils/roles"; import { toast } from "@/lib/toast"; -import { Plugin, ListPluginsResponse } from "@/components/claude_code_plugins/types"; +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; userRole?: string; + isViewOnly?: boolean; } -const ClaudeCodePluginsPanel: React.FC = ({ accessToken, userRole }) => { +const CATALOG_STATE: UrlTableStateOptions = { + keyPrefix: "skills_", + defaultPageSize: 50, + maxPageSize: 100, + sortFields: ["name"], + defaultSort: { id: "name", desc: false }, + filterColumns: [], +}; + +const SKILLS_TABS = ["skills", "submissions"] as const; + +function skillPermissions(accessToken: string | null, userRole: string, isViewOnly: boolean) { + const readOnly = + isViewOnly || isViewOnlySessionRole(userRole) || ["Admin Viewer", "Internal Viewer"].includes(userRole); + const canWrite = Boolean(accessToken && userRole) && !readOnly; + return { + readOnly, + canWrite, + isAdmin: isProxyAdminRole(userRole) && canWrite, + adminView: isProxyAdminTierRole(userRole), + }; +} + +interface CatalogSkillDetailProps { + skill: SkillHubSkill; + accessToken: string | null; + queryScope: QueryKey; + canWrite: boolean; + onBack: () => void; + onEdit: (skill: SkillHubSkill) => void; +} + +function CatalogSkillDetail({ skill, accessToken, queryScope, canWrite, onBack, onEdit }: CatalogSkillDetailProps) { const { t } = useTranslation(); - const [pluginsList, setPluginsList] = useState([]); + const detail = useQuery({ + queryKey: [...queryScope, "detail", skill.id, skill.name], + queryFn: () => getSkillHubSkill(accessToken!, skill.name), + enabled: Boolean(accessToken), + }); + return ( + <> + {detail.isError && ( +
+ {t("skills.panel.detailFailed")} + +
+ )} + onEdit(detail.data) : undefined} + /> + + ); +} + +const SkillsPanelContent: React.FC = ({ + accessToken, + userRole = "", + isViewOnly = false, +}) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [tab, setTab] = useUrlTab(SKILLS_TABS, "skills"); const [isAddModalVisible, setIsAddModalVisible] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const [editingSkill, setEditingSkill] = useState(); const [isDeleting, setIsDeleting] = useState(false); const [pluginToDelete, setPluginToDelete] = useState<{ name: string; displayName: string; } | null>(null); - const [selectedSkill, setSelectedSkill] = useState(null); - - const isAdmin = userRole ? isAdminRole(userRole) : false; - - const fetchPlugins = async () => { - if (!accessToken) { - setIsLoading(false); + const [selectedSkillId, setSelectedSkillId] = useState(null); + 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 { pageIndex, pageSize } = pagination; + const plugins = useQuery({ + queryKey: [...queryScope, "published", pageIndex, pageSize], + queryFn: () => listSkillHubSkills(accessToken!, { limit: pageSize, offset: pageIndex * pageSize }), + enabled: Boolean(accessToken), + }); + const pluginsList = plugins.data?.skills ?? []; + const selectedSkill = pluginsList.find((plugin) => plugin.id === selectedSkillId); + const refreshSkills = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryScope, refetchType: "all" }), + queryClient.invalidateQueries({ queryKey: ["skill-versions"], refetchType: "all" }), + ]); + }; + const handleSaved = async (response: SkillMutationResponse) => { + if (isPendingSkill(response)) { + await queryClient.invalidateQueries({ queryKey: [...queryScope, "submissions"], refetchType: "all" }); return; } - - setIsLoading(true); - try { - const response: ListPluginsResponse = await getClaudeCodePluginsList(accessToken, false); - setPluginsList(response.plugins); - } catch (error) { - console.error("Error fetching skills:", error); - } finally { - setIsLoading(false); - } + await refreshSkills(); }; - useEffect(() => { - fetchPlugins(); - }, [accessToken]); - const handleDeleteClick = (pluginName: string, displayName: string) => { setPluginToDelete({ name: pluginName, displayName }); }; const handleDeleteConfirm = async () => { - if (!pluginToDelete || !accessToken) return; + if (!pluginToDelete || !accessToken || !isAdmin) return; setIsDeleting(true); try { - await deleteClaudeCodePlugin(accessToken, pluginToDelete.name); + await deleteSkillHubSkill(accessToken, pluginToDelete.name); toast.success(t("skills.panel.toasts.deleted", { name: pluginToDelete.displayName })); - fetchPlugins(); + await refreshSkills(); } catch (error) { console.error("Error deleting skill:", error); toast.error(t("skills.panel.toasts.deleteFailed")); @@ -82,51 +154,96 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT return (
{selectedSkill ? ( - setSelectedSkill(null)} - isAdmin={isAdmin} accessToken={accessToken} - onPublishClick={fetchPlugins} + queryScope={queryScope} + canWrite={canWrite} + onBack={() => setSelectedSkillId(null)} + onEdit={setEditingSkill} /> ) : ( <>

{t("skills.panel.title")}

-

- /claude-code/marketplace.json, - }} - /> -

+

{t("skills.panel.intro")}

-
- { - const skill = pluginsList.find((p) => p.id === id); - if (skill) setSelectedSkill(skill); - }} - /> + setTab(value === "submissions" ? "submissions" : "skills")}> + + {t("skills.panel.tabs.skills")} + + {t(adminView ? "skills.panel.tabs.submissions" : "skills.panel.tabs.mySubmissions")} + + + + {plugins.isError && ( +
+ {t("skills.panel.loadFailed")} + +
+ )} + +
+ {t("skills.submissions.page", { page: pageIndex + 1 })} + + +
+
+ + {accessToken && ( + + )} + +
)} - setIsAddModalVisible(false)} - accessToken={accessToken} - onSuccess={fetchPlugins} - /> + {showForm && ( + { + setIsAddModalVisible(false); + setEditingSkill(undefined); + }} + accessToken={accessToken} + onSuccess={(response) => void handleSaved(response)} + /> + )} {pluginToDelete && ( = ({ accessT ); }; +const ClaudeCodePluginsPanel: React.FC = (props) => ( + +); + export default ClaudeCodePluginsPanel; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.test.tsx index 66a4d7e2524..4299045fd81 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.test.tsx @@ -2,27 +2,27 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { Plugin } from "@/components/claude_code_plugins/types"; +import { SkillHubSkill } from "@/components/claude_code_plugins/types"; import PluginTable from "./PluginTable"; -const mockPlugins: Plugin[] = [ +const mockPlugins: SkillHubSkill[] = [ { id: "plugin-id-newer", name: "newer-skill", version: "1.2.0", description: "A skill for testing", source: { source: "github", repo: "org/newer-skill" }, - category: "development", - enabled: true, - created_at: "2025-01-15T10:30:00Z", + domain: "development", + visibility: "public", + updated_at: "2025-01-15T10:30:00Z", }, { id: "plugin-id-older", name: "older-skill", source: { source: "github", repo: "org/older-skill" }, - enabled: false, - created_at: "2024-01-10T09:15:00Z", + visibility: "private", + updated_at: "2024-01-10T09:15:00Z", }, ]; @@ -44,7 +44,7 @@ describe("PluginTable", () => { it("should render every column header", () => { render(); - for (const header of ["Skill Name", "Version", "Description", "Category", "Public", "Created At"]) { + for (const header of ["Skill Name", "Version", "Description", "Domain", "Public", "Updated at"]) { expect(screen.getByText(header)).toBeInTheDocument(); } }); @@ -54,7 +54,7 @@ describe("PluginTable", () => { expect(screen.getByText("No skills found")).toBeInTheDocument(); }); - it("should sort by created date descending by default", () => { + it("should sort by name ascending by default", () => { render(); const rows = screen.getAllByRole("row").slice(1); expect(within(rows[0]).getByText("newer-skill")).toBeInTheDocument(); @@ -75,10 +75,10 @@ describe("PluginTable", () => { expect(mockOnPluginClick).not.toHaveBeenCalled(); }); - it("should badge the category and fall back to Uncategorized", () => { + it("should display the domain and a fallback when unset", () => { render(); expect(screen.getByText("development")).toBeInTheDocument(); - expect(screen.getByText("Uncategorized")).toBeInTheDocument(); + expect(screen.getAllByText("—")[0]).toBeInTheDocument(); }); it("should show whether the skill is public", () => { 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 6666f67ff4c..db61f80c690 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx @@ -6,19 +6,19 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { DataTable } from "@/components/shared/DataTable"; -import { Plugin } from "@/components/claude_code_plugins/types"; +import { SkillHubSkill } from "@/components/claude_code_plugins/types"; import { getPluginTableColumns } from "./PluginTableColumns"; interface PluginTableProps { - pluginsList: Plugin[]; + pluginsList: SkillHubSkill[]; isLoading: boolean; onDeleteClick: (pluginName: string, displayName: string) => void; isAdmin: boolean; onPluginClick: (pluginId: string) => void; } -const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; +const DEFAULT_SORTING: SortingState = [{ id: "name", desc: false }]; function EmptyState() { const { t } = useTranslation(); @@ -45,7 +45,7 @@ const PluginTable: React.FC = ({ pluginsList, isLoading, onDel return ( plugin.id || String(index)} sortingMode="client" 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 954d7531c8a..4c9b134050f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx @@ -6,10 +6,8 @@ import { useTranslation } from "react-i18next"; import { DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; -import { getCategoryBadgeColor } from "@/components/claude_code_plugins/helpers"; import i18n from "@/i18n/i18n"; -import { Plugin } from "@/components/claude_code_plugins/types"; -import { Badge } from "@/components/ui/badge"; +import { SkillHubSkill } from "@/components/claude_code_plugins/types"; import { buttonVariants } from "@/components/ui/button"; import { DropdownMenu, @@ -21,31 +19,8 @@ import { import { cn } from "@/lib/cva.config"; import { copyToClipboard } from "@/utils/dataUtils"; -const CATEGORY_BADGE_CLASS: Record, string> = { - blue: "border-info/20 bg-info/10 text-info", - green: "border-success/20 bg-success/10 text-success", - purple: - "border-purple-200 bg-purple-50 text-purple-600 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300", - red: "border-destructive/20 bg-destructive/10 text-destructive", - orange: "border-warning/20 bg-warning/10 text-warning", - yellow: "border-warning/20 bg-warning/10 text-warning", - gray: "border-border bg-muted text-muted-foreground", -}; - -function PluginCategoryBadge({ category }: { category?: string }) { - const { t } = useTranslation(); - return ( - - {category || t("skills.table.uncategorized")} - - ); -} - interface PluginRowActionsProps { - plugin: Plugin; + plugin: SkillHubSkill; isAdmin: boolean; onDeleteClick: (pluginName: string, displayName: string) => void; } @@ -97,7 +72,7 @@ export const getPluginTableColumns = ({ isAdmin, onPluginClick, onDeleteClick, -}: PluginTableColumnsDeps): ColumnDef[] => [ +}: PluginTableColumnsDeps): ColumnDef[] => [ { id: "name", accessorKey: "name", @@ -144,37 +119,39 @@ export const getPluginTableColumns = ({ }, }, { - id: "category", - accessorKey: "category", - meta: { title: i18n.t("skills.table.columns.category"), skeleton: "badge" }, - header: i18n.t("skills.table.columns.category"), + id: "domain", + accessorKey: "domain", + meta: { title: i18n.t("skills.table.columns.domain"), skeleton: "badge" }, + header: i18n.t("skills.table.columns.domain"), size: 150, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => {row.original.domain || "—"}, }, { - id: "enabled", - accessorKey: "enabled", + id: "visibility", + accessorKey: "visibility", meta: { title: i18n.t("skills.table.columns.public"), skeleton: "badge" }, header: i18n.t("skills.table.columns.public"), size: 100, enableSorting: false, cell: ({ row }) => ( ), }, { - id: "created_at", - accessorKey: "created_at", + id: "updated_at", + accessorKey: "updated_at", sortingFn: "datetime", - meta: { title: i18n.t("skills.table.columns.createdAt") }, - header: ({ column }) => , + meta: { title: i18n.t("skills.table.columns.updatedAt") }, + header: ({ column }) => , size: 160, enableSorting: true, - cell: ({ row }) => , + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/SkillSubmissions.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/SkillSubmissions.tsx new file mode 100644 index 00000000000..720103afe74 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/SkillSubmissions.tsx @@ -0,0 +1,358 @@ +import { useState } from "react"; +import { useQuery, type QueryKey } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { downloadBlob } from "@/components/EntityUsageExport/utils"; +import { + downloadSkillSubmission, + getSkillSubmission, + listSkillSubmissions, + reviewSkillSubmission, +} from "@/components/networking"; +import type { SkillHubSkill, SkillSubmission } from "@/components/claude_code_plugins/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState"; +import { toast } from "@/lib/toast"; + +const METADATA_FIELDS = ["name", "version", "description", "source", "keywords", "domain", "namespace"] as const; + +const displayValue = (value: unknown): string => { + if (value === null || value === undefined || value === "") return ""; + return typeof value === "object" ? JSON.stringify(value, null, 2) : String(value); +}; + +const STATUS_CLASSES = { + pending_review: "border-warning/30 text-warning", + approved: "border-success/30 text-success", + rejected: "border-destructive/30 text-destructive", +}; + +const SUBMISSION_TABLE_STATE: UrlTableStateOptions = { + keyPrefix: "submissions_", + defaultPageSize: 20, + maxPageSize: 100, + sortFields: ["created_at"], + defaultSort: { id: "created_at", desc: true }, + filterColumns: [], +}; + +function SubmissionStatus({ status }: { status: SkillSubmission["status"] }) { + const { t } = useTranslation(); + return ( + + {t(`skills.submissions.status.${status}`)} + + ); +} + +function SubmissionSummary({ submission }: { submission: SkillSubmission }) { + const { t } = useTranslation(); + return ( + <> +
+ + {t(`skills.submissions.operation.${submission.operation}`)} + + {t("skills.submissions.submittedBy", { name: submission.submitted_by })} + +
+ {submission.status === "pending_review" && ( +

{t("skills.submissions.pendingNotice")}

+ )} + {submission.rejection_reason && ( +

+ {t("skills.submissions.rejectionReason", { reason: submission.rejection_reason })} +

+ )} + {submission.reviewed_at && ( +

+ {t("skills.submissions.reviewedBy", { + name: submission.reviewed_by, + date: new Date(submission.reviewed_at).toLocaleString(), + })} +

+ )} + + ); +} + +function SubmissionComparison({ + submission, + current, +}: { + submission: SkillSubmission; + current?: SkillHubSkill | null; +}) { + const { t } = useTranslation(); + return ( +
+ + + + + + + + + + {METADATA_FIELDS.map((field) => ( + + + + + + ))} + + + + + + +
{t("skills.submissions.field")}{t("skills.submissions.current")}{t("skills.submissions.proposed")}
{t(`skills.submissions.fields.${field}`)} +
+                  {displayValue(current?.[field]) || t("skills.submissions.notSet")}
+                
+
+
+                  {field === "version" && submission.operation === "update" && !submission.proposal.version
+                    ? t("skills.submissions.nextPatchVersion")
+                    : displayValue(submission.proposal[field]) || t("skills.submissions.notSet")}
+                
+
{t("skills.submissions.fields.agent_skill")}{t("skills.submissions.descriptorUnavailable")} +
+                {displayValue(submission.proposal.agent_skill) || t("skills.submissions.notSet")}
+              
+
+
+ ); +} + +interface SubmissionReviewProps { + id: string; + accessToken: string; + queryScope: QueryKey; + canReview: boolean; + onClose: () => void; + onChanged: () => Promise; +} + +function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onChanged }: SubmissionReviewProps) { + const { t } = useTranslation(); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + const [downloading, setDownloading] = useState(false); + const [error, setError] = useState(null); + const detail = useQuery({ + queryKey: [...queryScope, "submission", id], + queryFn: () => getSkillSubmission(accessToken, id), + }); + const { submission, current_skill: current } = detail.data ?? {}; + const canDecide = canReview && submission?.status === "pending_review"; + const review = async (action: "approve" | "reject") => { + if (!canDecide || busy) return; + setBusy(true); + setError(null); + try { + await reviewSkillSubmission( + accessToken, + id, + action, + action === "reject" ? { reason: reason.trim() || null } : undefined, + ); + onClose(); + await onChanged(); + toast.success(t(action === "approve" ? "skills.submissions.approved" : "skills.submissions.rejected")); + } catch (err) { + setError(err instanceof Error ? err.message : t("skills.submissions.actionFailed")); + await onChanged(); + } finally { + setBusy(false); + } + }; + const download = async () => { + if (!submission || downloading) return; + setDownloading(true); + setError(null); + try { + downloadBlob(await downloadSkillSubmission(accessToken, id), `${submission.skill_name}.zip`); + } catch (err) { + setError(err instanceof Error ? err.message : t("skills.submissions.downloadFailed")); + } finally { + setDownloading(false); + } + }; + return ( + !open && !busy && onClose()}> + + + {t("skills.submissions.reviewTitle", { name: submission?.skill_name ?? "" })} + + {detail.isPending &&

{t("skills.submissions.loading")}

} + {detail.isError && ( +
+ {t("skills.submissions.loadFailed")} + +
+ )} + {submission && ( + <> + + {!current &&

{t("skills.submissions.noCurrent")}

} + + {submission.has_archive && submission.proposal.agent_skill && ( + + )} + {canDecide && ( + <> + +