mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
feat(access-groups): move the partial update to PATCH /management/v1/access-groups/{id}
The legacy PATCH /v1/access_group/{id} and its unified alias are dropped in favour of a
control-plane route that follows the mutation standard: {"data": ...} envelope, JSON
merge-patch semantics, unknown body keys refused with a 422, and RFC 9457 problem
documents for 403/404/409/422/500/503 declared in OpenAPI. PUT /v1/access_group/{id}
is untouched.
The update logic is factored into apply_access_group_update, which returns a tagged
outcome that both the legacy PUT and the new PATCH map onto their own error shapes.
The control plane's RequestValidationError handler now answers a bad body with a 422
problem instead of the 400 query-parameter one, and the ProblemDetail schema is
registered as an OpenAPI component so problem responses can reference it.
The edit dialog calls the new route through patchAccessGroup and unwraps the envelope.
This commit is contained in:
parent
08a306f198
commit
830063392f
17 changed files with 828 additions and 344 deletions
|
|
@ -782,13 +782,6 @@
|
|||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"ctx": {
|
||||
"title": "Context",
|
||||
"type": "object"
|
||||
},
|
||||
"input": {
|
||||
"title": "Input"
|
||||
},
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
|
|
@ -1209,61 +1202,6 @@
|
|||
"access_groups"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "update_access_group_v1_access_group__access_group_id__patch",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "access_group_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Access Group Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupUpdateRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Update Access Group",
|
||||
"tags": [
|
||||
"access_groups"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "update_access_group_v1_access_group__access_group_id__put",
|
||||
"parameters": [
|
||||
|
|
@ -1478,61 +1416,6 @@
|
|||
"access_groups"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "update_access_group_v1_unified_access_group__access_group_id__patch",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "access_group_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Access Group Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupUpdateRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Update Access Group",
|
||||
"tags": [
|
||||
"access_groups"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "update_access_group_v1_unified_access_group__access_group_id__put",
|
||||
"parameters": [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final, Protocol
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Protocol, assert_never
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ from litellm.proxy.auth.auth_checks import (
|
|||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
from litellm.proxy.management_helpers.access_group_team_sync import invalidate_access_group_cache
|
||||
from litellm.proxy.utils import get_prisma_client_or_throw
|
||||
from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw
|
||||
from litellm.repositories.table_repositories import AccessGroupRepository
|
||||
from litellm.types.access_group import (
|
||||
AccessGroupCreateRequest,
|
||||
|
|
@ -151,7 +152,7 @@ async def _cache_access_group_record(record: _AccessGroupRecord) -> None:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _sync_add_access_group_to_teams(tx: _AccessGroupTx, team_ids: list[str], access_group_id: str) -> None:
|
||||
async def _sync_add_access_group_to_teams(tx: _AccessGroupTx, team_ids: Sequence[str], access_group_id: str) -> None:
|
||||
"""Add access_group_id to each team's access_group_ids (idempotent)."""
|
||||
for team_id in team_ids:
|
||||
team = await tx.litellm_teamtable.find_unique(where={"team_id": team_id})
|
||||
|
|
@ -162,7 +163,9 @@ async def _sync_add_access_group_to_teams(tx: _AccessGroupTx, team_ids: list[str
|
|||
)
|
||||
|
||||
|
||||
async def _sync_remove_access_group_from_teams(tx: _AccessGroupTx, team_ids: list[str], access_group_id: str) -> None:
|
||||
async def _sync_remove_access_group_from_teams(
|
||||
tx: _AccessGroupTx, team_ids: Sequence[str], access_group_id: str
|
||||
) -> None:
|
||||
"""Remove access_group_id from each team's access_group_ids (idempotent)."""
|
||||
for team_id in team_ids:
|
||||
team = await tx.litellm_teamtable.find_unique(where={"team_id": team_id})
|
||||
|
|
@ -173,7 +176,7 @@ async def _sync_remove_access_group_from_teams(tx: _AccessGroupTx, team_ids: lis
|
|||
)
|
||||
|
||||
|
||||
async def _sync_add_access_group_to_keys(tx: _AccessGroupTx, key_tokens: list[str], access_group_id: str) -> None:
|
||||
async def _sync_add_access_group_to_keys(tx: _AccessGroupTx, key_tokens: Sequence[str], access_group_id: str) -> None:
|
||||
"""Add access_group_id to each key's access_group_ids (idempotent)."""
|
||||
for token in key_tokens:
|
||||
key = await tx.litellm_verificationtoken.find_unique(where={"token": token})
|
||||
|
|
@ -184,7 +187,9 @@ async def _sync_add_access_group_to_keys(tx: _AccessGroupTx, key_tokens: list[st
|
|||
)
|
||||
|
||||
|
||||
async def _sync_remove_access_group_from_keys(tx: _AccessGroupTx, key_tokens: list[str], access_group_id: str) -> None:
|
||||
async def _sync_remove_access_group_from_keys(
|
||||
tx: _AccessGroupTx, key_tokens: Sequence[str], access_group_id: str
|
||||
) -> None:
|
||||
"""Remove access_group_id from each key's access_group_ids (idempotent)."""
|
||||
for token in key_tokens:
|
||||
key = await tx.litellm_verificationtoken.find_unique(where={"token": token})
|
||||
|
|
@ -201,7 +206,7 @@ async def _sync_remove_access_group_from_keys(tx: _AccessGroupTx, key_tokens: li
|
|||
|
||||
|
||||
async def _patch_team_caches_add_access_group(
|
||||
team_ids: list[str],
|
||||
team_ids: Sequence[str],
|
||||
access_group_id: str,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj,
|
||||
|
|
@ -231,7 +236,7 @@ async def _patch_team_caches_add_access_group(
|
|||
|
||||
|
||||
async def _patch_team_caches_remove_access_group(
|
||||
team_ids: list[str],
|
||||
team_ids: Sequence[str],
|
||||
access_group_id: str,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj,
|
||||
|
|
@ -255,7 +260,7 @@ async def _patch_team_caches_remove_access_group(
|
|||
|
||||
|
||||
async def _patch_key_caches_add_access_group(
|
||||
key_tokens: list[str],
|
||||
key_tokens: Sequence[str],
|
||||
access_group_id: str,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj,
|
||||
|
|
@ -283,7 +288,7 @@ async def _patch_key_caches_add_access_group(
|
|||
|
||||
|
||||
async def _patch_key_caches_remove_access_group(
|
||||
key_tokens: list[str],
|
||||
key_tokens: Sequence[str],
|
||||
access_group_id: str,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj,
|
||||
|
|
@ -416,10 +421,106 @@ async def get_access_group(
|
|||
return _record_to_response(record)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/v1/access_group/{access_group_id}",
|
||||
response_model=AccessGroupResponse,
|
||||
_LIST_FIELDS: Final = frozenset(
|
||||
("assigned_team_ids", "assigned_key_ids", "access_model_names", "access_mcp_server_ids", "access_agent_ids")
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessGroupUpdated:
|
||||
record: _AccessGroupRecord
|
||||
teams_added: tuple[str, ...]
|
||||
teams_removed: tuple[str, ...]
|
||||
keys_added: tuple[str, ...]
|
||||
keys_removed: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessGroupNotFound:
|
||||
access_group_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessGroupNameTaken:
|
||||
access_group_name: str
|
||||
|
||||
|
||||
AccessGroupUpdateOutcome = AccessGroupUpdated | AccessGroupNotFound | AccessGroupNameTaken
|
||||
|
||||
|
||||
async def apply_access_group_update(
|
||||
prisma_client: PrismaClient,
|
||||
access_group_id: str,
|
||||
data: AccessGroupUpdateRequest,
|
||||
updated_by: str | None,
|
||||
) -> AccessGroupUpdateOutcome:
|
||||
"""Write the sent fields, sync team/key membership inside the same transaction, and report what changed.
|
||||
|
||||
A `None` list clears to `[]`; a key that was not sent is left alone.
|
||||
"""
|
||||
update_data: Final[dict[str, object]] = {
|
||||
"updated_by": updated_by,
|
||||
**{
|
||||
field: [] if field in _LIST_FIELDS and value is None else value
|
||||
for field, value in data.model_dump(exclude_unset=True).items()
|
||||
},
|
||||
}
|
||||
try:
|
||||
tx: _AccessGroupTx
|
||||
async with prisma_client.db.tx() as tx:
|
||||
# Read inside the transaction so the membership delta is computed against the row being written
|
||||
existing: Final = await tx.litellm_accessgrouptable.find_unique(where={"access_group_id": access_group_id})
|
||||
if existing is None:
|
||||
return AccessGroupNotFound(access_group_id=access_group_id)
|
||||
|
||||
old_team_ids: Final = frozenset(existing.assigned_team_ids or ())
|
||||
old_key_ids: Final = frozenset(existing.assigned_key_ids or ())
|
||||
new_team_ids: Final = (
|
||||
frozenset(data.assigned_team_ids or ())
|
||||
if "assigned_team_ids" in data.model_fields_set
|
||||
else old_team_ids
|
||||
)
|
||||
new_key_ids: Final = (
|
||||
frozenset(data.assigned_key_ids or ()) if "assigned_key_ids" in data.model_fields_set else old_key_ids
|
||||
)
|
||||
outcome: Final = AccessGroupUpdated(
|
||||
record=await tx.litellm_accessgrouptable.update(
|
||||
where={"access_group_id": access_group_id},
|
||||
data=update_data,
|
||||
),
|
||||
teams_added=tuple(new_team_ids - old_team_ids),
|
||||
teams_removed=tuple(old_team_ids - new_team_ids),
|
||||
keys_added=tuple(new_key_ids - old_key_ids),
|
||||
keys_removed=tuple(old_key_ids - new_key_ids),
|
||||
)
|
||||
await _sync_add_access_group_to_teams(tx, outcome.teams_added, access_group_id)
|
||||
await _sync_remove_access_group_from_teams(tx, outcome.teams_removed, access_group_id)
|
||||
await _sync_add_access_group_to_keys(tx, outcome.keys_added, access_group_id)
|
||||
await _sync_remove_access_group_from_keys(tx, outcome.keys_removed, access_group_id)
|
||||
return outcome
|
||||
except Exception as e:
|
||||
if "unique constraint" in str(e).lower() or "P2002" in str(e):
|
||||
return AccessGroupNameTaken(access_group_name=str(update_data.get("access_group_name", "")))
|
||||
raise
|
||||
|
||||
|
||||
async def propagate_access_group_update(outcome: AccessGroupUpdated, access_group_id: str) -> None:
|
||||
"""Refresh the access-group, team and key caches after the transaction has committed."""
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
|
||||
|
||||
await _cache_access_group_record(outcome.record)
|
||||
await _patch_team_caches_add_access_group(
|
||||
outcome.teams_added, access_group_id, user_api_key_cache, proxy_logging_obj
|
||||
)
|
||||
await _patch_team_caches_remove_access_group(
|
||||
outcome.teams_removed, access_group_id, user_api_key_cache, proxy_logging_obj
|
||||
)
|
||||
await _patch_key_caches_add_access_group(outcome.keys_added, access_group_id, user_api_key_cache, proxy_logging_obj)
|
||||
await _patch_key_caches_remove_access_group(
|
||||
outcome.keys_removed, access_group_id, user_api_key_cache, proxy_logging_obj
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/v1/access_group/{access_group_id}",
|
||||
response_model=AccessGroupResponse,
|
||||
|
|
@ -432,87 +533,23 @@ async def update_access_group(
|
|||
_require_proxy_admin(user_api_key_dict)
|
||||
prisma_client: Final = get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value)
|
||||
|
||||
update_fields: Final = data.model_dump(exclude_unset=True)
|
||||
update_data: Final[dict] = {"updated_by": user_api_key_dict.user_id}
|
||||
for field, value in update_fields.items():
|
||||
if (
|
||||
field
|
||||
in (
|
||||
"assigned_team_ids",
|
||||
"assigned_key_ids",
|
||||
"access_model_names",
|
||||
"access_mcp_server_ids",
|
||||
"access_agent_ids",
|
||||
outcome: Final = await apply_access_group_update(prisma_client, access_group_id, data, user_api_key_dict.user_id)
|
||||
match outcome:
|
||||
case AccessGroupNotFound():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Access group '{access_group_id}' not found",
|
||||
)
|
||||
and value is None
|
||||
):
|
||||
value = []
|
||||
update_data[field] = value
|
||||
|
||||
# Initialize delta lists before the try block so they remain accessible
|
||||
# for cache updates after the transaction, even if an error path is added later.
|
||||
teams_to_add: list[str] = []
|
||||
teams_to_remove: list[str] = []
|
||||
keys_to_add: list[str] = []
|
||||
keys_to_remove: list[str] = []
|
||||
|
||||
try:
|
||||
tx: _AccessGroupTx
|
||||
async with prisma_client.db.tx() as tx:
|
||||
# Read inside the transaction so delta computation is consistent with the write,
|
||||
# avoiding a TOCTOU race where a concurrent update could make deltas stale.
|
||||
existing: Final = await tx.litellm_accessgrouptable.find_unique(where={"access_group_id": access_group_id})
|
||||
if existing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Access group '{access_group_id}' not found",
|
||||
)
|
||||
|
||||
old_team_ids: Final[set[str]] = set(existing.assigned_team_ids or [])
|
||||
old_key_ids: Final[set[str]] = set(existing.assigned_key_ids or [])
|
||||
new_team_ids: Final[set[str]] = (
|
||||
set(update_fields["assigned_team_ids"] or []) if "assigned_team_ids" in update_fields else old_team_ids
|
||||
)
|
||||
new_key_ids: Final[set[str]] = (
|
||||
set(update_fields["assigned_key_ids"] or []) if "assigned_key_ids" in update_fields else old_key_ids
|
||||
)
|
||||
|
||||
teams_to_add = list(new_team_ids - old_team_ids)
|
||||
teams_to_remove = list(old_team_ids - new_team_ids)
|
||||
keys_to_add = list(new_key_ids - old_key_ids)
|
||||
keys_to_remove = list(old_key_ids - new_key_ids)
|
||||
|
||||
record: Final = await tx.litellm_accessgrouptable.update(
|
||||
where={"access_group_id": access_group_id},
|
||||
data=update_data,
|
||||
)
|
||||
|
||||
await _sync_add_access_group_to_teams(tx, teams_to_add, access_group_id)
|
||||
await _sync_remove_access_group_from_teams(tx, teams_to_remove, access_group_id)
|
||||
await _sync_add_access_group_to_keys(tx, keys_to_add, access_group_id)
|
||||
await _sync_remove_access_group_from_keys(tx, keys_to_remove, access_group_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Unique constraint violation (e.g. access_group_name already exists).
|
||||
if "unique constraint" in str(e).lower() or "P2002" in str(e):
|
||||
case AccessGroupNameTaken(access_group_name=access_group_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Access group '{update_data.get('access_group_name', '')}' already exists",
|
||||
detail=f"Access group '{access_group_name}' already exists",
|
||||
)
|
||||
raise
|
||||
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
|
||||
|
||||
await _cache_access_group_record(record)
|
||||
await _patch_team_caches_add_access_group(teams_to_add, access_group_id, user_api_key_cache, proxy_logging_obj)
|
||||
await _patch_team_caches_remove_access_group(
|
||||
teams_to_remove, access_group_id, user_api_key_cache, proxy_logging_obj
|
||||
)
|
||||
await _patch_key_caches_add_access_group(keys_to_add, access_group_id, user_api_key_cache, proxy_logging_obj)
|
||||
await _patch_key_caches_remove_access_group(keys_to_remove, access_group_id, user_api_key_cache, proxy_logging_obj)
|
||||
|
||||
return _record_to_response(record)
|
||||
case AccessGroupUpdated():
|
||||
await propagate_access_group_update(outcome, access_group_id)
|
||||
return _record_to_response(outcome.record)
|
||||
case _:
|
||||
assert_never(outcome)
|
||||
|
||||
|
||||
@router.delete(
|
||||
|
|
@ -641,12 +678,6 @@ router.add_api_route(
|
|||
methods=["PUT"],
|
||||
response_model=AccessGroupResponse,
|
||||
)
|
||||
router.add_api_route(
|
||||
"/v1/unified_access_group/{access_group_id}",
|
||||
update_access_group,
|
||||
methods=["PATCH"],
|
||||
response_model=AccessGroupResponse,
|
||||
)
|
||||
router.add_api_route(
|
||||
"/v1/unified_access_group/{access_group_id}",
|
||||
delete_access_group,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ from typing import Final
|
|||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from litellm.proxy.management_endpoints.management_v1.access_groups import (
|
||||
router as access_groups_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.budgets import (
|
||||
router as budgets_router,
|
||||
)
|
||||
|
|
@ -12,6 +15,7 @@ from litellm.proxy.management_endpoints.management_v1.spend_logs import (
|
|||
)
|
||||
|
||||
router: Final = APIRouter()
|
||||
router.include_router(access_groups_router)
|
||||
router.include_router(budgets_router)
|
||||
router.include_router(spend_logs_router)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
"""`PATCH /management/v1/access-groups/{access_group_id}`."""
|
||||
|
||||
from typing import Annotated, Final, assert_never
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
problem_responses,
|
||||
)
|
||||
from litellm.types.access_group import AccessGroupPatchRequest, AccessGroupResponse
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ItemResponse, ProblemDetail
|
||||
|
||||
router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
|
||||
|
||||
|
||||
def _forbidden(caller: UserAPIKeyAuth) -> ManagementProblem:
|
||||
return ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}forbidden",
|
||||
title="Forbidden",
|
||||
status=403,
|
||||
detail=f"Only proxy admins can update access groups, your role={caller.user_role}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/access-groups/{access_group_id}",
|
||||
tags=("access group management",),
|
||||
dependencies=(Depends(user_api_key_auth),),
|
||||
response_model=ItemResponse[AccessGroupResponse],
|
||||
responses=problem_responses(403, 404, 409, 422, 500, 503),
|
||||
)
|
||||
async def patch_access_group(
|
||||
access_group_id: str,
|
||||
body: AccessGroupPatchRequest,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> ItemResponse[AccessGroupResponse]:
|
||||
"""
|
||||
Update one access group as a JSON merge patch: a key that is sent is written, `null` clears
|
||||
it (a cleared list becomes `[]`), and a key that is omitted keeps its value. Unknown keys
|
||||
are refused with a 422 so a typo is never a silent no-op.
|
||||
|
||||
Proxy admins only. Errors are RFC 9457 problem documents.
|
||||
|
||||
Example curl:
|
||||
```
|
||||
curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/access-groups/<access_group_id>' \
|
||||
--header 'Authorization: Bearer sk-1234' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"description": "Production models", "access_model_names": ["gpt-5.2"]}'
|
||||
```
|
||||
"""
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise _forbidden(user_api_key_dict)
|
||||
|
||||
# access_group_endpoints is a lazy feature: importing it at module load would mark it warm in
|
||||
# /openapi.json before its legacy routes are registered, so it is imported per request instead
|
||||
from litellm.proxy.management_endpoints.access_group_endpoints import (
|
||||
AccessGroupNameTaken,
|
||||
AccessGroupNotFound,
|
||||
AccessGroupUpdated,
|
||||
apply_access_group_update,
|
||||
propagate_access_group_update,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
|
||||
title="Database not connected",
|
||||
status=503,
|
||||
detail=CommonProxyErrors.db_not_connected_error.value,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
outcome: Final = await apply_access_group_update(
|
||||
prisma_client, access_group_id, body, user_api_key_dict.user_id
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape
|
||||
verbose_proxy_logger.exception(
|
||||
"litellm.proxy.management_endpoints.management_v1.access_groups.patch_access_group(): Exception occured - %s",
|
||||
e,
|
||||
)
|
||||
raise ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
|
||||
title="Internal server error",
|
||||
status=500,
|
||||
detail="Failed to update the access group.",
|
||||
)
|
||||
)
|
||||
|
||||
match outcome:
|
||||
case AccessGroupNotFound():
|
||||
raise ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}not-found",
|
||||
title="Not found",
|
||||
status=404,
|
||||
detail=f"Access group '{access_group_id}' not found.",
|
||||
)
|
||||
)
|
||||
case AccessGroupNameTaken(access_group_name=access_group_name):
|
||||
raise ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}conflict",
|
||||
title="Conflict",
|
||||
status=409,
|
||||
detail=f"Access group '{access_group_name}' already exists.",
|
||||
)
|
||||
)
|
||||
case AccessGroupUpdated():
|
||||
await propagate_access_group_update(outcome, access_group_id)
|
||||
return ItemResponse(data=AccessGroupResponse.model_validate(outcome.record.dict()))
|
||||
case _:
|
||||
assert_never(outcome)
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
"""Contract machinery shared by every `/management/v1` route."""
|
||||
|
||||
from typing import Final
|
||||
from collections.abc import Sequence
|
||||
from http import HTTPStatus
|
||||
from typing import Final, TypedDict
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.dependencies.utils import get_flat_params
|
||||
from fastapi.params import ParamTypes
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import (
|
||||
ListLinks,
|
||||
|
|
@ -38,6 +41,68 @@ def problem_response(problem: ProblemDetail) -> JSONResponse:
|
|||
)
|
||||
|
||||
|
||||
PROBLEM_DETAIL_SCHEMA_NAME: Final = "ProblemDetail"
|
||||
PROBLEM_DETAIL_REF: Final = f"#/components/schemas/{PROBLEM_DETAIL_SCHEMA_NAME}"
|
||||
|
||||
|
||||
def problem_responses(*status_codes: int) -> dict[int | str, dict[str, object]]:
|
||||
"""OpenAPI `responses` for a route: each code answers with a problem document.
|
||||
|
||||
FastAPI can only label a `model=` response with the route's own media type, so the
|
||||
problem+json content is spelled out here and the `ProblemDetail` component is added
|
||||
to the schema by `add_problem_detail_component`.
|
||||
"""
|
||||
return {
|
||||
code: {
|
||||
"description": HTTPStatus(code).phrase,
|
||||
"content": {PROBLEM_CONTENT_TYPE: {"schema": {"$ref": PROBLEM_DETAIL_REF}}},
|
||||
}
|
||||
for code in status_codes
|
||||
}
|
||||
|
||||
|
||||
_SCHEMA_SECTION: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def add_problem_detail_component(openapi_schema: dict[str, object]) -> dict[str, object]:
|
||||
components: Final = _SCHEMA_SECTION.validate_python(openapi_schema.get("components", {}))
|
||||
schemas: Final = _SCHEMA_SECTION.validate_python(components.get("schemas", {}))
|
||||
if PROBLEM_DETAIL_SCHEMA_NAME in schemas:
|
||||
return openapi_schema
|
||||
problem_detail_schema: Final = _SCHEMA_SECTION.validate_python(ProblemDetail.model_json_schema())
|
||||
return {
|
||||
**openapi_schema,
|
||||
"components": {**components, "schemas": {**schemas, PROBLEM_DETAIL_SCHEMA_NAME: problem_detail_schema}},
|
||||
}
|
||||
|
||||
|
||||
class ValidationErrorDetail(TypedDict):
|
||||
loc: tuple[int | str, ...]
|
||||
msg: str
|
||||
|
||||
|
||||
def _describe(errors: Sequence[ValidationErrorDetail]) -> str:
|
||||
return "; ".join(f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in errors)
|
||||
|
||||
|
||||
def validation_problem(errors: Sequence[ValidationErrorDetail]) -> ProblemDetail:
|
||||
"""A body that fails validation, unknown keys included, is a 422; a bad query string stays a 400."""
|
||||
body_errors: Final = tuple(error for error in errors if tuple(error["loc"][:1]) == ("body",))
|
||||
if body_errors:
|
||||
return ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}invalid-request-body",
|
||||
title="Invalid request body",
|
||||
status=422,
|
||||
detail=_describe(body_errors),
|
||||
)
|
||||
return ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter",
|
||||
title="Invalid query parameter",
|
||||
status=400,
|
||||
detail=_describe(errors) or "The request query parameters are invalid.",
|
||||
)
|
||||
|
||||
|
||||
def _declared_query_params(request: Request) -> frozenset[str]:
|
||||
route: Final = request.scope.get("route")
|
||||
dependant: Final = getattr(route, "dependant", None)
|
||||
|
|
|
|||
|
|
@ -459,9 +459,10 @@ from litellm.proxy.management_endpoints.management_v1 import (
|
|||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
add_problem_detail_component,
|
||||
problem_response,
|
||||
validation_problem,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
router as model_access_group_management_router,
|
||||
|
|
@ -522,7 +523,6 @@ from litellm.proxy.plugin_routes import (
|
|||
from litellm.proxy.plugin_routes import (
|
||||
router as plugin_router,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
|
||||
try:
|
||||
from litellm.proxy.enterprise_billing.billing_metrics import (
|
||||
|
|
@ -1475,6 +1475,7 @@ def get_openapi_schema():
|
|||
|
||||
openapi_schema = inject_lazy_stubs(openapi_schema)
|
||||
openapi_schema = ensure_unique_openapi_operation_ids(openapi_schema)
|
||||
openapi_schema = add_problem_detail_component(openapi_schema)
|
||||
|
||||
# Fix Swagger UI execute path error when server_root_path is set
|
||||
if server_root_path:
|
||||
|
|
@ -1642,27 +1643,12 @@ class _ExceptionRow(TypedDict, total=False):
|
|||
exception_counts: Mapping[str, int]
|
||||
|
||||
|
||||
class _ValidationErrorDetail(TypedDict):
|
||||
loc: tuple[int | str, ...]
|
||||
msg: str
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def otel_request_validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
if request.url.path.startswith(MANAGEMENT_V1_PREFIX):
|
||||
_close_dangling_otel_server_span(request, 400, exc=exc)
|
||||
validation_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors()
|
||||
return problem_response(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter",
|
||||
title="Invalid query parameter",
|
||||
status=400,
|
||||
detail="; ".join(
|
||||
f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in validation_errors
|
||||
)
|
||||
or "The request query parameters are invalid.",
|
||||
)
|
||||
)
|
||||
problem: Final = validation_problem(exc.errors())
|
||||
_close_dangling_otel_server_span(request, problem.status, exc=exc)
|
||||
return problem_response(problem)
|
||||
_close_dangling_otel_server_span(request, 422, exc=exc)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AccessGroupCreateRequest(BaseModel):
|
||||
|
|
@ -23,6 +23,13 @@ class AccessGroupUpdateRequest(BaseModel):
|
|||
assigned_key_ids: list[str] | None = None
|
||||
|
||||
|
||||
class AccessGroupPatchRequest(AccessGroupUpdateRequest):
|
||||
"""`PATCH /management/v1/access-groups/{id}` body: a JSON merge patch, so an unknown key is a 422 rather
|
||||
than a silent no-op."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AccessGroupResponse(BaseModel):
|
||||
access_group_id: str
|
||||
access_group_name: str
|
||||
|
|
|
|||
|
|
@ -71,3 +71,10 @@ class ListResponse(BaseModel, Generic[TOut]):
|
|||
data: list[TOut]
|
||||
meta: ListMeta
|
||||
links: ListLinks
|
||||
|
||||
|
||||
class ItemResponse(BaseModel, Generic[TOut]):
|
||||
"""One resource on read, create and update, in the same `data` envelope as a list row, so etags or
|
||||
warnings can join it later as siblings of `data` instead of colliding with a field."""
|
||||
|
||||
data: TOut
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
"""`PATCH /management/v1/access-groups/{id}` against the real proxy app, so the problem+json handlers
|
||||
registered in proxy_server are the ones rendering every error."""
|
||||
|
||||
import types
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
PROBLEM_CONTENT_TYPE,
|
||||
PROBLEM_TYPE_BASE,
|
||||
)
|
||||
|
||||
PATH = f"{MANAGEMENT_V1_PREFIX}/access-groups/ag-1"
|
||||
|
||||
|
||||
def _record(**overrides):
|
||||
data = {
|
||||
"access_group_id": "ag-1",
|
||||
"access_group_name": "prod",
|
||||
"description": "Production models",
|
||||
"access_model_names": ["gpt-5.2"],
|
||||
"access_mcp_server_ids": [],
|
||||
"access_agent_ids": ["agent-1"],
|
||||
"assigned_team_ids": ["team-a"],
|
||||
"assigned_key_ids": [],
|
||||
"created_at": datetime(2026, 8, 1, tzinfo=timezone.utc),
|
||||
"created_by": "admin",
|
||||
"updated_at": datetime(2026, 8, 2, tzinfo=timezone.utc),
|
||||
"updated_by": "admin",
|
||||
**overrides,
|
||||
}
|
||||
record = MagicMock()
|
||||
for key, value in data.items():
|
||||
setattr(record, key, value)
|
||||
record.dict = lambda: data
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_group_table(monkeypatch):
|
||||
"""A prisma double whose transaction hands back the same table mocks; `update` echoes the written data."""
|
||||
table = MagicMock()
|
||||
table.find_unique = AsyncMock(return_value=_record())
|
||||
table.update = AsyncMock(side_effect=lambda *, where, data: _record(**{k: v for k, v in data.items()}))
|
||||
|
||||
team_table = MagicMock()
|
||||
team_table.find_unique = AsyncMock(return_value=None)
|
||||
team_table.update = AsyncMock(return_value=None)
|
||||
key_table = MagicMock()
|
||||
key_table.find_unique = AsyncMock(return_value=None)
|
||||
key_table.update = AsyncMock(return_value=None)
|
||||
|
||||
@asynccontextmanager
|
||||
async def tx():
|
||||
yield types.SimpleNamespace(
|
||||
litellm_accessgrouptable=table, litellm_teamtable=team_table, litellm_verificationtoken=key_table
|
||||
)
|
||||
|
||||
prisma = MagicMock()
|
||||
prisma.db = types.SimpleNamespace(
|
||||
litellm_accessgrouptable=table, litellm_teamtable=team_table, litellm_verificationtoken=key_table, tx=tx
|
||||
)
|
||||
monkeypatch.setattr(ps, "prisma_client", prisma)
|
||||
|
||||
cache = MagicMock()
|
||||
cache.async_set_cache = AsyncMock(return_value=None)
|
||||
cache.async_get_cache = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(ps, "user_api_key_cache", cache)
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.internal_usage_cache.dual_cache.async_get_cache = AsyncMock(return_value=None)
|
||||
logging_obj.internal_usage_cache.dual_cache.async_set_cache = AsyncMock(return_value=None)
|
||||
logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(ps, "proxy_logging_obj", logging_obj)
|
||||
return table
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
ps.app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
yield TestClient(ps.app)
|
||||
ps.app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _as_role(role: LitellmUserRoles) -> None:
|
||||
ps.app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="u", user_role=role)
|
||||
|
||||
|
||||
def _assert_problem(resp, status: int, problem_type: str) -> dict:
|
||||
assert resp.status_code == status
|
||||
assert resp.headers["content-type"] == PROBLEM_CONTENT_TYPE
|
||||
body = resp.json()
|
||||
assert body["type"] == f"{PROBLEM_TYPE_BASE}{problem_type}"
|
||||
assert body["status"] == status
|
||||
assert isinstance(body["detail"], str)
|
||||
return body
|
||||
|
||||
|
||||
def test_writes_only_the_sent_fields_and_answers_in_the_data_envelope(client, access_group_table):
|
||||
resp = client.patch(PATH, json={"description": "Only this changes"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/json"
|
||||
assert access_group_table.update.call_args.kwargs == {
|
||||
"where": {"access_group_id": "ag-1"},
|
||||
"data": {"updated_by": "admin", "description": "Only this changes"},
|
||||
}
|
||||
body = resp.json()
|
||||
assert set(body) == {"data"}
|
||||
assert body["data"]["access_group_id"] == "ag-1"
|
||||
assert body["data"]["description"] == "Only this changes"
|
||||
|
||||
|
||||
def test_null_clears_a_scalar_and_a_list(client, access_group_table):
|
||||
resp = client.patch(PATH, json={"description": None, "access_model_names": None})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert access_group_table.update.call_args.kwargs["data"] == {
|
||||
"updated_by": "admin",
|
||||
"description": None,
|
||||
"access_model_names": [],
|
||||
}
|
||||
|
||||
|
||||
def test_syncs_team_membership_from_the_assigned_team_delta(client, access_group_table):
|
||||
team_table = ps.prisma_client.db.litellm_teamtable
|
||||
team_table.find_unique = AsyncMock(
|
||||
side_effect=lambda *, where: types.SimpleNamespace(team_id=where["team_id"], access_group_ids=[])
|
||||
)
|
||||
|
||||
resp = client.patch(PATH, json={"assigned_team_ids": ["team-b"]})
|
||||
|
||||
assert resp.status_code == 200
|
||||
updates = {call.kwargs["where"]["team_id"]: call.kwargs["data"] for call in team_table.update.call_args_list}
|
||||
assert updates == {"team-b": {"access_group_ids": ["ag-1"]}}
|
||||
|
||||
|
||||
def test_an_unknown_body_key_is_a_422_problem_not_a_silent_no_op(client, access_group_table):
|
||||
resp = client.patch(PATH, json={"descripton": "typo"})
|
||||
|
||||
body = _assert_problem(resp, 422, "invalid-request-body")
|
||||
assert "descripton" in body["detail"]
|
||||
access_group_table.update.assert_not_awaited()
|
||||
|
||||
|
||||
def test_a_wrongly_typed_field_is_a_422_problem(client, access_group_table):
|
||||
resp = client.patch(PATH, json={"access_model_names": "gpt-5.2"})
|
||||
|
||||
body = _assert_problem(resp, 422, "invalid-request-body")
|
||||
assert "access_model_names" in body["detail"]
|
||||
access_group_table.update.assert_not_awaited()
|
||||
|
||||
|
||||
def test_a_missing_group_is_a_404_problem(client, access_group_table):
|
||||
access_group_table.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
body = _assert_problem(client.patch(PATH, json={"description": "x"}), 404, "not-found")
|
||||
assert "ag-1" in body["detail"]
|
||||
access_group_table.update.assert_not_awaited()
|
||||
|
||||
|
||||
def test_a_taken_name_is_a_409_problem(client, access_group_table):
|
||||
access_group_table.update = AsyncMock(side_effect=Exception("P2002: Unique constraint failed"))
|
||||
|
||||
body = _assert_problem(client.patch(PATH, json={"access_group_name": "taken"}), 409, "conflict")
|
||||
assert "taken" in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.TEAM],
|
||||
)
|
||||
def test_a_caller_who_is_not_a_proxy_admin_is_refused_with_a_403_problem(client, access_group_table, role):
|
||||
_as_role(role)
|
||||
|
||||
_assert_problem(client.patch(PATH, json={"description": "x"}), 403, "forbidden")
|
||||
access_group_table.update.assert_not_awaited()
|
||||
|
||||
|
||||
def test_a_driver_error_is_a_500_problem_not_the_openai_error_shape(client, access_group_table):
|
||||
access_group_table.update = AsyncMock(side_effect=RuntimeError("connection reset"))
|
||||
|
||||
body = _assert_problem(client.patch(PATH, json={"description": "x"}), 500, "internal-server-error")
|
||||
assert "connection reset" not in body["detail"]
|
||||
|
||||
|
||||
def test_no_database_is_a_503_problem(client, access_group_table, monkeypatch):
|
||||
monkeypatch.setattr(ps, "prisma_client", None)
|
||||
|
||||
_assert_problem(client.patch(PATH, json={"description": "x"}), 503, "database-not-connected")
|
||||
|
||||
|
||||
def test_the_route_advertises_its_errors_as_problem_documents():
|
||||
operation = ps.app.openapi()["paths"][f"{MANAGEMENT_V1_PREFIX}/access-groups/{{access_group_id}}"]["patch"]
|
||||
|
||||
assert sorted(operation["responses"]) == ["200", "403", "404", "409", "422", "500", "503"]
|
||||
assert operation["responses"]["422"]["content"] == {
|
||||
PROBLEM_CONTENT_TYPE: {"schema": {"$ref": "#/components/schemas/ProblemDetail"}}
|
||||
}
|
||||
assert "ProblemDetail" in ps.app.openapi()["components"]["schemas"]
|
||||
|
|
@ -14,8 +14,11 @@ from litellm.proxy.management_endpoints.management_v1.common import (
|
|||
PROBLEM_CONTENT_TYPE,
|
||||
ManagementProblem,
|
||||
_declared_query_params,
|
||||
add_problem_detail_component,
|
||||
problem_response,
|
||||
problem_responses,
|
||||
reject_unknown_query_params,
|
||||
validation_problem,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -102,6 +105,60 @@ def test_declared_query_params_is_empty_when_the_route_has_no_dependant():
|
|||
assert _declared_query_params(request) == frozenset()
|
||||
|
||||
|
||||
def test_a_body_validation_error_is_a_422_problem_naming_the_field():
|
||||
problem = validation_problem([{"loc": ("body", "descripton"), "msg": "Extra inputs are not permitted"}])
|
||||
assert problem.status == 422
|
||||
assert problem.type.endswith("invalid-request-body")
|
||||
assert problem.detail == "descripton: Extra inputs are not permitted"
|
||||
|
||||
|
||||
def test_a_query_validation_error_stays_a_400_problem():
|
||||
problem = validation_problem([{"loc": ("query", "page_size"), "msg": "Input should be <= 100"}])
|
||||
assert problem.status == 400
|
||||
assert problem.type.endswith("invalid-query-parameter")
|
||||
assert problem.detail == "page_size: Input should be <= 100"
|
||||
|
||||
|
||||
def test_a_mixed_error_list_reports_only_the_body_errors_as_a_422():
|
||||
problem = validation_problem(
|
||||
[
|
||||
{"loc": ("query", "page"), "msg": "Input should be >= 1"},
|
||||
{"loc": ["body", "access_model_names"], "msg": "Input should be a valid list"},
|
||||
]
|
||||
)
|
||||
assert problem.status == 422
|
||||
assert problem.detail == "access_model_names: Input should be a valid list"
|
||||
|
||||
|
||||
def test_an_empty_error_list_is_a_400_with_a_generic_detail():
|
||||
problem = validation_problem([])
|
||||
assert problem.status == 400
|
||||
assert problem.detail == "The request query parameters are invalid."
|
||||
|
||||
|
||||
def test_problem_responses_declares_each_code_as_a_problem_document():
|
||||
responses = problem_responses(404, 422)
|
||||
assert sorted(responses) == [404, 422]
|
||||
assert responses[404]["description"] == "Not Found"
|
||||
assert responses[422]["content"] == {
|
||||
PROBLEM_CONTENT_TYPE: {"schema": {"$ref": "#/components/schemas/ProblemDetail"}}
|
||||
}
|
||||
|
||||
|
||||
def test_add_problem_detail_component_registers_the_schema_once_and_leaves_the_rest_alone():
|
||||
schema = {"openapi": "3.1.0", "components": {"schemas": {"Other": {"type": "object"}}}}
|
||||
added = add_problem_detail_component(schema)
|
||||
assert set(added["components"]["schemas"]) == {"Other", "ProblemDetail"}
|
||||
assert added["components"]["schemas"]["ProblemDetail"]["required"] == ["type", "title", "status", "detail"]
|
||||
assert schema["components"]["schemas"] == {"Other": {"type": "object"}}
|
||||
assert add_problem_detail_component(added) is added
|
||||
|
||||
|
||||
def test_add_problem_detail_component_creates_the_components_section_when_missing():
|
||||
added = add_problem_detail_component({"openapi": "3.1.0"})
|
||||
assert set(added["components"]["schemas"]) == {"ProblemDetail"}
|
||||
|
||||
|
||||
# fastapi removed these in 0.140.7, which `pyproject.toml` still allows via
|
||||
# `fastapi>=0.136.3,<1.0`. Add a name here whenever a supported release drops one.
|
||||
FASTAPI_NAMES_REMOVED_IN_0_140_7 = frozenset({"get_flat_dependant"})
|
||||
|
|
|
|||
|
|
@ -410,7 +410,6 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["put", "patch"])
|
||||
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
|
||||
@pytest.mark.parametrize(
|
||||
"update_payload",
|
||||
|
|
@ -420,36 +419,18 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role):
|
|||
{"assigned_team_ids": [], "assigned_key_ids": ["key-1"]},
|
||||
],
|
||||
)
|
||||
def test_update_access_group_success(client_and_mocks, method, base_path, update_payload):
|
||||
"""Update access group with various payloads returns 200 over both PUT and PATCH."""
|
||||
def test_update_access_group_success(client_and_mocks, base_path, update_payload):
|
||||
"""Update access group with various payloads returns 200."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
|
||||
existing = _make_access_group_record(access_group_id="ag-update")
|
||||
mock_table.find_unique = AsyncMock(return_value=existing)
|
||||
|
||||
resp = client.request(method, f"{base_path}/ag-update", json=update_payload)
|
||||
resp = client.put(f"{base_path}/ag-update", json=update_payload)
|
||||
assert resp.status_code == 200
|
||||
mock_table.update.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
|
||||
def test_patch_access_group_writes_only_sent_fields(client_and_mocks, base_path):
|
||||
"""PATCH with one field leaves every other column out of the write, so untouched grants survive."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
|
||||
existing = _make_access_group_record(
|
||||
access_group_id="ag-update", access_model_names=["model-1"], access_agent_ids=["agent-1"]
|
||||
)
|
||||
mock_table.find_unique = AsyncMock(return_value=existing)
|
||||
|
||||
resp = client.patch(f"{base_path}/ag-update", json={"description": "Only this changes"})
|
||||
assert resp.status_code == 200
|
||||
assert mock_table.update.call_args.kwargs["data"] == {
|
||||
"updated_by": "admin_user",
|
||||
"description": "Only this changes",
|
||||
}
|
||||
|
||||
|
||||
def test_update_access_group_not_found(client_and_mocks):
|
||||
"""Update access group returns 404 when not found."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
|
|
|
|||
|
|
@ -242,6 +242,23 @@ async def test_otel_request_validation_exception_handler_returns_a_problem_on_th
|
|||
assert "detail" in body and not isinstance(body["detail"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_otel_request_validation_exception_handler_answers_a_bad_body_on_the_control_plane_with_a_422_problem():
|
||||
"""A merge-patch body with an unknown key must not be a silent no-op, so `/management/v1`
|
||||
turns body validation errors into a 422 problem document that names the field."""
|
||||
errors = [{"loc": ["body", "descripton"], "msg": "Extra inputs are not permitted", "type": "extra_forbidden"}]
|
||||
exc = RequestValidationError(errors)
|
||||
request = _make_request(path="/management/v1/access-groups/ag-1")
|
||||
|
||||
response = await otel_request_validation_exception_handler(request=request, exc=exc)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.media_type == "application/problem+json"
|
||||
assert body["type"] == "urn:litellm:error:invalid-request-body"
|
||||
assert body["detail"] == "descripton: Extra inputs are not permitted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_otel_request_validation_exception_handler_leaves_other_routes_on_422():
|
||||
"""The problem+json branch is scoped by path prefix. A route that merely contains
|
||||
|
|
|
|||
|
|
@ -9,22 +9,11 @@ import { Button } from "@/components/ui/button";
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { usePickDirty } from "@/lib/forms/pickDirty";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
|
||||
import { AccessGroupFormFields, GENERAL_TAB } from "../access-group-form/AccessGroupFormFields";
|
||||
import { accessGroupFormSchema } from "../access-group-form/schema";
|
||||
import { buildAccessGroupPatchBody, formValuesFromAccessGroup, type AccessGroupPatchBody } from "./mapper";
|
||||
|
||||
const defaultPatchAccessGroup = async (
|
||||
accessGroupId: string,
|
||||
body: AccessGroupPatchBody,
|
||||
): Promise<AccessGroupResponse | undefined> => {
|
||||
const { data } = await fetchClient.PATCH("/v1/access_group/{access_group_id}", {
|
||||
params: { path: { access_group_id: accessGroupId } },
|
||||
body,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
import { patchAccessGroup as defaultPatchAccessGroup } from "./patchAccessGroup";
|
||||
|
||||
interface AccessGroupEditDialogProps {
|
||||
open: boolean;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { components } from "@/lib/http/schema";
|
|||
|
||||
import type { AccessGroupFormValues } from "../access-group-form/schema";
|
||||
|
||||
export type AccessGroupPatchBody = components["schemas"]["AccessGroupUpdateRequest"];
|
||||
export type AccessGroupPatchBody = components["schemas"]["AccessGroupPatchRequest"];
|
||||
|
||||
export const formValuesFromAccessGroup = (group: AccessGroupResponse): AccessGroupFormValues => ({
|
||||
name: group.access_group_name,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const patchMock = vi.fn();
|
||||
vi.mock("@/lib/http/api", () => ({ fetchClient: { PATCH: (...args: unknown[]) => patchMock(...args) } }));
|
||||
|
||||
import { patchAccessGroup } from "./patchAccessGroup";
|
||||
|
||||
const record = { access_group_id: "ag-1", access_group_name: "prod", access_model_names: ["gpt-5.2"] };
|
||||
|
||||
describe("patchAccessGroup", () => {
|
||||
it("PATCHes the control-plane route with the id in the path and unwraps the data envelope", async () => {
|
||||
patchMock.mockResolvedValueOnce({ data: { data: record } });
|
||||
|
||||
const result = await patchAccessGroup("ag-1", { description: null });
|
||||
|
||||
expect(patchMock).toHaveBeenCalledWith("/management/v1/access-groups/{access_group_id}", {
|
||||
params: { path: { access_group_id: "ag-1" } },
|
||||
body: { description: null },
|
||||
});
|
||||
expect(result).toBe(record);
|
||||
});
|
||||
|
||||
it("returns undefined when the response has no body", async () => {
|
||||
patchMock.mockResolvedValueOnce({ data: undefined });
|
||||
|
||||
await expect(patchAccessGroup("ag-1", { description: "x" })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
|
||||
import type { AccessGroupPatchBody } from "./mapper";
|
||||
|
||||
export const patchAccessGroup = async (
|
||||
accessGroupId: string,
|
||||
body: AccessGroupPatchBody,
|
||||
): Promise<AccessGroupResponse | undefined> => {
|
||||
const { data } = await fetchClient.PATCH("/management/v1/access-groups/{access_group_id}", {
|
||||
params: { path: { access_group_id: accessGroupId } },
|
||||
body,
|
||||
});
|
||||
return data?.data;
|
||||
};
|
||||
229
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
229
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -7550,6 +7550,35 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/management/v1/access-groups/{access_group_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Patch Access Group
|
||||
* @description Update one access group as a JSON merge patch: a key that is sent is written, `null` clears
|
||||
* it (a cleared list becomes `[]`), and a key that is omitted keeps its value. Unknown keys
|
||||
* are refused with a 422 so a typo is never a silent no-op.
|
||||
*
|
||||
* Proxy admins only. Errors are RFC 9457 problem documents.
|
||||
*
|
||||
* Example curl:
|
||||
* ```
|
||||
* curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/access-groups/<access_group_id>' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"description": "Production models", "access_model_names": ["gpt-5.2"]}'
|
||||
* ```
|
||||
*/
|
||||
patch: operations["patch_access_group_management_v1_access_groups__access_group_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/management/v1/budgets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -15725,8 +15754,7 @@ export interface paths {
|
|||
delete: operations["delete_access_group_v1_access_group__access_group_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update Access Group */
|
||||
patch: operations["update_access_group_v1_access_group__access_group_id__patch"];
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/agents": {
|
||||
|
|
@ -18742,8 +18770,7 @@ export interface paths {
|
|||
delete: operations["delete_access_group_v1_unified_access_group__access_group_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update Access Group */
|
||||
patch: operations["update_access_group_v1_unified_access_group__access_group_id__patch"];
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/vector_store/list": {
|
||||
|
|
@ -21040,6 +21067,27 @@ export interface components {
|
|||
/** Model Names */
|
||||
model_names: string[];
|
||||
};
|
||||
/**
|
||||
* AccessGroupPatchRequest
|
||||
* @description `PATCH /management/v1/access-groups/{id}` body: a JSON merge patch, so an unknown key is a 422 rather
|
||||
* than a silent no-op.
|
||||
*/
|
||||
AccessGroupPatchRequest: {
|
||||
/** Access Agent Ids */
|
||||
access_agent_ids?: string[] | null;
|
||||
/** Access Group Name */
|
||||
access_group_name?: string | null;
|
||||
/** Access Mcp Server Ids */
|
||||
access_mcp_server_ids?: string[] | null;
|
||||
/** Access Model Names */
|
||||
access_model_names?: string[] | null;
|
||||
/** Assigned Key Ids */
|
||||
assigned_key_ids?: string[] | null;
|
||||
/** Assigned Team Ids */
|
||||
assigned_team_ids?: string[] | null;
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
};
|
||||
/** AccessGroupResponse */
|
||||
AccessGroupResponse: {
|
||||
/** Access Agent Ids */
|
||||
|
|
@ -25848,6 +25896,10 @@ export interface components {
|
|||
/** Is Accepted */
|
||||
is_accepted: boolean;
|
||||
};
|
||||
/** ItemResponse[AccessGroupResponse] */
|
||||
ItemResponse_AccessGroupResponse_: {
|
||||
data: components["schemas"]["AccessGroupResponse"];
|
||||
};
|
||||
/** JWTKeyMappingResponse */
|
||||
JWTKeyMappingResponse: {
|
||||
/**
|
||||
|
|
@ -31229,6 +31281,25 @@ export interface components {
|
|||
*/
|
||||
version_status: string;
|
||||
};
|
||||
/**
|
||||
* ProblemDetail
|
||||
* @description RFC 9457 problem details, served as `application/problem+json`.
|
||||
*/
|
||||
ProblemDetail: {
|
||||
/**
|
||||
* Allowed
|
||||
* @default null
|
||||
*/
|
||||
allowed: string[] | null;
|
||||
/** Detail */
|
||||
detail: string;
|
||||
/** Status */
|
||||
status: number;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Type */
|
||||
type: string;
|
||||
};
|
||||
/** Prompt */
|
||||
Prompt: {
|
||||
litellm_params: components["schemas"]["PromptLiteLLMParams"];
|
||||
|
|
@ -45841,6 +45912,86 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
patch_access_group_management_v1_access_groups__access_group_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
access_group_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupPatchRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ItemResponse_AccessGroupResponse_"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Service Unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_budgets_management_v1_budgets_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -55439,41 +55590,6 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
update_access_group_v1_access_group__access_group_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
access_group_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_agents_v1_agents_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
@ -59655,41 +59771,6 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
update_access_group_v1_unified_access_group__access_group_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
access_group_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_vector_stores_v1_vector_store_list_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue