mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
feat(proxy): granular key/team access control for Claude Code marketplace plugins (#40518)
Adds object_permission.skills to keys and teams, enforces it on
/claude-code/marketplace.json?key=, /claude-code/plugins and
/claude-code/plugins/{name}, and exposes an Allowed Skills selector in
the key and team create/edit forms of the Admin UI
Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
03815cf9f6
commit
6cc13e07a6
35 changed files with 892 additions and 34 deletions
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN IF NOT EXISTS "skills" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
|
@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
|
|||
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
|
||||
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
|
||||
mcp_tool_search_enabled Boolean?
|
||||
skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
|
||||
teams LiteLLM_TeamTable[]
|
||||
projects LiteLLM_ProjectTable[]
|
||||
verification_tokens LiteLLM_VerificationToken[]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ 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)
|
||||
|
|
@ -24,11 +24,15 @@ 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
|
||||
|
|
@ -84,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.
|
||||
|
||||
|
|
@ -92,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:
|
||||
|
|
@ -149,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)
|
||||
|
|
@ -395,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 {}
|
||||
|
||||
|
|
@ -473,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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
Claude Code marketplace visibility: enabled plugins are public, disabled plugins
|
||||
are private and resolve only for proxy admins or keys granted them via
|
||||
``object_permission.skills``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma.types import LiteLLM_ClaudeCodePluginTableWhereInput
|
||||
|
||||
|
||||
class _SkillRecord(Protocol):
|
||||
name: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
def _skills_of(permission: LiteLLM_ObjectPermissionTable | None) -> frozenset[str]:
|
||||
return frozenset(permission.skills or ()) if permission is not None else frozenset()
|
||||
|
||||
|
||||
def granted_skills(user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]:
|
||||
"""Key grant intersected with the team grant when both are non-empty; either alone applies as is.
|
||||
|
||||
An empty list is the Prisma column default for every object-permission row, so it means
|
||||
"no private grants configured here" and defers to the other scope, same as the agents check.
|
||||
"""
|
||||
key_skills: Final = _skills_of(user_api_key_dict.object_permission)
|
||||
team_skills: Final = _skills_of(user_api_key_dict.team_object_permission)
|
||||
match (bool(key_skills), bool(team_skills)):
|
||||
case (True, True):
|
||||
return key_skills & team_skills
|
||||
case (True, False):
|
||||
return key_skills
|
||||
case _:
|
||||
return team_skills
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillVisibility:
|
||||
granted: frozenset[str]
|
||||
sees_private: bool
|
||||
|
||||
def allows(self, skill: _SkillRecord) -> bool:
|
||||
return skill.enabled or self.sees_private or skill.name in self.granted
|
||||
|
||||
def where(self) -> "LiteLLM_ClaudeCodePluginTableWhereInput":
|
||||
if self.sees_private:
|
||||
return {}
|
||||
if not self.granted:
|
||||
return {"enabled": True}
|
||||
return {"OR": [{"enabled": True}, {"name": {"in": sorted(self.granted)}}]}
|
||||
|
||||
|
||||
PUBLIC_ONLY: Final = SkillVisibility(granted=frozenset(), sees_private=False)
|
||||
|
||||
|
||||
def skill_visibility(user_api_key_dict: UserAPIKeyAuth | None) -> SkillVisibility:
|
||||
if user_api_key_dict is None:
|
||||
return PUBLIC_ONLY
|
||||
if is_proxy_admin(user_api_key_dict):
|
||||
return SkillVisibility(granted=frozenset(), sees_private=True)
|
||||
return SkillVisibility(granted=granted_skills(user_api_key_dict), sees_private=False)
|
||||
|
|
@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
|
|||
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
|
||||
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
|
||||
mcp_tool_search_enabled Boolean?
|
||||
skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
|
||||
teams LiteLLM_TeamTable[]
|
||||
projects LiteLLM_ProjectTable[]
|
||||
verification_tokens LiteLLM_VerificationToken[]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -271,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."""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"""Unit tests for claude_code_skill_access.py: key/team grant resolution."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
|
||||
granted_skills,
|
||||
skill_visibility,
|
||||
)
|
||||
|
||||
|
||||
def _perm(skills: list[str] | None) -> LiteLLM_ObjectPermissionTable:
|
||||
return LiteLLM_ObjectPermissionTable(object_permission_id="perm", skills=skills)
|
||||
|
||||
|
||||
def _key(key_skills: list[str] | None, team_skills: list[str] | None) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
api_key="sk-user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
object_permission=_perm(key_skills) if key_skills is not None else None,
|
||||
team_object_permission=_perm(team_skills) if team_skills is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _plugin(name: str, enabled: bool) -> MagicMock:
|
||||
record = MagicMock()
|
||||
record.name = name
|
||||
record.enabled = enabled
|
||||
return record
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key_skills", "team_skills", "expected"),
|
||||
[
|
||||
(None, None, frozenset()),
|
||||
([], [], frozenset()),
|
||||
(["a", "b"], None, frozenset({"a", "b"})),
|
||||
(None, ["a", "b"], frozenset({"a", "b"})),
|
||||
(["a", "b"], ["b", "c"], frozenset({"b"})),
|
||||
(["a"], ["c"], frozenset()),
|
||||
(["a", "b"], [], frozenset({"a", "b"})),
|
||||
([], ["a", "b"], frozenset({"a", "b"})),
|
||||
],
|
||||
)
|
||||
def test_granted_skills_intersects_key_with_team(key_skills, team_skills, expected):
|
||||
assert granted_skills(_key(key_skills, team_skills)) == expected
|
||||
|
||||
|
||||
def test_visibility_enabled_plugin_is_public_for_everyone():
|
||||
public = _plugin("public-skill", enabled=True)
|
||||
|
||||
assert skill_visibility(None).allows(public)
|
||||
assert skill_visibility(_key(None, None)).allows(public)
|
||||
assert skill_visibility(_key([], ["other"])).allows(public)
|
||||
|
||||
|
||||
def test_visibility_disabled_plugin_needs_grant_or_admin():
|
||||
private = _plugin("private-skill", enabled=False)
|
||||
admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
assert not skill_visibility(None).allows(private)
|
||||
assert not skill_visibility(_key(None, None)).allows(private)
|
||||
assert not skill_visibility(_key(["other-skill"], None)).allows(private)
|
||||
assert not skill_visibility(_key(["private-skill"], ["other-skill"])).allows(private)
|
||||
assert skill_visibility(_key(["private-skill"], None)).allows(private)
|
||||
assert skill_visibility(_key(None, ["private-skill"])).allows(private)
|
||||
assert skill_visibility(admin).allows(private)
|
||||
|
||||
|
||||
def test_where_clause_bounds_the_plugin_query_to_what_the_caller_may_see():
|
||||
admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
assert skill_visibility(None).where() == {"enabled": True}
|
||||
assert skill_visibility(_key(None, None)).where() == {"enabled": True}
|
||||
assert skill_visibility(_key(["b", "a"], None)).where() == {"OR": [{"enabled": True}, {"name": {"in": ["a", "b"]}}]}
|
||||
assert skill_visibility(_key(["a", "b"], ["b"])).where() == {"OR": [{"enabled": True}, {"name": {"in": ["b"]}}]}
|
||||
assert skill_visibility(admin).where() == {}
|
||||
|
|
@ -3826,3 +3826,17 @@ def test_team_disable_logging_stays_proxy_admin_only():
|
|||
def test_neighbouring_team_routes_stay_closed(route):
|
||||
"""The grant is the callback paths and nothing else on the team namespace."""
|
||||
assert "Only proxy admin" in _gate(route, LitellmUserRoles.INTERNAL_USER.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[
|
||||
"/claude-code/marketplace.json",
|
||||
"/claude-code/plugins",
|
||||
"/claude-code/plugins/my-skill",
|
||||
],
|
||||
)
|
||||
def test_claude_code_marketplace_routes_open_to_internal_users(route):
|
||||
"""Per-skill visibility is enforced inside the handler, so the route gate must let non-admins through."""
|
||||
assert RouteChecks.is_llm_api_route(route) is True
|
||||
assert _gate(route, LitellmUserRoles.INTERNAL_USER.value) == "allowed"
|
||||
|
|
|
|||
|
|
@ -763,6 +763,7 @@ _EXPECTED_CUSTOMER = {
|
|||
"blocked_tools": [],
|
||||
"search_tools": [],
|
||||
"mcp_tool_search_enabled": None,
|
||||
"skills": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,29 @@ async def test_set_object_permission_persists_mcp_tool_search_enabled():
|
|||
assert created_data["mcp_tool_search_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_object_permission_persists_skills():
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_created_permission = MagicMock()
|
||||
mock_created_permission.object_permission_id = "perm_id"
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock(
|
||||
return_value=mock_created_permission
|
||||
)
|
||||
|
||||
data_json = {
|
||||
"object_permission": LiteLLM_ObjectPermissionBase(skills=["private-skill"]).model_dump(),
|
||||
}
|
||||
|
||||
await _set_object_permission(data_json=data_json, prisma_client=mock_prisma_client)
|
||||
|
||||
created_data = (
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.create.call_args.kwargs[
|
||||
"data"
|
||||
]
|
||||
)
|
||||
assert created_data["skills"] == ["private-skill"]
|
||||
|
||||
|
||||
# ---- Tests for _extract_requested_mcp_server_ids ----
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ vi.mock("./mcp_server_management/MCPServerSelector", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("./skills/SkillSelector", () => ({
|
||||
default: ({ onChange }: { onChange: (selected: string[]) => void }) => (
|
||||
<button type="button" data-testid="select-private-skill" onClick={() => onChange(["private-skill"])}>
|
||||
Select private skill
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const can = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
|
||||
default: (...args: unknown[]) => can(...args),
|
||||
|
|
@ -1359,6 +1367,27 @@ describe("Teams - the exact bytes the create call sends", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("puts the selected skills into object_permission.skills and drops the form key", async () => {
|
||||
await openCreateModal();
|
||||
await openSection("Skill Settings", /Allowed Skills/);
|
||||
fireEvent.click(screen.getByTestId("select-private-skill"));
|
||||
|
||||
const payload = await submit();
|
||||
|
||||
expect(payload.object_permission).toStrictEqual({ skills: ["private-skill"] });
|
||||
expect(payload).not.toHaveProperty("object_permission_skills");
|
||||
});
|
||||
|
||||
it("sends no object_permission when Skill Settings is opened but nothing is selected", async () => {
|
||||
await openCreateModal();
|
||||
await openSection("Skill Settings", /Allowed Skills/);
|
||||
|
||||
const payload = await submit();
|
||||
|
||||
expect(payload).not.toHaveProperty("object_permission");
|
||||
expect(payload).not.toHaveProperty("object_permission_skills");
|
||||
});
|
||||
|
||||
it("includes selected MCP toolsets in the create object permission", async () => {
|
||||
await openCreateModal();
|
||||
await openSection("MCP Settings", /Allowed MCP Servers/);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import { Organization, getDefaultTeamSettings, getGuardrailsList, getPoliciesLis
|
|||
import NumericalInput from "./shared/numerical_input";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "./search_tools/SearchToolSelector";
|
||||
import SkillSelector from "./skills/SkillSelector";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface TeamProps {
|
||||
|
|
@ -99,6 +100,7 @@ const teamCreateFieldsSchema = z.object({
|
|||
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
|
||||
allowed_agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
|
||||
object_permission_search_tools: z.array(z.string()).optional(),
|
||||
object_permission_skills: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type TeamCreateFormValues = z.infer<typeof teamCreateFieldsSchema>;
|
||||
|
|
@ -128,6 +130,7 @@ const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = {
|
|||
mcp_tool_permissions: {},
|
||||
allowed_agents_and_groups: undefined,
|
||||
object_permission_search_tools: undefined,
|
||||
object_permission_skills: undefined,
|
||||
};
|
||||
|
||||
const ADDITIONAL_SETTINGS_FIELDS = [
|
||||
|
|
@ -147,6 +150,7 @@ const ADDITIONAL_SETTINGS_FIELDS = [
|
|||
const MCP_SETTINGS_FIELDS = ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"] as const;
|
||||
const AGENT_SETTINGS_FIELDS = ["allowed_agents_and_groups"] as const;
|
||||
const SEARCH_TOOL_SETTINGS_FIELDS = ["object_permission_search_tools"] as const;
|
||||
const SKILL_SETTINGS_FIELDS = ["object_permission_skills"] as const;
|
||||
|
||||
const isParsableJson = (value: string | undefined): boolean => {
|
||||
if (!value) {
|
||||
|
|
@ -214,6 +218,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
const [mcpSettingsOpen, setMcpSettingsOpen] = useState(false);
|
||||
const [agentSettingsOpen, setAgentSettingsOpen] = useState(false);
|
||||
const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false);
|
||||
const [skillSettingsOpen, setSkillSettingsOpen] = useState(false);
|
||||
|
||||
const adminOrgs = useMemo(
|
||||
() => getAdminOrganizations(userRole, userID, organizations),
|
||||
|
|
@ -505,6 +510,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
delete formValues.object_permission_search_tools;
|
||||
}
|
||||
|
||||
if (Array.isArray(formValues.object_permission_skills) && formValues.object_permission_skills.length > 0) {
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.skills = formValues.object_permission_skills;
|
||||
}
|
||||
delete formValues.object_permission_skills;
|
||||
|
||||
// Add model_aliases if any are defined
|
||||
if (Object.keys(modelAliases).length > 0) {
|
||||
formValues.model_aliases = modelAliases;
|
||||
|
|
@ -540,6 +553,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
...(mcpSettingsOpen ? [] : MCP_SETTINGS_FIELDS),
|
||||
...(agentSettingsOpen ? [] : AGENT_SETTINGS_FIELDS),
|
||||
...(searchToolSettingsOpen ? [] : SEARCH_TOOL_SETTINGS_FIELDS),
|
||||
...(skillSettingsOpen ? [] : SKILL_SETTINGS_FIELDS),
|
||||
]);
|
||||
return Object.fromEntries(Object.entries(values).filter(([key]) => !unmounted.has(key)));
|
||||
};
|
||||
|
|
@ -1163,6 +1177,38 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
open={skillSettingsOpen}
|
||||
onOpenChange={setSkillSettingsOpen}
|
||||
className="mt-8 mb-8 overflow-hidden rounded-lg border"
|
||||
>
|
||||
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
|
||||
<b>Skill Settings</b>
|
||||
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="object_permission_skills"
|
||||
className="mt-4"
|
||||
label={labelWithHint(
|
||||
"Allowed Skills",
|
||||
"Enabled skills are visible to every team. Grant disabled (private) Claude Code plugins to this team here.",
|
||||
)}
|
||||
description="Private skills keys on this team may see in the Claude Code marketplace."
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select skills (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
|
||||
<b>Logging Settings</b>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function ObjectPermissionsView({
|
|||
const agents = objectPermission?.agents || [];
|
||||
const agentAccessGroups = objectPermission?.agent_access_groups || [];
|
||||
const searchTools = objectPermission?.search_tools || [];
|
||||
const skills = objectPermission?.skills || [];
|
||||
|
||||
const content = (
|
||||
<div className={variant === "card" ? "grid grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 gap-6" : "space-y-4"}>
|
||||
|
|
@ -58,6 +59,16 @@ export function ObjectPermissionsView({
|
|||
<p className="mt-1 block text-xs break-words text-foreground">{searchTools.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md border border-border p-4">
|
||||
<p className="text-sm font-medium text-foreground">Skills</p>
|
||||
{skills.length === 0 ? (
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
No private skills granted. Only enabled (public) Claude Code plugins are visible.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 block text-xs break-words text-foreground">{skills.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -301,6 +301,16 @@ describe("object_permission", () => {
|
|||
).toStrictEqual(aliasOnly({ object_permission: { agents: ["a-1"], agent_access_groups: ["ag-1"] } }));
|
||||
});
|
||||
|
||||
it("moves the selected skills under object_permission and off the top level", () => {
|
||||
expect(payloadOf(build({ key_alias: "my-key", allowed_skills: ["private-skill"] }))).toStrictEqual(
|
||||
aliasOnly({ object_permission: { skills: ["private-skill"] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no object_permission for an empty skill selection", () => {
|
||||
expect(payloadOf(build({ key_alias: "my-key", allowed_skills: [] }))).toStrictEqual(aliasOnly());
|
||||
});
|
||||
|
||||
it("merges every source into a single object_permission", () => {
|
||||
const everySource = {
|
||||
key_alias: "my-key",
|
||||
|
|
@ -308,6 +318,7 @@ describe("object_permission", () => {
|
|||
allowed_mcp_servers_and_groups: { servers: ["s-1"], accessGroups: ["g-1"], toolsets: ["t-1"] },
|
||||
mcp_tool_permissions: { "s-1": ["read"] },
|
||||
allowed_agents_and_groups: { agents: ["a-1"], accessGroups: ["ag-1"] },
|
||||
allowed_skills: ["private-skill"],
|
||||
};
|
||||
expect(payloadOf(build(everySource))).toStrictEqual(
|
||||
aliasOnly({
|
||||
|
|
@ -319,6 +330,7 @@ describe("object_permission", () => {
|
|||
mcp_tool_permissions: { "s-1": ["read"] },
|
||||
agents: ["a-1"],
|
||||
agent_access_groups: ["ag-1"],
|
||||
skills: ["private-skill"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ interface PermissionSources {
|
|||
readonly toolPermissions: unknown | undefined;
|
||||
readonly extraMcpAccessGroups: unknown[] | undefined;
|
||||
readonly agents: AgentSelection | undefined;
|
||||
readonly skills: unknown[] | undefined;
|
||||
}
|
||||
|
||||
const readPermissionSources = (values: Record<string, unknown>): PermissionSources => ({
|
||||
|
|
@ -120,6 +121,7 @@ const readPermissionSources = (values: Record<string, unknown>): PermissionSourc
|
|||
toolPermissions: readToolPermissions(values.mcp_tool_permissions),
|
||||
extraMcpAccessGroups: nonEmptyList(values.allowed_mcp_access_groups),
|
||||
agents: readAgentSelection(values.allowed_agents_and_groups),
|
||||
skills: nonEmptyList(values.allowed_skills),
|
||||
});
|
||||
|
||||
const buildObjectPermission = ({
|
||||
|
|
@ -128,6 +130,7 @@ const buildObjectPermission = ({
|
|||
toolPermissions,
|
||||
extraMcpAccessGroups,
|
||||
agents,
|
||||
skills,
|
||||
}: PermissionSources): Record<string, unknown> | undefined => {
|
||||
const permission: Record<string, unknown> = {
|
||||
...(vectorStores && { vector_stores: vectorStores }),
|
||||
|
|
@ -138,6 +141,7 @@ const buildObjectPermission = ({
|
|||
...(extraMcpAccessGroups && { mcp_access_groups: extraMcpAccessGroups }),
|
||||
...(agents?.agents && { agents: agents.agents }),
|
||||
...(agents?.accessGroups && { agent_access_groups: agents.accessGroups }),
|
||||
...(skills && { skills }),
|
||||
};
|
||||
return Object.keys(permission).length > 0 ? permission : undefined;
|
||||
};
|
||||
|
|
@ -148,6 +152,7 @@ const consumedSourceKeys = (
|
|||
): ReadonlySet<string> =>
|
||||
new Set<string>([
|
||||
"mcp_tool_permissions",
|
||||
"allowed_skills",
|
||||
...(values.disable_global_guardrails ? [] : ["disable_global_guardrails"]),
|
||||
...(vectorStores ? ["allowed_vector_store_ids"] : []),
|
||||
...(mcp ? ["allowed_mcp_servers_and_groups"] : []),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,13 @@ vi.mock("../networking", async (importOriginal) => {
|
|||
getPossibleUserRoles: vi.fn().mockResolvedValue({}),
|
||||
userFilterUICall: vi.fn().mockResolvedValue([]),
|
||||
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
|
||||
getClaudeCodePluginsList: vi.fn().mockResolvedValue({
|
||||
plugins: [
|
||||
{ name: "public-skill", enabled: true },
|
||||
{ name: "private-skill", enabled: false },
|
||||
],
|
||||
count: 2,
|
||||
}),
|
||||
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
listMCPTools: vi.fn().mockResolvedValue(emptyMcpTools),
|
||||
|
|
@ -109,6 +116,7 @@ const OPENAPI_SCHEMA = {
|
|||
const SECTIONS = {
|
||||
mcp: /MCP Settings/i,
|
||||
agent: /Agent Settings/i,
|
||||
skill: /Skill Settings/i,
|
||||
logging: /Logging Settings/i,
|
||||
router: /Router Settings/i,
|
||||
aliases: /Model Aliases/i,
|
||||
|
|
@ -164,6 +172,7 @@ const ROUTER_SETTINGS_DEFAULT = {
|
|||
const SECTION_PAYLOAD_ADDITIONS: Record<keyof typeof SECTIONS, Record<string, unknown>> = {
|
||||
mcp: { allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] } },
|
||||
agent: { allowed_agents_and_groups: undefined },
|
||||
skill: {},
|
||||
logging: {},
|
||||
router: { router_settings: ROUTER_SETTINGS_DEFAULT },
|
||||
aliases: {},
|
||||
|
|
@ -329,6 +338,21 @@ describe("CreateKey", () => {
|
|||
expect(Object.keys(serialised).sort()).toStrictEqual([...wireKeys].sort());
|
||||
});
|
||||
|
||||
it("moves a picked private skill under object_permission.skills and off the top level", async () => {
|
||||
await openModal();
|
||||
await nameTheKey();
|
||||
await openSection(/Optional Settings/i);
|
||||
await openSection(SECTIONS.skill);
|
||||
await userEvent.click(await screen.findByRole("combobox", { name: "Select skills (optional)" }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "private-skill (private)" }));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await submit();
|
||||
|
||||
const payload = await createdPayload();
|
||||
expect(payload.object_permission).toStrictEqual({ skills: ["private-skill"] });
|
||||
expect(payload).not.toHaveProperty("allowed_skills");
|
||||
});
|
||||
|
||||
it("omits a budget typed into a section the user closed again, rather than sending it as null", async () => {
|
||||
await openModal();
|
||||
await nameTheKey();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
|||
import { type Control, useForm, useWatch, type UseFormSetValue } from "react-hook-form";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import SkillSelector from "../skills/SkillSelector";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
|
||||
import SchemaFormFields from "../common_components/check_openapi_schema";
|
||||
|
|
@ -1557,6 +1558,36 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible className="mt-4 mb-4 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
|
||||
<b>Skill Settings</b>
|
||||
<ChevronDown className={SECTION_CHEVRON_CLASS} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-3">
|
||||
<MountedFormField
|
||||
label={
|
||||
<span>
|
||||
Allowed Skills{" "}
|
||||
<SimpleTooltip content="Enabled skills are visible to every key. Grant disabled (private) Claude Code plugins to this key here">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_skills"
|
||||
help="Select private skills this key can access in the Claude Code marketplace"
|
||||
>
|
||||
{(control) => (
|
||||
<SkillSelector
|
||||
onChange={control.onChange}
|
||||
value={control.value as string[] | undefined}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select skills (optional)"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{premiumUser ? (
|
||||
<Collapsible className="mt-4 mb-4 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { getClaudeCodePluginsList } from "../networking";
|
||||
import SkillSelector from "./SkillSelector";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
getClaudeCodePluginsList: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SkillSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getClaudeCodePluginsList).mockResolvedValue({
|
||||
plugins: [
|
||||
{ name: "public-skill", enabled: true },
|
||||
{ name: "private-skill", enabled: false },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("should list marketplace plugins and mark disabled ones as private", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SkillSelector accessToken="token" onChange={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
expect(await screen.findByRole("option", { name: "public-skill" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "private-skill (private)" })).toBeInTheDocument();
|
||||
expect(getClaudeCodePluginsList).toHaveBeenCalledWith("token");
|
||||
});
|
||||
|
||||
it("should report the selected skill names", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<SkillSelector accessToken="token" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByRole("option", { name: "private-skill (private)" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["private-skill"]);
|
||||
});
|
||||
|
||||
it("should clear all selected skills", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<SkillSelector accessToken="" value={["public-skill"]} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Clear all skills" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
109
ui/litellm-dashboard/src/components/skills/SkillSelector.tsx
Normal file
109
ui/litellm-dashboard/src/components/skills/SkillSelector.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { getClaudeCodePluginsList } from "../networking";
|
||||
|
||||
export interface SkillSelectorProps {
|
||||
onChange: (selected: string[]) => void;
|
||||
value?: string[];
|
||||
className?: string;
|
||||
accessToken: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SkillOption {
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
const readSkillOptions = (data: unknown): SkillOption[] => {
|
||||
const plugins = (data as { plugins?: unknown[] } | undefined)?.plugins;
|
||||
if (!Array.isArray(plugins)) return [];
|
||||
return plugins.flatMap((plugin) => {
|
||||
const record = plugin as { name?: unknown; enabled?: unknown };
|
||||
return typeof record.name === "string" && record.name.length > 0
|
||||
? [{ name: record.name, enabled: record.enabled !== false }]
|
||||
: [];
|
||||
});
|
||||
};
|
||||
|
||||
const SkillSelector: React.FC<SkillSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
accessToken,
|
||||
placeholder = "Select skills (optional)",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [options, setOptions] = useState<SkillOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setOptions(readSkillOptions(await getClaudeCodePluginsList(accessToken)));
|
||||
} catch (e) {
|
||||
console.error("Failed to load skills:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
multiple
|
||||
items={options.map((option) => option.name)}
|
||||
value={value ?? []}
|
||||
onValueChange={(selected: string[]) => onChange(selected)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxChips render={<div ref={anchor} />} className={cn("w-full", className)} aria-busy={loading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((skill) => (
|
||||
<ComboboxChip key={skill} aria-label={skill}>
|
||||
{skill}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder={placeholder} aria-label={placeholder} disabled={disabled} />
|
||||
{value && value.length > 0 && <ComboboxClear aria-label="Clear all skills" disabled={disabled} />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>{loading ? "Loading skills…" : "No skills found"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(skill: string) => {
|
||||
const isPrivate = options.some((option) => option.name === skill && !option.enabled);
|
||||
return (
|
||||
<ComboboxItem key={skill} value={skill} aria-label={isPrivate ? `${skill} (private)` : skill}>
|
||||
{skill}
|
||||
{isPrivate && <span className="ml-2 text-xs text-muted-foreground">private</span>}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillSelector;
|
||||
|
|
@ -52,6 +52,7 @@ vi.mock("@/components/networking", () => ({
|
|||
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
|
||||
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
|
||||
}));
|
||||
|
||||
const can = vi.fn();
|
||||
|
|
@ -1990,6 +1991,7 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
|
|||
agents: [],
|
||||
agent_access_groups: [],
|
||||
vector_stores: ["vs-1"],
|
||||
skills: [],
|
||||
};
|
||||
|
||||
it("leaves every team member key out of the request body for an untouched save with both sections closed", async () => {
|
||||
|
|
@ -2052,6 +2054,47 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
|
|||
expect(objectPermission.agent_access_groups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("resends the stored skills when the selector is left untouched", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ models: ["gpt-4"], object_permission: { skills: ["private-skill"] } }),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...props} />);
|
||||
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await screen.findByLabelText("Team Name");
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
const objectPermission = wireBody(payload).object_permission as Record<string, unknown>;
|
||||
expect(objectPermission.skills).toStrictEqual(["private-skill"]);
|
||||
});
|
||||
|
||||
it("sends an empty skills array after the last skill chip is removed", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ models: ["gpt-4"], object_permission: { skills: ["private-skill"] } }),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...props} />);
|
||||
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await screen.findByLabelText("Team Name");
|
||||
|
||||
await user.click(within(screen.getByLabelText("private-skill")).getByRole("button"));
|
||||
expect(screen.queryByLabelText("private-skill")).not.toBeInTheDocument();
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
const objectPermission = wireBody(payload).object_permission as Record<string, unknown>;
|
||||
expect(objectPermission.skills).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("sends an empty vector_stores array after the last vector store chip is removed", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await openEditor(user);
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ import ObjectPermissionsView from "../object_permissions_view";
|
|||
import NumericalInput from "../shared/numerical_input";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "../search_tools/SearchToolSelector";
|
||||
import SkillSelector from "../skills/SkillSelector";
|
||||
import EditLoggingSettings from "./EditLoggingSettings";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
|
||||
import MemberModal from "./EditMembership";
|
||||
|
|
@ -371,6 +372,7 @@ const teamUpdateFieldsSchema = z.object({
|
|||
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
|
||||
agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
|
||||
object_permission_search_tools: z.array(z.string()).optional(),
|
||||
object_permission_skills: z.array(z.string()).optional(),
|
||||
organization_id: z.string().nullish(),
|
||||
logging_settings: z.array(z.unknown()).optional(),
|
||||
secret_manager_settings: z.string().optional(),
|
||||
|
|
@ -419,6 +421,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
|
|||
mcp_tool_permissions: {},
|
||||
agents_and_groups: { agents: [], accessGroups: [] },
|
||||
object_permission_search_tools: [],
|
||||
object_permission_skills: [],
|
||||
organization_id: null,
|
||||
logging_settings: [],
|
||||
secret_manager_settings: "",
|
||||
|
|
@ -485,6 +488,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
|
|||
accessGroups: info.object_permission?.agent_access_groups || [],
|
||||
},
|
||||
object_permission_search_tools: info.object_permission?.search_tools || [],
|
||||
object_permission_skills: info.object_permission?.skills || [],
|
||||
organization_id: info.organization_id,
|
||||
logging_settings: info.metadata?.logging || [],
|
||||
secret_manager_settings: info.metadata?.secret_manager_settings
|
||||
|
|
@ -1047,6 +1051,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
updateData.object_permission.search_tools = values.object_permission_search_tools;
|
||||
}
|
||||
|
||||
if (Array.isArray(values.object_permission_skills)) {
|
||||
updateData.object_permission.skills = values.object_permission_skills;
|
||||
}
|
||||
|
||||
// Pass access_group_ids to the update request
|
||||
if (values.access_group_ids !== undefined) {
|
||||
updateData.access_group_ids = values.access_group_ids;
|
||||
|
|
@ -1848,6 +1856,24 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="object_permission_skills"
|
||||
label={labelWithHint(
|
||||
"Skills",
|
||||
"Enabled skills are visible to every team. Grant disabled (private) Claude Code plugins to this team here.",
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select skills (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="organization_id" label="Organization">
|
||||
{({ id, value, onChange }) => (
|
||||
<SearchSelect
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import { KeyEditFormValues } from "./keyEditFormValues";
|
||||
import SkillSelector from "../skills/SkillSelector";
|
||||
import { AgentsAndGroups, KeyEditFormValues } from "./keyEditFormValues";
|
||||
|
||||
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
|
||||
<>
|
||||
|
|
@ -53,6 +55,36 @@ export const KeyTypeSelect = ({
|
|||
</Select>
|
||||
);
|
||||
|
||||
const SKILLS_HINT =
|
||||
"Enabled skills are visible to every key. Grant disabled (private) Claude Code plugins to this key here.";
|
||||
|
||||
export const KeyAgentAndSkillFields = ({
|
||||
control,
|
||||
accessToken,
|
||||
}: {
|
||||
control: Control<KeyEditFormValues>;
|
||||
accessToken: string;
|
||||
}) => (
|
||||
<>
|
||||
<FormField control={control} name="agents_and_groups" label="Agents / Access Groups">
|
||||
{({ value, onChange }) => (
|
||||
<AgentSelector
|
||||
onChange={onChange}
|
||||
value={value as AgentsAndGroups | undefined}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select agents or access groups (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={control} name="skills" label={labelWithHint("Skills", SKILLS_HINT)}>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector onChange={onChange} value={value as string[] | undefined} accessToken={accessToken} />
|
||||
)}
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
|
||||
export const KeyBudgetNumberField = ({
|
||||
control,
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -366,6 +366,45 @@ describe("KeyInfoView handleKeyUpdate mcp_toolsets", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("KeyInfoView handleKeyUpdate skills", () => {
|
||||
it("should forward the skills the edit form supplies into object_permission and drop the form key", async () => {
|
||||
renderView(true);
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
skills: ["private-skill"],
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
|
||||
|
||||
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
|
||||
expect(sentPayload.object_permission.skills).toEqual(["private-skill"]);
|
||||
expect(sentPayload).not.toHaveProperty("skills");
|
||||
});
|
||||
|
||||
it("should send an explicit empty skills list when the form clears every skill", async () => {
|
||||
renderView(true);
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
skills: [],
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
|
||||
|
||||
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
|
||||
expect(sentPayload.object_permission.skills).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyInfoView handleKeyUpdate budget_duration", () => {
|
||||
it("should send a canonical budget_duration through unchanged", async () => {
|
||||
renderView(true);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export interface KeyEditFormValues {
|
|||
mcp_servers_and_groups?: McpServersAndGroups;
|
||||
mcp_tool_permissions?: Record<string, string[]>;
|
||||
agents_and_groups?: AgentsAndGroups;
|
||||
skills?: string[];
|
||||
organization_id?: string | null;
|
||||
team_id?: string | null;
|
||||
logging_settings?: unknown[];
|
||||
|
|
@ -102,6 +103,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues =>
|
|||
agents: keyData.object_permission?.agents || [],
|
||||
accessGroups: keyData.object_permission?.agent_access_groups || [],
|
||||
},
|
||||
skills: keyData.object_permission?.skills || [],
|
||||
organization_id: keyData.organization_id,
|
||||
team_id: keyData.team_id,
|
||||
logging_settings: extractLoggingSettings(keyData.metadata),
|
||||
|
|
@ -148,6 +150,7 @@ export const keyEditFormSchema = z.object({
|
|||
mcp_servers_and_groups: z.custom<McpServersAndGroups | undefined>(),
|
||||
mcp_tool_permissions: z.custom<Record<string, string[]> | undefined>(),
|
||||
agents_and_groups: z.custom<AgentsAndGroups | undefined>(),
|
||||
skills: z.custom<string[] | undefined>(),
|
||||
organization_id: z.custom<string | null | undefined>(),
|
||||
team_id: z.custom<string | null | undefined>(),
|
||||
logging_settings: z.custom<unknown[] | undefined>(),
|
||||
|
|
@ -196,6 +199,7 @@ export const toSubmittedValues = (
|
|||
mcp_servers_and_groups: values.mcp_servers_and_groups,
|
||||
mcp_tool_permissions: values.mcp_tool_permissions,
|
||||
agents_and_groups: values.agents_and_groups,
|
||||
skills: values.skills,
|
||||
organization_id: values.organization_id,
|
||||
team_id: values.team_id,
|
||||
logging_settings: values.logging_settings,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ vi.mock("../networking", async () => {
|
|||
agents: [],
|
||||
}),
|
||||
getAgentAccessGroups: vi.fn().mockResolvedValue([]),
|
||||
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -135,6 +136,14 @@ vi.mock("../agent_management/AgentSelector", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("../skills/SkillSelector", () => ({
|
||||
default: ({ onChange }: { onChange: (selected: string[]) => void }) => (
|
||||
<button type="button" data-testid="skill-selector" onClick={() => onChange(["private-skill"])}>
|
||||
pick skill
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/AccessGroupSelector", () => ({
|
||||
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
|
||||
<input
|
||||
|
|
@ -1907,6 +1916,7 @@ describe("KeyEditView", () => {
|
|||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
mcp_tool_permissions: {},
|
||||
agents_and_groups: { agents: [], accessGroups: [] },
|
||||
skills: [],
|
||||
organization_id: null,
|
||||
team_id: null,
|
||||
logging_settings: [],
|
||||
|
|
@ -2241,6 +2251,36 @@ describe("KeyEditView", () => {
|
|||
expect(onSubmitMock.mock.calls[0][0].agents_and_groups.agents).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
it("carries a picked skill into the payload", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock);
|
||||
await screen.findByRole("button", { name: /save changes/i });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "pick skill" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSubmitMock.mock.calls[0][0].skills).toEqual(["private-skill"]);
|
||||
});
|
||||
|
||||
it("preloads the stored skills into the payload when the selector is left untouched", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock, {
|
||||
...MOCK_KEY_DATA,
|
||||
object_permission: { ...MOCK_KEY_DATA.object_permission, skills: ["stored-skill"] },
|
||||
} as KeyResponse);
|
||||
await screen.findByRole("button", { name: /save changes/i });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSubmitMock.mock.calls[0][0].skills).toEqual(["stored-skill"]);
|
||||
});
|
||||
|
||||
it("carries an added logging integration into the payload", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { FormField } from "@/components/shared/form/FormField";
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { hasCapability } from "../../utils/capabilities";
|
||||
import { isProxyAdminRole, rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
|
||||
import { mapInternalToDisplayNames } from "../callback_info_helpers";
|
||||
|
|
@ -32,9 +31,8 @@ import {
|
|||
modelSentinelOptions,
|
||||
parseAllowedRoutes,
|
||||
} from "./keyEditFieldNormalizers";
|
||||
import { KeyBudgetNumberField, KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
|
||||
import { KeyAgentAndSkillFields, KeyBudgetNumberField, KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
|
||||
import {
|
||||
AgentsAndGroups,
|
||||
KeyEditFormValues,
|
||||
keyEditFormSchema,
|
||||
McpServersAndGroups,
|
||||
|
|
@ -762,16 +760,7 @@ export function KeyEditView({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<FormField control={form.control} name="agents_and_groups" label="Agents / Access Groups">
|
||||
{({ value, onChange }) => (
|
||||
<AgentSelector
|
||||
onChange={onChange}
|
||||
value={value as AgentsAndGroups | undefined}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select agents or access groups (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<KeyAgentAndSkillFields control={form.control} accessToken={accessToken || ""} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
|
|||
|
|
@ -277,6 +277,14 @@ export default function KeyInfoView({
|
|||
delete formValues.agents_and_groups;
|
||||
}
|
||||
|
||||
if (formValues.skills !== undefined) {
|
||||
formValues.object_permission = {
|
||||
...formValues.object_permission,
|
||||
skills: formValues.skills || [],
|
||||
};
|
||||
delete formValues.skills;
|
||||
}
|
||||
|
||||
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
|
||||
formValues.tpm_limit = mapEmptyStringToNull(formValues.tpm_limit);
|
||||
formValues.rpm_limit = mapEmptyStringToNull(formValues.rpm_limit);
|
||||
|
|
|
|||
22
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
22
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -2110,12 +2110,17 @@ export interface paths {
|
|||
* - claude plugin marketplace add <url>
|
||||
* - claude plugin install <name>@<marketplace>
|
||||
*
|
||||
* Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`
|
||||
* the key is authenticated and the catalog also holds the disabled plugins granted
|
||||
* to it through `object_permission.skills` on the key or its team.
|
||||
*
|
||||
* Returns:
|
||||
* Marketplace catalog with list of available plugins and their git sources.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
* claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json
|
||||
* claude plugin marketplace add "http://localhost:4000/claude-code/marketplace.json?key=sk-..."
|
||||
* claude plugin install my-plugin@litellm
|
||||
* ```
|
||||
*/
|
||||
|
|
@ -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 []
|
||||
|
|
@ -43206,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;
|
||||
|
|
@ -43222,6 +43233,15 @@ export interface operations {
|
|||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_plugins_claude_code_plugins_get: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue