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

232 statements  

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

1""" 

2CACHE SETTINGS MANAGEMENT 

3 

4Endpoints for managing cache configuration 

5 

6GET /cache/settings - Get cache configuration including available settings 

7POST /cache/settings/test - Test cache connection with provided credentials 

8POST /cache/settings - Save cache settings to database 

9""" 

10 

11import asyncio 

12import json 

13from collections.abc import Mapping 

14from datetime import datetime, timezone 

15from typing import TYPE_CHECKING, Any, Final, Protocol 

16 

17from fastapi import APIRouter, Depends, Header, HTTPException 

18from pydantic import BaseModel, Field, TypeAdapter 

19 

20from litellm._logging import verbose_proxy_logger 

21from litellm._redis import _redis_kwargs_from_environment 

22from litellm._uuid import uuid 

23from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker 

24from litellm.proxy._types import ( 

25 AUDIT_ACTIONS, 

26 LiteLLM_AuditLogs, 

27 LitellmTableNames, 

28 UserAPIKeyAuth, 

29) 

30from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

31from litellm.proxy.db.exception_handler import call_with_db_reconnect_retry 

32from litellm.repositories.table_repositories import CacheConfigRepository 

33from litellm.types.management_endpoints import ( 

34 CACHE_SETTINGS_FIELDS, 

35 REDIS_TYPE_DESCRIPTIONS, 

36 CacheSettingsField, 

37) 

38 

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

40 from litellm.proxy.utils import PrismaClient 

41 

42router: Final = APIRouter() 

43 

44_STORED_CACHE_SETTINGS_ADAPTER: Final = TypeAdapter(dict[str, object]) 

45 

46 

47class _CacheConfigRow(Protocol): 

48 @property 

49 def cache_settings(self) -> str | Mapping[str, object] | None: ... 49 ↛ exitline 49 didn't return from function 'cache_settings' because

50 

51 

52class _CacheConfigTable(Protocol): 

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

54 

55 async def upsert(self, where: Mapping[str, str], data: Mapping[str, Mapping[str, str]]) -> _CacheConfigRow: ... 55 ↛ exitline 55 didn't return from function 'upsert' because

56 

57 

58def _cache_config_table(prisma_client: "PrismaClient") -> _CacheConfigTable: 

59 return CacheConfigRepository(prisma_client).table 

60 

61 

62# Cache fields holding credentials. Masked on read so plaintext Redis / 

63# Sentinel passwords never leave the server in a GET response. `url` is here 

64# because a Redis/Valkey URL can embed a password inline 

65# (e.g. redis://:secret@host:6379/1). 

66_CACHE_SENSITIVE_FIELDS: Final[set[str]] = {"password", "sentinel_password", "url"} 

67 

68# The env fallback resolves the full set of redis.Redis kwargs, which includes 

69# credential-bearing params (azure_client_secret, ssl_password, ...) that are 

70# not cache UI fields. Only overlay fields the settings page actually renders, 

71# so the read never surfaces a credential the UI does not manage. 

72_CACHE_SETTINGS_FIELD_NAMES: Final[frozenset[str]] = frozenset(field.field_name for field in CACHE_SETTINGS_FIELDS) 

73 

74# Classifier used, alongside _CACHE_SENSITIVE_FIELDS, to redact any 

75# credential-bearing key before it leaves the server (`url` is kept in the 

76# explicit set because its name carries no sensitive segment). 

77_CREDENTIAL_CLASSIFIER: Final = SensitiveDataMasker() 

78 

79 

80_REDACTED_VALUE: Final = "***REDACTED***" 

81 

82 

83_URL_OVERRIDDEN_CONNECTION_FIELDS: Final[frozenset[str]] = frozenset({"host", "port", "db", "password", "username"}) 

84 

85 

86def _resolve_cache_url_precedence(settings: Mapping[str, object]) -> dict[str, Any]: 

87 """Return cache settings with the url-vs-discrete-fields ambiguity resolved. 

88 

89 When a full ``url`` is supplied it wins: the discrete 

90 host/port/db/password/username fields are dropped so the persisted config 

91 is unambiguous and matches runtime resolution in ``litellm._redis`` 

92 (``redis.Redis.from_url`` ignores them). Cluster mode 

93 (``redis_startup_nodes``) is exempt because it authenticates via the 

94 discrete fields rather than a url. 

95 """ 

