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
+85 -45
View File
@@ -55,38 +55,57 @@ enum CacheAuthMethod {
Platforms,
}
/// Resolved open-platform API keys (PRD F2): platform-scoped env >
/// generic `KIGI_MOONSHOT_API_KEY` env > `[platforms.*]` config.
/// Resolved API-key platform credentials (PRD F2), one entry per registry
/// platform with a usable key: platform env var(s) > auth.json platform
/// scope > `[platforms.*]` config.
///
/// SECURITY: values are secrets — the manual `Debug` impl prints presence
/// only, and nothing here may be logged or persisted.
#[derive(Clone, Default)]
pub(crate) struct PlatformApiKeys {
moonshot_cn: Option<String>,
moonshot_ai: Option<String>,
keys: std::collections::BTreeMap<kigi_models::PlatformId, String>,
}
impl std::fmt::Debug for PlatformApiKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlatformApiKeys")
.field("moonshot_cn", &self.moonshot_cn.is_some())
.field("moonshot_ai", &self.moonshot_ai.is_some())
.finish()
let mut s = f.debug_struct("PlatformApiKeys");
for platform in kigi_models::PlatformId::ALL {
if !platform.uses_oauth() {
s.field(platform.as_str(), &self.keys.contains_key(&platform));
}
}
s.finish()
}
}
impl PlatformApiKeys {
pub(crate) fn resolve(platforms: &config::PlatformsConfig) -> Self {
Self {
moonshot_cn: config::resolve_platform_api_key(
kigi_models::PlatformId::MoonshotCn,
// Read auth.json ONCE for the whole registry sweep — per-platform
// re-reads would mean one file parse per provider on every resolve.
let stored =
crate::auth::read_auth_json(&crate::util::kigi_home::kigi_home().join("auth.json"))
.ok();
let mut keys = std::collections::BTreeMap::new();
for platform in kigi_models::PlatformId::ALL {
if platform.uses_oauth() {
continue;
}
let key = config::resolve_platform_api_key_with(
platform,
platforms,
),
moonshot_ai: config::resolve_platform_api_key(
kigi_models::PlatformId::MoonshotAi,
platforms,
),
|name| std::env::var(name).ok(),
|p| {
stored
.as_ref()
.and_then(|m| m.get(p.as_str()))
.map(|a| a.key.clone())
},
);
if let Some(key) = key {
keys.insert(platform, key);
}
}
Self { keys }
}
/// Resolve from the effective on-disk config (startup paths that have no
@@ -101,26 +120,26 @@ impl PlatformApiKeys {
}
pub(crate) fn key_for(&self, platform: kigi_models::PlatformId) -> Option<&str> {
match platform {
kigi_models::PlatformId::KimiCode => None,
kigi_models::PlatformId::MoonshotCn => self.moonshot_cn.as_deref(),
kigi_models::PlatformId::MoonshotAi => self.moonshot_ai.as_deref(),
}
self.keys.get(&platform).map(String::as_str)
}
/// Any open-platform key configured? Drives "should we prefetch without a
/// session" and the F2 acceptance path (moonshot key only, no login).
/// Any API-key platform credentialed? Drives "should we prefetch without
/// a session" and the F2 acceptance path (platform key only, no login).
pub(crate) fn any(&self) -> bool {
self.moonshot_cn.is_some() || self.moonshot_ai.is_some()
!self.keys.is_empty()
}
/// Test-only constructor (fields are private to this module).
#[cfg(test)]
pub(crate) fn test_keys(cn: Option<&str>, ai: Option<&str>) -> Self {
Self {
moonshot_cn: cn.map(str::to_owned),
moonshot_ai: ai.map(str::to_owned),
let mut keys = std::collections::BTreeMap::new();
if let Some(k) = cn {
keys.insert(kigi_models::PlatformId::MoonshotCn, k.to_owned());
}
if let Some(k) = ai {
keys.insert(kigi_models::PlatformId::MoonshotAi, k.to_owned());
}
Self { keys }
}
}
@@ -300,7 +319,11 @@ impl ModelsManager {
.map(|c| c.models)
});
let has_prefetched = prefetched_models.is_some();
let catalog = resolve_model_catalog(cfg, prefetched_models.clone());
let catalog = resolve_model_catalog(
cfg,
prefetched_models.clone(),
&PlatformApiKeys::resolve(&cfg.platforms),
);
// Validate only against a real catalog; a bundled-only first run defers
// to the async fetch (`apply_refresh_result`).
@@ -348,7 +371,11 @@ impl ModelsManager {
return;
}
let prefetched = self.inner.prefetched.read().clone();
let new_catalog = resolve_model_catalog(&new_config, prefetched);
let new_catalog = resolve_model_catalog(
&new_config,
prefetched,
&PlatformApiKeys::resolve(&new_config.platforms),
);
let has_real_catalog = *self.inner.has_fetched_real_catalog.read();
if has_real_catalog && let Err(e) = validate_selectable(&new_config, &new_catalog) {
tracing::error!(error = %e, "ignoring config reload: allowed_models excludes all models");
@@ -636,7 +663,8 @@ impl ModelsManager {
// ── Mutations ───────────────────────────────────────────────────
fn rebuild(&self, cfg: &config::Config, prefetched: Option<IndexMap<String, ModelEntry>>) {
*self.inner.models.write() = resolve_model_catalog(cfg, prefetched);
*self.inner.models.write() =
resolve_model_catalog(cfg, prefetched, &PlatformApiKeys::resolve(&cfg.platforms));
}
/// Refresh models when the etag changes.
@@ -2015,11 +2043,13 @@ impl ModelGlobSet {
/// `find_model_by_id`/`models()` and ignore `user_selectable`, so they need no
/// exemption. Globs are validated at load (`Config::validate_model_filters`);
/// the arms here fail closed if one slips through.
pub fn resolve_model_catalog(
pub(crate) fn resolve_model_catalog(
cfg: &config::Config,
prefetched: Option<IndexMap<String, ModelEntry>>,
platform_keys: &PlatformApiKeys,
) -> IndexMap<String, ModelEntry> {
let mut catalog: IndexMap<String, ModelEntry> = config::resolve_model_list(cfg, prefetched);
let mut catalog: IndexMap<String, ModelEntry> =
config::resolve_model_list(cfg, prefetched, platform_keys);
if let Ok(Some(disabled)) = ModelGlobSet::compile(cfg.models.disabled_models.as_ref()) {
let before = catalog.len();
@@ -2271,7 +2301,7 @@ mod tests {
context_window = 256000
"#,
);
let catalog = resolve_model_catalog(&cfg, None);
let catalog = resolve_model_catalog(&cfg, None, &Default::default());
let (_key, entry, _src) = resolve_default_model(&cfg, &catalog, true);
assert!(
entry.info.user_selectable,
@@ -2298,7 +2328,7 @@ mod tests {
context_window = 256000
"#,
);
let catalog = resolve_model_catalog(&excluded, None);
let catalog = resolve_model_catalog(&excluded, None, &Default::default());
assert!(
validate_selectable(&excluded, &catalog)
.unwrap_err()
@@ -2316,7 +2346,7 @@ mod tests {
context_window = 256000
"#,
);
let catalog = resolve_model_catalog(&zero, None);
let catalog = resolve_model_catalog(&zero, None, &Default::default());
assert!(validate_selectable(&zero, &catalog).is_err());
}
@@ -2463,7 +2493,7 @@ mod tests {
reasoning_entry.info.supports_reasoning_effort = true;
prefetched.insert("reasoning-model".to_string(), reasoning_entry);
let catalog = resolve_model_catalog(&cfg, Some(prefetched));
let catalog = resolve_model_catalog(&cfg, Some(prefetched), &Default::default());
assert_eq!(
catalog["reasoning-model"].info.reasoning_effort,
Some(ReasoningEffort::High),
@@ -2484,7 +2514,7 @@ mod tests {
};
prefetched.insert("plain-model".to_string(), plain_entry);
let catalog = resolve_model_catalog(&cfg, Some(prefetched));
let catalog = resolve_model_catalog(&cfg, Some(prefetched), &Default::default());
assert_eq!(
catalog["plain-model"].info.reasoning_effort, None,
"non-reasoning default model must NOT be stamped with persisted effort",
@@ -2537,7 +2567,7 @@ mod tests {
}];
prefetched.insert("legacy-none".to_string(), with_none);
let catalog = resolve_model_catalog(&cfg, Some(prefetched));
let catalog = resolve_model_catalog(&cfg, Some(prefetched), &Default::default());
assert_eq!(
catalog["kigi-4.5"].info.reasoning_effort,
Some(ReasoningEffort::High),
@@ -2583,7 +2613,7 @@ mod tests {
cfg.config_models
.insert("plain".to_string(), config::ConfigModelOverride::default());
let catalog = resolve_model_catalog(&cfg, None);
let catalog = resolve_model_catalog(&cfg, None, &Default::default());
let info = &catalog["menu-only"].info;
assert!(
info.supports_reasoning_effort,
@@ -2644,7 +2674,7 @@ mod tests {
};
prefetched.insert("plain-model".to_string(), plain_entry);
let catalog = resolve_model_catalog(&cfg, Some(prefetched));
let catalog = resolve_model_catalog(&cfg, Some(prefetched), &Default::default());
assert_eq!(
catalog["reasoning-model"].info.reasoning_effort,
Some(ReasoningEffort::High),
@@ -3314,10 +3344,7 @@ mod tests {
use serial_test::serial;
fn keys(cn: Option<&str>, ai: Option<&str>) -> PlatformApiKeys {
PlatformApiKeys {
moonshot_cn: cn.map(str::to_owned),
moonshot_ai: ai.map(str::to_owned),
}
PlatformApiKeys::test_keys(cn, ai)
}
#[test]
@@ -3366,6 +3393,7 @@ mod tests {
kigi_models::PlatformId::MoonshotCn,
&platforms,
getenv,
|_| None,
)
.as_deref(),
Some("env-cn"),
@@ -3376,6 +3404,7 @@ mod tests {
kigi_models::PlatformId::MoonshotAi,
&platforms,
getenv,
|_| None,
)
.as_deref(),
Some("env-generic"),
@@ -3387,6 +3416,7 @@ mod tests {
kigi_models::PlatformId::MoonshotAi,
&platforms,
|_| None,
|_| None,
)
.as_deref(),
Some("cfg-ai"),
@@ -3397,6 +3427,7 @@ mod tests {
kigi_models::PlatformId::KimiCode,
&platforms,
getenv,
|_| None,
),
None,
);
@@ -3962,6 +3993,15 @@ mod tests {
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
// Hermetic home + no platform keys: this path resolves PlatformApiKeys
// (env + auth.json). A dev machine's real ~/.kigi/auth.json platform
// scope or moonshot env var would enable a LIVE moonshot fetch here,
// short-circuiting the refresh-retry under test.
let hermetic_home = tempfile::tempdir().unwrap();
let _home = EnvGuard::set("KIGI_SHARE_DIR", hermetic_home.path());
let _cn = EnvGuard::unset(kigi_models::MOONSHOT_CN_API_KEY_ENV);
let _ai = EnvGuard::unset(kigi_models::MOONSHOT_AI_API_KEY_ENV);
let _gen = EnvGuard::unset(kigi_models::MOONSHOT_API_KEY_ENV);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
@@ -4063,7 +4103,7 @@ mod tests {
true,
);
assert!(outcome.models.is_none(), "no cache and no network → None");
let bundled = resolve_model_catalog(&config::Config::default(), None);
let bundled = resolve_model_catalog(&config::Config::default(), None, &Default::default());
assert!(bundled.contains_key("kimi-code/kimi-for-coding"));
assert!(bundled.contains_key("moonshot-cn/kimi-k2-thinking-turbo"));
assert!(bundled.contains_key("moonshot-ai/kimi-k2-turbo-preview"));