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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2CACHE SETTINGS MANAGEMENT
4Endpoints for managing cache configuration
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"""
11import asyncio
12import json
13from collections.abc import Mapping
14from datetime import datetime, timezone
15from typing import TYPE_CHECKING, Any, Final, Protocol
17from fastapi import APIRouter, Depends, Header, HTTPException
18from pydantic import BaseModel, Field, TypeAdapter
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)
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
42router: Final = APIRouter()
44_STORED_CACHE_SETTINGS_ADAPTER: Final = TypeAdapter(dict[str, object])
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
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
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
58def _cache_config_table(prisma_client: "PrismaClient") -> _CacheConfigTable:
59 return CacheConfigRepository(prisma_client).table
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"}
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)
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()
80_REDACTED_VALUE: Final = "***REDACTED***"
83_URL_OVERRIDDEN_CONNECTION_FIELDS: Final[frozenset[str]] = frozenset({"host", "port", "db", "password", "username"})
86def _resolve_cache_url_precedence(settings: Mapping[str, object]) -> dict[str, Any]:
87 """Return cache settings with the url-vs-discrete-fields ambiguity resolved.
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}
103def _parse_stored_settings(cache_settings_value: object) -> dict[str, object]:
104 """Normalize a stored cache_settings blob to a dict.
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 {}
114def _overlay_environment(stored: Mapping[str, object]) -> dict[str, object]:
115 """Fill connection fields from the REDIS_* environment the cache actually reads.
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
136def _redact_credentials(settings: Mapping[str, object]) -> dict[str, object]:
137 """Replace credential-bearing values with a fixed marker, keeping the rest.
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 }
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)
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, [], {})
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)
175def _target_repr(value: object) -> str:
176 """Canonical string form of a connection-target value for equality checks.
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)
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.
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.
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
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.
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.
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
262def _redact_settings(settings: Mapping[str, object] | None) -> dict[str, object]:
263 """Replace every value in a settings map with a fixed marker.
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}
275def _log_audit_task_exception(task: "asyncio.Task[None]") -> None:
276 """Surface a fire-and-forget audit-log task failure as a warning.
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)
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.
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
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
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)
331class CacheSettingsManager:
332 """
333 Manages cache settings initialization and updates.
334 Tracks last cache params to avoid unnecessary reinitialization.
335 """
337 _last_cache_params: dict[str, object] | None = None
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 """
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
357 normalized1: Final = normalize(params1)
358 normalized2: Final = normalize(params2)
360 return normalized1 == normalized2
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 )
383 # Decrypt cache settings
384 decrypted_settings: Final = proxy_config._decrypt_db_variables(variables_dict=cache_settings_dict)
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"}
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
397 # Initialize cache only if params changed or cache not initialized
398 proxy_config._init_cache(cache_params=cache_params)
400 # Store the params we just initialized
401 CacheSettingsManager._last_cache_params = cache_params.copy()
403 # Switch on LLM response caching
404 proxy_config.switch_on_llm_response_caching()
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 )
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()
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")
428class CacheTestRequest(BaseModel):
429 cache_settings: dict[str, object] = Field(description="Cache settings to test connection with")
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")
438class CacheSettingsUpdateRequest(BaseModel):
439 cache_settings: dict[str, object] = Field(description="Cache settings to save")
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.
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
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]
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 )
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))
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"
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)
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]
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}")
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.
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
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))
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 )
552 # Create temporary cache instance
553 temp_cache: Final = Cache(**cache_settings)
555 # Use the cache's test_connection method
556 result: Final = await temp_cache.cache.test_connection()
558 return CacheTestResponse(**result)
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 )
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.
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 )
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 )
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 )
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"
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))
623 # Encrypt sensitive fields (keep redis_type for storage)
624 encrypted_settings: Final = proxy_config._encrypt_env_variables(environment_variables=cache_settings)
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 )
640 # Reinitialize cache with new settings
641 # Decrypt for initialization
642 decrypted_settings: Final = proxy_config._decrypt_db_variables(variables_dict=encrypted_settings)
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"}
647 # Initialize cache (frontend sends type="redis", not redis_type)
648 proxy_config._init_cache(cache_params=cache_params)
650 # Update the last cache params to avoid reinitializing unnecessarily
651 CacheSettingsManager.update_cache_params(cache_params)
653 # Switch on LLM response caching
654 proxy_config.switch_on_llm_response_caching()
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 )
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}")