fix(toolchoice): default required tool choice on OpenAI-compatible custom endpoints

Reasoning models on OpenAI-compatible custom endpoints (e.g. GLM / Kimi)
often reason and reply with prose instead of emitting native tool calls
under the default auto tool choice, stalling the tool-driven scan loop
while burning tokens. Default force_required_tool_choice to auto (None):
enable tool_choice=required when an api_base is set (still gated to
models that accept it), off otherwise. Explicit 0/1 overrides.
This commit is contained in:
Alex Schapiro
2026-07-29 03:27:40 +00:00
parent d2fd1d6103
commit b861413d10
6 changed files with 56 additions and 4 deletions
+5 -2
View File
@@ -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(
+10 -2
View File
@@ -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
+1
View File
@@ -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,
)
+38
View File
@@ -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",
+1
View File
@@ -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,
),
+1
View File
@@ -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,
),