From 7e4ad6eb333071d67c2eb03ce6468f89a0855027 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Thu, 5 Nov 2026 00:56:51 -0800 Subject: [PATCH] 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 && ( + <> + +