Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py: 57%

372 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2Allow proxy admin to manage model access groups 

3 

4Endpoints here: 

5- POST /access_group/new - Create a new access group with multiple model names 

6- GET /access_group/list - List every access group 

7- GET /access_group/{access_group}/info - Read one access group, including its budget 

8- PUT /access_group/{access_group}/update - Replace an access group's deployments 

9- DELETE /access_group/{access_group}/delete - Delete an access group and its budget 

10- GET /access_group/{access_group}/budget - Read an access group's shared budget and spend 

11- PUT /access_group/{access_group}/budget - Set or replace an access group's shared budget 

12- DELETE /access_group/{access_group}/budget - Clear an access group's shared budget 

13""" 

14 

15import json 

16from collections.abc import Mapping, Sequence 

17from datetime import datetime 

18from types import MappingProxyType 

19from typing import TYPE_CHECKING, Annotated, Any, Final, Protocol 

20 

21from fastapi import APIRouter, Depends, HTTPException 

22from typing_extensions import ReadOnly, TypedDict 

23 

24from litellm._logging import verbose_proxy_logger 

25from litellm.proxy._types import UserAPIKeyAuth 

26from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

27from litellm.proxy.common_utils.user_api_key_cache import ( 

28 UserApiKeyCache, 

29 model_access_group_cache_key, 

30 model_access_group_registry_cache_key, 

31) 

32from litellm.proxy.management_endpoints.common_utils import validate_budget_duration 

33 

34# Clear cache and reload models to pick up the access group changes 

35from litellm.proxy.management_endpoints.model_management_endpoints import ( 

36 clear_cache, 

37 live_model_ids_snapshot, 

38 model_info_as_mapping, 

39 reload_serving_verdict, 

40) 

41from litellm.proxy.management_helpers.utils import handle_budget_for_entity 

42from litellm.proxy.utils import PrismaClient 

43from litellm.repositories.model_repository import ModelRepository 

44from litellm.repositories.table_repositories import ModelAccessGroupBudgetRepository 

45from litellm.types.proxy.management_endpoints.model_management_endpoints import ( 

46 AccessGroupBudget, 

47 AccessGroupBudgetRequest, 

48 AccessGroupBudgetResponse, 

49 AccessGroupInfo, 

50 DeleteAccessGroupBudgetResponse, 

51 DeleteModelGroupResponse, 

52 ListAccessGroupsResponse, 

53 NewModelGroupRequest, 

54 NewModelGroupResponse, 

55 UpdateModelGroupRequest, 

56) 

57 

58if TYPE_CHECKING: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true

59 from litellm import Router 

60 

61router: Final = APIRouter(tags=["model management"]) 

62 

63_AUTH_DEPENDENCIES: Final = (Depends(user_api_key_auth),) 

64 

65 

66class _ErrorDetail(TypedDict): 

67 error: ReadOnly[str] 

68 

69 

70class _ModelAccessGroupWhere(TypedDict): 

71 access_group_name: ReadOnly[str] 

72 

73 

74class _BudgetInclude(TypedDict): 

75 litellm_budget_table: ReadOnly[bool] 

76 

77 

78class _ModelAccessGroupBudgetCreate(TypedDict): 

79 access_group_name: ReadOnly[str] 

80 budget_id: ReadOnly[str | None] 

81 created_by: ReadOnly[str] 

82 updated_by: ReadOnly[str] 

83 

84 

85class _ModelAccessGroupBudgetUpdate(TypedDict): 

86 budget_id: ReadOnly[str | None] 

87 updated_by: ReadOnly[str] 

88 

89 

90class _ModelAccessGroupBudgetUpsert(TypedDict): 

91 create: ReadOnly[_ModelAccessGroupBudgetCreate] 

92 update: ReadOnly[_ModelAccessGroupBudgetUpdate] 

93 

94 

95def _http_error(status_code: int, message: str) -> HTTPException: 

96 detail: Final[_ErrorDetail] = {"error": message} 

97 return HTTPException(status_code=status_code, detail=detail) 

98 

99 

100class _DeploymentRow(Protocol): 

101 @property 

102 def model_id(self) -> str: ... 102 ↛ exitline 102 didn't return from function 'model_id' because

103 

104 @property 

105 def model_name(self) -> str: ... 105 ↛ exitline 105 didn't return from function 'model_name' because

106 

107 @property 

108 def model_info(self) -> object: ... 108 ↛ exitline 108 didn't return from function 'model_info' because

109 

110 

111class _ModelTableClient(Protocol): 

112 async def find_many(self, *, where: Mapping[str, object] | None = None) -> Sequence[_DeploymentRow]: ... 112 ↛ exitline 112 didn't return from function 'find_many' because

113 

114 async def find_unique(self, *, where: Mapping[str, object]) -> _DeploymentRow | None: ... 114 ↛ exitline 114 didn't return from function 'find_unique' because

115 

116 async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> object: ... 116 ↛ exitline 116 didn't return from function 'update' because

117 

118 

119class _BudgetRow(Protocol): 

120 @property 

121 def budget_id(self) -> str: ... 121 ↛ exitline 121 didn't return from function 'budget_id' because

122 

123 @property 

124 def max_budget(self) -> float | None: ... 124 ↛ exitline 124 didn't return from function 'max_budget' because

125 

126 @property 

127 def soft_budget(self) -> float | None: ... 127 ↛ exitline 127 didn't return from function 'soft_budget' because

128 

129 @property 

130 def budget_duration(self) -> str | None: ... 130 ↛ exitline 130 didn't return from function 'budget_duration' because

131 

132 @property 

133 def budget_reset_at(self) -> datetime | None: ... 133 ↛ exitline 133 didn't return from function 'budget_reset_at' because

134 

135 

136class _ModelAccessGroupBudgetRow(Protocol): 

137 @property 

138 def access_group_name(self) -> str: ... 138 ↛ exitline 138 didn't return from function 'access_group_name' because

139 

140 @property 

141 def spend(self) -> float: ... 141 ↛ exitline 141 didn't return from function 'spend' because

142 

143 @property 

144 def budget_id(self) -> str | None: ... 144 ↛ exitline 144 didn't return from function 'budget_id' because

145 

146 @property 

147 def litellm_budget_table(self) -> _BudgetRow | None: ... 147 ↛ exitline 147 didn't return from function 'litellm_budget_table' because

148 

149 

150class _ModelAccessGroupBudgetTableClient(Protocol): 

151 async def find_unique( 151 ↛ exitline 151 didn't return from function 'find_unique' because

152 self, *, where: Mapping[str, object], include: Mapping[str, object] | None = None 

153 ) -> _ModelAccessGroupBudgetRow | None: ... 

154 

155 async def upsert( 155 ↛ exitline 155 didn't return from function 'upsert' because

156 self, 

157 *, 

158 where: Mapping[str, object], 

159 data: Mapping[str, object], 

160 include: Mapping[str, object] | None = None, 

161 ) -> _ModelAccessGroupBudgetRow: ... 

162 

163 async def find_many( 163 ↛ exitline 163 didn't return from function 'find_many' because

164 self, *, include: Mapping[str, object] | None = None 

165 ) -> Sequence[_ModelAccessGroupBudgetRow]: ... 

166 

167 async def delete(self, *, where: Mapping[str, object]) -> _ModelAccessGroupBudgetRow | None: ... 167 ↛ exitline 167 didn't return from function 'delete' because

168 

169 

170def _model_table(prisma_client: PrismaClient) -> _ModelTableClient: 

171 return ModelRepository(prisma_client).table 

172 

173 

174def _model_access_group_budget_table(prisma_client: PrismaClient) -> _ModelAccessGroupBudgetTableClient: 

175 return ModelAccessGroupBudgetRepository(prisma_client).table 

176 

177 

178def _prisma_client_or_500() -> PrismaClient: 

179 from litellm.proxy.proxy_server import prisma_client 

180 

181 if prisma_client is None: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 raise _http_error(500, "Database not connected.") 

183 return prisma_client 

184 

185 

186def _auth_cache() -> UserApiKeyCache: 

187 from litellm.proxy.proxy_server import user_api_key_cache 

188 

189 return user_api_key_cache 

190 

191 

192async def _evict_model_access_group_cache_keys(access_group: str, auth_cache: UserApiKeyCache) -> None: 

193 """ 

