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

@@ -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:<name>`).
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:<name>` 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);
@@ -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<acp::AuthMethod> {
ALL_APIS.into_iter().map(picker_method).collect()
}
/// The wire behind a picker row id.
pub fn picker_api(id: &str) -> Option<CustomApi> {
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}");
}
}
@@ -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.
@@ -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)
{
@@ -526,6 +526,43 @@ impl MvpAgent {
Some("platform_key_invalid_or_missing"),
);
})?;
Ok(self.finish_api_key_login(method_id).await)
}
/// `authenticate(custom:<name>)`: validate the saved provider's key.
pub(super) async fn authenticate_custom_provider(
&self,
name: &str,
method_id: acp::AuthMethodId,
) -> Result<AuthenticateResponse, acp::Error> {
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(<generic-oauth platform id>)`: interactive device-code
/// login for a `uses_oauth` platform carrying an `OAuthConfig` (xai-grok).