Files
EvoScientist-Multi/EvoScientist/config/onboard/helpers.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

717 lines
22 KiB
Python

"""Cross-step helpers: API key prompt loop, ccproxy login, npx/node bootstrapping,
LaTeX detection/install, iMessage setup.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import questionary
from ..settings import EvoScientistConfig
from .style import QMARK, WIZARD_STYLE, console
from .validators import (
validate_anthropic_key,
validate_atlascloud_key,
validate_dashscope_code_key,
validate_dashscope_key,
validate_deepseek_key,
validate_google_key,
validate_kimi_key,
validate_minimax_key,
validate_moonshot_key,
validate_novita_key,
validate_nvidia_key,
validate_openai_key,
validate_openrouter_key,
validate_requesty_key,
validate_siliconflow_key,
validate_volcengine_key,
validate_zhipu_key,
)
def _provider_key_info(config: EvoScientistConfig, provider: str):
"""Return (display_name, current_value, validate_fn) for a provider."""
mapping = {
"anthropic": (
"Anthropic",
config.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", ""),
validate_anthropic_key,
),
"minimax": (
"MiniMax",
config.minimax_api_key or os.environ.get("MINIMAX_API_KEY", ""),
lambda key: validate_minimax_key(
key,
base_url=config.minimax_base_url
or os.environ.get(
"MINIMAX_BASE_URL", "https://api.minimaxi.com/anthropic"
),
),
),
"nvidia": (
"NVIDIA",
config.nvidia_api_key or os.environ.get("NVIDIA_API_KEY", ""),
validate_nvidia_key,
),
"google-genai": (
"Google",
config.google_api_key or os.environ.get("GOOGLE_API_KEY", ""),
validate_google_key,
),
"siliconflow": (
"SiliconFlow",
config.siliconflow_api_key or os.environ.get("SILICONFLOW_API_KEY", ""),
validate_siliconflow_key,
),
"openrouter": (
"OpenRouter",
config.openrouter_api_key or os.environ.get("OPENROUTER_API_KEY", ""),
validate_openrouter_key,
),
"atlascloud": (
"Atlas Cloud",
config.atlascloud_api_key or os.environ.get("ATLASCLOUD_API_KEY", ""),
validate_atlascloud_key,
),
"requesty": (
"Requesty",
config.requesty_api_key or os.environ.get("REQUESTY_API_KEY", ""),
validate_requesty_key,
),
"novita": (
"Novita",
config.novita_api_key or os.environ.get("NOVITA_API_KEY", ""),
validate_novita_key,
),
"deepseek": (
"DeepSeek",
config.deepseek_api_key or os.environ.get("DEEPSEEK_API_KEY", ""),
validate_deepseek_key,
),
"zhipu": (
"ZhipuAI",
config.zhipu_api_key or os.environ.get("ZHIPU_API_KEY", ""),
validate_zhipu_key,
),
"zhipu-code": (
"ZhipuAI CodePlan",
config.zhipu_api_key or os.environ.get("ZHIPU_API_KEY", ""),
validate_zhipu_key,
),
"volcengine": (
"Volcengine",
config.volcengine_api_key or os.environ.get("VOLCENGINE_API_KEY", ""),
validate_volcengine_key,
),
"volcengine-code": (
"Volcengine Coding Plan",
config.volcengine_api_key or os.environ.get("VOLCENGINE_API_KEY", ""),
validate_volcengine_key,
),
"dashscope": (
"DashScope",
config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""),
validate_dashscope_key,
),
"dashscope-code": (
"DashScope Coding Plan",
config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""),
validate_dashscope_code_key,
),
"moonshot": (
"Moonshot",
config.moonshot_api_key or os.environ.get("MOONSHOT_API_KEY", ""),
validate_moonshot_key,
),
"kimi-coding": (
"Kimi Coding Plan",
config.kimi_api_key or os.environ.get("KIMI_API_KEY", ""),
validate_kimi_key,
),
"custom-openai": (
"OpenAI-compatible",
config.custom_openai_api_key or os.environ.get("CUSTOM_OPENAI_API_KEY", ""),
None,
),
"custom-anthropic": (
"Custom Anthropic",
config.custom_anthropic_api_key
or os.environ.get("CUSTOM_ANTHROPIC_API_KEY", ""),
None,
),
"ollama": ("Ollama", "__no_key__", None),
}
return mapping.get(
provider,
(
"OpenAI",
config.openai_api_key or os.environ.get("OPENAI_API_KEY", ""),
validate_openai_key,
),
)
def _prompt_and_validate_api_key(
prompt_text: str,
current: str,
validate_fn,
skip_validation: bool = False,
placeholder=None,
) -> str | None:
"""Prompt user for an API key, validate, offer save-anyway on failure.
Args:
prompt_text: The question shown to the user.
current: Currently stored key value (may be empty).
validate_fn: Callable(key) -> (bool, str).
skip_validation: If True, skip the validation step entirely.
placeholder: Optional placeholder for the password input.
Returns:
New key string if the user entered one, or None to keep existing.
"""
kwargs: dict = {"style": WIZARD_STYLE, "qmark": QMARK}
if placeholder is not None:
kwargs["placeholder"] = placeholder
new_key = questionary.password(prompt_text, **kwargs).ask()
if new_key is None:
raise KeyboardInterrupt()
new_key = new_key.strip()
# Determine which key to validate: new input or existing
key_to_validate = new_key or current
if not key_to_validate:
return None
if not skip_validation and validate_fn is not None:
console.print(" [dim]Validating...[/dim]", end="")
valid, msg = validate_fn(key_to_validate)
if valid:
console.print(f"\r [green]\u2713 {msg}[/green] ")
return new_key or None
else:
console.print(f"\r [red]\u2717 {msg}[/red] ")
if not new_key:
# Existing key is invalid — warn but keep (user didn't change it)
return None
save_anyway = questionary.confirm(
"Save anyway?",
default=False,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if save_anyway is None:
raise KeyboardInterrupt()
return new_key if save_anyway else None
return new_key or None
def _prompt_ccproxy_port(config: EvoScientistConfig) -> None:
"""Prompt the user for a ccproxy port and save it to config."""
def valid_port(value: str) -> bool:
if not value: # empty = keep default
return True
try:
return 0 < int(value) < 2**16
except (ValueError, TypeError):
return False
current_port = getattr(config, "ccproxy_port", 8000)
try:
raw = questionary.text(
f"Enter port number for ccproxy to run on (Current: {current_port}, Enter to keep):",
validate=valid_port,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if raw is None:
raise KeyboardInterrupt()
raw = raw.strip()
ccproxy_port = int(raw) if raw else current_port
except (ValueError, TypeError):
ccproxy_port = current_port
console.print(f" [dim]Using default port: {ccproxy_port}[/dim]")
config.ccproxy_port = ccproxy_port
console.print(
f" [green]✓ ccproxy will run on http://127.0.0.1:{ccproxy_port}[/green]"
)
def _run_ccproxy_login(provider: str, label: str) -> None:
"""Run ccproxy auth login for the given provider and show status."""
from ...ccproxy_manager import _ccproxy_exe, check_ccproxy_auth
console.print(" [dim]Opening browser for authentication...[/dim]")
try:
proc = subprocess.run(
[_ccproxy_exe() or "ccproxy", "auth", "login", provider],
capture_output=True,
text=True,
timeout=120,
)
for line in proc.stdout.splitlines():
if line.strip().startswith("https://"):
console.print(f" [dim]Visit: {line.strip()}[/dim]")
break
authed, msg = check_ccproxy_auth(provider)
if authed:
console.print(f" [green]✓ {label}: {msg}[/green]")
else:
console.print(f" [red]Authentication failed: {msg}[/red]")
except subprocess.TimeoutExpired:
console.print(" [red]Login timed out.[/red]")
except Exception as exc:
console.print(f" [red]Login error: {exc}[/red]")
def _check_npx() -> bool:
"""Check if npx is available on the system.
Uses shutil.which() to resolve the executable path, which correctly
finds .cmd/.bat wrappers on Windows (e.g., npx.cmd).
Returns:
True if npx is found and working.
"""
npx = shutil.which("npx")
if not npx:
return False
try:
result = subprocess.run(
[npx, "--version"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _detect_node_install_method() -> tuple[str, str]:
"""Detect the best way to install Node.js for this environment.
Returns:
Tuple of (method_name, install_command).
"""
# Conda environment (any platform)
if os.environ.get("CONDA_PREFIX"):
return "conda", "conda install -y nodejs"
# macOS with Homebrew
if sys.platform == "darwin":
try:
result = subprocess.run(
["brew", "--version"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return "brew", "brew install node"
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Windows: winget (built-in on Win 10+) or chocolatey
if sys.platform == "win32":
if shutil.which("winget"):
return "winget", "winget install OpenJS.NodeJS.LTS"
if shutil.which("choco"):
return "choco", "choco install nodejs-lts -y"
return "manual", "https://nodejs.org"
def _install_node(method: str, command: str) -> bool:
"""Install Node.js using the detected method.
Returns:
True if installation succeeded.
"""
if method == "manual":
return False
parts = command.split()
exe = shutil.which(parts[0]) or parts[0]
try:
proc = subprocess.run(
[exe, *parts[1:]],
capture_output=True,
text=True,
timeout=120,
)
return proc.returncode == 0
except FileNotFoundError:
console.print(f" [red]✗ {method} not found[/red]")
return False
except subprocess.TimeoutExpired:
console.print(" [red]✗ Installation timed out[/red]")
return False
except Exception as e:
console.print(f" [red]✗ Installation failed: {e}[/red]")
return False
def _ensure_npx(reason: str) -> bool:
"""Check for npx and offer to install Node.js if missing.
Args:
reason: Why npx is needed (shown in the warning message).
Returns:
True if npx is available (was already present or just installed).
"""
if _check_npx():
return True
console.print(f" [yellow]✗ npx not found — {reason}[/yellow]")
method, command = _detect_node_install_method()
if method != "manual":
install_node = questionary.confirm(
f"Install Node.js via {method}? ({command})",
default=True,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if install_node is None:
raise KeyboardInterrupt()
if install_node:
console.print(" [dim]Installing Node.js...[/dim]")
if _install_node(method, command):
if _check_npx():
console.print(" [green]✓ npx now available[/green]")
return True
else:
console.print(
" [yellow]✗ npx still not found after install[/yellow]"
)
else:
console.print(" [red]✗ Installation failed[/red]")
else:
console.print(f" [dim]Install Node.js: {command}[/dim]")
return False
# =============================================================================
# TinyTeX (LaTeX) helpers
# =============================================================================
def _check_latex_components() -> dict[str, bool]:
"""Check which LaTeX components are available.
Returns:
Dict mapping component name to availability:
``{"pdflatex": bool, "latexmk": bool, "tlmgr": bool}``.
"""
result: dict[str, bool] = {}
for cmd in ("pdflatex", "latexmk", "tlmgr"):
exe = shutil.which(cmd)
if not exe:
result[cmd] = False
continue
try:
proc = subprocess.run(
[exe, "--version"],
capture_output=True,
text=True,
timeout=10,
)
result[cmd] = proc.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
result[cmd] = False
return result
def _check_tinytex() -> bool:
"""Check if a usable LaTeX distribution is available.
Returns:
True if pdflatex is found and working.
"""
return _check_latex_components().get("pdflatex", False)
def _detect_tinytex_install_method() -> tuple[str, str]:
"""Detect the best way to install TinyTeX for this platform.
Returns:
Tuple of (method_name, install_command_or_url).
"""
if sys.platform == "win32":
if shutil.which("choco"):
return "choco", "choco install tinytex -y"
if shutil.which("scoop"):
return "scoop", "scoop install tinytex"
return "manual", "https://yihui.org/tinytex/"
# macOS and Linux: use the official install script
if shutil.which("curl"):
return (
"curl",
'curl -sL "https://yihui.org/tinytex/install-bin-unix.sh" | sh',
)
if shutil.which("wget"):
return (
"wget",
'wget -qO- "https://yihui.org/tinytex/install-bin-unix.sh" | sh',
)
return "manual", "https://yihui.org/tinytex/"
def _install_tinytex(method: str, command: str) -> bool:
"""Install TinyTeX using the detected method.
Returns:
True if installation succeeded.
"""
if method == "manual":
return False
if method in ("curl", "wget"):
# Pipe-to-shell commands must run through the shell
try:
proc = subprocess.run(
command,
shell=True, # user confirmed install in wizard
capture_output=True,
text=True,
timeout=300,
)
return proc.returncode == 0
except subprocess.TimeoutExpired:
console.print(" [red]✗ Installation timed out[/red]")
return False
except Exception as e:
console.print(f" [red]✗ Installation failed: {e}[/red]")
return False
# choco / scoop
parts = command.split()
exe = shutil.which(parts[0]) or parts[0]
try:
proc = subprocess.run(
[exe, *parts[1:]],
capture_output=True,
text=True,
timeout=300,
)
return proc.returncode == 0
except FileNotFoundError:
console.print(f" [red]✗ {method} not found[/red]")
return False
except subprocess.TimeoutExpired:
console.print(" [red]✗ Installation timed out[/red]")
return False
except Exception as e:
console.print(f" [red]✗ Installation failed: {e}[/red]")
return False
def _print_latex_status(components: dict[str, bool]) -> None:
"""Print a single-line status showing all LaTeX components."""
parts: list[str] = []
for cmd, _role in (
("pdflatex", "compiler"),
("latexmk", "build tool"),
("tlmgr", "package manager"),
):
if components.get(cmd, False):
parts.append(f"[green]✓ {cmd}[/green]")
else:
parts.append(f"[yellow]✗ {cmd}[/yellow]")
console.print(" " + " ".join(parts))
def _auto_install_latexmk() -> None:
"""Auto-install latexmk via tlmgr when it is missing."""
console.print(" [dim]Installing latexmk via tlmgr...[/dim]")
tlmgr = shutil.which("tlmgr")
if not tlmgr:
return
try:
proc = subprocess.run(
[tlmgr, "install", "latexmk"],
capture_output=True,
text=True,
timeout=60,
)
if proc.returncode == 0 and shutil.which("latexmk"):
console.print(" [green]✓ latexmk installed[/green]")
else:
console.print(
" [yellow]⚠ Failed to install latexmk"
" (run: tlmgr install latexmk)[/yellow]"
)
except (FileNotFoundError, subprocess.TimeoutExpired):
console.print(
" [yellow]⚠ Failed to install latexmk"
" (run: tlmgr install latexmk)[/yellow]"
)
def validate_imessage() -> tuple[bool, str]:
"""Validate iMessage environment by checking for the imsg CLI.
Returns:
Tuple of (is_valid, message).
"""
# macOS only
if sys.platform != "darwin":
return False, "iMessage requires macOS"
from ...channels.imessage.probe import find_cli
cli_path = find_cli()
if not cli_path:
return False, "not_installed"
# Check version
try:
result = subprocess.run(
[cli_path, "--version"],
capture_output=True,
text=True,
timeout=5,
)
version = result.stdout.strip() if result.returncode == 0 else None
except Exception:
version = None
# Check RPC support
try:
result = subprocess.run(
[cli_path, "rpc", "--help"],
capture_output=True,
text=True,
timeout=5,
)
rpc_ok = result.returncode == 0
except Exception:
rpc_ok = False
if not rpc_ok:
return (
False,
f"imsg found at {cli_path} but RPC not supported (update with: brew upgrade imsg)",
)
version_str = f" ({version})" if version else ""
return True, f"imsg{version_str} at {cli_path}"
def _install_ccproxy() -> bool:
"""Run pip install for ccproxy (evoscientist[oauth]).
Uses uv pip install when available (uv-managed envs don't ship pip).
Returns:
True if installation succeeded and ccproxy is available.
"""
from ...ccproxy_manager import is_ccproxy_available
from ...mcp.registry import install_library
ok = install_library("evoscientist[oauth]")
if not ok:
console.print(" [red]✗ Installation failed.[/red]")
return False
return is_ccproxy_available()
def _install_imsg() -> bool:
"""Run brew install for imsg CLI.
Returns:
True if installation succeeded.
"""
try:
proc = subprocess.run(
["brew", "install", "steipete/tap/imsg"],
capture_output=True,
text=True,
timeout=120,
)
return proc.returncode == 0
except FileNotFoundError:
console.print(" [red]✗ Homebrew not found[/red]")
console.print(" [dim]Install Homebrew first: https://brew.sh[/dim]")
return False
except subprocess.TimeoutExpired:
console.print(" [red]✗ Installation timed out[/red]")
return False
except Exception as e:
console.print(f" [red]✗ Installation failed: {e}[/red]")
return False
def _setup_imessage() -> bool:
"""Guide the user through iMessage setup: install, validate, test.
Returns:
True if iMessage is ready to use.
"""
# Step 1: Validate
console.print(" [dim]Checking iMessage environment...[/dim]")
valid, msg = validate_imessage()
if valid:
console.print(f" [green]✓ {msg}[/green]")
return True
if msg == "iMessage requires macOS":
console.print(f" [red]✗ {msg}[/red]")
return False
if msg == "not_installed":
console.print(" [yellow]✗ imsg CLI not installed[/yellow]")
console.print()
# Step 2: Offer to install
install = questionary.confirm(
"Install imsg via Homebrew? (brew install steipete/tap/imsg)",
default=True,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if install is None:
raise KeyboardInterrupt()
if install:
console.print()
if _install_imsg():
console.print()
# Re-validate after install
valid, msg = validate_imessage()
if valid:
console.print(f" [green]✓ {msg}[/green]")
return True
else:
console.print(f" [red]✗ {msg}[/red]")
return False
else:
return False
else:
console.print(
" [dim]Skipped. Install manually: brew install steipete/tap/imsg[/dim]"
)
return False
else:
# RPC not supported or other issue
console.print(f" [red]✗ {msg}[/red]")
return False