194 Every endpoint that writes an access group budget row must call this, or the budget stays 

195 unenforced until the TTL expires: auth gates the feature on a cached registry of the groups 

196 that have a budget row, read cache-first with no freshness check. 

197 """ 

198 from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import ( 

199 evict_and_broadcast, 

200 ) 

201 

202 await evict_and_broadcast( 

203 cache_keys=(model_access_group_cache_key(access_group), model_access_group_registry_cache_key()), 

204 user_api_key_cache=auth_cache, 

205 ) 

206 

207 

208async def _model_access_group_budget_row( 

209 access_group: str, prisma_client: PrismaClient 

210) -> _ModelAccessGroupBudgetRow | None: 

211 where: Final[_ModelAccessGroupWhere] = {"access_group_name": access_group} 

212 include: Final[_BudgetInclude] = {"litellm_budget_table": True} 

213 return await _model_access_group_budget_table(prisma_client).find_unique(where=where, include=include) 

214 

215 

216async def _model_access_group_budget_rows( 

217 prisma_client: PrismaClient, 

218) -> Mapping[str, _ModelAccessGroupBudgetRow]: 

219 """Every group's budget row in one read, so listing groups does not fan out into one query 

220 per group.""" 

221 include: Final[_BudgetInclude] = {"litellm_budget_table": True} 

222 rows: Final = await _model_access_group_budget_table(prisma_client).find_many(include=include) 

223 return MappingProxyType({row.access_group_name: row for row in rows}) 

224 

225 

226def _with_budget(info: AccessGroupInfo, row: _ModelAccessGroupBudgetRow | None) -> AccessGroupInfo: 

227 """The group as listed, plus whatever budget hangs off it. A group with no row has spent 

228 nothing, because clearing a budget drops the row that recorded the spend.""" 

229 return AccessGroupInfo( 

230 access_group=info.access_group, 

231 model_names=info.model_names, 

232 deployment_count=info.deployment_count, 

233 spend=row.spend if row is not None else 0.0, 

234 budget=_budget_or_none(row), 

235 ) 

236 

237 

238def _budget_or_none(row: _ModelAccessGroupBudgetRow | None) -> AccessGroupBudget | None: 

239 budget: Final = row.litellm_budget_table if row is not None else None 

240 if budget is None: 

241 return None 

242 return AccessGroupBudget( 

243 budget_id=budget.budget_id, 

244 max_budget=budget.max_budget, 

245 soft_budget=budget.soft_budget, 

246 budget_duration=budget.budget_duration, 

247 budget_reset_at=budget.budget_reset_at, 

248 ) 

249 

250 

251def _budget_response(access_group: str, row: _ModelAccessGroupBudgetRow | None) -> AccessGroupBudgetResponse: 

252 return AccessGroupBudgetResponse( 

253 access_group=access_group, 

254 spend=row.spend if row is not None else 0.0, 

255 budget=_budget_or_none(row), 

256 ) 

257 

258 

259async def _delete_model_access_group_budget_row( 

260 access_group: str, prisma_client: PrismaClient, auth_cache: UserApiKeyCache 

261) -> bool: 

262 """ 

263 Drop the group's budget row only, matching /tag/delete: the LiteLLM_BudgetTable row survives 

264 because the link is ON DELETE SET NULL and a budget_id an admin passed in may be shared with 

265 other entities. 

266 

267 Evicts unconditionally: a group with no row of its own can still be sitting in the cached 

268 registry, so skipping the eviction when nothing was deleted would leave that stale. 

269 """ 

270 where: Final[_ModelAccessGroupWhere] = {"access_group_name": access_group} 

271 row: Final = await _model_access_group_budget_table(prisma_client).delete(where=where) 

272 await _evict_model_access_group_cache_keys(access_group, auth_cache) 

273 return row is not None 

274 

275 

276async def _raise_404_if_model_access_group_missing(access_group: str, prisma_client: PrismaClient) -> None: 

277 access_groups_map: Final = await get_all_access_groups_from_db(prisma_client=prisma_client) 

278 if access_group not in access_groups_map: 278 ↛ exitline 278 didn't return from function '_raise_404_if_model_access_group_missing' because the condition on line 278 was always true

279 raise _http_error(404, f"Access group '{access_group}' not found") 

280 

281 

282def validate_models_exist(model_names: Sequence[str], llm_router: "Router | None") -> tuple[bool, Sequence[str]]: 

283 """ 

