diff --git a/backend/tests/test_setup_wizard.py b/backend/tests/test_setup_wizard.py index 7456811c3..3538289a3 100644 --- a/backend/tests/test_setup_wizard.py +++ b/backend/tests/test_setup_wizard.py @@ -7,7 +7,8 @@ Run from repo root: from __future__ import annotations import yaml -from wizard.providers import LLM_PROVIDERS, SEARCH_PROVIDERS, WEB_FETCH_PROVIDERS +from wizard.providers import LLM_PROVIDERS, SEARCH_PROVIDERS, WEB_FETCH_PROVIDERS, LLMProvider +from wizard.steps import llm as llm_step from wizard.steps import search as search_step from wizard.writer import ( build_minimal_config, @@ -21,6 +22,38 @@ class TestProviders: def test_llm_providers_not_empty(self): assert len(LLM_PROVIDERS) >= 8 + def test_llm_providers_cover_config_example_families(self): + providers = {provider.name: provider for provider in LLM_PROVIDERS} + + expected = { + "volcengine", + "openai", + "openai_responses", + "ollama_qwen", + "ollama_gemma", + "anthropic", + "google", + "gemini_openai_gateway", + "mimo", + "deepseek", + "kimi", + "novita", + "minimax", + "minimax_cn", + "openrouter", + "vllm", + "mindie", + "codex", + "claude_code", + } + assert expected.issubset(providers) + + assert providers["openai_responses"].extra_config["use_responses_api"] is True + assert providers["gemini_openai_gateway"].use == "deerflow.models.patched_openai:PatchedChatOpenAI" + assert providers["mimo"].use == "deerflow.models.patched_mimo:PatchedChatMiMo" + assert providers["deepseek"].use == "deerflow.models.patched_deepseek:PatchedChatDeepSeek" + assert providers["volcengine"].extra_config["api_base"] == "https://ark.cn-beijing.volces.com/api/v3" + def test_llm_providers_have_required_fields(self): for p in LLM_PROVIDERS: assert p.name @@ -236,6 +269,97 @@ class TestBuildMinimalConfig: model = data["models"][0] assert "api_key" not in model + def test_responses_api_provider_defaults_are_preserved(self): + provider = next(p for p in LLM_PROVIDERS if p.name == "openai_responses") + content = build_minimal_config( + provider_use=provider.use, + model_name=provider.default_model, + display_name=provider.display_name, + api_key_field=provider.api_key_field, + env_var=provider.env_var, + extra_model_config=provider.extra_config, + ) + data = yaml.safe_load(content) + model = data["models"][0] + assert model["use_responses_api"] is True + assert model["output_version"] == "responses/v1" + assert model["supports_vision"] is True + + def test_patched_thinking_provider_defaults_are_preserved(self): + provider = next(p for p in LLM_PROVIDERS if p.name == "mimo") + content = build_minimal_config( + provider_use=provider.use, + model_name=provider.default_model, + display_name=provider.display_name, + api_key_field=provider.api_key_field, + env_var=provider.env_var, + extra_model_config=provider.extra_config, + ) + data = yaml.safe_load(content) + model = data["models"][0] + assert model["use"] == "deerflow.models.patched_mimo:PatchedChatMiMo" + assert model["base_url"] == "https://api.xiaomimimo.com/v1" + assert model["api_key"] == "$MIMO_API_KEY" + assert model["supports_thinking"] is True + assert model["when_thinking_enabled"]["extra_body"]["thinking"]["type"] == "enabled" + assert model["when_thinking_disabled"]["extra_body"]["thinking"]["type"] == "disabled" + + +class TestLLMStep: + def test_model_selection_defaults_to_provider_default_model(self, monkeypatch): + provider = LLMProvider( + name="test", + display_name="Test", + description="provider", + use="langchain_openai:ChatOpenAI", + models=["first-model", "default-model"], + default_model="default-model", + env_var="TEST_API_KEY", + package="langchain-openai", + ) + prompts: list[tuple[str, int | None]] = [] + + def fake_choice(prompt, options, default=None): + prompts.append((prompt, default)) + return default if default is not None else 0 + + monkeypatch.setattr(llm_step, "LLM_PROVIDERS", [provider]) + monkeypatch.setattr(llm_step, "ask_choice", fake_choice) + monkeypatch.setattr(llm_step, "ask_secret", lambda _prompt: "key") + 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 == "default-model" + assert prompts == [("Enter choice", None), ("Select model", 1)] + + def test_base_url_prompt_is_used_for_custom_gateway(self, monkeypatch): + provider = LLMProvider( + name="gateway", + display_name="Gateway", + description="provider", + use="langchain_openai:ChatOpenAI", + models=["gateway/model"], + default_model="gateway/model", + env_var="GATEWAY_API_KEY", + package="langchain-openai", + base_url_prompt="Gateway URL", + ) + + 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: "https://gateway.example/v1") + monkeypatch.setattr(llm_step, "ask_secret", lambda _prompt: "key") + 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.base_url == "https://gateway.example/v1" + # --------------------------------------------------------------------------- # writer.py — env file helpers diff --git a/scripts/wizard/providers.py b/scripts/wizard/providers.py index 0b09d7770..f45057cd0 100644 --- a/scripts/wizard/providers.py +++ b/scripts/wizard/providers.py @@ -20,6 +20,8 @@ class LLMProvider: # Extra config fields beyond the common ones (merged into YAML) extra_config: dict = field(default_factory=dict) auth_hint: str | None = None + base_url_prompt: str | None = None + model_prompt: str | None = None @dataclass @@ -44,48 +46,292 @@ class SearchProvider: extra_config: dict = field(default_factory=dict) +OPENAI_COMPAT_THINKING_CONFIG = { + "supports_thinking": True, + "when_thinking_enabled": { + "extra_body": { + "thinking": { + "type": "enabled", + } + } + }, + "when_thinking_disabled": { + "extra_body": { + "thinking": { + "type": "disabled", + } + } + }, +} + +ANTHROPIC_THINKING_CONFIG = { + "supports_thinking": True, + "when_thinking_enabled": { + "thinking": { + "type": "enabled", + "budget_tokens": 4096, + } + }, + "when_thinking_disabled": { + "thinking": { + "type": "disabled", + } + }, +} + + LLM_PROVIDERS: list[LLMProvider] = [ + LLMProvider( + name="volcengine", + display_name="Volcengine Doubao", + description="Doubao Seed with thinking support", + use="deerflow.models.patched_deepseek:PatchedChatDeepSeek", + models=["doubao-seed-1-8-251228"], + default_model="doubao-seed-1-8-251228", + env_var="VOLCENGINE_API_KEY", + package="langchain-deepseek", + extra_config={ + "api_base": "https://ark.cn-beijing.volces.com/api/v3", + "timeout": 600.0, + "max_retries": 2, + "supports_vision": True, + "supports_reasoning_effort": True, + **OPENAI_COMPAT_THINKING_CONFIG, + }, + ), LLMProvider( name="openai", display_name="OpenAI", - description="GPT-4o, GPT-4.1, o3", + description="GPT-5, GPT-4.1, GPT-4o", use="langchain_openai:ChatOpenAI", - models=["gpt-4o", "gpt-4.1", "o3"], - default_model="gpt-4o", + models=["gpt-5", "gpt-5-mini", "gpt-4.1", "gpt-4o"], + default_model="gpt-5", env_var="OPENAI_API_KEY", package="langchain-openai", + extra_config={ + "request_timeout": 600.0, + "max_retries": 2, + "max_tokens": 4096, + "temperature": 0.7, + "supports_vision": True, + }, + ), + LLMProvider( + name="openai_responses", + display_name="OpenAI Responses API", + description="GPT-5 via /v1/responses", + use="langchain_openai:ChatOpenAI", + models=["gpt-5", "gpt-5-mini"], + default_model="gpt-5", + env_var="OPENAI_API_KEY", + package="langchain-openai", + extra_config={ + "request_timeout": 600.0, + "max_retries": 2, + "use_responses_api": True, + "output_version": "responses/v1", + "supports_vision": True, + }, ), LLMProvider( name="anthropic", display_name="Anthropic", - description="Claude Opus 4, Sonnet 4", + description="Claude Sonnet 4 with extended thinking", use="langchain_anthropic:ChatAnthropic", - models=["claude-opus-4-5", "claude-sonnet-4-5"], - default_model="claude-sonnet-4-5", + models=["claude-sonnet-4-20250514", "claude-opus-4-5", "claude-sonnet-4-5"], + default_model="claude-sonnet-4-20250514", env_var="ANTHROPIC_API_KEY", package="langchain-anthropic", - extra_config={"max_tokens": 8192}, + extra_config={ + "default_request_timeout": 600.0, + "max_retries": 2, + "max_tokens": 16000, + "supports_vision": True, + **ANTHROPIC_THINKING_CONFIG, + }, ), LLMProvider( name="deepseek", display_name="DeepSeek", - description="V3, R1", - use="langchain_deepseek:ChatDeepSeek", - models=["deepseek-chat", "deepseek-reasoner"], - default_model="deepseek-chat", + description="DeepSeek Reasoner with thinking support", + use="deerflow.models.patched_deepseek:PatchedChatDeepSeek", + models=["deepseek-reasoner", "deepseek-chat"], + default_model="deepseek-reasoner", env_var="DEEPSEEK_API_KEY", package="langchain-deepseek", + extra_config={ + "timeout": 600.0, + "max_retries": 2, + "max_tokens": 8192, + "supports_vision": False, + **OPENAI_COMPAT_THINKING_CONFIG, + }, ), LLMProvider( name="google", display_name="Google Gemini", - description="2.0 Flash, 2.5 Pro", + description="Native Gemini SDK, no thinking support", use="langchain_google_genai:ChatGoogleGenerativeAI", - models=["gemini-2.0-flash", "gemini-2.5-pro"], - default_model="gemini-2.0-flash", + models=["gemini-2.5-pro", "gemini-2.0-flash"], + default_model="gemini-2.5-pro", env_var="GEMINI_API_KEY", package="langchain-google-genai", api_key_field="gemini_api_key", + extra_config={ + "timeout": 600.0, + "max_retries": 2, + "max_tokens": 8192, + "supports_vision": True, + }, + ), + LLMProvider( + name="gemini_openai_gateway", + display_name="Gemini OpenAI-compatible", + description="Gemini thinking via an OpenAI-compatible gateway", + use="deerflow.models.patched_openai:PatchedChatOpenAI", + models=["google/gemini-2.5-pro-preview"], + default_model="google/gemini-2.5-pro-preview", + env_var="GEMINI_API_KEY", + package="langchain-openai", + extra_config={ + "request_timeout": 600.0, + "max_retries": 2, + "max_tokens": 16384, + "supports_vision": True, + **OPENAI_COMPAT_THINKING_CONFIG, + }, + base_url_prompt="Gateway base URL (e.g. https://your-gateway.example/v1)", + ), + LLMProvider( + name="ollama_qwen", + display_name="Ollama Qwen3", + description="Native local Ollama provider with thinking support", + use="langchain_ollama:ChatOllama", + models=["qwen3:32b"], + default_model="qwen3:32b", + env_var=None, + package="langchain-ollama", + extra_config={ + "base_url": "http://localhost:11434", + "num_predict": 8192, + "temperature": 0.7, + "reasoning": True, + "supports_thinking": True, + "supports_vision": False, + }, + auth_hint="No API key is required. Ensure Ollama is running and the model is pulled.", + ), + LLMProvider( + name="ollama_gemma", + display_name="Ollama Gemma", + description="Native local Ollama provider with vision support", + use="langchain_ollama:ChatOllama", + models=["gemma4:27b"], + default_model="gemma4:27b", + env_var=None, + package="langchain-ollama", + extra_config={ + "base_url": "http://localhost:11434", + "num_predict": 8192, + "temperature": 0.7, + "reasoning": True, + "supports_thinking": True, + "supports_vision": True, + }, + auth_hint="No API key is required. Ensure Ollama is running and the model is pulled.", + ), + LLMProvider( + name="mimo", + display_name="Xiaomi MiMo", + description="MiMo thinking models with reasoning replay", + use="deerflow.models.patched_mimo:PatchedChatMiMo", + models=["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"], + default_model="mimo-v2.5-pro", + env_var="MIMO_API_KEY", + package="langchain-openai", + extra_config={ + "base_url": "https://api.xiaomimimo.com/v1", + "request_timeout": 600.0, + "max_retries": 2, + "max_tokens": 8192, + "supports_vision": False, + **OPENAI_COMPAT_THINKING_CONFIG, + }, + ), + LLMProvider( + name="kimi", + display_name="Moonshot Kimi", + description="Kimi K2.5 with thinking support", + use="deerflow.models.patched_deepseek:PatchedChatDeepSeek", + models=["kimi-k2.5"], + default_model="kimi-k2.5", + env_var="MOONSHOT_API_KEY", + package="langchain-deepseek", + extra_config={ + "api_base": "https://api.moonshot.cn/v1", + "timeout": 600.0, + "max_retries": 2, + "max_tokens": 32768, + "supports_vision": True, + **OPENAI_COMPAT_THINKING_CONFIG, + }, + ), + LLMProvider( + name="novita", + display_name="Novita AI", + description="DeepSeek V3.2 via OpenAI-compatible API", + use="langchain_openai:ChatOpenAI", + models=["deepseek/deepseek-v3.2"], + default_model="deepseek/deepseek-v3.2", + env_var="NOVITA_API_KEY", + package="langchain-openai", + extra_config={ + "base_url": "https://api.novita.ai/openai", + "request_timeout": 600.0, + "max_retries": 2, + "max_tokens": 4096, + "temperature": 0.7, + "supports_vision": True, + **OPENAI_COMPAT_THINKING_CONFIG, + }, + ), + LLMProvider( + name="minimax", + display_name="MiniMax", + description="International OpenAI-compatible endpoint", + use="langchain_openai:ChatOpenAI", + models=["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"], + default_model="MiniMax-M3", + env_var="MINIMAX_API_KEY", + package="langchain-openai", + extra_config={ + "base_url": "https://api.minimax.io/v1", + "request_timeout": 600.0, + "max_retries": 2, + "max_tokens": 4096, + "temperature": 1.0, + "supports_vision": True, + "supports_thinking": True, + }, + ), + LLMProvider( + name="minimax_cn", + display_name="MiniMax CN", + description="China OpenAI-compatible endpoint", + use="langchain_openai:ChatOpenAI", + models=["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"], + default_model="MiniMax-M3", + env_var="MINIMAX_API_KEY", + package="langchain-openai", + extra_config={ + "base_url": "https://api.minimaxi.com/v1", + "request_timeout": 600.0, + "max_retries": 2, + "max_tokens": 4096, + "temperature": 1.0, + "supports_vision": True, + "supports_thinking": True, + }, ), LLMProvider( name="openrouter", @@ -127,6 +373,35 @@ LLM_PROVIDERS: list[LLMProvider] = [ } } }, + "when_thinking_disabled": { + "extra_body": { + "chat_template_kwargs": { + "enable_thinking": False, + } + } + }, + }, + ), + LLMProvider( + name="mindie", + display_name="MindIE", + description="Qwen3-Coder on MindIE Engine", + use="deerflow.models.mindie_provider:MindIEChatModel", + models=["Qwen3-Coder-480B-A35B-Instruct-Client"], + default_model="Qwen3-Coder-480B-A35B-Instruct-Client", + env_var="OPENAI_API_KEY", + package=None, + extra_config={ + "base_url": "http://localhost:8989/v1", + "temperature": 0, + "max_retries": 1, + "supports_thinking": False, + "supports_vision": False, + "supports_reasoning_effort": False, + "read_timeout": 900.0, + "connect_timeout": 30.0, + "write_timeout": 60.0, + "pool_timeout": 30.0, }, ), LLMProvider( @@ -163,6 +438,8 @@ LLM_PROVIDERS: list[LLMProvider] = [ default_model="gpt-4o", env_var="OPENAI_API_KEY", package="langchain-openai", + base_url_prompt="Base URL (e.g. https://api.openai.com/v1)", + model_prompt="Model name", ), ] diff --git a/scripts/wizard/steps/llm.py b/scripts/wizard/steps/llm.py index 7e8ffd401..4291181bc 100644 --- a/scripts/wizard/steps/llm.py +++ b/scripts/wizard/steps/llm.py @@ -32,10 +32,11 @@ def run_llm_step(step_label: str = "Step 1/3") -> LLMStepResult: print() - # Model selection (show list, default to first) + # Model selection (show list, default to provider preference) if len(provider.models) > 1: print_info(f"Available models for {provider.display_name}:") - model_idx = ask_choice("Select model", provider.models, default=0) + default_model_idx = provider.models.index(provider.default_model) + model_idx = ask_choice("Select model", provider.models, default=default_model_idx) model_name = provider.models[model_idx] else: model_name = provider.models[0] @@ -44,11 +45,14 @@ def run_llm_step(step_label: str = "Step 1/3") -> LLMStepResult: base_url: str | None = None if provider.name in {"openrouter", "vllm"}: base_url = provider.extra_config.get("base_url") - if provider.name == "other": + + if provider.base_url_prompt: print_header(f"{step_label} · Connection details") - base_url = ask_text("Base URL (e.g. https://api.openai.com/v1)", required=True) - model_name = ask_text("Model name", default=provider.default_model) - elif provider.auth_hint: + base_url = ask_text(provider.base_url_prompt, default=base_url or "", required=True) + if provider.model_prompt: + model_name = ask_text(provider.model_prompt, default=model_name) + + if provider.auth_hint: print_header(f"{step_label} · Authentication") print_info(provider.auth_hint) api_key = None