96 url: Final = settings.get("url") 

97 has_url: Final = isinstance(url, str) and url.strip() != "" 

98 if not has_url or settings.get("redis_startup_nodes"): 98 ↛ 100line 98 didn't jump to line 100 because the condition on line 98 was always true

99 return dict(settings) 

100 return {k: v for k, v in settings.items() if k not in _URL_OVERRIDDEN_CONNECTION_FIELDS} 

101 

102 

103def _parse_stored_settings(cache_settings_value: object) -> dict[str, object]: 

104 """Normalize a stored cache_settings blob to a dict. 

105 

106 The prisma column comes back as either a JSON string or an already-parsed 

107 dict depending on the client, so callers that json.loads unconditionally 

108 silently drop the whole (still-encrypted) row on the dict path. 

109 """ 

110 parsed: Final = json.loads(cache_settings_value) if isinstance(cache_settings_value, str) else cache_settings_value 

111 return parsed if isinstance(parsed, dict) else {} 

112 

113 

114def _overlay_environment(stored: Mapping[str, object]) -> dict[str, object]: 

115 """Fill connection fields from the REDIS_* environment the cache actually reads. 

116 

117 A response cache pointed at Redis resolves host/port/password/etc. from the 

118 REDIS_* env vars when the stored config leaves them unset, so a cache 

119 configured purely through the environment works while its settings page, 

120 which reads only the database row, shows blank. Overlaying the same env 

121 kwargs the runtime uses makes the page reflect the effective connection. 

122 Stored values win; the environment only fills what the stored config omits. 

123 """ 

124 env_kwargs: Final = { 

125 key: value for key, value in _redis_kwargs_from_environment().items() if key in _CACHE_SETTINGS_FIELD_NAMES 

126 } 

127 if not env_kwargs: 127 ↛ 129line 127 didn't jump to line 129 because the condition on line 127 was always true

128 return dict(stored) 

129 effective: Final = {**env_kwargs, **stored} 

130 # the env fallback is a Redis connection, so name the type when the stored 

131 # config did not, letting the UI render the Redis fields it just populated 

132 effective.setdefault("type", "redis") 

133 return effective 

134 

135 

136def _redact_credentials(settings: Mapping[str, object]) -> dict[str, object]: 

137 """Replace credential-bearing values with a fixed marker, keeping the rest. 

138 

139 The marker is unambiguous on the way back in: an admin who edits an 

140 unrelated field and re-submits sends the marker for the untouched secret, 

141 which the update path maps back to the stored value rather than persisting 

142 the marker over a working password. 

143 """ 

144 return { 

145 key: (_REDACTED_VALUE if value is not None and _is_credential_field(key) else value) 

146 for key, value in settings.items() 

147 } 

148 

149 

150def _is_credential_field(key: str) -> bool: 

151 """Whether a cache setting carries a credential and must be redacted on read.""" 

152 return key in _CACHE_SENSITIVE_FIELDS or _CREDENTIAL_CLASSIFIER.is_sensitive_key(key) 

153 

154 

155def _has_connection_target(value: object) -> bool: 

156 """Whether a payload value names a live discrete connection target.""" 

157 if isinstance(value, str): 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 return value.strip() != "" and value != _REDACTED_VALUE 

159 return value not in (None, [], {}) 

160 

161 

162# Every field that identifies which Redis a credential belongs to, across node 

163# (host/port/url), cluster (redis_startup_nodes), and sentinel 

164# (sentinel_nodes/service_name) modes. A stored secret is bound to these. 

165_CONNECTION_TARGET_FIELDS: Final[tuple[str, ...]] = ( 

166 "host", 

167 "port", 

168 "url", 

169 "redis_startup_nodes", 

170 "sentinel_nodes", 

171 "service_name", 

172) 

173 

174 

175def _target_repr(value: object) -> str: 

176 """Canonical string form of a connection-target value for equality checks. 

177 

178 The client may serialize the same target differently from storage (a port as 

179 "6379" vs 6379, node lists round-tripped through JSON), so compare normalized 

180 forms rather than raw values to avoid treating an unchanged target as a change. 

181 """ 

182 if isinstance(value, (list, dict)): 

183 return json.dumps(value, sort_keys=True, default=str) 

184 return str(value) 

185 

186 