284 Validate that all requested model names exist in the router. 

285 Checks only exact model name matches. 

286 

287 Returns: 

288 (all_valid, missing_models) 

289 """ 

290 if llm_router is None: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 return False, model_names 

292 

293 router_model_names: Final = frozenset(llm_router.get_model_names()) 

294 missing: Final = tuple(m for m in model_names if m not in router_model_names) 

295 return (not missing, missing) 

296 

297 

298async def _missing_models_after_read_through( 

299 model_names: Sequence[str], llm_router: "Router | None" 

300) -> tuple[str, ...]: 

301 from litellm.proxy import proxy_server 

302 from litellm.proxy.common_utils.registry_read_through import ( 

303 model_registry_read_through, 

304 ) 

305 

306 _, missing = validate_models_exist(model_names=model_names, llm_router=llm_router) 

307 if not missing: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 return () 

309 for name in missing: 

310 await model_registry_read_through.attempt(name) 

311 _, still_missing = validate_models_exist(model_names=model_names, llm_router=proxy_server.llm_router) 

312 return tuple(still_missing) 

313 

314 

315def add_access_group_to_deployment(model_info: dict[str, Any], access_group: str) -> tuple[dict[str, Any], bool]: 

316 """ 

317 Add an access group to a deployment's model_info. 

318 

319 Args: 

320 model_info: The model_info dictionary from the deployment 

321 access_group: The access group name to add 

322 

323 Returns: 

324 Tuple[Dict[str, Any], bool]: (updated_model_info, was_modified) 

325 """ 

326 access_groups: Final = model_info.get("access_groups", []) 

327 

328 # Check if access group already exists 

329 if access_group in access_groups: 

330 return model_info, False 

331 

332 # Add the access group 

333 access_groups.append(access_group) 

334 model_info["access_groups"] = access_groups 

335 

336 return model_info, True 

337 

338 

339def _raise_http_if_reload_degraded_serving( 

340 before: frozenset[str], 

341 written_models: Sequence[tuple[str, object]], 

342 access_group: str, 

343 still_desired: frozenset[str] | None, 

344 live_after: frozenset[str] | None, 

345) -> None: 

346 """Same verdict as the model-write endpoints, expressed through this file's 

347 HTTPException error convention, with the metadata-only obligation: these writes 

348 change group membership, not the models themselves, so a row that was already not 

349 serving before the reload is never blamed here; only a model this reload stopped 

350 serving is reported.""" 

351 missing, collateral = reload_serving_verdict( 

352 before=before, 

353 written_models=written_models, 

354 written_must_serve=False, 

355 still_desired=still_desired, 

356 live_after=live_after, 

357 ) 

358 gone: Final = tuple(dict.fromkeys((*missing, *collateral))) 

359 if not gone: 

360 return 

361 raise HTTPException( 

362 status_code=500, 

363 detail={ 

364 "error": ( 

365 f"Access group '{access_group}' was saved to the database, but model id(s) {list(gone)} that " 

366 "this pod was serving are no longer live after the reload it triggered. Other pods reload on " 

367 "their own interval. Check server logs for 'Error upserting deployment' for the cause." 

368 ) 

369 }, 

370 ) 

371 

372 

373async def _tag_deployment_with_access_group( 

374 model_id: str, 

375 model_info: object, 

376 access_group: str, 

377 prisma_client: PrismaClient, 

378) -> tuple[str, Mapping[str, object]] | None: 

379 """Write `access_group` into one deployment's model_info; returns the 

380 (model_id, updated model_info) pair when a write happened, None when the 

381 deployment already carried the group.""" 

382 updated_model_info, was_modified = add_access_group_to_deployment( 

383 model_info=dict(_readable_model_info_or_raise(model_id=model_id, model_info=model_info)), 

384 access_group=access_group, 

385 ) 

386 if not was_modified: 

387 return None 

388 await _model_table(prisma_client).update( 

389 where={"model_id": model_id}, 

390 data={"model_info": json.dumps(updated_model_info)}, 

391 ) 

392 verbose_proxy_logger.debug("Updated deployment %s with access group: %s", model_id, access_group) 

393 return (model_id, updated_model_info) 

394 

395 

396def _readable_model_info_or_raise(model_id: str, model_info: object) -> Mapping[str, object]: 

397 """These helpers rewrite the model_info column wholesale, so a present-but-unreadable 

398 value must refuse loudly rather than be silently replaced with a fresh object; an 

399 absent value stays a legitimate empty start.""" 

400 parsed: Final = model_info_as_mapping(model_info) 

401 if parsed is None and model_info is not None: 

402 raise ValueError(f"model_info for deployment {model_id} is not a readable JSON object; refusing to rewrite it") 

403 return parsed or {} 

404 

405 

406async def _strip_access_group_from_deployment( 

407 model_id: str, 

408 model_info: object, 

409 access_group: str, 

410 prisma_client: PrismaClient, 

411) -> tuple[str, Mapping[str, object]] | None: 

412 """Remove `access_group` from one deployment's model_info; returns the 

413 (model_id, updated model_info) pair when a write happened, None when the 

414 deployment did not carry the group.""" 

415 updated_model_info, was_modified = remove_access_group_from_deployment( 

416 model_info=dict(_readable_model_info_or_raise(model_id=model_id, model_info=model_info)), 

417 access_group=access_group, 

418 ) 

419 if not was_modified: 

420 return None 

421 await _model_table(prisma_client).update( 

422 where={"model_id": model_id}, 

423 data={"model_info": json.dumps(updated_model_info)}, 

424 ) 

425 return (model_id, updated_model_info) 

426 

427 

428async def update_deployments_with_access_group( 

429 model_names: list[str], 

430 access_group: str, 

431 prisma_client: PrismaClient, 

432) -> tuple[tuple[str, Mapping[str, object]], ...]: 

433 """ 

