diff --git a/strix/config/models.py b/strix/config/models.py index 931d15b1..a54edd4d 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -21,6 +21,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``. + + 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. + """ + if not timeout_s or timeout_s <= 0: + return None + return {"timeout": float(timeout_s)} + + def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool: """Retry statusless provider errors (e.g. mid-stream quota/billing), but not aborts.""" normalized = context.normalized diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 1712fbd3..de922d07 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -12,6 +12,7 @@ from strix.config.models import ( DEFAULT_MODEL_RETRY, is_known_openai_bare_model, model_supports_reasoning, + request_timeout_extra_args, ) from strix.core.sessions import scrub_images_from_items @@ -126,11 +127,13 @@ def make_model_settings( *, model_name: str, force_required_tool_choice: bool = False, + request_timeout: float | None = None, ) -> ModelSettings: model_settings = ModelSettings( parallel_tool_calls=False, retry=DEFAULT_MODEL_RETRY, include_usage=True, + extra_args=request_timeout_extra_args(request_timeout), ) if ( reasoning_effort is not None diff --git a/strix/core/runner.py b/strix/core/runner.py index e6a15dc3..153d16dd 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -215,6 +215,7 @@ async def run_strix_scan( settings.llm.reasoning_effort, model_name=resolved_model, force_required_tool_choice=settings.llm.force_required_tool_choice, + request_timeout=settings.llm.timeout, ) run_config = RunConfig( model=resolved_model, diff --git a/strix/report/dedupe.py b/strix/report/dedupe.py index b670a410..c2970d95 100644 --- a/strix/report/dedupe.py +++ b/strix/report/dedupe.py @@ -16,6 +16,7 @@ from strix.config.models import ( DEFAULT_MODEL_RETRY, StrixProvider, configure_sdk_model_defaults, + request_timeout_extra_args, ) from strix.report.state import get_global_report_state @@ -310,7 +311,11 @@ async def check_duplicate( response = await model.get_response( system_instructions=DEDUPE_SYSTEM_PROMPT, input=user_msg, - model_settings=ModelSettings(retry=DEFAULT_MODEL_RETRY, include_usage=True), + model_settings=ModelSettings( + retry=DEFAULT_MODEL_RETRY, + include_usage=True, + extra_args=request_timeout_extra_args(settings.llm.timeout), + ), tools=[], output_schema=None, handoffs=[], diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 486c7e54..7c51c795 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -155,3 +155,31 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() -> ) assert settings.tool_choice == "required" + + +def test_make_model_settings_sets_request_timeout() -> None: + settings = make_model_settings( + "none", + model_name="gpt-4o", + request_timeout=300.0, + ) + + assert settings.extra_args == {"timeout": 300.0} + + +def test_make_model_settings_omits_timeout_when_unset() -> None: + settings = make_model_settings("none", model_name="gpt-4o") + + assert settings.extra_args is None + + +def test_make_model_settings_timeout_survives_reasoning_resolve() -> None: + # Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args + # must not be dropped when a reasoning override is merged in. + settings = make_model_settings( + "high", + model_name="openai/o3", + request_timeout=120.0, + ) + + assert settings.extra_args == {"timeout": 120.0} diff --git a/tests/test_model_retry.py b/tests/test_model_retry.py index 64b4002f..af2ae67a 100644 --- a/tests/test_model_retry.py +++ b/tests/test_model_retry.py @@ -55,6 +55,14 @@ def test_rate_limit_and_server_errors_are_retried() -> None: assert _retries(ModelRetryNormalizedError(status_code=status)) is True +def test_timeout_error_is_retried() -> None: + # A stalled model stream trips the per-request read/inactivity timeout, which + # the SDK normalizes as a timeout. DEFAULT_MODEL_RETRY must retry it so a hung + # turn recovers instead of silently wedging the agent. + assert _retries(ModelRetryNormalizedError(is_timeout=True)) is True + assert _retries(ModelRetryNormalizedError(is_network_error=True)) is True + + def test_policy_helper_matches_statusless_only() -> None: assert _retry_statusless_provider_errors(_context(ModelRetryNormalizedError())) is True assert ( diff --git a/tests/test_models.py b/tests/test_models.py index dc86e98f..cda1c6b6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,7 +4,11 @@ from __future__ import annotations import pytest -from strix.config.models import RECOMMENDED_MODEL_NAMES, is_recommended_or_frontier_model +from strix.config.models import ( + RECOMMENDED_MODEL_NAMES, + is_recommended_or_frontier_model, + request_timeout_extra_args, +) @pytest.mark.parametrize("model_name", RECOMMENDED_MODEL_NAMES) @@ -12,6 +16,16 @@ def test_recommended_models_are_accepted(model_name: str) -> None: assert is_recommended_or_frontier_model(model_name) +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} + + +@pytest.mark.parametrize("value", [None, 0, -1]) +def test_request_timeout_extra_args_disabled(value: float | None) -> None: + assert request_timeout_extra_args(value) is None + + def test_recommended_models_are_matched_case_insensitively() -> None: assert is_recommended_or_frontier_model("Vertex_AI/Gemini-3-Pro-Preview") diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index 401caa15..62242339 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -37,6 +37,7 @@ async def test_persistent_rate_limit_stops_gracefully( model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + timeout=300, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index 3692d4d4..e765b196 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -45,6 +45,7 @@ def _patch_engine_scaffold( model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + timeout=300, ) ) monkeypatch.setattr(runner, "load_settings", lambda: settings) @@ -124,8 +125,7 @@ async def test_root_prompt_options_flow_into_root_agent( assert "https://example.com" in instructions_override assert "CUSTOM SCAN PROMPT" in instructions_override assert ( - "cannot expand, replace, or weaken authorized target constraints" - in instructions_override + "cannot expand, replace, or weaken authorized target constraints" in instructions_override ) assert kwargs["system_prompt_context"] == { **scope_context,