Skip to content

Commit e5567da

Browse files
perf: reuse serializer wrapper models instead of rebuilding them on every call (#1335)
2 parents 29b56b4 + b64010f commit e5567da

3 files changed

Lines changed: 225 additions & 0 deletions

File tree

‎src/openrouter/_hooks/registration.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .types import Hooks
2+
from . import serializer_cache
23

34

45
# This file is only ever generated once on the first generation and then is free to be modified.
@@ -11,3 +12,4 @@ def init_hooks(hooks: Hooks):
1112
"""Add hooks by calling hooks.register{sdk_init/before_request/after_success/after_error}Hook
1213
with an instance of a hook that implements that specific Hook interface
1314
Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance"""
15+
serializer_cache.install()
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Reuse the wrapper models that ``utils.serializers`` builds for each type.
2+
3+
``marshal_json`` and ``unmarshal`` call ``pydantic.create_model`` on every
4+
call, so each request builds and compiles new model classes. This module
5+
replaces ``serializers.create_model`` with a version that builds one model per
6+
(name, type, config) and returns it on later calls. Calls in any other shape,
7+
or with unhashable arguments, fall through to ``pydantic.create_model``.
8+
"""
9+
10+
import functools
11+
from typing import Any, Callable, Tuple, Type, get_args, get_origin
12+
13+
from pydantic import BaseModel, ConfigDict
14+
15+
from ..utils import serializers
16+
17+
_uncached_create_model: Callable[..., Type[BaseModel]] = serializers.create_model
18+
19+
# Bounded so dynamically created types can't grow the cache without limit.
20+
_CACHE_SIZE = 1024
21+
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)
34+
def _cached_wrapper_model(
35+
name: str, type_key: Any, config_items: Tuple[Tuple[str, Any], ...], typ: Any
36+
) -> Type[BaseModel]:
37+
del type_key
38+
return _uncached_create_model(
39+
name, body=(typ, ...), __config__=ConfigDict(**dict(config_items)) # type: ignore[typeddict-item]
40+
)
41+
42+
43+
def _create_model(name: str, **kwargs: Any) -> Type[BaseModel]:
44+
body, config = kwargs.get("body"), kwargs.get("__config__")
45+
if (
46+
set(kwargs) != {"body", "__config__"}
47+
or not isinstance(body, tuple)
48+
or len(body) != 2
49+
or body[1] is not ...
50+
or not isinstance(config, dict)
51+
):
52+
return _uncached_create_model(name, **kwargs)
53+
typ = body[0]
54+
try:
55+
return _cached_wrapper_model(
56+
name, _type_key(typ), tuple(sorted(config.items())), typ
57+
)
58+
except TypeError:
59+
return _uncached_create_model(name, **kwargs)
60+
61+
62+
def install() -> None:
63+
setattr(serializers, "create_model", _create_model)

‎tests/test_serializer_cache.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from typing import Annotated, List, Literal, Optional, Union
2+
3+
import httpx
4+
import pydantic
5+
import pytest
6+
7+
from openrouter import OpenRouter
8+
from openrouter._hooks import serializer_cache
9+
from openrouter.utils import serializers
10+
11+
COMPLETION = {
12+
"id": "gen-test",
13+
"object": "chat.completion",
14+
"created": 1760000000,
15+
"model": "openai/gpt-4o-mini",
16+
"system_fingerprint": None,
17+
"choices": [
18+
{
19+
"index": 0,
20+
"finish_reason": "stop",
21+
"message": {"role": "assistant", "content": "ok"},
22+
}
23+
],
24+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
25+
}
26+
27+
28+
def _client() -> OpenRouter:
29+
transport = httpx.MockTransport(
30+
lambda request: httpx.Response(200, json=COMPLETION)
31+
)
32+
return OpenRouter(api_key="test-key", client=httpx.Client(transport=transport))
33+
34+
35+
def _send(client: OpenRouter):
36+
return client.chat.send(
37+
model="openai/gpt-4o-mini",
38+
messages=[{"role": "user", "content": "hi"}],
39+
)
40+
41+
42+
def test_sdk_init_installs_serializer_cache():
43+
_client()
44+
45+
assert serializers.create_model is serializer_cache._create_model
46+
47+
48+
def test_repeated_requests_reuse_serializer_models(monkeypatch):
49+
built = []
50+
uncached_create_model = serializer_cache._uncached_create_model
51+
52+
def counting_create_model(*args, **kwargs):
53+
built.append(args[0] if args else kwargs.get("__model_name"))
54+
return uncached_create_model(*args, **kwargs)
55+
56+
client = _client()
57+
serializer_cache._cached_wrapper_model.cache_clear()
58+
monkeypatch.setattr(serializer_cache, "_uncached_create_model", counting_create_model)
59+
60+
first = _send(client)
61+
built_by_first_request = len(built)
62+
second = _send(client)
63+
64+
assert built_by_first_request > 0
65+
assert len(built) == built_by_first_request
66+
assert first.choices[0].message.content == "ok"
67+
assert second.model_dump() == first.model_dump()
68+
69+
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+
139+
_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()
153+
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)
157+
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)