434 Update all deployments for the given model names to include the access group. 

435 

436 Args: 

437 model_names: List of model names whose deployments should be updated 

438 access_group: The access group name to add 

439 prisma_client: Database client 

440 

441 Returns: 

442 The (model_id, updated model_info) pair of every deployment actually written, 

443 so callers can verify each one survived the post-write reload 

444 """ 

445 deployments: Final = await _model_table(prisma_client).find_many(where={"model_name": {"in": model_names}}) 

446 verbose_proxy_logger.debug("Found %s deployments for model_names: %s", len(deployments), model_names) 

447 

448 found_names: Final = {deployment.model_name for deployment in deployments} 

449 for model_name in model_names: 

450 if model_name not in found_names: 

451 raise HTTPException( 

452 status_code=400, 

453 detail={ 

454 "error": f"Can't find model '{model_name}' in Database. Access group management is only supported for database models." 

455 }, 

456 ) 

457 

458 tagged: Final = [ 

459 await _tag_deployment_with_access_group( 

460 model_id=deployment.model_id, 

461 model_info=deployment.model_info, 

462 access_group=access_group, 

463 prisma_client=prisma_client, 

464 ) 

465 for deployment in deployments 

466 ] 

467 return tuple(pair for pair in tagged if pair is not None) 

468 

469 

470async def update_specific_deployments_with_access_group( 

471 model_ids: list[str], 

472 access_group: str, 

473 prisma_client: PrismaClient, 

474) -> tuple[tuple[str, Mapping[str, object]], ...]: 

475 """ 

476 Update specific deployments (by model_id) to include the access group. 

477 

478 Unlike update_deployments_with_access_group which tags ALL deployments sharing 

479 a model_name, this function only tags the specific deployments identified by 

480 their unique model_id. Returns the (model_id, updated model_info) pair of every 

481 deployment actually written. 

482 """ 

483 verbose_proxy_logger.debug("Updating specific deployment model_ids: %s", model_ids) 

484 tagged: Final = [ 

485 await _tag_deployment_with_access_group( 

486 model_id=model_id, 

487 model_info=(await _find_deployment_or_400(model_id=model_id, prisma_client=prisma_client)), 

488 access_group=access_group, 

489 prisma_client=prisma_client, 

490 ) 

491 for model_id in model_ids 

492 ] 

493 return tuple(pair for pair in tagged if pair is not None) 

494 

495 

496async def _find_deployment_or_400(model_id: str, prisma_client: PrismaClient) -> object: 

497 deployment: Final = await _model_table(prisma_client).find_unique(where={"model_id": model_id}) 

498 if deployment is None: 498 ↛ 503line 498 didn't jump to line 503 because the condition on line 498 was always true

499 raise HTTPException( 

500 status_code=400, 

501 detail={"error": f"Deployment with model_id '{model_id}' not found in Database."}, 

502 ) 

503 return deployment.model_info 

504 

505 

506def remove_access_group_from_deployment(model_info: dict[str, Any], access_group: str) -> tuple[dict[str, Any], bool]: 

507 """ 

508 Remove an access group from a deployment's model_info. 

509 

510 Args: 

511 model_info: The model_info dictionary from the deployment 

512 access_group: The access group name to remove 

513 

514 Returns: 

515 Tuple[Dict[str, Any], bool]: (updated_model_info, was_modified) 

516 """ 

517 access_groups: Final = model_info.get("access_groups", []) 

518 

519 # Check if access group exists 

520 if access_group not in access_groups: 

521 return model_info, False 

522 

523 # Remove the access group 

524 access_groups.remove(access_group) 

525 model_info["access_groups"] = access_groups 

526 

527 return model_info, True 

528 

529 

530async def get_all_access_groups_from_db( 

531 prisma_client: PrismaClient, 

532) -> dict[str, AccessGroupInfo]: 

533 """ 

534 Get all access groups from the database. 

535 

536 Returns: 

537 Dict[str, AccessGroupInfo]: Dictionary mapping access_group name to info 

538 """ 

539 # Get all deployments 

540 deployments: Final = await ModelRepository(prisma_client).table.find_many() 

541 

542 # Build access group map 

543 model_names_by_group: Final[dict[str, list[str]]] = {} 

544 

545 for deployment in deployments: 

546 model_info = deployment.model_info or {} 

547 access_groups = model_info.get( # pyright: ignore[reportAttributeAccessIssue] # Json reads back as a dict 

548 "access_groups", [] 

549 ) 

550 model_name = deployment.model_name 

551 

552 for access_group in access_groups: 552 ↛ 553line 552 didn't jump to line 553 because the loop on line 552 never started

553 if access_group not in model_names_by_group: 

554 model_names_by_group[access_group] = [] 

555 

556 model_names_by_group[access_group].append(model_name) 

557 

558 # Convert to AccessGroupInfo objects 

559 return { 

560 access_group: AccessGroupInfo( 

561 access_group=access_group, 

562 model_names=sorted(frozenset(model_names)), 

563 deployment_count=len(model_names), 

564 ) 

565 for access_group, model_names in model_names_by_group.items() 

566 } 

567 

568 

569@router.post( 

570 "/access_group/new", 

571 dependencies=_AUTH_DEPENDENCIES, 

572 response_model=NewModelGroupResponse, 

573) 

574async def create_model_group( 

575 data: NewModelGroupRequest, 

576 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

577): 

578 """ 

579 Create a new access group containing multiple model names. 

580  

581 An access group is a named collection of model groups that can be referenced 

582 by teams/keys for simplified access control. 

583  

584 Example: 

585 ```bash 

586 curl -X POST 'http://localhost:4000/access_group/new' \\ 

587 -H 'Authorization: Bearer sk-1234' \\ 

588 -H 'Content-Type: application/json' \\ 

589 -d '{ 

590 "access_group": "production-models", 

591 "model_names": ["gpt-4", "claude-3-opus", "gemini-pro"] 

592 }' 

593 ``` 

594  

595 Parameters: 

596 - access_group: str - The access group name (e.g., "production-models") 

597 - model_names: List[str] - List of existing model groups to include 

598  

599 Returns: 

600 - NewModelGroupResponse with the created access group details 

601  

602 Raises: 

603 - HTTPException 400: If any model names don't exist 

604 - HTTPException 500: If database operations fail 

605 """ 

606 from litellm.proxy.proxy_server import ( 

607 llm_router, 

608 prisma_client, 

609 ) 

610 

611 verbose_proxy_logger.debug("Creating access group: %s with models: %s", data.access_group, data.model_names) 

612 

613 # Validation: Check if access_group is provided 

614 if not data.access_group or not data.access_group.strip(): 

615 raise HTTPException( 

616 status_code=400, 

617 detail={"error": "access_group is required and cannot be empty"}, 

618 ) 

619 

620 # Validation: Check that at least one of model_names or model_ids is provided 

621 has_model_names: Final = data.model_names and len(data.model_names) > 0 

622 has_model_ids: Final = data.model_ids and len(data.model_ids) > 0 

623 

624 if not has_model_names and not has_model_ids: 

625 raise HTTPException( 

626 status_code=400, 

627 detail={"error": "Either model_names or model_ids must be provided and non-empty"}, 

628 ) 

629 

630 # If model_ids is provided, use it (more precise targeting) 

631 use_model_ids: Final = has_model_ids 

632 

633 # Validate model_names exist in router (only if using model_names path) 

634 if not use_model_ids and has_model_names: 

635 assert data.model_names is not None 

636 missing_models: Final = await _missing_models_after_read_through( 

637 model_names=data.model_names, 

638 llm_router=llm_router, 

639 ) 

640 

641 if missing_models: 641 ↛ 648line 641 didn't jump to line 648 because the condition on line 641 was always true

642 raise HTTPException( 

643 status_code=400, 

644 detail={"error": f"Model(s) not found: {', '.join(missing_models)}"}, 

645 ) 

646 

647 # Check if database is connected 

648 if prisma_client is None: 648 ↛ 649line 648 didn't jump to line 649 because the condition on line 648 was never true

649 raise HTTPException( 

650 status_code=500, 

651 detail={"error": "Database not connected. Cannot create access group."}, 

652 ) 

653 

654 try: 

655 # Check if access group already exists 

656 existing_access_groups: Final = await get_all_access_groups_from_db(prisma_client=prisma_client) 

657 

658 if data.access_group in existing_access_groups: 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

659 raise HTTPException( 

660 status_code=409, 

661 detail={ 

662 "error": f"Access group '{data.access_group}' already exists. Use PUT /access_group/{data.access_group}/update to modify it." 

663 }, 

664 ) 

665 

666 # Update deployments using the appropriate method 

667 if use_model_ids: 667 ↛ 675line 667 didn't jump to line 675 because the condition on line 667 was always true

668 assert data.model_ids is not None 

669 updated_pairs = await update_specific_deployments_with_access_group( 

670 model_ids=data.model_ids, 

671 access_group=data.access_group, 

672 prisma_client=prisma_client, 

673 ) 

674 else: 

675 assert data.model_names is not None 

676 updated_pairs = await update_deployments_with_access_group( 

677 model_names=data.model_names, 

678 access_group=data.access_group, 

679 prisma_client=prisma_client, 

680 ) 

681 models_updated: Final = len(updated_pairs) 

682 

683 live_before_reload: Final = live_model_ids_snapshot() 

684 

685 reload_outcome: Final = await clear_cache() 

686 _raise_http_if_reload_degraded_serving( 

687 before=live_before_reload, 

688 written_models=updated_pairs, 

689 access_group=data.access_group, 

690 still_desired=reload_outcome.still_desired, 

691 live_after=reload_outcome.live_after, 

692 ) 

693 

694 verbose_proxy_logger.info( 

695 "Successfully created access group '%s' with %s models updated", data.access_group, models_updated 

696 ) 

697 

698 return NewModelGroupResponse( 

699 access_group=data.access_group, 

700 model_names=data.model_names, 

701 model_ids=data.model_ids, 

702 models_updated=models_updated, 

703 ) 

704 

705 except HTTPException: 

706 raise 

707 except Exception as e: 

708 verbose_proxy_logger.exception("Error creating access group '%s': %s", data.access_group, e) 

709 raise HTTPException( 

710 status_code=500, 

711 detail={"error": f"Failed to create access group: {e}"}, 

712 ) 

713 

714 

715@router.get( 

716 "/access_group/list", 

717 dependencies=_AUTH_DEPENDENCIES, 

718 response_model=ListAccessGroupsResponse, 

719) 

720async def list_access_groups( 

721 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

722): 

723 """ 

724 List all access groups. 

725  

726 Returns a list of all access groups with their model names, deployment counts, shared budget 

727 and the spend drawn against it. 

728  

729 Example: 

730 ```bash 

731 curl -X GET 'http://localhost:4000/access_group/list' \\ 

732 -H 'Authorization: Bearer sk-1234' 

733 ``` 

734  

735 Returns: 

736 - ListAccessGroupsResponse with all access groups 

737 """ 

738 from litellm.proxy.proxy_server import prisma_client 

739 

740 if prisma_client is None: 740 ↛ 741line 740 didn't jump to line 741 because the condition on line 740 was never true

741 raise HTTPException( 

742 status_code=500, 

743 detail={"error": "Database not connected."}, 

744 ) 

745 

746 try: 

747 access_groups_map: Final = await get_all_access_groups_from_db(prisma_client=prisma_client) 

748 budget_rows: Final = await _model_access_group_budget_rows(prisma_client) 

749 

750 access_groups_list: Final = sorted( 

751 (_with_budget(info, budget_rows.get(info.access_group)) for info in access_groups_map.values()), 

752 key=lambda group: group.access_group, 

753 ) 

754 

755 return ListAccessGroupsResponse(access_groups=access_groups_list) 

756 

757 except Exception as e: 

758 verbose_proxy_logger.exception("Error listing access groups: %s", e) 

759 raise HTTPException( 

760 status_code=500, 

761 detail={"error": f"Failed to list access groups: {e}"}, 

762 ) 

763 

764 

765@router.get( 

766 "/access_group/{access_group}/info", 

767 dependencies=_AUTH_DEPENDENCIES, 

768 response_model=AccessGroupInfo, 

769) 

770async def get_access_group_info( 

771 access_group: str, 

772 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

773): 

774 """ 

