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

1016 statements  

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

1""" 

2Allow proxy admin to add/update/delete models in the db 

3 

4Currently most endpoints are in `proxy_server.py`, but those should be moved here over time. 

5 

6Endpoints here: 

7 

8model/{model_id}/update - PATCH endpoint for model update. 

9""" 

10 

11#### MODEL MANAGEMENT #### 

12 

13import asyncio 

14import datetime 

15import json 

16from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence 

17from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress 

18from dataclasses import dataclass 

19from fnmatch import fnmatchcase 

20from json import JSONDecodeError 

21from types import MappingProxyType 

22from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, TypeAlias, TypeVar, cast, runtime_checkable 

23 

24from fastapi import APIRouter, Depends, Header, HTTPException, Request, status 

25from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, field_validator 

26 

27import litellm 

28from litellm._logging import verbose_proxy_logger 

29from litellm._uuid import uuid 

30from litellm.constants import LITELLM_PROXY_ADMIN_NAME 

31from litellm.litellm_core_utils.credential_accessor import CredentialAccessor 

32from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap 

33from litellm.litellm_core_utils.ptu_pricing import ( 

34 CUSTOM_PRICING_FIELDS, 

35 PTU_EMPTIED_PRICING_FIELDS, 

36 PTU_MODEL_INFO_FIELDS, 

37 PTU_ZEROED_PRICING_FIELDS, 

38 PTU_ZEROED_TABLE_FIELDS, 

39 SEARCH_CONTEXT_SIZES, 

40 ptu_config_error, 

41) 

42from litellm.proxy._types import ( 

43 BlockModelRequest, 

44 CommonProxyErrors, 

45 LiteLLM_ProxyModelTable, 

46 LiteLLM_TeamTable, 

47 LitellmTableNames, 

48 LitellmUserRoles, 

49 ModelInfoDelete, 

50 PrismaCompatibleUpdateDBModel, 

51 ProxyErrorTypes, 

52 ProxyException, 

53 ReconcileOutcome, 

54 TeamModelAddRequest, 

55 TeamModelDeleteRequest, 

56 UserAPIKeyAuth, 

57) 

58from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY 

59from litellm.proxy.auth.team_grants import team_model_aliases 

60from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

61from litellm.proxy.common_utils.config_sync_pubsub import ( 

62 coordination_redis_cache, 

63 publish_config_change, 

64) 

65from litellm.proxy.common_utils.encrypt_decrypt_utils import ( 

66 decrypt_value_helper, 

67 encrypt_value_helper, 

68) 

69from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache 

70from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient 

71from litellm.proxy.management_endpoints.common_utils import _is_user_team_admin 

72from litellm.proxy.management_endpoints.team_endpoints import ( 

73 _refresh_cached_team, 

74 append_team_models, 

75 team_model_add, 

76 team_model_delete, 

77) 

78from litellm.proxy.management_endpoints.team_endpoints import ( 

79 update_team as _legacy_update_team, 

80) 

81from litellm.proxy.management_helpers.access_group_model_sync import ( 

82 sync_access_groups_for_deleted_model, 

83 sync_access_groups_for_renamed_model, 

84) 

85from litellm.proxy.management_helpers.audit_logs import create_object_audit_log 

86from litellm.proxy.management_helpers.auto_router_permissions import ( 

87 MemberAutoRouterWrite, 

88 StoredAutoRouterIdentity, 

89 authorize_member_auto_router_dependencies, 

90 authorize_member_auto_router_team, 

91 authorize_member_auto_router_write, 

92) 

93from litellm.proxy.management_helpers.model_allowlist_rename_sync import sync_model_allowlists_for_renamed_model 

94from litellm.proxy.spend_tracking.ptu_feature_flag import ( 

95 PTU_COST_ATTRIBUTION_ENV_VAR, 

96 is_ptu_cost_attribution_enabled, 

97) 

98from litellm.proxy.utils import PrismaClient, ProxyLogging 

99from litellm.repositories.credentials_repository import CredentialsRepository 

100from litellm.repositories.model_repository import ModelRepository 

101from litellm.repositories.prisma_protocols import TableActions 

102from litellm.repositories.table_repositories import ModelTableRepository 

103from litellm.repositories.team_repository import TeamRepository 

104from litellm.router import Router 

105from litellm.router_strategy.complexity_router import ( 

106 DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, 

107 ClassificationRubric, 

108 ComplexityRouterConfig, 

109 ComplexityTier, 

110 TierDefinition, 

111 built_in_tier_classification_prompt, 

112 classification_system_prompt, 

113 custom_tier_classification_prompt, 

114 normalize_classification_examples, 

115 normalize_classification_prompt, 

116) 

117from litellm.router_utils.auto_router_model_naming import ( 

118 GATED_AUTO_ROUTER_CAPABILITIES, 

119 STRATEGY_ROUTER_PARAM_FIELDS, 

120 capability_limit_violation, 

121 carries_complexity_router_settings, 

122 count_capability_routers, 

123 gated_capability_of, 

124 is_complexity_router_model, 

125 validate_complexity_router_config_placement, 

126 validate_complexity_router_config_write, 

127 validate_strategy_router_model_write, 

128) 

129from litellm.router_utils.auto_router_tuning_baseline import is_mutable_tuned_candidate, tuning_quota_violation 

130from litellm.types.llms.bedrock import AwsSessionTag 

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

132 AutoRouterClassifierDefaultPromptResponse, 

133 UpdateUsefulLinksRequest, 

134) 

135from litellm.types.router import ( 

136 SPECIAL_MODEL_INFO_PARAMS, 

137 Deployment, 

138 GenericLiteLLMParams, 

139 ModelInfo, 

140 updateDeployment, 

141 updateLiteLLMParams, 

142) 

143from litellm.types.utils import ( 

144 COST_MAP_LOOKUP_KEY, 

145 echoed_cost_map_fields, 

146 echoed_cost_map_pricing_fields, 

147 without_server_derived_pricing, 

148) 

149from litellm.utils import get_utc_datetime 

150 

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

152 from prisma import models as prisma_models 

153 from prisma import types as prisma_types 

154 

155router: Final = APIRouter() 

156CLEARABLE_LITELLM_PARAMS: Final = frozenset({"cache_control_injection_points", "litellm_credential_name"}) 

157NULL_CLEARABLE_LITELLM_PARAMS: Final = frozenset((*SPECIAL_MODEL_INFO_PARAMS, *CLEARABLE_LITELLM_PARAMS)) 

158 

159 

160async def update_team(*args, **kwargs): 

161 """ 

162 Backward-compatible shim for tests/legacy call sites that patch this symbol. 

163 Team model management now uses team_model_add/team_model_delete directly. 

164 """ 

165 return await _legacy_update_team(*args, **kwargs) 

166 

167 

168class UpdatePublicModelGroupsRequest(BaseModel): 

169 """Request model for updating public model groups""" 

170 

171 model_groups: list[str] = Field(description="List of model group names to make public") 

172 

173 model_config = ConfigDict(extra="forbid") 

174 

175 

176class _ProxyModelRow(Protocol): 

177 @property 

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

179 

180 @property 

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

182 

183 @property 

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

185 

186 def model_dump_json(self, *, exclude_none: bool = False) -> str: ... 186 ↛ exitline 186 didn't return from function 'model_dump_json' because

187 

188 

189class _ProxyModelTable(Protocol): 

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

191 

192 def find_many(self, *, where: Mapping[str, object]) -> Awaitable[Sequence[_ProxyModelRow]]: ... 192 ↛ exitline 192 didn't return from function 'find_many' because

193 

194 def create(self, *, data: Mapping[str, object]) -> Awaitable[_ProxyModelRow]: ... 194 ↛ exitline 194 didn't return from function 'create' because

195 

196 def update( 196 ↛ exitline 196 didn't return from function 'update' because

197 self, *, where: Mapping[str, object], data: Mapping[str, object] 

198 ) -> Awaitable[_ProxyModelRow | None]: ... 

199 

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

201 

202 def delete_many(self, *, where: Mapping[str, object]) -> Awaitable[int]: ... 202 ↛ exitline 202 didn't return from function 'delete_many' because

203 

204 

205class _TxModelTables(Protocol): 

206 litellm_proxymodeltable: _ProxyModelTable 

207 

208 async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]: ... 208 ↛ exitline 208 didn't return from function 'query_raw' because

209 

210 

211@runtime_checkable 

212class _TransactionFactory(Protocol): 

213 def __call__(self, *, timeout: datetime.timedelta = ...) -> AbstractAsyncContextManager[_TxModelTables]: ... 213 ↛ exitline 213 didn't return from function '__call__' because

214 

215 

216class _ModelTransactionClient(BaseModel): 

217 model_config = ConfigDict(arbitrary_types_allowed=True, from_attributes=True) 

218 

219 tx: _TransactionFactory 

220 

221 

222@dataclass(frozen=True, slots=True) 

223class _TransactionClient: 

224 db: _TxModelTables 

225 

226 

227_RowT = TypeVar("_RowT") 

228 

229 

230class _ExistingModelRow(Protocol): 

231 @property 

232 def litellm_params(self) -> Mapping[str, object]: ... 232 ↛ exitline 232 didn't return from function 'litellm_params' because

233 

234 def model_dump_json(self, *, exclude_none: bool = False) -> str: ... 234 ↛ exitline 234 didn't return from function 'model_dump_json' because

235 

236 

237class _TeamRow(Protocol): 

238 @property 

239 def models(self) -> Sequence[str]: ... 239 ↛ exitline 239 didn't return from function 'models' because

240 

241 def model_dump(self) -> Mapping[str, object]: ... 241 ↛ exitline 241 didn't return from function 'model_dump' because

242 

243 

244class _TeamLookupTable(Protocol): 

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

246 

247 

248class _TeamTable(_TeamLookupTable, Protocol): 

249 def update( 249 ↛ exitline 249 didn't return from function 'update' because

250 self, *, where: Mapping[str, object], data: Mapping[str, object], include: Mapping[str, bool] 

251 ) -> Awaitable[LiteLLM_TeamTable]: ... 

252 

253 

254def _proxy_model_table(prisma_client: PrismaClient) -> _ProxyModelTable: 

255 return ModelRepository(prisma_client).table 

256 

257 

258def _repo_team_table(prisma_client: PrismaClient) -> _TeamLookupTable: 

259 return TeamRepository(WriterPinnedClient(prisma_client.db)).table 

260 

261 

262def _db_team_table(prisma_client: PrismaClient) -> _TeamTable: 

263 return prisma_client.db.litellm_teamtable 

264 

265 

266def _model_alias_table(prisma_client: PrismaClient) -> "TableActions[prisma_models.LiteLLM_ModelTable]": 

267 return ModelTableRepository(prisma_client).table 

268 

269 

270async def get_db_model(model_id: str, prisma_client: PrismaClient) -> Deployment | None: 

271 db_model: Final = await _proxy_model_table(prisma_client).find_unique(where={"model_id": model_id}) 

272 

273 if not db_model: 273 ↛ 276line 273 didn't jump to line 276 because the condition on line 273 was always true

274 return None 

275 

276 deployment_pydantic_obj: Final = Deployment(**db_model.model_dump(exclude_none=True)) 

277 return deployment_pydantic_obj 

278 

279 

280def _strategy_router_write_violation( 

281 incoming_params: GenericLiteLLMParams | None, 

282 existing_params: GenericLiteLLMParams | None, 

283) -> str | None: 

284 """Reject writes that would corrupt a strategy router's pseudo-model. 

285 

286 An auto-router deployment's ``litellm_params.model`` (``auto_router/...``) is 

287 the discriminator the router loads it by; a write that mangles it makes the 

288 router drop the deployment silently under ``ignore_invalid_deployments``. 

289 A patch adding auto-router settings is judged against the effective model, 

290 decrypting the stored model when the patch omits it, so a regular deployment 

291 cannot claim a strategy-router configuration. Unrelated partial patches and 

292 restores that do not touch strategy-router settings stay legal. A config is 

293 judged only when the write carries one, for the same reason: a rename must 

294 not be held hostage by a stored config it does not touch. Returns the 

295 violation, or None. 

296 """ 

297 if incoming_params is None: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 return None 

299 config_violation: Final = validate_complexity_router_config_write( 

300 complexity_router_config=( 

301 _effective_complexity_router_config(incoming_params, existing_params) 

302 if incoming_params.complexity_router_config is not None 

303 else None 

304 ) 

305 ) 

306 if config_violation is not None: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true

307 return config_violation 

308 present_fields: Final = frozenset( 

309 field 

310 for field in STRATEGY_ROUTER_PARAM_FIELDS 

311 for source in (incoming_params, existing_params) 

312 if source is not None and getattr(source, field, None) is not None 

313 ) 

314 effective_params: Final = _effective_complexity_router_params(incoming_params, existing_params) 

315 effective_model: Final = effective_params.get("model") 

316 if carries_complexity_router_settings( 

317 effective_model if isinstance(effective_model, str) else None, present_fields 

318 ): 

319 placement_violation: Final = validate_complexity_router_config_placement(incoming_params.model_extra) 

320 if placement_violation is not None: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 return placement_violation 

322 return validate_strategy_router_model_write( 

323 model=effective_model if isinstance(effective_model, str) else "", 

324 present_fields=present_fields, 

325 ) 

326 

327 

328def _raise_on_strategy_router_write_violation( 

329 incoming_params: GenericLiteLLMParams | None, 

330 existing_params: GenericLiteLLMParams | None, 

331) -> None: 

332 violation = _strategy_router_write_violation(incoming_params=incoming_params, existing_params=existing_params) 

333 if violation is None: 

334 return 

335 raise ProxyException( 

336 message=violation, 

337 type=ProxyErrorTypes.validation_error.value, 

338 code=status.HTTP_400_BAD_REQUEST, 

339 param="litellm_params.model", 

340 ) 

341 

342 

343def _stored_credential_name(existing_litellm_params: GenericLiteLLMParams | None) -> str | None: 

344 if existing_litellm_params is None or existing_litellm_params.litellm_credential_name is None: 

345 return None 

346 return decrypt_value_helper( 

347 value=existing_litellm_params.litellm_credential_name, 

348 key="litellm_credential_name", 

349 exception_type="debug", 

350 return_original_value=True, 

351 ) 

352 

353 

354async def _raise_on_invalid_credential_name( 

355 litellm_params: updateLiteLLMParams | None, 

356 existing_litellm_params: GenericLiteLLMParams | None, 

357 prisma_client: PrismaClient, 

358) -> None: 

359 if litellm_params is None or "litellm_credential_name" not in litellm_params.model_fields_set: 

360 return 

361 credential_name: Final = litellm_params.litellm_credential_name 

362 if credential_name is None: 

363 return 

364 if credential_name == "": 

365 raise ProxyException( 

366 message="litellm_credential_name cannot be an empty string. Send null to detach the stored credential or omit the field to leave it unchanged.", 

367 type=ProxyErrorTypes.validation_error.value, 

368 code=status.HTTP_400_BAD_REQUEST, 

369 param="litellm_credential_name", 

370 ) 

371 if credential_name == _stored_credential_name(existing_litellm_params): 

372 return 

373 if CredentialAccessor.find_credential(credential_name) is not None: 

374 return 

375 stored_credential: Final = await CredentialsRepository(WriterPinnedClient(prisma_client.db)).find_by_name( 

376 credential_name 

377 ) 

378 if stored_credential is not None: 

379 return 

380 raise ProxyException( 

381 message=f"Credential '{credential_name}' not found. Create it via /credentials before attaching it to a model.", 

382 type=ProxyErrorTypes.validation_error.value, 

383 code=status.HTTP_400_BAD_REQUEST, 

384 param="litellm_credential_name", 

385 ) 

386 

387 

388AUTO_ROUTER_CAPABILITY_SLOT_LOCK_KEY: Final = 5_872_301 

389_CAPABILITY_LOCK_SQL: Final = "SELECT 1 AS locked FROM pg_advisory_xact_lock($1)" 

390_STORED_LITELLM_PARAMS_SQL: Final = ( 

391 "(CASE jsonb_typeof(litellm_params) WHEN 'string' THEN (litellm_params #>> '{}')::jsonb ELSE litellm_params END)" 

392) 

393_STORED_COMPLEXITY_CONFIG_SQL: Final = f"{_STORED_LITELLM_PARAMS_SQL} -> 'complexity_router_config'" 

394_CAPABILITY_DB_ROWS_SQL: Final[Mapping[str, str]] = MappingProxyType( 

395 { 

396 capability.key: f""" 

397SELECT {_STORED_LITELLM_PARAMS_SQL} ->> 'model' AS model 

398FROM "LiteLLM_ProxyModelTable" 

399WHERE model_id <> $1 

400 AND ({capability.sql_config_predicate.format(config=_STORED_COMPLEXITY_CONFIG_SQL)}) 

401""" 

402 for capability in GATED_AUTO_ROUTER_CAPABILITIES 

403 } 

404) 

405 

406 

407def _effective_complexity_router_config( 

408 incoming_params: GenericLiteLLMParams | None, existing_params: GenericLiteLLMParams | None 

409) -> object: 

410 incoming: Final = None if incoming_params is None else incoming_params.complexity_router_config 

411 existing: Final = None if existing_params is None else existing_params.complexity_router_config 

412 if incoming is None: 

413 return existing 

414 if existing is None or incoming.get("classifier_type") != "jev" or existing.get("classifier_type") != "jev": 414 ↛ 416line 414 didn't jump to line 416 because the condition on line 414 was always true

415 return incoming 

416 incoming_jev: Final[object] = incoming.get("jev_classifier_config") 

417 existing_jev: Final[object] = existing.get("jev_classifier_config") 

418 if not isinstance(incoming_jev, Mapping) or not isinstance(existing_jev, Mapping): 

419 return incoming 

420 supplied: Final = TypeAdapter(dict[str, object]).validate_python(incoming_jev) 

421 stored: Final = TypeAdapter(dict[str, object]).validate_python(existing_jev) 

422 same_base: Final = "api_base" not in supplied or supplied["api_base"] == stored.get("api_base") 

423 transport: Final = MappingProxyType( 

424 { 

425 key: value 

426 for key, value in stored.items() 

427 if key in ("api_key", "api_base") and (key != "api_key" or same_base) 

428 } 

429 ) 

430 return { # mutable-ok: persisted JSON requires concrete nested dicts 

431 **incoming, 

432 "jev_classifier_config": { # mutable-ok: json.dumps cannot serialize MappingProxyType 

433 **transport, 

434 **supplied, 

435 }, 

436 } 

437 

438 

439def _effective_model( 

440 incoming_params: GenericLiteLLMParams | None, existing_params: GenericLiteLLMParams | None 

441) -> str | None: 

442 """The model a write leaves on the row, decrypting an existing value only when the patch omits it.""" 

443 incoming: Final = None if incoming_params is None else incoming_params.model 

444 if incoming is not None: 444 ↛ 446line 444 didn't jump to line 446 because the condition on line 444 was always true

445 return incoming 

446 existing: Final = None if existing_params is None else existing_params.model 

447 if existing is None: 

448 return None 

449 decrypted: Final = decrypt_value_helper( 

450 value=existing, 

451 key="model", 

452 exception_type="debug", 

453 return_original_value=True, 

454 ) 

455 return decrypted if isinstance(decrypted, str) else None 

456 

457 

458def _effective_complexity_router_params( 

459 incoming_params: GenericLiteLLMParams | None, existing_params: GenericLiteLLMParams | None 

460) -> Mapping[str, object]: 

461 """The model and complexity config a write leaves, for placement and capability decisions.""" 

462 return MappingProxyType( 

463 { 

464 "model": _effective_model(incoming_params, existing_params), 

465 "complexity_router_config": _effective_complexity_router_config(incoming_params, existing_params), 

466 } 

467 ) 

468 

469 

470def _member_auto_router_marker_for_update( 

471 *, 

472 incoming_params: updateLiteLLMParams | None, 

473 existing: Deployment, 

474 member_write: MemberAutoRouterWrite | None, 

475) -> bool | None: 

476 if member_write is not None: 476 ↛ 477line 476 didn't jump to line 477 because the condition on line 476 was never true

477 return True 

478 if not existing.model_info.member_auto_router: 478 ↛ 480line 478 didn't jump to line 480 because the condition on line 478 was always true

479 return None 

480 if incoming_params is None: 

481 return True 

482 if any(getattr(incoming_params, field, None) is not None for field in STRATEGY_ROUTER_PARAM_FIELDS): 

483 return False 

484 if incoming_params.model is not None and incoming_params.model != _effective_model(None, existing.litellm_params): 

485 return False 

486 return True 

487 

488 

489def _decrypted_model(stored_model: object) -> str | None: 

490 if not isinstance(stored_model, str): 

491 return None 

492 decrypted: Final = decrypt_value_helper( 

493 value=stored_model, key="model", exception_type="debug", return_original_value=True 

494 ) 

495 return decrypted if isinstance(decrypted, str) else None 

496 

497 

498def _tuning_candidate(effective_params: Mapping[str, object], model_id: str | None) -> Mapping[str, object]: 

499 return MappingProxyType( 

500 { 

501 "litellm_params": effective_params, 

502 "model_info": MappingProxyType({"id": model_id, "db_model": True}), 

503 } 

504 ) 

505 

506 

507def _raise_on_tuning_quota_violation( 

508 *, 

509 candidate: Mapping[str, object], 

510 others: Sequence[Mapping[str, object]], 

511 baselines: Mapping[str, str], 

512 limit: int | None, 

513) -> None: 

514 violation: Final = tuning_quota_violation(candidate=candidate, others=others, baselines=baselines, limit=limit) 

515 if violation is not None: 

516 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{violation} {AUTO_ROUTER_LICENSE_REMEDY}") 

517 

518 

519@asynccontextmanager 

520async def _auto_router_capability_slot( 

521 prisma_client: PrismaClient, 

522 *, 

523 effective_params: Mapping[str, object], 

524 model_id: str | None, 

525 member_write: MemberAutoRouterWrite | None = None, 

526) -> AsyncGenerator[_ProxyModelTable, None]: 

527 """Hand out the model table to write through while the row's claim on a licensed capability is settled. 

528 

529 A write that leaves the row claiming a licensed capability under a limited license runs 

530 inside one transaction that takes an advisory lock in its own statement before counting 

531 (a statement's snapshot predates anything it locks), so pods cannot both pass the count: 

532 the DB rows (any pod, either JSON shape) plus this proxy's config.yaml routers are judged 

533 against the license limit and the write is refused with a 403 before it happens. The row 

534 being edited keeps its own slot through ``model_id``. Member writes also recheck their 

535 authorization under this lock. Team model bookkeeping needs a second connection and 

536 must wait until the transaction has committed and the lock is released. The transaction 

537 writes bypass the repository's publish-on-write, so the config change is published once 

538 after commit, the way delete_team_models does. 

539 

540 A heuristic-v1 router whose tuning has moved off its recorded baseline is judged the same 

541 way under the same lock, against the DB rows plus this proxy's config.yaml routers. 

542 """ 

543 from litellm.proxy.proxy_server import ( 

544 _license_check, # pyright: ignore[reportPrivateUsage] # existing capability slot reads the proxy license singleton 

545 heuristic_v1_tuning_baselines, 

546 llm_router, 

547 premium_user, 

548 ) 

549 

550 limit: Final = _license_check.auto_router_capability_limit() 

551 capability: Final = gated_capability_of(effective_params) 

552 baselines: Final = heuristic_v1_tuning_baselines 

553 tuning_candidate: Final = _tuning_candidate(effective_params, model_id=model_id) 

554 judges_tuning: Final = baselines is not None and is_mutable_tuned_candidate(tuning_candidate, baselines) 

555 if member_write is None and (limit is None or (capability is None and not judges_tuning)): 555 ↛ 558line 555 didn't jump to line 558 because the condition on line 555 was always true

556 yield _proxy_model_table(prisma_client) 

557 return 

558 transaction_client: Final = _ModelTransactionClient.model_validate(prisma_client.db) 

559 transaction: Final = ( 

560 transaction_client.tx(timeout=datetime.timedelta(seconds=30)) 

561 if member_write is not None 

562 else transaction_client.tx() 

563 ) 

564 async with transaction as tx_ctx: 

565 tables: Final[_TxModelTables] = tx_ctx 

566 await tx_ctx.query_raw(_CAPABILITY_LOCK_SQL, AUTO_ROUTER_CAPABILITY_SLOT_LOCK_KEY) 

567 config_rows: Final = () if llm_router is None else tuple(llm_router.config_deployments()) 

568 if member_write is not None: 

569 if member_write.model_id is not None: 

570 await tx_ctx.query_raw( 

571 'SELECT model_id FROM "LiteLLM_ProxyModelTable" WHERE model_id = $1 FOR UPDATE', 

572 member_write.model_id, 

573 ) 

574 pinned_client: Final = _TransactionClient(tx_ctx) 

575 team_where: Final[prisma_types.LiteLLM_TeamTableWhereUniqueInput] = {"team_id": member_write.team_id} 

576 team_include: Final[prisma_types.LiteLLM_TeamTableInclude] = {"litellm_model_table": True} 

577 team_row: Final = await TeamRepository(pinned_client).table.find_unique( 

578 where=team_where, include=team_include 

579 ) 

580 if team_row is None or llm_router is None: 

581 raise HTTPException(status_code=403, detail="The auto router's team or model catalog is unavailable.") 

582 team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) 

583 authorize_member_auto_router_team( 

584 user_api_key_dict=member_write.actor, team=team, premium_user=premium_user 

585 ) 

586 if member_write.model_id is not None: 

587 model_where: Final[prisma_types.LiteLLM_ProxyModelTableWhereInput] = {"model_id": member_write.model_id} 

588 current_row: Final = await tables.litellm_proxymodeltable.find_unique(where=model_where) 

589 current_identity: Final = ( 

590 StoredAutoRouterIdentity.model_validate(current_row.model_dump()) 

591 if current_row is not None 

592 else None 

593 ) 

594 current_model: Final = ( 

595 Deployment.model_validate(current_row.model_dump()) if current_row is not None else None 

596 ) 

597 if ( 

598 current_identity is None 

599 or current_identity.created_by != member_write.actor.user_id 

600 or current_model is None 

601 or current_model.model_info.team_id != member_write.team_id 

602 ): 

603 raise HTTPException(status_code=403, detail="Team members can update only their own auto routers.") 

604 if current_identity.updated_at != member_write.updated_at: 

605 raise HTTPException(status_code=409, detail="This auto router changed. Reload it before updating.") 

606 else: 

607 all_models: Final[prisma_types.LiteLLM_ProxyModelTableWhereInput] = {} 

608 rows_for_names: Final = await tables.litellm_proxymodeltable.find_many(where=all_models) 

609 stored_names: Final = tuple( 

610 ( 

611 row.model_name, 

612 model_info_as_mapping(row.model_info), 

613 ) 

614 for row in rows_for_names 

615 ) 

616 config_names: Final = tuple( 

617 (str(row.get("model_name", "")), model_info_as_mapping(row.get("model_info"))) 

618 for row in config_rows 

619 ) 

620 team_aliases: Final = team_model_aliases(team) 

621 aliases: Final = ( 

622 *(llm_router.model_group_alias or ()), 

623 *(litellm.model_alias_map or ()), 

624 *(team_aliases or ()), 

625 ) 

626 if member_write.public_name in aliases or any( 

627 fnmatchcase( 

628 member_write.public_name, 

629 str(info.get("team_public_model_name") or name) 

630 if info is not None and info.get("team_id") == member_write.team_id 

631 else name, 

632 ) 

633 for name, info in (*stored_names, *config_names) 

634 if info is None or info.get("team_id") in (None, member_write.team_id) 

635 ): 

636 raise HTTPException(status_code=409, detail="This auto-router name is already used by a model.") 

637 await authorize_member_auto_router_dependencies( 

638 config=member_write.config, 

639 default_model=member_write.default_model, 

640 user_api_key_dict=member_write.actor, 

641 team=team, 

642 prisma_client=pinned_client, 

643 llm_router=llm_router, 

644 ) 

645 if capability is not None: 

646 rows: Sequence[Mapping[str, object]] = await tx_ctx.query_raw( 

647 _CAPABILITY_DB_ROWS_SQL[capability.key], model_id or "" 

648 ) 

649 db_held: Final = sum(1 for row in rows if is_complexity_router_model(_decrypted_model(row.get("model")))) 

650 held: Final = db_held + count_capability_routers(config_rows, capability=capability) 

651 violation: Final = capability_limit_violation(capability=capability, held=held + 1, limit=limit) 

652 if violation is not None: 

653 raise HTTPException( 

654 status_code=status.HTTP_403_FORBIDDEN, detail=f"{violation} {AUTO_ROUTER_LICENSE_REMEDY}" 

655 ) 

656 if judges_tuning and baselines is not None: 

657 model_rows: Final = await ModelRepository(_TransactionClient(tx_ctx)).find_all_except(model_id or "") 

658 _raise_on_tuning_quota_violation( 

659 candidate=tuning_candidate, 

660 others=tuple( 

661 MappingProxyType( 

662 { 

663 "litellm_params": row.litellm_params, 

664 "model_info": MappingProxyType({"id": row.model_id, "db_model": True}), 

665 } 

666 ) 

667 for row in model_rows 

668 ) 

669 + config_rows, 

670 baselines=baselines, 

671 limit=limit, 

672 ) 

673 yield tables.litellm_proxymodeltable 

674 await publish_config_change(redis_cache=coordination_redis_cache(), object_type="litellm_proxymodeltable") 

675 

676 

677ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING: Final = "enforce_rpm_tpm_on_model_add" 

678 

679 

680def _raise_if_rate_limits_required_but_missing(*, litellm_params: GenericLiteLLMParams, enforced: bool) -> None: 

681 """Require both rpm and tpm (each a positive value) when the operator opts in via config.yaml. 

682 

683 Off by default, so deployments keep adding models without limits. When 

684 ``enforce_rpm_tpm_on_model_add: true`` is set under general_settings, a model added 

685 without both rpm and tpm set to a positive value is rejected rather than stored 

686 unbounded (or effectively excluded from routing by a zero/negative limit). 

687 """ 

688 if not enforced: 688 ↛ 690line 688 didn't jump to line 690 because the condition on line 688 was always true

689 return 

690 missing: Final = tuple( 

691 field 

692 for field, value in (("rpm", litellm_params.rpm), ("tpm", litellm_params.tpm)) 

693 if value is None or value <= 0 

694 ) 

695 if not missing: 

696 return 

697 raise ProxyException( 

698 message=( 

699 f"{' and '.join(missing)} must be set to a positive value when " 

700 f"'{ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING}' is enabled in general_settings" 

701 ), 

702 type=ProxyErrorTypes.validation_error.value, 

703 code=status.HTTP_400_BAD_REQUEST, 

704 param=f"litellm_params.{missing[0]}", 

705 ) 

706 

707 

708_PTU_PRICED_PAIR: Final = frozenset({"ptu_count", "cost_per_ptu_per_hour"}) 

709 

710 

711def _explicitly_cleared_ptu_fields(model_info: ModelInfo | None) -> frozenset[str]: 

712 """The PTU fields a patch sends as an explicit null, which update_db_model drops. 

713 

714 Empty while the feature is off, so disabling pauses PTU rather than letting a client 

715 that round-trips a model_info blob erase a configuration set up during an earlier opt-in. 

716 """ 

717 if model_info is None or not is_ptu_cost_attribution_enabled(): 

718 return frozenset() 

719 return frozenset( 

720 field 

721 for field in PTU_MODEL_INFO_FIELDS 

722 if field in model_info.model_fields_set and getattr(model_info, field) is None 

723 ) 

724 

725 

726def _merged_ptu_model_info(*, db_model: Deployment, patch_data: updateDeployment) -> Mapping[str, object]: 

727 """The model_info a patch would store, which is the stored blob updated by the patch. 

728 

729 A PTU invariant holds over the deployment as it will exist, not over whichever subset 

730 of fields a caller happened to send. 

731 """ 

732 stored: Final = db_model.model_info.model_dump(exclude_none=True) if db_model.model_info else _EMPTY_MODEL_INFO 

733 incoming: Final = ( 

734 patch_data.model_info.model_dump(exclude_none=True) if patch_data.model_info else _EMPTY_MODEL_INFO 

735 ) 

736 cleared: Final = _explicitly_cleared_ptu_fields(patch_data.model_info) 

737 return MappingProxyType({k: v for k, v in {**stored, **incoming}.items() if k not in cleared}) 

738 

739 

740def _raise_if_ptu_cost_attribution_disabled(incoming_model_info: Mapping[str, object]) -> None: 

741 """Reject PTU model_info fields unless the operator opted into PTU cost attribution. 

742 

743 Takes the incoming request's model_info rather than the merged deployment, so an 

744 unrelated patch of a model that still stores PTU config from an earlier opt-in is 

745 left alone. The fields are rejected rather than dropped so a caller never believes 

746 a flat cost was configured while the rollup that would price it is not running. 

747 

748 Only a value is rejected. An explicit null reaches the clear loop, which is gated on 

749 the same flag, so a disabled proxy neither writes PTU config nor erases what an 

750 earlier opt-in stored. Disabling pauses the feature rather than discarding its setup. 

751 """ 

752 if is_ptu_cost_attribution_enabled(): 752 ↛ 753line 752 didn't jump to line 753 because the condition on line 752 was never true

753 return 

754 supplied: Final = tuple(field for field in PTU_MODEL_INFO_FIELDS if incoming_model_info.get(field) is not None) 

755 if not supplied: 

756 return 

757 raise HTTPException( 

758 status_code=400, 

759 detail=( 

760 f"PTU cost attribution is disabled, so {', '.join(supplied)} cannot be set. " 

761 f"Set {PTU_COST_ATTRIBUTION_ENV_VAR}=true to enable it." 

762 ), 

763 ) 

764 

765 

766def _validate_ptu_model_info(model_info: Mapping[str, object]) -> None: 

767 """Enforce the PTU cross-field invariant on the effective model_info. 

768 

769 The rules live in litellm_core_utils.ptu_pricing so that config.yaml registration 

770 refuses the same deployments this endpoint does, for the same reason. Per-field bounds 

771 (positive count, non-negative rate) are enforced by ModelInfo itself. 

772 

773 Registration additionally requires an operator-declared ``model_info.id``, which this 

774 endpoint does not: a stored deployment already holds a stable primary key, where a 

775 config-declared one is otherwise keyed by a hash of its own parameters. 

776 """ 

777 error: Final = ptu_config_error(model_info) 

778 if error is not None: 778 ↛ 779line 778 didn't jump to line 779 because the condition on line 778 was never true

779 raise HTTPException(status_code=400, detail=error) 

780 

781 

782# The mirrored per-token pricing fields plus the remaining rates the public cost map or a 

783# provider default would otherwise supply (the cache back-fills, the Maps grounding rate). An 

784# unset field falls back to those sources, so a field left out here is one a PTU deployment 

785# still bills. 

786# tiered_pricing is the one mirrored field that is a table of ranges, not a rate, so it is stored 

787# empty (see _PTU_EMPTIED_PRICING_FIELDS): its tiers outrank the zeros written beside them, so 

788# dropping it would leave the cost map's tiers billing the traffic the reserved capacity covers. 

789_PTU_ZEROED_PRICING_FIELDS: Final = PTU_ZEROED_PRICING_FIELDS 

790_PTU_EMPTIED_PRICING_FIELDS: Final = PTU_EMPTIED_PRICING_FIELDS 

791_PTU_ZEROED_PRICING: Final[Mapping[str, float | tuple[()]]] = MappingProxyType( 

792 { 

793 **dict.fromkeys(_PTU_ZEROED_PRICING_FIELDS, 0.0), 

794 **dict.fromkeys(_PTU_EMPTIED_PRICING_FIELDS, ()), 

795 } 

796) 

797_NO_PRICING_OVERRIDE: Final[Mapping[str, float | tuple[()]]] = MappingProxyType({}) 

798_EMPTY_MODEL_INFO: Final[Mapping[str, object]] = _NO_PRICING_OVERRIDE 

799# Rate fields only. CustomPricingLiteLLMParams also carries settings that are not charges 

800# (an embedding's output_vector_size, the regional uplift multipliers), and zeroing one of 

801# those would destroy the deployment's configuration rather than stop a charge. 

802_CUSTOM_PRICING_FIELDS: Final = CUSTOM_PRICING_FIELDS 

803# search_context_cost_per_query holds its rates in a table keyed by context size, and an absent 

804# table means the provider's own default rate rather than free (litellm/llms/gemini/cost_calculator 

805# falls back to $0.035), so it is zeroed in place rather than emptied like tiered_pricing, and 

806# written on every PTU deployment rather than only where a table is already stored. 

807_PTU_ZEROED_TABLE_FIELDS: Final = PTU_ZEROED_TABLE_FIELDS 

808_SEARCH_CONTEXT_SIZES: Final = SEARCH_CONTEXT_SIZES 

809 

810 

811def _is_nonzero_rate(value: object) -> bool: 

812 return isinstance(value, (int, float)) and not isinstance(value, bool) and value != 0 

