mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
feat(skills)!: version published Skills Hub updates (#50152)
This commit is contained in:
parent
da2ecbc5bb
commit
0ad5d750d4
27 changed files with 2050 additions and 166 deletions
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
|
|||
"LiteLLM_SpendLogDelivery",
|
||||
"LiteLLM_ClaudeCodePluginTable",
|
||||
"LiteLLM_ClaudeCodePluginUpload",
|
||||
"LiteLLM_ClaudeCodePluginVersion",
|
||||
"LiteLLM_MemoryTable",
|
||||
"LiteLLM_AdaptiveRouterState",
|
||||
"LiteLLM_AdaptiveRouterSession",
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
175
litellm/repositories/plugin_publication.py
Normal file
175
litellm/repositories/plugin_publication.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
201
tests/integration/database/test_skill_publication.py
Normal file
201
tests/integration/database/test_skill_publication.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
149
tests/unit/repositories/test_plugin_publication.py
Normal file
149
tests/unit/repositories/test_plugin_publication.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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<typeof fetch>();
|
||||
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(<SkillVersionHistory skill={skill} accessToken="test-token" />);
|
||||
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(<SkillVersionHistory skill={skill} accessToken="test-token" />);
|
||||
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");
|
||||
});
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={downloading}>
|
||||
{t("claudeCodePlugins.skillDetail.history.download")}
|
||||
</Button>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<Versions>(`/skill-hub/skills/${encodeURIComponent(skill.name)}/versions`, {
|
||||
accessToken,
|
||||
query: { limit: 20, offset },
|
||||
}),
|
||||
});
|
||||
return (
|
||||
<section className="space-y-4" aria-label={t("claudeCodePlugins.skillDetail.history.title")}>
|
||||
<p className="text-sm text-muted-foreground">{t("claudeCodePlugins.skillDetail.history.explanation")}</p>
|
||||
{history.isPending && <p>{t("claudeCodePlugins.skillDetail.history.loading")}</p>}
|
||||
{history.isError && <p role="alert">{t("claudeCodePlugins.skillDetail.history.failed")}</p>}
|
||||
{history.data?.versions.length === 0 && <p>{t("claudeCodePlugins.skillDetail.history.empty")}</p>}
|
||||
{history.data?.versions.map((version) => (
|
||||
<article key={version.id} className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="font-medium">{version.version ?? t("claudeCodePlugins.skillDetail.history.legacy")}</h3>
|
||||
{version.published_at && (
|
||||
<time className="text-sm text-muted-foreground">{new Date(version.published_at).toLocaleString()}</time>
|
||||
)}
|
||||
</div>
|
||||
{version.description && <p className="mt-2 text-sm">{version.description}</p>}
|
||||
<p className="my-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
version.content_kind === "stored_archive"
|
||||
? "claudeCodePlugins.skillDetail.history.retained"
|
||||
: "claudeCodePlugins.skillDetail.history.reference",
|
||||
)}
|
||||
</p>
|
||||
{version.content_kind === "stored_archive" && (
|
||||
<VersionArchive skillName={skill.name} version={version} accessToken={accessToken} />
|
||||
)}
|
||||
<details className="mt-3 text-sm">
|
||||
<summary className="cursor-pointer">{t("claudeCodePlugins.skillDetail.history.metadata")}</summary>
|
||||
<pre className="mt-2 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||
{JSON.stringify(version.manifest, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</article>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - 20))}>
|
||||
{t("claudeCodePlugins.skillDetail.history.newer")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={history.data?.next_offset == null}
|
||||
onClick={() => setOffset(history.data?.next_offset ?? offset)}
|
||||
>
|
||||
{t("claudeCodePlugins.skillDetail.history.older")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
MARKETPLACE_KEY_PLACEHOLDER,
|
||||
} from "./helpers";
|
||||
import { Plugin } from "./types";
|
||||
import SkillVersionHistory from "./SkillVersionHistory";
|
||||
|
||||
const SkillSource: React.FC<Pick<SkillDetailProps, "skill" | "accessToken">> = ({ skill, accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -133,6 +134,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ 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<SkillDetailProps> = ({ skill, onBack, accessToken })
|
|||
</div>
|
||||
|
||||
{/* Overview tab */}
|
||||
{activeTab === "history" && accessToken && (
|
||||
<SkillVersionHistory key={`${skill.id}:${accessToken}`} skill={skill} accessToken={accessToken} />
|
||||
)}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex gap-16">
|
||||
{/* Left column */}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
221
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
221
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
13
uv.lock
generated
13
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue