Files
EvoScientist-Multi/EvoScientist/config/onboard/wizard.py
T
jax-novita bcee009917 Add Novita AI as an LLM provider (#422)
* Add Novita as an LLM provider

Registers Novita (novita.ai) as an OpenAI-routed provider, following the
same pattern as Requesty/Atlas Cloud/SiliconFlow: a base_url + API key env
var entry in _OPENAI_ROUTED_PROVIDERS, a handful of model registry entries
(DeepSeek/Qwen/GLM), onboarding wizard support (constants/steps/wizard/
helpers), a key validator using the auth-preflight sentinel pattern (Novita's
/v1/models endpoint returns the public catalog even for an invalid key, so
auth must be checked via a chat completion instead), and a host-to-provider
mapping entry for error attribution.

* Recommend Novita's current flagship models

The models listed for Novita were older ids that no longer reflect what
the platform leads with. Point the recommendations at the three current
flagships instead, each verified against api.novita.ai:

  moonshotai/kimi-k3              1M context, native vision
  zai-org/glm-5.2                 1M context, long-horizon agentic work
  deepseek/deepseek-v4-flash-0731 1M context, cheapest of the three

Context windows, output limits, input modalities and pricing were taken
from the live /openai/v1/models response rather than carried over.

* Keep branch CI workflow files unchanged (no workflow OAuth scope)

Co-authored-by: multica-agent <github@multica.ai>

* ci: restore workflow files to match main

---------

Co-authored-by: jax-novita <jax-novita@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Dinos Papakostas <dinospk1999@gmail.com>
Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-08-18 08:16:45 +00:00

967 lines
38 KiB
Python

"""Onboarding wizard entry point and progress display."""
from __future__ import annotations
import copy
import os
import questionary
from rich.panel import Panel
from rich.text import Text
from ...runtime import AsyncRuntime
from ..settings import (
EvoScientistConfig,
get_config_path,
load_config,
save_config,
)
from .channels import _step_channels
from .steps import (
_step_anthropic_auth_mode,
_step_auxiliary_enable,
_step_base_url,
_step_langgraph_dev_port,
_step_mcp_servers,
_step_minimax_region,
_step_model,
_step_ollama_base_url,
_step_openai_auth_mode,
_step_provider,
_step_provider_api_key,
_step_reasoning_effort,
_step_skills,
_step_tavily_key,
_step_thinking,
_step_tinytex,
_step_ui_backend,
_step_webui_port,
_step_workspace,
)
from .style import (
CONFIRM_STYLE,
QMARK,
_print_header,
_print_section,
_print_step_skipped,
console,
)
STEPS = [
"UI",
"LangGraph Port",
"Provider",
"API Key",
"Model",
"Auxiliary Model",
"Tavily Key",
"Workspace",
"Thinking",
"Skills",
"MCP Servers",
"LaTeX",
"Channels",
]
def render_progress(current_step: int, completed: set[int]) -> Panel:
"""Render the progress indicator panel.
Args:
current_step: Index of the current step (0-based).
completed: Set of completed step indices.
Returns:
A Rich Panel displaying the progress.
"""
lines = []
for i, step_name in enumerate(STEPS):
if i in completed:
icon = Text("●", style="green bold")
label = Text(f" {step_name}", style="green")
elif i == current_step:
icon = Text("◉", style="cyan bold")
label = Text(f" {step_name}", style="cyan bold")
else:
icon = Text("○", style="dim")
label = Text(f" {step_name}", style="dim")
line = Text()
line.append_text(icon)
line.append_text(label)
lines.append(line)
# Add connector line between steps
if i < len(STEPS) - 1:
if i in completed:
connector_style = "green"
elif i == current_step:
connector_style = "cyan"
else:
connector_style = "dim"
lines.append(Text("│", style=connector_style))
# Join all lines with newlines
content = Text("\n").join(lines)
return Panel(content, title="[bold]EvoScientist Setup[/bold]", border_style="blue")
# =============================================================================
# Main onboard function
# =============================================================================
_PROVIDER_KEY_ATTR = {
"anthropic": "anthropic_api_key",
"minimax": "minimax_api_key",
"nvidia": "nvidia_api_key",
"google-genai": "google_api_key",
"siliconflow": "siliconflow_api_key",
"openrouter": "openrouter_api_key",
"atlascloud": "atlascloud_api_key",
"requesty": "requesty_api_key",
"novita": "novita_api_key",
"deepseek": "deepseek_api_key",
"zhipu": "zhipu_api_key",
"zhipu-code": "zhipu_api_key",
"volcengine": "volcengine_api_key",
"volcengine-code": "volcengine_api_key",
"dashscope": "dashscope_api_key",
"dashscope-code": "dashscope_api_key",
"moonshot": "moonshot_api_key",
"kimi-coding": "kimi_api_key",
"custom-openai": "custom_openai_api_key",
"custom-anthropic": "custom_anthropic_api_key",
}
_MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io/anthropic"
_CUSTOM_PROVIDER_BASE_URL = {
"custom-openai": ("custom_openai_base_url", "CUSTOM_OPENAI_BASE_URL"),
"custom-anthropic": ("custom_anthropic_base_url", "CUSTOM_ANTHROPIC_BASE_URL"),
}
def _autosave(config: EvoScientistConfig) -> None:
"""Persist current config to disk between phases.
Silently swallows IO errors so a transient disk issue doesn't abort the
wizard — the final save at the end will surface anything broken.
"""
try:
save_config(config)
except Exception:
pass
def _configure_provider_base_url(
config: EvoScientistConfig,
provider: str,
*,
strict: bool,
) -> list[str]:
"""Configure provider-specific base URL/region and return Ollama models."""
if provider in _CUSTOM_PROVIDER_BASE_URL:
attr_name, env_name = _CUSTOM_PROVIDER_BASE_URL[provider]
current_base_url = getattr(config, attr_name) or os.environ.get(env_name, "")
if strict:
if not current_base_url:
raise RuntimeError(
f"--non-interactive: {provider} provider needs a base URL. "
f"Set the {env_name} env var or run without --non-interactive."
)
setattr(config, attr_name, current_base_url)
else:
setattr(
config,
attr_name,
_step_base_url(config, current_value=current_base_url),
)
elif provider == "minimax":
if strict:
config.minimax_base_url = (
config.minimax_base_url or _MINIMAX_GLOBAL_BASE_URL
)
else:
config.minimax_base_url = _step_minimax_region(config)
elif provider == "ollama":
if strict:
config.ollama_base_url = (
config.ollama_base_url
or os.environ.get("OLLAMA_BASE_URL", "")
or "http://localhost:11434"
)
else:
ollama_url, ollama_detected_models = _step_ollama_base_url(config)
config.ollama_base_url = ollama_url
return ollama_detected_models
return []
def _configure_provider_auth_mode(
config: EvoScientistConfig,
provider: str,
*,
strict: bool,
) -> None:
"""Configure Anthropic/OpenAI auth mode for the selected provider."""
if provider == "anthropic":
if strict:
config.anthropic_auth_mode = "api_key"
else:
config.anthropic_auth_mode = _step_anthropic_auth_mode(config)
elif provider == "openai":
if strict:
config.openai_auth_mode = "api_key"
else:
config.openai_auth_mode = _step_openai_auth_mode(config)
def _active_llm_providers(config: EvoScientistConfig) -> set[str]:
"""Return providers currently selected by the main and auxiliary models."""
providers = {config.provider}
if config.auxiliary_provider:
providers.add(config.auxiliary_provider)
return providers
def _reconcile_oauth_modes(config: EvoScientistConfig) -> None:
"""Clear OAuth flags for providers no selected model uses."""
active_providers = _active_llm_providers(config)
if "anthropic" not in active_providers:
config.anthropic_auth_mode = "api_key"
if "openai" not in active_providers:
config.openai_auth_mode = "api_key"
def _provider_uses_oauth(config: EvoScientistConfig, provider: str) -> bool:
return (provider == "anthropic" and config.anthropic_auth_mode == "oauth") or (
provider == "openai" and config.openai_auth_mode == "oauth"
)
def _apply_preset_provider_api_key(
config: EvoScientistConfig,
provider: str,
preset_api_key: str,
*,
skip_validation: bool,
) -> None:
"""Validate and store a CLI-supplied provider API key."""
if not skip_validation:
from .helpers import _provider_key_info
_info = _provider_key_info(config, provider)
validate_fn = _info[2] if _info else None
if validate_fn is not None:
console.print(" [dim]Validating preset API key...[/dim]", end="")
valid, msg = validate_fn(preset_api_key)
if valid:
console.print(f"\r [green]✓ {msg}[/green] ")
else:
console.print(f"\r [red]✗ {msg}[/red] ")
raise RuntimeError(
f"--api-key rejected by {provider} validator: {msg}. "
"Pass --skip-validation to override."
)
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
setattr(config, key_attr, preset_api_key)
console.print(
f" [green]✓ API key: ***{preset_api_key[-4:]}[/green] [dim](--api-key)[/dim]"
)
def _configure_provider_api_key(
config: EvoScientistConfig,
provider: str,
*,
skip_validation: bool,
preset_api_key: str | None = None,
require_api_key=None,
) -> None:
"""Configure provider API key unless the provider does not need one."""
if provider == "ollama" or _provider_uses_oauth(config, provider):
return
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
if preset_api_key is not None:
_apply_preset_provider_api_key(
config,
provider,
preset_api_key,
skip_validation=skip_validation,
)
return
if require_api_key is not None:
require_api_key()
new_key = _step_provider_api_key(config, provider, skip_validation)
if new_key is not None:
setattr(config, key_attr, new_key)
elif not getattr(config, key_attr):
_print_step_skipped("API Key", "not set")
def _provider_connection_configured(config: EvoScientistConfig, provider: str) -> bool:
"""Return True when provider-level setup can be safely reused."""
if provider == "ollama":
return bool(config.ollama_base_url)
if provider == "custom-openai" and not config.custom_openai_base_url:
return False
if provider == "custom-anthropic" and not config.custom_anthropic_base_url:
return False
if provider == "minimax" and not config.minimax_base_url:
return False
if _provider_uses_oauth(config, provider):
return True
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
return bool(getattr(config, key_attr))
def _configure_provider_connection(
config: EvoScientistConfig,
provider: str,
*,
strict: bool,
skip_validation: bool,
preset_api_key: str | None = None,
require_api_key=None,
) -> list[str]:
"""Configure provider base URL/region, auth mode, and API key."""
ollama_detected_models = _configure_provider_base_url(
config,
provider,
strict=strict,
)
_configure_provider_auth_mode(
config,
provider,
strict=strict,
)
_configure_provider_api_key(
config,
provider,
skip_validation=skip_validation,
preset_api_key=preset_api_key,
require_api_key=require_api_key,
)
return ollama_detected_models
# Sections offered in Keep/Modify/Reset → which step labels they enable.
_SECTION_LABELS: list[tuple[str, str]] = [
("ui", "UI backend"),
("port", "LangGraph server port"),
("provider", "LLM provider + auth + API key"),
("model", "Model + reasoning effort"),
("auxiliary_model", "Auxiliary model (optional)"),
("tavily", "Tavily search key"),
("workspace", "Workspace mode"),
("thinking", "Thinking panel"),
("skills", "Skills"),
("mcp", "MCP servers"),
("latex", "LaTeX (TinyTeX)"),
("channels", "Channels"),
]
_ALL_SECTIONS: frozenset[str] = frozenset(s for s, _ in _SECTION_LABELS)
# Each preset flag implies the section(s) it would change. ``--provider`` also
# cascades into ``model`` because the model list depends on the provider —
# silently keeping a stale model id would leave the first request broken.
_FLAG_TO_SECTIONS: dict[str, frozenset[str]] = {
"ui": frozenset({"ui"}),
"port": frozenset({"port"}),
"provider": frozenset({"provider", "model"}),
# ``--api-key`` re-runs the provider section, which can change provider —
# cascade to model for the same reason ``--provider`` does.
"api_key": frozenset({"provider", "model"}),
"model": frozenset({"model"}),
"tavily_key": frozenset({"tavily"}),
"workspace_mode": frozenset({"workspace"}),
"show_thinking": frozenset({"thinking"}),
}
def _sections_implied_by_flags(prompter) -> frozenset[str]:
"""Sections the user's flag-supplied answers imply should run.
Empty frozenset means no preset flags were passed (only ``--skip-*`` or
``--non-interactive`` or no flags at all).
"""
if prompter is None:
return frozenset()
out: set[str] = set()
for pid in prompter.answers:
out |= _FLAG_TO_SECTIONS.get(pid, set())
return frozenset(out)
def _config_has_meaningful_settings(config: EvoScientistConfig) -> bool:
"""True if the user has been through onboarding before.
Compares ``config`` against fresh ``EvoScientistConfig()`` defaults — any
non-default field means the user has customised something previously.
"""
import dataclasses
default = EvoScientistConfig()
return any(
getattr(config, f.name) != getattr(default, f.name)
for f in dataclasses.fields(config)
)
def _open_existing_config_prompt(
config: EvoScientistConfig,
) -> tuple[frozenset[str], EvoScientistConfig] | None:
"""Offer Keep / Modify / Reset on an existing config.
Returns:
- ``None`` if user chose Keep (wizard should exit early).
- ``(sections, config)`` otherwise: the sections to run and the
(possibly reset) config to operate on.
"""
from questionary import Choice
from .style import QMARK, WIZARD_STYLE
choice = questionary.select(
"Found existing configuration. What would you like to do?",
choices=[
Choice(title="Keep current configuration — exit wizard", value="keep"),
Choice(title="Modify — pick specific sections to update", value="modify"),
Choice(title="Reset — start over from defaults", value="reset"),
],
default="modify",
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if choice is None:
raise KeyboardInterrupt()
if choice == "keep":
console.print()
console.print("[green]✓ Keeping current configuration.[/green]")
console.print(f"[dim] → {get_config_path()}[/dim]")
console.print()
return None
if choice == "reset":
console.print()
console.print("[yellow]Resetting to defaults …[/yellow]")
return _ALL_SECTIONS, EvoScientistConfig()
# Modify: ask which sections.
from .style import _checkbox_ask
section_choices = [
Choice(title=label, value=sid, checked=False) for sid, label in _SECTION_LABELS
]
selected = _checkbox_ask(
section_choices,
"Which sections to update? (Space to toggle, Enter to confirm)",
)
if selected is None:
raise KeyboardInterrupt()
if not selected:
# No section picked → effectively the same as Keep.
console.print()
console.print(
"[green]✓ Nothing selected. Keeping current configuration.[/green]"
)
console.print()
return None
return frozenset(selected), config
def run_onboard(
skip_validation: bool = False,
prompter=None,
only_sections: set[str] | frozenset[str] | None = None,
runtime: AsyncRuntime | None = None,
) -> bool:
"""Run the interactive onboarding wizard.
Args:
skip_validation: Skip API key validation.
prompter: Optional :class:`NonInteractivePrompter` carrying
CLI-supplied answers (``--provider``, ``--model``, …) and
``skip_set`` (sections to bypass). When None, all prompts
fall through to the interactive questionary form.
only_sections: If given, restrict the wizard to exactly these section
ids — the Keep/Modify/Reset prompt is skipped. Used by ``EvoSci
configure <section>`` to re-run a single phase.
runtime: Optional application-scoped async runtime used by channel
login and credential probes. Direct callers may omit it; the
channel step then owns a runtime for the duration of that step.
Returns:
True if configuration was saved, False if cancelled.
Behaviour notes
---------------
Config is **autosaved between phases**: each completed section is written
to ``~/.config/evoscientist/config.yaml`` immediately, so a Ctrl+C does
not lose what's been answered so far. The final ``Save this configuration?``
prompt is the user's chance to *revert* — declining writes the original
snapshot back to disk.
.. warning::
Revert covers **the YAML config file only**. Sections with filesystem
side effects — ``_step_skills`` (downloads + ``npm`` installs),
``_step_mcp_servers`` (writes to ``mcp.yaml``), ``_step_tinytex``
(installs TinyTeX), and ``_step_channels`` (may ``pip install``
channel deps) — execute their side effects *before* the final
confirmation and are **not** rolled back when the user declines to
save. "No" thus restores the YAML but does not uninstall packages,
delete skill files, or remove MCP server entries.
"""
from .prompter import NonInteractivePrompter, select_navigation_active
p = prompter if isinstance(prompter, NonInteractivePrompter) else None
strict = bool(p and p.strict)
def _preset(pid: str):
"""Return preset answer for ``pid`` if available, else None."""
return p.answers.get(pid) if p else None
def _require(pid: str, label: str) -> None:
if strict and not (p and p.has(pid)):
flag = "--" + pid.replace("_", "-")
raise RuntimeError(
f"--non-interactive: missing required answer for {label!r}. "
f"Pass {flag} on the command line."
)
try:
with select_navigation_active():
# Print header once
_print_header()
# Load existing config as starting point + snapshot for revert.
# Also capture raw file state so a "No" at the final save can
# restore the exact pre-wizard byte content (or remove the file
# entirely if it did not exist before). ``existed`` and
# ``bytes`` are tracked independently so a read failure on a
# file that DID exist doesn't get downgraded to "no file" —
# which would cause revert to delete the user's config.
config = load_config()
snapshot = copy.deepcopy(config)
config_path = get_config_path()
original_file_existed = config_path.exists()
original_file_bytes: bytes | None = None
if original_file_existed:
try:
original_file_bytes = config_path.read_bytes()
except OSError as exc:
console.print(
"[yellow]Warning: could not snapshot existing config "
f"bytes ({exc}); revert will fall back to a re-serialized "
"snapshot, which may not preserve comments / unknown "
"fields.[/yellow]"
)
# Decide which sections this run should cover.
#
# - ``only_sections`` (programmatic, e.g. ``configure provider``):
# run exactly those sections, no Keep/Modify/Reset prompt.
# - Any preset flag (``--provider``/``--model``/…): treat as
# explicit user intent — skip Keep/Modify/Reset and run ONLY
# the sections each flag implies (see ``_FLAG_TO_SECTIONS``).
# - Strict ``--non-interactive`` with no preset flags: run all
# sections; the inner ``_require()`` calls will raise on
# missing answers.
# - Otherwise: full wizard, with Keep/Modify/Reset offered when
# an existing config is detected.
sections_to_run: frozenset[str]
implied_sections = _sections_implied_by_flags(p)
if only_sections is not None:
sections_to_run = frozenset(only_sections)
elif implied_sections:
sections_to_run = implied_sections
console.print(
"[dim] CLI flags detected — running only the implied "
f"sections: {', '.join(sorted(implied_sections))}.[/dim]"
)
console.print(
"[dim] (Use 'EvoSci configure <section>' or 'EvoSci "
"onboard' with no flags to revisit other sections.)[/dim]"
)
else:
sections_to_run = _ALL_SECTIONS
if not strict and _config_has_meaningful_settings(config):
result = _open_existing_config_prompt(config)
if result is None:
return True # Keep
sections_to_run, config = result
# NOTE: ``snapshot`` is intentionally NOT refreshed after Reset.
# "Save? = No" must restore the user's pre-wizard config — if
# we re-snapped here, declining the save after Reset would
# silently overwrite the user's previous settings with
# ``EvoScientistConfig()`` defaults.
# CLI --skip-* flags remove sections entirely.
if p and p.skip_set:
sections_to_run = sections_to_run - p.skip_set
# In strict --non-interactive mode, optional sections that have
# no flag-driven equivalent (skills / mcp / latex / channels)
# would otherwise still open their interactive pickers — that
# breaks the "no prompts" contract advertised by the flag.
# Auto-skip them unless the caller explicitly opted in by NOT
# passing the corresponding --skip-* flag AND providing answers.
# Today none of these have preset support, so always auto-skip.
if strict:
sections_to_run = sections_to_run - {
"skills",
"mcp",
"latex",
"channels",
}
console.print(
"[dim] Progress is autosaved after every step. Ctrl+C is safe.[/dim]"
)
console.print()
if "ui" in sections_to_run:
_require("ui", "UI backend")
preset_ui = _preset("ui")
if preset_ui is not None:
config.ui_backend = preset_ui
console.print(
f" [green]✓ UI: {preset_ui}[/green] [dim](--ui)[/dim]"
)
else:
config.ui_backend = _step_ui_backend(config)
# WebUI mode needs a front-end port; ask right after the mode
# choice (only when chosen interactively — non-interactive /
# preset runs keep the config default).
if config.ui_backend == "webui" and not strict and preset_ui is None:
config.webui_port = _step_webui_port(config)
_autosave(config)
if "port" in sections_to_run:
preset_port = _preset("port")
if preset_port is not None:
config.langgraph_dev_port = int(preset_port)
console.print(
f" [green]✓ Port: {preset_port}[/green] [dim](--port)[/dim]"
)
elif strict:
# ``--non-interactive`` without ``--port`` — keep the
# existing config value (has a sensible default in
# ``EvoScientistConfig``) instead of opening the
# questionary prompt and hanging the wizard.
console.print(
f" [green]✓ Port: {config.langgraph_dev_port} "
"(kept)[/green] [dim](no --port; non-interactive)[/dim]"
)
else:
config.langgraph_dev_port = _step_langgraph_dev_port(config)
_autosave(config)
ollama_detected_models: list[str] = []
if "provider" in sections_to_run:
from .prompter import GoBack
_print_section("EvoScientist · Pilot (Main model)")
_require("provider", "LLM provider")
# Provider sub-loop: auth_mode can raise GoBack to re-pick provider.
# We snapshot config at the top of each iteration so a GoBack can
# roll back partial writes (base_url, minimax region, ollama URL,
# provider id itself) — otherwise picking `custom-openai`, entering
# a base URL, going Back, then picking `anthropic` would leave a
# stale ``custom_openai_base_url`` in the final saved config.
while True:
loop_snapshot = copy.deepcopy(config)
preset_provider = _preset("provider")
if preset_provider is not None:
provider = preset_provider
config.provider = provider
console.print(
f" [green]✓ Provider: {provider}[/green] "
"[dim](--provider)[/dim]"
)
else:
provider = _step_provider(config)
config.provider = provider
try:
ollama_detected_models = _configure_provider_connection(
config,
provider,
strict=strict,
skip_validation=skip_validation,
preset_api_key=_preset("api_key"),
require_api_key=lambda provider=provider: _require(
"api_key", f"{provider} API key"
),
)
except GoBack:
# User picked "← Back" — restore config to its state at the
# top of this iteration (drops any base_url / region /
# provider writes), then discard ALL provider-coupled
# presets and re-prompt. Clearing only ``provider``
# leaves a stale ``--model`` / ``--api-key`` that would
# be re-applied under a different provider, producing
# an invalid pair (e.g. ``provider=openai`` +
# ``model=claude-sonnet-4-6``).
for field_name in vars(loop_snapshot):
setattr(
config, field_name, getattr(loop_snapshot, field_name)
)
if p:
for stale_key in ("provider", "model", "api_key"):
p.answers.pop(stale_key, None)
ollama_detected_models = []
console.print(" [dim]↩ Returning to provider selection.[/dim]")
continue
break # Provider setup succeeded — exit sub-loop
_reconcile_oauth_modes(config)
_autosave(config)
else:
# Provider section skipped — keep prior provider value to drive
# downstream sections that depend on it (e.g., model picker).
provider = config.provider
if "model" in sections_to_run:
_require("model", "Model")
preset_model = _preset("model")
if preset_model is not None:
config.model = preset_model
console.print(
f" [green]✓ Model: {preset_model}[/green] [dim](--model)[/dim]"
)
else:
config.model = _step_model(
config, provider, ollama_detected_models=ollama_detected_models
)
if provider == "openrouter" and _preset("model") is None:
config.reasoning_effort = _step_reasoning_effort(config)
_autosave(config)
if "auxiliary_model" in sections_to_run:
_print_section("Co-pilot (Auxiliary model)")
if strict:
# Optional; never prompt under --non-interactive. Keep
# current (default empty = use main model).
_print_step_skipped(
"Auxiliary Model",
"kept current" if config.auxiliary_model else "not set",
)
elif _step_auxiliary_enable(config):
from .prompter import GoBack
aux_ollama_detected_models: list[str] = []
while True:
loop_snapshot = copy.deepcopy(config)
aux_provider = _step_provider(
config,
label="co-pilot",
default_value=config.auxiliary_provider,
)
config.auxiliary_provider = aux_provider
if (
aux_provider == config.provider
and _provider_connection_configured(config, aux_provider)
):
if aux_provider == "ollama":
aux_ollama_detected_models = ollama_detected_models
_print_step_skipped(
"Co-pilot credentials",
"reusing main provider settings",
)
else:
try:
aux_ollama_detected_models = (
_configure_provider_connection(
config,
aux_provider,
strict=False,
skip_validation=skip_validation,
)
)
except GoBack:
for field_name in vars(loop_snapshot):
setattr(
config,
field_name,
getattr(loop_snapshot, field_name),
)
aux_ollama_detected_models = []
console.print(
" [dim]↩ Returning to co-pilot provider "
"selection.[/dim]"
)
continue
break
config.auxiliary_model = _step_model(
config,
aux_provider,
ollama_detected_models=aux_ollama_detected_models,
label="co-pilot",
default_value=config.auxiliary_model,
)
else:
# Skip: single driver — clear any prior auxiliary config.
config.auxiliary_provider = ""
config.auxiliary_model = ""
_reconcile_oauth_modes(config)
_autosave(config)
if "tavily" in sections_to_run:
preset_tavily = _preset("tavily_key")
if preset_tavily is not None:
# Validate the preset key like the --api-key path does;
# the non-interactive flow can't show a "Save anyway?"
# prompt, so a failed validation is fatal.
if not skip_validation:
from .validators import validate_tavily_key
console.print(
" [dim]Validating preset Tavily key...[/dim]", end=""
)
valid, msg = validate_tavily_key(preset_tavily)
if valid:
console.print(f"\r [green]✓ {msg}[/green] ")
else:
console.print(f"\r [red]✗ {msg}[/red] ")
raise RuntimeError(
f"--tavily-key rejected by validator: {msg}. "
"Pass --skip-validation to override."
)
config.tavily_api_key = preset_tavily
console.print(
f" [green]✓ Tavily key: ***{preset_tavily[-4:]}[/green]"
" [dim](--tavily-key)[/dim]"
)
elif strict:
# ``--non-interactive`` without ``--tavily-key`` — Tavily
# is optional (web search). Keep whatever's in config
# (likely empty for first-time setup); never open the
# interactive password prompt under strict.
if config.tavily_api_key:
_print_step_skipped("Tavily Key", "kept current")
else:
_print_step_skipped("Tavily Key", "not set")
else:
new_tavily_key = _step_tavily_key(config, skip_validation)
if new_tavily_key is not None:
config.tavily_api_key = new_tavily_key
elif not config.tavily_api_key:
_print_step_skipped("Tavily Key", "not set")
_autosave(config)
if "workspace" in sections_to_run:
_require("workspace_mode", "Workspace mode")
preset_ws = _preset("workspace_mode")
if preset_ws is not None:
config.default_mode = preset_ws
console.print(
f" [green]✓ Workspace: {preset_ws}[/green]"
" [dim](--workspace-mode)[/dim]"
)
else:
config.default_mode = _step_workspace(config)
_autosave(config)
if "thinking" in sections_to_run:
_require("show_thinking", "Thinking panel")
preset_thinking = _preset("show_thinking")
if preset_thinking is not None:
config.show_thinking = bool(preset_thinking)
console.print(
f" [green]✓ Thinking: {'on' if preset_thinking else 'off'}[/green]"
" [dim](--show-thinking)[/dim]"
)
else:
config.show_thinking = _step_thinking(config)
_autosave(config)
if "skills" in sections_to_run:
_step_skills()
if "mcp" in sections_to_run:
_step_mcp_servers()
if "latex" in sections_to_run:
_step_tinytex()
if "channels" in sections_to_run:
for key, value in _step_channels(config, runtime=runtime).items():
setattr(config, key, value)
_autosave(config)
# Final confirmation — opportunity to revert. In strict
# non-interactive mode, skip the prompt and commit silently.
if strict:
save = True
else:
console.print()
save = questionary.confirm(
"Save this configuration?",
default=True,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if save is None:
raise KeyboardInterrupt()
if save:
save_config(config)
console.print()
console.print("[green]✓ Configuration saved![/green]")
console.print(f"[dim] → {get_config_path()}[/dim]")
console.print()
return True
else:
# User declined — restore exact pre-wizard file state.
# Three cases driven by the capture-time flags:
# 1. ``existed=False`` → file is new, delete it (autosaves
# during the run created it).
# 2. ``existed=True`` + bytes captured → restore bytes
# verbatim, preserves comments / unknown fields.
# 3. ``existed=True`` + bytes None (read failed at capture)
# → fall back to ``save_config(snapshot)`` since we
# can't restore the exact bytes; still better than
# leaving the mid-wizard state in place.
try:
if not original_file_existed:
if config_path.exists():
config_path.unlink()
elif original_file_bytes is not None:
config_path.write_bytes(original_file_bytes)
else:
save_config(snapshot)
except OSError as exc:
save_config(snapshot)
console.print(
f"[yellow]Revert via raw bytes failed ({exc}); "
"wrote parsed snapshot instead.[/yellow]"
)
console.print()
console.print(
"[yellow]Reverted to previous configuration "
"(autosaved progress discarded).[/yellow]"
)
console.print()
return False
except KeyboardInterrupt:
console.print()
console.print(
"[yellow]Setup interrupted. "
"Progress through the last completed step has been autosaved.[/yellow]"
)
console.print(
f"[dim] Run [bold]EvoSci onboard[/bold] again to resume — "
f"answers persist in {get_config_path()}.[/dim]"
)
console.print()
return False