e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
518 lines
18 KiB
Python
518 lines
18 KiB
Python
"""Interactive runtime settings initializer.
|
|
|
|
``deeptutor init`` walks the user through a four-step wizard (ports → LLM →
|
|
embedding → review) that writes the same files as the Web Settings page.
|
|
|
|
Heavy lifting (provider menu, live ``/models`` fetch, connectivity probe,
|
|
review panel) lives in :mod:`deeptutor_cli.init_wizard`. This module is
|
|
intentionally thin so the order of steps is easy to read top-to-bottom.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
import typer
|
|
|
|
from deeptutor.runtime.home import DEEPTUTOR_HOME_ENV, get_runtime_home
|
|
|
|
from . import init_wizard as wiz
|
|
|
|
|
|
def _reset_runtime_singletons() -> None:
|
|
"""Drop cached service instances so the new DEEPTUTOR_HOME takes effect.
|
|
|
|
``deeptutor init`` may pass ``--home`` to target a different workspace; the
|
|
singletons cache paths from the *previous* PathService and will silently
|
|
write to the wrong place if not cleared.
|
|
"""
|
|
try:
|
|
from deeptutor.services.path_service import PathService
|
|
|
|
PathService.reset_instance()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from deeptutor.services.config.runtime_settings import RuntimeSettingsService
|
|
|
|
RuntimeSettingsService._instances.clear()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from deeptutor.services.config.model_catalog import ModelCatalogService
|
|
|
|
ModelCatalogService._instances.clear()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _ensure_model_service(catalog: dict, service_name: str, profile_id: str, model_id: str):
|
|
"""Locate or create the default profile + model rows we'll mutate in place."""
|
|
services = catalog.setdefault("services", {})
|
|
service = services.setdefault(
|
|
service_name,
|
|
{"active_profile_id": profile_id, "active_model_id": model_id, "profiles": []},
|
|
)
|
|
profiles = service.setdefault("profiles", [])
|
|
profile = next(
|
|
(item for item in profiles if item.get("id") == service.get("active_profile_id")), None
|
|
)
|
|
if profile is None:
|
|
profile = {
|
|
"id": profile_id,
|
|
"name": "Default LLM Endpoint"
|
|
if service_name == "llm"
|
|
else "Default Embedding Endpoint",
|
|
"binding": "openai",
|
|
"base_url": "",
|
|
"api_key": "",
|
|
"api_version": "",
|
|
"extra_headers": {},
|
|
"models": [],
|
|
}
|
|
profiles.append(profile)
|
|
service["active_profile_id"] = profile_id
|
|
models = profile.setdefault("models", [])
|
|
model = next(
|
|
(item for item in models if item.get("id") == service.get("active_model_id")), None
|
|
)
|
|
if model is None:
|
|
model = {"id": model_id, "name": "Default Model", "model": ""}
|
|
models.append(model)
|
|
service["active_model_id"] = model_id
|
|
return profile, model
|
|
|
|
|
|
def _llm_step(
|
|
console: Console,
|
|
strings: dict,
|
|
current_profile: dict,
|
|
current_model: dict,
|
|
) -> wiz.LLMChoice:
|
|
spec = wiz.select_llm_provider(
|
|
console,
|
|
strings,
|
|
current_binding=str(current_profile.get("binding") or "openai"),
|
|
)
|
|
|
|
if spec is not None:
|
|
binding = spec.name
|
|
default_base = spec.default_api_base or str(current_profile.get("base_url") or "")
|
|
display_provider = spec.label
|
|
env_key = spec.env_key
|
|
else:
|
|
binding = (
|
|
typer.prompt(
|
|
strings["init.binding"],
|
|
default=str(current_profile.get("binding") or "openai"),
|
|
).strip()
|
|
or "openai"
|
|
)
|
|
default_base = str(current_profile.get("base_url") or "")
|
|
display_provider = "Custom"
|
|
env_key = ""
|
|
|
|
edit_base = typer.confirm(strings["init.edit_base_url"], default=not bool(default_base))
|
|
if edit_base:
|
|
base_url = typer.prompt(strings["init.new_base_url"], default=default_base or "")
|
|
else:
|
|
base_url = default_base
|
|
wiz.info(console, f"Base URL · {base_url or '(empty)'}")
|
|
|
|
api_key = wiz.capture_api_key(
|
|
console,
|
|
strings,
|
|
env_key=env_key,
|
|
current=str(current_profile.get("api_key") or ""),
|
|
)
|
|
|
|
models = wiz.fetch_models(
|
|
console,
|
|
strings,
|
|
base_url=base_url,
|
|
api_key=api_key,
|
|
binding=binding,
|
|
)
|
|
if not models:
|
|
models = list(wiz.LLM_FALLBACK_MODELS.get(binding, ()))
|
|
|
|
model = wiz.select_model(
|
|
console,
|
|
strings,
|
|
models=models,
|
|
current=str(current_model.get("model") or ""),
|
|
)
|
|
|
|
choice = wiz.LLMChoice(
|
|
binding=binding,
|
|
base_url=base_url,
|
|
api_key=api_key,
|
|
model=model,
|
|
display_provider=display_provider,
|
|
)
|
|
|
|
if typer.confirm(strings["init.probe_offer"], default=True):
|
|
_probe_llm_with_retry(console, strings, choice)
|
|
|
|
return choice
|
|
|
|
|
|
def _probe_llm_with_retry(console: Console, strings: dict, choice: wiz.LLMChoice) -> None:
|
|
"""Run the probe; on failure, offer a single retry with a fresh API key."""
|
|
while True:
|
|
wiz.info(console, strings["init.probe_running"].format(what=choice.display_provider))
|
|
ok_result, elapsed_ms, error = wiz.probe_llm(
|
|
base_url=choice.base_url,
|
|
api_key=choice.api_key,
|
|
binding=choice.binding,
|
|
model=choice.model,
|
|
)
|
|
choice.probed = True
|
|
choice.probe_ok = ok_result
|
|
choice.probe_ms = elapsed_ms
|
|
if ok_result:
|
|
wiz.ok(
|
|
console,
|
|
strings["init.probe_ok"].format(what=choice.display_provider, ms=elapsed_ms),
|
|
)
|
|
return
|
|
wiz.fail(
|
|
console, strings["init.probe_fail"].format(what=choice.display_provider, error=error)
|
|
)
|
|
if not typer.confirm(strings["init.probe_retry"], default=False):
|
|
return
|
|
choice.api_key = typer.prompt(
|
|
strings["init.api_key_prompt"], default="", hide_input=True, show_default=False
|
|
)
|
|
|
|
|
|
def _embedding_step(
|
|
console: Console,
|
|
strings: dict,
|
|
catalog: dict,
|
|
llm_api_key: str,
|
|
) -> wiz.EmbeddingChoice | None:
|
|
"""Returns ``None`` when the user picks ``[s] Skip``."""
|
|
|
|
from deeptutor.services.config.embedding_endpoint import (
|
|
EMBEDDING_PROVIDER_LABELS,
|
|
normalize_embedding_endpoint_for_display,
|
|
)
|
|
from deeptutor.services.config.provider_runtime import EMBEDDING_PROVIDERS
|
|
|
|
current_profile = (catalog.get("services", {}).get("embedding", {}).get("profiles") or [{}])[
|
|
0
|
|
] or {}
|
|
current_binding = str(current_profile.get("binding") or "openai")
|
|
|
|
provider_pick = wiz.select_embedding_provider(console, strings, current=current_binding)
|
|
if provider_pick == wiz.SKIP_SENTINEL:
|
|
wiz.info(console, strings["init.skipped"])
|
|
return None
|
|
if provider_pick is None:
|
|
provider = typer.prompt(strings["init.binding"], default=current_binding or "openai")
|
|
else:
|
|
provider = provider_pick
|
|
|
|
spec = EMBEDDING_PROVIDERS.get(provider)
|
|
display_provider = (
|
|
spec.label if spec else EMBEDDING_PROVIDER_LABELS.get(provider, provider.title())
|
|
)
|
|
default_endpoint = spec.default_api_base if spec else str(current_profile.get("base_url") or "")
|
|
|
|
edit_endpoint = typer.confirm(strings["init.edit_base_url"], default=not bool(default_endpoint))
|
|
endpoint = (
|
|
typer.prompt(strings["init.embedding_endpoint"], default=default_endpoint)
|
|
if edit_endpoint
|
|
else default_endpoint
|
|
)
|
|
endpoint = normalize_embedding_endpoint_for_display(provider, endpoint)
|
|
if not edit_endpoint:
|
|
wiz.info(console, f"Endpoint · {endpoint or '(empty)'}")
|
|
|
|
# Reuse the LLM key by default — most users share creds across services.
|
|
masked = wiz._mask_secret(llm_api_key)
|
|
if llm_api_key and typer.confirm(
|
|
strings["init.api_key_reuse_llm"].format(masked=masked), default=True
|
|
):
|
|
api_key = llm_api_key
|
|
else:
|
|
api_key = typer.prompt(
|
|
strings["init.embedding_api_key"], default="", hide_input=True, show_default=False
|
|
)
|
|
|
|
# Try live ``/models`` first; fall back to the curated list (spec default
|
|
# first, then EMBEDDING_FALLBACK_MODELS) when the fetch returns nothing.
|
|
models = wiz.fetch_embedding_models(
|
|
console, strings, endpoint=endpoint, api_key=api_key, provider=provider
|
|
)
|
|
if not models:
|
|
models = list(wiz.EMBEDDING_FALLBACK_MODELS.get(provider, ()))
|
|
if spec and spec.default_model and spec.default_model not in models:
|
|
models = [spec.default_model] + models
|
|
model = wiz.select_model(
|
|
console,
|
|
strings,
|
|
models=models,
|
|
current=str((current_profile.get("models") or [{}])[0].get("model") or ""),
|
|
custom_prompt_label=strings["init.embedding_model"],
|
|
)
|
|
dimension = typer.prompt(strings["init.embedding_dimension"], default="")
|
|
|
|
choice = wiz.EmbeddingChoice(
|
|
binding=provider,
|
|
base_url=endpoint,
|
|
api_key=api_key,
|
|
model=model,
|
|
dimension=str(dimension or "").strip(),
|
|
display_provider=display_provider,
|
|
)
|
|
|
|
if typer.confirm(strings["init.probe_offer"], default=True):
|
|
wiz.info(console, strings["init.probe_running"].format(what=display_provider))
|
|
ok_result, elapsed_ms, error = wiz.probe_embedding(
|
|
base_url=choice.base_url, api_key=choice.api_key, model=choice.model
|
|
)
|
|
choice.probed = True
|
|
choice.probe_ok = ok_result
|
|
choice.probe_ms = elapsed_ms
|
|
if ok_result:
|
|
wiz.ok(console, strings["init.probe_ok"].format(what=display_provider, ms=elapsed_ms))
|
|
else:
|
|
wiz.fail(console, strings["init.probe_fail"].format(what=display_provider, error=error))
|
|
|
|
return choice
|
|
|
|
|
|
def _search_step(
|
|
console: Console,
|
|
strings: dict,
|
|
catalog: dict,
|
|
) -> wiz.SearchChoice | None:
|
|
"""Returns ``None`` when the user picks ``[s] Skip``.
|
|
|
|
A ``provider == "none"`` result is NOT skip — it's "explicitly disable
|
|
web search". We still write that into the catalog so agents stop trying.
|
|
"""
|
|
|
|
current_profile = (catalog.get("services", {}).get("search", {}).get("profiles") or [{}])[
|
|
0
|
|
] or {}
|
|
current_provider = str(current_profile.get("provider") or "tavily")
|
|
|
|
spec = wiz.select_search_provider(console, strings, current=current_provider)
|
|
if spec is None:
|
|
wiz.info(console, strings["init.skipped"])
|
|
return None
|
|
|
|
if spec.name == "none":
|
|
wiz.info(console, strings["init.search_disabled_note"])
|
|
return wiz.SearchChoice(provider="none", label=spec.label)
|
|
|
|
api_key = ""
|
|
if spec.requires_api_key:
|
|
env_key, env_name = wiz.search_api_key_from_env(spec.env_keys)
|
|
if env_key:
|
|
masked = wiz._mask_secret(env_key)
|
|
offer = strings["init.api_key_env_detected"].format(env_var=env_name, masked=masked)
|
|
if typer.confirm(offer, default=True):
|
|
api_key = env_key
|
|
if not api_key:
|
|
current_key = str(current_profile.get("api_key") or "")
|
|
if current_key:
|
|
masked = wiz._mask_secret(current_key)
|
|
if typer.confirm(
|
|
strings["init.api_key_reuse_llm"].format(masked=masked), default=True
|
|
):
|
|
api_key = current_key
|
|
if not api_key:
|
|
api_key = typer.prompt(
|
|
strings["init.search_api_key_prompt"],
|
|
default="",
|
|
hide_input=True,
|
|
show_default=False,
|
|
)
|
|
else:
|
|
wiz.info(console, strings["init.search_no_key_note"].format(label=spec.label))
|
|
|
|
base_url = ""
|
|
if spec.requires_base_url:
|
|
default_url = str(current_profile.get("base_url") or "") or spec.default_base_url
|
|
base_url = typer.prompt(strings["init.search_base_url_prompt"], default=default_url)
|
|
|
|
return wiz.SearchChoice(
|
|
provider=spec.name,
|
|
label=spec.label,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
)
|
|
|
|
|
|
def _ensure_search_service(catalog: dict, profile_id: str) -> dict:
|
|
"""Locate or create the default search profile we'll mutate in place."""
|
|
services = catalog.setdefault("services", {})
|
|
service = services.setdefault(
|
|
"search",
|
|
{"active_profile_id": profile_id, "profiles": []},
|
|
)
|
|
profiles = service.setdefault("profiles", [])
|
|
profile = next(
|
|
(item for item in profiles if item.get("id") == service.get("active_profile_id")), None
|
|
)
|
|
if profile is None:
|
|
profile = {
|
|
"id": profile_id,
|
|
"name": "Default Search",
|
|
"provider": "brave",
|
|
"base_url": "",
|
|
"api_key": "",
|
|
"api_version": "",
|
|
"extra_headers": {},
|
|
"proxy": "",
|
|
"models": [],
|
|
}
|
|
profiles.append(profile)
|
|
service["active_profile_id"] = profile_id
|
|
return profile
|
|
|
|
|
|
def run_init(*, cli_only: bool = False, home: str | Path | None = None) -> None:
|
|
runtime_home = get_runtime_home(home)
|
|
runtime_home.mkdir(parents=True, exist_ok=True)
|
|
import os
|
|
|
|
os.environ[DEEPTUTOR_HOME_ENV] = str(runtime_home)
|
|
_reset_runtime_singletons()
|
|
|
|
from deeptutor.runtime.banner import labels_for, print_banner, resolve_language
|
|
from deeptutor.services.config import get_model_catalog_service, get_runtime_settings_service
|
|
from deeptutor.services.setup import init_user_directories
|
|
|
|
init_user_directories(runtime_home)
|
|
|
|
language = resolve_language()
|
|
strings = labels_for(language)
|
|
console = Console()
|
|
# CLI-only: LLM, Embedding, Search, Review = 4 steps.
|
|
# Full: Ports, LLM, Embedding, Search, Review = 5 steps.
|
|
total_steps = 4 if cli_only else 5
|
|
|
|
try:
|
|
print_banner(console, language=language, mode_key="init.mode")
|
|
console.print(f"{strings['init.workspace']}: [bold]{runtime_home}[/bold]")
|
|
console.print(f"[dim]{strings['init.note_settings_dir']}[/dim]")
|
|
|
|
runtime = get_runtime_settings_service()
|
|
system = runtime.load_system(include_process_overrides=False)
|
|
|
|
# --- Step 1 (CLI mode skips ports) ---
|
|
step_num = 0
|
|
if not cli_only:
|
|
step_num += 1
|
|
wiz.step_header(
|
|
console,
|
|
strings["init.step_ports"].format(n=step_num, total=total_steps),
|
|
)
|
|
system["backend_port"] = int(
|
|
typer.prompt(
|
|
strings["init.backend_port"],
|
|
default=str(system.get("backend_port") or 8001),
|
|
)
|
|
)
|
|
system["frontend_port"] = int(
|
|
typer.prompt(
|
|
strings["init.frontend_port"],
|
|
default=str(system.get("frontend_port") or 3782),
|
|
)
|
|
)
|
|
|
|
# --- Step 2: LLM ---
|
|
catalog_service = get_model_catalog_service()
|
|
catalog = catalog_service.load()
|
|
llm_profile, llm_model = _ensure_model_service(
|
|
catalog, "llm", "llm-profile-default", "llm-model-default"
|
|
)
|
|
step_num += 1
|
|
wiz.step_header(console, strings["init.step_llm"].format(n=step_num, total=total_steps))
|
|
llm_choice = _llm_step(console, strings, llm_profile, llm_model)
|
|
|
|
# Apply LLM choice back into the catalog draft.
|
|
llm_profile["binding"] = llm_choice.binding
|
|
llm_profile["base_url"] = llm_choice.base_url
|
|
llm_profile["api_key"] = llm_choice.api_key
|
|
llm_model["model"] = llm_choice.model
|
|
llm_model["name"] = llm_choice.model or "Default Model"
|
|
|
|
# --- Step 3: Embedding (skip via [s] inside the picker) ---
|
|
embedding_choice: wiz.EmbeddingChoice | None = None
|
|
step_num += 1
|
|
wiz.step_header(
|
|
console, strings["init.step_embedding"].format(n=step_num, total=total_steps)
|
|
)
|
|
embedding_choice = _embedding_step(console, strings, catalog, llm_choice.api_key)
|
|
if embedding_choice is not None:
|
|
emb_profile, emb_model = _ensure_model_service(
|
|
catalog,
|
|
"embedding",
|
|
"embedding-profile-default",
|
|
"embedding-model-default",
|
|
)
|
|
emb_profile["binding"] = embedding_choice.binding
|
|
emb_profile["base_url"] = embedding_choice.base_url
|
|
emb_profile["api_key"] = embedding_choice.api_key
|
|
emb_model["model"] = embedding_choice.model
|
|
emb_model["name"] = embedding_choice.model or "Default Embedding Model"
|
|
if embedding_choice.dimension:
|
|
emb_model["dimension"] = embedding_choice.dimension
|
|
|
|
# --- Step 4: Search (skip via [s] inside the picker) ---
|
|
search_choice: wiz.SearchChoice | None = None
|
|
step_num += 1
|
|
wiz.step_header(console, strings["init.step_search"].format(n=step_num, total=total_steps))
|
|
search_choice = _search_step(console, strings, catalog)
|
|
if search_choice is not None:
|
|
search_profile = _ensure_search_service(catalog, "search-profile-default")
|
|
search_profile["provider"] = search_choice.provider
|
|
search_profile["api_key"] = search_choice.api_key
|
|
search_profile["base_url"] = search_choice.base_url
|
|
|
|
# --- Step 5: Review & save ---
|
|
step_num += 1
|
|
wiz.step_header(console, strings["init.step_review"].format(n=step_num, total=total_steps))
|
|
wiz.render_review_panel(
|
|
console,
|
|
strings,
|
|
llm=llm_choice,
|
|
embedding=embedding_choice,
|
|
search=search_choice,
|
|
backend_port=None if cli_only else system.get("backend_port"),
|
|
frontend_port=None if cli_only else system.get("frontend_port"),
|
|
)
|
|
if not typer.confirm(strings["init.confirm_save"], default=True):
|
|
wiz.warn(console, strings["init.cancelled"])
|
|
raise typer.Exit(code=1)
|
|
|
|
if not cli_only:
|
|
runtime.save_system(system)
|
|
catalog_service.save(catalog)
|
|
console.print()
|
|
wiz.ok(console, strings["init.saved"])
|
|
console.print(f"[dim]{strings['init.next_step']}[/dim]")
|
|
|
|
except (KeyboardInterrupt, typer.Abort):
|
|
console.print()
|
|
wiz.warn(console, strings["init.cancelled"])
|
|
raise typer.Exit(code=130)
|
|
|
|
|
|
def register(app: typer.Typer) -> None:
|
|
@app.command("init")
|
|
def init_command(
|
|
cli: bool = typer.Option(False, "--cli", help="Initialize for CLI-only use."),
|
|
home: Path | None = typer.Option(None, "--home", help="Runtime workspace root."),
|
|
) -> None:
|
|
"""Create or update data/user/settings for this workspace."""
|
|
|
|
run_init(cli_only=cli, home=home)
|