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

{error}

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

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

+ {history.isPending &&

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

} + {history.isError &&

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

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

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

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

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

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

{version.description}

} +

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

+ {version.content_kind === "stored_archive" && ( + + )} +
+ {t("claudeCodePlugins.skillDetail.history.metadata")} +
+              {JSON.stringify(version.manifest, null, 2)}
+            
+
+
+ ))} +
+ + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index 8d247ff21b7..c6e49e0b187 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -15,6 +15,7 @@ import { MARKETPLACE_KEY_PLACEHOLDER, } from "./helpers"; import { Plugin } from "./types"; +import SkillVersionHistory from "./SkillVersionHistory"; const SkillSource: React.FC> = ({ skill, accessToken }) => { const { t } = useTranslation(); @@ -133,6 +134,7 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken }) const tabs = [ { key: "overview", label: t("claudeCodePlugins.skillDetail.tabs.overview") }, { key: "usage", label: t("claudeCodePlugins.skillDetail.tabs.usage") }, + ...(accessToken ? [{ key: "history", label: t("claudeCodePlugins.skillDetail.history.title") }] : []), ]; return ( @@ -175,6 +177,9 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken }) {/* Overview tab */} + {activeTab === "history" && accessToken && ( + + )} {activeTab === "overview" && (
{/* Left column */} diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 89847982082..68472d413eb 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -1728,6 +1728,21 @@ "unknown": "Unknown source" }, "skillDetail": { + "history": { + "title": "Version history", + "explanation": "Published metadata and retained uploads. Updates automatically increment the patch version unless a higher version is specified.", + "loading": "Loading versions…", + "failed": "Could not load version history.", + "empty": "No recorded releases yet. Existing skills start recording history on their next published update.", + "legacy": "Legacy version", + "retained": "Uploaded ZIP retained and verified against its source digest.", + "reference": "Source reference only. External files may change and are not retained by LiteLLM.", + "metadata": "Published metadata", + "download": "Download retained ZIP", + "downloadFailed": "Could not download this version.", + "newer": "Newer", + "older": "Older" + }, "back": "Skills", "tabs": { "overview": "Overview", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index da96e9ffa01..041429c047d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2611,7 +2611,8 @@ export interface paths { * The plugin is identified by its name in the path, which is the resource * identity and cannot be changed here. This is a full replace, not a merge: * the manifest is rebuilt from the request body, so any optional field left - * out is reset to its default (e.g. an omitted version is cleared, not kept). + * out is reset to its default, except version which automatically increments + * the published patch version when omitted or null. * Send the full desired state. * * Returns 404 if no plugin with the given name exists; use @@ -15838,6 +15839,66 @@ export interface paths { patch?: never; trace?: never; }; + "/skill-hub/skills/{name}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Skill Versions + * @description Read published metadata history. External references are not retained artifacts. + */ + get: operations["list_skill_versions_skill_hub_skills__name__versions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/skill-hub/skills/{name}/versions/{version_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Skill Version + * @description Read a retained publication under the current skill's visibility policy. + */ + get: operations["get_skill_version_skill_hub_skills__name__versions__version_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/skill-hub/skills/{name}/versions/{version_id}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download Skill Version + * @description Download a retained publication as a portable ZIP with SKILL.md at its root. + */ + get: operations["download_skill_version_skill_hub_skills__name__versions__version_id__archive_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/spend/calculate": { parameters: { query?: never; @@ -34400,7 +34461,7 @@ export interface components { url: string; /** * Version - * @description Version label for the imported snapshot + * @description Semantic version, or omit/null to start at 1.0.0 */ version?: string | null; }; @@ -43322,7 +43383,7 @@ export interface components { }; /** * Version - * @description Semantic version + * @description Semantic version, defaults to 1.0.0 on creation * @default 1.0.0 */ version: string | null; @@ -46614,11 +46675,19 @@ export interface components { description?: string | null; /** Domain */ domain?: string | null; + /** + * Expected Updated At + * @description Published updated_at read by the editor. A stale value returns 409 + */ + expected_updated_at?: string | null; /** Keywords */ keywords?: string[] | null; /** Namespace */ namespace?: string | null; - /** Version */ + /** + * Version + * @description Explicit higher SemVer, or omit/null for an automatic patch bump + */ version?: string | null; }; /** SkillHubPublication */ @@ -46639,6 +46708,8 @@ export interface components { source: { [key: string]: string; }; + /** Updated At */ + updated_at?: string | null; /** Version */ version?: string | null; /** @@ -46647,6 +46718,37 @@ export interface components { */ visibility: "public" | "private"; }; + /** SkillHubVersion */ + SkillHubVersion: { + /** + * Content Kind + * @enum {string} + */ + content_kind: "stored_archive" | "source_reference"; + /** Description */ + description: string | null; + /** Download Url */ + download_url: string | null; + /** Id */ + id: string; + /** Manifest */ + manifest: { + [key: string]: unknown; + }; + /** Published At */ + published_at: string | null; + /** Published By */ + published_by: string | null; + /** Version */ + version: string | null; + }; + /** SkillHubVersions */ + SkillHubVersions: { + /** Next Offset */ + next_offset: number | null; + /** Versions */ + versions: components["schemas"]["SkillHubVersion"][]; + }; /** SkillReference */ SkillReference: { /** Skill Id */ @@ -49689,8 +49791,8 @@ export interface components { * * The plugin name is the resource identity and is supplied as the path * parameter, so it cannot be changed here. This is a full replace: omitted - * fields reset to their defaults, so version is cleared rather than - * defaulting to the create-time "1.0.0". + * fields reset to their defaults, except version which automatically increments + * the published patch version when omitted or null. */ UpdatePluginRequest: { /** @description Plugin author */ @@ -49710,6 +49812,11 @@ export interface components { * @description Skill domain (e.g., 'Productivity') */ domain?: string | null; + /** + * Expected Updated At + * @description Published updated_at read by the editor. A stale value returns 409 + */ + expected_updated_at?: string | null; /** * Homepage * @description Plugin homepage URL @@ -49743,7 +49850,7 @@ export interface components { }; /** * Version - * @description Semantic version; cleared if omitted + * @description Explicit higher SemVer, or omit/null for an automatic patch bump */ version?: string | null; }; @@ -50326,7 +50433,7 @@ export interface components { namespace?: string | null; /** * Version - * @description Semantic version + * @description Semantic version, defaults to 1.0.0 on creation * @default 1.0.0 */ version: string | null; @@ -74096,6 +74203,104 @@ export interface operations { }; }; }; + list_skill_versions_skill_hub_skills__name__versions_get: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SkillHubVersions"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_skill_version_skill_hub_skills__name__versions__version_id__get: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + version_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SkillHubVersion"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_skill_version_skill_hub_skills__name__versions__version_id__archive_get: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + version_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/zip": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; calculate_spend_spend_calculate_post: { parameters: { query?: never; diff --git a/uv.lock b/uv.lock index 4f36ef20bfb..954b9f90a75 100644 --- a/uv.lock +++ b/uv.lock @@ -5097,6 +5097,7 @@ proxy = [ { name = "restrictedpython" }, { name = "rich" }, { name = "rq" }, + { name = "semver" }, { name = "soundfile" }, { name = "starlette" }, { name = "tomlkit" }, @@ -5258,6 +5259,7 @@ proxy-dev = [ { name = "opentelemetry-sdk" }, { name = "prisma" }, { name = "prometheus-client" }, + { name = "semver" }, { name = "sentry-sdk" }, ] @@ -5365,6 +5367,7 @@ requires-dist = [ { name = "rich", marker = "extra == 'proxy'", specifier = ">=13.9.4,<14.0" }, { name = "rq", marker = "extra == 'proxy'", specifier = ">=2.7.0,<3.0" }, { name = "semantic-router", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.1.15,<1.0" }, + { name = "semver", marker = "extra == 'proxy'", specifier = ">=3.0.4,<4" }, { name = "sentry-sdk", marker = "extra == 'proxy-runtime'", specifier = ">=2.21.0,<3.0" }, { name = "soundfile", marker = "extra == 'proxy'", specifier = ">=0.12.1,<1.0" }, { name = "soundfile", marker = "extra == 'stt-nvidia-riva'", specifier = ">=0.12.1" }, @@ -5490,6 +5493,7 @@ proxy-dev = [ { name = "opentelemetry-sdk", specifier = "==1.45.1" }, { name = "prisma", specifier = "==0.11.0" }, { name = "prometheus-client", specifier = "==0.20.0" }, + { name = "semver", specifier = ">=3.0.4,<4" }, { name = "sentry-sdk", specifier = "==2.21.0" }, ] @@ -10281,6 +10285,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/c7/f4a20292aef9badd277efbb24d697c5b934693fb21e8e490d3ecb0fc83f0/semantic_router-0.1.15-py3-none-any.whl", hash = "sha256:c08978584c73c5ff8e75005202007ac8ee6593d77deaf8c7ec53f71e01e7f757", size = 128102, upload-time = "2026-05-23T12:58:14.295Z" }, ] +[[package]] +name = "semver" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/f5/e1dfe8e1d91c54ce212fd93916eb01fd1c590f413be0a0978c39a97aa1bb/semver-3.1.0.tar.gz", hash = "sha256:14bc073439513d7773662a338f4db9829cf16c12b74b9568e2d2689975fbd7fc", size = 304162, upload-time = "2026-09-12T18:38:14.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl", hash = "sha256:6e4998f8b4762acdfea85f9bfd5b4032e0bb50038dc04bf82011b42d5d1145c5", size = 18783, upload-time = "2026-09-12T18:38:12.457Z" }, +] + [[package]] name = "sentry-sdk" version = "2.21.0"