From 791f953c07e4fc972d867beea762cbca39f608ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 29 Sep 2026 16:10:41 -0400 Subject: [PATCH] feat(login): custom provider rows, URL / name / key steps, and custom: 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. --- crates/codegen/kigi-models/src/custom.rs | 50 ++++ .../kigi-shell/src/agent/auth_method.rs | 45 ++- .../src/agent/custom_providers/login.rs | 171 +++++++++++ .../src/agent/custom_providers/mod.rs | 3 + .../src/agent/mvp_agent/acp_agent.rs | 5 + .../src/agent/mvp_agent/agent_ops.rs | 39 ++- crates/codegen/kigi-tui/src/app/actions.rs | 11 + crates/codegen/kigi-tui/src/app/app_view.rs | 63 +++- .../codegen/kigi-tui/src/app/custom_entry.rs | 239 +++++++++++++++ .../codegen/kigi-tui/src/app/dispatch/auth.rs | 71 ++++- .../kigi-tui/src/app/dispatch/router.rs | 11 +- .../kigi-tui/src/app/dispatch/tests/auth.rs | 138 +++++++++ .../kigi-tui/src/app/dispatch/tests/mod.rs | 1 + .../codegen/kigi-tui/src/app/effects/mod.rs | 29 ++ crates/codegen/kigi-tui/src/app/mod.rs | 1 + .../codegen/kigi-tui/src/views/welcome/mod.rs | 275 +++++++++++++----- .../kigi-tui/tests/pty_e2e/custom_provider.rs | 129 ++++++++ .../codegen/kigi-tui/tests/pty_e2e_smoke.rs | 2 + 18 files changed, 1198 insertions(+), 85 deletions(-) create mode 100644 crates/codegen/kigi-shell/src/agent/custom_providers/login.rs create mode 100644 crates/codegen/kigi-tui/src/app/custom_entry.rs create mode 100644 crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs diff --git a/crates/codegen/kigi-models/src/custom.rs b/crates/codegen/kigi-models/src/custom.rs index 719fcbb..ec799b6 100644 --- a/crates/codegen/kigi-models/src/custom.rs +++ b/crates/codegen/kigi-models/src/custom.rs @@ -165,10 +165,60 @@ pub fn normalize_base_url(raw: &str) -> Result { Ok(url.to_owned()) } +/// A valid provider name from a base URL's host, for a login default. +pub fn default_name(base_url: &str) -> String { + let host = base_url + .split("://") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .and_then(|authority| authority.split(':').next()) + .unwrap_or("") + .to_ascii_lowercase(); + let labels: Vec<&str> = host + .split('.') + .filter(|label| !label.is_empty() && !matches!(*label, "api" | "www")) + .collect(); + let joined: String = labels + .join("-") + .chars() + .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-') + .take(MAX_NAME_LEN) + .collect(); + let name = joined.trim_matches('-').to_owned(); + if validate_name(&name).is_ok() { + name + } else { + "custom".to_owned() + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn default_names_come_from_the_host() { + for (url, expected) in [ + ("https://api.together.xyz/v1", "together-xyz"), + ("https://openrouter.ai/api/v1", "openrouter-ai"), + ("http://localhost:11434/v1", "localhost"), + ("http://127.0.0.1:8080/v1", "127-0-0-1"), + ("https://api.openai.com/v1", "openai-com"), + ( + "https://gateway.internal.example.org/anthropic", + "gateway-internal-example-org", + ), + ("https://API.Example.COM/v1", "example-com"), + ("not a url", "custom"), + ("https://api/v1", "custom"), + ("https://[::1]:9000/v1", "custom"), + ] { + assert_eq!(default_name(url), expected, "{url}"); + assert!(validate_name(&default_name(url)).is_ok(), "{url}"); + } + assert!(default_name(&format!("https://{}.example/v1", "a".repeat(60))).len() <= 32); + } + #[test] fn api_round_trips_and_maps_to_the_wire() { for api in [CustomApi::OpenAi, CustomApi::Anthropic] { diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index 42dbda0..67770fe 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -114,6 +114,7 @@ pub struct BuiltAuthMethods { /// `PlatformId::ALL` order — interactive logins after `kimi-code` /// 5. every API-key registry platform, in `PlatformId::ALL` order /// (`moonshot-cn`, `moonshot-ai`, …), always advertised +/// 6. the two custom provider rows (`custom-openai`, `custom-anthropic`) /// /// The platform methods are for the INTERACTIVE login picker only: they come /// after `kimi-code` so they can never become `auth_methods.first()` (the @@ -174,6 +175,7 @@ pub fn build_auth_methods(inputs: AuthMethodsBuildInputs<'_>) -> BuiltAuthMethod methods.push(platform_auth_method(platform)); } } + methods.extend(crate::agent::custom_providers::picker_methods()); BuiltAuthMethods { methods, @@ -189,6 +191,8 @@ pub enum AuthMethodKind { KimiCode, /// Registry API-key platform login (method id = the platform id). ApiKeyPlatform(kigi_models::PlatformId), + /// A saved custom provider's login (method id = `custom:`). + CustomProvider, /// Generic device-code OAuth platform login (method id = the platform id, /// e.g. `xai-grok`). Interactive, like Kimi Code. OAuthPlatform(kigi_models::PlatformId), @@ -201,6 +205,9 @@ impl AuthMethodKind { XAI_API_KEY_METHOD_ID => Self::XaiApiKey, CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken, KIMI_CODE_METHOD_ID => Self::KimiCode, + other if crate::agent::custom_providers::login_provider_name(other).is_some() => { + Self::CustomProvider + } other => match kigi_models::PlatformId::parse(other) { Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p), Some(p) if !p.uses_oauth() => Self::ApiKeyPlatform(p), @@ -213,7 +220,10 @@ impl AuthMethodKind { /// The registry platform methods qualify — they validate a configured /// platform key and then behave exactly like an external-API-key session. pub fn is_api_key(self) -> bool { - matches!(self, Self::XaiApiKey | Self::ApiKeyPlatform(_)) + matches!( + self, + Self::XaiApiKey | Self::ApiKeyPlatform(_) | Self::CustomProvider + ) } /// `true` for session-based methods (cached_token, interactive login). @@ -589,6 +599,23 @@ mod tests { ); } + /// `custom:` is an API-key login; the picker rows are not loginable. + #[test] + fn custom_provider_method_ids_classify_as_api_key() { + let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new("custom:my-proxy")); + assert_eq!(kind, AuthMethodKind::CustomProvider); + assert!(kind.is_api_key()); + assert!(!kind.is_session_based()); + assert!(!kind.needs_interactive_login()); + for picker in ["custom-openai", "custom-anthropic"] { + assert_eq!( + AuthMethodKind::from_id(&acp::AuthMethodId::new(picker)), + AuthMethodKind::Unknown, + "{picker} only opens the TUI flow; it cannot authenticate" + ); + } + } + /// Classifier matrix for all auth method variants. #[test] fn auth_method_kind_classifier_matrix() { @@ -896,7 +923,9 @@ mod tests { "xiaomi", "xiaomi-token-plan-cn", "minimax", - "minimax-cn" + "minimax-cn", + "custom-openai", + "custom-anthropic" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -948,7 +977,9 @@ mod tests { "xiaomi", "xiaomi-token-plan-cn", "minimax", - "minimax-cn" + "minimax-cn", + "custom-openai", + "custom-anthropic" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -993,7 +1024,9 @@ mod tests { "xiaomi", "xiaomi-token-plan-cn", "minimax", - "minimax-cn" + "minimax-cn", + "custom-openai", + "custom-anthropic" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -1041,7 +1074,9 @@ mod tests { "xiaomi", "xiaomi-token-plan-cn", "minimax", - "minimax-cn" + "minimax-cn", + "custom-openai", + "custom-anthropic" ] ); assert_eq!(default_id(&built), None); diff --git a/crates/codegen/kigi-shell/src/agent/custom_providers/login.rs b/crates/codegen/kigi-shell/src/agent/custom_providers/login.rs new file mode 100644 index 0000000..86eaf15 --- /dev/null +++ b/crates/codegen/kigi-shell/src/agent/custom_providers/login.rs @@ -0,0 +1,171 @@ +//! Login for custom providers: picker rows, method ids, key validation. + +use agent_client_protocol as acp; +use kigi_models::custom::CustomApi; + +use super::CredentialedProvider; + +const PICKER_PREFIX: &str = "custom-"; +const LOGIN_PREFIX: &str = "custom:"; +const ALL_APIS: [CustomApi; 2] = [CustomApi::OpenAi, CustomApi::Anthropic]; + +/// The picker row that starts the custom provider flow for one wire. +pub fn picker_method(api: CustomApi) -> acp::AuthMethod { + acp::AuthMethod::Agent( + acp::AuthMethodAgent::new( + acp::AuthMethodId::new(format!("{PICKER_PREFIX}{}", api.as_str())), + format!("Custom provider ({})", api.label()), + ) + .description(Some("Your own base URL and API key".to_owned())), + ) +} + +/// Both picker rows, in picker order. +pub fn picker_methods() -> Vec { + ALL_APIS.into_iter().map(picker_method).collect() +} + +/// The wire behind a picker row id. +pub fn picker_api(id: &str) -> Option { + let suffix = id.strip_prefix(PICKER_PREFIX)?; + ALL_APIS.into_iter().find(|api| api.as_str() == suffix) +} + +/// The id `authenticate` takes once the provider is saved. +pub fn login_method_id(name: &str) -> acp::AuthMethodId { + acp::AuthMethodId::new(format!("{LOGIN_PREFIX}{name}")) +} + +/// The provider name inside an `authenticate` method id. +pub fn login_provider_name(id: &str) -> Option<&str> { + id.strip_prefix(LOGIN_PREFIX) +} + +/// Probes the listing endpoint with the key; the error text never holds the key. +pub(crate) async fn validate_key(credentialed: &CredentialedProvider) -> Result<(), String> { + let provider = &credentialed.provider; + let url = provider.models_url(); + let client = crate::http::shared_client(); + let request = match provider.api { + CustomApi::OpenAi => client + .get(&url) + .header("Authorization", format!("Bearer {}", credentialed.key())), + CustomApi::Anthropic => client + .get(&url) + .header("x-api-key", credentialed.key()) + .header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION), + }; + let response = request + .send() + .await + .map_err(|e| format!("Couldn't reach {url}: {e}"))?; + let status = response.status().as_u16(); + match status { + 200..=299 => Ok(()), + 401 | 403 => Err(format!( + "{} rejected the API key (HTTP {status})", + provider.name + )), + _ => Err(format!("{url} answered HTTP {status}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kigi_models::custom::CustomProvider; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn credentialed(api: CustomApi, base: &str, key: &str) -> CredentialedProvider { + CredentialedProvider::for_test(CustomProvider::new("proxy", api, base).unwrap(), key) + } + + #[test] + fn picker_ids_round_trip_and_reject_lookalikes() { + for api in ALL_APIS { + let method = picker_method(api); + assert_eq!(picker_api(method.id().0.as_ref()), Some(api)); + } + assert_eq!(picker_api("custom-responses"), None); + assert_eq!(picker_api("custom-OpenAI"), None); + assert_eq!(picker_api("custom:proxy"), None); + assert_eq!(picker_api("moonshot-cn"), None); + assert_eq!(picker_methods().len(), 2); + } + + #[test] + fn login_ids_carry_the_provider_name() { + let id = login_method_id("my-proxy"); + assert_eq!(id.0.as_ref(), "custom:my-proxy"); + assert_eq!(login_provider_name(id.0.as_ref()), Some("my-proxy")); + assert_eq!(login_provider_name("custom-openai"), None); + assert_eq!(login_provider_name("proxy"), None); + } + + #[tokio::test] + async fn validation_sends_the_dialects_key_header() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/models")) + .and(header("authorization", "Bearer sk-openai")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(1) + .mount(&server) + .await; + let base = format!("{}/v1", server.uri()); + validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-openai")) + .await + .unwrap(); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/models")) + .and(header("x-api-key", "sk-anthropic")) + .and(header( + "anthropic-version", + kigi_sampling_types::ANTHROPIC_VERSION, + )) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(1) + .mount(&server) + .await; + let base = format!("{}/v1", server.uri()); + validate_key(&credentialed(CustomApi::Anthropic, &base, "sk-anthropic")) + .await + .unwrap(); + } + + #[tokio::test] + async fn validation_names_the_failure_without_the_key() { + let server = MockServer::start().await; + for (status, path_suffix) in [(401, "a"), (403, "b"), (404, "c"), (500, "d")] { + Mock::given(path(format!("/{path_suffix}/models"))) + .respond_with(ResponseTemplate::new(status)) + .mount(&server) + .await; + } + let check = |suffix: &str| { + let base = format!("{}/{suffix}", server.uri()); + async move { + validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-secret-key")) + .await + .unwrap_err() + } + }; + assert!(check("a").await.contains("rejected the API key (HTTP 401)")); + assert!(check("b").await.contains("rejected the API key (HTTP 403)")); + assert!(check("c").await.contains("answered HTTP 404")); + assert!(check("d").await.contains("answered HTTP 500")); + + let err = validate_key(&credentialed( + CustomApi::OpenAi, + "http://127.0.0.1:1/v1", + "sk-secret-key", + )) + .await + .unwrap_err(); + assert!(err.contains("Couldn't reach"), "{err}"); + assert!(!err.contains("sk-secret-key"), "{err}"); + } +} diff --git a/crates/codegen/kigi-shell/src/agent/custom_providers/mod.rs b/crates/codegen/kigi-shell/src/agent/custom_providers/mod.rs index 3733c1c..1c29dc2 100644 --- a/crates/codegen/kigi-shell/src/agent/custom_providers/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/custom_providers/mod.rs @@ -5,9 +5,12 @@ use kigi_models::custom::{CustomApi, CustomProvider}; use crate::agent::config::{PlatformCredentialConfig, PlatformsConfig}; mod fetch; +mod login; mod save; pub(crate) use fetch::{drop_orphans, fetch_models_blocking, stamp_credentials}; +pub(crate) use login::validate_key; +pub use login::{login_method_id, login_provider_name, picker_api, picker_methods}; pub use save::save_custom_provider; /// Tables setting `base_url` or `api`, valid or not. diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs index b0c7cd0..f61c056 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs @@ -557,6 +557,11 @@ impl acp::Agent for MvpAgent { .oauth_platform() { self.authenticate_oauth_platform(platform, arguments).await + } else if let Some(name) = crate::agent::custom_providers::login_provider_name( + arguments.method_id.0.as_ref(), + ) { + self.authenticate_custom_provider(name, arguments.method_id.clone()) + .await } else if let Some(platform) = auth_method::platform_for_method_id(&arguments.method_id) { diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs index 9c3c6ca..22eebbf 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs @@ -526,6 +526,43 @@ impl MvpAgent { Some("platform_key_invalid_or_missing"), ); })?; + Ok(self.finish_api_key_login(method_id).await) + } + /// `authenticate(custom:)`: validate the saved provider's key. + pub(super) async fn authenticate_custom_provider( + &self, + name: &str, + method_id: acp::AuthMethodId, + ) -> Result { + let keys = + crate::agent::models::PlatformApiKeys::resolve_from_effective_config(); + let auth_err = |message: String| { + let mut err = acp::Error::auth_required(); + err.message = message; + err + }; + let outcome = match keys.custom().iter().find(|c| c.provider.name == name) { + Some(credentialed) => { + crate::agent::custom_providers::validate_key(credentialed).await + } + None => Err(format!( + "No custom provider named {name} with an API key in config.toml or auth.json" + )), + }; + if let Err(message) = outcome { + tracing::warn!(provider = name, %message, "custom provider login failed"); + emit_login_span( + false, + "custom-provider", + None, + Some("custom_provider_invalid_or_missing"), + ); + return Err(auth_err(message)); + } + Ok(self.finish_api_key_login(method_id).await) + } + /// Shared tail of an API-key login: reload config, refresh the catalog. + async fn finish_api_key_login(&self, method_id: acp::AuthMethodId) -> AuthenticateResponse { // Rebuild the catalog from the on-disk config: the rebuild freshly // resolves platform keys (env > auth.json > config), so the key just // persisted to auth.json is stamped onto the platform's entries; a @@ -557,7 +594,7 @@ impl MvpAgent { let meta = serde_json::to_value(auth_meta) .ok() .and_then(|v| v.as_object().cloned()); - Ok(AuthenticateResponse::new().meta(meta)) + AuthenticateResponse::new().meta(meta) } /// `authenticate()`: interactive device-code /// login for a `uses_oauth` platform carrying an `OAuthConfig` (xai-grok). diff --git a/crates/codegen/kigi-tui/src/app/actions.rs b/crates/codegen/kigi-tui/src/app/actions.rs index eef1af2..b2b35e5 100644 --- a/crates/codegen/kigi-tui/src/app/actions.rs +++ b/crates/codegen/kigi-tui/src/app/actions.rs @@ -570,6 +570,10 @@ pub enum Action { /// under the platform-id scope, then authenticate with the platform's /// method id. The key must never be logged. SubmitPlatformApiKey(String), + /// A custom provider row was picked: start the URL, name, key steps. + BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi), + /// Enter in a custom provider step, with the trimmed input. + SubmitCustomProviderInput(String), /// Copy the auth URL to the clipboard during authentication. CopyAuthUrl, /// Show the raw auth URL with mouse capture disabled for manual copy. @@ -1614,6 +1618,13 @@ pub enum Effect { target: crate::app::app_view::PlatformLogin, key: String, }, + /// Save a custom provider (config.toml and auth.json), then send + /// AuthenticateRequest with `custom:`. SECURITY: never log the key. + PersistCustomProviderAndAuthenticate { + request_seq: u64, + provider: kigi_shell::models::custom::CustomProvider, + key: String, + }, /// Fetch MCP server list from the shell (kigi/mcp/list). FetchMcpsList { agent_id: AgentId, diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index 8f8a238..7bdaaef 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -262,6 +262,8 @@ pub enum AuthMode { /// Open-platform API-key entry: paste box for a Moonshot key selected /// from the welcome login picker. Esc returns to the picker (no quit). ApiKeyEntry(PlatformLogin), + /// Custom provider login: one field per step; Esc returns to the picker. + CustomProviderEntry(crate::app::custom_entry::CustomEntryStep), } /// API-key platform login target, selected from the welcome picker. Wraps a /// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]); @@ -310,6 +312,11 @@ pub enum PendingMenuItem { target: PlatformLogin, label: String, }, + /// Custom provider login for one wire. + CustomProvider { + api: kigi_shell::models::custom::CustomApi, + label: String, + }, Quit, } impl PendingMenuItem { @@ -324,7 +331,9 @@ impl PendingMenuItem { } pub fn label(&self) -> &str { match self { - Self::Login { label, .. } | Self::ApiKey { label, .. } => label, + Self::Login { label, .. } + | Self::ApiKey { label, .. } + | Self::CustomProvider { label, .. } => label, Self::Quit => "Quit", } } @@ -366,7 +375,12 @@ pub fn pending_menu_items( use kigi_shell::agent::auth_method::AuthMethodKind; let mut items: Vec = Vec::new(); for method in auth_methods { - if let Some(target) = PlatformLogin::from_method_id(method.id()) { + if let Some(api) = kigi_shell::agent::custom_providers::picker_api(method.id().0.as_ref()) { + items.push(PendingMenuItem::CustomProvider { + api, + label: method.name().to_string(), + }); + } else if let Some(target) = PlatformLogin::from_method_id(method.id()) { items.push(PendingMenuItem::ApiKey { target, label: method.name().to_string(), @@ -908,6 +922,8 @@ pub struct AppView { pub auth_start_mode: AuthMode, /// Text buffer for manual auth token paste (loopback mode). pub auth_code_input: String, + /// Values collected by the custom provider login steps. + pub custom_entry: crate::app::custom_entry::CustomEntryDraft, /// Monotonically increasing sequence number for auth requests. pub next_auth_request_seq: u64, /// Every session/chat/worktree/prompt action deferred behind startup gates. @@ -1111,6 +1127,7 @@ impl 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, @@ -2648,7 +2665,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco return InputOutcome::Unchanged; } AuthState::Authenticating { - mode: AuthMode::ApiKeyEntry(_), + mode: mode @ (AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_)), .. } => { // Esc cancels BACK TO THE PICKER (unlike the OAuth flows, @@ -2664,6 +2681,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco } if key!(Enter).matches(key) { let trimmed = ctx.auth_code_input.trim().to_string(); + if matches!(mode, AuthMode::CustomProviderEntry(_)) { + return InputOutcome::Action(Action::SubmitCustomProviderInput(trimmed)); + } if !trimmed.is_empty() { return InputOutcome::Action(Action::SubmitPlatformApiKey(trimmed)); } @@ -2726,7 +2746,8 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco return InputOutcome::ActionThenForward(Action::NewSession); } AuthState::Authenticating { - mode: AuthMode::Loopback | AuthMode::ApiKeyEntry(_), + mode: + AuthMode::Loopback | AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_), .. } => { let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); @@ -2889,6 +2910,9 @@ fn dispatch_pending_menu_action( Some(PendingMenuItem::ApiKey { target, .. }) => { InputOutcome::Action(Action::BeginPlatformKeyEntry(*target)) } + Some(PendingMenuItem::CustomProvider { api, .. }) => { + InputOutcome::Action(Action::BeginCustomProviderEntry(*api)) + } Some(PendingMenuItem::Quit) if mid_session => InputOutcome::Action(Action::CancelLogin), Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit), None => InputOutcome::Unchanged, @@ -3223,6 +3247,7 @@ impl AppView { auth_methods: &self.auth_methods, login_label: self.login_label.as_deref(), auth_code_input: &self.auth_code_input, + custom_entry: &self.custom_entry, clipboard_copied: self.auth_clipboard_copied, show_raw_url: self.auth_show_raw_url, tip, @@ -4358,6 +4383,7 @@ pub(crate) mod tests { 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, @@ -7005,7 +7031,7 @@ pub(crate) mod tests { #[test] fn pending_menu_items_lists_interactive_methods_plus_quit() { let items = pending_menu_items(&fresh_user_auth_methods(), None); - assert_eq!(items.len(), 30, "29 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 32, "31 login rows + Quit, got {items:?}"); assert_login_row(&items[0], "kimi-code", "Kimi Code (OAuth)"); assert_login_row(&items[1], "xai-grok", "xAI Grok (subscription) (OAuth)"); assert_login_row( @@ -7192,7 +7218,28 @@ pub(crate) mod tests { label: "MiniMax China (API key)".into(), } ); - assert_eq!(items[29], PendingMenuItem::Quit); + assert_eq!( + items[29], + PendingMenuItem::CustomProvider { + api: kigi_shell::models::custom::CustomApi::OpenAi, + label: "Custom provider (OpenAI compatible)".into(), + } + ); + assert_eq!( + items[30], + PendingMenuItem::CustomProvider { + api: kigi_shell::models::custom::CustomApi::Anthropic, + label: "Custom provider (Anthropic compatible)".into(), + } + ); + assert_eq!(items[31], PendingMenuItem::Quit); + assert!(!items[29].connected(&fresh_user_auth_methods())); + assert!(matches!( + dispatch_pending_menu_action(&items, 30, false), + InputOutcome::Action(Action::BeginCustomProviderEntry( + kigi_shell::models::custom::CustomApi::Anthropic + )) + )); // The non-interactive methods must never appear as rows. let byok = kigi_shell::agent::auth_method::build_auth_methods( kigi_shell::agent::auth_method::AuthMethodsBuildInputs { @@ -7203,8 +7250,8 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 30, - "xai.api_key / cached_token must not add rows (29 login rows + Quit)" + 32, + "xai.api_key / cached_token must not add rows (31 login rows + Quit)" ); } /// Startup lands on the picker only when there is a real choice: the diff --git a/crates/codegen/kigi-tui/src/app/custom_entry.rs b/crates/codegen/kigi-tui/src/app/custom_entry.rs new file mode 100644 index 0000000..989754a --- /dev/null +++ b/crates/codegen/kigi-tui/src/app/custom_entry.rs @@ -0,0 +1,239 @@ +//! The base URL, name, and key steps of the custom provider login. + +use kigi_shell::models::custom::{ + CustomApi, CustomProvider, default_name, normalize_base_url, validate_name, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomEntryField { + BaseUrl, + Name, + Key, +} + +/// Which field the input box holds, for which wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CustomEntryStep { + pub api: CustomApi, + pub field: CustomEntryField, +} + +impl CustomEntryStep { + pub fn first(api: CustomApi) -> Self { + Self { + api, + field: CustomEntryField::BaseUrl, + } + } + + /// Only the key is a secret; URL and name show as typed. + pub fn masks_input(self) -> bool { + self.field == CustomEntryField::Key + } + + pub fn placeholder(self) -> &'static str { + match self.field { + CustomEntryField::BaseUrl => "https://host/v1", + CustomEntryField::Name => "provider name", + CustomEntryField::Key => "Paste your API key here...", + } + } +} + +/// What one Enter press did. +#[derive(Debug, PartialEq, Eq)] +pub enum CustomEntryOutcome { + /// The input stays; `CustomEntryDraft::error` says why, if it does. + Stay, + /// Move to the next field with this text in the box. + Next { + field: CustomEntryField, + prefill: String, + }, + /// Every field is valid. + Done { + provider: CustomProvider, + key: String, + }, +} + +/// Values collected so far, and the last refusal. +#[derive(Debug, Default, Clone)] +pub struct CustomEntryDraft { + base_url: String, + name: String, + pub error: Option, +} + +impl CustomEntryDraft { + pub const fn new() -> Self { + Self { + base_url: String::new(), + name: String::new(), + error: None, + } + } + + pub fn clear(&mut self) { + *self = Self::new(); + } + + pub fn instruction(&self, step: CustomEntryStep) -> String { + match step.field { + CustomEntryField::BaseUrl => format!( + "Base URL of the {} API, for example https://host/v1", + step.api.label() + ), + CustomEntryField::Name => { + "Name for this provider. Its models appear as name/model".to_owned() + } + CustomEntryField::Key => format!( + "API key for {}. A local server that ignores auth takes any value", + self.name + ), + } + } + + pub fn submit(&mut self, step: CustomEntryStep, input: &str) -> CustomEntryOutcome { + let input = input.trim(); + if input.is_empty() { + self.error = None; + return CustomEntryOutcome::Stay; + } + self.error = None; + match step.field { + CustomEntryField::BaseUrl => match normalize_base_url(input) { + Ok(url) => { + let prefill = default_name(&url); + self.base_url = url; + CustomEntryOutcome::Next { + field: CustomEntryField::Name, + prefill, + } + } + Err(e) => self.refuse(e), + }, + CustomEntryField::Name => match validate_name(input) { + Ok(()) => { + self.name = input.to_owned(); + CustomEntryOutcome::Next { + field: CustomEntryField::Key, + prefill: String::new(), + } + } + Err(e) => self.refuse(e), + }, + CustomEntryField::Key => { + match CustomProvider::new(&self.name, step.api, &self.base_url) { + Ok(provider) => CustomEntryOutcome::Done { + provider, + key: input.to_owned(), + }, + Err(e) => self.refuse(e), + } + } + } + } + + fn refuse(&mut self, reason: impl std::fmt::Display) -> CustomEntryOutcome { + self.error = Some(reason.to_string()); + CustomEntryOutcome::Stay + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn step(field: CustomEntryField) -> CustomEntryStep { + CustomEntryStep { + api: CustomApi::Anthropic, + field, + } + } + + #[test] + fn three_valid_steps_build_the_provider() { + let mut draft = CustomEntryDraft::default(); + let next = draft.submit( + step(CustomEntryField::BaseUrl), + " https://api.gw.example/v1/ ", + ); + assert_eq!( + next, + CustomEntryOutcome::Next { + field: CustomEntryField::Name, + prefill: "gw-example".to_owned() + } + ); + let next = draft.submit(step(CustomEntryField::Name), "gw"); + assert_eq!( + next, + CustomEntryOutcome::Next { + field: CustomEntryField::Key, + prefill: String::new() + } + ); + assert!( + draft + .instruction(step(CustomEntryField::Key)) + .contains("API key for gw") + ); + + let done = draft.submit(step(CustomEntryField::Key), " sk-1 "); + let CustomEntryOutcome::Done { provider, key } = done else { + panic!("expected Done, got {done:?}"); + }; + 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-1"); + assert!(draft.error.is_none()); + } + + #[test] + fn bad_input_stays_on_the_field_with_a_reason() { + let mut draft = CustomEntryDraft::default(); + assert_eq!( + draft.submit(step(CustomEntryField::BaseUrl), "host.example"), + CustomEntryOutcome::Stay + ); + assert!(draft.error.as_deref().is_some_and(|e| e.contains("http"))); + + draft.submit(step(CustomEntryField::BaseUrl), "https://h.example/v1"); + assert_eq!( + draft.submit(step(CustomEntryField::Name), "OpenAI"), + CustomEntryOutcome::Stay + ); + assert!(draft.error.is_some()); + assert_eq!( + draft.submit(step(CustomEntryField::Name), "openai"), + CustomEntryOutcome::Stay + ); + assert!( + draft + .error + .as_deref() + .is_some_and(|e| e.contains("built-in")) + ); + } + + #[test] + fn empty_input_stays_and_clears_the_last_error() { + let mut draft = CustomEntryDraft::default(); + draft.submit(step(CustomEntryField::BaseUrl), "nope"); + assert!(draft.error.is_some()); + assert_eq!( + draft.submit(step(CustomEntryField::BaseUrl), " "), + CustomEntryOutcome::Stay + ); + assert!(draft.error.is_none()); + } + + #[test] + fn only_the_key_is_masked() { + assert!(step(CustomEntryField::Key).masks_input()); + assert!(!step(CustomEntryField::BaseUrl).masks_input()); + assert!(!step(CustomEntryField::Name).masks_input()); + } +} diff --git a/crates/codegen/kigi-tui/src/app/dispatch/auth.rs b/crates/codegen/kigi-tui/src/app/dispatch/auth.rs index fabc956..2d815a1 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/auth.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/auth.rs @@ -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 { + 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 { + 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 Vec Vec { 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), diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs index 2db9d29..7923f28 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs @@ -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); +} diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/mod.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/mod.rs index ff52880..fdae6f0 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/mod.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/mod.rs @@ -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, diff --git a/crates/codegen/kigi-tui/src/app/effects/mod.rs b/crates/codegen/kigi-tui/src/app/effects/mod.rs index c580aa6..063323d 100644 --- a/crates/codegen/kigi-tui/src/app/effects/mod.rs +++ b/crates/codegen/kigi-tui/src/app/effects/mod.rs @@ -1911,6 +1911,35 @@ pub(crate) fn execute( }); meta.auth_abort_handle = Some((request_seq, abort_handle)); } + Effect::PersistCustomProviderAndAuthenticate { + request_seq, + provider, + key, + } => { + 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 + { + let error = format!("Couldn't save the provider: {e:#}"); + ulog::error( + "custom provider persist failed", + None, + Some(serde_json::json!({ "error" : & error })), + ); + return TaskResult::AuthFailed { + request_seq, + error, + }; + } + let method_id = + kigi_shell::agent::custom_providers::login_method_id(&provider.name); + send_authenticate(&tx, request_seq, method_id, false, false).await + }); + meta.auth_abort_handle = Some((request_seq, abort_handle)); + } Effect::SubmitAuthCode { request_seq, code } => { let tx = acp_tx.clone(); tasks diff --git a/crates/codegen/kigi-tui/src/app/mod.rs b/crates/codegen/kigi-tui/src/app/mod.rs index 98d06c7..638bf5c 100644 --- a/crates/codegen/kigi-tui/src/app/mod.rs +++ b/crates/codegen/kigi-tui/src/app/mod.rs @@ -15,6 +15,7 @@ pub mod agent_view; pub mod app_view; pub mod bundle; pub mod cli; +pub mod custom_entry; pub use crate::link_opener; /// Off-thread full-file syntax highlight upgrade for edit diffs. pub mod edit_highlight_worker; diff --git a/crates/codegen/kigi-tui/src/views/welcome/mod.rs b/crates/codegen/kigi-tui/src/views/welcome/mod.rs index c6a0f37..f312434 100644 --- a/crates/codegen/kigi-tui/src/views/welcome/mod.rs +++ b/crates/codegen/kigi-tui/src/views/welcome/mod.rs @@ -461,6 +461,7 @@ pub struct WelcomeRenderParams<'a> { pub auth_methods: &'a [acp::AuthMethod], pub login_label: Option<&'a str>, pub auth_code_input: &'a str, + pub custom_entry: &'a crate::app::custom_entry::CustomEntryDraft, pub clipboard_copied: bool, pub show_raw_url: bool, pub tip: Option<&'a str>, @@ -603,6 +604,7 @@ pub fn render_welcome( auth_url.as_deref(), *mode, params.auth_code_input, + params.custom_entry, params.clipboard_copied, params.show_raw_url, ); @@ -1176,6 +1178,7 @@ fn render_welcome_authenticating( auth_url: Option<&str>, mode: AuthMode, auth_code_input: &str, + custom_entry: &crate::app::custom_entry::CustomEntryDraft, clipboard_copied: bool, show_raw_url: bool, ) -> (Option, Option) { @@ -1270,6 +1273,7 @@ fn render_welcome_authenticating( theme, auth_code_input, "Paste your token here...", + true, ); // Hints @@ -1290,81 +1294,45 @@ fn render_welcome_authenticating( } AuthMode::ApiKeyEntry(target) => { - // Platform API-key paste box: instruction + input + hints. No - // auth-URL machinery — the key comes from the platform console. - let h_pad: u16 = content_area.width / 6; - let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1); + // Platform API-key paste box: the key comes from the platform console. let instruction = format!( "Paste your {} API key (from {})", target.vendor(), target.console_host() ); - let msg_height = (instruction.len() as u16).div_ceil(inner_width); - let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([ - Constraint::Length(top_pad), - Constraint::Length(logo_line_count), - // gap - Constraint::Length(1), - // instruction - Constraint::Length(msg_height), - // gap - Constraint::Min(1), - // prompt box - Constraint::Length(5), - // gap - Constraint::Length(1), - // hints - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas(content_area); - - render_logo(logo_area, buf, theme, content_area.height); - - let msg = Line::from(Span::styled( - instruction, - Style::default().fg(theme.gray_bright), - )) - .alignment(Alignment::Center); - Paragraph::new(msg) - .wrap(Wrap { trim: false }) - .block(Block::default().padding(Padding::horizontal(h_pad))) - .render(msg_area, buf); - - let [_, prompt_centered, _] = Layout::horizontal([ - Constraint::Min(0), - Constraint::Length(content_area.width), - Constraint::Min(0), - ]) - .flex(Flex::Center) - .areas(prompt_area); - render_auth_input_box( - prompt_centered, + render_entry_box( + content_area, buf, theme, - auth_code_input, - "Paste your API key here...", + top_pad, + logo_line_count, + &EntryBox { + instruction: &instruction, + error: None, + input: auth_code_input, + placeholder: "Paste your API key here...", + masked: true, + }, ); + (None, None) + } - let hints = Line::from(vec![ - Span::styled( - "enter", - Style::default() - .fg(theme.accent_user) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" submit ", Style::default().fg(theme.gray)), - Span::styled( - "esc", - Style::default() - .fg(theme.accent_user) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" back", Style::default().fg(theme.gray)), - ]) - .alignment(Alignment::Center); - Paragraph::new(hints).render(hint_area, buf); - + AuthMode::CustomProviderEntry(step) => { + let instruction = custom_entry.instruction(step); + render_entry_box( + content_area, + buf, + theme, + top_pad, + logo_line_count, + &EntryBox { + instruction: &instruction, + error: custom_entry.error.as_deref(), + input: auth_code_input, + placeholder: step.placeholder(), + masked: step.masks_input(), + }, + ); (None, None) } @@ -1971,12 +1939,109 @@ pub(crate) fn render_session_picker( } /// Render the auth token input box (loopback mode). +/// One text field under the logo: instruction, optional error, input box, hints. +struct EntryBox<'a> { + instruction: &'a str, + error: Option<&'a str>, + input: &'a str, + placeholder: &'a str, + masked: bool, +} + +fn render_entry_box( + content_area: Rect, + buf: &mut Buffer, + theme: &Theme, + top_pad: u16, + logo_line_count: u16, + entry: &EntryBox<'_>, +) { + let h_pad: u16 = content_area.width / 6; + let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1); + let rows = |text: &str| (text.len() as u16).div_ceil(inner_width); + let msg_height = rows(entry.instruction) + entry.error.map_or(0, rows); + let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([ + Constraint::Length(top_pad), + Constraint::Length(logo_line_count), + // gap + Constraint::Length(1), + // instruction + Constraint::Length(msg_height), + // gap + Constraint::Min(1), + // prompt box + Constraint::Length(5), + // gap + Constraint::Length(1), + // hints + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas(content_area); + + render_logo(logo_area, buf, theme, content_area.height); + + let mut lines = vec![ + Line::from(Span::styled( + entry.instruction, + Style::default().fg(theme.gray_bright), + )) + .alignment(Alignment::Center), + ]; + if let Some(error) = entry.error { + lines.push( + Line::from(Span::styled(error, Style::default().fg(theme.warning))) + .alignment(Alignment::Center), + ); + } + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block(Block::default().padding(Padding::horizontal(h_pad))) + .render(msg_area, buf); + + let [_, prompt_centered, _] = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(content_area.width), + Constraint::Min(0), + ]) + .flex(Flex::Center) + .areas(prompt_area); + render_auth_input_box( + prompt_centered, + buf, + theme, + entry.input, + entry.placeholder, + entry.masked, + ); + + let hints = Line::from(vec![ + Span::styled( + "enter", + Style::default() + .fg(theme.accent_user) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" submit ", Style::default().fg(theme.gray)), + Span::styled( + "esc", + Style::default() + .fg(theme.accent_user) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" back", Style::default().fg(theme.gray)), + ]) + .alignment(Alignment::Center); + Paragraph::new(hints).render(hint_area, buf); +} + fn render_auth_input_box( area: Rect, buf: &mut Buffer, theme: &Theme, input: &str, placeholder: &str, + masked: bool, ) { let prompt_block = Block::default() .borders(Borders::ALL) @@ -1993,8 +2058,10 @@ fn render_auth_input_box( if inner.height > 0 && inner.width > 2 { let display = if input.is_empty() { placeholder.to_string() - } else { + } else if masked { mask_auth_token_for_display(input) + } else { + input.to_string() }; let style = if input.is_empty() { @@ -2078,6 +2145,8 @@ fn mask_auth_token_for_display(input: &str) -> String { #[cfg(test)] mod tests { use super::*; + static EMPTY_CUSTOM_ENTRY: crate::app::custom_entry::CustomEntryDraft = + crate::app::custom_entry::CustomEntryDraft::new(); use crate::app::app_view::SessionPickerEntry; use crate::views::picker::PickerState; use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data}; @@ -2127,6 +2196,7 @@ mod tests { auth_methods: &[], login_label: None, auth_code_input: "", + custom_entry: &EMPTY_CUSTOM_ENTRY, clipboard_copied: false, show_raw_url: false, tip: None, @@ -2325,6 +2395,71 @@ mod tests { /// The Moonshot API-key entry arm renders the platform copy, the paste /// box, and the esc-back hint — and no OAuth-URL affordances. + fn render_custom_entry( + step: crate::app::custom_entry::CustomEntryStep, + draft: &crate::app::custom_entry::CustomEntryDraft, + input: &str, + ) -> String { + let area = Rect::new(0, 0, 100, 40); + let mut buf = Buffer::empty(area); + render_welcome_authenticating( + area, + &mut buf, + &Theme::current(), + logo_line_count(area.height), + None, + AuthMode::CustomProviderEntry(step), + input, + draft, + false, + false, + ); + buffer_text(&buf) + } + + /// The URL and name steps show typed text; only the key step masks it. + #[test] + fn custom_entry_arm_masks_only_the_key() { + use crate::app::custom_entry::{CustomEntryDraft, CustomEntryField, CustomEntryStep}; + use kigi_shell::models::custom::CustomApi; + let step = |field| CustomEntryStep { + api: CustomApi::OpenAi, + field, + }; + let draft = CustomEntryDraft::new(); + + let url = render_custom_entry( + step(CustomEntryField::BaseUrl), + &draft, + "https://very-long-host.example/v1", + ); + assert!(url.contains("https://very-long-host.example/v1"), "{url}"); + assert!(url.contains("OpenAI compatible"), "{url}"); + + let key = render_custom_entry(step(CustomEntryField::Key), &draft, "sk-abcdefghijklmnop"); + assert!( + !key.contains("sk-abcdefghij"), + "the key prefix must stay hidden: {key}" + ); + assert!(key.contains("mnop"), "{key}"); + + let empty = render_custom_entry(step(CustomEntryField::Name), &draft, ""); + assert!(empty.contains("provider name"), "{empty}"); + } + + /// A refusal shows under the instruction so the user sees what to fix. + #[test] + fn custom_entry_arm_shows_the_refusal() { + use crate::app::custom_entry::{CustomEntryDraft, CustomEntryStep}; + use kigi_shell::models::custom::CustomApi; + let step = CustomEntryStep::first(CustomApi::Anthropic); + let mut draft = CustomEntryDraft::new(); + draft.submit(step, "host.example"); + let text = render_custom_entry(step, &draft, "host.example"); + assert!(text.contains("Anthropic compatible"), "{text}"); + assert!(text.contains("must start with http"), "{text}"); + } + #[test] fn api_key_entry_arm_shows_platform_copy_and_paste_box() { let area = Rect::new(0, 0, 80, 40); @@ -2343,6 +2478,7 @@ mod tests { )), // auth_code_input "", + &crate::app::custom_entry::CustomEntryDraft::new(), // clipboard_copied false, // show_raw_url @@ -3010,6 +3146,7 @@ mod tests { AuthMode::Device, // auth_code_input — unused in device mode "", + &crate::app::custom_entry::CustomEntryDraft::new(), // clipboard_copied false, // show_raw_url @@ -3066,6 +3203,7 @@ mod tests { Some(url), AuthMode::Device, "", + &crate::app::custom_entry::CustomEntryDraft::new(), false, // show_raw_url true, @@ -3093,6 +3231,7 @@ mod tests { Some(url), AuthMode::Device, "", + &crate::app::custom_entry::CustomEntryDraft::new(), false, // show_raw_url true, @@ -3131,6 +3270,7 @@ mod tests { Some(url), AuthMode::Device, "", + &crate::app::custom_entry::CustomEntryDraft::new(), false, // show_raw_url true, @@ -3172,6 +3312,7 @@ mod tests { AuthMode::Command, // auth_code_input — unused "", + &crate::app::custom_entry::CustomEntryDraft::new(), // clipboard_copied false, // show_raw_url diff --git a/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs b/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs new file mode 100644 index 0000000..14dd496 --- /dev/null +++ b/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs @@ -0,0 +1,129 @@ +// Per-test-case module for the `pty_e2e` integration test crate. +#[allow(unused_imports)] +use super::common::*; + +const CUSTOM_KEY: &str = "sk-custom-provider-key"; +const HOUSE_KEY: &str = "test-key-for-ci"; + +/// Declares `[platforms.proxy]` in the pager's isolated home. +fn declare_provider(content: &ContentController, api: &str) { + let kigi_home = content.home().join(".kigi"); + std::fs::create_dir_all(&kigi_home).expect("create .kigi"); + std::fs::write( + kigi_home.join("config.toml"), + format!( + "[platforms.proxy]\nbase_url = \"{}\"\napi = \"{api}\"\napi_key = \"{CUSTOM_KEY}\"\n", + content.url() + ), + ) + .expect("write config.toml"); +} + +fn turn_requests( + content: &ContentController, + path: &str, +) -> Vec { + content + .requests() + .into_iter() + .filter(|e| { + e.path == path + && e.body + .as_ref() + .and_then(|b| b.get("model")) + .and_then(|m| m.as_str()) + == Some("custom-m1") + }) + .collect() +} + +async fn run_turn(content: &ContentController) -> PtyHarness { + content.set_response(format!( + "{MOCK_RESPONSE_SENTINEL} from the custom provider." + )); + let binary = pager_binary().expect("resolve pager binary"); + let mut harness = + PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, content, &[]) + .expect("spawn pager with content"); + harness + .wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT) + .expect("welcome text"); + harness + .inject_keys(format!("{PROMPT}\r").as_bytes()) + .expect("submit prompt"); + harness + .wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30)) + .expect("mock response on screen"); + harness +} + +/// A declared OpenAI-compatible provider supplies the catalog and the turn: +/// the request goes to `/chat/completions` for the fetched model with the +/// provider's own key. The house key, which is set in this environment on the +/// same loopback URL as the session's coding endpoint, never rides it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] +async fn custom_openai_provider_lists_models_and_uses_its_own_key() { + let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")]) + .await + .expect("start content"); + declare_provider(&content, "openai"); + + let mut harness = run_turn(&content).await; + + let turns = turn_requests(&content, "/v1/chat/completions"); + assert!( + !turns.is_empty(), + "no chat completion for custom-m1: {:?}", + content.requests() + ); + for turn in &turns { + assert_eq!( + turn.authorization.as_deref(), + Some(format!("Bearer {CUSTOM_KEY}").as_str()), + "the custom provider's own key must ride, never {HOUSE_KEY}" + ); + } + + harness + .inject_keys(b"/model ") + .expect("open the /model list"); + harness + .wait_for_text("proxy", Duration::from_secs(10)) + .expect("the fetched model is listed with its provider name"); + harness.quit().expect("clean quit"); +} + +/// An Anthropic-compatible provider is reached on `/messages` with +/// `x-api-key` and `anthropic-version`, and no Bearer header. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] +async fn custom_anthropic_provider_uses_messages_and_x_api_key() { + let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")]) + .await + .expect("start content"); + declare_provider(&content, "anthropic"); + + let mut harness = run_turn(&content).await; + + let turns = turn_requests(&content, "/v1/messages"); + assert!( + !turns.is_empty(), + "no messages request for custom-m1: {:?}", + content.requests() + ); + for turn in &turns { + assert_eq!( + turn.header("x-api-key"), + Some(CUSTOM_KEY), + "request headers: {:?}", + turn.headers + ); + assert!(turn.header("anthropic-version").is_some()); + assert_eq!( + turn.authorization, None, + "an x-api-key wire carries no Bearer" + ); + } + harness.quit().expect("clean quit"); +} diff --git a/crates/codegen/kigi-tui/tests/pty_e2e_smoke.rs b/crates/codegen/kigi-tui/tests/pty_e2e_smoke.rs index 6b55362..038f319 100644 --- a/crates/codegen/kigi-tui/tests/pty_e2e_smoke.rs +++ b/crates/codegen/kigi-tui/tests/pty_e2e_smoke.rs @@ -15,6 +15,8 @@ mod agent_response; mod auto_compact_top_row; #[path = "pty_e2e/basename_path_demo_pty.rs"] mod basename_path_demo_pty; +#[path = "pty_e2e/custom_provider.rs"] +mod custom_provider; #[path = "pty_e2e/doubled_lines_out_of_band_repro.rs"] mod doubled_lines_out_of_band_repro; #[path = "pty_e2e/initial_prompt_positional_auto_submits.rs"]