diff --git a/strix/config/models.py b/strix/config/models.py index a54edd4d..858d7dd7 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -5,6 +5,7 @@ from __future__ import annotations import os from typing import TYPE_CHECKING +import httpx from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled from agents.models.multi_provider import MultiProvider from agents.retry import ( @@ -21,17 +22,19 @@ if TYPE_CHECKING: from strix.config.settings import Settings -def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None: - """Per-request model timeout (connect + read/inactivity) as ``extra_args``. +def request_timeout_extra_args(timeout_s: float | None) -> dict[str, httpx.Timeout] | None: + """Per-request model timeout as ``extra_args``, forwarded to the provider call. - Restores pre-v1 behavior: a stalled model stream trips this timeout and is - retried by ``DEFAULT_MODEL_RETRY`` instead of hanging the agent indefinitely. - The value is forwarded to the underlying ``responses.create`` / - ``chat.completions.create`` / ``litellm.acompletion`` call. + Uses ``read`` for inactivity (matching pre-v1's per-chunk ``wait_for``): a stalled + stream trips ``read`` and is retried by ``DEFAULT_MODEL_RETRY``, while a healthy + long stream that keeps emitting tokens never trips it. An explicit ``httpx.Timeout`` + (rather than a scalar) keeps this a read-inactivity timeout instead of a + total-duration deadline on every httpx-based backend (``responses.create`` / + ``chat.completions.create`` / ``litellm.acompletion``). """ if not timeout_s or timeout_s <= 0: return None - return {"timeout": float(timeout_s)} + return {"timeout": httpx.Timeout(timeout_s, connect=min(timeout_s, 30.0))} def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool: diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 7c51c795..b14280c1 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -164,7 +164,8 @@ def test_make_model_settings_sets_request_timeout() -> None: request_timeout=300.0, ) - assert settings.extra_args == {"timeout": 300.0} + assert settings.extra_args is not None + assert settings.extra_args["timeout"].read == 300.0 def test_make_model_settings_omits_timeout_when_unset() -> None: @@ -182,4 +183,5 @@ def test_make_model_settings_timeout_survives_reasoning_resolve() -> None: request_timeout=120.0, ) - assert settings.extra_args == {"timeout": 120.0} + assert settings.extra_args is not None + assert settings.extra_args["timeout"].read == 120.0 diff --git a/tests/test_models.py b/tests/test_models.py index cda1c6b6..df1231c4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -17,8 +17,18 @@ def test_recommended_models_are_accepted(model_name: str) -> None: def test_request_timeout_extra_args_positive() -> None: - assert request_timeout_extra_args(300) == {"timeout": 300.0} - assert request_timeout_extra_args(120.5) == {"timeout": 120.5} + args = request_timeout_extra_args(300) + assert args is not None + timeout = args["timeout"] + # read (inactivity) carries the configured value; connect is capped so a dead + # endpoint fails fast rather than waiting the full read window. + assert timeout.read == 300.0 + assert timeout.connect == 30.0 + + short = request_timeout_extra_args(10) + assert short is not None + assert short["timeout"].read == 10.0 + assert short["timeout"].connect == 10.0 @pytest.mark.parametrize("value", [None, 0, -1])