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
+258 -182
View File
@@ -794,22 +794,17 @@ pub struct PlatformCredentialConfig {
pub api_key: Option<String>,
}
/// Resolve the API key for an open-platform registry entry:
/// platform-scoped env > generic `KIGI_MOONSHOT_API_KEY` env > config file.
/// `None` for the OAuth platform and when nothing is configured.
/// The returned value must never be logged.
pub(crate) fn resolve_platform_api_key(
platform: kigi_models::PlatformId,
platforms: &PlatformsConfig,
) -> Option<String> {
resolve_platform_api_key_with(platform, platforms, |name| std::env::var(name).ok())
}
/// Testable core of [`resolve_platform_api_key`] with an injected getenv.
/// Resolve the API key for an API-key registry platform with injected env
/// and auth.json readers. Precedence: env > auth.json > config file — the
/// same "env always wins" rule the config-file layer already follows.
/// `None` for OAuth platforms and when nothing is configured. The batch
/// production caller is `PlatformApiKeys::resolve` (reads auth.json once
/// for the whole registry sweep). The returned value must never be logged.
pub(crate) fn resolve_platform_api_key_with(
platform: kigi_models::PlatformId,
platforms: &PlatformsConfig,
mut getenv: impl FnMut(&str) -> Option<String>,
stored: impl FnOnce(kigi_models::PlatformId) -> Option<String>,
) -> Option<String> {
for name in platform.api_key_env_names() {
if let Some(value) = getenv(name)
@@ -818,15 +813,19 @@ pub(crate) fn resolve_platform_api_key_with(
return Some(value);
}
}
if let Some(value) = stored(platform)
&& !value.trim().is_empty()
{
return Some(value);
}
platforms.config_api_key(platform)
}
/// Persist `[platforms.<id>].api_key` into `~/.kigi/config.toml` — the exact
/// table [`resolve_platform_api_key`] reads back (env vars still win over the
/// file). Shared writer for the CLI and the TUI login screen; same in-process
/// pattern as the `kigi mcp add` writer (whole-file toml round-trip, atomic
/// tmp+rename), taken under the config write lock so it can't interleave with
/// a settings save.
/// Persist a platform API key into `~/.kigi/auth.json` under the platform-id
/// scope — the per-provider auth.json key contract that
/// [`resolve_platform_api_key`] reads back (env vars still win). Shared
/// writer for the CLI and the TUI login screen. Legacy `[platforms.*]`
/// config.toml keys remain a read-only fallback source.
///
/// SECURITY: the key lands in the file by design; it must never be logged,
/// and errors carry only path/IO context — never the key.
@@ -834,21 +833,15 @@ pub async fn save_platform_api_key(
platform: kigi_models::PlatformId,
api_key: &str,
) -> anyhow::Result<()> {
let _guard = crate::util::config::lock_config_writes().await;
save_platform_api_key_at(&crate::util::config::user_config_path(), platform, api_key).await
save_platform_api_key_in(&crate::util::kigi_home::kigi_home(), platform, api_key)
}
/// Path-injectable core of [`save_platform_api_key`] (tests use a tempdir).
/// Does NOT take the config write lock — production callers go through
/// [`save_platform_api_key`].
pub async fn save_platform_api_key_at(
path: &std::path::Path,
/// Home-injectable core of [`save_platform_api_key`] (tests use a tempdir).
pub fn save_platform_api_key_in(
kigi_home: &std::path::Path,
platform: kigi_models::PlatformId,
api_key: &str,
) -> anyhow::Result<()> {
use toml::Value as TomlValue;
use toml::map::Map as TomlMap;
anyhow::ensure!(
!platform.uses_oauth(),
"{} authenticates via OAuth and takes no API key",
@@ -856,38 +849,13 @@ pub async fn save_platform_api_key_at(
);
let api_key = api_key.trim();
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
let mut root: TomlValue = match tokio::fs::read_to_string(path).await {
Ok(s) => toml::from_str(&s).map_err(|e| {
// Refuse to overwrite an unparseable config — a silent fallback
// to an empty table would drop every other section.
anyhow::anyhow!("refusing to overwrite unparseable {}: {e}", path.display())
})?,
Err(_) => TomlValue::Table(TomlMap::new()),
};
let table = root
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("config root is not a table"))?;
let platforms = table
.entry("platforms")
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("[platforms] is not a table"))?;
let entry = platforms
.entry(platform.as_str().to_string())
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("[platforms.{}] is not a table", platform.as_str()))?;
entry.insert(
"api_key".to_string(),
TomlValue::String(api_key.to_string()),
);
let toml_str = toml::to_string_pretty(&root)?;
// Mode-preserving atomic write: a 0600 config must not widen while
// receiving a secret.
crate::util::config::atomic_write_string(path, &toml_str)?;
Ok(())
crate::auth::store_platform_api_key(kigi_home, platform, api_key).map_err(|e| {
anyhow::anyhow!(
"saving {} API key to auth.json in {}: {e}",
platform.as_str(),
kigi_home.display()
)
})
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -2565,9 +2533,10 @@ fn managed_settings_env_flag(key: &str) -> Option<bool> {
}
/// Assemble the final model map. Priority (highest wins):
/// config.toml `[model.*]` > prefetched (remote) > hardcoded defaults.
pub fn resolve_model_list(
pub(crate) fn resolve_model_list(
cfg: &Config,
prefetched: Option<IndexMap<String, ModelEntry>>,
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> IndexMap<String, ModelEntry> {
let mut resolved: IndexMap<String, ModelEntry> = IndexMap::new();
if cfg.endpoints.has_custom_endpoint() {
@@ -2681,7 +2650,7 @@ pub fn resolve_model_list(
}
apply_global_extra_headers(&mut resolved, &cfg.models);
apply_global_scalar_defaults(&mut resolved, &cfg.models);
apply_platform_credentials(&mut resolved, &cfg.platforms);
apply_platform_credentials(&mut resolved, &cfg.platforms, platform_keys);
for entry in resolved.values_mut() {
entry.info.derive_reasoning_effort_fields();
}
@@ -2704,6 +2673,7 @@ pub fn resolve_model_list(
fn apply_platform_credentials(
resolved: &mut IndexMap<String, ModelEntry>,
platforms: &PlatformsConfig,
platform_keys: &crate::agent::models::PlatformApiKeys,
) {
for (key, entry) in resolved.iter_mut() {
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
@@ -2720,15 +2690,23 @@ fn apply_platform_credentials(
.env_key
.as_ref()
.is_some_and(|k| k.resolve_value().is_some());
if entry.api_key.is_none()
&& !env_resolves
&& let Some(config_key) = platforms.config_api_key(platform)
{
tracing::debug!(
model_key = %key, platform = platform.as_str(),
"stamped [platforms] config api_key onto open-platform entry"
);
entry.api_key = Some(config_key);
if entry.api_key.is_none() && !env_resolves {
// The resolved snapshot (env > auth.json > config, minus env
// which stays live via env_key above) wins over a raw config.toml
// read, so a key rotated via the TUI login can never lose to a
// stale `[platforms.*]` entry. The config fallback keeps callers
// that pass an empty snapshot (tests, pure-config paths) working.
let stamped = platform_keys
.key_for(platform)
.map(str::to_owned)
.or_else(|| platforms.config_api_key(platform));
if let Some(stamped) = stamped {
tracing::debug!(
model_key = %key, platform = platform.as_str(),
"stamped resolved platform api_key onto open-platform entry"
);
entry.api_key = Some(stamped);
}
}
// A credentialed open-platform entry is usable by API-key users.
if entry.has_own_credentials() {
@@ -3877,7 +3855,7 @@ pub fn try_resolve_model_credentials(
let cfg = Config::new_from_toml_cfg(&raw)
.map_err(|e| tracing::warn!(error = % e, "config parse failed for credential resolution"))
.ok()?;
let models = resolve_model_list(&cfg, None);
let models = resolve_model_list(&cfg, None, &Default::default());
let entry = find_model_by_id(&models, model_id)?;
let credentials = resolve_credentials(entry, session_key);
Some(credentials)
@@ -3936,7 +3914,7 @@ fn with_resolved_model<T>(model_id: &str, f: impl FnOnce(ModelLookup) -> T) -> T
else {
return f(ModelLookup::ConfigUnavailable);
};
let models = resolve_model_list(&cfg, None);
let models = resolve_model_list(&cfg, None, &Default::default());
f(ModelLookup::Loaded(find_model_by_id(&models, model_id)))
}
/// Resolve a standalone `SamplerConfig` for an auxiliary model slug (image
@@ -4616,7 +4594,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-custom-model").expect("model should exist");
assert_eq!(model.info.model, "kigi-4.5");
assert_eq!(model.info.base_url, "https://api.example.com/v1");
@@ -5131,7 +5109,7 @@ reasoning_effort = "low"
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get(dm).expect("model should exist");
assert_eq!(model.api_key, Some("user-custom-api-key".to_string()));
assert_eq!(model.info.model, dm);
@@ -5184,7 +5162,7 @@ reasoning_effort = "low"
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let model = resolve_model_list(&cfg, None)
let model = resolve_model_list(&cfg, None, &Default::default())
.get(dm)
.expect("model should exist")
.clone();
@@ -5200,7 +5178,7 @@ reasoning_effort = "low"
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let model = resolve_model_list(&cfg, None)
let model = resolve_model_list(&cfg, None, &Default::default())
.get(dm)
.expect("model should exist")
.clone();
@@ -5221,7 +5199,7 @@ reasoning_effort = "low"
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let model = resolve_model_list(&cfg, None)
let model = resolve_model_list(&cfg, None, &Default::default())
.get(dm)
.expect("model should exist")
.clone();
@@ -5237,7 +5215,7 @@ reasoning_effort = "low"
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let model = resolve_model_list(&cfg, None)
let model = resolve_model_list(&cfg, None, &Default::default())
.get(dm)
.expect("model should exist")
.clone();
@@ -5253,7 +5231,7 @@ reasoning_effort = "low"
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let model = resolve_model_list(&cfg, None)
let model = resolve_model_list(&cfg, None, &Default::default())
.get(dm)
.expect("model should exist")
.clone();
@@ -5367,7 +5345,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-custom-model").expect("model should exist");
assert_eq!(model.info.context_window, NonZeroU64::new(256_000).unwrap());
}
@@ -5394,7 +5372,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved
.get("my-responses-model")
.expect("model should exist");
@@ -5413,7 +5391,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-chat-model").expect("model should exist");
assert_eq!(model.info.api_backend, ApiBackend::ChatCompletions);
}
@@ -5433,7 +5411,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-claude").expect("model should exist");
assert!(
model.info.supports_reasoning_effort,
@@ -5456,7 +5434,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-claude").expect("model should exist");
assert!(
!model.info.supports_reasoning_effort,
@@ -5478,7 +5456,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-openai").expect("model should exist");
assert!(
!model.info.supports_reasoning_effort,
@@ -5497,7 +5475,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-model").expect("model should exist");
assert_eq!(model.info.api_backend, ApiBackend::ChatCompletions);
}
@@ -5523,7 +5501,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved
.get("my-concise-model")
.expect("model should exist");
@@ -5541,7 +5519,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-model").expect("model should exist");
assert!(!model.info.use_concise);
}
@@ -5600,7 +5578,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-model").expect("model should exist");
assert!(
!model.info.use_concise,
@@ -5685,7 +5663,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-agent-model").expect("model should exist");
assert_eq!(model.info.agent_type, "codex");
}
@@ -5701,7 +5679,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("my-model").expect("model should exist");
assert_eq!(model.info.agent_type, DEFAULT_AGENT_TYPE);
}
@@ -5962,7 +5940,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).unwrap();
let catalog = resolve_model_catalog(&cfg, None);
let catalog = resolve_model_catalog(&cfg, None, &Default::default());
let available = available_models(&catalog, true);
assert!(
catalog.contains_key("visible-model"),
@@ -5995,7 +5973,11 @@ reasoning_effort = "low"
"#,
)
.unwrap();
let catalog = resolve_model_catalog(&Config::new_from_toml_cfg(&raw).unwrap(), None);
let catalog = resolve_model_catalog(
&Config::new_from_toml_cfg(&raw).unwrap(),
None,
&Default::default(),
);
assert!(!catalog.contains_key("to-disable"));
}
#[test]
@@ -6012,7 +5994,11 @@ reasoning_effort = "low"
"#,
)
.unwrap();
let catalog = resolve_model_catalog(&Config::new_from_toml_cfg(&raw).unwrap(), None);
let catalog = resolve_model_catalog(
&Config::new_from_toml_cfg(&raw).unwrap(),
None,
&Default::default(),
);
let available = available_models(&catalog, true);
assert!(catalog.contains_key("to-hide"));
assert!(catalog["to-hide"].info.hidden);
@@ -6040,7 +6026,11 @@ reasoning_effort = "low"
"#,
)
.unwrap();
let catalog = resolve_model_catalog(&Config::new_from_toml_cfg(&raw).unwrap(), None);
let catalog = resolve_model_catalog(
&Config::new_from_toml_cfg(&raw).unwrap(),
None,
&Default::default(),
);
assert!(catalog["keep-one"].info.user_selectable, "wildcard match");
assert!(
catalog["explicit-key"].info.user_selectable,
@@ -6065,7 +6055,11 @@ reasoning_effort = "low"
"#,
)
.unwrap();
let catalog = resolve_model_catalog(&Config::new_from_toml_cfg(&raw).unwrap(), None);
let catalog = resolve_model_catalog(
&Config::new_from_toml_cfg(&raw).unwrap(),
None,
&Default::default(),
);
assert!(
catalog["foo"].info.user_selectable,
"empty allowed_models must not restrict"
@@ -6110,7 +6104,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).unwrap();
let catalog = resolve_model_catalog(&cfg, None);
let catalog = resolve_model_catalog(&cfg, None, &Default::default());
assert!(catalog.contains_key("oauth-only-model"));
assert!(catalog.contains_key("public-model"));
let api_available = available_models(&catalog, false);
@@ -6137,7 +6131,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("slow-model").expect("model should exist");
assert_eq!(model.info.inference_idle_timeout_secs, Some(600));
}
@@ -6153,7 +6147,7 @@ reasoning_effort = "low"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let model = resolved.get("default-model").expect("model should exist");
assert_eq!(model.info.inference_idle_timeout_secs, None);
}
@@ -6214,7 +6208,7 @@ reasoning_effort = "low"
) -> (Config, IndexMap<String, ModelEntry>) {
let raw: toml::Value = toml::from_str(toml_str).expect("test TOML should parse");
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, prefetched);
let resolved = resolve_model_list(&cfg, prefetched, &Default::default());
(cfg, resolved)
}
fn resolve_sampling(model: &ModelEntry, session_key: Option<&str>) -> SamplerConfig {
@@ -6539,7 +6533,7 @@ reasoning_effort = "low"
None,
),
);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
assert!(
resolved.contains_key("acme-model"),
"enterprise model should be present"
@@ -6553,7 +6547,7 @@ reasoning_effort = "low"
#[test]
fn e2e_default_endpoint_still_injects_defaults() {
let cfg = Config::default();
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
assert!(
resolved.contains_key(BUNDLED_DEFAULT_KEY),
"default model should be present when using default endpoint"
@@ -8703,7 +8697,7 @@ default = "kigi-4.5"
);
entry.info.context_window = NonZeroU64::new(default_cw).unwrap();
prefetched.insert("kigi-4.5".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let by_key = resolved
.get("kigi-build")
.expect("kigi-build key must exist");
@@ -8740,7 +8734,7 @@ default = "kigi-4.5"
entry.info.agent_type = default_agent_type();
entry.info.api_backend = ApiBackend::default();
prefetched.insert("kigi-4.5".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let latest = resolved.get("kigi-4.5").unwrap();
assert_eq!(
latest.info.agent_type,
@@ -8772,7 +8766,7 @@ default = "kigi-4.5"
test_model_entry("kigi-4.5", "https://test.example.com/v1", None, None, None);
entry.info.context_window = NonZeroU64::new(65_536).unwrap();
prefetched.insert("kigi-4.5".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let latest = resolved.get("kigi-4.5").unwrap();
assert_eq!(
latest.info.context_window.get(),
@@ -8795,7 +8789,7 @@ default = "kigi-4.5"
);
entry.info.context_window = NonZeroU64::new(default_cw).unwrap();
prefetched.insert("some-unknown-model".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let model = resolved.get("some-unknown-model").unwrap();
assert_eq!(
model.info.context_window.get(),
@@ -8944,7 +8938,7 @@ default = "kigi-4.5"
let entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert("remote-only-model".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let model = resolved
.get("remote-only-model")
.expect("prefetched model should exist");
@@ -8970,7 +8964,7 @@ default = "kigi-4.5"
let entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert("remote-only-model".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let info = &resolved
.get("remote-only-model")
.expect("prefetched model should exist")
@@ -8997,7 +8991,7 @@ default = "kigi-4.5"
let entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert("remote-only-model".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let model = resolved
.get("remote-only-model")
.expect("model should exist");
@@ -9021,7 +9015,7 @@ default = "kigi-4.5"
entry.info.max_retries = Some(3);
let mut prefetched = IndexMap::new();
prefetched.insert("remote-only-model".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let model = resolved
.get("remote-only-model")
.expect("prefetched model should exist");
@@ -9058,7 +9052,7 @@ default = "kigi-4.5"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let custom = &resolved.get("custom").expect("custom model").info;
assert_eq!(custom.reasoning_efforts.len(), 2);
assert_eq!(custom.reasoning_efforts[0].label, "High");
@@ -9094,7 +9088,7 @@ default = "kigi-4.5"
}];
let mut prefetched = IndexMap::new();
prefetched.insert("kigi-x".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let efforts = &resolved
.get("kigi-x")
.expect("kigi-x")
@@ -9113,7 +9107,7 @@ default = "kigi-4.5"
let entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, default_cw, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist");
assert_ne!(
entry.info.context_window.get(),
@@ -9128,7 +9122,7 @@ default = "kigi-4.5"
let entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, explicit_cw, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist");
assert_eq!(
entry.info.context_window.get(),
@@ -9143,7 +9137,7 @@ default = "kigi-4.5"
let entry = prefetch_model_entry("kigi", default_cw, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert("kigi".to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
let entry = resolved.get("kigi").expect("model must exist");
let defaults = default_model_entries(&EndpointsConfig::default());
if let Some(default) = defaults.get("kigi") {
@@ -9169,9 +9163,9 @@ default = "kigi-4.5"
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
}
let resolved = resolve_model_list(&cfg, Some(p));
let resolved = resolve_model_list(&cfg, Some(p), &Default::default());
assert!(resolved.contains_key(BUNDLED_DEFAULT_KEY));
let no_p = resolve_model_list(&cfg, None);
let no_p = resolve_model_list(&cfg, None, &Default::default());
assert!(no_p.contains_key(BUNDLED_DEFAULT_KEY));
}
#[test]
@@ -9182,7 +9176,7 @@ default = "kigi-4.5"
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
}
let resolved = resolve_model_list(&cfg, Some(p));
let resolved = resolve_model_list(&cfg, Some(p), &Default::default());
let sess: Vec<_> = resolved
.values()
.filter(|e| e.visible_for_auth(true))
@@ -9203,7 +9197,7 @@ default = "kigi-4.5"
let mut p = IndexMap::new();
let e = prefetch_model_entry("secret-xyz", 200000, ApiBackend::default());
p.insert("secret-xyz".to_string(), e);
let resolved = resolve_model_list(&cfg, Some(p));
let resolved = resolve_model_list(&cfg, Some(p), &Default::default());
assert!(resolved.contains_key("secret-xyz"));
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
}
@@ -9213,14 +9207,14 @@ default = "kigi-4.5"
let mut p = IndexMap::new();
let e = prefetch_model_entry("kimi-fresh", 500_000, ApiBackend::Responses);
p.insert("kimi-fresh".to_string(), e);
let resolved = resolve_model_list(&cfg, Some(p));
let resolved = resolve_model_list(&cfg, Some(p), &Default::default());
assert!(resolved.contains_key("kimi-fresh"));
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
}
#[test]
fn resolve_model_list_empty_prefetch_yields_empty_base() {
let cfg = Config::default();
let resolved = resolve_model_list(&cfg, Some(IndexMap::new()));
let resolved = resolve_model_list(&cfg, Some(IndexMap::new()), &Default::default());
assert!(resolved.is_empty());
}
/// Regression: enterprise managed config aliases the bundled subscription
@@ -9239,7 +9233,7 @@ default = "kigi-4.5"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let entry = resolved
.get(BUNDLED_DEFAULT_KEY)
.expect("bundled default must exist");
@@ -9261,7 +9255,7 @@ default = "kigi-4.5"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let entry = resolved
.get(BUNDLED_DEFAULT_KEY)
.expect("bundled default must exist");
@@ -9287,7 +9281,7 @@ default = "kigi-4.5"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let resolved = resolve_model_list(&cfg, None, &Default::default());
let cn = resolved
.get("moonshot-cn/kimi-k2-turbo-preview")
@@ -9318,6 +9312,76 @@ default = "kigi-4.5"
"the OAuth platform takes no API key"
);
}
/// A key that resolves ONLY via auth.json (the TUI-paste storage) must be
/// stamped onto the platform's catalog entries exactly like a config.toml
/// key — otherwise login validates the key but every completion goes out
/// keyless (401), and restart falls back to the login screen.
#[test]
#[serial]
fn auth_json_resolved_key_stamps_matching_open_platform_entries() {
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 cfg = Config::default();
// The resolved snapshot as PlatformApiKeys::resolve would build it
// from an auth.json `moonshot-cn` scope (no env, no config.toml).
let keys =
crate::agent::models::PlatformApiKeys::test_keys(Some("sk-from-auth-json"), None);
let resolved = resolve_model_list(&cfg, None, &keys);
let cn = resolved
.get("moonshot-cn/kimi-k2-turbo-preview")
.expect("bundled moonshot-cn entry");
assert_eq!(
cn.api_key.as_deref(),
Some("sk-from-auth-json"),
"auth.json-resolved key must be stamped onto the entry"
);
assert!(
cn.visible_for_auth(false),
"credentialed open-platform entry must be visible to API-key users"
);
assert!(
crate::agent::auth_method::should_advertise_xai_api_key(resolved.values()),
"a stamped auth.json key alone must advertise the API-key auth \
method on restart (no login screen)"
);
let ai = resolved
.get("moonshot-ai/kimi-k2-turbo-preview")
.expect("bundled moonshot-ai entry");
assert!(
ai.api_key.is_none(),
"the cn key must not leak onto the ai platform"
);
}
/// When auth.json and config.toml disagree, the resolved snapshot
/// (auth.json) wins — the same precedence the login validator uses, so a
/// key rotated via the TUI can never lose to a stale config.toml key.
#[test]
#[serial]
fn auth_json_key_beats_stale_config_key_when_stamping() {
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 raw: toml::Value = toml::from_str(
r#"
[platforms.moonshot-cn]
api_key = "sk-stale-config"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let keys = crate::agent::models::PlatformApiKeys::test_keys(Some("sk-rotated"), None);
let resolved = resolve_model_list(&cfg, None, &keys);
let cn = resolved
.get("moonshot-cn/kimi-k2-turbo-preview")
.expect("bundled moonshot-cn entry");
assert_eq!(
cn.api_key.as_deref(),
Some("sk-rotated"),
"the resolved snapshot must beat the stale config.toml key"
);
}
/// F2 acceptance: with ONLY a moonshot key (env), the api-key auth method
/// is advertised (no login screen) because the catalog has a credentialed
/// entry.
@@ -9326,84 +9390,96 @@ default = "kigi-4.5"
fn moonshot_env_key_advertises_api_key_auth_method() {
let _gen = EnvGuard::set(kigi_models::MOONSHOT_API_KEY_ENV, "sk-only-moonshot");
let cfg = Config::default();
let models = resolve_model_list(&cfg, None);
let models = resolve_model_list(&cfg, None, &Default::default());
assert!(
crate::agent::auth_method::should_advertise_xai_api_key(models.values()),
"a moonshot env key alone must advertise the API-key auth method"
);
}
/// The login-screen writer persists `[platforms.<id>].api_key` into the
/// exact table `resolve_platform_api_key` reads back, preserving sibling
/// tables and never leaking onto the other platform.
#[tokio::test]
async fn save_platform_api_key_round_trips_through_resolver() {
/// The login-screen writer persists the key into auth.json under the
/// platform-id scope — the exact scope `resolve_platform_api_key` reads
/// back — trimming whitespace and never leaking onto the other platform.
#[test]
fn save_platform_api_key_round_trips_through_resolver() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[ui]\ncompact_mode = true\n").unwrap();
let home = dir.path();
save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, "sk-from-tui")
.await
save_platform_api_key_in(home, kigi_models::PlatformId::MoonshotCn, " sk-from-tui ")
.expect("write must succeed");
let raw: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let platforms: PlatformsConfig = raw
.get("platforms")
.cloned()
.expect("[platforms] written")
.try_into()
.expect("PlatformsConfig parses");
// Env unset in this resolve (injected getenv) → config file wins.
let resolved =
resolve_platform_api_key_with(kigi_models::PlatformId::MoonshotCn, &platforms, |_| {
None
});
assert_eq!(resolved.as_deref(), Some("sk-from-tui"));
assert!(
platforms
.config_api_key(kigi_models::PlatformId::MoonshotAi)
.is_none(),
// Env unset in this resolve (injected getenv) → auth.json wins.
let platforms = PlatformsConfig::default();
let resolved = resolve_platform_api_key_with(
kigi_models::PlatformId::MoonshotCn,
&platforms,
|_| None,
|p| crate::auth::read_platform_api_key(home, p),
);
assert_eq!(
resolved.as_deref(),
Some("sk-from-tui"),
"trimmed key round-trips"
);
assert_eq!(
crate::auth::read_platform_api_key(home, kigi_models::PlatformId::MoonshotAi),
None,
"the cn key must not leak onto the ai platform"
);
assert!(
raw.get("ui")
.and_then(|ui| ui.get("compact_mode"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
"sibling [ui] table must be preserved"
);
}
/// Writer guardrails: the OAuth platform takes no key, empty keys are
/// rejected, and an unparseable config is refused (never clobbered).
#[tokio::test]
async fn save_platform_api_key_rejects_invalid_inputs() {
/// Precedence: env var > auth.json scope > `[platforms.*]` config file.
#[test]
fn platform_key_precedence_env_then_auth_json_then_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let home = dir.path();
save_platform_api_key_in(home, kigi_models::PlatformId::MoonshotCn, "sk-auth-json")
.expect("write must succeed");
let platforms: PlatformsConfig =
toml::from_str("[moonshot-cn]\napi_key = \"sk-config\"\n").unwrap();
let stored = |p| crate::auth::read_platform_api_key(home, p);
// Env wins over both files.
let resolved = resolve_platform_api_key_with(
kigi_models::PlatformId::MoonshotCn,
&platforms,
|name| (name == kigi_models::MOONSHOT_CN_API_KEY_ENV).then(|| "sk-env".to_owned()),
stored,
);
assert_eq!(resolved.as_deref(), Some("sk-env"));
// auth.json wins over config.toml.
let resolved = resolve_platform_api_key_with(
kigi_models::PlatformId::MoonshotCn,
&platforms,
|_| None,
stored,
);
assert_eq!(resolved.as_deref(), Some("sk-auth-json"));
// config.toml is the last fallback.
let resolved = resolve_platform_api_key_with(
kigi_models::PlatformId::MoonshotCn,
&platforms,
|_| None,
|_| None,
);
assert_eq!(resolved.as_deref(), Some("sk-config"));
}
/// Writer guardrails: the OAuth platform takes no key and empty keys are
/// rejected — and a rejected write never creates auth.json.
#[test]
fn save_platform_api_key_rejects_invalid_inputs() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(
save_platform_api_key_at(&path, kigi_models::PlatformId::KimiCode, "sk-x")
.await
.is_err(),
save_platform_api_key_in(home, kigi_models::PlatformId::KimiCode, "sk-x").is_err(),
"kimi-code authenticates via OAuth and must reject an API key"
);
assert!(
save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, " ")
.await
.is_err(),
save_platform_api_key_in(home, kigi_models::PlatformId::MoonshotCn, " ").is_err(),
"blank keys must be rejected"
);
let bad = "this is [not valid toml\n";
std::fs::write(&path, bad).unwrap();
assert!(
save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, "sk-x")
.await
.is_err(),
"unparseable config must be refused"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
bad,
"unparseable config must be left untouched"
!home.join("auth.json").exists(),
"rejected writes must not create auth.json"
);
}
#[test]