diff --git a/README.md b/README.md index b32f74315ec..6ccb7cac398 100644 --- a/README.md +++ b/README.md @@ -109,20 +109,30 @@ One TOML file at `~/.zeroclaw/config.toml`. Pointers: A V3 config has at minimum four section headers (`.` shaped) — a provider entry, an agent that references it, and a risk profile the agent gates against. See [Provider Configuration → Minimal working example](docs/book/src/providers/configuration.md#minimal-working-example) for the canonical four-section form with inline type/alias commentary. -For standard OpenAI Codex subscription auth, swap the provider entry to: +For standard OpenAI Codex subscription auth, Quickstart can write the provider +entry for you: + +```bash +zeroclaw auth login --model-provider openai-codex --import ~/.codex/auth.json # if already signed in with Codex CLI +zeroclaw quickstart --model-provider openai-codex --model gpt-5.4 +``` + +The provider entry uses the canonical OpenAI shape; the alias below is an +example: ```toml [providers.models.openai.coding] # type = openai; alias = coding (you choose) -model = "gpt-5-codex" +model = "gpt-5.4" wire_api = "responses" requires_openai_auth = true ``` -…and point your agent at it with `model_provider = "openai.coding"`. +…and point your agent at it with `model_provider = "openai."`. Notes: - Normal OpenAI Codex subscription auth uses stored auth profiles, not an `api_key` on the provider entry. +- Claude Max setup-token auth stays on the canonical Anthropic slot: run `claude setup-token`, choose `setup_token` in Quickstart, and paste the generated token into the API key/token prompt. - Only set `api_key` / `uri` on `[providers.models.openai.]` when intentionally targeting a custom OpenAI-compatible gateway or endpoint. - If you see `provider streaming failed, falling back to non-streaming chat`, ZeroClaw retries the same request in non-streaming mode. Check `zeroclaw auth status` before changing provider config. diff --git a/apps/zerocode/src/quickstart_pane.rs b/apps/zerocode/src/quickstart_pane.rs index 74807fcb884..69a43eb03d6 100644 --- a/apps/zerocode/src/quickstart_pane.rs +++ b/apps/zerocode/src/quickstart_pane.rs @@ -949,10 +949,9 @@ impl QuickstartPane { pub fn wants_text_input(&self) -> bool { match self.active_modal.as_ref() { Some(Modal::TextInput(_)) => true, - Some(Modal::FieldForm(f)) => f - .fields - .get(f.cursor) - .is_some_and(|row| field_row_variants(row).is_none()), + Some(Modal::FieldForm(f)) => f.fields.get(f.cursor).is_some_and(|row| { + field_form_row_visible(f, f.cursor) && field_row_variants(row).is_none() + }), Some(Modal::Agent(a)) => a.editor.is_some() || a.cursor == 0, _ => false, } @@ -1055,7 +1054,8 @@ impl QuickstartPane { match modal { Modal::TextInput(t) => t.buf.push_str(text), Modal::FieldForm(f) => { - if let Some(row) = f.fields.get_mut(f.cursor) + if field_form_row_visible(f, f.cursor) + && let Some(row) = f.fields.get_mut(f.cursor) && row.descriptor.enum_variants.is_none() { row.buf.push_str(text); @@ -1168,9 +1168,17 @@ impl QuickstartPane { editor.scroll_lines(delta); return; } + if let Modal::FieldForm(f) = modal { + if delta >= 0 { + move_field_form_cursor(f, 1); + } else { + move_field_form_cursor(f, -1); + } + return; + } let (cur, len) = match modal { Modal::Picker(p) => (&mut p.cursor, p.options.len()), - Modal::FieldForm(f) => (&mut f.cursor, f.fields.len()), + Modal::FieldForm(_) => unreachable!("handled above"), Modal::ChannelList(cl) => (&mut cl.cursor, self.modal_row_rects.len()), Modal::PeerGroupList(pl) => (&mut pl.cursor, self.modal_row_rects.len()), Modal::Agent(a) => (&mut a.cursor, self.modal_row_rects.len()), @@ -1198,6 +1206,7 @@ impl QuickstartPane { Modal::FieldForm(f) => { if idx < f.fields.len() { f.cursor = idx; + normalize_field_form_cursor(f); } } Modal::ChannelList(cl) => { @@ -1455,105 +1464,100 @@ impl QuickstartPane { } } }, - Modal::FieldForm(f) => match action { - Some(QuickstartModalAction::Cancel) => { - self.active_modal = None; - } - Some(QuickstartModalAction::NextField) | Some(QuickstartModalAction::Down) => { - if f.cursor + 1 < f.fields.len() { - f.cursor += 1; - } else { - f.cursor = 0; - } - } - Some(QuickstartModalAction::PrevField) | Some(QuickstartModalAction::Up) => { - if f.cursor == 0 { - f.cursor = f.fields.len().saturating_sub(1); - } else { - f.cursor -= 1; - } - } - Some(QuickstartModalAction::Confirm) => { - if f.cursor + 1 < f.fields.len() { - f.cursor += 1; - return; + Modal::FieldForm(f) => { + normalize_field_form_cursor(f); + match action { + Some(QuickstartModalAction::Cancel) => { + self.active_modal = None; } - let selector = f.selector; - if !self.commit_field_form() { - return; + Some(QuickstartModalAction::NextField) | Some(QuickstartModalAction::Down) => { + move_field_form_cursor(f, 1); } - let from_channel = matches!( - self.active_modal.as_ref(), - Some(Modal::FieldForm(f)) if f.selector == Selector::Channels - ); - if from_channel { - self.active_modal = - Some(Modal::ChannelList(ChannelListModal { cursor: 0 })); - } else { - self.active_modal = None; - self.advance_after_completed(selector); + Some(QuickstartModalAction::PrevField) | Some(QuickstartModalAction::Up) => { + move_field_form_cursor(f, -1); } - self.revalidate().await; - } - Some(QuickstartModalAction::Left) => { - let variants = f - .fields - .get(f.cursor) - .and_then(field_row_variants) - .map(|v| v.to_vec()); - if let (Some(row), Some(variants)) = (f.fields.get_mut(f.cursor), variants) - && !variants.is_empty() - { - let cur = variants.iter().position(|v| v == &row.buf).unwrap_or(0); - let next = if cur == 0 { - variants.len() - 1 + Some(QuickstartModalAction::Confirm) => { + if !field_form_cursor_is_last_visible(f) { + move_field_form_cursor(f, 1); + return; + } + let selector = f.selector; + if !self.commit_field_form() { + return; + } + let from_channel = matches!( + self.active_modal.as_ref(), + Some(Modal::FieldForm(f)) if f.selector == Selector::Channels + ); + if from_channel { + self.active_modal = + Some(Modal::ChannelList(ChannelListModal { cursor: 0 })); } else { - cur - 1 - }; - row.buf = variants[next].clone(); + self.active_modal = None; + self.advance_after_completed(selector); + } + self.revalidate().await; } - } - Some(QuickstartModalAction::Right) => { - let variants = f - .fields - .get(f.cursor) - .and_then(field_row_variants) - .map(|v| v.to_vec()); - if let (Some(row), Some(variants)) = (f.fields.get_mut(f.cursor), variants) - && !variants.is_empty() - { - let cur = variants.iter().position(|v| v == &row.buf).unwrap_or(0); - let next = (cur + 1) % variants.len(); - row.buf = variants[next].clone(); + Some(QuickstartModalAction::Left) => { + let variants = f + .fields + .get(f.cursor) + .and_then(field_row_variants) + .map(|v| v.to_vec()); + if let (Some(row), Some(variants)) = (f.fields.get_mut(f.cursor), variants) + && !variants.is_empty() + { + let cur = variants.iter().position(|v| v == &row.buf).unwrap_or(0); + let next = if cur == 0 { + variants.len() - 1 + } else { + cur - 1 + }; + row.buf = variants[next].clone(); + } } - } - Some(QuickstartModalAction::Backspace) => { - let is_enum = f - .fields - .get(f.cursor) - .and_then(field_row_variants) - .is_some(); - if let Some(row) = f.fields.get_mut(f.cursor) - && !is_enum - { - row.buf.pop(); + Some(QuickstartModalAction::Right) => { + let variants = f + .fields + .get(f.cursor) + .and_then(field_row_variants) + .map(|v| v.to_vec()); + if let (Some(row), Some(variants)) = (f.fields.get_mut(f.cursor), variants) + && !variants.is_empty() + { + let cur = variants.iter().position(|v| v == &row.buf).unwrap_or(0); + let next = (cur + 1) % variants.len(); + row.buf = variants[next].clone(); + } } - } - _ => { - let is_enum = f - .fields - .get(f.cursor) - .and_then(field_row_variants) - .is_some(); - if let KeyCode::Char(c) = key.code - && !key.modifiers.contains(KeyModifiers::CONTROL) - && let Some(row) = f.fields.get_mut(f.cursor) - && !is_enum - { - row.buf.push(c); + Some(QuickstartModalAction::Backspace) => { + let is_enum = f + .fields + .get(f.cursor) + .and_then(field_row_variants) + .is_some(); + if let Some(row) = f.fields.get_mut(f.cursor) + && !is_enum + { + row.buf.pop(); + } + } + _ => { + let is_enum = f + .fields + .get(f.cursor) + .and_then(field_row_variants) + .is_some(); + if let KeyCode::Char(c) = key.code + && !key.modifiers.contains(KeyModifiers::CONTROL) + && let Some(row) = f.fields.get_mut(f.cursor) + && !is_enum + { + row.buf.push(c); + } } } - }, + } Modal::ChannelList(cl) => { let drafts = self.form.channels.len(); let row_count = drafts + 2; // drafts + Add + Done @@ -1968,8 +1972,13 @@ impl QuickstartPane { let missing: Vec<&str> = f .fields .iter() - .filter(|r| r.descriptor.required && r.buf.trim().is_empty()) - .map(|r| r.descriptor.key.as_str()) + .enumerate() + .filter(|(index, r)| { + field_form_row_visible(f, *index) + && r.descriptor.required + && r.buf.trim().is_empty() + }) + .map(|(_, r)| r.descriptor.key.as_str()) .collect(); if !missing.is_empty() { self.last_errors = missing @@ -1993,7 +2002,10 @@ impl QuickstartPane { }; let mut provider_fields: std::collections::HashMap = std::collections::HashMap::new(); - for row in &f.fields { + for (index, row) in f.fields.iter().enumerate() { + if !field_form_row_visible(f, index) { + continue; + } // `model` and `alias` are hoisted to FormState // fields; every other descriptor flows through // `provider_fields` keyed by its schema identifier @@ -2374,6 +2386,58 @@ fn field_row_variants(row: &FieldFormRow) -> Option<&[String]> { None } +fn field_form_uses_openai_codex_auth(form: &FieldFormModal) -> bool { + matches!(form.selector, Selector::ModelProvider) + && form.type_key.trim().eq_ignore_ascii_case("openai") + && form.fields.iter().any(|row| { + row.descriptor.key == "auth_mode" && row.buf.trim().eq_ignore_ascii_case("codex") + }) +} + +fn field_form_row_visible(form: &FieldFormModal, index: usize) -> bool { + let Some(row) = form.fields.get(index) else { + return false; + }; + !(field_form_uses_openai_codex_auth(form) && row.descriptor.key == "api_key") +} + +fn visible_field_form_indices(form: &FieldFormModal) -> Vec { + form.fields + .iter() + .enumerate() + .filter_map(|(index, _)| field_form_row_visible(form, index).then_some(index)) + .collect() +} + +fn normalize_field_form_cursor(form: &mut FieldFormModal) { + if field_form_row_visible(form, form.cursor) { + return; + } + if let Some(index) = visible_field_form_indices(form).into_iter().next() { + form.cursor = index; + } +} + +fn move_field_form_cursor(form: &mut FieldFormModal, delta: i32) { + let visible = visible_field_form_indices(form); + if visible.is_empty() { + return; + } + let current_pos = visible + .iter() + .position(|index| *index == form.cursor) + .unwrap_or(0); + let next_pos = (current_pos as i32 + delta).rem_euclid(visible.len() as i32); + form.cursor = visible[next_pos as usize]; +} + +fn field_form_cursor_is_last_visible(form: &FieldFormModal) -> bool { + visible_field_form_indices(form) + .into_iter() + .next_back() + .is_none_or(|index| index == form.cursor) +} + fn missing_template_error(filename: &str) -> QuickstartError { QuickstartError { step: QuickstartStep::Agent, @@ -2476,6 +2540,10 @@ fn draw_modal( ])); lines.push(Line::from("")); for (i, row) in f.fields.iter().enumerate() { + if !field_form_row_visible(f, i) { + cursor_lines.push(usize::MAX); + continue; + } cursor_lines.push(lines.len()); let is_cursor = i == f.cursor; let glyph = if is_cursor { " › " } else { " " }; @@ -2892,7 +2960,10 @@ fn draw_modal( // maps it into wrapped-row space so the scroll math survives wrapping. let selected_line = match modal { Modal::Picker(p) => cursor_lines.get(p.cursor).copied(), - Modal::FieldForm(f) => cursor_lines.get(f.cursor).copied(), + Modal::FieldForm(f) => cursor_lines + .get(f.cursor) + .copied() + .filter(|line| *line != usize::MAX), Modal::ChannelList(cl) => cursor_lines.get(cl.cursor).copied(), Modal::PeerGroupList(pl) => cursor_lines.get(pl.cursor).copied(), Modal::Agent(a) => { @@ -2958,6 +3029,9 @@ fn draw_modal( let row_rects: Vec = cursor_lines .into_iter() .map(|line_idx| { + if line_idx == usize::MAX { + return Rect::new(0, 0, 0, 0); + } let start = row_starts.get(line_idx).copied().unwrap_or(0); let height = body_heights.get(line_idx).copied().unwrap_or(1).max(1); match start.checked_sub(scroll_offset) { @@ -3188,6 +3262,75 @@ mod tests { assert_eq!(rows[1].buf, "gpt-5"); } + #[test] + fn openai_codex_auth_hides_api_key_row_in_tui_form() { + let fields = vec![ + QuickstartFieldDescriptor { + key: "model".into(), + label: "model".into(), + help: String::new(), + kind: crate::client::QuickstartFieldKind::String, + is_secret: false, + enum_variants: None, + required: true, + default: None, + }, + QuickstartFieldDescriptor { + key: "auth_mode".into(), + label: "Authentication".into(), + help: String::new(), + kind: crate::client::QuickstartFieldKind::Enum, + is_secret: false, + enum_variants: Some(vec!["api_key".into(), "codex".into()]), + required: true, + default: Some("codex".into()), + }, + QuickstartFieldDescriptor { + key: "api_key".into(), + label: "api_key".into(), + help: String::new(), + kind: crate::client::QuickstartFieldKind::String, + is_secret: true, + enum_variants: None, + required: false, + default: None, + }, + ]; + let mut form = FieldFormModal { + selector: Selector::ModelProvider, + type_key: "openai".into(), + alias: "default".into(), + model_catalog_state: ModelCatalogState::Empty, + model_catalog_attempts: 0, + fields: build_field_form_rows(QuickstartFieldSection::ModelProvider, fields, None), + cursor: 2, + }; + + let keys: Vec<&str> = visible_field_form_indices(&form) + .into_iter() + .map(|index| form.fields[index].descriptor.key.as_str()) + .collect(); + assert_eq!(keys, vec!["alias", "model", "auth_mode"]); + + move_field_form_cursor(&mut form, 1); + assert_eq!( + form.fields[form.cursor].descriptor.key, "alias", + "next from auth_mode must skip the hidden api_key row" + ); + + let auth_mode = form + .fields + .iter_mut() + .find(|row| row.descriptor.key == "auth_mode") + .expect("auth_mode row"); + auth_mode.buf = "api_key".into(); + let keys: Vec<&str> = visible_field_form_indices(&form) + .into_iter() + .map(|index| form.fields[index].descriptor.key.as_str()) + .collect(); + assert_eq!(keys, vec!["alias", "model", "auth_mode", "api_key"]); + } + #[test] fn transient_model_catalog_miss_retries_before_manual_fallback() { let mut form = FieldFormModal { diff --git a/crates/zeroclaw-config/src/schema.rs b/crates/zeroclaw-config/src/schema.rs index 8ee32bdac48..d92d0f7b822 100644 --- a/crates/zeroclaw-config/src/schema.rs +++ b/crates/zeroclaw-config/src/schema.rs @@ -813,7 +813,7 @@ pub struct ModelProviderConfig { #[tab(Advanced)] #[serde(default, skip_serializing_if = "Option::is_none")] pub wire_api: Option, - /// When true, the client pulls credentials from `OPENAI_API_KEY` or `~/.codex/auth.json` instead of the `api_key` field above. Turn on only for the OpenAI Codex model_provider; leave off for standard API-key model_providers. + /// When true, the client pulls credentials from ZeroClaw's stored `openai-codex` auth profile instead of the `api_key` field above. Import an existing Codex CLI login with `zeroclaw auth login --model-provider openai-codex --import ~/.codex/auth.json`, or run `zeroclaw auth login --model-provider openai-codex`. Turn on only for the OpenAI Codex model_provider; leave off for standard API-key model_providers. #[tab(Connection)] #[serde(default, skip_serializing_if = "is_false")] #[credential_class = "external_auth_store"] diff --git a/crates/zeroclaw-providers/src/openai_codex.rs b/crates/zeroclaw-providers/src/openai_codex.rs index 7dff6261f78..373229d937c 100644 --- a/crates/zeroclaw-providers/src/openai_codex.rs +++ b/crates/zeroclaw-providers/src/openai_codex.rs @@ -1219,7 +1219,7 @@ impl OpenAiCodexModelProvider { "openai_codex: auth profile present but no usable access token" ); anyhow::Error::msg( - "OpenAI Codex credentials are present but expired or could not be refreshed. Re-run `zeroclaw auth login --provider openai-codex` to sign in again.", + "OpenAI Codex credentials are present but expired or could not be refreshed. Re-run `zeroclaw auth login --model-provider openai-codex` to sign in again.", ) } else { ::zeroclaw_log::record!( @@ -1233,7 +1233,7 @@ impl OpenAiCodexModelProvider { "openai_codex: no auth profile found" ); anyhow::Error::msg( - "No OpenAI Codex credentials found. Run `zeroclaw auth login --provider openai-codex` to sign in.", + "No OpenAI Codex credentials found. Run `zeroclaw auth login --model-provider openai-codex` to sign in.", ) } })?) @@ -1251,7 +1251,7 @@ impl OpenAiCodexModelProvider { "openai_codex: account_id not found in profile/token" ); anyhow::Error::msg( - "OpenAI Codex account id not found in auth profile/token. Run `zeroclaw auth login --provider openai-codex` again.", + "OpenAI Codex account id not found in auth profile/token. Run `zeroclaw auth login --model-provider openai-codex` again.", ) })?) }; diff --git a/crates/zeroclaw-runtime/locales/en/cli.ftl b/crates/zeroclaw-runtime/locales/en/cli.ftl index ce80807f75b..eb23395400a 100644 --- a/crates/zeroclaw-runtime/locales/en/cli.ftl +++ b/crates/zeroclaw-runtime/locales/en/cli.ftl @@ -549,6 +549,11 @@ cli-quickstart-peer-group-row = {$channel} → {$name} ({$count} peers) cli-quickstart-provider-local-label = {$name} (local) cli-quickstart-provider-type-prompt = Provider type cli-quickstart-alias-for = Alias for {$name} +cli-quickstart-openai-auth-mode-label = Authentication +cli-quickstart-openai-auth-mode-help = Choose `codex` to use a ChatGPT/Codex subscription auth profile. If you already signed in with the Codex CLI, run `zeroclaw auth login --model-provider openai-codex --import ~/.codex/auth.json`; otherwise run `zeroclaw auth login --model-provider openai-codex`. +cli-quickstart-anthropic-auth-mode-label = Authentication +cli-quickstart-anthropic-auth-mode-help = Choose `api_key` for an Anthropic Console key, or `setup_token` if you will run `claude setup-token` for Claude Max and paste the generated token. +cli-quickstart-anthropic-api-key-help = Paste an Anthropic Console API key or the token generated by `claude setup-token`. cli-quickstart-model-field-missing-warning = WARN: schema produced no `model` field for `{$provider}` — falling back to manual entry. Please report this. cli-quickstart-model-id-for = Model id for {$name} cli-quickstart-risk-profile-prompt = Risk profile @@ -594,6 +599,8 @@ cli-quickstart-error-not-type-alias-ref = `{$reference}` is not a `. Option<(&'static str, bool)> { + if let Ok(provider) = type_key.parse::() { + return Some(match provider { + zeroclaw_providers::auth::AuthProvider::OpenaiCodex => ("openai", true), + zeroclaw_providers::auth::AuthProvider::Anthropic => ("anthropic", false), + zeroclaw_providers::auth::AuthProvider::Gemini => ("gemini", false), + zeroclaw_providers::auth::AuthProvider::Xai => ("xai", false), + }); + } + + let trimmed = type_key.trim(); + zeroclaw_providers::list_model_providers() + .into_iter() + .find(|info| info.name.eq_ignore_ascii_case(trimmed)) + .map(|info| (info.name, false)) +} + /// Build a [`QuickstartState`] snapshot from the live config. /// /// The two `*_types` lists are populated from the canonical sources @@ -690,13 +714,21 @@ pub fn field_shape(section: FieldSection, type_key: &str) -> Vec ( - format!("providers.models.{type_key}"), - MODEL_PROVIDER_ESSENTIALS, - ), - FieldSection::Channel => (format!("channels.{type_key}"), CHANNEL_ESSENTIALS), - FieldSection::PeerGroup => ("peer_groups".to_string(), PEER_GROUP_ESSENTIALS), + let (section_path, essentials, codex_auth_preselected) = match section { + FieldSection::ModelProvider => { + let Some((provider_type, codex_auth_preselected)) = + resolve_model_provider_type(type_key) + else { + return Vec::new(); + }; + ( + format!("providers.models.{provider_type}"), + MODEL_PROVIDER_ESSENTIALS, + codex_auth_preselected, + ) + } + FieldSection::Channel => (format!("channels.{type_key}"), CHANNEL_ESSENTIALS, false), + FieldSection::PeerGroup => ("peer_groups".to_string(), PEER_GROUP_ESSENTIALS, false), }; // A throwaway Config we can mutate freely. Inject one default @@ -736,29 +768,37 @@ pub fn field_shape(section: FieldSection, type_key: &str) -> Vec Vec FieldDescriptor { + FieldDescriptor { + key: QUICKSTART_AUTH_MODE_FIELD.to_string(), + label: crate::i18n::get_required_cli_string("cli-quickstart-openai-auth-mode-label"), + help: crate::i18n::get_required_cli_string("cli-quickstart-openai-auth-mode-help"), + kind: zeroclaw_config::traits::PropKind::Enum, + is_secret: false, + enum_variants: Some(vec![ + QUICKSTART_OPENAI_AUTH_MODE_API_KEY.to_string(), + QUICKSTART_OPENAI_AUTH_MODE_CODEX.to_string(), + ]), + required: true, + default: Some( + if codex_auth_preselected { + QUICKSTART_OPENAI_AUTH_MODE_CODEX + } else { + QUICKSTART_OPENAI_AUTH_MODE_API_KEY + } + .to_string(), + ), + } +} + +fn anthropic_auth_mode_descriptor() -> FieldDescriptor { + FieldDescriptor { + key: QUICKSTART_AUTH_MODE_FIELD.to_string(), + label: crate::i18n::get_required_cli_string("cli-quickstart-anthropic-auth-mode-label"), + help: crate::i18n::get_required_cli_string("cli-quickstart-anthropic-auth-mode-help"), + kind: zeroclaw_config::traits::PropKind::Enum, + is_secret: false, + enum_variants: Some(vec![ + QUICKSTART_ANTHROPIC_AUTH_MODE_API_KEY.to_string(), + QUICKSTART_ANTHROPIC_AUTH_MODE_SETUP_TOKEN.to_string(), + ]), + required: true, + default: Some(QUICKSTART_ANTHROPIC_AUTH_MODE_API_KEY.to_string()), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpenAiQuickstartAuthMode { + ApiKey, + Codex, +} + +fn parse_openai_quickstart_auth_mode(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "" | QUICKSTART_OPENAI_AUTH_MODE_API_KEY => Some(OpenAiQuickstartAuthMode::ApiKey), + QUICKSTART_OPENAI_AUTH_MODE_CODEX + | "codex_subscription" + | "openai-codex" + | "openai_codex" => Some(OpenAiQuickstartAuthMode::Codex), + _ => None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AnthropicQuickstartAuthMode { + ApiKey, + SetupToken, +} + +fn parse_anthropic_quickstart_auth_mode(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "" | QUICKSTART_ANTHROPIC_AUTH_MODE_API_KEY | "key" => { + Some(AnthropicQuickstartAuthMode::ApiKey) + } + QUICKSTART_ANTHROPIC_AUTH_MODE_SETUP_TOKEN | "setup-token" | "claude_setup_token" => { + Some(AnthropicQuickstartAuthMode::SetupToken) + } + _ => None, + } +} + /// Runtime profile the Quickstart silently installs. The Runtime Profile /// picker was removed from every surface; apply always writes this preset. const FORCED_RUNTIME_PRESET: &str = "unbounded"; @@ -1004,28 +1118,67 @@ fn apply_model_provider( // whitespace-padded value (e.g. "llamacpp ", "llama.cpp") would // otherwise reach `create_map_key` verbatim and fail with a cryptic // "no map-keyed/list section" because the family key doesn't match. - let provider_type = choice.provider_type.trim(); - let provider_type = match zeroclaw_providers::list_model_providers() - .into_iter() - .find(|info| info.name.eq_ignore_ascii_case(provider_type)) - { - Some(info) => info.name.to_string(), - None => { - errors.push(QuickstartError::for_surface( - ctx, - QuickstartStep::ModelProvider, - "provider_type", - format!( - "unknown model provider type `{}` — pick one from the provider list", - choice.provider_type.trim() - ), - "cli-quickstart-error-unknown-provider-type", - &[("provider", choice.provider_type.trim())], - )); - return None; + // `openai-codex` is accepted as a convenience input alias and + // normalizes to the existing `openai` config family. + let Some((provider_type, codex_alias_requested)) = + resolve_model_provider_type(&choice.provider_type) + else { + errors.push(QuickstartError::for_surface( + ctx, + QuickstartStep::ModelProvider, + "provider_type", + format!( + "unknown model provider type `{}` — pick one from the provider list", + choice.provider_type.trim() + ), + "cli-quickstart-error-unknown-provider-type", + &[("provider", choice.provider_type.trim())], + )); + return None; + }; + let auth_mode = if provider_type == "openai" { + match choice.fields.get(QUICKSTART_AUTH_MODE_FIELD) { + Some(value) => match parse_openai_quickstart_auth_mode(value) { + Some(mode) => mode, + None => { + errors.push(QuickstartError::for_surface( + ctx, + QuickstartStep::ModelProvider, + QUICKSTART_AUTH_MODE_FIELD, + format!( + "unknown OpenAI auth mode `{}` — pick `api_key` or `codex`", + value.trim() + ), + "cli-quickstart-error-unknown-openai-auth-mode", + &[("mode", value.trim())], + )); + return None; + } + }, + None if codex_alias_requested => OpenAiQuickstartAuthMode::Codex, + None => OpenAiQuickstartAuthMode::ApiKey, } + } else { + OpenAiQuickstartAuthMode::ApiKey }; - if section_has_alias(config, "providers.models", &provider_type, &choice.alias) { + if provider_type == "anthropic" + && let Some(value) = choice.fields.get(QUICKSTART_AUTH_MODE_FIELD) + && parse_anthropic_quickstart_auth_mode(value).is_none() + { + errors.push(QuickstartError::for_surface( + ctx, + QuickstartStep::ModelProvider, + QUICKSTART_AUTH_MODE_FIELD, + format!( + "unknown Anthropic auth mode `{}` — pick `api_key` or `setup_token`", + value.trim() + ), + "cli-quickstart-error-unknown-anthropic-auth-mode", + &[("mode", value.trim())], + )); + return None; + } + if section_has_alias(config, "providers.models", provider_type, &choice.alias) { let alias_ref = format!("{}.{}", provider_type, choice.alias); errors.push(QuickstartError::for_surface( ctx, @@ -1037,6 +1190,8 @@ fn apply_model_provider( )); return None; } + let codex_auth = + provider_type == "openai" && matches!(auth_mode, OpenAiQuickstartAuthMode::Codex); let prefix = format!("providers.models.{}.{}", provider_type, choice.alias); if let Err(err) = config.create_map_key( &format!("providers.models.{}", provider_type), @@ -1058,12 +1213,45 @@ fn apply_model_provider( )); return None; } + if codex_auth { + if let Err(err) = config + .set_prop_persistent(&format!("{prefix}.wire_api"), WireApi::Responses.as_str()) + { + errors.push(QuickstartError::new( + QuickstartStep::ModelProvider, + "wire_api", + err.to_string(), + )); + return None; + } + if let Err(err) = + config.set_prop_persistent(&format!("{prefix}.requires_openai_auth"), "true") + { + errors.push(QuickstartError::new( + QuickstartStep::ModelProvider, + "requires_openai_auth", + err.to_string(), + )); + return None; + } + } // Round-trip every field the surface echoed back. Keys are // whatever `field_shape()` emitted — the daemon authored // them, so it knows where they go. let mut entries: Vec<(&String, &String)> = choice.fields.iter().collect(); entries.sort_by(|a, b| a.0.cmp(b.0)); for (key, value) in entries { + if key == QUICKSTART_AUTH_MODE_FIELD { + continue; + } + if codex_auth + && matches!( + key.as_str(), + "api_key" | "wire_api" | "requires_openai_auth" + ) + { + continue; + } if value.is_empty() { continue; } @@ -1942,6 +2130,124 @@ mod tests { assert!(cfg.providers.models.find("anthropic", "main").is_some()); } + #[test] + fn apply_claude_alias_writes_canonical_anthropic_config() { + let mut cfg = Config::default(); + let mut submission = fresh_submission("bot"); + submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice { + provider_type: "claude".into(), + alias: "max".into(), + model: "claude-sonnet-4-5".into(), + fields: std::collections::HashMap::from([ + ("auth_mode".to_string(), "setup_token".to_string()), + ("api_key".to_string(), "sk-ant-oat01-test-token".to_string()), + ]), + }); + let mut staged = Vec::new(); + let mut errors = Vec::new(); + let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None); + assert!(errors.is_empty(), "apply_into errors: {errors:?}"); + assert!(applied.is_some()); + let entry = cfg + .providers + .models + .find("anthropic", "max") + .expect("anthropic.max entry"); + assert_eq!(entry.model.as_deref(), Some("claude-sonnet-4-5")); + assert_eq!(entry.api_key.as_deref(), Some("sk-ant-oat01-test-token")); + assert!( + cfg.get_prop("providers.models.anthropic.max.auth_mode") + .is_err() + ); + let agent = cfg.agents.get("bot").expect("agent created"); + assert_eq!(agent.model_provider.as_str(), "anthropic.max"); + } + + #[test] + fn apply_openai_codex_alias_writes_canonical_openai_auth_config() { + let mut cfg = Config::default(); + let mut submission = fresh_submission("bot"); + submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice { + provider_type: "openai-codex".into(), + alias: "coding".into(), + model: "gpt-5.4".into(), + fields: std::collections::HashMap::new(), + }); + let mut staged = Vec::new(); + let mut errors = Vec::new(); + let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None); + assert!(errors.is_empty(), "apply_into errors: {errors:?}"); + assert!(applied.is_some()); + let entry = cfg + .providers + .models + .find("openai", "coding") + .expect("openai.coding entry"); + assert_eq!(entry.model.as_deref(), Some("gpt-5.4")); + assert_eq!(entry.wire_api, Some(WireApi::Responses)); + assert!(entry.requires_openai_auth); + let agent = cfg.agents.get("bot").expect("agent created"); + assert_eq!(agent.model_provider.as_str(), "openai.coding"); + } + + #[test] + fn apply_openai_auth_mode_codex_ignores_api_key_field() { + let mut cfg = Config::default(); + let mut submission = fresh_submission("bot"); + submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice { + provider_type: "openai".into(), + alias: "coding".into(), + model: "gpt-5.4".into(), + fields: std::collections::HashMap::from([ + ("auth_mode".to_string(), "codex".to_string()), + ("api_key".to_string(), "sk-should-not-persist".to_string()), + ]), + }); + let mut staged = Vec::new(); + let mut errors = Vec::new(); + let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None); + assert!(errors.is_empty(), "apply_into errors: {errors:?}"); + assert!(applied.is_some()); + let entry = cfg + .providers + .models + .find("openai", "coding") + .expect("openai.coding entry"); + assert_eq!(entry.wire_api, Some(WireApi::Responses)); + assert!(entry.requires_openai_auth); + assert!( + entry.api_key.is_none(), + "Codex auth must not persist an API key from the Quickstart form" + ); + } + + #[test] + fn apply_unknown_anthropic_auth_mode_errors_clearly() { + let mut cfg = Config::default(); + let mut submission = fresh_submission("bot"); + submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice { + provider_type: "anthropic".into(), + alias: "main".into(), + model: "claude-sonnet-4-5".into(), + fields: std::collections::HashMap::from([( + "auth_mode".to_string(), + "not_real".to_string(), + )]), + }); + let mut staged = Vec::new(); + let mut errors = Vec::new(); + let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None); + assert!(applied.is_none()); + assert!( + errors + .iter() + .any(|e| e.step == QuickstartStep::ModelProvider + && e.field == "auth_mode" + && e.message.contains("unknown Anthropic auth mode")), + "expected a clear unknown-Anthropic-auth-mode error, got: {errors:?}" + ); + } + #[test] fn apply_unknown_provider_type_errors_clearly() { let mut cfg = Config::default(); @@ -2041,33 +2347,32 @@ mod tests { } } - /// Codex subscription auth: `field_shape(ModelProvider, "openai")` must - /// include the `requires_openai_auth` and `wire_api` rows so the - /// Quickstart form can offer Codex subscription auth (no API key needed). - /// These fields are non-required — they default to `false`/empty and are - /// harmless for non-OpenAI providers. + /// Codex subscription auth: `field_shape(ModelProvider, "openai")` exposes + /// one Quickstart-only auth selector instead of raw config toggles. Apply + /// translates `auth_mode = "codex"` into the canonical persisted + /// `wire_api = "responses"` + `requires_openai_auth = true` fields. #[test] - fn field_shape_openai_includes_codex_auth_fields() { + fn field_shape_openai_includes_codex_auth_mode() { let rows = super::field_shape(super::FieldSection::ModelProvider, "openai"); let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect(); assert!( - keys.contains(&"requires_openai_auth"), - "field_shape for openai must include `requires_openai_auth` for Codex subscription; got {keys:?}", + keys.contains(&"auth_mode"), + "field_shape for openai must include `auth_mode` for Codex subscription; got {keys:?}", ); assert!( - keys.contains(&"wire_api"), - "field_shape for openai must include `wire_api` for Codex subscription; got {keys:?}", + !keys.contains(&"requires_openai_auth") && !keys.contains(&"wire_api"), + "field_shape for openai should hide raw Codex config toggles; got {keys:?}", ); - // Both must be non-required so Quickstart doesn't block on them. - for row in &rows { - if row.key == "requires_openai_auth" || row.key == "wire_api" { - assert!( - !row.required, - "`{}` must be non-required in the Quickstart form", - row.key - ); - } - } + let auth = rows + .iter() + .find(|row| row.key == "auth_mode") + .expect("auth_mode row"); + assert!(auth.required); + assert_eq!( + auth.enum_variants.as_deref(), + Some(["api_key".to_string(), "codex".to_string()].as_slice()) + ); + assert_eq!(auth.default.as_deref(), Some("api_key")); // No row may carry the `` placeholder as its default. // It's a display sentinel for an unset Option; echoing it back // through any surface (CLI/TUI/web) makes the daemon validate @@ -2082,6 +2387,45 @@ mod tests { } } + #[test] + fn field_shape_openai_codex_alias_preselects_codex_auth() { + let rows = super::field_shape(super::FieldSection::ModelProvider, "openai-codex"); + let auth = rows + .iter() + .find(|row| row.key == "auth_mode") + .expect("auth_mode row"); + assert_eq!(auth.default.as_deref(), Some("codex")); + } + + #[test] + fn field_shape_anthropic_includes_claude_auth_mode() { + let rows = super::field_shape(super::FieldSection::ModelProvider, "claude"); + let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect(); + assert!( + keys.contains(&"auth_mode"), + "field_shape for claude/anthropic must include `auth_mode`; got {keys:?}", + ); + let auth = rows + .iter() + .find(|row| row.key == "auth_mode") + .expect("auth_mode row"); + assert!(auth.required); + assert_eq!( + auth.enum_variants.as_deref(), + Some(["api_key".to_string(), "setup_token".to_string()].as_slice()) + ); + assert_eq!(auth.default.as_deref(), Some("api_key")); + let api_key = rows + .iter() + .find(|row| row.key == "api_key") + .expect("api_key row"); + assert!( + api_key.help.contains("claude setup-token"), + "Anthropic API key help should mention setup-token flow; got {:?}", + api_key.help + ); + } + /// `api_key` must be non-required in the Quickstart form so Codex /// subscription (no API key) and local providers (Ollama) can proceed /// without one. diff --git a/docs/book/src/getting-started/quickstart.md b/docs/book/src/getting-started/quickstart.md index 5e12ac4084c..f4ae192b7a5 100644 --- a/docs/book/src/getting-started/quickstart.md +++ b/docs/book/src/getting-started/quickstart.md @@ -40,6 +40,43 @@ You answer one prompt per step in the terminal. The built-in `cli` channel works immediately, so Channels and Peer groups can be skipped. For an all-defaults, no-approvals config, see [YOLO mode](./yolo.md). +### OpenAI Codex subscription auth + +Quickstart can configure the OpenAI Codex subscription path without an API key. +Authenticate once, then choose **OpenAI** as the provider and set +**Authentication** to `codex` when prompted: + +```sh +# If you already signed in with the Codex CLI: +zeroclaw auth login --model-provider openai-codex --import ~/.codex/auth.json + +# Or start ZeroClaw's own OpenAI Codex login flow: +zeroclaw auth login --model-provider openai-codex +``` + +For scripted setup, `openai-codex` is accepted as a quickstart input alias and +writes the canonical `[providers.models.openai.]` entry: + +```sh +zeroclaw quickstart --model-provider openai-codex --model gpt-5.4 --agent assistant +``` + +### Claude subscription setup-token auth + +Quickstart can also configure Claude/Anthropic with a normal Console API key +or a token generated by Claude Max: + +```sh +claude setup-token +zeroclaw quickstart --model-provider anthropic --model claude-sonnet-4-5 --agent assistant +``` + +Choose **Anthropic**, set **Authentication** to `setup_token`, then paste the +token from `claude setup-token` into the API key/token prompt. Scripted input +may use `--model-provider claude` as an alias. Quickstart still writes the +canonical `[providers.models.anthropic.]` entry; the token is stored +through the same credential path as `api_key`. + ## zerocode In the [zerocode](./zerocode.md) terminal interface, the Quickstart pane is one of diff --git a/docs/book/src/providers/catalog.md b/docs/book/src/providers/catalog.md index ed8cc9d64a1..12a0ab1157b 100644 --- a/docs/book/src/providers/catalog.md +++ b/docs/book/src/providers/catalog.md @@ -10,9 +10,9 @@ See [Configuration](./configuration.md) for universal fields (`api_key`, `uri`, ## Native -### Anthropic: slot `anthropic` +### Anthropic / Claude: slot `anthropic` -Supports OAuth tokens (`sk-ant-oat*`) from Claude Pro/Team subscriptions, no separate API billing. Streaming, tool calls, vision, and reasoning all supported. Custom endpoints (Anthropic-compatible proxies, e.g. Z.AI's Anthropic API) go on this slot too: set `uri` to override. +Supports Console API keys and tokens generated by `claude setup-token` for Claude Max. Both credential forms live on the canonical `anthropic` slot's `api_key` field; Quickstart exposes them as `api_key` and `setup_token` choices. Streaming, tool calls, vision, and reasoning all supported. Custom endpoints (Anthropic-compatible proxies, e.g. Z.AI's Anthropic API) go on this slot too: set `uri` to override. ### OpenAI: slot `openai` diff --git a/docs/book/src/providers/configuration.md b/docs/book/src/providers/configuration.md index 763f7062918..9999cc5727c 100644 --- a/docs/book/src/providers/configuration.md +++ b/docs/book/src/providers/configuration.md @@ -53,8 +53,8 @@ Schema-mirror env overrides win at startup. They replace the in-memory credentia Several providers accept OAuth or subscription-style tokens instead of raw API keys. Get the token from the vendor's own dashboard or CLI flow, then drop it into the alias entry the same way you would an API key: -- **Anthropic**: `sk-ant-oat-*` OAuth tokens (from Claude Pro/Team) go in `api_key` on `[providers.models.anthropic.]`. -- **OpenAI Codex subscription**: set `requires_openai_auth = true` and leave `api_key` unset on `[providers.models.openai.]`; the runtime reads the stored Codex login. +- **Anthropic / Claude**: Console API keys and tokens generated by `claude setup-token` for Claude Max go in `api_key` on `[providers.models.anthropic.]`. In Quickstart, pick `api_key` or `setup_token`; the saved provider entry is still the canonical `anthropic` slot. +- **OpenAI Codex subscription**: run `zeroclaw auth login --model-provider openai-codex` (or import an existing Codex CLI login with `--import ~/.codex/auth.json`), then set `requires_openai_auth = true` and leave `api_key` unset on `[providers.models.openai.]`; the runtime reads ZeroClaw's stored `openai-codex` auth profile. - **Gemini CLI**: `[providers.models.gemini_cli.]` shells out to the `gemini` CLI; use the CLI's own auth flow. - **Qwen / MiniMax**: set `auth_mode = "oauth"` on the alias entry plus the relevant `oauth_*` fields (see [env-vars → OAuth and CLI-path fields](../reference/env-vars.md#oauth-and-cli-path-fields)). diff --git a/docs/book/src/providers/openai-codex-subscription.md b/docs/book/src/providers/openai-codex-subscription.md index a1f5336f622..0a352d2b788 100644 --- a/docs/book/src/providers/openai-codex-subscription.md +++ b/docs/book/src/providers/openai-codex-subscription.md @@ -18,7 +18,23 @@ see [Configuration](./configuration.md); for the one-line catalog entry see the Codex subscription auth lives on the `openai` slot. Set `wire_api = "responses"` to route through `POST /v1/responses` (the Codex backend, not the chat completions API) and `requires_openai_auth = true` to pull credentials from -`~/.codex/auth.json` instead of an `api_key` field: +ZeroClaw's stored `openai-codex` auth profile instead of an `api_key` field: + +```bash +# Reuse an existing Codex CLI login: +zeroclaw auth login --model-provider openai-codex --import ~/.codex/auth.json + +# Or start ZeroClaw's own OpenAI Codex login flow: +zeroclaw auth login --model-provider openai-codex +``` + +Quickstart can write the provider entry for you: + +```bash +zeroclaw quickstart --model-provider openai-codex --model gpt-5.4 +``` + +Manual config uses the same canonical OpenAI slot: ```toml [providers.models.openai.coding] diff --git a/src/main.rs b/src/main.rs index 7509e2e8c92..e06f7fc7132 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1289,12 +1289,19 @@ async fn run_quickstart_cli( // discarded rather than left half-built — the user opens the // selector and starts fresh. if let (Some(mp), Some(m)) = (model_provider.as_deref(), model.as_deref()) - && let Some(found) = providers.iter().find(|p| p.kind.eq_ignore_ascii_case(mp)) + && let Some((canonical_provider, codex_auth)) = + zeroclaw_runtime::quickstart::resolve_model_provider_type(mp) + && let Some(found) = providers + .iter() + .find(|p| p.kind.eq_ignore_ascii_case(canonical_provider)) { - let needs_key = !found.local && api_key.is_none(); + let needs_key = !found.local && api_key.is_none() && !codex_auth; if !needs_key { let mut fields: std::collections::HashMap = std::collections::HashMap::new(); + if codex_auth { + fields.insert("auth_mode".to_string(), "codex".to_string()); + } if let Some(key) = api_key.as_deref().filter(|s| !s.is_empty()) { // Submission field keys are snake_case (`api_key`) — the apply // path round-trips them verbatim into `set_prop_persistent`, @@ -1599,6 +1606,13 @@ async fn run_quickstart_cli( std::collections::HashMap::new(); let mut aborted = false; for d in &descriptors { + if d.key == "api_key" + && field_buf + .get("auth_mode") + .is_some_and(|value| value.trim().eq_ignore_ascii_case("codex")) + { + continue; + } // For the model field, upgrade the descriptor with a // live catalog so `prompt_for_field` renders a picker // instead of a free-text input. Empty catalog (live=false) diff --git a/web/src/pages/quickstart/Quickstart.tsx b/web/src/pages/quickstart/Quickstart.tsx index 6059b44820a..4a6077334c3 100644 --- a/web/src/pages/quickstart/Quickstart.tsx +++ b/web/src/pages/quickstart/Quickstart.tsx @@ -686,6 +686,44 @@ function LabeledInput({ ); } +function LabeledSelect({ + label, + value, + onChange, + options, + help, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: string[]; + help?: string; +}) { + return ( + + ); +} + function ProviderForm({ state, onStage, @@ -723,10 +761,15 @@ function ProviderForm({ setDescriptors(f.fields); // Reset the buffer to an empty value per descriptor so the // ghost-text placeholder (descriptor.default) is what the - // user sees until they type. + // user sees until they type. Enum rows are different: the + // selected value is real state, so seed it from the descriptor + // default or first variant and submit it back. const next: Record = {}; for (const d of f.fields) { - next[d.key] = ""; + next[d.key] = + d.enum_variants && d.enum_variants.length > 0 + ? (d.default ?? d.enum_variants[0] ?? "") + : ""; } setFieldValues(next); } @@ -818,19 +861,44 @@ function ProviderForm({ {descriptors .filter((d) => d.key !== "model") - .map((d) => ( - - setFieldValues((prev) => ({ ...prev, [d.key]: value })) - } - /> - ))} + .filter( + (d) => + !( + d.key === "api_key" && + (fieldValues["auth_mode"] ?? "").trim() === "codex" + ), + ) + .map((d) => + d.enum_variants && d.enum_variants.length > 0 ? ( + + setFieldValues((prev) => ({ ...prev, [d.key]: value })) + } + /> + ) : ( + + setFieldValues((prev) => ({ ...prev, [d.key]: value })) + } + /> + ), + )}