diff --git a/README.md b/README.md index c2c3706..8a34cd2 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,8 @@ Anything that speaks the OpenAI Chat Completions API or the Anthropic Messages API works: a gateway, a proxy, Ollama, LM Studio, vLLM. In `kigi`, open `/login` (or the welcome login screen) and pick **Custom provider (OpenAI compatible)** or **(Anthropic compatible)**, then enter a base URL, a name, and an API key. -Kigi checks the key against -`GET {base}/models`, saves the provider, and `/model` lists what the endpoint +Kigi saves the provider, +checks the key against `GET {base}/models`, and `/model` lists what the endpoint serves as `name/model`. ```toml diff --git a/crates/codegen/kigi-models/src/custom.rs b/crates/codegen/kigi-models/src/custom.rs index ec799b6..e440318 100644 --- a/crates/codegen/kigi-models/src/custom.rs +++ b/crates/codegen/kigi-models/src/custom.rs @@ -165,7 +165,7 @@ 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. +/// A valid provider name derived from a host. pub fn default_name(base_url: &str) -> String { let host = base_url .split("://") diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index 67770fe..9d8e153 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -599,7 +599,7 @@ mod tests { ); } - /// `custom:` is an API-key login; the picker rows are not loginable. + /// `custom:` logs in; picker rows cannot. #[test] fn custom_provider_method_ids_classify_as_api_key() { let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new("custom:my-proxy")); diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index 0626a95..0bf220d 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -3960,8 +3960,7 @@ pub fn resolve_model_auth_facts(model_id: &str) -> ModelAuthFacts { }, }) } -/// Auth facts of a catalog entry the caller already holds. A fetched entry -/// exists only in the live catalog, never in a disk reload. +/// Auth facts of a held live-catalog entry. pub(crate) fn auth_facts_of(entry: &ModelEntry) -> ModelAuthFacts { ModelAuthFacts { byok: if entry.has_own_credentials() { @@ -5286,7 +5285,7 @@ reasoning_effort = "low" auth_scheme: Default::default(), } } - /// A fetched entry's scheme and BYOK status come from the entry itself. + /// Scheme and BYOK status come from the entry. #[test] fn auth_facts_come_from_the_held_entry() { let mut model = test_model_entry("claude-x", "https://gw.example/v1", None, None, 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 index 86eaf15..f7b58ba 100644 --- a/crates/codegen/kigi-shell/src/agent/custom_providers/login.rs +++ b/crates/codegen/kigi-shell/src/agent/custom_providers/login.rs @@ -9,7 +9,7 @@ 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. +/// The picker row for one wire. pub fn picker_method(api: CustomApi) -> acp::AuthMethod { acp::AuthMethod::Agent( acp::AuthMethodAgent::new( @@ -41,7 +41,7 @@ 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. +/// Probes the listing endpoint; errors never hold the key. pub(crate) async fn validate_key(credentialed: &CredentialedProvider) -> Result<(), String> { let provider = &credentialed.provider; let url = provider.models_url(); 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 22eebbf..72a72c9 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 @@ -553,7 +553,7 @@ impl MvpAgent { tracing::warn!(provider = name, %message, "custom provider login failed"); emit_login_span( false, - "custom-provider", + method_id.0.as_ref(), None, Some("custom_provider_invalid_or_missing"), ); @@ -561,7 +561,7 @@ impl MvpAgent { } Ok(self.finish_api_key_login(method_id).await) } - /// Shared tail of an API-key login: reload config, refresh the catalog. + /// Shared tail of an API-key login. 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 diff --git a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs index fc8b47f..ba97f50 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs @@ -879,6 +879,20 @@ fn log_subagent_model_resolution( )), ); } +/// The parent model's auth scheme: its live catalog entry, else a disk lookup. +fn parent_auth_scheme( + available: &indexmap::IndexMap, + catalog_id: &acp::ModelId, + slug: &str, +) -> kigi_sampler::AuthScheme { + crate::agent::models::entry_for_slug(available, Some(catalog_id.0.as_ref()), slug) + .map(|entry| crate::agent::config::auth_facts_of(entry).auth_scheme) + .or_else(|| { + crate::agent::config::try_resolve_model_credentials(slug, None).map(|r| r.auth_scheme) + }) + .unwrap_or_default() +} + /// Read the parent session's actual current sampling config. /// /// Prefers the live state from `ChatStateHandle` (authoritative). Falls back @@ -897,17 +911,7 @@ async fn read_parent_sampling_config( creds.alpha_test_key.as_deref(), &cfg.base_url, ); - let auth_scheme = crate::agent::models::entry_for_slug( - &ctx.available_models, - Some(ctx.model_id.0.as_ref()), - &cfg.model, - ) - .map(|entry| crate::agent::config::auth_facts_of(entry).auth_scheme) - .or_else(|| { - crate::agent::config::try_resolve_model_credentials(&cfg.model, None) - .map(|r| r.auth_scheme) - }) - .unwrap_or_default(); + let auth_scheme = parent_auth_scheme(&ctx.available_models, &ctx.model_id, &cfg.model); // Claude Pro/Max OAuth Messages adaptation inherits from the parent // model's platform (claude-pro-max → true); every other platform, // and BYOK, → false, so the API-key paths stay byte-identical. @@ -2667,3 +2671,32 @@ pub(crate) fn reconcile_orphaned_subagents( } #[cfg(test)] mod tests; + +#[cfg(test)] +mod parent_auth_scheme_tests { + use super::*; + + /// A fetched Messages model exists only in the live catalog. + #[test] + fn a_fetched_entry_gives_its_own_scheme_to_the_child() { + let wire: kigi_models::WireModel = + serde_json::from_value(serde_json::json!({ "id": "claude-x" })).unwrap(); + let cfg = crate::agent::models_fetch::wire_model_to_entry( + "gw/claude-x".into(), + kigi_models::PlatformWireApi::Messages, + kigi_models::PlatformKeyHeader::XApiKey, + None, + true, + wire, + "https://gw.example/v1", + ); + let available = indexmap::IndexMap::from([( + "gw/claude-x".to_owned(), + crate::agent::config::ModelEntry::from_config_entry(&cfg), + )]); + + let scheme = parent_auth_scheme(&available, &acp::ModelId::new("gw/claude-x"), "claude-x"); + + assert_eq!(scheme, kigi_sampler::AuthScheme::XApiKey); + } +} diff --git a/crates/codegen/kigi-tui/src/app/actions.rs b/crates/codegen/kigi-tui/src/app/actions.rs index b2b35e5..dcf908a 100644 --- a/crates/codegen/kigi-tui/src/app/actions.rs +++ b/crates/codegen/kigi-tui/src/app/actions.rs @@ -570,9 +570,9 @@ 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. + /// Custom provider row picked: open the URL step. BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi), - /// Enter in a custom provider step, with the trimmed input. + /// Enter in a custom provider step. SubmitCustomProviderInput(String), /// Copy the auth URL to the clipboard during authentication. CopyAuthUrl, @@ -1618,8 +1618,7 @@ 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. + /// Saves the provider, then authenticates; never log the key. PersistCustomProviderAndAuthenticate { request_seq: u64, provider: kigi_shell::models::custom::CustomProvider, diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index 7bdaaef..b26f2cf 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -262,7 +262,7 @@ 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. + /// Custom provider login, one field per step. CustomProviderEntry(crate::app::custom_entry::CustomEntryStep), } /// API-key platform login target, selected from the welcome picker. Wraps a diff --git a/crates/codegen/kigi-tui/src/app/custom_entry.rs b/crates/codegen/kigi-tui/src/app/custom_entry.rs index 989754a..cbf877d 100644 --- a/crates/codegen/kigi-tui/src/app/custom_entry.rs +++ b/crates/codegen/kigi-tui/src/app/custom_entry.rs @@ -1,4 +1,4 @@ -//! The base URL, name, and key steps of the custom provider login. +//! Custom provider login steps: base URL, name, key. use kigi_shell::models::custom::{ CustomApi, CustomProvider, default_name, normalize_base_url, validate_name, @@ -26,7 +26,7 @@ impl CustomEntryStep { } } - /// Only the key is a secret; URL and name show as typed. + /// Only the key is masked. pub fn masks_input(self) -> bool { self.field == CustomEntryField::Key } @@ -43,9 +43,9 @@ impl CustomEntryStep { /// What one Enter press did. #[derive(Debug, PartialEq, Eq)] pub enum CustomEntryOutcome { - /// The input stays; `CustomEntryDraft::error` says why, if it does. + /// The input stays; the draft error says why. Stay, - /// Move to the next field with this text in the box. + /// Next field, with this text in the box. Next { field: CustomEntryField, prefill: String, diff --git a/crates/codegen/kigi-tui/src/app/dispatch/auth.rs b/crates/codegen/kigi-tui/src/app/dispatch/auth.rs index 2d815a1..c6fc8c4 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/auth.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/auth.rs @@ -326,7 +326,7 @@ pub(super) fn dispatch_begin_platform_key_entry( vec![] } -/// A custom provider row was selected: open the first step (base URL). +/// Custom provider row selected: open the URL step. pub(super) fn dispatch_begin_custom_provider_entry( app: &mut AppView, api: kigi_shell::models::custom::CustomApi, @@ -344,7 +344,7 @@ pub(super) fn dispatch_begin_custom_provider_entry( vec![] } -/// Enter in a custom provider step: advance, stay with a reason, or save. +/// Enter in a custom provider step. pub(super) fn dispatch_submit_custom_provider_input( app: &mut AppView, input: String, 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 7923f28..8f5b330 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs @@ -582,8 +582,7 @@ fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField { } } -/// The custom provider flow: URL, then name (prefilled from the host), then -/// key; the last Enter emits ONE persist+authenticate effect for the provider. +/// URL, name, key; the last Enter emits one persist effect. #[test] fn custom_provider_steps_end_in_one_persist_effect() { use crate::app::custom_entry::CustomEntryField; @@ -649,7 +648,7 @@ fn custom_provider_steps_end_in_one_persist_effect() { assert!(app.auth_code_input.is_empty()); } -/// A refused field keeps the user on it and does not emit an effect. +/// A refused field keeps the user on it. #[test] fn custom_provider_refusals_stay_on_the_field() { use crate::app::custom_entry::CustomEntryField; @@ -687,7 +686,7 @@ fn custom_provider_refusals_stay_on_the_field() { assert!(app.custom_entry.error.is_some()); } -/// Esc at any step returns to the picker and drops the draft. +/// Esc returns to the picker and drops the draft. #[test] fn cancel_custom_provider_entry_returns_to_picker() { use kigi_shell::models::custom::CustomApi; diff --git a/crates/codegen/kigi-tui/src/views/welcome/mod.rs b/crates/codegen/kigi-tui/src/views/welcome/mod.rs index f312434..3aa0654 100644 --- a/crates/codegen/kigi-tui/src/views/welcome/mod.rs +++ b/crates/codegen/kigi-tui/src/views/welcome/mod.rs @@ -1294,7 +1294,7 @@ fn render_welcome_authenticating( } AuthMode::ApiKeyEntry(target) => { - // Platform API-key paste box: the key comes from the platform console. + // Platform API-key paste box. let instruction = format!( "Paste your {} API key (from {})", target.vendor(), @@ -1938,8 +1938,7 @@ 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. +/// One text field under the logo. struct EntryBox<'a> { instruction: &'a str, error: Option<&'a str>, @@ -2035,6 +2034,7 @@ fn render_entry_box( Paragraph::new(hints).render(hint_area, buf); } +/// Render the auth token input box (loopback mode). fn render_auth_input_box( area: Rect, buf: &mut Buffer, @@ -2393,8 +2393,6 @@ mod tests { assert!(!text.contains("Moonshot"), "{text}"); } - /// 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, @@ -2417,7 +2415,7 @@ mod tests { buffer_text(&buf) } - /// The URL and name steps show typed text; only the key step masks it. + /// Only the key step masks its input. #[test] fn custom_entry_arm_masks_only_the_key() { use crate::app::custom_entry::{CustomEntryDraft, CustomEntryField, CustomEntryStep}; @@ -2447,7 +2445,7 @@ mod tests { assert!(empty.contains("provider name"), "{empty}"); } - /// A refusal shows under the instruction so the user sees what to fix. + /// A refusal shows under the instruction. #[test] fn custom_entry_arm_shows_the_refusal() { use crate::app::custom_entry::{CustomEntryDraft, CustomEntryStep}; @@ -2460,6 +2458,8 @@ mod tests { assert!(text.contains("must start with http"), "{text}"); } + /// The Moonshot API-key entry arm renders the platform copy, the paste + /// box, and the esc-back hint — and no OAuth-URL affordances. #[test] fn api_key_entry_arm_shows_platform_copy_and_paste_box() { let area = Rect::new(0, 0, 80, 40); diff --git a/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs b/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs index 8d742c1..689197e 100644 --- a/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs +++ b/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs @@ -57,10 +57,7 @@ async fn run_turn(content: &ContentController) -> PtyHarness { 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. +/// An OpenAI provider lists models and uses its own key. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore] async fn custom_openai_provider_lists_models_and_uses_its_own_key() { @@ -94,8 +91,7 @@ async fn custom_openai_provider_lists_models_and_uses_its_own_key() { harness.quit().expect("clean quit"); } -/// An Anthropic-compatible provider is reached on `/messages` with -/// `x-api-key` and `anthropic-version`, and no Bearer header. +/// An Anthropic provider uses `/messages` with `x-api-key`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore] async fn custom_anthropic_provider_uses_messages_and_x_api_key() { @@ -128,8 +124,7 @@ async fn custom_anthropic_provider_uses_messages_and_x_api_key() { harness.quit().expect("clean quit"); } -/// A saved provider is a complete login: with no house key and no session, the -/// pager starts authenticated and the turn reaches the provider. +/// A saved provider alone is a complete login. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore] async fn custom_provider_alone_authenticates_at_startup() {