187def _saved_secret_is_reusable(incoming: Mapping[str, object], saved: Mapping[str, object]) -> bool: 

188 """Whether a stored credential may be restored for this request. 

189 

190 A stored secret belongs to the stored connection target, so it is reused only 

191 when the request describes that same target on every dimension the stored 

192 config pins (host/port, url, cluster nodes, sentinel nodes/service). This 

193 prevents credential replay: a caller cannot omit the credential, point at a 

194 different (or incomplete) target, and have the proxy send the stored secret 

195 to a Redis of their choosing. 

196 

197 Non-secret target fields (host/port/nodes/service) must be supplied and match 

198 in normalized form, so equivalent representations (port "6379" vs 6379) are 

199 not seen as a change while an omitted or different value is. ``url`` is the 

200 exception: it is itself the secret and the form never re-prefills it, so a 

201 redacted or omitted url means "keep the stored url" (same target) and only a 

202 different supplied url blocks reuse. 

203 """ 

204 for field in _CONNECTION_TARGET_FIELDS: 

205 saved_value = saved.get(field) 

206 if saved_value in (None, "", [], {}): 206 ↛ 208line 206 didn't jump to line 208 because the condition on line 206 was always true

207 continue # the stored config does not pin this dimension 

208 incoming_value = incoming.get(field) 

209 if field == "url": 

210 if incoming_value in (None, "", _REDACTED_VALUE): 

211 continue # url kept as-is (same target) 

212 if _target_repr(incoming_value) != _target_repr(saved_value): 

213 return False 

214 continue 

215 if _target_repr(incoming_value) != _target_repr(saved_value): 

216 return False # a pinned target field is missing or different 

217 return True 

218 

219 

220def _merge_over_saved(incoming: Mapping[str, object], saved: Mapping[str, object]) -> Mapping[str, object]: 

221 """Keep the stored secret behind any credential the caller echoed back redacted or omitted. 

222 

223 GET returns credentials as the marker and the form never re-prefills a 

224 secret, so a save that does not touch a credential arrives with the marker 

225 or with the field absent. Either way the real secret must survive: it is 

226 restored from the stored row, or dropped when there is no stored row (the 

227 value is env-sourced and the marker must never be persisted). Non-secret 

228 fields are taken from the incoming payload as-is, so clearing one still works. 

229 

230 ``url`` is the exception: it is credential-bearing (redacted) yet also a 

231 connection-mode selector that url-precedence resolves against host/port. If 

232 the caller supplies a discrete target (host, cluster, or sentinel nodes), a 

233 stored url is a stale mode the caller is leaving, so it is dropped rather 

234 than restored, otherwise url-precedence would resurrect it and discard the 

235 submitted host/port. 

236 """ 

237 switching_to_discrete_target: Final = ( 

238 _has_connection_target(incoming.get("host")) 

239 or _has_connection_target(incoming.get("redis_startup_nodes")) 

240 or _has_connection_target(incoming.get("sentinel_nodes")) 

241 ) 

242 reuse_saved_secret: Final = _saved_secret_is_reusable(incoming, saved) 

243 merged: Final = dict(incoming) 

244 for field in _CACHE_SENSITIVE_FIELDS: 

245 # A value the caller explicitly supplied is honored verbatim: a new 

246 # secret, or an empty string / null to clear the stored one. Only an 

247 # omitted field or the echoed-back marker triggers preserve-or-drop. 

248 if field in incoming and incoming[field] != _REDACTED_VALUE: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 continue 

250 if field == "url" and switching_to_discrete_target: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true

251 merged.pop(field, None) 

252 continue 

253 if field in saved and reuse_saved_secret: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 merged[field] = saved[field] 

255 else: 

256 # nothing stored to reuse, or the caller is pointing at a different 

257 # target: never persist/replay the marker or the stored secret 

258 merged.pop(field, None) 

259 return merged 

260 

261 

262def _redact_settings(settings: Mapping[str, object] | None) -> dict[str, object]: 

263 """Replace every value in a settings map with a fixed marker. 

264 

265 Cache config carries Redis credentials (passwords, connection strings). 

266 The audit-log row preserves the field names so a reader can see *which* 

267 fields changed, but values are stripped so the audit table can't itself 

268 become a credential-harvest sink. 

269 """ 

270 if not settings: 

271 return {} 

