diff --git a/backend/tests/test_setup_wizard.py b/backend/tests/test_setup_wizard.py index 5f8be4ae0..f90c2f8f4 100644 --- a/backend/tests/test_setup_wizard.py +++ b/backend/tests/test_setup_wizard.py @@ -8,7 +8,7 @@ from __future__ import annotations import yaml from wizard import ui as wizard_ui -from wizard.providers import LLM_PROVIDERS, SEARCH_PROVIDERS, WEB_FETCH_PROVIDERS, LLMProvider +from wizard.providers import LLM_PROVIDERS, SEARCH_PROVIDERS, WEB_FETCH_PROVIDERS, LLMProvider, with_thinking_support from wizard.steps import channels as channels_step from wizard.steps import llm as llm_step from wizard.steps import search as search_step @@ -368,6 +368,32 @@ class TestBuildMinimalConfig: assert all(not config["enabled"] for provider, config in channel_connections.items() if provider != "enabled") +class TestThinkingSupport: + def test_other_provider_requests_thinking_prompt(self): + other = next(p for p in LLM_PROVIDERS if p.name == "other") + assert other.ask_thinking_support is True + + def test_with_thinking_support_enabled_wires_toggles(self): + other = next(p for p in LLM_PROVIDERS if p.name == "other") + original = dict(other.extra_config) + + updated = with_thinking_support(other, True) + + assert updated.extra_config["supports_thinking"] is True + assert updated.extra_config["when_thinking_enabled"]["extra_body"]["thinking"]["type"] == "enabled" + assert updated.extra_config["when_thinking_disabled"]["extra_body"]["thinking"]["type"] == "disabled" + # The shared provider singleton must not be mutated. + assert other.extra_config == original + + def test_with_thinking_support_disabled_marks_unsupported(self): + other = next(p for p in LLM_PROVIDERS if p.name == "other") + + updated = with_thinking_support(other, False) + + assert updated.extra_config["supports_thinking"] is False + assert "when_thinking_enabled" not in updated.extra_config + + class TestLLMStep: def test_model_selection_defaults_to_provider_default_model(self, monkeypatch): provider = LLMProvider( @@ -423,6 +449,65 @@ class TestLLMStep: assert result.base_url == "https://gateway.example/v1" + def test_other_gateway_prompts_and_enables_thinking(self, monkeypatch): + provider = LLMProvider( + name="other", + display_name="Other OpenAI-compatible", + description="Custom gateway", + use="langchain_openai:ChatOpenAI", + models=["gpt-4o"], + default_model="gpt-4o", + env_var="OPENAI_API_KEY", + package="langchain-openai", + base_url_prompt="Base URL", + model_prompt="Model name", + ask_thinking_support=True, + ) + + monkeypatch.setattr(llm_step, "LLM_PROVIDERS", [provider]) + monkeypatch.setattr(llm_step, "ask_choice", lambda *_args, **_kwargs: 0) + monkeypatch.setattr(llm_step, "ask_text", lambda *_args, **_kwargs: "custom-thinking-model") + monkeypatch.setattr(llm_step, "ask_secret", lambda _prompt: "key") + monkeypatch.setattr(llm_step, "ask_yes_no", lambda *_args, **_kwargs: True) + monkeypatch.setattr(llm_step, "print_header", lambda *_args, **_kwargs: None) + monkeypatch.setattr(llm_step, "print_info", lambda *_args, **_kwargs: None) + monkeypatch.setattr(llm_step, "print_success", lambda *_args, **_kwargs: None) + + result = llm_step.run_llm_step() + + assert result.model_name == "custom-thinking-model" + assert result.provider.extra_config["supports_thinking"] is True + assert result.provider.extra_config["when_thinking_enabled"]["extra_body"]["thinking"]["type"] == "enabled" + + def test_other_gateway_declined_thinking_marks_unsupported(self, monkeypatch): + provider = LLMProvider( + name="other", + display_name="Other OpenAI-compatible", + description="Custom gateway", + use="langchain_openai:ChatOpenAI", + models=["gpt-4o"], + default_model="gpt-4o", + env_var="OPENAI_API_KEY", + package="langchain-openai", + base_url_prompt="Base URL", + model_prompt="Model name", + ask_thinking_support=True, + ) + + monkeypatch.setattr(llm_step, "LLM_PROVIDERS", [provider]) + monkeypatch.setattr(llm_step, "ask_choice", lambda *_args, **_kwargs: 0) + monkeypatch.setattr(llm_step, "ask_text", lambda *_args, **_kwargs: "plain-model") + monkeypatch.setattr(llm_step, "ask_secret", lambda _prompt: "key") + monkeypatch.setattr(llm_step, "ask_yes_no", lambda *_args, **_kwargs: False) + monkeypatch.setattr(llm_step, "print_header", lambda *_args, **_kwargs: None) + monkeypatch.setattr(llm_step, "print_info", lambda *_args, **_kwargs: None) + monkeypatch.setattr(llm_step, "print_success", lambda *_args, **_kwargs: None) + + result = llm_step.run_llm_step() + + assert result.provider.extra_config["supports_thinking"] is False + assert "when_thinking_enabled" not in result.provider.extra_config + class TestChannelsStep: def test_returns_selected_channel_keys(self, monkeypatch): diff --git a/scripts/wizard/providers.py b/scripts/wizard/providers.py index 05b4c515a..9d260c75d 100644 --- a/scripts/wizard/providers.py +++ b/scripts/wizard/providers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace @dataclass @@ -26,6 +26,9 @@ class LLMProvider: auth_hint: str | None = None base_url_prompt: str | None = None model_prompt: str | None = None + # For generic OpenAI-compatible gateways the wizard cannot infer whether the + # user-supplied model supports thinking/reasoning, so prompt for it explicitly. + ask_thinking_support: bool = False def extra_config_for(self, model_name: str) -> dict: """Return extra_config for a selected model, applying per-model overrides. @@ -94,6 +97,22 @@ ANTHROPIC_THINKING_CONFIG = { } +def with_thinking_support(provider: LLMProvider, supports_thinking: bool) -> LLMProvider: + """Return a copy of *provider* with thinking-capability flags applied. + + For generic OpenAI-compatible gateways the wizard cannot infer whether the + user-supplied model supports thinking/reasoning. When the user confirms + support we also wire the common OpenAI-compatible enable/disable toggles so + the runtime can switch thinking on and off; otherwise we record the + capability as unsupported. The shared provider definition is never mutated. + """ + if supports_thinking: + extra_config = {**provider.extra_config, **OPENAI_COMPAT_THINKING_CONFIG} + else: + extra_config = {**provider.extra_config, "supports_thinking": False} + return replace(provider, extra_config=extra_config) + + LLM_PROVIDERS: list[LLMProvider] = [ LLMProvider( name="volcengine", @@ -462,6 +481,7 @@ LLM_PROVIDERS: list[LLMProvider] = [ package="langchain-openai", base_url_prompt="Base URL (e.g. https://api.openai.com/v1)", model_prompt="Model name", + ask_thinking_support=True, ), ] diff --git a/scripts/wizard/steps/llm.py b/scripts/wizard/steps/llm.py index 4291181bc..c20956ac4 100644 --- a/scripts/wizard/steps/llm.py +++ b/scripts/wizard/steps/llm.py @@ -4,11 +4,12 @@ from __future__ import annotations from dataclasses import dataclass -from wizard.providers import LLM_PROVIDERS, LLMProvider +from wizard.providers import LLM_PROVIDERS, LLMProvider, with_thinking_support from wizard.ui import ( ask_choice, ask_secret, ask_text, + ask_yes_no, print_header, print_info, print_success, @@ -52,6 +53,16 @@ def run_llm_step(step_label: str = "Step 1/3") -> LLMStepResult: if provider.model_prompt: model_name = ask_text(provider.model_prompt, default=model_name) + if provider.ask_thinking_support: + print_header(f"{step_label} · Model capabilities") + supports_thinking = ask_yes_no( + f"Does '{model_name}' support thinking/reasoning?", + default=False, + ) + provider = with_thinking_support(provider, supports_thinking) + if supports_thinking: + print_info("Thinking enabled. Adjust the toggle in config.yaml if your gateway uses a different mechanism.") + if provider.auth_hint: print_header(f"{step_label} · Authentication") print_info(provider.auth_hint)