From 830063392fe506d5d8c7d32ed14f5b9683971ab2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 17 Aug 2026 16:13:12 -0700 Subject: [PATCH] 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. --- litellm/proxy/_lazy_openapi_snapshot.json | 117 --------- .../access_group_endpoints.py | 223 +++++++++-------- .../management_v1/__init__.py | 4 + .../management_v1/access_groups.py | 125 ++++++++++ .../management_v1/common.py | 67 ++++- litellm/proxy/proxy_server.py | 26 +- litellm/types/access_group.py | 9 +- .../management_endpoints/management_v1.py | 7 + .../management_v1/test_access_groups.py | 208 ++++++++++++++++ .../management_v1/test_common.py | 57 +++++ .../test_access_group_endpoints.py | 25 +- .../proxy_server/test_exception_handlers.py | 17 ++ .../AccessGroupEditDialog.tsx | 13 +- .../_components/access-group-edit/mapper.ts | 2 +- .../patchAccessGroup.test.ts | 28 +++ .../access-group-edit/patchAccessGroup.ts | 15 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 229 ++++++++++++------ 17 files changed, 828 insertions(+), 344 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/access_groups.py create mode 100644 tests/test_litellm/proxy/management_endpoints/management_v1/test_access_groups.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/patchAccessGroup.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/patchAccessGroup.ts diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 0f7a933e77c..7fe02c6d8bc 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -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": [ diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 117b5b3f824..c10d02a595b 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -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, diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index 342e6525cda..3675747f183 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -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) diff --git a/litellm/proxy/management_endpoints/management_v1/access_groups.py b/litellm/proxy/management_endpoints/management_v1/access_groups.py new file mode 100644 index 00000000000..5e9d6bdfb24 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/access_groups.py @@ -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/' \ + --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) diff --git a/litellm/proxy/management_endpoints/management_v1/common.py b/litellm/proxy/management_endpoints/management_v1/common.py index ec79820465a..7a062bfac15 100644 --- a/litellm/proxy/management_endpoints/management_v1/common.py +++ b/litellm/proxy/management_endpoints/management_v1/common.py @@ -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) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index bda6fc25499..e58f04f57e4 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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, diff --git a/litellm/types/access_group.py b/litellm/types/access_group.py index b477ce309b7..30729e06279 100644 --- a/litellm/types/access_group.py +++ b/litellm/types/access_group.py @@ -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 diff --git a/litellm/types/proxy/management_endpoints/management_v1.py b/litellm/types/proxy/management_endpoints/management_v1.py index b2244f6eb9b..726df29a9c2 100644 --- a/litellm/types/proxy/management_endpoints/management_v1.py +++ b/litellm/types/proxy/management_endpoints/management_v1.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_access_groups.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_access_groups.py new file mode 100644 index 00000000000..5d922aa6e61 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_access_groups.py @@ -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"] diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py index f3515e84d0d..234e9c5a09f 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py @@ -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"}) diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py index 9331555bd62..016e10859b6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py b/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py index 4aea2e16364..52babcdfdbe 100644 --- a/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py +++ b/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/AccessGroupEditDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/AccessGroupEditDialog.tsx index e4655b0df5e..844d7791855 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/AccessGroupEditDialog.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/AccessGroupEditDialog.tsx @@ -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 => { - 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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/mapper.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/mapper.ts index 89098df3603..b9636b3d792 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/mapper.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/mapper.ts @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/patchAccessGroup.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/patchAccessGroup.test.ts new file mode 100644 index 00000000000..6c725b83ae4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/patchAccessGroup.test.ts @@ -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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/patchAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/patchAccessGroup.ts new file mode 100644 index 00000000000..6ae5bfea5d6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/patchAccessGroup.ts @@ -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 => { + const { data } = await fetchClient.PATCH("/management/v1/access-groups/{access_group_id}", { + params: { path: { access_group_id: accessGroupId } }, + body, + }); + return data?.data; +}; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 146b01c586e..dd330bd3d57 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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/' --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?: {