feat(access-groups): move the partial update to PATCH /management/v1/access-groups/{id}

The legacy PATCH /v1/access_group/{id} and its unified alias are dropped in favour of a
control-plane route that follows the mutation standard: {"data": ...} envelope, JSON
merge-patch semantics, unknown body keys refused with a 422, and RFC 9457 problem
documents for 403/404/409/422/500/503 declared in OpenAPI. PUT /v1/access_group/{id}
is untouched.

The update logic is factored into apply_access_group_update, which returns a tagged
outcome that both the legacy PUT and the new PATCH map onto their own error shapes.
The control plane's RequestValidationError handler now answers a bad body with a 422
problem instead of the 400 query-parameter one, and the ProblemDetail schema is
registered as an OpenAPI component so problem responses can reference it.

The edit dialog calls the new route through patchAccessGroup and unwraps the envelope.
This commit is contained in:
ryan-crabbe-berri 2026-08-17 16:13:12 -07:00
parent 08a306f198
commit 830063392f
17 changed files with 828 additions and 344 deletions

View file

@ -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": [

View file

@ -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,

View file

@ -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)

View file

@ -0,0 +1,125 @@
"""`PATCH /management/v1/access-groups/{access_group_id}`."""
from typing import Annotated, Final, assert_never
from fastapi import APIRouter, Depends
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.management_v1.common import (
MANAGEMENT_V1_PREFIX,
PROBLEM_TYPE_BASE,
ManagementProblem,
problem_responses,
)
from litellm.types.access_group import AccessGroupPatchRequest, AccessGroupResponse
from litellm.types.proxy.management_endpoints.management_v1 import ItemResponse, ProblemDetail
router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
def _forbidden(caller: UserAPIKeyAuth) -> ManagementProblem:
return ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}forbidden",
title="Forbidden",
status=403,
detail=f"Only proxy admins can update access groups, your role={caller.user_role}",
)
)
@router.patch(
"/access-groups/{access_group_id}",
tags=("access group management",),
dependencies=(Depends(user_api_key_auth),),
response_model=ItemResponse[AccessGroupResponse],
responses=problem_responses(403, 404, 409, 422, 500, 503),
)
async def patch_access_group(
access_group_id: str,
body: AccessGroupPatchRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> ItemResponse[AccessGroupResponse]:
"""
Update one access group as a JSON merge patch: a key that is sent is written, `null` clears
it (a cleared list becomes `[]`), and a key that is omitted keeps its value. Unknown keys
are refused with a 422 so a typo is never a silent no-op.
Proxy admins only. Errors are RFC 9457 problem documents.
Example curl:
```
curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/access-groups/<access_group_id>' \
--header 'Authorization: Bearer sk-1234' \
--header 'Content-Type: application/json' \
--data '{"description": "Production models", "access_model_names": ["gpt-5.2"]}'
```
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise _forbidden(user_api_key_dict)
# access_group_endpoints is a lazy feature: importing it at module load would mark it warm in
# /openapi.json before its legacy routes are registered, so it is imported per request instead
from litellm.proxy.management_endpoints.access_group_endpoints import (
AccessGroupNameTaken,
AccessGroupNotFound,
AccessGroupUpdated,
apply_access_group_update,
propagate_access_group_update,
)
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
title="Database not connected",
status=503,
detail=CommonProxyErrors.db_not_connected_error.value,
)
)
try:
outcome: Final = await apply_access_group_update(
prisma_client, access_group_id, body, user_api_key_dict.user_id
)
except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.management_v1.access_groups.patch_access_group(): Exception occured - %s",
e,
)
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
title="Internal server error",
status=500,
detail="Failed to update the access group.",
)
)
match outcome:
case AccessGroupNotFound():
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}not-found",
title="Not found",
status=404,
detail=f"Access group '{access_group_id}' not found.",
)
)
case AccessGroupNameTaken(access_group_name=access_group_name):
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}conflict",
title="Conflict",
status=409,
detail=f"Access group '{access_group_name}' already exists.",
)
)
case AccessGroupUpdated():
await propagate_access_group_update(outcome, access_group_id)
return ItemResponse(data=AccessGroupResponse.model_validate(outcome.record.dict()))
case _:
assert_never(outcome)

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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"})

View file

@ -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

View file

@ -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

View file

@ -9,22 +9,11 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { usePickDirty } from "@/lib/forms/pickDirty";
import { useZodForm } from "@/lib/forms/useZodForm";
import { fetchClient } from "@/lib/http/api";
import { AccessGroupFormFields, GENERAL_TAB } from "../access-group-form/AccessGroupFormFields";
import { accessGroupFormSchema } from "../access-group-form/schema";
import { buildAccessGroupPatchBody, formValuesFromAccessGroup, type AccessGroupPatchBody } from "./mapper";
const defaultPatchAccessGroup = async (
accessGroupId: string,
body: AccessGroupPatchBody,
): Promise<AccessGroupResponse | undefined> => {
const { data } = await fetchClient.PATCH("/v1/access_group/{access_group_id}", {
params: { path: { access_group_id: accessGroupId } },
body,
});
return data;
};
import { patchAccessGroup as defaultPatchAccessGroup } from "./patchAccessGroup";
interface AccessGroupEditDialogProps {
open: boolean;

View file

@ -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,

View file

@ -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();
});
});

View file

@ -0,0 +1,15 @@
import type { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { fetchClient } from "@/lib/http/api";
import type { AccessGroupPatchBody } from "./mapper";
export const patchAccessGroup = async (
accessGroupId: string,
body: AccessGroupPatchBody,
): Promise<AccessGroupResponse | undefined> => {
const { data } = await fetchClient.PATCH("/management/v1/access-groups/{access_group_id}", {
params: { path: { access_group_id: accessGroupId } },
body,
});
return data?.data;
};

View file

@ -7550,6 +7550,35 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/access-groups/{access_group_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Patch Access Group
* @description Update one access group as a JSON merge patch: a key that is sent is written, `null` clears
* it (a cleared list becomes `[]`), and a key that is omitted keeps its value. Unknown keys
* are refused with a 422 so a typo is never a silent no-op.
*
* Proxy admins only. Errors are RFC 9457 problem documents.
*
* Example curl:
* ```
* curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/access-groups/<access_group_id>' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"description": "Production models", "access_model_names": ["gpt-5.2"]}'
* ```
*/
patch: operations["patch_access_group_management_v1_access_groups__access_group_id__patch"];
trace?: never;
};
"/management/v1/budgets": {
parameters: {
query?: never;
@ -15725,8 +15754,7 @@ export interface paths {
delete: operations["delete_access_group_v1_access_group__access_group_id__delete"];
options?: never;
head?: never;
/** Update Access Group */
patch: operations["update_access_group_v1_access_group__access_group_id__patch"];
patch?: never;
trace?: never;
};
"/v1/agents": {
@ -18742,8 +18770,7 @@ export interface paths {
delete: operations["delete_access_group_v1_unified_access_group__access_group_id__delete"];
options?: never;
head?: never;
/** Update Access Group */
patch: operations["update_access_group_v1_unified_access_group__access_group_id__patch"];
patch?: never;
trace?: never;
};
"/v1/vector_store/list": {
@ -21040,6 +21067,27 @@ export interface components {
/** Model Names */
model_names: string[];
};
/**
* AccessGroupPatchRequest
* @description `PATCH /management/v1/access-groups/{id}` body: a JSON merge patch, so an unknown key is a 422 rather
* than a silent no-op.
*/
AccessGroupPatchRequest: {
/** Access Agent Ids */
access_agent_ids?: string[] | null;
/** Access Group Name */
access_group_name?: string | null;
/** Access Mcp Server Ids */
access_mcp_server_ids?: string[] | null;
/** Access Model Names */
access_model_names?: string[] | null;
/** Assigned Key Ids */
assigned_key_ids?: string[] | null;
/** Assigned Team Ids */
assigned_team_ids?: string[] | null;
/** Description */
description?: string | null;
};
/** AccessGroupResponse */
AccessGroupResponse: {
/** Access Agent Ids */
@ -25848,6 +25896,10 @@ export interface components {
/** Is Accepted */
is_accepted: boolean;
};
/** ItemResponse[AccessGroupResponse] */
ItemResponse_AccessGroupResponse_: {
data: components["schemas"]["AccessGroupResponse"];
};
/** JWTKeyMappingResponse */
JWTKeyMappingResponse: {
/**
@ -31229,6 +31281,25 @@ export interface components {
*/
version_status: string;
};
/**
* ProblemDetail
* @description RFC 9457 problem details, served as `application/problem+json`.
*/
ProblemDetail: {
/**
* Allowed
* @default null
*/
allowed: string[] | null;
/** Detail */
detail: string;
/** Status */
status: number;
/** Title */
title: string;
/** Type */
type: string;
};
/** Prompt */
Prompt: {
litellm_params: components["schemas"]["PromptLiteLLMParams"];
@ -45841,6 +45912,86 @@ export interface operations {
};
};
};
patch_access_group_management_v1_access_groups__access_group_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
access_group_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AccessGroupPatchRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ItemResponse_AccessGroupResponse_"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Unprocessable Content */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Service Unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
};
};
list_budgets_management_v1_budgets_get: {
parameters: {
query?: never;
@ -55439,41 +55590,6 @@ export interface operations {
};
};
};
update_access_group_v1_access_group__access_group_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
access_group_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AccessGroupUpdateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AccessGroupResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_agents_v1_agents_get: {
parameters: {
query?: {
@ -59655,41 +59771,6 @@ export interface operations {
};
};
};
update_access_group_v1_unified_access_group__access_group_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
access_group_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AccessGroupUpdateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AccessGroupResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_vector_stores_v1_vector_store_list_get: {
parameters: {
query?: {