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
+68 -1
View File
@@ -60,7 +60,8 @@ enum BaseUrlSource {
/// `all_covers_every_variant` test — a variant missing from `ALL` would
/// otherwise be silently unparseable and excluded from model sync.
struct PlatformSpec {
/// Wire id (auth method id, managed-model-key prefix, config key).
/// Wire id (auth method id, managed-model-key prefix, config key, and —
/// for API-key platforms — the auth.json scope the key is stored under).
id: &'static str,
display_name: &'static str,
base_url: BaseUrlSource,
@@ -74,6 +75,13 @@ struct PlatformSpec {
///
/// SECURITY: the *values* behind these names must never be logged.
api_key_envs: &'static [&'static str],
/// Short vendor word for login copy ("Paste your {vendor} API key").
vendor: &'static str,
/// Where the user gets an API key (login copy + key-validation errors).
/// `None` for OAuth channels.
console_host: Option<&'static str>,
/// Interactive login-picker label. `None` = fall back to `display_name`.
login_label: Option<&'static str>,
}
const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
@@ -83,6 +91,9 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
uses_oauth: true,
allowed_model_prefixes: None,
api_key_envs: &[],
vendor: "Kimi",
console_host: None,
login_label: None,
};
const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
@@ -95,6 +106,9 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
uses_oauth: false,
allowed_model_prefixes: Some(&["kimi-k"]),
api_key_envs: &[MOONSHOT_CN_API_KEY_ENV, MOONSHOT_API_KEY_ENV],
vendor: "Moonshot",
console_host: Some("platform.moonshot.cn"),
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
};
const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
@@ -107,6 +121,9 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
uses_oauth: false,
allowed_model_prefixes: Some(&["kimi-k"]),
api_key_envs: &[MOONSHOT_AI_API_KEY_ENV, MOONSHOT_API_KEY_ENV],
vendor: "Moonshot",
console_host: Some("platform.moonshot.ai"),
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.ai)"),
};
/// The platform registry. Platforms are compiled-in spec rows; there is no
@@ -183,6 +200,23 @@ impl PlatformId {
pub fn managed_model_key(self, model_id: &str) -> String {
format!("{}/{model_id}", self.as_str())
}
/// Short vendor word for login copy ("Paste your {vendor} API key").
pub fn vendor(self) -> &'static str {
self.spec().vendor
}
/// Console host where the user obtains an API key, for login copy and
/// key-validation errors. `None` for OAuth channels.
pub fn console_host(self) -> Option<&'static str> {
self.spec().console_host
}
/// Label for the interactive login picker (falls back to the display
/// name when the row doesn't override it).
pub fn login_label(self) -> &'static str {
self.spec().login_label.unwrap_or(self.spec().display_name)
}
}
/// Split a managed catalog key `{platform_id}/{model_id}` back into its
@@ -531,6 +565,39 @@ mod tests {
);
}
/// Row-shape invariants the login UI and key resolution rely on:
/// API-key platforms carry a console host (paste-box copy) and at least
/// one key env var (missing-key error names it); OAuth channels carry
/// neither key envs nor a console host requirement.
#[test]
fn api_key_rows_carry_console_host_and_env_names() {
for p in PlatformId::ALL {
if p.uses_oauth() {
assert!(
p.api_key_env_names().is_empty(),
"{}: OAuth platforms take no key envs",
p.as_str()
);
} else {
assert!(
p.console_host().is_some(),
"{}: API-key platforms must name their console host",
p.as_str()
);
assert!(
!p.api_key_env_names().is_empty(),
"{}: API-key platforms must name at least one key env",
p.as_str()
);
assert!(
!p.vendor().is_empty(),
"{}: API-key platforms must set a vendor word",
p.as_str()
);
}
}
}
/// `parse` resolves by scanning spec rows, so duplicate ids would
/// silently shadow a platform. Pin uniqueness as rows are added.
#[test]