feat(login): custom provider rows, URL / name / key steps, and custom:<name> authenticate
The picker lists Custom provider (OpenAI compatible) and (Anthropic compatible).
The TUI asks for a base URL, a name (default from the host), and a key, saves the
definition and the key, and the agent validates the key against GET {base}/models.
PTY e2e covers both wires against the mock inference server.
This commit is contained in:
1 parent
eb86d7ec26
commit
791f953c07
18 files changed
+1198
-85
No files matched your search
@@ -8,6 +8,7 @@ use crate::app::actions::{Action, Effect};
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLogin};
|
||||
use crate::app::custom_entry::{CustomEntryOutcome, CustomEntryStep};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
@@ -325,6 +326,73 @@ pub(super) fn dispatch_begin_platform_key_entry(
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// A custom provider row was selected: open the first step (base URL).
|
||||
pub(super) fn dispatch_begin_custom_provider_entry(
|
||||
app: &mut AppView,
|
||||
api: kigi_shell::models::custom::CustomApi,
|
||||
) -> Vec<Effect> {
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_entry.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::CustomProviderEntry(CustomEntryStep::first(api)),
|
||||
};
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Enter in a custom provider step: advance, stay with a reason, or save.
|
||||
pub(super) fn dispatch_submit_custom_provider_input(
|
||||
app: &mut AppView,
|
||||
input: String,
|
||||
) -> Vec<Effect> {
|
||||
let (request_seq, step) = match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::CustomProviderEntry(step),
|
||||
..
|
||||
} => (*request_seq, *step),
|
||||
_ => return vec![],
|
||||
};
|
||||
match app.custom_entry.submit(step, &input) {
|
||||
CustomEntryOutcome::Stay => vec![],
|
||||
CustomEntryOutcome::Next { field, prefill } => {
|
||||
app.auth_code_input = prefill;
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::CustomProviderEntry(CustomEntryStep {
|
||||
api: step.api,
|
||||
field,
|
||||
}),
|
||||
};
|
||||
vec![]
|
||||
}
|
||||
CustomEntryOutcome::Done { provider, key } => {
|
||||
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
|
||||
&provider.name,
|
||||
));
|
||||
app.custom_entry.clear();
|
||||
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,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -332,7 +400,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
||||
if !matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::ApiKeyEntry(_),
|
||||
mode: AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
|
||||
..
|
||||
}
|
||||
) {
|
||||
@@ -340,6 +408,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.auth_state = AuthState::Pending { error: None };
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Top-level action router: maps actions and action results to handlers.
|
||||
use super::auth::{
|
||||
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_platform_api_key, dispatch_switch_account,
|
||||
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,
|
||||
};
|
||||
use super::ctx::{
|
||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||
@@ -889,6 +890,10 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
||||
Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app),
|
||||
Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key),
|
||||
Action::BeginCustomProviderEntry(api) => dispatch_begin_custom_provider_entry(app, api),
|
||||
Action::SubmitCustomProviderInput(input) => {
|
||||
dispatch_submit_custom_provider_input(app, input)
|
||||
}
|
||||
Action::CopyAuthUrl => {
|
||||
if let AuthState::Authenticating {
|
||||
auth_url: Some(url),
|
||||
|
||||
@@ -571,3 +571,141 @@ fn auth_complete_marks_the_authenticated_method_connected() {
|
||||
"other methods must stay unmarked"
|
||||
);
|
||||
}
|
||||
|
||||
fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
|
||||
match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderEntry(step),
|
||||
..
|
||||
} => step.field,
|
||||
other => panic!("expected a custom provider step, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The custom provider flow: URL, then name (prefilled from the host), then
|
||||
/// key; the last Enter emits ONE persist+authenticate effect for the provider.
|
||||
#[test]
|
||||
fn custom_provider_steps_end_in_one_persist_effect() {
|
||||
use crate::app::custom_entry::CustomEntryField;
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
|
||||
let effects = dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::Anthropic),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://api.gw.example/v1/".into()),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Name);
|
||||
assert_eq!(
|
||||
app.auth_code_input, "gw-example",
|
||||
"the name is prefilled from the host"
|
||||
);
|
||||
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Key);
|
||||
assert!(app.auth_code_input.is_empty(), "the key box starts empty");
|
||||
|
||||
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
},
|
||||
] => {
|
||||
assert_eq!(*request_seq, seq);
|
||||
assert_eq!(provider.name, "gw");
|
||||
assert_eq!(provider.api, CustomApi::Anthropic);
|
||||
assert_eq!(provider.base_url, "https://api.gw.example/v1");
|
||||
assert_eq!(key, "sk-gw");
|
||||
}
|
||||
other => panic!("expected exactly the persist effect, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::Pending,
|
||||
..
|
||||
} if request_seq == seq
|
||||
));
|
||||
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());
|
||||
}
|
||||
|
||||
/// A refused field keeps the user on it and does not emit an effect.
|
||||
#[test]
|
||||
fn custom_provider_refusals_stay_on_the_field() {
|
||||
use crate::app::custom_entry::CustomEntryField;
|
||||
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,
|
||||
);
|
||||
|
||||
assert!(
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("host.example".into()),
|
||||
&mut app
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("anthropic".into()),
|
||||
&mut app
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Name);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
}
|
||||
|
||||
/// Esc at any step returns to the picker and drops the draft.
|
||||
#[test]
|
||||
fn cancel_custom_provider_entry_returns_to_picker() {
|
||||
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("nope".into()), &mut app);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
let seq_before = app.next_auth_request_seq;
|
||||
|
||||
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
|
||||
assert!(app.custom_entry.error.is_none());
|
||||
assert!(app.next_auth_request_seq > seq_before);
|
||||
}
|
||||
@@ -126,6 +126,7 @@ fn test_app() -> AppView {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
|
||||
Reference in new issue
Block a user