Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_sanitize_unknown_model_spend_rows

This commit is contained in:
mateo-berri 2026-09-10 14:36:07 -07:00
commit 3e356366ce
58 changed files with 2260 additions and 159 deletions

View file

@ -0,0 +1 @@
ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN IF NOT EXISTS "skills" TEXT[] DEFAULT ARRAY[]::TEXT[];

View file

@ -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[]

View file

@ -1201,6 +1201,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
# Cast to Any to match the expected union type for tools list items
tools.append(cast(Any, web_search_tool))
def transform_response_format_to_text_format(self, response_format: object) -> "ResponseText | None":
return self._transform_response_format_to_text_format(response_format)
def _transform_response_format_to_text_format(self, response_format: object) -> "ResponseText | None":
"""
Transform Chat Completion response_format parameter to Responses API text.format parameter.

View file

@ -62,6 +62,9 @@ class BaseResponsesAPIConfig(ABC):
"""
return False
def supports_encrypted_agent_messages(self) -> bool:
return False
def sign_request(
self,
headers: dict,

View file

@ -110,6 +110,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
def supports_native_file_search(self) -> bool:
return True
def supports_encrypted_agent_messages(self) -> bool:
return self.custom_llm_provider in (LlmProviders.OPENAI, LlmProviders.AZURE)
@staticmethod
def _is_gpt_5_model(model: str) -> bool:
"""Return True only for actual OpenAI GPT-5 models.

View file

@ -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")

View file

@ -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

View file

@ -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:

View file

@ -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": [
{

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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[]

View file

@ -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")

View file

@ -4,10 +4,11 @@ from collections.abc import Coroutine, Generator, Iterable, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, Optional, TypeAlias, cast
import httpx
from pydantic import BaseModel
from typing_extensions import assert_never
import litellm
from litellm._logging import verbose_logger
@ -407,6 +408,37 @@ def _bridges_to_chat_completions(
return responses_api_provider_config is None or use_chat_completions_api is True
_ResponsesCompatibilityFailure: TypeAlias = Literal["encrypted_task_unsupported"]
def _encrypted_task_support_failure(
responses_api_provider_config: BaseResponsesAPIConfig | None, use_chat_completions_api: bool
) -> _ResponsesCompatibilityFailure | None:
if (
responses_api_provider_config is None
or _bridges_to_chat_completions(responses_api_provider_config, use_chat_completions_api)
or not responses_api_provider_config.supports_encrypted_agent_messages()
):
return "encrypted_task_unsupported"
return None
def _raise_responses_compatibility_failure(
failure: _ResponsesCompatibilityFailure, model: str, custom_llm_provider: str | None
) -> NoReturn:
match failure:
case "encrypted_task_unsupported":
raise litellm.exception_type(
model=model,
custom_llm_provider=custom_llm_provider,
original_exception=ValueError(
"Encrypted task classification requires a compatible native Responses deployment"
),
)
case _:
assert_never(failure)
def _deployment_passes_through_responses(model_info: object) -> bool:
"""Whether ``model_info.supported_endpoints`` opts the deployment into native ``{api_base}/responses``."""
if not isinstance(model_info, dict):
@ -1078,6 +1110,7 @@ def responses(
litellm_call_id: Final[str | None] = kwargs.get("litellm_call_id", None)
_is_async: Final = kwargs.pop("aresponses", False) is True
skip_mcp_handler: Final = kwargs.pop("_skip_mcp_handler", False)
require_encrypted_task_support: Final = kwargs.pop("_require_encrypted_task_support", False) is True
use_chat_completions_api = _pop_use_chat_completions_api_kw(kwargs)
client_headers: Final = kwargs.get("headers")
@ -1186,6 +1219,17 @@ def responses(
model, custom_llm_provider, deployment_model_info
)
if (
require_encrypted_task_support
and (
compatibility_failure := _encrypted_task_support_failure(
responses_api_provider_config, use_chat_completions_api
)
)
is not None
):
_raise_responses_compatibility_failure(compatibility_failure, model, custom_llm_provider)
local_vars.update(kwargs)
# Map reasoning_effort (from litellm_params/proxy config) to reasoning when not set
if reasoning is None and "reasoning_effort" in local_vars:

View file

@ -361,6 +361,19 @@ model_list:
keep the classifier deployment or provider default, or set a supported value such as `none` or
`low` to override that call.
When the current ask is a Responses API `agent_message` containing `encrypted_content`, LLM
classification preserves the encrypted task and uses native Responses. This also bypasses the
local scoring shortcut in `heuristic_first` and `hybrid` modes. The configured classifier must use
a native OpenAI or Azure OpenAI Responses deployment with access to the encrypted content. The
provider handles the encrypted task, and the classifier still chooses the tier dynamically
Compatibility is checked after normal deployment selection. A paused incompatible member of the
classifier group does not prevent an eligible compatible deployment from classifying the task
Unsupported classifier deployments and provider decryption errors use the existing
`classifier_fallback` policy. No fixed tier is introduced for encrypted tasks. Plaintext asks and
requests carrying only historical encrypted reasoning retain the existing classifier path
Classifier calls have a one-attempt hard deadline. After a timeout, the router opens a process-local
circuit for that classifier and sends every session through `classifier_fallback` for
`classifier_llm_config.circuit_breaker_cooldown_seconds` (30 seconds by default). When the cooldown

View file

@ -25,7 +25,7 @@ from threading import Lock
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, cast
from pydantic import BaseModel, create_model
from pydantic import BaseModel, TypeAdapter, ValidationError, create_model
from litellm._logging import verbose_router_logger
from litellm.constants import (
@ -57,6 +57,7 @@ from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionImageObject,
ChatCompletionTextObject,
ResponsesAPIResponse,
)
from litellm.types.utils import (
AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
@ -365,7 +366,7 @@ def _parent_session_kwargs(request_kwargs: Mapping[str, Any] | None) -> Mapping[
return {k: kwargs[k] for k in ("litellm_session_id", "litellm_trace_id") if kwargs.get(k) is not None}
def _response_cost_or_none(response: ModelResponse) -> float | None:
def _response_cost_or_none(response: ModelResponse | ResponsesAPIResponse) -> float | None:
hidden_params: Final = response._hidden_params
if not isinstance(hidden_params, dict):
return None
@ -494,6 +495,42 @@ def _human_text(content: object, marker_pairs: tuple[tuple[str, str], ...] = _DE
return _strip_reminder_blocks(_message_text(content), marker_pairs)
def _encrypted_classifier_task(
request_kwargs: Mapping[str, object] | None,
marker_pairs: tuple[tuple[str, str], ...],
) -> dict[str, object] | None:
from litellm.litellm_core_utils.prompt_templates.factory import resolve_structured_messages
raw_input: Final = (request_kwargs or EMPTY_MAPPING).get("input")
if not isinstance(raw_input, list) or (request_kwargs or EMPTY_MAPPING).get("messages"):
return None
try:
items: Final = TypeAdapter(tuple[dict[str, object], ...]).validate_python(raw_input)
except ValidationError:
return None
current: Final = next(
(
item
for item in reversed(items)
if (messages := resolve_structured_messages(messages=None, request_kwargs={"input": [item]}))
and any(_iter_human_asks_newest_first(messages, marker_pairs))
),
None,
)
if current is None or current.get("type") != "agent_message" or not isinstance(current.get("content"), list):
return None
try:
parts: Final = TypeAdapter(tuple[dict[str, object], ...]).validate_python(current["content"])
except ValidationError:
return None
if not any(part.get("type") == "encrypted_content" and part.get("encrypted_content") for part in parts):
return None
return {
**current,
"content": [part for part in parts if part.get("type") in ("input_text", "encrypted_content")],
}
def _iter_human_asks_newest_first(
messages: Sequence[Mapping[str, object]],
marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS,
@ -1652,6 +1689,10 @@ class ComplexityRouter(CustomLogger):
return self._classify_with_heuristic_v2(prompt)
if self.config.classifier_type == "custom":
return await self._classify_with_plugin(prompt, system_prompt, request_kwargs, raw_messages)
if self.config.classifier_type in ("heuristic_first", "hybrid") and _encrypted_classifier_task(
request_kwargs, self._reminder_markers_for_request(request_kwargs or EMPTY_MAPPING)
):
return await self._llm_classifier_outcome(prompt, system_prompt, request_kwargs, messages)
if self.config.classifier_type == "heuristic_first" and self.config.classifier_llm_config is not None:
return await self._classify_heuristic_first(prompt, system_prompt, request_kwargs, messages)
if self.config.classifier_type == "hybrid" and self.config.classifier_llm_config is not None:
@ -1987,8 +2028,9 @@ class ComplexityRouter(CustomLogger):
> 1
)
encrypted_task: Final = _encrypted_classifier_task(request_kwargs, marker_pairs)
user_payload: Final = self._build_classifier_user_payload(
prompt=prompt,
prompt="The delegated task in the following agent_message." if encrypted_task is not None else prompt,
system_prompt=system_prompt,
prior_turns=prior_turns,
messages=messages,
@ -2021,34 +2063,37 @@ class ComplexityRouter(CustomLogger):
if llm_config.reasoning_effort is not None:
classifier_call_params = MappingProxyType({"reasoning_effort": llm_config.reasoning_effort})
proxy_server_request: Final = {
"body": {
"model": llm_config.model,
"messages": messages_for_call,
"response_format": response_format,
**classifier_call_params,
}
}
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}
)
proxy_server_request: Final = {"body": {"model": llm_config.model, **payload}}
classify: Final = (
self.litellm_router_instance.aresponses
if encrypted_task is not None
else self.litellm_router_instance.acompletion
)
classifier_timeout_s: Final[float] = llm_config.timeout_ms / 1000
response: Final[ModelResponse] = await asyncio.wait_for(
self.litellm_router_instance.acompletion(
response: Final[ModelResponse | ResponsesAPIResponse] = await asyncio.wait_for(
classify(
model=llm_config.model,
messages=messages_for_call,
stream=False,
response_format=response_format,
timeout=classifier_timeout_s,
num_retries=0,
disable_fallbacks=True,
metadata=metadata,
proxy_server_request=proxy_server_request,
turn_off_message_logging=turn_off_message_logging,
**classifier_call_params,
**payload,
**_parent_session_kwargs(request_kwargs),
),
timeout=classifier_timeout_s,
)
content: Final = response.choices[0].message.content
content: Final = (
response.output_text if isinstance(response, ResponsesAPIResponse) else response.choices[0].message.content
)
if not content:
raise ValueError("LLM classifier returned empty content")
raw_tier: Final = _LabeledTierClassification.model_validate_json(content).tier
@ -2057,6 +2102,33 @@ class ComplexityRouter(CustomLogger):
raise ValueError(f"LLM classifier returned an unrecognized tier: {raw_tier!r}")
return tier, _response_cost_or_none(response)
def _native_classifier_payload(
self,
messages: list[AllMessageValues], # mutable-ok: existing transformation accepts the SDK message list
response_format: Mapping[str, object],
encrypted_task: Mapping[str, object],
) -> Mapping[str, object]:
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
transformation: Final = LiteLLMResponsesTransformationHandler()
input_items, instructions = transformation.convert_chat_completion_messages_to_responses_api(messages)
llm_config: Final = self.config.classifier_llm_config
reasoning: Final = (
{"reasoning": {"effort": llm_config.reasoning_effort}}
if llm_config is not None and llm_config.reasoning_effort is not None
else {}
)
return {
"input": [*input_items, encrypted_task],
"instructions": instructions,
"text": transformation.transform_response_format_to_text_format(dict(response_format)),
"store": False,
"_require_encrypted_task_support": True,
**reasoning,
}
@staticmethod
def _build_classifier_user_payload(
prompt: str,

View file

@ -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]

View file

@ -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")

View file

@ -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[]

View file

@ -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

View file

@ -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(

View file

@ -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())

View file

@ -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"

View file

@ -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:

View file

@ -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",

View file

@ -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()).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

View file

@ -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() == {}

View file

@ -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"

View file

@ -763,6 +763,7 @@ _EXPECTED_CUSTOMER = {
"blocked_tools": [],
"search_tools": [],
"mcp_tool_search_enabled": None,
"skills": None,
},
}

View file

@ -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 ----

View file

@ -7,10 +7,14 @@ calls so routed requests do not hit a custom api_base /v1/responses endpoint.
"""
from importlib import import_module
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
from litellm.types.utils import Choices, Message, ModelResponse, Usage
@ -18,6 +22,26 @@ from litellm.types.utils import Choices, Message, ModelResponse, Usage
class TestUseResponsesApiBridgeFlag:
"""Test that bridge opt-in forces the chat completions path."""
@pytest.mark.parametrize("model", ["openai/chat_completions/gpt-6-astra", "xai/test-classifier"])
def test_encrypted_classifier_rejection_preserves_public_error(self, model: str) -> None:
respond: Final = MagicMock(side_effect=AssertionError("Incompatible classifier sent an upstream request"))
with httpx.Client(transport=httpx.MockTransport(respond)) as client:
with pytest.raises(
litellm.APIConnectionError,
match="Encrypted task classification requires a compatible native Responses deployment",
) as error:
litellm.responses(
model=model,
input="Delegated task",
api_key="test-key",
api_base="https://classifier.test/v1",
client=HTTPHandler(client=client),
_require_encrypted_task_support=True,
num_retries=0,
)
assert error.value.status_code == 500
respond.assert_not_called()
@patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
)

