From 63ef65ecc52724f4036facb089a5609ef7d0f37b Mon Sep 17 00:00:00 2001 From: fatihcvs <105765934+fatihcvs@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:40:26 +0300 Subject: [PATCH 1/2] feat: make router HTTP timeout configurable --- CHANGELOG.md | 3 +++ README.md | 2 +- src/model_router/catalog.py | 4 ++-- src/model_router/jev.py | 6 ++++-- src/model_router/router.py | 6 ++++-- tests/test_router.py | 42 ++++++++++++++++++++++++++++++++++++- 6 files changed, 55 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5204def..aa1ec51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project are documented here. The format follows [Kee ## [Unreleased] +### Added +- `Router(timeout=60)` configures the HTTP timeout in seconds for catalog downloads and both routing backends. + ## [0.1.0] - 2026-09-23 ### Added diff --git a/README.md b/README.md index adad85e..8629601 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ These are real outputs from `examples/basic.py`. | Call | What it does | |---|---| -| `Router(*, jev_api_key=None, openrouter_api_key=None, providers=None, models=None, limits=Limits(), models_per_provider=None)` | All arguments are keyword-only. Loads live prices, context sizes and output limits (cached for 24h, no key needed). Raises `UnknownModelError` for unknown model ids or providers. | +| `Router(*, jev_api_key=None, openrouter_api_key=None, providers=None, models=None, limits=Limits(), models_per_provider=None, timeout=60)` | All arguments are keyword-only. Loads live prices, context sizes and output limits (cached for 24h, no key needed). `timeout` is the HTTP timeout in seconds for catalog downloads and Jev/OpenRouter routing calls (e.g. `timeout=5`); it is passed to `urllib.request.urlopen`, not a total routing deadline. Raises `UnknownModelError` for unknown model ids or providers. | | `refresh_catalog()` | Clears the cached model list, so the next `Router` downloads fresh prices. | | `router.api_key_for(model_id) -> str \| None` | The key you passed in `providers={...}` for this model's provider. | | `router.route(task, limits=None) -> str` | Returns the best model id for `task`. | diff --git a/src/model_router/catalog.py b/src/model_router/catalog.py index 2532999..9279497 100644 --- a/src/model_router/catalog.py +++ b/src/model_router/catalog.py @@ -9,7 +9,7 @@ _cache = {"at": 0.0, "data": None} -def fetch_catalog(max_age=CATALOG_TTL_SECONDS): +def fetch_catalog(max_age=CATALOG_TTL_SECONDS, *, timeout=60): """Live prices, context and output limits for every text model on OpenRouter, keyed by id. Public endpoint, no key needed. Fetched once and shared by every Router in the process, @@ -19,7 +19,7 @@ def fetch_catalog(max_age=CATALOG_TTL_SECONDS): if _cache["data"] is None or time.time() - _cache["at"] > max_age: _cache["data"] = { m["id"]: ModelInfo.from_openrouter(m) - for m in request_json(MODELS_URL)["data"] + for m in request_json(MODELS_URL, timeout=timeout)["data"] if "text" in ((m.get("architecture") or {}).get("output_modalities") or ["text"]) } _cache["at"] = time.time() diff --git a/src/model_router/jev.py b/src/model_router/jev.py index a144754..fa7fc68 100644 --- a/src/model_router/jev.py +++ b/src/model_router/jev.py @@ -51,7 +51,7 @@ def _fit(build, n_candidates): ) -def choose_via_jev(api_key, task, in_tokens, candidates, limits): +def choose_via_jev(api_key, task, in_tokens, candidates, limits, *, timeout=60): """jevai.org model-route preset. Returns the chosen model id.""" res = request_json( JEV_ROUTE_URL, @@ -69,6 +69,7 @@ def choose_via_jev(api_key, task, in_tokens, candidates, limits): }, len(candidates), ), + timeout=timeout, ) decision = (res.get("data") or {}).get("decision") if res.get("code") != 0 or decision not in {m.id for m in candidates}: @@ -76,7 +77,7 @@ def choose_via_jev(api_key, task, in_tokens, candidates, limits): return decision -def choose_via_openrouter(api_key, task, in_tokens, candidates, limits): +def choose_via_openrouter(api_key, task, in_tokens, candidates, limits, *, timeout=60): """OpenRouter decisions API running Jev. Returns the chosen model id.""" # Aliases keep criteria keys plain; model ids contain "/" and ".". alias = {f"m{i}": m for i, m in enumerate(candidates)} @@ -97,6 +98,7 @@ def choose_via_openrouter(api_key, task, in_tokens, candidates, limits): }, len(candidates), ), + timeout=timeout, ) picked = ((res.get("answers") or {}).get("model") or {}).get("choice") if picked not in alias: diff --git a/src/model_router/router.py b/src/model_router/router.py index 4d8e332..1ad8b32 100644 --- a/src/model_router/router.py +++ b/src/model_router/router.py @@ -21,6 +21,7 @@ def __init__( models=None, limits=Limits(), models_per_provider=None, + timeout=60, ): """ Routing backend (one is required; jev_api_key wins if both are given): @@ -42,8 +43,9 @@ def __init__( self.provider_keys = dict(providers) if isinstance(providers, dict) else {} self.limits = limits + self.timeout = timeout # Live prices/context/limits, fetched once and cached (see catalog.fetch_catalog). - catalog = fetch_catalog() + catalog = fetch_catalog(timeout=self.timeout) if models: unknown = [m for m in models if m not in catalog] @@ -89,4 +91,4 @@ def route(self, task, limits=None): raise NoModelFitsError("; ".join(f"{i}: {r}" for i, r in reasons.items())) if len(candidates) == 1: return candidates[0].id - return self._choose(task, in_tokens, candidates, limits) + return self._choose(task, in_tokens, candidates, limits, timeout=self.timeout) diff --git a/tests/test_router.py b/tests/test_router.py index 07b7e9e..a9f68fb 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,6 +1,7 @@ +import io import json import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from model_router import ( Limits, @@ -38,6 +39,45 @@ def make_router(models=("cheap/small", "big/smart"), limits=None, **kw): class ConfigTest(unittest.TestCase): + def test_timeout_reaches_both_routing_backends(self): + for backend, payload in [ + ("jev_api_key", {"code": 0, "data": {"decision": "cheap/small"}}), + ("openrouter_api_key", {"answers": {"model": {"choice": "m0"}}}), + ]: + for options, expected in [({}, 60), ({"timeout": 2.5}, 2.5)]: + with self.subTest(backend=backend, timeout=expected): + response = MagicMock() + response.__enter__.return_value = io.BytesIO(json.dumps(payload).encode()) + with patch("urllib.request.urlopen", return_value=response) as urlopen: + router = make_router(**{backend: "test-key"}, **options) + self.assertEqual(router.route("hello"), "cheap/small") + self.assertEqual(urlopen.call_args.kwargs["timeout"], expected) + + def test_timeout_reaches_catalog_download(self): + from model_router.catalog import refresh_catalog + + refresh_catalog() + self.addCleanup(refresh_catalog) + response = MagicMock() + response.__enter__.return_value = io.BytesIO( + json.dumps( + { + "data": [ + { + "id": "cheap/small", + "context_length": 8000, + "pricing": {"prompt": "0.0000001", "completion": "0.0000004"}, + } + ] + } + ).encode() + ) + with patch("urllib.request.urlopen", return_value=response) as urlopen: + router = Router(openrouter_api_key="test-key", models=["cheap/small"], timeout=5) + self.assertEqual(router.route("hello"), "cheap/small") + self.assertEqual(urlopen.call_count, 1) + self.assertEqual(urlopen.call_args.kwargs["timeout"], 5) + def test_needs_a_routing_key(self): with self.assertRaises(RouterError): make_router(openrouter_api_key=None) From cc0ce599c708e837e49259a30be6906a87c4ea3f Mon Sep 17 00:00:00 2001 From: thecoder30ec4 Date: Fri, 25 Sep 2026 10:39:49 +0530 Subject: [PATCH 2/2] Restore timeout wiring and io import lost in main merge Co-Authored-By: Claude Opus 5.5 --- src/model_router/router.py | 4 ++-- tests/test_router.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/model_router/router.py b/src/model_router/router.py index ecf6c88..61ebdc7 100644 --- a/src/model_router/router.py +++ b/src/model_router/router.py @@ -34,9 +34,9 @@ def __init__( models exact OpenRouter-style ids, e.g. ["anthropic/claude-opus-5.5"]; overrides the auto-pick """ if jev_api_key: - self._choose = partial(jev.choose_via_jev, jev_api_key) + self._choose = partial(jev.choose_via_jev, jev_api_key, timeout=timeout) elif openrouter_api_key: - self._choose = partial(jev.choose_via_openrouter, openrouter_api_key) + self._choose = partial(jev.choose_via_openrouter, openrouter_api_key, timeout=timeout) else: raise RouterError("Pass jev_api_key or openrouter_api_key") if not providers and not models: diff --git a/tests/test_router.py b/tests/test_router.py index 9dde6a8..0e4a879 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,4 +1,5 @@ import asyncio +import io import json import unittest from unittest.mock import MagicMock, patch