775 Get information about a specific access group. 

776  

777 Example: 

778 ```bash 

779 curl -X GET 'http://localhost:4000/access_group/production-models/info' \\ 

780 -H 'Authorization: Bearer sk-1234' 

781 ``` 

782  

783 Parameters: 

784 - access_group: str - The access group name (URL path parameter) 

785  

786 Returns: 

787 - AccessGroupInfo with the access group details, its shared budget and its spend 

788  

789 Raises: 

790 - HTTPException 404: If access group not found 

791 """ 

792 from litellm.proxy.proxy_server import prisma_client 

793 

794 if prisma_client is None: 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true

795 raise HTTPException( 

796 status_code=500, 

797 detail={"error": "Database not connected."}, 

798 ) 

799 

800 try: 

801 access_groups_map: Final = await get_all_access_groups_from_db(prisma_client=prisma_client) 

802 

803 if access_group not in access_groups_map: 803 ↛ 809line 803 didn't jump to line 809 because the condition on line 803 was always true

804 raise HTTPException( 

805 status_code=404, 

806 detail={"error": f"Access group '{access_group}' not found"}, 

807 ) 

808 

809 return _with_budget( 

810 access_groups_map[access_group], 

811 await _model_access_group_budget_row(access_group, prisma_client), 

812 ) 

813 

814 except HTTPException: 

815 raise 

816 except Exception as e: 

817 verbose_proxy_logger.exception("Error getting access group info for '%s': %s", access_group, e) 

818 raise HTTPException( 

819 status_code=500, 

820 detail={"error": f"Failed to get access group info: {e}"}, 

821 ) 

822 

823 

824@router.put( 

825 "/access_group/{access_group}/update", 

826 dependencies=_AUTH_DEPENDENCIES, 

827 response_model=NewModelGroupResponse, 

828) 

829async def update_access_group( 

830 access_group: str, 

831 data: UpdateModelGroupRequest, 

832 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

833): 

834 """ 

835 Update an access group's model names. 

836  

837 This will: 

838 1. Remove the access group from all current deployments 

839 2. Add the access group to all deployments for the new model_names list 

840  

841 Example: 

842 ```bash 

843 curl -X PUT 'http://localhost:4000/access_group/production-models/update' \\ 

844 -H 'Authorization: Bearer sk-1234' \\ 

845 -H 'Content-Type: application/json' \\ 

846 -d '{ 

847 "model_names": ["gpt-4", "claude-3-sonnet"] 

848 }' 

849 ``` 

850  

851 Parameters: 

852 - access_group: str - The access group name (URL path parameter) 

853 - model_names: List[str] - New list of model groups to include 

854  

855 Returns: 

856 - NewModelGroupResponse with the updated access group details 

857  

858 Raises: 

859 - HTTPException 400: If any model names don't exist 

860 - HTTPException 404: If access group not found 

861 """ 

862 from litellm.proxy.proxy_server import llm_router, prisma_client 

863 

864 if prisma_client is None: 864 ↛ 865line 864 didn't jump to line 865 because the condition on line 864 was never true

865 raise HTTPException( 

866 status_code=500, 

867 detail={"error": "Database not connected."}, 

868 ) 

869 

870 verbose_proxy_logger.debug("Updating access group: %s with models: %s", access_group, data.model_names) 

871 

872 # Validation: Check that at least one of model_names or model_ids is provided 

873 has_model_names: Final = data.model_names and len(data.model_names) > 0 

874 has_model_ids: Final = data.model_ids and len(data.model_ids) > 0 

875 

876 if not has_model_names and not has_model_ids: 

877 raise HTTPException( 

878 status_code=400, 

879 detail={"error": "Either model_names or model_ids must be provided and non-empty"}, 

880 ) 

881 

882 use_model_ids: Final = has_model_ids 

883 

884 # Validation: Check if access group exists 

885 try: 

886 access_groups_map: Final = await get_all_access_groups_from_db(prisma_client=prisma_client) 

887 if access_group not in access_groups_map: 887 ↛ 901line 887 didn't jump to line 901 because the condition on line 887 was always true

888 raise HTTPException( 

889 status_code=404, 

890 detail={"error": f"Access group '{access_group}' not found"}, 

891 ) 

892 except HTTPException: 

893 raise 

894 except Exception as e: 

895 raise HTTPException( 

896 status_code=500, 

897 detail={"error": f"Failed to check access group existence: {e}"}, 

898 ) 

899 

900 # Validation: Check if all new models exist (only if using model_names path) 

901 if not use_model_ids and has_model_names: 

902 assert data.model_names is not None 

903 missing_models: Final = await _missing_models_after_read_through( 

904 model_names=data.model_names, 

905 llm_router=llm_router, 

906 ) 

907 

908 if missing_models: 

909 raise HTTPException( 

910 status_code=400, 

911 detail={"error": f"Model(s) not found: {', '.join(missing_models)}"}, 

912 ) 

913 

914 try: 

915 # Step 1: Remove access group from ALL DB deployments (skip config models) 

916 all_deployments: Final = await _model_table(prisma_client).find_many() 

917 

918 stripped: Final = [ 

919 await _strip_access_group_from_deployment( 

920 model_id=deployment.model_id, 

921 model_info=deployment.model_info, 

922 access_group=access_group, 

923 prisma_client=prisma_client, 

924 ) 

925 for deployment in all_deployments 

926 ] 

927 stripped_pairs: Final = tuple(pair for pair in stripped if pair is not None) 

928 

929 # Step 2: Add access group using the appropriate method 

930 if use_model_ids: 

931 assert data.model_ids is not None 

932 updated_pairs = await update_specific_deployments_with_access_group( 

933 model_ids=data.model_ids, 

934 access_group=access_group, 

935 prisma_client=prisma_client, 

936 ) 

937 else: 

938 assert data.model_names is not None 

939 updated_pairs = await update_deployments_with_access_group( 

940 model_names=data.model_names, 

941 access_group=access_group, 

942 prisma_client=prisma_client, 

943 ) 

944 models_updated: Final = len(updated_pairs) 

945 

946 # Clear cache and reload models to pick up the access group changes 

947 live_before_reload: Final = live_model_ids_snapshot() 

948 reload_outcome: Final = await clear_cache() 

949 _raise_http_if_reload_degraded_serving( 

950 before=live_before_reload, 

951 written_models=list({**dict(stripped_pairs), **dict(updated_pairs)}.items()), 

952 access_group=access_group, 

953 still_desired=reload_outcome.still_desired, 

954 live_after=reload_outcome.live_after, 

955 ) 

956 

957 verbose_proxy_logger.info( 

958 "Successfully updated access group '%s' with %s models updated", access_group, models_updated 

959 ) 

960 

961 return NewModelGroupResponse( 

962 access_group=access_group, 

963 model_names=data.model_names, 

964 model_ids=data.model_ids, 

965 models_updated=models_updated, 

966 ) 

967 

968 except HTTPException: 

969 raise 

970 except Exception as e: 

971 verbose_proxy_logger.exception("Error updating access group '%s': %s", access_group, e) 

972 raise HTTPException( 

973 status_code=500, 

974 detail={"error": f"Failed to update access group: {e}"}, 

975 ) 

976 

977 

978@router.delete( 

979 "/access_group/{access_group}/delete", 

980 dependencies=_AUTH_DEPENDENCIES, 

981 response_model=DeleteModelGroupResponse, 

982) 

983async def delete_access_group( 

984 access_group: str, 

985 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

986 auth_cache: Annotated[UserApiKeyCache, Depends(_auth_cache)], 

987): 

988 """ 