View file

@ -5,7 +5,10 @@ Tests the rule-based complexity scoring and tier assignment logic.
"""
import asyncio
from collections.abc import AsyncIterator
import json
from copy import deepcopy
from functools import partial
import logging
import sys
import time
@ -13,6 +16,7 @@ from typing import Dict, Final, List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import httpx
from pydantic import ValidationError
import litellm
@ -67,6 +71,8 @@ from litellm.types.router import (
LiteLLM_Params,
TaggedPreRoutingStrategy,
)
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
requires_semantic_router = pytest.mark.skipif(
@ -2490,6 +2496,340 @@ class TestTierLabels:
assert set(config.tier_boundaries) == {"simple_medium", "medium_complex", "complex_reasoning"}
def _encrypted_agent_task() -> dict[str, object]:
return {
"type": "agent_message",
"author": "/root",
"recipient": "/root/child",
"content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nTask name: /root/child\nPayload:\nHello"},
{"type": "encrypted_content", "encrypted_content": "opaque-provider-task"},
],
}
def _native_classifier_response(content: str) -> ResponsesAPIResponse:
response: Final = ResponsesAPIResponse(
id="resp_classifier",
created_at=0,
status="completed",
output=[{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": content}]}],
)
response._hidden_params = {"response_cost": 0.0001}
return response
def _native_classifier_router(
output: str = '{"tier":"REASONING"}',
classifier_type: str = "llm",
deployment_model: str = "openai/gpt-6-astra",
failure: Exception | None = None,
native_router: Router | None = None,
http_handler: AsyncHTTPHandler | None = None,
) -> tuple[ComplexityRouter, MagicMock]:
dependency: Final = MagicMock(
aresponses=(
native_router.factory_function(partial(litellm.aresponses, client=http_handler), call_type="aresponses")
if native_router is not None
else AsyncMock(return_value=_native_classifier_response(output), side_effect=failure)
),
acompletion=AsyncMock(return_value=_llm_response('{"tier":"SIMPLE"}')),
get_model_list=(
native_router.get_model_list
if native_router is not None
else MagicMock(return_value=[{"litellm_params": {"model": deployment_model}}])
),
)
return (
ComplexityRouter(
model_name="encrypted-router",
litellm_router_instance=dependency,
complexity_router_config={
"tiers": {"SIMPLE": "cheap-model", "REASONING": "deep-model"},
"classifier_type": classifier_type,
"classifier_llm_config": {
"model": "classifier",
"timeout_ms": 5000 if native_router is not None else 100,
"reasoning_effort": "low",
},
"heuristic_first_max_tier": "SIMPLE" if classifier_type == "heuristic_first" else None,
"hybrid_boundary_margin": 0.01 if classifier_type == "hybrid" else None,
"classifier_fallback": "default_model",
"default_model": "deep-model",
"session_affinity": False,
"deployment_affinity": False,
},
),
dependency,
)
@pytest.fixture
async def native_classifier_http() -> AsyncIterator[tuple[AsyncHTTPHandler, MagicMock]]:
respond: Final = MagicMock(
return_value=httpx.Response(200, json=_native_classifier_response('{"tier":"REASONING"}').model_dump())
)
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
handler: Final = AsyncHTTPHandler()
await handler.client.aclose()
handler.client = client
yield handler, respond
class TestEncryptedTaskClassifier:
@pytest.mark.asyncio
@pytest.mark.parametrize("classifier_type", ["llm", "heuristic_first", "hybrid"])
@pytest.mark.parametrize("codex", [True, False])
@pytest.mark.parametrize(
"reminder",
[
"<environment_context>cwd=/repo</environment_context>",
"<user_instructions>Keep answers concise</user_instructions>",
],
)
async def test_encrypted_task_detection_uses_request_reminder_markers(
self, classifier_type: str, codex: bool, reminder: str
):
router, dependency = _native_classifier_router(classifier_type=classifier_type)
task: Final = _encrypted_agent_task()
request: Final = {
"input": [task, {"role": "user", "content": reminder}],
"metadata": {"user_agent": "codex-tui" if codex else "curl/8.7.1"},
}
original: Final = deepcopy(request)
result: Final = await router.async_pre_routing_hook(model="encrypted-router", request_kwargs=request)
assert request == original
assert result.model == ("deep-model" if codex else "cheap-model")
if codex:
assert result.routing_decision["cause"] == "llm_classifier"
assert result.routing_decision["tier"] == "REASONING"
dependency.aresponses.assert_awaited_once()
assert dependency.aresponses.call_args.kwargs["input"][-1] == task
dependency.acompletion.assert_not_called()
else:
dependency.aresponses.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("classifier_type", ["llm", "heuristic_first", "hybrid"])
@pytest.mark.parametrize("tier,model", [("SIMPLE", "cheap-model"), ("REASONING", "deep-model")])
async def test_encrypted_task_routes_by_native_verdict(self, classifier_type: str, tier: str, model: str):
router, dependency = _native_classifier_router(json.dumps({"tier": tier}), classifier_type)
task: Final = _encrypted_agent_task()
request: Final = {
"input": [
{"role": "user", "content": "Prior task context"},
task,
{"type": "function_call_output", "call_id": "call_1", "output": "Tool output"},
{"role": "user", "content": "<system-reminder>Injected reminder</system-reminder>"},
],
"instructions": "Caller constraints",
"tools": [{"type": "function", "name": "execute"}],
"previous_response_id": "resp_parent",
"litellm_session_id": "parent-session",
"litellm_trace_id": "parent-trace",
"turn_off_message_logging": True,
"litellm_metadata": {"user_api_key_hash": "caller-key-hash"},
}
original: Final = deepcopy(request)
result: Final = await router.async_pre_routing_hook(model="encrypted-router", request_kwargs=request)
assert result.model == model
assert result.routing_decision["tier"] == tier
assert result.routing_decision["cause"] == "llm_classifier"
assert result.routing_decision["classifier_cost"] == 0.0001
assert result.messages is None
assert request == original
dependency.acompletion.assert_not_called()
call: Final = dependency.aresponses.call_args.kwargs
assert call["input"][-1] == task
assert "opaque-provider-task" not in json.dumps(call["input"][:-1])
assert "Prior task context" in json.dumps(call["input"][:-1])
assert "Caller constraints" in json.dumps(call["input"][:-1])
assert "Caller constraints" not in call["instructions"]
assert "SIMPLE" in call["instructions"] and "REASONING" in call["instructions"]
assert call["text"]["format"]["schema"]["properties"]["tier"]["enum"] == [
"SIMPLE",
"MEDIUM",
"COMPLEX",
"REASONING",
]
assert call["text"]["format"]["strict"] is True
assert call["reasoning"] == {"effort": "low"}
assert call["store"] is False
assert call["_require_encrypted_task_support"] is True
assert call["stream"] is False
assert "tools" not in call and "previous_response_id" not in call
assert "messages" not in call and "response_format" not in call
assert call["timeout"] == 0.1 and call["num_retries"] == 0 and call["disable_fallbacks"] is True
assert call["litellm_session_id"] == "parent-session"
assert call["litellm_trace_id"] == "parent-trace"
assert call["turn_off_message_logging"] is True
assert call["metadata"]["user_api_key_hash"] == "caller-key-hash"
assert call["proxy_server_request"]["body"]["input"] == call["input"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"items",
[
[
{"type": "reasoning", "encrypted_content": "opaque-history", "summary": []},
{"role": "user", "content": "hi"},
],
[_encrypted_agent_task(), {"role": "user", "content": "hi"}],
[{**_encrypted_agent_task(), "content": [{"type": "input_text", "text": "hi"}]}],
[{"role": "user", "content": "gAAAA is plain text"}],
[
{"role": "user", "content": "hi"},
{"type": "function_call_output", "call_id": "call_1", "output": "opaque-provider-task"},
],
],
ids=[
"historical-reasoning",
"older-encrypted-task",
"plaintext-agent",
"ciphertext-looking-text",
"tool-output",
],
)
async def test_other_asks_keep_chat_classifier(self, items: list[dict[str, object]]):
router, dependency = _native_classifier_router()
result: Final = await router.async_pre_routing_hook(model="encrypted-router", request_kwargs={"input": items})
assert result.model == "cheap-model"
assert result.routing_decision["cause"] == "llm_classifier"
dependency.aresponses.assert_not_called()
dependency.acompletion.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("output", ["", "not-json", '{"tier":"UNKNOWN"}'])
async def test_invalid_native_verdict_uses_existing_fallback(self, output: str):
router, dependency = _native_classifier_router(output=output)
result: Final = await router.async_pre_routing_hook(
model="encrypted-router", request_kwargs={"input": [_encrypted_agent_task()]}
)
assert result.model == "deep-model"
assert result.routing_decision["cause"] == "default_model_fallback"
dependency.aresponses.assert_awaited_once()
dependency.acompletion.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"deployment_model",
["anthropic/test-classifier", "openai/chat_completions/gpt-6-astra", "xai/test-classifier"],
)
async def test_incompatible_classifier_does_not_flatten_encryption(
self, deployment_model: str, native_classifier_http: tuple[AsyncHTTPHandler, MagicMock]
):
handler, respond = native_classifier_http
native: Final = Router(
model_list=[
{
"model_name": "classifier",
"litellm_params": {
"model": deployment_model,
"api_key": "test-key",
"api_base": "https://classifier.test/v1",
},
}
],
num_retries=0,
)
router, _ = _native_classifier_router(native_router=native, http_handler=handler)
result: Final = await router.async_pre_routing_hook(
model="encrypted-router", request_kwargs={"input": [_encrypted_agent_task()]}
)
assert result.model == "deep-model"
assert result.routing_decision["cause"] == "default_model_fallback"
respond.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("blocked", [True, False])
async def test_native_classifier_validates_selected_deployment(
self, blocked: bool, native_classifier_http: tuple[AsyncHTTPHandler, MagicMock]
):
handler, respond = native_classifier_http
native: Final = Router(
model_list=[
{
"model_name": "classifier",
"litellm_params": {"model": "anthropic/test-classifier", "api_key": "test-key", "order": 0},
"model_info": {"id": "incompatible", "blocked": blocked},
},
{
"model_name": "classifier",
"litellm_params": {
"model": "openai/gpt-6-astra",
"api_key": "test-key",
"order": 1,
"api_base": "https://classifier.test/v1",
},
"model_info": {"id": "compatible"},
},
],
num_retries=0,
)
router, _ = _native_classifier_router(native_router=native, http_handler=handler)
task: Final = _encrypted_agent_task()
result: Final = await router.async_pre_routing_hook(model="encrypted-router", request_kwargs={"input": [task]})
assert result.model == "deep-model"
assert result.routing_decision["cause"] == ("llm_classifier" if blocked else "default_model_fallback")
if blocked:
respond.assert_called_once()
request: Final = respond.call_args.args[0]
assert request.url.path == "/v1/responses"
body: Final = json.loads(request.content)
assert body["input"][-1] == task
assert "_require_encrypted_task_support" not in body
else:
respond.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("classifier_type", ["llm", "heuristic_first", "hybrid"])
@pytest.mark.parametrize(
"input_items",
[
["unsupported-input-item"],
[{**_encrypted_agent_task(), "content": [{"type": "input_text", "text": "hi"}, None]}],
],
)
async def test_encrypted_detection_does_not_reject_other_input_shapes(
self, classifier_type: str, input_items: list[object]
):
router, dependency = _native_classifier_router(classifier_type=classifier_type)
result: Final = await router.aclassify("hi", request_kwargs={"input": input_items})
assert result.cause != "default_model_fallback"
assert result.tier == ComplexityTier.SIMPLE
dependency.aresponses.assert_not_called()
if classifier_type == "llm":
dependency.acompletion.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", [ValueError("invalid_encrypted_content"), TimeoutError("classifier timed out")])
async def test_native_provider_failure_uses_existing_fallback(self, failure: Exception):
router, dependency = _native_classifier_router(failure=failure)
result: Final = await router.async_pre_routing_hook(
model="encrypted-router", request_kwargs={"input": [_encrypted_agent_task()]}
)
assert result.model == "deep-model"
assert result.routing_decision["cause"] == "default_model_fallback"
dependency.aresponses.assert_awaited_once()
dependency.acompletion.assert_not_called()
class TestLLMClassifier:
"""Test the LLM-based classifier path (aclassify) and its fallback behavior."""

View file

@ -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} />);

View file

@ -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}

View file

@ -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;

View file

@ -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/);

View file

@ -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>

View file

@ -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");

View file

@ -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;
};
/**

View file

@ -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;
})();

View file

@ -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"];

View file

@ -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>
);

View file

@ -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"],
},
}),
);

View file

@ -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"] : []),

View file

@ -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();

View file

@ -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}>

View file

@ -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([]);
});
});

View 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;

View file

@ -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);

View file

@ -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

View file

@ -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,

View file

@ -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);

View file

@ -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,

View file

@ -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);

View file

@ -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}

View file

@ -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);

View file

@ -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: {