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
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user