Compare commits

...

67 commits

Author SHA1 Message Date
yuneng-jiang
9cfc5b97de [Fix] Search Tools: Handle wildcard "*" default in permissions and move auth helper
The DB migration sets search_tools DEFAULT ARRAY['*'], but the auth check
and listing filter treated "*" as a literal name, causing 401s and empty
list responses for all callers with the default permission.

- Add wildcard handling in _can_object_call_search_tools
- Add _normalize_search_tools_wildcard to convert ["*"] → None (no restriction)
- Move get_allowed_search_tool_names from endpoints.py to auth_checks.py
  to eliminate cross-router import between search_tool_management and endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:50:25 -07:00
yuneng-jiang
d8704a59db poetry lock 2026-03-21 17:43:59 -07:00
yuneng-jiang
0ca2472d83 fix(types): resolve mypy errors in search_tool_management.py
Add type casts for TypedDict .get() calls and rename loop variable to
avoid type shadowing between config and DB search tool iterations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:31:48 -07:00
yuneng-jiang
5da2f35fd3 adding build 2026-03-21 17:28:26 -07:00
yuneng-jiang
b69fd49af6 bump: version 0.4.58 → 0.4.59 2026-03-21 17:28:01 -07:00
yuneng-jiang
efa5a3fc69 [Feature] UI - Search Tools: Add permissions UI, modernize page, infinite team dropdown
Add search_tools permission support across the UI (key create/edit, team create,
object permissions view) with breaking-change alerts for the new least-privilege
default. Modernize the Search Tools page with AntD Tabs, a Test playground tab,
and ProviderLogo integration. Migrate TeamDropdown to self-fetching infinite
scroll pattern using useInfiniteTeams hook. Scope search tools visibility for
internal users based on their team memberships.

Backend: Add search_tools field to Prisma schema (all copies), Pydantic models
(ObjectPermissionBase + ObjectPermissionTable), and allowed routes for virtual
keys. Add permission filtering to /search_tools/list endpoint. Include
object_permission in team list v2 queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:23:49 -07:00
yuneng-jiang
75bd742d18
Merge pull request #24239 from BerriAI/litellm_ui_vitest_coverage
[Test] UI: Add vitest coverage for 10 untested components
2026-03-21 14:38:35 -07:00
yuneng-jiang
9f7e65a92d fix(test): add missing team_id to MCP mock tests and update auth test
Tests using MagicMock for MCPServerTable were missing the new team_id
field, causing Pydantic validation errors. Updated auth failure test
to expect 400 (team_id required) instead of 403 since non-admin users
now must provide team_id before permission checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:26:36 -07:00
yuneng-jiang
33aa190b16 fix(test): add missing team_id to MCP sigv4 test mocks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:21:04 -07:00
yuneng-jiang
dae638b2f0 fix(test): update internal user test to match new create flow
Internal users can now see the create modal (with a team selection
prompt). Updated the test from asserting the modal is hidden to
asserting the team selection prompt is shown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:14:34 -07:00
yuneng-jiang
1a63a7bab8 fix(test): update mcp_servers tests for removed Alias column and Team header
- Remove assertions for alias text (column was removed)
- Disambiguate "Team" filter label from "Team (Owner)" column header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:11:54 -07:00
yuneng-jiang
5c953c6b61 fix(ui): handle nullable userRole after removing early return guard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:04:58 -07:00
yuneng-jiang
8366d24e22 fix(ui): use old leftnav (SidebarProvider) in dashboard layout for path-routed pages
The dashboard layout was using Sidebar2, which routed all entries to
path-based URLs like /ui/keys — but only api-reference has been migrated.
Switch back to the old leftnav so unmigrated pages navigate to the legacy
root page (?page=X) and migrated pages use path routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:14:56 -07:00
yuneng-jiang
83eac06b32 feat(ui): add TeamDropdown to MCP create form, fix health recheck cache
- Add TeamDropdown as first field in create MCP server form
- For internal users, hide form fields until team is selected
- Fix useMCPServerHealth to append new servers to cache on recheck

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:12:36 -07:00
yuneng-jiang
456e326c95 fix(ui): reduce MCP table columns and add explicit column widths
Removed low-value columns (Alias, Auth Type, Updated) to reduce
horizontal overflow. Added explicit size hints to remaining columns.
DataTable now applies column sizes to header and body cells.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:11:22 -07:00
yuneng-jiang
a022956724 fix: propagate team_id through MCPServer registry and list responses
Added team_id to the MCPServer model class, build_mcp_server_from_table,
and _build_mcp_server_table so team_id flows from DB → registry →
list endpoint responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:08:00 -07:00
yuneng-jiang
478ec3d392 fix(ui): remove early loading return, let DataTable handle loading state
The early return on missing auth params blocked the entire page from
rendering. The hooks already guard on accessToken being available, and
DataTable shows its own loading state via isLoading prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:04:30 -07:00
yuneng-jiang
baa1fa4151 feat(ui): add Team (Owner) column to MCP servers table
Shows the owning team alias resolved from team_id. Global servers
(team_id=null) display "Global". Header includes info icon explaining
that only servers from teams with mcp:read permission are visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:03:25 -07:00
yuneng-jiang
79ca00bbec fix(ui): show helpful empty state for internal users without MCP access
The list endpoint returns an empty list (not 403) when a user has no
MCP servers available. For internal users, show a message suggesting
they may need mcp:read permission or team MCP server assignments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:59:44 -07:00
yuneng-jiang
017d87b7ab fix(ui): show helpful error when user lacks MCP read permission
Replace "Missing required authentication parameters" with a loading
state. When the MCP servers fetch fails (e.g. 403), show a clear
error message with steps on how to get mcp:read permission.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:58:38 -07:00
yuneng-jiang
535c368f3b fix: proxy admin delete now cleans up ObjectPermissionTable
When a proxy admin deleted an MCP server, team_id was None so
remove_mcp_server_from_team was skipped, leaving stale server IDs
in the team's ObjectPermissionTable. Now fetches the server's
team_id before deletion to ensure cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:50:12 -07:00
yuneng-jiang
e4611faf58 fix: invalidate ObjectPermissionTable cache alongside team cache
_invalidate_team_cache only cleared the team object cache but not the
ObjectPermissionTable cache. After add_mcp_server_to_team, the team
was re-fetched from DB but its object_permission was served from stale
cache, causing newly created servers to not appear in the list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:46:33 -07:00
yuneng-jiang
bb019c8920 fix: ownership transfer DB writes and registry reload ordering
Move team_id clear-to-null and ObjectPermissionTable sync before
registry reload so the cache reflects the new ownership state.
Also update the response object when clearing team_id to null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:36:50 -07:00
yuneng-jiang
bdab813f21 test: add MCP server team ownership tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:28:24 -07:00
yuneng-jiang
5151a835a3 chore: remove unused get_objectpermissions_for_mcp_server import 2026-03-21 12:24:48 -07:00
yuneng-jiang
f51078761a types(ui): add team_id to MCPServer interface 2026-03-21 12:24:41 -07:00
yuneng-jiang
4dd2089eee feat: update endpoint uses server.team_id for auth, supports ownership transfer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:24:00 -07:00
yuneng-jiang
c99257ddd4 feat: delete endpoint uses server.team_id for ownership auth
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:20:43 -07:00
yuneng-jiang
9ff49bc75b feat: set team_id ownership on MCP server creation, registration, and approval
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:18:01 -07:00
yuneng-jiang
cc17c370de types: add team_id to MCPServerTable and UpdateMCPServerRequest models 2026-03-21 12:15:09 -07:00
yuneng-jiang
c893324a5b schema: add team_id column to LiteLLM_MCPServerTable for ownership tracking 2026-03-21 12:14:49 -07:00
yuneng-jiang
334c455150 db: persist team_id on MCP server create and update 2026-03-21 12:14:43 -07:00
yuneng-jiang
31bc13eb17 Merge branch 'main' into litellm_yj_march_20_2026 2026-03-21 10:27:05 -07:00
yuneng-jiang
185db1941f
Merge pull request #24075 from BerriAI/litellm_search-tool-permissions-f5e4
[Feature] Search Tools: Add access control via object permissions
2026-03-21 10:23:08 -07:00
yuneng-jiang
9051bf1e63 chore: remove migration file (will be created via standard process)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:07:15 -07:00
yuneng-jiang
2c4be6f5c7 fix: nullable schema, remove dead code, add return type annotation
- Make search_tools String[]? (nullable) in all 3 schema.prisma files
  so Prisma preserves NULL from DB instead of coercing to []
- Remove get_permitted_search_tool_names (dead code — never called
  from any production path) and its tests
- Add -> bool return type annotation to _can_object_call_search_tools

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:02:47 -07:00
yuneng-jiang
9019207111 fix: revert out-of-scope changes, fix remaining review feedback
- Revert MCP management endpoint changes: team-scoped CRUD depends on
  check_member_permission, add_mcp_server_to_team, remove_mcp_server_from_team
  which exist only in the base branch, not main. Importing them crashes
  all MCP endpoints on main.
- Revert ObjectPermissionTable default changes for pre-existing fields
  (mcp_servers, mcp_access_groups, vector_stores, agents, agent_access_groups)
  back to [] to avoid backwards-incompatible behavior change. Only
  search_tools (new field) uses None default.
- Restore vector store access check: [] = allow all (existing behavior)
- Restore vector store test assertion
- Revert common_utils.py inline import changes (infrastructure not in scope)
- Add ValueError fallback to get_search_tool_access_error_type_for_object
  for unrecognized object_type values

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:42:49 -07:00
yuneng-jiang
d1a0b94919 fix: address greptile feedback on search_tools RBAC
- Migration: remove DEFAULT ARRAY[]::TEXT[] so existing rows get NULL
  (NULL = all access) instead of [] (no access)
- Schema: remove @default([]) for search_tools in all 3 schema.prisma files
- Use cached get_object_permission instead of raw DB queries in
  search_tool_access_check and _get_allowed_search_tool_names
- Reject requests with missing search_tool_name (400) instead of
  silently bypassing access control
- Fix vector store test: [] now correctly denies access (not allows)
- Update search_tool_access_check tests to mock get_object_permission

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:28:08 -07:00
yuneng-jiang
071e590f80
Merge pull request #24274 from BerriAI/litellm_mcp_team_member_ui
[Feature] UI - MCP: Team member permissions drawer and CRUD access
2026-03-21 09:06:10 -07:00
yuneng-jiang
bca07dca61 fix: change ObjectPermissionTable defaults from [] to None, add team-scoped MCP RBAC
- Change all ObjectPermissionTable array field defaults from [] to None
  (None = all access / no restriction, [] = no access)
- Update vector store access check: [] now denies access instead of allowing all
- Add team-scoped MCP server management (create/update/delete) with
  granular permissions (mcp:create, mcp:update, mcp:delete)
- Auto-assign created servers to team's ObjectPermissionTable
- Auto-remove deleted servers from team's ObjectPermissionTable
- Fix unreachable special MCP server name guard in add_mcp_server
- Fix server_id validation ordering in edit_mcp_server
- Fix description typo on PUT /server endpoint
- Move inline imports to module level in common_utils.py (CLAUDE.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:37:20 -07:00
yuneng-jiang
5905100cdc address greptile review feedback (greploop iteration 3)
- Fix double error handling in getTeamPermissionsCall: return empty data
  on HTTP error instead of calling handleError + throwing, preventing
  duplicate error notifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:35:54 -07:00
yuneng-jiang
290706a708 address greptile review feedback (greploop iteration 3)
- Fix double error handling in getTeamPermissionsCall: return empty data
  on HTTP error instead of calling handleError + throwing, preventing
  duplicate error notifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:35:36 -07:00
yuneng-jiang
24be81094b address greptile review feedback (greploop iteration 2)
- Fix unknown permissions duplication: seed selected state with only known
  permissions so existingUnknown and selected are disjoint on save
- Disable Add MCP Server button for non-admins without a team selected,
  show tooltip explaining they need to select a team first

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:28:27 -07:00
yuneng-jiang
42fe911e1c address greptile review feedback (greploop iteration 1)
- Separate save failure from refresh failure: close drawer after successful
  save even if teamInfoCall refresh fails
- Preserve unknown permissions not in availablePermissions when saving,
  preventing silent drops of permissions from newer backend versions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:21:39 -07:00
yuneng-jiang
4f98b4ea3c address greptile review feedback
- Type onUpdate as () => Promise<void> and await it before closing drawer
- Replace accessToken! assertion with explicit null guard
- Gate fetchAvailableTeamMemberPermissions behind canEditTeam check
- Pass team_id for admins too when a team is selected in the filter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:59:42 -07:00
yuneng-jiang
18fae5377e [Feature] UI - MCP: Team member permissions drawer and CRUD access
Add UI support for the MCP team management permissions introduced in PR #24266.

- Add MemberPermissionsDrawer component (Ant Design Drawer) for managing
  per-member MCP permissions (mcp:read, mcp:create, mcp:update, mcp:delete)
- Add permissions button to team member table actions column
- Fetch available permissions from GET /team/available_permissions
- Pass extra_permissions in team member update API calls
- Allow all users to create MCP servers directly (backend enforces permissions)
- Pass team_id when non-admin users create MCP servers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:52:55 -07:00
yuneng-jiang
db8050bb60
Merge pull request #24259 from BerriAI/litellm_ui_refactor_policy_versions_hook
[Refactor] UI - Policies: Extract usePolicyVersions Hook
2026-03-20 23:45:31 -07:00
yuneng-jiang
492c0cd3ba address greptile review feedback (greploop iteration 3)
- Move callbacks outside try/catch so only mutation errors are caught,
  not errors from onVersionCreated/onVersionStatusUpdated callbacks
- Replace policyName! non-null assertion with DISABLED_POLICY_KEY
  sentinel to avoid undefined in cache keys when query is disabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:39:01 -07:00
yuneng-jiang
41d12ed106 address greptile review feedback (greploop iteration 2)
- Add PolicyVersionsData type for select output; specify TData generic
  so consumers get Policy[] (not Policy[] | undefined) for versions
- Remove empty-string queryKey fallback — use policyName! since
  enabled:false prevents fetch when policyName is null
- Add cache invalidation tests for both mutation hooks
- Add explanatory comment for ?? [] fallback in component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:33:50 -07:00
yuneng-jiang
80e55804af [Fix] UI - Policies: Type accuracy and mutation guard for usePolicyVersions
- Make PolicyVersionsResponse.versions optional (Policy[] | undefined)
  to match real API shape — select fallback handles normalization
- Add policyName guard to useUpdatePolicyVersionStatus mutationFn
  to fail loudly instead of silently skipping cache invalidation
- Add test for null policyName in updateStatus mutation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:27:02 -07:00
yuneng-jiang
00f852cec4
Merge pull request #24266 from BerriAI/litellm_/eager-chaum
[Feature] MCP team management + per-member granular permissions
2026-03-20 23:19:54 -07:00
yuneng-jiang
8aa1ebfb07 [Fix] UI - Policies: Fix unhandled promise rejections and isPending semantics
- Wrap mutateAsync calls in try/catch to swallow re-thrown errors
  (notifications already handled by onError in mutation hooks)
- Use isLoading instead of isPending for version loading state —
  isPending is true when query is disabled with no cache, isLoading
  is only true during active fetches (matches original behavior)
- Add isLoading assertions to disabled-state tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:04:25 -07:00
yuneng-jiang
b78e3bad85 address greptile review feedback (greploop iteration 4)
- Move extra_permissions validation before budget upsert to prevent
  partial DB writes on validation failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:54:25 -07:00
yuneng-jiang
7c9664147f address greptile review feedback (greploop iteration 3)
- Revert PUT /v1/mcp/server status code to 202 (backwards-compatible)
- Strengthen Member.extra_permissions validator to check VALID_PERMISSIONS
- Invalidate team cache after add/remove_mcp_server_to_team

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:43:01 -07:00
yuneng-jiang
5acb52b40a address greptile review feedback (greploop iteration 2)
- Handle ValueError on team-link failure as 400 (orphaned server fix)
- Wrap delete's remove_from_team in try/except (prevent 500 after
  successful delete)
- Revert DELETE status code to 202 (backwards-compatible)
- Add extra_permissions to TeamMemberUpdateResponse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:24:02 -07:00
yuneng-jiang
edd41cf88d address greptile review feedback (greploop iteration 1)
- Wrap add/remove_mcp_server_to_team in DB transactions (race condition fix)
- Consolidate role + extra_permissions into single DB write (atomicity)
- Remove silent try/except on team linking (surface errors to caller)
- Add email fallback to _find_member_in_team (email-only members)
- Add None guard on payload.server_id in update endpoint
- Add field_validator on Member.extra_permissions (resource:action format)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:11:17 -07:00
yuneng-jiang
d7890fb278 [Fix] Standardize MCP endpoint HTTP status codes
- PUT /v1/mcp/server: 202 → 200 (synchronous update returns body)
- DELETE /v1/mcp/server: 202 → 204 (synchronous delete, no body)
- Also fix description typo ("deleting" → "updating") on PUT endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:07:48 -07:00
yuneng-jiang
0ca13cc6a5 [Fix] Invalidate team cache after extra_permissions update
Ensures permission changes take effect immediately instead of waiting
for cache TTL expiry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:04:01 -07:00
yuneng-jiang
4f6b8a2a3e [Fix] Exclude team_id from MCP server DB create payload
team_id is a request-level field for auth scoping, not a DB column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:01:16 -07:00
yuneng-jiang
f80d7dc2f2 [Fix] Add /team/available_permissions to self_managed_routes
Without this, internal users get 401 when calling the endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:57:35 -07:00
yuneng-jiang
5d688c9f27 [Feature] MCP team management + per-member granular permissions
Allow team admins and permissioned members to manage MCP servers scoped
to their team, laying groundwork for full Permission Strings RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:53:39 -07:00
yuneng-jiang
79aea5ddcf [Refactor] UI - Policies: Extract usePolicyVersions hook from FlowBuilderPage
Replace useEffect + useState fetch pattern for policy version management
with React Query hooks (useQuery + useMutation), following established
codebase conventions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:25:40 -07:00
yuneng-jiang
4b80094b90 [Test] UI: Add vitest coverage for 10 previously untested components
Add tests for ResponseTimeIndicator, KeyValueInput, QueryParamInput,
RoutePreview, OnboardingModal, EditUserModal, ModelFilters,
AuditLogDrawer, PassThroughInfoView, and EmailSettings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:04:10 -07:00
Cursor Agent
049d951e71
test: add unit tests for search tool access control
- Test _can_object_call_search_tools() with least-privilege semantics
- Test search_tool_access_check() for key/team level permissions
- Test get_permitted_search_tool_names() helper
- Test ProxyErrorTypes for search tool access
- Regression tests ensuring vector store semantics unchanged

Co-authored-by: yuneng-jiang <yuneng-jiang@users.noreply.github.com>
2026-03-19 01:56:59 +00:00
Cursor Agent
fd975053ba
feat: integrate search tool access control into search endpoints
- Add access check in search() endpoint after resolving search_tool_name
- Add _get_allowed_search_tool_names() helper for computing allowed tools
- Filter list_search_tools() results based on key/team permissions
- Least privilege: empty search_tools list means no access

Co-authored-by: yuneng-jiang <yuneng-jiang@users.noreply.github.com>
2026-03-19 01:37:51 +00:00
Cursor Agent
c219302bfe
feat: add search tool access control logic in auth_checks.py
- Add _can_object_call_search_tools() with least-privilege semantics (empty list = no access)
- Add search_tool_access_check() that checks both key-level and team-level permissions
- Add get_permitted_search_tool_names() helper for filtering search tool listings

Co-authored-by: yuneng-jiang <yuneng-jiang@users.noreply.github.com>
2026-03-19 01:36:45 +00:00
Cursor Agent
a8be4d3d68
feat: add search_tools field to ObjectPermission schema and types
- Add search_tools String[] column to LiteLLM_ObjectPermissionTable in all 3 Prisma schema files
- Add Prisma migration for the new column
- Add search_tools field to LiteLLM_ObjectPermissionBase and LiteLLM_ObjectPermissionTable Python types
- Add ProxyErrorTypes for search tool access denied (key, team, org)
- Add get_search_tool_access_error_type_for_object() classmethod to ProxyErrorTypes

Co-authored-by: yuneng-jiang <yuneng-jiang@users.noreply.github.com>
2026-03-19 01:35:49 +00:00
81 changed files with 4203 additions and 631 deletions

Binary file not shown.

View file

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "team_id" TEXT;
-- AlterTable
ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[] DEFAULT ARRAY['*']::TEXT[];

View file

@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable {
mcp_access_groups String[] @default([])
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
vector_stores String[] @default([])
search_tools String[] @default(["*"]) // ["*"] = all access (default), [] = no access, ["tool-a"] = only those tools
agents String[] @default([])
agent_access_groups String[] @default([])
models String[] @default([])
@ -297,6 +298,7 @@ model LiteLLM_MCPServerTable {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
team_id String?
mcp_info Json? @default("{}")
mcp_access_groups String[]
allowed_tools String[] @default([])

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-proxy-extras"
version = "0.4.58"
version = "0.4.59"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
authors = ["BerriAI"]
readme = "README.md"
@ -22,7 +22,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
version = "0.4.58"
version = "0.4.59"
version_files = [
"pyproject.toml:version",
"../requirements.txt:litellm-proxy-extras==",

View file

@ -40,7 +40,7 @@ def _prepare_mcp_server_data(
"""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
# Convert model to dict
# Convert model to dict, excluding fields that are not DB columns
data_dict = data.model_dump(exclude_none=True)
# Ensure alias is always present in the dict (even if None)
if "alias" not in data_dict:

View file

@ -669,6 +669,7 @@ class MCPServerManager:
available_on_public_internet=bool(
getattr(mcp_server, "available_on_public_internet", True)
),
team_id=getattr(mcp_server, "team_id", None),
created_at=getattr(mcp_server, "created_at", None),
updated_at=getattr(mcp_server, "updated_at", None),
tool_name_to_display_name=_deserialize_json_dict(
@ -2714,6 +2715,7 @@ class MCPServerManager:
auth_type=server.auth_type,
created_at=server.created_at,
updated_at=server.updated_at,
team_id=server.team_id,
teams=[],
mcp_access_groups=server.access_groups or [],
allowed_tools=server.allowed_tools or [],
@ -2805,6 +2807,7 @@ class MCPServerManager:
auth_type=server.auth_type,
created_at=server.created_at,
updated_at=server.updated_at,
team_id=server.team_id,
teams=[],
mcp_access_groups=server.access_groups or [],
allowed_tools=server.allowed_tools or [],

View file

@ -372,6 +372,10 @@ class LiteLLMRoutes(enum.Enum):
"/v1/search",
"/search/{search_tool_name}",
"/v1/search/{search_tool_name}",
"/search_tools/list",
"/search_tools/ui/available_providers",
"/search/tools",
"/v1/search/tools",
# OCR
"/ocr",
"/v1/ocr",
@ -655,6 +659,7 @@ class LiteLLMRoutes(enum.Enum):
"/model/delete",
"/user/daily/activity",
"/user/available_roles", # read-only role metadata; any authenticated user may read
"/team/available_permissions", # read-only permission metadata; any authenticated user may read
"/user/list", # org admins checked in endpoint; non-admins get 403
"/model/{model_id}/update",
"/prompt/list",
@ -856,6 +861,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
mcp_access_groups: Optional[List[str]] = None
mcp_tool_permissions: Optional[Dict[str, List[str]]] = None
vector_stores: Optional[List[str]] = None
search_tools: Optional[List[str]] = ["*"] # ["*"] = all access, [] = no access
agents: Optional[List[str]] = None
agent_access_groups: Optional[List[str]] = None
models: Optional[List[str]] = None
@ -1104,6 +1110,11 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
server_name: Optional[str] = None
alias: Optional[str] = None
description: Optional[str] = None
team_id: Optional[str] = Field(
default=None,
description="Team ID to scope this MCP server to. Required for non-proxy-admin users. "
"When provided, the server is auto-assigned to the team's ObjectPermissionTable.",
)
transport: MCPTransportType = MCPTransport.sse
auth_type: Optional[MCPAuthType] = None
credentials: Optional[MCPCredentials] = None
@ -1204,6 +1215,11 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
byok_description: List[str] = Field(default_factory=list)
byok_api_key_help_url: Optional[str] = None
source_url: Optional[str] = None
team_id: Optional[str] = Field(
default=None,
description="Team ID that owns this MCP server. Only proxy admins can change ownership. "
"Set to null to make the server global.",
)
@model_validator(mode="before")
@classmethod
@ -1239,6 +1255,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
created_by: Optional[str] = None
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
team_id: Optional[str] = None
teams: List[Dict[str, Optional[str]]] = Field(default_factory=list)
mcp_access_groups: List[str] = Field(default_factory=list)
allowed_tools: List[str] = Field(default_factory=list)
@ -1611,6 +1628,33 @@ class Member(MemberBase):
] = Field(
description="The role of the user within the team. 'admin' users can manage team settings and members, 'user' is a regular team member"
)
extra_permissions: Optional[List[str]] = Field(
default=None,
description="Granular permissions granted to this member (e.g. 'mcp:create', 'mcp:delete'). Used for per-member permission grants.",
)
@field_validator("extra_permissions", mode="before")
@classmethod
def validate_permission_format(cls, v):
"""Validate that all permission strings are known valid permissions."""
if v is None:
return v
if not isinstance(v, list):
raise ValueError("extra_permissions must be a list of strings")
from litellm.proxy.auth.permissions import VALID_PERMISSIONS
for perm in v:
if not isinstance(perm, str) or ":" not in perm:
raise ValueError(
f"Invalid permission format: '{perm}'. "
"Must follow 'resource:action' format (e.g. 'mcp:create')."
)
if perm not in VALID_PERMISSIONS:
raise ValueError(
f"Unknown permission: '{perm}'. "
f"Valid permissions: {sorted(VALID_PERMISSIONS)}"
)
return v
class OrgMember(MemberBase):
@ -1844,6 +1888,7 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
"""
vector_stores: Optional[List[str]] = []
search_tools: Optional[List[str]] = ["*"] # ["*"] = all access, [] = no access
agents: Optional[List[str]] = []
agent_access_groups: Optional[List[str]] = []
@ -3530,6 +3575,21 @@ class ProxyErrorTypes(str, enum.Enum):
Organization does not have access to the vector store
"""
key_search_tool_access_denied = "key_search_tool_access_denied"
"""
Key does not have access to the search tool
"""
team_search_tool_access_denied = "team_search_tool_access_denied"
"""
Team does not have access to the search tool
"""
org_search_tool_access_denied = "org_search_tool_access_denied"
"""
Organization does not have access to the search tool
"""
team_member_already_in_team = "team_member_already_in_team"
"""
Team member is already in team
@ -3572,6 +3632,23 @@ class ProxyErrorTypes(str, enum.Enum):
elif object_type == "org":
return cls.org_vector_store_access_denied
@classmethod
def get_search_tool_access_error_type_for_object(
cls, object_type: Literal["key", "team", "org"]
) -> "ProxyErrorTypes":
"""
Get the search tool access error type for object_type
"""
if object_type == "key":
return cls.key_search_tool_access_denied
elif object_type == "team":
return cls.team_search_tool_access_denied
elif object_type == "org":
return cls.org_search_tool_access_denied
raise ValueError(
f"Unknown object_type '{object_type}' for search tool access error"
)
DB_CONNECTION_ERROR_TYPES = (
httpx.ConnectError,
@ -3729,6 +3806,10 @@ class TeamMemberDeleteRequest(MemberDeleteRequest):
class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
max_budget_in_team: Optional[float] = None
role: Optional[Literal["admin", "user"]] = None
extra_permissions: Optional[List[str]] = Field(
default=None,
description="Granular permissions to grant to this team member (e.g. ['mcp:create', 'mcp:delete']). Replaces any existing extra_permissions.",
)
tpm_limit: Optional[int] = Field(
default=None, description="Tokens per minute limit for this team member"
)
@ -3740,6 +3821,7 @@ class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
class TeamMemberUpdateResponse(MemberUpdateResponse):
team_id: str
max_budget_in_team: Optional[float] = None
extra_permissions: Optional[List[str]] = None
tpm_limit: Optional[int] = None
rpm_limit: Optional[int] = None

View file

@ -3574,3 +3574,177 @@ def _can_object_call_vector_stores(
)
return True
def _can_object_call_search_tools(
object_type: Literal["key", "team", "org"],
search_tool_name: str,
object_permissions: Optional[LiteLLM_ObjectPermissionTable],
) -> bool:
"""
Raises ProxyException if the object (key, team, org) cannot access the specific search tool.
Key difference from vector stores: follows principle of least privilege.
- object_permissions is None allow (no permission record)
- search_tools is None allow (field not configured, no restriction)
- search_tools == [] DENY ALL (empty list = no access granted)
- search_tool_name in search_tools allow
- search_tool_name not in search_tools deny
"""
if object_permissions is None:
return True
if object_permissions.search_tools is None:
return True
# Wildcard "*" = all tools accessible (migration default)
if "*" in object_permissions.search_tools:
return True
# Empty list = no access (principle of least privilege)
# Non-empty list = only listed tools are accessible
if search_tool_name not in object_permissions.search_tools:
raise ProxyException(
message=f"User not allowed to access search tool '{search_tool_name}'. Allowed search tools: {object_permissions.search_tools}",
type=ProxyErrorTypes.get_search_tool_access_error_type_for_object(
object_type
),
param="search_tool",
code=status.HTTP_401_UNAUTHORIZED,
)
return True
async def search_tool_access_check(
search_tool_name: str,
valid_token: Optional[UserAPIKeyAuth],
):
"""
Checks if the key/team has access to a specific search tool.
Uses valid_token.object_permission_id (key level) and
valid_token.team_object_permission_id (team level) to look up permissions
via the cached get_object_permission helper.
Raises ProxyException if access is denied.
"""
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if prisma_client is None:
verbose_proxy_logger.debug(
"Prisma client not found, skipping search tool access check"
)
return True
# Check key-level permissions
if valid_token is not None and valid_token.object_permission_id is not None:
key_object_permission = await get_object_permission(
object_permission_id=valid_token.object_permission_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=getattr(valid_token, "parent_otel_span", None),
proxy_logging_obj=proxy_logging_obj,
)
if key_object_permission is not None:
_can_object_call_search_tools(
object_type="key",
search_tool_name=search_tool_name,
object_permissions=key_object_permission,
)
# Check team-level permissions
team_object_permission_id = (
getattr(valid_token, "team_object_permission_id", None)
if valid_token
else None
)
if team_object_permission_id is not None:
team_object_permission = await get_object_permission(
object_permission_id=team_object_permission_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=getattr(valid_token, "parent_otel_span", None),
proxy_logging_obj=proxy_logging_obj,
)
if team_object_permission is not None:
_can_object_call_search_tools(
object_type="team",
search_tool_name=search_tool_name,
object_permissions=team_object_permission,
)
return True
def _normalize_search_tools_wildcard(
search_tools: Optional[List[str]],
) -> Optional[List[str]]:
"""Treat ["*"] (the migration default) as None (no restriction)."""
if search_tools is not None and "*" in search_tools:
return None
return search_tools
async def get_allowed_search_tool_names(
user_api_key_dict: "UserAPIKeyAuth",
) -> Optional[List[str]]:
"""
Compute the intersection of key-level and team-level search tool permissions.
Returns:
None no restriction (all tools accessible)
list only those tool names are accessible (may be empty = none)
"""
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if prisma_client is None:
return None
key_allowed: Optional[List[str]] = None
team_allowed: Optional[List[str]] = None
# Key-level permissions (via cached helper)
if user_api_key_dict.object_permission_id is not None:
key_perm = await get_object_permission(
object_permission_id=user_api_key_dict.object_permission_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=getattr(user_api_key_dict, "parent_otel_span", None),
proxy_logging_obj=proxy_logging_obj,
)
if key_perm is not None:
key_allowed = _normalize_search_tools_wildcard(key_perm.search_tools)
# Team-level permissions (via cached helper)
team_perm_id = getattr(user_api_key_dict, "team_object_permission_id", None)
if team_perm_id is not None:
team_perm = await get_object_permission(
object_permission_id=team_perm_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=getattr(user_api_key_dict, "parent_otel_span", None),
proxy_logging_obj=proxy_logging_obj,
)
if team_perm is not None:
team_allowed = _normalize_search_tools_wildcard(team_perm.search_tools)
# Combine: both None → None (no restriction)
# One set → use that set
# Both set → intersection
if key_allowed is None and team_allowed is None:
return None
if key_allowed is None:
return team_allowed
if team_allowed is None:
return key_allowed
# Both are set - return the intersection
return list(set(key_allowed) & set(team_allowed))

View file

@ -0,0 +1,59 @@
"""
Granular permission strings for the LiteLLM proxy.
Permission format: `resource:action`
This module defines the valid permission strings that can be granted to
team members via `extra_permissions`. It is the first step toward a full
Permission Strings RBAC system (custom roles, org intersection, denied_permissions).
New resource permissions should be added here as enums and included in
VALID_PERMISSIONS so that the validation in team_member_update rejects typos.
"""
from enum import Enum
from typing import Dict, List
class MCPPermission(str, Enum):
"""Permissions for MCP server management."""
READ = "mcp:read"
CREATE = "mcp:create"
UPDATE = "mcp:update"
DELETE = "mcp:delete"
# All known permission strings — used for validation when granting permissions.
# Grows as more resource permissions are added (keys:create, teams:create, etc.)
VALID_PERMISSIONS: set = {p.value for p in MCPPermission}
def get_available_permissions() -> List[Dict[str, str]]:
"""
Return the list of valid permission strings with labels, grouped by resource.
Used by the GET /team/available_permissions endpoint for UI dropdowns.
"""
return [
{
"value": MCPPermission.READ.value,
"label": "View MCP servers",
"resource": "mcp",
},
{
"value": MCPPermission.CREATE.value,
"label": "Create MCP servers",
"resource": "mcp",
},
{
"value": MCPPermission.UPDATE.value,
"label": "Edit MCP servers",
"resource": "mcp",
},
{
"value": MCPPermission.DELETE.value,
"label": "Delete MCP servers",
"resource": "mcp",
},
]

View file

@ -11,6 +11,7 @@ from litellm.proxy._types import (
LiteLLM_TeamTable,
LiteLLM_UserTable,
LitellmUserRoles,
Member,
NewProjectRequest,
UpdateProjectRequest,
UserAPIKeyAuth,
@ -41,6 +42,70 @@ def _is_user_team_admin(
return False
def _find_member_in_team(
user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable
) -> Optional[Member]:
"""Find and return the Member object for the given user in the team, or None.
Matches by user_id first, then falls back to user_email for email-only members.
"""
for member in team_obj.members_with_roles:
if (
user_api_key_dict.user_id
and member.user_id is not None
and member.user_id == user_api_key_dict.user_id
):
return member
# Fallback: match by email for email-only members
if user_api_key_dict.user_email:
for member in team_obj.members_with_roles:
if (
member.user_email is not None
and member.user_email == user_api_key_dict.user_email
):
return member
return None
def check_member_permission(
user_api_key_dict: UserAPIKeyAuth,
team_obj: LiteLLM_TeamTable,
required_permission: str,
) -> bool:
"""
Check if a user has a specific permission for a team.
Resolution order:
1. Proxy admin True
2. Team admin True
3. Member with required_permission in extra_permissions True
4. Otherwise False
This is the minimal permission check for the first phase of granular RBAC.
When the full RBAC engine ships, this function will be replaced by
check_permission() in permissions.py with role resolution, org intersection,
and denied_permissions support.
"""
# 1. Proxy admin
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return True
# Find member in team
member = _find_member_in_team(user_api_key_dict, team_obj)
if member is None:
return False
# 2. Team admin
if member.role == "admin":
return True
# 3. Check extra_permissions
if member.extra_permissions and required_permission in member.extra_permissions:
return True
return False
async def _is_user_org_admin_for_team(
user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable
) -> bool:

View file

@ -111,6 +111,7 @@ if MCP_AVAILABLE:
build_effective_auth_contexts,
)
from litellm.proxy._types import (
UI_TEAM_ID,
LiteLLM_MCPServerTable,
LitellmUserRoles,
MakeMCPServersPublicRequest,
@ -130,7 +131,14 @@ if MCP_AVAILABLE:
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_view,
check_member_permission,
)
from litellm.proxy.management_helpers.object_permission_utils import (
add_mcp_server_to_team,
remove_mcp_server_from_team,
)
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.types.mcp import MCPCredentials
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@ -914,6 +922,19 @@ if MCP_AVAILABLE:
validate_and_normalize_mcp_server_payload(payload)
_validate_mcp_required_fields(payload)
# Guard against virtual UI-session team
register_team_id = user_api_key_dict.team_id
if register_team_id == UI_TEAM_ID:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "Cannot register MCP servers with the dashboard session team. Use a real team-scoped key."
},
)
# Set ownership — registered servers belong to the submitter's team
payload.team_id = register_team_id
payload.approval_status = MCPApprovalStatus.pending_review
payload.submitted_by = user_api_key_dict.user_id
payload.submitted_at = datetime.now(timezone.utc)
@ -1008,6 +1029,18 @@ if MCP_AVAILABLE:
)
await global_mcp_server_manager.reload_servers_from_database()
# Grant the owning team access to the now-approved server
approved_server = await get_mcp_server(prisma_client, server_id)
if approved_server and approved_server.team_id:
try:
await add_mcp_server_to_team(
prisma_client, approved_server.team_id, server_id
)
except ValueError:
verbose_proxy_logger.warning(
f"Could not add approved server {server_id} to team {approved_server.team_id}: team not found"
)
return _redact_mcp_credentials(approved)
@router.put(
@ -1206,14 +1239,39 @@ if MCP_AVAILABLE:
# Validate and normalize payload fields
validate_and_normalize_mcp_server_payload(payload)
# AuthZ - restrict only proxy admins to create mcp servers
# AuthZ - proxy admins, team admins, or members with mcp:create permission
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.proxy_server import user_api_key_cache
team_obj = None
team_id = payload.team_id or user_api_key_dict.team_id
# litellm-dashboard is a virtual UI-session team, not a real DB team
if team_id == UI_TEAM_ID:
team_id = None
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "User does not have permission to create mcp servers. You can only create mcp servers if you are a PROXY_ADMIN."
},
if not team_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "team_id is required for non-proxy-admin users to create MCP servers."
},
)
team_obj = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
if not check_member_permission(
user_api_key_dict, team_obj, "mcp:create"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "User does not have permission to create MCP servers for this team. "
"Requires team admin role or 'mcp:create' permission."
},
)
# Block reserved special server IDs
if (
@ -1240,6 +1298,9 @@ if MCP_AVAILABLE:
# TODO: audit log for create
# Set ownership team_id on the server record
payload.team_id = team_id
# Admin-created servers are always active — clear any submission lifecycle
# fields the caller may have provided to prevent fake entries appearing in
# the submissions queue.
@ -1254,16 +1315,32 @@ if MCP_AVAILABLE:
payload,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
)
await global_mcp_server_manager.add_server(new_mcp_server)
# Ensure registry is up to date by reloading from database
await global_mcp_server_manager.reload_servers_from_database()
except Exception as e:
verbose_proxy_logger.exception(f"Error creating mcp server: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": f"Error creating mcp server: {str(e)}"},
)
# Auto-assign server to team's ObjectPermissionTable if team-scoped.
# Must happen before the server manager reload so the registry
# reflects team membership immediately.
if team_id and new_mcp_server.server_id:
try:
await add_mcp_server_to_team(
prisma_client, team_id, new_mcp_server.server_id
)
except ValueError as e:
# Team not found — surface as 400 so caller knows
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": str(e)},
)
await global_mcp_server_manager.add_server(new_mcp_server)
# Ensure registry is up to date by reloading from database
await global_mcp_server_manager.reload_servers_from_database()
return _redact_mcp_credentials(new_mcp_server)
@router.post(
@ -1452,7 +1529,7 @@ if MCP_AVAILABLE:
@router.delete(
"/server/{server_id}",
description="Allows deleting mcp serves in the db",
description="Allows deleting mcp servers in the db",
dependencies=[Depends(user_api_key_auth)],
response_class=JSONResponse,
status_code=status.HTTP_202_ACCEPTED,
@ -1480,16 +1557,55 @@ if MCP_AVAILABLE:
"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
)
# Authz - restrict only admins to delete mcp servers
# AuthZ - proxy admins, team admins, or members with mcp:delete permission
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.proxy_server import user_api_key_cache
team_id: Optional[str] = None
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Call not allowed to delete MCP server. User is not a proxy admin. route={}".format(
"DELETE /v1/mcp/server"
)
},
# Look up the server's owning team directly
mcp_server = await get_mcp_server(prisma_client, server_id)
if mcp_server is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": f"MCP Server not found, server_id={server_id}"
},
)
team_id = mcp_server.team_id
if not team_id:
# Global server (team_id=NULL) — only proxy admins can manage
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "This is a global MCP server. Only proxy admins can delete it."
},
)
team_obj = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
if not check_member_permission(
user_api_key_dict, team_obj, "mcp:delete"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "User does not have permission to delete this MCP server. "
"Requires team admin role or 'mcp:delete' permission in the server's owning team."
},
)
# For proxy admins, look up the server's team_id before deleting
# so we can clean up ObjectPermissionTable
if not team_id and LitellmUserRoles.PROXY_ADMIN == user_api_key_dict.user_role:
existing_server = await get_mcp_server(prisma_client, server_id)
if existing_server:
team_id = existing_server.team_id
# try to delete the mcp server
mcp_server_record_deleted = await delete_mcp_server(prisma_client, server_id)
@ -1504,16 +1620,20 @@ if MCP_AVAILABLE:
# Ensure registry is up to date by reloading from database
await global_mcp_server_manager.reload_servers_from_database()
# Remove server from team's ObjectPermissionTable
if team_id:
try:
await remove_mcp_server_from_team(prisma_client, team_id, server_id)
except Exception as e:
verbose_proxy_logger.warning(
f"Failed to remove server {server_id} from team {team_id} permissions: {e}. "
"Server was deleted but team's ObjectPermissionTable may contain a stale entry."
)
# TODO: Enterprise: Finish audit log trail
if litellm.store_audit_logs:
pass
# TODO: Delete from virtual keys
# TODO: Delete from teams
# Update from global mcp store
return Response(status_code=status.HTTP_202_ACCEPTED)
@router.post(
@ -1774,7 +1894,7 @@ if MCP_AVAILABLE:
@router.put(
"/server",
description="Allows deleting mcp serves in the db",
description="Allows updating mcp servers in the db",
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_MCPServerTable,
status_code=status.HTTP_202_ACCEPTED,
@ -1805,16 +1925,67 @@ if MCP_AVAILABLE:
# Validate and normalize payload fields
validate_and_normalize_mcp_server_payload(payload)
# Authz - restrict only admins to delete mcp servers
# AuthZ - proxy admins, team admins, or members with mcp:update permission
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.proxy_server import user_api_key_cache
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Call not allowed to update MCP server. User is not a proxy admin. route={}".format(
"PUT /v1/mcp/server"
)
},
mcp_server = await get_mcp_server(prisma_client, payload.server_id)
if mcp_server is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": f"MCP Server not found, server_id={payload.server_id}"
},
)
owner_team_id = mcp_server.team_id
if not owner_team_id:
# Global server (team_id=NULL) — only proxy admins can manage
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "This is a global MCP server. Only proxy admins can update it."
},
)
team_obj = await get_team_object(
team_id=owner_team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
if not check_member_permission(
user_api_key_dict, team_obj, "mcp:update"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "User does not have permission to update this MCP server. "
"Requires team admin role or 'mcp:update' permission in the server's owning team."
},
)
# Non-admins cannot change ownership at all
if "team_id" in payload.model_fields_set:
if payload.team_id != owner_team_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Only proxy admins can change MCP server ownership."
},
)
# Preserve existing team_id — non-admins can't change it
payload.team_id = owner_team_id
# For proxy admins: detect if ownership is changing
is_admin = LitellmUserRoles.PROXY_ADMIN == user_api_key_dict.user_role
team_id_explicitly_set = "team_id" in payload.model_fields_set
old_team_id: Optional[str] = None
if is_admin and team_id_explicitly_set:
existing_server = await get_mcp_server(prisma_client, payload.server_id)
if existing_server:
old_team_id = existing_server.team_id
# try to update the mcp server
mcp_server_record_updated = await update_mcp_server(
@ -1830,6 +2001,42 @@ if MCP_AVAILABLE:
"error": f"MCP Server not found, passed server_id={payload.server_id}"
},
)
# Handle explicit team_id changes (including clearing to null/global)
# Must happen before registry reload so the cache reflects the new state.
if is_admin and team_id_explicitly_set:
new_team_id = payload.team_id # could be a team ID or None (global)
# Direct DB update for team_id — exclude_none in _prepare_mcp_server_data
# skips None, so we must write it explicitly when clearing to global
if new_team_id is None:
await prisma_client.db.litellm_mcpservertable.update(
where={"server_id": payload.server_id},
data={"team_id": None},
)
if mcp_server_record_updated is not None:
mcp_server_record_updated.team_id = None
# Sync ObjectPermissionTable
if old_team_id != new_team_id:
if old_team_id:
try:
await remove_mcp_server_from_team(
prisma_client, old_team_id, payload.server_id
)
except Exception as e:
verbose_proxy_logger.warning(
f"Failed to remove server {payload.server_id} from old team {old_team_id}: {e}"
)
if new_team_id:
try:
await add_mcp_server_to_team(
prisma_client, new_team_id, payload.server_id
)
except ValueError as e:
verbose_proxy_logger.warning(
f"Failed to add server {payload.server_id} to new team {new_team_id}: {e}"
)
await global_mcp_server_manager.update_server(mcp_server_record_updated)
# Ensure registry is up to date by reloading from database
@ -1958,16 +2165,6 @@ if MCP_AVAILABLE:
Used by the UI to show a discovery grid when adding new MCP servers.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={
"error": "Only proxy admins can access MCP discovery. Your role={}".format(
user_api_key_dict.user_role
)
},
)
registry = _load_mcp_registry()
servers = registry.get("servers", [])

View file

@ -1001,7 +1001,7 @@ async def new_team( # noqa: PLR0915
team_row: LiteLLM_TeamTable = await prisma_client.db.litellm_teamtable.create(
data=complete_team_data_dict,
include={"litellm_model_table": True}, # type: ignore
include={"litellm_model_table": True, "object_permission": True}, # type: ignore
)
## ADD TEAM ID TO USER TABLE ##
@ -1550,7 +1550,7 @@ async def update_team( # noqa: PLR0915
] = await prisma_client.db.litellm_teamtable.update(
where={"team_id": data.team_id},
data=updated_kv,
include={"litellm_model_table": True}, # type: ignore
include={"litellm_model_table": True, "object_permission": True}, # type: ignore
)
if team_row is None or team_row.team_id is None:
@ -2424,6 +2424,20 @@ async def team_member_update(
identified_budget_id = tm.budget_id
break
### Validate extra_permissions BEFORE any DB writes
if data.extra_permissions is not None:
from litellm.proxy.auth.permissions import VALID_PERMISSIONS
invalid_perms = set(data.extra_permissions) - VALID_PERMISSIONS
if invalid_perms:
raise HTTPException(
status_code=400,
detail={
"error": f"Invalid permission strings: {sorted(invalid_perms)}. "
f"Valid permissions: {sorted(VALID_PERMISSIONS)}"
},
)
### upsert new budget
async with prisma_client.db.tx() as tx:
await _upsert_budget_and_membership(
@ -2437,39 +2451,69 @@ async def team_member_update(
rpm_limit=data.rpm_limit,
)
### update team member role
if data.role is not None:
team_members: List[Member] = []
### Apply role and extra_permissions updates in-memory, then do a single DB write
members_changed = data.role is not None or data.extra_permissions is not None
if members_changed:
updated_members: List[Member] = []
for member in team_table.members_with_roles:
if member.user_id == received_user_id:
team_members.append(
updated_members.append(
Member(
user_id=member.user_id,
role=data.role,
role=data.role if data.role is not None else member.role,
user_email=data.user_email or member.user_email,
extra_permissions=(
data.extra_permissions
if data.extra_permissions is not None
else member.extra_permissions
),
)
)
else:
team_members.append(member)
updated_members.append(member)
team_table.members_with_roles = team_members
team_table.members_with_roles = updated_members
_db_team_members: List[dict] = [m.model_dump() for m in team_members]
_db_team_members: List[dict] = [m.model_dump() for m in updated_members]
await prisma_client.db.litellm_teamtable.update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_team_members)}, # type: ignore
)
# Invalidate team cache so changes take effect immediately
from litellm.proxy.proxy_server import user_api_key_cache
user_api_key_cache.delete_cache(key="team_id:{}".format(data.team_id))
return TeamMemberUpdateResponse(
team_id=data.team_id,
user_id=received_user_id,
user_email=data.user_email,
max_budget_in_team=data.max_budget_in_team,
extra_permissions=data.extra_permissions,
tpm_limit=data.tpm_limit,
rpm_limit=data.rpm_limit,
)
@router.get(
"/team/available_permissions",
tags=["team management"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_available_permissions(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
List all valid permission strings that can be granted to team members.
Used by the UI to populate permission dropdowns when editing team member permissions.
"""
from litellm.proxy.auth.permissions import get_available_permissions
return get_available_permissions()
def _create_results_from_response(
members: List[Member],
response: TeamAddMemberResponse,
@ -3546,6 +3590,7 @@ async def list_team_v2(
skip=skip,
take=page_size,
order=order_by if order_by else {"created_at": "desc"}, # Default sort
include={"object_permission": True},
)
# Get total count for pagination
total_count = await prisma_client.db.litellm_teamtable.count(
@ -3624,7 +3669,7 @@ async def _authorize_and_filter_teams(
# Org admin: query DB for teams in their orgs
org_teams = await prisma_client.db.litellm_teamtable.find_many(
where={"organization_id": {"in": allowed_org_ids}},
include={"litellm_model_table": True},
include={"litellm_model_table": True, "object_permission": True},
)
if not user_id:
return list(org_teams)
@ -3634,7 +3679,7 @@ async def _authorize_and_filter_teams(
# Prisma doesn't support filtering JSON array fields, so we fetch by membership separately
member_teams = await prisma_client.db.litellm_teamtable.find_many(
where={"team_id": {"not_in": list(seen_team_ids)}} if seen_team_ids else {},
include={"litellm_model_table": True},
include={"litellm_model_table": True, "object_permission": True},
)
for team in member_teams:
if team.members_with_roles and any(
@ -3645,7 +3690,7 @@ async def _authorize_and_filter_teams(
elif user_id:
# Regular user: fetch all and filter by membership (Prisma can't filter JSON arrays)
response = await prisma_client.db.litellm_teamtable.find_many(
include={"litellm_model_table": True}
include={"litellm_model_table": True, "object_permission": True}
)
return [
team
@ -3657,7 +3702,7 @@ async def _authorize_and_filter_teams(
# Proxy admin: all teams
return list(
await prisma_client.db.litellm_teamtable.find_many(
include={"litellm_model_table": True}
include={"litellm_model_table": True, "object_permission": True}
)
)

View file

@ -364,3 +364,112 @@ async def validate_key_mcp_servers_against_team(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": detail},
)
def _invalidate_team_cache(
team_id: str, object_permission_id: Optional[str] = None
) -> None:
"""Invalidate the cached team object and its ObjectPermissionTable so subsequent reads see updated data."""
from litellm.proxy.proxy_server import user_api_key_cache
user_api_key_cache.delete_cache(key="team_id:{}".format(team_id))
if object_permission_id:
user_api_key_cache.delete_cache(
key="object_permission_id:{}".format(object_permission_id)
)
async def add_mcp_server_to_team(
prisma_client: PrismaClient, team_id: str, server_id: str
) -> None:
"""
Add an MCP server ID to a team's ObjectPermissionTable.mcp_servers.
If the team has no ObjectPermissionTable yet, one is created.
Uses a DB transaction to prevent race conditions when multiple
concurrent calls try to initialize the ObjectPermissionTable.
"""
async with prisma_client.db.tx() as tx:
team = await tx.litellm_teamtable.find_unique(
where={"team_id": team_id}
)
if team is None:
raise ValueError(f"Team {team_id} not found")
object_permission_id = team.object_permission_id or str(uuid.uuid4())
# Get existing object permission or start fresh
existing_mcp_servers: List[str] = []
if team.object_permission_id:
existing_perm = (
await tx.litellm_objectpermissiontable.find_unique(
where={"object_permission_id": team.object_permission_id}
)
)
if existing_perm and existing_perm.mcp_servers:
existing_mcp_servers = list(existing_perm.mcp_servers)
# Add server if not already present
if server_id not in existing_mcp_servers:
existing_mcp_servers.append(server_id)
# Upsert the ObjectPermissionTable
await tx.litellm_objectpermissiontable.upsert(
where={"object_permission_id": object_permission_id},
data={
"create": {
"object_permission_id": object_permission_id,
"mcp_servers": existing_mcp_servers,
},
"update": {
"mcp_servers": existing_mcp_servers,
},
},
)
# Link the team to the ObjectPermissionTable if not already linked
if not team.object_permission_id:
await tx.litellm_teamtable.update(
where={"team_id": team_id},
data={"object_permission_id": object_permission_id},
)
# Invalidate team cache so the updated mcp_servers list is visible immediately
_invalidate_team_cache(team_id, object_permission_id)
async def remove_mcp_server_from_team(
prisma_client: PrismaClient, team_id: str, server_id: str
) -> None:
"""
Remove an MCP server ID from a team's ObjectPermissionTable.mcp_servers.
No-op if the team has no ObjectPermissionTable or the server isn't in the list.
Uses a DB transaction for consistency.
"""
async with prisma_client.db.tx() as tx:
team = await tx.litellm_teamtable.find_unique(
where={"team_id": team_id}
)
if team is None or not team.object_permission_id:
return
existing_perm = (
await tx.litellm_objectpermissiontable.find_unique(
where={"object_permission_id": team.object_permission_id}
)
)
if existing_perm is None or not existing_perm.mcp_servers:
return
updated_servers = [s for s in existing_perm.mcp_servers if s != server_id]
if len(updated_servers) != len(existing_perm.mcp_servers):
await tx.litellm_objectpermissiontable.update(
where={"object_permission_id": team.object_permission_id},
data={"mcp_servers": updated_servers},
)
# Invalidate team cache so the updated mcp_servers list is visible immediately
_invalidate_team_cache(
team_id, team.object_permission_id if team else None
)

View file

@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable {
mcp_access_groups String[] @default([])
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
vector_stores String[] @default([])
search_tools String[] @default(["*"]) // ["*"] = all access (default), [] = no access, ["tool-a"] = only those tools
agents String[] @default([])
agent_access_groups String[] @default([])
models String[] @default([])
@ -297,6 +298,7 @@ model LiteLLM_MCPServerTable {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
team_id String?
mcp_info Json? @default("{}")
mcp_access_groups String[]
allowed_tools String[] @default([])

View file

@ -6,6 +6,7 @@ from fastapi.responses import ORJSONResponse
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import get_allowed_search_tool_names
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
@ -163,6 +164,24 @@ async def search(
data["metadata"] = {}
data["metadata"]["model_group"] = search_tool_name_value
# Access control check for search tools
resolved_search_tool_name = data.get("search_tool_name")
if resolved_search_tool_name:
from litellm.proxy.auth.auth_checks import search_tool_access_check
await search_tool_access_check(
search_tool_name=resolved_search_tool_name,
valid_token=user_api_key_dict,
)
else:
raise HTTPException(
status_code=400,
detail={
"error": "search_tool_name is required. Provide it in the URL path "
"(/v1/search/{search_tool_name}) or in the request body."
},
)
# Process request using ProxyBaseLLMRequestProcessing
processor = ProxyBaseLLMRequestProcessing(data=data)
try:
@ -258,6 +277,15 @@ async def list_search_tools(
search_tools_list.append(tool_info)
# Filter search tools based on user's permissions
allowed_names = await get_allowed_search_tool_names(user_api_key_dict)
if allowed_names is not None:
search_tools_list = [
tool
for tool in search_tools_list
if tool.get("search_tool_name") in allowed_names
]
return {"object": "list", "data": search_tools_list}
except Exception as e:
from litellm._logging import verbose_proxy_logger

View file

@ -2,13 +2,14 @@
CRUD ENDPOINTS FOR SEARCH TOOLS
"""
from datetime import datetime
from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Optional, Union, cast
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from litellm._logging import verbose_proxy_logger
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.auth.auth_checks import get_allowed_search_tool_names
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
from litellm.proxy.search_endpoints.search_tool_registry import SearchToolRegistry
from litellm.types.search import (
ListSearchToolsResponse,
@ -46,7 +47,9 @@ def _convert_datetime_to_str(value: Union[datetime, str, None]) -> Union[str, No
dependencies=[Depends(user_api_key_auth)],
response_model=ListSearchToolsResponse,
)
async def list_search_tools():
async def list_search_tools(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
List all search tools that are available in the database and config file.
@ -128,7 +131,7 @@ async def list_search_tools():
search_tool_id=None,
search_tool_name=tool_name,
litellm_params=masked_litellm_params_dict,
search_tool_info=search_tool.get("search_tool_info"),
search_tool_info=cast(Optional[dict], search_tool.get("search_tool_info")),
created_at=None,
updated_at=None,
is_from_config=True,
@ -141,8 +144,8 @@ async def list_search_tools():
if tool.get("search_tool_name") not in db_tool_names
]
for search_tool in search_tools_from_db:
litellm_params_dict = dict(search_tool.get("litellm_params", {}))
for db_tool in search_tools_from_db:
litellm_params_dict = dict(db_tool.get("litellm_params", {}))
masked_litellm_params_dict = _get_masked_values(
litellm_params_dict,
unmasked_length=4,
@ -151,16 +154,25 @@ async def list_search_tools():
search_tool_configs.append(
SearchToolInfoResponse(
search_tool_id=search_tool.get("search_tool_id"),
search_tool_name=search_tool.get("search_tool_name", ""),
search_tool_id=cast(Optional[str], db_tool.get("search_tool_id")),
search_tool_name=db_tool.get("search_tool_name", ""),
litellm_params=masked_litellm_params_dict,
search_tool_info=search_tool.get("search_tool_info"),
created_at=_convert_datetime_to_str(search_tool.get("created_at")),
updated_at=_convert_datetime_to_str(search_tool.get("updated_at")),
search_tool_info=cast(Optional[dict], db_tool.get("search_tool_info")),
created_at=_convert_datetime_to_str(cast(Optional[Union[datetime, str]], db_tool.get("created_at"))),
updated_at=_convert_datetime_to_str(cast(Optional[Union[datetime, str]], db_tool.get("updated_at"))),
is_from_config=False,
)
)
# Filter based on caller's key/team permissions
allowed_names = await get_allowed_search_tool_names(user_api_key_dict)
if allowed_names is not None:
search_tool_configs = [
tool
for tool in search_tool_configs
if tool.get("search_tool_name") in allowed_names
]
return ListSearchToolsResponse(search_tools=search_tool_configs)
except Exception as e:
verbose_proxy_logger.exception(f"Error getting search tools: {e}")

View file

@ -64,6 +64,7 @@ class MCPServer(BaseModel):
is_byok: bool = False
byok_description: List[str] = []
byok_api_key_help_url: Optional[str] = None
team_id: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# OAuth2 flow type. Defaults to None (interactive / authorization_code).

73
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "a2a-sdk"
@ -7,11 +7,11 @@ description = "A2A Python SDK"
optional = false
python-versions = ">=3.10"
groups = ["main", "proxy-dev"]
markers = "python_version >= \"3.10\""
files = [
{file = "a2a_sdk-0.3.22-py3-none-any.whl", hash = "sha256:b98701135bb90b0ff85d35f31533b6b7a299bf810658c1c65f3814a6c15ea385"},
{file = "a2a_sdk-0.3.22.tar.gz", hash = "sha256:77a5694bfc4f26679c11b70c7f1062522206d430b34bc1215cfbb1eba67b7e7d"},
]
markers = {main = "python_version >= \"3.10\" and extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
google-api-core = ">=1.26.0"
@ -385,7 +385,6 @@ files = [
{file = "azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b"},
{file = "azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7"},
]
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
requests = ">=2.21.0"
@ -406,7 +405,6 @@ files = [
{file = "azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651"},
{file = "azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456"},
]
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
azure-core = ">=1.31.0"
@ -600,7 +598,7 @@ files = [
{file = "cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace"},
{file = "cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
[[package]]
name = "certifi"
@ -707,7 +705,7 @@ files = [
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
]
markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""}
markers = {main = "platform_python_implementation != \"PyPy\" or extra == \"proxy\"", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""}
[package.dependencies]
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
@ -1057,7 +1055,6 @@ files = [
{file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"},
{file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\") or extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
@ -1840,11 +1837,11 @@ description = "Google API client core library"
optional = false
python-versions = ">=3.7"
groups = ["main", "proxy-dev"]
markers = "python_version >= \"3.14\""
files = [
{file = "google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7"},
{file = "google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300"},
]
markers = {main = "python_version >= \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.14\""}
[package.dependencies]
google-auth = ">=2.14.1,<3.0.0"
@ -1872,7 +1869,7 @@ files = [
{file = "google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c"},
{file = "google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8"},
]
markers = {main = "python_version < \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""}
markers = {main = "(python_version >= \"3.10\" or extra == \"google\" or extra == \"extra-proxy\") and python_version < \"3.14\"", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""}
[package.dependencies]
google-auth = ">=2.14.1,<3.0.0"
@ -1909,7 +1906,7 @@ files = [
{file = "google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16"},
{file = "google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
cachetools = ">=2.0.0,<7.0"
@ -2081,11 +2078,11 @@ files = [
]
[package.dependencies]
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0"
grpc-google-iam-v1 = ">=0.12.4,<1.0.0.dev0"
proto-plus = ">=1.22.3,<2.0.0.dev0"
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev"
proto-plus = ">=1.22.3,<2.0.0dev"
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
[[package]]
name = "google-cloud-resource-manager"
@ -2267,7 +2264,7 @@ files = [
{file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"},
{file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\") or extra == \"google\" or extra == \"extra-proxy\""}
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""}
[package.dependencies]
grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""}
@ -2676,11 +2673,11 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX."
optional = false
python-versions = ">=3.9"
groups = ["main", "proxy-dev"]
markers = "python_version >= \"3.10\""
files = [
{file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"},
{file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\")", proxy-dev = "python_version >= \"3.10\""}
[[package]]
name = "huey"
@ -3045,7 +3042,7 @@ files = [
[package.dependencies]
attrs = ">=22.2.0"
jsonschema-specifications = ">=2023.3.6"
jsonschema-specifications = ">=2023.03.6"
referencing = ">=0.28.4"
rpds-py = ">=0.7.1"
@ -3222,15 +3219,15 @@ files = [
[[package]]
name = "litellm-proxy-extras"
version = "0.4.58"
version = "0.4.59"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
optional = true
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
groups = ["main"]
markers = "extra == \"proxy\""
files = [
{file = "litellm_proxy_extras-0.4.58-py3-none-any.whl", hash = "sha256:8863e70de833c0e35119a1cbbf583619bdebe52222efd5654586519175ba403b"},
{file = "litellm_proxy_extras-0.4.58.tar.gz", hash = "sha256:84a67483329eced8be4fc61c4e43f117287aa4e3deeb8ddf8fe8cdc9a8508836"},
{file = "litellm_proxy_extras-0.4.59-py3-none-any.whl", hash = "sha256:537080d1b2de32eafc386202cf38c789a1da7f6e75b206fbeb83b935330e8b79"},
{file = "litellm_proxy_extras-0.4.59.tar.gz", hash = "sha256:11ed5f7f71e48b27fdfc030c86d933bf88f7ad09f0743eafde084390b6bed26f"},
]
[[package]]
@ -3716,7 +3713,6 @@ files = [
{file = "msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1"},
{file = "msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f"},
]
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
cryptography = ">=2.5,<49"
@ -3737,7 +3733,6 @@ files = [
{file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"},
{file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"},
]
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
msal = ">=1.29,<2"
@ -3988,7 +3983,6 @@ files = [
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
markers = {main = "extra == \"extra-proxy\""}
[[package]]
name = "numpy"
@ -4111,7 +4105,7 @@ files = [
{file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"},
{file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"},
]
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
markers = {main = "python_version >= \"3.10\""}
[package.dependencies]
importlib-metadata = ">=6.0,<8.8.0"
@ -4226,7 +4220,7 @@ files = [
{file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"},
{file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"},
]
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
markers = {main = "python_version >= \"3.10\""}
[package.dependencies]
opentelemetry-api = "1.39.1"
@ -4244,7 +4238,7 @@ files = [
{file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"},
{file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"},
]
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
markers = {main = "python_version >= \"3.10\""}
[package.dependencies]
opentelemetry-api = "1.39.1"
@ -4743,7 +4737,6 @@ files = [
{file = "prisma-0.11.0-py3-none-any.whl", hash = "sha256:22bb869e59a2968b99f3483bb417717273ffbc569fd1e9ceed95e5614cbaf53a"},
{file = "prisma-0.11.0.tar.gz", hash = "sha256:3f2f2fd2361e1ec5ff655f2a04c7860c2f2a5bc4c91f78ca9c5c6349735bf693"},
]
markers = {main = "extra == \"extra-proxy\""}
[package.dependencies]
click = ">=7.1.2"
@ -4917,7 +4910,7 @@ files = [
{file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"},
{file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"},
]
markers = {main = "extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
protobuf = ">=3.19.0,<7.0.0"
@ -4945,7 +4938,7 @@ files = [
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\""}
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""}
[[package]]
name = "psutil"
@ -5105,7 +5098,7 @@ files = [
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
[[package]]
name = "pyasn1-modules"
@ -5118,7 +5111,7 @@ files = [
{file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"},
{file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
pyasn1 = ">=0.6.1,<0.7.0"
@ -5146,7 +5139,7 @@ files = [
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
]
markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
[[package]]
name = "pydantic"
@ -5369,7 +5362,6 @@ files = [
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
]
markers = {main = "(python_version <= \"3.13\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"extra-proxy\" or extra == \"proxy\")"}
[package.dependencies]
cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
@ -6313,7 +6305,7 @@ files = [
{file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
{file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
pyasn1 = ">=0.1.3"
@ -6359,10 +6351,10 @@ files = [
]
[package.dependencies]
botocore = ">=1.37.4,<2.0a0"
botocore = ">=1.37.4,<2.0a.0"
[package.extras]
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
[[package]]
name = "scikit-learn"
@ -6515,9 +6507,9 @@ tornado = ">=6.4.2,<7"
urllib3 = ">=1.26,<3"
[package.extras]
all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.0)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.00)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
bedrock = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)"]
cohere = ["cohere (>=5.9.4,<6.0)"]
cohere = ["cohere (>=5.9.4,<6.00)"]
dev = ["dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "ipykernel (>=6.25.0,<7)", "mypy (>=1.7.1,<2)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
docs = ["pydoc-markdown (>=4.8.2) ; python_version < \"3.12\""]
fastembed = ["fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\""]
@ -7245,7 +7237,6 @@ files = [
{file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"},
{file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"},
]
markers = {main = "extra == \"extra-proxy\""}
[[package]]
name = "tornado"
@ -8018,4 +8009,4 @@ utils = ["numpydoc"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<4.0"
content-hash = "2cf958f1a04fd5f1ab0e5cfc33bdbf441b518ed6c82d0f2546bf64cd3d2f89be"
content-hash = "c2b65cf2afb0783061ee8156f8d814b0d950bc3803821a97f9775a287a75fa6e"

View file

@ -61,7 +61,7 @@ boto3 = { version = "^1.40.76", optional = true }
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
litellm-proxy-extras = {version = "^0.4.58", optional = true}
litellm-proxy-extras = {version = "^0.4.59", optional = true}
rich = {version = "^13.7.1", optional = true}
litellm-enterprise = {version = "^0.1.33", optional = true}
diskcache = {version = "^5.6.1", optional = true}

View file

@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14"
sentry_sdk==2.21.0 # for sentry error handling
detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
tzdata==2025.1 # IANA time zone database
litellm-proxy-extras==0.4.58 # for proxy extras - e.g. prisma migrations
litellm-proxy-extras==0.4.59 # for proxy extras - e.g. prisma migrations
llm-sandbox==0.3.31 # for skill execution in sandbox
### LITELLM PACKAGE DEPENDENCIES
python-dotenv==1.0.1 # for env

View file

@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable {
mcp_access_groups String[] @default([])
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
vector_stores String[] @default([])
search_tools String[] @default(["*"]) // ["*"] = all access (default), [] = no access, ["tool-a"] = only those tools
agents String[] @default([])
agent_access_groups String[] @default([])
models String[] @default([])
@ -297,6 +298,7 @@ model LiteLLM_MCPServerTable {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
team_id String?
mcp_info Json? @default("{}")
mcp_access_groups String[]
allowed_tools String[] @default([])

View file

@ -157,3 +157,127 @@ class TestUpdateMetadataFieldsPremiumCheck:
}
_update_metadata_fields(updated_kv)
mock_check.assert_called()
class TestCheckMemberPermission:
"""Tests for check_member_permission and _find_member_in_team."""
def _make_user_api_key_dict(self, user_id="user-1", user_role=None):
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
return UserAPIKeyAuth(
user_id=user_id,
user_role=user_role or LitellmUserRoles.INTERNAL_USER,
api_key="sk-test",
)
def _make_team(self, members):
from litellm.proxy._types import LiteLLM_TeamTable
return LiteLLM_TeamTable(
team_id="team-1",
members_with_roles=members,
)
def test_proxy_admin_always_allowed(self):
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.management_endpoints.common_utils import (
check_member_permission,
)
user = self._make_user_api_key_dict(
user_role=LitellmUserRoles.PROXY_ADMIN
)
team = self._make_team([])
assert check_member_permission(user, team, "mcp:create") is True
def test_team_admin_always_allowed(self):
from litellm.proxy._types import Member
from litellm.proxy.management_endpoints.common_utils import (
check_member_permission,
)
user = self._make_user_api_key_dict(user_id="admin-1")
team = self._make_team(
[Member(user_id="admin-1", role="admin")]
)
assert check_member_permission(user, team, "mcp:create") is True
def test_member_with_permission_allowed(self):
from litellm.proxy._types import Member
from litellm.proxy.management_endpoints.common_utils import (
check_member_permission,
)
user = self._make_user_api_key_dict(user_id="member-1")
team = self._make_team(
[Member(user_id="member-1", role="user", extra_permissions=["mcp:create"])]
)
assert check_member_permission(user, team, "mcp:create") is True
def test_member_without_permission_denied(self):
from litellm.proxy._types import Member
from litellm.proxy.management_endpoints.common_utils import (
check_member_permission,
)
user = self._make_user_api_key_dict(user_id="member-1")
team = self._make_team(
[Member(user_id="member-1", role="user", extra_permissions=["mcp:read"])]
)
assert check_member_permission(user, team, "mcp:create") is False
def test_member_with_no_permissions_denied(self):
from litellm.proxy._types import Member
from litellm.proxy.management_endpoints.common_utils import (
check_member_permission,
)
user = self._make_user_api_key_dict(user_id="member-1")
team = self._make_team(
[Member(user_id="member-1", role="user")]
)
assert check_member_permission(user, team, "mcp:create") is False
def test_user_not_in_team_denied(self):
from litellm.proxy._types import Member
from litellm.proxy.management_endpoints.common_utils import (
check_member_permission,
)
user = self._make_user_api_key_dict(user_id="outsider")
team = self._make_team(
[Member(user_id="member-1", role="user", extra_permissions=["mcp:create"])]
)
assert check_member_permission(user, team, "mcp:create") is False
class TestMemberExtraPermissionsSerialization:
"""Verify Member with extra_permissions round-trips through JSON."""
def test_member_with_permissions_roundtrip(self):
import json
from litellm.proxy._types import Member
original = Member(
user_id="user-1",
role="user",
extra_permissions=["mcp:create", "mcp:delete"],
)
serialized = json.dumps(original.model_dump())
deserialized = Member(**json.loads(serialized))
assert deserialized.extra_permissions == ["mcp:create", "mcp:delete"]
assert deserialized.user_id == "user-1"
assert deserialized.role == "user"
def test_member_without_permissions_roundtrip(self):
import json
from litellm.proxy._types import Member
original = Member(user_id="user-1", role="admin")
serialized = json.dumps(original.model_dump())
deserialized = Member(**json.loads(serialized))
assert deserialized.extra_permissions is None
assert deserialized.role == "admin"

View file

@ -1474,6 +1474,7 @@ async def test_add_update_server_with_alias():
mock_mcp_server.byok_api_key_help_url = None
mock_mcp_server.created_at = None
mock_mcp_server.updated_at = None
mock_mcp_server.team_id = None
# Add server to manager
await test_manager.add_server(mock_mcp_server)
@ -1530,6 +1531,7 @@ async def test_add_update_server_without_alias():
mock_mcp_server.byok_api_key_help_url = None
mock_mcp_server.created_at = None
mock_mcp_server.updated_at = None
mock_mcp_server.team_id = None
# Add server to manager
await test_manager.add_server(mock_mcp_server)
@ -1587,6 +1589,7 @@ async def test_add_update_server_fallback_to_server_id():
mock_mcp_server.byok_api_key_help_url = None
mock_mcp_server.created_at = None
mock_mcp_server.updated_at = None
mock_mcp_server.team_id = None
# Add server to manager
await test_manager.add_server(mock_mcp_server)

View file

@ -261,7 +261,7 @@ async def test_create_duplicate_mcp_server():
@pytest.mark.asyncio
async def test_create_mcp_server_auth_failure():
"""
Test that non-admin users cannot create MCP servers.
Test that non-admin users without a team_id cannot create MCP servers.
"""
# Mock the database functions directly
with mock.patch(
@ -291,15 +291,15 @@ async def test_create_mcp_server_auth_failure():
user_role=LitellmUserRoles.INTERNAL_USER, # Not an admin
)
# Expect HTTPException to be raised
# Expect HTTPException — non-admin users must provide team_id
with pytest.raises(HTTPException) as exc_info:
await add_mcp_server(
payload=mcp_server_request, user_api_key_dict=user_auth
)
# Verify the exception details
assert exc_info.value.status_code == 403
assert "permission" in str(exc_info.value.detail)
# Non-admin without team_id gets a 400 requiring team_id
assert exc_info.value.status_code == 400
assert "team_id is required" in str(exc_info.value.detail)
@pytest.mark.asyncio

View file

@ -624,6 +624,7 @@ class TestSigV4BuildFromTable:
table_record.tool_name_to_description = None
table_record.byok_api_key_help_url = None
table_record.oauth2_flow = None
table_record.team_id = None
manager = MCPServerManager()
@ -681,6 +682,7 @@ class TestSigV4BuildFromTable:
table_record.tool_name_to_description = None
table_record.byok_api_key_help_url = None
table_record.oauth2_flow = None
table_record.team_id = None
manager = MCPServerManager()

View file

@ -0,0 +1,509 @@
"""
Tests for search tool access control.
Covers:
- _can_object_call_search_tools() with least-privilege semantics
- search_tool_access_check() for key and team level permissions
- ProxyErrorTypes for search tool access denied
- Ensures vector store access check semantics are unchanged
"""
from typing import List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
ProxyErrorTypes,
ProxyException,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import (
_can_object_call_search_tools,
_can_object_call_vector_stores,
_normalize_search_tools_wildcard,
get_allowed_search_tool_names,
search_tool_access_check,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_object_permission(
search_tools: Optional[List[str]] = None,
vector_stores: Optional[List[str]] = None,
) -> LiteLLM_ObjectPermissionTable:
"""Create a minimal object permission for testing."""
return LiteLLM_ObjectPermissionTable(
object_permission_id="test-perm-id",
search_tools=search_tools,
vector_stores=vector_stores if vector_stores is not None else [],
)
# ===========================================================================
# _can_object_call_search_tools — least-privilege semantics
# ===========================================================================
class TestCanObjectCallSearchTools:
"""should enforce least-privilege semantics for search tools."""
def test_should_allow_when_permissions_are_none(self):
"""None object_permissions → allow (no permission record)."""
result = _can_object_call_search_tools(
object_type="key",
search_tool_name="any-tool",
object_permissions=None,
)
assert result is True
def test_should_allow_when_search_tools_field_is_none(self):
"""search_tools=None → allow (field not configured)."""
perm = _make_object_permission(search_tools=None)
result = _can_object_call_search_tools(
object_type="key",
search_tool_name="any-tool",
object_permissions=perm,
)
assert result is True
def test_should_deny_when_search_tools_is_empty_list(self):
"""search_tools=[] → DENY (principle of least privilege)."""
perm = _make_object_permission(search_tools=[])
with pytest.raises(ProxyException) as exc_info:
_can_object_call_search_tools(
object_type="key",
search_tool_name="any-tool",
object_permissions=perm,
)
assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied
def test_should_allow_tool_in_allowed_list(self):
"""Requesting a tool that is in the allowed list → allow."""
perm = _make_object_permission(search_tools=["tool-a", "tool-b"])
result = _can_object_call_search_tools(
object_type="key",
search_tool_name="tool-a",
object_permissions=perm,
)
assert result is True
def test_should_deny_tool_not_in_allowed_list(self):
"""Requesting a tool NOT in the allowed list → deny."""
perm = _make_object_permission(search_tools=["tool-a"])
with pytest.raises(ProxyException) as exc_info:
_can_object_call_search_tools(
object_type="key",
search_tool_name="tool-b",
object_permissions=perm,
)
assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied
def test_should_use_team_error_type_for_team_object(self):
"""Team denial uses team-specific error type."""
perm = _make_object_permission(search_tools=[])
with pytest.raises(ProxyException) as exc_info:
_can_object_call_search_tools(
object_type="team",
search_tool_name="any-tool",
object_permissions=perm,
)
assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied
def test_should_use_org_error_type_for_org_object(self):
"""Org denial uses org-specific error type."""
perm = _make_object_permission(search_tools=["other"])
with pytest.raises(ProxyException) as exc_info:
_can_object_call_search_tools(
object_type="org",
search_tool_name="not-other",
object_permissions=perm,
)
assert exc_info.value.type == ProxyErrorTypes.org_search_tool_access_denied
def test_should_allow_any_tool_when_wildcard_present(self):
"""search_tools=["*"] (migration default) → allow any tool."""
perm = _make_object_permission(search_tools=["*"])
result = _can_object_call_search_tools(
object_type="key",
search_tool_name="any-tool",
object_permissions=perm,
)
assert result is True
def test_should_allow_any_tool_when_wildcard_mixed_with_names(self):
"""search_tools=["*", "tool-a"] → wildcard dominates, allow any."""
perm = _make_object_permission(search_tools=["*", "tool-a"])
result = _can_object_call_search_tools(
object_type="key",
search_tool_name="tool-b",
object_permissions=perm,
)
assert result is True
def test_should_allow_all_tools_in_allowed_list(self):
"""Multiple tools in allowed list all pass."""
perm = _make_object_permission(search_tools=["a", "b", "c"])
for name in ["a", "b", "c"]:
result = _can_object_call_search_tools(
object_type="key",
search_tool_name=name,
object_permissions=perm,
)
assert result is True
with pytest.raises(ProxyException):
_can_object_call_search_tools(
object_type="key",
search_tool_name="d",
object_permissions=perm,
)
# ===========================================================================
# search_tool_access_check — async DB lookups
# ===========================================================================
_PROXY_SERVER = "litellm.proxy.proxy_server"
_GET_OBJ_PERM = "litellm.proxy.auth.auth_checks.get_object_permission"
@pytest.mark.asyncio
async def test_should_allow_when_no_prisma_client():
"""No prisma client → allow."""
with patch(f"{_PROXY_SERVER}.prisma_client", None):
result = await search_tool_access_check(
search_tool_name="any-tool",
valid_token=None,
)
assert result is True
@pytest.mark.asyncio
async def test_should_allow_when_no_token():
"""No valid_token → allow."""
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()):
result = await search_tool_access_check(
search_tool_name="any-tool",
valid_token=None,
)
assert result is True
@pytest.mark.asyncio
async def test_should_allow_when_no_permission_ids():
"""Token with no object_permission_id or team_object_permission_id → allow."""
token = UserAPIKeyAuth(
object_permission_id=None,
team_object_permission_id=None,
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()):
result = await search_tool_access_check(
search_tool_name="any-tool",
valid_token=token,
)
assert result is True
@pytest.mark.asyncio
async def test_should_allow_key_with_matching_permission():
"""Key with object_permission that includes the tool → allow."""
mock_perm = MagicMock()
mock_perm.search_tools = ["my-tool"]
token = UserAPIKeyAuth(
object_permission_id="key-perm-id",
team_object_permission_id=None,
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_perm)):
result = await search_tool_access_check(
search_tool_name="my-tool",
valid_token=token,
)
assert result is True
@pytest.mark.asyncio
async def test_should_deny_key_with_empty_search_tools():
"""Key with empty search_tools → deny."""
mock_perm = MagicMock()
mock_perm.search_tools = []
token = UserAPIKeyAuth(
object_permission_id="key-perm-id",
team_object_permission_id=None,
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_perm)):
with pytest.raises(ProxyException) as exc_info:
await search_tool_access_check(
search_tool_name="any-tool",
valid_token=token,
)
assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied
@pytest.mark.asyncio
async def test_should_deny_team_with_empty_search_tools():
"""Team with empty search_tools → deny."""
mock_team_perm = MagicMock()
mock_team_perm.search_tools = []
token = UserAPIKeyAuth(
object_permission_id=None,
team_object_permission_id="team-perm-id",
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_team_perm)):
with pytest.raises(ProxyException) as exc_info:
await search_tool_access_check(
search_tool_name="any-tool",
valid_token=token,
)
assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied
@pytest.mark.asyncio
async def test_should_deny_when_key_allows_but_team_denies():
"""Key allows, team denies → deny (team check second)."""
key_perm = MagicMock()
key_perm.search_tools = ["the-tool"]
team_perm = MagicMock()
team_perm.search_tools = []
async def mock_get_perm(object_permission_id, **kwargs):
if object_permission_id == "key-perm-id":
return key_perm
elif object_permission_id == "team-perm-id":
return team_perm
return None
token = UserAPIKeyAuth(
object_permission_id="key-perm-id",
team_object_permission_id="team-perm-id",
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)):
with pytest.raises(ProxyException) as exc_info:
await search_tool_access_check(
search_tool_name="the-tool",
valid_token=token,
)
assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied
@pytest.mark.asyncio
async def test_should_allow_when_both_key_and_team_allow():
"""Both key and team allow → allow."""
key_perm = MagicMock()
key_perm.search_tools = ["tool-x"]
team_perm = MagicMock()
team_perm.search_tools = ["tool-x", "tool-y"]
async def mock_get_perm(object_permission_id, **kwargs):
if object_permission_id == "key-perm-id":
return key_perm
elif object_permission_id == "team-perm-id":
return team_perm
return None
token = UserAPIKeyAuth(
object_permission_id="key-perm-id",
team_object_permission_id="team-perm-id",
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)):
result = await search_tool_access_check(
search_tool_name="tool-x",
valid_token=token,
)
assert result is True
# ===========================================================================
# ProxyErrorTypes classmethod
# ===========================================================================
class TestSearchToolErrorTypes:
def test_should_return_key_error_type(self):
assert (
ProxyErrorTypes.get_search_tool_access_error_type_for_object("key")
== ProxyErrorTypes.key_search_tool_access_denied
)
def test_should_return_team_error_type(self):
assert (
ProxyErrorTypes.get_search_tool_access_error_type_for_object("team")
== ProxyErrorTypes.team_search_tool_access_denied
)
def test_should_return_org_error_type(self):
assert (
ProxyErrorTypes.get_search_tool_access_error_type_for_object("org")
== ProxyErrorTypes.org_search_tool_access_denied
)
# ===========================================================================
# Regression: vector store semantics unchanged (empty = allow all)
# ===========================================================================
class TestVectorStoreAccessNotBroken:
"""Preserves existing vector store semantics: empty list = allow ALL."""
def test_should_allow_all_when_vector_stores_is_empty(self):
"""Vector stores: empty list = access to ALL (existing behavior)."""
perm = MagicMock()
perm.vector_stores = []
result = _can_object_call_vector_stores(
object_type="key",
vector_store_ids_to_run=["store-1"],
object_permissions=perm,
)
assert result is True
def test_should_allow_when_vector_stores_is_none(self):
perm = MagicMock()
perm.vector_stores = None
result = _can_object_call_vector_stores(
object_type="key",
vector_store_ids_to_run=["store-1"],
object_permissions=perm,
)
assert result is True
def test_should_deny_unlisted_vector_store(self):
perm = MagicMock()
perm.vector_stores = ["store-1"]
with pytest.raises(ProxyException) as exc_info:
_can_object_call_vector_stores(
object_type="key",
vector_store_ids_to_run=["store-99"],
object_permissions=perm,
)
assert exc_info.value.type == ProxyErrorTypes.key_vector_store_access_denied
# ===========================================================================
# _normalize_search_tools_wildcard
# ===========================================================================
class TestNormalizeSearchToolsWildcard:
def test_none_stays_none(self):
assert _normalize_search_tools_wildcard(None) is None
def test_empty_list_stays_empty(self):
assert _normalize_search_tools_wildcard([]) == []
def test_explicit_names_unchanged(self):
assert _normalize_search_tools_wildcard(["a", "b"]) == ["a", "b"]
def test_wildcard_only_returns_none(self):
assert _normalize_search_tools_wildcard(["*"]) is None
def test_wildcard_mixed_returns_none(self):
assert _normalize_search_tools_wildcard(["*", "tool-a"]) is None
# ===========================================================================
# get_allowed_search_tool_names — wildcard handling
# ===========================================================================
@pytest.mark.asyncio
async def test_get_allowed_should_return_none_when_key_has_wildcard():
"""Key with ["*"] → None (no restriction)."""
key_perm = MagicMock()
key_perm.search_tools = ["*"]
token = UserAPIKeyAuth(
object_permission_id="key-perm-id",
team_object_permission_id=None,
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
patch(_GET_OBJ_PERM, AsyncMock(return_value=key_perm)):
result = await get_allowed_search_tool_names(token)
assert result is None
@pytest.mark.asyncio
async def test_get_allowed_should_intersect_wildcard_key_with_team_list():
"""Key ["*"] + team ["tool-a"] → ["tool-a"]."""
key_perm = MagicMock()
key_perm.search_tools = ["*"]
team_perm = MagicMock()
team_perm.search_tools = ["tool-a"]
async def mock_get_perm(object_permission_id, **kwargs):
if object_permission_id == "key-perm-id":
return key_perm
elif object_permission_id == "team-perm-id":
return team_perm
return None
token = UserAPIKeyAuth(
object_permission_id="key-perm-id",
team_object_permission_id="team-perm-id",
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)):
result = await get_allowed_search_tool_names(token)
assert result == ["tool-a"]
@pytest.mark.asyncio
async def test_get_allowed_should_return_none_when_both_wildcard():
"""Key ["*"] + team ["*"] → None (no restriction)."""
key_perm = MagicMock()
key_perm.search_tools = ["*"]
team_perm = MagicMock()
team_perm.search_tools = ["*"]
async def mock_get_perm(object_permission_id, **kwargs):
if object_permission_id == "key-perm-id":
return key_perm
elif object_permission_id == "team-perm-id":
return team_perm
return None
token = UserAPIKeyAuth(
object_permission_id="key-perm-id",
team_object_permission_id="team-perm-id",
)
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)):
result = await get_allowed_search_tool_names(token)
assert result is None

View file

@ -0,0 +1,82 @@
"""Tests for MCP server team ownership via MCPServerTable.team_id."""
import uuid
import pytest
def test_prepare_mcp_server_data_includes_team_id():
"""team_id should be included in data dict when set."""
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
from litellm.proxy._types import NewMCPServerRequest
team_id = str(uuid.uuid4())
request = NewMCPServerRequest(
server_name="test_server",
transport="http",
url="https://example.com/mcp",
team_id=team_id,
)
data_dict = _prepare_mcp_server_data(request)
assert data_dict["team_id"] == team_id
def test_prepare_mcp_server_data_excludes_none_team_id():
"""team_id=None should not be in the data dict (exclude_none=True)."""
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
from litellm.proxy._types import NewMCPServerRequest
request = NewMCPServerRequest(
server_name="test_server",
transport="http",
url="https://example.com/mcp",
)
data_dict = _prepare_mcp_server_data(request)
assert "team_id" not in data_dict
def test_prepare_mcp_server_data_update_includes_team_id():
"""UpdateMCPServerRequest with team_id should include it in data dict."""
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
from litellm.proxy._types import UpdateMCPServerRequest
team_id = str(uuid.uuid4())
request = UpdateMCPServerRequest(
server_id="test-server-id",
transport="http",
url="https://example.com/mcp",
team_id=team_id,
)
data_dict = _prepare_mcp_server_data(request)
assert data_dict["team_id"] == team_id
def test_prepare_mcp_server_data_update_excludes_none_team_id():
"""UpdateMCPServerRequest without team_id should not have it in data dict."""
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
from litellm.proxy._types import UpdateMCPServerRequest
request = UpdateMCPServerRequest(
server_id="test-server-id",
transport="http",
url="https://example.com/mcp",
)
data_dict = _prepare_mcp_server_data(request)
assert "team_id" not in data_dict
def test_update_request_model_fields_set_detects_explicit_null():
"""When team_id is explicitly set to None in JSON, model_fields_set should contain it."""
from litellm.proxy._types import UpdateMCPServerRequest
# Simulate JSON: {"server_id": "x", "transport": "http", "url": "...", "team_id": null}
request = UpdateMCPServerRequest.model_validate(
{"server_id": "x", "transport": "http", "url": "https://example.com/mcp", "team_id": None}
)
assert "team_id" in request.model_fields_set
# Simulate JSON: {"server_id": "x", "transport": "http", "url": "..."} — team_id absent
request2 = UpdateMCPServerRequest.model_validate(
{"server_id": "x", "transport": "http", "url": "https://example.com/mcp"}
)
assert "team_id" not in request2.model_fields_set

View file

@ -36,10 +36,18 @@ export const useMCPServerHealth = () => {
{ queryKey: mcpServerHealthKeys.lists() },
(oldData) => {
if (!oldData) return result;
return oldData.map((h) => {
const updated = result.find((r) => r.server_id === h.server_id);
return updated ?? h;
const existingIds = new Set(oldData.map((h) => h.server_id));
const updated = oldData.map((h) => {
const fresh = result.find((r) => r.server_id === h.server_id);
return fresh ?? h;
});
// Append servers not already in the cached list
for (const r of result) {
if (!existingIds.has(r.server_id)) {
updated.push(r);
}
}
return updated;
},
);
} finally {

View file

@ -0,0 +1,350 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import React, { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
usePolicyVersions,
useCreatePolicyVersion,
useUpdatePolicyVersionStatus,
} from "./usePolicyVersions";
// ── Mocks ───────────────────────────────────────────────────────────────────
const mockListPolicyVersions = vi.fn();
const mockCreatePolicyVersion = vi.fn();
const mockUpdatePolicyVersionStatus = vi.fn();
vi.mock("@/components/networking", () => ({
listPolicyVersions: (...args: unknown[]) => mockListPolicyVersions(...args),
createPolicyVersion: (...args: unknown[]) => mockCreatePolicyVersion(...args),
updatePolicyVersionStatus: (...args: unknown[]) =>
mockUpdatePolicyVersionStatus(...args),
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),
},
}));
// Import the mocked module to assert on it
import NotificationsManager from "@/components/molecules/notifications_manager";
const mockUseAuthorized = vi.fn();
vi.mock("../useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
// ── Setup ───────────────────────────────────────────────────────────────────
describe("usePolicyVersions", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userRole: "Admin",
userId: "test-user-id",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
// ── Query tests ─────────────────────────────────────────────────────────
it("fetches versions when policyName is provided", async () => {
const mockResponse = {
policy_name: "my-policy",
versions: [
{ policy_id: "v1", policy_name: "my-policy", version_number: 1, version_status: "production" },
{ policy_id: "v2", policy_name: "my-policy", version_number: 2, version_status: "draft" },
],
total_count: 2,
};
mockListPolicyVersions.mockResolvedValue(mockResponse);
const { result } = renderHook(
() => usePolicyVersions({ policyName: "my-policy" }),
{ wrapper }
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockListPolicyVersions).toHaveBeenCalledWith("test-access-token", "my-policy");
expect(result.current.data?.versions).toHaveLength(2);
expect(result.current.data?.versions[0].policy_id).toBe("v1");
});
it("does not fetch when policyName is null", () => {
const { result } = renderHook(
() => usePolicyVersions({ policyName: null }),
{ wrapper }
);
expect(result.current.fetchStatus).toBe("idle");
// isLoading (not isPending) must be false when query is disabled
expect(result.current.isLoading).toBe(false);
expect(mockListPolicyVersions).not.toHaveBeenCalled();
});
it("does not fetch when enabled is false", () => {
const { result } = renderHook(
() => usePolicyVersions({ policyName: "my-policy", enabled: false }),
{ wrapper }
);
expect(result.current.fetchStatus).toBe("idle");
// isLoading (not isPending) must be false when query is disabled
expect(result.current.isLoading).toBe(false);
expect(mockListPolicyVersions).not.toHaveBeenCalled();
});
it("defaults versions to empty array when response has undefined versions", async () => {
mockListPolicyVersions.mockResolvedValue({
policy_name: "my-policy",
versions: undefined,
total_count: 0,
});
const { result } = renderHook(
() => usePolicyVersions({ policyName: "my-policy" }),
{ wrapper }
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.versions).toEqual([]);
});
});
describe("useCreatePolicyVersion", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userRole: "Admin",
userId: "test-user-id",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("calls createPolicyVersion and shows success notification", async () => {
const newPolicy = { policy_id: "v3", policy_name: "my-policy", version_number: 3 };
mockCreatePolicyVersion.mockResolvedValue(newPolicy);
const { result } = renderHook(
() => useCreatePolicyVersion("my-policy"),
{ wrapper }
);
const returned = await result.current.mutateAsync();
expect(mockCreatePolicyVersion).toHaveBeenCalledWith("test-access-token", "my-policy");
expect(returned).toEqual(newPolicy);
expect(NotificationsManager.success).toHaveBeenCalledWith("New draft version created");
});
it("invalidates the versions cache on success", async () => {
mockCreatePolicyVersion.mockResolvedValue({ policy_id: "v3" });
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(
() => useCreatePolicyVersion("my-policy"),
{ wrapper }
);
await result.current.mutateAsync();
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["policyVersions", "detail", "my-policy"],
});
});
it("shows error notification on failure", async () => {
mockCreatePolicyVersion.mockRejectedValue(new Error("Server error"));
const { result } = renderHook(
() => useCreatePolicyVersion("my-policy"),
{ wrapper }
);
await expect(result.current.mutateAsync()).rejects.toThrow("Server error");
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
"Failed to create version: Server error"
);
});
it("throws when policyName is null", async () => {
const { result } = renderHook(
() => useCreatePolicyVersion(null),
{ wrapper }
);
await expect(result.current.mutateAsync()).rejects.toThrow(
"Missing access token or policy name"
);
});
});
describe("useUpdatePolicyVersionStatus", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userRole: "Admin",
userId: "test-user-id",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("publishes a version and shows success notification", async () => {
const updatedPolicy = { policy_id: "v2", version_status: "published" };
mockUpdatePolicyVersionStatus.mockResolvedValue(updatedPolicy);
const { result } = renderHook(
() => useUpdatePolicyVersionStatus("my-policy"),
{ wrapper }
);
const returned = await result.current.mutateAsync({
policyId: "v2",
status: "published",
});
expect(mockUpdatePolicyVersionStatus).toHaveBeenCalledWith(
"test-access-token",
"v2",
"published"
);
expect(returned).toEqual(updatedPolicy);
expect(NotificationsManager.success).toHaveBeenCalledWith(
expect.stringContaining("Version published")
);
});
it("invalidates the versions cache on success", async () => {
mockUpdatePolicyVersionStatus.mockResolvedValue({ policy_id: "v2" });
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(
() => useUpdatePolicyVersionStatus("my-policy"),
{ wrapper }
);
await result.current.mutateAsync({ policyId: "v2", status: "published" });
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["policyVersions", "detail", "my-policy"],
});
});
it("promotes to production and shows success notification", async () => {
const updatedPolicy = { policy_id: "v2", version_status: "production" };
mockUpdatePolicyVersionStatus.mockResolvedValue(updatedPolicy);
const { result } = renderHook(
() => useUpdatePolicyVersionStatus("my-policy"),
{ wrapper }
);
await result.current.mutateAsync({
policyId: "v2",
status: "production",
});
expect(mockUpdatePolicyVersionStatus).toHaveBeenCalledWith(
"test-access-token",
"v2",
"production"
);
expect(NotificationsManager.success).toHaveBeenCalledWith(
"Version promoted to production"
);
});
it("shows error notification on publish failure", async () => {
mockUpdatePolicyVersionStatus.mockRejectedValue(new Error("Forbidden"));
const { result } = renderHook(
() => useUpdatePolicyVersionStatus("my-policy"),
{ wrapper }
);
await expect(
result.current.mutateAsync({ policyId: "v2", status: "published" })
).rejects.toThrow("Forbidden");
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
"Failed to publish: Forbidden"
);
});
it("shows error notification on promote failure", async () => {
mockUpdatePolicyVersionStatus.mockRejectedValue(new Error("Not found"));
const { result } = renderHook(
() => useUpdatePolicyVersionStatus("my-policy"),
{ wrapper }
);
await expect(
result.current.mutateAsync({ policyId: "v2", status: "production" })
).rejects.toThrow("Not found");
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
"Failed to promote to production: Not found"
);
});
it("throws when policyName is null", async () => {
const { result } = renderHook(
() => useUpdatePolicyVersionStatus(null),
{ wrapper }
);
await expect(
result.current.mutateAsync({ policyId: "v2", status: "published" })
).rejects.toThrow("Missing access token or policy name");
});
});

View file

@ -0,0 +1,134 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import useAuthorized from "../useAuthorized";
import { createQueryKeys } from "../common/queryKeysFactory";
import {
listPolicyVersions,
createPolicyVersion,
updatePolicyVersionStatus,
} from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Policy } from "@/components/policies/types";
// ── Query keys ──────────────────────────────────────────────────────────────
export const policyVersionKeys = createQueryKeys("policyVersions");
// ── Types ───────────────────────────────────────────────────────────────────
export interface PolicyVersionsResponse {
policy_name: string;
versions: Policy[] | undefined;
total_count: number;
}
/** Output type after `select` normalizes the response — versions is always defined. */
export interface PolicyVersionsData {
policy_name: string;
versions: Policy[];
total_count: number;
}
// ── Fetch function ──────────────────────────────────────────────────────────
const fetchPolicyVersions = async (
accessToken: string,
policyName: string
): Promise<PolicyVersionsResponse> => {
return await listPolicyVersions(accessToken, policyName);
};
// ── Hook ────────────────────────────────────────────────────────────────────
export interface UsePolicyVersionsOptions {
policyName: string | null | undefined;
enabled?: boolean;
}
/** Stable key used when the query is disabled to avoid undefined in cache keys. */
const DISABLED_POLICY_KEY = "__disabled__";
export const usePolicyVersions = ({
policyName,
enabled = true,
}: UsePolicyVersionsOptions) => {
const { accessToken } = useAuthorized();
const isEnabled = Boolean(accessToken && policyName && enabled);
return useQuery<PolicyVersionsResponse, Error, PolicyVersionsData>({
queryKey: policyVersionKeys.detail(policyName ?? DISABLED_POLICY_KEY),
queryFn: async () => await fetchPolicyVersions(accessToken!, policyName!),
enabled: isEnabled,
select: (data) => ({
...data,
versions: data.versions ?? [],
}),
});
};
// ── Mutations ───────────────────────────────────────────────────────────────
export const useCreatePolicyVersion = (policyName: string | null | undefined) => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<Policy, Error>({
mutationFn: async () => {
if (!accessToken || !policyName) {
throw new Error("Missing access token or policy name");
}
return await createPolicyVersion(accessToken, policyName);
},
onSuccess: () => {
NotificationsManager.success("New draft version created");
if (policyName) {
queryClient.invalidateQueries({
queryKey: policyVersionKeys.detail(policyName),
});
}
},
onError: (error) => {
NotificationsManager.fromBackend(
"Failed to create version: " + error.message
);
},
});
};
export const useUpdatePolicyVersionStatus = (
policyName: string | null | undefined
) => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<
Policy,
Error,
{ policyId: string; status: "published" | "production" }
>({
mutationFn: async ({ policyId, status }) => {
if (!accessToken || !policyName) {
throw new Error("Missing access token or policy name");
}
return await updatePolicyVersionStatus(accessToken, policyId, status);
},
onSuccess: (_data, variables) => {
const label =
variables.status === "published"
? "Version published. You can test it in the Playground by selecting this version in the Policies dropdown."
: "Version promoted to production";
NotificationsManager.success(label);
if (policyName) {
queryClient.invalidateQueries({
queryKey: policyVersionKeys.detail(policyName),
});
}
},
onError: (error, variables) => {
const action =
variables.status === "published" ? "publish" : "promote to production";
NotificationsManager.fromBackend(
`Failed to ${action}: ${error.message}`
);
},
});
};

View file

@ -1,4 +1,4 @@
import { keepPreviousData, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import { Team } from "@/components/key_team_helpers/key_list";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchTeams } from "@/app/(dashboard)/networking";
@ -124,6 +124,43 @@ export const useTeam = (teamId?: string) => {
});
};
const infiniteTeamKeys = createQueryKeys("infiniteTeams");
export const useInfiniteTeams = (
pageSize: number = 50,
search?: string,
organizationId?: string | null,
) => {
const { accessToken, userId, userRole } = useAuthorized();
const isAdmin = userRole === "Admin" || userRole === "Admin Viewer";
return useInfiniteQuery<TeamsResponse>({
queryKey: infiniteTeamKeys.list({
filters: {
pageSize,
...(search && { search }),
...(organizationId && { organizationId }),
...(userId && { userId }),
},
}),
queryFn: async ({ pageParam }) => {
return await teamListCall(accessToken!, pageParam as number, pageSize, {
team_alias: search || undefined,
organizationID: organizationId,
userID: !isAdmin ? userId : undefined,
});
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.total_pages) {
return lastPage.page + 1;
}
return undefined;
},
enabled: Boolean(accessToken),
});
};
const deletedTeamListCall = async (
accessToken: string,
page: number,

View file

@ -3,7 +3,7 @@
import React, { Suspense, useEffect, useState } from "react";
import Navbar from "@/components/navbar";
import { ThemeProvider } from "@/contexts/ThemeContext";
import Sidebar2 from "@/app/(dashboard)/components/Sidebar2";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useRouter, useSearchParams } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
@ -23,6 +23,17 @@ function withBase(path: string): string {
}
/** -------------------------------- */
/**
* Pages that have been migrated to path-based routing under (dashboard)/.
* When the leftnav triggers one of these, navigate to the path route instead
* of the legacy query-param root page.
*
* Key = legacy page id used in leftnav, Value = route segment under (dashboard)/
*/
const MIGRATED_PAGES: Record<string, string> = {
"api-reference": "api-reference",
};
function LayoutContent({ children }: { children: React.ReactNode }) {
const router = useRouter();
const searchParams = useSearchParams();
@ -32,10 +43,17 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
return searchParams.get("page") || "api-keys";
});
const updatePage = (newPage: string) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("page", newPage);
router.push(withBase(`/?${newSearchParams.toString()}`)); // always under BASE
const handleSetPage = (newPage: string) => {
// If the page has been migrated to path routing, navigate there
const migratedRoute = MIGRATED_PAGES[newPage];
if (migratedRoute) {
router.push(withBase(migratedRoute));
setPage(newPage);
return;
}
// Otherwise, navigate back to the legacy root page with query params
router.push(withBase(`?page=${newPage}`));
setPage(newPage);
};
@ -65,7 +83,11 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
<DebugWarningBanner />
<div className="flex flex-1 overflow-auto">
<div className="mt-2">
<Sidebar2 defaultSelectedKey={page} accessToken={accessToken} userRole={userRole} />
<SidebarProvider
setPage={handleSetPage}
defaultSelectedKey={page}
sidebarCollapsed={sidebarCollapsed}
/>
</div>
<main className="flex-1">{children}</main>
</div>

View file

@ -210,9 +210,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
</Select2>
</Form.Item>
<Form.Item label="Team" name="team_id">
<Select placeholder="Select Team" style={{ width: "100%" }}>
<TeamDropdown teams={availableTeams} />
</Select>
<TeamDropdown />
</Form.Item>
<Form.Item label="Metadata" name="metadata">
@ -294,7 +292,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
name="team_id"
help="If selected, user will be added as a 'user' role to the team."
>
<TeamDropdown teams={availableTeams} />
<TeamDropdown />
</Form.Item>
<Form.Item

View file

@ -16,6 +16,7 @@ import {
TextInput,
} from "@tremor/react";
import {
Alert,
Button,
Card,
Flex,
@ -59,6 +60,7 @@ import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
import NotificationsManager from "./molecules/notifications_manager";
import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking";
import NumericalInput from "./shared/numerical_input";
import SearchToolSelector from "./SearchTools/SearchToolSelector";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
interface TeamProps {
@ -563,6 +565,14 @@ const Teams: React.FC<TeamProps> = ({
delete formValues.allowed_agents_and_groups;
}
// Always send search_tools to ensure the permission record is created.
// Empty array = no access (least privilege for new teams).
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.search_tools = formValues.allowed_search_tool_ids || [];
delete formValues.allowed_search_tool_ids;
// Add model_aliases if any are defined
if (Object.keys(modelAliases).length > 0) {
formValues.model_aliases = modelAliases;
@ -579,14 +589,12 @@ const Teams: React.FC<TeamProps> = ({
}
}
const response: any = await teamCreateCall(accessToken, formValues);
if (teams !== null) {
setTeams([...teams, response]);
} else {
setTeams([response]);
}
console.log(`response for team create call: ${response}`);
await teamCreateCall(accessToken, formValues);
NotificationsManager.success("Team created");
await fetchTeamsV2({
page: currentPage,
size: pageSize,
});
form.resetFields();
setLoggingSettings([]);
setModelAliases({});
@ -1516,6 +1524,40 @@ const Teams: React.FC<TeamProps> = ({
</AccordionBody>
</Accordion>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<b>Search Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<Alert
message="BREAKING CHANGE"
description="New teams have no search tool access by default. Select specific tools to grant access."
type="warning"
showIcon
className="mb-4"
/>
<Form.Item
label={
<span>
Allowed Search Tools{" "}
<Tooltip title="Select which search tools this team can access. New teams default to no access — explicitly grant access to specific search tools.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_search_tool_ids"
className="mt-4"
>
<SearchToolSelector
onChange={(values: string[]) => form.setFieldValue("allowed_search_tool_ids", values)}
value={form.getFieldValue("allowed_search_tool_ids")}
accessToken={accessToken || ""}
placeholder="Select search tools (defaults to no access)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<b>Logging Settings</b>

View file

@ -1,50 +1,14 @@
import { isAdminRole } from "@/utils/roles";
import { InfoCircleOutlined } from "@ant-design/icons";
import { useQuery } from "@tanstack/react-query";
import { Button, TextInput } from "@tremor/react";
import { Form, Input, Modal, Select, Tooltip, Typography } from "antd";
import Image from "next/image";
import { Button, Form, Input, Modal, Select, Tooltip, Typography } from "antd";
import React, { useState } from "react";
import { ProviderLogo } from "../molecules/models/ProviderLogo";
import NotificationsManager from "../molecules/notifications_manager";
import { createSearchTool, fetchAvailableSearchProviders } from "../networking";
import SearchConnectionTest from "./SearchConnectionTest";
import { AvailableSearchProvider, SearchTool } from "./types";
const { TextArea } = Input;
// Search provider logos folder path (matches existing provider logo pattern)
const searchProviderLogosFolder = "../ui/assets/logos/";
// Helper function to get logo path for a search provider
const getSearchProviderLogo = (providerName: string): string => {
return `${searchProviderLogosFolder}${providerName}.png`;
};
// Component to display search provider logo and name
interface SearchProviderLabelProps {
providerName: string;
displayName: string;
}
const SearchProviderLabel: React.FC<SearchProviderLabelProps> = ({ providerName, displayName }) => (
<div style={{ display: "flex", alignItems: "center" }}>
<Image
src={getSearchProviderLogo(providerName)}
alt=""
width={20}
height={20}
style={{
marginRight: "8px",
objectFit: "contain",
}}
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
<span>{displayName}</span>
</div>
);
interface CreateSearchToolProps {
userRole: string;
accessToken: string | null;
@ -85,7 +49,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
const handleCreate = async (formValues: Record<string, any>) => {
setIsLoading(true);
try {
// Prepare the payload
const payload = {
search_tool_name: formValues.search_tool_name,
litellm_params: {
@ -102,8 +65,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
: undefined,
};
console.log(`Creating search tool with payload:`, payload);
if (accessToken != null) {
const response = await createSearchTool(accessToken, payload);
@ -128,13 +89,10 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
const handleTestConnection = async () => {
try {
// Validate required fields for testing
await form.validateFields(["search_provider", "api_key"]);
setIsTestingConnection(true);
// Generate a new test ID (using timestamp for uniqueness)
setConnectionTestId(`test-${Date.now()}`);
// Show the modal with the fresh test
setIsTestModalVisible(true);
} catch (error) {
NotificationsManager.error("Please fill in Search Provider and API Key before testing");
@ -154,144 +112,112 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
return (
<Modal
title={
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
<span className="text-2xl">🔍</span>
<h2 className="text-xl font-semibold text-gray-900">Add New Search Tool</h2>
title="Add New Search Tool"
open={isModalVisible}
width={600}
onCancel={handleCancel}
footer={
<div className="flex justify-between items-center">
<Typography.Link href="https://github.com/BerriAI/litellm/issues" target="_blank">
Need Help?
</Typography.Link>
<div className="space-x-2">
<Button onClick={handleTestConnection} loading={isTestingConnection}>
Test Connection
</Button>
<Button type="primary" onClick={() => form.submit()} loading={isLoading}>
Add Search Tool
</Button>
</div>
</div>
}
open={isModalVisible}
width={800}
onCancel={handleCancel}
footer={null}
className="top-8"
styles={{
body: { padding: "24px" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
<div className="mt-6">
<Form
form={form}
onFinish={handleCreate}
onValuesChange={(_, allValues) => setFormValues(allValues)}
layout="vertical"
className="space-y-6"
<Form
form={form}
onFinish={handleCreate}
onValuesChange={(_, allValues) => setFormValues(allValues)}
layout="vertical"
className="mt-4"
>
<Form.Item
label={
<span>
Search Tool Name{" "}
<Tooltip title="A unique name to identify this search tool configuration (e.g., 'perplexity-search', 'tavily-news-search').">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="search_tool_name"
rules={[
{ required: true, message: "Please enter a search tool name" },
{
pattern: /^[a-zA-Z0-9_-]+$/,
message: "Name can only contain letters, numbers, hyphens, and underscores",
},
]}
>
<div className="grid grid-cols-1 gap-6">
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Search Tool Name
<Tooltip title="A unique name to identify this search tool configuration (e.g., 'perplexity-search', 'tavily-news-search').">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="search_tool_name"
rules={[
{ required: true, message: "Please enter a search tool name" },
{
pattern: /^[a-zA-Z0-9_-]+$/,
message: "Name can only contain letters, numbers, hyphens, and underscores",
},
]}
>
<TextInput
placeholder="e.g., perplexity-search, my-tavily-tool"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Input placeholder="e.g., perplexity-search, my-tavily-tool" />
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Search Provider
<Tooltip title="Select the search provider you want to use. Each provider has different capabilities and pricing.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="search_provider"
rules={[{ required: true, message: "Please select a search provider" }]}
>
<Select
placeholder="Select a search provider"
className="rounded-lg"
size="large"
loading={isLoadingProviders}
showSearch
optionFilterProp="children"
optionLabelProp="label"
<Form.Item
label={
<span>
Search Provider{" "}
<Tooltip title="Select the search provider you want to use. Each provider has different capabilities and pricing.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="search_provider"
rules={[{ required: true, message: "Please select a search provider" }]}
>
<Select
placeholder="Select a search provider"
showSearch
optionFilterProp="children"
optionLabelProp="label"
notFoundContent={isLoadingProviders ? "Loading providers..." : "No providers found"}
>
{availableProviders.map((provider) => (
<Select.Option
key={provider.provider_name}
value={provider.provider_name}
label={provider.ui_friendly_name}
>
{availableProviders.map((provider) => (
<Select.Option
key={provider.provider_name}
value={provider.provider_name}
label={
<SearchProviderLabel
providerName={provider.provider_name}
displayName={provider.ui_friendly_name}
/>
}
>
<SearchProviderLabel
providerName={provider.provider_name}
displayName={provider.ui_friendly_name}
/>
</Select.Option>
))}
</Select>
</Form.Item>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<ProviderLogo provider={provider.provider_name} className="w-5 h-5" />
<span>{provider.ui_friendly_name}</span>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
API Key
<Tooltip title="The API key for authenticating with the search provider. This will be securely stored.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="api_key"
rules={[{ required: false, message: "Please enter an API key" }]}
>
<TextInput
type="password"
placeholder="Enter your API key"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span>
API Key{" "}
<Tooltip title="The API key for authenticating with the search provider. This will be securely stored.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="api_key"
>
<Input.Password placeholder="Enter your API key" />
</Form.Item>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Description (Optional)</span>}
name="description"
>
<TextArea
rows={3}
placeholder="Brief description of this search tool's purpose"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
</div>
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
<Tooltip title="Get help on our github">
<Typography.Link href="https://github.com/BerriAI/litellm/issues" target="_blank">
Need Help?
</Typography.Link>
</Tooltip>
<div className="space-x-2">
<Button onClick={handleTestConnection} loading={isTestingConnection}>
Test Connection
</Button>
<Button loading={isLoading} type="submit">
Add Search Tool
</Button>
</div>
</div>
</Form>
</div>
<Form.Item
label="Description (Optional)"
name="description"
>
<Input.TextArea
rows={3}
placeholder="Brief description of this search tool's purpose"
/>
</Form.Item>
</Form>
{/* Test Connection Results Modal */}
<Modal
@ -314,7 +240,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
]}
width={700}
>
{/* Only render the SearchConnectionTest when modal is visible and we have a test ID */}
{isTestModalVisible && accessToken && (
<SearchConnectionTest
key={connectionTestId}
@ -333,4 +258,3 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
};
export default CreateSearchTool;

View file

@ -27,31 +27,41 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({
} | null>(null);
const [showDetails, setShowDetails] = useState(false);
// Run test only once on mount — parent controls remounting via `key` prop
useEffect(() => {
let cancelled = false;
const runTest = async () => {
setIsLoading(true);
try {
const result = await testSearchToolConnection(accessToken, litellmParams);
if (cancelled) return;
setTestResult(result);
if (result.status === "success") {
NotificationsManager.success("Connection test successful!");
}
} catch (error) {
if (cancelled) return;
setTestResult({
status: "error",
message: error instanceof Error ? error.message : "Unknown error occurred",
error_type: "NetworkError",
});
} finally {
setIsLoading(false);
if (onTestComplete) {
onTestComplete();
if (!cancelled) {
setIsLoading(false);
onTestComplete?.();
}
}
};
runTest();
}, [accessToken, litellmParams, onTestComplete]);
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getCleanErrorMessage = (errorMsg: string) => {
if (!errorMsg) return "Unknown error";

View file

@ -8,6 +8,7 @@ export const searchToolColumns = (
onEdit: (searchToolId: string) => void,
onDelete: (searchToolId: string) => void,
availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>,
isAdmin: boolean = true,
): ColumnsType<SearchTool> => [
{
title: "Search Tool ID",
@ -88,10 +89,10 @@ export const searchToolColumns = (
<TableIconActionButton
variant="Edit"
tooltipText="Edit search tool"
disabled={isFromConfig}
disabledTooltipText="Config search tool cannot be edited on the dashboard. Please edit it from the config file."
disabled={isFromConfig || !isAdmin}
disabledTooltipText={!isAdmin ? "Only admins can edit search tools" : "Config search tool cannot be edited on the dashboard. Please edit it from the config file."}
onClick={() => {
if (toolId && !isFromConfig) {
if (toolId && !isFromConfig && isAdmin) {
onEdit(toolId);
}
}}
@ -99,10 +100,10 @@ export const searchToolColumns = (
<TableIconActionButton
variant="Delete"
tooltipText="Delete search tool"
disabled={isFromConfig}
disabledTooltipText="Config search tool cannot be deleted on the dashboard. Please delete it from the config file."
disabled={isFromConfig || !isAdmin}
disabledTooltipText={!isAdmin ? "Only admins can delete search tools" : "Config search tool cannot be deleted on the dashboard. Please delete it from the config file."}
onClick={() => {
if (toolId && !isFromConfig) {
if (toolId && !isFromConfig && isAdmin) {
onDelete(toolId);
}
}}

View file

@ -0,0 +1,90 @@
import React, { useEffect, useMemo, useState } from "react";
import { Select } from "antd";
import { SearchTool } from "./types";
import { fetchSearchTools } from "../networking";
interface SearchToolSelectorProps {
onChange: (selectedSearchTools: string[]) => void;
value?: string[];
className?: string;
accessToken: string;
placeholder?: string;
disabled?: boolean;
/**
* When set, only search tools whose IDs appear in this list are shown.
* A list containing "*" means all tools are allowed (wildcard / legacy).
* Undefined means no filtering (proxy admin without a team context).
*/
allowedSearchToolIds?: string[];
}
const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
onChange,
value,
className,
accessToken,
placeholder = "Select search tools",
disabled = false,
allowedSearchToolIds,
}) => {
const [searchTools, setSearchTools] = useState<SearchTool[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadSearchTools = async () => {
if (!accessToken) return;
setLoading(true);
try {
const response = await fetchSearchTools(accessToken);
if (response.search_tools) {
setSearchTools(response.search_tools);
}
} catch (error) {
console.error("Error fetching search tools:", error);
} finally {
setLoading(false);
}
};
loadSearchTools();
}, [accessToken]);
// Filter tools based on team permissions
const filteredTools = useMemo(() => {
if (allowedSearchToolIds === undefined) return searchTools;
// Wildcard means all tools are allowed
if (allowedSearchToolIds.length === 1 && allowedSearchToolIds[0] === "*") return searchTools;
// Empty list means no tools are allowed
if (allowedSearchToolIds.length === 0) return [];
// Filter to only allowed IDs
return searchTools.filter(
(tool) => allowedSearchToolIds.includes(tool.search_tool_id || tool.search_tool_name),
);
}, [searchTools, allowedSearchToolIds]);
return (
<div>
<Select
mode="multiple"
placeholder={placeholder}
onChange={onChange}
value={value}
loading={loading}
className={className}
allowClear
options={filteredTools.map((tool) => ({
label: `${tool.search_tool_name}${tool.search_tool_id ? ` (${tool.search_tool_id})` : ""}`,
value: tool.search_tool_id || tool.search_tool_name,
title: tool.search_tool_info?.description || tool.search_tool_name,
}))}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
disabled={disabled}
/>
</div>
);
};
export default SearchToolSelector;

View file

@ -0,0 +1,72 @@
import React, { useState } from "react";
import { Select, Typography } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { SearchToolTester } from "./SearchToolTester";
import { SearchTool, AvailableSearchProvider } from "./types";
const { Text } = Typography;
interface SearchToolTestPlaygroundProps {
searchTools: SearchTool[];
availableProviders: AvailableSearchProvider[];
isLoading: boolean;
accessToken: string;
}
const SearchToolTestPlayground: React.FC<SearchToolTestPlaygroundProps> = ({
searchTools,
availableProviders,
isLoading,
accessToken,
}) => {
const [selectedToolName, setSelectedToolName] = useState<string | null>(null);
const getProviderDisplayName = (providerName: string) => {
const provider = availableProviders.find((p) => p.provider_name === providerName);
return provider?.ui_friendly_name || providerName;
};
return (
<div className="w-full">
<div className="mb-6">
<Text className="text-sm text-gray-600 mb-3 block">
Select a search tool to test with live queries.
</Text>
<Select
placeholder="Select a search tool to test"
className="w-full"
size="large"
value={selectedToolName}
onChange={setSelectedToolName}
loading={isLoading}
showSearch
optionFilterProp="label"
allowClear
options={searchTools.map((tool) => ({
label: `${tool.search_tool_name} (${getProviderDisplayName(tool.litellm_params.search_provider)})`,
value: tool.search_tool_name,
}))}
/>
</div>
{selectedToolName ? (
<SearchToolTester
searchToolName={selectedToolName}
accessToken={accessToken}
/>
) : (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<SearchOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
<Text className="text-lg font-medium text-gray-600 mb-2">
Select a Search Tool to Test
</Text>
<Text className="text-center text-gray-500 max-w-md">
Choose a search tool from the dropdown above to start testing queries and viewing results.
</Text>
</div>
)}
</div>
);
};
export default SearchToolTestPlayground;

View file

@ -8,14 +8,7 @@ vi.mock("@/utils/dataUtils", () => ({
copyToClipboard: vi.fn().mockResolvedValue(true),
}));
vi.mock("./SearchToolTester", () => ({
SearchToolTester: ({ searchToolName, accessToken }: { searchToolName: string; accessToken: string }) => (
<div data-testid="search-tool-tester">
<span>Search Tool Tester for {searchToolName}</span>
<span>Access Token: {accessToken}</span>
</div>
),
}));
describe("SearchToolView", () => {
const mockSearchTool: SearchTool = {
@ -260,19 +253,6 @@ describe("SearchToolView", () => {
expect(nameCopyButton).not.toHaveClass("text-green-600");
});
it("should render SearchToolTester when accessToken is provided", () => {
render(<SearchToolView {...defaultProps} />);
expect(screen.getByTestId("search-tool-tester")).toBeInTheDocument();
expect(screen.getByText(/Search Tool Tester for Test Search Tool/)).toBeInTheDocument();
});
it("should not render SearchToolTester when accessToken is null", () => {
render(<SearchToolView {...defaultProps} accessToken={null} />);
expect(screen.queryByTestId("search-tool-tester")).not.toBeInTheDocument();
});
it("should pass correct props to SearchToolTester", () => {
render(<SearchToolView {...defaultProps} />);
expect(screen.getByText("Access Token: test-token")).toBeInTheDocument();
});
});

View file

@ -4,7 +4,6 @@ import { Button, Card, Grid, Text, Title } from "@tremor/react";
import { Button as AntdButton } from "antd";
import { CheckIcon, CopyIcon } from "lucide-react";
import React, { useState } from "react";
import { SearchToolTester } from "./SearchToolTester";
import { AvailableSearchProvider, SearchTool } from "./types";
interface SearchToolViewProps {
@ -109,15 +108,6 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
</Card>
)}
{/* Search Tool Tester */}
<div className="mt-6">
{accessToken && (
<SearchToolTester
searchToolName={searchTool.search_tool_name}
accessToken={accessToken}
/>
)}
</div>
</div>
);
};

View file

@ -46,6 +46,14 @@ vi.mock("./CreateSearchTools", () => {
return { default: CreateSearchTools };
});
vi.mock("./SearchToolTestPlayground", () => {
const SearchToolTestPlayground = () => (
<div data-testid="search-tool-test-playground">Test Playground</div>
);
SearchToolTestPlayground.displayName = "SearchToolTestPlayground";
return { default: SearchToolTestPlayground };
});
vi.mock("../common_components/DeleteResourceModal", () => {
const DeleteResourceModal = ({
isOpen,
@ -132,7 +140,7 @@ describe("SearchTools", () => {
it("should render", async () => {
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText("Search Tools")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /Search Tools/i })).toBeInTheDocument();
});
});
@ -171,7 +179,7 @@ describe("SearchTools", () => {
it("should show Add New Search Tool button when user is admin", async () => {
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add search tool/i })).toBeInTheDocument();
});
});
@ -180,9 +188,9 @@ describe("SearchTools", () => {
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText("Search Tools")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /Search Tools/i })).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: /add new search tool/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add search tool/i })).not.toBeInTheDocument();
});
it("should open create modal when Add New Search Tool button is clicked", async () => {
@ -190,10 +198,10 @@ describe("SearchTools", () => {
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add search tool/i })).toBeInTheDocument();
});
const addButton = screen.getByRole("button", { name: /add new search tool/i });
const addButton = screen.getByRole("button", { name: /add search tool/i });
await user.click(addButton);
expect(screen.getByTestId("create-search-tool-modal")).toBeInTheDocument();

View file

@ -1,9 +1,9 @@
import { isAdminRole } from "@/utils/roles";
import { LoadingOutlined } from "@ant-design/icons";
import { teamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
import { PlusOutlined, SearchOutlined } from "@ant-design/icons";
import { useQuery } from "@tanstack/react-query";
import { Button, Text, Title } from "@tremor/react";
import { Form, Input, Modal, Select, Spin, Table } from "antd";
import React, { useState } from "react";
import { Button, Form, Input, Modal, Select, Table, Tabs } from "antd";
import React, { useMemo, useState } from "react";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import NotificationsManager from "../molecules/notifications_manager";
import {
@ -12,8 +12,10 @@ import {
fetchSearchTools,
updateSearchTool,
} from "../networking";
import { AntDLoadingSpinner } from "../ui/AntDLoadingSpinner";
import CreateSearchTool from "./CreateSearchTools";
import { searchToolColumns } from "./SearchToolColumn";
import SearchToolTestPlayground from "./SearchToolTestPlayground";
import { SearchToolView } from "./SearchToolView";
import { AvailableSearchProvider, SearchTool } from "./types";
@ -23,7 +25,6 @@ interface SearchToolsProps {
userID: string | null;
}
const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID }) => {
const {
data: searchTools,
@ -52,6 +53,45 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
const availableProviders = providersResponse?.providers || [];
// For non-admin users, fetch their teams to scope search tools
const isAdmin = userRole ? isAdminRole(userRole) : false;
const { data: userTeamsResponse } = useQuery({
queryKey: ["userTeamsForSearchTools", userID],
queryFn: () => {
if (!accessToken || !userID) throw new Error("Missing auth");
return teamListCall(accessToken, 1, 100, { userID }) as Promise<TeamsResponse>;
},
enabled: !!accessToken && !!userID && !isAdmin,
});
// Compute allowed search tool IDs from user's teams
const scopedSearchTools = useMemo(() => {
if (!searchTools) return [];
if (isAdmin) return searchTools;
if (!userTeamsResponse?.teams) return [];
// Collect all search_tool IDs the user's teams grant access to
const allowedIds = new Set<string>();
let hasWildcard = false;
for (const team of userTeamsResponse.teams) {
const teamSearchTools = team.object_permission?.search_tools;
if (!teamSearchTools) continue;
if (teamSearchTools.includes("*")) {
hasWildcard = true;
break;
}
for (const id of teamSearchTools) {
allowedIds.add(id);
}
}
if (hasWildcard) return searchTools;
if (allowedIds.size === 0) return [];
return searchTools.filter(
(tool) => allowedIds.has(tool.search_tool_id || tool.search_tool_name),
);
}, [searchTools, isAdmin, userTeamsResponse]);
// State
const [toolIdToDelete, setToolToDelete] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -87,8 +127,9 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
},
handleDelete,
availableProviders,
isAdmin,
),
[availableProviders, searchTools, form],
[availableProviders, searchTools, form, isAdmin],
);
function handleDelete(toolId: string) {
@ -176,7 +217,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
label="Search Provider"
rules={[{ required: true, message: "Please select a search provider" }]}
>
<Select placeholder="Select a search provider" loading={isLoadingProviders}>
<Select placeholder="Select a search provider" notFoundContent={isLoadingProviders ? "Loading providers..." : "No providers found"}>
{availableProviders.map((provider) => (
<Select.Option key={provider.provider_name} value={provider.provider_name}>
{provider.ui_friendly_name}
@ -196,7 +237,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
);
if (!accessToken || !userRole || !userID) {
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>;
}
@ -221,27 +261,68 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
accessToken={accessToken}
availableProviders={availableProviders}
/>
) : isLoadingTools ? (
<div className="flex justify-center items-center py-16">
<AntDLoadingSpinner size="large" />
</div>
) : (
<div className="w-full h-full">
<Spin spinning={isLoadingTools} indicator={<LoadingOutlined spin />} size="large">
<Table
bordered
dataSource={searchTools || []}
columns={columns}
rowKey={(record) => record.search_tool_id || record.search_tool_name}
pagination={false}
locale={{
emptyText: "No search tools configured",
}}
size="small"
/>
</Spin>
</div>
<Table
bordered
dataSource={scopedSearchTools}
columns={columns}
rowKey={(record) => record.search_tool_id || record.search_tool_name}
pagination={false}
locale={{
emptyText: "No search tools configured",
}}
size="small"
/>
);
return (
<div className="w-full h-full p-6">
<div className="w-full mx-4 h-[75vh]">
<div className="gap-2 p-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-semibold text-gray-900"><SearchOutlined style={{ marginRight: 8 }} />Search Tools</h1>
<p className="text-sm text-gray-500 mt-1">Configure and manage your search providers</p>
</div>
{isAdminRole(userRole) && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalVisible(true)}
>
Add Search Tool
</Button>
)}
</div>
<Tabs
defaultActiveKey="tools"
items={[
{
key: "tools",
label: "Search Tools",
children: <ToolsTab />,
},
{
key: "test",
label: "Test Search Tools",
children: (
<SearchToolTestPlayground
searchTools={scopedSearchTools}
availableProviders={availableProviders}
isLoading={isLoadingTools}
accessToken={accessToken}
/>
),
},
]}
/>
</div>
{/* Modals */}
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Search Tool"
@ -273,7 +354,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
setModalVisible={setCreateModalVisible}
/>
{/* Edit Modal */}
<Modal
title="Edit Search Tool"
open={isEditModalVisible}
@ -287,16 +367,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
>
{renderEditForm()}
</Modal>
<Title>Search Tools</Title>
<Text className="text-tremor-content mt-2">Configure and manage your search providers</Text>
{isAdminRole(userRole) && (
<Button className="mt-4 mb-4" onClick={() => setCreateModalVisible(true)}>
+ Add New Search Tool
</Button>
)}
<ToolsTab />
</div>
);
};

View file

@ -387,7 +387,6 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</span>
{blockScope === "team" ? (
<TeamDropdown
teams={teams}
value={blockTeamId ?? undefined}
onChange={(id) => setBlockTeamId(id || null)}
/>

View file

@ -81,6 +81,20 @@ vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({
}),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: () => ({
data: {
pages: [{ teams: [
{ team_id: "team-1", team_alias: "Test Team", models: ["gpt-4"] },
], total: 1, page: 1, page_size: 50, total_pages: 1 }],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
const mockAuthorizedUser = (userRole: string, userId: string, premiumUser: boolean) => ({
token: "test-token",
accessToken: "test-access-token",
@ -227,7 +241,7 @@ describe("AddModelForm", () => {
const teamSelect = screen.getByRole("combobox");
await userEvent.click(teamSelect);
await userEvent.click(screen.getByText("Test Team"));
await userEvent.click(screen.getByText(/Test Team/));
await waitFor(() => {
expect(screen.getByText("Provider")).toBeInTheDocument();
@ -246,7 +260,7 @@ describe("AddModelForm", () => {
const teamSelect = screen.getByRole("combobox");
await userEvent.click(teamSelect);
await userEvent.click(screen.getByText("Test Team"));
await userEvent.click(screen.getByText(/Test Team/));
await waitFor(() => {
expect(screen.getByText("Provider")).toBeInTheDocument();

View file

@ -131,7 +131,6 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
tooltip="Select the team for which you want to add this model"
>
<TeamDropdown
teams={teams}
onChange={(value) => {
setTeamAdminSelectedTeam(value);
}}
@ -325,7 +324,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
},
]}
>
<TeamDropdown teams={teams} disabled={!premiumUser} />
<TeamDropdown disabled={!premiumUser} />
</Form.Item>
)}
{isAdmin && (

View file

@ -723,10 +723,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
name="team_id"
tooltip="Optionally assign this agent to a team. The agent and its key will belong to the selected team."
>
<TeamDropdown
teams={teams}
loading={!teams}
/>
<TeamDropdown />
</Form.Item>
<Divider className="my-4" />

View file

@ -1,5 +1,5 @@
import { Member } from "@/components/networking";
import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
import { CrownOutlined, InfoCircleOutlined, SafetyCertificateOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
import type { ColumnsType } from "antd/es/table";
import React from "react";
@ -13,6 +13,7 @@ export interface MemberTableProps {
onEdit: (member: Member) => void;
onDelete: (member: Member) => void;
onAddMember?: () => void;
onPermissions?: (member: Member) => void;
roleColumnTitle?: string;
roleTooltip?: string;
extraColumns?: ColumnsType<Member>;
@ -26,6 +27,7 @@ export default function MemberTable({
onEdit,
onDelete,
onAddMember,
onPermissions,
roleColumnTitle = "Role",
roleTooltip,
extraColumns = [],
@ -83,6 +85,17 @@ export default function MemberTable({
render: (_: unknown, record: Member) =>
canEdit ? (
<Space>
{onPermissions && (
<Tooltip title="Permissions">
<Button
type="text"
size="small"
icon={<SafetyCertificateOutlined />}
onClick={() => onPermissions(record)}
data-testid="permissions-member"
/>
</Tooltip>
)}
<TableIconActionButton
variant="Edit"
tooltipText="Edit member"

View file

@ -1,46 +1,120 @@
import React from "react";
import React, { useMemo, useState, type UIEvent } from "react";
import { Select } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../key_team_helpers/key_list";
interface TeamDropdownProps {
teams?: Team[] | null;
value?: string;
onChange?: (value: string) => void;
/** Callback with the full Team object (or null on clear). */
onTeamSelect?: (team: Team | null) => void;
disabled?: boolean;
loading?: boolean;
/** Filter teams by organization. */
organizationId?: string | null;
pageSize?: number;
}
const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, disabled, loading }) => {
const SCROLL_THRESHOLD = 0.8;
const DEBOUNCE_MS = 300;
const TeamDropdown: React.FC<TeamDropdownProps> = ({
value,
onChange,
onTeamSelect,
disabled,
organizationId,
pageSize = 50,
}) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_MS,
});
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteTeams(
pageSize,
debouncedSearch || undefined,
organizationId,
);
const teams = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
const result: Team[] = [];
for (const page of data.pages) {
for (const team of page.teams) {
if (seen.has(team.team_id)) continue;
seen.add(team.team_id);
result.push(team);
}
}
return result;
}, [data]);
const options = useMemo(
() =>
teams.map((team) => ({
label: `${team.team_alias} (${team.team_id})`,
value: team.team_id,
})),
[teams],
);
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio =
(target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const handleSearch = (val: string) => {
setSearchInput(val);
setDebouncedSearch(val);
};
const handleChange = (teamId: string | undefined) => {
onChange?.(teamId ?? "");
if (onTeamSelect) {
const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null;
onTeamSelect(team);
}
};
return (
<Select
showSearch
placeholder="Search or select a team"
value={value}
onChange={onChange}
value={value || undefined}
onChange={handleChange}
disabled={disabled}
loading={loading}
allowClear
filterOption={(input, option) => {
if (!option) return false;
// Get team data from the option key
const team = teams?.find((t) => t.team_id === option.key);
if (!team) return false;
const searchTerm = input.toLowerCase().trim();
const teamAlias = (team.team_alias || "").toLowerCase();
const teamId = (team.team_id || "").toLowerCase();
// Search in both team alias and team ID
return teamAlias.includes(searchTerm) || teamId.includes(searchTerm);
}}
optionFilterProp="children"
>
{teams?.map((team) => (
<Select.Option key={team.team_id} value={team.team_id}>
<span className="font-medium">{team.team_alias}</span> <span className="text-gray-500">({team.team_id})</span>
</Select.Option>
))}
</Select>
filterOption={false}
onSearch={handleSearch}
searchValue={searchInput}
onPopupScroll={handlePopupScroll}
loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
options={options}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
);
};

View file

@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import EditUserModal from "./edit_user";
const possibleUIRoles: Record<string, Record<string, string>> = {
admin: { ui_label: "Admin", description: "Full access" },
user: { ui_label: "User", description: "Standard access" },
};
const mockUser = {
user_id: "user-123",
user_email: "test@example.com",
user_role: "user",
spend: 10.5,
max_budget: 100,
};
describe("EditUserModal", () => {
const defaultProps = {
visible: true,
possibleUIRoles,
onCancel: vi.fn(),
user: mockUser,
onSubmit: vi.fn(),
};
it("should render", () => {
render(<EditUserModal {...defaultProps} />);
expect(screen.getByText(/edit user user-123/i)).toBeInTheDocument();
});
it("should return null when user is null", () => {
render(<EditUserModal {...defaultProps} user={null} />);
expect(screen.queryByText(/edit user/i)).not.toBeInTheDocument();
});
it("should display the user email field", () => {
render(<EditUserModal {...defaultProps} />);
expect(screen.getByText("User Email")).toBeInTheDocument();
});
it("should display the user role field", () => {
render(<EditUserModal {...defaultProps} />);
expect(screen.getByText("User Role")).toBeInTheDocument();
});
it("should call onCancel when cancel is triggered", async () => {
const onCancel = vi.fn();
const user = userEvent.setup();
render(<EditUserModal {...defaultProps} onCancel={onCancel} />);
// Click the X close button on the modal
await user.click(screen.getByRole("button", { name: /close/i }));
expect(onCancel).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,77 @@
import { render, screen } from "@testing-library/react";
import EmailSettings from "./email_settings";
vi.mock("./networking", () => ({
serviceHealthCheck: vi.fn(),
setCallbacksCall: vi.fn(),
}));
vi.mock("./email_events", () => ({
EmailEventSettings: () => <div data-testid="email-event-settings" />,
}));
const mockAlerts = [
{
name: "email",
variables: {
SMTP_HOST: "smtp.example.com",
SMTP_PORT: "587",
SMTP_USERNAME: "user",
SMTP_PASSWORD: "pass",
SMTP_SENDER_EMAIL: "sender@example.com",
TEST_EMAIL_ADDRESS: "test@example.com",
},
},
];
describe("EmailSettings", () => {
const defaultProps = {
accessToken: "test-token",
premiumUser: true,
alerts: mockAlerts,
};
it("should render", () => {
render(<EmailSettings {...defaultProps} />);
expect(screen.getByText("Email Server Settings")).toBeInTheDocument();
});
it("should display SMTP fields from alerts", () => {
render(<EmailSettings {...defaultProps} />);
expect(screen.getByText("SMTP_HOST")).toBeInTheDocument();
expect(screen.getByText("SMTP_PORT")).toBeInTheDocument();
expect(screen.getByText("SMTP_USERNAME")).toBeInTheDocument();
});
it("should show Save Changes button", () => {
render(<EmailSettings {...defaultProps} />);
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
});
it("should show Test Email Alerts button", () => {
render(<EmailSettings {...defaultProps} />);
expect(screen.getByRole("button", { name: /test email alerts/i })).toBeInTheDocument();
});
it("should show Required markers for required SMTP fields", () => {
render(<EmailSettings {...defaultProps} />);
expect(screen.getAllByText(/required \*/i).length).toBeGreaterThanOrEqual(5);
});
it("should disable premium fields for non-premium users", () => {
render(<EmailSettings {...defaultProps} premiumUser={false} />);
// EMAIL_LOGO_URL and EMAIL_SUPPORT_CONTACT should have a sparkle prefix for non-premium
// They'll still show but with different rendering
expect(screen.getByText("SMTP_HOST")).toBeInTheDocument();
});
it("should render EmailEventSettings sub-component", () => {
render(<EmailSettings {...defaultProps} />);
expect(screen.getByTestId("email-event-settings")).toBeInTheDocument();
});
it("should render docs link", () => {
render(<EmailSettings {...defaultProps} />);
expect(screen.getByText(/litellm docs: email alerts/i)).toBeInTheDocument();
});
});

View file

@ -15,6 +15,16 @@ export interface Team {
keys: KeyResponse[];
members_with_roles: Member[];
spend: number;
object_permission?: {
object_permission_id?: string;
search_tools?: string[];
mcp_servers?: string[];
mcp_access_groups?: string[];
mcp_tool_permissions?: Record<string, string[]>;
vector_stores?: string[];
agents?: string[];
agent_access_groups?: string[];
};
}
export interface KeyResponse {
@ -89,6 +99,7 @@ export interface KeyResponse {
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
};
access_group_ids?: string[];
auto_rotate?: boolean;

View file

@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import KeyValueInput from "./key_value_input";
describe("KeyValueInput", () => {
it("should render", () => {
render(<KeyValueInput />);
expect(screen.getByRole("button", { name: /add header/i })).toBeInTheDocument();
});
it("should render existing key-value pairs from initial value", () => {
render(<KeyValueInput value={{ "X-Api-Key": "secret123" }} />);
expect(screen.getByDisplayValue("X-Api-Key")).toBeInTheDocument();
expect(screen.getByDisplayValue("secret123")).toBeInTheDocument();
});
it("should add a new empty pair when clicking Add Header", async () => {
const user = userEvent.setup();
render(<KeyValueInput />);
await user.click(screen.getByRole("button", { name: /add header/i }));
expect(screen.getByPlaceholderText("Header Name")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Header Value")).toBeInTheDocument();
});
it("should call onChange when a key is typed", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<KeyValueInput value={{}} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /add header/i }));
await user.type(screen.getByPlaceholderText("Header Name"), "Authorization");
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ Authorization: "" })
);
});
it("should remove a pair when clicking the remove icon", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<KeyValueInput value={{ key1: "val1" }} onChange={onChange} />);
expect(screen.getByDisplayValue("key1")).toBeInTheDocument();
// MinusCircleOutlined renders with role="img"
await user.click(screen.getByRole("img", { name: /minus-circle/i }));
expect(screen.queryByDisplayValue("key1")).not.toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith({});
});
});

View file

@ -36,8 +36,34 @@ import { all_admin_roles, internalUserRoles, isAdminRole, isUserTeamAdminForAnyT
import NewBadge from "./common_components/NewBadge";
import type { Organization } from "./networking";
import UsageIndicator from "./UsageIndicator";
import { serverRootPath } from "./networking";
const { Sider } = Layout;
/**
* Pages migrated to path-based routing under (dashboard)/.
* Key = legacy page id, Value = route segment.
* Keep in sync with MIGRATED_PAGES in (dashboard)/layout.tsx and
* LEGACY_REDIRECTS in app/page.tsx.
*/
const MIGRATED_PAGES: Record<string, string> = {
"api-reference": "api-reference",
};
/** Build an absolute href for a migrated page, respecting base URL + serverRootPath. */
function migratedHref(routeSegment: string): string {
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? "";
const trimmed = raw.replace(/^\/+|\/+$/g, "");
let base = trimmed ? `/${trimmed}/` : "/";
if (serverRootPath && serverRootPath !== "/") {
const cleanRoot = serverRootPath.replace(/\/+$/, "");
const cleanBase = base.replace(/^\/+/, "");
base = `${cleanRoot}/${cleanBase}`;
}
return `${base}${routeSegment}`;
}
// Define the props type
interface SidebarProps {
setPage: (page: string) => void;
@ -379,6 +405,11 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
// Navigate to page helper
const navigateToPage = (page: string) => {
// For migrated pages, just call setPage — the parent layout handles routing
if (MIGRATED_PAGES[page]) {
setPage(page);
return;
}
const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("page", page);
window.history.pushState(null, "", `?${newSearchParams.toString()}`);
@ -405,9 +436,11 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
</a>
);
}
const params = new URLSearchParams(window.location.search);
params.set("page", page);
const href = `?${params.toString()}`;
// For migrated pages, generate a path-based href for right-click "Open in new tab"
const migratedRoute = MIGRATED_PAGES[page];
const href = migratedRoute
? migratedHref(migratedRoute)
: (() => { const params = new URLSearchParams(window.location.search); params.set("page", page); return `?${params.toString()}`; })();
return (
<a
href={href}

View file

@ -9,6 +9,28 @@ vi.mock("../networking", () => ({
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => ({
data: [
{ team_id: "team-1", team_alias: "Team One" },
{ team_id: "team-2", team_alias: "Team Two" },
],
isLoading: false,
}),
useInfiniteTeams: () => ({
data: {
pages: [{ teams: [
{ team_id: "team-1", team_alias: "Team One" },
{ team_id: "team-2", team_alias: "Team Two" },
], total: 2, page: 1, page_size: 50, total_pages: 1 }],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("@/hooks/useMcpOAuthFlow", () => ({
useMcpOAuthFlow: () => ({
startOAuthFlow: vi.fn(),
@ -100,10 +122,12 @@ describe("CreateMCPServer", () => {
expect(screen.getByText("Add New MCP Server")).toBeInTheDocument();
});
it("should not render when user is not an admin", () => {
it("should render for internal users with team selection prompt", () => {
render(<CreateMCPServer {...defaultProps} userRole="Internal User" />);
expect(screen.queryByText("Add New MCP Server")).not.toBeInTheDocument();
expect(screen.getByText("Add New MCP Server")).toBeInTheDocument();
// Internal users without a selected team see a prompt
expect(screen.getByText("Select a team to create an MCP server for your team.")).toBeInTheDocument();
});
it("should show transport type options", async () => {

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer, registerMCPServer } from "../networking";
import { createMCPServer } from "../networking";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
@ -17,6 +17,8 @@ import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import TeamDropdown from "../common_components/team_dropdown";
const asset_logos_folder = "../ui/assets/logos/";
export const mcpLogoImg = `${asset_logos_folder}mcp_logo.png`;
@ -30,6 +32,7 @@ interface CreateMCPServerProps {
availableAccessGroups: string[];
prefillData?: DiscoverableMCPServer | null;
onBackToDiscovery?: () => void;
teamId?: string | null;
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.TOKEN, AUTH_TYPE.BASIC];
@ -54,6 +57,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
availableAccessGroups,
prefillData,
onBackToDiscovery,
teamId,
}) => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
@ -73,6 +77,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(teamId ?? undefined);
const { data: teams, isLoading: isLoadingTeams } = useTeams();
// Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests.
const { tools, isLoadingTools, toolsError, toolsErrorStackTrace, canFetchTools, fetchTools, clearTools } = useTestMCPConnection({
@ -385,18 +391,17 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
payload.credentials = credentialsPayload;
}
// Include team_id when a team is selected
if (selectedTeamId) {
payload.team_id = selectedTeamId;
}
console.log(`Payload: ${JSON.stringify(payload)}`);
if (accessToken != null) {
const response = isAdmin
? await createMCPServer(accessToken, payload)
: await registerMCPServer(accessToken, payload);
const response = await createMCPServer(accessToken, payload);
NotificationsManager.success(
isAdmin
? "MCP Server created successfully"
: "MCP Server submitted for admin review"
);
NotificationsManager.success("MCP Server created successfully");
form.resetFields();
setCostConfig({});
clearTools();
@ -408,9 +413,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
NotificationsManager.fromBackend(
isAdmin ? `Error creating MCP Server: ${reason}` : `Error submitting MCP Server: ${reason}`
);
NotificationsManager.fromBackend(`Error creating MCP Server: ${reason}`);
} finally {
setIsLoading(false);
}
@ -480,6 +483,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
}, [formValues.server_name]);
// Sync selectedTeamId when the parent teamId prop changes
React.useEffect(() => {
setSelectedTeamId(teamId ?? undefined);
}, [teamId]);
// Clear formValues when modal closes to reset child components
React.useEffect(() => {
if (!isModalVisible) {
@ -514,7 +522,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}}
/>
<h2 className="text-xl font-semibold text-gray-900">
{isAdmin ? "Add New MCP Server" : "Submit MCP Server for Review"}
Add New MCP Server
</h2>
</div>
}
@ -537,12 +545,27 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
layout="vertical"
className="space-y-6"
>
{!isAdmin && (
<div className="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800">
Your submission will be sent for admin review before it becomes active.
{" "}Note: the request must be made with a team-scoped API key.
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Team
<Tooltip title="Assign this MCP server to a team. The server will be added to the team's permissions automatically.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
>
<TeamDropdown
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value)}
/>
</Form.Item>
{!isAdmin && !selectedTeamId ? (
<div className="rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800">
Select a team to create an MCP server for your team.
</div>
)}
) : (
<>
<div className="grid grid-cols-1 gap-6">
<Form.Item
label={
@ -994,12 +1017,14 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
disabled={false}
/>
</div>
</>
)}
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
<Button variant="secondary" onClick={handleCancel}>
Cancel
</Button>
<Button variant="primary" loading={isLoading}>
<Button variant="primary" loading={isLoading} disabled={!isAdmin && !selectedTeamId}>
{isLoading ? "Creating..." : "Add MCP Server"}
</Button>
</div>

View file

@ -5,7 +5,7 @@ import { Icon } from "@tremor/react";
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import { getMaskedAndFullUrl } from "./utils";
import { Tooltip } from "antd";
import { CheckOutlined } from "@ant-design/icons";
import { CheckOutlined, InfoCircleOutlined } from "@ant-design/icons";
const HealthStatusBadge: React.FC<{
server: MCPServer;
@ -92,10 +92,12 @@ export const mcpServerColumns = (
onByokConnect?: (server: MCPServer) => void,
onRecheckHealth?: (serverId: string) => void,
recheckingServerIds?: Set<string>,
teamAliasMap?: Map<string, string>,
): ColumnDef<MCPServer>[] => [
{
accessorKey: "server_id",
header: "Server ID",
size: 90,
enableSorting: true,
cell: ({ row }) => (
<button
@ -109,12 +111,13 @@ export const mcpServerColumns = (
{
accessorKey: "server_name",
header: "Name",
size: 150,
enableSorting: true,
cell: ({ row }) => {
const logoUrl = row.original.mcp_info?.logo_url;
const name = row.original.server_name;
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 truncate">
{logoUrl ? (
<img
src={logoUrl}
@ -123,31 +126,53 @@ export const mcpServerColumns = (
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : null}
<span>{name}</span>
<span className="truncate">{name}</span>
</div>
);
},
},
{
accessorKey: "alias",
header: "Alias",
accessorKey: "team_id",
header: () => (
<span className="flex items-center gap-1">
Team (Owner)
<Tooltip title="Shows the team that owns this MCP server. You can only see servers owned by teams where you have the mcp:read permission.">
<InfoCircleOutlined className="text-gray-400 cursor-help" style={{ fontSize: 12 }} />
</Tooltip>
</span>
),
size: 130,
enableSorting: true,
cell: ({ row }) => {
const tid = row.original.team_id;
if (!tid) {
return <span className="text-xs text-gray-400 italic">Global</span>;
}
const alias = teamAliasMap?.get(tid);
return (
<Tooltip title={tid}>
<span className="text-sm truncate block">{alias || tid.slice(0, 8) + "..."}</span>
</Tooltip>
);
},
},
{
id: "url",
header: "URL",
size: 180,
cell: ({ row }) => {
const url = row.original.url;
if (!url) {
return <span className="text-gray-400"></span>;
}
const { maskedUrl } = getMaskedAndFullUrl(url);
return <span className="font-mono text-sm">{maskedUrl}</span>;
return <span className="font-mono text-sm truncate block">{maskedUrl}</span>;
},
},
{
accessorKey: "transport",
header: "Transport",
size: 80,
enableSorting: true,
cell: ({ row }) => {
const transport = row.original.transport || "http";
@ -161,22 +186,10 @@ export const mcpServerColumns = (
);
},
},
{
accessorKey: "auth_type",
header: "Auth Type",
enableSorting: true,
cell: ({ getValue }) => {
const authType = (getValue() as string) || "none";
return (
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200">
{authType}
</span>
);
},
},
{
id: "health_status",
header: "Health Status",
header: "Health",
size: 80,
cell: ({ row }) => (
<HealthStatusBadge
server={row.original}
@ -189,6 +202,7 @@ export const mcpServerColumns = (
{
id: "mcp_access_groups",
header: "Access Groups",
size: 130,
cell: ({ row }) => {
const groups = row.original.mcp_access_groups;
if (Array.isArray(groups) && groups.length > 0) {
@ -196,8 +210,8 @@ export const mcpServerColumns = (
const joined = groups.join(", ");
return (
<Tooltip title={joined}>
<div className="flex items-center gap-1 max-w-[200px]">
<span className="inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200 truncate max-w-[140px]">
<div className="flex items-center gap-1">
<span className="inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200 truncate max-w-[100px]">
{groups[0]}
</span>
{groups.length > 1 && (
@ -213,7 +227,8 @@ export const mcpServerColumns = (
},
{
id: "available_on_public_internet",
header: "Network Access",
header: "Network",
size: 80,
cell: ({ row }) => {
const isPublic = row.original.available_on_public_internet;
return isPublic ? (
@ -232,6 +247,7 @@ export const mcpServerColumns = (
{
header: "Created",
accessorKey: "created_at",
size: 90,
enableSorting: true,
sortingFn: "datetime",
cell: ({ row }) => {
@ -245,25 +261,10 @@ export const mcpServerColumns = (
);
},
},
{
header: "Updated",
accessorKey: "updated_at",
enableSorting: true,
sortingFn: "datetime",
cell: ({ row }) => {
const server = row.original;
if (!server.updated_at) return <span className="text-xs text-gray-400"></span>;
const date = new Date(server.updated_at);
return (
<Tooltip title={date.toLocaleString()}>
<span className="text-xs text-gray-600">{date.toLocaleDateString()}</span>
</Tooltip>
);
},
},
{
id: "byok_credential",
header: "Credential",
size: 90,
cell: ({ row }) => {
const server = row.original;
if (!server.is_byok) {
@ -299,6 +300,7 @@ export const mcpServerColumns = (
{
id: "actions",
header: "Actions",
size: 70,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Tooltip title="Edit">

View file

@ -121,8 +121,6 @@ describe("MCPServers", () => {
// Verify the mocked server data is rendered in the table
expect(getByText("Test Server 1")).toBeInTheDocument();
expect(getByText("Test Server 2")).toBeInTheDocument();
expect(getByText("test-server-1")).toBeInTheDocument();
expect(getByText("test-server-2")).toBeInTheDocument();
// Verify the API was called
// Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock
@ -306,8 +304,9 @@ describe("MCPServers", () => {
expect(screen.getByText("Team B Server")).toBeInTheDocument();
expect(screen.getByText("Team A Server 2")).toBeInTheDocument();
// Find the team select dropdown by looking for the "Team" label
const teamLabel = screen.getByText("Team");
// Find the team filter dropdown (not the "Team (Owner)" column header)
const teamLabels = screen.getAllByText("Team");
const teamLabel = teamLabels.find((el) => el.classList.contains("text-sm") && el.classList.contains("font-medium") && el.classList.contains("text-gray-600"))!;
const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select");
expect(teamSelectContainer).toBeTruthy();

View file

@ -15,6 +15,7 @@ import MCPConnect from "./mcp_connect";
import { mcpServerColumns } from "./mcp_server_columns";
import { MCPServerView } from "./mcp_server_view";
import { DiscoverableMCPServer, MCPServer, MCPServerProps, Team } from "./types";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
import MCPNetworkSettings from "./MCPNetworkSettings";
import MCPDiscovery from "./mcp_discovery";
@ -26,7 +27,7 @@ const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
const { Option } = Select;
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => {
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
const { data: mcpServers, isLoading: isLoadingServers, error: mcpServersError, refetch } = useMCPServers();
// Fetch health status for all servers
const { data: healthStatuses, isLoading: isLoadingHealth, recheckServerHealth, recheckingServerIds } = useMCPServerHealth();
@ -63,6 +64,16 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
const isInternalUser = userRole === "Internal User";
const { data: allTeams } = useTeams();
const teamAliasMap = React.useMemo(() => {
const map = new Map<string, string>();
if (allTeams) {
for (const t of allTeams) {
if (t.team_alias) map.set(t.team_id, t.team_alias);
}
}
return map;
}, [allTeams]);
useEffect(() => {
if (typeof window === "undefined") {
@ -171,8 +182,9 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
(server: MCPServer) => setByokModalServer(server),
recheckServerHealth,
recheckingServerIds,
teamAliasMap,
),
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds],
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds, teamAliasMap],
);
function handleDelete(server_id: string) {
@ -237,11 +249,6 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
refetch();
}, [refetch]);
if (!accessToken || !userRole || !userID) {
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>;
}
return (
<div className="w-full h-full p-6">
<Modal
@ -284,7 +291,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</div>
</Modal>
<CreateMCPServer
userRole={userRole}
userRole={userRole ?? ""}
accessToken={accessToken}
onCreateSuccess={handleCreateSuccess}
isModalVisible={isModalVisible}
@ -296,6 +303,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
setPrefillData(null);
setDiscoveryVisible(true);
}}
teamId={selectedTeam !== "all" && selectedTeam !== "personal" ? selectedTeam : null}
/>
<div className="flex items-center justify-between">
<div>
@ -310,23 +318,9 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<Text className="text-tremor-content mt-1">Configure and manage your MCP servers</Text>
</div>
<div className="flex items-center gap-2">
{isAdminRole(userRole) && (
<Button className="flex-shrink-0" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
)}
{!isAdminRole(userRole) && (
<Button
className="flex-shrink-0"
onClick={() => {
setPrefillData(null);
setModalVisible(true);
}}
variant="secondary"
>
+ Submit MCP Server
</Button>
)}
<Button className="flex-shrink-0" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
</div>
</div>
<MCPDiscovery
@ -351,7 +345,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<Tab>Connect</Tab>
<Tab>Semantic Filter</Tab>
<Tab>Network Settings</Tab>
{isAdminRole(userRole) && <Tab><span className="flex items-center gap-2">Submitted MCPs <NewBadge /></span></Tab>}
{isAdminRole(userRole ?? "") && <Tab><span className="flex items-center gap-2">Submitted MCPs <NewBadge /></span></Tab>}
</div>
</TabList>
<TabPanels>
@ -361,7 +355,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
key={selectedServerId}
mcpServer={selectedServer}
onBack={handleBack}
isProxyAdmin={isAdminRole(userRole)}
isProxyAdmin={isAdminRole(userRole ?? "")}
isEditing={editServer}
accessToken={accessToken}
userID={userID}
@ -412,16 +406,38 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</div>
</div>
<div className="w-full mt-6">
{mcpServersError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-8 text-center">
<h3 className="text-lg font-semibold text-red-800 mb-2">Unable to load MCP servers</h3>
<p className="text-sm text-red-700 mb-4">
{mcpServersError.message?.includes("403")
? "You do not have permission to view MCP servers."
: `Error: ${mcpServersError.message}`}
</p>
<div className="text-sm text-red-600 bg-red-100 rounded-md p-4 inline-block text-left">
<p className="font-medium mb-2">To resolve this, ask your team admin or proxy admin to:</p>
<ol className="list-decimal list-inside space-y-1">
<li>Go to <span className="font-mono">Teams</span> and select your team</li>
<li>Find your user in the team members list</li>
<li>Add the <span className="font-mono bg-red-200 px-1 rounded">mcp:read</span> permission to your user</li>
</ol>
</div>
</div>
) : (
<DataTable
data={filteredServers}
columns={columns}
renderSubComponent={() => <div></div>}
getRowCanExpand={() => false}
isLoading={isLoadingServers}
noDataMessage="No MCP servers configured. Click '+ Add New MCP Server' to get started."
noDataMessage={isInternalUser
? "No MCP servers available. Your team may not have any MCP servers assigned, or you may need the mcp:read permission. Contact your team admin."
: "No MCP servers configured. Click '+ Add New MCP Server' to get started."
}
loadingMessage="Loading MCP servers..."
enableSorting={true}
/>
)}
</div>
</div>
)}
@ -435,7 +451,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<TabPanel>
<MCPNetworkSettings accessToken={accessToken} />
</TabPanel>
{isAdminRole(userRole) && (
{isAdminRole(userRole ?? "") && (
<TabPanel>
<MCPSubmissionsTab accessToken={accessToken} />
</TabPanel>

View file

@ -190,6 +190,7 @@ export interface MCPServer {
created_by: string;
updated_at: string;
updated_by: string;
team_id?: string | null;
extra_headers?: string[] | null;
static_headers?: Record<string, string> | null;
status?: "healthy" | "unhealthy" | "unknown";

View file

@ -0,0 +1,93 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ModelFilters from "./model_filters";
const mockData = [
{
model_group: "gpt-4",
providers: ["openai"],
mode: "chat",
supports_function_calling: true,
supports_vision: false,
supports_parallel_function_calling: false,
is_public_model_group: true,
},
{
model_group: "claude-3",
providers: ["anthropic"],
mode: "chat",
supports_function_calling: true,
supports_vision: true,
supports_parallel_function_calling: false,
is_public_model_group: true,
},
{
model_group: "whisper-1",
providers: ["openai"],
mode: "audio_transcription",
supports_function_calling: false,
supports_vision: false,
supports_parallel_function_calling: false,
is_public_model_group: true,
},
];
describe("ModelFilters", () => {
const defaultProps = {
modelHubData: mockData,
onFilteredDataChange: vi.fn(),
};
it("should render", () => {
render(<ModelFilters {...defaultProps} />);
expect(screen.getByPlaceholderText(/search model names/i)).toBeInTheDocument();
});
it("should filter models by search term", async () => {
const onFilteredDataChange = vi.fn();
const user = userEvent.setup();
render(<ModelFilters {...defaultProps} onFilteredDataChange={onFilteredDataChange} />);
await user.type(screen.getByPlaceholderText(/search model names/i), "gpt");
expect(onFilteredDataChange).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ model_group: "gpt-4" })])
);
});
it("should show provider dropdown with available providers", () => {
render(<ModelFilters {...defaultProps} />);
const providerSelect = screen.getByDisplayValue("All Providers");
expect(providerSelect).toBeInTheDocument();
});
it("should show mode dropdown with available modes", () => {
render(<ModelFilters {...defaultProps} />);
expect(screen.getByDisplayValue("All Modes")).toBeInTheDocument();
});
it("should show features dropdown", () => {
render(<ModelFilters {...defaultProps} />);
expect(screen.getByDisplayValue("All Features")).toBeInTheDocument();
});
it("should show Clear Filters button when a filter is active", async () => {
const user = userEvent.setup();
render(<ModelFilters {...defaultProps} />);
await user.type(screen.getByPlaceholderText(/search model names/i), "gpt");
expect(screen.getByText("Clear Filters")).toBeInTheDocument();
});
it("should not show Clear Filters button when no filters are active", () => {
render(<ModelFilters {...defaultProps} />);
expect(screen.queryByText("Clear Filters")).not.toBeInTheDocument();
});
it("should render without Card wrapper when showFiltersCard is false", () => {
const { container } = render(<ModelFilters {...defaultProps} showFiltersCard={false} />);
// When showFiltersCard=false, it renders a plain div instead of a Card
expect(container.querySelector(".tremor-Card-root")).toBeNull();
});
});

View file

@ -3753,6 +3753,7 @@ export interface Member {
max_budget_in_team?: number | null;
tpm_limit?: number | null;
rpm_limit?: number | null;
extra_permissions?: string[] | null;
}
export const teamMemberAddCall = async (accessToken: string, teamId: string, formValues: Member) => {
@ -3892,6 +3893,9 @@ export const teamMemberUpdateCall = async (
if (formValues.rpm_limit !== undefined && formValues.rpm_limit !== null) {
requestBody.rpm_limit = formValues.rpm_limit;
}
if (formValues.extra_permissions !== undefined) {
requestBody.extra_permissions = formValues.extra_permissions;
}
console.log("Final request body:", requestBody);
@ -6570,6 +6574,41 @@ export const fetchMCPClientIp = async (accessToken: string): Promise<string | nu
}
};
export interface AvailablePermission {
value: string;
label: string;
resource: string;
}
export const fetchAvailableTeamMemberPermissions = async (accessToken: string): Promise<AvailablePermission[]> => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/team/available_permissions`
: `/team/available_permissions`;
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data || [];
} catch (error) {
console.error("Failed to fetch available permissions:", error);
throw error;
}
};
export const createMCPServer = async (
accessToken: string,
formValues: Record<string, any>, // Assuming formValues is an object
@ -7288,12 +7327,11 @@ export const getTeamPermissionsCall = async (accessToken: string, teamId: string
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
console.error("Available permissions fetch failed:", errorMessage);
return { all_available_permissions: [], team_member_permissions: [] };
}
const data = await response.json();
console.log("Team permissions response:", data);
return data;
} catch (error) {
console.error("Failed to get team permissions:", error);

View file

@ -3,6 +3,7 @@ import { Text } from "@tremor/react";
import VectorStorePermissions from "./permissions/VectorStorePermissions";
import MCPServerPermissions from "./permissions/MCPServerPermissions";
import AgentPermissions from "./permissions/AgentPermissions";
import SearchToolPermissions from "./permissions/SearchToolPermissions";
interface ObjectPermission {
object_permission_id: string;
@ -12,6 +13,7 @@ interface ObjectPermission {
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
}
interface ObjectPermissionsViewProps {
@ -33,21 +35,26 @@ export function ObjectPermissionsView({
const mcpToolPermissions = objectPermission?.mcp_tool_permissions || {};
const agents = objectPermission?.agents || [];
const agentAccessGroups = objectPermission?.agent_access_groups || [];
const searchTools = objectPermission?.search_tools || [];
const content = (
<div className={variant === "card" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" : "space-y-4"}>
<div className={variant === "card" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" : "space-y-4"}>
<VectorStorePermissions vectorStores={vectorStores} accessToken={accessToken} />
<MCPServerPermissions
mcpServers={mcpServers}
mcpAccessGroups={mcpAccessGroups}
<MCPServerPermissions
mcpServers={mcpServers}
mcpAccessGroups={mcpAccessGroups}
mcpToolPermissions={mcpToolPermissions}
accessToken={accessToken}
accessToken={accessToken}
/>
<AgentPermissions
agents={agents}
agentAccessGroups={agentAccessGroups}
accessToken={accessToken}
/>
<SearchToolPermissions
searchTools={searchTools}
accessToken={accessToken}
/>
</div>
);
@ -57,7 +64,7 @@ export function ObjectPermissionsView({
<div className="flex items-center gap-2 mb-6">
<div>
<Text className="font-semibold text-gray-900">Object Permissions</Text>
<Text className="text-xs text-gray-500">Access control for Vector Stores and MCP Servers</Text>
<Text className="text-xs text-gray-500">Access control for Vector Stores, MCP Servers, and Search Tools</Text>
</div>
</div>
{content}

View file

@ -0,0 +1,69 @@
import { render, screen } from "@testing-library/react";
import OnboardingModal, { InvitationLink } from "./onboarding_link";
const baseLinkData: InvitationLink = {
id: "inv-123",
user_id: "user-456",
is_accepted: false,
accepted_at: null,
expires_at: new Date("2026-04-01"),
created_at: new Date("2026-03-01"),
created_by: "admin",
updated_at: new Date("2026-03-01"),
updated_by: "admin",
has_user_setup_sso: false,
};
describe("OnboardingModal", () => {
const defaultProps = {
isInvitationLinkModalVisible: true,
setIsInvitationLinkModalVisible: vi.fn(),
baseUrl: "https://proxy.example.com",
invitationLinkData: baseLinkData,
};
it("should render", () => {
render(<OnboardingModal {...defaultProps} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("should display the user ID", () => {
render(<OnboardingModal {...defaultProps} />);
expect(screen.getByText("user-456")).toBeInTheDocument();
});
it("should generate an invitation URL with invitation_id param", () => {
render(<OnboardingModal {...defaultProps} />);
expect(
screen.getByText("https://proxy.example.com/ui?invitation_id=inv-123")
).toBeInTheDocument();
});
it("should show reset password title when modalType is resetPassword", () => {
render(<OnboardingModal {...defaultProps} modalType="resetPassword" />);
expect(screen.getAllByText("Reset Password Link").length).toBeGreaterThanOrEqual(1);
});
it("should append action=reset_password to URL for resetPassword type", () => {
render(<OnboardingModal {...defaultProps} modalType="resetPassword" />);
expect(
screen.getByText("https://proxy.example.com/ui?invitation_id=inv-123&action=reset_password")
).toBeInTheDocument();
});
it("should show plain UI URL when user has SSO setup", () => {
const ssoLink = { ...baseLinkData, has_user_setup_sso: true };
render(<OnboardingModal {...defaultProps} invitationLinkData={ssoLink} />);
expect(screen.getByText("https://proxy.example.com/ui")).toBeInTheDocument();
});
it("should show copy invitation link button by default", () => {
render(<OnboardingModal {...defaultProps} />);
expect(screen.getByRole("button", { name: /copy invitation link/i })).toBeInTheDocument();
});
it("should show copy password reset link button for resetPassword type", () => {
render(<OnboardingModal {...defaultProps} modalType="resetPassword" />);
expect(screen.getByRole("button", { name: /copy password reset link/i })).toBeInTheDocument();
});
});

View file

@ -154,7 +154,11 @@ vi.mock("antd", () => {
const Button = ({ children, htmlType, ...props }: { children?: any; htmlType?: string }) =>
React.createElement("button", { ...props, type: htmlType ?? props.type }, children);
const Alert = ({ message, description, ...props }: any) =>
React.createElement("div", { role: "alert", ...props }, message, description);
return {
Alert,
Button,
Form,
Input,
@ -173,6 +177,21 @@ vi.mock("antd", () => {
};
});
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: () => ({
data: {
pages: [{ teams: [
{ team_id: "team-1", team_alias: "Team One" },
{ team_id: "team-2", team_alias: "Team Two" },
], total: 2, page: 1, page_size: 50, total_pages: 1 }],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("../networking", () => ({
keyCreateCall: mockKeyCreateCall,
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }),

View file

@ -8,7 +8,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react";
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
import { Alert, Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
import debounce from "lodash/debounce";
import React, { useCallback, useEffect, useState } from "react";
import { rolesWithWriteAccess } from "../../utils/roles";
@ -46,6 +46,7 @@ import {
} from "../networking";
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
import NumericalInput from "../shared/numerical_input";
import SearchToolSelector from "../SearchTools/SearchToolSelector";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import { simplifyKeyGenerateError } from "./utils";
@ -499,6 +500,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
delete formValues.allowed_agents_and_groups;
}
// Always send search_tools to ensure the permission record is created.
// Empty array = no access (least privilege for new keys).
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.search_tools = formValues.allowed_search_tool_ids || [];
delete formValues.allowed_search_tool_ids;
// Add model_aliases if any are defined
if (Object.keys(modelAliases).length > 0) {
formValues.aliases = JSON.stringify(modelAliases);
@ -806,19 +815,16 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
help={keyOwner === "service_account" ? "required" : ""}
>
<TeamDropdown
teams={selectedOrganizationId ? teams?.filter((t) => t.organization_id === selectedOrganizationId) : teams}
disabled={selectedProjectId !== null}
loading={!teams}
onChange={(teamId) => {
const selectedTeam = teams?.find((t) => t.team_id === teamId) || null;
setSelectedCreateKeyTeam(selectedTeam);
organizationId={selectedOrganizationId}
onTeamSelect={(team) => {
setSelectedCreateKeyTeam(team);
setSelectedProjectId(null);
form.setFieldValue("project_id", undefined);
// Auto-populate org from team for non-admin users
if (selectedTeam?.organization_id) {
setSelectedOrganizationId(selectedTeam.organization_id);
form.setFieldValue("organization_id", selectedTeam.organization_id);
} else if (!teamId) {
if (team?.organization_id) {
setSelectedOrganizationId(team.organization_id);
form.setFieldValue("organization_id", team.organization_id);
} else if (!team) {
setSelectedOrganizationId(null);
form.setFieldValue("organization_id", undefined);
}
@ -1424,6 +1430,40 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
</AccordionBody>
</Accordion>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<b>Search Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<Alert
message="BREAKING CHANGE"
description="New keys have no search tool access by default. Select specific tools to grant access."
type="warning"
showIcon
className="mb-4"
/>
<Form.Item
label={
<span>
Allowed Search Tools{" "}
<Tooltip title="Select which search tools this key can access. New keys default to no access — explicitly grant access to specific search tools.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_search_tool_ids"
>
<SearchToolSelector
onChange={(values: string[]) => form.setFieldValue("allowed_search_tool_ids", values)}
value={form.getFieldValue("allowed_search_tool_ids")}
accessToken={accessToken}
placeholder="Select search tools (defaults to no access)"
allowedSearchToolIds={selectedCreateKeyTeam ? (selectedCreateKeyTeam.object_permission?.search_tools ?? []) : undefined}
/>
</Form.Item>
</AccordionBody>
</Accordion>
{premiumUser ? (
<Accordion className="mt-4 mb-4">
<AccordionHeader>

View file

@ -0,0 +1,93 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import PassThroughInfoView from "./pass_through_info";
vi.mock("./networking", () => ({
getProxyBaseUrl: () => "https://proxy.example.com",
updatePassThroughEndpoint: vi.fn(),
deletePassThroughEndpointsCall: vi.fn(),
}));
vi.mock("./common_components/PassThroughSecuritySection", () => ({
default: () => <div data-testid="security-section" />,
}));
vi.mock("./common_components/PassThroughGuardrailsSection", () => ({
default: () => <div data-testid="guardrails-section" />,
}));
const mockEndpoint = {
id: "ep-123",
path: "/custom/api",
target: "https://target.example.com",
headers: { Authorization: "Bearer token123" },
include_subpath: true,
cost_per_request: 0.01,
auth: true,
methods: ["GET", "POST"],
};
describe("PassThroughInfoView", () => {
const defaultProps = {
endpointData: mockEndpoint,
onClose: vi.fn(),
accessToken: "test-token",
isAdmin: true,
};
it("should render", () => {
render(<PassThroughInfoView {...defaultProps} />);
expect(screen.getByText(/pass through endpoint: \/custom\/api/i)).toBeInTheDocument();
});
it("should display the endpoint ID", () => {
render(<PassThroughInfoView {...defaultProps} />);
expect(screen.getByText("ep-123")).toBeInTheDocument();
});
it("should show Include Subpath badge when enabled", () => {
render(<PassThroughInfoView {...defaultProps} />);
expect(screen.getAllByText("Include Subpath").length).toBeGreaterThanOrEqual(1);
});
it("should show Auth Required badge when auth is true", () => {
render(<PassThroughInfoView {...defaultProps} />);
expect(screen.getByText("Auth Required")).toBeInTheDocument();
});
it("should display HTTP methods as badges", () => {
render(<PassThroughInfoView {...defaultProps} />);
expect(screen.getByText("GET")).toBeInTheDocument();
expect(screen.getByText("POST")).toBeInTheDocument();
});
it("should show 'All HTTP methods supported' when no methods specified", () => {
const noMethodEndpoint = { ...mockEndpoint, methods: [] };
render(<PassThroughInfoView {...defaultProps} endpointData={noMethodEndpoint} />);
expect(screen.getByText("All HTTP methods supported")).toBeInTheDocument();
});
it("should show headers count badge", () => {
render(<PassThroughInfoView {...defaultProps} />);
expect(screen.getByText("1 headers configured")).toBeInTheDocument();
});
it("should call onClose when Back button is clicked", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<PassThroughInfoView {...defaultProps} onClose={onClose} />);
await user.click(screen.getByRole("button", { name: /back/i }));
expect(onClose).toHaveBeenCalled();
});
it("should show Settings tab for admin users", () => {
render(<PassThroughInfoView {...defaultProps} isAdmin={true} />);
expect(screen.getByText("Settings")).toBeInTheDocument();
});
it("should not show Settings tab for non-admin users", () => {
render(<PassThroughInfoView {...defaultProps} isAdmin={false} />);
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,90 @@
import React, { useState, useEffect } from "react";
import { Text, Badge } from "@tremor/react";
import { SearchIcon } from "@heroicons/react/outline";
import { fetchSearchTools } from "../networking";
interface SearchToolDetails {
search_tool_id: string;
search_tool_name?: string;
}
interface SearchToolPermissionsProps {
searchTools: string[];
accessToken?: string | null;
}
export function SearchToolPermissions({ searchTools, accessToken }: SearchToolPermissionsProps) {
const [searchToolDetails, setSearchToolDetails] = useState<SearchToolDetails[]>([]);
useEffect(() => {
const loadSearchTools = async () => {
if (!accessToken || searchTools.length === 0) return;
try {
const response = await fetchSearchTools(accessToken);
if (response.search_tools) {
setSearchToolDetails(
response.search_tools.map((tool: any) => ({
search_tool_id: tool.search_tool_id,
search_tool_name: tool.search_tool_name,
})),
);
}
} catch (error) {
console.error("Error fetching search tools:", error);
}
};
loadSearchTools();
}, [accessToken, searchTools.length]);
const getSearchToolDisplayName = (toolId: string) => {
if (toolId === "*") return "All Search Tools (wildcard)";
const toolDetail = searchToolDetails.find((tool) => tool.search_tool_id === toolId);
if (toolDetail) {
return `${toolDetail.search_tool_name || toolDetail.search_tool_id} (${toolDetail.search_tool_id})`;
}
return toolId;
};
// Check if this is a wildcard permission (legacy/migrated)
const isWildcard = searchTools.length === 1 && searchTools[0] === "*";
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<SearchIcon className="h-4 w-4 text-amber-600" />
<Text className="font-semibold text-gray-900">Search Tools</Text>
<Badge color="amber" size="xs">
{isWildcard ? "All" : searchTools.length}
</Badge>
</div>
{isWildcard ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200">
<Text className="text-amber-700 text-sm">
All search tools accessible (migrated permission consider restricting to specific tools)
</Text>
</div>
) : searchTools.length > 0 ? (
<div className="flex flex-wrap gap-2">
{searchTools.map((tool, index) => (
<div
key={index}
className="inline-flex items-center px-3 py-1.5 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium"
>
{getSearchToolDisplayName(tool)}
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<SearchIcon className="h-4 w-4 text-gray-400" />
<Text className="text-gray-500 text-sm">No search tools configured</Text>
</div>
)}
</div>
);
}
export default SearchToolPermissions;

View file

@ -6,8 +6,13 @@ import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline";
import { DotsVerticalIcon } from "@heroicons/react/solid";
import { GuardrailPipeline, PipelineStep, PipelineTestResult, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
import { Guardrail } from "../guardrails/types";
import { testPipelineCall, listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking";
import { testPipelineCall } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import {
usePolicyVersions,
useCreatePolicyVersion,
useUpdatePolicyVersionStatus,
} from "@/app/(dashboard)/hooks/policies/usePolicyVersions";
import {
getComplianceDatasetPrompts,
getFrameworks,
@ -1289,10 +1294,6 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
const [pipeline, setPipeline] = useState<GuardrailPipeline>(
() => derivePipelineFromPolicy(editingPolicy)
);
const [versions, setVersions] = useState<Policy[]>([]);
const [isVersionsLoading, setIsVersionsLoading] = useState(false);
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
// Sync local state when editingPolicy changes (e.g. user switched version)
React.useEffect(() => {
@ -1301,45 +1302,28 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
setPipeline(derivePipelineFromPolicy(editingPolicy));
}, [editingPolicy?.policy_id, editingPolicy?.policy_name, editingPolicy?.description, editingPolicy?.pipeline, editingPolicy?.guardrails_add]);
// Fetch versions when editing an existing policy by name
React.useEffect(() => {
if (!showVersionsSidebar || !editingPolicy?.policy_name || !accessToken) {
setVersions([]);
return;
}
let cancelled = false;
setIsVersionsLoading(true);
listPolicyVersions(accessToken, editingPolicy.policy_name)
.then((res) => {
if (!cancelled) setVersions(res.versions || []);
})
.catch(() => {
if (!cancelled) setVersions([]);
})
.finally(() => {
if (!cancelled) setIsVersionsLoading(false);
});
return () => {
cancelled = true;
};
}, [showVersionsSidebar, editingPolicy?.policy_name, accessToken]);
// ── Version management via React Query hooks ──────────────────────────────
const {
data: versionsData,
isLoading: isVersionsLoading,
} = usePolicyVersions({
policyName: editingPolicy?.policy_name,
enabled: showVersionsSidebar,
});
const versions = versionsData?.versions ?? []; // versionsData?.versions is Policy[] after select, fallback covers undefined data
const createVersionMutation = useCreatePolicyVersion(editingPolicy?.policy_name);
const updateStatusMutation = useUpdatePolicyVersionStatus(editingPolicy?.policy_name);
const handleNewVersion = async () => {
if (!accessToken || !editingPolicy?.policy_name) return;
setIsCreatingVersion(true);
let newPolicy: Policy;
try {
const newPolicy = await createPolicyVersion(accessToken, editingPolicy.policy_name);
NotificationsManager.success("New draft version created");
onVersionCreated?.(newPolicy);
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name);
setVersions(list.versions ?? []);
} catch (error) {
NotificationsManager.fromBackend(
"Failed to create version: " + (error instanceof Error ? error.message : String(error))
);
} finally {
setIsCreatingVersion(false);
newPolicy = await createVersionMutation.mutateAsync();
} catch {
return; // Notification already shown by onError in the mutation hook
}
onVersionCreated?.(newPolicy);
};
const handleSelectVersion = (policy: Policy) => {
@ -1347,41 +1331,31 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
};
const handlePublishVersion = async () => {
if (!accessToken || !editingPolicy?.policy_id) return;
setIsUpdatingStatus(true);
if (!editingPolicy?.policy_id) return;
let updated: Policy;
try {
const updated = await updatePolicyVersionStatus(accessToken, editingPolicy.policy_id, "published");
NotificationsManager.success(
"Version published. You can test it in the Playground by selecting this version in the Policies dropdown."
);
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name ?? "");
setVersions(list.versions ?? []);
onVersionStatusUpdated?.(updated);
} catch (error) {
NotificationsManager.fromBackend(
"Failed to publish: " + (error instanceof Error ? error.message : String(error))
);
} finally {
setIsUpdatingStatus(false);
updated = await updateStatusMutation.mutateAsync({
policyId: editingPolicy.policy_id,
status: "published",
});
} catch {
return; // Notification already shown by onError in the mutation hook
}
onVersionStatusUpdated?.(updated);
};
const handlePromoteToProduction = async () => {
if (!accessToken || !editingPolicy?.policy_id) return;
setIsUpdatingStatus(true);
if (!editingPolicy?.policy_id) return;
let updated: Policy;
try {
const updated = await updatePolicyVersionStatus(accessToken, editingPolicy.policy_id, "production");
NotificationsManager.success("Version promoted to production");
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name ?? "");
setVersions(list.versions ?? []);
onVersionStatusUpdated?.(updated);
} catch (error) {
NotificationsManager.fromBackend(
"Failed to promote to production: " + (error instanceof Error ? error.message : String(error))
);
} finally {
setIsUpdatingStatus(false);
updated = await updateStatusMutation.mutateAsync({
policyId: editingPolicy.policy_id,
status: "production",
});
} catch {
return; // Notification already shown by onError in the mutation hook
}
onVersionStatusUpdated?.(updated);
};
const handleSave = async () => {
@ -1541,8 +1515,8 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
accessToken={accessToken}
versions={versions}
isLoading={isVersionsLoading}
isCreatingVersion={isCreatingVersion}
isUpdatingStatus={isUpdatingStatus}
isCreatingVersion={createVersionMutation.isPending}
isUpdatingStatus={updateStatusMutation.isPending}
onNewVersion={handleNewVersion}
onSelectVersion={handleSelectVersion}
onPublish={handlePublishVersion}

View file

@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import QueryParamInput from "./query_param_input";
describe("QueryParamInput", () => {
it("should render", () => {
render(<QueryParamInput />);
expect(screen.getByRole("button", { name: /add query parameter/i })).toBeInTheDocument();
});
it("should render existing pairs from initial value", () => {
render(<QueryParamInput value={{ version: "v1" }} />);
expect(screen.getByDisplayValue("version")).toBeInTheDocument();
expect(screen.getByDisplayValue("v1")).toBeInTheDocument();
});
it("should add a new empty pair when clicking Add Query Parameter", async () => {
const user = userEvent.setup();
render(<QueryParamInput />);
await user.click(screen.getByRole("button", { name: /add query parameter/i }));
expect(screen.getByPlaceholderText(/parameter name/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/parameter value/i)).toBeInTheDocument();
});
it("should call onChange when a value is typed", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<QueryParamInput value={{}} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /add query parameter/i }));
await user.type(screen.getByPlaceholderText(/parameter name/i), "limit");
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ limit: "" })
);
});
it("should remove a pair when clicking the remove icon", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<QueryParamInput value={{ foo: "bar" }} onChange={onChange} />);
await user.click(screen.getByRole("img", { name: /minus-circle/i }));
expect(screen.queryByDisplayValue("foo")).not.toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith({});
});
});

View file

@ -0,0 +1,24 @@
import { render, screen } from "@testing-library/react";
import { ResponseTimeIndicator } from "./response_time_indicator";
describe("ResponseTimeIndicator", () => {
it("should render", () => {
render(<ResponseTimeIndicator responseTimeMs={150} />);
expect(screen.getByText("150ms")).toBeInTheDocument();
});
it("should return null when responseTimeMs is null", () => {
const { container } = render(<ResponseTimeIndicator responseTimeMs={null} />);
expect(container.innerHTML).toBe("");
});
it("should round the displayed time to the nearest integer", () => {
render(<ResponseTimeIndicator responseTimeMs={123.456} />);
expect(screen.getByText("123ms")).toBeInTheDocument();
});
it("should display 0ms for a zero response time", () => {
render(<ResponseTimeIndicator responseTimeMs={0} />);
expect(screen.getByText("0ms")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import RoutePreview from "./route_preview";
vi.mock("./networking", () => ({
getProxyBaseUrl: () => "https://proxy.example.com",
}));
describe("RoutePreview", () => {
it("should render", () => {
render(
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={false} />
);
expect(screen.getByText("Route Preview")).toBeInTheDocument();
});
it("should return null when pathValue is empty", () => {
const { container } = render(
<RoutePreview pathValue="" targetValue="https://target.com" includeSubpath={false} />
);
expect(container.innerHTML).toBe("");
});
it("should return null when targetValue is empty", () => {
const { container } = render(
<RoutePreview pathValue="/api/v1" targetValue="" includeSubpath={false} />
);
expect(container.innerHTML).toBe("");
});
it("should display the full proxy URL for the endpoint", () => {
render(
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={false} />
);
expect(screen.getByText("https://proxy.example.com/api/v1")).toBeInTheDocument();
});
it("should display target URL in the forwards-to section", () => {
render(
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={false} />
);
expect(screen.getByText("https://target.com")).toBeInTheDocument();
});
it("should show subpath routing info when includeSubpath is true", () => {
render(
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={true} />
);
expect(screen.getByText("With subpaths:")).toBeInTheDocument();
});
it("should show the 'enable subpaths' hint when includeSubpath is false", () => {
render(
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={false} />
);
expect(screen.getByText(/not seeing the routing you wanted/i)).toBeInTheDocument();
});
});

View file

@ -21,7 +21,7 @@ interface ModalConfig {
additionalFields?: Array<{
name: string;
label: string | React.ReactNode;
type: "input" | "select" | "numerical";
type: "input" | "select" | "numerical" | "multi_select";
options?: Array<{ label: string; value: string }>;
rules?: any[];
step?: number;
@ -65,6 +65,7 @@ const MemberModal = <T extends BaseMember>({
max_budget_in_team: (initialData as any).max_budget_in_team || null,
tpm_limit: (initialData as any).tpm_limit || null,
rpm_limit: (initialData as any).rpm_limit || null,
extra_permissions: (initialData as any).extra_permissions || [],
};
console.log("Setting form values:", formValues);
form.setFieldsValue(formValues);
@ -115,7 +116,7 @@ const MemberModal = <T extends BaseMember>({
const renderField = (field: {
name: string;
label: string | React.ReactNode;
type: "input" | "select" | "numerical";
type: "input" | "select" | "numerical" | "multi_select";
options?: Array<{ label: string; value: string }>;
rules?: any[];
step?: number;
@ -144,6 +145,17 @@ const MemberModal = <T extends BaseMember>({
))}
</Select>
);
case "multi_select":
return (
<Select
mode="multiple"
allowClear
placeholder={field.placeholder || "Select permissions"}
style={{ width: "100%" }}
optionFilterProp="label"
options={field.options}
/>
);
default:
return null;
}

View file

@ -0,0 +1,220 @@
import { AvailablePermission, Member, teamMemberUpdateCall } from "@/components/networking";
import { CloudServerOutlined, SaveOutlined, UserOutlined } from "@ant-design/icons";
import { Alert, Button, Drawer, Switch, Tag, Typography } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import NotificationsManager from "../molecules/notifications_manager";
const { Text, Title: AntTitle } = Typography;
interface MemberPermissionsDrawerProps {
open: boolean;
onClose: () => void;
member: Member | null;
availablePermissions: AvailablePermission[];
accessToken: string | null;
teamId: string;
onUpdate: () => Promise<void>;
}
const MemberPermissionsDrawer: React.FC<MemberPermissionsDrawerProps> = ({
open,
onClose,
member,
availablePermissions,
accessToken,
teamId,
onUpdate,
}) => {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
// Sync state when member changes or drawer opens — only seed with known permissions
// so that unknown permissions are preserved separately on save without duplication
useEffect(() => {
if (open && member) {
const knownValues = new Set(availablePermissions.map((p) => p.value));
setSelected(new Set((member.extra_permissions || []).filter((p) => knownValues.has(p))));
}
}, [open, member, availablePermissions]);
const initial = useMemo(
() => {
const knownValues = new Set(availablePermissions.map((p) => p.value));
return new Set((member?.extra_permissions || []).filter((p) => knownValues.has(p)));
},
[member, availablePermissions],
);
const isDirty = useMemo(() => {
if (selected.size !== initial.size) return true;
for (const p of selected) {
if (!initial.has(p)) return true;
}
return false;
}, [selected, initial]);
const allSelected = availablePermissions.length > 0 && availablePermissions.every((p) => selected.has(p.value));
const handleToggle = (value: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) {
next.add(value);
} else {
next.delete(value);
}
return next;
});
};
const handleToggleAll = (checked: boolean) => {
if (checked) {
setSelected(new Set(availablePermissions.map((p) => p.value)));
} else {
setSelected(new Set());
}
};
const handleSave = async () => {
if (!accessToken || !member) return;
setSaving(true);
try {
// Preserve unknown permissions not present in the available set
const availableValues = new Set(availablePermissions.map((p) => p.value));
const existingUnknown = (member.extra_permissions || []).filter(
(p) => !availableValues.has(p),
);
const updatedMember: Member = {
...member,
extra_permissions: [...existingUnknown, ...Array.from(selected)],
};
await teamMemberUpdateCall(accessToken, teamId, updatedMember);
NotificationsManager.success("Permissions updated successfully");
onClose();
try {
await onUpdate();
} catch (refreshError) {
console.error("Failed to refresh team data after permission update:", refreshError);
}
} catch (error: any) {
const errMsg = error?.message || "Failed to update permissions";
NotificationsManager.fromBackend(errMsg);
console.error("Error updating permissions:", error);
} finally {
setSaving(false);
}
};
const memberDisplay = member?.user_email || member?.user_id || "Unknown";
const isAdmin = member?.role?.toLowerCase() === "admin";
return (
<Drawer
title={
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 bg-blue-50 text-blue-600 rounded-full shrink-0">
<UserOutlined />
</div>
<div className="flex flex-col overflow-hidden">
<Text strong className="text-base leading-tight truncate">
{memberDisplay}
</Text>
<Text type="secondary" className="text-xs truncate">
{member?.user_id || ""}
</Text>
</div>
<Tag color={isAdmin ? "gold" : "default"} className="ml-auto shrink-0">
{member?.role || "user"}
</Tag>
</div>
}
placement="right"
width={480}
open={open}
onClose={onClose}
closable={!saving}
maskClosable={!saving}
footer={
<div className="flex justify-end gap-2">
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
disabled={!isDirty}
>
Save Changes
</Button>
</div>
}
>
<div className="flex flex-col gap-6">
{isAdmin ? (
<Alert
message="Team admins have all permissions by default. Extra permissions only apply to non-admin members."
type="info"
showIcon
/>
) : (
<Alert
message="Permissions allow this member to manage MCP servers for this team."
type="info"
showIcon
/>
)}
{/* MCP Server Permissions */}
<div>
<div className="flex items-center gap-2 mb-4">
<CloudServerOutlined className="text-lg text-gray-600" />
<AntTitle level={5} className="!m-0">
MCP Server Permissions
</AntTitle>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-200 mb-4 flex items-center justify-between">
<Text strong>{allSelected ? "Deselect All" : "Select All"}</Text>
<Switch
checked={allSelected}
onChange={handleToggleAll}
disabled={isAdmin}
/>
</div>
<div className="flex flex-col">
{availablePermissions.map((perm) => (
<div
key={perm.value}
className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0"
>
<div className="flex flex-col gap-1">
<Text>{perm.label}</Text>
<Text
type="secondary"
className="font-mono text-xs bg-gray-100 px-1.5 py-0.5 rounded w-fit border border-gray-200"
>
{perm.value}
</Text>
</div>
<Switch
checked={selected.has(perm.value)}
onChange={(checked) => handleToggle(perm.value, checked)}
disabled={isAdmin}
/>
</div>
))}
</div>
</div>
{availablePermissions.length === 0 && (
<div className="text-center py-8 text-gray-400 text-sm">
No permissions available.
</div>
)}
</div>
</Drawer>
);
};
export default MemberPermissionsDrawer;

View file

@ -2,6 +2,8 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import UserSearchModal from "@/components/common_components/user_search_modal";
import {
AvailablePermission,
fetchAvailableTeamMemberPermissions,
getGuardrailsList,
getPoliciesList,
getPolicyInfoWithGuardrails,
@ -49,6 +51,7 @@ import {
TEAM_INFO_TAB_KEYS,
TEAM_INFO_TAB_LABELS,
} from "./tabVisibilityUtils";
import MemberPermissionsDrawer from "./MemberPermissionsDrawer";
import TeamMembersComponent from "./TeamMemberTab";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
@ -184,6 +187,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const [isDeleting, setIsDeleting] = useState(false);
const [isTeamSaving, setIsTeamSaving] = useState(false);
const [organization, setOrganization] = useState<Organization | null>(null);
const [availablePermissions, setAvailablePermissions] = useState<AvailablePermission[]>([]);
const [permissionsDrawerMember, setPermissionsDrawerMember] = useState<Member | null>(null);
const { userRole, userId } = useAuthorized();
const { data: userOrganizations = [] } = useOrganizations();
@ -220,6 +225,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
fetchTeamInfo();
}, [teamId, accessToken]);
// Fetch available permissions for team member editing (only for users who can edit the team)
useEffect(() => {
if (!canEditTeam) return;
const fetchPermissions = async () => {
if (!accessToken) return;
try {
const permissions = await fetchAvailableTeamMemberPermissions(accessToken);
setAvailablePermissions(permissions);
} catch (error) {
console.error("Error fetching available permissions:", error);
}
};
fetchPermissions();
}, [accessToken, canEditTeam]);
// Fetch organization data when team has organization_id
useEffect(() => {
const fetchOrganization = async () => {
@ -366,6 +386,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
max_budget_in_team: values.max_budget_in_team,
tpm_limit: values.tpm_limit,
rpm_limit: values.rpm_limit,
extra_permissions: values.extra_permissions,
};
MessageManager.destroy(); // Remove all existing toasts
@ -762,6 +783,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
setSelectedEditMember={setSelectedEditMember}
setIsEditMemberModalVisible={setIsEditMemberModalVisible}
setIsAddMemberModalVisible={setIsAddMemberModalVisible}
onPermissions={(member) => setPermissionsDrawerMember(member)}
/>
),
},
@ -1335,6 +1357,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
onOk={handleDeleteConfirm}
confirmLoading={isDeleting}
/>
<MemberPermissionsDrawer
open={!!permissionsDrawerMember}
onClose={() => setPermissionsDrawerMember(null)}
member={permissionsDrawerMember}
availablePermissions={availablePermissions}
accessToken={accessToken}
teamId={teamId}
onUpdate={async () => {
if (!accessToken) return;
const updatedTeamData = await teamInfoCall(accessToken, teamId);
setTeamData(updatedTeamData);
onUpdate(updatedTeamData);
}}
/>
</div>
);
};

View file

@ -16,6 +16,7 @@ interface TeamMemberTabProps {
setSelectedEditMember: (member: Member) => void;
setIsEditMemberModalVisible: (visible: boolean) => void;
setIsAddMemberModalVisible: (visible: boolean) => void;
onPermissions?: (member: Member) => void;
}
export default function TeamMemberTab({
@ -25,6 +26,7 @@ export default function TeamMemberTab({
setSelectedEditMember,
setIsEditMemberModalVisible,
setIsAddMemberModalVisible,
onPermissions,
}: TeamMemberTabProps) {
const formatNumber = (value: number | null): string => {
if (value === null || value === undefined) return "0";
@ -144,6 +146,7 @@ export default function TeamMemberTab({
}}
onDelete={handleMemberDelete}
onAddMember={() => setIsAddMemberModalVisible(true)}
onPermissions={onPermissions}
roleColumnTitle="Team Role"
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
extraColumns={extraColumns}

View file

@ -5,7 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
import PolicySelector from "@/components/policies/PolicySelector";
import { InfoCircleOutlined } from "@ant-design/icons";
import { TextInput, Button as TremorButton } from "@tremor/react";
import { Form, Input, Select, Switch, Tooltip } from "antd";
import { Alert, Form, Input, Select, Switch, Tooltip } from "antd";
import { useEffect, useState } from "react";
import { rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
@ -25,6 +25,7 @@ import { fetchTeamModels } from "../organisms/create_key_button";
import NumericalInput from "../shared/numerical_input";
import { Tag } from "../tag_management/types";
import EditLoggingSettings from "../team/EditLoggingSettings";
import SearchToolSelector from "../SearchTools/SearchToolSelector";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
interface KeyEditViewProps {
@ -186,6 +187,7 @@ export function KeyEditView({
agents: keyData.object_permission?.agents || [],
accessGroups: keyData.object_permission?.agent_access_groups || [],
},
search_tools: keyData.object_permission?.search_tools || [],
logging_settings: extractLoggingSettings(keyData.metadata),
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
@ -214,6 +216,7 @@ export function KeyEditView({
accessGroups: keyData.object_permission?.mcp_access_groups || [],
},
mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {},
search_tools: keyData.object_permission?.search_tools || [],
logging_settings: extractLoggingSettings(keyData.metadata),
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
@ -614,6 +617,26 @@ export function KeyEditView({
/>
</Form.Item>
<Alert
message="BREAKING CHANGE"
description="New keys have no search tool access by default. Select specific tools to grant access."
type="warning"
showIcon
className="mb-4"
/>
<Form.Item
label="Search Tools"
name="search_tools"
>
<SearchToolSelector
onChange={(values: string[]) => form.setFieldValue("search_tools", values)}
value={form.getFieldValue("search_tools")}
accessToken={accessToken || ""}
placeholder="Select search tools (defaults to no access)"
allowedSearchToolIds={team ? (team.object_permission?.search_tools ?? []) : undefined}
/>
</Form.Item>
<Form.Item
label={
<span>

View file

@ -193,6 +193,15 @@ export default function KeyInfoView({
delete formValues.agents_and_groups;
}
// Handle search tool permissions
if (formValues.search_tools !== undefined) {
formValues.object_permission = {
...formValues.object_permission,
search_tools: formValues.search_tools || [],
};
delete formValues.search_tools;
}
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
formValues.tpm_limit = mapEmptyStringToNull(formValues.tpm_limit);
formValues.rpm_limit = mapEmptyStringToNull(formValues.rpm_limit);

View file

@ -0,0 +1,85 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AuditLogDrawer } from "./AuditLogDrawer";
import { AuditLogEntry } from "../columns";
vi.mock("../../common_components/DefaultProxyAdminTag", () => ({
default: ({ userId }: { userId: string }) => <span data-testid="proxy-admin-tag">{userId}</span>,
}));
const mockLog: AuditLogEntry = {
id: "log-1",
updated_at: "2026-03-15T10:30:00Z",
changed_by: "admin-user",
changed_by_api_key: "sk-abc123hash",
action: "created",
table_name: "LiteLLM_TeamTable",
object_id: "team-456",
before_value: {},
updated_values: { team_alias: "My Team", max_budget: 100 },
};
describe("AuditLogDrawer", () => {
const defaultProps = {
open: true,
onClose: vi.fn(),
log: mockLog,
};
it("should render", () => {
render(<AuditLogDrawer {...defaultProps} />);
expect(screen.getByText("created")).toBeInTheDocument();
});
it("should return null when log is null", () => {
const { container } = render(<AuditLogDrawer open={true} onClose={vi.fn()} log={null} />);
expect(container.innerHTML).toBe("");
});
it("should display the friendly table name", () => {
render(<AuditLogDrawer {...defaultProps} />);
expect(screen.getByText("Teams")).toBeInTheDocument();
});
it("should display the object ID", () => {
render(<AuditLogDrawer {...defaultProps} />);
expect(screen.getByText("team-456")).toBeInTheDocument();
});
it("should display the changed_by user", () => {
render(<AuditLogDrawer {...defaultProps} />);
expect(screen.getByText("admin-user")).toBeInTheDocument();
});
it("should display the API key hash", () => {
render(<AuditLogDrawer {...defaultProps} />);
expect(screen.getByText("sk-abc123hash")).toBeInTheDocument();
});
it("should show a close button", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<AuditLogDrawer {...defaultProps} onClose={onClose} />);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(onClose).toHaveBeenCalled();
});
it("should display raw table name when no friendly mapping exists", () => {
const customLog = { ...mockLog, table_name: "CustomTable" };
render(<AuditLogDrawer {...defaultProps} log={customLog} />);
expect(screen.getByText("CustomTable")).toBeInTheDocument();
});
it("should show diff sections for updated action with changed fields", () => {
const updatedLog: AuditLogEntry = {
...mockLog,
action: "updated",
before_value: { team_alias: "Old Name", max_budget: 50 },
updated_values: { team_alias: "New Name", max_budget: 100 },
};
render(<AuditLogDrawer {...defaultProps} log={updatedLog} />);
expect(screen.getByText("Before")).toBeInTheDocument();
expect(screen.getByText("After")).toBeInTheDocument();
});
});

View file

@ -65,9 +65,10 @@ export function DataTable<TData, TValue>({
const isSorted = header.column.getIsSorted();
return (
<TableHeaderCell
key={header.id}
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${canSort ? 'cursor-pointer select-none hover:bg-gray-50' : ''}`}
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
>
{header.isPlaceholder ? null : (
@ -103,7 +104,7 @@ export function DataTable<TData, TValue>({
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap">
<TableCell key={cell.id} className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap" style={{ width: cell.column.getSize() !== 150 ? cell.column.getSize() : undefined }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}