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
This commit is contained in:
ryan-crabbe-berri 2026-07-06 12:53:00 -07:00 committed by GitHub
parent 29035c4a99
commit 7148c7c53d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 60 additions and 18 deletions

View file

@ -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

View file

@ -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

View file

@ -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