fix(cli): support Codex OAuth in quick start
This commit is contained in:
+96
-11
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import types
|
import types
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, Literal, NamedTuple, get_args, get_origin
|
from typing import Any, Literal, NamedTuple, get_args, get_origin
|
||||||
@@ -22,7 +23,7 @@ from nanobot.cli.models import (
|
|||||||
get_model_context_limit,
|
get_model_context_limit,
|
||||||
get_model_suggestions,
|
get_model_suggestions,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -44,6 +45,8 @@ class _QuickStartProviderInfo(NamedTuple):
|
|||||||
default_api_base: str
|
default_api_base: str
|
||||||
backend: str
|
backend: str
|
||||||
is_direct: bool
|
is_direct: bool
|
||||||
|
is_oauth: bool
|
||||||
|
default_model: str
|
||||||
|
|
||||||
|
|
||||||
class _QuickStartEndpointChoice(NamedTuple):
|
class _QuickStartEndpointChoice(NamedTuple):
|
||||||
@@ -73,6 +76,7 @@ _BACK_PRESSED = object() # Sentinel value for back navigation
|
|||||||
_MODEL_PRESET_CACHE: set[str] = set()
|
_MODEL_PRESET_CACHE: set[str] = set()
|
||||||
|
|
||||||
_QUICK_START_CUSTOM_PROVIDER_CHOICE = "Other OpenAI-compatible"
|
_QUICK_START_CUSTOM_PROVIDER_CHOICE = "Other OpenAI-compatible"
|
||||||
|
_QUICK_START_OAUTH_PROVIDERS = {"openai_codex"}
|
||||||
|
|
||||||
_CLEAR_CHOICE = "Clear value"
|
_CLEAR_CHOICE = "Clear value"
|
||||||
_QUICK_START_MENU_CHOICE = "[Q] Quick Start"
|
_QUICK_START_MENU_CHOICE = "[Q] Quick Start"
|
||||||
@@ -1576,7 +1580,11 @@ def _get_quick_start_provider_info() -> dict[str, _QuickStartProviderInfo]:
|
|||||||
|
|
||||||
result: dict[str, _QuickStartProviderInfo] = {}
|
result: dict[str, _QuickStartProviderInfo] = {}
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
if spec.name == "custom" or spec.is_oauth or spec.is_transcription_only:
|
if (
|
||||||
|
spec.name == "custom"
|
||||||
|
or spec.is_transcription_only
|
||||||
|
or (spec.is_oauth and spec.name not in _QUICK_START_OAUTH_PROVIDERS)
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
result[spec.name] = _QuickStartProviderInfo(
|
result[spec.name] = _QuickStartProviderInfo(
|
||||||
display_name=spec.display_name or spec.name,
|
display_name=spec.display_name or spec.name,
|
||||||
@@ -1584,6 +1592,8 @@ def _get_quick_start_provider_info() -> dict[str, _QuickStartProviderInfo]:
|
|||||||
default_api_base=spec.default_api_base,
|
default_api_base=spec.default_api_base,
|
||||||
backend=spec.backend,
|
backend=spec.backend,
|
||||||
is_direct=spec.is_direct,
|
is_direct=spec.is_direct,
|
||||||
|
is_oauth=spec.is_oauth,
|
||||||
|
default_model=spec.builtin_models[0].id if spec.builtin_models else "",
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1599,7 +1609,64 @@ def _get_quick_start_provider_choices() -> dict[str, str]:
|
|||||||
|
|
||||||
def _quick_start_requires_api_key(provider_name: str, info: _QuickStartProviderInfo | None) -> bool:
|
def _quick_start_requires_api_key(provider_name: str, info: _QuickStartProviderInfo | None) -> bool:
|
||||||
"""Return whether Quick Start should ask for an API key."""
|
"""Return whether Quick Start should ask for an API key."""
|
||||||
return provider_name == "custom" or not (info and info.is_local)
|
return provider_name == "custom" or not (info and (info.is_local or info.is_oauth))
|
||||||
|
|
||||||
|
|
||||||
|
def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
|
||||||
|
"""Authenticate an OAuth provider supported by Quick Start."""
|
||||||
|
if provider_name != "openai_codex":
|
||||||
|
console.print(f"[red]OAuth login is not supported for {provider_name}[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||||
|
except ImportError:
|
||||||
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
proxy = resolve_config_env_vars(config).providers.openai_codex.proxy or None
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]{exc}[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
token = None
|
||||||
|
with suppress(Exception):
|
||||||
|
token = get_token(proxy=proxy)
|
||||||
|
if not (token and token.access):
|
||||||
|
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||||
|
try:
|
||||||
|
token = login_oauth_interactive(
|
||||||
|
print_fn=lambda message: console.print(message),
|
||||||
|
prompt_fn=lambda prompt: _get_questionary().text(prompt).ask() or "",
|
||||||
|
proxy=proxy,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f"[red]OAuth login failed: {exc}[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not (token and token.access):
|
||||||
|
console.print("[red]OAuth login failed[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
account = getattr(token, "account_id", None)
|
||||||
|
suffix = f" [dim]{account}[/dim]" if account else ""
|
||||||
|
console.print(f"[green]Authenticated with OpenAI Codex[/green]{suffix}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _quick_start_oauth_is_authenticated(config: Config, provider_name: str) -> bool:
|
||||||
|
"""Return whether Quick Start can load a usable OAuth token."""
|
||||||
|
if provider_name != "openai_codex":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from oauth_cli_kit import get_token
|
||||||
|
|
||||||
|
proxy = resolve_config_env_vars(config).providers.openai_codex.proxy or None
|
||||||
|
token = get_token(proxy=proxy)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return bool(token and token.access)
|
||||||
|
|
||||||
|
|
||||||
def _quick_start_requires_base_url(provider_name: str, info: _QuickStartProviderInfo | None) -> bool:
|
def _quick_start_requires_base_url(provider_name: str, info: _QuickStartProviderInfo | None) -> bool:
|
||||||
@@ -1710,7 +1777,11 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
|
|||||||
console.print(f"[red]Unknown provider: {provider_name}[/red]")
|
console.print(f"[red]Unknown provider: {provider_name}[/red]")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
model = _input_model_with_autocomplete("Model ID", "", provider_name)
|
model = _input_model_with_autocomplete(
|
||||||
|
"Model ID",
|
||||||
|
provider_info.default_model if provider_info else "",
|
||||||
|
provider_name,
|
||||||
|
)
|
||||||
if model is _BACK_PRESSED:
|
if model is _BACK_PRESSED:
|
||||||
continue
|
continue
|
||||||
model = (model or "").strip()
|
model = (model or "").strip()
|
||||||
@@ -1718,6 +1789,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
|
|||||||
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
|
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if provider_info and provider_info.is_oauth:
|
||||||
|
if not _quick_start_oauth_login(config, provider_name):
|
||||||
|
return False
|
||||||
|
|
||||||
if api_key is not None:
|
if api_key is not None:
|
||||||
provider_config.api_key = api_key
|
provider_config.api_key = api_key
|
||||||
if api_base:
|
if api_base:
|
||||||
@@ -1784,17 +1859,27 @@ def _show_quick_start_summary(config: Config) -> None:
|
|||||||
_show_quick_start_progress(3)
|
_show_quick_start_progress(3)
|
||||||
preset = config.model_presets.get("primary")
|
preset = config.model_presets.get("primary")
|
||||||
provider_label = "AI provider"
|
provider_label = "AI provider"
|
||||||
has_api_key = True
|
credentials_ready = True
|
||||||
|
credential_name = "API key"
|
||||||
if preset:
|
if preset:
|
||||||
provider_config = getattr(config.providers, preset.provider, None)
|
provider_config = getattr(config.providers, preset.provider, None)
|
||||||
provider_label, _is_gateway, is_local, _api_base = _get_provider_info().get(
|
provider_info = _get_quick_start_provider_info().get(preset.provider)
|
||||||
preset.provider, (preset.provider, False, False, "")
|
if provider_info:
|
||||||
)
|
provider_label = provider_info.display_name
|
||||||
has_api_key = is_local or bool(provider_config and provider_config.api_key)
|
if provider_info.is_oauth:
|
||||||
|
credential_name = "OAuth login"
|
||||||
|
credentials_ready = _quick_start_oauth_is_authenticated(config, preset.provider)
|
||||||
|
else:
|
||||||
|
credentials_ready = provider_info.is_local or bool(
|
||||||
|
provider_config and provider_config.api_key
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
provider_label = _get_provider_names().get(preset.provider, preset.provider)
|
||||||
|
credentials_ready = bool(provider_config and provider_config.api_key)
|
||||||
|
|
||||||
status = "Ready"
|
status = "Ready"
|
||||||
if not has_api_key:
|
if not credentials_ready:
|
||||||
status = f"{provider_label} API key missing"
|
status = f"{provider_label} {credential_name} missing"
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
("Status", status),
|
("Status", status),
|
||||||
|
|||||||
@@ -978,7 +978,14 @@ class TestMainMenuUpdate:
|
|||||||
expected_provider_names = set()
|
expected_provider_names = set()
|
||||||
seen_display_names: set[str] = set()
|
seen_display_names: set[str] = set()
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
if spec.name == "custom" or spec.is_oauth or spec.is_transcription_only:
|
if (
|
||||||
|
spec.name == "custom"
|
||||||
|
or spec.is_transcription_only
|
||||||
|
or (
|
||||||
|
spec.is_oauth
|
||||||
|
and spec.name not in onboard_wizard._QUICK_START_OAUTH_PROVIDERS
|
||||||
|
)
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if spec.display_name in seen_display_names:
|
if spec.display_name in seen_display_names:
|
||||||
continue
|
continue
|
||||||
@@ -988,9 +995,118 @@ class TestMainMenuUpdate:
|
|||||||
|
|
||||||
assert selected_provider_names == expected_provider_names
|
assert selected_provider_names == expected_provider_names
|
||||||
assert "assemblyai" not in selected_provider_names
|
assert "assemblyai" not in selected_provider_names
|
||||||
|
assert choices["OpenAI Codex"] == "openai_codex"
|
||||||
|
assert "github_copilot" not in selected_provider_names
|
||||||
assert choices["OpenCode Zen"] == "opencode"
|
assert choices["OpenCode Zen"] == "opencode"
|
||||||
assert choices[onboard_wizard._QUICK_START_CUSTOM_PROVIDER_CHOICE] == "custom"
|
assert choices[onboard_wizard._QUICK_START_CUSTOM_PROVIDER_CHOICE] == "custom"
|
||||||
|
|
||||||
|
def test_quick_start_openai_codex_uses_oauth_and_default_model(self, monkeypatch):
|
||||||
|
"""Codex should authenticate without asking for an API key."""
|
||||||
|
config = Config()
|
||||||
|
oauth_calls: list[tuple[Config, str]] = []
|
||||||
|
model_prompts: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_select_with_back",
|
||||||
|
lambda *args, **kwargs: "OpenAI Codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail_api_key_prompt(*_args, **_kwargs):
|
||||||
|
raise AssertionError("OpenAI Codex Quick Start should not ask for an API key")
|
||||||
|
|
||||||
|
def fake_model_input(prompt, current, provider):
|
||||||
|
model_prompts.append((prompt, current, provider))
|
||||||
|
return current
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_input_text", fail_api_key_prompt)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_input_model_with_autocomplete", fake_model_input)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_quick_start_oauth_login",
|
||||||
|
lambda selected_config, provider: oauth_calls.append(
|
||||||
|
(selected_config, provider)
|
||||||
|
)
|
||||||
|
or True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert onboard_wizard._configure_quick_start_provider(config) is True
|
||||||
|
|
||||||
|
assert oauth_calls == [(config, "openai_codex")]
|
||||||
|
assert model_prompts == [
|
||||||
|
("Model ID", "openai-codex/gpt-5.6-sol", "openai_codex")
|
||||||
|
]
|
||||||
|
assert config.providers.openai_codex.api_key is None
|
||||||
|
assert config.model_presets["primary"].provider == "openai_codex"
|
||||||
|
assert config.model_presets["primary"].model == "openai-codex/gpt-5.6-sol"
|
||||||
|
|
||||||
|
def test_quick_start_openai_codex_login_failure_does_not_create_preset(self, monkeypatch):
|
||||||
|
"""A failed Codex login must not leave a ready-looking model preset."""
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_select_with_back",
|
||||||
|
lambda *args, **kwargs: "OpenAI Codex",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_input_model_with_autocomplete",
|
||||||
|
lambda *args, **kwargs: "openai-codex/gpt-5.6-sol",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_quick_start_oauth_login", lambda *args: False)
|
||||||
|
|
||||||
|
assert onboard_wizard._configure_quick_start_provider(config) is False
|
||||||
|
assert "primary" not in config.model_presets
|
||||||
|
|
||||||
|
def test_quick_start_openai_codex_login_reuses_existing_token(self, monkeypatch):
|
||||||
|
"""Quick Start should not open a new login flow when Codex is already authenticated."""
|
||||||
|
import oauth_cli_kit
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
token = SimpleNamespace(access="existing-token", account_id="account-123")
|
||||||
|
login_calls: list[object] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(oauth_cli_kit, "get_token", lambda **kwargs: token)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
oauth_cli_kit,
|
||||||
|
"login_oauth_interactive",
|
||||||
|
lambda **kwargs: login_calls.append(kwargs),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(onboard_wizard.console, "print", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
|
assert onboard_wizard._quick_start_oauth_login(config, "openai_codex") is True
|
||||||
|
assert login_calls == []
|
||||||
|
|
||||||
|
def test_quick_start_summary_reports_missing_codex_oauth(self, monkeypatch):
|
||||||
|
"""The review step should distinguish OAuth from an API-key setup."""
|
||||||
|
config = Config()
|
||||||
|
config.model_presets["primary"] = ModelPresetConfig(
|
||||||
|
model="openai-codex/gpt-5.6-sol",
|
||||||
|
provider="openai_codex",
|
||||||
|
)
|
||||||
|
captured: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_quick_start_oauth_is_authenticated",
|
||||||
|
lambda *args: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_print_summary_panel",
|
||||||
|
lambda rows, _title: captured.setdefault("rows", rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
onboard_wizard._show_quick_start_summary(config)
|
||||||
|
|
||||||
|
rows = dict(captured["rows"])
|
||||||
|
assert rows["Status"] == "OpenAI Codex OAuth login missing"
|
||||||
|
assert rows["WebSocket channel"] == "enabled"
|
||||||
|
|
||||||
def test_quick_start_provider_choice_skips_advanced_prompts(self, monkeypatch):
|
def test_quick_start_provider_choice_skips_advanced_prompts(self, monkeypatch):
|
||||||
"""The beginner path should ask for provider credentials and model."""
|
"""The beginner path should ask for provider credentials and model."""
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
Reference in New Issue
Block a user