Skip to content

Commit b64010f

Browse files
fix: keep union order and literal types in serializer cache keys, bound the cache
1 parent c28044d commit b64010f

2 files changed

Lines changed: 112 additions & 11 deletions

File tree

‎src/openrouter/_hooks/serializer_cache.py‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,33 @@
88
"""
99

1010
import functools
11-
from typing import Any, Callable, Tuple, Type
11+
from typing import Any, Callable, Tuple, Type, get_args, get_origin
1212

1313
from pydantic import BaseModel, ConfigDict
1414

1515
from ..utils import serializers
1616

1717
_uncached_create_model: Callable[..., Type[BaseModel]] = serializers.create_model
1818

19+
# Bounded so dynamically created types can't grow the cache without limit.
20+
_CACHE_SIZE = 1024
1921

20-
@functools.lru_cache(maxsize=None)
22+
23+
def _type_key(typ: Any) -> Any:
24+
# Equal annotations can still validate differently: `int | float ==
25+
# float | int` and `Literal[1] == Literal[True]`, but pydantic tries union
26+
# members in order and checks literal types. Keep order and leaf types.
27+
args = get_args(typ)
28+
if not args:
29+
return (type(typ), typ)
30+
return (get_origin(typ), tuple(_type_key(arg) for arg in args))
31+
32+
33+
@functools.lru_cache(maxsize=_CACHE_SIZE)
2134
def _cached_wrapper_model(
22-
name: str, typ: Any, config_items: Tuple[Tuple[str, Any], ...]
35+
name: str, type_key: Any, config_items: Tuple[Tuple[str, Any], ...], typ: Any
2336
) -> Type[BaseModel]:
37+
del type_key
2438
return _uncached_create_model(
2539
name, body=(typ, ...), __config__=ConfigDict(**dict(config_items)) # type: ignore[typeddict-item]
2640
)
@@ -36,8 +50,11 @@ def _create_model(name: str, **kwargs: Any) -> Type[BaseModel]:
3650
or not isinstance(config, dict)
3751
):
3852
return _uncached_create_model(name, **kwargs)
53+
typ = body[0]
3954
try:
40-
return _cached_wrapper_model(name, body[0], tuple(sorted(config.items())))
55+
return _cached_wrapper_model(
56+
name, _type_key(typ), tuple(sorted(config.items())), typ
57+
)
4158
except TypeError:
4259
return _uncached_create_model(name, **kwargs)
4360

‎tests/test_serializer_cache.py‎

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import json
1+
from typing import Annotated, List, Literal, Optional, Union
22

33
import httpx
4+
import pydantic
5+
import pytest
46

57
from openrouter import OpenRouter
68
from openrouter._hooks import serializer_cache
@@ -65,12 +67,94 @@ def counting_create_model(*args, **kwargs):
6567
assert second.model_dump() == first.model_dump()
6668

6769

68-
def test_cached_models_serialize_like_uncached_ones():
70+
def _uncached(monkeypatch):
71+
monkeypatch.setattr(
72+
serializers, "create_model", serializer_cache._uncached_create_model
73+
)
74+
75+
76+
def _outcome(fn):
77+
try:
78+
result = fn()
79+
except pydantic.ValidationError as error:
80+
return ("error", [(e["type"], e["loc"]) for e in error.errors()])
81+
return ("ok", type(result), result)
82+
83+
84+
class _Aliased(pydantic.BaseModel):
85+
model_config = pydantic.ConfigDict(populate_by_name=True)
86+
max_tokens: Optional[int] = pydantic.Field(default=None, alias="maxTokens")
87+
88+
89+
CASES = [
90+
("1", int | float),
91+
("1", float | int),
92+
("1", Union[int, float]),
93+
("1", Union[float, int]),
94+
(True, Literal[1]),
95+
(True, Literal[True]),
96+
(1, Literal[True]),
97+
({"a": [1, 2]}, dict[str, list[int]]),
98+
({"a": ["x"]}, dict[str, list[int]]),
99+
({"maxTokens": 3}, _Aliased),
100+
({"max_tokens": 3}, _Aliased),
101+
({"maxTokens": "x"}, _Aliased),
102+
(None, Optional[_Aliased]),
103+
([1, "2"], List[int]),
104+
]
105+
106+
107+
def _all_outcomes():
108+
unmarshalled = [_outcome(lambda: serializers.unmarshal(v, t)) for v, t in CASES]
109+
marshalled = [
110+
_outcome(lambda: serializers.marshal_json(v, t)) for v, t in CASES
111+
]
112+
return unmarshalled + marshalled
113+
114+
115+
@pytest.mark.parametrize("order", [1, -1], ids=["forward", "reverse"])
116+
def test_cached_models_behave_like_uncached_ones(monkeypatch, order):
117+
with monkeypatch.context() as m:
118+
_uncached(m)
119+
expected = _all_outcomes()
120+
121+
_client()
122+
serializer_cache._cached_wrapper_model.cache_clear()
123+
# Warm the cache in the given order, then compare every case.
124+
for value, typ in CASES[::order]:
125+
_outcome(lambda: serializers.unmarshal(value, typ))
126+
_outcome(lambda: serializers.marshal_json(value, typ))
127+
128+
assert _all_outcomes() == expected
129+
130+
131+
def test_unhashable_types_fall_back_to_uncached_models(monkeypatch):
132+
built = []
133+
uncached_create_model = serializer_cache._uncached_create_model
134+
135+
def counting_create_model(*args, **kwargs):
136+
built.append(args[0])
137+
return uncached_create_model(*args, **kwargs)
138+
69139
_client()
140+
serializer_cache._cached_wrapper_model.cache_clear()
141+
monkeypatch.setattr(serializer_cache, "_uncached_create_model", counting_create_model)
142+
typ = Annotated[int, {"unhashable": []}]
143+
144+
assert serializers.unmarshal("5", typ) == 5
145+
assert serializers.unmarshal("5", typ) == 5
146+
assert built == ["Unmarshaller", "Unmarshaller"]
147+
assert serializer_cache._cached_wrapper_model.cache_info().currsize == 0
148+
149+
150+
def test_cache_size_is_bounded():
151+
_client()
152+
serializer_cache._cached_wrapper_model.cache_clear()
70153

71-
value = {"a": [1, 2]}
72-
typ = dict[str, list[int]]
154+
for i in range(serializer_cache._CACHE_SIZE + 50):
155+
dynamic = pydantic.create_model(f"Dynamic{i}", x=(int, ...))
156+
serializers.unmarshal({"x": i}, dynamic)
73157

74-
assert json.loads(serializers.marshal_json(value, typ)) == value
75-
assert serializers.unmarshal(value, typ) == value
76-
assert serializers.unmarshal(value, typ) == value
158+
info = serializer_cache._cached_wrapper_model.cache_info()
159+
assert info.maxsize == serializer_cache._CACHE_SIZE
160+
assert info.currsize == serializer_cache._CACHE_SIZE

0 commit comments

Comments
 (0)