mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
29035c4a99
commit
7148c7c53d
3 changed files with 60 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue