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