mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
168 lines
6.8 KiB
Python
168 lines
6.8 KiB
Python
import logging
|
|
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
|
|
|
|
|
|
class _SampleModel(BaseModel):
|
|
name: str
|
|
count: Optional[int] = None
|
|
|
|
|
|
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)
|
|
out = CacheCodec.serialize(m)
|
|
assert out == {"name": "a", "count": 1}
|
|
|
|
def test_without_model_type_dict_unchanged(self):
|
|
d = {"name": "x"}
|
|
assert CacheCodec.serialize(d) is d
|
|
|
|
def test_without_model_type_primitive_unchanged(self):
|
|
assert CacheCodec.serialize(42) == 42
|
|
|
|
def test_with_model_type_dict_validated_and_dumped(self):
|
|
out = CacheCodec.serialize({"name": "b", "count": 2}, model_type=_SampleModel)
|
|
assert out == {"name": "b", "count": 2}
|
|
|
|
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", "count": None}
|
|
|
|
def test_with_model_type_none_field_preserved_on_dump(self):
|
|
out = CacheCodec.serialize({"name": "d"}, model_type=_SampleModel)
|
|
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"
|
|
|
|
def test_with_model_type_invalid_dict_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
CacheCodec.serialize({"count": 1}, model_type=_SampleModel)
|
|
|
|
def test_with_model_type_already_correct_instance_skips_revalidation(self):
|
|
"""Fast-path: value is already model_type — model_validate must NOT be called."""
|
|
m = _SampleModel(name="fast", count=7)
|
|
with patch.object(_SampleModel, "model_validate", wraps=_SampleModel.model_validate) as mock_validate:
|
|
out = CacheCodec.serialize(m, model_type=_SampleModel)
|
|
assert out == {"name": "fast", "count": 7}
|
|
mock_validate.assert_not_called()
|
|
|
|
def test_with_model_type_subclass_instance_skips_revalidation(self):
|
|
"""Subclass is isinstance of base → should also take the fast path."""
|
|
sub = _SampleSubModel(name="sub", count=2)
|
|
with patch.object(_SampleModel, "model_validate", wraps=_SampleModel.model_validate) as mock_validate:
|
|
out = CacheCodec.serialize(sub, model_type=_SampleModel)
|
|
assert out == {"name": "sub", "count": 2}
|
|
mock_validate.assert_not_called()
|
|
|
|
def test_with_model_type_dict_input_goes_through_model_validate(self):
|
|
"""A dict value (not yet an instance) must still go through model_validate."""
|
|
raw = {"name": "via-dict", "count": 5}
|
|
with patch.object(
|
|
_SampleModel, "model_validate", wraps=_SampleModel.model_validate
|
|
) as mock_validate:
|
|
out = CacheCodec.serialize(raw, model_type=_SampleModel)
|
|
assert out == {"name": "via-dict", "count": 5}
|
|
mock_validate.assert_called_once()
|
|
|
|
def test_with_model_type_incompatible_model_raises_validation_error(self):
|
|
"""Passing a BaseModel whose fields don't satisfy model_type's required fields raises.
|
|
|
|
_IncompatibleModel only has `foo: int`, so when Pydantic v2 extracts its
|
|
data and validates it against _SampleModel (which requires `name: str`),
|
|
a ValidationError is raised.
|
|
"""
|
|
|
|
class _IncompatibleModel(BaseModel):
|
|
foo: int # missing required 'name' field of _SampleModel
|
|
|
|
with pytest.raises(ValidationError):
|
|
CacheCodec.serialize(_IncompatibleModel(foo=1), model_type=_SampleModel)
|
|
|
|
|
|
class TestCacheCodecDeserialize:
|
|
def test_none_returns_none(self):
|
|
assert CacheCodec.deserialize(None, _SampleModel) is None
|
|
|
|
def test_dict_validates_to_model(self):
|
|
m = CacheCodec.deserialize({"name": "e", "count": 3}, _SampleModel)
|
|
assert isinstance(m, _SampleModel)
|
|
assert m.name == "e"
|
|
assert m.count == 3
|
|
|
|
def test_instance_same_type_returned_as_is(self):
|
|
original = _SampleModel(name="f")
|
|
m = CacheCodec.deserialize(original, _SampleModel)
|
|
assert m is original
|
|
|
|
def test_subclass_instance_accepted(self):
|
|
sub = _SampleSubModel(name="g")
|
|
m = CacheCodec.deserialize(sub, _SampleModel)
|
|
assert m is sub
|
|
|
|
def test_wrong_type_returns_none(self):
|
|
assert CacheCodec.deserialize("not-a-dict", _SampleModel) is None
|
|
|
|
def test_invalid_dict_returns_none_and_logs_warning(self, caplog):
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
|
out = CacheCodec.deserialize({"count": 1}, _SampleModel)
|
|
assert out is None
|
|
assert any(
|
|
"CacheCodec.deserialize" in r.message and "_SampleModel" in r.message
|
|
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
|