4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
940 lines
34 KiB
Python
940 lines
34 KiB
Python
"""Interactive quickstart flow for local LLM configuration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
import questionary
|
|
from rich.text import Text
|
|
|
|
import surfaces.cli.wizard._integration_configurators as _integration_configurators_module
|
|
from config.llm_auth.auth_method import (
|
|
API_KEY_AUTH_METHOD,
|
|
OAUTH_AUTH_METHOD,
|
|
OAUTH_BACKEND_PROVIDER_BY_PROVIDER,
|
|
LLMAuthMethod,
|
|
normalize_llm_auth_method,
|
|
supports_oauth_auth_method,
|
|
)
|
|
from config.llm_auth.records import save_provider_auth_record
|
|
from integrations.llm_cli.binary_resolver import diagnose_binary_path
|
|
from integrations.llm_cli.codex_oauth import CodexOAuthError, run_codex_oauth_login
|
|
from platform.terminal.theme import (
|
|
ERROR,
|
|
GLYPH_ERROR,
|
|
GLYPH_WARNING,
|
|
SECONDARY,
|
|
TEXT,
|
|
WARNING,
|
|
)
|
|
from surfaces.cli.wizard._ui import (
|
|
Choice,
|
|
WizardBack,
|
|
_choose,
|
|
_choose_model,
|
|
_confirm,
|
|
_console,
|
|
_local_defaults,
|
|
_persist_llm_api_key,
|
|
_prompt_value,
|
|
_render_header,
|
|
_render_next_steps,
|
|
_render_saved_summary,
|
|
_select_target_for_advanced,
|
|
_step,
|
|
_step_header,
|
|
)
|
|
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE, SUPPORTED_PROVIDERS, ProviderOption
|
|
from surfaces.cli.wizard.env_sync import sync_env_values, sync_provider_env
|
|
from surfaces.cli.wizard.integration_health import IntegrationHealthResult
|
|
from surfaces.cli.wizard.probes import ProbeResult, probe_local_target, probe_remote_target
|
|
from surfaces.cli.wizard.store import get_store_path, save_local_config
|
|
from surfaces.cli.wizard.validation import build_demo_action_response as _build_demo_action_response
|
|
|
|
DEFAULT_GITHUB_MCP_MODE = _integration_configurators_module.DEFAULT_GITHUB_MCP_MODE
|
|
DEFAULT_GITHUB_MCP_URL = _integration_configurators_module.DEFAULT_GITHUB_MCP_URL
|
|
WIZARD_TOTAL_STEPS = 4
|
|
_CLI_SUBSCRIPTION_LOGIN_ARGS: dict[str, tuple[str, ...]] = {
|
|
"claude-code": ("auth", "login"),
|
|
"codex": ("login",),
|
|
}
|
|
_HIDDEN_ONBOARDING_BACKEND_PROVIDERS = frozenset(OAUTH_BACKEND_PROVIDER_BY_PROVIDER.values())
|
|
_CODEX_CONFIG_ERROR_RE = re.compile(
|
|
r"Error loading configuration:\s*(?P<location>[^\n]+config\.toml:\d+:\d+):\s*(?P<detail>[^\n]+)"
|
|
)
|
|
_CODEX_CONFIG_LOCATION_RE = re.compile(r"^(?P<path>.+config\.toml):(?P<line>\d+):(?P<column>\d+)$")
|
|
_CODEX_STALE_SERVICE_TIER_DETAIL_RE = re.compile(
|
|
r"unknown variant [`'\"]priority[`'\"], expected [`'\"]fast[`'\"] or [`'\"]flex[`'\"]"
|
|
)
|
|
_CODEX_PRIORITY_SERVICE_TIER_RE = re.compile(
|
|
r"^(?P<prefix>[ \t]*service_tier[ \t]*=[ \t]*)(?P<quote>[\"'])"
|
|
r"priority(?P=quote)(?P<suffix>[ \t]*(?:#.*)?)?(?P<newline>\r?\n)?$"
|
|
)
|
|
|
|
__all__ = [
|
|
"DEFAULT_GITHUB_MCP_MODE",
|
|
"DEFAULT_GITHUB_MCP_URL",
|
|
"IntegrationHealthResult",
|
|
"build_demo_action_response",
|
|
"questionary",
|
|
]
|
|
|
|
|
|
# Re-export build_demo_action_response from validation as a stable module-level
|
|
# attribute. The wrapper indirection (instead of `from x import y`) is
|
|
# preserved so the function remains patchable via monkeypatch.setattr(flow,
|
|
# "build_demo_action_response", ...) — but we also keep the underlying import
|
|
# at module load time so the attribute exists immediately, even in CI parallel
|
|
# test workers where lazy imports inside the wrapper occasionally fail to
|
|
# materialize on first access.
|
|
def build_demo_action_response():
|
|
return _build_demo_action_response()
|
|
|
|
|
|
def _provider_label_for_saved_summary(
|
|
provider: ProviderOption, auth_method: str | None = None
|
|
) -> str:
|
|
if normalize_llm_auth_method(auth_method) == OAUTH_AUTH_METHOD:
|
|
return f"{_provider_choice_label(provider)} OAuth"
|
|
return provider.label
|
|
|
|
|
|
def _credential_line_for_saved_summary(
|
|
provider: ProviderOption, auth_method: str | None = None
|
|
) -> str:
|
|
"""One-line credential description for the post-wizard saved summary."""
|
|
if normalize_llm_auth_method(auth_method) == OAUTH_AUTH_METHOD:
|
|
if provider.value == "openai":
|
|
return "OpenAI OAuth tokens (Codex CLI)"
|
|
return f"{_provider_choice_label(provider)} OAuth session"
|
|
if provider.credential_kind != "cli":
|
|
return "system keychain"
|
|
if provider.adapter_factory is None:
|
|
return f"{provider.label} (CLI)"
|
|
cli_adapter = provider.adapter_factory()
|
|
return f"{provider.label} ({cli_adapter.auth_hint})"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _SubscriptionLoginResult:
|
|
ok: bool
|
|
detail: str = ""
|
|
config_error: bool = False
|
|
config_error_location: str = ""
|
|
config_error_detail: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _LoginProcessResult:
|
|
returncode: int
|
|
stdout: str = ""
|
|
stderr: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _CodexConfigRepairResult:
|
|
ok: bool
|
|
detail: str
|
|
|
|
|
|
def _provider_choice_label(provider: ProviderOption) -> str:
|
|
if provider.value == "openai":
|
|
return "OpenAI"
|
|
if provider.value == "anthropic":
|
|
return "Anthropic"
|
|
return provider.label
|
|
|
|
|
|
def _onboarding_provider_options() -> tuple[ProviderOption, ...]:
|
|
return tuple(
|
|
provider
|
|
for provider in SUPPORTED_PROVIDERS
|
|
if provider.value not in _HIDDEN_ONBOARDING_BACKEND_PROVIDERS
|
|
)
|
|
|
|
|
|
def _auth_method_label(auth_method: str) -> str:
|
|
return "OAuth" if normalize_llm_auth_method(auth_method) == OAUTH_AUTH_METHOD else "API key"
|
|
|
|
|
|
def _choose_auth_method(
|
|
provider: ProviderOption,
|
|
*,
|
|
default: str | None,
|
|
) -> LLMAuthMethod:
|
|
if provider.value in _HIDDEN_ONBOARDING_BACKEND_PROVIDERS:
|
|
return OAUTH_AUTH_METHOD
|
|
if not supports_oauth_auth_method(provider.value):
|
|
return API_KEY_AUTH_METHOD
|
|
method = _choose(
|
|
f"Choose {provider.label.removesuffix(' API key')} auth method",
|
|
[
|
|
Choice(
|
|
value=OAUTH_AUTH_METHOD,
|
|
label="OAuth",
|
|
hint="Browser login managed by onboarding",
|
|
),
|
|
Choice(
|
|
value=API_KEY_AUTH_METHOD,
|
|
label="API key",
|
|
hint=f"Paste {provider.api_key_env}",
|
|
),
|
|
],
|
|
default=default
|
|
if default in {API_KEY_AUTH_METHOD, OAUTH_AUTH_METHOD}
|
|
else OAUTH_AUTH_METHOD,
|
|
back_on_cancel=True,
|
|
)
|
|
return normalize_llm_auth_method(method)
|
|
|
|
|
|
def _oauth_backend_provider(provider: ProviderOption, auth_method: str) -> ProviderOption:
|
|
if normalize_llm_auth_method(auth_method) != OAUTH_AUTH_METHOD:
|
|
return provider
|
|
backend = OAUTH_BACKEND_PROVIDER_BY_PROVIDER.get(provider.value)
|
|
if backend is None:
|
|
return provider
|
|
return PROVIDER_BY_VALUE[backend]
|
|
|
|
|
|
def _persisted_auth_method(
|
|
provider: ProviderOption, auth_method: str | None
|
|
) -> LLMAuthMethod | None:
|
|
if auth_method is None:
|
|
return None
|
|
if provider.value in _HIDDEN_ONBOARDING_BACKEND_PROVIDERS or supports_oauth_auth_method(
|
|
provider.value
|
|
):
|
|
return normalize_llm_auth_method(auth_method)
|
|
return None
|
|
|
|
|
|
def _credential_prompt_label(provider: ProviderOption) -> str:
|
|
"""Provider label without the credential kind when the choice already includes it."""
|
|
suffix = f" {provider.credential_label}"
|
|
if provider.label.lower().endswith(suffix.lower()):
|
|
return provider.label[: -len(suffix)]
|
|
return provider.label
|
|
|
|
|
|
def _azure_openai_endpoint_env(provider: ProviderOption) -> dict[str, str]:
|
|
"""Return Azure endpoint env vars, using the default API version when unset."""
|
|
from core.llm.providers.azure_openai import resolve_azure_openai_api_version
|
|
|
|
return {
|
|
provider.endpoint_env: os.getenv(provider.endpoint_env, "").strip(),
|
|
provider.api_version_env: resolve_azure_openai_api_version(),
|
|
}
|
|
|
|
|
|
def _prompt_azure_openai_endpoint_settings(provider: ProviderOption) -> dict[str, str] | None:
|
|
"""Collect Azure OpenAI resource URL during onboarding."""
|
|
from core.llm.providers.azure_openai import (
|
|
normalize_azure_openai_base_url,
|
|
resolve_azure_openai_api_version,
|
|
)
|
|
|
|
if not provider.endpoint_env or not provider.api_version_env:
|
|
return {}
|
|
|
|
_step("Azure endpoint")
|
|
try:
|
|
base_url = _prompt_value(
|
|
f"Azure OpenAI resource URL ({provider.endpoint_env})",
|
|
default=os.getenv(provider.endpoint_env, provider.credential_default),
|
|
secret=False,
|
|
back_on_cancel=True,
|
|
)
|
|
except WizardBack:
|
|
return None
|
|
|
|
normalized_base = normalize_azure_openai_base_url(base_url)
|
|
if not normalized_base:
|
|
_console.print(f"[{ERROR}]Azure OpenAI resource URL is required.[/]")
|
|
return None
|
|
return {
|
|
provider.endpoint_env: normalized_base,
|
|
provider.api_version_env: resolve_azure_openai_api_version(),
|
|
}
|
|
|
|
|
|
def _ensure_azure_openai_endpoint_settings(provider: ProviderOption) -> dict[str, str] | None:
|
|
"""Return Azure endpoint env vars, prompting when missing."""
|
|
from core.llm.providers.azure_openai import azure_openai_endpoint_configured
|
|
|
|
if provider.value != "azure-openai":
|
|
return {}
|
|
if azure_openai_endpoint_configured():
|
|
return _azure_openai_endpoint_env(provider)
|
|
return _prompt_azure_openai_endpoint_settings(provider)
|
|
|
|
|
|
def _subscription_login_command(
|
|
provider: ProviderOption, binary_path: str | None
|
|
) -> list[str] | None:
|
|
"""Return the vendor CLI login command for subscription-backed LLM providers."""
|
|
if not binary_path:
|
|
return None
|
|
args = _CLI_SUBSCRIPTION_LOGIN_ARGS.get(provider.value)
|
|
if args is None:
|
|
return None
|
|
return [binary_path, *args]
|
|
|
|
|
|
def _subscription_login_preflight_command(
|
|
provider: ProviderOption, binary_path: str | None
|
|
) -> list[str] | None:
|
|
"""Return a non-OAuth command that validates config before interactive login."""
|
|
if not binary_path:
|
|
return None
|
|
if provider.value == "codex":
|
|
return [binary_path, "login", "--help"]
|
|
return None
|
|
|
|
|
|
def _parse_codex_config_error_location(location: str) -> tuple[Path, int] | None:
|
|
match = _CODEX_CONFIG_LOCATION_RE.match(location.strip())
|
|
if match is None:
|
|
return None
|
|
try:
|
|
line_no = int(match.group("line"))
|
|
except ValueError:
|
|
return None
|
|
if line_no < 1:
|
|
return None
|
|
return Path(match.group("path")).expanduser(), line_no
|
|
|
|
|
|
def _codex_priority_service_tier_repair_hint(*, location: str, detail: str) -> str | None:
|
|
if not _CODEX_STALE_SERVICE_TIER_DETAIL_RE.search(detail):
|
|
return None
|
|
parsed = _parse_codex_config_error_location(location)
|
|
if parsed is None:
|
|
return None
|
|
path, line_no = parsed
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
except OSError:
|
|
return None
|
|
if line_no > len(lines):
|
|
return None
|
|
if _CODEX_PRIORITY_SERVICE_TIER_RE.match(lines[line_no - 1]) is None:
|
|
return None
|
|
return f"Change {path}:{line_no} service_tier from priority to fast"
|
|
|
|
|
|
def _repair_codex_priority_service_tier(*, location: str, detail: str) -> _CodexConfigRepairResult:
|
|
hint = _codex_priority_service_tier_repair_hint(location=location, detail=detail)
|
|
if hint is None:
|
|
return _CodexConfigRepairResult(
|
|
ok=False,
|
|
detail="This Codex config error is not one OpenSRE can repair safely.",
|
|
)
|
|
parsed = _parse_codex_config_error_location(location)
|
|
if parsed is None:
|
|
return _CodexConfigRepairResult(ok=False, detail=f"Could not parse {location}.")
|
|
|
|
path, line_no = parsed
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
except OSError as exc:
|
|
return _CodexConfigRepairResult(
|
|
ok=False,
|
|
detail=f"Could not read {path}: {exc}",
|
|
)
|
|
if line_no > len(lines):
|
|
return _CodexConfigRepairResult(
|
|
ok=False,
|
|
detail=f"Could not repair {path}: line {line_no} is outside the file.",
|
|
)
|
|
|
|
match = _CODEX_PRIORITY_SERVICE_TIER_RE.match(lines[line_no - 1])
|
|
if match is None:
|
|
return _CodexConfigRepairResult(
|
|
ok=False,
|
|
detail=f'Could not repair {path}: line {line_no} is no longer service_tier = "priority".',
|
|
)
|
|
|
|
lines[line_no - 1] = (
|
|
f"{match.group('prefix')}{match.group('quote')}fast{match.group('quote')}"
|
|
f"{match.group('suffix') or ''}{match.group('newline') or ''}"
|
|
)
|
|
try:
|
|
path.write_text("".join(lines), encoding="utf-8")
|
|
except OSError as exc:
|
|
return _CodexConfigRepairResult(
|
|
ok=False,
|
|
detail=f"Could not update {path}: {exc}",
|
|
)
|
|
return _CodexConfigRepairResult(ok=True, detail=hint)
|
|
|
|
|
|
def _run_login_preflight_process(command: list[str]) -> _LoginProcessResult:
|
|
result = subprocess.run(
|
|
command,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
return _LoginProcessResult(
|
|
returncode=result.returncode,
|
|
stdout=result.stdout or "",
|
|
stderr=result.stderr or "",
|
|
)
|
|
|
|
|
|
def _run_interactive_login_process(command: list[str]) -> _LoginProcessResult:
|
|
result = subprocess.run(command, check=False)
|
|
return _LoginProcessResult(
|
|
returncode=result.returncode,
|
|
)
|
|
|
|
|
|
def _subscription_login_error(
|
|
provider: ProviderOption, result: _LoginProcessResult
|
|
) -> _SubscriptionLoginResult:
|
|
text = "\n".join(
|
|
part.strip()
|
|
for part in (getattr(result, "stdout", "") or "", getattr(result, "stderr", "") or "")
|
|
if part and part.strip()
|
|
)
|
|
if provider.value == "codex":
|
|
match = _CODEX_CONFIG_ERROR_RE.search(text)
|
|
if match:
|
|
location = match.group("location")
|
|
detail = match.group("detail")
|
|
return _SubscriptionLoginResult(
|
|
ok=False,
|
|
config_error=True,
|
|
config_error_location=location,
|
|
config_error_detail=detail,
|
|
detail=(
|
|
"Codex CLI could not start because its local config is invalid: "
|
|
f"{location} ({detail}). "
|
|
"Fix that file, then retry OAuth login."
|
|
),
|
|
)
|
|
tail = text[:500]
|
|
return _SubscriptionLoginResult(
|
|
ok=False,
|
|
detail=(
|
|
f"Login exited with code {result.returncode}: {tail}"
|
|
if tail
|
|
else f"Login exited with code {result.returncode}."
|
|
),
|
|
)
|
|
|
|
|
|
def _run_subscription_login(
|
|
provider: ProviderOption, binary_path: str | None
|
|
) -> _SubscriptionLoginResult:
|
|
"""Launch the provider CLI login flow and report whether it exited cleanly."""
|
|
if provider.value == "codex":
|
|
_console.print(
|
|
f"[{SECONDARY}]Starting OpenSRE Codex OAuth server on http://localhost:1455[/]"
|
|
)
|
|
try:
|
|
oauth_result = run_codex_oauth_login()
|
|
except CodexOAuthError as exc:
|
|
detail = str(exc)
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {detail}[/]")
|
|
return _SubscriptionLoginResult(ok=False, detail=detail)
|
|
save_provider_auth_record(
|
|
provider="codex",
|
|
auth_name="chatgpt",
|
|
kind="cli_subscription",
|
|
source="codex-oauth",
|
|
detail=oauth_result.detail,
|
|
)
|
|
_console.print(f"[{SECONDARY}]{oauth_result.detail}[/]")
|
|
return _SubscriptionLoginResult(ok=True, detail=oauth_result.detail)
|
|
|
|
command = _subscription_login_command(provider, binary_path)
|
|
if command is None:
|
|
auth_hint = provider.adapter_factory().auth_hint if provider.adapter_factory else ""
|
|
detail = f"No browser login command is registered for {provider.label}. {auth_hint}"
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {detail}[/]")
|
|
return _SubscriptionLoginResult(ok=False, detail=detail)
|
|
|
|
preflight_command = _subscription_login_preflight_command(provider, binary_path)
|
|
if preflight_command is not None:
|
|
try:
|
|
preflight_result = _run_login_preflight_process(preflight_command)
|
|
except OSError as exc:
|
|
detail = f"Could not check login config: {exc}"
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {detail}[/]")
|
|
return _SubscriptionLoginResult(ok=False, detail=detail)
|
|
if preflight_result.returncode != 0:
|
|
login_result = _subscription_login_error(provider, preflight_result)
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {login_result.detail}[/]")
|
|
return login_result
|
|
|
|
_console.print(f"[{SECONDARY}]Launching {shlex.join(command)} for browser login…[/]")
|
|
try:
|
|
result = _run_interactive_login_process(command)
|
|
except KeyboardInterrupt:
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} Login cancelled.[/]")
|
|
return _SubscriptionLoginResult(ok=False, detail="Login cancelled.")
|
|
except OSError as exc:
|
|
detail = f"Could not launch login: {exc}"
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {detail}[/]")
|
|
return _SubscriptionLoginResult(ok=False, detail=detail)
|
|
if result.returncode != 0:
|
|
login_result = _subscription_login_error(provider, result)
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {login_result.detail}[/]")
|
|
return login_result
|
|
return _SubscriptionLoginResult(ok=True)
|
|
|
|
|
|
def _recover_subscription_config_error(
|
|
provider: ProviderOption,
|
|
*,
|
|
provider_label: str,
|
|
binary_path: str | None,
|
|
login_result: _SubscriptionLoginResult,
|
|
) -> Literal["ok", "continue", "repick"]:
|
|
repair_hint: str | None = None
|
|
if provider.value == "codex":
|
|
repair_hint = _codex_priority_service_tier_repair_hint(
|
|
location=login_result.config_error_location,
|
|
detail=login_result.config_error_detail,
|
|
)
|
|
|
|
choices: list[Choice] = []
|
|
if repair_hint is not None:
|
|
choices.append(
|
|
Choice(
|
|
value="repair",
|
|
label="Apply known Codex config fix and retry",
|
|
hint=repair_hint,
|
|
)
|
|
)
|
|
choices.extend(
|
|
[
|
|
Choice(
|
|
value="retry",
|
|
label="Retry after fixing local config",
|
|
hint=login_result.detail,
|
|
),
|
|
Choice(
|
|
value="repick",
|
|
label="Pick a different LLM provider",
|
|
hint=None,
|
|
),
|
|
]
|
|
)
|
|
recovery = _choose(
|
|
f"{provider_label} OAuth could not start. What next?",
|
|
choices,
|
|
default="repair" if repair_hint is not None else "retry",
|
|
)
|
|
if recovery == "repick":
|
|
return "repick"
|
|
if recovery != "repair":
|
|
return "continue"
|
|
|
|
repair_result = _repair_codex_priority_service_tier(
|
|
location=login_result.config_error_location,
|
|
detail=login_result.config_error_detail,
|
|
)
|
|
if not repair_result.ok:
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {repair_result.detail}[/]")
|
|
return "continue"
|
|
|
|
_console.print(f"[{SECONDARY}] Updated Codex config: {repair_result.detail}.[/]")
|
|
retry_result = _run_subscription_login(provider, binary_path)
|
|
if retry_result.ok:
|
|
return "ok"
|
|
return "continue"
|
|
|
|
|
|
def _run_cli_llm_onboarding(
|
|
provider: ProviderOption, *, display_label: str | None = None
|
|
) -> Literal["ok", "abort", "repick"]:
|
|
"""Probe CLI binary + auth; recovery menu when missing. ``repick`` = choose another LLM."""
|
|
factory = provider.adapter_factory
|
|
if factory is None:
|
|
_console.print(
|
|
f"[{ERROR}] {GLYPH_ERROR} Internal error: CLI provider missing adapter factory.[/]"
|
|
)
|
|
return "abort"
|
|
adapter = factory()
|
|
env_key = adapter.binary_env_key
|
|
install_hint = adapter.install_hint
|
|
auth_hint = adapter.auth_hint
|
|
name = adapter.name
|
|
provider_label = display_label or provider.label
|
|
for _attempt in range(10):
|
|
probe = adapter.detect()
|
|
if probe.installed and probe.logged_in is True:
|
|
_console.print(f"[{SECONDARY}]{probe.detail}[/]")
|
|
return "ok"
|
|
if probe.installed and probe.logged_in is not True:
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {probe.detail}[/]")
|
|
status_prompt = (
|
|
f"{provider_label} requires login. What next?"
|
|
if probe.logged_in is False
|
|
else f"Could not verify {provider_label} login. What next?"
|
|
)
|
|
choices = []
|
|
if _subscription_login_command(provider, probe.bin_path) is not None:
|
|
choices.append(
|
|
Choice(
|
|
value="login",
|
|
label="Open browser login now",
|
|
hint=auth_hint,
|
|
)
|
|
)
|
|
choices.extend(
|
|
[
|
|
Choice(
|
|
value="retry",
|
|
label="Re-detect after logging in",
|
|
hint=auth_hint,
|
|
),
|
|
Choice(
|
|
value="repick",
|
|
label="Pick a different LLM provider",
|
|
hint=None,
|
|
),
|
|
]
|
|
)
|
|
action = _choose(
|
|
status_prompt,
|
|
choices,
|
|
default="login" if choices and choices[0].value == "login" else "retry",
|
|
)
|
|
if action == "repick":
|
|
return "repick"
|
|
if action == "login":
|
|
login_result = _run_subscription_login(provider, probe.bin_path)
|
|
if login_result.ok:
|
|
return "ok"
|
|
if login_result.config_error:
|
|
recovery = _recover_subscription_config_error(
|
|
provider,
|
|
provider_label=provider_label,
|
|
binary_path=probe.bin_path,
|
|
login_result=login_result,
|
|
)
|
|
if recovery == "ok":
|
|
return "ok"
|
|
if recovery == "repick":
|
|
return "repick"
|
|
continue
|
|
continue
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} {probe.detail}[/]")
|
|
action = _choose(
|
|
f"{provider_label} not found. What next?",
|
|
[
|
|
Choice(
|
|
value="retry",
|
|
label="Re-detect after install",
|
|
hint=install_hint,
|
|
),
|
|
Choice(
|
|
value="path",
|
|
label="Enter full path to the binary",
|
|
hint=f"Writes {env_key} to .env",
|
|
),
|
|
Choice(
|
|
value="repick",
|
|
label="Pick a different LLM provider",
|
|
hint=None,
|
|
),
|
|
],
|
|
default="retry",
|
|
)
|
|
if action == "repick":
|
|
return "repick"
|
|
if action == "path":
|
|
path = _prompt_value(f"Full path to {name} binary")
|
|
reason = diagnose_binary_path(path)
|
|
if reason:
|
|
_console.print(f"[{WARNING}]{reason} Try again.[/]")
|
|
continue
|
|
sync_env_values({env_key: path})
|
|
os.environ[env_key] = path
|
|
continue
|
|
_console.print(f"[{SECONDARY}] Hint: {install_hint}[/]")
|
|
_console.print(f"[{WARNING}] {GLYPH_WARNING} Too many retry attempts. Aborting setup.[/]")
|
|
return "abort"
|
|
|
|
|
|
def run_wizard(_argv: list[str] | None = None) -> int:
|
|
"""Run the interactive wizard."""
|
|
_render_header()
|
|
defaults = _local_defaults()
|
|
saved_provider_value = defaults["provider"] if isinstance(defaults["provider"], str) else None
|
|
saved_model_value = defaults["model"] if isinstance(defaults["model"], str) else ""
|
|
default_wizard_mode = (
|
|
defaults["wizard_mode"] if isinstance(defaults["wizard_mode"], str) else "quickstart"
|
|
)
|
|
raw_saved_auth_method = defaults.get("auth_method")
|
|
saved_auth_method = (
|
|
normalize_llm_auth_method(raw_saved_auth_method)
|
|
if isinstance(raw_saved_auth_method, str)
|
|
else API_KEY_AUTH_METHOD
|
|
)
|
|
provider_options = _onboarding_provider_options()
|
|
provider_option_values = {p.value for p in provider_options}
|
|
default_provider_value = (
|
|
saved_provider_value
|
|
if saved_provider_value in provider_option_values
|
|
else provider_options[0].value
|
|
)
|
|
|
|
_step_header(1, WIZARD_TOTAL_STEPS, "Setup Mode")
|
|
wizard_mode = _choose(
|
|
"How do you want to get started?",
|
|
[
|
|
Choice(
|
|
value="quickstart", label="Quickstart", hint="Local setup with the usual defaults"
|
|
),
|
|
Choice(
|
|
value="advanced",
|
|
label="Advanced",
|
|
hint="Show probes and choose the target explicitly",
|
|
),
|
|
],
|
|
default=default_wizard_mode,
|
|
)
|
|
|
|
store_path = get_store_path()
|
|
local_probe = probe_local_target(store_path)
|
|
remote_probe = ProbeResult(
|
|
target="remote",
|
|
reachable=False,
|
|
detail="Remote probing is shown during Advanced setup.",
|
|
)
|
|
|
|
if wizard_mode == "advanced":
|
|
remote_probe = probe_remote_target()
|
|
target = _select_target_for_advanced(local_probe, remote_probe)
|
|
if target is None:
|
|
return 1
|
|
else:
|
|
target = "local"
|
|
|
|
if target != "local":
|
|
print("Only local configuration is supported today.", file=sys.stderr)
|
|
return 1
|
|
|
|
force_repick = False
|
|
provider: ProviderOption
|
|
model_provider: ProviderOption
|
|
auth_method: LLMAuthMethod | None
|
|
model: str
|
|
provider_extra_env: dict[str, str] = {}
|
|
while True:
|
|
_step_header(2, WIZARD_TOTAL_STEPS, "LLM Provider")
|
|
saved_provider = (
|
|
PROVIDER_BY_VALUE.get(saved_provider_value) if saved_provider_value else None
|
|
)
|
|
if saved_provider is not None and not force_repick:
|
|
saved_model_provider = _oauth_backend_provider(saved_provider, saved_auth_method)
|
|
current_model = saved_model_value or saved_model_provider.default_model
|
|
auth_segment = (
|
|
f" · {_auth_method_label(saved_auth_method)}"
|
|
if supports_oauth_auth_method(saved_provider.value)
|
|
else ""
|
|
)
|
|
_console.print(
|
|
f"[{SECONDARY}]current provider {_provider_choice_label(saved_provider)}{auth_segment} · {current_model}[/]"
|
|
)
|
|
change_provider = _confirm("Change provider?", default=False)
|
|
else:
|
|
change_provider = True
|
|
force_repick = False
|
|
|
|
if change_provider:
|
|
try:
|
|
provider = PROVIDER_BY_VALUE[
|
|
_choose(
|
|
"Choose your LLM provider",
|
|
[
|
|
Choice(
|
|
value=p.value,
|
|
label=_provider_choice_label(p),
|
|
hint=p.group,
|
|
)
|
|
for p in provider_options
|
|
],
|
|
default=default_provider_value,
|
|
)
|
|
]
|
|
auth_method = _choose_auth_method(provider, default=OAUTH_AUTH_METHOD)
|
|
model_provider = _oauth_backend_provider(provider, auth_method)
|
|
except WizardBack:
|
|
force_repick = True
|
|
continue
|
|
model = model_provider.default_model
|
|
if auth_method == API_KEY_AUTH_METHOD and provider.credential_kind not in (
|
|
"cli",
|
|
"none",
|
|
):
|
|
_step(provider.credential_label.title())
|
|
try:
|
|
api_key = _prompt_value(
|
|
f"{_credential_prompt_label(provider)} {provider.credential_label} ({provider.api_key_env})",
|
|
default=provider.credential_default,
|
|
secret=provider.credential_secret,
|
|
back_on_cancel=True,
|
|
)
|
|
except WizardBack:
|
|
force_repick = True
|
|
continue
|
|
except KeyboardInterrupt:
|
|
_console.print(f"\n[{WARNING}]Setup cancelled.[/]")
|
|
return 1
|
|
if not _persist_llm_api_key(provider.api_key_env, api_key):
|
|
return 1
|
|
azure_env = _ensure_azure_openai_endpoint_settings(provider)
|
|
if azure_env is None:
|
|
force_repick = True
|
|
continue
|
|
provider_extra_env = azure_env
|
|
os.environ.update(azure_env)
|
|
else:
|
|
assert saved_provider is not None
|
|
provider = saved_provider
|
|
auth_method = saved_auth_method
|
|
model_provider = _oauth_backend_provider(provider, auth_method)
|
|
model = saved_model_value or model_provider.default_model
|
|
if auth_method == API_KEY_AUTH_METHOD and provider.credential_kind not in (
|
|
"cli",
|
|
"none",
|
|
):
|
|
has_api_key = bool(defaults["has_api_key"])
|
|
legacy_api_key = str(defaults["legacy_api_key"] or "").strip()
|
|
if not has_api_key and legacy_api_key:
|
|
if not _persist_llm_api_key(provider.api_key_env, legacy_api_key):
|
|
return 1
|
|
has_api_key = True
|
|
if not has_api_key:
|
|
_step(provider.credential_label.title())
|
|
try:
|
|
api_key = _prompt_value(
|
|
f"{_credential_prompt_label(provider)} {provider.credential_label} ({provider.api_key_env})",
|
|
default=provider.credential_default,
|
|
secret=provider.credential_secret,
|
|
back_on_cancel=True,
|
|
)
|
|
except WizardBack:
|
|
force_repick = True
|
|
continue
|
|
except KeyboardInterrupt:
|
|
_console.print(f"\n[{WARNING}]Setup cancelled.[/]")
|
|
return 1
|
|
if not _persist_llm_api_key(provider.api_key_env, api_key):
|
|
return 1
|
|
azure_env = _ensure_azure_openai_endpoint_settings(provider)
|
|
if azure_env is None:
|
|
force_repick = True
|
|
continue
|
|
provider_extra_env = azure_env
|
|
os.environ.update(azure_env)
|
|
|
|
if change_provider:
|
|
try:
|
|
model = _choose_model(
|
|
model_provider,
|
|
default=model,
|
|
prompt_label=(
|
|
f"{_provider_choice_label(provider)} OAuth"
|
|
if auth_method == OAUTH_AUTH_METHOD
|
|
else _provider_choice_label(provider)
|
|
),
|
|
back_on_cancel=True,
|
|
)
|
|
except WizardBack:
|
|
force_repick = True
|
|
continue
|
|
elif model_provider.models:
|
|
current_display = model or "CLI default"
|
|
_console.print(f"[{SECONDARY}]current model {current_display}[/]")
|
|
if _confirm("Change model?", default=False):
|
|
model = _choose_model(
|
|
model_provider,
|
|
default=model,
|
|
prompt_label=(
|
|
f"{_provider_choice_label(provider)} OAuth"
|
|
if auth_method == OAUTH_AUTH_METHOD
|
|
else _provider_choice_label(provider)
|
|
),
|
|
)
|
|
|
|
if model_provider.credential_kind == "cli":
|
|
cli_out = _run_cli_llm_onboarding(
|
|
model_provider,
|
|
display_label=(
|
|
f"{_provider_choice_label(provider)} OAuth"
|
|
if auth_method == OAUTH_AUTH_METHOD
|
|
else None
|
|
),
|
|
)
|
|
if cli_out == "abort":
|
|
return 1
|
|
if cli_out == "repick":
|
|
force_repick = True
|
|
continue
|
|
break
|
|
|
|
probes = {
|
|
"local": local_probe.as_dict(),
|
|
"remote": remote_probe.as_dict(),
|
|
}
|
|
persisted_auth_method = _persisted_auth_method(provider, auth_method)
|
|
saved_path = save_local_config(
|
|
wizard_mode=wizard_mode,
|
|
provider=provider.value,
|
|
model=model,
|
|
api_key_env=provider.api_key_env,
|
|
model_env=model_provider.model_env,
|
|
auth_method=persisted_auth_method,
|
|
probes=probes,
|
|
)
|
|
env_path = sync_provider_env(
|
|
provider=provider,
|
|
model=model,
|
|
model_provider=model_provider,
|
|
auth_method=persisted_auth_method,
|
|
extra_env=provider_extra_env or None,
|
|
)
|
|
|
|
_step_header(3, WIZARD_TOTAL_STEPS, "Integrations")
|
|
try:
|
|
configured_integrations, integration_env_path = (
|
|
_integration_configurators_module._configure_selected_integrations()
|
|
)
|
|
except KeyboardInterrupt:
|
|
cancelled = Text()
|
|
cancelled.append(f"\n {GLYPH_WARNING} ", style=f"bold {WARNING}")
|
|
cancelled.append("Integration setup cancelled. AI config was kept.", style=TEXT)
|
|
_console.print(cancelled)
|
|
configured_integrations = []
|
|
integration_env_path = None
|
|
|
|
summary_env_path = integration_env_path or str(env_path)
|
|
|
|
_step_header(4, WIZARD_TOTAL_STEPS, "Summary")
|
|
_render_saved_summary(
|
|
provider_label=_provider_label_for_saved_summary(provider, persisted_auth_method),
|
|
model=model,
|
|
saved_path=str(saved_path),
|
|
env_path=summary_env_path,
|
|
configured_integrations=configured_integrations,
|
|
credential_line=_credential_line_for_saved_summary(provider, persisted_auth_method),
|
|
)
|
|
_render_next_steps()
|
|
return 0
|