272 return {k: _REDACTED_VALUE for k in settings} 

273 

274 

275def _log_audit_task_exception(task: "asyncio.Task[None]") -> None: 

276 """Surface a fire-and-forget audit-log task failure as a warning. 

277 

278 ``asyncio.create_task`` swallows exceptions silently — if the audit 

279 write fails we'd otherwise lose the row without any signal. 

280 """ 

281 if task.cancelled(): 

282 return 

283 exc: Final = task.exception() 

284 if exc is not None: 

285 verbose_proxy_logger.warning("Failed to write cache-settings audit log: %s", exc) 

286 

287 

288async def _emit_cache_settings_audit_log( 

289 *, 

290 action: AUDIT_ACTIONS, 

291 before_settings: Mapping[str, object] | None, 

292 after_settings: Mapping[str, object] | None, 

293 user_api_key_dict: UserAPIKeyAuth, 

294 litellm_changed_by: str | None, 

295) -> None: 

296 """Emit an audit-log row for a /cache/settings mutation. 

297 

298 Mirrors the ``store_audit_logs``-gated pattern used in 

299 ``team_callback_endpoints.py``: fire-and-forget, no-op when audit 

300 logging is disabled, with a done-callback that surfaces any task 

301 exception. Captured under ``LiteLLM_CacheConfig`` so the row 

302 co-locates with the table it mutates. 

303 """ 

304 from litellm.proxy.management_helpers.audit_logs import ( 

305 create_audit_log_for_update, 

306 is_audit_logging_enabled, 

307 ) 

308 from litellm.proxy.proxy_server import litellm_proxy_admin_name 

309 

310 if not is_audit_logging_enabled(): 310 ↛ 313line 310 didn't jump to line 313 because the condition on line 310 was always true

311 return 

312 

313 task: Final = asyncio.create_task( 

314 create_audit_log_for_update( 

315 request_data=LiteLLM_AuditLogs( 

316 id=str(uuid.uuid4()), 

317 updated_at=datetime.now(timezone.utc), 

318 changed_by=litellm_changed_by or user_api_key_dict.user_id or litellm_proxy_admin_name, 

319 changed_by_api_key=user_api_key_dict.api_key, 

320 table_name=LitellmTableNames.CACHE_CONFIG_TABLE_NAME, 

321 object_id="cache_config", 

322 action=action, 

323 updated_values=json.dumps({"settings": _redact_settings(after_settings)}, default=str), 

324 before_value=json.dumps({"settings": _redact_settings(before_settings)}, default=str), 

325 ) 

326 ) 

327 ) 

328 task.add_done_callback(_log_audit_task_exception) 

329 

330 

331class CacheSettingsManager: 

332 """ 

333 Manages cache settings initialization and updates. 

334 Tracks last cache params to avoid unnecessary reinitialization. 

335 """ 

336 

337 _last_cache_params: dict[str, object] | None = None 

338 

339 @staticmethod 

340 def _cache_params_equal(params1: dict[str, object], params2: dict[str, object]) -> bool: 

341 """ 

342 Compare two cache parameter dictionaries for equality. 

343 Normalizes values and filters out UI-only fields. 

344 """ 

345 

346 # Normalize by removing None values and UI-only fields 

347 def normalize(params: dict[str, object]) -> dict[str, object]: 

348 normalized: Final = {} 

349 for k, v in params.items(): 

350 if k == "redis_type": # Skip UI-only field 350 ↛ 351line 350 didn't jump to line 351 because the condition on line 350 was never true

351 continue 

352 if v is not None: 

353 # Convert to string for comparison to handle different types 

354 normalized[k] = str(v) if not isinstance(v, (list, dict)) else v 

355 return normalized 

356 

357 normalized1: Final = normalize(params1) 

358 normalized2: Final = normalize(params2) 

359 

360 return normalized1 == normalized2 

361 

362 @staticmethod 

363 async def init_cache_settings_in_db(prisma_client: "PrismaClient", proxy_config): 

364 """ 

365 Initialize cache settings from database into the router on startup. 

366 Only reinitializes if cache params have changed. 

367 """ 

368 try: 

369 cache_config: Final = await call_with_db_reconnect_retry( 

370 prisma_client, 

371 lambda: _cache_config_table(prisma_client).find_unique(where={"id": "cache_config"}), 

372 reason="init_cache_settings_in_db_lookup_failure", 

373 ) 

