mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
feat(skills)!: add portable Agent Skills Hub API (#50159)
Some checks are pending
Cost map pins recheck / rerun-cost-map-guard (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust DB / db-queries (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Modules / fmt, validate, test (aws) (push) Waiting to run
Terraform Modules / fmt, validate, test (gcp) (push) Waiting to run
unit / rust-bridge (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.0.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.25.0 compatibility (push) Blocked by required conditions
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Some checks are pending
Cost map pins recheck / rerun-cost-map-guard (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust DB / db-queries (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Modules / fmt, validate, test (aws) (push) Waiting to run
Terraform Modules / fmt, validate, test (gcp) (push) Waiting to run
unit / rust-bridge (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.0.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.25.0 compatibility (push) Blocked by required conditions
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
This commit is contained in:
parent
b4a8bc715f
commit
3f4dc93b40
20 changed files with 3310 additions and 91 deletions
|
|
@ -1,8 +1,12 @@
|
|||
from collections.abc import Mapping
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
def has_agent_skill_metadata(manifest: Mapping[str, object]) -> bool:
|
||||
return isinstance(manifest.get("agent_skill"), dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginUpload:
|
||||
name: str
|
||||
|
|
@ -19,6 +23,10 @@ class PluginArchiveMetadata:
|
|||
enabled: bool
|
||||
source: Mapping[str, str]
|
||||
manifest_json: str | None
|
||||
id: str | None = None
|
||||
version: str | None = None
|
||||
description: str | None = None
|
||||
has_agent_skill_metadata: bool = False
|
||||
|
||||
|
||||
class PluginUploadStore(Protocol):
|
||||
|
|
@ -27,3 +35,5 @@ class PluginUploadStore(Protocol):
|
|||
async def get_metadata(self, name: str) -> PluginArchiveMetadata | None: ...
|
||||
|
||||
async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None: ...
|
||||
|
||||
def public_metadata(self) -> AsyncIterator[PluginArchiveMetadata]: ...
|
||||
|
|
|
|||
|
|
@ -283,6 +283,11 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
|
|||
module_path="litellm.proxy.decisions_endpoints.endpoints",
|
||||
path_prefixes=("/v1/decisions", "/decisions", "/v1/systemone", "/systemone", "/v1/extraction", "/extraction"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="skill_hub",
|
||||
module_path="litellm.proxy.discovery_endpoints.skill_hub_endpoints",
|
||||
path_prefixes=("/skill-hub",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="claude_code_marketplace",
|
||||
module_path="litellm.proxy.anthropic_endpoints.claude_code_endpoints",
|
||||
|
|
|
|||
|
|
@ -58849,6 +58849,871 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"skill_hub": {
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Body_upload_skill_skill_hub_skills_upload_post": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"description": "Agent Skills ZIP with SKILL.md and optional supporting files",
|
||||
"title": "File",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Version"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"title": "Body_upload_skill_skill_hub_skills_upload_post",
|
||||
"type": "object"
|
||||
},
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
},
|
||||
"title": "Detail",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object"
|
||||
},
|
||||
"ImportSkillRequest": {
|
||||
"properties": {
|
||||
"path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Repository-relative skill directory",
|
||||
"title": "Path"
|
||||
},
|
||||
"ref": {
|
||||
"default": "HEAD",
|
||||
"description": "Git commit, tag or branch to import",
|
||||
"title": "Ref",
|
||||
"type": "string"
|
||||
},
|
||||
"skill": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Skill name to select when a repository contains several skills",
|
||||
"title": "Skill"
|
||||
},
|
||||
"url": {
|
||||
"description": "Public skills.sh skill URL or GitHub repository URL",
|
||||
"title": "Url",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Version label for the imported snapshot",
|
||||
"title": "Version"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"title": "ImportSkillRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillHubList": {
|
||||
"properties": {
|
||||
"count": {
|
||||
"title": "Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"next_offset": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Next Offset"
|
||||
},
|
||||
"skills": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SkillHubSkill"
|
||||
},
|
||||
"title": "Skills",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"skills",
|
||||
"count",
|
||||
"next_offset"
|
||||
],
|
||||
"title": "SkillHubList",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillHubMetadata": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 1024,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Description"
|
||||
},
|
||||
"domain": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Domain"
|
||||
},
|
||||
"keywords": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Keywords"
|
||||
},
|
||||
"namespace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Namespace"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Version"
|
||||
}
|
||||
},
|
||||
"title": "SkillHubMetadata",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillHubPublication": {
|
||||
"additionalProperties": false,
|
||||
"properties": {},
|
||||
"title": "SkillHubPublication",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillHubSkill": {
|
||||
"properties": {
|
||||
"content_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Content Url"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Description"
|
||||
},
|
||||
"download_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Download Url"
|
||||
},
|
||||
"id": {
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Source",
|
||||
"type": "object"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Version"
|
||||
},
|
||||
"visibility": {
|
||||
"enum": [
|
||||
"public",
|
||||
"private"
|
||||
],
|
||||
"title": "Visibility",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"visibility",
|
||||
"source"
|
||||
],
|
||||
"title": "SkillHubSkill",
|
||||
"type": "object"
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"ctx": {
|
||||
"title": "Context",
|
||||
"type": "object"
|
||||
},
|
||||
"input": {
|
||||
"title": "Input"
|
||||
},
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": "Location",
|
||||
"type": "array"
|
||||
},
|
||||
"msg": {
|
||||
"title": "Message",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"title": "Error Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"loc",
|
||||
"msg",
|
||||
"type"
|
||||
],
|
||||
"title": "ValidationError",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/skill-hub/skills": {
|
||||
"get": {
|
||||
"description": "Browse the caller's public and granted private skills.",
|
||||
"operationId": "list_skills_skill_hub_skills_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "Query",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "offset",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"title": "Offset",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 50,
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubList"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "List Skills",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/skills/import": {
|
||||
"post": {
|
||||
"description": "Import a public skills.sh or GitHub skill as a portable snapshot.",
|
||||
"operationId": "import_skill_skill_hub_skills_import_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ImportSkillRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSkill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Import Skill",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/skills/upload": {
|
||||
"post": {
|
||||
"description": "Publish a portable skill while retaining Claude marketplace compatibility.",
|
||||
"operationId": "upload_skill_skill_hub_skills_upload_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_upload_skill_skill_hub_skills_upload_post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSkill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Upload Skill",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/skills/{name}": {
|
||||
"delete": {
|
||||
"description": "Delete a skill and its managed archive.",
|
||||
"operationId": "remove_skill_skill_hub_skills__name__delete",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Remove Skill",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"description": "Read one visible skill and its portable download URL.",
|
||||
"operationId": "get_skill_skill_hub_skills__name__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSkill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Get Skill",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Replace registry metadata, clearing omitted fields and preserving skill files.",
|
||||
"operationId": "replace_skill_metadata_skill_hub_skills__name__put",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubMetadata"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSkill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Replace Skill Metadata",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/skills/{name}/archive": {
|
||||
"get": {
|
||||
"description": "Download the complete portable skill, with SKILL.md at the ZIP root.",
|
||||
"operationId": "download_skill_skill_hub_skills__name__archive_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "digest",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Digest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"summary": "Download Skill",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/skills/{name}/content": {
|
||||
"get": {
|
||||
"description": "Read the original SKILL.md without downloading supporting files.",
|
||||
"operationId": "skill_content_skill_hub_skills__name__content_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"text/markdown": {}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Skill Content",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/skills/{name}/publish": {
|
||||
"post": {
|
||||
"description": "Make a skill public using the existing Hub publication policy.",
|
||||
"operationId": "publish_skill_skill_hub_skills__name__publish_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SkillHubPublication"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": " Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSkill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Publish Skill",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/skills/{name}/unpublish": {
|
||||
"post": {
|
||||
"description": "Remove a skill from public discovery while retaining explicit grants.",
|
||||
"operationId": "unpublish_skill_skill_hub_skills__name__unpublish_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SkillHubPublication"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": " Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSkill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Unpublish Skill",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"components": {
|
||||
"schemas": {
|
||||
|
|
|
|||
|
|
@ -450,6 +450,14 @@ class LiteLLMRoutes(enum.Enum):
|
|||
)
|
||||
|
||||
litellm_native_routes = [
|
||||
"/skill-hub/skills",
|
||||
"/skill-hub/skills/upload",
|
||||
"/skill-hub/skills/import",
|
||||
"/skill-hub/skills/{name}",
|
||||
"/skill-hub/skills/{name}/archive",
|
||||
"/skill-hub/skills/{name}/content",
|
||||
"/skill-hub/skills/{name}/publish",
|
||||
"/skill-hub/skills/{name}/unpublish",
|
||||
"/rag/ingest",
|
||||
"/v1/rag/ingest",
|
||||
"/rag/query",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ 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
|
||||
from litellm.models.plugin_upload import PluginUpload, 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,
|
||||
|
|
@ -125,7 +125,23 @@ async def get_prisma_client() -> object:
|
|||
_get_prisma_client: Final = get_prisma_client
|
||||
|
||||
|
||||
async def _header_key_caller(request: Request) -> UserAPIKeyAuth:
|
||||
def plugin_archive_url(request: Request, name: str, digest: str) -> str:
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
|
||||
return get_custom_url(
|
||||
request_base_url=str(request.base_url), route=f"claude-code/plugins/{name}/archive/{digest}.zip"
|
||||
)
|
||||
|
||||
|
||||
def plugin_archive_source_matches(
|
||||
request: Request, name: str, source: Mapping[str, str], digest: str, *, has_agent_skill_metadata: bool
|
||||
) -> bool:
|
||||
if has_agent_skill_metadata:
|
||||
return source.get("source") == "archive" and source.get("sha256") == digest
|
||||
return source == {"source": "archive", "url": plugin_archive_url(request, name, digest), "sha256": digest}
|
||||
|
||||
|
||||
async def header_key_caller(request: Request) -> UserAPIKeyAuth:
|
||||
try:
|
||||
caller: Final = await user_api_key_auth(
|
||||
request=request,
|
||||
|
|
@ -154,7 +170,7 @@ async def _header_key_caller(request: Request) -> UserAPIKeyAuth:
|
|||
|
||||
async def _marketplace_caller(request: Request, key: str | None) -> UserAPIKeyAuth | None:
|
||||
if marketplace_auth_required():
|
||||
return await _header_key_caller(request)
|
||||
return await header_key_caller(request)
|
||||
if key:
|
||||
return await user_api_key_auth(request=request, api_key=f"Bearer {key}")
|
||||
return None
|
||||
|
|
@ -173,8 +189,6 @@ async def upload_plugin(
|
|||
metadata: Annotated[Json[UploadPluginRequest], Form(description="JSON skill metadata without a source URL")],
|
||||
) -> RegisterPluginResponse:
|
||||
"""Create a skill and its uploaded ZIP atomically. Duplicate names return 409."""
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
if uploads is None:
|
||||
raise _error_response(500, CommonProxyErrors.db_not_connected_error.value)
|
||||
|
|
@ -189,10 +203,7 @@ async def upload_plugin(
|
|||
digest: Final = hashlib.sha256(archive).hexdigest()
|
||||
source: Final = {
|
||||
"source": "archive",
|
||||
"url": get_custom_url(
|
||||
request_base_url=str(request.base_url),
|
||||
route=f"claude-code/plugins/{metadata.name}/archive/{digest}.zip",
|
||||
),
|
||||
"url": plugin_archive_url(request, metadata.name, digest),
|
||||
"sha256": digest,
|
||||
}
|
||||
spec: Final = RegisterPluginRequest.model_validate({**metadata.model_dump(), "source": source})
|
||||
|
|
@ -237,10 +248,8 @@ async def download_plugin_archive(
|
|||
uploads: PluginUploadRepositoryDep,
|
||||
) -> Response:
|
||||
"""Download an uploaded ZIP under the same visibility policy as its skill."""
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
|
||||
caller: Final = (
|
||||
await _header_key_caller(request)
|
||||
await header_key_caller(request)
|
||||
if marketplace_key_header_present(request)
|
||||
else await _marketplace_caller(request, None)
|
||||
)
|
||||
|
|
@ -248,13 +257,12 @@ async def download_plugin_archive(
|
|||
if uploads is None:
|
||||
raise _error_response(500, CommonProxyErrors.db_not_connected_error.value)
|
||||
archive: Final = await uploads.get_metadata(plugin_name)
|
||||
expected_url: Final = get_custom_url(
|
||||
request_base_url=str(request.base_url), route=f"claude-code/plugins/{plugin_name}/archive/{digest}.zip"
|
||||
)
|
||||
if (
|
||||
archive is None
|
||||
or not visibility.allows(archive)
|
||||
or archive.source != {"source": "archive", "url": expected_url, "sha256": digest}
|
||||
or not plugin_archive_source_matches(
|
||||
request, plugin_name, archive.source, digest, has_agent_skill_metadata=archive.has_agent_skill_metadata
|
||||
)
|
||||
):
|
||||
raise _error_response(404, "Skill archive not found")
|
||||
content: Final = await uploads.get_content(archive)
|
||||
|
|
@ -608,7 +616,7 @@ async def register_plugin(
|
|||
async def list_plugins(
|
||||
enabled_only: bool = False,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
) -> ListPluginsResponse:
|
||||
"""
|
||||
List all plugins in the marketplace.
|
||||
|
||||
|
|
@ -640,6 +648,7 @@ async def list_plugins(
|
|||
version=p.version,
|
||||
description=p.description,
|
||||
source=manifest.get("source", {}),
|
||||
has_agent_skill_metadata=has_agent_skill_metadata(MANIFEST_FIELDS.validate_python(manifest)),
|
||||
author=manifest.get("author"),
|
||||
homepage=manifest.get("homepage"),
|
||||
keywords=manifest.get("keywords"),
|
||||
|
|
@ -805,7 +814,14 @@ async def update_plugin(
|
|||
_validate_plugin_source(request.source)
|
||||
_validate_installation_preference(request)
|
||||
|
||||
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request)
|
||||
manifest: Final[Mapping[str, object]] = {
|
||||
**_build_plugin_manifest(plugin_name, request),
|
||||
**(
|
||||
{"agent_skill": current_manifest["agent_skill"]}
|
||||
if "agent_skill" in current_manifest and request.source == current_manifest.get("source")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
plugin: Final[_PluginRecord | None] = await repository.table.update(
|
||||
where={"name": plugin_name},
|
||||
|
|
|
|||
|
|
@ -11,26 +11,63 @@ import io
|
|||
import lzma
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
import zipfile
|
||||
import zlib
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from typing import ClassVar, Final
|
||||
|
||||
import yaml
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
MAX_ARCHIVE_UNPACKED_BYTES: Final = 50 * 1024 * 1024
|
||||
MAX_ARCHIVE_ENTRIES: Final = 1000
|
||||
MAX_SKILL_FILE_BYTES: Final = 10 * 1024 * 1024
|
||||
MAX_SKILL_MANIFEST_BYTES: Final = 1024 * 1024
|
||||
SKILL_ARCHIVE_ERRORS: Final = (
|
||||
zipfile.BadZipFile,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
EOFError,
|
||||
lzma.LZMAError,
|
||||
zlib.error,
|
||||
struct.error,
|
||||
UnicodeError,
|
||||
)
|
||||
SKILL_MANIFEST_FILENAME: Final = "SKILL.md"
|
||||
|
||||
_ZIP_ENTRY_TIMESTAMP: Final = (1980, 1, 1, 0, 0, 0)
|
||||
_ZIP_ENTRY_MODE: Final = 0o644
|
||||
_FRONTMATTER_PATTERN: Final = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL)
|
||||
_AGENT_SKILL_FRONTMATTER_PATTERN: Final = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.DOTALL)
|
||||
_AGENT_SKILL_YAML_SCALARS: Final = MappingProxyType(
|
||||
{
|
||||
"tag:yaml.org,2002:null": re.compile(r"^(?:null|Null|NULL|~|)$"),
|
||||
"tag:yaml.org,2002:bool": re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"),
|
||||
"tag:yaml.org,2002:int": re.compile(r"^(?:[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+)$"),
|
||||
"tag:yaml.org,2002:float": re.compile(
|
||||
r"^(?:[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?"
|
||||
r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$"
|
||||
),
|
||||
}
|
||||
)
|
||||
_MAX_FRONTMATTER_DEPTH: Final = 32
|
||||
_WINDOWS_DRIVE_PATTERN: Final = re.compile(r"^[A-Za-z]:")
|
||||
_EMPTY_FRONTMATTER: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
_DECODED_YAML: Final = TypeAdapter(object)
|
||||
_YAML_EVENTS: Final = TypeAdapter(Iterable[object])
|
||||
_YAML_NODES: Final = TypeAdapter(tuple[yaml.Node, ...], config=ConfigDict(arbitrary_types_allowed=True))
|
||||
_YAML_PAIRS: Final = TypeAdapter(
|
||||
tuple[tuple[yaml.Node, yaml.Node], ...], config=ConfigDict(arbitrary_types_allowed=True)
|
||||
)
|
||||
|
||||
|
||||
class _AgentSkillFrontmatterLoader(yaml.BaseLoader):
|
||||
yaml_implicit_resolvers: ClassVar[Mapping[str | None, Sequence[tuple[str, re.Pattern[str]]]]] = { # pyright: ignore[reportIncompatibleVariableOverride] # PyYAML resolver tables are class state
|
||||
None: list(_AGENT_SKILL_YAML_SCALARS.items())
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -39,37 +76,100 @@ class SkillArchive:
|
|||
digest: str
|
||||
declared_name: str | None
|
||||
declared_description: str | None
|
||||
manifest_content: bytes
|
||||
|
||||
|
||||
def read_skill_archive_members(
|
||||
stored_content: bytes, *, for_upload: bool = False
|
||||
stored_content: bytes, *, for_upload: bool = False, skill_path: str | None = None
|
||||
) -> tuple[tuple[str, bytes, int], ...] | None:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(stored_content)) as uploaded:
|
||||
return _flattened_members(uploaded, for_upload=for_upload)
|
||||
except (zipfile.BadZipFile, OSError, RuntimeError, EOFError, lzma.LZMAError, zlib.error):
|
||||
return _flattened_members(uploaded, for_upload=for_upload, skill_path=skill_path)
|
||||
except SKILL_ARCHIVE_ERRORS:
|
||||
return None
|
||||
|
||||
|
||||
def read_skill_archive_entry(uploaded: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
|
||||
with uploaded.open(info) as member:
|
||||
return member.read(info.file_size + 1)
|
||||
|
||||
|
||||
def build_skill_archive(stored_content: bytes) -> SkillArchive | None:
|
||||
"""Return the installable archive for an upload, or None when it holds no root SKILL.md."""
|
||||
members: Final = read_skill_archive_members(stored_content)
|
||||
if members is None:
|
||||
return None
|
||||
|
||||
frontmatter: Final = _manifest_frontmatter(
|
||||
next(data for name, data, _ in members if name == SKILL_MANIFEST_FILENAME)
|
||||
)
|
||||
manifest_content: Final = next(data for name, data, _ in members if name == SKILL_MANIFEST_FILENAME)
|
||||
frontmatter: Final = _manifest_frontmatter(manifest_content)
|
||||
content: Final = pack_skill_archive(members)
|
||||
return SkillArchive(
|
||||
content=content,
|
||||
digest=f"sha256:{hashlib.sha256(content).hexdigest()}",
|
||||
declared_name=_frontmatter_text(frontmatter, "name"),
|
||||
declared_description=_frontmatter_text(frontmatter, "description"),
|
||||
manifest_content=manifest_content,
|
||||
)
|
||||
|
||||
|
||||
def _flattened_members(uploaded: zipfile.ZipFile, *, for_upload: bool) -> tuple[tuple[str, bytes, int], ...] | None:
|
||||
def build_agent_skill_archive(
|
||||
content: bytes, *, skill_path: str | None = None, plugin_name: str | None = None
|
||||
) -> SkillArchive | None:
|
||||
members: Final = read_skill_archive_members(content, for_upload=True, skill_path=skill_path)
|
||||
selected: Final = (
|
||||
members
|
||||
if members is not None or plugin_name is None
|
||||
else read_skill_archive_members(content, for_upload=True, skill_path=f"skills/{plugin_name}")
|
||||
)
|
||||
if selected is None:
|
||||
return None
|
||||
manifest_content: Final = next(data for path, data, _ in selected if path == SKILL_MANIFEST_FILENAME)
|
||||
manifest: Final = agent_skill_frontmatter(manifest_content)
|
||||
name: Final = _frontmatter_text(manifest, "name")
|
||||
description: Final = _frontmatter_text(manifest, "description")
|
||||
if (
|
||||
name is None
|
||||
or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name)
|
||||
or len(name) > 64
|
||||
or description is None
|
||||
or len(description) > 1024
|
||||
):
|
||||
return None
|
||||
archive: Final = pack_skill_archive(selected)
|
||||
return SkillArchive(
|
||||
content=archive,
|
||||
digest=f"sha256:{hashlib.sha256(archive).hexdigest()}",
|
||||
declared_name=name,
|
||||
declared_description=description,
|
||||
manifest_content=manifest_content,
|
||||
)
|
||||
|
||||
|
||||
def _flattened_members(
|
||||
uploaded: zipfile.ZipFile, *, for_upload: bool, skill_path: str | None
|
||||
) -> tuple[tuple[str, bytes, int], ...] | None:
|
||||
kept: Final = skill_archive_entries(uploaded, for_upload=for_upload)
|
||||
if kept is None:
|
||||
return None
|
||||
prefix: Final = f"{skill_path}/" if skill_path is not None else _common_root_prefix(tuple(path for _, path in kept))
|
||||
flattened: Final = tuple((info, path[len(prefix) :]) for info, path in kept if path.startswith(prefix))
|
||||
names: Final = frozenset(name for _, name in flattened)
|
||||
if (
|
||||
SKILL_MANIFEST_FILENAME not in names
|
||||
or len(names) != len(flattened)
|
||||
or (for_upload and any(not safe_skill_archive_entry(info) for info, _ in flattened))
|
||||
):
|
||||
return None
|
||||
|
||||
return tuple(
|
||||
(name, read_skill_archive_entry(uploaded, info), _entry_mode(info, preserve_executable_bits=for_upload))
|
||||
for info, name in sorted(flattened, key=lambda member: member[1])
|
||||
)
|
||||
|
||||
|
||||
def skill_archive_entries(
|
||||
uploaded: zipfile.ZipFile, *, for_upload: bool
|
||||
) -> tuple[tuple[zipfile.ZipInfo, str], ...] | None:
|
||||
infos: Final = tuple(info for info in uploaded.infolist() if for_upload or not info.is_dir())
|
||||
if not infos or len(infos) > MAX_ARCHIVE_ENTRIES:
|
||||
return None
|
||||
|
|
@ -81,26 +181,13 @@ def _flattened_members(uploaded: zipfile.ZipFile, *, for_upload: bool) -> tuple[
|
|||
)
|
||||
if any(path is None for _, path in normalized):
|
||||
return None
|
||||
if for_upload and (
|
||||
len(frozenset(path for _, path in normalized)) != len(normalized)
|
||||
or any(not _safe_upload_entry(info) for info in infos)
|
||||
):
|
||||
return None
|
||||
|
||||
kept: Final = tuple(
|
||||
(info, path)
|
||||
for info, path in normalized
|
||||
if path is not None and not info.is_dir() and (not for_upload or not _is_macos_metadata(path))
|
||||
)
|
||||
prefix: Final = _common_root_prefix(tuple(path for _, path in kept))
|
||||
flattened: Final = tuple((info, path[len(prefix) :]) for info, path in kept)
|
||||
names: Final = frozenset(name for _, name in flattened)
|
||||
if SKILL_MANIFEST_FILENAME not in names or len(names) != len(flattened):
|
||||
if for_upload and len(frozenset(path for _, path in normalized)) != len(normalized):
|
||||
return None
|
||||
|
||||
return tuple(
|
||||
(name, uploaded.read(info), _entry_mode(info, preserve_executable_bits=for_upload))
|
||||
for info, name in sorted(flattened, key=lambda member: member[1])
|
||||
(info, path)
|
||||
for info, path in normalized
|
||||
if path is not None and not info.is_dir() and (not for_upload or not _is_macos_metadata(path))
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -113,9 +200,11 @@ def _entry_mode(info: zipfile.ZipInfo, *, preserve_executable_bits: bool) -> int
|
|||
return _ZIP_ENTRY_MODE | (mode & 0o111)
|
||||
|
||||
|
||||
def _safe_upload_entry(info: zipfile.ZipInfo) -> bool:
|
||||
return not info.flag_bits & 1 and (
|
||||
info.create_system != 3 or stat.S_IFMT(info.external_attr >> 16) in (0, stat.S_IFREG, stat.S_IFDIR)
|
||||
def safe_skill_archive_entry(info: zipfile.ZipInfo) -> bool:
|
||||
return (
|
||||
info.file_size <= MAX_SKILL_FILE_BYTES
|
||||
and not info.flag_bits & 1
|
||||
and (info.create_system != 3 or stat.S_IFMT(info.external_attr >> 16) in (0, stat.S_IFREG, stat.S_IFDIR))
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -154,6 +243,65 @@ def _manifest_frontmatter(manifest: bytes) -> Mapping[str, object]:
|
|||
return parsed
|
||||
|
||||
|
||||
def agent_skill_frontmatter(manifest: bytes) -> Mapping[str, str]:
|
||||
if len(manifest) > MAX_SKILL_MANIFEST_BYTES:
|
||||
return MappingProxyType({})
|
||||
try:
|
||||
match: Final = _AGENT_SKILL_FRONTMATTER_PATTERN.match(manifest.decode("utf-8-sig"))
|
||||
if match is None:
|
||||
return MappingProxyType({})
|
||||
frontmatter: Final = match.group(1)
|
||||
events: Final = _YAML_EVENTS.validate_python(yaml.parse(frontmatter, Loader=yaml.BaseLoader)) # pyright: ignore[reportUnknownMemberType] # PyYAML parse stubs omit their event iterator return type
|
||||
if any(isinstance(event, yaml.AliasEvent) for event in events):
|
||||
return MappingProxyType({})
|
||||
node: Final = _DECODED_YAML.validate_python(yaml.compose(frontmatter, Loader=_AgentSkillFrontmatterLoader)) # pyright: ignore[reportUnknownMemberType] # PyYAML compose stubs omit their return type
|
||||
except (yaml.YAMLError, UnicodeError, RecursionError, ValidationError):
|
||||
return MappingProxyType({})
|
||||
if not isinstance(node, yaml.MappingNode) or not _valid_frontmatter_node(node):
|
||||
return MappingProxyType({})
|
||||
pairs: Final = _YAML_PAIRS.validate_python(node.value)
|
||||
fields: Final = {
|
||||
key_text: value_text
|
||||
for key, value in pairs
|
||||
if (key_text := _frontmatter_string(key)) is not None and (value_text := _frontmatter_string(value)) is not None
|
||||
}
|
||||
if "name" not in fields or "description" not in fields:
|
||||
return MappingProxyType({})
|
||||
return MappingProxyType(fields)
|
||||
|
||||
|
||||
def _frontmatter_string(node: yaml.Node) -> str | None:
|
||||
if not isinstance(node, yaml.ScalarNode) or node.tag != "tag:yaml.org,2002:str":
|
||||
return None
|
||||
value: Final = _DECODED_YAML.validate_python(node.value)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _valid_frontmatter_node(node: yaml.Node, depth: int = 0) -> bool:
|
||||
if depth > _MAX_FRONTMATTER_DEPTH:
|
||||
return False
|
||||
if isinstance(node, yaml.ScalarNode):
|
||||
pattern: Final = _AGENT_SKILL_YAML_SCALARS.get(node.tag)
|
||||
scalar: Final = _DECODED_YAML.validate_python(node.value)
|
||||
return isinstance(scalar, str) and (
|
||||
node.tag == "tag:yaml.org,2002:str" or (pattern is not None and pattern.fullmatch(scalar) is not None)
|
||||
)
|
||||
if isinstance(node, yaml.SequenceNode):
|
||||
values: Final = _YAML_NODES.validate_python(node.value)
|
||||
return node.tag == "tag:yaml.org,2002:seq" and all(
|
||||
_valid_frontmatter_node(value, depth + 1) for value in values
|
||||
)
|
||||
if not isinstance(node, yaml.MappingNode) or node.tag != "tag:yaml.org,2002:map":
|
||||
return False
|
||||
pairs: Final = _YAML_PAIRS.validate_python(node.value)
|
||||
keys: Final = tuple(_frontmatter_string(key) for key, _ in pairs)
|
||||
return (
|
||||
None not in keys
|
||||
and len(frozenset(keys)) == len(keys)
|
||||
and all(_valid_frontmatter_node(value, depth + 1) for _, value in pairs)
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_text(frontmatter: Mapping[str, object], key: str) -> str | None:
|
||||
value: Final = frontmatter.get(key)
|
||||
if not isinstance(value, str):
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ is enabled, which publishes every stored skill to anyone who can reach the proxy
|
|||
import asyncio
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from itertools import groupby
|
||||
from itertools import count
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ import litellm
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.models.skills import LiteLLM_SkillsTable
|
||||
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import SkillArchive, build_skill_archive
|
||||
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
MAX_SKILL_DESCRIPTION_LENGTH,
|
||||
|
|
@ -83,28 +84,34 @@ async def stored_skill(skill_id: str) -> LiteLLM_SkillsTable | None:
|
|||
)
|
||||
async def agent_skills_index(
|
||||
request: Request,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
skills: Sequence[LiteLLM_SkillsTable] = Depends(stored_skills),
|
||||
) -> AgentSkillsIndex:
|
||||
"""Agent Skills v0.2.0 discovery index over every skill stored on this proxy."""
|
||||
"""Discover public Hub skills and the separately enabled hosted skill store."""
|
||||
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import public_hub_entries
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
|
||||
installable: Final = await _installable(skills)
|
||||
installable: Final = await _installable(skills[:MAX_INDEXED_SKILLS])
|
||||
names: Final = _deduplicated(tuple(_base_name(skill, archive) for skill, archive in installable))
|
||||
|
||||
return AgentSkillsIndex(
|
||||
skills=tuple(
|
||||
AgentSkillsIndexEntry(
|
||||
name=name,
|
||||
type="archive",
|
||||
description=_description(skill, archive, name),
|
||||
url=get_custom_url(
|
||||
request_base_url=str(request.base_url),
|
||||
route=f"v1/skills/{skill.skill_id}/archive",
|
||||
),
|
||||
digest=archive.digest,
|
||||
)
|
||||
for (skill, archive), name in zip(installable, names, strict=True)
|
||||
legacy: Final = tuple(
|
||||
AgentSkillsIndexEntry(
|
||||
name=name,
|
||||
type="archive",
|
||||
description=_description(skill, archive, name),
|
||||
url=get_custom_url(
|
||||
request_base_url=str(request.base_url),
|
||||
route=f"v1/skills/{skill.skill_id}/archive",
|
||||
),
|
||||
digest=archive.digest,
|
||||
)
|
||||
for (skill, archive), name in zip(installable, names, strict=True)
|
||||
)
|
||||
remaining: Final = MAX_INDEXED_SKILLS - len(legacy)
|
||||
hub: Final = (await public_hub_entries(request, uploads))[:remaining] if remaining else ()
|
||||
hub_names: Final = _deduplicated(tuple(entry.name for entry in hub), reserved=frozenset(names))
|
||||
return AgentSkillsIndex(
|
||||
skills=legacy
|
||||
+ tuple(entry.model_copy(update={"name": name}) for entry, name in zip(hub, hub_names, strict=True))
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -153,7 +160,7 @@ async def _archive_for(skill: LiteLLM_SkillsTable) -> SkillArchive | None:
|
|||
)
|
||||
return None
|
||||
|
||||
if cache_key is not None and len(archive.content) <= MAX_CACHED_ARCHIVE_BYTES:
|
||||
if cache_key is not None and len(archive.content) + len(archive.manifest_content) <= MAX_CACHED_ARCHIVE_BYTES:
|
||||
_ARCHIVE_CACHE.set_cache(cache_key, archive)
|
||||
return archive
|
||||
|
||||
|
|
@ -172,19 +179,25 @@ def _slugify(raw: str | None) -> str | None:
|
|||
return _NON_SLUG_PATTERN.sub("-", raw.lower()).strip("-")[:MAX_SKILL_NAME_LENGTH].rstrip("-") or None
|
||||
|
||||
|
||||
def _entry_name(entry: tuple[int, str]) -> str:
|
||||
return entry[1]
|
||||
def _deduplicated(names: Sequence[str], reserved: frozenset[str] = frozenset()) -> tuple[str, ...]:
|
||||
natural_names: Final = frozenset(names) | reserved
|
||||
ordinals: Final = MappingProxyType({name: count(1) for name in frozenset(names)})
|
||||
used: Final[set[str]] = set(reserved) # mutable-ok: bounded local allocation avoids rebuilding the set
|
||||
|
||||
def allocate(name: str) -> str:
|
||||
selected: Final = (
|
||||
name
|
||||
if name not in used
|
||||
else next(
|
||||
candidate
|
||||
for candidate in (_with_ordinal(name, ordinal) for ordinal in ordinals[name])
|
||||
if candidate not in natural_names and candidate not in used
|
||||
)
|
||||
)
|
||||
used.add(selected)
|
||||
return selected
|
||||
|
||||
def _deduplicated(names: Sequence[str]) -> tuple[str, ...]:
|
||||
ordinals: Final = MappingProxyType(
|
||||
{
|
||||
position: ordinal
|
||||
for _, duplicates in groupby(sorted(enumerate(names), key=_entry_name), key=_entry_name)
|
||||
for ordinal, (position, _) in enumerate(duplicates)
|
||||
}
|
||||
)
|
||||
return tuple(_with_ordinal(name, ordinals[position]) for position, name in enumerate(names))
|
||||
return tuple(allocate(name) for name in names)
|
||||
|
||||
|
||||
def _with_ordinal(name: str, ordinal: int) -> str:
|
||||
|
|
|
|||
439
litellm/proxy/discovery_endpoints/skill_hub_endpoints.py
Normal file
439
litellm/proxy/discovery_endpoints/skill_hub_endpoints.py
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
from typing import Annotated, Final
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile
|
||||
|
||||
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.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
MANIFEST_FIELDS,
|
||||
delete_plugin,
|
||||
disable_plugin,
|
||||
enable_plugin,
|
||||
header_key_caller,
|
||||
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
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.claude_code_marketplace_auth import (
|
||||
marketplace_auth_required,
|
||||
marketplace_key_header_present,
|
||||
)
|
||||
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
|
||||
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
|
||||
SkillArchive,
|
||||
build_agent_skill_archive,
|
||||
)
|
||||
from litellm.proxy.discovery_endpoints.skill_import import (
|
||||
MAX_IMPORT_BYTES,
|
||||
SkillImportError,
|
||||
fetch_skill_source,
|
||||
parse_skill_source,
|
||||
select_imported_skill,
|
||||
)
|
||||
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
|
||||
from litellm.types.proxy.claude_code_endpoints import PluginListItem, UpdatePluginRequest
|
||||
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import AgentSkillsIndexEntry
|
||||
from litellm.types.proxy.skill_hub import (
|
||||
ImportSkillRequest,
|
||||
SkillHubList,
|
||||
SkillHubMetadata,
|
||||
SkillHubPublication,
|
||||
SkillHubSkill,
|
||||
)
|
||||
|
||||
router: Final = APIRouter(prefix="/skill-hub/skills", tags=["Skills Hub"])
|
||||
|
||||
|
||||
def _url(request: Request, route: str) -> str:
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
|
||||
return get_custom_url(request_base_url=str(request.base_url), route=route.lstrip("/"))
|
||||
|
||||
|
||||
def _download_url(request: Request, name: str) -> str:
|
||||
return _url(request, f"skill-hub/skills/{quote(name, safe='')}/archive")
|
||||
|
||||
|
||||
def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill:
|
||||
local_archive: Final = plugin_archive_source_matches(
|
||||
request,
|
||||
plugin.name,
|
||||
plugin.source,
|
||||
plugin.source.get("sha256", ""),
|
||||
has_agent_skill_metadata=plugin.has_agent_skill_metadata,
|
||||
)
|
||||
return SkillHubSkill(
|
||||
id=plugin.id,
|
||||
name=plugin.name,
|
||||
description=plugin.description,
|
||||
version=plugin.version,
|
||||
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,
|
||||
content_url=_url(request, f"skill-hub/skills/{quote(plugin.name, safe='')}/content") if local_archive else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=SkillHubList)
|
||||
async def list_skills(
|
||||
request: Request,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
query: str = "",
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
) -> SkillHubList:
|
||||
"""Browse the caller's public and granted private skills."""
|
||||
catalog: Final = await list_plugins(enabled_only=False, user_api_key_dict=caller)
|
||||
needle: Final = query.casefold()
|
||||
matched: Final = tuple(
|
||||
plugin
|
||||
for plugin in sorted(catalog.plugins, key=lambda item: item.name)
|
||||
if needle in " ".join((plugin.name, plugin.description or "", *(plugin.keywords or ()))).casefold()
|
||||
)
|
||||
return SkillHubList(
|
||||
skills=tuple(_skill_response(request, plugin) for plugin in matched[offset : offset + limit]),
|
||||
count=len(matched),
|
||||
next_offset=offset + limit if offset + limit < len(matched) else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=SkillHubSkill)
|
||||
async def get_skill(
|
||||
request: Request,
|
||||
name: str,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> SkillHubSkill:
|
||||
"""Read one visible skill and its portable download URL."""
|
||||
store: Final = _require_store(uploads)
|
||||
metadata: Final = await store.get_metadata(name)
|
||||
if metadata is None or not (await resolve_skill_visibility(caller)).allows(metadata):
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return _metadata_response(request, metadata)
|
||||
|
||||
|
||||
def _metadata_response(request: Request, metadata: PluginArchiveMetadata) -> SkillHubSkill:
|
||||
return _skill_response(
|
||||
request,
|
||||
PluginListItem(
|
||||
id=metadata.id or metadata.name,
|
||||
name=metadata.name,
|
||||
description=metadata.description,
|
||||
version=metadata.version,
|
||||
source=dict(metadata.source),
|
||||
has_agent_skill_metadata=metadata.has_agent_skill_metadata,
|
||||
enabled=metadata.enabled,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _require_store(uploads: PluginUploadStore | None) -> PluginUploadStore:
|
||||
if uploads is None:
|
||||
raise HTTPException(503, "Skills Hub requires a connected database")
|
||||
return uploads
|
||||
|
||||
|
||||
def _require_admin(caller: UserAPIKeyAuth) -> None:
|
||||
if not is_proxy_admin(caller):
|
||||
raise HTTPException(403, "Only proxy admins may publish skills")
|
||||
|
||||
|
||||
async def skill_publisher(
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> UserAPIKeyAuth:
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
_require_admin(caller)
|
||||
limiter: Final = proxy_logging_obj.get_proxy_hook("parallel_request_limiter")
|
||||
if not isinstance(limiter, PROXY_MaxParallelRequestsHandler_v3):
|
||||
raise HTTPException(503, "Skills Hub publishing requires the default v3 rate limiter")
|
||||
key_identity: Final = caller.api_key or caller.token
|
||||
identity: Final = (
|
||||
f"user:{caller.user_id}" if caller.user_id else f"key:{key_identity}" if key_identity else "local-admin"
|
||||
)
|
||||
result: Final = await limiter.atomic_check_and_increment_by_n(
|
||||
descriptors=[
|
||||
{
|
||||
"key": "skill_hub_publish",
|
||||
"value": hashlib.sha256(identity.encode()).hexdigest(),
|
||||
"rate_limit": {"requests_per_unit": 10, "window_size": 60},
|
||||
}
|
||||
],
|
||||
increments=[{"requests": 1}],
|
||||
parent_otel_span=caller.parent_otel_span,
|
||||
)
|
||||
if result["overall_code"] == "OVER_LIMIT":
|
||||
raise HTTPException(
|
||||
429, "Skills Hub allows 10 uploads or imports per minute per user", headers={"Retry-After": "60"}
|
||||
)
|
||||
return caller
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=SkillHubSkill)
|
||||
async def replace_skill_metadata(
|
||||
request: Request,
|
||||
name: str,
|
||||
body: SkillHubMetadata,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> SkillHubSkill:
|
||||
"""Replace registry metadata, clearing omitted fields and preserving skill files."""
|
||||
_require_admin(caller)
|
||||
store: Final = _require_store(uploads)
|
||||
metadata: Final = await store.get_metadata(name)
|
||||
if metadata is None:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}")
|
||||
updated: Final = UpdatePluginRequest.model_validate(
|
||||
{**manifest, **body.model_dump(), "source": dict(metadata.source)}
|
||||
)
|
||||
await update_plugin(plugin_name=name, request=updated, user_api_key_dict=caller)
|
||||
return await get_skill(request, name, uploads, caller)
|
||||
|
||||
|
||||
@router.post("/{name}/publish", response_model=SkillHubSkill)
|
||||
async def publish_skill(
|
||||
request: Request,
|
||||
name: str,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
_body: SkillHubPublication | None = None,
|
||||
) -> SkillHubSkill:
|
||||
"""Make a skill public using the existing Hub publication policy."""
|
||||
_require_admin(caller)
|
||||
await enable_plugin(plugin_name=name, user_api_key_dict=caller)
|
||||
return await get_skill(request, name, uploads, caller)
|
||||
|
||||
|
||||
@router.post("/{name}/unpublish", response_model=SkillHubSkill)
|
||||
async def unpublish_skill(
|
||||
request: Request,
|
||||
name: str,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
_body: SkillHubPublication | None = None,
|
||||
) -> SkillHubSkill:
|
||||
"""Remove a skill from public discovery while retaining explicit grants."""
|
||||
_require_admin(caller)
|
||||
await disable_plugin(plugin_name=name, user_api_key_dict=caller)
|
||||
return await get_skill(request, name, uploads, caller)
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=204, response_class=Response)
|
||||
async def remove_skill(name: str, caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)]) -> Response:
|
||||
"""Delete a skill and its managed archive."""
|
||||
_require_admin(caller)
|
||||
await delete_plugin(plugin_name=name, user_api_key_dict=caller)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=SkillHubSkill)
|
||||
async def upload_skill(
|
||||
request: Request,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(skill_publisher)],
|
||||
file: Annotated[UploadFile, File(description="Agent Skills ZIP with SKILL.md and optional supporting files")],
|
||||
version: Annotated[str | None, Form()] = None,
|
||||
) -> SkillHubSkill:
|
||||
"""Publish a portable skill while retaining Claude marketplace compatibility."""
|
||||
store: Final = _require_store(uploads)
|
||||
content: Final = await file.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(content) > MAX_IMPORT_BYTES:
|
||||
raise HTTPException(413, "Skill ZIP must be 10 MiB or smaller")
|
||||
archive: Final = await asyncio.to_thread(build_agent_skill_archive, content)
|
||||
if archive is None:
|
||||
raise HTTPException(422, "Upload a valid Agent Skill with name and description in SKILL.md")
|
||||
return await _publish(request, store, caller, archive, version, None)
|
||||
|
||||
|
||||
def import_http_client() -> httpx.AsyncClient:
|
||||
return get_async_httpx_client(llm_provider="skill_hub_import").client
|
||||
|
||||
|
||||
@router.post("/import", response_model=SkillHubSkill)
|
||||
async def import_skill(
|
||||
request: Request,
|
||||
body: ImportSkillRequest,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(skill_publisher)],
|
||||
client: Annotated[httpx.AsyncClient, Depends(import_http_client)],
|
||||
) -> SkillHubSkill:
|
||||
"""Import a public skills.sh or GitHub skill as a portable snapshot."""
|
||||
store: Final = _require_store(uploads)
|
||||
source: Final = parse_skill_source(body.url, skill=body.skill, ref=body.ref, path=body.path)
|
||||
if isinstance(source, SkillImportError):
|
||||
raise HTTPException(source.status, source.message)
|
||||
content: Final = await fetch_skill_source(source, client)
|
||||
if isinstance(content, SkillImportError):
|
||||
raise HTTPException(content.status, content.message)
|
||||
archive: Final = await asyncio.to_thread(select_imported_skill, content, source)
|
||||
if isinstance(archive, SkillImportError):
|
||||
raise HTTPException(archive.status, archive.message)
|
||||
return await _publish(request, store, caller, archive, body.version, body.url)
|
||||
|
||||
|
||||
async def _publish(
|
||||
request: Request,
|
||||
store: PluginUploadStore,
|
||||
caller: UserAPIKeyAuth,
|
||||
archive: SkillArchive,
|
||||
version: str | None,
|
||||
origin: str | None,
|
||||
) -> SkillHubSkill:
|
||||
name: Final = archive.declared_name
|
||||
if name is None:
|
||||
raise HTTPException(422, "SKILL.md must declare a skill name")
|
||||
plugin: Final = await asyncio.to_thread(build_uploaded_skill_archive, archive.content, name)
|
||||
if plugin is None:
|
||||
raise HTTPException(422, "Skill ZIP exceeds the supported archive limits")
|
||||
portable: Final = await asyncio.to_thread(build_agent_skill_archive, plugin, plugin_name=name)
|
||||
if portable is None or portable.digest != archive.digest:
|
||||
raise HTTPException(422, "Skill ZIP exceeds the supported archive limits after compatibility packaging")
|
||||
digest: Final = hashlib.sha256(plugin).hexdigest()
|
||||
source: Final = {
|
||||
"source": "archive",
|
||||
"url": plugin_archive_url(request, name, digest),
|
||||
"sha256": digest,
|
||||
}
|
||||
manifest: Final = {
|
||||
"name": name,
|
||||
"description": archive.declared_description,
|
||||
"version": version,
|
||||
"source": source,
|
||||
"agent_skill": {
|
||||
"name": name,
|
||||
"description": archive.declared_description,
|
||||
"digest": archive.digest,
|
||||
"origin": origin,
|
||||
},
|
||||
}
|
||||
identifier: Final = await store.create(
|
||||
PluginUpload(name, version, archive.declared_description, manifest, plugin, caller.user_id)
|
||||
)
|
||||
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 _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 = (
|
||||
await header_key_caller(request) if marketplace_key_header_present(request) or not anonymous_allowed else None
|
||||
)
|
||||
store: Final = _require_store(uploads)
|
||||
metadata: Final = await store.get_metadata(name)
|
||||
if metadata is None or not (await resolve_skill_visibility(caller)).allows(metadata):
|
||||
raise HTTPException(404, "Skill not found")
|
||||
if not plugin_archive_source_matches(
|
||||
request,
|
||||
name,
|
||||
metadata.source,
|
||||
metadata.source.get("sha256", ""),
|
||||
has_agent_skill_metadata=metadata.has_agent_skill_metadata,
|
||||
):
|
||||
raise HTTPException(404, "This skill no longer refers to a managed archive")
|
||||
content: Final = await store.get_content(metadata)
|
||||
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 skill has no stored Agent Skills archive")
|
||||
return archive
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{name}/archive",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}}}},
|
||||
)
|
||||
async def download_skill(
|
||||
request: Request, name: str, uploads: PluginUploadRepositoryDep, digest: str | None = None
|
||||
) -> Response:
|
||||
"""Download the complete portable skill, with SKILL.md at the ZIP root."""
|
||||
archive: Final = await _visible_archive(request, name, uploads)
|
||||
if digest is not None and digest != archive.digest:
|
||||
raise HTTPException(404, "Skill archive version not found")
|
||||
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}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{name}/content", response_class=Response, responses={200: {"content": {"text/markdown": {}}}})
|
||||
async def skill_content(request: Request, name: str, uploads: PluginUploadRepositoryDep) -> Response:
|
||||
"""Read the original SKILL.md without downloading supporting files."""
|
||||
archive: Final = await _visible_archive(request, name, uploads)
|
||||
return Response(
|
||||
archive.manifest_content,
|
||||
media_type="text/markdown",
|
||||
headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
|
||||
|
||||
async def public_hub_entries(request: Request, store: PluginUploadStore | None) -> tuple[AgentSkillsIndexEntry, ...]:
|
||||
if store is None or marketplace_auth_required():
|
||||
return ()
|
||||
entries: Final[list[AgentSkillsIndexEntry]] = [] # mutable-ok: bounded inventory avoids copying on each append
|
||||
async for item in store.public_metadata():
|
||||
entry: AgentSkillsIndexEntry | None = _index_entry(request, item)
|
||||
if entry is None:
|
||||
continue
|
||||
entries.append(entry)
|
||||
if len(entries) == 1000:
|
||||
break
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _index_entry(request: Request, item: PluginArchiveMetadata) -> AgentSkillsIndexEntry | None:
|
||||
manifest: Final = MANIFEST_FIELDS.validate_json(item.manifest_json or "{}")
|
||||
raw: Final = manifest.get("agent_skill")
|
||||
if (
|
||||
not isinstance(raw, dict)
|
||||
or not item.enabled
|
||||
or not plugin_archive_source_matches(
|
||||
request,
|
||||
item.name,
|
||||
item.source,
|
||||
item.source.get("sha256", ""),
|
||||
has_agent_skill_metadata=item.has_agent_skill_metadata,
|
||||
)
|
||||
):
|
||||
return None
|
||||
skill: Final = MANIFEST_FIELDS.validate_python(raw)
|
||||
digest: Final = skill.get("digest")
|
||||
description: Final = item.description or skill.get("description")
|
||||
if not isinstance(digest, str) or not isinstance(description, str):
|
||||
return None
|
||||
return AgentSkillsIndexEntry(
|
||||
name=item.name,
|
||||
type="archive",
|
||||
description=description,
|
||||
url=f"{_download_url(request, item.name)}?digest={quote(digest, safe=':')}",
|
||||
digest=digest,
|
||||
)
|
||||
126
litellm/proxy/discovery_endpoints/skill_import.py
Normal file
126
litellm/proxy/discovery_endpoints/skill_import.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
|
||||
MAX_ARCHIVE_ENTRIES,
|
||||
MAX_ARCHIVE_UNPACKED_BYTES,
|
||||
MAX_SKILL_MANIFEST_BYTES,
|
||||
SKILL_ARCHIVE_ERRORS,
|
||||
SkillArchive,
|
||||
agent_skill_frontmatter,
|
||||
build_agent_skill_archive,
|
||||
read_skill_archive_entry,
|
||||
safe_skill_archive_entry,
|
||||
skill_archive_entries,
|
||||
)
|
||||
|
||||
MAX_IMPORT_BYTES: Final = 10 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillImportError:
|
||||
status: int
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GitHubSkillSource:
|
||||
owner: str
|
||||
repository: str
|
||||
ref: str
|
||||
skill: str | None
|
||||
path: str | None
|
||||
|
||||
@property
|
||||
def archive_url(self) -> str:
|
||||
return f"https://codeload.github.com/{self.owner}/{self.repository}/zip/{quote(self.ref, safe='')}"
|
||||
|
||||
|
||||
def parse_skill_source(
|
||||
url: str, *, skill: str | None, ref: str, path: str | None
|
||||
) -> GitHubSkillSource | SkillImportError:
|
||||
try:
|
||||
parsed: Final = urlsplit(url)
|
||||
except ValueError:
|
||||
return SkillImportError(400, "Invalid skill source URL")
|
||||
if parsed.scheme != "https" or parsed.netloc not in ("skills.sh", "github.com") or parsed.query or parsed.fragment:
|
||||
return SkillImportError(400, "Use an HTTPS skills.sh skill URL or a public github.com repository URL")
|
||||
parts: Final = tuple(unquote(part) for part in parsed.path.strip("/").split("/"))
|
||||
if len(parts) < 2 or any(re.fullmatch(r"[A-Za-z0-9_.-]+", part) is None for part in parts[:2]):
|
||||
return SkillImportError(400, "The source URL must identify a GitHub owner and repository")
|
||||
if parsed.netloc == "skills.sh":
|
||||
if len(parts) != 3 or (skill is not None and skill != parts[2]):
|
||||
return SkillImportError(400, "Use skills.sh/<owner>/<repository>/<skill> with a matching skill selection")
|
||||
return GitHubSkillSource(parts[0], parts[1], ref, parts[2], path)
|
||||
if len(parts) == 2:
|
||||
return GitHubSkillSource(parts[0], parts[1].removesuffix(".git"), ref, skill, path)
|
||||
if len(parts) < 5 or parts[2] != "tree" or path is not None or ref != "HEAD":
|
||||
return SkillImportError(400, "Use a repository URL with ref and path, or a GitHub tree URL")
|
||||
return GitHubSkillSource(parts[0], parts[1], parts[3], skill, "/".join(parts[4:]))
|
||||
|
||||
|
||||
async def fetch_skill_source(source: GitHubSkillSource, client: httpx.AsyncClient) -> bytes | SkillImportError:
|
||||
buffer: Final = io.BytesIO()
|
||||
request: Final = httpx.Request(
|
||||
"GET",
|
||||
source.archive_url,
|
||||
headers={"Accept": "application/zip"},
|
||||
extensions={"timeout": httpx.Timeout(30).as_dict()},
|
||||
)
|
||||
try:
|
||||
response: Final = await client.send(request, stream=True, follow_redirects=False, auth=None)
|
||||
try:
|
||||
if response.status_code != 200:
|
||||
return SkillImportError(422, "Unable to download the public GitHub repository at the requested ref")
|
||||
async for chunk in response.aiter_bytes():
|
||||
if buffer.tell() + len(chunk) > MAX_IMPORT_BYTES:
|
||||
return SkillImportError(413, "Repository ZIP must be 10 MiB or smaller")
|
||||
buffer.write(chunk)
|
||||
finally:
|
||||
await response.aclose()
|
||||
except httpx.HTTPError:
|
||||
return SkillImportError(502, "The skill source could not be reached")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def select_imported_skill(content: bytes, source: GitHubSkillSource) -> SkillArchive | SkillImportError:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||
infos: Final = archive.infolist()
|
||||
if len(infos) > MAX_ARCHIVE_ENTRIES or sum(info.file_size for info in infos) > MAX_ARCHIVE_UNPACKED_BYTES:
|
||||
return SkillImportError(413, "Repository exceeds the skill archive limits")
|
||||
entries: Final = skill_archive_entries(archive, for_upload=True)
|
||||
if entries is None:
|
||||
return SkillImportError(422, "Repository contains unsafe archive entries")
|
||||
candidates: Final = tuple(info for info, path in entries if path.endswith("/SKILL.md"))
|
||||
if any(not safe_skill_archive_entry(info) for info in candidates):
|
||||
return SkillImportError(422, "Repository contains unsafe skill manifests")
|
||||
selected: Final = tuple(info for info in candidates if _matches_selection(info, archive, source))
|
||||
if len(selected) != 1:
|
||||
return SkillImportError(422, "Select exactly one skill using its name or repository-relative path")
|
||||
root: Final = selected[0].filename.rsplit("/", 1)[0]
|
||||
skill: Final = build_agent_skill_archive(content, skill_path=root)
|
||||
return (
|
||||
skill
|
||||
if skill is not None
|
||||
else SkillImportError(422, "The selected directory is not a valid Agent Skill")
|
||||
)
|
||||
except SKILL_ARCHIVE_ERRORS:
|
||||
return SkillImportError(422, "The repository did not return a readable skill ZIP")
|
||||
|
||||
|
||||
def _matches_selection(info: zipfile.ZipInfo, archive: zipfile.ZipFile, source: GitHubSkillSource) -> bool:
|
||||
if info.file_size > MAX_SKILL_MANIFEST_BYTES:
|
||||
return False
|
||||
relative: Final = info.filename.split("/", 1)[1].rpartition("/")[0]
|
||||
if source.path is not None and relative != source.path.strip("/"):
|
||||
return False
|
||||
if source.skill is None:
|
||||
return True
|
||||
return agent_skill_frontmatter(read_skill_archive_entry(archive, info)).get("name") == source.skill
|
||||
|
|
@ -2934,7 +2934,7 @@ def configured_max_request_size_mb() -> int | float | None:
|
|||
|
||||
|
||||
_ROUTE_MAX_REQUEST_SIZE_MB: Final[Mapping[tuple[str, str], float]] = MappingProxyType(
|
||||
{("POST", "/claude-code/plugins/upload"): 11}
|
||||
{("POST", "/claude-code/plugins/upload"): 11, ("POST", "/skill-hub/skills/upload"): 11}
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import json
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # Prisma dynamically exposes its typed table actions
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload
|
||||
from litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload, has_agent_skill_metadata
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ if TYPE_CHECKING:
|
|||
|
||||
_MANIFEST: Final = TypeAdapter(dict[str, object])
|
||||
_SOURCE: Final = TypeAdapter(dict[str, str])
|
||||
PAGE_SIZE: Final = 100
|
||||
|
||||
|
||||
class PluginUploadRepository:
|
||||
|
|
@ -48,13 +50,27 @@ class PluginUploadRepository:
|
|||
plugin: Final = await self._plugins.find_unique(where={"name": name})
|
||||
if plugin is None:
|
||||
return None
|
||||
manifest: Final = _MANIFEST.validate_json(plugin.manifest_json or "{}")
|
||||
return PluginArchiveMetadata(
|
||||
name=plugin.name,
|
||||
enabled=plugin.enabled,
|
||||
source=MappingProxyType(_SOURCE.validate_python(manifest.get("source", {}))),
|
||||
manifest_json=plugin.manifest_json,
|
||||
)
|
||||
return _archive_metadata(plugin)
|
||||
|
||||
async def public_metadata(self) -> AsyncIterator[PluginArchiveMetadata]:
|
||||
after: str | None = None # rebind-ok: bounded keyset traversal advances after each raw page
|
||||
while True:
|
||||
plugins: Sequence[prisma_models.LiteLLM_ClaudeCodePluginTable] = await self._plugins.find_many(
|
||||
where={"enabled": True} if after is None else {"enabled": True, "name": {"gt": after}},
|
||||
take=PAGE_SIZE,
|
||||
order={"name": "asc"},
|
||||
)
|
||||
if not plugins:
|
||||
return
|
||||
for plugin in plugins:
|
||||
try:
|
||||
metadata: PluginArchiveMetadata = _archive_metadata(plugin)
|
||||
except ValidationError:
|
||||
continue
|
||||
yield metadata
|
||||
if len(plugins) < PAGE_SIZE:
|
||||
return
|
||||
after = plugins[-1].name
|
||||
|
||||
async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None:
|
||||
archive: Final = await self._archives.find_first(
|
||||
|
|
@ -69,3 +85,17 @@ class PluginUploadRepository:
|
|||
}
|
||||
)
|
||||
return None if archive is None else archive.content.decode()
|
||||
|
||||
|
||||
def _archive_metadata(plugin: "prisma_models.LiteLLM_ClaudeCodePluginTable") -> PluginArchiveMetadata:
|
||||
manifest: Final = _MANIFEST.validate_json(plugin.manifest_json or "{}")
|
||||
return PluginArchiveMetadata(
|
||||
name=plugin.name,
|
||||
enabled=plugin.enabled,
|
||||
source=MappingProxyType(_SOURCE.validate_python(manifest.get("source", {}))),
|
||||
manifest_json=plugin.manifest_json,
|
||||
id=plugin.id,
|
||||
version=plugin.version,
|
||||
description=plugin.description,
|
||||
has_agent_skill_metadata=has_agent_skill_metadata(manifest),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ class PluginListItem(LiteLLMBaseModel):
|
|||
enabled: bool
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
has_agent_skill_metadata: bool = Field(default=False, exclude=True)
|
||||
|
||||
|
||||
class ListPluginsResponse(LiteLLMBaseModel):
|
||||
|
|
|
|||
51
litellm/types/proxy/skill_hub.py
Normal file
51
litellm/types/proxy/skill_hub.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
|
||||
class SkillHubSkill(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
version: str | None = None
|
||||
visibility: Literal["public", "private"]
|
||||
source: Mapping[str, str]
|
||||
download_url: str | None = None
|
||||
content_url: str | None = None
|
||||
|
||||
|
||||
class SkillHubList(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
skills: tuple[SkillHubSkill, ...]
|
||||
count: int
|
||||
next_offset: int | None
|
||||
|
||||
|
||||
class ImportSkillRequest(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
url: str = Field(description="Public skills.sh skill URL or GitHub repository URL")
|
||||
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")
|
||||
|
||||
|
||||
class SkillHubMetadata(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
description: str | None = Field(None, max_length=1024)
|
||||
version: str | None = None
|
||||
domain: str | None = None
|
||||
namespace: str | None = None
|
||||
keywords: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
class SkillHubPublication(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
|
@ -90,6 +90,7 @@ IGNORE_FUNCTIONS = [
|
|||
"_json", # router_rust index views: max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by raising ValueError at the cap.
|
||||
"to_wire", # router_rust model info: bounded by the nesting depth of a stored deployment or cost-map entry (finite JSON trees: pydantic refuses a cycle when it dumps a deployment).
|
||||
"from_wire", # router_rust model info: walks only what router-core merged from to_wire's output, so it never goes deeper than to_wire did.
|
||||
"_valid_frontmatter_node", # max depth 32, manifest size capped at 1 MiB, and YAML aliases rejected before traversal.
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import stat
|
|||
import zipfile
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
|
||||
MAX_ARCHIVE_ENTRIES,
|
||||
agent_skill_frontmatter,
|
||||
build_agent_skill_archive,
|
||||
build_skill_archive,
|
||||
)
|
||||
|
||||
|
|
@ -118,3 +122,127 @@ def test_discovery_keeps_its_permission_and_finder_metadata_policy() -> None:
|
|||
with zipfile.ZipFile(io.BytesIO(result.content)) as archive:
|
||||
assert {entry.create_system for entry in archive.infolist()} == {3}
|
||||
assert {entry.external_attr >> 16 for entry in archive.infolist()} == {0o644}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest",
|
||||
(
|
||||
pytest.param(b"Instructions without frontmatter", id="missing-frontmatter"),
|
||||
pytest.param(b"---\n- pdf-summarizer\n- description\n---\n", id="non-mapping"),
|
||||
pytest.param(b"---\nname: [unterminated\ndescription: invalid\n---\n", id="invalid-yaml"),
|
||||
pytest.param(b"---\nname: pdf-summarizer\ndescription: !!python/object:unsafe {}\n---\n", id="unsafe-yaml-tag"),
|
||||
pytest.param(b"---\nname: Bad_Name\ndescription: invalid\n---\n", id="invalid-name"),
|
||||
pytest.param(b"---\nname: " + b"a" * 65 + b"\ndescription: too long\n---\n", id="name-too-long"),
|
||||
pytest.param(b"---\nname: pdf-summarizer\n---\n", id="missing-description"),
|
||||
pytest.param(b"---\nname: pdf-summarizer\ndescription:\n - item\n---\n", id="non-string-description"),
|
||||
pytest.param(b"---\nname: pdf-summarizer\ndescription: ' '\n---\n", id="empty-description"),
|
||||
pytest.param(b"---\nname: pdf-summarizer\ndescription: " + b"a" * 1025 + b"\n---\n", id="description-too-long"),
|
||||
pytest.param(b"---\nname: pdf-summarizer\nname: other-skill\ndescription: ok\n---\n", id="duplicate-name"),
|
||||
pytest.param(b"---\nname: a\ndescription: ok\nlicense: MIT\nlicense: MIT\n---\n", id="duplicate-optional-key"),
|
||||
),
|
||||
)
|
||||
def test_portable_upload_rejects_malformed_or_invalid_frontmatter(manifest: bytes) -> None:
|
||||
assert build_agent_skill_archive(zip_bytes({"SKILL.md": manifest})) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe_path",
|
||||
("../escape", "scripts/../../escape", "/absolute", r"scripts\escape", "C:/escape", "./scripts/run.sh"),
|
||||
)
|
||||
def test_portable_upload_rejects_unsafe_paths(unsafe_path: str) -> None:
|
||||
assert build_agent_skill_archive(zip_bytes({"SKILL.md": MANIFEST, unsafe_path: b"unsafe"})) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_type", (stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR))
|
||||
def test_portable_upload_rejects_links_and_special_files(file_type: int) -> None:
|
||||
entry: Final = zipfile.ZipInfo("scripts/run.sh")
|
||||
entry.create_system = 3
|
||||
entry.external_attr = (file_type | 0o777) << 16
|
||||
assert build_agent_skill_archive(zip_bytes({"SKILL.md": MANIFEST, entry: b"../../private"})) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size, accepted", ((10 * 1024 * 1024, True), (10 * 1024 * 1024 + 1, False)))
|
||||
def test_file_size_limit_applies_to_selected_skill_members(size: int, accepted: bool) -> None:
|
||||
content: Final = zip_bytes(
|
||||
{
|
||||
"selected/SKILL.md": MANIFEST,
|
||||
"selected/asset.bin": b"x" * size,
|
||||
"unrelated.bin": b"x" * (size + 1),
|
||||
}
|
||||
)
|
||||
assert (build_agent_skill_archive(content, skill_path="selected") is not None) == accepted
|
||||
|
||||
|
||||
def test_portable_upload_rejects_entries_that_normalize_to_the_same_path() -> None:
|
||||
content: Final = zip_bytes({"SKILL.md": MANIFEST, "scripts//run.sh": b"first", "scripts/run.sh": b"second"})
|
||||
assert build_agent_skill_archive(content) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bom", (b"", b"\xef\xbb\xbf"))
|
||||
@pytest.mark.parametrize("newline", ("\n", "\r\n"))
|
||||
@pytest.mark.parametrize(
|
||||
("name", "description", "expected"),
|
||||
(
|
||||
("on", "yes", ("on", "yes")),
|
||||
("yes", "on", ("yes", "on")),
|
||||
('"123"', "'true'", ("123", "true")),
|
||||
("!!str 123", "!!str null", ("123", "null")),
|
||||
('"null"', '"false"', ("null", "false")),
|
||||
("date-reader", "2026-11-05", ("date-reader", "2026-11-05")),
|
||||
("block-reader", "|-\n yes", ("block-reader", "yes")),
|
||||
),
|
||||
)
|
||||
def test_frontmatter_preserves_yaml12_strings_bom_newlines_and_extensions(
|
||||
bom: bytes, newline: str, name: str, description: str, expected: tuple[str, str]
|
||||
) -> None:
|
||||
frontmatter: Final = (
|
||||
f"---\nname: {name}\ndescription: {description}\n"
|
||||
"metadata:\n"
|
||||
" tags: [on, yes, 123]\n"
|
||||
"x-extension:\n"
|
||||
" nested: {count: 1e3, enabled: false}\n"
|
||||
"---\nPreserve this body and its trailing spaces. \n"
|
||||
)
|
||||
manifest: Final = bom + frontmatter.replace("\n", newline).encode()
|
||||
result: Final = build_agent_skill_archive(zip_bytes({"SKILL.md": manifest}))
|
||||
|
||||
assert result is not None
|
||||
assert (result.declared_name, result.declared_description) == expected
|
||||
assert result.manifest_content == manifest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ("name", "description"))
|
||||
@pytest.mark.parametrize("scalar", ("123", "1e3", "012", "0o12", "0x12", "true", "FALSE", "null", "~"))
|
||||
def test_frontmatter_rejects_non_string_required_scalars(field: str, scalar: str) -> None:
|
||||
manifest: Final = f"---\n{field}: {scalar}\n".encode() + (
|
||||
b"description: Instructions\n---\n" if field == "name" else b"name: pdf-summarizer\n---\n"
|
||||
)
|
||||
|
||||
assert build_agent_skill_archive(zip_bytes({"SKILL.md": manifest})) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extension",
|
||||
(
|
||||
pytest.param("metadata:\n owner: first\n owner: second", id="duplicate-metadata-key"),
|
||||
pytest.param("metadata:\n owners:\n - {owner: first, owner: second}", id="duplicate-key-in-sequence"),
|
||||
pytest.param("metadata:\n owner: !!python/object:unsafe {}", id="unsafe-nested-tag"),
|
||||
pytest.param("metadata:\n count: !!int invalid", id="invalid-explicit-scalar"),
|
||||
pytest.param("metadata:\n ? [complex, key]\n : value", id="complex-key"),
|
||||
pytest.param("metadata: &loop\n self: *loop", id="recursive-alias"),
|
||||
pytest.param("metadata: " + "[" * 40 + "nested" + "]" * 40, id="excessive-depth"),
|
||||
pytest.param("metadata: [unterminated", id="malformed-extension"),
|
||||
),
|
||||
)
|
||||
def test_frontmatter_rejects_invalid_nested_metadata(extension: str) -> None:
|
||||
manifest: Final = f"---\nname: pdf-summarizer\ndescription: Instructions\n{extension}\n---\n".encode()
|
||||
|
||||
assert build_agent_skill_archive(zip_bytes({"SKILL.md": manifest})) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ("résumé", "мой-навык", "技能", "foo"))
|
||||
def test_hub_registry_retains_its_ascii_name_policy(name: str) -> None:
|
||||
manifest: Final = f"---\nname: {name}\ndescription: Instructions\n---\n".encode()
|
||||
|
||||
assert agent_skill_frontmatter(manifest) == {"name": name, "description": "Instructions"}
|
||||
assert build_agent_skill_archive(zip_bytes({"SKILL.md": manifest})) is None
|
||||
|
|
|
|||
|
|
@ -1,21 +1,36 @@
|
|||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from prisma import Base64
|
||||
from prisma import models as prisma_models
|
||||
|
||||
import litellm
|
||||
from litellm.models.skills import LiteLLM_SkillsTable
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import build_uploaded_skill_archive
|
||||
from litellm.proxy.deps.repositories import get_plugin_upload_repository
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import build_agent_skill_archive
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
MAX_CACHED_ARCHIVE_BYTES,
|
||||
MAX_INDEXED_SKILLS,
|
||||
_ARCHIVE_CACHE,
|
||||
router,
|
||||
stored_skill,
|
||||
stored_skills,
|
||||
)
|
||||
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import router as hub_router
|
||||
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
|
||||
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
AGENT_SKILLS_DISCOVERY_SCHEMA_URL,
|
||||
MAX_SKILL_NAME_LENGTH,
|
||||
)
|
||||
|
||||
WELL_KNOWN_PATHS = ("/.well-known/agent-skills/index.json", "/.well-known/skills/index.json")
|
||||
|
|
@ -54,9 +69,10 @@ def skill(
|
|||
)
|
||||
|
||||
|
||||
def client_for(*skills: LiteLLM_SkillsTable) -> TestClient:
|
||||
def client_for(*skills: LiteLLM_SkillsTable, uploads: PluginUploadRepository | None = None) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.include_router(hub_router)
|
||||
|
||||
def _skills() -> tuple[LiteLLM_SkillsTable, ...]:
|
||||
return skills
|
||||
|
|
@ -66,9 +82,50 @@ def client_for(*skills: LiteLLM_SkillsTable) -> TestClient:
|
|||
|
||||
app.dependency_overrides[stored_skills] = _skills
|
||||
app.dependency_overrides[stored_skill] = _skill
|
||||
app.dependency_overrides[get_plugin_upload_repository] = lambda: uploads
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def hub_record(
|
||||
name: str,
|
||||
) -> tuple[prisma_models.LiteLLM_ClaudeCodePluginTable, prisma_models.LiteLLM_ClaudeCodePluginUpload]:
|
||||
manifest: Final = MANIFEST.replace(b"pdf-summarizer", name.encode())
|
||||
portable: Final = build_agent_skill_archive(zip_bytes({"SKILL.md": manifest}))
|
||||
assert portable is not None
|
||||
wrapper: Final = build_uploaded_skill_archive(portable.content, name)
|
||||
assert wrapper is not None
|
||||
digest: Final = hashlib.sha256(wrapper).hexdigest()
|
||||
return (
|
||||
prisma_models.LiteLLM_ClaudeCodePluginTable(
|
||||
id=f"hub-{name}", name=name, enabled=True, manifest_json=json.dumps({
|
||||
"source": {
|
||||
"source": "archive", "sha256": digest,
|
||||
"url": f"http://testserver/claude-code/plugins/{name}/archive/{digest}.zip",
|
||||
},
|
||||
"agent_skill": {"name": name, "description": portable.declared_description, "digest": portable.digest},
|
||||
}),
|
||||
),
|
||||
prisma_models.LiteLLM_ClaudeCodePluginUpload(plugin_name=name, content=Base64.encode(wrapper)),
|
||||
)
|
||||
|
||||
|
||||
def hub_repository(*names: str) -> PluginUploadRepository:
|
||||
records: Final = tuple(hub_record(name) for name in names)
|
||||
prisma: Final = MagicMock()
|
||||
plugins: Final = prisma.writer_db.litellm_claudecodeplugintable
|
||||
plugins.find_many = AsyncMock(return_value=[record for record, _ in records])
|
||||
|
||||
async def metadata(where: Mapping[str, str]) -> prisma_models.LiteLLM_ClaudeCodePluginTable | None:
|
||||
return next((record for record, _ in records if record.name == where["name"]), None)
|
||||
|
||||
async def content(where: Mapping[str, object]) -> prisma_models.LiteLLM_ClaudeCodePluginUpload | None:
|
||||
return next((archive for _, archive in records if archive.plugin_name == where["plugin_name"]), None)
|
||||
|
||||
plugins.find_unique = AsyncMock(side_effect=metadata)
|
||||
prisma.writer_db.litellm_claudecodepluginupload.find_first = AsyncMock(side_effect=content)
|
||||
return PluginUploadRepository(prisma)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_enabled(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "public_skills_index", True)
|
||||
|
|
@ -149,6 +206,55 @@ def test_skills_sharing_a_title_get_distinct_install_names(index_enabled):
|
|||
assert names == ["pdf-summarizer", "pdf-summarizer-2"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hosted_names, hub_names, aliases", (
|
||||
(("foo",), ("foo", "foo-2"), ("foo-3", "foo-2")),
|
||||
(("a" * 64,), ("a" * 64, "a" * 62 + "-2"), ("a" * 62 + "-3", "a" * 62 + "-2")),
|
||||
(("foo", "foo"), ("foo", "foo-2"), ("foo-3", "foo-2-2")),
|
||||
(("foo", "foo", "foo-2"), ("foo", "foo-2", "foo-3"), ("foo-4", "foo-2-2", "foo-3-2")),
|
||||
(("bar", "bar", "foo", "foo"), ("bar", "bar-2", "foo", "foo-2"), ("bar-3", "bar-2-2", "foo-3", "foo-2-2")),
|
||||
))
|
||||
def test_hub_collisions_keep_every_hosted_entry_and_only_alias_hub_install_names(
|
||||
index_enabled: None, hosted_names: tuple[str, ...], hub_names: tuple[str, ...], aliases: tuple[str, ...],
|
||||
) -> None:
|
||||
hosted: Final = tuple(
|
||||
skill(f"hosted-{position}", files={"s/SKILL.md": MANIFEST.replace(b"pdf-summarizer", name.encode())})
|
||||
for position, name in enumerate(hosted_names)
|
||||
)
|
||||
before: Final = client_for(*reversed(hosted)).get(WELL_KNOWN_PATHS[0]).json()["skills"]
|
||||
client: Final = client_for(*reversed(hosted), uploads=hub_repository(*hub_names))
|
||||
response: Final = client.get(WELL_KNOWN_PATHS[0])
|
||||
assert response.status_code == 200, response.text
|
||||
entries: Final = response.json()["skills"]
|
||||
assert entries[:len(hosted)] == before
|
||||
assert len({entry["name"] for entry in entries}) == len(hosted) + len(hub_names)
|
||||
assert all(len(entry["name"]) <= MAX_SKILL_NAME_LENGTH for entry in entries)
|
||||
assert tuple(entry["name"] for entry in entries[len(hosted):]) == aliases
|
||||
assert client.get(WELL_KNOWN_PATHS[1]).json() == response.json()
|
||||
assert client.get(WELL_KNOWN_PATHS[0]).json() == response.json()
|
||||
for entry, declared_name in zip(entries, hosted_names + hub_names, strict=True):
|
||||
downloaded: Final = client.get(entry["url"])
|
||||
assert downloaded.status_code == 200, downloaded.text
|
||||
assert entry["digest"] == f"sha256:{hashlib.sha256(downloaded.content).hexdigest()}"
|
||||
with zipfile.ZipFile(io.BytesIO(downloaded.content)) as archive:
|
||||
assert archive.read("SKILL.md") == MANIFEST.replace(b"pdf-summarizer", declared_name.encode())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hosted_count", (MAX_INDEXED_SKILLS - 1, MAX_INDEXED_SKILLS + 1))
|
||||
def test_combined_index_cap_keeps_hosted_entries_before_hub_entries(index_enabled: None, hosted_count: int) -> None:
|
||||
hosted: Final = tuple(skill(f"hosted-{position}") for position in range(hosted_count))
|
||||
client: Final = client_for(*hosted, uploads=hub_repository("first", "second"))
|
||||
response: Final = client.get(WELL_KNOWN_PATHS[0])
|
||||
assert response.status_code == 200, response.text
|
||||
entries: Final = response.json()["skills"]
|
||||
retained: Final = min(hosted_count, MAX_INDEXED_SKILLS)
|
||||
assert len(entries) == MAX_INDEXED_SKILLS
|
||||
assert len({entry["name"] for entry in entries}) == MAX_INDEXED_SKILLS
|
||||
assert [entry["url"] for entry in entries[:retained]] == [
|
||||
f"http://testserver/v1/skills/{item.skill_id}/archive" for item in reversed(hosted[:MAX_INDEXED_SKILLS])
|
||||
]
|
||||
assert [entry["name"] for entry in entries[retained:]] == (["first"] if retained < MAX_INDEXED_SKILLS else [])
|
||||
|
||||
|
||||
def test_uploads_without_a_root_manifest_are_left_out_of_the_index(index_enabled):
|
||||
client = client_for(
|
||||
skill("litellm_skill_1"),
|
||||
|
|
@ -182,6 +288,24 @@ def test_a_stored_skill_is_repacked_once_per_version(index_enabled):
|
|||
)
|
||||
|
||||
|
||||
def test_retained_manifest_bytes_count_toward_the_archive_cache_limit(
|
||||
index_enabled: None,
|
||||
) -> None:
|
||||
stamp: Final = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc)
|
||||
name: Final = "litellm_skill_large_manifest"
|
||||
manifest: Final = MANIFEST + b"x" * MAX_CACHED_ARCHIVE_BYTES
|
||||
client: Final = client_for(skill(name, files={"s/SKILL.md": manifest}, updated_at=stamp))
|
||||
response: Final = client.get(f"/v1/skills/{name}/archive")
|
||||
assert response.status_code == 200
|
||||
assert len(response.content) < MAX_CACHED_ARCHIVE_BYTES
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
stored: Final = archive.read("SKILL.md")
|
||||
actual: Final = (len(stored), hashlib.sha256(stored).hexdigest())
|
||||
expected: Final = (len(manifest), hashlib.sha256(manifest).hexdigest())
|
||||
assert actual == expected
|
||||
assert _ARCHIVE_CACHE.get_cache(f"{name}:{stamp.isoformat()}") is None
|
||||
|
||||
|
||||
def test_a_skill_edited_since_the_last_read_is_republished(index_enabled):
|
||||
stamp = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc)
|
||||
before = client_for(skill("litellm_skill_edited", files={"s/SKILL.md": MANIFEST}, updated_at=stamp))
|
||||
|
|
|
|||
420
tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py
Normal file
420
tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import stat
|
||||
import threading
|
||||
import zipfile
|
||||
import zlib
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from prisma import models as prisma_models
|
||||
from prisma.errors import UniqueViolationError
|
||||
|
||||
import litellm
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
|
||||
from litellm.proxy.deps.repositories import get_plugin_upload_repository
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
|
||||
MAX_ARCHIVE_ENTRIES,
|
||||
MAX_ARCHIVE_UNPACKED_BYTES,
|
||||
)
|
||||
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import import_http_client
|
||||
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
|
||||
from litellm.proxy.utils import InternalUsageCache
|
||||
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
|
||||
from tests._master_key import MASTER_KEY
|
||||
|
||||
NAME: Final = "portable-notes"
|
||||
MANIFEST: Final = (
|
||||
b"---\nname: portable-notes\ndescription: Summarize synthetic notes.\n---\n\nRead references/guide.md.\n"
|
||||
)
|
||||
FILES: Final = {
|
||||
"SKILL.md": MANIFEST,
|
||||
"references/guide.md": b"Use only the supplied notes.\n",
|
||||
"assets/example.bin": b"\x00\x01\xff",
|
||||
"scripts/check.sh": b"#!/bin/sh\nprintf 'synthetic notes\\n'\n",
|
||||
}
|
||||
HUB: Final = "/skill-hub/skills"
|
||||
INDEX: Final = "/.well-known/agent-skills/index.json"
|
||||
GRANTED_KEY: Final = "sk-hub-granted"
|
||||
PLAIN_KEY: Final = "sk-hub-plain"
|
||||
|
||||
|
||||
def _zip(files: Mapping[str, bytes], prefix: str = "") -> bytes:
|
||||
buffer: Final = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path, content in files.items():
|
||||
entry: Final = zipfile.ZipInfo(f"{prefix}{path}", (2024, 1, 1, 0, 0, 0))
|
||||
entry.create_system = 3
|
||||
entry.external_attr = (stat.S_IFREG | (0o755 if path.endswith(".sh") else 0o644)) << 16
|
||||
archive.writestr(entry, content, compress_type=zipfile.ZIP_DEFLATED)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _headers(key: str | None) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {key}"} if key is not None else {}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HubClient:
|
||||
client: httpx.AsyncClient
|
||||
plugins: MagicMock
|
||||
archives: MagicMock
|
||||
upstream: MagicMock
|
||||
clock: MagicMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]:
|
||||
prisma: Final = MagicMock()
|
||||
prisma.writer_db = prisma.db
|
||||
prisma.get_data = AsyncMock(return_value=None)
|
||||
prisma.db.litellm_skillstable.find_many = AsyncMock(return_value=[])
|
||||
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,
|
||||
))
|
||||
plugins.find_unique = AsyncMock(return_value=None)
|
||||
plugins.find_many = AsyncMock(return_value=[])
|
||||
|
||||
async def update_row(where: Mapping[str, str], data: Mapping[str, object]) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
|
||||
current: Final = plugins.find_unique.return_value.model_copy(update=dict(data))
|
||||
plugins.find_unique.return_value = current
|
||||
plugins.find_many.return_value = [current]
|
||||
return current
|
||||
|
||||
plugins.update = AsyncMock(side_effect=update_row)
|
||||
plugins.delete = AsyncMock()
|
||||
archives.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)
|
||||
monkeypatch.setattr(proxy_server.proxy_logging_obj, "proxy_hook_mapping", {"parallel_request_limiter": limiter})
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
monkeypatch.setattr(proxy_server, "master_key", MASTER_KEY)
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {})
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
|
||||
monkeypatch.setattr(litellm, "public_skills_index", True)
|
||||
monkeypatch.setenv("PROXY_BASE_URL", "http://test")
|
||||
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
|
||||
for key, skills in ((GRANTED_KEY, [NAME]), (PLAIN_KEY, [])):
|
||||
proxy_server.user_api_key_cache.set_cache(
|
||||
key=proxy_server.hash_token(key),
|
||||
value=UserAPIKeyAuth(
|
||||
token=proxy_server.hash_token(key),
|
||||
user_role=proxy_server.LitellmUserRoles.INTERNAL_USER,
|
||||
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id=key, skills=skills),
|
||||
),
|
||||
)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(upstream), trust_env=False) as source_client,
|
||||
httpx.AsyncClient(transport=httpx.ASGITransport(app=proxy_server.app), base_url="http://test") as client,
|
||||
):
|
||||
monkeypatch.setattr(proxy_server.app, "dependency_overrides", {
|
||||
get_plugin_upload_repository: lambda: PluginUploadRepository(prisma),
|
||||
import_http_client: lambda: source_client,
|
||||
})
|
||||
yield HubClient(client, plugins, archives, upstream, clock)
|
||||
|
||||
|
||||
async def _upload(hub: HubClient, content: bytes, key: str | None = MASTER_KEY) -> httpx.Response:
|
||||
return await hub.client.post(
|
||||
f"{HUB}/upload",
|
||||
headers=_headers(key),
|
||||
data={"version": "2.0.0"},
|
||||
files={"file": ("notes.zip", content, "application/zip")},
|
||||
)
|
||||
|
||||
|
||||
def _stored_upload(hub: HubClient, enabled: bool = True) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
|
||||
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,
|
||||
)
|
||||
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"],
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _assert_portable(response: httpx.Response) -> None:
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"] == "application/zip"
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
assert {name: archive.read(name) for name in archive.namelist()} == FILES
|
||||
assert stat.S_IMODE(archive.getinfo("scripts/check.sh").external_attr >> 16) == 0o755
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ("upload", "import"))
|
||||
@pytest.mark.parametrize("public_base, root_path", (
|
||||
("http://test", ""), ("https://public.example", ""), ("https://public.example", "/gateway"),
|
||||
))
|
||||
async def test_publication_round_trips_both_formats_without_inflating_on_the_request_thread(
|
||||
hub: HubClient, monkeypatch: pytest.MonkeyPatch, operation: str, public_base: str, root_path: str,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PROXY_BASE_URL")
|
||||
hub.upstream.return_value = httpx.Response(200, content=_zip(FILES, "repo-revision/"))
|
||||
response: Final = (
|
||||
await _upload(hub, _zip(FILES)) if operation == "upload" else await hub.client.post(
|
||||
f"{HUB}/import", headers=_headers(MASTER_KEY),
|
||||
json={"url": f"https://skills.sh/synthetic-author/notes-library/{NAME}"},
|
||||
)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
record: Final = _stored_upload(hub)
|
||||
manifest: Final = json.loads(record.manifest_json)
|
||||
monkeypatch.setattr(proxy_server.app, "root_path", root_path)
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", root_path)
|
||||
serving_base: Final = public_base + root_path
|
||||
hub.client.base_url = httpx.URL(serving_base)
|
||||
details: Final = await hub.client.get(f"{HUB}/{NAME}", headers=_headers(PLAIN_KEY))
|
||||
listed: Final = await hub.client.get(HUB, headers=_headers(PLAIN_KEY))
|
||||
assert listed.json()["skills"] == [details.json()]
|
||||
assert "has_agent_skill_metadata" not in listed.text + details.text
|
||||
assert details.json()["download_url"] == f"{serving_base}{HUB}/{NAME}/archive"
|
||||
assert details.json()["content_url"] == f"{serving_base}{HUB}/{NAME}/content"
|
||||
catalog: Final = await hub.client.get("/claude-code/plugins", headers=_headers(PLAIN_KEY))
|
||||
assert catalog.status_code == 200 and "has_agent_skill_metadata" not in catalog.text
|
||||
request_thread: Final = threading.get_ident()
|
||||
decompress: Final = zlib.decompressobj
|
||||
|
||||
def off_thread_decompressor(wbits: int) -> zlib.Decompress:
|
||||
assert threading.get_ident() != request_thread
|
||||
return decompress(wbits)
|
||||
|
||||
monkeypatch.setattr(zlib, "decompressobj", off_thread_decompressor)
|
||||
portable: Final = await hub.client.get(details.json()["download_url"])
|
||||
content: Final = await hub.client.get(details.json()["content_url"])
|
||||
assert content.status_code == 200 and content.content == MANIFEST
|
||||
digest: Final = "sha256:" + hashlib.sha256(portable.content).hexdigest()
|
||||
assert manifest["agent_skill"]["digest"] == digest
|
||||
entry: Final = (await hub.client.get(INDEX)).json()["skills"][0]
|
||||
assert entry["digest"] == digest
|
||||
assert entry["url"].startswith(f"{serving_base}{HUB}/{NAME}/archive?")
|
||||
assert (await hub.client.get(entry["url"])).content == portable.content
|
||||
assert (await hub.client.get(f"{HUB}/{NAME}/archive?digest=sha256:stale")).status_code == 404
|
||||
legacy_path: Final = httpx.URL(manifest["source"]["url"]).path
|
||||
legacy: Final = await hub.client.get(legacy_path)
|
||||
assert legacy.status_code == 200
|
||||
assert hashlib.sha256(legacy.content).hexdigest() == manifest["source"]["sha256"]
|
||||
assert (await hub.client.get(legacy_path.replace(manifest["source"]["sha256"], "stale"))).status_code == 404
|
||||
monkeypatch.setattr(zlib, "decompressobj", decompress)
|
||||
_assert_portable(portable)
|
||||
with zipfile.ZipFile(io.BytesIO(legacy.content)) as archive:
|
||||
assert {path: archive.read(path) for path in archive.namelist()} == {
|
||||
".claude-plugin/plugin.json": json.dumps({"name": NAME}).encode(),
|
||||
**{f"skills/{NAME}/{path}": content for path, content in FILES.items()},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ("upload", "import"))
|
||||
@pytest.mark.parametrize("boundary", ("entries", "bytes", "file"))
|
||||
@pytest.mark.parametrize("fits", (True, False))
|
||||
async def test_publication_reserves_wrapper_capacity_before_persisting(
|
||||
hub: HubClient, operation: str, boundary: str, fits: bool,
|
||||
) -> None:
|
||||
file_limit: Final = 10 * 1024 * 1024
|
||||
padding_size: Final = (
|
||||
MAX_ARCHIVE_UNPACKED_BYTES - len(MANIFEST) - len(json.dumps({"name": NAME}).encode()) * int(fits)
|
||||
if boundary == "bytes" else file_limit + int(not fits)
|
||||
)
|
||||
files: Final = (
|
||||
{"SKILL.md": MANIFEST, **{
|
||||
f"references/{index}.txt": b"x" for index in range(MAX_ARCHIVE_ENTRIES - 1 - int(fits))
|
||||
}} if boundary == "entries" else {
|
||||
"SKILL.md": MANIFEST,
|
||||
**({
|
||||
f"assets/{index}.bin": b"x" * min(file_limit, padding_size - index * file_limit)
|
||||
for index in range(5)
|
||||
} if boundary == "bytes" else {"assets/padding.bin": b"x" * padding_size}),
|
||||
}
|
||||
)
|
||||
content: Final = _zip(files, prefix="repo-revision/" if operation == "import" else "")
|
||||
hub.upstream.return_value = httpx.Response(200, content=content)
|
||||
response: Final = (
|
||||
await _upload(hub, content) if operation == "upload" else await hub.client.post(
|
||||
f"{HUB}/import", headers=_headers(MASTER_KEY),
|
||||
json={"url": "https://github.com/synthetic-author/notes-library"},
|
||||
)
|
||||
)
|
||||
assert response.status_code == (200 if fits else 422), response.text
|
||||
if not fits:
|
||||
hub.plugins.create.assert_not_awaited()
|
||||
return
|
||||
_stored_upload(hub)
|
||||
downloaded: Final = await hub.client.get(response.json()["download_url"])
|
||||
assert downloaded.status_code == 200, downloaded.text
|
||||
with zipfile.ZipFile(io.BytesIO(downloaded.content)) as archive:
|
||||
actual: Final = {
|
||||
path: (archive.getinfo(path).file_size, hashlib.sha256(archive.read(path)).hexdigest())
|
||||
for path in archive.namelist()
|
||||
}
|
||||
expected: Final = {path: (len(content), hashlib.sha256(content).hexdigest()) for path, content in files.items()}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled, public_index, auth_required, key, status", (
|
||||
(True, True, False, None, 200), (False, True, False, None, 404),
|
||||
(True, False, False, None, 401), (True, True, True, None, 401),
|
||||
(False, True, False, GRANTED_KEY, 200), (False, True, False, PLAIN_KEY, 404),
|
||||
(True, True, False, "", 401), (True, True, False, "sk-invalid", 401),
|
||||
))
|
||||
async def test_download_policy_requires_publication_or_a_valid_grant(
|
||||
hub: HubClient, monkeypatch: pytest.MonkeyPatch, enabled: bool,
|
||||
public_index: bool, auth_required: bool, key: str | None, status: int,
|
||||
) -> None:
|
||||
assert (await _upload(hub, _zip(FILES))).status_code == 200
|
||||
_stored_upload(hub, enabled=enabled)
|
||||
monkeypatch.setattr(litellm, "public_skills_index", public_index)
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {"claude_code_marketplace_auth_required": auth_required})
|
||||
for suffix in ("archive", "content"):
|
||||
response: Final = await hub.client.get(f"{HUB}/{NAME}/{suffix}", headers=_headers(key))
|
||||
assert response.status_code == status, response.text
|
||||
if status != 200:
|
||||
hub.archives.find_first.assert_not_awaited()
|
||||
index: Final = await hub.client.get(INDEX)
|
||||
assert index.status_code == (200 if public_index else 404)
|
||||
if public_index:
|
||||
assert [entry["name"] for entry in index.json()["skills"]] == ([NAME] if enabled and not auth_required else [])
|
||||
|
||||
|
||||
async def test_search_paginates_visible_matches(hub: HubClient) -> None:
|
||||
assert (await _upload(hub, _zip(FILES))).status_code == 200
|
||||
record: Final = _stored_upload(hub)
|
||||
hub.plugins.find_many.return_value = [
|
||||
record.model_copy(update={"name": "private-notes", "enabled": False}), record,
|
||||
record.model_copy(update={"name": "other-notes"}),
|
||||
record.model_copy(update={"name": "unmatched", "description": "Different topic"}),
|
||||
]
|
||||
for offset, name in enumerate(("other-notes", NAME)):
|
||||
response: Final = await hub.client.get(
|
||||
HUB, headers=_headers(PLAIN_KEY), params={"query": "SYNTHETIC", "limit": 1, "offset": offset},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [item["name"] for item in response.json()["skills"]] == [name]
|
||||
assert response.json()["count"] == 2
|
||||
assert response.json()["next_offset"] == (1 if offset == 0 else None)
|
||||
|
||||
|
||||
async def test_metadata_publication_and_deletion_preserve_files_and_grants(hub: HubClient) -> None:
|
||||
assert (await _upload(hub, _zip(FILES))).status_code == 200
|
||||
original: Final = _stored_upload(hub)
|
||||
manifest: Final = json.loads(original.manifest_json)
|
||||
|
||||
for route, metadata in (
|
||||
(f"/claude-code/plugins/{NAME}", {"source": manifest["source"], "description": "Legacy edit"}),
|
||||
(f"{HUB}/{NAME}", {"description": "Neutral edit", "version": "3.0.0", "keywords": ["notes"]}),
|
||||
(f"{HUB}/{NAME}", {}),
|
||||
):
|
||||
response: Final = await hub.client.put(route, headers=_headers(MASTER_KEY), json=metadata)
|
||||
assert response.status_code == 200, response.text
|
||||
assert json.loads(hub.plugins.find_unique.return_value.manifest_json)["agent_skill"] == manifest["agent_skill"]
|
||||
details: Final = await hub.client.get(f"{HUB}/{NAME}", headers=_headers(PLAIN_KEY))
|
||||
assert details.json()["description"] == metadata.get("description")
|
||||
_assert_portable(await hub.client.get(details.json()["download_url"]))
|
||||
for action, public in (("unpublish", False), ("publish", True)):
|
||||
changed: Final = await hub.client.post(f"{HUB}/{NAME}/{action}", headers=_headers(MASTER_KEY))
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["visibility"] == ("public" if public else "private")
|
||||
assert (await hub.client.get(f"{HUB}/{NAME}/archive")).status_code == (200 if public else 404)
|
||||
_assert_portable(await hub.client.get(f"{HUB}/{NAME}/archive", headers=_headers(GRANTED_KEY)))
|
||||
assert (await hub.client.get(f"{HUB}/{NAME}", headers=_headers(PLAIN_KEY))).status_code == (200 if public else 404)
|
||||
assert bool((await hub.client.get(INDEX)).json()["skills"]) == public
|
||||
assert (await hub.client.delete(f"{HUB}/{NAME}", headers=_headers(MASTER_KEY))).status_code == 204
|
||||
hub.plugins.delete.assert_awaited_once_with(where={"name": NAME})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method, suffix", (("PUT", ""), ("POST", "/publish"), ("POST", "/unpublish"), ("DELETE", "")))
|
||||
async def test_granted_readers_cannot_manage_skills(hub: HubClient, method: str, suffix: str) -> None:
|
||||
response: Final = await hub.client.request(
|
||||
method, f"{HUB}/{NAME}{suffix}", headers=_headers(GRANTED_KEY), json={} if method == "PUT" else None,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
hub.plugins.update.assert_not_awaited()
|
||||
hub.plugins.delete.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content, key, status", (
|
||||
(b"not a zip", MASTER_KEY, 422),
|
||||
(_zip(FILES), PLAIN_KEY, 403), (_zip(FILES), None, 401),
|
||||
), ids=("invalid-zip", "forbidden", "unauthenticated"))
|
||||
async def test_rejected_uploads_do_not_write(hub: HubClient, content: bytes, key: str | None, status: int) -> None:
|
||||
assert (await _upload(hub, content, key)).status_code == status
|
||||
hub.plugins.create.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_stored_content_survives_duplicate_upload_and_is_hidden_after_source_replacement(hub: HubClient) -> None:
|
||||
assert (await _upload(hub, _zip(FILES))).status_code == 200
|
||||
original: Final = _stored_upload(hub)
|
||||
hub.plugins.create.side_effect = UniqueViolationError({"message": "duplicate", "error_code": "P2002"})
|
||||
response: Final = await _upload(hub, _zip({**FILES, "references/guide.md": b"replacement"}))
|
||||
assert response.status_code == 409
|
||||
_assert_portable(await hub.client.get(f"{HUB}/{NAME}/archive"))
|
||||
hub.plugins.update.assert_not_awaited()
|
||||
hub.archives.find_first.reset_mock()
|
||||
manifest: Final = json.loads(original.manifest_json)
|
||||
external: Final = {
|
||||
**manifest["source"],
|
||||
"url": str(httpx.URL(manifest["source"]["url"]).copy_with(scheme="https", host="external.example")),
|
||||
}
|
||||
replaced: Final = await hub.client.put(
|
||||
f"/claude-code/plugins/{NAME}", headers=_headers(MASTER_KEY),
|
||||
json={"source": external, "agent_skill": manifest["agent_skill"]},
|
||||
)
|
||||
assert replaced.status_code == 200, replaced.text
|
||||
assert "agent_skill" not in json.loads(hub.plugins.find_unique.return_value.manifest_json)
|
||||
details: Final = await hub.client.get(f"{HUB}/{NAME}", headers=_headers(MASTER_KEY))
|
||||
assert details.json()["source"] == external and details.json()["download_url"] is None
|
||||
for suffix in ("archive", "content"):
|
||||
assert (await hub.client.get(f"{HUB}/{NAME}/{suffix}")).status_code == 404
|
||||
assert (await hub.client.get(httpx.URL(manifest["source"]["url"]).path)).status_code == 404
|
||||
hub.archives.find_first.assert_not_awaited()
|
||||
assert (await hub.client.get(INDEX)).json()["skills"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_id, admitted", (("publisher", 10), (None, 20)))
|
||||
async def test_publication_limit_is_shared_by_user_and_route_and_resets(
|
||||
hub: HubClient, monkeypatch: pytest.MonkeyPatch, user_id: str | None, admitted: int,
|
||||
) -> None:
|
||||
for key in (PLAIN_KEY, GRANTED_KEY):
|
||||
proxy_server.user_api_key_cache.set_cache(
|
||||
key=proxy_server.hash_token(key),
|
||||
value=UserAPIKeyAuth(
|
||||
token=proxy_server.hash_token(key), user_id=user_id,
|
||||
user_role=proxy_server.LitellmUserRoles.PROXY_ADMIN,
|
||||
),
|
||||
)
|
||||
responses: Final = await asyncio.gather(*(
|
||||
_upload(hub, b"invalid", PLAIN_KEY) if index % 2 else hub.client.post(
|
||||
f"{HUB}/import", headers=_headers(GRANTED_KEY), json={"url": "https://example.com/skill"},
|
||||
) for index in range(20)
|
||||
))
|
||||
assert sum(response.status_code in (400, 422) for response in responses) == admitted
|
||||
assert sum(response.status_code == 429 for response in responses) == 20 - admitted
|
||||
assert all(response.headers["retry-after"] == "60" for response in responses if response.status_code == 429)
|
||||
hub.plugins.create.assert_not_awaited()
|
||||
hub.upstream.assert_not_called()
|
||||
assert (await _upload(hub, _zip(FILES))).status_code == 200
|
||||
_stored_upload(hub)
|
||||
for _ in range(11):
|
||||
assert (await hub.client.get(INDEX)).status_code == 200
|
||||
assert (await hub.client.get(f"{HUB}/{NAME}/archive", headers=_headers(PLAIN_KEY))).status_code == 200
|
||||
hub.clock.return_value += timedelta(seconds=60)
|
||||
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
|
||||
189
tests/unit/proxy/discovery_endpoints/test_skill_import.py
Normal file
189
tests/unit/proxy/discovery_endpoints/test_skill_import.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import hashlib
|
||||
import io
|
||||
import stat
|
||||
import struct
|
||||
import zipfile
|
||||
import zlib
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, NoReturn
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
|
||||
MAX_ARCHIVE_ENTRIES,
|
||||
MAX_ARCHIVE_UNPACKED_BYTES,
|
||||
MAX_SKILL_MANIFEST_BYTES,
|
||||
)
|
||||
from litellm.proxy.discovery_endpoints.skill_import import (
|
||||
MAX_IMPORT_BYTES,
|
||||
GitHubSkillSource,
|
||||
SkillImportError,
|
||||
fetch_skill_source,
|
||||
parse_skill_source,
|
||||
select_imported_skill,
|
||||
)
|
||||
|
||||
MANIFEST: Final = b"---\nname: pdf-summarizer\ndescription: Summarize PDFs.\n---\n\nRead the PDF.\n"
|
||||
SOURCE: Final = GitHubSkillSource("author", "repo", "HEAD", "pdf-summarizer", None)
|
||||
NESTED: Final = "skills/pdf-summarizer"
|
||||
|
||||
|
||||
def repository_zip(entries: tuple[tuple[str | zipfile.ZipInfo, bytes], ...]) -> bytes:
|
||||
buffer: Final = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
for name, content in entries:
|
||||
entry: Final = zipfile.ZipInfo(name) if isinstance(name, str) else name
|
||||
archive.writestr(entry, content, compress_type=zipfile.ZIP_DEFLATED)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("url", "skill", "ref", "path", "expected"), (
|
||||
("https://skills.sh/author/repo/pdf-summarizer", None, "HEAD", None, SOURCE),
|
||||
("https://github.com/author/repo.git/", "pdf-summarizer", "release/v2", NESTED,
|
||||
GitHubSkillSource("author", "repo", "release/v2", "pdf-summarizer", NESTED)),
|
||||
(f"https://github.com/author/repo/tree/release%2Fv2/{NESTED}", None, "HEAD", None,
|
||||
GitHubSkillSource("author", "repo", "release/v2", None, NESTED)),
|
||||
("http://github.com/author/repo", None, "HEAD", None, 400),
|
||||
("https://github.com.example/author/repo", None, "HEAD", None, 400),
|
||||
("https://user:secret@github.com/author/repo", None, "HEAD", None, 400),
|
||||
("https://github.com/author/repo?token=secret", None, "HEAD", None, 400),
|
||||
("https://github.com/author/repo#fragment", None, "HEAD", None, 400),
|
||||
("https://github.com/author%2Fother/repo", None, "HEAD", None, 400),
|
||||
("https://[invalid/author/repo", None, "HEAD", None, 400),
|
||||
("https://skills.sh/author/repo", None, "HEAD", None, 400),
|
||||
("https://skills.sh/author/repo/pdf-summarizer", "other", "HEAD", None, 400),
|
||||
(f"https://github.com/author/repo/tree/main/{NESTED}", None, "other", None, 400),
|
||||
(f"https://github.com/author/repo/tree/main/{NESTED}", None, "HEAD", "other", 400),
|
||||
))
|
||||
def test_source_urls(
|
||||
url: str, skill: str | None, ref: str, path: str | None, expected: GitHubSkillSource | int
|
||||
) -> None:
|
||||
result: Final = parse_skill_source(url, skill=skill, ref=ref, path=path)
|
||||
assert (result.status if isinstance(result, SkillImportError) else result) == expected
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadStream(httpx.AsyncByteStream):
|
||||
overflow: bool
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
yield b"a" * MAX_IMPORT_BYTES
|
||||
if self.overflow:
|
||||
yield b"!"
|
||||
pytest.fail("The download kept reading after exceeding the byte limit")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("mode", "status"), (
|
||||
("bounded", None), ("overflow", 413), ("redirect", 422), ("http-error", 422), ("timeout", 502),
|
||||
))
|
||||
async def test_public_download_transport(mode: str, status: int | None) -> None:
|
||||
credentials: Final = {
|
||||
"Authorization": "Bearer unit-secret",
|
||||
"Proxy-Authorization": "Basic unit-secret",
|
||||
"X-API-Key": "unit-secret",
|
||||
"Cookie": "session=unit-secret",
|
||||
}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "GET"
|
||||
assert str(request.url) == "https://codeload.github.com/author/repo/zip/release%2Fv2"
|
||||
assert not frozenset(key.lower() for key in credentials).intersection(request.headers)
|
||||
if mode == "timeout":
|
||||
raise httpx.ReadTimeout("simulated timeout", request=request)
|
||||
return httpx.Response(
|
||||
307 if mode == "redirect" else 404 if mode == "http-error" else 200,
|
||||
headers={"Location": "https://untrusted.example/private", "Content-Length": "1"},
|
||||
stream=DownloadStream(mode == "overflow"),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), headers=credentials, auth=httpx.BasicAuth("unit-user", "unit-secret"),
|
||||
follow_redirects=True, trust_env=False,
|
||||
) as client:
|
||||
result: Final = await fetch_skill_source(GitHubSkillSource("author", "repo", "release/v2", None, None), client)
|
||||
if status is None:
|
||||
assert isinstance(result, bytes)
|
||||
actual: Final = (len(result), hashlib.sha256(result).hexdigest())
|
||||
expected: Final = (MAX_IMPORT_BYTES, hashlib.sha256(b"a" * MAX_IMPORT_BYTES).hexdigest())
|
||||
assert actual == expected
|
||||
else:
|
||||
assert isinstance(result, SkillImportError) and result.status == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("skill", "path", "root_name", "expected"), (
|
||||
(None, "", b"root", "root"),
|
||||
(None, "/", b"root", "root"),
|
||||
(None, "SKILL.md", b"root", 422),
|
||||
(None, NESTED, b"root", "pdf-summarizer"),
|
||||
("pdf-summarizer", None, b"root", "pdf-summarizer"),
|
||||
("pdf-summarizer", f"/{NESTED}/", b"pdf-summarizer", "pdf-summarizer"),
|
||||
(None, None, b"root", 422),
|
||||
("missing", None, b"root", 422),
|
||||
("pdf-summarizer", "", b"root", 422),
|
||||
("pdf-summarizer", None, b"pdf-summarizer", 422),
|
||||
(None, "", None, 422),
|
||||
))
|
||||
def test_repository_selection(skill: str | None, path: str | None, root_name: bytes | None, expected: str | int) -> None:
|
||||
root: Final = (
|
||||
(("repo/SKILL.md", MANIFEST.replace(b"pdf-summarizer", root_name)),)
|
||||
if root_name is not None else ()
|
||||
)
|
||||
result: Final = select_imported_skill(
|
||||
repository_zip(root + ((f"repo/{NESTED}/SKILL.md", MANIFEST),)),
|
||||
GitHubSkillSource("author", "repo", "HEAD", skill, path),
|
||||
)
|
||||
assert (result.status if isinstance(result, SkillImportError) else result.declared_name) == expected
|
||||
|
||||
|
||||
def test_corrupt_manifest_compression_returns_an_import_error() -> None:
|
||||
content: Final = repository_zip(((f"repo/{NESTED}/SKILL.md", MANIFEST),))
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||
entry: Final = archive.infolist()[0]
|
||||
filename_size, extra_size = struct.unpack_from("<HH", content, entry.header_offset + 26)
|
||||
offset: Final = entry.header_offset + 30 + filename_size + extra_size
|
||||
invalid: Final = content[:offset] + b"\xff" * entry.compress_size + content[offset + entry.compress_size:]
|
||||
result: Final = select_imported_skill(invalid, SOURCE)
|
||||
assert isinstance(result, SkillImportError) and result.status == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("violation", "expected"), (
|
||||
("path", 422), ("inside-link", 422), ("outside-link", "pdf-summarizer"), ("entries", 413), ("unpacked-bytes", 413),
|
||||
))
|
||||
def test_repository_validation_scope(violation: str, expected: str | int) -> None:
|
||||
paths: Final = {"path": "repo/../escape", "inside-link": f"repo/{NESTED}/run.sh"}
|
||||
entry: Final = zipfile.ZipInfo(paths.get(violation, "repo/CLAUDE.md"))
|
||||
entry.create_system = 3
|
||||
entry.external_attr = ((stat.S_IFLNK if violation.endswith("-link") else stat.S_IFREG) | 0o644) << 16
|
||||
payload: Final = b"x" * (MAX_ARCHIVE_UNPACKED_BYTES if violation == "unpacked-bytes" else 1)
|
||||
extra: Final = (
|
||||
tuple((f"repo/unrelated/{index}", b"x") for index in range(MAX_ARCHIVE_ENTRIES))
|
||||
if violation == "entries" else ((entry, payload),)
|
||||
)
|
||||
result: Final = select_imported_skill(
|
||||
repository_zip(((f"repo/{NESTED}/SKILL.md", MANIFEST),) + extra), SOURCE
|
||||
)
|
||||
assert (result.status if isinstance(result, SkillImportError) else result.declared_name) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("violation", "status"), (("path", 422), ("symlink", 422), ("manifest-bytes", 422)))
|
||||
def test_candidate_metadata_is_validated_before_manifest_decompression(
|
||||
violation: str, status: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
entry: Final = zipfile.ZipInfo(
|
||||
"repo/../pdf-summarizer/SKILL.md" if violation == "path" else f"repo/{NESTED}/SKILL.md"
|
||||
)
|
||||
entry.create_system = 3
|
||||
entry.external_attr = ((stat.S_IFLNK if violation == "symlink" else stat.S_IFREG) | 0o644) << 16
|
||||
content: Final = repository_zip(
|
||||
((entry, b"x" * (MAX_SKILL_MANIFEST_BYTES + 1) if violation == "manifest-bytes" else MANIFEST),)
|
||||
)
|
||||
|
||||
def reject_decompression(*args: object, **kwargs: object) -> NoReturn:
|
||||
pytest.fail("Invalid candidate metadata reached the decompressor before rejection")
|
||||
|
||||
monkeypatch.setattr(zlib, "decompressobj", reject_decompression)
|
||||
result: Final = select_imported_skill(content, SOURCE)
|
||||
assert isinstance(result, SkillImportError) and result.status == status
|
||||
62
tests/unit/repositories/test_plugin_upload_repository.py
Normal file
62
tests/unit/repositories/test_plugin_upload_repository.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from prisma import models as prisma_models
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import public_hub_entries
|
||||
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
|
||||
|
||||
|
||||
def plugin(
|
||||
name: str, manifest_json: str | None = None, *, host: str = "testserver",
|
||||
) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
|
||||
digest: Final = "a" * 64
|
||||
return prisma_models.LiteLLM_ClaudeCodePluginTable(
|
||||
id=name, name=name, enabled=True,
|
||||
manifest_json=manifest_json if manifest_json is not None else json.dumps({
|
||||
"source": {
|
||||
"source": "archive", "url": f"http://{host}/claude-code/plugins/{name}/archive/{digest}.zip",
|
||||
"sha256": digest,
|
||||
},
|
||||
"agent_skill": {"name": name, "description": f"Synthetic skill {name}", "digest": f"sha256:{digest}"},
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_discovery_pages_past_ineligible_and_invalid_rows_before_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
malformed: Final = ("{invalid-json", '{"source": {"url": 42}}')
|
||||
rows: Final = (
|
||||
tuple(plugin(f"a-invalid-{number:04}", malformed[number % len(malformed)]) for number in range(100))
|
||||
+ tuple(plugin(f"b-legacy-{number:04}", "{}") for number in range(1000))
|
||||
+ (plugin("c-external", '{"source": {"source": "archive", "url": "https://external.example/skill.zip"}}'),)
|
||||
+ tuple(plugin(f"z-portable-{number:04}") for number in range(2000))
|
||||
)
|
||||
prisma: Final = MagicMock()
|
||||
|
||||
async def find_many(
|
||||
*, where: Mapping[str, Mapping[str, str] | bool], take: int, order: Mapping[str, str],
|
||||
) -> tuple[prisma_models.LiteLLM_ClaudeCodePluginTable, ...]:
|
||||
name_filter: Final = where.get("name", {})
|
||||
after: Final = name_filter.get("gt", "") if isinstance(name_filter, Mapping) else ""
|
||||
assert after < "z-portable-0999"
|
||||
ordered: Final = sorted(rows, key=lambda row: row.name, reverse=order.get("name") == "desc")
|
||||
return tuple(row for row in ordered if row.enabled == where["enabled"] and row.name > after)[:take]
|
||||
|
||||
prisma.writer_db.litellm_claudecodeplugintable.find_many = AsyncMock(side_effect=find_many)
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {})
|
||||
monkeypatch.setenv("PROXY_BASE_URL", "http://testserver")
|
||||
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
|
||||
request: Final = Request({
|
||||
"type": "http", "scheme": "http", "server": ("testserver", 80),
|
||||
"path": "/.well-known/agent-skills/index.json", "headers": [],
|
||||
})
|
||||
|
||||
entries: Final = await public_hub_entries(request, PluginUploadRepository(prisma))
|
||||
|
||||
assert tuple(entry.name for entry in entries) == tuple(f"z-portable-{number:04}" for number in range(1000))
|
||||
587
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
587
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -30,7 +30,7 @@ export interface paths {
|
|||
};
|
||||
/**
|
||||
* Agent Skills Index
|
||||
* @description Agent Skills v0.2.0 discovery index over every skill stored on this proxy.
|
||||
* @description Discover public Hub skills and the separately enabled hosted skill store.
|
||||
*/
|
||||
get: operations["agent_skills_index__well_known_agent_skills_index_json_get"];
|
||||
put?: never;
|
||||
|
|
@ -339,7 +339,7 @@ export interface paths {
|
|||
};
|
||||
/**
|
||||
* Agent Skills Index
|
||||
* @description Agent Skills v0.2.0 discovery index over every skill stored on this proxy.
|
||||
* @description Discover public Hub skills and the separately enabled hosted skill store.
|
||||
*/
|
||||
get: operations["agent_skills_index__well_known_skills_index_json_get"];
|
||||
put?: never;
|
||||
|
|
@ -15670,6 +15670,174 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/skills": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Skills
|
||||
* @description Browse the caller's public and granted private skills.
|
||||
*/
|
||||
get: operations["list_skills_skill_hub_skills_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/skills/import": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Import Skill
|
||||
* @description Import a public skills.sh or GitHub skill as a portable snapshot.
|
||||
*/
|
||||
post: operations["import_skill_skill_hub_skills_import_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/skills/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Upload Skill
|
||||
* @description Publish a portable skill while retaining Claude marketplace compatibility.
|
||||
*/
|
||||
post: operations["upload_skill_skill_hub_skills_upload_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/skills/{name}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Skill
|
||||
* @description Read one visible skill and its portable download URL.
|
||||
*/
|
||||
get: operations["get_skill_skill_hub_skills__name__get"];
|
||||
/**
|
||||
* Replace Skill Metadata
|
||||
* @description Replace registry metadata, clearing omitted fields and preserving skill files.
|
||||
*/
|
||||
put: operations["replace_skill_metadata_skill_hub_skills__name__put"];
|
||||
post?: never;
|
||||
/**
|
||||
* Remove Skill
|
||||
* @description Delete a skill and its managed archive.
|
||||
*/
|
||||
delete: operations["remove_skill_skill_hub_skills__name__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/skills/{name}/archive": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Download Skill
|
||||
* @description Download the complete portable skill, with SKILL.md at the ZIP root.
|
||||
*/
|
||||
get: operations["download_skill_skill_hub_skills__name__archive_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/skills/{name}/content": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Skill Content
|
||||
* @description Read the original SKILL.md without downloading supporting files.
|
||||
*/
|
||||
get: operations["skill_content_skill_hub_skills__name__content_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/skills/{name}/publish": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Publish Skill
|
||||
* @description Make a skill public using the existing Hub publication policy.
|
||||
*/
|
||||
post: operations["publish_skill_skill_hub_skills__name__publish_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/skills/{name}/unpublish": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Unpublish Skill
|
||||
* @description Remove a skill from public discovery while retaining explicit grants.
|
||||
*/
|
||||
post: operations["unpublish_skill_skill_hub_skills__name__unpublish_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/spend/calculate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -28173,6 +28341,16 @@ export interface components {
|
|||
*/
|
||||
metadata: string;
|
||||
};
|
||||
/** Body_upload_skill_skill_hub_skills_upload_post */
|
||||
Body_upload_skill_skill_hub_skills_upload_post: {
|
||||
/**
|
||||
* File
|
||||
* @description Agent Skills ZIP with SKILL.md and optional supporting files
|
||||
*/
|
||||
file: string;
|
||||
/** Version */
|
||||
version?: string | null;
|
||||
};
|
||||
/** Body_video_create_character_v1_videos_characters_post */
|
||||
Body_video_create_character_v1_videos_characters_post: {
|
||||
/** Name */
|
||||
|
|
@ -34197,6 +34375,35 @@ export interface components {
|
|||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** ImportSkillRequest */
|
||||
ImportSkillRequest: {
|
||||
/**
|
||||
* Path
|
||||
* @description Repository-relative skill directory
|
||||
*/
|
||||
path?: string | null;
|
||||
/**
|
||||
* Ref
|
||||
* @description Git commit, tag or branch to import
|
||||
* @default HEAD
|
||||
*/
|
||||
ref: string;
|
||||
/**
|
||||
* Skill
|
||||
* @description Skill name to select when a repository contains several skills
|
||||
*/
|
||||
skill?: string | null;
|
||||
/**
|
||||
* Url
|
||||
* @description Public skills.sh skill URL or GitHub repository URL
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Version
|
||||
* @description Version label for the imported snapshot
|
||||
*/
|
||||
version?: string | null;
|
||||
};
|
||||
/**
|
||||
* IncompleteDetails
|
||||
* @description Details about why the response is incomplete.
|
||||
|
|
@ -46392,6 +46599,54 @@ export interface components {
|
|||
/** Updated At */
|
||||
updated_at: string;
|
||||
};
|
||||
/** SkillHubList */
|
||||
SkillHubList: {
|
||||
/** Count */
|
||||
count: number;
|
||||
/** Next Offset */
|
||||
next_offset: number | null;
|
||||
/** Skills */
|
||||
skills: components["schemas"]["SkillHubSkill"][];
|
||||
};
|
||||
/** SkillHubMetadata */
|
||||
SkillHubMetadata: {
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
/** Domain */
|
||||
domain?: string | null;
|
||||
/** Keywords */
|
||||
keywords?: string[] | null;
|
||||
/** Namespace */
|
||||
namespace?: string | null;
|
||||
/** Version */
|
||||
version?: string | null;
|
||||
};
|
||||
/** SkillHubPublication */
|
||||
SkillHubPublication: Record<string, never>;
|
||||
/** SkillHubSkill */
|
||||
SkillHubSkill: {
|
||||
/** Content Url */
|
||||
content_url?: string | null;
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
/** Download Url */
|
||||
download_url?: string | null;
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Source */
|
||||
source: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** Version */
|
||||
version?: string | null;
|
||||
/**
|
||||
* Visibility
|
||||
* @enum {string}
|
||||
*/
|
||||
visibility: "public" | "private";
|
||||
};
|
||||
/** SkillReference */
|
||||
SkillReference: {
|
||||
/** Skill Id */
|
||||
|
|
@ -73513,6 +73768,334 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
list_skills_skill_hub_skills_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
query?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubList"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
import_skill_skill_hub_skills_import_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ImportSkillRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSkill"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
upload_skill_skill_hub_skills_upload_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_upload_skill_skill_hub_skills_upload_post"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSkill"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_skill_skill_hub_skills__name__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSkill"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
replace_skill_metadata_skill_hub_skills__name__put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubMetadata"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSkill"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
remove_skill_skill_hub_skills__name__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
download_skill_skill_hub_skills__name__archive_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
digest?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
name: 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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
skill_content_skill_hub_skills__name__content_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"text/markdown": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
publish_skill_skill_hub_skills__name__publish_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubPublication"] | null;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSkill"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
unpublish_skill_skill_hub_skills__name__unpublish_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubPublication"] | null;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSkill"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
calculate_spend_spend_calculate_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue