Add per-provider auth.json keys; make auth methods registry-generic (P0b)

Platform API keys now live in auth.json under the platform-id scope (the
per-provider auth.json key contract), resolved env > auth.json > legacy
[platforms.*] config.toml (read-only fallback). The TUI login picker,
paste box, auth-method advertising, and authenticate handler are all
registry-generic: a new PlatformSpec row appears in the login UI and
authenticates with zero UI changes. Spec rows gained vendor/console_host/
login_label display fields (moonshot strings byte-identical, pinned by
tests).

Adversarial review caught that auth.json keys were validated at login but
never stamped onto catalog entries (completions would 401; restart lost
eager auth). Fixed red-green: resolve_model_list/resolve_model_catalog now
take a resolved PlatformApiKeys snapshot consumed by the credential-
stamping layer (auth.json beats stale config.toml, matching the login
validator), with production callers resolving fresh per catalog build.
Also from review: the new auth.json writer takes the manager's cross-
process flock (bounded retry — an unlocked RMW racing a token refresh
could revert a rotated refresh token); the oauth-401 wiremock test is
hermetic (KIGI_SHARE_DIR tempdir; it could read a dev's real auth.json
and hit live moonshot); cli_models resolves real keys; auth.json is read
once per registry sweep; caller-less lock_config_writes deleted; catalog
resolvers tightened to pub(crate); stale config.toml doc comments and the
no-credentials error copy updated.
This commit is contained in:
2026-07-21 01:18:46 -04:00
parent 99d99fb47a
commit c5ddaec71e
19 changed files with 731 additions and 375 deletions
@@ -102,13 +102,13 @@ pub struct BuiltAuthMethods {
/// 1. `xai.api_key` (if `has_external_api_key`)
/// 2. `cached_token` (if `has_cached_token`)
/// 3. `kimi-code` (the Kimi Code device login)
/// 4. `moonshot-cn` (Moonshot Open Platform API-key login, always)
/// 5. `moonshot-ai` (Moonshot Open Platform API-key login, always)
/// 4. every API-key registry platform, in `PlatformId::ALL` order
/// (`moonshot-cn`, `moonshot-ai`, …), always advertised
///
/// The moonshot methods are for the INTERACTIVE login picker only: they come
/// The platform methods are for the INTERACTIVE login picker only: they come
/// after `kimi-code` so they can never become `auth_methods.first()` (the
/// pager's startup metadata / eager-auth fallback reads `first()`), and they
/// are never the `default_auth_method_id` (a configured moonshot key already
/// are never the `default_auth_method_id` (a configured platform key already
/// authenticates eagerly via `xai.api_key` — the catalog entries it stamps
/// satisfy `should_advertise_xai_api_key`).
///
@@ -150,8 +150,11 @@ pub fn build_auth_methods(inputs: AuthMethodsBuildInputs<'_>) -> BuiltAuthMethod
}
methods.push(kimi_code_auth_method(login_label));
methods.push(moonshot_auth_method(kigi_models::PlatformId::MoonshotCn));
methods.push(moonshot_auth_method(kigi_models::PlatformId::MoonshotAi));
for platform in kigi_models::PlatformId::ALL {
if !platform.uses_oauth() {
methods.push(platform_auth_method(platform));
}
}
BuiltAuthMethods {
methods,
@@ -165,10 +168,8 @@ pub enum AuthMethodKind {
XaiApiKey,
CachedToken,
KimiCode,
/// Moonshot Open Platform API-key login (moonshot.cn).
MoonshotCn,
/// Moonshot Open Platform API-key login (moonshot.ai).
MoonshotAi,
/// Registry API-key platform login (method id = the platform id).
ApiKeyPlatform(kigi_models::PlatformId),
Unknown,
}
@@ -178,17 +179,18 @@ impl AuthMethodKind {
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
KIMI_CODE_METHOD_ID => Self::KimiCode,
MOONSHOT_CN_METHOD_ID => Self::MoonshotCn,
MOONSHOT_AI_METHOD_ID => Self::MoonshotAi,
_ => Self::Unknown,
other => match platform_for_method_id_str(other) {
Some(platform) => Self::ApiKeyPlatform(platform),
None => Self::Unknown,
},
}
}
/// API key auth: no auth.json session, no refresh, no browser round-trip.
/// The moonshot methods qualify — they validate a configured platform key
/// and then behave exactly like an external-API-key session.
/// 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::MoonshotCn | Self::MoonshotAi)
matches!(self, Self::XaiApiKey | Self::ApiKeyPlatform(_))
}
/// `true` for session-based methods (cached_token, interactive login).
@@ -323,65 +325,56 @@ pub fn kimi_code_auth_method(label: Option<&str>) -> acp::AuthMethod {
)
}
/// Interactive API-key login for the Moonshot open platforms. Method ids
/// equal [`kigi_models::PlatformId::as_str`] (`moonshot-cn` / `moonshot-ai`),
/// which is also the `[platforms.<id>]` config-table name — one id everywhere.
/// Interactive API-key login method ids equal
/// [`kigi_models::PlatformId::as_str`] (`moonshot-cn` / `moonshot-ai` / …),
/// which is also the `[platforms.<id>]` config-table name and the auth.json
/// scope — one id everywhere.
pub const MOONSHOT_CN_METHOD_ID: &str = "moonshot-cn";
pub const MOONSHOT_AI_METHOD_ID: &str = "moonshot-ai";
/// The open platform behind an interactive moonshot method id. `None` for
/// every other id (including `kimi-code`, whose platform uses OAuth).
pub fn moonshot_platform_for_method_id(id: &acp::AuthMethodId) -> Option<kigi_models::PlatformId> {
match id.0.as_ref() {
MOONSHOT_CN_METHOD_ID => Some(kigi_models::PlatformId::MoonshotCn),
MOONSHOT_AI_METHOD_ID => Some(kigi_models::PlatformId::MoonshotAi),
_ => None,
}
/// The API-key registry platform behind an interactive method id. `None`
/// for every other id (including `kimi-code`, whose platform uses OAuth).
pub fn platform_for_method_id(id: &acp::AuthMethodId) -> Option<kigi_models::PlatformId> {
platform_for_method_id_str(id.0.as_ref())
}
/// Console host for an open platform, used in method descriptions and login
/// copy ("platform.moonshot.cn" / "platform.moonshot.ai").
pub fn moonshot_console_host(platform: kigi_models::PlatformId) -> &'static str {
match platform {
kigi_models::PlatformId::MoonshotCn => "platform.moonshot.cn",
_ => "platform.moonshot.ai",
}
fn platform_for_method_id_str(id: &str) -> Option<kigi_models::PlatformId> {
kigi_models::PlatformId::parse(id).filter(|p| !p.uses_oauth())
}
/// A Moonshot Open Platform API-key login method.
pub fn moonshot_auth_method(platform: kigi_models::PlatformId) -> acp::AuthMethod {
let host_suffix = match platform {
kigi_models::PlatformId::MoonshotCn => "moonshot.cn",
_ => "moonshot.ai",
/// An API-key registry platform's login method (picker label + description
/// from the platform's spec row).
pub fn platform_auth_method(platform: kigi_models::PlatformId) -> acp::AuthMethod {
let description = match platform.console_host() {
Some(host) => format!("API key from {host}"),
None => format!("API key for {}", platform.display_name()),
};
acp::AuthMethod::Agent(
acp::AuthMethodAgent::new(
acp::AuthMethodId::new(platform.as_str()),
format!("Moonshot Open Platform (API key \u{b7} {host_suffix})"),
platform.login_label().to_string(),
)
.description(Some(format!(
"API key from {}",
moonshot_console_host(platform)
))),
.description(Some(description)),
)
}
/// Actionable error for a moonshot `authenticate` with no key configured.
pub fn missing_moonshot_key_error(platform: kigi_models::PlatformId) -> String {
let env_var = platform
.api_key_env_names()
.first()
.copied()
.unwrap_or(kigi_models::MOONSHOT_API_KEY_ENV);
format!(
"No API key configured for {} \u{2014} paste one in the login screen or set {env_var}",
platform.as_str(),
)
/// Actionable error for a platform `authenticate` with no key configured.
pub fn missing_platform_key_error(platform: kigi_models::PlatformId) -> String {
match platform.api_key_env_names().first() {
Some(env_var) => format!(
"No API key configured for {} \u{2014} paste one in the login screen or set {env_var}",
platform.as_str(),
),
None => format!(
"No API key configured for {} \u{2014} paste one in the login screen",
platform.as_str(),
),
}
}
/// Validate + accept a Moonshot open-platform API key for `authenticate`.
/// Validate + accept an API-key platform's key for `authenticate`.
///
/// `key` is the caller-resolved credential (env > config; see
/// `key` is the caller-resolved credential (env > auth.json > config; see
/// `resolve_platform_api_key`) — `None` fails with the actionable
/// missing-key message. A present key is validated with
/// `GET {platform_base}/models` (the same endpoint the catalog fetch uses):
@@ -398,7 +391,7 @@ pub(crate) async fn authenticate_platform_api_key(
err
};
let Some(key) = key else {
return Err(auth_err(missing_moonshot_key_error(platform)));
return Err(auth_err(missing_platform_key_error(platform)));
};
let url = format!("{}/models", platform.base_url().trim_end_matches('/'));
let response = crate::http::shared_client()
@@ -412,7 +405,7 @@ pub(crate) async fn authenticate_platform_api_key(
return Err(auth_err(format!(
"Invalid API key for {} \u{2014} check your key on {}",
platform.as_str(),
moonshot_console_host(platform),
platform.console_host().unwrap_or("the provider console"),
)));
}
if !status.is_success() {
@@ -464,10 +457,14 @@ mod tests {
assert!(api.is_api_key());
assert!(!api.is_session_based());
assert!(!api.needs_interactive_login());
// Moonshot methods are API-key shaped: NOT session-based (no token
// refresh may ever run for them) and no browser round-trip.
// Registry platform methods are API-key shaped: NOT session-based (no
// token refresh may ever run for them) and no browser round-trip.
for id in [MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID] {
let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new(id));
assert!(
matches!(kind, AuthMethodKind::ApiKeyPlatform(p) if p.as_str() == id),
"{id} must classify as its ApiKeyPlatform"
);
assert!(kind.is_api_key(), "{id} must classify as api-key");
assert!(!kind.is_session_based(), "{id} must not be session-based");
assert!(
@@ -493,6 +490,21 @@ mod tests {
);
}
/// The OAuth platform id must never resolve as an API-key platform
/// method — `platform_for_method_id`'s `uses_oauth` filter is what keeps
/// the generic `authenticate` arm from hijacking the device login.
#[test]
fn oauth_platform_id_is_not_an_api_key_method() {
assert_eq!(
platform_for_method_id(&acp::AuthMethodId::new(KIMI_CODE_METHOD_ID)),
None
);
assert_eq!(
AuthMethodKind::from_id(&acp::AuthMethodId::new(KIMI_CODE_METHOD_ID)),
AuthMethodKind::KimiCode
);
}
#[test]
fn session_token_auth_gate_matrix() {
// Session method + NotByok → refresh.
@@ -673,7 +685,7 @@ mod tests {
fn global_external_api_key_advertises_xai_api_key_first() {
let _set = EnvGuard::set(XAI_API_KEY_ENV_VAR, "xai-external-key");
let cfg = Config::default();
let models = resolve_model_list(&cfg, None);
let models = resolve_model_list(&cfg, None, &Default::default());
let has_external_api_key = should_advertise_xai_api_key(models.values());
assert!(has_external_api_key);
let built = build_auth_methods(AuthMethodsBuildInputs {