use anyhow::Result;
use atomcode_core::config::provider::ProviderConfig;
use crossterm::event::{KeyCode, KeyModifiers};
use super::{Modal, ModalAction};
use crate::event_loop::{build_status, save_and_reload, Buffer, LoopCtx};
use crate::input::key_action::classify;
use crate::render::{MenuPayload, Renderer, UiLine};
use crate::state::UiState;
pub enum ProviderWizard {
MainMenu { selected: usize },
Add {
step: WizardStep,
draft: DraftProvider,
plan: Vec<WizardStep>,
idx: usize,
},
EditPick {
providers: Vec<String>,
selected: usize,
},
Edit {
target: String,
step: WizardStep,
draft: DraftProvider,
},
DeletePick {
providers: Vec<String>,
selected: usize,
},
DeleteConfirm { target: String },
SetDefaultPick {
providers: Vec<String>,
selected: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum WizardStep {
Template,
Name,
ProviderType,
BaseUrl,
ApiKey,
Model,
}
#[derive(Clone, Debug, Default)]
pub struct DraftProvider {
pub name: String,
pub provider_type: String,
pub base_url: String,
pub api_key: String,
pub model: String,
}
impl DraftProvider {
fn apply_onto(&self, base: &mut ProviderConfig) {
if !self.provider_type.is_empty() {
base.provider_type = self.provider_type.clone();
}
if !self.base_url.is_empty() {
base.base_url = Some(self.base_url.clone());
}
if !self.api_key.is_empty() {
base.api_key = Some(self.api_key.clone());
}
if !self.model.is_empty() {
base.model = self.model.clone();
}
}
fn into_config(self) -> ProviderConfig {
use atomcode_core::config::provider::default_context_window_for;
let provider_type = self.provider_type.clone();
ProviderConfig {
provider_type: provider_type.clone(),
api_key: if self.api_key.is_empty() {
None
} else {
Some(self.api_key)
},
model: self.model,
base_url: if self.base_url.is_empty() {
None
} else {
Some(self.base_url)
},
system_prompt: None,
user_agent: None,
context_window: default_context_window_for(&provider_type),
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
}
}
}
impl Modal for ProviderWizard {
fn handle_key(
&mut self,
code: KeyCode,
mods: KeyModifiers,
buf: &mut Buffer,
state: &mut UiState,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
) -> Result<ModalAction> {
handle_key(code, mods, buf, state, ctx, renderer, self)
}
fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
redraw(buf, state, ctx, self, renderer);
}
}
fn handle_key(
code: KeyCode,
_mods: KeyModifiers,
buf: &mut Buffer,
state: &mut UiState,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
wizard: &mut ProviderWizard,
) -> Result<ModalAction> {
if matches!(code, KeyCode::Esc) {
buf.text.clear();
buf.cursor = 0;
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderWizardCancelled));
return Ok(ModalAction::Close);
}
let current = std::mem::replace(wizard, ProviderWizard::MainMenu { selected: 0 });
match current {
ProviderWizard::MainMenu { mut selected } => {
const ITEMS: [&str; 4] = ["add", "edit", "delete", "set-default"];
match code {
KeyCode::Up => {
selected = selected.saturating_sub(1);
*wizard = ProviderWizard::MainMenu { selected };
}
KeyCode::Down => {
if selected + 1 < ITEMS.len() {
selected += 1;
}
*wizard = ProviderWizard::MainMenu { selected };
}
KeyCode::Enter => {
let providers: Vec<String> = {
let mut v: Vec<String> = ctx.config.providers.keys().cloned().collect();
v.sort();
v
};
match ITEMS[selected] {
"add" => {
let new = ProviderWizard::Add {
step: WizardStep::Template,
draft: DraftProvider::default(),
plan: Vec::new(),
idx: 0,
};
show_add_step_prompt(
WizardStep::Template,
&DraftProvider::default(),
0,
0,
buf,
state,
ctx,
&new,
renderer,
);
*wizard = new;
}
"edit" | "delete" | "set-default" if providers.is_empty() => {
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderNoProviders));
return Ok(ModalAction::Close);
}
"edit" => {
let new = ProviderWizard::EditPick {
providers,
selected: 0,
};
redraw(buf, state, ctx, &new, renderer);
*wizard = new;
}
"delete" => {
let new = ProviderWizard::DeletePick {
providers,
selected: 0,
};
redraw(buf, state, ctx, &new, renderer);
*wizard = new;
}
"set-default" => {
let new = ProviderWizard::SetDefaultPick {
providers,
selected: 0,
};
redraw(buf, state, ctx, &new, renderer);
*wizard = new;
}
_ => {
*wizard = ProviderWizard::MainMenu { selected };
}
}
}
_ => {
*wizard = ProviderWizard::MainMenu { selected };
}
}
redraw(buf, state, ctx, wizard, renderer);
Ok(ModalAction::Continue)
}
ProviderWizard::EditPick {
providers,
mut selected,
} => {
match code {
KeyCode::Up => selected = selected.saturating_sub(1),
KeyCode::Down => {
if selected + 1 < providers.len() {
selected += 1;
}
}
KeyCode::Enter => {
let target = providers[selected].clone();
let existing = ctx.config.providers.get(&target).cloned();
let new = ProviderWizard::Edit {
target: target.clone(),
step: WizardStep::ProviderType,
draft: DraftProvider::default(),
};
show_step_prompt(
WizardStep::ProviderType,
existing.as_ref(),
buf,
state,
ctx,
&new,
renderer,
);
*wizard = new;
return Ok(ModalAction::Continue);
}
_ => {}
}
*wizard = ProviderWizard::EditPick {
providers,
selected,
};
redraw(buf, state, ctx, wizard, renderer);
Ok(ModalAction::Continue)
}
ProviderWizard::DeletePick {
providers,
mut selected,
} => {
match code {
KeyCode::Up => selected = selected.saturating_sub(1),
KeyCode::Down => {
if selected + 1 < providers.len() {
selected += 1;
}
}
KeyCode::Enter => {
let target = providers[selected].clone();
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderDeleteConfirm { name: &target }));
*wizard = ProviderWizard::DeleteConfirm { target };
redraw(buf, state, ctx, wizard, renderer);
return Ok(ModalAction::Continue);
}
_ => {}
}
*wizard = ProviderWizard::DeletePick {
providers,
selected,
};
redraw(buf, state, ctx, wizard, renderer);
Ok(ModalAction::Continue)
}
ProviderWizard::SetDefaultPick {
providers,
mut selected,
} => {
match code {
KeyCode::Up => selected = selected.saturating_sub(1),
KeyCode::Down => {
if selected + 1 < providers.len() {
selected += 1;
}
}
KeyCode::Enter => {
let chosen = providers[selected].clone();
ctx.config.default_provider = chosen.clone();
if let Some(p) = ctx.config.providers.get(&chosen) {
ctx.model_name = p.model.clone();
}
save_and_reload(ctx, renderer);
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderDefaultSet { name: &chosen }));
return Ok(ModalAction::Close);
}
_ => {}
}
*wizard = ProviderWizard::SetDefaultPick {
providers,
selected,
};
redraw(buf, state, ctx, wizard, renderer);
Ok(ModalAction::Continue)
}
ProviderWizard::DeleteConfirm { target } => {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ctx.config.providers.remove(&target);
if ctx.config.default_provider == target {
ctx.config.default_provider = ctx
.config
.providers
.keys()
.next()
.cloned()
.unwrap_or_default();
}
save_and_reload(ctx, renderer);
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderDeleted { name: &target }));
}
_ => {
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderDeleteKept));
}
}
Ok(ModalAction::Close)
}
ProviderWizard::Add { step, mut draft, plan, idx } => {
if matches!(code, KeyCode::Enter) {
let answer = buf.expanded_text();
if !matches!(step, WizardStep::Template) {
push(renderer, &format!(" ↳ {}", answer));
}
buf.text.clear();
buf.cursor = 0;
if matches!(step, WizardStep::Template) {
match resolve_template(&mut draft, &answer, &ctx.config.providers, renderer) {
TemplateOutcome::Manual => {
let new = ProviderWizard::Add {
step: WizardStep::BaseUrl,
draft,
plan: Vec::new(),
idx: 0,
};
if let ProviderWizard::Add { step, draft, .. } = &new {
show_add_step_prompt(*step, draft, 0, 0, buf, state, ctx, &new, renderer);
}
*wizard = new;
return Ok(ModalAction::Continue);
}
TemplateOutcome::Retry => {
let new = ProviderWizard::Add {
step: WizardStep::Template,
draft,
plan: Vec::new(),
idx: 0,
};
show_add_step_prompt(
WizardStep::Template, &DraftProvider::default(), 0, 0,
buf, state, ctx, &new, renderer,
);
*wizard = new;
return Ok(ModalAction::Continue);
}
TemplateOutcome::Import => {}
}
ensure_name_default(&mut draft, &ctx.config.providers);
let plan = import_plan(&draft);
let total = plan.len();
let first = plan[0];
let new = ProviderWizard::Add { step: first, draft, plan, idx: 0 };
if let ProviderWizard::Add { step, draft, .. } = &new {
show_add_step_prompt(*step, draft, 0, total, buf, state, ctx, &new, renderer);
}
*wizard = new;
return Ok(ModalAction::Continue);
}
if matches!(step, WizardStep::BaseUrl) {
let url = answer.trim();
if url.is_empty() {
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderBaseUrlEmpty));
let new = ProviderWizard::Add {
step: WizardStep::BaseUrl,
draft,
plan: Vec::new(),
idx: 0,
};
if let ProviderWizard::Add { step, draft, .. } = &new {
show_add_step_prompt(*step, draft, 0, 0, buf, state, ctx, &new, renderer);
}
*wizard = new;
return Ok(ModalAction::Continue);
}
let ptype = apply_manual_base_url(&mut draft, url, &ctx.config.providers);
push(
renderer,
&crate::i18n::t(crate::i18n::Msg::ProviderTypeInferred { type_name: ptype }),
);
let plan = import_plan(&draft);
let total = plan.len();
let first = plan[0];
let new = ProviderWizard::Add { step: first, draft, plan, idx: 0 };
if let ProviderWizard::Add { step, draft, .. } = &new {
show_add_step_prompt(*step, draft, 0, total, buf, state, ctx, &new, renderer);
}
*wizard = new;
return Ok(ModalAction::Continue);
}
match store_step(&mut draft, step, &answer, &ctx.config.providers, renderer) {
StepOutcome::Retry => {
let total = plan.len();
let new = ProviderWizard::Add { step, draft, plan, idx };
if let ProviderWizard::Add { step, draft, .. } = &new {
show_add_step_prompt(*step, draft, idx, total, buf, state, ctx, &new, renderer);
}
*wizard = new;
return Ok(ModalAction::Continue);
}
StepOutcome::Ok => {
let next_idx = idx + 1;
if next_idx >= plan.len() {
let name = draft.name.clone();
let model = draft.model.clone();
let cfg = draft.into_config();
ctx.config.providers.insert(name.clone(), cfg);
ctx.config.default_provider = name.clone();
ctx.model_name = model.clone();
save_and_reload(ctx, renderer);
push(
renderer,
&crate::i18n::t(crate::i18n::Msg::ProviderAdded { name: &name, model: &model }),
);
return Ok(ModalAction::Close);
}
let next = plan[next_idx];
if matches!(next, WizardStep::Name) {
ensure_name_default(&mut draft, &ctx.config.providers);
}
let total = plan.len();
let new = ProviderWizard::Add { step: next, draft, plan, idx: next_idx };
if let ProviderWizard::Add { step, draft, .. } = &new {
show_add_step_prompt(*step, draft, next_idx, total, buf, state, ctx, &new, renderer);
}
*wizard = new;
return Ok(ModalAction::Continue);
}
}
}
forward_to_buffer(code, _mods, buf, state, ctx);
*wizard = ProviderWizard::Add { step, draft, plan, idx };
redraw(buf, state, ctx, wizard, renderer);
Ok(ModalAction::Continue)
}
ProviderWizard::Edit {
target,
step,
mut draft,
} => {
if matches!(code, KeyCode::Enter) {
let answer = buf.expanded_text();
push(
renderer,
&format!(
" ↳ {}",
if answer.is_empty() {
crate::i18n::t(crate::i18n::Msg::ProviderEditKeep).into_owned()
} else {
answer.clone()
}
),
);
buf.text.clear();
buf.cursor = 0;
match advance_edit(&mut draft, step, &answer, renderer) {
Some(next) => {
let existing = ctx.config.providers.get(&target).cloned();
let new = ProviderWizard::Edit {
target: target.clone(),
step: next,
draft,
};
show_step_prompt(next, existing.as_ref(), buf, state, ctx, &new, renderer);
*wizard = new;
return Ok(ModalAction::Continue);
}
None => {
if let Some(existing) = ctx.config.providers.get_mut(&target) {
draft.apply_onto(existing);
}
save_and_reload(ctx, renderer);
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderUpdated { name: &target }));
return Ok(ModalAction::Close);
}
}
}
forward_to_buffer(code, _mods, buf, state, ctx);
*wizard = ProviderWizard::Edit {
target,
step,
draft,
};
redraw(buf, state, ctx, wizard, renderer);
Ok(ModalAction::Continue)
}
}
}
fn redraw(
buf: &Buffer,
state: &UiState,
ctx: &LoopCtx,
wizard: &ProviderWizard,
renderer: &mut dyn Renderer,
) {
let menu = match wizard {
ProviderWizard::MainMenu { selected } => Some(MenuPayload {
items: vec![
(crate::i18n::t(crate::i18n::Msg::ProviderMenuAdd).into_owned(),
crate::i18n::t(crate::i18n::Msg::ProviderMenuAddDesc).into_owned()),
(crate::i18n::t(crate::i18n::Msg::ProviderMenuEdit).into_owned(),
crate::i18n::t(crate::i18n::Msg::ProviderMenuEditDesc).into_owned()),
(crate::i18n::t(crate::i18n::Msg::ProviderMenuDelete).into_owned(),
crate::i18n::t(crate::i18n::Msg::ProviderMenuDeleteDesc).into_owned()),
(crate::i18n::t(crate::i18n::Msg::ProviderMenuSetDefault).into_owned(),
crate::i18n::t(crate::i18n::Msg::ProviderMenuSetDefaultDesc).into_owned()),
],
selected: *selected,
kind: crate::render::MenuKind::SlashCommand,
}),
ProviderWizard::EditPick {
providers,
selected,
}
| ProviderWizard::DeletePick {
providers,
selected,
}
| ProviderWizard::SetDefaultPick {
providers,
selected,
} => {
let items: Vec<(String, String)> = providers
.iter()
.map(|name| {
let desc = ctx
.config
.providers
.get(name)
.map(|c| format!("{} · {}", c.provider_type, c.model))
.unwrap_or_default();
(name.clone(), desc)
})
.collect();
Some(MenuPayload {
items,
selected: *selected,
kind: crate::render::MenuKind::SlashCommand,
})
}
ProviderWizard::Add { .. }
| ProviderWizard::Edit { .. }
| ProviderWizard::DeleteConfirm { .. } => None,
};
renderer.render(UiLine::InputPrompt {
buf: buf.text.clone(),
cursor_byte: buf.cursor,
menu,
status: build_status(state, ctx),
attachments: Vec::new(),
});
renderer.flush();
}
fn push(renderer: &mut dyn Renderer, text: &str) {
renderer.render(UiLine::CommandOutput(format!(" {}\n", text)));
renderer.flush();
}
fn step_prompt_text(step: WizardStep, existing: Option<&ProviderConfig>) -> String {
use crate::i18n::{t, Msg};
match (step, existing) {
(WizardStep::Template, _) => t(Msg::ProviderImportPrompt).into_owned(),
(WizardStep::Name, _) => t(Msg::ProviderStepName).into_owned(),
(WizardStep::ProviderType, None) => t(Msg::ProviderStepType).into_owned(),
(WizardStep::ProviderType, Some(p)) => {
t(Msg::ProviderStepTypeWithHint { current: &p.provider_type }).into_owned()
}
(WizardStep::BaseUrl, None) => t(Msg::ProviderStepBaseUrl).into_owned(),
(WizardStep::BaseUrl, Some(p)) => {
let default_hint = t(Msg::ProviderDefaultHint);
let hint = p.base_url.as_deref().unwrap_or(&default_hint);
t(Msg::ProviderStepBaseUrlWithHint { current: hint }).into_owned()
}
(WizardStep::ApiKey, None) => t(Msg::ProviderStepApiKey).into_owned(),
(WizardStep::ApiKey, Some(p)) => {
let hint = if p.api_key.is_some() {
t(Msg::ProviderStepApiKeySet)
} else {
t(Msg::ProviderStepApiKeyUnset)
};
t(Msg::ProviderStepApiKeyWithHint { hint: &hint }).into_owned()
}
(WizardStep::Model, None) => t(Msg::ProviderStepModel).into_owned(),
(WizardStep::Model, Some(p)) =>
t(Msg::ProviderStepModelWithHint { current: &p.model }).into_owned(),
}
}
fn show_add_step_prompt(
step: WizardStep,
draft: &DraftProvider,
idx: usize,
total: usize,
buf: &Buffer,
state: &UiState,
ctx: &LoopCtx,
wizard: &ProviderWizard,
renderer: &mut dyn Renderer,
) {
use crate::i18n::{t, Msg};
let body = match step {
WizardStep::Template => t(Msg::ProviderImportPrompt).into_owned(),
WizardStep::Name => t(Msg::ProviderStepNameDefault { default: &draft.name }).into_owned(),
other => step_prompt_text(other, None),
};
if matches!(step, WizardStep::Template) || total == 0 {
push(renderer, &body);
} else {
let progress = t(Msg::ProviderStepProgress { current: idx + 1, total });
push(renderer, &format!("{progress} {body}"));
}
redraw(buf, state, ctx, wizard, renderer);
}
fn show_step_prompt(
step: WizardStep,
existing: Option<&ProviderConfig>,
buf: &Buffer,
state: &UiState,
ctx: &LoopCtx,
wizard: &ProviderWizard,
renderer: &mut dyn Renderer,
) {
push(renderer, &step_prompt_text(step, existing));
redraw(buf, state, ctx, wizard, renderer);
}
#[derive(Debug, PartialEq)]
enum TemplateOutcome {
Manual,
Import,
Retry,
}
fn resolve_template(
draft: &mut DraftProvider,
answer: &str,
existing: &std::collections::HashMap<String, ProviderConfig>,
renderer: &mut dyn Renderer,
) -> TemplateOutcome {
use crate::i18n::{t, Msg};
if answer.trim().is_empty() {
return TemplateOutcome::Manual;
}
let parsed = parse_template(answer);
let Some(raw_url) = parsed.url.as_deref() else {
push(renderer, &t(Msg::ProviderImportFailed));
return TemplateOutcome::Retry;
};
let ptype = infer_type(raw_url);
draft.provider_type = ptype.to_string();
draft.base_url = normalize_base_url(raw_url, ptype);
if let Some(k) = parsed.api_key {
if !is_placeholder(&k) {
draft.api_key = k;
}
}
if let Some(m) = parsed.model {
if !m.trim().is_empty() {
draft.model = m;
}
}
let base = parsed
.name
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| derive_name(&draft.base_url, ptype, |n| existing.contains_key(n)));
draft.name = dedupe_name(&base, |n| existing.contains_key(n));
let model_disp = if draft.model.is_empty() { "?" } else { &draft.model };
push(
renderer,
&t(Msg::ProviderImportParsed {
base_url: &draft.base_url,
type_name: ptype,
model: model_disp,
}),
);
TemplateOutcome::Import
}
fn apply_manual_base_url(
draft: &mut DraftProvider,
url: &str,
existing: &std::collections::HashMap<String, ProviderConfig>,
) -> &'static str {
let ptype = infer_type(url);
draft.base_url = url.to_string();
draft.provider_type = ptype.to_string();
ensure_name_default(draft, existing);
ptype
}
fn import_plan(draft: &DraftProvider) -> Vec<WizardStep> {
let mut plan = Vec::new();
if draft.api_key.is_empty() {
plan.push(WizardStep::ApiKey);
}
if draft.model.is_empty() {
plan.push(WizardStep::Model);
}
plan.push(WizardStep::Name);
plan
}
fn ensure_name_default(
draft: &mut DraftProvider,
existing: &std::collections::HashMap<String, ProviderConfig>,
) {
if draft.name.is_empty() {
draft.name = derive_name(&draft.base_url, &draft.provider_type, |n| {
existing.contains_key(n)
});
}
}
#[derive(Debug, PartialEq)]
enum StepOutcome {
Ok,
Retry,
}
fn store_step(
draft: &mut DraftProvider,
step: WizardStep,
answer: &str,
existing: &std::collections::HashMap<String, ProviderConfig>,
renderer: &mut dyn Renderer,
) -> StepOutcome {
let ans = answer.trim();
match step {
WizardStep::ApiKey => draft.api_key = ans.to_string(),
WizardStep::Model => {
if ans.is_empty() {
push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderModelEmpty));
return StepOutcome::Retry;
}
draft.model = ans.to_string();
}
WizardStep::Name => {
let chosen = if ans.is_empty() {
draft.name.clone()
} else {
ans.to_string()
};
draft.name = dedupe_name(&chosen, |n| existing.contains_key(n));
}
WizardStep::Template | WizardStep::BaseUrl | WizardStep::ProviderType => {}
}
StepOutcome::Ok
}
fn advance_edit(
draft: &mut DraftProvider,
step: WizardStep,
answer: &str,
renderer: &mut dyn Renderer,
) -> Option<WizardStep> {
let ans = answer.trim();
match step {
WizardStep::Template | WizardStep::Name => {
Some(WizardStep::ProviderType)
}
WizardStep::ProviderType => {
if !ans.is_empty() && !["openai", "claude", "ollama"].contains(&ans) {
push(
renderer,
&crate::i18n::t(crate::i18n::Msg::ProviderUnknownTypeEdit),
);
return Some(WizardStep::ProviderType);
}
draft.provider_type = ans.to_string();
Some(WizardStep::BaseUrl)
}
WizardStep::BaseUrl => {
draft.base_url = ans.to_string();
Some(WizardStep::ApiKey)
}
WizardStep::ApiKey => {
draft.api_key = ans.to_string();
Some(WizardStep::Model)
}
WizardStep::Model => {
draft.model = ans.to_string();
None
}
}
}
#[derive(Debug, Default, PartialEq)]
struct ParsedTemplate {
url: Option<String>,
api_key: Option<String>,
model: Option<String>,
name: Option<String>,
}
fn parse_template(input: &str) -> ParsedTemplate {
let lower = input.to_ascii_lowercase();
let is_curl = lower.contains("curl")
|| lower.contains("authorization")
|| lower.contains("x-api-key")
|| lower.contains(" -h ")
|| lower.contains("-d ");
if is_curl {
ParsedTemplate {
url: first_url(input),
api_key: curl_api_key(input),
model: quoted_value_after(input, "model"),
name: None,
}
} else {
ParsedTemplate {
url: quoted_value_after(input, "base_url")
.or_else(|| quoted_value_after(input, "baseurl")),
api_key: quoted_value_after(input, "api_key")
.or_else(|| quoted_value_after(input, "apikey")),
model: quoted_value_after(input, "model"),
name: toml_provider_name(input),
}
}
}
fn first_url(input: &str) -> Option<String> {
let lower = input.to_ascii_lowercase();
let start = ["https://", "http://"]
.iter()
.filter_map(|s| lower.find(s))
.min()?;
let rest = &input[start..];
let end = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '\\')
.unwrap_or(rest.len());
Some(rest[..end].to_string())
}
/// API key from a curl command: `Authorization: Bearer X` or `x-api-key: X`.
fn curl_api_key(input: &str) -> Option<String> {
let lower = input.to_ascii_lowercase();
if let Some(i) = lower.find("authorization") {
let rest = &input[i..];
if let Some(b) = lower[i..].find("bearer") {
return Some(take_token(&rest[b + "bearer".len()..]));
}
if let Some(c) = rest.find(':') {
return Some(take_token(&rest[c + 1..]));
}
}
if let Some(i) = lower.find("x-api-key") {
let rest = &input[i + "x-api-key".len()..];
if let Some(c) = rest.find(':') {
return Some(take_token(&rest[c + 1..]));
}
}
None
}
/// Trim leading whitespace then read up to the next whitespace/quote/backslash.
fn take_token(s: &str) -> String {
let s = s.trim_start();
let end = s
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '\\')
.unwrap_or(s.len());
s[..end].to_string()
}
/// Value of a `key: "value"` / `key = "value"` pair (JSON or TOML style),
/// matching the key case-insensitively. Skips the key's own closing quote.
fn quoted_value_after(input: &str, key: &str) -> Option<String> {
let lower = input.to_ascii_lowercase();
let key_l = key.to_ascii_lowercase();
let mut from = 0;
while let Some(rel) = lower[from..].find(&key_l) {
let pos = from + rel + key.len();
let rest = &input[pos..];
if let Some(sep) = rest.find(|c| c == ':' || c == '=') {
// Only quotes/whitespace may sit between the key and its separator.
if rest[..sep]
.chars()
.all(|c| c == '"' || c == '\'' || c.is_whitespace())
{
let after_sep = &rest[sep + 1..];
if let Some(oq) = after_sep.find(|c| c == '"' || c == '\'') {
let quote = after_sep.as_bytes()[oq] as char;
let val_start = &after_sep[oq + 1..];
if let Some(end) = val_start.find(quote) {
return Some(val_start[..end].to_string());
}
}
}
}
from = pos;
}
None
}
fn toml_provider_name(input: &str) -> Option<String> {
let i = input.find("[providers.")?;
let rest = &input[i + "[providers.".len()..];
let end = rest.find(']')?;
Some(rest[..end].trim().to_string())
}
fn normalize_base_url(raw_url: &str, provider_type: &str) -> String {
let url = raw_url.trim().trim_end_matches('/');
match provider_type {
"claude" | "ollama" => match url.find("://") {
Some(scheme_end) => {
let after = scheme_end + 3;
let authority_end = url[after..].find('/').map_or(url.len(), |i| after + i);
url[..authority_end].to_string()
}
None => url.to_string(),
},
_ => {
const ENDPOINTS: [&str; 5] = [
"/chat/completions",
"/completions",
"/embeddings",
"/responses",
"/messages",
];
for ep in ENDPOINTS {
if let Some(stripped) = url.strip_suffix(ep) {
return stripped.to_string();
}
}
url.to_string()
}
}
}
fn is_placeholder(value: &str) -> bool {
let v = value.trim();
if v.is_empty() {
return true;
}
if v.starts_with('$') {
return true;
}
if v.starts_with('<') && v.ends_with('>') {
return true;
}
if v.contains('_')
&& v.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
&& (v.contains("KEY") || v.contains("TOKEN") || v.contains("SECRET"))
{
return true;
}
let lower = v.to_ascii_lowercase();
if lower.contains("your") && lower.contains("key") {
return true;
}
if matches!(lower.as_str(), "api_key" | "apikey" | "placeholder" | "token") {
return true;
}
let bytes = lower.as_bytes();
bytes.windows(4).any(|w| w == b"xxxx") || v.as_bytes().windows(4).any(|w| w == b"****")
}
fn infer_type(base_url: &str) -> &'static str {
let url = base_url.to_ascii_lowercase();
if url.contains("anthropic") {
"claude"
} else if url.contains("ollama") || url.contains("11434") {
"ollama"
} else {
"openai"
}
}
fn derive_name(base_url: &str, provider_type: &str, is_taken: impl Fn(&str) -> bool) -> String {
let host = base_url
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
let labels: Vec<&str> = host.split('.').filter(|s| !s.is_empty()).collect();
let label = if labels.len() >= 2 {
let mut i = labels.len() - 2;
const GENERIC_SLD: [&str; 7] = ["com", "net", "org", "co", "gov", "edu", "ac"];
if i >= 1 && GENERIC_SLD.contains(&labels[i]) {
i -= 1;
}
labels[i]
} else {
""
};
let base = if label.chars().any(|c| c.is_ascii_alphabetic()) {
label
} else {
provider_type
};
dedupe_name(base, is_taken)
}
fn dedupe_name(base: &str, is_taken: impl Fn(&str) -> bool) -> String {
if !is_taken(base) {
return base.to_string();
}
let mut n = 2;
loop {
let candidate = format!("{base}-{n}");
if !is_taken(&candidate) {
return candidate;
}
n += 1;
}
}
fn forward_to_buffer(code: KeyCode, modifiers: KeyModifiers, buf: &mut Buffer, state: &mut UiState, ctx: &LoopCtx) {
let action = classify(code, modifiers);
let _ = buf.apply(action, ctx.history.entries(), &ctx.commands);
crate::event_loop::sync_recalled_attachments(state, buf, ctx.history.entries());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_template_curl_openai() {
let input = r#"curl -X POST https://taotoken.net/api/v1/chat/completions \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.7-max","messages":[{"role":"user","content":"你好"}]}'"#;
let p = parse_template(input);
assert_eq!(p.url.as_deref(), Some("https://taotoken.net/api/v1/chat/completions"));
assert_eq!(p.api_key.as_deref(), Some("<API_KEY>"));
assert_eq!(p.model.as_deref(), Some("qwen3.7-max"));
}
#[test]
fn parse_template_curl_anthropic_x_api_key() {
let input = r#"curl https://api.anthropic.com/v1/messages \
-H "x-api-key: sk-ant-123" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"claude-opus-4","max_tokens":1024}'"#;
let p = parse_template(input);
assert_eq!(p.url.as_deref(), Some("https://api.anthropic.com/v1/messages"));
assert_eq!(p.api_key.as_deref(), Some("sk-ant-123"));
assert_eq!(p.model.as_deref(), Some("claude-opus-4"));
}
#[test]
fn parse_template_python_requests_snippet() {
let input = r#"import requests
API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer MWtoCL7zchAZpQGssV6uDdRE",
}
chunks = query({
"model": "deepseek-ai/DeepSeek-R1",
"stream": True,
})"#;
let p = parse_template(input);
assert_eq!(p.url.as_deref(), Some("https://api-ai.gitcode.com/v1/chat/completions"));
assert_eq!(p.api_key.as_deref(), Some("MWtoCL7zchAZpQGssV6uDdRE"));
assert_eq!(p.model.as_deref(), Some("deepseek-ai/DeepSeek-R1"));
}
#[test]
fn parse_template_json_block() {
let input = r#"{"type":"openai","base_url":"https://x.ai/v1","api_key":"sk-1","model":"grok-2"}"#;
let p = parse_template(input);
assert_eq!(p.url.as_deref(), Some("https://x.ai/v1"));
assert_eq!(p.api_key.as_deref(), Some("sk-1"));
assert_eq!(p.model.as_deref(), Some("grok-2"));
}
#[test]
fn parse_template_toml_block() {
let input = "[providers.foo]\ntype = \"openai\"\nbase_url = \"https://y.com/v1\"\napi_key = \"sk-2\"\nmodel = \"m2\"";
let p = parse_template(input);
assert_eq!(p.url.as_deref(), Some("https://y.com/v1"));
assert_eq!(p.api_key.as_deref(), Some("sk-2"));
assert_eq!(p.model.as_deref(), Some("m2"));
assert_eq!(p.name.as_deref(), Some("foo"));
}
#[test]
fn normalize_base_url_openai_strips_endpoint() {
assert_eq!(
normalize_base_url("https://taotoken.net/api/v1/chat/completions", "openai"),
"https://taotoken.net/api/v1"
);
}
#[test]
fn normalize_base_url_openai_keeps_plain() {
assert_eq!(normalize_base_url("https://x.ai/v1", "openai"), "https://x.ai/v1");
}
#[test]
fn normalize_base_url_claude_strips_path() {
assert_eq!(
normalize_base_url("https://api.anthropic.com/v1/messages", "claude"),
"https://api.anthropic.com"
);
}
#[test]
fn normalize_base_url_ollama_keeps_host_port() {
assert_eq!(
normalize_base_url("http://localhost:11434/api/chat", "ollama"),
"http://localhost:11434"
);
}
#[test]
fn is_placeholder_detects_angle_brackets() {
assert!(is_placeholder("<API_KEY>"));
}
#[test]
fn is_placeholder_detects_your_api_key() {
assert!(is_placeholder("YOUR_API_KEY"));
}
#[test]
fn is_placeholder_detects_x_run() {
assert!(is_placeholder("sk-xxxxxxxx"));
}
#[test]
fn is_placeholder_accepts_real_key() {
assert!(!is_placeholder("sk-ant-abc123def456"));
assert!(!is_placeholder("9f8c7b6a5"));
assert!(!is_placeholder("MWtoCL7zchAZpQGssV6uDdRE"));
}
#[test]
fn is_placeholder_detects_shell_var() {
assert!(is_placeholder("$OPENROUTER_API_KEY"));
assert!(is_placeholder("${OPENROUTER_API_KEY}"));
assert!(is_placeholder("$API_KEY"));
}
#[test]
fn is_placeholder_detects_env_var_name() {
assert!(is_placeholder("OPENROUTER_API_KEY"));
assert!(is_placeholder("INSERT_TOKEN_HERE"));
}
#[test]
fn resolve_template_drops_shell_var_key_then_import_asks_key() {
let input = r#"curl https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-d '{"model":"stepfun/step-3.7-flash"}'"#;
let mut d = DraftProvider::default();
let existing = std::collections::HashMap::new();
let mut sink = crate::render::plain::PlainRenderer::with_writer(std::io::sink());
let outcome = resolve_template(&mut d, input, &existing, &mut sink);
assert_eq!(outcome, TemplateOutcome::Import);
assert_eq!(d.base_url, "https://openrouter.ai/api/v1");
assert_eq!(d.model, "stepfun/step-3.7-flash");
assert!(d.api_key.is_empty(), "shell-var key must be dropped, got {:?}", d.api_key);
assert_eq!(d.name, "openrouter");
assert_eq!(import_plan(&d), vec![WizardStep::ApiKey, WizardStep::Name]);
}
#[test]
fn infer_type_detects_claude() {
assert_eq!(infer_type("https://api.anthropic.com"), "claude");
}
#[test]
fn infer_type_detects_ollama_by_port() {
assert_eq!(infer_type("http://localhost:11434"), "ollama");
}
#[test]
fn infer_type_detects_ollama_by_name() {
assert_eq!(infer_type("http://my-ollama-host:8080"), "ollama");
}
#[test]
fn infer_type_defaults_to_openai() {
assert_eq!(infer_type("https://api.deepseek.com/v1"), "openai");
}
#[test]
fn infer_type_empty_defaults_to_openai() {
assert_eq!(infer_type(""), "openai");
}
#[test]
fn derive_name_strips_api_prefix_and_tld() {
assert_eq!(derive_name("https://api.deepseek.com/v1", "openai", |_| false), "deepseek");
}
#[test]
fn derive_name_handles_cn_tld() {
assert_eq!(derive_name("https://api.moonshot.cn/v1", "openai", |_| false), "moonshot");
}
#[test]
fn derive_name_handles_no_api_prefix() {
assert_eq!(derive_name("https://openrouter.ai/api/v1", "openai", |_| false), "openrouter");
}
#[test]
fn derive_name_prefers_registrable_domain_over_subdomain() {
assert_eq!(derive_name("https://api-ai.gitcode.com/v1", "openai", |_| false), "gitcode");
assert_eq!(derive_name("https://www.openrouter.ai/api/v1", "openai", |_| false), "openrouter");
}
#[test]
fn derive_name_handles_multi_part_tld() {
assert_eq!(derive_name("https://api.example.com.cn/v1", "openai", |_| false), "example");
}
#[test]
fn derive_name_falls_back_to_type_for_localhost() {
assert_eq!(derive_name("http://localhost:11434", "ollama", |_| false), "ollama");
}
#[test]
fn derive_name_falls_back_to_type_when_empty() {
assert_eq!(derive_name("", "claude", |_| false), "claude");
}
#[test]
fn derive_name_suffixes_on_collision() {
assert_eq!(
derive_name("https://api.deepseek.com/v1", "openai", |n| n == "deepseek"),
"deepseek-2"
);
}
fn draft_filled(provider_type: &str, api_key: &str, model: &str) -> DraftProvider {
DraftProvider {
provider_type: provider_type.to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
..Default::default()
}
}
#[test]
fn apply_manual_base_url_infers_type_and_plans_gaps() {
let mut d = DraftProvider::default();
let existing = std::collections::HashMap::new();
let ptype = apply_manual_base_url(&mut d, "https://api.anthropic.com", &existing);
assert_eq!(ptype, "claude");
assert_eq!(d.base_url, "https://api.anthropic.com");
assert_eq!(d.provider_type, "claude");
assert_eq!(d.name, "anthropic");
assert_eq!(
import_plan(&d),
vec![WizardStep::ApiKey, WizardStep::Model, WizardStep::Name]
);
}
#[test]
fn import_plan_only_covers_gaps() {
assert_eq!(
import_plan(&draft_filled("openai", "", "")),
vec![WizardStep::ApiKey, WizardStep::Model, WizardStep::Name]
);
assert_eq!(
import_plan(&draft_filled("openai", "sk-1", "gpt-4o")),
vec![WizardStep::Name]
);
assert_eq!(
import_plan(&draft_filled("openai", "", "gpt-4o")),
vec![WizardStep::ApiKey, WizardStep::Name]
);
}
#[test]
fn resolve_template_blank_goes_manual() {
let mut d = DraftProvider::default();
let existing = std::collections::HashMap::new();
let mut sink = crate::render::plain::PlainRenderer::with_writer(std::io::sink());
assert_eq!(
resolve_template(&mut d, "", &existing, &mut sink),
TemplateOutcome::Manual
);
assert!(d.base_url.is_empty());
}
#[test]
fn resolve_template_curl_fills_draft() {
let input = r#"curl https://taotoken.net/api/v1/chat/completions \
-H "Authorization: Bearer <API_KEY>" \
-d '{"model":"qwen3.7-max"}'"#;
let mut d = DraftProvider::default();
let existing = std::collections::HashMap::new();
let mut sink = crate::render::plain::PlainRenderer::with_writer(std::io::sink());
assert_eq!(
resolve_template(&mut d, input, &existing, &mut sink),
TemplateOutcome::Import
);
assert_eq!(d.provider_type, "openai");
assert_eq!(d.base_url, "https://taotoken.net/api/v1");
assert_eq!(d.model, "qwen3.7-max");
assert!(d.api_key.is_empty(), "placeholder key must be dropped");
assert_eq!(d.name, "taotoken");
assert_eq!(import_plan(&d), vec![WizardStep::ApiKey, WizardStep::Name]);
}
#[test]
fn resolve_template_bare_url_reprompts_not_base_url() {
let mut d = DraftProvider::default();
let existing = std::collections::HashMap::new();
let mut sink = crate::render::plain::PlainRenderer::with_writer(std::io::sink());
assert_eq!(
resolve_template(&mut d, "https://api.deepseek.com/v1", &existing, &mut sink),
TemplateOutcome::Retry
);
assert!(d.base_url.is_empty());
}
#[test]
fn derive_name_suffixes_until_free() {
assert_eq!(
derive_name("https://api.deepseek.com/v1", "openai", |n| n == "deepseek" || n == "deepseek-2"),
"deepseek-3"
);
}
}