From 76528243258de927e3610ea3c1ce3f1ec1390536 Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 12 Aug 2026 03:03:13 +0000 Subject: [PATCH 1/2] feat(skills): self-service skill submission with admin review Non-admin registrations land as approval_status=pending_review and disabled, so only skills an admin approves reach marketplace.json and the public Skill Hub. Admins approve or reject with notes through new /claude-code/plugins/{name}/approve and /reject routes, and the Skills page gets a submit button for everyone plus a pending queue with approve and reject for admins. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../migration.sql | 8 + .../litellm_proxy_extras/schema.prisma | 25 +- litellm/proxy/_lazy_openapi_snapshot.json | 299 ++++++++++++++++- litellm/proxy/_types.py | 4 + .../claude_code_marketplace.py | 268 ++++++++++++++- .../public_endpoints/public_endpoints.py | 5 +- litellm/proxy/schema.prisma | 25 +- litellm/types/proxy/claude_code_endpoints.py | 34 +- schema.prisma | 25 +- .../test_claude_code_marketplace.py | 306 +++++++++++++++++- .../proxy/auth/test_route_checks.py | 26 ++ .../public_endpoints/test_public_endpoints.py | 45 +++ .../ClaudeCodePluginsPanel.test.tsx | 90 +++++- .../_components/ClaudeCodePluginsPanel.tsx | 65 +++- .../skills/_components/PluginTable.test.tsx | 47 ++- .../skills/_components/PluginTable.tsx | 14 +- .../skills/_components/PluginTableColumns.tsx | 52 ++- .../skills/_components/ReviewSkillDialog.tsx | 83 +++++ .../skills/_components/add_plugin_form.tsx | 8 +- .../MakeSkillPublicForm.tsx | 34 +- .../claude_code_plugins/helpers.test.ts | 32 ++ .../components/claude_code_plugins/helpers.ts | 27 +- .../components/claude_code_plugins/types.ts | 10 + .../src/components/networking.tsx | 20 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 235 +++++++++++++- 25 files changed, 1683 insertions(+), 104 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ReviewSkillDialog.tsx diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql new file mode 100644 index 00000000000..0010e5934ca --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "approval_status" TEXT DEFAULT 'active'; +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "review_notes" TEXT; +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "reviewed_by" TEXT; +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "reviewed_at" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginTable_approval_status_idx" ON "LiteLLM_ClaudeCodePluginTable"("approval_status"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 854602f5380..d78457b3594 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable { } // Claude Code Plugin Marketplace table model LiteLLM_ClaudeCodePluginTable { - id String @id @default(uuid()) - name String @unique - version String? - description String? - manifest_json String? - files_json String? @default("{}") - enabled Boolean @default(true) - created_at DateTime? @default(now()) - updated_at DateTime? @default(now()) @updatedAt - created_by String? + id String @id @default(uuid()) + name String @unique + version String? + description String? + manifest_json String? + files_json String? @default("{}") + enabled Boolean @default(true) + approval_status String? @default("active") + review_notes String? + reviewed_by String? + reviewed_at DateTime? + created_at DateTime? @default(now()) + updated_at DateTime? @default(now()) @updatedAt + created_by String? + @@index([approval_status]) @@map("LiteLLM_ClaudeCodePluginTable") } diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 7fe02c6d8bc..bfab71a45f8 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -4380,6 +4380,16 @@ "PluginListItem": { "description": "Plugin item in list responses.", "properties": { + "approval_status": { + "default": "active", + "enum": [ + "pending_review", + "active", + "rejected" + ], + "title": "Approval Status", + "type": "string" + }, "author": { "anyOf": [ { @@ -4412,6 +4422,17 @@ ], "title": "Created At" }, + "created_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created By" + }, "description": { "anyOf": [ { @@ -4482,6 +4503,39 @@ ], "title": "Namespace" }, + "review_notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Review Notes" + }, + "reviewed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reviewed At" + }, + "reviewed_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reviewed By" + }, "source": { "additionalProperties": { "type": "string" @@ -4528,6 +4582,17 @@ "PluginResponse": { "description": "Plugin information in API responses.", "properties": { + "approval_status": { + "default": "active", + "description": "Administrator approval state", + "enum": [ + "pending_review", + "active", + "rejected" + ], + "title": "Approval Status", + "type": "string" + }, "description": { "anyOf": [ { @@ -4713,7 +4778,7 @@ "description": "Response from plugin registration.", "properties": { "action": { - "description": "Action taken (created/updated)", + "description": "Action taken (created/submitted_for_review/updated)", "title": "Action", "type": "string" }, @@ -4735,6 +4800,99 @@ "title": "RegisterPluginResponse", "type": "object" }, + "RejectPluginRequest": { + "description": "Administrator rejection of a submitted skill.", + "properties": { + "review_notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Reviewer feedback shown to the submitter", + "title": "Review Notes" + } + }, + "title": "RejectPluginRequest", + "type": "object" + }, + "ReviewPluginResponse": { + "description": "Response from approving or rejecting a submitted skill.", + "properties": { + "approval_status": { + "description": "Resulting approval state", + "enum": [ + "pending_review", + "active", + "rejected" + ], + "title": "Approval Status", + "type": "string" + }, + "enabled": { + "description": "Whether the skill is now served to users", + "title": "Enabled", + "type": "boolean" + }, + "name": { + "description": "Skill name", + "title": "Name", + "type": "string" + }, + "review_notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Reviewer feedback", + "title": "Review Notes" + }, + "reviewed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO timestamp of the review", + "title": "Reviewed At" + }, + "reviewed_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "User id of the reviewing administrator", + "title": "Reviewed By" + }, + "status": { + "description": "Operation status", + "title": "Status", + "type": "string" + } + }, + "required": [ + "status", + "name", + "approval_status", + "enabled" + ], + "title": "ReviewPluginResponse", + "type": "object" + }, "UpdatePluginRequest": { "description": "Request body for replacing an existing plugin.\n\nThe plugin name is the resource identity and is supplied as the path\nparameter, so it cannot be changed here. This is a full replace: omitted\nfields reset to their defaults, so version is cleared rather than\ndefaulting to the create-time \"1.0.0\".", "properties": { @@ -4916,7 +5074,7 @@ }, "/claude-code/plugins": { "get": { - "description": "List all plugins in the marketplace.\n\nParameters:\n - enabled_only: If true, only return enabled plugins\n\nReturns:\n List of plugins with their metadata.", + "description": "List plugins in the marketplace.\n\nAdmins see every skill, including submissions awaiting review. Everyone\nelse sees approved skills plus their own submissions, so a submitter can\ntrack the status of what they sent in.\n\nParameters:\n - enabled_only: If true, only return enabled plugins\n - approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue\n\nReturns:\n List of plugins with their metadata and review state.", "operationId": "list_plugins_claude_code_plugins_get", "parameters": [ { @@ -4928,6 +5086,27 @@ "title": "Enabled Only", "type": "boolean" } + }, + { + "in": "query", + "name": "approval_status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "pending_review", + "active", + "rejected" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Approval Status" + } } ], "responses": { @@ -4963,7 +5142,7 @@ ] }, "post": { - "description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nCallers that are not proxy admins are self-service submitters: the skill\nis stored with approval_status=pending_review and stays disabled until an\nadmin approves it via POST /claude-code/plugins/{plugin_name}/approve.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (\"created\" for admins, \"submitted_for_review\" otherwise)\n and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", "operationId": "register_plugin_claude_code_plugins_post", "requestBody": { "content": { @@ -5010,7 +5189,7 @@ }, "/claude-code/plugins/{plugin_name}": { "delete": { - "description": "Delete a plugin from the marketplace.\n\nParameters:\n - plugin_name: The name of the plugin to delete", + "description": "Delete a plugin from the marketplace. Admins can delete any skill; a\nsubmitter can withdraw one they submitted.\n\nParameters:\n - plugin_name: The name of the plugin to delete", "operationId": "delete_plugin_claude_code_plugins__plugin_name__delete", "parameters": [ { @@ -5098,7 +5277,7 @@ ] }, "put": { - "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nAdmins can update any skill and the review state is left untouched. A\nsubmitter can only update their own skill, and doing so sends it back to\npending review, since the content an admin approved has changed.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", "operationId": "update_plugin_claude_code_plugins__plugin_name__put", "parameters": [ { @@ -5154,9 +5333,57 @@ ] } }, + "/claude-code/plugins/{plugin_name}/approve": { + "post": { + "description": "Approve a submitted skill (admin only).\n\nApproving sets approval_status=active and publishes the skill to\nmarketplace.json and the public Skill Hub.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\\n -H \"Authorization: Bearer sk-admin-...\"\n ```", + "operationId": "approve_plugin_claude_code_plugins__plugin_name__approve_post", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewPluginResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Approve Plugin", + "tags": [ + "claude_code_marketplace" + ] + } + }, "/claude-code/plugins/{plugin_name}/disable": { "post": { - "description": "Disable a plugin without deleting it.\n\nParameters:\n - plugin_name: The name of the plugin to disable", + "description": "Disable a plugin without deleting it. Proxy admins only.\n\nParameters:\n - plugin_name: The name of the plugin to disable", "operationId": "disable_plugin_claude_code_plugins__plugin_name__disable_post", "parameters": [ { @@ -5202,7 +5429,7 @@ }, "/claude-code/plugins/{plugin_name}/enable": { "post": { - "description": "Enable a disabled plugin.\n\nParameters:\n - plugin_name: The name of the plugin to enable", + "description": "Enable a disabled plugin. Proxy admins only.\n\nA skill that has not been approved cannot be enabled here: approve it\nthrough POST /claude-code/plugins/{plugin_name}/approve instead, so the\nreviewer is recorded on the row.\n\nParameters:\n - plugin_name: The name of the plugin to enable", "operationId": "enable_plugin_claude_code_plugins__plugin_name__enable_post", "parameters": [ { @@ -5245,6 +5472,64 @@ "claude_code_marketplace" ] } + }, + "/claude-code/plugins/{plugin_name}/reject": { + "post": { + "description": "Reject a submitted skill (admin only).\n\nThe row is kept unpublished so the submitter can read review_notes and fix the submission.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \\\n -H \"Authorization: Bearer sk-admin-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"review_notes\": \"point the source at the skill folder\"}'\n ```", + "operationId": "reject_plugin_claude_code_plugins__plugin_name__reject_post", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RejectPluginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewPluginResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Reject Plugin", + "tags": [ + "claude_code_marketplace" + ] + } } } }, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 08348187645..8fbc8758b4b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -753,6 +753,10 @@ class LiteLLMRoutes(enum.Enum): # Create/update/delete and test_connection stay admin-only. "/search_tools/list", "/search_tools/ui/available_providers", + # Self-service skill submission. Approve/reject stay admin-only. + "/claude-code/marketplace.json", + "/claude-code/plugins", + "/claude-code/plugins/{plugin_name}", ] + spend_tracking_routes + key_management_routes diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index 46ee9b0911d..a2b57158a63 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -13,14 +13,21 @@ /claude-code/plugins/{name} - PUT - Update an existing plugin /claude-code/plugins/{name}/enable - POST - Enable a plugin /claude-code/plugins/{name}/disable - POST - Disable a plugin +/claude-code/plugins/{name}/approve - POST - Approve a submitted plugin (admin) +/claude-code/plugins/{name}/reject - POST - Reject a submitted plugin (admin) /claude-code/plugins/{name} - DELETE - Delete a plugin + +Skills registered by a non-admin are submissions: they are stored with +approval_status="pending_review" and disabled until an administrator approves +them, so only approved skills reach marketplace.json and the public Skill Hub. +This mirrors the MCP server and guardrail submission flows. """ import json import re from collections.abc import Mapping, Sequence from datetime import datetime, timezone -from typing import Final, Protocol, TypedDict +from typing import Annotated, Final, Protocol, TypedDict from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse @@ -28,14 +35,25 @@ from litellm._logging import verbose_proxy_logger from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.resource_ownership import ( + get_primary_resource_owner_scope, + get_resource_owner_scopes, + is_proxy_admin, +) from litellm.repositories.table_repositories import ClaudeCodePluginRepository from litellm.types.proxy.claude_code_endpoints import ( + SKILL_ACTIVE, + SKILL_PENDING_REVIEW, + SKILL_REJECTED, ListPluginsResponse, PluginListItem, PluginResponse, PluginSpec, RegisterPluginRequest, RegisterPluginResponse, + RejectPluginRequest, + ReviewPluginResponse, + SkillApprovalStatus, UpdatePluginRequest, ) @@ -49,6 +67,10 @@ class _PluginRecord(Protocol): description: str | None manifest_json: str | None enabled: bool + approval_status: str | None + review_notes: str | None + reviewed_by: str | None + reviewed_at: datetime | None created_at: datetime | None updated_at: datetime | None created_by: str | None @@ -65,6 +87,49 @@ class _MarketplaceEntry(TypedDict, total=False): category: object +def published_skill_filter() -> dict[str, object]: # mutable-ok: prisma query arguments must be plain dicts + """Where-clause for the skills served to users: admin-approved and enabled.""" + return {"enabled": True, "approval_status": SKILL_ACTIVE} # mutable-ok: prisma query arguments are dicts + + +def _as_approval_status(raw: str | None) -> SkillApprovalStatus: + """Rows written before approval existed, and any hand-edited row, read back as active.""" + match raw: + case "pending_review": + return SKILL_PENDING_REVIEW + case "rejected": + return SKILL_REJECTED + case _: + return SKILL_ACTIVE + + +def _caller_can_see(plugin: "_PluginRecord", user_api_key_dict: UserAPIKeyAuth) -> bool: + return ( + is_proxy_admin(user_api_key_dict) + or _as_approval_status(plugin.approval_status) == SKILL_ACTIVE + or plugin.created_by in get_resource_owner_scopes(user_api_key_dict) + ) + + +def _list_plugins_filter( + *, + enabled_only: bool, + approval_status: SkillApprovalStatus | None, + user_api_key_dict: UserAPIKeyAuth, +) -> dict[str, object]: # mutable-ok: prisma query arguments must be plain dicts + status_terms: Final[tuple[tuple[str, object], ...]] = ( + *((("enabled", True),) if enabled_only else ()), + *((("approval_status", approval_status),) if approval_status is not None else ()), + ) + if is_proxy_admin(user_api_key_dict): + return dict(status_terms) # mutable-ok: prisma query arguments are dicts + + owner_scopes: Final = get_resource_owner_scopes(user_api_key_dict) + own_skills: Final = ({"created_by": {"in": owner_scopes}},) if owner_scopes else () # mutable-ok: prisma dicts + visible: Final = [{"approval_status": SKILL_ACTIVE}, *own_skills] # mutable-ok: prisma query arguments are dicts + return dict((*status_terms, ("OR", visible))) # mutable-ok: prisma query arguments are dicts + + async def _get_prisma_client() -> object: """Get the prisma client from proxy_server.""" from litellm.proxy.proxy_server import prisma_client @@ -102,7 +167,7 @@ async def get_marketplace(): prisma_client: Final = await _get_prisma_client() plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many( - where={"enabled": True} + where=published_skill_filter() ) plugin_list: Final = [] @@ -242,6 +307,10 @@ async def register_plugin( the same name already exists it returns 409 Conflict; use PUT /claude-code/plugins/{plugin_name} to update an existing plugin. + Callers that are not proxy admins are self-service submitters: the skill + is stored with approval_status=pending_review and stays disabled until an + admin approves it via POST /claude-code/plugins/{plugin_name}/approve. + Parameters: - name: Plugin name (kebab-case) - source: Git source reference (github, url, or git-subdir format) @@ -253,7 +322,8 @@ async def register_plugin( - category: Plugin category (optional) Returns: - Registration status (action is always "created") and plugin information. + Registration status ("created" for admins, "submitted_for_review" otherwise) + and plugin information. Example: ```bash @@ -288,6 +358,8 @@ async def register_plugin( raise _name_conflict_error(request.name) manifest: Final[Mapping[str, object]] = _build_plugin_manifest(request.name, request) + submitted_for_review: Final = not is_proxy_admin(user_api_key_dict) + approval_status: Final[SkillApprovalStatus] = SKILL_PENDING_REVIEW if submitted_for_review else SKILL_ACTIVE try: plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.create( @@ -297,20 +369,21 @@ async def register_plugin( "description": request.description, "manifest_json": json.dumps(manifest), "files_json": "{}", - "enabled": True, + "enabled": not submitted_for_review, + "approval_status": approval_status, "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc), - "created_by": user_api_key_dict.user_id, + "created_by": user_api_key_dict.user_id or get_primary_resource_owner_scope(user_api_key_dict), } ) except UniqueViolationError: raise _name_conflict_error(request.name) - verbose_proxy_logger.info("Plugin %s created successfully", request.name) + verbose_proxy_logger.info("Plugin %s created with approval_status=%s", request.name, approval_status) return RegisterPluginResponse( status="success", - action="created", + action="submitted_for_review" if submitted_for_review else "created", plugin=PluginResponse( id=plugin.id, name=plugin.name, @@ -318,6 +391,7 @@ async def register_plugin( description=plugin.description, source=request.source, enabled=plugin.enabled, + approval_status=approval_status, ), ) @@ -339,23 +413,32 @@ async def register_plugin( ) async def list_plugins( enabled_only: bool = False, + approval_status: SkillApprovalStatus | None = None, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - List all plugins in the marketplace. + List plugins in the marketplace. + + Admins see every skill, including submissions awaiting review. Everyone + else sees approved skills plus their own submissions, so a submitter can + track the status of what they sent in. Parameters: - enabled_only: If true, only return enabled plugins + - approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue Returns: - List of plugins with their metadata. + List of plugins with their metadata and review state. """ try: prisma_client: Final = await _get_prisma_client() - where: Final = {"enabled": True} if enabled_only else {} plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many( - where=where + where=_list_plugins_filter( + enabled_only=enabled_only, + approval_status=approval_status, + user_api_key_dict=user_api_key_dict, + ) ) plugin_list: Final = [] @@ -377,6 +460,11 @@ async def list_plugins( domain=manifest.get("domain"), namespace=manifest.get("namespace"), enabled=p.enabled, + approval_status=_as_approval_status(p.approval_status), + review_notes=p.review_notes, + reviewed_by=p.reviewed_by, + reviewed_at=p.reviewed_at.isoformat() if p.reviewed_at else None, + created_by=p.created_by, created_at=p.created_at.isoformat() if p.created_at else None, updated_at=p.updated_at.isoformat() if p.updated_at else None, ) @@ -425,7 +513,7 @@ async def get_plugin( where={"name": plugin_name} ) - if not plugin: + if not plugin or not _caller_can_see(plugin, user_api_key_dict): raise HTTPException( status_code=404, detail={"error": f"Plugin '{plugin_name}' not found"}, @@ -444,6 +532,10 @@ async def get_plugin( "keywords": manifest.get("keywords"), "category": manifest.get("category"), "enabled": plugin.enabled, + "approval_status": _as_approval_status(plugin.approval_status), + "review_notes": plugin.review_notes, + "reviewed_by": plugin.reviewed_by, + "reviewed_at": plugin.reviewed_at.isoformat() if plugin.reviewed_at else None, "created_at": plugin.created_at.isoformat() if plugin.created_at else None, "updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None, "created_by": plugin.created_by, @@ -468,6 +560,7 @@ async def get_plugin( async def update_plugin( plugin_name: str, request: UpdatePluginRequest, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], ): """ Update an existing plugin in the LiteLLM marketplace. @@ -481,6 +574,10 @@ async def update_plugin( Returns 404 if no plugin with the given name exists; use POST /claude-code/plugins to create a new plugin. + Admins can update any skill and the review state is left untouched. A + submitter can only update their own skill, and doing so sends it back to + pending review, since the content an admin approved has changed. + Parameters: - plugin_name: Name of the plugin to update (path parameter) - source: Git source reference (github, url, or git-subdir format) @@ -519,6 +616,10 @@ async def update_plugin( if not existing: raise _error_response(404, f"Plugin '{plugin_name}' not found") + is_admin: Final = is_proxy_admin(user_api_key_dict) + if not is_admin and existing.created_by not in get_resource_owner_scopes(user_api_key_dict): + raise _error_response(403, "Only proxy admins or the submitter can update this skill") + manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request) plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.update( @@ -529,6 +630,9 @@ async def update_plugin( "manifest_json": json.dumps(manifest), "files_json": "{}", "updated_at": datetime.now(timezone.utc), + **( + {} if is_admin else {"approval_status": SKILL_PENDING_REVIEW, "enabled": False} + ), # mutable-ok: prisma query arguments must be plain dicts }, ) @@ -544,6 +648,7 @@ async def update_plugin( description=plugin.description, source=request.source, enabled=plugin.enabled, + approval_status=_as_approval_status(plugin.approval_status), ), ) @@ -554,6 +659,116 @@ async def update_plugin( raise _error_response(500, f"Update failed: {e}") +async def _record_review( + *, + plugin_name: str, + approval_status: SkillApprovalStatus, + review_notes: str | None, + user_api_key_dict: UserAPIKeyAuth, +) -> ReviewPluginResponse: + if not is_proxy_admin(user_api_key_dict): + raise _error_response(403, "Admin access required to review submitted skills") + + prisma_client: Final = await _get_prisma_client() + repository: Final = ClaudeCodePluginRepository(prisma_client) + + existing: Final[_PluginRecord | None] = await repository.table.find_unique( + where={"name": plugin_name} # mutable-ok: prisma query arguments must be plain dicts + ) + if not existing: + raise _error_response(404, f"Plugin '{plugin_name}' not found") + + if _as_approval_status(existing.approval_status) == approval_status: + raise _error_response(400, f"Skill '{plugin_name}' is already {approval_status}") + + reviewed_at: Final = datetime.now(timezone.utc) + plugin: Final[_PluginRecord] = await repository.table.update( + where={"name": plugin_name}, # mutable-ok: prisma query arguments must be plain dicts + data={ # mutable-ok: prisma query arguments must be plain dicts + "approval_status": approval_status, + "review_notes": review_notes, + "reviewed_by": user_api_key_dict.user_id, + "reviewed_at": reviewed_at, + "enabled": approval_status == SKILL_ACTIVE, + "updated_at": reviewed_at, + }, + ) + + verbose_proxy_logger.info("Plugin %s reviewed: approval_status=%s", plugin_name, approval_status) + + return ReviewPluginResponse( + status="success", + name=plugin.name, + approval_status=approval_status, + enabled=plugin.enabled, + reviewed_by=plugin.reviewed_by, + reviewed_at=plugin.reviewed_at.isoformat() if plugin.reviewed_at else None, + review_notes=plugin.review_notes, + ) + + +@router.post( + "/claude-code/plugins/{plugin_name}/approve", + tags=["Claude Code Marketplace"], + dependencies=[Depends(user_api_key_auth)], + response_model=ReviewPluginResponse, +) +async def approve_plugin( + plugin_name: str, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +): + """ + Approve a submitted skill (admin only). + + Approving sets approval_status=active and publishes the skill to + marketplace.json and the public Skill Hub. + + Example: + ```bash + curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\ + -H "Authorization: Bearer sk-admin-..." + ``` + """ + return await _record_review( + plugin_name=plugin_name, + approval_status=SKILL_ACTIVE, + review_notes=None, + user_api_key_dict=user_api_key_dict, + ) + + +@router.post( + "/claude-code/plugins/{plugin_name}/reject", + tags=["Claude Code Marketplace"], + dependencies=[Depends(user_api_key_auth)], + response_model=ReviewPluginResponse, +) +async def reject_plugin( + plugin_name: str, + request: RejectPluginRequest, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +): + """ + Reject a submitted skill (admin only). + + The row is kept unpublished so the submitter can read review_notes and fix the submission. + + Example: + ```bash + curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \\ + -H "Authorization: Bearer sk-admin-..." \\ + -H "Content-Type: application/json" \\ + -d '{"review_notes": "point the source at the skill folder"}' + ``` + """ + return await _record_review( + plugin_name=plugin_name, + approval_status=SKILL_REJECTED, + review_notes=request.review_notes, + user_api_key_dict=user_api_key_dict, + ) + + @router.post( "/claude-code/plugins/{plugin_name}/enable", tags=["Claude Code Marketplace"], @@ -564,11 +779,18 @@ async def enable_plugin( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Enable a disabled plugin. + Enable a disabled plugin. Proxy admins only. + + A skill that has not been approved cannot be enabled here: approve it + through POST /claude-code/plugins/{plugin_name}/approve instead, so the + reviewer is recorded on the row. Parameters: - plugin_name: The name of the plugin to enable """ + if not is_proxy_admin(user_api_key_dict): + raise _error_response(403, "Only proxy admins can publish skills") + try: prisma_client: Final = await _get_prisma_client() @@ -581,6 +803,13 @@ async def enable_plugin( detail={"error": f"Plugin '{plugin_name}' not found"}, ) + if _as_approval_status(plugin.approval_status) != SKILL_ACTIVE: + raise _error_response( + 409, + f"Skill '{plugin_name}' is awaiting review. Approve it via " + f"POST /claude-code/plugins/{plugin_name}/approve", + ) + await ClaudeCodePluginRepository(prisma_client).table.update( where={"name": plugin_name}, data={"enabled": True, "updated_at": datetime.now(timezone.utc)}, @@ -609,11 +838,14 @@ async def disable_plugin( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Disable a plugin without deleting it. + Disable a plugin without deleting it. Proxy admins only. Parameters: - plugin_name: The name of the plugin to disable """ + if not is_proxy_admin(user_api_key_dict): + raise _error_response(403, "Only proxy admins can unpublish skills") + try: prisma_client: Final = await _get_prisma_client() @@ -654,7 +886,8 @@ async def delete_plugin( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Delete a plugin from the marketplace. + Delete a plugin from the marketplace. Admins can delete any skill; a + submitter can withdraw one they submitted. Parameters: - plugin_name: The name of the plugin to delete @@ -671,6 +904,11 @@ async def delete_plugin( detail={"error": f"Plugin '{plugin_name}' not found"}, ) + if not is_proxy_admin(user_api_key_dict) and plugin.created_by not in get_resource_owner_scopes( + user_api_key_dict + ): + raise _error_response(403, "Only proxy admins or the submitter can delete this skill") + await ClaudeCodePluginRepository(prisma_client).table.delete(where={"name": plugin_name}) verbose_proxy_logger.info("Plugin %s deleted", plugin_name) diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 79791607b2e..53e31297f70 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -248,9 +248,10 @@ async def get_mcp_servers(): tags=["public", "Claude Code Marketplace"], ) async def public_skill_hub(): - """Return enabled (public) Claude Code skills — no auth required.""" + """Return approved, enabled (public) Claude Code skills. No auth required.""" from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( _get_prisma_client, + published_skill_filter, ) from litellm.types.proxy.claude_code_endpoints import ( ListPluginsResponse, @@ -259,7 +260,7 @@ async def public_skill_hub(): try: prisma_client: Final = await _get_prisma_client() - plugins: Final = await ClaudeCodePluginRepository(prisma_client).table.find_many(where={"enabled": True}) + plugins: Final = await ClaudeCodePluginRepository(prisma_client).table.find_many(where=published_skill_filter()) items: Final = [] for plugin in plugins: raw = plugin.manifest_json or {} diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 854602f5380..d78457b3594 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable { } // Claude Code Plugin Marketplace table model LiteLLM_ClaudeCodePluginTable { - id String @id @default(uuid()) - name String @unique - version String? - description String? - manifest_json String? - files_json String? @default("{}") - enabled Boolean @default(true) - created_at DateTime? @default(now()) - updated_at DateTime? @default(now()) @updatedAt - created_by String? + id String @id @default(uuid()) + name String @unique + version String? + description String? + manifest_json String? + files_json String? @default("{}") + enabled Boolean @default(true) + approval_status String? @default("active") + review_notes String? + reviewed_by String? + reviewed_at DateTime? + created_at DateTime? @default(now()) + updated_at DateTime? @default(now()) @updatedAt + created_by String? + @@index([approval_status]) @@map("LiteLLM_ClaudeCodePluginTable") } diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 2ee1bbbbb98..59eb961907c 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -2,8 +2,16 @@ Claude Code Marketplace endpoint types for LiteLLM Proxy """ +from typing import Final, Literal + from pydantic import BaseModel, Field +SkillApprovalStatus = Literal["pending_review", "active", "rejected"] + +SKILL_PENDING_REVIEW: Final[SkillApprovalStatus] = "pending_review" +SKILL_ACTIVE: Final[SkillApprovalStatus] = "active" +SKILL_REJECTED: Final[SkillApprovalStatus] = "rejected" + class PluginAuthor(BaseModel): """Plugin author information.""" @@ -78,16 +86,35 @@ class PluginResponse(BaseModel): description: str | None = Field(None, description="Plugin description") source: dict[str, str] = Field(..., description="Git source reference") enabled: bool = Field(..., description="Whether plugin is enabled") + approval_status: SkillApprovalStatus = Field("active", description="Administrator approval state") class RegisterPluginResponse(BaseModel): """Response from plugin registration.""" status: str = Field(..., description="Operation status") - action: str = Field(..., description="Action taken (created/updated)") + action: str = Field(..., description="Action taken (created/submitted_for_review/updated)") plugin: PluginResponse = Field(..., description="Plugin information") +class RejectPluginRequest(BaseModel): + """Administrator rejection of a submitted skill.""" + + review_notes: str | None = Field(None, description="Reviewer feedback shown to the submitter") + + +class ReviewPluginResponse(BaseModel): + """Response from approving or rejecting a submitted skill.""" + + status: str = Field(..., description="Operation status") + name: str = Field(..., description="Skill name") + approval_status: SkillApprovalStatus = Field(..., description="Resulting approval state") + enabled: bool = Field(..., description="Whether the skill is now served to users") + reviewed_by: str | None = Field(None, description="User id of the reviewing administrator") + reviewed_at: str | None = Field(None, description="ISO timestamp of the review") + review_notes: str | None = Field(None, description="Reviewer feedback") + + class PluginListItem(BaseModel): """Plugin item in list responses.""" @@ -103,6 +130,11 @@ class PluginListItem(BaseModel): domain: str | None = None namespace: str | None = None enabled: bool + approval_status: SkillApprovalStatus = "active" + review_notes: str | None = None + reviewed_by: str | None = None + reviewed_at: str | None = None + created_by: str | None = None created_at: str | None updated_at: str | None diff --git a/schema.prisma b/schema.prisma index 854602f5380..d78457b3594 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable { } // Claude Code Plugin Marketplace table model LiteLLM_ClaudeCodePluginTable { - id String @id @default(uuid()) - name String @unique - version String? - description String? - manifest_json String? - files_json String? @default("{}") - enabled Boolean @default(true) - created_at DateTime? @default(now()) - updated_at DateTime? @default(now()) @updatedAt - created_by String? + id String @id @default(uuid()) + name String @unique + version String? + description String? + manifest_json String? + files_json String? @default("{}") + enabled Boolean @default(true) + approval_status String? @default("active") + review_notes String? + reviewed_by String? + reviewed_at DateTime? + created_at DateTime? @default(now()) + updated_at DateTime? @default(now()) @updatedAt + created_by String? + @@index([approval_status]) @@map("LiteLLM_ClaudeCodePluginTable") } diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 18e0f2cb559..4439c2dbf41 100644 --- a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -15,11 +15,19 @@ from litellm.proxy.proxy_server import LitellmUserRoles from litellm.types.proxy.claude_code_endpoints import ( RegisterPluginRequest, + RejectPluginRequest, UpdatePluginRequest, ) from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( + approve_plugin, + delete_plugin, + disable_plugin, + enable_plugin, get_marketplace, + get_plugin, + list_plugins, register_plugin, + reject_plugin, update_plugin, ) @@ -35,20 +43,41 @@ def _make_mock_prisma(): async def _find_unique(where): return store.get(where.get("name")) + def _matches(record, where): + for key, expected in where.items(): + if key == "OR": + if not any(_matches(record, clause) for clause in expected): + return False + continue + actual = getattr(record, key) + if isinstance(expected, dict) and "in" in expected: + if actual not in expected["in"]: + return False + elif actual != expected: + return False + return True + async def _find_many(where=None): records = list(store.values()) - if where and "enabled" in where: - return [r for r in records if r.enabled == where["enabled"]] - return records + if not where: + return records + return [r for r in records if _matches(r, where)] async def _create(data): record = MagicMock() - record.id = "test-id" + record.id = f"test-id-{data['name']}" record.name = data["name"] record.version = data.get("version") record.description = data.get("description") record.manifest_json = data.get("manifest_json", "{}") record.enabled = data.get("enabled", True) + record.approval_status = data.get("approval_status", "active") + record.review_notes = data.get("review_notes") + record.reviewed_by = data.get("reviewed_by") + record.reviewed_at = data.get("reviewed_at") + record.created_by = data.get("created_by") + record.created_at = data.get("created_at") + record.updated_at = data.get("updated_at") store[data["name"]] = record return record @@ -151,6 +180,7 @@ async def test_update_plugin_replaces_existing_source(): response = await update_plugin( plugin_name=name, request=UpdatePluginRequest(source=new_source, version="2.0.0", description="updated"), + user_api_key_dict=_USER, ) assert response.status == "success" @@ -170,6 +200,7 @@ async def test_update_plugin_not_found(): await update_plugin( plugin_name="does-not-exist", request=UpdatePluginRequest(source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, ) assert exc_info.value.status_code == 404 @@ -213,6 +244,7 @@ async def test_update_plugin_db_error_maps_to_structured_500(): await update_plugin( plugin_name=name, request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}), + user_api_key_dict=_USER, ) assert exc_info.value.status_code == 500 @@ -341,3 +373,269 @@ async def test_register_plugin_unknown_source_type(): assert exc_info.value.status_code == 400 assert "git-subdir" in exc_info.value.detail["error"] + + +_SUBMITTER = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-submitter", + user_id="submitter-user", +) + +_OTHER_USER = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-other", + user_id="other-user", +) + + +async def _stored(name: str): + table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable + return await table.find_unique(where={"name": name}) + + +@pytest.mark.asyncio +async def test_non_admin_submission_is_pending_and_unpublished(): + response = await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + assert response.action == "submitted_for_review" + assert response.plugin.approval_status == "pending_review" + assert response.plugin.enabled is False + + stored = await _stored("submitted-skill") + assert stored.approval_status == "pending_review" + assert stored.enabled is False + assert stored.created_by == "submitter-user" + + +@pytest.mark.asyncio +async def test_admin_registration_stays_auto_approved(): + response = await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + assert response.action == "created" + assert response.plugin.approval_status == "active" + assert response.plugin.enabled is True + + +@pytest.mark.asyncio +async def test_pending_submission_is_not_served_by_marketplace(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + body = json.loads((await get_marketplace()).body) + + assert [plugin["name"] for plugin in body["plugins"]] == ["admin-skill"] + + +@pytest.mark.asyncio +async def test_approval_publishes_submission_and_records_reviewer(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + response = await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER) + + assert response.approval_status == "active" + assert response.enabled is True + assert response.reviewed_by == "test-user" + assert response.reviewed_at is not None + + body = json.loads((await get_marketplace()).body) + assert [plugin["name"] for plugin in body["plugins"]] == ["submitted-skill"] + + +@pytest.mark.asyncio +async def test_rejection_keeps_submission_unpublished_with_notes(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + response = await reject_plugin( + plugin_name="submitted-skill", + request=RejectPluginRequest(review_notes="point at the skill folder"), + user_api_key_dict=_USER, + ) + + assert response.approval_status == "rejected" + assert response.enabled is False + + body = json.loads((await get_marketplace()).body) + assert body["plugins"] == [] + + stored = await _stored("submitted-skill") + assert stored.review_notes == "point at the skill folder" + + +@pytest.mark.asyncio +async def test_non_admin_cannot_review(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as exc_info: + await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_SUBMITTER) + + assert exc_info.value.status_code == 403 + stored = await _stored("submitted-skill") + assert stored.approval_status == "pending_review" + assert stored.enabled is False + + +@pytest.mark.asyncio +async def test_review_unknown_skill_returns_404(): + with pytest.raises(HTTPException) as exc_info: + await approve_plugin(plugin_name="does-not-exist", user_api_key_dict=_USER) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_submitter_sees_own_pending_skill_but_not_another_users(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await register_plugin( + request=RegisterPluginRequest(name="other-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_OTHER_USER, + ) + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + submitter_view = await list_plugins(user_api_key_dict=_SUBMITTER) + admin_view = await list_plugins(user_api_key_dict=_USER) + + assert sorted(p.name for p in submitter_view.plugins) == ["admin-skill", "submitted-skill"] + assert sorted(p.name for p in admin_view.plugins) == ["admin-skill", "other-skill", "submitted-skill"] + + +@pytest.mark.asyncio +async def test_admin_can_filter_the_pending_queue(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + queue = await list_plugins(approval_status="pending_review", user_api_key_dict=_USER) + + assert [p.name for p in queue.plugins] == ["submitted-skill"] + + +@pytest.mark.asyncio +async def test_get_plugin_hides_another_users_pending_submission(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as exc_info: + await get_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER) + + assert exc_info.value.status_code == 404 + + own = await get_plugin(plugin_name="submitted-skill", user_api_key_dict=_SUBMITTER) + assert own["approval_status"] == "pending_review" + + +@pytest.mark.asyncio +async def test_enable_cannot_bypass_review(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as exc_info: + await enable_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER) + + assert exc_info.value.status_code == 409 + stored = await _stored("submitted-skill") + assert stored.enabled is False + + +@pytest.mark.asyncio +async def test_non_admin_cannot_publish_or_unpublish(): + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + with pytest.raises(HTTPException) as exc_info: + await disable_plugin(plugin_name="admin-skill", user_api_key_dict=_SUBMITTER) + + assert exc_info.value.status_code == 403 + stored = await _stored("admin-skill") + assert stored.enabled is True + + +@pytest.mark.asyncio +async def test_submitter_edit_sends_skill_back_to_review(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER) + + await update_plugin( + plugin_name="submitted-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/changed"}), + user_api_key_dict=_SUBMITTER, + ) + + stored = await _stored("submitted-skill") + assert stored.approval_status == "pending_review" + assert stored.enabled is False + + +@pytest.mark.asyncio +async def test_unrelated_user_cannot_edit_or_delete_a_submission(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as update_exc: + await update_plugin( + plugin_name="submitted-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/hijacked"}), + user_api_key_dict=_OTHER_USER, + ) + assert update_exc.value.status_code == 403 + + with pytest.raises(HTTPException) as delete_exc: + await delete_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER) + assert delete_exc.value.status_code == 403 + + assert await _stored("submitted-skill") is not None + + +@pytest.mark.asyncio +async def test_approving_an_already_active_skill_is_rejected(): + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + with pytest.raises(HTTPException) as exc_info: + await approve_plugin(plugin_name="admin-skill", user_api_key_dict=_USER) + + assert exc_info.value.status_code == 400 diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 0bfb10320f7..7a4d2fbb871 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3298,3 +3298,29 @@ def test_user_daily_activity_aggregated_not_covered_by_prefix_match(): route="/user/daily/activity/aggregated", allowed_routes=["/user/daily/activity"], ) + + +@pytest.mark.parametrize( + "route, allowed", + [ + ("/claude-code/marketplace.json", True), + ("/claude-code/plugins", True), + ("/claude-code/plugins/my-skill", True), + ("/claude-code/plugins/my-skill/approve", False), + ("/claude-code/plugins/my-skill/reject", False), + ("/claude-code/plugins/my-skill/enable", False), + ("/claude-code/plugins/my-skill/disable", False), + ], +) +def test_skill_submission_routes_open_to_internal_users_but_review_stays_admin_only( + route, allowed +): + """Internal users can submit and track their own skills, while approving, + rejecting and publishing a skill stays with proxy admins. + """ + assert ( + RouteChecks.check_route_access( + route=route, allowed_routes=LiteLLMRoutes.internal_user_routes.value + ) + is allowed + ) diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 88dc07e741b..062a7b71aec 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -954,3 +954,48 @@ def test_public_mcp_hub_does_not_expose_upstream_url(): assert all("url" not in item for item in data) assert secret_url not in response.text app.dependency_overrides.clear() + + +def test_public_skill_hub_serves_only_approved_and_enabled_skills(): + """A submission awaiting review is enabled=False and approval_status=pending_review, so + /public/skill_hub must not serve it even if a row is enabled without approval.""" + import json as json_lib + + import litellm.proxy.proxy_server + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + def _record(name, enabled, approval_status): + record = MagicMock() + record.id = name + record.name = name + record.version = "1.0.0" + record.description = name + record.manifest_json = json_lib.dumps({"source": {"source": "github", "repo": f"org/{name}"}}) + record.enabled = enabled + record.approval_status = approval_status + record.created_at = datetime.now(timezone.utc) + record.updated_at = datetime.now(timezone.utc) + record.created_by = "someone" + return record + + records = [ + _record("approved-skill", True, "active"), + _record("pending-skill", False, "pending_review"), + _record("enabled-but-unapproved", True, "pending_review"), + _record("rejected-skill", True, "rejected"), + ] + + async def _find_many(where=None): + return [r for r in records if all(getattr(r, key) == value for key, value in (where or {}).items())] + + mock_client = MagicMock() + mock_client.db.litellm_claudecodeplugintable.find_many = AsyncMock(side_effect=_find_many) + + with patch.object(litellm.proxy.proxy_server, "prisma_client", mock_client): + response = client.get("/public/skill_hub") + + assert response.status_code == 200 + assert [plugin["name"] for plugin in response.json()["plugins"]] == ["approved-skill"] 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..9ceedf9ceda 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 @@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import { getClaudeCodePluginsList, deleteClaudeCodePlugin, reviewClaudeCodePlugin } from "@/components/networking"; import type { Plugin } from "@/components/claude_code_plugins/types"; import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; @@ -10,6 +10,7 @@ import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; vi.mock("@/components/networking", () => ({ getClaudeCodePluginsList: vi.fn(), deleteClaudeCodePlugin: vi.fn(), + reviewClaudeCodePlugin: vi.fn(), })); vi.mock("./PluginTable", () => ({ @@ -18,21 +19,28 @@ vi.mock("./PluginTable", () => ({ isLoading, pluginsList, onDeleteClick, + onReviewClick, }: { isLoading: boolean; pluginsList: Plugin[]; onDeleteClick: (pluginName: string, displayName: string) => void; + onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void; }) => (
{isLoading ? "table-loading" : "table-loaded"} {pluginsList.map((plugin) => ( - +
+ {`row-${plugin.name}`} + + + +
))}
), @@ -43,6 +51,7 @@ vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: tr const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin); +const mockReviewClaudeCodePlugin = vi.mocked(reviewClaudeCodePlugin); const skill: Plugin = { id: "plugin-1", @@ -122,3 +131,68 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => { expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); }); }); + +describe("ClaudeCodePluginsPanel review queue", () => { + const submission: Plugin = { + id: "plugin-2", + name: "submitted-skill", + source: { source: "github", repo: "acme/submitted-skill" }, + enabled: false, + approval_status: "pending_review", + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill, submission], count: 2 }); + }); + + it("should let a non-admin submit a skill instead of disabling the button", async () => { + render(); + const submit = await screen.findByRole("button", { name: "+ Submit Skill" }); + expect(submit).toBeEnabled(); + expect(screen.queryByTestId("toggle-pending-review")).not.toBeInTheDocument(); + }); + + it("should filter the table down to submissions awaiting review", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("toggle-pending-review")); + + expect(screen.getByText("row-submitted-skill")).toBeInTheDocument(); + expect(screen.queryByText("row-my-skill")).not.toBeInTheDocument(); + }); + + it("should approve a submission and refresh the list", async () => { + const user = userEvent.setup(); + mockReviewClaudeCodePlugin.mockResolvedValue({}); + render(); + + await user.click(await screen.findByTestId("row-approve-plugin-2")); + await user.click(await screen.findByTestId("review-confirm")); + + await waitFor(() => + expect(mockReviewClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "submitted-skill", "approve", ""), + ); + await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2)); + }); + + it("should send the rejection notes typed by the reviewer", async () => { + const user = userEvent.setup(); + mockReviewClaudeCodePlugin.mockResolvedValue({}); + render(); + + await user.click(await screen.findByTestId("row-reject-plugin-2")); + await user.type(await screen.findByTestId("review-notes"), "point at the skill folder"); + await user.click(screen.getByTestId("review-confirm")); + + await waitFor(() => + expect(mockReviewClaudeCodePlugin).toHaveBeenCalledWith( + "sk-test", + "submitted-skill", + "reject", + "point at the skill folder", + ), + ); + }); +}); 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 47fc8f41307..82a6293ed2a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { AlertDialog, @@ -9,9 +9,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import { getClaudeCodePluginsList, deleteClaudeCodePlugin, reviewClaudeCodePlugin } from "@/components/networking"; import AddPluginForm from "./add_plugin_form"; import PluginTable from "./PluginTable"; +import ReviewSkillDialog, { ReviewDecision } from "./ReviewSkillDialog"; +import { countAwaitingReview, isAwaitingReview } from "@/components/claude_code_plugins/helpers"; import SkillDetail from "@/components/claude_code_plugins/skill_detail"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -32,8 +34,16 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT displayName: string; } | null>(null); const [selectedSkill, setSelectedSkill] = useState(null); + const [pluginToReview, setPluginToReview] = useState<{ plugin: Plugin; decision: ReviewDecision } | null>(null); + const [isReviewing, setIsReviewing] = useState(false); + const [showPendingOnly, setShowPendingOnly] = useState(false); const isAdmin = userRole ? isAdminRole(userRole) : false; + const pendingCount = useMemo(() => countAwaitingReview(pluginsList), [pluginsList]); + const visiblePlugins = useMemo( + () => (showPendingOnly ? pluginsList.filter(isAwaitingReview) : pluginsList), + [pluginsList, showPendingOnly], + ); const fetchPlugins = async () => { if (!accessToken) { @@ -77,6 +87,27 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT } }; + const handleReviewConfirm = async (notes: string) => { + if (!pluginToReview || !accessToken) return; + + setIsReviewing(true); + try { + await reviewClaudeCodePlugin(accessToken, pluginToReview.plugin.name, pluginToReview.decision, notes); + NotificationsManager.success( + pluginToReview.decision === "approve" + ? `Skill "${pluginToReview.plugin.name}" approved and published` + : `Skill "${pluginToReview.plugin.name}" rejected`, + ); + fetchPlugins(); + } catch (error) { + console.error("Error reviewing skill:", error); + NotificationsManager.error("Failed to review skill"); + } finally { + setIsReviewing(false); + setPluginToReview(null); + } + }; + return (
{selectedSkill ? ( @@ -92,20 +123,32 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT

Skills

- Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "} + {isAdmin + ? "Register Claude Code skills and review skills submitted by your users. Approved skills appear in the Skill Hub for all users and are served via " + : "Submit Claude Code skills for administrator review. Once approved, a skill appears in the Skill Hub for all users and is served via "} /claude-code/marketplace.json.

- + {isAdmin && pendingCount > 0 && ( + + )}
setPluginToReview({ plugin, decision })} isAdmin={isAdmin} onPluginClick={(id) => { const skill = pluginsList.find((p) => p.id === id); @@ -122,6 +165,16 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT onSuccess={fetchPlugins} /> + {pluginToReview && ( + setPluginToReview(null)} + onConfirm={handleReviewConfirm} + /> + )} + {pluginToDelete && ( { beforeEach(() => { vi.clearAllMocks(); @@ -44,7 +55,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", "Category", "Public", "Review", "Created At"]) { expect(screen.getByText(header)).toBeInTheDocument(); } }); @@ -110,4 +121,38 @@ describe("PluginTable", () => { expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); expect(screen.queryByTestId("plugin-action-delete")).not.toBeInTheDocument(); }); + + it("should badge a submission as pending review and a legacy skill as active", () => { + render(); + expect(screen.getByTestId("approval-status-submitted-skill")).toHaveTextContent("Pending Review"); + expect(screen.getByTestId("approval-status-newer-skill")).toHaveTextContent("Active"); + }); + + it.each([ + ["approve", "plugin-action-approve"], + ["reject", "plugin-action-reject"], + ])("should let an admin %s a submission from the actions menu", async (decision, testId) => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-submitted-skill")); + await user.click(await screen.findByTestId(testId)); + expect(mockOnReviewClick).toHaveBeenCalledWith(submittedSkill, decision); + }); + + it("should not offer review actions on an already active skill", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-newer-skill")); + expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); + expect(screen.queryByTestId("plugin-action-approve")).not.toBeInTheDocument(); + expect(screen.queryByTestId("plugin-action-reject")).not.toBeInTheDocument(); + }); + + it("should not offer review actions to a non-admin viewing a submission", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-submitted-skill")); + expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); + expect(screen.queryByTestId("plugin-action-approve")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx index c581b0dfdeb..7750b883f8c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx @@ -13,6 +13,7 @@ interface PluginTableProps { pluginsList: Plugin[]; isLoading: boolean; onDeleteClick: (pluginName: string, displayName: string) => void; + onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void; isAdmin: boolean; onPluginClick: (pluginId: string) => void; } @@ -31,12 +32,19 @@ function EmptyState() { ); } -const PluginTable: React.FC = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => { +const PluginTable: React.FC = ({ + pluginsList, + isLoading, + onDeleteClick, + onReviewClick, + isAdmin, + onPluginClick, +}) => { const [sorting, setSorting] = useState(DEFAULT_SORTING); const columns = useMemo( - () => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }), - [isAdmin, onPluginClick, onDeleteClick], + () => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick, onReviewClick }), + [isAdmin, onPluginClick, onDeleteClick, onReviewClick], ); return ( 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 95c9924b375..1f86c81a834 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx @@ -1,11 +1,15 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Copy, MoreHorizontal, Trash2 } from "lucide-react"; +import { Check, Copy, MoreHorizontal, Trash2, X } from "lucide-react"; import { DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; -import { getCategoryBadgeColor } from "@/components/claude_code_plugins/helpers"; +import { + getCategoryBadgeColor, + getApprovalStatusDisplay, + isAwaitingReview, +} from "@/components/claude_code_plugins/helpers"; import { Plugin } from "@/components/claude_code_plugins/types"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; @@ -44,9 +48,10 @@ interface PluginRowActionsProps { plugin: Plugin; isAdmin: boolean; onDeleteClick: (pluginName: string, displayName: string) => void; + onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void; } -function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsProps) { +function PluginRowActions({ plugin, isAdmin, onDeleteClick, onReviewClick }: PluginRowActionsProps) { return ( Copy skill ID + {isAdmin && isAwaitingReview(plugin) && ( + <> + + onReviewClick(plugin, "approve")}> + + Approve + + onReviewClick(plugin, "reject")}> + + Reject + + + )} {isAdmin && ( <> @@ -86,12 +104,14 @@ interface PluginTableColumnsDeps { isAdmin: boolean; onPluginClick: (pluginId: string) => void; onDeleteClick: (pluginName: string, displayName: string) => void; + onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void; } export const getPluginTableColumns = ({ isAdmin, onPluginClick, onDeleteClick, + onReviewClick, }: PluginTableColumnsDeps): ColumnDef[] => [ { id: "name", @@ -154,6 +174,25 @@ export const getPluginTableColumns = ({ ), }, + { + id: "approval_status", + accessorKey: "approval_status", + meta: { title: "Review", skeleton: "badge" }, + header: "Review", + size: 140, + enableSorting: false, + cell: ({ row }) => { + const display = getApprovalStatusDisplay(row.original.approval_status); + return ( + + ); + }, + }, { id: "created_at", accessorKey: "created_at", @@ -173,7 +212,12 @@ export const getPluginTableColumns = ({ enableHiding: false, cell: ({ row }) => (
- +
), }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ReviewSkillDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ReviewSkillDialog.tsx new file mode 100644 index 00000000000..8661f821226 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ReviewSkillDialog.tsx @@ -0,0 +1,83 @@ +"use client"; + +import React, { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +export type ReviewDecision = "approve" | "reject"; + +interface ReviewSkillDialogProps { + skillName: string; + decision: ReviewDecision; + isSubmitting: boolean; + onCancel: () => void; + onConfirm: (notes: string) => void; +} + +const ReviewSkillDialog: React.FC = ({ + skillName, + decision, + isSubmitting, + onCancel, + onConfirm, +}) => { + const [notes, setNotes] = useState(""); + const approving = decision === "approve"; + + return ( + { + if (!open) onCancel(); + }} + > + + + {approving ? "Approve skill" : "Reject skill"} + + {approving + ? `Approving "${skillName}" publishes it to the Skill Hub and marketplace.json for all users.` + : `Rejecting "${skillName}" keeps it unpublished. The submitter sees your notes.`} + + + {!approving && ( +
+ +