diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index f14b110f..c490ed3f 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -54,6 +54,11 @@ Configure Strix using environment variables or a config file. failing with `LLM CONNECTION FAILED`. Scan requests use `LLM_TIMEOUT`. + + Optional maximum output tokens for each agent LLM request. Leave unset to use + the provider/model default. + + Maximum number of retries for LLM API calls on transient failures. diff --git a/strix/config/settings.py b/strix/config/settings.py index 22e6c596..6e8002f8 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -81,6 +81,7 @@ class LlmSettings(BaseSettings): default=False, alias="LLM_DISABLE_STREAMING", ) + max_tokens: int | None = Field(default=None, gt=0, alias="STRIX_LLM_MAX_TOKENS") timeout: int = Field(default=300, alias="LLM_TIMEOUT") preflight_timeout: int = Field(default=30, ge=1, alias="LLM_PREFLIGHT_TIMEOUT") stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT") diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 7bb2b72c..40feccd5 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -252,12 +252,14 @@ def make_model_settings( extra_headers: dict[str, str] | None = None, extra_body: dict[str, Any] | None = None, has_tools: bool = True, + max_tokens: int | None = None, ) -> ModelSettings: headers = _request_headers(model_name, extra_headers) model_settings = ModelSettings( parallel_tool_calls=False if has_tools else None, retry=DEFAULT_MODEL_RETRY, include_usage=True, + max_tokens=max_tokens, extra_args=request_timeout_extra_args(request_timeout), extra_headers=headers, extra_body=dict(extra_body) if extra_body else None, diff --git a/strix/core/runner.py b/strix/core/runner.py index ae6dc08b..7c967fa8 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -402,6 +402,7 @@ async def run_strix_scan( prompt_cache=settings.llm.prompt_cache, extra_headers=settings.llm.extra_headers, extra_body=settings.llm.extra_body, + max_tokens=settings.llm.max_tokens, ) run_config = RunConfig( model=resolved_model, diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index 463853d2..80eb084a 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -10,7 +10,7 @@ from pydantic import AliasChoices, Field, ValidationError from pydantic.fields import FieldInfo from strix.config import loader -from strix.config.settings import ContextSettings +from strix.config.settings import ContextSettings, LlmSettings if TYPE_CHECKING: @@ -28,6 +28,7 @@ _LLM_ENV_KEYS = [ "OLLAMA_API_BASE", "STRIX_REASONING_EFFORT", "STRIX_FORCE_REQUIRED_TOOL_CHOICE", + "STRIX_LLM_MAX_TOKENS", "LLM_TIMEOUT", "PERPLEXITY_API_KEY", "EXA_API_KEY", @@ -148,6 +149,10 @@ def test_tool_output_max_bytes_accepts_floor() -> None: assert ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=1024).tool_output_max_bytes == 1024 +def test_llm_max_tokens_env_alias() -> None: + assert LlmSettings(STRIX_LLM_MAX_TOKENS=12_000).max_tokens == 12_000 + + # --------------------------------------------------------------------------- # # _aliases_for # --------------------------------------------------------------------------- # diff --git a/tests/test_inputs.py b/tests/test_inputs.py index f54d3717..7026b3cc 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -331,6 +331,16 @@ def test_make_model_settings_sets_request_timeout() -> None: assert settings.extra_args["timeout"] == 300.0 +def test_make_model_settings_sets_configured_token_budget() -> None: + settings = make_model_settings( + "none", + model_name="gpt-4o", + max_tokens=12_000, + ) + + assert settings.max_tokens == 12_000 + + def test_make_model_settings_omits_timeout_when_unset() -> None: settings = make_model_settings("none", model_name="gpt-4o") diff --git a/tests/test_runner_interrupt.py b/tests/test_runner_interrupt.py index b6271407..c9003a39 100644 --- a/tests/test_runner_interrupt.py +++ b/tests/test_runner_interrupt.py @@ -29,6 +29,7 @@ def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: prompt_cache=True, extra_headers=None, extra_body=None, + max_tokens=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_mcp.py b/tests/test_runner_mcp.py index 74f73c36..b91969ad 100644 --- a/tests/test_runner_mcp.py +++ b/tests/test_runner_mcp.py @@ -45,6 +45,7 @@ def _settings() -> Any: prompt_cache=True, extra_headers=None, extra_body=None, + max_tokens=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index d6bad9ae..e7b954ea 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -43,6 +43,7 @@ async def test_persistent_rate_limit_stops_gracefully( prompt_cache=True, extra_headers=None, extra_body=None, + max_tokens=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index 8c2e2047..76f1d242 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -65,6 +65,7 @@ def _patch_engine_scaffold( prompt_cache=True, extra_headers=None, extra_body=None, + max_tokens=12_000, ), runtime=types.SimpleNamespace(max_context_images=3), ) @@ -90,7 +91,6 @@ def _patch_engine_scaffold( monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task") monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: scope_context) - monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings()) monkeypatch.setattr( mcp_client, "_build_server", @@ -99,6 +99,12 @@ def _patch_engine_scaffold( captured: dict[str, Any] = {} + def _make_model_settings(*_args: Any, **kwargs: Any) -> ModelSettings: + captured["model_settings_kwargs"] = kwargs + return ModelSettings() + + monkeypatch.setattr(runner, "make_model_settings", _make_model_settings) + def _build_strix_agent(**kwargs: Any) -> object: if kwargs.get("is_root") and "kwargs" not in captured: captured["kwargs"] = kwargs @@ -285,6 +291,23 @@ async def test_unknown_tool_calls_are_returned_to_the_model( assert captured["run_config"].tool_not_found_behavior == "return_error_to_model" +@pytest.mark.asyncio +async def test_llm_max_tokens_flows_into_model_settings( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + captured = _patch_engine_scaffold(monkeypatch, tmp_path, {"scope": "built-in"}) + + await runner.run_strix_scan( + scan_config={"targets": [], "scan_mode": "deep"}, + scan_id="scan-token-budget", + image="img", + coordinator=AgentCoordinator(), + ) + + assert captured["model_settings_kwargs"]["max_tokens"] == 12_000 + + def test_scope_is_rendered_once_at_the_end_of_the_prompt() -> None: prompt = render_system_prompt( system_prompt_context={ diff --git a/tests/test_runner_teardown.py b/tests/test_runner_teardown.py index e3c43696..a86a4e73 100644 --- a/tests/test_runner_teardown.py +++ b/tests/test_runner_teardown.py @@ -53,6 +53,7 @@ def _settings() -> Any: prompt_cache=True, extra_headers=None, extra_body=None, + max_tokens=None, ), runtime=types.SimpleNamespace(max_context_images=3), )