989 Delete an access group. 

990  

991 Removes the access group from all deployments that have it. 

992  

993 Example: 

994 ```bash 

995 curl -X DELETE 'http://localhost:4000/access_group/production-models/delete' \\ 

996 -H 'Authorization: Bearer sk-1234' 

997 ``` 

998  

999 Parameters: 

1000 - access_group: str - The access group name (URL path parameter) 

1001  

1002 Returns: 

1003 - DeleteModelGroupResponse with deletion details 

1004  

1005 Raises: 

1006 - HTTPException 404: If access group not found 

1007 """ 

1008 from litellm.proxy.proxy_server import prisma_client 

1009 

1010 if prisma_client is None: 1010 ↛ 1011line 1010 didn't jump to line 1011 because the condition on line 1010 was never true

1011 raise HTTPException( 

1012 status_code=500, 

1013 detail={"error": "Database not connected."}, 

1014 ) 

1015 

1016 verbose_proxy_logger.debug("Deleting access group: %s", access_group) 

1017 

1018 # Validation: Check if access group exists 

1019 try: 

1020 access_groups_map: Final = await get_all_access_groups_from_db(prisma_client=prisma_client) 

1021 if access_group not in access_groups_map: 1021 ↛ 1034line 1021 didn't jump to line 1034 because the condition on line 1021 was always true

1022 raise HTTPException( 

1023 status_code=404, 

1024 detail={"error": f"Access group '{access_group}' not found"}, 

1025 ) 

1026 except HTTPException: 

1027 raise 

1028 except Exception as e: 

1029 raise HTTPException( 

1030 status_code=500, 

1031 detail={"error": f"Failed to check access group existence: {e}"}, 

1032 ) 

1033 

1034 try: 

1035 # Remove access group from all DB deployments (skip config models) 

1036 all_deployments: Final = await _model_table(prisma_client).find_many() 

1037 

1038 removed: Final = [ 

1039 await _strip_access_group_from_deployment( 

1040 model_id=deployment.model_id, 

1041 model_info=deployment.model_info, 

1042 access_group=access_group, 

1043 prisma_client=prisma_client, 

1044 ) 

1045 for deployment in all_deployments 

1046 ] 

1047 removed_pairs: Final = tuple(pair for pair in removed if pair is not None) 

1048 models_updated: Final = len(removed_pairs) 

1049 

1050 # Budget last, deliberately: failing here strands a budget row for a group already on no 

1051 # deployment (clutter), where the reverse order can leave a live group enforcing nothing. 

1052 # The LiteLLM_BudgetTable row it linked is left alone, as /tag/delete leaves a tag's. 

1053 await _delete_model_access_group_budget_row( 

1054 access_group=access_group, prisma_client=prisma_client, auth_cache=auth_cache 

1055 ) 

1056 

1057 # Clear cache and reload models to pick up the access group changes 

1058 live_before_reload: Final = live_model_ids_snapshot() 

1059 reload_outcome: Final = await clear_cache() 

1060 _raise_http_if_reload_degraded_serving( 

1061 before=live_before_reload, 

1062 written_models=removed_pairs, 

1063 access_group=access_group, 

1064 still_desired=reload_outcome.still_desired, 

1065 live_after=reload_outcome.live_after, 

1066 ) 

1067 

1068 verbose_proxy_logger.info( 

1069 "Successfully deleted access group '%s' from %s deployments", access_group, models_updated 

1070 ) 

1071 

1072 return DeleteModelGroupResponse( 

1073 access_group=access_group, 

1074 models_updated=models_updated, 

1075 message=f"Access group '{access_group}' deleted successfully", 

1076 ) 

1077 

1078 except HTTPException: 

1079 raise 

1080 except Exception as e: 

1081 verbose_proxy_logger.exception("Error deleting access group '%s': %s", access_group, e) 

1082 raise HTTPException( 

1083 status_code=500, 

1084 detail={"error": f"Failed to delete access group: {e}"}, 

1085 ) 

1086 

1087 

1088@router.get( 

1089 "/access_group/{access_group}/budget", 

1090 dependencies=_AUTH_DEPENDENCIES, 

1091 response_model=AccessGroupBudgetResponse, 

1092) 

1093async def get_access_group_budget( 

1094 access_group: str, 

1095) -> AccessGroupBudgetResponse: 

1096 """ 

1097 Get the shared budget of an access group, and the spend drawn against it. 

1098 

1099 Example: 

1100 ```bash 

1101 curl -X GET 'http://localhost:4000/access_group/production-models/budget' \\ 

1102 -H 'Authorization: Bearer sk-1234' 

1103 ``` 

1104 

1105 Parameters: 

1106 - access_group: str - The access group name (URL path parameter) 

