Skip to content

Commit c28044d

Browse files
perf: reuse serializer wrapper models instead of rebuilding them on every call
marshal_json and unmarshal call pydantic.create_model on every call, so each chat request built about 19 model classes. An SDK init hook now caches one wrapper model per type. Fixes #1056.
1 parent ba5d1f6 commit c28044d

3 files changed

Lines changed: 124 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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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
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+
20+
@functools.lru_cache(maxsize=None)
21+
def _cached_wrapper_model(
22+
name: str, typ: Any, config_items: Tuple[Tuple[str, Any], ...]
23+
) -> Type[BaseModel]:
24+
return _uncached_create_model(
25+
name, body=(typ, ...), __config__=ConfigDict(**dict(config_items)) # type: ignore[typeddict-item]
26+
)
27+
28+
29+
def _create_model(name: str, **kwargs: Any) -> Type[BaseModel]:
30+
body, config = kwargs.get("body"), kwargs.get("__config__")
31+
if (
32+
set(kwargs) != {"body", "__config__"}
33+
or not isinstance(body, tuple)
34+
or len(body) != 2
35+
or body[1] is not ...
36+
or not isinstance(config, dict)
37+
):
38+
return _uncached_create_model(name, **kwargs)
39+
try:
40+
return _cached_wrapper_model(name, body[0], tuple(sorted(config.items())))
41+
except TypeError:
42+
return _uncached_create_model(name, **kwargs)
43+
44+
45+
def install() -> None:
46+
setattr(serializers, "create_model", _create_model)

‎tests/test_serializer_cache.py‎

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

0 commit comments

Comments
 (0)