diff --git a/strix/config/settings.py b/strix/config/settings.py index 78d52273..13b96c19 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -36,8 +36,11 @@ class LlmSettings(BaseSettings): ), ) reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT") - force_required_tool_choice: bool = Field( - default=False, + # None = auto: force required tool choice on OpenAI-compatible custom + # endpoints (where reasoning models otherwise burn tokens thinking without + # ever calling a tool), off elsewhere. True/False overrides explicitly. + force_required_tool_choice: bool | None = Field( + default=None, alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE", ) prompt_cache: bool = Field( diff --git a/strix/core/inputs.py b/strix/core/inputs.py index aef1fe13..a39b6523 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -129,7 +129,8 @@ def make_model_settings( reasoning_effort: ReasoningEffort | None, *, model_name: str, - force_required_tool_choice: bool = False, + force_required_tool_choice: bool | None = None, + custom_api_base: bool = False, request_timeout: float | None = None, prompt_cache: bool = True, ) -> ModelSettings: @@ -147,7 +148,14 @@ def make_model_settings( model_settings = model_settings.resolve( ModelSettings(reasoning=Reasoning(effort=reasoning_effort)), ) - if force_required_tool_choice and _accepts_required_tool_choice(model_name): + # Strix is fully tool-driven, so a turn that returns prose instead of a tool + # call stalls the scan. Reasoning models on OpenAI-compatible custom + # endpoints (e.g. GLM / Kimi via cortecs) do exactly that under the default + # ``auto`` tool choice, so default to ``required`` there; ``None`` means auto. + use_required = ( + custom_api_base if force_required_tool_choice is None else force_required_tool_choice + ) + if use_required and _accepts_required_tool_choice(model_name): model_settings = model_settings.resolve(ModelSettings(tool_choice="required")) cache_extra_args = _prompt_cache_extra_args(model_name) if prompt_cache else None diff --git a/strix/core/runner.py b/strix/core/runner.py index c5f51b15..3b29aed6 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -248,6 +248,7 @@ async def run_strix_scan( settings.llm.reasoning_effort, model_name=resolved_model, force_required_tool_choice=settings.llm.force_required_tool_choice, + custom_api_base=bool(settings.llm.api_base), request_timeout=settings.llm.timeout, prompt_cache=settings.llm.prompt_cache, ) diff --git a/tests/test_inputs.py b/tests/test_inputs.py index da914879..c0c0f275 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -255,6 +255,44 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() -> assert settings.tool_choice == "required" +def test_make_model_settings_auto_forces_required_on_custom_openai_endpoint() -> None: + # GLM / Kimi via cortecs: openai/-routed reasoning model on a custom base. + settings = make_model_settings( + None, + model_name="openai/glm-5.2", + custom_api_base=True, + ) + + assert settings.tool_choice == "required" + + +def test_make_model_settings_auto_skips_required_without_custom_endpoint() -> None: + settings = make_model_settings(None, model_name="openai/glm-5.2") + + assert settings.tool_choice is None + + +def test_make_model_settings_explicit_false_overrides_custom_endpoint() -> None: + settings = make_model_settings( + None, + model_name="openai/glm-5.2", + force_required_tool_choice=False, + custom_api_base=True, + ) + + assert settings.tool_choice is None + + +def test_make_model_settings_auto_skips_required_for_non_openai_custom_endpoint() -> None: + settings = make_model_settings( + None, + model_name="anthropic/claude-3-7-sonnet-latest", + custom_api_base=True, + ) + + assert settings.tool_choice is None + + def test_make_model_settings_sets_request_timeout() -> None: settings = make_model_settings( "none", diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index 482c4730..c5e22ac9 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -38,6 +38,7 @@ async def test_persistent_rate_limit_stops_gracefully( model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + api_base=None, timeout=300, prompt_cache=True, ), diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index 56d7caa6..ef8be1a0 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -46,6 +46,7 @@ def _patch_engine_scaffold( model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + api_base=None, timeout=300, prompt_cache=True, ),