feat(login): custom provider logins fetch and select models
After the base URL, name, and key steps, the login now opens a model
selection screen instead of blindly saving. The listing fetch runs
concurrently with a manual-id input — a WAF challenge page or a 404 no
longer passes validation silently: a base without a /vN tail is probed
once as {base}/v1 and the working base is adopted, an anthropic-wire
listing that 401s on x-api-key retries once with Bearer (relays gate
GET /models on Bearer while /messages takes the key header), and a
failed fetch shows its reason while manual add stays usable. The chosen
ids persist as [platforms.<name>] models = [...]; catalog assembly
synthesizes selected ids the listing never served (before [model.*]
overrides so they compose) and filters unselected provider entries
(after overrides, authoritative in the provider namespace). Provider-
owned entries keep their declared wire against donor backend
inheritance, and listing errors quote line/column only — a reflected
body could carry the key.
This commit is contained in:
1 parent
1d631a07fa
commit
9a2a9eac99
20 files changed
+1655
-68
No files matched your search
@@ -574,6 +574,8 @@ pub enum Action {
|
||||
BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi),
|
||||
/// Enter in a custom provider step.
|
||||
SubmitCustomProviderInput(String),
|
||||
/// Enter on an empty manual-model box: finish the model selection.
|
||||
SubmitCustomProviderSelection,
|
||||
/// Copy the auth URL to the clipboard during authentication.
|
||||
CopyAuthUrl,
|
||||
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
||||
@@ -1623,6 +1625,13 @@ pub enum Effect {
|
||||
request_seq: u64,
|
||||
provider: kigi_shell::models::custom::CustomProvider,
|
||||
key: String,
|
||||
models: Vec<String>,
|
||||
},
|
||||
/// List the provider's models for the login selection screen.
|
||||
FetchCustomProviderModels {
|
||||
request_seq: u64,
|
||||
provider: kigi_shell::models::custom::CustomProvider,
|
||||
key: String,
|
||||
},
|
||||
/// Fetch MCP server list from the shell (kigi/mcp/list).
|
||||
FetchMcpsList {
|
||||
@@ -2166,6 +2175,11 @@ pub enum TaskResult {
|
||||
AuthCodeSubmitted {
|
||||
request_seq: u64,
|
||||
},
|
||||
/// The login-step model listing came back (or failed).
|
||||
CustomProviderModelsListed {
|
||||
request_seq: u64,
|
||||
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
|
||||
},
|
||||
/// MCP server list fetched from shell.
|
||||
McpsListLoaded {
|
||||
agent_id: AgentId,
|
||||
|
||||
@@ -264,6 +264,8 @@ pub enum AuthMode {
|
||||
ApiKeyEntry(PlatformLogin),
|
||||
/// Custom provider login, one field per step.
|
||||
CustomProviderEntry(crate::app::custom_entry::CustomEntryStep),
|
||||
/// Custom provider login, the model-selection screen.
|
||||
CustomProviderSelect,
|
||||
}
|
||||
/// API-key platform login target, selected from the welcome picker. Wraps a
|
||||
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
|
||||
@@ -924,6 +926,8 @@ pub struct AppView {
|
||||
pub auth_code_input: String,
|
||||
/// Values collected by the custom provider login steps.
|
||||
pub custom_entry: crate::app::custom_entry::CustomEntryDraft,
|
||||
/// Model-selection state of a custom provider login; present in that mode.
|
||||
pub custom_select: Option<crate::app::custom_entry::CustomSelectState>,
|
||||
/// Monotonically increasing sequence number for auth requests.
|
||||
pub next_auth_request_seq: u64,
|
||||
/// Every session/chat/worktree/prompt action deferred behind startup gates.
|
||||
@@ -1128,6 +1132,7 @@ impl AppView {
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
@@ -1663,6 +1668,7 @@ impl AppView {
|
||||
mid_session_login: self.auth_return_view.is_some(),
|
||||
auth_methods: &self.auth_methods,
|
||||
auth_code_input: &mut self.auth_code_input,
|
||||
custom_select: &mut self.custom_select,
|
||||
prompt: &mut self.welcome_prompt,
|
||||
prompt_focused: &mut self.welcome_prompt_focused,
|
||||
new_worktree_dialog: &mut self.new_worktree_dialog,
|
||||
@@ -2188,6 +2194,7 @@ struct WelcomeInputCtx<'a> {
|
||||
/// the `AuthState::Pending` welcome menu.
|
||||
auth_methods: &'a [acp::AuthMethod],
|
||||
auth_code_input: &'a mut String,
|
||||
custom_select: &'a mut Option<crate::app::custom_entry::CustomSelectState>,
|
||||
prompt: &'a mut PromptWidget,
|
||||
prompt_focused: &'a mut bool,
|
||||
new_worktree_dialog: &'a mut Option<NewWorktreeDialogState>,
|
||||
@@ -2712,6 +2719,70 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => {
|
||||
// Esc cancels back to the picker, like the entry steps.
|
||||
if key!(Esc).matches(key) {
|
||||
return InputOutcome::Action(Action::CancelPlatformKeyEntry);
|
||||
}
|
||||
if key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) {
|
||||
if ctx.mid_session_login {
|
||||
return InputOutcome::Action(Action::CancelLogin);
|
||||
}
|
||||
return InputOutcome::Action(Action::QuitConfirmed);
|
||||
}
|
||||
let Some(select) = ctx.custom_select.as_mut() else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
if key!(Up).matches(key) {
|
||||
select.move_highlight(-1);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Down).matches(key) {
|
||||
select.move_highlight(1);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Model ids hold no spaces, so the spacebar toggles rows.
|
||||
if key!(Char(' ')).matches(key) {
|
||||
select.toggle_highlighted();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if crate::input::key::is_paste_key(key)
|
||||
|| crate::input::key::is_inline_paste_key(key)
|
||||
{
|
||||
if let Some(text) = crate::clipboard::system_clipboard_get() {
|
||||
let cleaned: String =
|
||||
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
ctx.auth_code_input.push_str(&cleaned);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if key!(Enter).matches(key) {
|
||||
let trimmed = ctx.auth_code_input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return InputOutcome::Action(Action::SubmitCustomProviderSelection);
|
||||
}
|
||||
select.add_manual(&trimmed);
|
||||
if select.error.is_none() {
|
||||
ctx.auth_code_input.clear();
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Backspace).matches(key) {
|
||||
ctx.auth_code_input.pop();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code
|
||||
&& crate::input::key::is_text_input_key(key)
|
||||
{
|
||||
ctx.auth_code_input.push(c);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::Loopback,
|
||||
..
|
||||
@@ -2775,7 +2846,10 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode:
|
||||
AuthMode::Loopback | AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
|
||||
AuthMode::Loopback
|
||||
| AuthMode::ApiKeyEntry(_)
|
||||
| AuthMode::CustomProviderEntry(_)
|
||||
| AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => {
|
||||
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
@@ -3276,6 +3350,7 @@ impl AppView {
|
||||
login_label: self.login_label.as_deref(),
|
||||
auth_code_input: &self.auth_code_input,
|
||||
custom_entry: &self.custom_entry,
|
||||
custom_select: self.custom_select.as_ref(),
|
||||
clipboard_copied: self.auth_clipboard_copied,
|
||||
show_raw_url: self.auth_show_raw_url,
|
||||
tip,
|
||||
@@ -4412,6 +4487,7 @@ pub(crate) mod tests {
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
@@ -7632,12 +7708,24 @@ pub(crate) mod tests {
|
||||
/// Ctrl+V / Super+V paste the host clipboard into every auth input.
|
||||
#[test]
|
||||
fn auth_inputs_paste_key_chords_insert_clipboard_text() {
|
||||
let select_state = || {
|
||||
crate::app::custom_entry::CustomSelectState::new(
|
||||
kigi_shell::models::custom::CustomProvider::new(
|
||||
"gw",
|
||||
kigi_shell::models::custom::CustomApi::OpenAi,
|
||||
"https://h.example/v1",
|
||||
)
|
||||
.unwrap(),
|
||||
"sk".to_owned(),
|
||||
)
|
||||
};
|
||||
let modes = [
|
||||
AuthMode::Loopback,
|
||||
AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
|
||||
AuthMode::CustomProviderEntry(crate::app::custom_entry::CustomEntryStep::first(
|
||||
kigi_shell::models::custom::CustomApi::OpenAi,
|
||||
)),
|
||||
AuthMode::CustomProviderSelect,
|
||||
];
|
||||
for mode in modes {
|
||||
for mods in [KeyModifiers::CONTROL, KeyModifiers::SUPER] {
|
||||
@@ -7648,12 +7736,13 @@ pub(crate) mod tests {
|
||||
auth_url: None,
|
||||
mode,
|
||||
};
|
||||
crate::clipboard::set_clipboard_probe_hook(
|
||||
crate::clipboard::ClipboardProbeHook {
|
||||
text: Some("sk-pasted\nkey".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if matches!(mode, AuthMode::CustomProviderSelect) {
|
||||
app.custom_select = Some(select_state());
|
||||
}
|
||||
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
|
||||
text: Some("sk-pasted\nkey".to_owned()),
|
||||
..Default::default()
|
||||
});
|
||||
let outcome = app.handle_input(&key_event(KeyCode::Char('v'), mods));
|
||||
crate::clipboard::clear_clipboard_probe_hook();
|
||||
assert!(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Custom provider login steps: base URL, name, key.
|
||||
|
||||
use kigi_shell::models::custom::{
|
||||
CustomApi, CustomProvider, default_name, normalize_base_url, validate_name,
|
||||
CustomApi, CustomProvider, default_name, normalize_base_url, validate_model_id, validate_name,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -141,6 +141,141 @@ impl CustomEntryDraft {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch status of the model-selection screen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SelectFetch {
|
||||
InFlight,
|
||||
Listed,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// One selectable model row; `manual` marks user-typed ids.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SelectRow {
|
||||
pub id: String,
|
||||
pub selected: bool,
|
||||
pub manual: bool,
|
||||
}
|
||||
|
||||
/// Model selection after the key step: fetched rows plus manual adds.
|
||||
#[derive(Clone)]
|
||||
pub struct CustomSelectState {
|
||||
/// The fetch may adopt a `/v1` base URL.
|
||||
pub provider: CustomProvider,
|
||||
key: String,
|
||||
pub rows: Vec<SelectRow>,
|
||||
pub highlight: usize,
|
||||
pub fetch: SelectFetch,
|
||||
pub error: Option<String>,
|
||||
/// Enter hit while the listing is in flight; it completes on arrival.
|
||||
pub finish_requested: bool,
|
||||
}
|
||||
|
||||
impl CustomSelectState {
|
||||
pub fn new(provider: CustomProvider, key: String) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
key,
|
||||
rows: Vec::new(),
|
||||
highlight: 0,
|
||||
fetch: SelectFetch::InFlight,
|
||||
error: None,
|
||||
finish_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_key(self) -> String {
|
||||
self.key
|
||||
}
|
||||
|
||||
pub fn apply_fetch(&mut self, fetch: kigi_shell::agent::custom_providers::LoginFetch) {
|
||||
self.provider = fetch.provider;
|
||||
for id in fetch.model_ids {
|
||||
if self.rows.iter().any(|r| r.id == id) {
|
||||
continue;
|
||||
}
|
||||
self.rows.push(SelectRow {
|
||||
id,
|
||||
selected: false,
|
||||
manual: false,
|
||||
});
|
||||
}
|
||||
self.fetch = SelectFetch::Listed;
|
||||
}
|
||||
|
||||
pub fn fail_fetch(&mut self, reason: String) {
|
||||
self.fetch = SelectFetch::Failed(reason);
|
||||
}
|
||||
|
||||
pub fn move_highlight(&mut self, delta: isize) {
|
||||
if self.rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max = self.rows.len() - 1;
|
||||
self.highlight = self.highlight.saturating_add_signed(delta).min(max);
|
||||
}
|
||||
|
||||
pub fn toggle_highlighted(&mut self) {
|
||||
if let Some(row) = self.rows.get_mut(self.highlight) {
|
||||
row.selected = !row.selected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a typed id selected; an id already listed just gets selected.
|
||||
pub fn add_manual(&mut self, raw: &str) {
|
||||
let id = raw.trim();
|
||||
if let Err(e) = validate_model_id(id) {
|
||||
self.error = Some(e.to_string());
|
||||
return;
|
||||
}
|
||||
if let Some(row) = self.rows.iter_mut().find(|r| r.id == id) {
|
||||
row.selected = true;
|
||||
} else {
|
||||
self.rows.push(SelectRow {
|
||||
id: id.to_owned(),
|
||||
selected: true,
|
||||
manual: true,
|
||||
});
|
||||
}
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// The chosen ids, or a refusal when nothing is chosen.
|
||||
pub fn try_finish(&mut self) -> Option<Vec<String>> {
|
||||
// Wait for the listing: it may adopt a probed `/v1` base URL.
|
||||
if self.fetch == SelectFetch::InFlight {
|
||||
self.finish_requested = true;
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<String> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.selected)
|
||||
.map(|r| r.id.clone())
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
self.finish_requested = false;
|
||||
self.error = Some("Select at least one model, or type one and press enter".to_owned());
|
||||
return None;
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CustomSelectState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CustomSelectState")
|
||||
.field("provider", &self.provider)
|
||||
.field("key", &"<set>")
|
||||
.field("rows", &self.rows)
|
||||
.field("highlight", &self.highlight)
|
||||
.field("fetch", &self.fetch)
|
||||
.field("error", &self.error)
|
||||
.field("finish_requested", &self.finish_requested)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -236,4 +371,69 @@ mod tests {
|
||||
assert!(!step(CustomEntryField::BaseUrl).masks_input());
|
||||
assert!(!step(CustomEntryField::Name).masks_input());
|
||||
}
|
||||
|
||||
fn select_state() -> CustomSelectState {
|
||||
let provider =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
CustomSelectState::new(provider, "sk-secret".to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_toggles_adds_and_finishes() {
|
||||
let mut state = select_state();
|
||||
assert!(state.try_finish().is_none(), "in flight: finish waits");
|
||||
assert!(state.finish_requested && state.error.is_none());
|
||||
state.finish_requested = false;
|
||||
|
||||
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: state.provider.clone(),
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
});
|
||||
assert!(state.try_finish().is_none());
|
||||
assert!(state.error.is_some(), "listed: an empty selection refuses");
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
|
||||
state.toggle_highlighted();
|
||||
state.move_highlight(1);
|
||||
state.toggle_highlighted();
|
||||
state.move_highlight(9);
|
||||
assert_eq!(state.highlight, 1, "highlight clamps at the last row");
|
||||
|
||||
state.add_manual(" claude-opus-5-5[1m] ");
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
state.add_manual("m1");
|
||||
assert_eq!(
|
||||
state.rows.len(),
|
||||
3,
|
||||
"a listed id is selected, not duplicated"
|
||||
);
|
||||
state.add_manual("has space");
|
||||
assert!(state.error.is_some());
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
|
||||
let ids = state.try_finish().expect("three selected");
|
||||
assert_eq!(ids, ["m1", "m2", "claude-opus-5-5[1m]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_apply_fetch_adopts_the_probed_base_and_dedups() {
|
||||
let mut state = select_state();
|
||||
state.add_manual("m1");
|
||||
let adopted =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/coding/v1").unwrap();
|
||||
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
});
|
||||
assert_eq!(state.provider.base_url, "https://h.example/coding/v1");
|
||||
assert_eq!(state.rows.len(), 2, "the manual row covers the fetched id");
|
||||
assert!(state.rows[0].manual && state.rows[0].selected);
|
||||
assert!(!state.rows[1].selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_debug_hides_the_key() {
|
||||
let state = select_state();
|
||||
assert!(!format!("{state:?}").contains("sk-secret"));
|
||||
}
|
||||
}
|
||||
@@ -282,6 +282,7 @@ pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = None;
|
||||
app.auth_in_flight_method = None;
|
||||
restore_auth_return_view(app, return_view);
|
||||
// The user bailed out of re-auth — drop stashed prompts and strip the
|
||||
@@ -335,6 +336,7 @@ pub(super) fn dispatch_begin_custom_provider_entry(
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_entry.clear();
|
||||
app.custom_select = None;
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
@@ -373,18 +375,20 @@ pub(super) fn dispatch_submit_custom_provider_input(
|
||||
vec![]
|
||||
}
|
||||
CustomEntryOutcome::Done { provider, key } => {
|
||||
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
|
||||
&provider.name,
|
||||
));
|
||||
// Key accepted: open the model selection and fetch concurrently.
|
||||
app.custom_entry.clear();
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = Some(crate::app::custom_entry::CustomSelectState::new(
|
||||
provider.clone(),
|
||||
key.clone(),
|
||||
));
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
};
|
||||
vec![Effect::PersistCustomProviderAndAuthenticate {
|
||||
vec![Effect::FetchCustomProviderModels {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
@@ -393,6 +397,81 @@ pub(super) fn dispatch_submit_custom_provider_input(
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter on an empty manual-model box: persist the chosen models, then auth.
|
||||
pub(super) fn dispatch_submit_custom_provider_selection(app: &mut AppView) -> Vec<Effect> {
|
||||
let request_seq = match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => *request_seq,
|
||||
_ => return vec![],
|
||||
};
|
||||
complete_custom_provider_selection(app, request_seq)
|
||||
}
|
||||
|
||||
/// Persists the selection once the listing no longer gates the base URL.
|
||||
fn complete_custom_provider_selection(app: &mut AppView, request_seq: u64) -> Vec<Effect> {
|
||||
let Some(state) = app.custom_select.as_mut() else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(models) = state.try_finish() else {
|
||||
return vec![];
|
||||
};
|
||||
let state = app.custom_select.take().expect("checked above");
|
||||
let provider = state.provider.clone();
|
||||
let key = state.take_key();
|
||||
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
|
||||
&provider.name,
|
||||
));
|
||||
app.auth_code_input.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
vec![Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
models,
|
||||
}]
|
||||
}
|
||||
|
||||
/// The login-step model listing came back; fill or flag the selection.
|
||||
pub(super) fn handle_custom_provider_models_listed(
|
||||
app: &mut AppView,
|
||||
request_seq: u64,
|
||||
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
|
||||
) -> Vec<Effect> {
|
||||
let in_flight = matches!(
|
||||
&app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
request_seq: current_seq,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} if *current_seq == request_seq
|
||||
);
|
||||
if !in_flight {
|
||||
return vec![];
|
||||
}
|
||||
match (app.custom_select.as_mut(), result) {
|
||||
(Some(state), Ok(fetch)) => state.apply_fetch(fetch),
|
||||
(Some(state), Err(reason)) => state.fail_fetch(reason),
|
||||
(None, _) => {}
|
||||
}
|
||||
// A finish pressed during the fetch completes now, with the adopted base.
|
||||
if app
|
||||
.custom_select
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.finish_requested)
|
||||
{
|
||||
return complete_custom_provider_selection(app, request_seq);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Esc in the API-key paste box: back to the login picker (no error line).
|
||||
/// Bumps the request seq so any stale in-flight auth result is dropped by
|
||||
/// the `AuthComplete`/`AuthFailed` guards.
|
||||
@@ -400,7 +479,9 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
||||
if !matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
|
||||
mode: AuthMode::ApiKeyEntry(_)
|
||||
| AuthMode::CustomProviderEntry(_)
|
||||
| AuthMode::CustomProviderSelect,
|
||||
..
|
||||
}
|
||||
) {
|
||||
@@ -409,6 +490,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_entry.clear();
|
||||
app.custom_select = None;
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ use super::auth::{
|
||||
dispatch_begin_custom_provider_entry, dispatch_begin_platform_key_entry, dispatch_cancel_login,
|
||||
dispatch_cancel_platform_key_entry, dispatch_login, dispatch_login_with, dispatch_logout,
|
||||
dispatch_open_login_picker, dispatch_submit_auth_code, dispatch_submit_custom_provider_input,
|
||||
dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||
dispatch_submit_custom_provider_selection, dispatch_submit_platform_api_key,
|
||||
dispatch_switch_account,
|
||||
};
|
||||
use super::ctx::{
|
||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||
@@ -894,6 +895,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
Action::SubmitCustomProviderInput(input) => {
|
||||
dispatch_submit_custom_provider_input(app, input)
|
||||
}
|
||||
Action::SubmitCustomProviderSelection => dispatch_submit_custom_provider_selection(app),
|
||||
Action::CopyAuthUrl => {
|
||||
if let AuthState::Authenticating {
|
||||
auth_url: Some(url),
|
||||
|
||||
@@ -509,9 +509,14 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
{
|
||||
app.auth_state = AuthState::Pending { error: Some(error) };
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = None;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::CustomProviderModelsListed {
|
||||
request_seq,
|
||||
result,
|
||||
} => super::auth::handle_custom_provider_models_listed(app, request_seq, result),
|
||||
TaskResult::AuthUrlReady {
|
||||
request_seq,
|
||||
auth_url,
|
||||
@@ -798,6 +803,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
app.login_method_id = None;
|
||||
ensure_login_method(app);
|
||||
app.auth_clipboard_copied = false;
|
||||
app.custom_select = None;
|
||||
let effects = dispatch_exit_session(app);
|
||||
app.welcome_prompt_focused = false;
|
||||
effects
|
||||
|
||||
@@ -582,9 +582,9 @@ fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL, name, key; the last Enter emits one persist effect.
|
||||
/// URL, name, key; the last Enter opens the selection screen and fetches.
|
||||
#[test]
|
||||
fn custom_provider_steps_end_in_one_persist_effect() {
|
||||
fn custom_provider_steps_end_in_the_model_selection() {
|
||||
use crate::app::custom_entry::CustomEntryField;
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
@@ -619,7 +619,7 @@ fn custom_provider_steps_end_in_one_persist_effect() {
|
||||
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
Effect::FetchCustomProviderModels {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
@@ -631,21 +631,236 @@ fn custom_provider_steps_end_in_one_persist_effect() {
|
||||
assert_eq!(provider.base_url, "https://api.gw.example/v1");
|
||||
assert_eq!(key, "sk-gw");
|
||||
}
|
||||
other => panic!("expected exactly the persist effect, got {other:?}"),
|
||||
other => panic!("expected exactly the fetch effect, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::Pending,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} if request_seq == seq
|
||||
));
|
||||
assert!(app.custom_select.is_some());
|
||||
assert!(app.auth_code_input.is_empty());
|
||||
}
|
||||
|
||||
/// The listing fills rows; the selection persists only the chosen models.
|
||||
#[test]
|
||||
fn custom_provider_selection_persists_the_chosen_models() {
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// The listing lands: rows appear, the probed base is adopted.
|
||||
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let listed = kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
};
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Ok(listed),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
let state = app.custom_select.as_ref().expect("selection lives");
|
||||
assert_eq!(state.provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
assert!(!state.rows[0].selected);
|
||||
|
||||
// An empty selection refuses to finish once the listing resolved.
|
||||
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
|
||||
assert!(
|
||||
app.custom_select
|
||||
.as_ref()
|
||||
.and_then(|s| s.error.as_deref())
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// A late manual id typed before the fetch survives the fill.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("claude-opus-5-5[1m]");
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
assert!(state.rows[2].manual && state.rows[2].selected);
|
||||
|
||||
// Select m2 as well, then finish.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.highlight = 1;
|
||||
state.toggle_highlighted();
|
||||
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
models,
|
||||
},
|
||||
] => {
|
||||
assert_eq!(*request_seq, seq);
|
||||
assert_eq!(provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(key, "sk-gw");
|
||||
assert_eq!(models, &["m2".to_owned(), "claude-opus-5-5[1m]".to_owned()]);
|
||||
}
|
||||
other => panic!("expected the persist effect, got {other:?}"),
|
||||
}
|
||||
assert!(app.custom_select.is_none());
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::Pending,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
|
||||
Some("custom:gw")
|
||||
);
|
||||
assert!(app.auth_code_input.is_empty());
|
||||
}
|
||||
|
||||
/// Finishing while the listing is in flight completes when it lands.
|
||||
#[test]
|
||||
fn custom_provider_selection_finish_waits_for_the_fetch() {
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("m1");
|
||||
// Enter with an empty box while the fetch runs: no persist yet.
|
||||
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
|
||||
assert!(app.custom_select.as_ref().expect("lives").finish_requested);
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
// The listing lands with an adopted /v1 base; the finish fires now.
|
||||
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m2".to_owned()],
|
||||
}),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
provider, models, ..
|
||||
},
|
||||
] => {
|
||||
assert_eq!(provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(models, &["m1".to_owned()]);
|
||||
}
|
||||
other => panic!("expected the persist effect, got {other:?}"),
|
||||
}
|
||||
assert!(app.custom_select.is_none());
|
||||
}
|
||||
|
||||
/// A failed listing keeps the manual input usable and shows the reason.
|
||||
#[test]
|
||||
fn custom_provider_selection_survives_a_failed_fetch() {
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Err("https://h.example/v1/models answered HTTP 404".to_owned()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
let state = app.custom_select.as_ref().expect("selection lives");
|
||||
assert!(matches!(
|
||||
&state.fetch,
|
||||
crate::app::custom_entry::SelectFetch::Failed(reason) if reason.contains("404")
|
||||
));
|
||||
|
||||
// A stale result for an older request never clobbers the screen.
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq + 99,
|
||||
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: state.provider.clone(),
|
||||
model_ids: vec!["stale".to_owned()],
|
||||
}),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
assert!(
|
||||
app.custom_select
|
||||
.as_ref()
|
||||
.expect("selection lives")
|
||||
.rows
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Manual add still finishes the flow.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("m1");
|
||||
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::PersistCustomProviderAndAuthenticate { models, .. }] if models == &["m1".to_owned()]
|
||||
));
|
||||
}
|
||||
|
||||
/// A refused field keeps the user on it.
|
||||
|
||||
@@ -127,6 +127,7 @@ fn test_app() -> AppView {
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
|
||||
@@ -1915,13 +1915,15 @@ pub(crate) fn execute(
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
models,
|
||||
} => {
|
||||
let tx = acp_tx.clone();
|
||||
let abort_handle = tasks.spawn(async move {
|
||||
// Persist first so authenticate finds the definition and key.
|
||||
if let Err(e) =
|
||||
kigi_shell::agent::custom_providers::save_custom_provider(&provider, &key)
|
||||
.await
|
||||
if let Err(e) = kigi_shell::agent::custom_providers::save_custom_provider(
|
||||
&provider, &key, &models,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let error = format!("Couldn't save the provider: {e:#}");
|
||||
ulog::error(
|
||||
@@ -1940,6 +1942,23 @@ pub(crate) fn execute(
|
||||
});
|
||||
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
||||
}
|
||||
Effect::FetchCustomProviderModels {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
} => {
|
||||
tasks.spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
kigi_shell::agent::custom_providers::fetch_listing_for_login(&provider, &key)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("listing task failed: {e}")));
|
||||
TaskResult::CustomProviderModelsListed {
|
||||
request_seq,
|
||||
result,
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::SubmitAuthCode { request_seq, code } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
|
||||
Reference in new issue
Block a user