From c28044d5ebf085038d527452a8df5f5cce16989e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:53:11 +0000 Subject: [PATCH] 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. --- src/openrouter/_hooks/registration.py | 2 + src/openrouter/_hooks/serializer_cache.py | 46 ++++++++++++++ tests/test_serializer_cache.py | 76 +++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 src/openrouter/_hooks/serializer_cache.py create mode 100644 tests/test_serializer_cache.py diff --git a/src/openrouter/_hooks/registration.py b/src/openrouter/_hooks/registration.py index cab477877..ff54f0fc8 100644 --- a/src/openrouter/_hooks/registration.py +++ b/src/openrouter/_hooks/registration.py @@ -1,4 +1,5 @@ from .types import Hooks +from . import serializer_cache # 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): """Add hooks by calling hooks.register{sdk_init/before_request/after_success/after_error}Hook with an instance of a hook that implements that specific Hook interface Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance""" + serializer_cache.install() diff --git a/src/openrouter/_hooks/serializer_cache.py b/src/openrouter/_hooks/serializer_cache.py new file mode 100644 index 000000000..ea5598857 --- /dev/null +++ b/src/openrouter/_hooks/serializer_cache.py @@ -0,0 +1,46 @@ +"""Reuse the wrapper models that ``utils.serializers`` builds for each type. + +``marshal_json`` and ``unmarshal`` call ``pydantic.create_model`` on every +call, so each request builds and compiles new model classes. This module +replaces ``serializers.create_model`` with a version that builds one model per +(name, type, config) and returns it on later calls. Calls in any other shape, +or with unhashable arguments, fall through to ``pydantic.create_model``. +""" + +import functools +from typing import Any, Callable, Tuple, Type + +from pydantic import BaseModel, ConfigDict + +from ..utils import serializers + +_uncached_create_model: Callable[..., Type[BaseModel]] = serializers.create_model + + +@functools.lru_cache(maxsize=None) +def _cached_wrapper_model( + name: str, typ: Any, config_items: Tuple[Tuple[str, Any], ...] +) -> Type[BaseModel]: + return _uncached_create_model( + name, body=(typ, ...), __config__=ConfigDict(**dict(config_items)) # type: ignore[typeddict-item] + ) + + +def _create_model(name: str, **kwargs: Any) -> Type[BaseModel]: + body, config = kwargs.get("body"), kwargs.get("__config__") + if ( + set(kwargs) != {"body", "__config__"} + or not isinstance(body, tuple) + or len(body) != 2 + or body[1] is not ... + or not isinstance(config, dict) + ): + return _uncached_create_model(name, **kwargs) + try: + return _cached_wrapper_model(name, body[0], tuple(sorted(config.items()))) + except TypeError: + return _uncached_create_model(name, **kwargs) + + +def install() -> None: + setattr(serializers, "create_model", _create_model) diff --git a/tests/test_serializer_cache.py b/tests/test_serializer_cache.py new file mode 100644 index 000000000..24e257bd9 --- /dev/null +++ b/tests/test_serializer_cache.py @@ -0,0 +1,76 @@ +import json + +import httpx + +from openrouter import OpenRouter +from openrouter._hooks import serializer_cache +from openrouter.utils import serializers + +COMPLETION = { + "id": "gen-test", + "object": "chat.completion", + "created": 1760000000, + "model": "openai/gpt-4o-mini", + "system_fingerprint": None, + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "ok"}, + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} + + +def _client() -> OpenRouter: + transport = httpx.MockTransport( + lambda request: httpx.Response(200, json=COMPLETION) + ) + return OpenRouter(api_key="test-key", client=httpx.Client(transport=transport)) + + +def _send(client: OpenRouter): + return client.chat.send( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "hi"}], + ) + + +def test_sdk_init_installs_serializer_cache(): + _client() + + assert serializers.create_model is serializer_cache._create_model + + +def test_repeated_requests_reuse_serializer_models(monkeypatch): + built = [] + uncached_create_model = serializer_cache._uncached_create_model + + def counting_create_model(*args, **kwargs): + built.append(args[0] if args else kwargs.get("__model_name")) + return uncached_create_model(*args, **kwargs) + + client = _client() + serializer_cache._cached_wrapper_model.cache_clear() + monkeypatch.setattr(serializer_cache, "_uncached_create_model", counting_create_model) + + first = _send(client) + built_by_first_request = len(built) + second = _send(client) + + assert built_by_first_request > 0 + assert len(built) == built_by_first_request + assert first.choices[0].message.content == "ok" + assert second.model_dump() == first.model_dump() + + +def test_cached_models_serialize_like_uncached_ones(): + _client() + + value = {"a": [1, 2]} + typ = dict[str, list[int]] + + assert json.loads(serializers.marshal_json(value, typ)) == value + assert serializers.unmarshal(value, typ) == value + assert serializers.unmarshal(value, typ) == value