813 

814 

815def _is_nonzero_price(value: object) -> bool: 

816 if isinstance(value, dict): # an all-zero table is how a rate is expressed as free 

817 return any(_is_nonzero_rate(rate) for rate in value.values()) 

818 return _is_nonzero_rate(value) 

819 

820 

821def _is_zero_price(value: object) -> bool: 

822 if isinstance(value, dict): 

823 return bool(value) and not _is_nonzero_price(value) 

824 if isinstance(value, (list, tuple)): 

825 return not value 

826 return isinstance(value, (int, float)) and not isinstance(value, bool) and value == 0 

827 

828 

829def _raise_if_ptu_deployment_is_priced(*, model_info: Mapping[str, object], supplied: Mapping[str, object]) -> None: 

830 """Refuse a rate the caller supplies for a deployment that bills reserved capacity. 

831 

832 Separate from the zeroing so the team-model path can run it before it touches the team, whose 

833 ACL write autocommits: a refusal raised after it would leave the team changed and the 

834 deployment row never written. 

835 """ 

836 if not is_ptu_cost_attribution_enabled(): 

837 return 

838 if model_info.get("ptu_count") is None or model_info.get("cost_per_ptu_per_hour") is None: 

839 return 

840 priced: Final = tuple( 

841 sorted( 

842 tuple(field for field in _CUSTOM_PRICING_FIELDS if _is_nonzero_price(supplied.get(field))) 

843 + tuple(field for field in _PTU_EMPTIED_PRICING_FIELDS if supplied.get(field)) 

844 ) 

845 ) 

846 if not priced: 

847 return 

848 raise HTTPException( 

849 status_code=400, 

850 detail=( 

851 f"A PTU deployment bills by reserved capacity, so {', '.join(priced)} cannot be charged on " 

852 "top of it. Send 0 or no value, or remove ptu_count and cost_per_ptu_per_hour to bill per token." 

853 ), 

854 ) 

855 

856 

857def _ptu_zeroed_pricing( 

858 *, 

859 model_info: Mapping[str, object], 

860 litellm_params: Mapping[str, object], 

861 supplied: Mapping[str, object], 

862) -> Mapping[str, float | tuple[()] | Mapping[str, float]]: 

863 """The pricing a PTU deployment must carry, empty unless one is being stored. 

864 

865 Reserved capacity is already billed by the flat cost the rollup writes, so charging the 

866 traffic it serves bills the same tokens twice. Left unset the rate falls back to the public 

867 cost map, which makes the double charge the default rather than an opt-in. 

868 

869 Only a price the caller supplies is refused. A non-zero price already on the row is zeroed 

870 instead, so a deployment priced through a path this rule does not cover heals on its next 

871 save rather than rejecting every later edit of a field that has nothing to do with pricing. 

872 

873 ``supplied`` is the caller's litellm_params alone, because that is the blob a price is 

874 authored on. model_info's copy is written by the server, both by the mirror in 

875 ``Deployment.__init__`` and by the cost-map defaults /model/info fills in, so a client that 

876 round-trips a model_info blob sends back prices it never chose. 

877 """ 

878 if not is_ptu_cost_attribution_enabled(): 878 ↛ 880line 878 didn't jump to line 880 because the condition on line 878 was always true

879 return _NO_PRICING_OVERRIDE 

880 if model_info.get("ptu_count") is None or model_info.get("cost_per_ptu_per_hour") is None: 

881 return _NO_PRICING_OVERRIDE 

882 _raise_if_ptu_deployment_is_priced(model_info=model_info, supplied=supplied) 

883 stored: Final = frozenset( 

884 field 

885 for field in _CUSTOM_PRICING_FIELDS 

886 if _is_nonzero_price(model_info.get(field)) or _is_nonzero_price(litellm_params.get(field)) 

887 ) 

888 return MappingProxyType( 

889 { 

890 **_PTU_ZEROED_PRICING, 

891 **dict.fromkeys(_PTU_ZEROED_TABLE_FIELDS, dict.fromkeys(_SEARCH_CONTEXT_SIZES, 0.0)), 

892 **dict.fromkeys(stored - _PTU_ZEROED_TABLE_FIELDS, 0.0), 

893 } 

894 ) 

895 

896 

897def _ptu_pricing_delta( 

898 *, 

899 stored_model_info: Mapping[str, object], 

900 model_info: Mapping[str, object], 

901 litellm_params: Mapping[str, object], 

902 patch: updateDeployment, 

903) -> tuple[Mapping[str, float | tuple[()] | Mapping[str, float]], frozenset[str]]: 

904 """The pricing a patch must write into both blobs, and the pricing it must drop from them. 

905 

906 A patch that takes the deployment off PTU takes the zeroed pricing with it, since the zeros 

907 exist only to stop the double charge. Left behind they would serve the deployment for free. 

908 Reading the stored row rather than the patch alone keeps that release off a deployment that 

909 never carried PTU config, whose zero price is a rate its operator chose. A zero the patch 

910 itself carries is released with the rest, because the dashboard echoes the whole stored 

911 blob on every save, so a supplied zero cannot be told apart from the one this rule wrote. 

912 

913 The release spans every field the zeroing could have written, not just the mirrored ones, or 

914 a rate zeroed on the way in (per-second, per-character tiers) would bill nothing forever. 

915 """ 

916 supplied: Final = patch.litellm_params.model_dump(exclude_none=True) if patch.litellm_params else _EMPTY_MODEL_INFO 

917 zeroed: Final = _ptu_zeroed_pricing(model_info=model_info, litellm_params=litellm_params, supplied=supplied) 

918 if zeroed: 

919 return zeroed, frozenset() 

920 was_ptu: Final = any(stored_model_info.get(field) is not None for field in _PTU_PRICED_PAIR) 

921 if not was_ptu or not _explicitly_cleared_ptu_fields(patch.model_info) & _PTU_PRICED_PAIR: 

922 return _NO_PRICING_OVERRIDE, frozenset() 

923 return _NO_PRICING_OVERRIDE, frozenset( 

924 field 

925 for field in _CUSTOM_PRICING_FIELDS.union(_PTU_ZEROED_PRICING_FIELDS, _PTU_EMPTIED_PRICING_FIELDS) 

926 if _is_zero_price(model_info.get(field)) or _is_zero_price(litellm_params.get(field)) 

927 ) 

928 

929 

930def _ptu_priced_deployment(model_params: Deployment) -> Deployment: 

931 """``model_params`` with PTU pricing applied, or itself when it configures no PTU.""" 

932 model_info: Final = model_params.model_info.model_dump(exclude_none=True) 

933 litellm_params: Final = model_params.litellm_params.model_dump(exclude_none=True) 

934 override: Final = _ptu_zeroed_pricing(model_info=model_info, litellm_params=litellm_params, supplied=litellm_params) 

935 if not override: 935 ↛ 939line 935 didn't jump to line 939 because the condition on line 935 was always true

936 return model_params 

937 # model_copy validates nothing, so the emptied tier table has to arrive as the list the field 

938 # declares or Pydantic warns on every later dump of it 

939 stored: Final = MappingProxyType( 

940 {key: [] if isinstance(value, tuple) else value for key, value in override.items()} 

941 ) 

942 return model_params.model_copy( 

943 update=MappingProxyType( 

944 { 

945 "litellm_params": model_params.litellm_params.model_copy(update=stored), 

946 "model_info": model_params.model_info.model_copy(update=stored), 

947 } 

948 ) 

949 ) 

950 

951 

952def _cost_map_entry(db_model: Deployment, incoming_model_info: Mapping[str, object]) -> Mapping[str, object]: 

953 base_model: Final = incoming_model_info.get("base_model") 

954 lookup: Final = base_model if isinstance(base_model, str) else _decrypted_model(db_model.litellm_params.model) 

955 if lookup is None: 

956 return MappingProxyType({}) 

957 with suppress(Exception): 

958 return MappingProxyType(dict(litellm.get_model_info(model=lookup))) 

959 return MappingProxyType({}) 

960 

961 

962LoadedCatalog: TypeAlias = Callable[[], Mapping[str, Mapping[str, object]]] # mutable-ok: Callable parameter syntax 

963 

964 

965def _loaded_catalog_entry( 

966 incoming_model_info: Mapping[str, object], loaded_catalog: LoadedCatalog 

967) -> Mapping[str, object]: 

968 catalog_key: Final = incoming_model_info.get(COST_MAP_LOOKUP_KEY) 

969 if not isinstance(catalog_key, str): 

970 return MappingProxyType({}) 

971 return loaded_catalog().get(catalog_key, MappingProxyType({})) 

972 

973 

974def update_db_model( 

975 db_model: Deployment, 

976 updated_patch: updateDeployment, 

977 loaded_catalog: LoadedCatalog = GetModelCostMap.loaded_model_cost_map, 

978) -> PrismaCompatibleUpdateDBModel: 

979 if updated_patch.model_info is not None: 

980 _raise_if_ptu_cost_attribution_disabled(updated_patch.model_info.model_dump(exclude_none=True)) 

981 merged_model_name: Final = updated_patch.model_name or db_model.model_name 

982 merged_litellm_params: Final = db_model.litellm_params.model_dump(exclude_none=True) 

983 stored_model_info: Final = db_model.model_info.model_dump(exclude_none=True) 

984 echoed_pricing: Final = echoed_cost_map_pricing_fields(stored_model_info) 

985 merged_model_info: Final[dict[str, object]] = { 

986 k: v for k, v in stored_model_info.items() if k not in echoed_pricing 

987 } 

988 

989 # update litellm params 

990 if updated_patch.litellm_params: 

991 # Encrypt any sensitive values 

992 encrypted_params: Final = { 

993 k: ( 

994 _effective_complexity_router_config(updated_patch.litellm_params, db_model.litellm_params) 

995 if k == "complexity_router_config" 

996 else encrypt_value_helper(v) 

997 ) 

998 for k, v in updated_patch.litellm_params.model_dump(exclude_none=True).items() 

999 } 

1000 

1001 merged_litellm_params.update(encrypted_params) 

1002 

1003 # update model info 

1004 if updated_patch.model_info: 

1005 incoming_model_info: Final = updated_patch.model_info.model_dump(exclude_none=True) 

1006 echoed_fields: Final = echoed_cost_map_fields( 

1007 incoming_model_info, 

1008 _cost_map_entry(db_model, incoming_model_info), 

1009 _loaded_catalog_entry(incoming_model_info, loaded_catalog), 

1010 ) 

1011 merged_model_info.update( 

1012 MappingProxyType( 

1013 dict( 

1014 (k, v) 

1015 for k, v in without_server_derived_pricing(incoming_model_info).items() 

1016 if k not in echoed_fields 

1017 ) 

1018 ) 

1019 ) 

1020 for k in echoed_fields: 

1021 if k in merged_model_info and merged_model_info[k] != incoming_model_info[k]: 

1022 del merged_model_info[k] 

1023 

1024 # Honor explicit-null clears LAST, after both merges, so a model_info blob a client 

1025 # passes through cannot silently undo a litellm_params clear via .update(). 

1026 # 

1027 # Restricted to SPECIAL_MODEL_INFO_PARAMS (input/output cost per token/character 

1028 # and cache read/write costs) so this path cannot be used to null out privileged 

1029 # model_info fields like team_id or access groups. SPECIAL_MODEL_INFO_PARAMS are 

1030 # mirrored between litellm_params and model_info by Deployment.__init__, so the 

1031 # clear propagates to both blobs. 

1032 if updated_patch.litellm_params: 

1033 for field in updated_patch.litellm_params.model_fields_set: 

1034 if getattr(updated_patch.litellm_params, field) is None and field in NULL_CLEARABLE_LITELLM_PARAMS: 

1035 merged_litellm_params.pop(field, None) 

1036 merged_model_info.pop(field, None) 

1037 elif ( 

1038 field 

1039 in ( 

1040 "auto_router_routing_compression", 

1041 "auto_router_model_compression", 

1042 ) 

1043 and getattr(updated_patch.litellm_params, field) is None 

1044 ): 

1045 merged_litellm_params.pop(field, None) 

1046 if updated_patch.model_info: 

1047 for field in updated_patch.model_info.model_fields_set: 

1048 if field in SPECIAL_MODEL_INFO_PARAMS and getattr(updated_patch.model_info, field) is None: 

1049 merged_model_info.pop(field, None) 

1050 merged_litellm_params.pop(field, None) 

1051 for field in _explicitly_cleared_ptu_fields(updated_patch.model_info): 

1052 merged_model_info.pop(field, None) 

1053 

1054 _validate_ptu_model_info(merged_model_info) 

1055 ptu_pricing, ptu_released = _ptu_pricing_delta( 

1056 stored_model_info=db_model.model_info.model_dump(exclude_none=True) 

1057 if db_model.model_info 

1058 else _EMPTY_MODEL_INFO, 

1059 model_info=merged_model_info, 

1060 litellm_params=merged_litellm_params, 

1061 patch=updated_patch, 

1062 ) 

1063 merged_model_info.update(ptu_pricing) 

1064 merged_litellm_params.update(ptu_pricing) 

1065 for field in ptu_released: 

1066 merged_model_info.pop(field, None) 

1067 merged_litellm_params.pop(field, None) 

1068 

1069 # convert to prisma compatible format 

1070 

1071 for key, value in merged_model_info.items(): 

1072 if isinstance(value, datetime.datetime): 

1073 merged_model_info[key] = value.isoformat() 

1074 

1075 prisma_compatible_model_dict: Final = PrismaCompatibleUpdateDBModel( 

1076 model_name=merged_model_name, 

1077 litellm_params=json.dumps(merged_litellm_params), 

1078 model_info=json.dumps(merged_model_info), 

1079 ) 

1080 

1081 if updated_patch.blocked is not None: 

1082 prisma_compatible_model_dict["blocked"] = updated_patch.blocked 

1083 

1084 return prisma_compatible_model_dict 

1085 

1086 

1087@router.patch( 

1088 "/model/{model_id}/update", 

1089 tags=["model management"], 

1090 dependencies=[Depends(user_api_key_auth)], 

1091) 

1092async def patch_model( 

1093 model_id: str, # Get model_id from path parameter 

1094 patch_data: updateDeployment, # Create a specific schema for PATCH operations 

1095 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1096): 

1097 """ 

1098 PATCH Endpoint for partial model updates. 

1099 

1100 Only updates the fields specified in the request while preserving other existing values. 

1101 Follows proper PATCH semantics by only modifying provided fields. 

1102 

1103 Args: 

1104 model_id: The ID of the model to update 

1105 patch_data: The fields to update and their new values 

1106 user_api_key_dict: User authentication information 

1107 

1108 Returns: 

1109 Updated model information 

1110 

1111 Raises: 

1112 ProxyException: For various error conditions including authentication and database errors 

1113 """ 

1114 from litellm.proxy.proxy_server import ( 

1115 litellm_proxy_admin_name, 

1116 llm_router, 

1117 premium_user, 

1118 prisma_client, 

1119 store_model_in_db, 

1120 user_api_key_cache, 

1121 ) 

1122 

1123 try: 

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

1125 raise HTTPException( 

1126 status_code=500, 

1127 detail={"error": CommonProxyErrors.db_not_connected_error.value}, 

1128 ) 

1129 

1130 # Verify model exists and is stored in DB 

1131 if not store_model_in_db: 1131 ↛ 1132line 1131 didn't jump to line 1132 because the condition on line 1131 was never true

1132 raise ProxyException( 

1133 message="Model updates only supported for DB-stored models", 

1134 type=ProxyErrorTypes.validation_error.value, 

1135 code=status.HTTP_400_BAD_REQUEST, 

1136 param=None, 

1137 ) 

1138 

1139 # Fetch existing model 

1140 db_model: Final = await get_db_model(model_id=model_id, prisma_client=prisma_client) 

1141 

1142 if db_model is None: 1142 ↛ 1158line 1142 didn't jump to line 1158 because the condition on line 1142 was always true

1143 # Check if model exists in config but not DB 

1144 if llm_router and llm_router.get_deployment(model_id=model_id) is not None: 1144 ↛ 1145line 1144 didn't jump to line 1145 because the condition on line 1144 was never true

1145 raise ProxyException( 

1146 message="Cannot edit config-based model. Store model in DB via /model/new first.", 

1147 type=ProxyErrorTypes.validation_error.value, 

1148 code=status.HTTP_400_BAD_REQUEST, 

1149 param=None, 

1150 ) 

1151 raise ProxyException( 

1152 message=f"Model {model_id} not found on proxy.", 

1153 type=ProxyErrorTypes.not_found_error, 

1154 code=status.HTTP_404_NOT_FOUND, 

1155 param=None, 

1156 ) 

1157 

1158 write_authorization: Final = await ModelManagementAuthChecks.can_user_make_model_call( 

1159 model_params=db_model, 

1160 user_api_key_dict=user_api_key_dict, 

1161 prisma_client=prisma_client, 

1162 premium_user=premium_user, 

1163 member_operation="update", 

1164 incoming_model_params=patch_data, 

1165 ) 

1166 member_write: Final = write_authorization if isinstance(write_authorization, MemberAutoRouterWrite) else None 

1167 member_marker: Final = _member_auto_router_marker_for_update( 

1168 incoming_params=patch_data.litellm_params, existing=db_model, member_write=member_write 

1169 ) 

1170 marker_info: Final = ( 

1171 ModelInfo(id=db_model.model_info.id) 

1172 if member_write is not None 

1173 else patch_data.model_info or ModelInfo(id=db_model.model_info.id) 

1174 ) 

1175 effective_info: Final = ( 

1176 marker_info.model_copy(update=MappingProxyType({"member_auto_router": member_marker})) 

1177 if member_marker is not None 

1178 else patch_data.model_info 

1179 ) 

1180 effective_patch: Final = ( 

1181 patch_data.model_copy( 

1182 update=MappingProxyType( 

1183 { 

1184 "model_name": None if member_write is not None else patch_data.model_name, 

1185 "model_info": effective_info, 

1186 } 

1187 ) 

1188 ) 

1189 if member_marker is not None 

1190 else patch_data 

1191 ) 

1192 

1193 # Pause/resume (`blocked`) is a proxy-admin-only privilege. Team admins 

1194 # passed the auth check above for team-scoped models, but they must not 

1195 # be able to unblock (or block) a model their proxy admin has paused. 

1196 if patch_data.blocked is not None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 

1197 raise ProxyException( 

1198 message="Only proxy admins can change a model's blocked flag.", 

1199 type=ProxyErrorTypes.auth_error.value, 

1200 code=status.HTTP_403_FORBIDDEN, 

1201 param="blocked", 

1202 ) 

1203 

1204 ModelManagementAuthChecks.can_user_attach_credential( 

1205 litellm_params=patch_data.litellm_params, 

1206 user_api_key_dict=user_api_key_dict, 

1207 existing_litellm_params=db_model.litellm_params, 

1208 null_detaches=True, 

1209 ) 

1210 await _raise_on_invalid_credential_name(patch_data.litellm_params, db_model.litellm_params, prisma_client) 

1211 

1212 ModelManagementAuthChecks.can_user_set_aws_session_tags( 

1213 litellm_params=patch_data.litellm_params, 

1214 user_api_key_dict=user_api_key_dict, 

1215 existing_litellm_params=db_model.litellm_params, 

1216 ) 

1217 

1218 _raise_on_strategy_router_write_violation( 

1219 incoming_params=patch_data.litellm_params, 

1220 existing_params=db_model.litellm_params, 

1221 ) 

1222 

1223 effective_params: Final = _effective_complexity_router_params( 

1224 patch_data.litellm_params, db_model.litellm_params 

1225 ) 

1226 requested_model_name: Final = patch_data.model_name 

1227 stored_model_name: str | None = None 

1228 

1229 async def write_row(update_data: PrismaCompatibleUpdateDBModel) -> _ProxyModelRow | None: 

1230 nonlocal stored_model_name 

1231 stored_model_name = update_data.get("model_name") 

1232 update_data["updated_by"] = user_api_key_dict.user_id or litellm_proxy_admin_name 

1233 update_data["updated_at"] = cast(str, get_utc_datetime()) 

1234 async with _auto_router_capability_slot( 

1235 prisma_client, 

1236 effective_params=effective_params, 

1237 model_id=model_id, 

1238 member_write=member_write, 

1239 ) as table: 

1240 return await table.update(where={"model_id": model_id}, data=update_data) 

1241 

1242 # Handle team model updates with proper alias management 

1243 updated_model: Final = await _update_team_model_in_db( 

1244 db_model=db_model, 

1245 patch_data=effective_patch, 

1246 user_api_key_dict=user_api_key_dict, 

1247 prisma_client=prisma_client, 

1248 write_row=write_row, 

1249 ) 

1250 

1251 if updated_model is None: 

1252 raise ProxyException( 

1253 message=f"Model {model_id} not found on proxy.", 

1254 type=ProxyErrorTypes.not_found_error, 

1255 code=status.HTTP_404_NOT_FOUND, 

1256 param=None, 

1257 ) 

1258 

1259 if ( 

1260 stored_model_name is not None 

1261 and stored_model_name == requested_model_name 

1262 and stored_model_name != db_model.model_name 

1263 ): 

1264 await sync_access_groups_for_renamed_model( 

1265 prisma_client=prisma_client, 

1266 model_id=model_id, 

1267 old_name=db_model.model_name, 

1268 new_name=stored_model_name, 

1269 llm_router=llm_router, 

1270 ) 

1271 await sync_model_allowlists_for_renamed_model( 

1272 prisma_client=prisma_client, 

1273 model_id=model_id, 

1274 old_name=db_model.model_name, 

1275 new_name=stored_model_name, 

1276 llm_router=llm_router, 

1277 user_api_key_cache=user_api_key_cache, 

1278 ) 

1279 

1280 # Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates) 

1281 live_before_reload: Final = live_model_ids_snapshot() 

1282 reload_outcome: Final = await clear_cache() 

1283 

1284 ## CREATE AUDIT LOG ## 

1285 asyncio.create_task( 

1286 create_object_audit_log( 

1287 object_id=model_id, 

1288 action="updated", 

1289 user_api_key_dict=user_api_key_dict, 

1290 table_name=LitellmTableNames.PROXY_MODEL_TABLE_NAME, 

1291 before_value=db_model.model_dump_json(exclude_none=True), 

1292 after_value=updated_model.model_dump_json(exclude_none=True), 

1293 litellm_changed_by=user_api_key_dict.user_id, 

1294 litellm_proxy_admin_name=LITELLM_PROXY_ADMIN_NAME, 

1295 ) 

1296 ) 

1297 

1298 raise_if_reload_degraded_serving( 

1299 before=live_before_reload, 

1300 written_models=[(model_id, getattr(updated_model, "model_info", None))], 

1301 action="update", 

1302 still_desired=reload_outcome.still_desired, 

1303 live_after=reload_outcome.live_after, 

1304 ) 

1305 

1306 return updated_model 

1307 

1308 except Exception as e: 

1309 verbose_proxy_logger.exception("Error in patch_model: %s", e) 

1310 

1311 if isinstance(e, (HTTPException, ProxyException)): 1311 ↛ 1314line 1311 didn't jump to line 1314 because the condition on line 1311 was always true

1312 raise e 

1313 

1314 raise ProxyException( 

1315 message=f"Error updating model: {e}", 

1316 type=ProxyErrorTypes.internal_server_error, 

1317 code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1318 param=None, 

1319 ) 

1320 

1321 

1322async def _set_model_blocked_status( 

1323 data: BlockModelRequest, 

1324 user_api_key_dict: UserAPIKeyAuth, 

1325 blocked: bool, 

1326 action: Literal["blocked", "unblocked"], 

1327 litellm_changed_by: str | None, 

1328) -> LiteLLM_ProxyModelTable | None: 

1329 from litellm.proxy.proxy_server import ( 

1330 litellm_proxy_admin_name, 

1331 llm_router, 

1332 prisma_client, 

1333 store_model_in_db, 

1334 ) 

1335 

1336 try: 

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

1338 raise HTTPException( 

1339 status_code=500, 

1340 detail={"error": CommonProxyErrors.db_not_connected_error.value}, 

1341 ) 

1342 

1343 if store_model_in_db is not True: 1343 ↛ 1344line 1343 didn't jump to line 1344 because the condition on line 1343 was never true

1344 raise ProxyException( 

1345 message="Model updates only supported for DB-stored models", 

1346 type=ProxyErrorTypes.validation_error.value, 

1347 code=status.HTTP_400_BAD_REQUEST, 

1348 param=None, 

1349 ) 

1350 

1351 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 1351 ↛ 1352line 1351 didn't jump to line 1352 because the condition on line 1351 was never true

1352 raise ProxyException( 

1353 message="Only proxy admins can change a model's blocked flag.", 

1354 type=ProxyErrorTypes.auth_error.value, 

1355 code=status.HTTP_403_FORBIDDEN, 

1356 param="blocked", 

1357 ) 

1358 

1359 db_model: Final = await get_db_model( 

1360 model_id=data.model_id, 

1361 prisma_client=prisma_client, 

1362 ) 

1363 

1364 if db_model is None: 1364 ↛ 1379line 1364 didn't jump to line 1379 because the condition on line 1364 was always true

1365 if llm_router and llm_router.get_deployment(model_id=data.model_id) is not None: 1365 ↛ 1366line 1365 didn't jump to line 1366 because the condition on line 1365 was never true

1366 raise ProxyException( 

1367 message="Cannot edit config-based model. Store model in DB via /model/new first.", 

1368 type=ProxyErrorTypes.validation_error.value, 

1369 code=status.HTTP_400_BAD_REQUEST, 

1370 param=None, 

1371 ) 

1372 raise ProxyException( 

1373 message=f"Model {data.model_id} not found on proxy.", 

1374 type=ProxyErrorTypes.not_found_error, 

1375 code=status.HTTP_404_NOT_FOUND, 

1376 param=None, 

1377 ) 

1378 

1379 updated_model: Final = await ModelRepository(prisma_client).table.update( 

1380 where={"model_id": data.model_id}, 

1381 data={ 

1382 "blocked": blocked, 

1383 "updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name, 

1384 "updated_at": cast(str, get_utc_datetime()), 

1385 }, 

1386 ) 

1387 

1388 live_before_reload: Final = live_model_ids_snapshot() 

1389 reload_outcome: Final = await clear_cache() 

1390 

1391 asyncio.create_task( 

1392 create_object_audit_log( 

1393 object_id=data.model_id, 

1394 action=action, 

1395 user_api_key_dict=user_api_key_dict, 

1396 table_name=LitellmTableNames.PROXY_MODEL_TABLE_NAME, 

1397 before_value=db_model.model_dump_json(exclude_none=True), 

1398 after_value=( 

1399 updated_model.model_dump_json(exclude_none=True) if isinstance(updated_model, BaseModel) else None 

1400 ), 

1401 litellm_changed_by=litellm_changed_by, 

1402 litellm_proxy_admin_name=litellm_proxy_admin_name, 

1403 ) 

1404 ) 

1405 

1406 raise_if_reload_degraded_serving( 

1407 before=live_before_reload, 

1408 written_models=[(data.model_id, getattr(updated_model, "model_info", None))], 

1409 action=action, 

1410 still_desired=reload_outcome.still_desired, 

1411 live_after=reload_outcome.live_after, 

1412 ) 

1413 

1414 return updated_model # pyright: ignore[reportReturnType] # prisma row, coerced by this route's response_model 

1415 

1416 except Exception as e: 

1417 verbose_proxy_logger.exception("Error in model %s: %s", action, e) 

1418 

1419 if isinstance(e, (HTTPException, ProxyException)): 

1420 raise e 

1421 

1422 raise ProxyException( 

1423 message=f"Error updating model blocked status: {e}", 

1424 type=ProxyErrorTypes.internal_server_error, 

1425 code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1426 param=None, 

1427 ) 

1428 

1429 

1430@router.post( 

1431 "/model/block", 

1432 tags=["model management"], 

1433 dependencies=[Depends(user_api_key_auth)], 

1434) 

1435async def block_model( 

1436 data: BlockModelRequest, 

1437 http_request: Request, 

1438 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1439 litellm_changed_by: str | None = Header( 

1440 None, 

1441 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

1442 ), 

1443) -> LiteLLM_ProxyModelTable | None: 

1444 """ 

1445 Block a DB-stored model deployment from serving requests. 

1446 

1447 Parameters: 

1448 - model_id: str - The model deployment id to block. 

1449 """ 

1450 return await _set_model_blocked_status( 

1451 data=data, 

1452 user_api_key_dict=user_api_key_dict, 

1453 blocked=True, 

1454 action="blocked", 

1455 litellm_changed_by=litellm_changed_by, 

1456 ) 

1457 

1458 

1459@router.post( 

1460 "/model/unblock", 

1461 tags=["model management"], 

1462 dependencies=[Depends(user_api_key_auth)], 

1463) 

1464async def unblock_model( 

1465 data: BlockModelRequest, 

1466 http_request: Request, 

1467 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1468 litellm_changed_by: str | None = Header( 

1469 None, 

1470 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

1471 ), 

1472) -> LiteLLM_ProxyModelTable | None: 

1473 """ 

1474 Unblock a DB-stored model deployment so it can serve requests again. 

1475 

1476 Parameters: 

1477 - model_id: str - The model deployment id to unblock. 

1478 """ 

1479 return await _set_model_blocked_status( 

1480 data=data, 

1481 user_api_key_dict=user_api_key_dict, 

1482 blocked=False, 

1483 action="unblocked", 

1484 litellm_changed_by=litellm_changed_by, 

1485 ) 

1486 

1487 

1488################################# Helper Functions ################################# 

1489#################################################################################### 

1490#################################################################################### 

1491#################################################################################### 

1492 

1493 

1494async def _add_model_to_db( 

1495 model_params: Deployment, 

1496 user_api_key_dict: UserAPIKeyAuth, 

1497 prisma_client: PrismaClient, 

1498 new_encryption_key: str | None = None, 

1499 should_create_model_in_db: bool = True, 

1500 slot: AbstractAsyncContextManager[_ProxyModelTable] | None = None, 

1501) -> "_ProxyModelRow | LiteLLM_ProxyModelTable": 

1502 # encrypt litellm params # 

1503 _litellm_params_dict: Final = model_params.litellm_params.dict(exclude_none=True) 

1504 _original_litellm_model_name: Final = model_params.litellm_params.model 

1505 for k, v in _litellm_params_dict.items(): 

1506 encrypted_value = encrypt_value_helper(value=v, new_encryption_key=new_encryption_key) 

1507 model_params.litellm_params[k] = encrypted_value 

1508 _data: Final[dict] = { 

1509 "model_id": model_params.model_info.id, 

1510 "model_name": model_params.model_name, 

1511 "litellm_params": model_params.litellm_params.model_dump_json(exclude_none=True), 

1512 "model_info": model_params.model_info.model_dump_json(exclude_none=True), 

1513 "created_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME, 

1514 "updated_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME, 

1515 } 

1516 if model_params.model_info.id is not None: 1516 ↛ 1518line 1516 didn't jump to line 1518 because the condition on line 1516 was always true

1517 _data["model_id"] = model_params.model_info.id 

1518 _create_data: Final = cast("Mapping[str, object]", _data) # cast-ok: str-keyed json payload built just above 

1519 if not should_create_model_in_db: 1519 ↛ 1520line 1519 didn't jump to line 1520 because the condition on line 1519 was never true

1520 return LiteLLM_ProxyModelTable(**_data) 

1521 if slot is None: 1521 ↛ 1522line 1521 didn't jump to line 1522 because the condition on line 1521 was never true

1522 return await _proxy_model_table(prisma_client).create(data=_create_data) 

1523 async with slot as table: 

1524 return await table.create(data=_create_data) 

1525 

1526 

1527async def _add_team_model_to_db( 

1528 model_params: Deployment, 

1529 user_api_key_dict: UserAPIKeyAuth, 

1530 prisma_client: PrismaClient, 

1531 slot: AbstractAsyncContextManager[_ProxyModelTable] | None = None, 

1532) -> "_ProxyModelRow | LiteLLM_ProxyModelTable | None": 

1533 """ 

1534 If 'team_id' is provided, 

1535 

1536 - generate a unique 'model_name' for the model (e.g. 'model_name_{team_id}_{uuid}) 

1537 - store the model in the db with the unique 'model_name' 

1538 - add the public model name to the team's allowed models list 

1539 """ 

1540 from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache 

1541 

1542 _team_id: Final = model_params.model_info.team_id 

1543 if _team_id is None: 

1544 return None 

1545 

1546 # Capture the original public name FIRST, before any mutations 

1547 original_model_name: Final = model_params.model_name 

1548 

1549 # Set team_public_model_name in model_info using the captured original_model_name 

1550 # This must happen BEFORE mutating model_params.model_name so _add_model_to_db 

1551 # serializes the correct team_public_model_name (not the internal UUID name) 

1552 if original_model_name: 

1553 model_params.model_info.team_public_model_name = original_model_name 

1554 

1555 # Generate and assign unique internal model_name LAST 

1556 # (after team_public_model_name is safely stored) 

1557 unique_model_name: Final = f"model_name_{_team_id}_{uuid.uuid4()}" 

1558 model_params.model_name = unique_model_name 

1559 

1560 ## CREATE MODEL IN DB ## 

1561 model_response: Final = await _add_model_to_db( 

1562 model_params=model_params, 

1563 user_api_key_dict=user_api_key_dict, 

1564 prisma_client=prisma_client, 

1565 slot=slot, 

1566 ) 

1567 

1568 if original_model_name: 

1569 await append_team_models( 

1570 data=TeamModelAddRequest( 

1571 team_id=_team_id, 

1572 models=[original_model_name], 

1573 ), 

1574 prisma_client=prisma_client, 

1575 user_api_key_cache=user_api_key_cache, 

1576 proxy_logging_obj=proxy_logging_obj, 

1577 ) 

1578 

1579 return model_response 

1580 

1581 

1582async def _update_team_model_in_db( 

1583 db_model: Deployment, 

1584 patch_data: updateDeployment, 

1585 user_api_key_dict: UserAPIKeyAuth, 

1586 prisma_client: PrismaClient, 

1587 write_row: Callable[[PrismaCompatibleUpdateDBModel], Awaitable[_RowT]], 

1588) -> _RowT: 

1589 """ 

1590 Handle team model updates with proper alias management. 

1591 

1592 If patch_data contains a team_id: 

1593 - Creates unique internal model_name and team alias 

1594 - Adds model to team object 

1595 - Preserves team_public_model_name for external reference 

1596 

1597 The row is written through ``write_row`` before the team's model list is touched, so a 

1598 refused or failed write leaves the team as it was (the create path orders itself the same way). 

1599 """ 

1600 # Validate team_id if present in patch_data 

1601 from litellm.proxy.proxy_server import premium_user 

1602 

1603 await ModelManagementAuthChecks.allow_team_model_action( 

1604 model_params=patch_data, 

1605 user_api_key_dict=user_api_key_dict, 

1606 prisma_client=prisma_client, 

1607 premium_user=premium_user, 

1608 ) 

1609 

1610 # Validated before the row write, beside the premium check the create path already runs here. 

1611 # 

1612 # The merged view is what gets stored, so that is what has to satisfy the invariants. 

1613 # Validating the patch alone rejected a partial edit of an already valid deployment: 

1614 # raising the rate on a configured model carries no ptu_effective_from, which the 

1615 # stored row supplies. 

1616 if patch_data.model_info is not None: 

1617 _raise_if_ptu_cost_attribution_disabled(patch_data.model_info.model_dump(exclude_none=True)) 

1618 _validate_ptu_model_info(_merged_ptu_model_info(db_model=db_model, patch_data=patch_data)) 

1619 _raise_if_ptu_deployment_is_priced( 

1620 model_info=_merged_ptu_model_info(db_model=db_model, patch_data=patch_data), 

1621 supplied=( 

1622 patch_data.litellm_params.model_dump(exclude_none=True) if patch_data.litellm_params else _EMPTY_MODEL_INFO 

1623 ), 

1624 ) 

1625 

1626 patch_team_id: Final = patch_data.model_info.team_id if patch_data.model_info else None 

1627 

1628 # No team_id in patch, proceed with standard update 

1629 if patch_team_id is None: 

1630 return await write_row(update_db_model(db_model=db_model, updated_patch=patch_data)) 

1631 

1632 # Determine public model name 

1633 public_model_name: Final = _get_public_model_name( 

1634 patch_data=patch_data, 

1635 db_model=db_model, 

1636 ) 

1637 

1638 # Ensure model_info exists and set team_public_model_name 

1639 if patch_data.model_info is None: 

1640 from litellm.types.router import ModelInfo 

1641 

1642 patch_data.model_info = ModelInfo() 

1643 patch_data.model_info.team_public_model_name = public_model_name 

1644 

1645 # Check if team assignment is new or changed 

1646 db_team_id: Final = db_model.model_info.team_id if db_model.model_info else None 

1647 is_new_team_assignment: Final = db_team_id != patch_team_id 

1648 

1649 # Team rows keep their internal UUID-based model_name; the public name lives in model_info 

1650 patch_data.model_name = f"model_name_{patch_team_id}_{uuid.uuid4()}" if is_new_team_assignment else None 

1651 row: Final = await write_row(update_db_model(db_model=db_model, updated_patch=patch_data)) 

1652 

1653 if is_new_team_assignment: 

1654 await _setup_new_team_model_assignment( 

1655 team_id=patch_team_id, 

1656 public_model_name=public_model_name, 

1657 user_api_key_dict=user_api_key_dict, 

1658 ) 

1659 else: 

1660 await _update_existing_team_model_assignment( 

1661 team_id=patch_team_id, 

1662 public_model_name=public_model_name, 

1663 db_model=db_model, 

1664 user_api_key_dict=user_api_key_dict, 

1665 prisma_client=prisma_client, 

1666 ) 

1667 

1668 return row 

1669 

1670 

1671def _get_public_model_name( 

1672 patch_data: updateDeployment, 

1673 db_model: Deployment, 

1674) -> str: 

1675 """Determine the public model name from patch or existing model. 

1676 

1677 The top-level ``model_name`` is the rename channel. For team-scoped rows 

1678 the DB ``model_name`` column holds an internal routing key 

1679 (``model_name_{team_id}_{uuid}``), and ``/model/info`` historically leaked 

1680 it into the dashboard edit form, so a non-rename save (e.g. a TPM tweak) 

1681 would PATCH the internal name and the update path would treat it as a 

1682 rename -- overwriting ``team_public_model_name`` and rewriting the team ACL 

1683 (see issue #28382). 

1684 

1685 Guard against that by ignoring an incoming ``model_name`` that matches the 

1686 internal shape, or is a no-op against the current DB column. Anything else 

1687 is a genuine rename and wins. We deliberately do NOT read 

1688 ``patch_data.model_info.team_public_model_name``: the dashboard passes the 

1689 existing ``model_info`` blob through untouched on a rename, so honoring it 

1690 would return the OLD public name and silently drop the rename. 

1691 

1692 Precedence (highest first): 

1693 1. patch_data.model_name -- a genuine rename: not internal-shape and not a 

1694 no-op against db_model.model_name. 

1695 2. db_model.model_info.team_public_model_name -- existing public name. 

1696 3. db_model.model_name -- last-resort fallback for legacy rows. 

1697 """ 

1698 team_id: Final = (patch_data.model_info.team_id if patch_data.model_info else None) or ( 

1699 db_model.model_info.team_id if db_model.model_info else None 

1700 ) 

1701 

1702 def _is_internal_shape(name: str | None) -> bool: 

1703 if team_id is None or not name: 

1704 return False 

1705 return name.startswith(f"model_name_{team_id}_") 

1706 

1707 incoming: Final = patch_data.model_name 

1708 if incoming and not _is_internal_shape(incoming) and incoming != db_model.model_name: 

1709 return incoming 

1710 

1711 if db_model.model_info and db_model.model_info.team_public_model_name: 

1712 return db_model.model_info.team_public_model_name 

1713 

1714 return db_model.model_name 

1715 

1716 

1717async def _setup_new_team_model_assignment( 

1718 team_id: str, 

1719 public_model_name: str, 

1720 user_api_key_dict: UserAPIKeyAuth, 

1721) -> None: 

1722 """Register a newly team-assigned model's public name on the team.""" 

1723 await team_model_add( 

1724 data=TeamModelAddRequest( 

1725 team_id=team_id, 

1726 models=[public_model_name], 

1727 ), 

1728 http_request=Request(scope={"type": "http"}), 

1729 user_api_key_dict=user_api_key_dict, 

1730 ) 

1731 

1732 

1733async def _get_team_deployments( 

1734 team_id: str, prisma_client: PrismaClient, table: _ProxyModelTable | None = None 

1735) -> Sequence[_ProxyModelRow]: 

1736 """ 

1737 Fetch all deployments for a given team_id from the database. 

1738 

1739 Centralizes team deployment queries to ensure consistent filtering and error handling. 

1740 This is the established helper pattern for team deployment DB access in this module. 

1741 

1742 Note: prisma-client-py 0.11.0 does not support JSON path filtering, so we filter 

1743 by the model_name prefix (team models use "model_name_{team_id}_*") and confirm 

1744 team_id in model_info with Python-side filtering. 

1745 

1746 Pass ``table`` (a transaction's proxy-model table) to run the read inside an 

1747 existing transaction. 

1748 """ 

1749 prefix: Final = f"model_name_{team_id}_" 

1750 table = table or _proxy_model_table(prisma_client) 

1751 response: Final = await table.find_many( 

1752 where={ 

1753 "model_name": {"startswith": prefix}, 

1754 } 

1755 ) 

1756 if not response: 

1757 return [] 

1758 

1759 # Confirm team_id in model_info (defensive check) 

1760 result: Final = [] 

1761 for row in response: 

1762 model_info = model_info_as_mapping(row.model_info) 

1763 if model_info is not None and model_info.get("team_id") == team_id: 

1764 result.append(row) 

1765 return result 

1766 

1767 

1768async def delete_team_models( 

1769 team_ids: list[str], 

1770 prisma_client: PrismaClient, 

1771 llm_router: Router | None, 

1772) -> list[str]: 

1773 """ 

1774 Delete every BYOK model owned by the given teams, from the DB and the router. 

1775 

1776 The DB rows are removed inside a single transaction, so deletion is atomic 

1777 across all team_ids. Each team's rows are deleted by the exact model_ids read 

1778 in the same transaction, which keeps the deleted set identical to the set 

1779 handed to the router. The router is synced only after the transaction commits, 

1780 so a rollback can never leave a deployment live in the router without its row. 

1781 

1782 Returns the model_ids that were deleted. 

1783 """ 

1784 deleted_model_ids: Final[list[str]] = [] 

1785 async with prisma_client.db.tx() as tx_ctx: 

1786 tx: Final[_TxModelTables] = tx_ctx 

1787 for team_id in team_ids: 1787 ↛ 1788line 1787 didn't jump to line 1788 because the loop on line 1787 never started

1788 rows = await _get_team_deployments(team_id, prisma_client, table=tx.litellm_proxymodeltable) 

1789 model_ids = [row.model_id for row in rows] 

1790 if model_ids: 

1791 await tx.litellm_proxymodeltable.delete_many(where={"model_id": {"in": model_ids}}) 

1792 deleted_model_ids.extend(model_ids) 

1793 

1794 if deleted_model_ids: 1794 ↛ 1795line 1794 didn't jump to line 1795 because the condition on line 1794 was never true

1795 await publish_config_change(redis_cache=coordination_redis_cache(), object_type="litellm_proxymodeltable") 

1796 

1797 # Under MODEL_RECONCILE_LOCK, for the same reason as delete_model: the rows are 

1798 # gone, but a reconcile holding a pre-delete snapshot would upsert these ids back 

1799 # onto this pod. The lock orders the eviction after any in-flight reconcile. 

1800 from litellm.proxy.proxy_server import MODEL_RECONCILE_LOCK, proxy_config 

1801 

1802 async with MODEL_RECONCILE_LOCK: 

1803 proxy_config.remove_auto_router_catalog_entries(frozenset(deleted_model_ids)) 

1804 if llm_router is not None: 1804 ↛ 1808line 1804 didn't jump to line 1808

1805 for model_id in deleted_model_ids: 1805 ↛ 1806line 1805 didn't jump to line 1806 because the loop on line 1805 never started

1806 llm_router.delete_deployment(id=model_id) 

1807 

1808 return deleted_model_ids 

1809 

1810 

1811async def _get_team_public_model_names( 

1812 team_id: str, 

1813 prisma_client: PrismaClient, 

1814) -> set[str]: 

1815 """ 

1816 Public model names currently backed by a deployment in the team. 

1817 

1818 Called on delete (after the deployment row is removed) so a public name that is 

1819 load-balanced across several deployments stays in team.models while a replica 

1820 still serves it. 

1821 """ 

1822 deployments: Final = await _get_team_deployments(team_id, prisma_client) 

1823 public_names: Final[set[str]] = set() 

1824 for row in deployments: 

1825 model_info = model_info_as_mapping(row.model_info) 

1826 if model_info is not None: 

1827 public_name = model_info.get("team_public_model_name") 

1828 if public_name: 

1829 public_names.add(public_name) 

1830 return public_names 

1831 

1832 

1833async def _remove_unbacked_team_models( 

1834 model_params: Deployment, 

1835 prisma_client: PrismaClient, 

1836 user_api_key_cache: UserApiKeyCache, 

1837 proxy_logging_obj: ProxyLogging, 

1838 llm_router: Router | None = None, 

1839) -> None: 

1840 """ 

1841 Strip a deleted team model's public name(s) from team.models and refresh the cache. 

1842 

1843 Must be called after the deployment row is deleted: a public name is removed only 

1844 when no remaining team deployment still backs it, so a load-balanced replica isn't 

1845 revoked while siblings serve it, and concurrent deletes can't leave a ghost. 

1846 

1847 Legacy team models (created before team_public_model_name existed) store a 

1848 ``{public_name: "model_name_{team_id}_{uuid}"}`` entry in the team's model_aliases, 

1849 so the alias scan runs for every team model; skipping it for internal-shaped names 

1850 left stale aliases that rewrote requests to deployments that no longer exist. 

1851 Aliases are scrubbed only when the deleted deployment's name no longer resolves in 

1852 the router, so deleting one replica of a load-balanced group never breaks aliases 

1853 that still route to the surviving replicas (in any team). 

1854 

1855 A public name that still resolves to a live router deployment (e.g. a gateway-level 

1856 model group shared with the team) is kept in team.models, so deleting a per-team 

1857 duplicate does not revoke the team's access to the shared deployment. 

1858 """ 

1859 team_id: Final = model_params.model_info.team_id 

1860 if team_id is None: 

1861 return 

1862 

1863 deleted_name_still_served: Final = ( 

1864 llm_router is not None and model_params.model_name in llm_router.model_name_to_deployment_indices 

1865 ) 

1866 removed_model_aliases: Final[list[tuple[str, str]]] = ( 

1867 [] 

1868 if deleted_name_still_served 

1869 else await delete_team_model_alias( 

1870 public_model_name=model_params.model_name, 

1871 prisma_client=prisma_client, 

1872 ) 

1873 ) 

1874 removed_alias_names: Final = {alias for alias_team_id, alias in removed_model_aliases if alias_team_id == team_id} 

1875 candidate_names: Final = ( 

1876 removed_alias_names | {model_params.model_info.team_public_model_name} 

1877 if model_params.model_info.team_public_model_name is not None 

1878 else removed_alias_names 

1879 ) 

1880 if not candidate_names: 

1881 return 

1882 

1883 team_backed_names: Final = await _get_team_public_model_names(team_id=team_id, prisma_client=prisma_client) 

1884 router_served_names: Final = ( 

1885 frozenset(name for name in candidate_names if name in llm_router.model_name_to_deployment_indices) 

1886 if llm_router is not None 

1887 else frozenset() 

1888 ) 

1889 names_to_remove: Final = candidate_names - team_backed_names - router_served_names 

1890 if not names_to_remove: 

1891 return 

1892 

1893 existing_team_row: Final = await _db_team_table(prisma_client).find_unique(where={"team_id": team_id}) 

1894 if existing_team_row is None: 

1895 return 

1896 

1897 updated_team_row: Final[LiteLLM_TeamTable] = await _db_team_table(prisma_client).update( 

1898 where={"team_id": team_id}, 

1899 data={"models": [model for model in existing_team_row.models if model not in names_to_remove]}, 

1900 include={"object_permission": True}, 

1901 ) 

1902 await _refresh_cached_team( 

1903 team_row=updated_team_row, 

1904 user_api_key_cache=user_api_key_cache, 

1905 proxy_logging_obj=proxy_logging_obj, 

1906 ) 

1907 

1908 

1909async def _update_existing_team_model_assignment( 

1910 team_id: str, 

1911 public_model_name: str, 

1912 db_model: Deployment, 

1913 user_api_key_dict: UserAPIKeyAuth, 

1914 prisma_client: PrismaClient | None, 

1915) -> None: 

1916 """Update an existing team model if the public name changed. 

1917 

1918 Note on DB scan: Prisma's JSON filtering does not support compound AND conditions 

1919 across multiple JSON paths, so we fetch all deployments for the team and filter 

1920 team_public_model_name in Python. For teams with many deployments this scan grows 

1921 linearly; if team deployment counts become large this should be revisited. 

1922 """ 

1923 

1924 def _get_team_public_model_name( 

1925 model_info: object, 

1926 ) -> str | None: 

1927 parsed: Final = model_info_as_mapping(model_info) 

1928 if parsed is None: 

1929 return None 

1930 value: Final = parsed.get("team_public_model_name") 

1931 return value if isinstance(value, str) else None 

1932 

1933 old_public_name: Final = db_model.model_info.team_public_model_name if db_model.model_info else None 

1934 

1935 if old_public_name and public_model_name != old_public_name: 

1936 if prisma_client is None: 

1937 verbose_proxy_logger.warning( 

1938 "prisma_client not initialized; skipping public name update entirely to avoid orphaned entries" 

1939 ) 

1940 return 

1941 

1942 # Query DB for all team deployments to check for sibling deployments 

1943 team_deployments: Final = await _get_team_deployments(team_id, prisma_client) 

1944 other_deployments_with_old_name: Final = [ 

1945 d 

1946 for d in team_deployments 

1947 if d.model_name != db_model.model_name and _get_team_public_model_name(d.model_info) == old_public_name 

1948 ] 

1949 

1950 # Add new name first, then delete old name to prevent access loss on partial failure 

1951 await team_model_add( 

1952 data=TeamModelAddRequest( 

1953 team_id=team_id, 

1954 models=[public_model_name], 

1955 ), 

1956 http_request=Request(scope={"type": "http"}), 

1957 user_api_key_dict=user_api_key_dict, 

1958 ) 

1959 

1960 if not other_deployments_with_old_name: 

1961 await team_model_delete( 

1962 data=TeamModelDeleteRequest( 

1963 team_id=team_id, 

1964 models=[old_public_name], 

1965 ), 

1966 http_request=Request(scope={"type": "http"}), 

1967 user_api_key_dict=user_api_key_dict, 

1968 ) 

1969 elif not old_public_name and public_model_name: 

1970 # First-time assignment of public name on an existing team deployment: 

1971 # ensure the team's models list is updated so team routing can resolve it. 

1972 await team_model_add( 

1973 data=TeamModelAddRequest( 

1974 team_id=team_id, 

1975 models=[public_model_name], 

1976 ), 

1977 http_request=Request(scope={"type": "http"}), 

1978 user_api_key_dict=user_api_key_dict, 

1979 ) 

1980 # else: old_public_name == public_model_name (no rename needed) 

1981 # No team_model_add/delete calls required; public name is already registered 

1982 

1983 

1984def _canonical_session_tags(tags: Sequence[AwsSessionTag]) -> tuple[tuple[str, str], ...]: 

1985 return tuple(sorted((tag["Key"], tag["Value"]) for tag in tags)) 

1986 

1987 

1988class ModelManagementAuthChecks: 

1989 """ 

1990 Common auth checks for model management endpoints 

1991 """ 

1992 

1993 @staticmethod 

1994 def can_user_make_team_model_call( 

1995 team_id: str, 

1996 user_api_key_dict: UserAPIKeyAuth, 

1997 team_obj: LiteLLM_TeamTable | None = None, 

1998 premium_user: bool = False, 

1999 ) -> Literal[True]: 

2000 if premium_user is False: 

2001 raise HTTPException( 

2002 status_code=403, 

2003 detail={"error": CommonProxyErrors.not_premium_user.value}, 

2004 ) 

2005 if user_api_key_dict.user_role and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: 

2006 return True 

2007 elif team_obj is None or not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): 

2008 raise HTTPException( 

2009 status_code=403, 

2010 detail={ 

2011 "error": f"Team ID={team_id} does not match the API key's team ID={user_api_key_dict.team_id}, OR you are not the admin for this team. Check `/user/info` to verify your team admin status." 

2012 }, 

2013 ) 

2014 return True 

2015 

2016 @staticmethod 

2017 def can_user_attach_credential( 

2018 litellm_params: GenericLiteLLMParams | None, 

2019 user_api_key_dict: UserAPIKeyAuth, 

2020 existing_litellm_params: GenericLiteLLMParams | None = None, 

2021 *, 

2022 null_detaches: bool = False, 

2023 ) -> Literal[True]: 

2024 if litellm_params is None: 2024 ↛ 2025line 2024 didn't jump to line 2025 because the condition on line 2024 was never true

2025 return True 

2026 if "litellm_credential_name" not in litellm_params.model_fields_set: 

2027 return True 

2028 if litellm_params.litellm_credential_name is None and not null_detaches: 2028 ↛ 2030line 2028 didn't jump to line 2030 because the condition on line 2028 was always true

2029 return True 

2030 requested_credential_name: Final = litellm_params.litellm_credential_name 

2031 if requested_credential_name == _stored_credential_name(existing_litellm_params): 

2032 return True 

2033 if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: 

2034 return True 

2035 action: Final = "detach" if requested_credential_name is None else "attach" 

2036 raise ProxyException( 

2037 message=f"Only a proxy admin can {action} a stored credential (litellm_credential_name) on a model. Your role={user_api_key_dict.user_role}.", 

2038 type=ProxyErrorTypes.auth_error.value, 

2039 code=status.HTTP_403_FORBIDDEN, 

2040 param="litellm_credential_name", 

2041 ) 

2042 

2043 @staticmethod 

2044 def can_user_set_aws_session_tags( 

2045 litellm_params: GenericLiteLLMParams | None, 

2046 user_api_key_dict: UserAPIKeyAuth, 

2047 existing_litellm_params: GenericLiteLLMParams | None = None, 

2048 ) -> Literal[True]: 

2049 if litellm_params is None or litellm_params.aws_session_tags is None: 

2050 return True 

2051 if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: 2051 ↛ 2053line 2051 didn't jump to line 2053 because the condition on line 2051 was always true

2052 return True 

2053 existing_tags: Final = existing_litellm_params.aws_session_tags if existing_litellm_params is not None else None 

2054 if existing_tags is not None and _canonical_session_tags(existing_tags) == _canonical_session_tags( 

2055 litellm_params.aws_session_tags 

2056 ): 

2057 return True 

2058 raise ProxyException( 

2059 message=f"Only a proxy admin can set aws_session_tags on a model. Your role={user_api_key_dict.user_role}.", 

2060 type=ProxyErrorTypes.auth_error.value, 

2061 code=status.HTTP_403_FORBIDDEN, 

2062 param="aws_session_tags", 

2063 ) 

2064 

2065 @staticmethod 

2066 async def allow_team_model_action( 

2067 model_params: Deployment | updateDeployment, 

2068 user_api_key_dict: UserAPIKeyAuth, 

2069 prisma_client: PrismaClient, 

2070 premium_user: bool, 

2071 ) -> Literal[True]: 

2072 if model_params.model_info is None or model_params.model_info.team_id is None: 

2073 return True 

2074 if model_params.model_info.team_id is not None and premium_user is not True: 

2075 raise HTTPException( 

2076 status_code=403, 

2077 detail={"error": CommonProxyErrors.not_premium_user.value}, 

2078 ) 

2079 

2080 _existing_team_row: Final = await _repo_team_table(prisma_client).find_unique( 

2081 where={"team_id": model_params.model_info.team_id} 

2082 ) 

2083 

2084 if _existing_team_row is None: 

2085 raise HTTPException( 

2086 status_code=400, 

2087 detail={"error": f"Team id={model_params.model_info.team_id} does not exist in db"}, 

2088 ) 

2089 existing_team_row: Final = LiteLLM_TeamTable.model_validate(_existing_team_row.model_dump()) 

2090 

2091 ModelManagementAuthChecks.can_user_make_team_model_call( 

2092 team_id=model_params.model_info.team_id, 

2093 user_api_key_dict=user_api_key_dict, 

2094 team_obj=existing_team_row, 

2095 premium_user=premium_user, 

2096 ) 

2097 return True 

2098 

2099 @staticmethod 

2100 async def can_user_make_model_call( 

2101 model_params: Deployment, 

2102 user_api_key_dict: UserAPIKeyAuth, 

2103 prisma_client: PrismaClient, 

2104 premium_user: bool, 

2105 allow_missing_team: bool = False, 

2106 member_operation: Literal["create", "update"] | None = None, 

2107 incoming_model_params: updateDeployment | None = None, 

2108 ) -> Literal[True] | MemberAutoRouterWrite: 

2109 if user_api_key_dict.user_role in ( 2109 ↛ 2113line 2109 didn't jump to line 2113 because the condition on line 2109 was never true

2110 LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, 

2111 LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, 

2112 ): 

2113 raise HTTPException(status_code=403, detail="View-only users cannot manage models.") 

2114 ## Check team model auth 

2115 if model_params.model_info.team_id is not None: 2115 ↛ 2116line 2115 didn't jump to line 2116 because the condition on line 2115 was never true

2116 team_obj_row: Final = await _repo_team_table(prisma_client).find_unique( 

2117 where={"team_id": model_params.model_info.team_id} 

2118 ) 

2119 if team_obj_row is None: 

2120 # The team was deleted. Callers that opt in (e.g. model deletion) may 

2121 # act on the orphaned model, but only as a proxy admin -- without the 

2122 # team there is no team-admin membership left to verify. 

2123 if allow_missing_team: 

2124 if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: 

2125 return True 

2126 raise HTTPException( 

2127 status_code=403, 

2128 detail={"error": "Only a proxy admin can delete a model whose team has been deleted."}, 

2129 ) 

2130 raise HTTPException( 

2131 status_code=400, 

2132 detail={"error": f"Team id={model_params.model_info.team_id} does not exist in db"}, 

2133 ) 

2134 team_obj: Final = LiteLLM_TeamTable.model_validate(team_obj_row.model_dump()) 

2135 

2136 if ( 

2137 member_operation is not None 

2138 and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN 

2139 and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj) 

2140 ): 

2141 from litellm.proxy.proxy_server import llm_router 

2142 

2143 if llm_router is None or (member_operation == "update" and incoming_model_params is None): 

2144 raise HTTPException( 

2145 status_code=400, detail="An auto-router configuration and model catalog are required." 

2146 ) 

2147 return await authorize_member_auto_router_write( 

2148 incoming=incoming_model_params if incoming_model_params is not None else model_params, 

2149 existing=model_params if member_operation == "update" else None, 

2150 user_api_key_dict=user_api_key_dict, 

2151 team=team_obj, 

2152 premium_user=premium_user, 

2153 prisma_client=prisma_client, 

2154 llm_router=llm_router, 

2155 ) 

2156 

2157 return ModelManagementAuthChecks.can_user_make_team_model_call( 

2158 team_id=model_params.model_info.team_id, 

2159 user_api_key_dict=user_api_key_dict, 

2160 team_obj=team_obj, 

2161 premium_user=premium_user, 

2162 ) 

2163 ## Check non-team model auth 

2164 elif user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 2164 ↛ 2165line 2164 didn't jump to line 2165 because the condition on line 2164 was never true

2165 raise HTTPException( 

2166 status_code=403, 

2167 detail={ 

2168 "error": f"User does not have permission to make this model call. Your role={user_api_key_dict.user_role}. You can only make model calls if you are a PROXY_ADMIN or if you are a team admin, by specifying a team_id in the model_info." 

2169 }, 

2170 ) 

2171 else: 

2172 return True 

2173 

2174 return True 

2175 

2176 

2177#### [BETA] - This is a beta endpoint, format might change based on user feedback. - https://github.com/BerriAI/litellm/issues/964 

2178@router.post( 

2179 "/model/delete", 

2180 description="Allows deleting models in the model list in the config.yaml", 

2181 tags=["model management"], 

2182 dependencies=[Depends(user_api_key_auth)], 

2183) 

2184async def delete_model( 

2185 model_info: ModelInfoDelete, 

2186 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

2187): 

2188 from litellm.proxy.proxy_server import llm_router 

2189 

2190 try: 

2191 """ 

2192 [BETA] - This is a beta endpoint, format might change based on user feedback. - https://github.com/BerriAI/litellm/issues/964 

2193 

2194 - Check if id in db 

2195 - Delete 

2196 """ 

2197 

2198 from litellm.proxy.proxy_server import ( 

2199 MODEL_RECONCILE_LOCK, 

2200 llm_router, 

2201 premium_user, 

2202 prisma_client, 

2203 proxy_config, 

2204 proxy_logging_obj, 

2205 store_model_in_db, 

2206 user_api_key_cache, 

2207 ) 

2208 

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

2210 raise HTTPException( 

2211 status_code=500, 

2212 detail={ 

2213 "error": "No DB Connected. Here's how to do it - https://docs.litellm.ai/docs/proxy/virtual_keys" 

2214 }, 

2215 ) 

2216 

2217 model_in_db: Final = await _proxy_model_table(prisma_client).find_unique(where={"model_id": model_info.id}) 

2218 if model_in_db is None: 

2219 raise HTTPException( 

2220 status_code=400, 

2221 detail={"error": f"Model with id={model_info.id} not found in db"}, 

2222 ) 

2223 

2224 model_params: Final = Deployment(**model_in_db.model_dump()) 

2225 await ModelManagementAuthChecks.can_user_make_model_call( 

2226 model_params=model_params, 

2227 user_api_key_dict=user_api_key_dict, 

2228 prisma_client=prisma_client, 

2229 premium_user=premium_user, 

2230 allow_missing_team=True, 

2231 ) 

2232 

2233 # update DB 

2234 if store_model_in_db is True: 2234 ↛ 2291line 2234 didn't jump to line 2291 because the condition on line 2234 was always true

2235 """ 

2236 - store model_list in db 

2237 - store keys separately 

2238 """ 

2239 # encrypt litellm params # 

2240 result: Final = await _proxy_model_table(prisma_client).delete(where={"model_id": model_info.id}) 

2241 

2242 if result is None: 2242 ↛ 2243line 2242 didn't jump to line 2243 because the condition on line 2242 was never true

2243 raise HTTPException( 

2244 status_code=400, 

2245 detail={"error": f"Model with id={model_info.id} not found in db"}, 

2246 ) 

2247 

2248 ## DELETE FROM ROUTER ## 

2249 # Under MODEL_RECONCILE_LOCK. The db row is already gone, but a reconcile 

2250 # that snapshotted the db BEFORE that delete still lists this id as desired, 

2251 # and its _add_deployment upserts the deployment straight back -- leaving 

2252 # this pod serving a model the database no longer has, until the next 

2253 # reconcile. Taking the lock orders this eviction after any such in-flight 

2254 # reconcile's re-add, so the eviction is the last word. 

2255 async with MODEL_RECONCILE_LOCK: 

2256 proxy_config.remove_auto_router_catalog_entries(frozenset({model_info.id})) 

2257 if llm_router is not None: 2257 ↛ 2261line 2257 didn't jump to line 2261

2258 llm_router.delete_deployment(id=model_info.id) 

2259 

2260 # Runs after the row delete so the sibling check sees post-delete state. 

2261 if model_params.model_info.team_id is not None: 2261 ↛ 2262line 2261 didn't jump to line 2262 because the condition on line 2261 was never true

2262 await _remove_unbacked_team_models( 

2263 model_params=model_params, 

2264 prisma_client=prisma_client, 

2265 user_api_key_cache=user_api_key_cache, 

2266 proxy_logging_obj=proxy_logging_obj, 

2267 llm_router=llm_router, 

2268 ) 

2269 await sync_access_groups_for_deleted_model( 

2270 prisma_client=prisma_client, 

2271 model_id=model_info.id, 

2272 model_name=model_params.model_name, 

2273 llm_router=llm_router, 

2274 ) 

2275 

2276 ## CREATE AUDIT LOG ## 

2277 asyncio.create_task( 

2278 create_object_audit_log( 

2279 object_id=model_info.id, 

2280 action="deleted", 

2281 user_api_key_dict=user_api_key_dict, 

2282 table_name=LitellmTableNames.PROXY_MODEL_TABLE_NAME, 

2283 before_value=result.model_dump_json(exclude_none=True), 

2284 after_value=None, 

2285 litellm_changed_by=user_api_key_dict.user_id, 

2286 litellm_proxy_admin_name=LITELLM_PROXY_ADMIN_NAME, 

2287 ) 

2288 ) 

2289 return {"message": f"Model: {result.model_id} deleted successfully"} 

2290 else: 

2291 raise HTTPException( 

2292 status_code=500, 

2293 detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."}, 

2294 ) 

2295 

2296 except Exception as e: 

2297 verbose_proxy_logger.exception("Failed to delete model. Due to error - %s", e) 

2298 if isinstance(e, HTTPException): 2298 ↛ 2305line 2298 didn't jump to line 2305 because the condition on line 2298 was always true

2299 raise ProxyException( 

2300 message=getattr(e, "detail", f"Authentication Error({e})"), 

2301 type=ProxyErrorTypes.auth_error, 

2302 param=getattr(e, "param", "None"), 

2303 code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), 

2304 ) 

2305 elif isinstance(e, ProxyException): 

2306 raise e 

2307 raise ProxyException( 

2308 message="Authentication Error, " + str(e), 

2309 type=ProxyErrorTypes.auth_error, 

2310 param=getattr(e, "param", "None"), 

2311 code=status.HTTP_400_BAD_REQUEST, 

2312 ) 

2313 

2314 

2315async def delete_team_model_alias( 

2316 public_model_name: str, 

2317 prisma_client: PrismaClient, 

2318) -> list[tuple[str, str]]: 

2319 """ 

2320 Delete a team model alias 

2321 

2322 Iterate through all team model aliases and delete the one that matches the model_id 

2323 

2324 Returns: 

2325 - List of team id + model alias pairs that were removed 

2326 """ 

2327 team_model_aliases: Final = await _model_alias_table(prisma_client).find_many(include={"team": True}) 

2328 tasks: Final = [] 

2329 removed_model_aliases: Final[list[tuple[str, str]]] = [] 

2330 for team_model_alias in team_model_aliases: 

2331 model_aliases = cast( # cast-ok: prisma types Json columns as `str`; the driver hands back the parsed dict 

2332 "dict[str, str]", team_model_alias.model_aliases 

2333 ) 

2334 id = team_model_alias.id 

2335 

2336 if public_model_name in model_aliases.values(): 

2337 key = list(model_aliases.keys())[list(model_aliases.values()).index(public_model_name)] 

2338 if team_model_alias.team is not None: 

2339 removed_model_aliases.append((team_model_alias.team.team_id, key)) 

2340 del model_aliases[key] 

2341 tasks.append( 

2342 _model_alias_table(prisma_client).update( 

2343 where={"id": id}, 

2344 data={"model_aliases": json.dumps(model_aliases)}, 

2345 ) 

2346 ) 

2347 await asyncio.gather(*tasks) 

2348 

2349 return removed_model_aliases 

2350 

2351 

2352#### [BETA] - This is a beta endpoint, format might change based on user feedback. - https://github.com/BerriAI/litellm/issues/964 

2353@router.post( 

2354 "/model/new", 

2355 description="Allows adding new models to the model list in the config.yaml", 

2356 tags=["model management"], 

2357 dependencies=[Depends(user_api_key_auth)], 

2358) 

2359async def add_new_model( 

2360 model_params: Deployment, 

2361 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

2362): 

2363 """ 

2364 Add a new model to the proxy. 

2365 

2366 Parameters: 

2367 - model_name: str - The name users will use to call this model (required) 

2368 - litellm_params: dict - LiteLLM-specific parameters (required) 

2369 - model: str - The actual model identifier, e.g., "azure/my-deployment-name" (required - this is the only required field in litellm_params) 

2370 - api_key: str - API key for the provider (optional) 

2371 - api_base: str - API base URL (optional) 

2372 - Other optional params: api_version, timeout, max_retries, etc. 

2373 - model_info: dict - Additional model metadata returned in /v1/model/info (optional) 

2374 

2375 Example curl: 

2376 

2377 ```bash 

2378 curl -L -X POST 'http://0.0.0.0:4000/model/new' \ 

2379 -H 'Authorization: Bearer LITELLM_VIRTUAL_KEY' \ 

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

2381 -d '{ 

2382 "model_name": "my-azure-model", 

2383 "litellm_params": { 

2384 "model": "azure/my-deployment-name", 

2385 "api_key": "my-azure-api-key", 

2386 "api_base": "https://my-endpoint.openai.azure.com" 

2387 }, 

2388 "model_info": { 

2389 "my_custom_key": "my_custom_value" 

2390 } 

2391 }' 

2392 ``` 

2393 

2394 Returns: 

2395 - The created model entry with model_id 

2396 """ 

2397 from litellm.proxy.proxy_server import ( 

2398 general_settings, 

2399 premium_user, 

2400 prisma_client, 

2401 proxy_config, 

2402 proxy_logging_obj, 

2403 store_model_in_db, 

2404 ) 

2405 

2406 try: 

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

2408 raise HTTPException( 

2409 status_code=500, 

2410 detail={ 

2411 "error": "No DB Connected. Here's how to do it - https://docs.litellm.ai/docs/proxy/virtual_keys" 

2412 }, 

2413 ) 

2414 

2415 ## Auth check 

2416 write_authorization: Final = await ModelManagementAuthChecks.can_user_make_model_call( 

2417 model_params=model_params, 

2418 user_api_key_dict=user_api_key_dict, 

2419 prisma_client=prisma_client, 

2420 premium_user=premium_user, 

2421 member_operation="create", 

2422 ) 

2423 member_write: Final = write_authorization if isinstance(write_authorization, MemberAutoRouterWrite) else None 

2424 

2425 ModelManagementAuthChecks.can_user_attach_credential( 

2426 litellm_params=model_params.litellm_params, 

2427 user_api_key_dict=user_api_key_dict, 

2428 ) 

2429 

2430 ModelManagementAuthChecks.can_user_set_aws_session_tags( 

2431 litellm_params=model_params.litellm_params, 

2432 user_api_key_dict=user_api_key_dict, 

2433 ) 

2434 

2435 _raise_on_strategy_router_write_violation( 

2436 incoming_params=model_params.litellm_params, 

2437 existing_params=None, 

2438 ) 

2439 

2440 _raise_if_rate_limits_required_but_missing( 

2441 litellm_params=model_params.litellm_params, 

2442 enforced=bool(general_settings.get(ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING, False)), 

2443 ) 

2444 

2445 clean_model_info: Final = ModelInfo( 

2446 **without_server_derived_pricing(model_params.model_info.model_dump(exclude_none=True)) 

2447 ) 

2448 model_params.model_info = ( # rebind-ok: downstream team-model handling mutates this same object 

2449 clean_model_info.model_copy(update=MappingProxyType({"member_auto_router": True})) 

2450 if member_write is not None 

2451 else clean_model_info 

2452 ) 

2453 

2454 model_response: prisma_models.LiteLLM_ProxyModelTable | LiteLLM_ProxyModelTable | None = None 

2455 # update DB 

2456 incoming_model_info: Final = model_params.model_info.model_dump(exclude_none=True) 

2457 _raise_if_ptu_cost_attribution_disabled(incoming_model_info) 

2458 _validate_ptu_model_info(incoming_model_info) 

2459 priced_model_params: Final = _ptu_priced_deployment(model_params) 

2460 

2461 if store_model_in_db is True: 2461 ↛ 2506line 2461 didn't jump to line 2506 because the condition on line 2461 was always true

2462 """ 

2463 - store model_list in db 

2464 - store keys separately 

2465 """ 

2466 

2467 live_before_reload: Final = live_model_ids_snapshot() 

2468 reload_outcome: ReconcileOutcome = ReconcileOutcome(still_desired=None, live_after=None) 

2469 try: 

2470 _original_litellm_model_name: Final = model_params.model_name 

2471 add_model: Final = ( 

2472 _add_model_to_db if model_params.model_info.team_id is None else _add_team_model_to_db 

2473 ) 

2474 model_response = await add_model( 

2475 model_params=priced_model_params, 

2476 user_api_key_dict=user_api_key_dict, 

2477 prisma_client=prisma_client, 

2478 slot=_auto_router_capability_slot( 

2479 prisma_client, 

2480 effective_params=_effective_complexity_router_params( 

2481 priced_model_params.litellm_params, 

2482 None, 

2483 ), 

2484 model_id=priced_model_params.model_info.id, 

2485 member_write=member_write, 

2486 ), 

2487 ) 

2488 reload_outcome = await proxy_config.add_deployment( 

2489 prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj 

2490 ) 

2491 # don't let failed slack alert block the /model/new response 

2492 _alerting: Final = general_settings.get("alerting", []) or [] 

2493 if "slack" in _alerting: 2493 ↛ 2495line 2493 didn't jump to line 2495 because the condition on line 2493 was never true

2494 # send notification - new model added 

2495 await proxy_logging_obj.slack_alerting_instance.model_added_alert( 

2496 model_name=priced_model_params.model_name, 

2497 litellm_model_name=_original_litellm_model_name, 

2498 passed_model_info=priced_model_params.model_info, 

2499 ) 

2500 except Exception as e: 

2501 if isinstance(e, HTTPException): 2501 ↛ 2502line 2501 didn't jump to line 2502 because the condition on line 2501 was never true

2502 raise 

2503 verbose_proxy_logger.exception("Exception in add_new_model: %s", e) 

2504 

2505 else: 

2506 raise HTTPException( 

2507 status_code=500, 

2508 detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."}, 

2509 ) 

2510 

2511 if model_response is None: 

2512 raise HTTPException( 

2513 status_code=500, 

2514 detail={"error": "Failed to add model to db. Check your server logs for more details."}, 

2515 ) 

2516 

2517 ## CREATE AUDIT LOG ## 

2518 asyncio.create_task( 

2519 create_object_audit_log( 

2520 object_id=model_response.model_id, 

2521 action="created", 

2522 user_api_key_dict=user_api_key_dict, 

2523 table_name=LitellmTableNames.PROXY_MODEL_TABLE_NAME, 

2524 before_value=None, 

2525 after_value=( 

2526 model_response.model_dump_json(exclude_none=True) if isinstance(model_response, BaseModel) else None 

2527 ), 

2528 litellm_changed_by=user_api_key_dict.user_id, 

2529 litellm_proxy_admin_name=LITELLM_PROXY_ADMIN_NAME, 

2530 ) 

2531 ) 

2532 

2533 raise_if_reload_degraded_serving( 

2534 before=live_before_reload, 

2535 written_models=[(model_response.model_id, getattr(model_response, "model_info", None))], 

2536 action="create", 

2537 still_desired=reload_outcome.still_desired, 

2538 live_after=reload_outcome.live_after, 

2539 ) 

2540 

2541 return model_response 

2542 

2543 except Exception as e: 

2544 verbose_proxy_logger.exception("litellm.proxy.proxy_server.add_new_model(): Exception occured - %s", e) 

2545 if isinstance(e, HTTPException): 

2546 raise ProxyException( 

2547 message=getattr(e, "detail", f"Authentication Error({e})"), 

2548 type=ProxyErrorTypes.auth_error, 

2549 param=getattr(e, "param", "None"), 

2550 code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), 

2551 ) 

2552 elif isinstance(e, ProxyException): 2552 ↛ 2554line 2552 didn't jump to line 2554 because the condition on line 2552 was always true

2553 raise e 

2554 raise ProxyException( 

2555 message="Authentication Error, " + str(e), 

2556 type=ProxyErrorTypes.auth_error, 

2557 param=getattr(e, "param", "None"), 

2558 code=status.HTTP_400_BAD_REQUEST, 

2559 ) 

2560 

2561 

2562#### MODEL MANAGEMENT #### 

2563@router.post( 

2564 "/model/update", 

2565 description="Edit existing model params", 

2566 tags=["model management"], 

2567 dependencies=[Depends(user_api_key_auth)], 

2568) 

2569async def update_model( 

2570 model_params: updateDeployment, 

2571 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

2572): 

2573 """ 

2574 Old endpoint for model update. Makes a PUT request. 

2575 

2576 Use `/model/{model_id}/update` to PATCH the stored model in db. 

2577 """ 

2578 from litellm.proxy.proxy_server import ( 

2579 LITELLM_PROXY_ADMIN_NAME, 

2580 llm_router, 

2581 premium_user, 

2582 prisma_client, 

2583 store_model_in_db, 

2584 user_api_key_cache, 

2585 ) 

2586 

2587 try: 

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

2589 raise HTTPException( 

2590 status_code=500, 

2591 detail={ 

2592 "error": "No DB Connected. Here's how to do it - https://docs.litellm.ai/docs/proxy/virtual_keys" 

2593 }, 

2594 ) 

2595 

2596 _model_id: str | None = None 

2597 _model_info: Final[ModelInfo | None] = getattr(model_params, "model_info", None) 

2598 if _model_info is None: 

2599 raise Exception("model_info not provided") 

2600 

2601 _model_id = _model_info.id 

2602 if _model_id is None: 2602 ↛ 2603line 2602 didn't jump to line 2603 because the condition on line 2602 was never true

2603 raise Exception("model_info.id not provided") 

2604 

2605 _existing_litellm_params = await ModelRepository(prisma_client).table.find_unique(where={"model_id": _model_id}) 

2606 

2607 if _existing_litellm_params is None: 

2608 if llm_router is not None and llm_router.get_deployment(model_id=_model_id) is not None: 2608 ↛ 2609line 2608 didn't jump to line 2609 because the condition on line 2608 was never true

2609 raise HTTPException( 

2610 status_code=400, 

2611 detail={"error": "Can't edit model. Model in config. Store model in db via `/model/new`. to edit."}, 

2612 ) 

2613 else: 

2614 raise Exception("model not found") 

2615 deployment: Final = Deployment(**_existing_litellm_params.model_dump()) 

2616 

2617 write_authorization: Final = await ModelManagementAuthChecks.can_user_make_model_call( 

2618 model_params=deployment, 

2619 user_api_key_dict=user_api_key_dict, 

2620 prisma_client=prisma_client, 

2621 premium_user=premium_user, 

2622 member_operation="update", 

2623 incoming_model_params=model_params, 

2624 ) 

2625 member_write: Final = write_authorization if isinstance(write_authorization, MemberAutoRouterWrite) else None 

2626 

2627 ModelManagementAuthChecks.can_user_attach_credential( 

2628 litellm_params=model_params.litellm_params, 

2629 user_api_key_dict=user_api_key_dict, 

2630 existing_litellm_params=deployment.litellm_params, 

2631 ) 

2632 

2633 ModelManagementAuthChecks.can_user_set_aws_session_tags( 

2634 litellm_params=model_params.litellm_params, 

2635 user_api_key_dict=user_api_key_dict, 

2636 existing_litellm_params=deployment.litellm_params, 

2637 ) 

2638 

2639 _raise_on_strategy_router_write_violation( 

2640 incoming_params=model_params.litellm_params, 

2641 existing_params=deployment.litellm_params, 

2642 ) 

2643 effective_params: Final = _effective_complexity_router_params( 

2644 model_params.litellm_params, deployment.litellm_params 

2645 ) 

2646 member_marker: Final = _member_auto_router_marker_for_update( 

2647 incoming_params=model_params.litellm_params, existing=deployment, member_write=member_write 

2648 ) 

2649 

2650 # update DB 

2651 if store_model_in_db is True: 2651 ↛ exitline 2651 didn't return from function 'update_model' because the condition on line 2651 was always true

2652 existing_model_row: Final = cast( # cast-ok: prisma types Json columns as `str`; the driver parses them 

2653 "_ExistingModelRow", _existing_litellm_params 

2654 ) 

2655 _existing_litellm_params_dict: Final = dict(existing_model_row.litellm_params) 

2656 

2657 if model_params.litellm_params is None: 2657 ↛ 2658line 2657 didn't jump to line 2658 because the condition on line 2657 was never true

2658 raise Exception("litellm_params not provided") 

2659 

2660 _new_litellm_params_dict: Final = model_params.litellm_params.dict(exclude_none=True) 

2661 

2662 ### ENCRYPT PARAMS ### 

2663 encrypted_params: Final = MappingProxyType( 

2664 { 

2665 k: ( 

2666 _effective_complexity_router_config(model_params.litellm_params, deployment.litellm_params) 

2667 if k == "complexity_router_config" 

2668 else encrypt_value_helper(value=v) 

2669 ) 

2670 for k, v in _new_litellm_params_dict.items() 

2671 } 

2672 ) 

2673 

2674 ### MERGE WITH EXISTING DATA ### 

2675 _mp: Final[dict[str, object]] = model_params.litellm_params.dict() 

2676 merged_dictionary: Final = { 

2677 key: _existing_litellm_params_dict[key] if value is None else encrypted_params[key] 

2678 for key, value in _mp.items() 

2679 if value is not None or _existing_litellm_params_dict.get(key) is not None 

2680 } 

2681 

2682 renamed_to: Final = ( 

2683 model_params.model_name 

2684 if model_params.model_name not in (None, deployment.model_name) 

2685 and deployment.model_info.team_id is None 

2686 else None 

2687 ) 

2688 base_update: Final[PrismaCompatibleUpdateDBModel] = { 

2689 "litellm_params": json.dumps(merged_dictionary), 

2690 "updated_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME, 

2691 } 

2692 renamed_update: Final[PrismaCompatibleUpdateDBModel] = ( 

2693 {**base_update, "model_name": renamed_to} # mutable-ok: Prisma serializes only concrete update dicts 

2694 if renamed_to is not None 

2695 else base_update 

2696 ) 

2697 _data: Final[PrismaCompatibleUpdateDBModel] = ( 

2698 { # mutable-ok: Prisma serializes only concrete update dicts 

2699 **renamed_update, 

2700 "model_info": deployment.model_info.model_copy( 

2701 update=MappingProxyType({"member_auto_router": member_marker}) 

2702 ).model_dump_json(exclude_none=True), 

2703 } 

2704 if member_marker is not None 

2705 else renamed_update 

2706 ) 

2707 async with _auto_router_capability_slot( 

2708 prisma_client, 

2709 effective_params=effective_params, 

2710 model_id=_model_id, 

2711 member_write=member_write, 

2712 ) as table: 

2713 model_response: Final = await table.update( 

2714 where={"model_id": _model_id}, 

2715 data=_data, 

2716 ) 

2717 if renamed_to is not None: 

2718 await sync_access_groups_for_renamed_model( 

2719 prisma_client=prisma_client, 

2720 model_id=_model_id, 

2721 old_name=deployment.model_name, 

2722 new_name=renamed_to, 

2723 llm_router=llm_router, 

2724 ) 

2725 await sync_model_allowlists_for_renamed_model( 

2726 prisma_client=prisma_client, 

2727 model_id=_model_id, 

2728 old_name=deployment.model_name, 

2729 new_name=renamed_to, 

2730 llm_router=llm_router, 

2731 user_api_key_cache=user_api_key_cache, 

2732 ) 

2733 

2734 # Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates) 

2735 live_before_reload: Final = live_model_ids_snapshot() 

2736 reload_outcome: Final = await clear_cache() 

2737 ## CREATE AUDIT LOG ## 

2738 asyncio.create_task( 

2739 create_object_audit_log( 

2740 object_id=_model_id, 

2741 action="updated", 

2742 user_api_key_dict=user_api_key_dict, 

2743 table_name=LitellmTableNames.PROXY_MODEL_TABLE_NAME, 

2744 before_value=( 

2745 existing_model_row.model_dump_json(exclude_none=True) 

2746 if isinstance(existing_model_row, BaseModel) 

2747 else None 

2748 ), 

2749 after_value=( 

2750 model_response.model_dump_json(exclude_none=True) 

2751 if isinstance(model_response, BaseModel) 

2752 else None 

2753 ), 

2754 litellm_changed_by=user_api_key_dict.user_id, 

2755 litellm_proxy_admin_name=LITELLM_PROXY_ADMIN_NAME, 

2756 ) 

2757 ) 

2758 

2759 raise_if_reload_degraded_serving( 

2760 before=live_before_reload, 

2761 written_models=[(_model_id, getattr(model_response, "model_info", None))], 

2762 action="update", 

2763 still_desired=reload_outcome.still_desired, 

2764 live_after=reload_outcome.live_after, 

2765 ) 

2766 

2767 return model_response 

2768 except Exception as e: 

2769 verbose_proxy_logger.exception("litellm.proxy.proxy_server.update_model(): Exception occured - %s", e) 

2770 if isinstance(e, HTTPException): 2770 ↛ 2771line 2770 didn't jump to line 2771 because the condition on line 2770 was never true

2771 raise ProxyException( 

2772 message=getattr(e, "detail", f"Authentication Error({e})"), 

2773 type=ProxyErrorTypes.auth_error, 

2774 param=getattr(e, "param", "None"), 

2775 code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), 

2776 ) 

2777 elif isinstance(e, ProxyException): 2777 ↛ 2778line 2777 didn't jump to line 2778 because the condition on line 2777 was never true

2778 raise e 

2779 raise ProxyException( 

2780 message="Authentication Error, " + str(e), 

2781 type=ProxyErrorTypes.auth_error, 

2782 param=getattr(e, "param", "None"), 

2783 code=status.HTTP_400_BAD_REQUEST, 

2784 ) 

2785 

2786 

2787@router.post( 

2788 "/model_group/make_public", 

2789 description="Update which model groups are public", 

2790 tags=["model management"], 

2791 dependencies=[Depends(user_api_key_auth)], 

2792) 

2793async def update_public_model_groups( 

2794 request: UpdatePublicModelGroupsRequest, 

2795 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

2796): 

2797 """ 

2798 Update which model groups are public. 

2799 

2800 This endpoint allows admins to specify which model groups should be publicly accessible. 

2801 Public model groups are visible via the /public/model_hub endpoint. 

2802 

2803 Args: 

2804 request: Request containing list of model group names to make public 

2805 user_api_key_dict: User authentication information 

2806 

2807 Returns: 

2808 Success message with updated public model groups 

2809 

2810 Raises: 

2811 ProxyException: For various error conditions including authentication errors 

2812 """ 

2813 try: 

2814 # Update the public model groups 

2815 from litellm.proxy.proxy_server import proxy_config, store_model_in_db 

2816 

2817 # Check if user has admin permissions 

2818 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 2818 ↛ 2819line 2818 didn't jump to line 2819 because the condition on line 2818 was never true

2819 raise HTTPException( 

2820 status_code=403, 

2821 detail={ 

2822 "error": f"Only proxy admins can update public model groups. Your role={user_api_key_dict.user_role}" 

2823 }, 

2824 ) 

2825 

2826 # Check if STORE_MODEL_IN_DB is enabled 

2827 if store_model_in_db is not True: 2827 ↛ 2828line 2827 didn't jump to line 2828 because the condition on line 2827 was never true

2828 raise HTTPException( 

2829 status_code=500, 

2830 detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."}, 

2831 ) 

2832 

2833 # Load existing config first (this may overwrite in-memory litellm settings 

2834 # from DB values via _update_config_from_db), so set the in-memory value AFTER 

2835 config: Final = await proxy_config.get_config() 

2836 

2837 # Update config with new settings 

2838 if "litellm_settings" not in config: 

2839 config["litellm_settings"] = {} 

2840 

2841 config["litellm_settings"]["public_model_groups"] = request.model_groups 

2842 

2843 # Save the updated config 

2844 await proxy_config.save_config(new_config=config) 

2845 

2846 # Set in-memory value AFTER get_config() and save_config() to avoid 

2847 # get_config() overwriting with stale DB value 

2848 litellm.public_model_groups = request.model_groups 

2849 

2850 verbose_proxy_logger.debug( 

2851 "Updated public model groups to: %s by user: %s", request.model_groups, user_api_key_dict.user_id 

2852 ) 

2853 

2854 return { 

2855 "message": "Successfully updated public model groups", 

2856 "public_model_groups": request.model_groups, 

2857 "updated_by": user_api_key_dict.user_id, 

2858 } 

2859 

2860 except Exception as e: 

2861 verbose_proxy_logger.exception("Error updating public model groups: %s", e) 

2862 

2863 if isinstance(e, HTTPException): 2863 ↛ 2864line 2863 didn't jump to line 2864 because the condition on line 2863 was never true

2864 raise e 

2865 

2866 raise ProxyException( 

2867 message=f"Error updating public model groups: {e}", 

2868 type=ProxyErrorTypes.internal_server_error, 

2869 code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

2870 param=None, 

2871 ) 

2872 

2873 

2874@router.post( 

2875 "/model_hub/update_useful_links", 

2876 description="Update useful links", 

2877 tags=["model management"], 

2878 dependencies=[Depends(user_api_key_auth)], 

2879) 

2880async def update_useful_links( 

2881 request: UpdateUsefulLinksRequest, 

2882 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

2883): 

2884 """ 

2885 Update useful links. 

2886 """ 

2887 try: 

2888 # Update the public model groups 

2889 from litellm.proxy.proxy_server import proxy_config 

2890 

2891 # Check if user has admin permissions 

2892 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 2892 ↛ 2893line 2892 didn't jump to line 2893 because the condition on line 2892 was never true

2893 raise HTTPException( 

2894 status_code=403, 

2895 detail={ 

2896 "error": f"Only proxy admins can update public model groups. Your role={user_api_key_dict.user_role}" 

2897 }, 

2898 ) 

2899 

2900 # Load existing config first (this may overwrite in-memory litellm settings 

2901 # from DB values via _update_config_from_db), so set the in-memory value AFTER 

2902 config: Final = await proxy_config.get_config() 

2903 

2904 # Update config with new settings 

2905 if "litellm_settings" not in config: 2905 ↛ 2906line 2905 didn't jump to line 2906 because the condition on line 2905 was never true

2906 config["litellm_settings"] = {} 

2907 

2908 config["litellm_settings"]["public_model_groups_links"] = request.useful_links 

2909 

2910 # Save the updated config 

2911 await proxy_config.save_config(new_config=config) 

2912 

2913 # Set in-memory value AFTER get_config() and save_config() to avoid 

2914 # get_config() overwriting with stale DB value 

2915 litellm.public_model_groups_links = request.useful_links 

2916 

2917 verbose_proxy_logger.debug( 

2918 "Updated useful links to: %s by user: %s", request.useful_links, user_api_key_dict.user_id 

2919 ) 

2920 

2921 return { 

2922 "message": "Successfully updated useful links", 

2923 "useful_links": request.useful_links, 

2924 "updated_by": user_api_key_dict.user_id, 

2925 } 

2926 

2927 except Exception as e: 

2928 verbose_proxy_logger.exception("Error updating public model groups: %s", e) 

2929 

2930 if isinstance(e, HTTPException): 2930 ↛ 2931line 2930 didn't jump to line 2931 because the condition on line 2930 was never true

2931 raise e 

2932 

2933 raise ProxyException( 

2934 message=f"Error updating public model groups: {e}", 

2935 type=ProxyErrorTypes.internal_server_error, 

2936 code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

2937 param=None, 

2938 ) 

2939 

2940 

2941def _validated_labeled_tiers( 

2942 tier_labels: dict[ComplexityTier, str], # mutable-ok: Pydantic materializes JSON object fields as dicts 

2943) -> tuple[tuple[ComplexityTier, str], ...]: 

2944 """Validate tier labels once for both prompt-preview transports.""" 

2945 try: 

2946 return ComplexityRouterConfig(tier_labels=tier_labels).labeled_tiers() 

2947 except (TypeError, ValidationError) as e: 

