fix: make quick start provider neutral

Maintainer edit: restart the Quick Start flow around an API-key-first path without recommending OpenRouter or DeepSeek. Detect unique key prefixes locally, fall back to a user-provided base URL, and only fetch models from that approved URL.
This commit is contained in:
chengyongru
2026-06-22 13:04:05 +08:00
committed by Xubin Ren
parent ed3a8f64d8
commit 2319b660e6
5 changed files with 213 additions and 71 deletions
+6 -5
View File
@@ -304,15 +304,16 @@ Skip this step if you already configured provider and model settings in the wiza
`nanobot onboard --defaults` creates `~/.nanobot/config.json` and `~/.nanobot/workspace/`. Configure these **two parts** in the config file. Add or merge the following blocks into the existing file instead of replacing the whole file.
The example below uses [DeepSeek](https://platform.deepseek.com/api_keys) only so the JSON has concrete names and the compact path does not depend on OpenRouter. Provider examples are recipes, not rankings or endorsements. If you use another provider, replace the provider config key, API key, preset provider name, and model ID together.
The example below uses a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service. Provider examples are recipes, not rankings or endorsements. For copyable provider-specific setup, see [Provider Cookbook](./docs/provider-cookbook.md).
*Set your API key*:
```json
{
"providers": {
"deepseek": {
"apiKey": "your-deepseek-api-key"
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
}
}
@@ -325,8 +326,8 @@ The example below uses [DeepSeek](https://platform.deepseek.com/api_keys) only s
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "deepseek",
"model": "deepseek-v4-flash",
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 8192,
"contextWindowTokens": 65536,
"temperature": 0.1
+11 -9
View File
@@ -9,7 +9,7 @@ If you have never used a terminal or edited a config file before, use [`start-wi
You need:
- Python 3.11 or newer.
- One LLM provider, company endpoint, subscription endpoint, or local model server you can call. The examples below use DeepSeek only so the snippets are concrete and the compact path does not depend on OpenRouter; any supported provider works when the key, provider name, and model ID match.
- One LLM provider, company endpoint, subscription endpoint, or local model server you can call. The examples below use a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service; any supported provider works when the key, provider name, and model ID match.
- Git only if you install from source.
- Node.js or Bun only if you are developing the WebUI itself.
@@ -128,8 +128,9 @@ Open `~/.nanobot/config.json`. Add or merge these blocks into the file created b
```json
{
"providers": {
"deepseek": {
"apiKey": "your-deepseek-api-key"
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
}
}
@@ -142,8 +143,8 @@ Open `~/.nanobot/config.json`. Add or merge these blocks into the file created b
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "deepseek",
"model": "deepseek-v4-flash",
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 8192,
"contextWindowTokens": 65536,
"temperature": 0.1
@@ -161,7 +162,7 @@ The provider and model inside a preset must match. The snippet above is only an
| Replace | Where |
|---|---|
| Provider config key, such as `deepseek` | `providers.<provider>` |
| Provider config key, such as `custom` | `providers.<provider>` |
| API key or environment variable | `providers.<provider>.apiKey` |
| Preset provider name | `modelPresets.primary.provider` |
| Model ID | `modelPresets.primary.model` |
@@ -207,8 +208,9 @@ If you prefer not to store secrets in `config.json`, reference an environment va
```json
{
"providers": {
"deepseek": {
"apiKey": "${DEEPSEEK_API_KEY}"
"custom": {
"apiKey": "${PROVIDER_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
}
}
@@ -260,7 +262,7 @@ Example prompt:
```text
Read docs/quick-start.md, docs/providers.md, and docs/configuration.md in this checkout.
Then update ~/.nanobot/config.json to add a DeepSeek model preset named "primary".
Then update ~/.nanobot/config.json to add a model preset named "primary" for my provider.
Tell me exactly what changed and whether I need to run /restart.
```
+23 -22
View File
@@ -59,15 +59,14 @@ If `python3` works but `python` does not, replace `python` with `python3` in the
## 3. Get a Provider API Key
nanobot does not create AI accounts or API keys for you. Use an AI provider account, company endpoint, subscription endpoint, or local model server that you already control. The steps below use DeepSeek because it is the recommended beginner path in the wizard and avoids making OpenRouter a first-run dependency; it is not a ranking or endorsement.
nanobot does not create AI accounts or API keys for you. Use an AI provider account, company endpoint, subscription endpoint, or local model server that you already control. If the provider has an OpenAI-compatible base URL in its docs, keep that nearby too.
For the example path:
For the setup path:
1. Open [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys).
1. Open your provider's API key page.
2. Create or copy an API key.
3. Keep the key private.
Keep the key nearby because the setup wizard will ask you to paste it.
4. Keep the provider's base URL nearby if the provider docs show one.
## 4. Install nanobot
@@ -157,7 +156,7 @@ You will see a menu like this:
```text
> What would you like to do?
[Q] Quick Start (API key only)
[Q] Quick Start (API key first)
[A] Advanced Settings
[X] Exit
```
@@ -167,18 +166,18 @@ Move through the wizard like this:
| When you see | Do this |
|---|---|
| A menu | Use the arrow keys to highlight an option, then press `Enter`. |
| The DeepSeek API key field | Paste the key, then press `Enter`. |
| The API key field | Paste the key, then press `Enter`. |
| A base URL field | Paste the provider base URL from its docs, then press `Enter`. |
| A back option in Advanced Settings | Choose it to return to the previous menu. |
For the first setup, choose `[Q] Quick Start (API key only)`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a different provider, chat app, or tool setup.
For the first setup, choose `[Q] Quick Start (API key first)`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a different provider, chat app, or tool setup.
If you are following the DeepSeek example:
1. Choose `[Q] Quick Start (API key first)`.
2. Paste your API key.
3. If nanobot cannot recognize the key, paste the provider base URL from that provider's docs.
4. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
1. Choose `[Q] Quick Start (API key only)`.
2. Paste your DeepSeek API key.
3. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
The recommended path enables the local WebUI and default AI settings. You do not need to choose a model, endpoint, or chat channel for the first run.
The recommended path enables the local WebUI and default AI settings. You do not need to choose a chat channel for the first run.
If you already know that you need another provider, choose `Advanced Settings` instead of Quick Start. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
@@ -211,8 +210,9 @@ Merge them into one object:
```json
{
"providers": {
"deepseek": {
"apiKey": "your-deepseek-api-key"
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
},
"channels": {
@@ -256,15 +256,16 @@ If this is a brand-new install and you have not configured anything else yet, re
```json
{
"providers": {
"deepseek": {
"apiKey": "your-deepseek-api-key"
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
},
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "deepseek",
"model": "deepseek-v4-flash",
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
@@ -283,9 +284,9 @@ If this is a brand-new install and you have not configured anything else yet, re
}
```
Replace `your-deepseek-api-key` with your real DeepSeek key.
Replace `your-api-key`, `https://api.example.com/v1`, and `model-id-from-your-provider` with values from your provider.
If you use another provider, replace `deepseek`, `your-deepseek-api-key`, and the `model` value with that provider's values. If the provider needs `apiBase`, add it under that provider's config block.
For copyable provider-specific examples, use [`provider-cookbook.md`](./provider-cookbook.md).
Save the file.
+115 -24
View File
@@ -54,8 +54,9 @@ _BACK_PRESSED = object() # Sentinel value for back navigation
# offer existing presets as choices (e.g. AgentDefaults.model_preset).
_MODEL_PRESET_CACHE: set[str] = set()
_QUICK_START_RECOMMENDED_PROVIDER = "deepseek"
_QUICK_START_RECOMMENDED_MODEL = "deepseek-v4-flash"
_QUICK_START_DEFAULT_MODELS = {
"deepseek": "deepseek-v4-flash",
}
_QUICK_START_STEPS = ("API key", "WebUI", "Review")
@@ -388,7 +389,7 @@ def _show_main_menu_header() -> None:
body = Table.grid(expand=True)
body.add_column(ratio=1)
body.add_row(f"{__logo__} [bold {_UI_TEXT}]nanobot[/] [{_UI_MUTED}]v{__version__}[/]")
body.add_row(f"[{_UI_ACCENT}]Quick Start only needs a DeepSeek API key.[/]")
body.add_row(f"[{_UI_ACCENT}]Quick Start starts with one API key.[/]")
body.add_row(
f"[{_UI_MUTED}]Use Advanced later for other providers or chat apps.[/]"
)
@@ -1410,36 +1411,126 @@ def _show_quick_start_progress(active_step: int) -> None:
console.print()
def _configure_recommended_provider(config: Config) -> bool:
"""Configure the beginner path provider with one API-key prompt."""
def _detect_quick_start_provider_from_key(api_key: str) -> str | None:
"""Return a provider when the API key prefix identifies exactly one provider."""
from nanobot.providers.registry import PROVIDERS
matches = [
spec.name
for spec in PROVIDERS
if spec.detect_by_key_prefix
and api_key.startswith(spec.detect_by_key_prefix)
and not spec.is_oauth
and not spec.is_transcription_only
]
return matches[0] if len(matches) == 1 else None
def _detect_quick_start_provider_from_base(api_base: str) -> str | None:
"""Return a provider when the user-provided base URL matches registry metadata."""
from nanobot.providers.registry import PROVIDERS
normalized = api_base.rstrip("/").lower()
for spec in PROVIDERS:
if spec.is_oauth or spec.is_transcription_only:
continue
default_base = spec.default_api_base.rstrip("/").lower()
if default_base and (normalized == default_base or normalized.startswith(default_base + "/")):
return spec.name
keyword = spec.detect_by_base_keyword.lower()
if keyword and keyword in normalized:
return spec.name
return None
def _models_url(api_base: str) -> str:
"""Return the OpenAI-compatible models endpoint for a base URL."""
return f"{api_base.rstrip('/')}/models"
def _fetch_first_quick_start_model(api_base: str, api_key: str) -> str | None:
"""Fetch the first model ID from a user-approved OpenAI-compatible base URL."""
import httpx
try:
response = httpx.get(
_models_url(api_base),
headers={"Authorization": f"Bearer {api_key}"},
timeout=8.0,
follow_redirects=True,
)
except httpx.HTTPError:
return None
if response.status_code != 200:
return None
try:
payload = response.json()
except ValueError:
return None
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list):
return None
for item in data:
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].strip():
return item["id"].strip()
return None
def _configure_quick_start_provider(config: Config) -> bool:
"""Configure the beginner path from one API key plus base URL fallback."""
_show_quick_start_progress(1)
provider_name = _QUICK_START_RECOMMENDED_PROVIDER
api_key = _input_text("API key", "", "str")
if api_key is None:
return False
api_key = api_key.strip()
if not api_key:
console.print("[yellow]! API key is required for Quick Start[/yellow]")
return False
provider_name = _detect_quick_start_provider_from_key(api_key)
provider_info = _get_provider_info()
api_base = ""
if provider_name:
_display, _is_gateway, _is_local, api_base = provider_info.get(
provider_name, (provider_name, False, False, "")
)
else:
base_answer = _input_text(
"Provider base URL (only this URL will be tested)",
"",
"str",
)
if base_answer is None:
return False
api_base = base_answer.strip().rstrip("/")
if not api_base:
console.print("[yellow]! Provider base URL is required when the key is not recognized[/yellow]")
return False
provider_name = _detect_quick_start_provider_from_base(api_base) or "custom"
provider_config = getattr(config.providers, provider_name, None)
if provider_config is None:
console.print(f"[red]Unknown provider: {provider_name}[/red]")
return False
_display, _is_gateway, _is_local, default_api_base = _get_provider_info().get(
provider_name, (provider_name, False, False, "")
)
provider_config.api_key = api_key
if api_base and not provider_config.api_base:
provider_config.api_base = api_base
api_key = _input_with_existing(
"DeepSeek API key (get one at https://platform.deepseek.com/api_keys)",
provider_config.api_key,
"str",
)
if api_key is not None:
provider_config.api_key = api_key.strip() or None
if not provider_config.api_key:
console.print("[yellow]! DeepSeek API key is required for Quick Start[/yellow]")
model = _QUICK_START_DEFAULT_MODELS.get(provider_name)
if not model and provider_config.api_base:
model = _fetch_first_quick_start_model(provider_config.api_base, api_key)
if not model:
model = _input_model_with_autocomplete("Model ID", "", provider_name)
if not model or not model.strip():
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
return False
if default_api_base and not provider_config.api_base:
provider_config.api_base = default_api_base
_set_primary_quick_start_preset(
config,
provider_name,
_QUICK_START_RECOMMENDED_MODEL,
model.strip(),
)
return True
@@ -1495,7 +1586,7 @@ def _configure_quick_start(config: Config) -> bool:
"Quick Start",
"Paste one API key. nanobot will use recommended local WebUI defaults.",
)
if not _configure_recommended_provider(config):
if not _configure_quick_start_provider(config):
_pause()
return False
if not _enable_quick_start_websocket_defaults(config):
@@ -1540,7 +1631,7 @@ def _prompt_main_menu_exit(has_unsaved_changes: bool) -> str:
def _get_main_menu_choices(has_unsaved_changes: bool) -> list[str]:
"""Return the top-level choices, keeping save actions hidden until needed."""
choices = [
"[Q] Quick Start (API key only)",
"[Q] Quick Start (API key first)",
"[A] Advanced Settings",
]
if has_unsaved_changes:
@@ -1645,7 +1736,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
return OnboardResult(config=original_config, should_save=False)
continue
if answer == "[Q] Quick Start (API key only)":
if answer == "[Q] Quick Start (API key first)":
if _configure_quick_start(config):
return OnboardResult(config=config, should_save=True)
continue
+58 -11
View File
@@ -866,7 +866,7 @@ class TestMainMenuUpdate:
dirty_choices = _get_main_menu_choices(True)
assert clean_choices == [
"[Q] Quick Start (API key only)",
"[Q] Quick Start (API key first)",
"[A] Advanced Settings",
"[X] Exit",
]
@@ -880,7 +880,7 @@ class TestMainMenuUpdate:
initial_config = Config()
responses = iter([
"[Q] Quick Start (API key only)",
"[Q] Quick Start (API key first)",
])
class FakePrompt:
@@ -906,21 +906,22 @@ class TestMainMenuUpdate:
assert result.should_save is True
assert result.config.agents.defaults.bot_name == "quickbot"
def test_quick_start_recommended_webui_skips_advanced_prompts(self, monkeypatch):
"""The beginner path should only ask for the API key and use safe defaults."""
def test_quick_start_base_url_fallback_skips_advanced_prompts(self, monkeypatch):
"""The beginner path should ask for a base URL only when the key is not recognized."""
config = Config()
text_answers = iter(["sk-ds-test", "https://api.deepseek.com"])
def fail_model_input(*_args, **_kwargs):
raise AssertionError("recommended Quick Start should not ask for a model ID")
raise AssertionError("Quick Start should not ask for a model ID when defaults are known")
def fail_websocket_config(*_args, **_kwargs):
raise AssertionError("recommended Quick Start should not open WebSocket settings")
raise AssertionError("Quick Start should not open WebSocket settings")
pause_messages: list[str] = []
monkeypatch.setattr(onboard_wizard.console, "clear", lambda: None)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_input_with_existing", lambda *a, **kw: "sk-ds-test")
monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: next(text_answers))
monkeypatch.setattr(onboard_wizard, "_input_model_with_autocomplete", fail_model_input)
monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fail_websocket_config)
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None)
@@ -933,29 +934,75 @@ class TestMainMenuUpdate:
assert config.providers.deepseek.api_base == "https://api.deepseek.com"
assert config.agents.defaults.model_preset == "primary"
assert config.model_presets["primary"].provider == "deepseek"
assert config.model_presets["primary"].model == onboard_wizard._QUICK_START_RECOMMENDED_MODEL
assert config.model_presets["primary"].model == onboard_wizard._QUICK_START_DEFAULT_MODELS["deepseek"]
websocket = getattr(config.channels, "websocket")
assert websocket["enabled"] is True
assert websocket["websocketRequiresToken"] is True
def test_quick_start_detects_provider_from_key_prefix(self, monkeypatch):
"""Unique key prefixes should identify the provider without asking for base URL."""
config = Config()
prompts: list[str] = []
def fake_input_text(prompt, *_args, **_kwargs):
prompts.append(prompt)
return "sk-or-test"
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None)
monkeypatch.setattr(onboard_wizard, "_input_text", fake_input_text)
monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", lambda *a, **kw: "openai/gpt-4o-mini")
assert onboard_wizard._configure_quick_start_provider(config) is True
assert prompts == ["API key"]
assert config.providers.openrouter.api_key == "sk-or-test"
assert config.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert config.model_presets["primary"].provider == "openrouter"
assert config.model_presets["primary"].model == "openai/gpt-4o-mini"
def test_quick_start_custom_base_url_fetches_first_model(self, monkeypatch):
"""Unknown providers should use only the user-provided base URL to fetch models."""
config = Config()
text_answers = iter(["sk-custom-test", "https://api.example.test/v1"])
calls: dict[str, str] = {}
def fake_fetch(api_base, api_key):
calls["api_base"] = api_base
calls["api_key"] = api_key
return "custom-model"
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None)
monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: next(text_answers))
monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", fake_fetch)
assert onboard_wizard._configure_quick_start_provider(config) is True
assert calls == {"api_base": "https://api.example.test/v1", "api_key": "sk-custom-test"}
assert config.providers.custom.api_key == "sk-custom-test"
assert config.providers.custom.api_base == "https://api.example.test/v1"
assert config.model_presets["primary"].provider == "custom"
assert config.model_presets["primary"].model == "custom-model"
def test_quick_start_requires_api_key_before_setting_defaults(self, monkeypatch):
"""Quick Start should not create a ready-looking config without an API key."""
config = Config()
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None)
monkeypatch.setattr(onboard_wizard, "_input_with_existing", lambda *a, **kw: "")
monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: "")
assert onboard_wizard._configure_recommended_provider(config) is False
assert onboard_wizard._configure_quick_start_provider(config) is False
assert config.providers.deepseek.api_key is None
assert config.providers.deepseek.api_base is None
assert config.providers.custom.api_key is None
assert config.providers.custom.api_base is None
assert "primary" not in config.model_presets
def test_quick_start_summary_calls_out_missing_api_key(self, monkeypatch):
"""Quick Start summary should not tell users to run gateway before adding a key."""
config = Config()
config.model_presets["primary"] = ModelPresetConfig(
model=onboard_wizard._QUICK_START_RECOMMENDED_MODEL,
model=onboard_wizard._QUICK_START_DEFAULT_MODELS["deepseek"],
provider="deepseek",
)