From d2452c54d51416e4efbedf66d6bc8799aed53c4f Mon Sep 17 00:00:00 2001 From: dinos Date: Wed, 8 Jul 2026 20:28:44 +0200 Subject: [PATCH] Refactor onboarding OAuth flow for auxiliary models (#337) * refactor(onboard): shared flow for ccproxy providers * feat(onboard): support oauth configuration for auxiliary models * fix(onboard): reuse main model auth for same-provider auxiliary * fix(onboard): reconcile oauth providers --- EvoScientist/config/onboard/steps.py | 175 ++++------ EvoScientist/config/onboard/wizard.py | 439 +++++++++++++++----------- tests/test_onboard.py | 309 ++++++++++++++++++ 3 files changed, 633 insertions(+), 290 deletions(-) diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py index 9ef7738..184e0bd 100644 --- a/EvoScientist/config/onboard/steps.py +++ b/EvoScientist/config/onboard/steps.py @@ -371,14 +371,31 @@ def _step_minimax_region(config: EvoScientistConfig) -> str: return _MINIMAX_REGIONS[region] -def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: - """Step 2a: Select Anthropic authentication mode (API key vs OAuth). +def _step_oauth_auth_mode( + config: EvoScientistConfig, + *, + provider_label: str, + ccproxy_provider: str, + config_attr: str, + prompt_login_label: str, + oauth_choice_label: str | None = None, + status_label: str | None = None, + question_label: str | None = None, +) -> str: + """Select API-key vs ccproxy OAuth authentication for a provider. Args: config: Current configuration. + provider_label: Provider display name for direct API-key access. + ccproxy_provider: ccproxy auth provider name. + config_attr: Config attribute storing this provider's auth mode. + prompt_login_label: Label used in "Log in to ..." prompts. + oauth_choice_label: Optional display label for the OAuth choice. + status_label: Optional display label for status messages. + question_label: Optional prompt label override. Returns: - Selected auth mode: "api_key", "oauth", or "auto". + Selected auth mode: "api_key" or "oauth". """ from ...ccproxy_manager import check_ccproxy_auth, is_ccproxy_available @@ -386,10 +403,14 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys + oauth_label = oauth_choice_label or f"{prompt_login_label} OAuth" + auth_status_label = status_label or oauth_label + auth_question_label = question_label or f"{provider_label} authentication mode" + choices = [ - Choice(title="API Key (direct Anthropic access)", value="api_key"), + Choice(title=f"API Key (direct {provider_label} access)", value="api_key"), Choice( - title="Claude Code OAuth (via ccproxy — no API key needed)" + title=f"{oauth_label} (via ccproxy — no API key needed)" + ( "" if ccproxy_available @@ -401,12 +422,12 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL), ] - current = config.anthropic_auth_mode + current = getattr(config, config_attr) if current not in ("api_key", "oauth"): current = "api_key" question = questionary.select( - "Authentication mode [Esc/← to go back]:", + f"{auth_question_label} [Esc/← to go back]:", choices=choices, default=current, style=WIZARD_STYLE, @@ -448,11 +469,9 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: if auth_mode == "oauth": _prompt_ccproxy_port(config) - # If OAuth selected, check auth status and offer login - if auth_mode in ("oauth", "auto"): - authed, msg = check_ccproxy_auth() + authed, msg = check_ccproxy_auth(ccproxy_provider) if authed: - console.print(f" [green]✓ OAuth: {msg}[/green]") + console.print(f" [green]✓ {auth_status_label}: {msg}[/green]") relogin = questionary.confirm( "Re-authenticate to refresh credentials?", default=False, @@ -462,11 +481,13 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: if relogin is None: raise KeyboardInterrupt() if relogin: - _run_ccproxy_login("claude_api", "OAuth") + _run_ccproxy_login(ccproxy_provider, auth_status_label) else: - console.print(f" [yellow]OAuth not authenticated: {msg}[/yellow]") + console.print( + f" [yellow]{auth_status_label} not authenticated: {msg}[/yellow]" + ) login = questionary.confirm( - "Log in to Claude now?", + f"Log in to {prompt_login_label} now?", default=True, style=CONFIRM_STYLE, qmark=QMARK, @@ -474,11 +495,32 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: if login is None: raise KeyboardInterrupt() if login: - _run_ccproxy_login("claude_api", "OAuth") + _run_ccproxy_login(ccproxy_provider, auth_status_label) return auth_mode +def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: + """Step 2a: Select Anthropic authentication mode (API key vs OAuth). + + Args: + config: Current configuration. + + Returns: + Selected auth mode: "api_key" or "oauth". + """ + return _step_oauth_auth_mode( + config, + provider_label="Anthropic", + ccproxy_provider="claude_api", + config_attr="anthropic_auth_mode", + prompt_login_label="Claude", + oauth_choice_label="Claude Code OAuth", + status_label="OAuth", + question_label="Authentication mode", + ) + + def _step_openai_auth_mode(config: EvoScientistConfig) -> str: """Step 2b: Select OpenAI authentication mode (API key vs Codex OAuth). @@ -488,101 +530,16 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str: Returns: Selected auth mode: "api_key" or "oauth". """ - from ...ccproxy_manager import check_ccproxy_auth, is_ccproxy_available - - ccproxy_available = is_ccproxy_available() - - from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys - - choices = [ - Choice(title="API Key (direct OpenAI access)", value="api_key"), - Choice( - title="Codex OAuth (via ccproxy — no API key needed)" - + ( - "" - if ccproxy_available - else " [requires: pip install evoscientist[oauth]]" - ), - value="oauth", - ), - questionary.Separator(), - Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL), - ] - - current = config.openai_auth_mode - if current not in ("api_key", "oauth"): - current = "api_key" - - question = questionary.select( - "OpenAI authentication mode [Esc/← to go back]:", - choices=choices, - default=current, - style=WIZARD_STYLE, - qmark=QMARK, - use_indicator=True, + return _step_oauth_auth_mode( + config, + provider_label="OpenAI", + ccproxy_provider="codex", + config_attr="openai_auth_mode", + prompt_login_label="Codex", + oauth_choice_label="Codex OAuth", + status_label="Codex OAuth", + question_label="OpenAI authentication mode", ) - install_navigation_keys(question, with_back=True) - auth_mode = question.ask() - - if auth_mode is None: - raise KeyboardInterrupt() - if auth_mode == BACK_SENTINEL: - raise GoBack() - - if auth_mode == "oauth" and not ccproxy_available: - console.print(" [yellow]✗ ccproxy not installed[/yellow]") - console.print() - install = questionary.confirm( - 'Install ccproxy now? (pip install "evoscientist[oauth]")', - default=True, - style=WIZARD_STYLE, - qmark=f" {QMARK}", - ).ask() - if install is None: - raise KeyboardInterrupt() - if install: - console.print() - if _install_ccproxy(): - console.print(" [green]✓ ccproxy installed successfully.[/green]") - else: - console.print(" [yellow]Falling back to API key mode.[/yellow]") - return "api_key" - else: - console.print( - ' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]' - ) - return "api_key" - - # If OAuth selected, prompt for port and check auth status - if auth_mode == "oauth": - _prompt_ccproxy_port(config) - authed, msg = check_ccproxy_auth("codex") - if authed: - console.print(f" [green]✓ Codex OAuth: {msg}[/green]") - relogin = questionary.confirm( - "Re-authenticate to refresh credentials?", - default=False, - style=CONFIRM_STYLE, - qmark=QMARK, - ).ask() - if relogin is None: - raise KeyboardInterrupt() - if relogin: - _run_ccproxy_login("codex", "Codex OAuth") - else: - console.print(f" [yellow]Codex OAuth not authenticated: {msg}[/yellow]") - login = questionary.confirm( - "Log in to Codex now?", - default=True, - style=CONFIRM_STYLE, - qmark=QMARK, - ).ask() - if login is None: - raise KeyboardInterrupt() - if login: - _run_ccproxy_login("codex", "Codex OAuth") - - return auth_mode def _step_provider_api_key( diff --git a/EvoScientist/config/onboard/wizard.py b/EvoScientist/config/onboard/wizard.py index 21008dc..3994db3 100644 --- a/EvoScientist/config/onboard/wizard.py +++ b/EvoScientist/config/onboard/wizard.py @@ -129,6 +129,12 @@ _PROVIDER_KEY_ATTR = { "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. @@ -142,6 +148,201 @@ def _autosave(config: EvoScientistConfig) -> None: 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"), @@ -479,102 +680,17 @@ def run_onboard( provider = _step_provider(config) config.provider = provider - # Step 2a: Base URL (custom-openai, custom-anthropic, - # minimax, ollama). In strict non-interactive mode we - # never call the interactive _step_base_url / - # _step_minimax_region / _step_ollama_base_url helpers — - # fall back to the existing config value or the - # CUSTOM_*_BASE_URL / OLLAMA_BASE_URL env var instead. - # If neither is set for a provider that needs it, raise - # so the user sees the same "missing required answer" - # error as for other required prompts. - if provider == "custom-openai": - current_base_url = ( - config.custom_openai_base_url - or os.environ.get("CUSTOM_OPENAI_BASE_URL", "") - ) - if strict: - if not current_base_url: - raise RuntimeError( - "--non-interactive: custom-openai provider " - "needs a base URL. Set the " - "CUSTOM_OPENAI_BASE_URL env var or run " - "without --non-interactive." - ) - config.custom_openai_base_url = current_base_url - else: - config.custom_openai_base_url = _step_base_url( - config, current_value=current_base_url - ) - elif provider == "custom-anthropic": - current_base_url = ( - config.custom_anthropic_base_url - or os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", "") - ) - if strict: - if not current_base_url: - raise RuntimeError( - "--non-interactive: custom-anthropic " - "provider needs a base URL. Set the " - "CUSTOM_ANTHROPIC_BASE_URL env var or run " - "without --non-interactive." - ) - config.custom_anthropic_base_url = current_base_url - else: - config.custom_anthropic_base_url = _step_base_url( - config, current_value=current_base_url - ) - elif provider == "minimax": - if strict: - # MiniMax has 2 region URLs; default to whatever - # is already in config, else the Global endpoint. - config.minimax_base_url = ( - config.minimax_base_url - or "https://api.minimax.io/anthropic" - ) - else: - config.minimax_base_url = _step_minimax_region(config) - elif provider == "ollama": - if strict: - # Ollama: existing config value > env var > - # localhost default. Skip the live connection - # validation under strict — model discovery - # happens at runtime anyway. - config.ollama_base_url = ( - config.ollama_base_url - or os.environ.get("OLLAMA_BASE_URL", "") - or "http://localhost:11434" - ) - # ollama_detected_models stays [] — model picker - # will fall back to free-text or the preset. - else: - ollama_url, ollama_detected_models = _step_ollama_base_url( - config - ) - config.ollama_base_url = ollama_url - - # Step 2b: Auth mode (Anthropic or OpenAI — API key vs OAuth). - # In strict non-interactive mode we assume "api_key". - # The prompt offers a `← Back` choice that raises GoBack so - # the user can re-pick the provider without exiting the wizard. try: - 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) - else: - # Non-Anthropic/OpenAI provider: reset OAuth modes to - # avoid stale oauth config triggering ccproxy at startup. - config.anthropic_auth_mode = "api_key" - config.openai_auth_mode = "api_key" + 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 / @@ -594,60 +710,9 @@ def run_onboard( ollama_detected_models = [] console.print(" [dim]↩ Returning to provider selection.[/dim]") continue - break # auth_mode succeeded — exit sub-loop + break # Provider setup succeeded — exit sub-loop - # Step 2c: Provider API Key (skip for Ollama and pure OAuth) - _skip_api_key = ( - provider == "ollama" - or ( - provider == "anthropic" - and config.anthropic_auth_mode == "oauth" - ) - or (provider == "openai" and config.openai_auth_mode == "oauth") - ) - if not _skip_api_key: - key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key") - preset_api_key = _preset("api_key") - if preset_api_key is not None: - # Validate the preset key against the same validator - # the interactive path uses, unless --skip-validation - # was passed. Interactive flow shows a "Save anyway?" - # confirm on failure; the non-interactive path has no - # way to ask, so a failed validation is fatal. - 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} " - f"validator: {msg}. Pass " - "--skip-validation to override." - ) - setattr(config, key_attr, preset_api_key) - console.print( - f" [green]✓ API key: ***{preset_api_key[-4:]}[/green]" - " [dim](--api-key)[/dim]" - ) - else: - _require("api_key", f"{provider} 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") + _reconcile_oauth_modes(config) _autosave(config) else: # Provider section skipped — keep prior provider value to drive @@ -680,44 +745,55 @@ def run_onboard( "kept current" if config.auxiliary_model else "not set", ) elif _step_auxiliary_enable(config): - # Assemble: pick provider -> base URL (custom) -> key -> model, - # mirroring the main flow's order. Keys/base URLs are stored - # per provider, so when the auxiliary provider matches the main - # one they're already set and the user just keeps them (Enter). - # Ollama needs no key. Re-runs default to the saved auxiliary - # provider/model rather than the main ones. - aux_provider = _step_provider( - config, - label="co-pilot", - default_value=config.auxiliary_provider, - ) - config.auxiliary_provider = aux_provider - if aux_provider == "custom-openai": - config.custom_openai_base_url = _step_base_url( + from .prompter import GoBack + + aux_ollama_detected_models: list[str] = [] + while True: + loop_snapshot = copy.deepcopy(config) + aux_provider = _step_provider( config, - current_value=config.custom_openai_base_url - or os.environ.get("CUSTOM_OPENAI_BASE_URL", ""), + label="co-pilot", + default_value=config.auxiliary_provider, ) - elif aux_provider == "custom-anthropic": - config.custom_anthropic_base_url = _step_base_url( - config, - current_value=config.custom_anthropic_base_url - or os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", ""), - ) - elif aux_provider == "minimax": - config.minimax_base_url = _step_minimax_region(config) - if aux_provider != "ollama": - aux_key_attr = _PROVIDER_KEY_ATTR.get( - aux_provider, "openai_api_key" - ) - new_aux_key = _step_provider_api_key( - config, aux_provider, skip_validation - ) - if new_aux_key is not None: - setattr(config, aux_key_attr, new_aux_key) + 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, ) @@ -725,6 +801,7 @@ def run_onboard( # 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: diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 5762e78..2d7c233 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -184,6 +184,38 @@ class TestSharedConstantsAlignment: ) +class TestOAuthModeReconcile: + def test_reconcile_preserves_auxiliary_openai_oauth(self): + from EvoScientist.config.onboard.wizard import _reconcile_oauth_modes + + config = EvoScientistConfig( + provider="minimax", + auxiliary_provider="openai", + auxiliary_model="gpt-5.5", + openai_auth_mode="oauth", + anthropic_auth_mode="oauth", + ) + + _reconcile_oauth_modes(config) + + assert config.openai_auth_mode == "oauth" + assert config.anthropic_auth_mode == "api_key" + + def test_reconcile_preserves_auxiliary_provider_without_model(self): + from EvoScientist.config.onboard.wizard import _reconcile_oauth_modes + + config = EvoScientistConfig( + provider="minimax", + auxiliary_provider="openai", + auxiliary_model="", + openai_auth_mode="oauth", + ) + + _reconcile_oauth_modes(config) + + assert config.openai_auth_mode == "oauth" + + # ============================================================================= # Test render_progress # ============================================================================= @@ -386,6 +418,109 @@ class TestStepProvider: _step_provider(config) +class TestStepOAuthAuthMode: + @pytest.mark.parametrize( + ( + "step_name", + "config_attr", + "provider_label", + "oauth_choice_label", + "ccproxy_provider", + "status_label", + "question_label", + "login_prompt", + ), + [ + ( + "_step_anthropic_auth_mode", + "anthropic_auth_mode", + "Anthropic", + "Claude Code OAuth", + "claude_api", + "OAuth", + "Authentication mode", + "Log in to Claude now?", + ), + ( + "_step_openai_auth_mode", + "openai_auth_mode", + "OpenAI", + "Codex OAuth", + "codex", + "Codex OAuth", + "OpenAI authentication mode", + "Log in to Codex now?", + ), + ], + ) + def test_oauth_wrappers_use_provider_specific_ccproxy_flow( + self, + step_name, + config_attr, + provider_label, + oauth_choice_label, + ccproxy_provider, + status_label, + question_label, + login_prompt, + ): + """Anthropic/OpenAI wrappers share flow but keep provider-specific IDs.""" + from EvoScientist.config.onboard import steps as onboard_steps + + config = EvoScientistConfig(**{config_attr: "oauth"}) + select_question = MagicMock() + select_question.ask.return_value = "oauth" + confirm_question = MagicMock() + confirm_question.ask.return_value = True + + with ( + patch( + "EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True + ), + patch( + "EvoScientist.ccproxy_manager.check_ccproxy_auth", + return_value=(False, "not authenticated"), + ) as mock_check_auth, + patch( + "EvoScientist.config.onboard.prompter.install_navigation_keys" + ) as mock_nav, + patch( + "EvoScientist.config.onboard.steps.questionary.select", + return_value=select_question, + ) as mock_select, + patch( + "EvoScientist.config.onboard.steps.questionary.confirm", + return_value=confirm_question, + ) as mock_confirm, + patch( + "EvoScientist.config.onboard.steps._prompt_ccproxy_port" + ) as mock_port, + patch("EvoScientist.config.onboard.steps._run_ccproxy_login") as mock_login, + ): + result = getattr(onboard_steps, step_name)(config) + + assert result == "oauth" + mock_nav.assert_called_once_with(select_question, with_back=True) + mock_port.assert_called_once_with(config) + mock_check_auth.assert_called_once_with(ccproxy_provider) + mock_login.assert_called_once_with(ccproxy_provider, status_label) + + select_call = mock_select.call_args + assert select_call.args[0] == f"{question_label} [Esc/← to go back]:" + assert select_call.kwargs["default"] == "oauth" + choice_titles = [ + choice.title + for choice in select_call.kwargs["choices"] + if getattr(choice, "value", None) in {"api_key", "oauth"} + ] + assert choice_titles == [ + f"API Key (direct {provider_label} access)", + f"{oauth_choice_label} (via ccproxy — no API key needed)", + ] + mock_confirm.assert_called_once() + assert mock_confirm.call_args.args[0] == login_prompt + + class TestStepModel: def test_returns_selected_model(self): """Test that _step_model returns selected model.""" @@ -1266,6 +1401,7 @@ class TestRunOnboard: "claude-sonnet-4-6", # Model "assemble", # Auxiliary: Assemble "openai", # Auxiliary provider (a different company) + "api_key", # Auxiliary OpenAI auth mode "gpt-5.5", # Auxiliary model "daemon", # Workspace mode True, # Show thinking @@ -1289,12 +1425,185 @@ class TestRunOnboard: final_config = mock_save.call_args_list[-1].args[0] assert final_config.auxiliary_provider == "openai" assert final_config.auxiliary_model == "gpt-5.5" + assert final_config.openai_auth_mode == "api_key" # The auxiliary provider's key is stored in its per-provider field. assert final_config.openai_api_key == "sk-aux-openai" # Main agent is untouched. assert final_config.provider == "anthropic" assert final_config.model == "claude-sonnet-4-6" + def test_auxiliary_same_provider_reuses_main_credentials(self): + """Same-provider co-pilot should not imply separate credentials exist.""" + from EvoScientist.config.onboard.wizard import run_onboard + + mock_q = MagicMock() + with ( + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + ): + mock_load.return_value = EvoScientistConfig( + provider="openai", + model="gpt-5.5", + openai_api_key="sk-main-openai", + ) + mock_q.select.return_value.ask.side_effect = [ + "assemble", # Auxiliary: Assemble + "openai", # Same provider as the main model + "gpt-5.5", # Auxiliary model + ] + mock_q.confirm.return_value.ask.side_effect = [True] # Save config + + result = run_onboard( + skip_validation=True, only_sections={"auxiliary_model"} + ) + + assert result is True + final_config = mock_save.call_args_list[-1].args[0] + assert final_config.auxiliary_provider == "openai" + assert final_config.auxiliary_model == "gpt-5.5" + assert final_config.openai_api_key == "sk-main-openai" + mock_q.password.assert_not_called() + assert mock_q.select.return_value.ask.call_count == 3 + + def test_auxiliary_same_provider_prompts_when_shared_key_missing(self): + """Same-provider reuse should not hide a missing shared API key.""" + from EvoScientist.config.onboard.wizard import run_onboard + + mock_q = MagicMock() + with ( + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.helpers.console"), + patch( + "EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True + ), + ): + mock_load.return_value = EvoScientistConfig( + provider="openai", + model="gpt-5.5", + openai_auth_mode="api_key", + openai_api_key="", + ) + mock_q.select.return_value.ask.side_effect = [ + "assemble", # Auxiliary: Assemble + "openai", # Same provider as the main model + "api_key", # Shared OpenAI auth mode + "gpt-5.5", # Auxiliary model + ] + mock_q.password.return_value.ask.side_effect = [ + "sk-shared-openai", + ] + mock_q.confirm.return_value.ask.side_effect = [True] # Save config + + result = run_onboard( + skip_validation=True, only_sections={"auxiliary_model"} + ) + + assert result is True + final_config = mock_save.call_args_list[-1].args[0] + assert final_config.auxiliary_provider == "openai" + assert final_config.auxiliary_model == "gpt-5.5" + assert final_config.openai_auth_mode == "api_key" + assert final_config.openai_api_key == "sk-shared-openai" + mock_q.password.assert_called_once() + assert mock_q.select.return_value.ask.call_count == 4 + + def test_auxiliary_openai_oauth_skips_api_key(self): + """Auxiliary OpenAI now uses the shared auth flow and skips keys on OAuth.""" + from EvoScientist.config.onboard.wizard import run_onboard + + mock_q = MagicMock() + with ( + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.helpers.console"), + patch( + "EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True + ), + patch( + "EvoScientist.ccproxy_manager.check_ccproxy_auth", + return_value=(False, "not authenticated"), + ) as mock_auth, + ): + mock_load.return_value = EvoScientistConfig() + mock_q.select.return_value.ask.side_effect = [ + "assemble", # Auxiliary: Assemble + "openai", # Auxiliary provider + "oauth", # OpenAI auth mode + "gpt-5.5", # Auxiliary model + ] + mock_q.text.return_value.ask.side_effect = [ + "", # ccproxy port (keep default) + ] + mock_q.confirm.return_value.ask.side_effect = [ + False, # Do not log in to Codex now + True, # Save config + ] + + result = run_onboard( + skip_validation=True, only_sections={"auxiliary_model"} + ) + + assert result is True + final_config = mock_save.call_args_list[-1].args[0] + assert final_config.auxiliary_provider == "openai" + assert final_config.auxiliary_model == "gpt-5.5" + assert final_config.openai_auth_mode == "oauth" + assert final_config.openai_api_key == "" + mock_q.password.assert_not_called() + mock_auth.assert_called_once_with("codex") + + def test_auxiliary_reconfigure_clears_unused_openai_oauth(self): + """Switching co-pilot away from OpenAI clears stale OpenAI OAuth mode.""" + from EvoScientist.config.onboard.wizard import run_onboard + + mock_q = MagicMock() + with ( + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.helpers.console"), + ): + mock_load.return_value = EvoScientistConfig( + provider="anthropic", + model="claude-sonnet-4-6", + anthropic_auth_mode="oauth", + auxiliary_provider="openai", + auxiliary_model="gpt-5.5", + openai_auth_mode="oauth", + ) + mock_q.select.return_value.ask.side_effect = [ + "assemble", # Auxiliary: Assemble + "minimax", # Auxiliary provider no longer uses OpenAI + "global", # MiniMax region + "minimax-m2", # Auxiliary model + ] + mock_q.password.return_value.ask.side_effect = [ + "sk-minimax", # MiniMax API key + ] + mock_q.confirm.return_value.ask.side_effect = [True] # Save config + + result = run_onboard( + skip_validation=True, only_sections={"auxiliary_model"} + ) + + assert result is True + final_config = mock_save.call_args_list[-1].args[0] + assert final_config.auxiliary_provider == "minimax" + assert final_config.openai_auth_mode == "api_key" + assert final_config.anthropic_auth_mode == "oauth" + def test_auxiliary_custom_provider_collects_base_url(self): """Regression for the custom-provider fix: a custom auxiliary provider collects its base URL (provider -> base URL -> key -> model order)."""