mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
fix(llm): add per-turn model request timeout so stalled streams fail fast and retry
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=[],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+15
-1
@@ -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")
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user