mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
test(proxy): fix marketplace request fixture after staging merge
This commit is contained in:
commit
989140d065
53 changed files with 1758 additions and 152 deletions
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN IF NOT EXISTS "skills" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
|
@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
|
|||
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
|
||||
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
|
||||
mcp_tool_search_enabled Boolean?
|
||||
skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
|
||||
teams LiteLLM_TeamTable[]
|
||||
projects LiteLLM_ProjectTable[]
|
||||
verification_tokens LiteLLM_VerificationToken[]
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
import re
|
||||
import traceback
|
||||
from collections.abc import AsyncGenerator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER, EMPTY_MAPPING
|
||||
from litellm.types.integrations.argilla import ArgillaItem
|
||||
from litellm.types.integrations.custom_logger import AgenticLoopPlan
|
||||
from litellm.types.llms.openai import AllMessageValues, ChatCompletionRequest
|
||||
|
|
@ -950,15 +951,18 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
model_response_dict: Final = model_response.model_dump()
|
||||
standard_logging_object_copy["response"] = model_response_dict
|
||||
|
||||
redacted_details: Final = {**model_call_details, "standard_logging_object": standard_logging_object_copy}
|
||||
params: Final = model_call_details.get("litellm_params")
|
||||
request: Final = params.get("proxy_server_request") if isinstance(params, dict) else None
|
||||
if turn_off_message_logging and isinstance(params, dict) and isinstance(request, dict):
|
||||
return {
|
||||
**redacted_details,
|
||||
"litellm_params": {**params, "proxy_server_request": without_classifier_audit(request)},
|
||||
}
|
||||
return redacted_details
|
||||
redacted_params: Final = (
|
||||
MappingProxyType({"litellm_params": {**params, "proxy_server_request": without_classifier_audit(request)}})
|
||||
if turn_off_message_logging and isinstance(params, dict) and isinstance(request, dict)
|
||||
else EMPTY_MAPPING
|
||||
)
|
||||
return {
|
||||
**model_call_details,
|
||||
**redacted_params,
|
||||
"standard_logging_object": standard_logging_object_copy,
|
||||
}
|
||||
|
||||
async def get_proxy_server_request_from_cold_storage_with_object_key(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ This module is used to transform the request and response for the Voyage context
|
|||
This would be used for all the contextualized embeddings models in Voyage.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -24,7 +25,10 @@ class VoyageError(BaseLLMException):
|
|||
):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.request = httpx.Request(method="POST", url="https://api.voyageai.com/v1/contextualizedembeddings")
|
||||
self.request = httpx.Request(
|
||||
method="POST",
|
||||
url="https://api.voyageai.com/v1/contextualizedembeddings",
|
||||
)
|
||||
self.response = httpx.Response(status_code=status_code, request=self.request)
|
||||
super().__init__(
|
||||
status_code=status_code,
|
||||
|
|
@ -56,16 +60,16 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig):
|
|||
return api_base
|
||||
return "https://api.voyageai.com/v1/contextualizedembeddings"
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
def get_supported_openai_params(self, model: str) -> list: # mutable-ok: base class signature
|
||||
return ["encoding_format", "dimensions"]
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: dict,
|
||||
optional_params: dict,
|
||||
non_default_params: dict, # mutable-ok: base class signature
|
||||
optional_params: dict, # mutable-ok: base class signature
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict:
|
||||
) -> dict: # mutable-ok: base class signature
|
||||
"""
|
||||
Map OpenAI params to Voyage params
|
||||
|
||||
|
|
@ -79,7 +83,7 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig):
|
|||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
headers: dict, # mutable-ok: base class signature
|
||||
model: str,
|
||||
messages: list[AllMessageValues],
|
||||
optional_params: dict,
|
||||
|
|
@ -97,6 +101,8 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig):
|
|||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
|
||||
AUTO_CHUNK_SIZE: Final = 32000
|
||||
|
||||
def transform_embedding_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -105,11 +111,27 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig):
|
|||
headers: dict,
|
||||
) -> dict:
|
||||
return {
|
||||
"inputs": input,
|
||||
"inputs": [input] if isinstance(input, str) else input,
|
||||
"model": model,
|
||||
**self._auto_chunk_params(input, optional_params),
|
||||
**optional_params,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _auto_chunk_params(
|
||||
cls,
|
||||
input: AllEmbeddingInputValues | list[list[str]],
|
||||
optional_params: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
is_flat: Final = isinstance(input, str) or all(isinstance(item, str) for item in input)
|
||||
if not is_flat or optional_params.get("input_type") == "query":
|
||||
return {}
|
||||
return {
|
||||
"enable_auto_chunking": True,
|
||||
"chunk_size": cls.AUTO_CHUNK_SIZE,
|
||||
"input_type": "document",
|
||||
}
|
||||
|
||||
def transform_embedding_response(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -124,9 +146,11 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig):
|
|||
try:
|
||||
raw_response_json: Final = raw_response.json()
|
||||
except Exception:
|
||||
raise VoyageError(message=raw_response.text, status_code=raw_response.status_code)
|
||||
raise VoyageError(
|
||||
message=raw_response.text,
|
||||
status_code=raw_response.status_code,
|
||||
)
|
||||
|
||||
# model_response.usage
|
||||
model_response.model = raw_response_json.get("model")
|
||||
model_response.data = raw_response_json.get("data")
|
||||
model_response.object = raw_response_json.get("object")
|
||||
|
|
|
|||
|
|
@ -23,3 +23,4 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
|
|||
blocked_tools: list[str] | None = []
|
||||
search_tools: list[str] | None = []
|
||||
mcp_tool_search_enabled: bool | None = None
|
||||
skills: list[str] | None = None
|
||||
|
|
|
|||
|
|
@ -2048,18 +2048,44 @@ if MCP_AVAILABLE:
|
|||
return texts[0][1]
|
||||
return "\n\n---\n\n".join(f"[{lbl}]\n{txt}" for lbl, txt in texts)
|
||||
|
||||
async def _raise_if_initialize_grants_no_mcp_servers(
|
||||
allowed: Sequence[MCPServer],
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
mcp_servers: Sequence[str] | None,
|
||||
client_ip: str | None,
|
||||
) -> None:
|
||||
if allowed or user_api_key_auth is None or not user_api_key_auth.api_key:
|
||||
return
|
||||
if mcp_servers:
|
||||
await raise_denied_scoped_mcp_access(
|
||||
requested_names=mcp_servers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
no_servers_denial: Final[_McpDeniedDetail] = {
|
||||
"error": (
|
||||
"The key has no MCP servers granted, or none of its granted servers is loaded and allowed for "
|
||||
"this client IP. Grant servers or access groups to the key, its team, or its organization "
|
||||
"(object_permission.mcp_servers), check the server's allowed IPs, and reconnect."
|
||||
)
|
||||
}
|
||||
raise HTTPException(status_code=403, detail=no_servers_denial)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _gateway_initialize_instructions_request_scope(
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
mcp_servers: list[str] | None,
|
||||
client_ip: str | None,
|
||||
scoped_server_endpoint: bool = False,
|
||||
is_initialize: bool = False,
|
||||
) -> AsyncIterator[None]:
|
||||
allowed: Final = await _get_allowed_mcp_servers(
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
if is_initialize:
|
||||
await _raise_if_initialize_grants_no_mcp_servers(allowed, user_api_key_auth, mcp_servers, client_ip)
|
||||
if allowed:
|
||||
# return_exceptions=True: a per-server probe failure (incl. CancelledError
|
||||
# bubbled from anyio task group teardown on connection refused) must not
|
||||
|
|
@ -4683,6 +4709,7 @@ if MCP_AVAILABLE:
|
|||
mcp_servers,
|
||||
_client_ip,
|
||||
scoped_server_endpoint=scoped_server_endpoint,
|
||||
is_initialize=is_initialize,
|
||||
):
|
||||
await target_manager.handle_request(scope, receive, local_send)
|
||||
if use_stateful and session_id and scope.get("method") == "DELETE":
|
||||
|
|
@ -4819,6 +4846,7 @@ if MCP_AVAILABLE:
|
|||
mcp_servers,
|
||||
_sse_client_ip,
|
||||
scoped_server_endpoint=scoped_server_endpoint,
|
||||
is_initialize=scope.get("method") == "GET",
|
||||
):
|
||||
await sse_session_manager.handle_request(scope, receive, send)
|
||||
except MCPUpstreamAuthError as e:
|
||||
|
|
|
|||
|
|
@ -5384,7 +5384,7 @@
|
|||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Git source reference",
|
||||
"description": "Plugin source reference",
|
||||
"title": "Source",
|
||||
"type": "object"
|
||||
},
|
||||
|
|
@ -5411,7 +5411,7 @@
|
|||
"type": "object"
|
||||
},
|
||||
"RegisterPluginRequest": {
|
||||
"description": "Request body for registering a plugin in the marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket and referenced by their git source.",
|
||||
"description": "Request body for registering a plugin in the marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket or as a zip archive on any https host and referenced by their source.",
|
||||
"properties": {
|
||||
"author": {
|
||||
"anyOf": [
|
||||
|
|
@ -5509,7 +5509,7 @@
|
|||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Git source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}",
|
||||
"description": "Plugin source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n- Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}",
|
||||
"title": "Source",
|
||||
"type": "object"
|
||||
},
|
||||
|
|
@ -5653,7 +5653,7 @@
|
|||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Git source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}",
|
||||
"description": "Plugin source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n- Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}",
|
||||
"title": "Source",
|
||||
"type": "object"
|
||||
},
|
||||
|
|
@ -5721,8 +5721,26 @@
|
|||
"paths": {
|
||||
"/claude-code/marketplace.json": {
|
||||
"get": {
|
||||
"description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add <url>\n- claude plugin install <name>@<marketplace>\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin install my-plugin@litellm\n ```",
|
||||
"description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add <url>\n- claude plugin install <name>@<marketplace>\n\nWithout `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`\nthe key is authenticated and the catalog also holds the disabled plugins granted\nto it through `object_permission.skills` on the key or its team.\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin marketplace add \"http://localhost:4000/claude-code/marketplace.json?key=sk-...\"\n claude plugin install my-plugin@litellm\n ```",
|
||||
"operationId": "get_marketplace_claude_code_marketplace_json_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Key"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
|
@ -5731,6 +5749,16 @@
|
|||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Get Marketplace",
|
||||
|
|
@ -5788,7 +5816,7 @@
|
|||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
|
||||
"description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket or as a zip archive on any https host (e.g. S3).\nClaude Code clones the git source or downloads the archive when users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Plugin source reference (github, url, git-subdir, or archive format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
|
||||
"operationId": "register_plugin_claude_code_plugins_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
|
@ -5923,7 +5951,7 @@
|
|||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
|
||||
"description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Plugin source reference (github, url, git-subdir, or archive format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
|
||||
"operationId": "update_plugin_claude_code_plugins__plugin_name__put",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -497,6 +497,9 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/v1/messages/count_tokens",
|
||||
"/v1/skills",
|
||||
"/v1/skills/{skill_id}",
|
||||
"/claude-code/marketplace.json",
|
||||
"/claude-code/plugins",
|
||||
"/claude-code/plugins/{plugin_name}",
|
||||
]
|
||||
|
||||
# MCP tool-call / passthrough routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS.
|
||||
|
|
@ -1124,6 +1127,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
|
|||
models: list[str] | None = None
|
||||
search_tools: list[str] | None = None
|
||||
mcp_tool_search_enabled: bool | None = None
|
||||
skills: list[str] | None = None
|
||||
|
||||
|
||||
from litellm.models.team import BudgetLimitEntry as BudgetLimitEntry # noqa: E402
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
CLAUDE CODE MARKETPLACE
|
||||
|
||||
Provides a registry/discovery layer for Claude Code plugins.
|
||||
Plugins are stored as metadata + git source references in LiteLLM database.
|
||||
Actual plugin files are hosted on GitHub/GitLab/Bitbucket.
|
||||
Plugins are stored as metadata + source references in LiteLLM database.
|
||||
Actual plugin files are hosted on GitHub/GitLab/Bitbucket or as a zip archive on
|
||||
any HTTPS host (S3, Artifactory, a static file server).
|
||||
|
||||
Endpoints:
|
||||
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (unauthenticated)
|
||||
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (unauthenticated; `?key=` adds the key's granted skills)
|
||||
/claude-code/plugins - POST - Register a new plugin (create-only, proxy admin only)
|
||||
/claude-code/plugins - GET - List plugins (any authenticated key)
|
||||
/claude-code/plugins/{name} - GET - Get plugin details (any authenticated key)
|
||||
/claude-code/plugins - GET - List plugins visible to the key (enabled, plus granted disabled ones)
|
||||
/claude-code/plugins/{name} - GET - Get plugin details (403 on a disabled plugin the key is not granted)
|
||||
/claude-code/plugins/{name} - PUT - Update an existing plugin (proxy admin only)
|
||||
/claude-code/plugins/{name}/enable - POST - Enable a plugin (proxy admin only)
|
||||
/claude-code/plugins/{name}/disable - POST - Disable a plugin (proxy admin only)
|
||||
|
|
@ -21,12 +22,17 @@ import re
|
|||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Final, Protocol, TypedDict
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
|
||||
SkillVisibility,
|
||||
skill_visibility,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
|
||||
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
|
||||
|
|
@ -82,7 +88,7 @@ async def _get_prisma_client() -> object:
|
|||
"/claude-code/marketplace.json",
|
||||
tags=["Claude Code Marketplace"],
|
||||
)
|
||||
async def get_marketplace():
|
||||
async def get_marketplace(request: Request, key: str | None = None):
|
||||
"""
|
||||
Serve marketplace.json for Claude Code plugin discovery.
|
||||
|
||||
|
|
@ -90,24 +96,35 @@ async def get_marketplace():
|
|||
- claude plugin marketplace add <url>
|
||||
- claude plugin install <name>@<marketplace>
|
||||
|
||||
Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`
|
||||
the key is authenticated and the catalog also holds the disabled plugins granted
|
||||
to it through `object_permission.skills` on the key or its team.
|
||||
|
||||
Returns:
|
||||
Marketplace catalog with list of available plugins and their git sources.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json
|
||||
claude plugin marketplace add "http://localhost:4000/claude-code/marketplace.json?key=sk-..."
|
||||
claude plugin install my-plugin@litellm
|
||||
```
|
||||
"""
|
||||
try:
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
caller: Final[UserAPIKeyAuth | None] = (
|
||||
await user_api_key_auth(request=request, api_key=f"Bearer {key}") if key else None
|
||||
)
|
||||
visibility: Final[SkillVisibility] = skill_visibility(caller)
|
||||
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
|
||||
where={"enabled": True}
|
||||
where=visibility.where()
|
||||
)
|
||||
|
||||
plugin_list: Final = []
|
||||
for plugin in plugins:
|
||||
if not visibility.allows(plugin):
|
||||
continue
|
||||
try:
|
||||
manifest: Mapping[str, object] = json.loads(plugin.manifest_json or "{}")
|
||||
except json.JSONDecodeError:
|
||||
|
|
@ -147,7 +164,7 @@ async def get_marketplace():
|
|||
|
||||
return JSONResponse(content=marketplace)
|
||||
|
||||
except HTTPException:
|
||||
except (HTTPException, ProxyException):
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error generating marketplace: %s", e)
|
||||
|
|
@ -162,6 +179,15 @@ async def get_marketplace():
|
|||
# alphanumeric characters, dots, hyphens, and underscores.
|
||||
# This implicitly blocks '..', leading '/', backslashes, and percent-encoded sequences.
|
||||
_VALID_GIT_SUBDIR_PATH_RE: Final = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*(/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$")
|
||||
_VALID_SHA256_RE: Final = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
|
||||
|
||||
def _is_https_url_with_host(url: str) -> bool:
|
||||
try:
|
||||
parts: Final = urlsplit(url)
|
||||
except ValueError:
|
||||
return False
|
||||
return parts.scheme == "https" and bool(parts.hostname)
|
||||
|
||||
|
||||
def _validate_plugin_source(source: Mapping[str, str]) -> None:
|
||||
|
|
@ -199,10 +225,24 @@ def _validate_plugin_source(source: Mapping[str, str]) -> None:
|
|||
"error": "git-subdir 'path' must be a relative path of the form 'segment/segment' (alphanumeric, dots, hyphens, underscores only)"
|
||||
},
|
||||
)
|
||||
elif source_type == "archive":
|
||||
if not _is_https_url_with_host(source.get("url", "")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "archive source must include an https 'url' field "
|
||||
"(e.g., 'https://bucket.s3.amazonaws.com/plugins/plugin-name.zip')"
|
||||
},
|
||||
)
|
||||
if "sha256" in source and not _VALID_SHA256_RE.match(source["sha256"]):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "archive 'sha256' must be a 64-character hex digest"},
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "source.source must be 'github', 'url', or 'git-subdir'"},
|
||||
detail={"error": "source.source must be 'github', 'url', 'git-subdir', or 'archive'"},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -248,8 +288,8 @@ async def register_plugin(
|
|||
Register a new plugin in the LiteLLM marketplace.
|
||||
|
||||
LiteLLM acts as a registry/discovery layer. Plugins are hosted on
|
||||
GitHub/GitLab/Bitbucket. Claude Code will clone from the git source
|
||||
when users install.
|
||||
GitHub/GitLab/Bitbucket or as a zip archive on any https host (e.g. S3).
|
||||
Claude Code clones the git source or downloads the archive when users install.
|
||||
|
||||
This endpoint is create-only and never overwrites. If a plugin with
|
||||
the same name already exists it returns 409 Conflict; use
|
||||
|
|
@ -259,7 +299,7 @@ async def register_plugin(
|
|||
|
||||
Parameters:
|
||||
- name: Plugin name (kebab-case)
|
||||
- source: Git source reference (github, url, or git-subdir format)
|
||||
- source: Plugin source reference (github, url, git-subdir, or archive format)
|
||||
- version: Semantic version (optional)
|
||||
- description: Plugin description (optional)
|
||||
- author: Author information (optional)
|
||||
|
|
@ -370,13 +410,15 @@ async def list_plugins(
|
|||
try:
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
where: Final = {"enabled": True} if enabled_only else {}
|
||||
visibility: Final[SkillVisibility] = skill_visibility(user_api_key_dict)
|
||||
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
|
||||
where=where
|
||||
where={"enabled": True} if enabled_only else visibility.where()
|
||||
)
|
||||
|
||||
plugin_list: Final = []
|
||||
for p in plugins:
|
||||
if not visibility.allows(p):
|
||||
continue
|
||||
# Parse manifest to get additional fields
|
||||
manifest = json.loads(p.manifest_json) if p.manifest_json else {}
|
||||
|
||||
|
|
@ -448,6 +490,12 @@ async def get_plugin(
|
|||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
)
|
||||
|
||||
if not skill_visibility(user_api_key_dict).allows(plugin):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"error": f"Plugin '{plugin_name}' is not granted to this key"},
|
||||
)
|
||||
|
||||
manifest: Final[Mapping[str, object]] = json.loads(plugin.manifest_json or "{}") if plugin.manifest_json else {}
|
||||
|
||||
return {
|
||||
|
|
@ -503,7 +551,7 @@ async def update_plugin(
|
|||
|
||||
Parameters:
|
||||
- plugin_name: Name of the plugin to update (path parameter)
|
||||
- source: Git source reference (github, url, or git-subdir format)
|
||||
- source: Plugin source reference (github, url, git-subdir, or archive format)
|
||||
- version: Semantic version (optional)
|
||||
- description: Plugin description (optional)
|
||||
- author: Author information (optional)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
Claude Code marketplace visibility: enabled plugins are public, disabled plugins
|
||||
are private and resolve only for proxy admins or keys granted them via
|
||||
``object_permission.skills``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma.types import LiteLLM_ClaudeCodePluginTableWhereInput
|
||||
|
||||
|
||||
class _SkillRecord(Protocol):
|
||||
name: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
def _skills_of(permission: LiteLLM_ObjectPermissionTable | None) -> frozenset[str]:
|
||||
return frozenset(permission.skills or ()) if permission is not None else frozenset()
|
||||
|
||||
|
||||
def granted_skills(user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]:
|
||||
"""Key grant intersected with the team grant when both are non-empty; either alone applies as is.
|
||||
|
||||
An empty list is the Prisma column default for every object-permission row, so it means
|
||||
"no private grants configured here" and defers to the other scope, same as the agents check.
|
||||
"""
|
||||
key_skills: Final = _skills_of(user_api_key_dict.object_permission)
|
||||
team_skills: Final = _skills_of(user_api_key_dict.team_object_permission)
|
||||
match (bool(key_skills), bool(team_skills)):
|
||||
case (True, True):
|
||||
return key_skills & team_skills
|
||||
case (True, False):
|
||||
return key_skills
|
||||
case _:
|
||||
return team_skills
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillVisibility:
|
||||
granted: frozenset[str]
|
||||
sees_private: bool
|
||||
|
||||
def allows(self, skill: _SkillRecord) -> bool:
|
||||
return skill.enabled or self.sees_private or skill.name in self.granted
|
||||
|
||||
def where(self) -> "LiteLLM_ClaudeCodePluginTableWhereInput":
|
||||
if self.sees_private:
|
||||
return {}
|
||||
if not self.granted:
|
||||
return {"enabled": True}
|
||||
return {"OR": [{"enabled": True}, {"name": {"in": sorted(self.granted)}}]}
|
||||
|
||||
|
||||
PUBLIC_ONLY: Final = SkillVisibility(granted=frozenset(), sees_private=False)
|
||||
|
||||
|
||||
def skill_visibility(user_api_key_dict: UserAPIKeyAuth | None) -> SkillVisibility:
|
||||
if user_api_key_dict is None:
|
||||
return PUBLIC_ONLY
|
||||
if is_proxy_admin(user_api_key_dict):
|
||||
return SkillVisibility(granted=frozenset(), sees_private=True)
|
||||
return SkillVisibility(granted=granted_skills(user_api_key_dict), sees_private=False)
|
||||
|
|
@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
|
|||
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
|
||||
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
|
||||
mcp_tool_search_enabled Boolean?
|
||||
skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
|
||||
teams LiteLLM_TeamTable[]
|
||||
projects LiteLLM_ProjectTable[]
|
||||
verification_tokens LiteLLM_VerificationToken[]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import secrets
|
|||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime as dt
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, Protocol, cast, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -862,7 +863,7 @@ _SENSITIVE_REQUEST_BODY_KEYS: Final = frozenset({"secret_fields"})
|
|||
|
||||
|
||||
def _sanitize_request_body_for_spend_logs_payload(
|
||||
request_body: dict,
|
||||
request_body: Mapping[str, object],
|
||||
visited: set | None = None,
|
||||
max_string_length_prompt_in_db: int | None = None,
|
||||
) -> dict:
|
||||
|
|
@ -1255,7 +1256,7 @@ def _get_proxy_server_request_for_spend_logs_payload(
|
|||
|
||||
standard_payload: Final = (kwargs or EMPTY_MAPPING).get("standard_logging_object")
|
||||
if isinstance(standard_payload, Mapping):
|
||||
_request_body = {**_request_body, **classifier_audit_fields(standard_payload)}
|
||||
_request_body = MappingProxyType({**_request_body, **classifier_audit_fields(standard_payload)})
|
||||
|
||||
if kwargs is not None:
|
||||
realtime_tools: Final = kwargs.get("realtime_tools")
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
|
|||
blocked_tools: list[str] | None = None,
|
||||
mcp_toolsets: list[str] | None = None,
|
||||
search_tools: list[str] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
) -> LiteLLM_ObjectPermissionTable:
|
||||
"""Create a new object permission record."""
|
||||
data: Final[dict[str, Any]] = {}
|
||||
|
|
@ -63,6 +64,8 @@ class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
|
|||
data["mcp_toolsets"] = mcp_toolsets
|
||||
if search_tools is not None:
|
||||
data["search_tools"] = search_tools
|
||||
if skills is not None:
|
||||
data["skills"] = skills
|
||||
|
||||
return await self.create(data)
|
||||
|
||||
|
|
@ -79,6 +82,7 @@ class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
|
|||
blocked_tools: list[str] | None = None,
|
||||
mcp_toolsets: list[str] | None = None,
|
||||
search_tools: list[str] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
) -> LiteLLM_ObjectPermissionTable | None:
|
||||
"""Update an object permission record."""
|
||||
data: Final[dict[str, Any]] = {}
|
||||
|
|
@ -102,6 +106,8 @@ class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
|
|||
data["mcp_toolsets"] = mcp_toolsets
|
||||
if search_tools is not None:
|
||||
data["search_tools"] = search_tools
|
||||
if skills is not None:
|
||||
data["skills"] = skills
|
||||
|
||||
return await self.update(object_permission_id, data, id_field="object_permission_id")
|
||||
|
||||
|
|
|
|||
|
|
@ -2064,7 +2064,9 @@ class ComplexityRouter(CustomLogger):
|
|||
payload: Final = (
|
||||
self._native_classifier_payload(messages_for_call, response_format, encrypted_task)
|
||||
if encrypted_task is not None
|
||||
else {"messages": messages_for_call, "response_format": response_format, **classifier_call_params}
|
||||
else MappingProxyType(
|
||||
{"messages": messages_for_call, "response_format": response_format, **classifier_call_params}
|
||||
)
|
||||
)
|
||||
proxy_server_request: Final = {
|
||||
"originating_request_masked": masked_originating_request(request_kwargs),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ can adopt the type without violating the SDK-must-not-import-from-proxy
|
|||
layering rule.
|
||||
"""
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
|
||||
class ObjectPermissionDict(TypedDict, total=False):
|
||||
|
|
@ -23,3 +23,4 @@ class ObjectPermissionDict(TypedDict, total=False):
|
|||
models: list[str] | None
|
||||
search_tools: list[str] | None
|
||||
mcp_tool_search_enabled: bool | None
|
||||
skills: ReadOnly[list[str] | None]
|
||||
|
|
|
|||
|
|
@ -25,10 +25,12 @@ class PluginSpec(BaseModel):
|
|||
source: dict[str, str] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Git source reference. Supported formats:\n"
|
||||
"Plugin source reference. Supported formats:\n"
|
||||
"- GitHub: {'source': 'github', 'repo': 'org/repo'}\n"
|
||||
"- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n"
|
||||
"- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}"
|
||||
"- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n"
|
||||
"- Zip archive on any https host (e.g. S3): "
|
||||
"{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}"
|
||||
),
|
||||
)
|
||||
version: str | None = Field("1.0.0", description="Semantic version")
|
||||
|
|
@ -46,7 +48,7 @@ class RegisterPluginRequest(PluginSpec):
|
|||
Request body for registering a plugin in the marketplace.
|
||||
|
||||
LiteLLM acts as a registry/discovery layer. Plugins are hosted on
|
||||
GitHub/GitLab/Bitbucket and referenced by their git source.
|
||||
GitHub/GitLab/Bitbucket or as a zip archive on any https host and referenced by their source.
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
|
|
@ -76,7 +78,7 @@ class PluginResponse(BaseModel):
|
|||
name: str = Field(..., description="Plugin name")
|
||||
version: str | None = Field(None, description="Plugin version")
|
||||
description: str | None = Field(None, description="Plugin description")
|
||||
source: dict[str, str] = Field(..., description="Git source reference")
|
||||
source: dict[str, str] = Field(..., description="Plugin source reference")
|
||||
enabled: bool = Field(..., description="Whether plugin is enabled")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
|
|||
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
|
||||
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
|
||||
mcp_tool_search_enabled Boolean?
|
||||
skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
|
||||
teams LiteLLM_TeamTable[]
|
||||
projects LiteLLM_ProjectTable[]
|
||||
verification_tokens LiteLLM_VerificationToken[]
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ class TestVoyageContextualEmbeddings:
|
|||
|
||||
# Test contextual model detection
|
||||
assert config.is_contextualized_embeddings("voyage-context-3") is True
|
||||
assert config.is_contextualized_embeddings("voyage-context-4") is True
|
||||
assert config.is_contextualized_embeddings("voyage-context-2") is True
|
||||
assert config.is_contextualized_embeddings("context-model") is True
|
||||
|
||||
|
|
|
|||
|
|
@ -510,6 +510,37 @@ async def _call(session: ClientSession, tool_id: str, a: int = 3, b: int = 4) ->
|
|||
return await session.call_tool("call_tool", arguments={"tool_id": tool_id, "arguments": {"a": a, "b": b}})
|
||||
|
||||
|
||||
async def _raw_rpc(
|
||||
proxy_server_url: str, key: str | None, method: str, params: dict[str, object], **headers: str
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient() as client:
|
||||
return await client.post(
|
||||
f"{proxy_server_url}/mcp/proxy",
|
||||
headers={
|
||||
"Accept": "application/json, text/event-stream",
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**headers,
|
||||
},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
|
||||
)
|
||||
|
||||
|
||||
async def _raw_initialize(proxy_server_url: str, key: str | None) -> httpx.Response:
|
||||
return await _raw_rpc(
|
||||
proxy_server_url,
|
||||
key,
|
||||
"initialize",
|
||||
{"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "auth-test", "version": "1"}},
|
||||
)
|
||||
|
||||
|
||||
def _rpc_result(response: httpx.Response) -> dict[str, typing.Any]:
|
||||
if response.headers["content-type"].startswith("text/event-stream"):
|
||||
data_line = next(line for line in response.text.splitlines() if line.startswith("data:"))
|
||||
return json.loads(data_line.removeprefix("data:"))["result"]
|
||||
return response.json()["result"]
|
||||
|
||||
|
||||
def _assert_unauthorized(result: CallToolResult) -> None:
|
||||
assert result.isError is True
|
||||
assert result.content[0].text == "Unknown or unauthorized tool_id"
|
||||
|
|
@ -527,13 +558,31 @@ class TestProxyMcpAuthorizationScope:
|
|||
_assert_unauthorized(await _call(ungranted, restricted_id))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_mcp_servers_sentinel_hides_every_tool(self, proxy_server_url: str) -> None:
|
||||
async def test_no_mcp_servers_sentinel_rejects_initialize_and_hides_every_tool(self, proxy_server_url: str) -> None:
|
||||
async with _scoped_session(proxy_server_url) as granted:
|
||||
tool_id = (await _search(granted, "add"))["math_stdio-add"]
|
||||
async with _scoped_session(proxy_server_url, "sk-none") as session:
|
||||
assert await _search(session, "add") == {}
|
||||
_assert_unauthorized(await session.call_tool("get_tool_schema", {"tool_id": tool_id}))
|
||||
_assert_unauthorized(await _call(session, tool_id))
|
||||
response = await _raw_initialize(proxy_server_url, "sk-none")
|
||||
assert response.status_code == 403, response.text
|
||||
assert "no MCP servers granted" in response.json()["detail"]["error"]
|
||||
|
||||
async def raw_call(name: str, arguments: dict[str, object]) -> dict[str, typing.Any]:
|
||||
call = await _raw_rpc(proxy_server_url, "sk-none", "tools/call", {"name": name, "arguments": arguments})
|
||||
assert call.status_code == 200, call.text
|
||||
return _rpc_result(call)
|
||||
|
||||
listed = await _raw_rpc(proxy_server_url, "sk-none", "tools/list", {})
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert {tool["name"] for tool in _rpc_result(listed)["tools"]} == {"search_tools", "get_tool_schema", "call_tool"}
|
||||
search = await raw_call("search_tools", {"query": "add"})
|
||||
assert search["isError"] is False, search
|
||||
assert json.loads(search["content"][0]["text"]) == []
|
||||
for name, arguments in (
|
||||
("get_tool_schema", {"tool_id": tool_id}),
|
||||
("call_tool", {"tool_id": tool_id, "arguments": {"a": 3, "b": 4}}),
|
||||
):
|
||||
denied = await raw_call(name, arguments)
|
||||
assert denied["isError"] is True, denied
|
||||
assert denied["content"][0]["text"] == "Unknown or unauthorized tool_id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_grant_hides_ungranted_tools_and_blocks_their_ids(self, proxy_server_url: str) -> None:
|
||||
|
|
@ -581,24 +630,7 @@ class TestProxyMcpAuthorizationScope:
|
|||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("key", [None, "sk-invalid"])
|
||||
async def test_missing_or_invalid_key_cannot_initialize(self, proxy_server_url: str, key: str | None) -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{proxy_server_url}/mcp/proxy",
|
||||
headers={
|
||||
"Accept": "application/json, text/event-stream",
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "auth-test", "version": "1"},
|
||||
},
|
||||
},
|
||||
)
|
||||
response = await _raw_initialize(proxy_server_url, key)
|
||||
assert response.status_code == 401, response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -646,25 +678,28 @@ class TestProxyMcpAuthorizationScope:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_scope_exception_returns_iserror_and_emits_failure_log(self, proxy_server_url: str) -> None:
|
||||
async with _scoped_session(
|
||||
response = await _raw_rpc(
|
||||
proxy_server_url,
|
||||
"sk-none",
|
||||
"tools/call",
|
||||
{"name": "call_tool", "arguments": {"tool_id": "denied-scope", "arguments": {}}},
|
||||
**{"x-mcp-servers": "math_restricted", "x-litellm-call-id": "proxy-scope-denial"},
|
||||
) as session:
|
||||
result = await session.call_tool("call_tool", {"tool_id": "denied-scope", "arguments": {}})
|
||||
assert result.isError is True
|
||||
assert result.content[0].text == (
|
||||
"Error: The key is not allowed to access the requested MCP servers: math_restricted"
|
||||
)
|
||||
async with asyncio.timeout(10):
|
||||
while True:
|
||||
payload = json.loads(await asyncio.to_thread(proxy_call_recorder.failures.get, True, 5))
|
||||
if payload["id"] == "proxy-scope-denial":
|
||||
break
|
||||
assert payload["call_type"] == "call_mcp_tool"
|
||||
assert payload["status"] == "failure"
|
||||
assert payload["response_cost"] == 0
|
||||
assert "math_restricted" in payload["error_str"]
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
result = _rpc_result(response)
|
||||
assert result["isError"] is True
|
||||
assert result["content"][0]["text"] == (
|
||||
"Error: The key is not allowed to access the requested MCP servers: math_restricted"
|
||||
)
|
||||
async with asyncio.timeout(10):
|
||||
while True:
|
||||
payload = json.loads(await asyncio.to_thread(proxy_call_recorder.failures.get, True, 5))
|
||||
if payload["id"] == "proxy-scope-denial":
|
||||
break
|
||||
assert payload["call_type"] == "call_mcp_tool"
|
||||
assert payload["status"] == "failure"
|
||||
assert payload["response_cost"] == 0
|
||||
assert "math_restricted" in payload["error_str"]
|
||||
|
||||
@pytest.mark.parametrize("arguments", ["wrong", False, None, [], 0])
|
||||
def test_handler_rejects_non_object_arguments(
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ async def test_get_marketplace(mock_prisma_client):
|
|||
)
|
||||
|
||||
# Now get the marketplace
|
||||
response = await get_marketplace()
|
||||
response = await get_marketplace(request=MagicMock())
|
||||
|
||||
# Response is a JSONResponse, get the body
|
||||
body = json.loads(response.body.decode())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestVoyageContextualEmbeddings:
|
||||
def test_contextual_model_detection(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
assert VoyageContextualEmbeddingConfig.is_contextualized_embeddings("voyage-context-3")
|
||||
assert VoyageContextualEmbeddingConfig.is_contextualized_embeddings("voyage-context-4")
|
||||
assert not VoyageContextualEmbeddingConfig.is_contextualized_embeddings("voyage-3-lite")
|
||||
|
||||
def test_url_generation(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
assert (
|
||||
config.get_complete_url(None, None, "voyage-context-4", {}, {})
|
||||
== "https://api.voyageai.com/v1/contextualizedembeddings"
|
||||
)
|
||||
assert (
|
||||
config.get_complete_url("https://custom.api.com", None, "voyage-context-4", {}, {})
|
||||
== "https://custom.api.com/contextualizedembeddings"
|
||||
)
|
||||
assert (
|
||||
config.get_complete_url(
|
||||
"https://custom.api.com/contextualizedembeddings",
|
||||
None,
|
||||
"voyage-context-4",
|
||||
{},
|
||||
{},
|
||||
)
|
||||
== "https://custom.api.com/contextualizedembeddings"
|
||||
)
|
||||
|
||||
def test_get_supported_openai_params(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
assert config.get_supported_openai_params("voyage-context-4") == [
|
||||
"encoding_format",
|
||||
"dimensions",
|
||||
]
|
||||
|
||||
def test_map_openai_params(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
result = config.map_openai_params(
|
||||
{"encoding_format": "float", "dimensions": 512}, {}, "voyage-context-4", False
|
||||
)
|
||||
assert result["encoding_format"] == "float"
|
||||
assert result["output_dimension"] == 512
|
||||
|
||||
def test_validate_environment_with_api_key(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
headers = config.validate_environment(
|
||||
{}, "voyage-context-4", [], {}, {}, api_key="test-key"
|
||||
)
|
||||
assert headers == {"Authorization": "Bearer test-key"}
|
||||
|
||||
def test_validate_environment_secret_fallback(self, monkeypatch):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "secret-key")
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
headers = config.validate_environment(
|
||||
{}, "voyage-context-4", [], {}, {}, api_key=None
|
||||
)
|
||||
assert headers == {"Authorization": "Bearer secret-key"}
|
||||
|
||||
def test_nested_list_passthrough(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
nested = [["Hello", "world"], ["Test"]]
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4", nested, {}, {}
|
||||
)
|
||||
assert transformed["inputs"] == nested
|
||||
assert transformed["model"] == "voyage-context-4"
|
||||
assert "enable_auto_chunking" not in transformed
|
||||
|
||||
def test_flat_list_str_auto_chunked(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4", ["Hello", "world"], {}, {}
|
||||
)
|
||||
assert transformed["inputs"] == ["Hello", "world"]
|
||||
assert transformed["enable_auto_chunking"] is True
|
||||
assert transformed["chunk_size"] == 32000
|
||||
assert transformed["input_type"] == "document"
|
||||
|
||||
def test_flat_list_str_query_no_auto_chunk(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4", ["Hello", "world"], {"input_type": "query"}, {}
|
||||
)
|
||||
assert transformed["inputs"] == ["Hello", "world"]
|
||||
assert transformed["input_type"] == "query"
|
||||
assert "enable_auto_chunking" not in transformed
|
||||
|
||||
def test_flat_list_str_document_preserves_input_type(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4", ["Hello"], {"input_type": "document"}, {}
|
||||
)
|
||||
assert transformed["input_type"] == "document"
|
||||
assert transformed["enable_auto_chunking"] is True
|
||||
|
||||
def test_flat_list_str_caller_chunk_params_win(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4",
|
||||
["Hello", "world"],
|
||||
{"input_type": "document", "chunk_size": 512, "chunk_overlap": 32},
|
||||
{},
|
||||
)
|
||||
assert transformed["enable_auto_chunking"] is True
|
||||
assert transformed["chunk_size"] == 512
|
||||
assert transformed["chunk_overlap"] == 32
|
||||
assert transformed["input_type"] == "document"
|
||||
|
||||
def test_flat_list_str_caller_can_disable_auto_chunking(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4", ["Hello"], {"enable_auto_chunking": False}, {}
|
||||
)
|
||||
assert transformed["enable_auto_chunking"] is False
|
||||
assert transformed["input_type"] == "document"
|
||||
|
||||
def test_nested_list_keeps_caller_params(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4", [["Hello", "world"]], {"input_type": "document", "output_dimension": 512}, {}
|
||||
)
|
||||
assert transformed == {
|
||||
"inputs": [["Hello", "world"]],
|
||||
"model": "voyage-context-4",
|
||||
"input_type": "document",
|
||||
"output_dimension": 512,
|
||||
}
|
||||
|
||||
def test_single_string_auto_chunked(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4", "Hello", {}, {}
|
||||
)
|
||||
assert transformed["inputs"] == ["Hello"]
|
||||
assert transformed["enable_auto_chunking"] is True
|
||||
assert transformed["input_type"] == "document"
|
||||
|
||||
def test_single_string_query_no_auto_chunk(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
transformed = config.transform_embedding_request(
|
||||
"voyage-context-4", "Hello", {"input_type": "query"}, {}
|
||||
)
|
||||
assert transformed["inputs"] == ["Hello"]
|
||||
assert transformed["input_type"] == "query"
|
||||
assert "enable_auto_chunking" not in transformed
|
||||
|
||||
def test_response_transformation(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
)
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
response_payload = {
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "embedding": [0.1, 0.2], "index": 0}],
|
||||
"model": "voyage-context-4",
|
||||
"usage": {"total_tokens": 24},
|
||||
}
|
||||
raw_response = MagicMock()
|
||||
raw_response.json.return_value = response_payload
|
||||
raw_response.status_code = 200
|
||||
raw_response.text = json.dumps(response_payload)
|
||||
|
||||
model_response = EmbeddingResponse()
|
||||
transformed = config.transform_embedding_response(
|
||||
"voyage-context-4", raw_response, model_response, MagicMock()
|
||||
)
|
||||
assert transformed.model == "voyage-context-4"
|
||||
assert transformed.object == "list"
|
||||
assert transformed.data == response_payload["data"]
|
||||
assert transformed.usage.prompt_tokens == 24
|
||||
assert transformed.usage.total_tokens == 24
|
||||
|
||||
def test_error_response_and_error_class(self):
|
||||
from litellm.llms.voyage.embedding.transformation_contextual import (
|
||||
VoyageContextualEmbeddingConfig,
|
||||
VoyageError,
|
||||
)
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
|
||||
config = VoyageContextualEmbeddingConfig()
|
||||
raw_response = MagicMock()
|
||||
raw_response.json.side_effect = ValueError("not json")
|
||||
raw_response.status_code = 400
|
||||
raw_response.text = "bad request"
|
||||
|
||||
with pytest.raises(VoyageError) as exc_info:
|
||||
config.transform_embedding_response(
|
||||
"voyage-context-4", raw_response, EmbeddingResponse(), MagicMock()
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.message == "bad request"
|
||||
|
||||
error = config.get_error_class("rate limited", 429, {"x-test": "1"})
|
||||
assert isinstance(error, VoyageError)
|
||||
assert error.status_code == 429
|
||||
assert error.message == "rate limited"
|
||||
|
|
@ -2003,6 +2003,11 @@ async def test_mcp_routing_chunked_initialize_to_stateful():
|
|||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server.set_auth_context",
|
||||
),
|
||||
patch( # test-quality-ok: registry is empty in unit tests; key owns one server
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[MagicMock()],
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED",
|
||||
True,
|
||||
|
|
@ -2488,6 +2493,11 @@ async def test_initialize_request_tracks_active_session_after_response_header():
|
|||
new_callable=AsyncMock,
|
||||
return_value=(owner_auth, None, None, None, None, None),
|
||||
),
|
||||
patch( # test-quality-ok: registry is empty in unit tests; key owns one server
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[MagicMock()],
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED",
|
||||
True,
|
||||
|
|
@ -2600,6 +2610,11 @@ async def test_initialize_request_with_existing_session_tracks_new_session():
|
|||
{"x-new-header": "new"},
|
||||
),
|
||||
),
|
||||
patch( # test-quality-ok: registry is empty in unit tests; key owns one server
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[MagicMock()],
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED",
|
||||
True,
|
||||
|
|
@ -5614,6 +5629,78 @@ class TestGatewayCreateInitializationOptions:
|
|||
|
||||
assert server.create_initialization_options().server_name == "litellm-mcp-server"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_no_granted_servers_returns_403(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_gateway_initialize_instructions_request_scope,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
with patch( # test-quality-ok: grant resolution is the input under test
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
async with _gateway_initialize_instructions_request_scope(
|
||||
user_api_key_auth=UserAPIKeyAuth(api_key="sk-no-mcp"),
|
||||
mcp_servers=None,
|
||||
client_ip=None,
|
||||
is_initialize=True,
|
||||
):
|
||||
pytest.fail("initialize must not proceed when the key grants no MCP servers")
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "no MCP servers granted" in exc_info.value.detail["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_no_granted_scoped_servers_returns_scoped_denial(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_gateway_initialize_instructions_request_scope,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
with patch( # test-quality-ok: grant resolution is the input under test
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
async with _gateway_initialize_instructions_request_scope(
|
||||
user_api_key_auth=UserAPIKeyAuth(api_key="sk-no-mcp"),
|
||||
mcp_servers=["grafana"],
|
||||
client_ip=None,
|
||||
is_initialize=True,
|
||||
):
|
||||
pytest.fail("scoped initialize must not proceed when nothing resolves")
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "grafana" in exc_info.value.detail["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_initialize_request_with_no_granted_servers_is_not_rejected_here(self):
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_gateway_initialize_instructions_request_scope,
|
||||
_mcp_gateway_initialize_instructions,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
with patch( # test-quality-ok: grant resolution is the input under test
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
async with _gateway_initialize_instructions_request_scope(
|
||||
user_api_key_auth=UserAPIKeyAuth(api_key="sk-no-mcp"),
|
||||
mcp_servers=None,
|
||||
client_ip=None,
|
||||
):
|
||||
assert _mcp_gateway_initialize_instructions.get() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_handler_scopes_server_name_from_single_server_path(self):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -750,8 +750,7 @@ async def test_admitted_subject_missing_stored_token_challenged_with_resource_me
|
|||
challenge = exc_info.value.headers["www-authenticate"]
|
||||
assert "authorization_uri=" not in challenge
|
||||
assert challenge == (
|
||||
'Bearer resource_metadata="http://localhost:8000'
|
||||
'/.well-known/oauth-protected-resource/mcp/repro_oauth_server"'
|
||||
'Bearer resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp/repro_oauth_server"'
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -938,6 +937,11 @@ async def test_handle_streamable_http_mcp_delegated_server_surfaces_upstream_cha
|
|||
"litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name",
|
||||
return_value=delegated_server,
|
||||
),
|
||||
patch( # test-quality-ok: registry is empty in unit tests; key owns the delegated server
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[delegated_server],
|
||||
),
|
||||
patch.object(
|
||||
session_manager_stateful,
|
||||
"handle_request",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Unit tests for claude_code_marketplace.py source validation.
|
||||
|
||||
Covers the git-subdir source type added alongside the existing github and url types.
|
||||
Covers the git-subdir and archive source types added alongside the existing github and url types.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -11,17 +11,20 @@ from fastapi import HTTPException
|
|||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import LitellmUserRoles
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
RegisterPluginRequest,
|
||||
UpdatePluginRequest,
|
||||
)
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import claude_code_marketplace
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
delete_plugin,
|
||||
disable_plugin,
|
||||
enable_plugin,
|
||||
get_marketplace,
|
||||
get_plugin,
|
||||
list_plugins,
|
||||
register_plugin,
|
||||
update_plugin,
|
||||
)
|
||||
|
|
@ -38,11 +41,15 @@ def _make_mock_prisma():
|
|||
async def _find_unique(where):
|
||||
return store.get(where.get("name"))
|
||||
|
||||
def _matches(record, where) -> bool:
|
||||
if "OR" in where:
|
||||
return any(_matches(record, clause) for clause in where["OR"])
|
||||
if "enabled" in where and record.enabled != where["enabled"]:
|
||||
return False
|
||||
return "name" not in where or record.name in where["name"]["in"]
|
||||
|
||||
async def _find_many(where=None):
|
||||
records = list(store.values())
|
||||
if where and "enabled" in where:
|
||||
return [r for r in records if r.enabled == where["enabled"]]
|
||||
return records
|
||||
return [r for r in store.values() if _matches(r, where or {})]
|
||||
|
||||
async def _create(data):
|
||||
record = MagicMock()
|
||||
|
|
@ -52,6 +59,8 @@ def _make_mock_prisma():
|
|||
record.description = data.get("description")
|
||||
record.manifest_json = data.get("manifest_json", "{}")
|
||||
record.enabled = data.get("enabled", True)
|
||||
record.created_at = data.get("created_at")
|
||||
record.updated_at = data.get("updated_at")
|
||||
store[data["name"]] = record
|
||||
return record
|
||||
|
||||
|
|
@ -87,6 +96,12 @@ _GIT_SUBDIR_SOURCE = {
|
|||
"path": "plugins/my-plugin",
|
||||
}
|
||||
|
||||
_ARCHIVE_SOURCE = {
|
||||
"source": "archive",
|
||||
"url": "https://skills-bucket.s3.us-east-1.amazonaws.com/plugins/s3-skill-1.0.0.zip",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_proxy_globals(monkeypatch):
|
||||
|
|
@ -265,13 +280,89 @@ async def test_get_marketplace_skips_plugin_with_null_manifest():
|
|||
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
|
||||
await table.create(data={"name": "null-manifest-plugin", "manifest_json": None, "enabled": True})
|
||||
|
||||
response = await get_marketplace()
|
||||
response = await get_marketplace(request=MagicMock())
|
||||
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
assert [plugin["name"] for plugin in body["plugins"]] == ["good-plugin"]
|
||||
|
||||
|
||||
def _granted_user(skills: list[str]) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
api_key="sk-granted",
|
||||
user_id="granted-user",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="perm-1", skills=skills),
|
||||
)
|
||||
|
||||
|
||||
async def _register_public_and_private_plugins() -> None:
|
||||
for name, enabled in (("public-skill", True), ("private-skill", False)):
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
if not enabled:
|
||||
await disable_plugin(plugin_name=name, user_api_key_dict=_USER)
|
||||
|
||||
|
||||
async def _listed_names(user: UserAPIKeyAuth) -> set[str]:
|
||||
response = await list_plugins(user_api_key_dict=user)
|
||||
return {plugin.name for plugin in response.plugins}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_plugins_shows_disabled_plugin_only_to_granted_key_or_admin():
|
||||
await _register_public_and_private_plugins()
|
||||
|
||||
assert await _listed_names(_NON_ADMIN_USER) == {"public-skill"}
|
||||
assert await _listed_names(_granted_user(["other-skill"])) == {"public-skill"}
|
||||
assert await _listed_names(_granted_user(["private-skill"])) == {"public-skill", "private-skill"}
|
||||
assert await _listed_names(_USER) == {"public-skill", "private-skill"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_plugin_returns_403_for_disabled_plugin_the_key_is_not_granted():
|
||||
await _register_public_and_private_plugins()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_plugin(plugin_name="private-skill", user_api_key_dict=_NON_ADMIN_USER)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
assert (await get_plugin(plugin_name="public-skill", user_api_key_dict=_NON_ADMIN_USER))["name"] == "public-skill"
|
||||
granted = await get_plugin(plugin_name="private-skill", user_api_key_dict=_granted_user(["private-skill"]))
|
||||
assert granted["name"] == "private-skill"
|
||||
assert granted["enabled"] is False
|
||||
|
||||
|
||||
async def _marketplace_names(key: str | None) -> list[str]:
|
||||
response = await get_marketplace(request=MagicMock(), key=key)
|
||||
assert response.status_code == 200
|
||||
return sorted(plugin["name"] for plugin in json.loads(response.body)["plugins"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(monkeypatch):
|
||||
await _register_public_and_private_plugins()
|
||||
keys = {"sk-granted": _granted_user(["private-skill"]), "sk-plain": _NON_ADMIN_USER}
|
||||
|
||||
async def _fake_auth(request, api_key: str) -> UserAPIKeyAuth:
|
||||
token = api_key.removeprefix("Bearer ")
|
||||
if token not in keys:
|
||||
raise ProxyException(message="invalid key", type="auth_error", param="key", code=401)
|
||||
return keys[token]
|
||||
|
||||
monkeypatch.setattr(claude_code_marketplace, "user_api_key_auth", _fake_auth)
|
||||
|
||||
assert await _marketplace_names(None) == ["public-skill"]
|
||||
assert await _marketplace_names("sk-plain") == ["public-skill"]
|
||||
assert await _marketplace_names("sk-granted") == ["private-skill", "public-skill"]
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await get_marketplace(request=MagicMock(), key="sk-bogus")
|
||||
assert exc_info.value.code == "401"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_plugin_git_subdir_missing_url():
|
||||
"""git-subdir without url field raises HTTP 400."""
|
||||
|
|
@ -377,6 +468,75 @@ async def test_register_plugin_unknown_source_type():
|
|||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "git-subdir" in exc_info.value.detail["error"]
|
||||
assert "archive" in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_source_registers_and_is_served_verbatim_in_marketplace():
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert response.action == "created"
|
||||
assert response.plugin.source == _ARCHIVE_SOURCE
|
||||
|
||||
marketplace = json.loads((await get_marketplace(request=MagicMock())).body)
|
||||
assert marketplace["plugins"] == [{"name": "s3-skill", "source": _ARCHIVE_SOURCE, "version": "1.0.0"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_source_without_sha256_is_accepted():
|
||||
source = {"source": "archive", "url": "https://artifacts.example.com/plugin.zip"}
|
||||
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(name="unpinned-skill", source=source),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert response.plugin.source == source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_plugin_to_archive_source():
|
||||
name = "my-monorepo-plugin"
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
response = await update_plugin(
|
||||
plugin_name=name,
|
||||
request=UpdatePluginRequest(source=_ARCHIVE_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert response.action == "updated"
|
||||
assert (await _read_stored_manifest(name))["source"] == _ARCHIVE_SOURCE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"source, expected_fragment",
|
||||
[
|
||||
({"source": "archive"}, "url"),
|
||||
({"source": "archive", "url": ""}, "url"),
|
||||
({"source": "archive", "url": "http://artifacts.example.com/plugin.zip"}, "https"),
|
||||
({"source": "archive", "url": "s3://skills-bucket/plugin.zip"}, "https"),
|
||||
({"source": "archive", "url": "https://"}, "https"),
|
||||
({"source": "archive", "url": "https:///plugin.zip"}, "https"),
|
||||
({"source": "archive", "url": "https://[::1/plugin.zip"}, "https"),
|
||||
({"source": "archive", "url": "https://artifacts.example.com/plugin.zip", "sha256": "a" * 63}, "sha256"),
|
||||
({"source": "archive", "url": "https://artifacts.example.com/plugin.zip", "sha256": "a" * 65}, "sha256"),
|
||||
({"source": "archive", "url": "https://artifacts.example.com/plugin.zip", "sha256": "g" * 64}, "sha256"),
|
||||
],
|
||||
)
|
||||
async def test_register_plugin_archive_rejects_malformed_source(source, expected_fragment):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await register_plugin(request=RegisterPluginRequest(name="bad-plugin", source=source), user_api_key_dict=_USER)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert expected_fragment in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"""Unit tests for claude_code_skill_access.py: key/team grant resolution."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
|
||||
granted_skills,
|
||||
skill_visibility,
|
||||
)
|
||||
|
||||
|
||||
def _perm(skills: list[str] | None) -> LiteLLM_ObjectPermissionTable:
|
||||
return LiteLLM_ObjectPermissionTable(object_permission_id="perm", skills=skills)
|
||||
|
||||
|
||||
def _key(key_skills: list[str] | None, team_skills: list[str] | None) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
api_key="sk-user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
object_permission=_perm(key_skills) if key_skills is not None else None,
|
||||
team_object_permission=_perm(team_skills) if team_skills is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _plugin(name: str, enabled: bool) -> MagicMock:
|
||||
record = MagicMock()
|
||||
record.name = name
|
||||
record.enabled = enabled
|
||||
return record
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key_skills", "team_skills", "expected"),
|
||||
[
|
||||
(None, None, frozenset()),
|
||||
([], [], frozenset()),
|
||||
(["a", "b"], None, frozenset({"a", "b"})),
|
||||
(None, ["a", "b"], frozenset({"a", "b"})),
|
||||
(["a", "b"], ["b", "c"], frozenset({"b"})),
|
||||
(["a"], ["c"], frozenset()),
|
||||
(["a", "b"], [], frozenset({"a", "b"})),
|
||||
([], ["a", "b"], frozenset({"a", "b"})),
|
||||
],
|
||||
)
|
||||
def test_granted_skills_intersects_key_with_team(key_skills, team_skills, expected):
|
||||
assert granted_skills(_key(key_skills, team_skills)) == expected
|
||||
|
||||
|
||||
def test_visibility_enabled_plugin_is_public_for_everyone():
|
||||
public = _plugin("public-skill", enabled=True)
|
||||
|
||||
assert skill_visibility(None).allows(public)
|
||||
assert skill_visibility(_key(None, None)).allows(public)
|
||||
assert skill_visibility(_key([], ["other"])).allows(public)
|
||||
|
||||
|
||||
def test_visibility_disabled_plugin_needs_grant_or_admin():
|
||||
private = _plugin("private-skill", enabled=False)
|
||||
admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
assert not skill_visibility(None).allows(private)
|
||||
assert not skill_visibility(_key(None, None)).allows(private)
|
||||
assert not skill_visibility(_key(["other-skill"], None)).allows(private)
|
||||
assert not skill_visibility(_key(["private-skill"], ["other-skill"])).allows(private)
|
||||
assert skill_visibility(_key(["private-skill"], None)).allows(private)
|
||||
assert skill_visibility(_key(None, ["private-skill"])).allows(private)
|
||||
assert skill_visibility(admin).allows(private)
|
||||
|
||||
|
||||
def test_where_clause_bounds_the_plugin_query_to_what_the_caller_may_see():
|
||||
admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
assert skill_visibility(None).where() == {"enabled": True}
|
||||
assert skill_visibility(_key(None, None)).where() == {"enabled": True}
|
||||
assert skill_visibility(_key(["b", "a"], None)).where() == {"OR": [{"enabled": True}, {"name": {"in": ["a", "b"]}}]}
|
||||
assert skill_visibility(_key(["a", "b"], ["b"])).where() == {"OR": [{"enabled": True}, {"name": {"in": ["b"]}}]}
|
||||
assert skill_visibility(admin).where() == {}
|
||||
|
|
@ -3826,3 +3826,17 @@ def test_team_disable_logging_stays_proxy_admin_only():
|
|||
def test_neighbouring_team_routes_stay_closed(route):
|
||||
"""The grant is the callback paths and nothing else on the team namespace."""
|
||||
assert "Only proxy admin" in _gate(route, LitellmUserRoles.INTERNAL_USER.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[
|
||||
"/claude-code/marketplace.json",
|
||||
"/claude-code/plugins",
|
||||
"/claude-code/plugins/my-skill",
|
||||
],
|
||||
)
|
||||
def test_claude_code_marketplace_routes_open_to_internal_users(route):
|
||||
"""Per-skill visibility is enforced inside the handler, so the route gate must let non-admins through."""
|
||||
assert RouteChecks.is_llm_api_route(route) is True
|
||||
assert _gate(route, LitellmUserRoles.INTERNAL_USER.value) == "allowed"
|
||||
|
|
|
|||
|
|
@ -763,6 +763,7 @@ _EXPECTED_CUSTOMER = {
|
|||
"blocked_tools": [],
|
||||
"search_tools": [],
|
||||
"mcp_tool_search_enabled": None,
|
||||
"skills": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,29 @@ async def test_set_object_permission_persists_mcp_tool_search_enabled():
|
|||
assert created_data["mcp_tool_search_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_object_permission_persists_skills():
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_created_permission = MagicMock()
|
||||
mock_created_permission.object_permission_id = "perm_id"
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock(
|
||||
return_value=mock_created_permission
|
||||
)
|
||||
|
||||
data_json = {
|
||||
"object_permission": LiteLLM_ObjectPermissionBase(skills=["private-skill"]).model_dump(),
|
||||
}
|
||||
|
||||
await _set_object_permission(data_json=data_json, prisma_client=mock_prisma_client)
|
||||
|
||||
created_data = (
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.create.call_args.kwargs[
|
||||
"data"
|
||||
]
|
||||
)
|
||||
assert created_data["skills"] == ["private-skill"]
|
||||
|
||||
|
||||
# ---- Tests for _extract_requested_mcp_server_ids ----
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,21 +20,25 @@ const DEFAULT_PROPS = {
|
|||
onSuccess: vi.fn(),
|
||||
};
|
||||
|
||||
const URL_PLACEHOLDER = "https://github.com/org/repo or https://gitlab.com/org/repo";
|
||||
const URL_PLACEHOLDER = "https://github.com/org/repo or https://bucket.s3.amazonaws.com/my-skill.zip";
|
||||
const SUBPATH_PLACEHOLDER = "plugins/my-skill";
|
||||
const SHA256_PLACEHOLDER = "64 hex characters";
|
||||
const S3_ZIP_URL = "https://skills-bucket.s3.us-east-1.amazonaws.com/plugins/s3-skill-1.0.0.zip";
|
||||
const DIGEST = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
describe("AddPluginForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the host-agnostic repository URL input and subfolder field", () => {
|
||||
it("renders the host-agnostic source URL input and subfolder field, hiding the digest until a zip is entered", () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
expect(screen.getByText("Repository URL")).toBeInTheDocument();
|
||||
expect(screen.getByText("Source URL")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toBeInTheDocument();
|
||||
expect(screen.getByText("Subfolder path (Optional)")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER)).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(SHA256_PLACEHOLDER)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows GitHub repo preview for a plain repo URL", async () => {
|
||||
|
|
@ -255,6 +259,85 @@ describe("AddPluginForm", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows a zip archive preview, disables the subfolder field, and reveals the digest field", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl(S3_ZIP_URL);
|
||||
|
||||
expect(await screen.findByText(/Zip archive/)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER)).toBeDisabled();
|
||||
expect(screen.getByText("A zip archive is installed as a whole, so this field is disabled")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(SHA256_PLACEHOLDER)).toBeInTheDocument();
|
||||
expect((screen.getByPlaceholderText("my-skill") as HTMLInputElement).value).toBe("s3-skill-1-0-0");
|
||||
});
|
||||
|
||||
it("submits an archive source without a digest when the field is left empty", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl(S3_ZIP_URL);
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith(
|
||||
"sk-test",
|
||||
expect.objectContaining({ source: { source: "archive", url: S3_ZIP_URL } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits an archive source pinned to the lowercased digest", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl(S3_ZIP_URL);
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), {
|
||||
target: { value: ` ${DIGEST.toUpperCase()} ` },
|
||||
});
|
||||
});
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith(
|
||||
"sk-test",
|
||||
expect.objectContaining({ source: { source: "archive", url: S3_ZIP_URL, sha256: DIGEST } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the digest once the archive URL changes so a stale checksum is never sent for a new file", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl(S3_ZIP_URL);
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), { target: { value: DIGEST } });
|
||||
});
|
||||
const otherZipUrl = S3_ZIP_URL.replace("1.0.0", "1.1.0");
|
||||
await typeUrl(otherZipUrl);
|
||||
|
||||
expect(screen.getByPlaceholderText(SHA256_PLACEHOLDER)).toHaveValue("");
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith(
|
||||
"sk-test",
|
||||
expect.objectContaining({ source: { source: "archive", url: otherZipUrl } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks submission and shows the digest error for a malformed sha256", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl(S3_ZIP_URL);
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), { target: { value: "not-a-digest" } });
|
||||
});
|
||||
await submit();
|
||||
|
||||
expect(await screen.findByText("SHA-256 must be a 64-character hex digest")).toBeInTheDocument();
|
||||
expect(mockRegister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the backend error message when registration fails", async () => {
|
||||
mockRegister.mockRejectedValueOnce(new Error("Plugin 'claude-code' already exists"));
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
parseKeywords,
|
||||
parseSkillSource,
|
||||
isValidSubPath,
|
||||
isValidSha256,
|
||||
SkillSourcePreview,
|
||||
} from "@/components/claude_code_plugins/helpers";
|
||||
import { PluginAuthor, PluginSource, SkillRegisterRequest } from "@/components/claude_code_plugins/types";
|
||||
|
|
@ -39,13 +40,14 @@ interface AddPluginFormProps {
|
|||
}
|
||||
|
||||
const addPluginShape = {
|
||||
skillUrl: z.string().min(1, "Please enter a repository URL"),
|
||||
skillUrl: z.string().min(1, "Please enter a repository or zip archive URL"),
|
||||
subPath: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => !value || isValidSubPath(value),
|
||||
"Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)",
|
||||
),
|
||||
sha256: z.string().refine(isValidSha256, "SHA-256 must be a 64-character hex digest"),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Please enter skill name")
|
||||
|
|
@ -69,6 +71,7 @@ type AddPluginFormValues = z.infer<typeof addPluginSchema>;
|
|||
const EMPTY_VALUES: AddPluginFormValues = {
|
||||
skillUrl: "",
|
||||
subPath: "",
|
||||
sha256: "",
|
||||
name: "",
|
||||
domain: "",
|
||||
namespace: "",
|
||||
|
|
@ -89,11 +92,19 @@ const buildAuthor = (values: AddPluginFormValues): PluginAuthor | undefined => {
|
|||
return email ? { name, email } : { name };
|
||||
};
|
||||
|
||||
const archiveUrlOf = (preview: SkillSourcePreview | null): string | undefined =>
|
||||
preview?.parsed.source === "archive" ? preview.parsed.url : undefined;
|
||||
|
||||
const withArchiveDigest = (source: PluginSource, sha256: string): PluginSource => {
|
||||
const digest = sha256.trim();
|
||||
return source.source === "archive" && digest ? { ...source, sha256: digest.toLowerCase() } : source;
|
||||
};
|
||||
|
||||
const buildRegisterRequest = (values: AddPluginFormValues, source: PluginSource): SkillRegisterRequest => {
|
||||
const author = buildAuthor(values);
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
source,
|
||||
source: withArchiveDigest(source, values.sha256),
|
||||
...(values.version ? { version: values.version.trim() } : {}),
|
||||
...(values.description ? { description: values.description.trim() } : {}),
|
||||
...(author ? { author } : {}),
|
||||
|
|
@ -115,6 +126,16 @@ const PREDEFINED_CATEGORIES = [
|
|||
"Documentation",
|
||||
];
|
||||
|
||||
const SUB_PATH_LOCK_REASON = {
|
||||
"git-subdir": "The URL already points to a subfolder, so this field is disabled",
|
||||
archive: "A zip archive is installed as a whole, so this field is disabled",
|
||||
} as const;
|
||||
|
||||
type SubPathLock = keyof typeof SUB_PATH_LOCK_REASON;
|
||||
|
||||
const subPathLockFor = (source: PluginSource["source"] | undefined): SubPathLock | null =>
|
||||
source === "git-subdir" || source === "archive" ? source : null;
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
|
|
@ -129,15 +150,18 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
const form = useZodForm(addPluginSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
|
||||
const [urlEncodesSubdir, setUrlEncodesSubdir] = useState(false);
|
||||
const [subPathLock, setSubPathLock] = useState<SubPathLock | null>(null);
|
||||
|
||||
const recomputePreview = (skillUrl: string, subPath: string) => {
|
||||
const encodesSubdir = parseSkillSource(skillUrl)?.parsed.source === "git-subdir";
|
||||
setUrlEncodesSubdir(encodesSubdir);
|
||||
if (encodesSubdir && form.getValues("subPath")) {
|
||||
const lock = subPathLockFor(parseSkillSource(skillUrl)?.parsed.source);
|
||||
setSubPathLock(lock);
|
||||
if (lock && form.getValues("subPath")) {
|
||||
form.setValue("subPath", "");
|
||||
}
|
||||
const preview = parseSkillSource(skillUrl, encodesSubdir ? undefined : subPath);
|
||||
const preview = parseSkillSource(skillUrl, lock ? undefined : subPath);
|
||||
if (archiveUrlOf(preview) !== archiveUrlOf(urlPreview) && form.getValues("sha256")) {
|
||||
form.setValue("sha256", "");
|
||||
}
|
||||
setUrlPreview(preview);
|
||||
if (preview && !form.getValues("name")) {
|
||||
form.setValue("name", preview.suggestedName);
|
||||
|
|
@ -151,7 +175,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
}
|
||||
|
||||
if (!urlPreview) {
|
||||
toast.error("Please enter a valid repository URL");
|
||||
toast.error("Please enter a valid repository or zip archive URL");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +200,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
toast.success("Skill registered successfully");
|
||||
form.reset(EMPTY_VALUES);
|
||||
setUrlPreview(null);
|
||||
setUrlEncodesSubdir(false);
|
||||
setSubPathLock(null);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
|
|
@ -190,7 +214,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
const handleCancel = () => {
|
||||
form.reset(EMPTY_VALUES);
|
||||
setUrlPreview(null);
|
||||
setUrlEncodesSubdir(false);
|
||||
setSubPathLock(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -207,15 +231,15 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
control={form.control}
|
||||
name="skillUrl"
|
||||
label={labelWithHint(
|
||||
"Repository URL",
|
||||
"Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host. E.g. github.com/org/repo, gitlab.com/org/repo, or github.com/org/repo/tree/main/my-skill",
|
||||
"Source URL",
|
||||
"Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server.",
|
||||
)}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="https://github.com/org/repo or https://gitlab.com/org/repo"
|
||||
placeholder="https://github.com/org/repo or https://bucket.s3.amazonaws.com/my-skill.zip"
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
|
|
@ -232,9 +256,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
"Subfolder path (Optional)",
|
||||
"Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
|
||||
)}
|
||||
description={
|
||||
urlEncodesSubdir ? "The URL already points to a subfolder, so this field is disabled" : undefined
|
||||
}
|
||||
description={subPathLock ? SUB_PATH_LOCK_REASON[subPathLock] : undefined}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
|
|
@ -246,11 +268,26 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
onChange(event);
|
||||
recomputePreview(form.getValues("skillUrl"), event.target.value);
|
||||
}}
|
||||
disabled={urlEncodesSubdir}
|
||||
disabled={subPathLock !== null}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{urlPreview?.parsed.source === "archive" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sha256"
|
||||
label={labelWithHint(
|
||||
"Archive SHA-256 (Optional)",
|
||||
"Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.",
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="64 hex characters" className="rounded-lg font-mono" />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{urlPreview && (
|
||||
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
|
||||
Detected: {urlPreview.label}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function getSkillSourceLink(skill: Plugin): { url: string; label: string } | nul
|
|||
const url = src.path ? `${src.url}/tree/main/${src.path}` : src.url;
|
||||
return { url, label: url.replace("https://github.com/", "") };
|
||||
}
|
||||
if (src?.source === "url" && src.url) {
|
||||
if ((src?.source === "url" || src?.source === "archive") && src.url) {
|
||||
return { url: src.url, label: src.url.replace(/^https?:\/\//, "") };
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ vi.mock("./mcp_server_management/MCPServerSelector", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("./skills/SkillSelector", () => ({
|
||||
default: ({ onChange }: { onChange: (selected: string[]) => void }) => (
|
||||
<button type="button" data-testid="select-private-skill" onClick={() => onChange(["private-skill"])}>
|
||||
Select private skill
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const can = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
|
||||
default: (...args: unknown[]) => can(...args),
|
||||
|
|
@ -1359,6 +1367,27 @@ describe("Teams - the exact bytes the create call sends", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("puts the selected skills into object_permission.skills and drops the form key", async () => {
|
||||
await openCreateModal();
|
||||
await openSection("Skill Settings", /Allowed Skills/);
|
||||
fireEvent.click(screen.getByTestId("select-private-skill"));
|
||||
|
||||
const payload = await submit();
|
||||
|
||||
expect(payload.object_permission).toStrictEqual({ skills: ["private-skill"] });
|
||||
expect(payload).not.toHaveProperty("object_permission_skills");
|
||||
});
|
||||
|
||||
it("sends no object_permission when Skill Settings is opened but nothing is selected", async () => {
|
||||
await openCreateModal();
|
||||
await openSection("Skill Settings", /Allowed Skills/);
|
||||
|
||||
const payload = await submit();
|
||||
|
||||
expect(payload).not.toHaveProperty("object_permission");
|
||||
expect(payload).not.toHaveProperty("object_permission_skills");
|
||||
});
|
||||
|
||||
it("includes selected MCP toolsets in the create object permission", async () => {
|
||||
await openCreateModal();
|
||||
await openSection("MCP Settings", /Allowed MCP Servers/);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import { Organization, getDefaultTeamSettings, getGuardrailsList, getPoliciesLis
|
|||
import NumericalInput from "./shared/numerical_input";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "./search_tools/SearchToolSelector";
|
||||
import SkillSelector from "./skills/SkillSelector";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface TeamProps {
|
||||
|
|
@ -99,6 +100,7 @@ const teamCreateFieldsSchema = z.object({
|
|||
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
|
||||
allowed_agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
|
||||
object_permission_search_tools: z.array(z.string()).optional(),
|
||||
object_permission_skills: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type TeamCreateFormValues = z.infer<typeof teamCreateFieldsSchema>;
|
||||
|
|
@ -128,6 +130,7 @@ const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = {
|
|||
mcp_tool_permissions: {},
|
||||
allowed_agents_and_groups: undefined,
|
||||
object_permission_search_tools: undefined,
|
||||
object_permission_skills: undefined,
|
||||
};
|
||||
|
||||
const ADDITIONAL_SETTINGS_FIELDS = [
|
||||
|
|
@ -147,6 +150,7 @@ const ADDITIONAL_SETTINGS_FIELDS = [
|
|||
const MCP_SETTINGS_FIELDS = ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"] as const;
|
||||
const AGENT_SETTINGS_FIELDS = ["allowed_agents_and_groups"] as const;
|
||||
const SEARCH_TOOL_SETTINGS_FIELDS = ["object_permission_search_tools"] as const;
|
||||
const SKILL_SETTINGS_FIELDS = ["object_permission_skills"] as const;
|
||||
|
||||
const isParsableJson = (value: string | undefined): boolean => {
|
||||
if (!value) {
|
||||
|
|
@ -214,6 +218,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
const [mcpSettingsOpen, setMcpSettingsOpen] = useState(false);
|
||||
const [agentSettingsOpen, setAgentSettingsOpen] = useState(false);
|
||||
const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false);
|
||||
const [skillSettingsOpen, setSkillSettingsOpen] = useState(false);
|
||||
|
||||
const adminOrgs = useMemo(
|
||||
() => getAdminOrganizations(userRole, userID, organizations),
|
||||
|
|
@ -505,6 +510,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
delete formValues.object_permission_search_tools;
|
||||
}
|
||||
|
||||
if (Array.isArray(formValues.object_permission_skills) && formValues.object_permission_skills.length > 0) {
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.skills = formValues.object_permission_skills;
|
||||
}
|
||||
delete formValues.object_permission_skills;
|
||||
|
||||
// Add model_aliases if any are defined
|
||||
if (Object.keys(modelAliases).length > 0) {
|
||||
formValues.model_aliases = modelAliases;
|
||||
|
|
@ -540,6 +553,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
...(mcpSettingsOpen ? [] : MCP_SETTINGS_FIELDS),
|
||||
...(agentSettingsOpen ? [] : AGENT_SETTINGS_FIELDS),
|
||||
...(searchToolSettingsOpen ? [] : SEARCH_TOOL_SETTINGS_FIELDS),
|
||||
...(skillSettingsOpen ? [] : SKILL_SETTINGS_FIELDS),
|
||||
]);
|
||||
return Object.fromEntries(Object.entries(values).filter(([key]) => !unmounted.has(key)));
|
||||
};
|
||||
|
|
@ -1163,6 +1177,38 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
open={skillSettingsOpen}
|
||||
onOpenChange={setSkillSettingsOpen}
|
||||
className="mt-8 mb-8 overflow-hidden rounded-lg border"
|
||||
>
|
||||
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
|
||||
<b>Skill Settings</b>
|
||||
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="object_permission_skills"
|
||||
className="mt-4"
|
||||
label={labelWithHint(
|
||||
"Allowed Skills",
|
||||
"Enabled skills are visible to every team. Grant disabled (private) Claude Code plugins to this team here.",
|
||||
)}
|
||||
description="Private skills keys on this team may see in the Claude Code marketplace."
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select skills (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
|
||||
<b>Logging Settings</b>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
formatKeywords,
|
||||
parseSkillSource,
|
||||
isValidSubPath,
|
||||
isValidSha256,
|
||||
buildMarketplaceSettingsSnippet,
|
||||
} from "./helpers";
|
||||
import { MarketplacePluginEntry } from "./types";
|
||||
|
|
@ -114,6 +115,12 @@ describe("getSourceDisplayText", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("shows the archive url for an archive source", () => {
|
||||
expect(getSourceDisplayText({ source: "archive", url: "https://bucket.s3.amazonaws.com/skill.zip" })).toBe(
|
||||
"https://bucket.s3.amazonaws.com/skill.zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unknown for missing data", () => {
|
||||
expect(getSourceDisplayText({ source: "github" })).toBe("Unknown source");
|
||||
});
|
||||
|
|
@ -140,6 +147,12 @@ describe("getSourceLink", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("returns the archive url for an archive source", () => {
|
||||
expect(getSourceLink({ source: "archive", url: "https://bucket.s3.amazonaws.com/skill.zip" })).toBe(
|
||||
"https://bucket.s3.amazonaws.com/skill.zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no repo or url", () => {
|
||||
expect(getSourceLink({ source: "github" })).toBeNull();
|
||||
});
|
||||
|
|
@ -329,6 +342,20 @@ describe("isValidUrl", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("isValidSha256", () => {
|
||||
it("accepts an empty digest and a 64-character hex digest in either case", () => {
|
||||
expect(isValidSha256("")).toBe(true);
|
||||
expect(isValidSha256("a".repeat(64))).toBe(true);
|
||||
expect(isValidSha256(" " + "ABCDEF0123456789".repeat(4) + " ")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wrong length and non-hex digests", () => {
|
||||
expect(isValidSha256("a".repeat(63))).toBe(false);
|
||||
expect(isValidSha256("a".repeat(65))).toBe(false);
|
||||
expect(isValidSha256("g".repeat(64))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseKeywords", () => {
|
||||
it("splits comma-separated keywords", () => {
|
||||
expect(parseKeywords("a, b, c")).toEqual(["a", "b", "c"]);
|
||||
|
|
@ -445,6 +472,39 @@ describe("parseSkillSource", () => {
|
|||
expect(parseSkillSource("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses an S3 zip URL into an archive source and names the skill after the file", () => {
|
||||
expect(parseSkillSource("https://skills-bucket.s3.us-east-1.amazonaws.com/plugins/My_Skill-1.0.0.zip")).toEqual({
|
||||
parsed: { source: "archive", url: "https://skills-bucket.s3.us-east-1.amazonaws.com/plugins/My_Skill-1.0.0.zip" },
|
||||
label: "Zip archive — skills-bucket.s3.us-east-1.amazonaws.com/plugins/My_Skill-1.0.0.zip",
|
||||
suggestedName: "my-skill-1-0-0",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the query string of a zip URL so versioned or signed object links still resolve", () => {
|
||||
expect(parseSkillSource("https://bucket.s3.amazonaws.com/skill.ZIP?versionId=abc")?.parsed).toEqual({
|
||||
source: "archive",
|
||||
url: "https://bucket.s3.amazonaws.com/skill.ZIP?versionId=abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the subfolder for a zip URL since the archive is installed whole", () => {
|
||||
expect(parseSkillSource("https://artifacts.example.com/skill.zip", "plugins/x")?.parsed).toEqual({
|
||||
source: "archive",
|
||||
url: "https://artifacts.example.com/skill.zip",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a plain http zip URL", () => {
|
||||
expect(parseSkillSource("http://artifacts.example.com/skill.zip")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a github zip download URL as an archive rather than a repo path", () => {
|
||||
expect(parseSkillSource("https://github.com/org/repo/releases/download/v1/skill.zip")?.parsed).toEqual({
|
||||
source: "archive",
|
||||
url: "https://github.com/org/repo/releases/download/v1/skill.zip",
|
||||
});
|
||||
});
|
||||
|
||||
it("suggests a kebab-friendly name from the last path segment", () => {
|
||||
expect(parseSkillSource("github.com/org/my-awesome-skill")?.suggestedName).toBe("my-awesome-skill");
|
||||
expect(parseSkillSource("github.com/org/repo/tree/main/plugins/cool-skill")?.suggestedName).toBe("cool-skill");
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ const GITHUB_HOST = "github.com";
|
|||
|
||||
const SKILL_FILE_EXTENSION_REGEX = /\.(md|markdown|txt|json|ya?ml|toml)$/i;
|
||||
|
||||
const ZIP_ARCHIVE_REGEX = /\.zip$/i;
|
||||
|
||||
export const SHA256_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
export const isValidSha256 = (digest: string): boolean => digest.trim() === "" || SHA256_REGEX.test(digest.trim());
|
||||
|
||||
// WHATWG normalizes obfuscated IPv4 (e.g. 2130706433, 0x7f.0.0.1) to dotted-decimal, so this
|
||||
// catches every IPv4 form; bracketed IPv6 is rejected separately.
|
||||
const IPV4_HOST_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
|
|
@ -160,16 +166,26 @@ const parseRawGitSource = (url: URL, subPath?: string): SkillSourcePreview | nul
|
|||
};
|
||||
};
|
||||
|
||||
const parseArchiveSource = (url: URL): SkillSourcePreview => ({
|
||||
parsed: { source: "archive", url: url.href },
|
||||
label: `Zip archive — ${url.host}${url.pathname}`,
|
||||
suggestedName: toKebabCase(lastSegment(url.pathname).replace(ZIP_ARCHIVE_REGEX, "")),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse any git-accessible repository URL into a registerable skill source.
|
||||
* GitHub URLs keep their `github`/`git-subdir` shorthand; every other host is
|
||||
* treated as a raw repo URL, with an optional subfolder turning it into git-subdir.
|
||||
* Parse any git-accessible repository URL or https zip archive URL into a registerable skill
|
||||
* source. A `.zip` path is an `archive` source (S3, Artifactory, any static host). GitHub URLs
|
||||
* keep their `github`/`git-subdir` shorthand; every other host is treated as a raw repo URL,
|
||||
* with an optional subfolder turning it into git-subdir.
|
||||
*/
|
||||
export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourcePreview | null => {
|
||||
const url = parseRepoUrl(rawUrl);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
if (ZIP_ARCHIVE_REGEX.test(url.pathname)) {
|
||||
return parseArchiveSource(url);
|
||||
}
|
||||
if (url.hostname.replace(/^www\./, "") === GITHUB_HOST) {
|
||||
return parseGitHubSource(url, subPath);
|
||||
}
|
||||
|
|
@ -245,7 +261,7 @@ export const getSourceDisplayText = (source: PluginSource): string => {
|
|||
if (source.source === "git-subdir" && source.url && source.path) {
|
||||
return `${source.url} @ ${source.path}`;
|
||||
}
|
||||
if (source.source === "url" && source.url) {
|
||||
if ((source.source === "url" || source.source === "archive") && source.url) {
|
||||
return source.url;
|
||||
}
|
||||
return "Unknown source";
|
||||
|
|
@ -258,10 +274,8 @@ export const getSourceLink = (source: PluginSource): string | null => {
|
|||
if (source.source === "github" && source.repo) {
|
||||
return `https://github.com/${source.repo}`;
|
||||
}
|
||||
if ((source.source === "url" || source.source === "git-subdir") && source.url) {
|
||||
return source.url;
|
||||
}
|
||||
return null;
|
||||
const linksToUrl = source.source === "url" || source.source === "git-subdir" || source.source === "archive";
|
||||
return linksToUrl && source.url ? source.url : null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
const src = skill.source;
|
||||
if (src.source === "github" && src.repo) return `https://github.com/${src.repo}`;
|
||||
if (src.source === "git-subdir" && src.url) return src.path ? `${src.url}/tree/main/${src.path}` : src.url;
|
||||
if (src.source === "url" && src.url) return src.url;
|
||||
if ((src.source === "url" || src.source === "archive") && src.url) return src.url;
|
||||
return null;
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ import type { components } from "@/lib/http/schema";
|
|||
// Kept hand-written: the backend types `source` as Dict[str, str], so the generated type is a
|
||||
// loose string map; this discriminant union is what the parser and display helpers rely on.
|
||||
export interface PluginSource {
|
||||
source: "github" | "url" | "git-subdir";
|
||||
source: "github" | "url" | "git-subdir" | "archive";
|
||||
repo?: string; // Format: "org/repo" for GitHub
|
||||
url?: string; // Full URL for other sources
|
||||
path?: string; // Subdirectory path for git-subdir
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export type PluginAuthor = components["schemas"]["PluginAuthor"];
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function ObjectPermissionsView({
|
|||
const agents = objectPermission?.agents || [];
|
||||
const agentAccessGroups = objectPermission?.agent_access_groups || [];
|
||||
const searchTools = objectPermission?.search_tools || [];
|
||||
const skills = objectPermission?.skills || [];
|
||||
|
||||
const content = (
|
||||
<div className={variant === "card" ? "grid grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 gap-6" : "space-y-4"}>
|
||||
|
|
@ -58,6 +59,16 @@ export function ObjectPermissionsView({
|
|||
<p className="mt-1 block text-xs break-words text-foreground">{searchTools.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md border border-border p-4">
|
||||
<p className="text-sm font-medium text-foreground">Skills</p>
|
||||
{skills.length === 0 ? (
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
No private skills granted. Only enabled (public) Claude Code plugins are visible.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 block text-xs break-words text-foreground">{skills.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -301,6 +301,16 @@ describe("object_permission", () => {
|
|||
).toStrictEqual(aliasOnly({ object_permission: { agents: ["a-1"], agent_access_groups: ["ag-1"] } }));
|
||||
});
|
||||
|
||||
it("moves the selected skills under object_permission and off the top level", () => {
|
||||
expect(payloadOf(build({ key_alias: "my-key", allowed_skills: ["private-skill"] }))).toStrictEqual(
|
||||
aliasOnly({ object_permission: { skills: ["private-skill"] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no object_permission for an empty skill selection", () => {
|
||||
expect(payloadOf(build({ key_alias: "my-key", allowed_skills: [] }))).toStrictEqual(aliasOnly());
|
||||
});
|
||||
|
||||
it("merges every source into a single object_permission", () => {
|
||||
const everySource = {
|
||||
key_alias: "my-key",
|
||||
|
|
@ -308,6 +318,7 @@ describe("object_permission", () => {
|
|||
allowed_mcp_servers_and_groups: { servers: ["s-1"], accessGroups: ["g-1"], toolsets: ["t-1"] },
|
||||
mcp_tool_permissions: { "s-1": ["read"] },
|
||||
allowed_agents_and_groups: { agents: ["a-1"], accessGroups: ["ag-1"] },
|
||||
allowed_skills: ["private-skill"],
|
||||
};
|
||||
expect(payloadOf(build(everySource))).toStrictEqual(
|
||||
aliasOnly({
|
||||
|
|
@ -319,6 +330,7 @@ describe("object_permission", () => {
|
|||
mcp_tool_permissions: { "s-1": ["read"] },
|
||||
agents: ["a-1"],
|
||||
agent_access_groups: ["ag-1"],
|
||||
skills: ["private-skill"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ interface PermissionSources {
|
|||
readonly toolPermissions: unknown | undefined;
|
||||
readonly extraMcpAccessGroups: unknown[] | undefined;
|
||||
readonly agents: AgentSelection | undefined;
|
||||
readonly skills: unknown[] | undefined;
|
||||
}
|
||||
|
||||
const readPermissionSources = (values: Record<string, unknown>): PermissionSources => ({
|
||||
|
|
@ -120,6 +121,7 @@ const readPermissionSources = (values: Record<string, unknown>): PermissionSourc
|
|||
toolPermissions: readToolPermissions(values.mcp_tool_permissions),
|
||||
extraMcpAccessGroups: nonEmptyList(values.allowed_mcp_access_groups),
|
||||
agents: readAgentSelection(values.allowed_agents_and_groups),
|
||||
skills: nonEmptyList(values.allowed_skills),
|
||||
});
|
||||
|
||||
const buildObjectPermission = ({
|
||||
|
|
@ -128,6 +130,7 @@ const buildObjectPermission = ({
|
|||
toolPermissions,
|
||||
extraMcpAccessGroups,
|
||||
agents,
|
||||
skills,
|
||||
}: PermissionSources): Record<string, unknown> | undefined => {
|
||||
const permission: Record<string, unknown> = {
|
||||
...(vectorStores && { vector_stores: vectorStores }),
|
||||
|
|
@ -138,6 +141,7 @@ const buildObjectPermission = ({
|
|||
...(extraMcpAccessGroups && { mcp_access_groups: extraMcpAccessGroups }),
|
||||
...(agents?.agents && { agents: agents.agents }),
|
||||
...(agents?.accessGroups && { agent_access_groups: agents.accessGroups }),
|
||||
...(skills && { skills }),
|
||||
};
|
||||
return Object.keys(permission).length > 0 ? permission : undefined;
|
||||
};
|
||||
|
|
@ -148,6 +152,7 @@ const consumedSourceKeys = (
|
|||
): ReadonlySet<string> =>
|
||||
new Set<string>([
|
||||
"mcp_tool_permissions",
|
||||
"allowed_skills",
|
||||
...(values.disable_global_guardrails ? [] : ["disable_global_guardrails"]),
|
||||
...(vectorStores ? ["allowed_vector_store_ids"] : []),
|
||||
...(mcp ? ["allowed_mcp_servers_and_groups"] : []),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,13 @@ vi.mock("../networking", async (importOriginal) => {
|
|||
getPossibleUserRoles: vi.fn().mockResolvedValue({}),
|
||||
userFilterUICall: vi.fn().mockResolvedValue([]),
|
||||
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
|
||||
getClaudeCodePluginsList: vi.fn().mockResolvedValue({
|
||||
plugins: [
|
||||
{ name: "public-skill", enabled: true },
|
||||
{ name: "private-skill", enabled: false },
|
||||
],
|
||||
count: 2,
|
||||
}),
|
||||
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
listMCPTools: vi.fn().mockResolvedValue(emptyMcpTools),
|
||||
|
|
@ -109,6 +116,7 @@ const OPENAPI_SCHEMA = {
|
|||
const SECTIONS = {
|
||||
mcp: /MCP Settings/i,
|
||||
agent: /Agent Settings/i,
|
||||
skill: /Skill Settings/i,
|
||||
logging: /Logging Settings/i,
|
||||
router: /Router Settings/i,
|
||||
aliases: /Model Aliases/i,
|
||||
|
|
@ -164,6 +172,7 @@ const ROUTER_SETTINGS_DEFAULT = {
|
|||
const SECTION_PAYLOAD_ADDITIONS: Record<keyof typeof SECTIONS, Record<string, unknown>> = {
|
||||
mcp: { allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] } },
|
||||
agent: { allowed_agents_and_groups: undefined },
|
||||
skill: {},
|
||||
logging: {},
|
||||
router: { router_settings: ROUTER_SETTINGS_DEFAULT },
|
||||
aliases: {},
|
||||
|
|
@ -329,6 +338,21 @@ describe("CreateKey", () => {
|
|||
expect(Object.keys(serialised).sort()).toStrictEqual([...wireKeys].sort());
|
||||
});
|
||||
|
||||
it("moves a picked private skill under object_permission.skills and off the top level", async () => {
|
||||
await openModal();
|
||||
await nameTheKey();
|
||||
await openSection(/Optional Settings/i);
|
||||
await openSection(SECTIONS.skill);
|
||||
await userEvent.click(await screen.findByRole("combobox", { name: "Select skills (optional)" }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "private-skill (private)" }));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await submit();
|
||||
|
||||
const payload = await createdPayload();
|
||||
expect(payload.object_permission).toStrictEqual({ skills: ["private-skill"] });
|
||||
expect(payload).not.toHaveProperty("allowed_skills");
|
||||
});
|
||||
|
||||
it("omits a budget typed into a section the user closed again, rather than sending it as null", async () => {
|
||||
await openModal();
|
||||
await nameTheKey();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
|||
import { type Control, useForm, useWatch, type UseFormSetValue } from "react-hook-form";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import SkillSelector from "../skills/SkillSelector";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
|
||||
import SchemaFormFields from "../common_components/check_openapi_schema";
|
||||
|
|
@ -1557,6 +1558,36 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible className="mt-4 mb-4 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
|
||||
<b>Skill Settings</b>
|
||||
<ChevronDown className={SECTION_CHEVRON_CLASS} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-3">
|
||||
<MountedFormField
|
||||
label={
|
||||
<span>
|
||||
Allowed Skills{" "}
|
||||
<SimpleTooltip content="Enabled skills are visible to every key. Grant disabled (private) Claude Code plugins to this key here">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_skills"
|
||||
help="Select private skills this key can access in the Claude Code marketplace"
|
||||
>
|
||||
{(control) => (
|
||||
<SkillSelector
|
||||
onChange={control.onChange}
|
||||
value={control.value as string[] | undefined}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select skills (optional)"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{premiumUser ? (
|
||||
<Collapsible className="mt-4 mb-4 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { getClaudeCodePluginsList } from "../networking";
|
||||
import SkillSelector from "./SkillSelector";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
getClaudeCodePluginsList: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SkillSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getClaudeCodePluginsList).mockResolvedValue({
|
||||
plugins: [
|
||||
{ name: "public-skill", enabled: true },
|
||||
{ name: "private-skill", enabled: false },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("should list marketplace plugins and mark disabled ones as private", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SkillSelector accessToken="token" onChange={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
expect(await screen.findByRole("option", { name: "public-skill" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "private-skill (private)" })).toBeInTheDocument();
|
||||
expect(getClaudeCodePluginsList).toHaveBeenCalledWith("token");
|
||||
});
|
||||
|
||||
it("should report the selected skill names", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<SkillSelector accessToken="token" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByRole("option", { name: "private-skill (private)" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["private-skill"]);
|
||||
});
|
||||
|
||||
it("should clear all selected skills", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<SkillSelector accessToken="" value={["public-skill"]} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Clear all skills" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
109
ui/litellm-dashboard/src/components/skills/SkillSelector.tsx
Normal file
109
ui/litellm-dashboard/src/components/skills/SkillSelector.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { getClaudeCodePluginsList } from "../networking";
|
||||
|
||||
export interface SkillSelectorProps {
|
||||
onChange: (selected: string[]) => void;
|
||||
value?: string[];
|
||||
className?: string;
|
||||
accessToken: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SkillOption {
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
const readSkillOptions = (data: unknown): SkillOption[] => {
|
||||
const plugins = (data as { plugins?: unknown[] } | undefined)?.plugins;
|
||||
if (!Array.isArray(plugins)) return [];
|
||||
return plugins.flatMap((plugin) => {
|
||||
const record = plugin as { name?: unknown; enabled?: unknown };
|
||||
return typeof record.name === "string" && record.name.length > 0
|
||||
? [{ name: record.name, enabled: record.enabled !== false }]
|
||||
: [];
|
||||
});
|
||||
};
|
||||
|
||||
const SkillSelector: React.FC<SkillSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
accessToken,
|
||||
placeholder = "Select skills (optional)",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [options, setOptions] = useState<SkillOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setOptions(readSkillOptions(await getClaudeCodePluginsList(accessToken)));
|
||||
} catch (e) {
|
||||
console.error("Failed to load skills:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
multiple
|
||||
items={options.map((option) => option.name)}
|
||||
value={value ?? []}
|
||||
onValueChange={(selected: string[]) => onChange(selected)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxChips render={<div ref={anchor} />} className={cn("w-full", className)} aria-busy={loading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((skill) => (
|
||||
<ComboboxChip key={skill} aria-label={skill}>
|
||||
{skill}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder={placeholder} aria-label={placeholder} disabled={disabled} />
|
||||
{value && value.length > 0 && <ComboboxClear aria-label="Clear all skills" disabled={disabled} />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>{loading ? "Loading skills…" : "No skills found"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(skill: string) => {
|
||||
const isPrivate = options.some((option) => option.name === skill && !option.enabled);
|
||||
return (
|
||||
<ComboboxItem key={skill} value={skill} aria-label={isPrivate ? `${skill} (private)` : skill}>
|
||||
{skill}
|
||||
{isPrivate && <span className="ml-2 text-xs text-muted-foreground">private</span>}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillSelector;
|
||||
|
|
@ -52,6 +52,7 @@ vi.mock("@/components/networking", () => ({
|
|||
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
|
||||
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
|
||||
}));
|
||||
|
||||
const can = vi.fn();
|
||||
|
|
@ -1990,6 +1991,7 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
|
|||
agents: [],
|
||||
agent_access_groups: [],
|
||||
vector_stores: ["vs-1"],
|
||||
skills: [],
|
||||
};
|
||||
|
||||
it("leaves every team member key out of the request body for an untouched save with both sections closed", async () => {
|
||||
|
|
@ -2052,6 +2054,47 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
|
|||
expect(objectPermission.agent_access_groups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("resends the stored skills when the selector is left untouched", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ models: ["gpt-4"], object_permission: { skills: ["private-skill"] } }),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...props} />);
|
||||
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await screen.findByLabelText("Team Name");
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
const objectPermission = wireBody(payload).object_permission as Record<string, unknown>;
|
||||
expect(objectPermission.skills).toStrictEqual(["private-skill"]);
|
||||
});
|
||||
|
||||
it("sends an empty skills array after the last skill chip is removed", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ models: ["gpt-4"], object_permission: { skills: ["private-skill"] } }),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...props} />);
|
||||
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await screen.findByLabelText("Team Name");
|
||||
|
||||
await user.click(within(screen.getByLabelText("private-skill")).getByRole("button"));
|
||||
expect(screen.queryByLabelText("private-skill")).not.toBeInTheDocument();
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
const objectPermission = wireBody(payload).object_permission as Record<string, unknown>;
|
||||
expect(objectPermission.skills).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("sends an empty vector_stores array after the last vector store chip is removed", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await openEditor(user);
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ import ObjectPermissionsView from "../object_permissions_view";
|
|||
import NumericalInput from "../shared/numerical_input";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "../search_tools/SearchToolSelector";
|
||||
import SkillSelector from "../skills/SkillSelector";
|
||||
import EditLoggingSettings from "./EditLoggingSettings";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
|
||||
import MemberModal from "./EditMembership";
|
||||
|
|
@ -371,6 +372,7 @@ const teamUpdateFieldsSchema = z.object({
|
|||
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
|
||||
agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
|
||||
object_permission_search_tools: z.array(z.string()).optional(),
|
||||
object_permission_skills: z.array(z.string()).optional(),
|
||||
organization_id: z.string().nullish(),
|
||||
logging_settings: z.array(z.unknown()).optional(),
|
||||
secret_manager_settings: z.string().optional(),
|
||||
|
|
@ -419,6 +421,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
|
|||
mcp_tool_permissions: {},
|
||||
agents_and_groups: { agents: [], accessGroups: [] },
|
||||
object_permission_search_tools: [],
|
||||
object_permission_skills: [],
|
||||
organization_id: null,
|
||||
logging_settings: [],
|
||||
secret_manager_settings: "",
|
||||
|
|
@ -485,6 +488,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
|
|||
accessGroups: info.object_permission?.agent_access_groups || [],
|
||||
},
|
||||
object_permission_search_tools: info.object_permission?.search_tools || [],
|
||||
object_permission_skills: info.object_permission?.skills || [],
|
||||
organization_id: info.organization_id,
|
||||
logging_settings: info.metadata?.logging || [],
|
||||
secret_manager_settings: info.metadata?.secret_manager_settings
|
||||
|
|
@ -1047,6 +1051,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
updateData.object_permission.search_tools = values.object_permission_search_tools;
|
||||
}
|
||||
|
||||
if (Array.isArray(values.object_permission_skills)) {
|
||||
updateData.object_permission.skills = values.object_permission_skills;
|
||||
}
|
||||
|
||||
// Pass access_group_ids to the update request
|
||||
if (values.access_group_ids !== undefined) {
|
||||
updateData.access_group_ids = values.access_group_ids;
|
||||
|
|
@ -1848,6 +1856,24 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="object_permission_skills"
|
||||
label={labelWithHint(
|
||||
"Skills",
|
||||
"Enabled skills are visible to every team. Grant disabled (private) Claude Code plugins to this team here.",
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select skills (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="organization_id" label="Organization">
|
||||
{({ id, value, onChange }) => (
|
||||
<SearchSelect
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import { KeyEditFormValues } from "./keyEditFormValues";
|
||||
import SkillSelector from "../skills/SkillSelector";
|
||||
import { AgentsAndGroups, KeyEditFormValues } from "./keyEditFormValues";
|
||||
|
||||
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
|
||||
<>
|
||||
|
|
@ -53,6 +55,36 @@ export const KeyTypeSelect = ({
|
|||
</Select>
|
||||
);
|
||||
|
||||
const SKILLS_HINT =
|
||||
"Enabled skills are visible to every key. Grant disabled (private) Claude Code plugins to this key here.";
|
||||
|
||||
export const KeyAgentAndSkillFields = ({
|
||||
control,
|
||||
accessToken,
|
||||
}: {
|
||||
control: Control<KeyEditFormValues>;
|
||||
accessToken: string;
|
||||
}) => (
|
||||
<>
|
||||
<FormField control={control} name="agents_and_groups" label="Agents / Access Groups">
|
||||
{({ value, onChange }) => (
|
||||
<AgentSelector
|
||||
onChange={onChange}
|
||||
value={value as AgentsAndGroups | undefined}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select agents or access groups (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={control} name="skills" label={labelWithHint("Skills", SKILLS_HINT)}>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector onChange={onChange} value={value as string[] | undefined} accessToken={accessToken} />
|
||||
)}
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
|
||||
export const KeyBudgetNumberField = ({
|
||||
control,
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -366,6 +366,45 @@ describe("KeyInfoView handleKeyUpdate mcp_toolsets", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("KeyInfoView handleKeyUpdate skills", () => {
|
||||
it("should forward the skills the edit form supplies into object_permission and drop the form key", async () => {
|
||||
renderView(true);
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
skills: ["private-skill"],
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
|
||||
|
||||
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
|
||||
expect(sentPayload.object_permission.skills).toEqual(["private-skill"]);
|
||||
expect(sentPayload).not.toHaveProperty("skills");
|
||||
});
|
||||
|
||||
it("should send an explicit empty skills list when the form clears every skill", async () => {
|
||||
renderView(true);
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
skills: [],
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
|
||||
|
||||
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
|
||||
expect(sentPayload.object_permission.skills).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyInfoView handleKeyUpdate budget_duration", () => {
|
||||
it("should send a canonical budget_duration through unchanged", async () => {
|
||||
renderView(true);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export interface KeyEditFormValues {
|
|||
mcp_servers_and_groups?: McpServersAndGroups;
|
||||
mcp_tool_permissions?: Record<string, string[]>;
|
||||
agents_and_groups?: AgentsAndGroups;
|
||||
skills?: string[];
|
||||
organization_id?: string | null;
|
||||
team_id?: string | null;
|
||||
logging_settings?: unknown[];
|
||||
|
|
@ -102,6 +103,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues =>
|
|||
agents: keyData.object_permission?.agents || [],
|
||||
accessGroups: keyData.object_permission?.agent_access_groups || [],
|
||||
},
|
||||
skills: keyData.object_permission?.skills || [],
|
||||
organization_id: keyData.organization_id,
|
||||
team_id: keyData.team_id,
|
||||
logging_settings: extractLoggingSettings(keyData.metadata),
|
||||
|
|
@ -148,6 +150,7 @@ export const keyEditFormSchema = z.object({
|
|||
mcp_servers_and_groups: z.custom<McpServersAndGroups | undefined>(),
|
||||
mcp_tool_permissions: z.custom<Record<string, string[]> | undefined>(),
|
||||
agents_and_groups: z.custom<AgentsAndGroups | undefined>(),
|
||||
skills: z.custom<string[] | undefined>(),
|
||||
organization_id: z.custom<string | null | undefined>(),
|
||||
team_id: z.custom<string | null | undefined>(),
|
||||
logging_settings: z.custom<unknown[] | undefined>(),
|
||||
|
|
@ -196,6 +199,7 @@ export const toSubmittedValues = (
|
|||
mcp_servers_and_groups: values.mcp_servers_and_groups,
|
||||
mcp_tool_permissions: values.mcp_tool_permissions,
|
||||
agents_and_groups: values.agents_and_groups,
|
||||
skills: values.skills,
|
||||
organization_id: values.organization_id,
|
||||
team_id: values.team_id,
|
||||
logging_settings: values.logging_settings,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ vi.mock("../networking", async () => {
|
|||
agents: [],
|
||||
}),
|
||||
getAgentAccessGroups: vi.fn().mockResolvedValue([]),
|
||||
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -135,6 +136,14 @@ vi.mock("../agent_management/AgentSelector", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("../skills/SkillSelector", () => ({
|
||||
default: ({ onChange }: { onChange: (selected: string[]) => void }) => (
|
||||
<button type="button" data-testid="skill-selector" onClick={() => onChange(["private-skill"])}>
|
||||
pick skill
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/AccessGroupSelector", () => ({
|
||||
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
|
||||
<input
|
||||
|
|
@ -1907,6 +1916,7 @@ describe("KeyEditView", () => {
|
|||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
mcp_tool_permissions: {},
|
||||
agents_and_groups: { agents: [], accessGroups: [] },
|
||||
skills: [],
|
||||
organization_id: null,
|
||||
team_id: null,
|
||||
logging_settings: [],
|
||||
|
|
@ -2241,6 +2251,36 @@ describe("KeyEditView", () => {
|
|||
expect(onSubmitMock.mock.calls[0][0].agents_and_groups.agents).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
it("carries a picked skill into the payload", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock);
|
||||
await screen.findByRole("button", { name: /save changes/i });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "pick skill" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSubmitMock.mock.calls[0][0].skills).toEqual(["private-skill"]);
|
||||
});
|
||||
|
||||
it("preloads the stored skills into the payload when the selector is left untouched", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock, {
|
||||
...MOCK_KEY_DATA,
|
||||
object_permission: { ...MOCK_KEY_DATA.object_permission, skills: ["stored-skill"] },
|
||||
} as KeyResponse);
|
||||
await screen.findByRole("button", { name: /save changes/i });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSubmitMock.mock.calls[0][0].skills).toEqual(["stored-skill"]);
|
||||
});
|
||||
|
||||
it("carries an added logging integration into the payload", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { FormField } from "@/components/shared/form/FormField";
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { hasCapability } from "../../utils/capabilities";
|
||||
import { isProxyAdminRole, rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
|
||||
import { mapInternalToDisplayNames } from "../callback_info_helpers";
|
||||
|
|
@ -32,9 +31,8 @@ import {
|
|||
modelSentinelOptions,
|
||||
parseAllowedRoutes,
|
||||
} from "./keyEditFieldNormalizers";
|
||||
import { KeyBudgetNumberField, KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
|
||||
import { KeyAgentAndSkillFields, KeyBudgetNumberField, KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
|
||||
import {
|
||||
AgentsAndGroups,
|
||||
KeyEditFormValues,
|
||||
keyEditFormSchema,
|
||||
McpServersAndGroups,
|
||||
|
|
@ -762,16 +760,7 @@ export function KeyEditView({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<FormField control={form.control} name="agents_and_groups" label="Agents / Access Groups">
|
||||
{({ value, onChange }) => (
|
||||
<AgentSelector
|
||||
onChange={onChange}
|
||||
value={value as AgentsAndGroups | undefined}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select agents or access groups (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<KeyAgentAndSkillFields control={form.control} accessToken={accessToken || ""} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
|
|||
|
|
@ -277,6 +277,14 @@ export default function KeyInfoView({
|
|||
delete formValues.agents_and_groups;
|
||||
}
|
||||
|
||||
if (formValues.skills !== undefined) {
|
||||
formValues.object_permission = {
|
||||
...formValues.object_permission,
|
||||
skills: formValues.skills || [],
|
||||
};
|
||||
delete formValues.skills;
|
||||
}
|
||||
|
||||
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
|
||||
formValues.tpm_limit = mapEmptyStringToNull(formValues.tpm_limit);
|
||||
formValues.rpm_limit = mapEmptyStringToNull(formValues.rpm_limit);
|
||||
|
|
|
|||
40
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
40
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -2110,12 +2110,17 @@ export interface paths {
|
|||
* - claude plugin marketplace add <url>
|
||||
* - claude plugin install <name>@<marketplace>
|
||||
*
|
||||
* Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`
|
||||
* the key is authenticated and the catalog also holds the disabled plugins granted
|
||||
* to it through `object_permission.skills` on the key or its team.
|
||||
*
|
||||
* Returns:
|
||||
* Marketplace catalog with list of available plugins and their git sources.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
* claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json
|
||||
* claude plugin marketplace add "http://localhost:4000/claude-code/marketplace.json?key=sk-..."
|
||||
* claude plugin install my-plugin@litellm
|
||||
* ```
|
||||
*/
|
||||
|
|
@ -2152,8 +2157,8 @@ export interface paths {
|
|||
* @description Register a new plugin in the LiteLLM marketplace.
|
||||
*
|
||||
* LiteLLM acts as a registry/discovery layer. Plugins are hosted on
|
||||
* GitHub/GitLab/Bitbucket. Claude Code will clone from the git source
|
||||
* when users install.
|
||||
* GitHub/GitLab/Bitbucket or as a zip archive on any https host (e.g. S3).
|
||||
* Claude Code clones the git source or downloads the archive when users install.
|
||||
*
|
||||
* This endpoint is create-only and never overwrites. If a plugin with
|
||||
* the same name already exists it returns 409 Conflict; use
|
||||
|
|
@ -2163,7 +2168,7 @@ export interface paths {
|
|||
*
|
||||
* Parameters:
|
||||
* - name: Plugin name (kebab-case)
|
||||
* - source: Git source reference (github, url, or git-subdir format)
|
||||
* - source: Plugin source reference (github, url, git-subdir, or archive format)
|
||||
* - version: Semantic version (optional)
|
||||
* - description: Plugin description (optional)
|
||||
* - author: Author information (optional)
|
||||
|
|
@ -2229,7 +2234,7 @@ export interface paths {
|
|||
*
|
||||
* Parameters:
|
||||
* - plugin_name: Name of the plugin to update (path parameter)
|
||||
* - source: Git source reference (github, url, or git-subdir format)
|
||||
* - source: Plugin source reference (github, url, git-subdir, or archive format)
|
||||
* - version: Semantic version (optional)
|
||||
* - description: Plugin description (optional)
|
||||
* - author: Author information (optional)
|
||||
|
|
@ -29333,6 +29338,8 @@ export interface components {
|
|||
models?: string[] | null;
|
||||
/** Search Tools */
|
||||
search_tools?: string[] | null;
|
||||
/** Skills */
|
||||
skills?: string[] | null;
|
||||
/** Vector Stores */
|
||||
vector_stores?: string[] | null;
|
||||
};
|
||||
|
|
@ -29386,6 +29393,8 @@ export interface components {
|
|||
* @default []
|
||||
*/
|
||||
search_tools: string[] | null;
|
||||
/** Skills */
|
||||
skills?: string[] | null;
|
||||
/**
|
||||
* Vector Stores
|
||||
* @default []
|
||||
|
|
@ -33638,7 +33647,7 @@ export interface components {
|
|||
name: string;
|
||||
/**
|
||||
* Source
|
||||
* @description Git source reference
|
||||
* @description Plugin source reference
|
||||
*/
|
||||
source: {
|
||||
[key: string]: string;
|
||||
|
|
@ -34887,7 +34896,7 @@ export interface components {
|
|||
* @description Request body for registering a plugin in the marketplace.
|
||||
*
|
||||
* LiteLLM acts as a registry/discovery layer. Plugins are hosted on
|
||||
* GitHub/GitLab/Bitbucket and referenced by their git source.
|
||||
* GitHub/GitLab/Bitbucket or as a zip archive on any https host and referenced by their source.
|
||||
*/
|
||||
RegisterPluginRequest: {
|
||||
/** @description Plugin author */
|
||||
|
|
@ -34929,10 +34938,11 @@ export interface components {
|
|||
namespace?: string | null;
|
||||
/**
|
||||
* Source
|
||||
* @description Git source reference. Supported formats:
|
||||
* @description Plugin source reference. Supported formats:
|
||||
* - GitHub: {'source': 'github', 'repo': 'org/repo'}
|
||||
* - Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}
|
||||
* - Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}
|
||||
* - Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}
|
||||
*/
|
||||
source: {
|
||||
[key: string]: string;
|
||||
|
|
@ -38211,10 +38221,11 @@ export interface components {
|
|||
namespace?: string | null;
|
||||
/**
|
||||
* Source
|
||||
* @description Git source reference. Supported formats:
|
||||
* @description Plugin source reference. Supported formats:
|
||||
* - GitHub: {'source': 'github', 'repo': 'org/repo'}
|
||||
* - Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}
|
||||
* - Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}
|
||||
* - Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}
|
||||
*/
|
||||
source: {
|
||||
[key: string]: string;
|
||||
|
|
@ -43204,7 +43215,9 @@ export interface operations {
|
|||
};
|
||||
get_marketplace_claude_code_marketplace_json_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
key?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
|
|
@ -43220,6 +43233,15 @@ export interface operations {
|
|||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_plugins_claude_code_plugins_get: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue