feat(onboarding): remove OpenRouter default, require explicit provider selection

Breaking changes:
- Quick setup now requires --provider flag (no default)
- TUI wizard requires provider selection (no pre-selection)
- Docker compose requires PROVIDER env var
- .env.example no longer defaults to openrouter

Changes:
- wizard.rs: Remove hardcoded "openrouter" defaults, require explicit provider
- tui.rs: Add provider placeholder, require selection before proceeding
- .env.example: Use provider-neutral placeholders
- docker-compose.yml: Require PROVIDER to be explicitly set
- docs: Update examples to be provider-agnostic

This makes ZeroClaw truly provider-agnostic - users must choose
their preferred LLM provider rather than being pushed toward OpenRouter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
argenis de la rosa 2026-03-05 15:01:40 -05:00
parent 1c0a3de4c0
commit b313ce9bcb
8 changed files with 119 additions and 106 deletions

View File

@ -12,10 +12,11 @@
API_KEY=your-api-key-here
# ZEROCLAW_API_KEY=your-api-key-here
# Default provider/model (can be overridden by CLI flags)
PROVIDER=openrouter
# ZEROCLAW_PROVIDER=openrouter
# ZEROCLAW_MODEL=anthropic/claude-sonnet-4-6
# Default provider/model (required - choose one)
# Options: openrouter, openai, anthropic, gemini, ollama, groq, mistral, deepseek, xai, and more
# PROVIDER=your-provider-here
# ZEROCLAW_PROVIDER=your-provider-here
# ZEROCLAW_MODEL=your-model-here
# ZEROCLAW_TEMPERATURE=0.7
# Workspace directory override

View File

@ -21,9 +21,9 @@ services:
# Or use the prefixed version:
# - ZEROCLAW_API_KEY=${ZEROCLAW_API_KEY:-}
# Optional: LLM provider (default: openrouter)
# Options: openrouter, openai, anthropic, ollama
- PROVIDER=${PROVIDER:-openrouter}
# Required: LLM provider (must be set)
# Options: openrouter, openai, anthropic, gemini, ollama, groq, mistral, deepseek, xai
- PROVIDER=${PROVIDER:?PROVIDER is required - set to openrouter, openai, anthropic, gemini, ollama, etc}
# Allow public bind inside Docker (required for container networking)
- ZEROCLAW_ALLOW_PUBLIC_BIND=true

View File

@ -14,16 +14,18 @@ For first-time setup and quick orientation.
| Scenario | Command |
|----------|---------|
| I have an API key, want fastest setup | `zeroclaw onboard --api-key sk-... --provider openrouter` |
| I want guided prompts | `zeroclaw onboard --interactive` |
| I have an API key, want fastest setup | `zeroclaw onboard --api-key sk-... --provider <provider>` |
| I want guided prompts (TUI wizard) | `zeroclaw onboard --interactive` |
| Config exists, just fix channels | `zeroclaw onboard --channels-only` |
| Config exists, I intentionally want full overwrite | `zeroclaw onboard --force` |
| Using OpenAI Codex subscription auth | See [OpenAI Codex OAuth Quick Setup](#openai-codex-oauth-quick-setup) |
**Supported providers:** openrouter, openai, anthropic, gemini, ollama, groq, mistral, deepseek, xai, together-ai, fireworks, perplexity, cohere, and more.
## Onboarding and Validation
- Quick onboarding: `zeroclaw onboard --api-key "sk-..." --provider openrouter`
- Interactive onboarding: `zeroclaw onboard --interactive`
- Quick onboarding: `zeroclaw onboard --api-key "sk-..." --provider <provider>`
- Interactive onboarding (recommended): `zeroclaw onboard --interactive`
- Existing config protection: reruns require explicit confirmation (or `--force` in non-interactive flows)
- Ollama cloud models (`:cloud`) require a remote `api_url` and API key (for example `api_url = "https://ollama.com"`).
- Validate environment: `zeroclaw status` + `zeroclaw doctor`

View File

@ -119,22 +119,24 @@ it pulls `ghcr.io/zeroclaw-labs/zeroclaw:latest` and tags it locally before runn
### Quick onboarding (non-interactive)
```bash
./bootstrap.sh --onboard --api-key "sk-..." --provider openrouter
# Replace <provider> with your choice: openrouter, openai, anthropic, gemini, ollama, etc.
./install.sh --onboard --api-key "sk-..." --provider <provider>
```
Or with environment variables:
```bash
ZEROCLAW_API_KEY="sk-..." ZEROCLAW_PROVIDER="openrouter" ./bootstrap.sh --onboard
# Replace <provider> with your choice
ZEROCLAW_API_KEY="sk-..." ZEROCLAW_PROVIDER="<provider>" ./install.sh --onboard
```
### Interactive onboarding
```bash
./bootstrap.sh --interactive-onboard
./install.sh --interactive-onboard
```
This launches the full-screen TUI onboarding flow (`zeroclaw onboard --interactive-ui`).
This launches the full-screen TUI onboarding flow (`zeroclaw onboard --interactive`) where you can select your provider interactively.
## Useful flags

View File

@ -26,7 +26,7 @@ use std::io::{self, IsTerminal};
use std::path::PathBuf;
use std::time::Duration;
const PROVIDER_OPTIONS: [&str; 5] = ["openrouter", "openai", "anthropic", "gemini", "ollama"];
const PROVIDER_OPTIONS: [&str; 6] = ["(select provider)", "openrouter", "openai", "anthropic", "gemini", "ollama"];
const MEMORY_OPTIONS: [&str; 4] = ["sqlite", "lucid", "markdown", "none"];
const TUNNEL_OPTIONS: [&str; 3] = ["none", "cloudflare", "ngrok"];
@ -228,13 +228,12 @@ struct TuiOnboardPlan {
impl TuiOnboardPlan {
fn new(default_workspace: PathBuf, force: bool) -> Self {
let provider = PROVIDER_OPTIONS[0];
Self {
workspace_path: default_workspace.display().to_string(),
force_overwrite: force,
provider_idx: 0,
provider_idx: 0, // Start with placeholder - user must select
api_key: String::new(),
model: provider_default_model(provider),
model: String::new(), // No default model until provider is selected
memory_idx: 0,
disable_totp: false,
enable_telegram: false,
@ -254,7 +253,16 @@ impl TuiOnboardPlan {
}
fn provider(&self) -> &str {
PROVIDER_OPTIONS[self.provider_idx]
let idx = self.provider_idx;
if idx == 0 {
"(select provider)"
} else {
PROVIDER_OPTIONS[idx]
}
}
fn provider_selected(&self) -> bool {
self.provider_idx > 0
}
fn memory_backend(&self) -> &str {
@ -765,6 +773,9 @@ impl TuiState {
Ok(())
}
Step::Provider => {
if !self.plan.provider_selected() {
bail!("Provider selection is required. Use arrow keys to select a provider.")
}
if self.plan.model.trim().is_empty() {
bail!("Default model is required")
}

View File

@ -477,8 +477,8 @@ fn apply_provider_update(
// ── Quick setup (zero prompts) ───────────────────────────────────
/// Non-interactive setup: generates a sensible default config instantly.
/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid|cortex-mem`.
/// Use `zeroclaw onboard --interactive` for the full wizard.
/// Use `zeroclaw onboard --api-key sk-... --provider <provider> --memory sqlite|lucid|cortex-mem`.
/// Use `zeroclaw onboard --interactive` for the full wizard with provider selection.
fn backend_key_from_choice(choice: usize) -> &'static str {
selectable_memory_backends()
.get(choice)
@ -741,7 +741,12 @@ async fn run_quick_setup_with_home(
.await
.context("Failed to create workspace directory")?;
let provider_name = provider.unwrap_or("openrouter").to_string();
let provider_name = provider
.ok_or_else(|| anyhow::anyhow!(
"Provider is required. Use --provider <name> or run with --interactive for the TUI wizard.\n\
\nSupported providers: openrouter, openai, anthropic, gemini, ollama, groq, mistral, deepseek, xai, and more."
))?
.to_string();
let model = model_override
.map(str::to_string)
.unwrap_or_else(|| default_model_for_provider(&provider_name));
@ -2200,11 +2205,15 @@ pub async fn run_models_refresh(
provider_override: Option<&str>,
force: bool,
) -> Result<()> {
let provider_name = provider_override
let provider_name = match provider_override
.or(config.default_provider.as_deref())
.unwrap_or("openrouter")
.trim()
.to_string();
{
Some(p) => p.trim().to_string(),
None => anyhow::bail!(
"No provider configured. Run `zeroclaw onboard --interactive` or set a provider with `--provider <name>`.\n\
\nSupported providers: openrouter, openai, anthropic, gemini, ollama, groq, mistral, deepseek, xai, and more."
),
};
if provider_name.is_empty() {
anyhow::bail!("Provider name cannot be empty");
@ -2284,11 +2293,17 @@ pub async fn run_models_refresh(
}
pub async fn run_models_list(config: &Config, provider_override: Option<&str>) -> Result<()> {
let provider_name = provider_override
let provider_name = match provider_override
.or(config.default_provider.as_deref())
.unwrap_or("openrouter");
{
Some(p) => p.to_string(),
None => anyhow::bail!(
"No provider configured. Run `zeroclaw onboard --interactive` or specify `--provider <name>`.\n\
\nSupported providers: openrouter, openai, anthropic, gemini, ollama, groq, mistral, deepseek, xai, and more."
),
};
let cached = load_any_cached_models_for_provider(&config.workspace_dir, provider_name).await?;
let cached = load_any_cached_models_for_provider(&config.workspace_dir, &provider_name).await?;
let Some(cached) = cached else {
println!();
@ -2336,7 +2351,7 @@ pub async fn run_models_set(config: &Config, model: &str) -> Result<()> {
}
pub async fn run_models_status(config: &Config) -> Result<()> {
let provider = config.default_provider.as_deref().unwrap_or("openrouter");
let provider = config.default_provider.as_deref().unwrap_or("(not set)");
let model = config.default_model.as_deref().unwrap_or("(not set)");
println!();
@ -6794,7 +6809,7 @@ fn print_summary(config: &Config) {
println!(
" {} Provider: {}",
style("🤖").cyan(),
config.default_provider.as_deref().unwrap_or("openrouter")
config.default_provider.as_deref().unwrap_or("(not set)")
);
println!(
" {} Model: {}",
@ -6914,57 +6929,59 @@ fn print_summary(config: &Config) {
let mut step = 1u8;
let provider = config.default_provider.as_deref().unwrap_or("openrouter");
let canonical_provider = canonical_provider_name(provider);
if config.api_key.is_none() && !provider_supports_keyless_local_usage(provider) {
if canonical_provider == "copilot" {
println!(
" {} Authenticate GitHub Copilot:",
style(format!("{step}.")).cyan().bold()
);
println!(" {}", style("zeroclaw agent -m \"Hello!\"").yellow());
println!(
" {}",
style("(device/OAuth prompt appears automatically on first run)").dim()
);
} else if canonical_provider == "openai-codex" {
println!(
" {} Authenticate OpenAI Codex:",
style(format!("{step}.")).cyan().bold()
);
println!(
" {}",
style("zeroclaw auth login --provider openai-codex --device-code").yellow()
);
} else if canonical_provider == "anthropic" {
println!(
" {} Configure Anthropic auth:",
style(format!("{step}.")).cyan().bold()
);
println!(
" {}",
style("export ANTHROPIC_API_KEY=\"sk-ant-...\"").yellow()
);
println!(
" {}",
style(
"or: zeroclaw auth paste-token --provider anthropic --auth-kind authorization"
)
.yellow()
);
} else {
let env_var = provider_env_var(provider);
println!(
" {} Set your API key:",
style(format!("{step}.")).cyan().bold()
);
println!(
" {}",
style(format!("export {env_var}=\"sk-...\"")).yellow()
);
let provider = config.default_provider.as_deref();
let canonical_provider = provider.map(canonical_provider_name);
if let (Some(p), Some(cp)) = (provider, canonical_provider) {
if config.api_key.is_none() && !provider_supports_keyless_local_usage(p) {
if cp == "copilot" {
println!(
" {} Authenticate GitHub Copilot:",
style(format!("{step}.")).cyan().bold()
);
println!(" {}", style("zeroclaw agent -m \"Hello!\"").yellow());
println!(
" {}",
style("(device/OAuth prompt appears automatically on first run)").dim()
);
} else if cp == "openai-codex" {
println!(
" {} Authenticate OpenAI Codex:",
style(format!("{step}.")).cyan().bold()
);
println!(
" {}",
style("zeroclaw auth login --provider openai-codex --device-code").yellow()
);
} else if cp == "anthropic" {
println!(
" {} Configure Anthropic auth:",
style(format!("{step}.")).cyan().bold()
);
println!(
" {}",
style("export ANTHROPIC_API_KEY=\"sk-ant-...\"").yellow()
);
println!(
" {}",
style(
"or: zeroclaw auth paste-token --provider anthropic --auth-kind authorization"
)
.yellow()
);
} else {
let env_var = provider_env_var(p);
println!(
" {} Set your API key:",
style(format!("{step}.")).cyan().bold()
);
println!(
" {}",
style(format!("export {env_var}=\"sk-...\"")).yellow()
);
}
println!();
step += 1;
}
println!();
step += 1;
}
// If channels are configured, show channel start as the primary next step

View File

@ -1,5 +1,5 @@
import { useLocation } from 'react-router-dom';
import { LogOut, Menu, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { LogOut, Menu } from 'lucide-react';
import { t, LANGUAGE_BUTTON_LABELS, LANGUAGE_SWITCH_ORDER } from '@/lib/i18n';
import { useLocaleContext } from '@/App';
import { useAuth } from '@/hooks/useAuth';
@ -21,16 +21,10 @@ const routeTitles: Record<string, string> = {
const languageSummary = 'English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά';
interface HeaderProps {
isSidebarCollapsed: boolean;
onToggleSidebar: () => void;
onToggleSidebarCollapse: () => void;
}
export default function Header({
isSidebarCollapsed,
onToggleSidebar,
onToggleSidebarCollapse,
}: HeaderProps) {
export default function Header({ onToggleSidebar }: HeaderProps) {
const location = useLocation();
const { logout } = useAuth();
const { locale, setAppLocale } = useLocaleContext();
@ -64,22 +58,12 @@ export default function Header({
{pageTitle}
</h1>
<p className="hidden text-[10px] uppercase tracking-[0.16em] text-[#7ea5eb] sm:block">
Electric dashboard
ZeroClaw dashboard
</p>
</div>
</div>
<div className="relative flex w-full items-center justify-end gap-1.5 sm:gap-2 md:w-auto md:gap-3">
<button
type="button"
onClick={onToggleSidebarCollapse}
className="hidden items-center gap-1 rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1.5 text-xs text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white md:flex md:text-sm"
title={isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
<span>{isSidebarCollapsed ? 'Expand' : 'Collapse'}</span>
</button>
<button
type="button"
onClick={toggleLanguage}

View File

@ -39,11 +39,7 @@ export default function Layout() {
sidebarCollapsed ? 'md:ml-[6.25rem]' : 'md:ml-[17.5rem]',
].join(' ')}
>
<Header
isSidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarOpen((open) => !open)}
onToggleSidebarCollapse={toggleSidebarCollapsed}
/>
<Header onToggleSidebar={() => setSidebarOpen((open) => !open)} />
<main className="flex-1 overflow-y-auto px-4 pb-8 pt-5 md:px-8 md:pt-8">
<Outlet />