374 if cache_config is not None and cache_config.cache_settings: 

375 # Parse cache settings JSON 

376 cache_settings_json: Final = cache_config.cache_settings 

377 cache_settings_dict: Final[dict[str, object]] = ( 

378 _STORED_CACHE_SETTINGS_ADAPTER.validate_json(cache_settings_json) 

379 if isinstance(cache_settings_json, str) 

380 else dict(cache_settings_json) 

381 ) 

382 

383 # Decrypt cache settings 

384 decrypted_settings: Final = proxy_config._decrypt_db_variables(variables_dict=cache_settings_dict) 

385 

386 # Remove redis_type if present (UI-only field, not a Cache parameter) 

387 # We derive it for UI in get_cache_settings endpoint 

388 cache_params: Final = {k: v for k, v in decrypted_settings.items() if k != "redis_type"} 

389 

390 # Check if cache params have changed 

391 if CacheSettingsManager._last_cache_params is not None and CacheSettingsManager._cache_params_equal( 391 ↛ 398line 391 didn't jump to line 398 because the condition on line 391 was always true

392 CacheSettingsManager._last_cache_params, cache_params 

393 ): 

394 verbose_proxy_logger.debug("Cache settings unchanged, skipping reinitialization") 

395 return 

396 

397 # Initialize cache only if params changed or cache not initialized 

398 proxy_config._init_cache(cache_params=cache_params) 

399 

400 # Store the params we just initialized 

401 CacheSettingsManager._last_cache_params = cache_params.copy() 

402 

403 # Switch on LLM response caching 

404 proxy_config.switch_on_llm_response_caching() 

405 

406 verbose_proxy_logger.info("Cache settings initialized from database") 

407 except Exception as e: 

408 verbose_proxy_logger.exception( 

409 "litellm.proxy.management_endpoints.cache_settings_endpoints.py::CacheSettingsManager::init_cache_settings_in_db - %s", 

410 e, 

411 ) 

412 

413 @staticmethod 

414 def update_cache_params(cache_params: dict[str, object]): 

415 """ 

416 Update the last cache params after initialization. 

417 Called after cache settings are updated via the API. 

418 """ 

419 CacheSettingsManager._last_cache_params = cache_params.copy() 

420 

421 

422class CacheSettingsResponse(BaseModel): 

423 fields: list[CacheSettingsField] = Field(description="List of all configurable cache settings with metadata") 

424 current_values: dict[str, object] = Field(description="Current values of cache settings") 

425 redis_type_descriptions: dict[str, str] = Field(description="Descriptions for each Redis type option") 

426 

427 

428class CacheTestRequest(BaseModel): 

429 cache_settings: dict[str, object] = Field(description="Cache settings to test connection with") 

430 

431 

432class CacheTestResponse(BaseModel): 

433 status: str = Field(description="Connection status: 'success' or 'failed'") 

434 message: str = Field(description="Connection result message") 

435 error: str | None = Field(default=None, description="Error message if connection failed") 

436 

437 

438class CacheSettingsUpdateRequest(BaseModel): 

439 cache_settings: dict[str, object] = Field(description="Cache settings to save") 

440 

441 

442@router.get( 

443 "/cache/settings", 

444 tags=["Cache Settings"], 

445 dependencies=[Depends(user_api_key_auth)], 

446 response_model=CacheSettingsResponse, 

447) 

448async def get_cache_settings( 

449 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

450): 

451 """ 

452 Get cache configuration and available settings. 

453 

454 Returns: 

455 - fields: List of all configurable cache settings with their metadata (type, description, default, options) 

456 - current_values: Current values of cache settings from database 

457 """ 

458 from litellm.proxy.proxy_server import prisma_client, proxy_config 

459 

460 try: 

461 # Get cache settings fields from types file 

462 cache_fields: Final = [field.model_copy(deep=True) for field in CACHE_SETTINGS_FIELDS] 

463 

464 # Read the stored settings (decrypted); an env-only cache has none. 

465 stored: dict[str, object] = {} 

466 if prisma_client is not None: 466 ↛ 477line 466 didn't jump to line 477 because the condition on line 466 was always true

467 cache_config = await _cache_config_table(prisma_client).find_unique(where={"id": "cache_config"}) 

468 if cache_config is not None and cache_config.cache_settings: 

469 stored = proxy_config._decrypt_db_variables( 

470 variables_dict=_parse_stored_settings(cache_config.cache_settings) 

471 ) 

472 

473 # Fill connection fields from the REDIS_* environment the cache resolves 

474 # from when the stored config leaves them unset, then apply url precedence 

475 # so a url-mode config does not surface conflicting discrete fields (which 

476 # would otherwise let a no-op save silently switch it to host/port). 

477 effective: Final = _resolve_cache_url_precedence(_overlay_environment(stored)) 

478 

479 # Derive redis_type for UI based on settings 

480 # UI uses redis_type to show/hide fields, backend only stores 'type' 

481 if effective.get("type") == "redis": 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true

482 if effective.get("redis_startup_nodes"): 

483 effective["redis_type"] = "cluster" 

484 elif effective.get("sentinel_nodes"): 

485 effective["redis_type"] = "sentinel" 

486 else: 

487 effective["redis_type"] = "node" 

488 

489 # Redact credential fields so the GET response never carries a plaintext 

490 # Redis / Sentinel password off the server. 

491 current_values: Final = _redact_credentials(effective) 

492 

493 # Update field values with current values 

494 for field in cache_fields: 

495 if field.field_name in current_values: 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true

496 field.field_value = current_values[field.field_name] 

497 

498 return CacheSettingsResponse( 

499 fields=cache_fields, 

500 current_values=current_values, 

501 redis_type_descriptions=REDIS_TYPE_DESCRIPTIONS, 

502 ) 

503 except Exception as e: 

504 verbose_proxy_logger.error("Error fetching cache settings: %s", e) 

505 raise HTTPException(status_code=500, detail=f"Error fetching cache settings: {e}") 

506 

507 

508@router.post( 

509 "/cache/settings/test", 

510 tags=["Cache Settings"], 

511 dependencies=[Depends(user_api_key_auth)], 

512 response_model=CacheTestResponse, 

513) 

514async def test_cache_connection( 

515 request: CacheTestRequest, 

516 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

517): 

518 """ 

519 Test cache connection with provided credentials. 

520 

521 Creates a temporary cache instance and uses its test_connection method 

522 to verify the credentials work without affecting global state. 

523 """ 

524 from litellm import Cache 

525 from litellm.proxy.proxy_server import prisma_client, proxy_config 

526 

527 try: 

528 # A credential the form left untouched arrives redacted; resolve it back 

529 # to the stored secret so the test connects with the real password. A 

530 # lookup failure must not block the test, so fall back to no stored row. 

531 saved_settings: dict[str, object] = {} 

532 if prisma_client is not None: 532 ↛ 541line 532 didn't jump to line 541 because the condition on line 532 was always true

533 try: 

534 existing_row: Final = await _cache_config_table(prisma_client).find_unique(where={"id": "cache_config"}) 

535 if existing_row is not None and existing_row.cache_settings: 

536 saved_settings = proxy_config._decrypt_db_variables( 

537 variables_dict=_parse_stored_settings(existing_row.cache_settings) 

538 ) 

539 except Exception: # noqa: BLE001 - a saved-settings lookup failure must not block a connection test 

540 saved_settings = {} 

541 cache_settings: Final = _resolve_cache_url_precedence(_merge_over_saved(request.cache_settings, saved_settings)) 

542 # cache_settings now carries the resolved plaintext credential; never log it raw 

543 verbose_proxy_logger.debug("Testing cache connection with settings: %s", _redact_credentials(cache_settings)) 

544 

545 # Only support Redis for now 

546 if cache_settings.get("type") != "redis": 546 ↛ 553line 546 didn't jump to line 553 because the condition on line 546 was always true

547 return CacheTestResponse( 

548 status="failed", 

549 message="Only Redis cache type is currently supported for testing", 

550 ) 

551 

552 # Create temporary cache instance 

553 temp_cache: Final = Cache(**cache_settings) 

554 

555 # Use the cache's test_connection method 

556 result: Final = await temp_cache.cache.test_connection() 

557 

558 return CacheTestResponse(**result) 

559 

560 except Exception as e: 

561 verbose_proxy_logger.error("Error testing cache connection: %s", e) 

562 return CacheTestResponse( 

563 status="failed", 

564 message=f"Cache connection test failed: {e}", 

565 error=str(e), 

566 ) 

567 

568 

569@router.post( 

570 "/cache/settings", 

571 tags=["Cache Settings"], 

572 dependencies=[Depends(user_api_key_auth)], 

573) 

574async def update_cache_settings( 

575 request: CacheSettingsUpdateRequest, 

576 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

577 litellm_changed_by: str | None = Header( 

578 None, 

579 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", 

580 ), 

581): 

582 """ 

583 Save cache settings to database and initialize cache. 

584 

585 This endpoint: 

586 1. Encrypts sensitive fields (passwords, etc.) 

587 2. Saves to LiteLLM_CacheConfig table 

588 3. Reinitializes cache with new settings 

589 """ 

590 from litellm.proxy.proxy_server import ( 

591 prisma_client, 

592 proxy_config, 

593 store_model_in_db, 

594 ) 

595 

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

597 raise HTTPException( 

598 status_code=500, 

599 detail={"error": "Database not connected. Please connect a database."}, 

600 ) 

601 

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

603 raise HTTPException( 

604 status_code=500, 

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

606 ) 

607 

608 try: 

609 # Read the stored row first: its decrypted values back any credential the 

610 # caller echoed back redacted, and its key set drives the audit diff. 

611 existing_row: Final = await _cache_config_table(prisma_client).find_unique(where={"id": "cache_config"}) 

612 before_settings: dict[str, object] | None = None 

613 saved_settings: dict[str, object] = {} 

614 if existing_row is not None and existing_row.cache_settings: 

615 before_settings = _parse_stored_settings(existing_row.cache_settings) 

616 saved_settings = proxy_config._decrypt_db_variables(variables_dict=before_settings) 

617 action: Final[AUDIT_ACTIONS] = "updated" if existing_row is not None else "created" 

618 

619 # Preserve stored secrets behind any redacted or omitted credential, then 

620 # resolve the url-vs-discrete-fields precedence. 

621 cache_settings: Final = _resolve_cache_url_precedence(_merge_over_saved(request.cache_settings, saved_settings)) 

622 

623 # Encrypt sensitive fields (keep redis_type for storage) 

624 encrypted_settings: Final = proxy_config._encrypt_env_variables(environment_variables=cache_settings) 

625 

626 # Save to database 

627 await _cache_config_table(prisma_client).upsert( 

628 where={"id": "cache_config"}, 

629 data={ 

630 "create": { 

631 "id": "cache_config", 

632 "cache_settings": json.dumps(encrypted_settings), 

633 }, 

634 "update": { 

635 "cache_settings": json.dumps(encrypted_settings), 

636 }, 

637 }, 

638 ) 

639 

640 # Reinitialize cache with new settings 

641 # Decrypt for initialization 

642 decrypted_settings: Final = proxy_config._decrypt_db_variables(variables_dict=encrypted_settings) 

643 

644 # Remove redis_type if present (UI-only field, not a Cache parameter) 

645 cache_params: Final = {k: v for k, v in decrypted_settings.items() if k != "redis_type"} 

646 

647 # Initialize cache (frontend sends type="redis", not redis_type) 

648 proxy_config._init_cache(cache_params=cache_params) 

649 

650 # Update the last cache params to avoid reinitializing unnecessarily 

651 CacheSettingsManager.update_cache_params(cache_params) 

652 

653 # Switch on LLM response caching 

654 proxy_config.switch_on_llm_response_caching() 

655 

656 # Cache settings carry Redis credentials and connection strings that 

657 # control where LLM responses are cached. An admin (or compromised 

658 # admin) flipping the cache backend silently is a data-routing 

659 # pivot; emit an audit-log row so the action is traceable. 

660 await _emit_cache_settings_audit_log( 

661 action=action, 

662 before_settings=before_settings, 

663 after_settings=cache_settings, 

664 user_api_key_dict=user_api_key_dict, 

665 litellm_changed_by=litellm_changed_by, 

666 ) 

667 

668 return { 

669 "message": "Cache settings updated successfully", 

670 "status": "success", 

671 "settings": _redact_credentials(cache_settings), 

672 } 

673 except Exception as e: 

674 verbose_proxy_logger.error("Error updating cache settings: %s", e) 

675 raise HTTPException(status_code=500, detail=f"Error updating cache settings: {e}")