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:
雷电芽衣 committed 2026-09-29 16:10:41 -04:00
1 parent eb86d7ec26
commit 791f953c07
18 files changed
+1198 -85

No files matched your search

@@ -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<kigi_test_support::mock_server::LogEntry> {
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");
}
@@ -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"]