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:
@@ -150,6 +150,22 @@ edges stay deterministic Rust. The harness appends a terminal
|
|||||||
the replan cap; `{"ops": []}` is a respected free no-op; failures
|
the replan cap; `{"ops": []}` is a respected free no-op; failures
|
||||||
degrade.
|
degrade.
|
||||||
|
|
||||||
|
## Provider registry & API-key auth (post-0.1.3 expansion)
|
||||||
|
|
||||||
|
- The platform registry is compiled-in spec rows in `kigi-models`
|
||||||
|
(`PlatformSpec`; adding a platform = enum variant + `ALL` entry + `spec()`
|
||||||
|
arm + row; registry tests enforce completeness/uniqueness/row shape).
|
||||||
|
- API-key resolution precedence, per platform: platform env var(s) >
|
||||||
|
`auth.json` scope named by the platform id (`moonshot-cn`, …) >
|
||||||
|
legacy `[platforms.<id>]` in config.toml (read-only fallback).
|
||||||
|
- The TUI login picker persists pasted keys to `auth.json` (platform-id
|
||||||
|
scope, `api_key` mode) — never to config.toml. The keyring holds ONLY the
|
||||||
|
OAuth session scope; platform keys are file-only.
|
||||||
|
- Auth method ids over ACP equal the platform ids; interactive picker rows
|
||||||
|
are built generically from advertised methods (`AuthMethodKind::
|
||||||
|
ApiKeyPlatform`), so new registry rows appear in the picker with no TUI
|
||||||
|
changes.
|
||||||
|
|
||||||
## Milestones (PRD §8.3)
|
## Milestones (PRD §8.3)
|
||||||
|
|
||||||
- M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/
|
- M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ enum BaseUrlSource {
|
|||||||
/// `all_covers_every_variant` test — a variant missing from `ALL` would
|
/// `all_covers_every_variant` test — a variant missing from `ALL` would
|
||||||
/// otherwise be silently unparseable and excluded from model sync.
|
/// otherwise be silently unparseable and excluded from model sync.
|
||||||
struct PlatformSpec {
|
struct PlatformSpec {
|
||||||
/// Wire id (auth method id, managed-model-key prefix, config key).
|
/// Wire id (auth method id, managed-model-key prefix, config key, and —
|
||||||
|
/// for API-key platforms — the auth.json scope the key is stored under).
|
||||||
id: &'static str,
|
id: &'static str,
|
||||||
display_name: &'static str,
|
display_name: &'static str,
|
||||||
base_url: BaseUrlSource,
|
base_url: BaseUrlSource,
|
||||||
@@ -74,6 +75,13 @@ struct PlatformSpec {
|
|||||||
///
|
///
|
||||||
/// SECURITY: the *values* behind these names must never be logged.
|
/// SECURITY: the *values* behind these names must never be logged.
|
||||||
api_key_envs: &'static [&'static str],
|
api_key_envs: &'static [&'static str],
|
||||||
|
/// Short vendor word for login copy ("Paste your {vendor} API key").
|
||||||
|
vendor: &'static str,
|
||||||
|
/// Where the user gets an API key (login copy + key-validation errors).
|
||||||
|
/// `None` for OAuth channels.
|
||||||
|
console_host: Option<&'static str>,
|
||||||
|
/// Interactive login-picker label. `None` = fall back to `display_name`.
|
||||||
|
login_label: Option<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
||||||
@@ -83,6 +91,9 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
uses_oauth: true,
|
uses_oauth: true,
|
||||||
allowed_model_prefixes: None,
|
allowed_model_prefixes: None,
|
||||||
api_key_envs: &[],
|
api_key_envs: &[],
|
||||||
|
vendor: "Kimi",
|
||||||
|
console_host: None,
|
||||||
|
login_label: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
|
const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
|
||||||
@@ -95,6 +106,9 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
uses_oauth: false,
|
uses_oauth: false,
|
||||||
allowed_model_prefixes: Some(&["kimi-k"]),
|
allowed_model_prefixes: Some(&["kimi-k"]),
|
||||||
api_key_envs: &[MOONSHOT_CN_API_KEY_ENV, MOONSHOT_API_KEY_ENV],
|
api_key_envs: &[MOONSHOT_CN_API_KEY_ENV, MOONSHOT_API_KEY_ENV],
|
||||||
|
vendor: "Moonshot",
|
||||||
|
console_host: Some("platform.moonshot.cn"),
|
||||||
|
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
|
const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
|
||||||
@@ -107,6 +121,9 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
uses_oauth: false,
|
uses_oauth: false,
|
||||||
allowed_model_prefixes: Some(&["kimi-k"]),
|
allowed_model_prefixes: Some(&["kimi-k"]),
|
||||||
api_key_envs: &[MOONSHOT_AI_API_KEY_ENV, MOONSHOT_API_KEY_ENV],
|
api_key_envs: &[MOONSHOT_AI_API_KEY_ENV, MOONSHOT_API_KEY_ENV],
|
||||||
|
vendor: "Moonshot",
|
||||||
|
console_host: Some("platform.moonshot.ai"),
|
||||||
|
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.ai)"),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The platform registry. Platforms are compiled-in spec rows; there is no
|
/// The platform registry. Platforms are compiled-in spec rows; there is no
|
||||||
@@ -183,6 +200,23 @@ impl PlatformId {
|
|||||||
pub fn managed_model_key(self, model_id: &str) -> String {
|
pub fn managed_model_key(self, model_id: &str) -> String {
|
||||||
format!("{}/{model_id}", self.as_str())
|
format!("{}/{model_id}", self.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short vendor word for login copy ("Paste your {vendor} API key").
|
||||||
|
pub fn vendor(self) -> &'static str {
|
||||||
|
self.spec().vendor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Console host where the user obtains an API key, for login copy and
|
||||||
|
/// key-validation errors. `None` for OAuth channels.
|
||||||
|
pub fn console_host(self) -> Option<&'static str> {
|
||||||
|
self.spec().console_host
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label for the interactive login picker (falls back to the display
|
||||||
|
/// name when the row doesn't override it).
|
||||||
|
pub fn login_label(self) -> &'static str {
|
||||||
|
self.spec().login_label.unwrap_or(self.spec().display_name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a managed catalog key `{platform_id}/{model_id}` back into its
|
/// Split a managed catalog key `{platform_id}/{model_id}` back into its
|
||||||
@@ -531,6 +565,39 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row-shape invariants the login UI and key resolution rely on:
|
||||||
|
/// API-key platforms carry a console host (paste-box copy) and at least
|
||||||
|
/// one key env var (missing-key error names it); OAuth channels carry
|
||||||
|
/// neither key envs nor a console host requirement.
|
||||||
|
#[test]
|
||||||
|
fn api_key_rows_carry_console_host_and_env_names() {
|
||||||
|
for p in PlatformId::ALL {
|
||||||
|
if p.uses_oauth() {
|
||||||
|
assert!(
|
||||||
|
p.api_key_env_names().is_empty(),
|
||||||
|
"{}: OAuth platforms take no key envs",
|
||||||
|
p.as_str()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
p.console_host().is_some(),
|
||||||
|
"{}: API-key platforms must name their console host",
|
||||||
|
p.as_str()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!p.api_key_env_names().is_empty(),
|
||||||
|
"{}: API-key platforms must name at least one key env",
|
||||||
|
p.as_str()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!p.vendor().is_empty(),
|
||||||
|
"{}: API-key platforms must set a vendor word",
|
||||||
|
p.as_str()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `parse` resolves by scanning spec rows, so duplicate ids would
|
/// `parse` resolves by scanning spec rows, so duplicate ids would
|
||||||
/// silently shadow a platform. Pin uniqueness as rows are added.
|
/// silently shadow a platform. Pin uniqueness as rows are added.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ pub struct BuiltAuthMethods {
|
|||||||
/// 1. `xai.api_key` (if `has_external_api_key`)
|
/// 1. `xai.api_key` (if `has_external_api_key`)
|
||||||
/// 2. `cached_token` (if `has_cached_token`)
|
/// 2. `cached_token` (if `has_cached_token`)
|
||||||
/// 3. `kimi-code` (the Kimi Code device login)
|
/// 3. `kimi-code` (the Kimi Code device login)
|
||||||
/// 4. `moonshot-cn` (Moonshot Open Platform API-key login, always)
|
/// 4. every API-key registry platform, in `PlatformId::ALL` order
|
||||||
/// 5. `moonshot-ai` (Moonshot Open Platform API-key login, always)
|
/// (`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
|
/// after `kimi-code` so they can never become `auth_methods.first()` (the
|
||||||
/// pager's startup metadata / eager-auth fallback reads `first()`), and they
|
/// 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
|
/// authenticates eagerly via `xai.api_key` — the catalog entries it stamps
|
||||||
/// satisfy `should_advertise_xai_api_key`).
|
/// 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(kimi_code_auth_method(login_label));
|
||||||
methods.push(moonshot_auth_method(kigi_models::PlatformId::MoonshotCn));
|
for platform in kigi_models::PlatformId::ALL {
|
||||||
methods.push(moonshot_auth_method(kigi_models::PlatformId::MoonshotAi));
|
if !platform.uses_oauth() {
|
||||||
|
methods.push(platform_auth_method(platform));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BuiltAuthMethods {
|
BuiltAuthMethods {
|
||||||
methods,
|
methods,
|
||||||
@@ -165,10 +168,8 @@ pub enum AuthMethodKind {
|
|||||||
XaiApiKey,
|
XaiApiKey,
|
||||||
CachedToken,
|
CachedToken,
|
||||||
KimiCode,
|
KimiCode,
|
||||||
/// Moonshot Open Platform API-key login (moonshot.cn).
|
/// Registry API-key platform login (method id = the platform id).
|
||||||
MoonshotCn,
|
ApiKeyPlatform(kigi_models::PlatformId),
|
||||||
/// Moonshot Open Platform API-key login (moonshot.ai).
|
|
||||||
MoonshotAi,
|
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,17 +179,18 @@ impl AuthMethodKind {
|
|||||||
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
|
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
|
||||||
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
|
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
|
||||||
KIMI_CODE_METHOD_ID => Self::KimiCode,
|
KIMI_CODE_METHOD_ID => Self::KimiCode,
|
||||||
MOONSHOT_CN_METHOD_ID => Self::MoonshotCn,
|
other => match platform_for_method_id_str(other) {
|
||||||
MOONSHOT_AI_METHOD_ID => Self::MoonshotAi,
|
Some(platform) => Self::ApiKeyPlatform(platform),
|
||||||
_ => Self::Unknown,
|
None => Self::Unknown,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API key auth: no auth.json session, no refresh, no browser round-trip.
|
/// API key auth: no auth.json session, no refresh, no browser round-trip.
|
||||||
/// The moonshot methods qualify — they validate a configured platform key
|
/// The registry platform methods qualify — they validate a configured
|
||||||
/// and then behave exactly like an external-API-key session.
|
/// platform key and then behave exactly like an external-API-key session.
|
||||||
pub fn is_api_key(self) -> bool {
|
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).
|
/// `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
|
/// Interactive API-key login method ids equal
|
||||||
/// equal [`kigi_models::PlatformId::as_str`] (`moonshot-cn` / `moonshot-ai`),
|
/// [`kigi_models::PlatformId::as_str`] (`moonshot-cn` / `moonshot-ai` / …),
|
||||||
/// which is also the `[platforms.<id>]` config-table name — one id everywhere.
|
/// 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_CN_METHOD_ID: &str = "moonshot-cn";
|
||||||
pub const MOONSHOT_AI_METHOD_ID: &str = "moonshot-ai";
|
pub const MOONSHOT_AI_METHOD_ID: &str = "moonshot-ai";
|
||||||
|
|
||||||
/// The open platform behind an interactive moonshot method id. `None` for
|
/// The API-key registry platform behind an interactive method id. `None`
|
||||||
/// every other id (including `kimi-code`, whose platform uses OAuth).
|
/// 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> {
|
pub fn platform_for_method_id(id: &acp::AuthMethodId) -> Option<kigi_models::PlatformId> {
|
||||||
match id.0.as_ref() {
|
platform_for_method_id_str(id.0.as_ref())
|
||||||
MOONSHOT_CN_METHOD_ID => Some(kigi_models::PlatformId::MoonshotCn),
|
|
||||||
MOONSHOT_AI_METHOD_ID => Some(kigi_models::PlatformId::MoonshotAi),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Console host for an open platform, used in method descriptions and login
|
fn platform_for_method_id_str(id: &str) -> Option<kigi_models::PlatformId> {
|
||||||
/// copy ("platform.moonshot.cn" / "platform.moonshot.ai").
|
kigi_models::PlatformId::parse(id).filter(|p| !p.uses_oauth())
|
||||||
pub fn moonshot_console_host(platform: kigi_models::PlatformId) -> &'static str {
|
|
||||||
match platform {
|
|
||||||
kigi_models::PlatformId::MoonshotCn => "platform.moonshot.cn",
|
|
||||||
_ => "platform.moonshot.ai",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Moonshot Open Platform API-key login method.
|
/// An API-key registry platform's login method (picker label + description
|
||||||
pub fn moonshot_auth_method(platform: kigi_models::PlatformId) -> acp::AuthMethod {
|
/// from the platform's spec row).
|
||||||
let host_suffix = match platform {
|
pub fn platform_auth_method(platform: kigi_models::PlatformId) -> acp::AuthMethod {
|
||||||
kigi_models::PlatformId::MoonshotCn => "moonshot.cn",
|
let description = match platform.console_host() {
|
||||||
_ => "moonshot.ai",
|
Some(host) => format!("API key from {host}"),
|
||||||
|
None => format!("API key for {}", platform.display_name()),
|
||||||
};
|
};
|
||||||
acp::AuthMethod::Agent(
|
acp::AuthMethod::Agent(
|
||||||
acp::AuthMethodAgent::new(
|
acp::AuthMethodAgent::new(
|
||||||
acp::AuthMethodId::new(platform.as_str()),
|
acp::AuthMethodId::new(platform.as_str()),
|
||||||
format!("Moonshot Open Platform (API key \u{b7} {host_suffix})"),
|
platform.login_label().to_string(),
|
||||||
)
|
)
|
||||||
.description(Some(format!(
|
.description(Some(description)),
|
||||||
"API key from {}",
|
|
||||||
moonshot_console_host(platform)
|
|
||||||
))),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actionable error for a moonshot `authenticate` with no key configured.
|
/// Actionable error for a platform `authenticate` with no key configured.
|
||||||
pub fn missing_moonshot_key_error(platform: kigi_models::PlatformId) -> String {
|
pub fn missing_platform_key_error(platform: kigi_models::PlatformId) -> String {
|
||||||
let env_var = platform
|
match platform.api_key_env_names().first() {
|
||||||
.api_key_env_names()
|
Some(env_var) => format!(
|
||||||
.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}",
|
"No API key configured for {} \u{2014} paste one in the login screen or set {env_var}",
|
||||||
platform.as_str(),
|
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
|
/// `resolve_platform_api_key`) — `None` fails with the actionable
|
||||||
/// missing-key message. A present key is validated with
|
/// missing-key message. A present key is validated with
|
||||||
/// `GET {platform_base}/models` (the same endpoint the catalog fetch uses):
|
/// `GET {platform_base}/models` (the same endpoint the catalog fetch uses):
|
||||||
@@ -398,7 +391,7 @@ pub(crate) async fn authenticate_platform_api_key(
|
|||||||
err
|
err
|
||||||
};
|
};
|
||||||
let Some(key) = key else {
|
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 url = format!("{}/models", platform.base_url().trim_end_matches('/'));
|
||||||
let response = crate::http::shared_client()
|
let response = crate::http::shared_client()
|
||||||
@@ -412,7 +405,7 @@ pub(crate) async fn authenticate_platform_api_key(
|
|||||||
return Err(auth_err(format!(
|
return Err(auth_err(format!(
|
||||||
"Invalid API key for {} \u{2014} check your key on {}",
|
"Invalid API key for {} \u{2014} check your key on {}",
|
||||||
platform.as_str(),
|
platform.as_str(),
|
||||||
moonshot_console_host(platform),
|
platform.console_host().unwrap_or("the provider console"),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
@@ -464,10 +457,14 @@ mod tests {
|
|||||||
assert!(api.is_api_key());
|
assert!(api.is_api_key());
|
||||||
assert!(!api.is_session_based());
|
assert!(!api.is_session_based());
|
||||||
assert!(!api.needs_interactive_login());
|
assert!(!api.needs_interactive_login());
|
||||||
// Moonshot methods are API-key shaped: NOT session-based (no token
|
// Registry platform methods are API-key shaped: NOT session-based (no
|
||||||
// refresh may ever run for them) and no browser round-trip.
|
// token refresh may ever run for them) and no browser round-trip.
|
||||||
for id in [MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID] {
|
for id in [MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID] {
|
||||||
let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new(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_api_key(), "{id} must classify as api-key");
|
||||||
assert!(!kind.is_session_based(), "{id} must not be session-based");
|
assert!(!kind.is_session_based(), "{id} must not be session-based");
|
||||||
assert!(
|
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]
|
#[test]
|
||||||
fn session_token_auth_gate_matrix() {
|
fn session_token_auth_gate_matrix() {
|
||||||
// Session method + NotByok → refresh.
|
// Session method + NotByok → refresh.
|
||||||
@@ -673,7 +685,7 @@ mod tests {
|
|||||||
fn global_external_api_key_advertises_xai_api_key_first() {
|
fn global_external_api_key_advertises_xai_api_key_first() {
|
||||||
let _set = EnvGuard::set(XAI_API_KEY_ENV_VAR, "xai-external-key");
|
let _set = EnvGuard::set(XAI_API_KEY_ENV_VAR, "xai-external-key");
|
||||||
let cfg = Config::default();
|
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());
|
let has_external_api_key = should_advertise_xai_api_key(models.values());
|
||||||
assert!(has_external_api_key);
|
assert!(has_external_api_key);
|
||||||
let built = build_auth_methods(AuthMethodsBuildInputs {
|
let built = build_auth_methods(AuthMethodsBuildInputs {
|
||||||
|
|||||||
@@ -794,22 +794,17 @@ pub struct PlatformCredentialConfig {
|
|||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the API key for an open-platform registry entry:
|
/// Resolve the API key for an API-key registry platform with injected env
|
||||||
/// platform-scoped env > generic `KIGI_MOONSHOT_API_KEY` env > config file.
|
/// and auth.json readers. Precedence: env > auth.json > config file — the
|
||||||
/// `None` for the OAuth platform and when nothing is configured.
|
/// same "env always wins" rule the config-file layer already follows.
|
||||||
/// The returned value must never be logged.
|
/// `None` for OAuth platforms and when nothing is configured. The batch
|
||||||
pub(crate) fn resolve_platform_api_key(
|
/// production caller is `PlatformApiKeys::resolve` (reads auth.json once
|
||||||
platform: kigi_models::PlatformId,
|
/// for the whole registry sweep). The returned value must never be logged.
|
||||||
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.
|
|
||||||
pub(crate) fn resolve_platform_api_key_with(
|
pub(crate) fn resolve_platform_api_key_with(
|
||||||
platform: kigi_models::PlatformId,
|
platform: kigi_models::PlatformId,
|
||||||
platforms: &PlatformsConfig,
|
platforms: &PlatformsConfig,
|
||||||
mut getenv: impl FnMut(&str) -> Option<String>,
|
mut getenv: impl FnMut(&str) -> Option<String>,
|
||||||
|
stored: impl FnOnce(kigi_models::PlatformId) -> Option<String>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
for name in platform.api_key_env_names() {
|
for name in platform.api_key_env_names() {
|
||||||
if let Some(value) = getenv(name)
|
if let Some(value) = getenv(name)
|
||||||
@@ -818,15 +813,19 @@ pub(crate) fn resolve_platform_api_key_with(
|
|||||||
return Some(value);
|
return Some(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(value) = stored(platform)
|
||||||
|
&& !value.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
platforms.config_api_key(platform)
|
platforms.config_api_key(platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist `[platforms.<id>].api_key` into `~/.kigi/config.toml` — the exact
|
/// Persist a platform API key into `~/.kigi/auth.json` under the platform-id
|
||||||
/// table [`resolve_platform_api_key`] reads back (env vars still win over the
|
/// scope — the per-provider auth.json key contract that
|
||||||
/// file). Shared writer for the CLI and the TUI login screen; same in-process
|
/// [`resolve_platform_api_key`] reads back (env vars still win). Shared
|
||||||
/// pattern as the `kigi mcp add` writer (whole-file toml round-trip, atomic
|
/// writer for the CLI and the TUI login screen. Legacy `[platforms.*]`
|
||||||
/// tmp+rename), taken under the config write lock so it can't interleave with
|
/// config.toml keys remain a read-only fallback source.
|
||||||
/// a settings save.
|
|
||||||
///
|
///
|
||||||
/// SECURITY: the key lands in the file by design; it must never be logged,
|
/// SECURITY: the key lands in the file by design; it must never be logged,
|
||||||
/// and errors carry only path/IO context — never the key.
|
/// 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,
|
platform: kigi_models::PlatformId,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let _guard = crate::util::config::lock_config_writes().await;
|
save_platform_api_key_in(&crate::util::kigi_home::kigi_home(), platform, api_key)
|
||||||
save_platform_api_key_at(&crate::util::config::user_config_path(), platform, api_key).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path-injectable core of [`save_platform_api_key`] (tests use a tempdir).
|
/// Home-injectable core of [`save_platform_api_key`] (tests use a tempdir).
|
||||||
/// Does NOT take the config write lock — production callers go through
|
pub fn save_platform_api_key_in(
|
||||||
/// [`save_platform_api_key`].
|
kigi_home: &std::path::Path,
|
||||||
pub async fn save_platform_api_key_at(
|
|
||||||
path: &std::path::Path,
|
|
||||||
platform: kigi_models::PlatformId,
|
platform: kigi_models::PlatformId,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use toml::Value as TomlValue;
|
|
||||||
use toml::map::Map as TomlMap;
|
|
||||||
|
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
!platform.uses_oauth(),
|
!platform.uses_oauth(),
|
||||||
"{} authenticates via OAuth and takes no API key",
|
"{} 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();
|
let api_key = api_key.trim();
|
||||||
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
|
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
|
||||||
|
crate::auth::store_platform_api_key(kigi_home, platform, api_key).map_err(|e| {
|
||||||
let mut root: TomlValue = match tokio::fs::read_to_string(path).await {
|
anyhow::anyhow!(
|
||||||
Ok(s) => toml::from_str(&s).map_err(|e| {
|
"saving {} API key to auth.json in {}: {e}",
|
||||||
// Refuse to overwrite an unparseable config — a silent fallback
|
platform.as_str(),
|
||||||
// to an empty table would drop every other section.
|
kigi_home.display()
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
#[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):
|
/// Assemble the final model map. Priority (highest wins):
|
||||||
/// config.toml `[model.*]` > prefetched (remote) > hardcoded defaults.
|
/// config.toml `[model.*]` > prefetched (remote) > hardcoded defaults.
|
||||||
pub fn resolve_model_list(
|
pub(crate) fn resolve_model_list(
|
||||||
cfg: &Config,
|
cfg: &Config,
|
||||||
prefetched: Option<IndexMap<String, ModelEntry>>,
|
prefetched: Option<IndexMap<String, ModelEntry>>,
|
||||||
|
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||||||
) -> IndexMap<String, ModelEntry> {
|
) -> IndexMap<String, ModelEntry> {
|
||||||
let mut resolved: IndexMap<String, ModelEntry> = IndexMap::new();
|
let mut resolved: IndexMap<String, ModelEntry> = IndexMap::new();
|
||||||
if cfg.endpoints.has_custom_endpoint() {
|
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_extra_headers(&mut resolved, &cfg.models);
|
||||||
apply_global_scalar_defaults(&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() {
|
for entry in resolved.values_mut() {
|
||||||
entry.info.derive_reasoning_effort_fields();
|
entry.info.derive_reasoning_effort_fields();
|
||||||
}
|
}
|
||||||
@@ -2704,6 +2673,7 @@ pub fn resolve_model_list(
|
|||||||
fn apply_platform_credentials(
|
fn apply_platform_credentials(
|
||||||
resolved: &mut IndexMap<String, ModelEntry>,
|
resolved: &mut IndexMap<String, ModelEntry>,
|
||||||
platforms: &PlatformsConfig,
|
platforms: &PlatformsConfig,
|
||||||
|
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||||||
) {
|
) {
|
||||||
for (key, entry) in resolved.iter_mut() {
|
for (key, entry) in resolved.iter_mut() {
|
||||||
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||||
@@ -2720,15 +2690,23 @@ fn apply_platform_credentials(
|
|||||||
.env_key
|
.env_key
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|k| k.resolve_value().is_some());
|
.is_some_and(|k| k.resolve_value().is_some());
|
||||||
if entry.api_key.is_none()
|
if entry.api_key.is_none() && !env_resolves {
|
||||||
&& !env_resolves
|
// The resolved snapshot (env > auth.json > config, minus env
|
||||||
&& let Some(config_key) = platforms.config_api_key(platform)
|
// 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!(
|
tracing::debug!(
|
||||||
model_key = %key, platform = platform.as_str(),
|
model_key = %key, platform = platform.as_str(),
|
||||||
"stamped [platforms] config api_key onto open-platform entry"
|
"stamped resolved platform api_key onto open-platform entry"
|
||||||
);
|
);
|
||||||
entry.api_key = Some(config_key);
|
entry.api_key = Some(stamped);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// A credentialed open-platform entry is usable by API-key users.
|
// A credentialed open-platform entry is usable by API-key users.
|
||||||
if entry.has_own_credentials() {
|
if entry.has_own_credentials() {
|
||||||
@@ -3877,7 +3855,7 @@ pub fn try_resolve_model_credentials(
|
|||||||
let cfg = Config::new_from_toml_cfg(&raw)
|
let cfg = Config::new_from_toml_cfg(&raw)
|
||||||
.map_err(|e| tracing::warn!(error = % e, "config parse failed for credential resolution"))
|
.map_err(|e| tracing::warn!(error = % e, "config parse failed for credential resolution"))
|
||||||
.ok()?;
|
.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 entry = find_model_by_id(&models, model_id)?;
|
||||||
let credentials = resolve_credentials(entry, session_key);
|
let credentials = resolve_credentials(entry, session_key);
|
||||||
Some(credentials)
|
Some(credentials)
|
||||||
@@ -3936,7 +3914,7 @@ fn with_resolved_model<T>(model_id: &str, f: impl FnOnce(ModelLookup) -> T) -> T
|
|||||||
else {
|
else {
|
||||||
return f(ModelLookup::ConfigUnavailable);
|
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)))
|
f(ModelLookup::Loaded(find_model_by_id(&models, model_id)))
|
||||||
}
|
}
|
||||||
/// Resolve a standalone `SamplerConfig` for an auxiliary model slug (image
|
/// Resolve a standalone `SamplerConfig` for an auxiliary model slug (image
|
||||||
@@ -4616,7 +4594,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-custom-model").expect("model should exist");
|
||||||
assert_eq!(model.info.model, "kigi-4.5");
|
assert_eq!(model.info.model, "kigi-4.5");
|
||||||
assert_eq!(model.info.base_url, "https://api.example.com/v1");
|
assert_eq!(model.info.base_url, "https://api.example.com/v1");
|
||||||
@@ -5131,7 +5109,7 @@ reasoning_effort = "low"
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get(dm).expect("model should exist");
|
||||||
assert_eq!(model.api_key, Some("user-custom-api-key".to_string()));
|
assert_eq!(model.api_key, Some("user-custom-api-key".to_string()));
|
||||||
assert_eq!(model.info.model, dm);
|
assert_eq!(model.info.model, dm);
|
||||||
@@ -5184,7 +5162,7 @@ reasoning_effort = "low"
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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)
|
.get(dm)
|
||||||
.expect("model should exist")
|
.expect("model should exist")
|
||||||
.clone();
|
.clone();
|
||||||
@@ -5200,7 +5178,7 @@ reasoning_effort = "low"
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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)
|
.get(dm)
|
||||||
.expect("model should exist")
|
.expect("model should exist")
|
||||||
.clone();
|
.clone();
|
||||||
@@ -5221,7 +5199,7 @@ reasoning_effort = "low"
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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)
|
.get(dm)
|
||||||
.expect("model should exist")
|
.expect("model should exist")
|
||||||
.clone();
|
.clone();
|
||||||
@@ -5237,7 +5215,7 @@ reasoning_effort = "low"
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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)
|
.get(dm)
|
||||||
.expect("model should exist")
|
.expect("model should exist")
|
||||||
.clone();
|
.clone();
|
||||||
@@ -5253,7 +5231,7 @@ reasoning_effort = "low"
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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)
|
.get(dm)
|
||||||
.expect("model should exist")
|
.expect("model should exist")
|
||||||
.clone();
|
.clone();
|
||||||
@@ -5367,7 +5345,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-custom-model").expect("model should exist");
|
||||||
assert_eq!(model.info.context_window, NonZeroU64::new(256_000).unwrap());
|
assert_eq!(model.info.context_window, NonZeroU64::new(256_000).unwrap());
|
||||||
}
|
}
|
||||||
@@ -5394,7 +5372,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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
|
let model = resolved
|
||||||
.get("my-responses-model")
|
.get("my-responses-model")
|
||||||
.expect("model should exist");
|
.expect("model should exist");
|
||||||
@@ -5413,7 +5391,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-chat-model").expect("model should exist");
|
||||||
assert_eq!(model.info.api_backend, ApiBackend::ChatCompletions);
|
assert_eq!(model.info.api_backend, ApiBackend::ChatCompletions);
|
||||||
}
|
}
|
||||||
@@ -5433,7 +5411,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-claude").expect("model should exist");
|
||||||
assert!(
|
assert!(
|
||||||
model.info.supports_reasoning_effort,
|
model.info.supports_reasoning_effort,
|
||||||
@@ -5456,7 +5434,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-claude").expect("model should exist");
|
||||||
assert!(
|
assert!(
|
||||||
!model.info.supports_reasoning_effort,
|
!model.info.supports_reasoning_effort,
|
||||||
@@ -5478,7 +5456,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-openai").expect("model should exist");
|
||||||
assert!(
|
assert!(
|
||||||
!model.info.supports_reasoning_effort,
|
!model.info.supports_reasoning_effort,
|
||||||
@@ -5497,7 +5475,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-model").expect("model should exist");
|
||||||
assert_eq!(model.info.api_backend, ApiBackend::ChatCompletions);
|
assert_eq!(model.info.api_backend, ApiBackend::ChatCompletions);
|
||||||
}
|
}
|
||||||
@@ -5523,7 +5501,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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
|
let model = resolved
|
||||||
.get("my-concise-model")
|
.get("my-concise-model")
|
||||||
.expect("model should exist");
|
.expect("model should exist");
|
||||||
@@ -5541,7 +5519,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-model").expect("model should exist");
|
||||||
assert!(!model.info.use_concise);
|
assert!(!model.info.use_concise);
|
||||||
}
|
}
|
||||||
@@ -5600,7 +5578,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-model").expect("model should exist");
|
||||||
assert!(
|
assert!(
|
||||||
!model.info.use_concise,
|
!model.info.use_concise,
|
||||||
@@ -5685,7 +5663,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-agent-model").expect("model should exist");
|
||||||
assert_eq!(model.info.agent_type, "codex");
|
assert_eq!(model.info.agent_type, "codex");
|
||||||
}
|
}
|
||||||
@@ -5701,7 +5679,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("my-model").expect("model should exist");
|
||||||
assert_eq!(model.info.agent_type, DEFAULT_AGENT_TYPE);
|
assert_eq!(model.info.agent_type, DEFAULT_AGENT_TYPE);
|
||||||
}
|
}
|
||||||
@@ -5962,7 +5940,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).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);
|
let available = available_models(&catalog, true);
|
||||||
assert!(
|
assert!(
|
||||||
catalog.contains_key("visible-model"),
|
catalog.contains_key("visible-model"),
|
||||||
@@ -5995,7 +5973,11 @@ reasoning_effort = "low"
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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"));
|
assert!(!catalog.contains_key("to-disable"));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
@@ -6012,7 +5994,11 @@ reasoning_effort = "low"
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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);
|
let available = available_models(&catalog, true);
|
||||||
assert!(catalog.contains_key("to-hide"));
|
assert!(catalog.contains_key("to-hide"));
|
||||||
assert!(catalog["to-hide"].info.hidden);
|
assert!(catalog["to-hide"].info.hidden);
|
||||||
@@ -6040,7 +6026,11 @@ reasoning_effort = "low"
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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["keep-one"].info.user_selectable, "wildcard match");
|
||||||
assert!(
|
assert!(
|
||||||
catalog["explicit-key"].info.user_selectable,
|
catalog["explicit-key"].info.user_selectable,
|
||||||
@@ -6065,7 +6055,11 @@ reasoning_effort = "low"
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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!(
|
assert!(
|
||||||
catalog["foo"].info.user_selectable,
|
catalog["foo"].info.user_selectable,
|
||||||
"empty allowed_models must not restrict"
|
"empty allowed_models must not restrict"
|
||||||
@@ -6110,7 +6104,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw).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("oauth-only-model"));
|
||||||
assert!(catalog.contains_key("public-model"));
|
assert!(catalog.contains_key("public-model"));
|
||||||
let api_available = available_models(&catalog, false);
|
let api_available = available_models(&catalog, false);
|
||||||
@@ -6137,7 +6131,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("slow-model").expect("model should exist");
|
||||||
assert_eq!(model.info.inference_idle_timeout_secs, Some(600));
|
assert_eq!(model.info.inference_idle_timeout_secs, Some(600));
|
||||||
}
|
}
|
||||||
@@ -6153,7 +6147,7 @@ reasoning_effort = "low"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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");
|
let model = resolved.get("default-model").expect("model should exist");
|
||||||
assert_eq!(model.info.inference_idle_timeout_secs, None);
|
assert_eq!(model.info.inference_idle_timeout_secs, None);
|
||||||
}
|
}
|
||||||
@@ -6214,7 +6208,7 @@ reasoning_effort = "low"
|
|||||||
) -> (Config, IndexMap<String, ModelEntry>) {
|
) -> (Config, IndexMap<String, ModelEntry>) {
|
||||||
let raw: toml::Value = toml::from_str(toml_str).expect("test TOML should parse");
|
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 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)
|
(cfg, resolved)
|
||||||
}
|
}
|
||||||
fn resolve_sampling(model: &ModelEntry, session_key: Option<&str>) -> SamplerConfig {
|
fn resolve_sampling(model: &ModelEntry, session_key: Option<&str>) -> SamplerConfig {
|
||||||
@@ -6539,7 +6533,7 @@ reasoning_effort = "low"
|
|||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let resolved = resolve_model_list(&cfg, Some(prefetched));
|
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||||||
assert!(
|
assert!(
|
||||||
resolved.contains_key("acme-model"),
|
resolved.contains_key("acme-model"),
|
||||||
"enterprise model should be present"
|
"enterprise model should be present"
|
||||||
@@ -6553,7 +6547,7 @@ reasoning_effort = "low"
|
|||||||
#[test]
|
#[test]
|
||||||
fn e2e_default_endpoint_still_injects_defaults() {
|
fn e2e_default_endpoint_still_injects_defaults() {
|
||||||
let cfg = Config::default();
|
let cfg = Config::default();
|
||||||
let resolved = resolve_model_list(&cfg, None);
|
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||||||
assert!(
|
assert!(
|
||||||
resolved.contains_key(BUNDLED_DEFAULT_KEY),
|
resolved.contains_key(BUNDLED_DEFAULT_KEY),
|
||||||
"default model should be present when using default endpoint"
|
"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();
|
entry.info.context_window = NonZeroU64::new(default_cw).unwrap();
|
||||||
prefetched.insert("kigi-4.5".to_owned(), entry);
|
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
|
let by_key = resolved
|
||||||
.get("kigi-build")
|
.get("kigi-build")
|
||||||
.expect("kigi-build key must exist");
|
.expect("kigi-build key must exist");
|
||||||
@@ -8740,7 +8734,7 @@ default = "kigi-4.5"
|
|||||||
entry.info.agent_type = default_agent_type();
|
entry.info.agent_type = default_agent_type();
|
||||||
entry.info.api_backend = ApiBackend::default();
|
entry.info.api_backend = ApiBackend::default();
|
||||||
prefetched.insert("kigi-4.5".to_owned(), entry);
|
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();
|
let latest = resolved.get("kigi-4.5").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
latest.info.agent_type,
|
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);
|
test_model_entry("kigi-4.5", "https://test.example.com/v1", None, None, None);
|
||||||
entry.info.context_window = NonZeroU64::new(65_536).unwrap();
|
entry.info.context_window = NonZeroU64::new(65_536).unwrap();
|
||||||
prefetched.insert("kigi-4.5".to_owned(), entry);
|
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();
|
let latest = resolved.get("kigi-4.5").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
latest.info.context_window.get(),
|
latest.info.context_window.get(),
|
||||||
@@ -8795,7 +8789,7 @@ default = "kigi-4.5"
|
|||||||
);
|
);
|
||||||
entry.info.context_window = NonZeroU64::new(default_cw).unwrap();
|
entry.info.context_window = NonZeroU64::new(default_cw).unwrap();
|
||||||
prefetched.insert("some-unknown-model".to_owned(), entry);
|
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();
|
let model = resolved.get("some-unknown-model").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
model.info.context_window.get(),
|
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 entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
|
||||||
let mut prefetched = IndexMap::new();
|
let mut prefetched = IndexMap::new();
|
||||||
prefetched.insert("remote-only-model".to_owned(), entry);
|
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
|
let model = resolved
|
||||||
.get("remote-only-model")
|
.get("remote-only-model")
|
||||||
.expect("prefetched model should exist");
|
.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 entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
|
||||||
let mut prefetched = IndexMap::new();
|
let mut prefetched = IndexMap::new();
|
||||||
prefetched.insert("remote-only-model".to_owned(), entry);
|
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
|
let info = &resolved
|
||||||
.get("remote-only-model")
|
.get("remote-only-model")
|
||||||
.expect("prefetched model should exist")
|
.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 entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
|
||||||
let mut prefetched = IndexMap::new();
|
let mut prefetched = IndexMap::new();
|
||||||
prefetched.insert("remote-only-model".to_owned(), entry);
|
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
|
let model = resolved
|
||||||
.get("remote-only-model")
|
.get("remote-only-model")
|
||||||
.expect("model should exist");
|
.expect("model should exist");
|
||||||
@@ -9021,7 +9015,7 @@ default = "kigi-4.5"
|
|||||||
entry.info.max_retries = Some(3);
|
entry.info.max_retries = Some(3);
|
||||||
let mut prefetched = IndexMap::new();
|
let mut prefetched = IndexMap::new();
|
||||||
prefetched.insert("remote-only-model".to_owned(), entry);
|
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
|
let model = resolved
|
||||||
.get("remote-only-model")
|
.get("remote-only-model")
|
||||||
.expect("prefetched model should exist");
|
.expect("prefetched model should exist");
|
||||||
@@ -9058,7 +9052,7 @@ default = "kigi-4.5"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
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;
|
let custom = &resolved.get("custom").expect("custom model").info;
|
||||||
assert_eq!(custom.reasoning_efforts.len(), 2);
|
assert_eq!(custom.reasoning_efforts.len(), 2);
|
||||||
assert_eq!(custom.reasoning_efforts[0].label, "High");
|
assert_eq!(custom.reasoning_efforts[0].label, "High");
|
||||||
@@ -9094,7 +9088,7 @@ default = "kigi-4.5"
|
|||||||
}];
|
}];
|
||||||
let mut prefetched = IndexMap::new();
|
let mut prefetched = IndexMap::new();
|
||||||
prefetched.insert("kigi-x".to_owned(), entry);
|
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
|
let efforts = &resolved
|
||||||
.get("kigi-x")
|
.get("kigi-x")
|
||||||
.expect("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 entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, default_cw, ApiBackend::default());
|
||||||
let mut prefetched = IndexMap::new();
|
let mut prefetched = IndexMap::new();
|
||||||
prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry);
|
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");
|
let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist");
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
entry.info.context_window.get(),
|
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 entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, explicit_cw, ApiBackend::default());
|
||||||
let mut prefetched = IndexMap::new();
|
let mut prefetched = IndexMap::new();
|
||||||
prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry);
|
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");
|
let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
entry.info.context_window.get(),
|
entry.info.context_window.get(),
|
||||||
@@ -9143,7 +9137,7 @@ default = "kigi-4.5"
|
|||||||
let entry = prefetch_model_entry("kigi", default_cw, ApiBackend::default());
|
let entry = prefetch_model_entry("kigi", default_cw, ApiBackend::default());
|
||||||
let mut prefetched = IndexMap::new();
|
let mut prefetched = IndexMap::new();
|
||||||
prefetched.insert("kigi".to_owned(), entry);
|
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 entry = resolved.get("kigi").expect("model must exist");
|
||||||
let defaults = default_model_entries(&EndpointsConfig::default());
|
let defaults = default_model_entries(&EndpointsConfig::default());
|
||||||
if let Some(default) = defaults.get("kigi") {
|
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) {
|
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
|
||||||
p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
|
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));
|
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));
|
assert!(no_p.contains_key(BUNDLED_DEFAULT_KEY));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
@@ -9182,7 +9176,7 @@ default = "kigi-4.5"
|
|||||||
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
|
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
|
||||||
p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
|
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
|
let sess: Vec<_> = resolved
|
||||||
.values()
|
.values()
|
||||||
.filter(|e| e.visible_for_auth(true))
|
.filter(|e| e.visible_for_auth(true))
|
||||||
@@ -9203,7 +9197,7 @@ default = "kigi-4.5"
|
|||||||
let mut p = IndexMap::new();
|
let mut p = IndexMap::new();
|
||||||
let e = prefetch_model_entry("secret-xyz", 200000, ApiBackend::default());
|
let e = prefetch_model_entry("secret-xyz", 200000, ApiBackend::default());
|
||||||
p.insert("secret-xyz".to_string(), e);
|
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("secret-xyz"));
|
||||||
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
||||||
}
|
}
|
||||||
@@ -9213,14 +9207,14 @@ default = "kigi-4.5"
|
|||||||
let mut p = IndexMap::new();
|
let mut p = IndexMap::new();
|
||||||
let e = prefetch_model_entry("kimi-fresh", 500_000, ApiBackend::Responses);
|
let e = prefetch_model_entry("kimi-fresh", 500_000, ApiBackend::Responses);
|
||||||
p.insert("kimi-fresh".to_string(), e);
|
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("kimi-fresh"));
|
||||||
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_model_list_empty_prefetch_yields_empty_base() {
|
fn resolve_model_list_empty_prefetch_yields_empty_base() {
|
||||||
let cfg = Config::default();
|
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());
|
assert!(resolved.is_empty());
|
||||||
}
|
}
|
||||||
/// Regression: enterprise managed config aliases the bundled subscription
|
/// Regression: enterprise managed config aliases the bundled subscription
|
||||||
@@ -9239,7 +9233,7 @@ default = "kigi-4.5"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
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
|
let entry = resolved
|
||||||
.get(BUNDLED_DEFAULT_KEY)
|
.get(BUNDLED_DEFAULT_KEY)
|
||||||
.expect("bundled default must exist");
|
.expect("bundled default must exist");
|
||||||
@@ -9261,7 +9255,7 @@ default = "kigi-4.5"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
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
|
let entry = resolved
|
||||||
.get(BUNDLED_DEFAULT_KEY)
|
.get(BUNDLED_DEFAULT_KEY)
|
||||||
.expect("bundled default must exist");
|
.expect("bundled default must exist");
|
||||||
@@ -9287,7 +9281,7 @@ default = "kigi-4.5"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
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
|
let cn = resolved
|
||||||
.get("moonshot-cn/kimi-k2-turbo-preview")
|
.get("moonshot-cn/kimi-k2-turbo-preview")
|
||||||
@@ -9318,6 +9312,76 @@ default = "kigi-4.5"
|
|||||||
"the OAuth platform takes no API key"
|
"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
|
/// F2 acceptance: with ONLY a moonshot key (env), the api-key auth method
|
||||||
/// is advertised (no login screen) because the catalog has a credentialed
|
/// is advertised (no login screen) because the catalog has a credentialed
|
||||||
/// entry.
|
/// entry.
|
||||||
@@ -9326,84 +9390,96 @@ default = "kigi-4.5"
|
|||||||
fn moonshot_env_key_advertises_api_key_auth_method() {
|
fn moonshot_env_key_advertises_api_key_auth_method() {
|
||||||
let _gen = EnvGuard::set(kigi_models::MOONSHOT_API_KEY_ENV, "sk-only-moonshot");
|
let _gen = EnvGuard::set(kigi_models::MOONSHOT_API_KEY_ENV, "sk-only-moonshot");
|
||||||
let cfg = Config::default();
|
let cfg = Config::default();
|
||||||
let models = resolve_model_list(&cfg, None);
|
let models = resolve_model_list(&cfg, None, &Default::default());
|
||||||
assert!(
|
assert!(
|
||||||
crate::agent::auth_method::should_advertise_xai_api_key(models.values()),
|
crate::agent::auth_method::should_advertise_xai_api_key(models.values()),
|
||||||
"a moonshot env key alone must advertise the API-key auth method"
|
"a moonshot env key alone must advertise the API-key auth method"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/// The login-screen writer persists `[platforms.<id>].api_key` into the
|
/// The login-screen writer persists the key into auth.json under the
|
||||||
/// exact table `resolve_platform_api_key` reads back, preserving sibling
|
/// platform-id scope — the exact scope `resolve_platform_api_key` reads
|
||||||
/// tables and never leaking onto the other platform.
|
/// back — trimming whitespace and never leaking onto the other platform.
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn save_platform_api_key_round_trips_through_resolver() {
|
fn save_platform_api_key_round_trips_through_resolver() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("config.toml");
|
let home = dir.path();
|
||||||
std::fs::write(&path, "[ui]\ncompact_mode = true\n").unwrap();
|
|
||||||
|
|
||||||
save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, "sk-from-tui")
|
save_platform_api_key_in(home, kigi_models::PlatformId::MoonshotCn, " sk-from-tui ")
|
||||||
.await
|
|
||||||
.expect("write must succeed");
|
.expect("write must succeed");
|
||||||
|
|
||||||
let raw: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
// Env unset in this resolve (injected getenv) → auth.json wins.
|
||||||
let platforms: PlatformsConfig = raw
|
let platforms = PlatformsConfig::default();
|
||||||
.get("platforms")
|
let resolved = resolve_platform_api_key_with(
|
||||||
.cloned()
|
kigi_models::PlatformId::MoonshotCn,
|
||||||
.expect("[platforms] written")
|
&platforms,
|
||||||
.try_into()
|
|_| None,
|
||||||
.expect("PlatformsConfig parses");
|
|p| crate::auth::read_platform_api_key(home, p),
|
||||||
// Env unset in this resolve (injected getenv) → config file wins.
|
);
|
||||||
let resolved =
|
assert_eq!(
|
||||||
resolve_platform_api_key_with(kigi_models::PlatformId::MoonshotCn, &platforms, |_| {
|
resolved.as_deref(),
|
||||||
None
|
Some("sk-from-tui"),
|
||||||
});
|
"trimmed key round-trips"
|
||||||
assert_eq!(resolved.as_deref(), Some("sk-from-tui"));
|
);
|
||||||
assert!(
|
assert_eq!(
|
||||||
platforms
|
crate::auth::read_platform_api_key(home, kigi_models::PlatformId::MoonshotAi),
|
||||||
.config_api_key(kigi_models::PlatformId::MoonshotAi)
|
None,
|
||||||
.is_none(),
|
|
||||||
"the cn key must not leak onto the ai platform"
|
"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
|
/// Precedence: env var > auth.json scope > `[platforms.*]` config file.
|
||||||
/// rejected, and an unparseable config is refused (never clobbered).
|
#[test]
|
||||||
#[tokio::test]
|
fn platform_key_precedence_env_then_auth_json_then_config() {
|
||||||
async fn save_platform_api_key_rejects_invalid_inputs() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
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!(
|
assert!(
|
||||||
save_platform_api_key_at(&path, kigi_models::PlatformId::KimiCode, "sk-x")
|
save_platform_api_key_in(home, kigi_models::PlatformId::KimiCode, "sk-x").is_err(),
|
||||||
.await
|
|
||||||
.is_err(),
|
|
||||||
"kimi-code authenticates via OAuth and must reject an API key"
|
"kimi-code authenticates via OAuth and must reject an API key"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, " ")
|
save_platform_api_key_in(home, kigi_models::PlatformId::MoonshotCn, " ").is_err(),
|
||||||
.await
|
|
||||||
.is_err(),
|
|
||||||
"blank keys must be rejected"
|
"blank keys must be rejected"
|
||||||
);
|
);
|
||||||
|
|
||||||
let bad = "this is [not valid toml\n";
|
|
||||||
std::fs::write(&path, bad).unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, "sk-x")
|
!home.join("auth.json").exists(),
|
||||||
.await
|
"rejected writes must not create auth.json"
|
||||||
.is_err(),
|
|
||||||
"unparseable config must be refused"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_to_string(&path).unwrap(),
|
|
||||||
bad,
|
|
||||||
"unparseable config must be left untouched"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ mod tests {
|
|||||||
w.kind == ModelOverrideWarningKind::DuplicateAlias
|
w.kind == ModelOverrideWarningKind::DuplicateAlias
|
||||||
&& w.field.as_deref() == Some("send_compactions_remaining")
|
&& 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"));
|
assert!(resolved.contains_key("kigi-4.5"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,38 +55,57 @@ enum CacheAuthMethod {
|
|||||||
Platforms,
|
Platforms,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolved open-platform API keys (PRD F2): platform-scoped env >
|
/// Resolved API-key platform credentials (PRD F2), one entry per registry
|
||||||
/// generic `KIGI_MOONSHOT_API_KEY` env > `[platforms.*]` config.
|
/// 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
|
/// SECURITY: values are secrets — the manual `Debug` impl prints presence
|
||||||
/// only, and nothing here may be logged or persisted.
|
/// only, and nothing here may be logged or persisted.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub(crate) struct PlatformApiKeys {
|
pub(crate) struct PlatformApiKeys {
|
||||||
moonshot_cn: Option<String>,
|
keys: std::collections::BTreeMap<kigi_models::PlatformId, String>,
|
||||||
moonshot_ai: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for PlatformApiKeys {
|
impl std::fmt::Debug for PlatformApiKeys {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("PlatformApiKeys")
|
let mut s = f.debug_struct("PlatformApiKeys");
|
||||||
.field("moonshot_cn", &self.moonshot_cn.is_some())
|
for platform in kigi_models::PlatformId::ALL {
|
||||||
.field("moonshot_ai", &self.moonshot_ai.is_some())
|
if !platform.uses_oauth() {
|
||||||
.finish()
|
s.field(platform.as_str(), &self.keys.contains_key(&platform));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformApiKeys {
|
impl PlatformApiKeys {
|
||||||
pub(crate) fn resolve(platforms: &config::PlatformsConfig) -> Self {
|
pub(crate) fn resolve(platforms: &config::PlatformsConfig) -> Self {
|
||||||
Self {
|
// Read auth.json ONCE for the whole registry sweep — per-platform
|
||||||
moonshot_cn: config::resolve_platform_api_key(
|
// re-reads would mean one file parse per provider on every resolve.
|
||||||
kigi_models::PlatformId::MoonshotCn,
|
let stored =
|
||||||
platforms,
|
crate::auth::read_auth_json(&crate::util::kigi_home::kigi_home().join("auth.json"))
|
||||||
),
|
.ok();
|
||||||
moonshot_ai: config::resolve_platform_api_key(
|
let mut keys = std::collections::BTreeMap::new();
|
||||||
kigi_models::PlatformId::MoonshotAi,
|
for platform in kigi_models::PlatformId::ALL {
|
||||||
platforms,
|
if platform.uses_oauth() {
|
||||||
),
|
continue;
|
||||||
}
|
}
|
||||||
|
let key = config::resolve_platform_api_key_with(
|
||||||
|
platform,
|
||||||
|
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
|
/// 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> {
|
pub(crate) fn key_for(&self, platform: kigi_models::PlatformId) -> Option<&str> {
|
||||||
match platform {
|
self.keys.get(&platform).map(String::as_str)
|
||||||
kigi_models::PlatformId::KimiCode => None,
|
|
||||||
kigi_models::PlatformId::MoonshotCn => self.moonshot_cn.as_deref(),
|
|
||||||
kigi_models::PlatformId::MoonshotAi => self.moonshot_ai.as_deref(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Any open-platform key configured? Drives "should we prefetch without a
|
/// Any API-key platform credentialed? Drives "should we prefetch without
|
||||||
/// session" and the F2 acceptance path (moonshot key only, no login).
|
/// a session" and the F2 acceptance path (platform key only, no login).
|
||||||
pub(crate) fn any(&self) -> bool {
|
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).
|
/// Test-only constructor (fields are private to this module).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn test_keys(cn: Option<&str>, ai: Option<&str>) -> Self {
|
pub(crate) fn test_keys(cn: Option<&str>, ai: Option<&str>) -> Self {
|
||||||
Self {
|
let mut keys = std::collections::BTreeMap::new();
|
||||||
moonshot_cn: cn.map(str::to_owned),
|
if let Some(k) = cn {
|
||||||
moonshot_ai: ai.map(str::to_owned),
|
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)
|
.map(|c| c.models)
|
||||||
});
|
});
|
||||||
let has_prefetched = prefetched_models.is_some();
|
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
|
// Validate only against a real catalog; a bundled-only first run defers
|
||||||
// to the async fetch (`apply_refresh_result`).
|
// to the async fetch (`apply_refresh_result`).
|
||||||
@@ -348,7 +371,11 @@ impl ModelsManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let prefetched = self.inner.prefetched.read().clone();
|
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();
|
let has_real_catalog = *self.inner.has_fetched_real_catalog.read();
|
||||||
if has_real_catalog && let Err(e) = validate_selectable(&new_config, &new_catalog) {
|
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");
|
tracing::error!(error = %e, "ignoring config reload: allowed_models excludes all models");
|
||||||
@@ -636,7 +663,8 @@ impl ModelsManager {
|
|||||||
// ── Mutations ───────────────────────────────────────────────────
|
// ── Mutations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
fn rebuild(&self, cfg: &config::Config, prefetched: Option<IndexMap<String, ModelEntry>>) {
|
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.
|
/// 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
|
/// `find_model_by_id`/`models()` and ignore `user_selectable`, so they need no
|
||||||
/// exemption. Globs are validated at load (`Config::validate_model_filters`);
|
/// exemption. Globs are validated at load (`Config::validate_model_filters`);
|
||||||
/// the arms here fail closed if one slips through.
|
/// the arms here fail closed if one slips through.
|
||||||
pub fn resolve_model_catalog(
|
pub(crate) fn resolve_model_catalog(
|
||||||
cfg: &config::Config,
|
cfg: &config::Config,
|
||||||
prefetched: Option<IndexMap<String, ModelEntry>>,
|
prefetched: Option<IndexMap<String, ModelEntry>>,
|
||||||
|
platform_keys: &PlatformApiKeys,
|
||||||
) -> IndexMap<String, ModelEntry> {
|
) -> 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()) {
|
if let Ok(Some(disabled)) = ModelGlobSet::compile(cfg.models.disabled_models.as_ref()) {
|
||||||
let before = catalog.len();
|
let before = catalog.len();
|
||||||
@@ -2271,7 +2301,7 @@ mod tests {
|
|||||||
context_window = 256000
|
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);
|
let (_key, entry, _src) = resolve_default_model(&cfg, &catalog, true);
|
||||||
assert!(
|
assert!(
|
||||||
entry.info.user_selectable,
|
entry.info.user_selectable,
|
||||||
@@ -2298,7 +2328,7 @@ mod tests {
|
|||||||
context_window = 256000
|
context_window = 256000
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let catalog = resolve_model_catalog(&excluded, None);
|
let catalog = resolve_model_catalog(&excluded, None, &Default::default());
|
||||||
assert!(
|
assert!(
|
||||||
validate_selectable(&excluded, &catalog)
|
validate_selectable(&excluded, &catalog)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@@ -2316,7 +2346,7 @@ mod tests {
|
|||||||
context_window = 256000
|
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());
|
assert!(validate_selectable(&zero, &catalog).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2463,7 +2493,7 @@ mod tests {
|
|||||||
reasoning_entry.info.supports_reasoning_effort = true;
|
reasoning_entry.info.supports_reasoning_effort = true;
|
||||||
prefetched.insert("reasoning-model".to_string(), reasoning_entry);
|
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!(
|
assert_eq!(
|
||||||
catalog["reasoning-model"].info.reasoning_effort,
|
catalog["reasoning-model"].info.reasoning_effort,
|
||||||
Some(ReasoningEffort::High),
|
Some(ReasoningEffort::High),
|
||||||
@@ -2484,7 +2514,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
prefetched.insert("plain-model".to_string(), plain_entry);
|
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!(
|
assert_eq!(
|
||||||
catalog["plain-model"].info.reasoning_effort, None,
|
catalog["plain-model"].info.reasoning_effort, None,
|
||||||
"non-reasoning default model must NOT be stamped with persisted effort",
|
"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);
|
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!(
|
assert_eq!(
|
||||||
catalog["kigi-4.5"].info.reasoning_effort,
|
catalog["kigi-4.5"].info.reasoning_effort,
|
||||||
Some(ReasoningEffort::High),
|
Some(ReasoningEffort::High),
|
||||||
@@ -2583,7 +2613,7 @@ mod tests {
|
|||||||
cfg.config_models
|
cfg.config_models
|
||||||
.insert("plain".to_string(), config::ConfigModelOverride::default());
|
.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;
|
let info = &catalog["menu-only"].info;
|
||||||
assert!(
|
assert!(
|
||||||
info.supports_reasoning_effort,
|
info.supports_reasoning_effort,
|
||||||
@@ -2644,7 +2674,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
prefetched.insert("plain-model".to_string(), plain_entry);
|
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!(
|
assert_eq!(
|
||||||
catalog["reasoning-model"].info.reasoning_effort,
|
catalog["reasoning-model"].info.reasoning_effort,
|
||||||
Some(ReasoningEffort::High),
|
Some(ReasoningEffort::High),
|
||||||
@@ -3314,10 +3344,7 @@ mod tests {
|
|||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
fn keys(cn: Option<&str>, ai: Option<&str>) -> PlatformApiKeys {
|
fn keys(cn: Option<&str>, ai: Option<&str>) -> PlatformApiKeys {
|
||||||
PlatformApiKeys {
|
PlatformApiKeys::test_keys(cn, ai)
|
||||||
moonshot_cn: cn.map(str::to_owned),
|
|
||||||
moonshot_ai: ai.map(str::to_owned),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3366,6 +3393,7 @@ mod tests {
|
|||||||
kigi_models::PlatformId::MoonshotCn,
|
kigi_models::PlatformId::MoonshotCn,
|
||||||
&platforms,
|
&platforms,
|
||||||
getenv,
|
getenv,
|
||||||
|
|_| None,
|
||||||
)
|
)
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("env-cn"),
|
Some("env-cn"),
|
||||||
@@ -3376,6 +3404,7 @@ mod tests {
|
|||||||
kigi_models::PlatformId::MoonshotAi,
|
kigi_models::PlatformId::MoonshotAi,
|
||||||
&platforms,
|
&platforms,
|
||||||
getenv,
|
getenv,
|
||||||
|
|_| None,
|
||||||
)
|
)
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("env-generic"),
|
Some("env-generic"),
|
||||||
@@ -3387,6 +3416,7 @@ mod tests {
|
|||||||
kigi_models::PlatformId::MoonshotAi,
|
kigi_models::PlatformId::MoonshotAi,
|
||||||
&platforms,
|
&platforms,
|
||||||
|_| None,
|
|_| None,
|
||||||
|
|_| None,
|
||||||
)
|
)
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("cfg-ai"),
|
Some("cfg-ai"),
|
||||||
@@ -3397,6 +3427,7 @@ mod tests {
|
|||||||
kigi_models::PlatformId::KimiCode,
|
kigi_models::PlatformId::KimiCode,
|
||||||
&platforms,
|
&platforms,
|
||||||
getenv,
|
getenv,
|
||||||
|
|_| None,
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -3962,6 +3993,15 @@ mod tests {
|
|||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
.with_test_writer()
|
.with_test_writer()
|
||||||
.try_init();
|
.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;
|
let server = MockServer::start().await;
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path("/models"))
|
.and(path("/models"))
|
||||||
@@ -4063,7 +4103,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert!(outcome.models.is_none(), "no cache and no network → None");
|
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("kimi-code/kimi-for-coding"));
|
||||||
assert!(bundled.contains_key("moonshot-cn/kimi-k2-thinking-turbo"));
|
assert!(bundled.contains_key("moonshot-cn/kimi-k2-thinking-turbo"));
|
||||||
assert!(bundled.contains_key("moonshot-ai/kimi-k2-turbo-preview"));
|
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);
|
let enabled = enabled_platforms(auth.is_some(), platform_keys);
|
||||||
if enabled.is_empty() {
|
if enabled.is_empty() {
|
||||||
return Err(BackendError::Auth(
|
return Err(BackendError::Auth(
|
||||||
"No platform credentials: log in with `kigi login` or configure a moonshot API key \
|
"No platform credentials: log in with `kigi login`, paste a platform API key in \
|
||||||
(KIGI_MOONSHOT_API_KEY or [platforms.*] in ~/.kigi/config.toml)."
|
the login screen (stored in ~/.kigi/auth.json), or set a platform env var such \
|
||||||
|
as KIGI_MOONSHOT_API_KEY."
|
||||||
.into(),
|
.into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,15 +508,16 @@ impl acp::Agent for MvpAgent {
|
|||||||
);
|
);
|
||||||
Ok(self.auth_response_with_meta())
|
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
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
|
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(
|
Err(
|
||||||
acp::Error::invalid_params()
|
acp::Error::invalid_params()
|
||||||
.data(
|
.data(
|
||||||
@@ -526,6 +527,7 @@ impl acp::Agent for MvpAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
async fn new_session(
|
async fn new_session(
|
||||||
&self,
|
&self,
|
||||||
arguments: acp::NewSessionRequest,
|
arguments: acp::NewSessionRequest,
|
||||||
|
|||||||
@@ -452,18 +452,19 @@ impl MvpAgent {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
/// `authenticate(moonshot-cn / moonshot-ai)`: interactive open-platform
|
/// `authenticate(<api-key platform id>)`: interactive API-key login from
|
||||||
/// API-key login from the welcome picker.
|
/// the welcome picker for any non-OAuth registry platform.
|
||||||
///
|
///
|
||||||
/// Reloads the platform keys from disk+env (the TUI persists the pasted
|
/// Reloads the platform keys from disk+env (the TUI persists the pasted
|
||||||
/// key to `[platforms.<id>]` in config.toml immediately before this call),
|
/// key to auth.json under the platform-id scope immediately before this
|
||||||
/// fails with an actionable error when none is configured, validates the
|
/// call), fails with an actionable error when none is configured,
|
||||||
/// key against `GET {platform_base}/models`, then marks the session
|
/// validates the key against `GET {platform_base}/models`, then marks the
|
||||||
/// authenticated exactly like an external API key: publish the method id
|
/// session authenticated exactly like an external API key: publish the
|
||||||
/// (NOT session-based — no token refresh), swap the freshly-stamped config
|
/// method id (NOT session-based — no token refresh), swap the
|
||||||
/// into the models manager, and trigger the model sync so the catalog
|
/// freshly-stamped config into the models manager, and trigger the model
|
||||||
/// gains the platform's entries. The key itself is never logged.
|
/// sync so the catalog gains the platform's entries. The key itself is
|
||||||
pub(super) async fn authenticate_moonshot(
|
/// never logged.
|
||||||
|
pub(super) async fn authenticate_api_key_platform(
|
||||||
&self,
|
&self,
|
||||||
platform: kigi_models::PlatformId,
|
platform: kigi_models::PlatformId,
|
||||||
method_id: acp::AuthMethodId,
|
method_id: acp::AuthMethodId,
|
||||||
@@ -480,10 +481,11 @@ impl MvpAgent {
|
|||||||
Some("platform_key_invalid_or_missing"),
|
Some("platform_key_invalid_or_missing"),
|
||||||
);
|
);
|
||||||
})?;
|
})?;
|
||||||
// Swap the on-disk config (now carrying the key) into the models
|
// Rebuild the catalog from the on-disk config: the rebuild freshly
|
||||||
// manager so `apply_platform_credentials` stamps the platform's
|
// resolves platform keys (env > auth.json > config), so the key just
|
||||||
// catalog entries; a parse failure keeps the last-known-good config
|
// persisted to auth.json is stamped onto the platform's entries; a
|
||||||
// (`on_auth_changed` below still re-resolves keys from disk itself).
|
// 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()
|
match crate::config::load_effective_config()
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
.and_then(|raw| crate::agent::config::Config::new_from_toml_cfg(&raw))
|
.and_then(|raw| crate::agent::config::Config::new_from_toml_cfg(&raw))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
#[path = "manager/lock.rs"]
|
#[path = "manager/lock.rs"]
|
||||||
mod lock;
|
mod lock;
|
||||||
|
pub(crate) use lock::try_lock_auth_file_nonblocking;
|
||||||
#[path = "manager/sleep_gate.rs"]
|
#[path = "manager/sleep_gate.rs"]
|
||||||
mod sleep_gate;
|
mod sleep_gate;
|
||||||
|
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ pub use meta::AuthMeta;
|
|||||||
pub use model::{AuthMode, KimiAuth, lookup_auth};
|
pub use model::{AuthMode, KimiAuth, lookup_auth};
|
||||||
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
|
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
clear_api_key, read_api_key, read_auth_json, read_token_by_scope, store_api_key,
|
clear_api_key, read_api_key, read_auth_json, read_platform_api_key, read_token_by_scope,
|
||||||
|
store_api_key, store_platform_api_key,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ pub(crate) fn disable_mock_keyring_for_test() {
|
|||||||
fn keyring_entry() -> Result<&'static keyring::Entry, keyring::Error> {
|
fn keyring_entry() -> Result<&'static keyring::Entry, keyring::Error> {
|
||||||
static ENTRY: std::sync::OnceLock<Result<keyring::Entry, keyring::Error>> =
|
static ENTRY: std::sync::OnceLock<Result<keyring::Entry, keyring::Error>> =
|
||||||
std::sync::OnceLock::new();
|
std::sync::OnceLock::new();
|
||||||
match ENTRY.get_or_init(|| {
|
match ENTRY
|
||||||
keyring::Entry::new(KEYRING_SERVICE, crate::auth::config::KIMI_CODE_OAUTH_SCOPE)
|
.get_or_init(|| keyring::Entry::new(KEYRING_SERVICE, crate::auth::KIMI_CODE_OAUTH_SCOPE))
|
||||||
}) {
|
{
|
||||||
Ok(entry) => Ok(entry),
|
Ok(entry) => Ok(entry),
|
||||||
// `keyring::Error` is not `Clone`; surface a stable equivalent.
|
// `keyring::Error` is not `Clone`; surface a stable equivalent.
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -536,6 +536,71 @@ pub fn store_api_key(kigi_home: &Path, api_key: &str) -> std::io::Result<()> {
|
|||||||
write_auth_json(&path, &map)
|
write_auth_json(&path, &map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an API-key platform's key from auth.json. The scope is the platform
|
||||||
|
/// id itself (`anthropic`, `moonshot-cn`, …) — the stable per-provider
|
||||||
|
/// auth.json key contract. `None` when absent or unreadable.
|
||||||
|
pub fn read_platform_api_key(
|
||||||
|
kigi_home: &Path,
|
||||||
|
platform: kigi_models::PlatformId,
|
||||||
|
) -> Option<String> {
|
||||||
|
let path = kigi_home.join("auth.json");
|
||||||
|
let map = read_auth_json(&path).ok()?;
|
||||||
|
map.get(platform.as_str()).map(|a| a.key.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an API-key platform's key in auth.json under its platform-id scope.
|
||||||
|
/// Same corrupt-recovery + atomic-write path as [`store_api_key`]; all other
|
||||||
|
/// scopes (OAuth session, other platforms) are preserved.
|
||||||
|
///
|
||||||
|
/// SECURITY: the key must never be logged; errors carry only IO context.
|
||||||
|
pub fn store_platform_api_key(
|
||||||
|
kigi_home: &Path,
|
||||||
|
platform: kigi_models::PlatformId,
|
||||||
|
api_key: &str,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if platform.uses_oauth() {
|
||||||
|
// Real error, not debug_assert: an OAuth scope written here would
|
||||||
|
// shadow the session entry in release builds too.
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"{} authenticates via OAuth and takes no API key",
|
||||||
|
platform.as_str()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = kigi_home.join("auth.json");
|
||||||
|
// Serialize with the manager's cross-process auth.json writers (token
|
||||||
|
// refresh holds the same flock): an unlocked read-modify-write here
|
||||||
|
// could write back a pre-refresh map and revert a rotated refresh
|
||||||
|
// token (token-family revocation → forced re-login). Bounded retry;
|
||||||
|
// a sustained holder fails loudly rather than racing.
|
||||||
|
let mut lock = None;
|
||||||
|
for _ in 0..20 {
|
||||||
|
lock = super::manager::try_lock_auth_file_nonblocking(&path);
|
||||||
|
if lock.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
let Some(_lock) = lock else {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WouldBlock,
|
||||||
|
"auth.json is locked by another kigi process; try again",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
|
||||||
|
map.insert(
|
||||||
|
platform.as_str().to_owned(),
|
||||||
|
KimiAuth {
|
||||||
|
key: api_key.to_owned(),
|
||||||
|
auth_mode: AuthMode::ApiKey,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
write_auth_json(&path, &map)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove the `kigi::api_key` scope from auth.json.
|
/// Remove the `kigi::api_key` scope from auth.json.
|
||||||
pub fn clear_api_key(kigi_home: &Path) -> std::io::Result<()> {
|
pub fn clear_api_key(kigi_home: &Path) -> std::io::Result<()> {
|
||||||
let path = kigi_home.join("auth.json");
|
let path = kigi_home.join("auth.json");
|
||||||
@@ -550,6 +615,80 @@ pub fn clear_api_key(kigi_home: &Path) -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod platform_key_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Store/read round-trip under the platform-id scope; the OAuth session
|
||||||
|
/// scope and other platform scopes in the same file are preserved.
|
||||||
|
#[test]
|
||||||
|
fn platform_key_round_trip_preserves_other_scopes() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let home = dir.path();
|
||||||
|
// Pre-existing OAuth session entry must survive platform-key writes.
|
||||||
|
let path = home.join("auth.json");
|
||||||
|
let mut map = AuthStore::new();
|
||||||
|
map.insert(
|
||||||
|
crate::auth::KIMI_CODE_OAUTH_SCOPE.to_owned(),
|
||||||
|
KimiAuth {
|
||||||
|
key: "oauth-token".to_owned(),
|
||||||
|
auth_mode: AuthMode::OAuth,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
write_auth_json(&path, &map).unwrap();
|
||||||
|
|
||||||
|
store_platform_api_key(home, kigi_models::PlatformId::MoonshotCn, "sk-cn").unwrap();
|
||||||
|
store_platform_api_key(home, kigi_models::PlatformId::MoonshotAi, "sk-ai").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_platform_api_key(home, kigi_models::PlatformId::MoonshotCn).as_deref(),
|
||||||
|
Some("sk-cn")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_platform_api_key(home, kigi_models::PlatformId::MoonshotAi).as_deref(),
|
||||||
|
Some("sk-ai")
|
||||||
|
);
|
||||||
|
let stored = read_auth_json(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
stored
|
||||||
|
.get(crate::auth::KIMI_CODE_OAUTH_SCOPE)
|
||||||
|
.map(|a| a.key.as_str()),
|
||||||
|
Some("oauth-token"),
|
||||||
|
"platform-key writes must not clobber the OAuth session scope"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stored.get("moonshot-cn").map(|a| a.auth_mode.clone()),
|
||||||
|
Some(AuthMode::ApiKey),
|
||||||
|
"platform keys are stored as api_key mode under the platform id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OAuth platform takes no API key — a real error in release builds.
|
||||||
|
#[test]
|
||||||
|
fn storing_key_for_oauth_platform_is_invalid_input() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let err = store_platform_api_key(dir.path(), kigi_models::PlatformId::KimiCode, "sk-x")
|
||||||
|
.expect_err("oauth platform must reject api keys");
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||||
|
assert!(
|
||||||
|
!dir.path().join("auth.json").exists(),
|
||||||
|
"rejected write must not create auth.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Missing file reads as None (not an error) — resolution treats absent
|
||||||
|
/// auth.json as "no stored key".
|
||||||
|
#[test]
|
||||||
|
fn reading_platform_key_without_auth_json_is_none() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_platform_api_key(dir.path(), kigi_models::PlatformId::MoonshotCn),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod write_fallback_tests {
|
mod write_fallback_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ impl AuthStatus {
|
|||||||
.unwrap_or(&origin);
|
.unwrap_or(&origin);
|
||||||
return Self::LoggedIn(host.to_owned());
|
return Self::LoggedIn(host.to_owned());
|
||||||
}
|
}
|
||||||
let models = crate::agent::config::resolve_model_list(agent_config, None);
|
let models = crate::agent::config::resolve_model_list(
|
||||||
|
agent_config,
|
||||||
|
None,
|
||||||
|
&crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms),
|
||||||
|
);
|
||||||
if crate::agent::auth_method::should_advertise_xai_api_key(models.values())
|
if crate::agent::auth_method::should_advertise_xai_api_key(models.values())
|
||||||
&& let Some(name) = models
|
&& let Some(name) = models
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -92,13 +92,6 @@ pub async fn save_config(config: &Config) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acquire the `config.toml` write lock used by [`save_config`], so callers that
|
|
||||||
/// mutate the file directly (marketplace add/remove) can't interleave with a
|
|
||||||
/// settings save and clobber it.
|
|
||||||
pub(crate) async fn lock_config_writes() -> tokio::sync::MutexGuard<'static, ()> {
|
|
||||||
SAVE_LOCK.lock().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a file, treating only `NotFound` as empty. Hard read errors (EACCES,
|
/// Read a file, treating only `NotFound` as empty. Hard read errors (EACCES,
|
||||||
/// EIO) propagate so callers don't clobber an unreadable file on the next write.
|
/// EIO) propagate so callers don't clobber an unreadable file on the next write.
|
||||||
pub(crate) fn read_to_string_or_empty(path: &std::path::Path) -> std::io::Result<String> {
|
pub(crate) fn read_to_string_or_empty(path: &std::path::Path) -> std::io::Result<String> {
|
||||||
|
|||||||
@@ -574,9 +574,9 @@ pub enum Action {
|
|||||||
BeginPlatformKeyEntry(crate::app::app_view::PlatformLogin),
|
BeginPlatformKeyEntry(crate::app::app_view::PlatformLogin),
|
||||||
/// Esc from the API-key entry box: return to the login picker.
|
/// Esc from the API-key entry box: return to the login picker.
|
||||||
CancelPlatformKeyEntry,
|
CancelPlatformKeyEntry,
|
||||||
/// User submitted a pasted Moonshot API key: persist it to
|
/// User submitted a pasted platform API key: persist it to auth.json
|
||||||
/// `[platforms.<id>]` in config.toml, then authenticate with the
|
/// under the platform-id scope, then authenticate with the platform's
|
||||||
/// platform's method id. The key must never be logged.
|
/// method id. The key must never be logged.
|
||||||
SubmitPlatformApiKey(String),
|
SubmitPlatformApiKey(String),
|
||||||
/// Copy the auth URL to the clipboard during authentication.
|
/// Copy the auth URL to the clipboard during authentication.
|
||||||
CopyAuthUrl,
|
CopyAuthUrl,
|
||||||
@@ -1612,7 +1612,7 @@ pub enum Effect {
|
|||||||
PollAuthUrl { request_seq: u64 },
|
PollAuthUrl { request_seq: u64 },
|
||||||
/// Submit a manually-pasted auth code (ext request).
|
/// Submit a manually-pasted auth code (ext request).
|
||||||
SubmitAuthCode { request_seq: u64, code: String },
|
SubmitAuthCode { request_seq: u64, code: String },
|
||||||
/// Persist a Moonshot API key to `[platforms.<id>]` in config.toml, then
|
/// Persist a platform API key to auth.json (platform-id scope), then
|
||||||
/// send AuthenticateRequest with the platform's method id. SECURITY: the
|
/// send AuthenticateRequest with the platform's method id. SECURITY: the
|
||||||
/// key must never appear in logs or errors.
|
/// key must never appear in logs or errors.
|
||||||
PersistPlatformApiKeyAndAuthenticate {
|
PersistPlatformApiKeyAndAuthenticate {
|
||||||
|
|||||||
@@ -264,41 +264,35 @@ pub enum AuthMode {
|
|||||||
/// from the welcome login picker. Esc returns to the picker (no quit).
|
/// from the welcome login picker. Esc returns to the picker (no quit).
|
||||||
ApiKeyEntry(PlatformLogin),
|
ApiKeyEntry(PlatformLogin),
|
||||||
}
|
}
|
||||||
/// Open-platform API-key login target, selected from the welcome picker.
|
/// API-key platform login target, selected from the welcome picker. Wraps a
|
||||||
/// Mirrors the shell's `moonshot-cn` / `moonshot-ai` interactive auth
|
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
|
||||||
/// methods ([`kigi_shell::agent::auth_method`]).
|
/// [`Self::from_method_id`] — the production entry point — guarantees the
|
||||||
|
/// non-OAuth invariant.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PlatformLogin {
|
pub struct PlatformLogin(pub kigi_shell::models::PlatformId);
|
||||||
MoonshotCn,
|
|
||||||
MoonshotAi,
|
|
||||||
}
|
|
||||||
impl PlatformLogin {
|
impl PlatformLogin {
|
||||||
/// The picker target behind an advertised ACP method id; `None` for every
|
/// The picker target behind an advertised ACP method id; `None` for every
|
||||||
/// non-moonshot method.
|
/// method that isn't an API-key registry platform.
|
||||||
pub fn from_method_id(id: &acp::AuthMethodId) -> Option<Self> {
|
pub fn from_method_id(id: &acp::AuthMethodId) -> Option<Self> {
|
||||||
match id.0.as_ref() {
|
kigi_shell::agent::auth_method::platform_for_method_id(id).map(Self)
|
||||||
kigi_shell::agent::auth_method::MOONSHOT_CN_METHOD_ID => Some(Self::MoonshotCn),
|
|
||||||
kigi_shell::agent::auth_method::MOONSHOT_AI_METHOD_ID => Some(Self::MoonshotAi),
|
|
||||||
_ => None,
|
|
||||||
}
|
}
|
||||||
}
|
/// The registry platform whose auth.json scope stores the key.
|
||||||
/// The registry platform whose `[platforms.<id>]` table stores the key.
|
|
||||||
pub fn platform_id(self) -> kigi_shell::models::PlatformId {
|
pub fn platform_id(self) -> kigi_shell::models::PlatformId {
|
||||||
match self {
|
self.0
|
||||||
Self::MoonshotCn => kigi_shell::models::PlatformId::MoonshotCn,
|
|
||||||
Self::MoonshotAi => kigi_shell::models::PlatformId::MoonshotAi,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/// The ACP auth method id to `authenticate` with after persisting the key.
|
/// The ACP auth method id to `authenticate` with after persisting the key.
|
||||||
pub fn method_id(self) -> acp::AuthMethodId {
|
pub fn method_id(self) -> acp::AuthMethodId {
|
||||||
acp::AuthMethodId::new(self.platform_id().as_str())
|
acp::AuthMethodId::new(self.platform_id().as_str())
|
||||||
}
|
}
|
||||||
/// Console host shown in the paste-box copy.
|
/// Vendor word for the paste-box copy ("Paste your {vendor} API key").
|
||||||
pub fn console_host(self) -> &'static str {
|
pub fn vendor(self) -> &'static str {
|
||||||
match self {
|
self.0.vendor()
|
||||||
Self::MoonshotCn => "platform.moonshot.cn",
|
|
||||||
Self::MoonshotAi => "platform.moonshot.ai",
|
|
||||||
}
|
}
|
||||||
|
/// Console host shown in the paste-box copy. Every API-key registry row
|
||||||
|
/// carries one (pinned by a kigi-models registry test); the fallback is
|
||||||
|
/// unreachable copy, not control flow.
|
||||||
|
pub fn console_host(self) -> &'static str {
|
||||||
|
self.0.console_host().unwrap_or("the provider console")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// One row of the unauthenticated welcome menu (the login picker).
|
/// One row of the unauthenticated welcome menu (the login picker).
|
||||||
@@ -6909,14 +6903,14 @@ pub(crate) mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
items[1],
|
items[1],
|
||||||
PendingMenuItem::ApiKey {
|
PendingMenuItem::ApiKey {
|
||||||
target: PlatformLogin::MoonshotCn,
|
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn),
|
||||||
label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(),
|
label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
items[2],
|
items[2],
|
||||||
PendingMenuItem::ApiKey {
|
PendingMenuItem::ApiKey {
|
||||||
target: PlatformLogin::MoonshotAi,
|
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi),
|
||||||
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
|
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -6960,7 +6954,9 @@ pub(crate) mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
outcome,
|
outcome,
|
||||||
InputOutcome::Action(Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn))
|
InputOutcome::Action(Action::BeginPlatformKeyEntry(PlatformLogin(
|
||||||
|
kigi_shell::models::PlatformId::MoonshotCn
|
||||||
|
)))
|
||||||
),
|
),
|
||||||
"Enter on row 1 must open moonshot-cn key entry, got {outcome:?}"
|
"Enter on row 1 must open moonshot-cn key entry, got {outcome:?}"
|
||||||
);
|
);
|
||||||
@@ -6976,7 +6972,7 @@ pub(crate) mod tests {
|
|||||||
request_seq: 1,
|
request_seq: 1,
|
||||||
handle: None,
|
handle: None,
|
||||||
auth_url: None,
|
auth_url: None,
|
||||||
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn),
|
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
|
||||||
};
|
};
|
||||||
let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE));
|
let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -6994,7 +6990,7 @@ pub(crate) mod tests {
|
|||||||
request_seq: 1,
|
request_seq: 1,
|
||||||
handle: None,
|
handle: None,
|
||||||
auth_url: None,
|
auth_url: None,
|
||||||
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotAi),
|
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi)),
|
||||||
};
|
};
|
||||||
// Empty input: Enter is a no-op.
|
// Empty input: Enter is a no-op.
|
||||||
let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE));
|
let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Enter with a non-empty key in the API-key paste box: persist the key to
|
/// Enter with a non-empty key in the API-key paste box: persist the key to
|
||||||
/// `[platforms.<id>]` in config.toml, then authenticate with the platform's
|
/// auth.json (platform-id scope), then authenticate with the platform's
|
||||||
/// method id (one sequential background task — see the effect handler).
|
/// method id (one sequential background task — see the effect handler).
|
||||||
/// The screen shows the connecting state while the key is validated; a
|
/// The screen shows the connecting state while the key is validated; a
|
||||||
/// failure lands back on the picker with the error line (`AuthFailed`).
|
/// failure lands back on the picker with the error line (`AuthFailed`).
|
||||||
|
|||||||
@@ -112,14 +112,14 @@ fn submit_platform_api_key_dispatches_persist_then_authenticate() {
|
|||||||
app.auth_state = AuthState::Pending { error: None };
|
app.auth_state = AuthState::Pending { error: None };
|
||||||
|
|
||||||
let effects = dispatch(
|
let effects = dispatch(
|
||||||
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn),
|
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
|
||||||
&mut app,
|
&mut app,
|
||||||
);
|
);
|
||||||
assert!(effects.is_empty(), "entering key entry is UI-only");
|
assert!(effects.is_empty(), "entering key entry is UI-only");
|
||||||
let seq = match &app.auth_state {
|
let seq = match &app.auth_state {
|
||||||
AuthState::Authenticating {
|
AuthState::Authenticating {
|
||||||
request_seq,
|
request_seq,
|
||||||
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn),
|
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
|
||||||
..
|
..
|
||||||
} => *request_seq,
|
} => *request_seq,
|
||||||
other => panic!("expected ApiKeyEntry(MoonshotCn), got {other:?}"),
|
other => panic!("expected ApiKeyEntry(MoonshotCn), got {other:?}"),
|
||||||
@@ -135,7 +135,10 @@ fn submit_platform_api_key_dispatches_persist_then_authenticate() {
|
|||||||
},
|
},
|
||||||
] => {
|
] => {
|
||||||
assert_eq!(*request_seq, seq);
|
assert_eq!(*request_seq, seq);
|
||||||
assert_eq!(*target, PlatformLogin::MoonshotCn);
|
assert_eq!(
|
||||||
|
*target,
|
||||||
|
PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)
|
||||||
|
);
|
||||||
assert_eq!(key, "sk-test-key");
|
assert_eq!(key, "sk-test-key");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target.method_id().0.as_ref(),
|
target.method_id().0.as_ref(),
|
||||||
@@ -179,7 +182,7 @@ fn cancel_platform_key_entry_returns_to_picker() {
|
|||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
app.auth_state = AuthState::Pending { error: None };
|
app.auth_state = AuthState::Pending { error: None };
|
||||||
dispatch(
|
dispatch(
|
||||||
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotAi),
|
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi)),
|
||||||
&mut app,
|
&mut app,
|
||||||
);
|
);
|
||||||
app.auth_code_input = "sk-half-typed".into();
|
app.auth_code_input = "sk-half-typed".into();
|
||||||
@@ -200,7 +203,7 @@ fn submit_platform_api_key_ignores_blank_key() {
|
|||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
app.auth_state = AuthState::Pending { error: None };
|
app.auth_state = AuthState::Pending { error: None };
|
||||||
dispatch(
|
dispatch(
|
||||||
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn),
|
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
|
||||||
&mut app,
|
&mut app,
|
||||||
);
|
);
|
||||||
let effects = dispatch(Action::SubmitPlatformApiKey(" ".into()), &mut app);
|
let effects = dispatch(Action::SubmitPlatformApiKey(" ".into()), &mut app);
|
||||||
@@ -208,7 +211,7 @@ fn submit_platform_api_key_ignores_blank_key() {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.auth_state,
|
app.auth_state,
|
||||||
AuthState::Authenticating {
|
AuthState::Authenticating {
|
||||||
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn),
|
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1218,12 +1218,13 @@ fn render_welcome_authenticating(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AuthMode::ApiKeyEntry(target) => {
|
AuthMode::ApiKeyEntry(target) => {
|
||||||
// Moonshot API-key paste box: instruction + input + hints. No
|
// Platform API-key paste box: instruction + input + hints. No
|
||||||
// auth-URL machinery — the key comes from the platform console.
|
// auth-URL machinery — the key comes from the platform console.
|
||||||
let h_pad: u16 = content_area.width / 6;
|
let h_pad: u16 = content_area.width / 6;
|
||||||
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
||||||
let instruction = format!(
|
let instruction = format!(
|
||||||
"Paste your Moonshot API key (from {})",
|
"Paste your {} API key (from {})",
|
||||||
|
target.vendor(),
|
||||||
target.console_host()
|
target.console_host()
|
||||||
);
|
);
|
||||||
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
|
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
|
||||||
@@ -2136,7 +2137,9 @@ mod tests {
|
|||||||
&theme,
|
&theme,
|
||||||
logo_line_count(area.height),
|
logo_line_count(area.height),
|
||||||
None, // auth_url — none in key-entry mode
|
None, // auth_url — none in key-entry mode
|
||||||
AuthMode::ApiKeyEntry(crate::app::app_view::PlatformLogin::MoonshotCn),
|
AuthMode::ApiKeyEntry(crate::app::app_view::PlatformLogin(
|
||||||
|
kigi_shell::models::PlatformId::MoonshotCn,
|
||||||
|
)),
|
||||||
"", // auth_code_input
|
"", // auth_code_input
|
||||||
false, // clipboard_copied
|
false, // clipboard_copied
|
||||||
false, // show_raw_url
|
false, // show_raw_url
|
||||||
|
|||||||
Reference in New Issue
Block a user