2948 raise ProxyException( 

2949 message=f"tier_labels must be a JSON object of tier name to display name: {e}", 

2950 type=ProxyErrorTypes.bad_request_error, 

2951 code=status.HTTP_400_BAD_REQUEST, 

2952 param="tier_labels", 

2953 ) from e 

2954 

2955 

2956def _labeled_tiers_from_query(tier_labels: str | None) -> tuple[tuple[ComplexityTier, str], ...] | None: 

2957 """Resolve the tier_labels query param into the labeled tiers the rubric is built from.""" 

2958 if not tier_labels: 

2959 return None 

2960 try: 

2961 parsed: Final = json.loads(tier_labels) 

2962 except JSONDecodeError as e: 

2963 raise ProxyException( 

2964 message=f"tier_labels must be a JSON object of tier name to display name: {e}", 

2965 type=ProxyErrorTypes.bad_request_error, 

2966 code=status.HTTP_400_BAD_REQUEST, 

2967 param="tier_labels", 

2968 ) from e 

2969 return _validated_labeled_tiers(parsed) 

2970 

2971 

2972class AutoRouterClassifierPromptPreviewRequest(BaseModel): 

2973 """A POST rather than query params: the classification sections are the operator's own text, 

2974 which must not reach access logs through a URL.""" 

2975 

2976 tier_definitions: tuple[TierDefinition, ...] | None = None 

2977 tier_labels: dict[ComplexityTier, str] | None = None # mutable-ok: FastAPI parses JSON object fields into dicts 

2978 classification_rubric: ClassificationRubric | None = None 

2979 context_window_size: Annotated[int, Field(ge=0)] = DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE 

2980 classification_prompt: str | None = None 

2981 classification_examples: str | None = None 

2982 

2983 _normalize_prompt = field_validator("classification_prompt")(normalize_classification_prompt) 

2984 _normalize_examples = field_validator("classification_examples")(normalize_classification_examples) 

2985 

2986 

2987@router.post( 

2988 "/auto_router/classifier/default_prompt", 

2989 description="Get the system prompt an auto-router's LLM classifier sends for an edited tier set", 

2990 tags=["model management"], # mutable-ok: fastapi's decorator signature types tags as a list 

2991 dependencies=[Depends(user_api_key_auth)], # mutable-ok: fastapi's decorator signature types dependencies as a list 

2992) 

2993async def preview_auto_router_classifier_prompt( 

2994 request: AutoRouterClassifierPromptPreviewRequest, 

2995) -> AutoRouterClassifierDefaultPromptResponse: 

2996 """ 

2997 Get the classifier system prompt an edited tier set sends, so the dashboard can show it. 

2998 

2999 Built by the same function the live classifier uses, so the preview cannot drift from what the 

3000 router sends. Payload validity beyond a renderable definition stays the dry-run's job. 

3001 """ 

3002 labeled_tiers: Final = _validated_labeled_tiers(request.tier_labels or {}) # mutable-ok: Pydantic field default 

3003 system_prompt: Final = ( 

3004 custom_tier_classification_prompt( 

3005 request.tier_definitions, 

3006 request.classification_prompt, 

3007 request.context_window_size, 

3008 classification_examples=request.classification_examples, 

3009 ) 

3010 if request.tier_definitions is not None 

3011 else built_in_tier_classification_prompt( 

3012 request.classification_prompt, 

3013 request.context_window_size, 

3014 labeled_tiers=labeled_tiers, 

3015 classification_rubric=request.classification_rubric, 

3016 classification_examples=request.classification_examples, 

3017 ) 

3018 ) 

3019 return AutoRouterClassifierDefaultPromptResponse(system_prompt=system_prompt) 

3020 

3021 

3022@router.get( 

3023 "/auto_router/classifier/default_prompt", 

3024 description="Get the built-in system prompt used by an auto-router's LLM classifier", 

3025 tags=["model management"], # mutable-ok: fastapi's decorator signature types tags as a list 

3026 dependencies=[Depends(user_api_key_auth)], # mutable-ok: fastapi's decorator signature types dependencies as a list 

3027) 

3028async def get_auto_router_classifier_default_prompt( 

3029 context_window_size: int = DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, 

3030 tier_labels: str | None = None, 

3031 classification_rubric: ClassificationRubric | None = None, 

3032) -> AutoRouterClassifierDefaultPromptResponse: 

3033 """ 

3034 Get the classifier system prompt a router would send, so the dashboard can show it. 

3035 

3036 The prompt's closing line depends on whether prior conversation turns are quoted to the 

3037 classifier, its tier bullets are named by the router's tier_labels, and its calibration examples 

3038 come from the router's classification rubric, so the caller passes all three to get the text that router 

3039 would actually send rather than a rubric it does not use. 

3040 

3041 An edited tier set replaces the whole rubric; POST to this path for that prompt, which carries 

3042 the operator's own instructions and so must not ride in a query string. 

3043 

3044 Parameters: 

3045 - context_window_size: int - The router's classifier_context_window_size. Defaults to the 

3046 built-in default. 

3047 - tier_labels: str | None - The router's tier_labels as a JSON object of canonical tier name to 

3048 display name, e.g. `{"SIMPLE": "Cheap"}`. Omit or pass an empty object for the default names. 

3049 - classification_rubric: ClassificationRubric | None - The router's 

3050 classifier_llm_config.classification_rubric. Omit for the default. 

3051 """ 

3052 if context_window_size < 0: 

3053 raise ProxyException( 

3054 message="context_window_size must be non-negative", 

3055 type=ProxyErrorTypes.bad_request_error, 

3056 code=status.HTTP_400_BAD_REQUEST, 

3057 param="context_window_size", 

3058 ) 

3059 

3060 labeled_tiers: Final = _labeled_tiers_from_query(tier_labels) 

3061 return AutoRouterClassifierDefaultPromptResponse( 

3062 system_prompt=( 

3063 classification_system_prompt(context_window_size, classification_rubric=classification_rubric) 

3064 if labeled_tiers is None 

3065 else classification_system_prompt( 

3066 context_window_size, labeled_tiers=labeled_tiers, classification_rubric=classification_rubric 

3067 ) 

3068 ) 

3069 ) 

3070 

3071 

3072def _deduplicate_litellm_router_models(models: list[dict]) -> list[dict]: 

3073 """ 

3074 Deduplicate models based on their model_info.id field. 

3075 Returns a list of unique models keeping only the first occurrence of each model ID. 

3076 

3077 Args: 

3078 models: List of model dictionaries containing model_info 

3079 

3080 Returns: 

3081 List of deduplicated model dictionaries 

3082 """ 

3083 seen_ids: Final = set() 

3084 unique_models: Final = [] 

3085 for model in models: 3085 ↛ 3086line 3085 didn't jump to line 3086 because the loop on line 3085 never started

3086 model_id = model.get("model_info", {}).get("id", None) 

3087 if model_id is not None and model_id not in seen_ids: 

3088 unique_models.append(model) 

3089 seen_ids.add(model_id) 

3090 return unique_models 

3091 

3092 

3093def model_info_as_mapping(model_info: object) -> Mapping[str, object] | None: 

3094 """A DB row's model_info column arrives as a dict or as its JSON string depending on 

3095 the query path, and every consumer needs the mapping. Single owner of that parse: 

3096 returns None when no usable mapping exists (None, an unparseable string, or JSON 

3097 that is not an object), and callers choose what None means for them.""" 

3098 if isinstance(model_info, Mapping): 3098 ↛ 3100line 3098 didn't jump to line 3100 because the condition on line 3098 was always true

3099 return model_info 

3100 if not isinstance(model_info, str): 

3101 return None 

3102 try: 

3103 parsed: Final = json.loads(model_info) 

3104 except (TypeError, ValueError): 

3105 return None 

3106 return parsed if isinstance(parsed, Mapping) else None 

3107 

3108 

3109def _expects_liveness_on_this_pod(model_info: object) -> bool: 

3110 from litellm.router import model_info_is_active_for_environment 

3111 

3112 try: 

3113 return model_info_is_active_for_environment(model_info=model_info_as_mapping(model_info)) 

3114 except ValueError: 

3115 return True 

3116 

3117 

3118def live_model_ids_snapshot() -> frozenset[str]: 

3119 """The ids this pod's router is currently serving, read fresh from the module global 

3120 because a reload can rebind it. The empirical ground truth every verdict below is 

3121 computed from; an absent router serves nothing.""" 

3122 from litellm.proxy.proxy_server import llm_router 

3123 

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

3125 return frozenset() 

3126 return frozenset(llm_router.get_model_ids()) 

3127 

3128 

3129def reload_serving_verdict( 

3130 before: frozenset[str], 

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

3132 written_must_serve: bool, 

3133 still_desired: frozenset[str] | None = None, 

3134 live_after: frozenset[str] | None = None, 

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

3136 """Judge a write-triggered reload by diffing the router's serving state instead of 

3137 trusting any layer of the reload stack to report its own failure. 

3138 

3139 The full cell matrix, per id: 

3140 - written, must-serve (the write's purpose is this model's serving state): live now 

3141 is fine; not live is reported unless the row is deliberately inactive for this 

3142 pod's LITELLM_ENVIRONMENT; a row whose model_info cannot be read counts as 

3143 expecting to serve, so its drop is still reported 

3144 - written, metadata-only (must_not_degrade): live before and gone now is reported; 

3145 a row that was already not serving stays silent, because its deadness predates 

3146 this write and blaming it would block unrelated metadata fixes 

3147 - not written but live before and gone now: collateral degradation of this pod 

3148 caused by the reload this request triggered (a wholesale re-add failure, or a 

3149 newly introduced conflict), reported only when the db still wants that id 

3150 

3151 ``still_desired`` is the db + config id set the reload just reconciled against. An 

3152 id absent from it was deleted on purpose, most often by another pod this one had not 

3153 yet polled, so the reload dropping it is the reconcile working rather than damage. 

3154 Without it (no reconcile ran) every drop is reported, which is the safe direction. 

3155 

3156 ``live_after`` is the router's serving state captured by the reload itself, while it 

3157 still held MODEL_RECONCILE_LOCK. Pass it whenever the caller has it: re-reading the 

3158 router here instead means sampling it after the lock was released, where the NEXT 

3159 reconcile's leading wipe (clear_cache un-serves every db model before reloading 

3160 them) shows up as this reload having dropped them. Falling back to a fresh read is 

3161 only correct when no reconcile ran and there is nothing to be concurrent with. 

3162 

3163 Returns (written ids violating their obligation, collateral ids no longer served). 

3164 """ 

3165 now: Final = live_model_ids_snapshot() if live_after is None else live_after 

3166 written_ids: Final = frozenset(model_id for model_id, _ in written_models) 

3167 if written_must_serve: 3167 ↛ 3174line 3167 didn't jump to line 3174 because the condition on line 3167 was always true

3168 missing = tuple( 

3169 model_id 

3170 for model_id, model_info in written_models 

3171 if model_id not in now and _expects_liveness_on_this_pod(model_info) 

3172 ) 

3173 else: 

3174 missing = tuple(model_id for model_id, _ in written_models if model_id in before and model_id not in now) 

3175 dropped: Final = before - now - written_ids 

3176 collateral: Final = tuple(sorted(dropped if still_desired is None else dropped & still_desired)) 

3177 return (missing, collateral) 

3178 

3179 

3180def raise_if_reload_degraded_serving( 

3181 before: frozenset[str], 

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

3183 action: str, 

3184 still_desired: frozenset[str] | None = None, 

3185 live_after: frozenset[str] | None = None, 

3186) -> None: 

3187 """The caller-visible error this pod's model-write endpoints owe their caller when 

3188 the model they wrote is not being served after the reload they triggered. The DB 

3189 write is durable either way and every other pod reloads on its own interval; this 

3190 speaks only for the handling pod. 

3191 

3192 Callers hold a ReconcileOutcome from the reload; pass BOTH of its fields. Supplying 

3193 still_desired without live_after mixes a snapshot taken under the reconcile lock 

3194 with one taken after it was released, which is what makes a concurrent model write 

3195 look like collateral damage.""" 

3196 missing, collateral = reload_serving_verdict( 

3197 before=before, 

3198 written_models=written_models, 

3199 written_must_serve=True, 

3200 still_desired=still_desired, 

3201 live_after=live_after, 

3202 ) 

3203 if not missing and not collateral: 3203 ↛ 3204line 3203 didn't jump to line 3204 because the condition on line 3203 was never true

3204 return 

3205 missing_clause: Final = ( 

3206 f"the model id(s) {list(missing)} are not live in this pod's router after the reload and are not " 

3207 "being served by this pod." 

3208 if missing 

3209 else "the reload it triggered degraded this pod's serving state." 

3210 ) 

3211 collateral_clause: Final = ( 

3212 f" Previously served model id(s) {list(collateral)} are also no longer being served by this pod." 

3213 if collateral 

3214 else "" 

3215 ) 

3216 raise ProxyException( 

3217 message=( 

3218 f"Model {action} was saved to the database, but {missing_clause}{collateral_clause} " 

3219 "Other pods reload on their own interval. Check server logs for 'Error upserting deployment' or " 

3220 "'Error creating deployment' for the cause." 

3221 ), 

3222 type=ProxyErrorTypes.internal_server_error, 

3223 code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

3224 param=None, 

3225 ) 

3226 

3227 

3228async def clear_cache() -> ReconcileOutcome: 

3229 """ 

3230 Clear router caches and reload models. 

3231 

3232 Returns what the reload saw (see ReconcileOutcome) so callers can pass it to 

3233 raise_if_reload_degraded_serving. 

3234 

3235 Runs under MODEL_RECONCILE_LOCK for its whole extent, not just the reload at the 

3236 end, so the auto-router reset and the reload that rebuilds those routers are atomic 

3237 to any other reconcile. The inner call is _add_deployment_locked because 

3238 add_deployment would re-acquire the same non-reentrant lock and deadlock. 

3239 """ 

3240 from litellm.proxy.proxy_server import ( 

3241 MODEL_RECONCILE_LOCK, 

3242 llm_router, 

3243 prisma_client, 

3244 proxy_config, 

3245 proxy_logging_obj, 

3246 verbose_proxy_logger, 

3247 ) 

3248 

3249 if llm_router is None or prisma_client is None: 

3250 verbose_proxy_logger.debug("llm_router or prisma_client is None, skipping cache clear") 

3251 return ReconcileOutcome(still_desired=None, live_after=None) 

3252 

3253 async with MODEL_RECONCILE_LOCK: 

3254 try: 

3255 # Only clear DB models, preserve config models 

3256 verbose_proxy_logger.debug("Clearing only DB models, preserving config models") 

3257 

3258 # Get current models and filter out DB models 

3259 current_models: Final = llm_router.model_list.copy() 

3260 config_models: Final = [] 

3261 db_model_ids: Final = [] 

3262 

3263 db_router_names: Final = set() 

3264 

3265 for model in current_models: 

3266 model_info = model.get("model_info", {}) 

3267 if model_info.get("db_model", False): 

3268 db_model_ids.append(model_info.get("id")) 

3269 # Auto-router deployments (and only those) are wiped here, in the 

3270 # same pass, so the reload rebuilds them -- see the comment below. 

3271 model_name = model.get("model_name") 

3272 if model_name is not None and str(model.get("litellm_params", {}).get("model", "")).startswith( 

3273 "auto_router/" 

3274 ): 

3275 db_router_names.add(model_name) 

3276 router_model_id = model_info.get("id") 

3277 if router_model_id is not None: 

3278 llm_router.delete_deployment(id=router_model_id) 

3279 else: 

3280 # This is a config model, preserved by the reconcile below 

3281 config_models.append(model) 

3282 

3283 # ORDINARY db deployments are deliberately NOT wiped. This used to 

3284 # delete_deployment() every db model before the reload put them back, which 

3285 # left the router serving ZERO db models for the whole width of the reload 

3286 # -- a real data-plane hole that every inference request landing in it fell 

3287 # into. It was also redundant for them: the reload's _delete_deployment 

3288 # evicts exactly the ids the db no longer lists, and upsert_deployment 

3289 # pops-and-re-adds a deployment whose params changed while no-opping one 

3290 # that did not, so the reconcile converges on its own. Every mutation is 

3291 # visible to that comparison -- `blocked` and (for premium) `updated_at` 

3292 # are written into model_info. 

3293 # 

3294 # AUTO-ROUTER db deployments are the exception and ARE wiped -- in the 

3295 # classification pass above, together with the strategy entries popped 

3296 # just below. Their strategy registries are keyed 

3297 # by model_name, which no deployment-id reconcile touches, so they have to 

3298 # be popped and rebuilt here. But the rebuild only happens on the ADD path: 

3299 # Router.upsert_deployment returns early when a deployment is unchanged and 

3300 # never reaches add_deployment -> _add_deployment -> 

3301 # init_auto_router_deployment, which is what repopulates the registries. 

3302 # Popping without deleting would therefore strip every db-backed auto, 

3303 # complexity, adaptive and quality router on this pod and never put it back, 

3304 # so ANY unrelated model write would leave them unroutable until a restart. 

3305 # Deleting the deployment forces upsert down the add path, which rebuilds 

3306 # both the deployment and its strategy entry. 

3307 # 

3308 # That pass restricts the wipe to deployments whose model is actually an 

3309 # auto_router/* so a config router that merely shares a model_name with a 

3310 # regular db model isn't evicted -- config routers are never re-added by the 

3311 # reload (it only reloads db models) and would be permanently unroutable. 

3312 # The auto_router/ prefix also covers quality_router/ and adaptive_router/, 

3313 # so pop the name from every registry (no-op where absent); a missing 

3314 # quality/adaptive entry would otherwise make init raise "already exists" 

3315 # on reload and abort it. 

3316 for model_name in db_router_names: 

3317 llm_router.auto_routers.pop(model_name, None) 

3318 llm_router.complexity_routers.pop(model_name, None) 

3319 llm_router.adaptive_routers.pop(model_name, None) 

3320 llm_router.quality_routers.pop(model_name, None) 

3321 

3322 # Reload only DB models. _add_deployment_locked, not add_deployment: this 

3323 # coroutine already holds MODEL_RECONCILE_LOCK and asyncio.Lock is not 

3324 # reentrant, so the public wrapper would deadlock against itself. 

3325 outcome: Final = await proxy_config._add_deployment_locked( 

3326 prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj 

3327 ) 

3328 

3329 verbose_proxy_logger.debug( 

3330 "Reconciled %s DB models, preserved %s config models", len(db_model_ids), len(config_models) 

3331 ) 

3332 return outcome 

3333 except Exception as e: 

3334 verbose_proxy_logger.exception("Failed to clear cache and reload models. Due to error - %s", e) 

3335 return ReconcileOutcome(still_desired=None, live_after=None)