1107 

1108 Returns: 

1109 - AccessGroupBudgetResponse; budget is null when the group has no budget set 

1110 

1111 Raises: 

1112 - HTTPException 404: If access group not found 

1113 """ 

1114 prisma_client: Final = _prisma_client_or_500() 

1115 await _raise_404_if_model_access_group_missing(access_group=access_group, prisma_client=prisma_client) 

1116 

1117 return _budget_response( 

1118 access_group=access_group, 

1119 row=await _model_access_group_budget_row(access_group, prisma_client), 

1120 ) 

1121 

1122 

1123@router.put( 

1124 "/access_group/{access_group}/budget", 

1125 dependencies=_AUTH_DEPENDENCIES, 

1126 response_model=AccessGroupBudgetResponse, 

1127) 

1128async def set_access_group_budget( 

1129 access_group: str, 

1130 data: AccessGroupBudgetRequest, 

1131 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

1132 auth_cache: Annotated[UserApiKeyCache, Depends(_auth_cache)], 

1133) -> AccessGroupBudgetResponse: 

1134 """ 

1135 Set or replace the shared budget of an access group. Idempotent. 

1136 

1137 Every key that can reach a model in the group draws from this one budget. 

1138 

1139 Example: 

1140 ```bash 

1141 curl -X PUT 'http://localhost:4000/access_group/production-models/budget' \\ 

1142 -H 'Authorization: Bearer sk-1234' \\ 

1143 -H 'Content-Type: application/json' \\ 

1144 -d '{ 

1145 "max_budget": 100.0, 

1146 "budget_duration": "30d" 

1147 }' 

1148 ``` 

1149 

1150 Parameters: 

1151 - access_group: str - The access group name (URL path parameter) 

1152 - max_budget: Optional[float] - Requests fail once the group's shared spend exceeds this 

1153 - soft_budget: Optional[float] - Fires an alert when reached; requests still succeed 

1154 - budget_duration: Optional[str] - Frequency of resetting the group's spend (e.g. '30d') 

1155 - budget_id: Optional[str] - Link an existing budget instead of creating one 

1156 

1157 Returns: 

1158 - AccessGroupBudgetResponse with the stored budget and current spend 

1159 

1160 Raises: 

1161 - HTTPException 400: If no budget field is given, or budget_duration cannot be parsed 

1162 - HTTPException 404: If access group not found 

1163 """ 

1164 from litellm.proxy.proxy_server import litellm_proxy_admin_name 

1165 

1166 prisma_client: Final = _prisma_client_or_500() 

1167 if not data.model_dump(exclude_none=True): 

1168 raise _http_error(400, "One of max_budget, soft_budget, budget_duration or budget_id is required") 

1169 validate_budget_duration(data.budget_duration) 

1170 await _raise_404_if_model_access_group_missing(access_group=access_group, prisma_client=prisma_client) 

1171 

1172 existing_row: Final = await _model_access_group_budget_row(access_group, prisma_client) 

1173 budget_id: Final = await handle_budget_for_entity( 

1174 data=data, 

1175 existing_budget_id=existing_row.budget_id if existing_row is not None else None, 

1176 user_api_key_dict=user_api_key_dict, 

1177 prisma_client=prisma_client, 

1178 litellm_proxy_admin_name=litellm_proxy_admin_name, 

1179 ) 

1180 actor: Final = user_api_key_dict.user_id or litellm_proxy_admin_name 

1181 upsert_data: Final[_ModelAccessGroupBudgetUpsert] = { 

1182 "create": { 

1183 "access_group_name": access_group, 

1184 "budget_id": budget_id, 

1185 "created_by": actor, 

1186 "updated_by": actor, 

1187 }, 

1188 "update": {"budget_id": budget_id, "updated_by": actor}, 

1189 } 

1190 where: Final[_ModelAccessGroupWhere] = {"access_group_name": access_group} 

1191 include: Final[_BudgetInclude] = {"litellm_budget_table": True} 

1192 row: Final = await _model_access_group_budget_table(prisma_client).upsert( 

1193 where=where, data=upsert_data, include=include 

1194 ) 

1195 await _evict_model_access_group_cache_keys(access_group, auth_cache) 

1196 

1197 verbose_proxy_logger.info("Set budget %s on access group '%s'", budget_id, access_group) 

1198 return _budget_response(access_group=access_group, row=row) 

1199 

1200 

1201@router.delete( 

1202 "/access_group/{access_group}/budget", 

1203 dependencies=_AUTH_DEPENDENCIES, 

1204 response_model=DeleteAccessGroupBudgetResponse, 

1205) 

1206async def delete_access_group_budget( 

1207 access_group: str, 

1208 auth_cache: Annotated[UserApiKeyCache, Depends(_auth_cache)], 

1209) -> DeleteAccessGroupBudgetResponse: 

1210 """ 

1211 Clear the shared budget of an access group, leaving the group itself in place. 

1212 

1213 Example: 

1214 ```bash 

1215 curl -X DELETE 'http://localhost:4000/access_group/production-models/budget' \\ 

1216 -H 'Authorization: Bearer sk-1234' 

1217 ``` 

1218 

1219 Parameters: 

1220 - access_group: str - The access group name (URL path parameter) 

1221 

1222 Returns: 

1223 - DeleteAccessGroupBudgetResponse; budget_deleted is false when there was nothing to clear 

1224 

1225 Raises: 

1226 - HTTPException 404: If access group not found 

1227 """ 

1228 prisma_client: Final = _prisma_client_or_500() 

1229 await _raise_404_if_model_access_group_missing(access_group=access_group, prisma_client=prisma_client) 

1230 

1231 budget_deleted: Final = await _delete_model_access_group_budget_row( 

1232 access_group=access_group, 

1233 prisma_client=prisma_client, 

1234 auth_cache=auth_cache, 

1235 ) 

1236 return DeleteAccessGroupBudgetResponse( 

1237 access_group=access_group, 

1238 budget_deleted=budget_deleted, 

1239 message=( 

1240 f"Budget for access group '{access_group}' deleted successfully" 

1241 if budget_deleted 

1242 else f"Access group '{access_group}' has no budget to delete" 

1243 ), 

1244 )