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:
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -311,7 +311,7 @@ mod tests {
|
||||
w.kind == ModelOverrideWarningKind::DuplicateAlias
|
||||
&& w.field.as_deref() == Some("send_compactions_remaining")
|
||||
}));
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None);
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &Default::default());
|
||||
assert!(resolved.contains_key("kigi-4.5"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -176,8 +176,9 @@ fn fetch_platform_models_blocking(
|
||||
let enabled = enabled_platforms(auth.is_some(), platform_keys);
|
||||
if enabled.is_empty() {
|
||||
return Err(BackendError::Auth(
|
||||
"No platform credentials: log in with `kigi login` or configure a moonshot API key \
|
||||
(KIGI_MOONSHOT_API_KEY or [platforms.*] in ~/.kigi/config.toml)."
|
||||
"No platform credentials: log in with `kigi login`, paste a platform API key in \
|
||||
the login screen (stored in ~/.kigi/auth.json), or set a platform env var such \
|
||||
as KIGI_MOONSHOT_API_KEY."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -508,21 +508,23 @@ impl acp::Agent for MvpAgent {
|
||||
);
|
||||
Ok(self.auth_response_with_meta())
|
||||
}
|
||||
auth_method::MOONSHOT_CN_METHOD_ID | auth_method::MOONSHOT_AI_METHOD_ID => {
|
||||
let platform = auth_method::moonshot_platform_for_method_id(
|
||||
&arguments.method_id,
|
||||
)
|
||||
.expect("match arm guarantees a moonshot method id");
|
||||
self.authenticate_moonshot(platform, arguments.method_id.clone())
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
Err(
|
||||
acp::Error::invalid_params()
|
||||
.data(
|
||||
format!("unsupported auth method: {}", arguments.method_id.0),
|
||||
),
|
||||
)
|
||||
if let Some(platform) =
|
||||
auth_method::platform_for_method_id(&arguments.method_id)
|
||||
{
|
||||
self.authenticate_api_key_platform(
|
||||
platform,
|
||||
arguments.method_id.clone(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Err(
|
||||
acp::Error::invalid_params()
|
||||
.data(
|
||||
format!("unsupported auth method: {}", arguments.method_id.0),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,18 +452,19 @@ impl MvpAgent {
|
||||
)
|
||||
.await
|
||||
}
|
||||
/// `authenticate(moonshot-cn / moonshot-ai)`: interactive open-platform
|
||||
/// API-key login from the welcome picker.
|
||||
/// `authenticate(<api-key platform id>)`: interactive API-key login from
|
||||
/// the welcome picker for any non-OAuth registry platform.
|
||||
///
|
||||
/// Reloads the platform keys from disk+env (the TUI persists the pasted
|
||||
/// key to `[platforms.<id>]` in config.toml immediately before this call),
|
||||
/// fails with an actionable error when none is configured, validates the
|
||||
/// key against `GET {platform_base}/models`, then marks the session
|
||||
/// authenticated exactly like an external API key: publish the method id
|
||||
/// (NOT session-based — no token refresh), swap the freshly-stamped config
|
||||
/// into the models manager, and trigger the model sync so the catalog
|
||||
/// gains the platform's entries. The key itself is never logged.
|
||||
pub(super) async fn authenticate_moonshot(
|
||||
/// key to auth.json under the platform-id scope immediately before this
|
||||
/// call), fails with an actionable error when none is configured,
|
||||
/// validates the key against `GET {platform_base}/models`, then marks the
|
||||
/// session authenticated exactly like an external API key: publish the
|
||||
/// method id (NOT session-based — no token refresh), swap the
|
||||
/// freshly-stamped config into the models manager, and trigger the model
|
||||
/// sync so the catalog gains the platform's entries. The key itself is
|
||||
/// never logged.
|
||||
pub(super) async fn authenticate_api_key_platform(
|
||||
&self,
|
||||
platform: kigi_models::PlatformId,
|
||||
method_id: acp::AuthMethodId,
|
||||
@@ -480,10 +481,11 @@ impl MvpAgent {
|
||||
Some("platform_key_invalid_or_missing"),
|
||||
);
|
||||
})?;
|
||||
// Swap the on-disk config (now carrying the key) into the models
|
||||
// manager so `apply_platform_credentials` stamps the platform's
|
||||
// catalog entries; a parse failure keeps the last-known-good config
|
||||
// (`on_auth_changed` below still re-resolves keys from disk itself).
|
||||
// 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
|
||||
// parse failure keeps the last-known-good config (`on_auth_changed`
|
||||
// below still re-resolves keys from disk itself).
|
||||
match crate::config::load_effective_config()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|raw| crate::agent::config::Config::new_from_toml_cfg(&raw))
|
||||
|
||||
Reference in New Issue
Block a user