From 7148c7c53d5cdd57178df3e2d616d987f7119e10 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 6 Jul 2026 12:53:00 -0700 Subject: [PATCH] fix(proxy): stop CacheCodec dropping null fields on cache round-trip (#32207) CacheCodec.serialize dumped cached Pydantic models with model_dump(exclude_none=True), which drops any None-valued key, while deserialize does a strict model_validate. For a model with a required-but-nullable field (Optional[X] with no default), a None value is dropped on write and then fails model_validate on read with "Field required", so the entry can never be read back; that is a permanent cache miss, and in readers that rebuild the model from the raw cached dict an uncaught ValidationError that surfaces to the client as a 401 Removing exclude_none makes serialize and deserialize a lossless pair, so None fields are written as null and survive the round trip. LiteLLM_ManagedVectorStoresTable, the one cached model still carrying required-nullable fields and mis-caching on every read today, also gets the None defaults its peers already have --- litellm/models/managed_files.py | 18 +++---- .../common_utils/cache_pydantic_utils.py | 8 +-- .../proxy/common_utils/test_cache_codec.py | 52 +++++++++++++++++-- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/litellm/models/managed_files.py b/litellm/models/managed_files.py index 24154768860..99ba764dd98 100644 --- a/litellm/models/managed_files.py +++ b/litellm/models/managed_files.py @@ -51,12 +51,12 @@ class LiteLLM_ManagedVectorStoreTable(LiteLLMPydanticObjectBase): class LiteLLM_ManagedVectorStoresTable(LiteLLMPydanticObjectBase): vector_store_id: str custom_llm_provider: str - vector_store_name: Optional[str] - vector_store_description: Optional[str] - vector_store_metadata: Optional[Dict[str, Any]] - created_at: Optional[datetime] - updated_at: Optional[datetime] - litellm_credential_name: Optional[str] - litellm_params: Optional[Dict[str, Any]] - team_id: Optional[str] - user_id: Optional[str] + vector_store_name: Optional[str] = None + vector_store_description: Optional[str] = None + vector_store_metadata: Optional[Dict[str, Any]] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + litellm_credential_name: Optional[str] = None + litellm_params: Optional[Dict[str, Any]] = None + team_id: Optional[str] = None + user_id: Optional[str] = None diff --git a/litellm/proxy/common_utils/cache_pydantic_utils.py b/litellm/proxy/common_utils/cache_pydantic_utils.py index 25d33a0aa52..f57f6a299ae 100644 --- a/litellm/proxy/common_utils/cache_pydantic_utils.py +++ b/litellm/proxy/common_utils/cache_pydantic_utils.py @@ -42,7 +42,7 @@ class CacheCodec: Encode a value for DualCache / Redis (``json.dumps``-safe). If ``model_type`` is set, the payload is validated with that model, then - ``model_dump(mode="json", exclude_none=True)`` — symmetric with ``deserialize``. + ``model_dump(mode="json")`` — symmetric with ``deserialize``. If the value is already an instance of ``model_type`` (or a subclass), ``model_validate`` is skipped to avoid an unnecessary Pydantic copy — the @@ -54,12 +54,12 @@ class CacheCodec: if model_type is not None: if isinstance(value, model_type): # Already the right type: dump directly, skip re-validation copy. - return value.model_dump(mode="json", exclude_none=True) + return value.model_dump(mode="json") if isinstance(value, (dict, BaseModel)): - return model_type.model_validate(value).model_dump(mode="json", exclude_none=True) + return model_type.model_validate(value).model_dump(mode="json") return value if isinstance(value, BaseModel): - return value.model_dump(mode="json", exclude_none=True) + return value.model_dump(mode="json") return value @staticmethod diff --git a/tests/test_litellm/proxy/common_utils/test_cache_codec.py b/tests/test_litellm/proxy/common_utils/test_cache_codec.py index 044d4c2d1a7..ded35227971 100644 --- a/tests/test_litellm/proxy/common_utils/test_cache_codec.py +++ b/tests/test_litellm/proxy/common_utils/test_cache_codec.py @@ -1,10 +1,11 @@ import logging -from typing import Optional +from typing import Any, Dict, Optional from unittest.mock import patch import pytest from pydantic import BaseModel, ValidationError +from litellm.models.managed_files import LiteLLM_ManagedVectorStoresTable from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec @@ -17,6 +18,11 @@ class _SampleSubModel(_SampleModel): pass +class _RequiredNullableModel(BaseModel): + id: str + budget_table: Optional[Dict[str, Any]] + + class TestCacheCodecSerialize: def test_without_model_type_base_model_dumped_json_safe(self): m = _SampleModel(name="a", count=1) @@ -37,12 +43,12 @@ class TestCacheCodecSerialize: def test_with_model_type_base_model_validated_and_dumped(self): m = _SampleModel(name="c", count=None) out = CacheCodec.serialize(m, model_type=_SampleModel) - assert out == {"name": "c"} + assert out == {"name": "c", "count": None} - def test_with_model_type_exclude_none_on_dump(self): + def test_with_model_type_none_field_preserved_on_dump(self): out = CacheCodec.serialize({"name": "d"}, model_type=_SampleModel) - assert out == {"name": "d"} - assert "count" not in out + assert out == {"name": "d", "count": None} + assert "count" in out def test_with_model_type_non_dict_non_model_passthrough(self): assert CacheCodec.serialize("raw", model_type=_SampleModel) == "raw" @@ -124,3 +130,39 @@ class TestCacheCodecDeserialize: for r in caplog.records if r.levelno >= logging.WARNING ), f"Expected deserialize validation warning. Records: {[r.message for r in caplog.records]}" + + +class TestCacheCodecRoundTripPreservesNoneFields: + def test_none_value_kept_as_null_not_dropped(self): + out = CacheCodec.serialize( + _RequiredNullableModel(id="x", budget_table=None), + model_type=_RequiredNullableModel, + ) + assert out == {"id": "x", "budget_table": None} + assert "budget_table" in out + + def test_required_nullable_none_field_survives_round_trip(self): + original = _RequiredNullableModel(id="x", budget_table=None) + wire = CacheCodec.serialize(original, model_type=_RequiredNullableModel) + restored = CacheCodec.deserialize(wire, model_type=_RequiredNullableModel) + assert restored == original + + def test_managed_vector_store_row_round_trips_with_optional_fields_none(self): + vs = LiteLLM_ManagedVectorStoresTable( + vector_store_id="vs_1", + custom_llm_provider="openai", + vector_store_name=None, + vector_store_description=None, + vector_store_metadata=None, + created_at=None, + updated_at=None, + litellm_credential_name=None, + litellm_params=None, + team_id=None, + user_id=None, + ) + wire = CacheCodec.serialize(vs, model_type=LiteLLM_ManagedVectorStoresTable) + assert wire.get("vector_store_name", "MISSING") is None + assert wire.get("team_id", "MISSING") is None + restored = CacheCodec.deserialize(wire, model_type=LiteLLM_ManagedVectorStoresTable) + assert restored == vs