diff --git a/Cargo.lock b/Cargo.lock index de0f7e2..6920eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6132,6 +6132,7 @@ dependencies = [ name = "kigi-models" version = "0.1.0" dependencies = [ + "kigi-env", "serde", "serde_json", ] diff --git a/README.md b/README.md index 05475f2..fc45380 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,46 @@ stores is touched. Authentication and inference against Kimi Code (milestone M1), the compatibility surface (M2), and release distribution (M3) are in progress. +## Providers and API keys + +Kigi talks to a fixed three-platform registry: + +| Platform id | Base URL | Auth | +| ------------- | ------------------------------- | --------------------------- | +| `kimi-code` | `https://api.kimi.com/coding/v1` | Kimi Code subscription OAuth (`kigi login`) | +| `moonshot-cn` | `https://api.moonshot.cn/v1` | Moonshot open-platform API key | +| `moonshot-ai` | `https://api.moonshot.ai/v1` | Moonshot open-platform API key | + +Moonshot API keys come from the environment or `~/.kigi/config.toml` +(environment wins; values are never logged): + +```sh +export KIGI_MOONSHOT_API_KEY=sk-... # applies to both open platforms +export KIGI_MOONSHOT_CN_API_KEY=sk-... # platform-scoped, beats the generic name +export KIGI_MOONSHOT_AI_API_KEY=sk-... +``` + +```toml +# ~/.kigi/config.toml +[platforms.moonshot-cn] +api_key = "sk-..." + +[platforms.moonshot-ai] +api_key = "sk-..." +``` + +On login and on startup Kigi syncs each configured platform's model list +from `GET {base}/models` and shows the merged catalog in the model picker +(catalog keys are `{platform_id}/{model_id}`). If the sync fails, the last +cached catalog is used; with no cache, a small built-in fallback list +applies. Model selection resolves as +`--model` CLI flag > `KIGI_DEFAULT_MODEL` > `[models] default` in +config.toml > server-delivered list > built-in fallback. + +`KIGI_CODE_BASE_URL` re-points the subscription platform (useful for +testing); `KIGI_MOONSHOT_CN_BASE_URL` / `KIGI_MOONSHOT_AI_BASE_URL` are the +equivalent dev/test overrides for the open platforms. + ## Building from source ```sh diff --git a/crates/codegen/kigi-models/Cargo.toml b/crates/codegen/kigi-models/Cargo.toml index 86ae5db..a7e8d89 100644 --- a/crates/codegen/kigi-models/Cargo.toml +++ b/crates/codegen/kigi-models/Cargo.toml @@ -3,9 +3,10 @@ license = "Apache-2.0" name = "kigi-models" version.workspace = true edition.workspace = true -description = "Default model IDs for the grok CLI, loaded from the embedded default_models.json." +description = "Kimi platform registry, /models wire contract, capability derivation, and the bundled offline fallback catalog (default_models.json)." [dependencies] +kigi-env = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/codegen/kigi-models/default_models.json b/crates/codegen/kigi-models/default_models.json index 74e9ba7..37a7222 100644 --- a/crates/codegen/kigi-models/default_models.json +++ b/crates/codegen/kigi-models/default_models.json @@ -1,18 +1,46 @@ { - "default": "grok-build", - "web_search": "grok-4.20-multi-agent", - "image_description": "grok-build", - "session_summary": "grok-build", + "default": "kimi-for-coding", "models": [ { - "model": "grok-build", - "name": "Grok Build", - "description": "Best for advanced coding tasks", - "context_window": 500000, - "temperature": 0.7, - "top_p": 0.95, - "api_backend": "responses", + "id": "kimi-code/kimi-for-coding", + "model": "kimi-for-coding", + "name": "Kimi for Coding", + "description": "Kimi Code subscription coding model (offline fallback entry)", + "context_window": 262144, + "capabilities": ["thinking", "image_in", "video_in"], "supported_in_api": false + }, + { + "id": "moonshot-cn/kimi-k2-turbo-preview", + "model": "kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo (moonshot.cn)", + "description": "Moonshot open platform model (offline fallback entry)", + "context_window": 262144, + "capabilities": ["thinking", "image_in", "video_in"] + }, + { + "id": "moonshot-cn/kimi-k2-thinking-turbo", + "model": "kimi-k2-thinking-turbo", + "name": "Kimi K2 Thinking Turbo (moonshot.cn)", + "description": "Moonshot open platform model (offline fallback entry)", + "context_window": 262144, + "capabilities": ["thinking", "always_thinking", "image_in", "video_in"] + }, + { + "id": "moonshot-ai/kimi-k2-turbo-preview", + "model": "kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo (moonshot.ai)", + "description": "Moonshot open platform model (offline fallback entry)", + "context_window": 262144, + "capabilities": ["thinking", "image_in", "video_in"] + }, + { + "id": "moonshot-ai/kimi-k2-thinking-turbo", + "model": "kimi-k2-thinking-turbo", + "name": "Kimi K2 Thinking Turbo (moonshot.ai)", + "description": "Moonshot open platform model (offline fallback entry)", + "context_window": 262144, + "capabilities": ["thinking", "always_thinking", "image_in", "video_in"] } ] } diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index d0cf929..adb3157 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -1,14 +1,293 @@ -//! Default model IDs loaded from `default_models.json` at runtime. -//! Edit that JSON file to change them. +//! Kimi model catalog primitives (PRD F2/F4). +//! +//! This crate owns: +//! - the fixed three-platform registry ([`PlatformId`]): the Kimi Code +//! subscription channel plus the two Moonshot open platforms; +//! - the `GET {base}/models` wire contract ([`WireModel`]) and the capability +//! derivation ported from kimi-cli `auth/platforms.py`; +//! - the managed catalog key format `{platform_id}/{model_id}`; +//! - the bundled OFFLINE-LAST-RESORT fallback catalog +//! (`default_models.json`), used only when the live `/models` sync fails +//! AND no disk cache is usable. Every id in that file is sourced from +//! kimi-cli 1.49.0 (see the module docs on [`DEFAULT_MODELS_JSON`]). //! //! At runtime each model is resolved via: -//! CLI flag > ENV var > config.toml > remote settings > these defaults +//! CLI flag > ENV var > config.toml > server-delivered > these defaults use std::sync::LazyLock; -/// The raw JSON, embedded at compile time. Re-exported through the -/// `kigi_shell::models` facade and consumed by `agent::config`, so it must -/// be `pub` (was `pub(crate)` when this lived inside the shell crate). +// ── Platform registry (PRD F2) ────────────────────────────────────────────── + +/// Env var holding the moonshot-cn API key (wins over the generic name). +pub const MOONSHOT_CN_API_KEY_ENV: &str = "KIGI_MOONSHOT_CN_API_KEY"; +/// Env var holding the moonshot-ai API key (wins over the generic name). +pub const MOONSHOT_AI_API_KEY_ENV: &str = "KIGI_MOONSHOT_AI_API_KEY"; +/// Generic moonshot API key env var, applied to BOTH open platforms when the +/// platform-scoped name is unset. +pub const MOONSHOT_API_KEY_ENV: &str = "KIGI_MOONSHOT_API_KEY"; +/// Base-URL override for moonshot-cn (dev/test escape hatch mirroring +/// `KIGI_CODE_BASE_URL`; production uses the compiled default). +pub const MOONSHOT_CN_BASE_URL_ENV: &str = "KIGI_MOONSHOT_CN_BASE_URL"; +/// Base-URL override for moonshot-ai (dev/test escape hatch mirroring +/// `KIGI_CODE_BASE_URL`; production uses the compiled default). +pub const MOONSHOT_AI_BASE_URL_ENV: &str = "KIGI_MOONSHOT_AI_BASE_URL"; + +/// Env override when set and non-blank, else the compiled default. +fn env_or(var: &str, compiled: &str) -> String { + match std::env::var(var) { + Ok(v) if !v.trim().is_empty() => v, + _ => compiled.to_string(), + } +} + +/// The fixed platform registry. Kigi talks to exactly these three model +/// providers; there is no dynamic provider registration (PRD F2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum PlatformId { + /// Kimi Code subscription (OAuth bearer from the F1 device flow). + KimiCode, + /// Moonshot AI open platform, api.moonshot.cn (API key). + MoonshotCn, + /// Moonshot AI open platform, api.moonshot.ai (API key). + MoonshotAi, +} + +impl PlatformId { + /// All platforms, in catalog precedence order: the subscription channel + /// first so "default model = first list item" favors it when present. + pub const ALL: [PlatformId; 3] = [Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi]; + + pub fn as_str(self) -> &'static str { + match self { + Self::KimiCode => "kimi-code", + Self::MoonshotCn => "moonshot-cn", + Self::MoonshotAi => "moonshot-ai", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "kimi-code" => Some(Self::KimiCode), + "moonshot-cn" => Some(Self::MoonshotCn), + "moonshot-ai" => Some(Self::MoonshotAi), + _ => None, + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::KimiCode => "Kimi Code", + Self::MoonshotCn => "Moonshot AI Open Platform (moonshot.cn)", + Self::MoonshotAi => "Moonshot AI Open Platform (moonshot.ai)", + } + } + + /// Inference/model-listing base URL. The subscription base honors the + /// `KIGI_CODE_BASE_URL` override via [`kigi_env::coding_api_base_url`]; + /// the open-platform bases are fixed in production, with + /// `KIGI_MOONSHOT_{CN,AI}_BASE_URL` as dev/test overrides. + pub fn base_url(self) -> String { + match self { + Self::KimiCode => kigi_env::coding_api_base_url(), + Self::MoonshotCn => env_or(MOONSHOT_CN_BASE_URL_ENV, "https://api.moonshot.cn/v1"), + Self::MoonshotAi => env_or(MOONSHOT_AI_BASE_URL_ENV, "https://api.moonshot.ai/v1"), + } + } + + /// True for the OAuth-bearer subscription channel. + pub fn uses_oauth(self) -> bool { + matches!(self, Self::KimiCode) + } + + /// Model-id prefixes admitted from this platform's `/models` listing. + /// `None` = no filtering (subscription listing is served pre-filtered). + pub fn allowed_model_prefixes(self) -> Option<&'static [&'static str]> { + match self { + Self::KimiCode => None, + Self::MoonshotCn | Self::MoonshotAi => Some(&["kimi-k"]), + } + } + + /// Env var names holding this platform's API key, in precedence order + /// (first set, non-blank value wins). Empty for the OAuth channel. + /// + /// SECURITY: the *values* behind these names must never be logged. + pub fn api_key_env_names(self) -> &'static [&'static str] { + match self { + Self::KimiCode => &[], + Self::MoonshotCn => &[MOONSHOT_CN_API_KEY_ENV, MOONSHOT_API_KEY_ENV], + Self::MoonshotAi => &[MOONSHOT_AI_API_KEY_ENV, MOONSHOT_API_KEY_ENV], + } + } + + /// Managed catalog key for a model served by this platform: + /// `{platform_id}/{model_id}` (kimi-cli `managed_model_key`). + pub fn managed_model_key(self, model_id: &str) -> String { + format!("{}/{model_id}", self.as_str()) + } +} + +/// Split a managed catalog key `{platform_id}/{model_id}` back into its +/// platform and bare model id. `None` when the key carries no known platform +/// prefix (e.g. a user-defined `[model.*]` entry). +pub fn parse_managed_model_key(key: &str) -> Option<(PlatformId, &str)> { + let (platform, model_id) = key.split_once('/')?; + let platform = PlatformId::parse(platform)?; + if model_id.is_empty() { + return None; + } + Some((platform, model_id)) +} + +// ── Wire contract + capability derivation (PRD F4) ────────────────────────── + +/// Model capabilities derived from the `/models` listing +/// (port of kimi-cli `ModelCapability` + `ModelInfo.capabilities`). +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum ModelCapability { + /// Supports reasoning ("thinking" mode toggleable on/off). + Thinking, + /// Thinking cannot be disabled (id contains "thinking"). + AlwaysThinking, + ImageIn, + VideoIn, +} + +impl ModelCapability { + pub fn as_str(self) -> &'static str { + match self { + Self::Thinking => "thinking", + Self::AlwaysThinking => "always_thinking", + Self::ImageIn => "image_in", + Self::VideoIn => "video_in", + } + } +} + +impl std::fmt::Display for ModelCapability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One entry of the `GET {base}/models` response `data` array (PRD F4). +#[derive(Debug, Clone, serde::Deserialize)] +pub struct WireModel { + pub id: String, + #[serde(default)] + pub context_length: u64, + #[serde(default)] + pub supports_reasoning: bool, + #[serde(default)] + pub supports_image_in: bool, + #[serde(default)] + pub supports_video_in: bool, + #[serde(default)] + pub display_name: Option, +} + +/// `GET {base}/models` response envelope. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct WireModelsResponse { + pub data: Vec, +} + +impl WireModel { + /// Capability derivation ported verbatim from kimi-cli + /// `auth/platforms.py::ModelInfo.capabilities`: + /// - `supports_reasoning` → thinking + /// - `"thinking"` in id → thinking + always_thinking + /// - `supports_image_in` → image_in; `supports_video_in` → video_in + /// - id starts with `kimi-k2` → thinking + image_in + video_in + /// + /// Returned sorted + deduplicated ([`ModelCapability`]'s `Ord`). + pub fn capabilities(&self) -> Vec { + derive_capabilities( + &self.id, + self.supports_reasoning, + self.supports_image_in, + self.supports_video_in, + ) + } +} + +/// See [`WireModel::capabilities`]; split out so fallback/bundled entries can +/// run the same derivation from an id alone. +pub fn derive_capabilities( + id: &str, + supports_reasoning: bool, + supports_image_in: bool, + supports_video_in: bool, +) -> Vec { + let id_lower = id.to_lowercase(); + let mut caps = std::collections::BTreeSet::new(); + if supports_reasoning { + caps.insert(ModelCapability::Thinking); + } + if id_lower.contains("thinking") { + caps.insert(ModelCapability::Thinking); + caps.insert(ModelCapability::AlwaysThinking); + } + if supports_image_in { + caps.insert(ModelCapability::ImageIn); + } + if supports_video_in { + caps.insert(ModelCapability::VideoIn); + } + if id_lower.starts_with("kimi-k2") { + caps.insert(ModelCapability::Thinking); + caps.insert(ModelCapability::ImageIn); + caps.insert(ModelCapability::VideoIn); + } + caps.into_iter().collect() +} + +/// Whether thinking should default ON for a model with these capabilities +/// (PRD F4: `thinking` or `always_thinking` present). +pub fn default_thinking_enabled(capabilities: &[ModelCapability]) -> bool { + capabilities.iter().any(|c| { + matches!( + c, + ModelCapability::Thinking | ModelCapability::AlwaysThinking + ) + }) +} + +/// Apply a platform's `allowed_model_prefixes` filter to a `/models` listing +/// (kimi-cli `list_models`). No-op for platforms without a filter. +pub fn filter_allowed_models(platform: PlatformId, models: Vec) -> Vec { + let Some(prefixes) = platform.allowed_model_prefixes() else { + return models; + }; + models + .into_iter() + .filter(|m| prefixes.iter().any(|p| m.id.starts_with(p))) + .collect() +} + +// ── Bundled offline fallback catalog ──────────────────────────────────────── + +/// The raw JSON, embedded at compile time. OFFLINE LAST RESORT: consulted only +/// when the live `/models` sync fails and no disk cache is usable. +/// +/// Sources for every id (do not add ids that cannot be sourced): +/// - `kimi-for-coding`: kimi-cli `src/kimi_cli/llm.py` (`model_display_name`, +/// `derive_model_capabilities`) — the Kimi Code subscription coding model. +/// Its capabilities {thinking, image_in, video_in} come from +/// `derive_model_capabilities` in the same file. +/// - `kimi-k2-turbo-preview` / `kimi-k2-thinking-turbo`: kimi-cli +/// `tests/core/test_create_llm.py` (`_make_kimi_plain_model`, +/// `_make_kimi_thinking_model`) — Moonshot open-platform models. Their +/// capabilities follow the `auth/platforms.py` derivation rules +/// ([`derive_capabilities`]). +/// - context_window 262144: the canonical Kimi context size used by kimi-cli's +/// own budget tests (`tests/core/test_create_llm.py`). +/// +/// Re-exported through the `kigi_shell::models` facade and consumed by +/// `agent::config`, so it must be `pub`. pub const DEFAULT_MODELS_JSON: &str = include_str!("../default_models.json"); #[derive(serde::Deserialize)] @@ -68,3 +347,181 @@ pub fn default_session_summary_model() -> &'static str { .as_deref() .unwrap_or(&DEFAULTS.default) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_ids_round_trip() { + for p in PlatformId::ALL { + assert_eq!(PlatformId::parse(p.as_str()), Some(p)); + } + assert_eq!(PlatformId::parse("openai"), None); + } + + #[test] + fn platform_base_urls() { + assert_eq!( + PlatformId::MoonshotCn.base_url(), + "https://api.moonshot.cn/v1" + ); + assert_eq!( + PlatformId::MoonshotAi.base_url(), + "https://api.moonshot.ai/v1" + ); + // Subscription base honors the env override. + let _g = kigi_env::EnvVarGuard::set(kigi_env::CODE_BASE_URL_ENV, "https://mock.test/v1"); + assert_eq!(PlatformId::KimiCode.base_url(), "https://mock.test/v1"); + } + + #[test] + fn managed_model_key_format_and_parse() { + let key = PlatformId::MoonshotCn.managed_model_key("kimi-k2-turbo-preview"); + assert_eq!(key, "moonshot-cn/kimi-k2-turbo-preview"); + assert_eq!( + parse_managed_model_key(&key), + Some((PlatformId::MoonshotCn, "kimi-k2-turbo-preview")) + ); + assert_eq!( + parse_managed_model_key("kimi-code/kimi-for-coding"), + Some((PlatformId::KimiCode, "kimi-for-coding")) + ); + // No prefix / unknown platform / empty model id → None. + assert_eq!(parse_managed_model_key("kimi-for-coding"), None); + assert_eq!(parse_managed_model_key("openai/gpt"), None); + assert_eq!(parse_managed_model_key("moonshot-cn/"), None); + } + + /// Capability derivation table ported from kimi-cli platforms.py. + #[test] + fn capability_derivation_table() { + use ModelCapability::*; + let cases: &[(&str, bool, bool, bool, &[ModelCapability])] = &[ + // supports_reasoning only → thinking + ("some-model", true, false, false, &[Thinking]), + // no flags, no name rules → empty + ("some-model", false, false, false, &[]), + // "thinking" in id → thinking + always_thinking + ( + "kimi-latest-thinking", + false, + false, + false, + &[Thinking, AlwaysThinking], + ), + // image/video flags map directly + ("some-model", false, true, true, &[ImageIn, VideoIn]), + // kimi-k2 prefix → thinking + image_in + video_in + ( + "kimi-k2-turbo-preview", + false, + false, + false, + &[Thinking, ImageIn, VideoIn], + ), + // kimi-k2 prefix + "thinking" in id → all four + ( + "kimi-k2-thinking-turbo", + false, + false, + false, + &[Thinking, AlwaysThinking, ImageIn, VideoIn], + ), + // Case-insensitive id rules (mirrors `.lower()` in platforms.py) + ( + "Kimi-K2-Thinking", + false, + false, + false, + &[Thinking, AlwaysThinking, ImageIn, VideoIn], + ), + ]; + for (id, reasoning, image, video, want) in cases { + let got = derive_capabilities(id, *reasoning, *image, *video); + assert_eq!(&got, want, "capabilities for {id}"); + } + } + + #[test] + fn default_thinking_from_capabilities() { + use ModelCapability::*; + assert!(default_thinking_enabled(&[Thinking])); + assert!(default_thinking_enabled(&[AlwaysThinking])); + assert!(default_thinking_enabled(&[Thinking, ImageIn])); + assert!(!default_thinking_enabled(&[ImageIn, VideoIn])); + assert!(!default_thinking_enabled(&[])); + } + + #[test] + fn moonshot_prefix_filter_applies_only_to_open_platforms() { + let listing = vec![ + WireModel { + id: "kimi-k2-turbo-preview".into(), + context_length: 262_144, + supports_reasoning: false, + supports_image_in: false, + supports_video_in: false, + display_name: None, + }, + WireModel { + id: "moonshot-v1-8k".into(), + context_length: 8_192, + supports_reasoning: false, + supports_image_in: false, + supports_video_in: false, + display_name: None, + }, + ]; + let filtered = filter_allowed_models(PlatformId::MoonshotCn, listing.clone()); + assert_eq!( + filtered.iter().map(|m| m.id.as_str()).collect::>(), + vec!["kimi-k2-turbo-preview"], + "moonshot listing must be filtered to the kimi-k prefix" + ); + let unfiltered = filter_allowed_models(PlatformId::KimiCode, listing); + assert_eq!(unfiltered.len(), 2, "subscription listing is not filtered"); + } + + #[test] + fn wire_response_parses_f4_shape() { + let raw = r#"{ + "data": [ + { + "id": "kimi-for-coding", + "context_length": 262144, + "supports_reasoning": true, + "supports_image_in": true, + "supports_video_in": false, + "display_name": "k2.6-code-preview" + }, + { "id": "kimi-k2-turbo-preview" } + ] + }"#; + let resp: WireModelsResponse = serde_json::from_str(raw).expect("F4 shape must parse"); + assert_eq!(resp.data.len(), 2); + let first = &resp.data[0]; + assert_eq!(first.id, "kimi-for-coding"); + assert_eq!(first.context_length, 262_144); + assert_eq!(first.display_name.as_deref(), Some("k2.6-code-preview")); + assert_eq!( + first.capabilities(), + vec![ModelCapability::Thinking, ModelCapability::ImageIn] + ); + // Missing optional fields default off/0. + let second = &resp.data[1]; + assert_eq!(second.context_length, 0); + assert!(!second.supports_reasoning); + } + + #[test] + fn bundled_fallback_is_kimi_catalog() { + assert_eq!(default_model(), "kimi-for-coding"); + // Aux models fall back to the default (no dedicated entries). + assert_eq!(default_web_search_model(), "kimi-for-coding"); + assert_eq!(default_image_description_model(), "kimi-for-coding"); + assert_eq!(default_session_summary_model(), "kimi-for-coding"); + // No grok remnants in the embedded fallback. + assert!(!DEFAULT_MODELS_JSON.contains("grok")); + } +} diff --git a/crates/codegen/kigi-shell/src/agent/app.rs b/crates/codegen/kigi-shell/src/agent/app.rs index 9bd80b4..5824a53 100644 --- a/crates/codegen/kigi-shell/src/agent/app.rs +++ b/crates/codegen/kigi-shell/src/agent/app.rs @@ -173,11 +173,12 @@ pub(crate) async fn run_auto_update_checker( async fn prefetch_models(agent_config: &AgentConfig) -> Option> { let auth = agent_config.create_auth_manager().current(); let endpoints = agent_config.endpoints.clone(); - let fetch_auth = ModelFetchAuth::resolve(&endpoints, auth.is_some()); + let fetch_auth = ModelFetchAuth::resolve(&endpoints); + let platform_keys = crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms); - if auth.is_some() || endpoints.has_custom_endpoint() || fetch_auth != ModelFetchAuth::Session { + if auth.is_some() || endpoints.has_custom_endpoint() || platform_keys.any() { tokio::task::spawn_blocking(move || { - prefetch_models_blocking(&endpoints, auth.as_ref(), fetch_auth) + prefetch_models_blocking(&endpoints, auth.as_ref(), fetch_auth, &platform_keys) }) .await .ok() @@ -617,7 +618,9 @@ pub async fn run_leader( let auth_for_prefetch: Option = auth.clone(); let endpoints_for_prefetch = agent_config.endpoints.clone(); - let fetch_auth_for_prefetch = ModelFetchAuth::resolve(&endpoints_for_prefetch, auth.is_some()); + let fetch_auth_for_prefetch = ModelFetchAuth::resolve(&endpoints_for_prefetch); + let platform_keys_for_prefetch = + crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms); // The shared pair helper owns the remote_fetch gate for both halves, so a // disabled knob cannot block leader readiness on settings retries. let (prefetched_models, remote_settings) = tokio::task::spawn_blocking(move || { @@ -625,6 +628,7 @@ pub async fn run_leader( &endpoints_for_prefetch, auth_for_prefetch.as_ref(), fetch_auth_for_prefetch, + &platform_keys_for_prefetch, ) }) .await diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index cad117b..8950982 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -854,7 +854,7 @@ pub struct ModelsConfig { #[serde(skip_serializing_if = "Option::is_none")] pub image_description: Option, /// Model pin for next-prompt suggestions (tab-autocomplete ghost text). - /// Unset = remote pin, then the client hint / built-in `grok-build-0.1` + /// Unset = remote pin, then the client hint / built-in bundled-model /// default with the catalog guard; see `ModelOverrideConfig::resolve`. #[serde(skip_serializing_if = "Option::is_none")] pub prompt_suggestion: Option, @@ -900,6 +900,98 @@ pub struct ModelsConfig { #[serde(skip_serializing_if = "Option::is_none")] pub stream_tool_calls: Option, } +/// `[platforms.]` section from config.toml (PRD F2): API keys for the +/// fixed platform registry ([`kigi_models::PlatformId`]). +/// +/// ```toml +/// [platforms.moonshot-cn] +/// api_key = "sk-..." +/// +/// [platforms.moonshot-ai] +/// api_key = "sk-..." +/// ``` +/// +/// Env vars win over the config file: +/// `KIGI_MOONSHOT_CN_API_KEY` / `KIGI_MOONSHOT_AI_API_KEY` (platform-scoped) +/// then `KIGI_MOONSHOT_API_KEY` (both open platforms). The subscription +/// platform (`kimi-code`) authenticates via OAuth and takes no API key. +/// +/// SECURITY: key values are never logged and never re-serialized +/// (`Config.platforms` is `skip_serializing`); only presence booleans may +/// appear in diagnostics. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct PlatformsConfig { + #[serde(flatten)] + pub entries: IndexMap, +} + +impl PlatformsConfig { + /// The config-file API key for `platform`, blank-as-unset. Unknown + /// platform ids in `[platforms.*]` are warned about at load + /// ([`Self::warn_unknown_platforms`]) and never resolve. + pub fn config_api_key(&self, platform: kigi_models::PlatformId) -> Option { + self.entries + .get(platform.as_str()) + .and_then(|e| e.api_key.as_deref()) + .filter(|k| !k.trim().is_empty()) + .map(str::to_owned) + } + + /// Warn (once per load) about `[platforms.]` tables that don't name a + /// registry platform, so a typo like `moonshot_cn` fails loudly instead of + /// silently never matching. Key values are not logged. + pub fn warn_unknown_platforms(&self) { + for id in self.entries.keys() { + if kigi_models::PlatformId::parse(id).is_none() { + tracing::warn!( + platform = %id, + known = ?kigi_models::PlatformId::ALL + .iter() + .map(|p| p.as_str()) + .collect::>(), + "[platforms.{id}] does not match any registry platform; its api_key is ignored" + ); + } + } + } +} + +/// One `[platforms.]` table. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PlatformCredentialConfig { + /// API key for this platform. NEVER logged; never re-serialized. + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} + +/// Resolve the API key for an open-platform registry entry: +/// platform-scoped env > generic `KIGI_MOONSHOT_API_KEY` env > config file. +/// `None` for the OAuth platform and when nothing is configured. +/// The returned value must never be logged. +pub(crate) fn resolve_platform_api_key( + platform: kigi_models::PlatformId, + platforms: &PlatformsConfig, +) -> Option { + 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( + platform: kigi_models::PlatformId, + platforms: &PlatformsConfig, + mut getenv: impl FnMut(&str) -> Option, +) -> Option { + for name in platform.api_key_env_names() { + if let Some(value) = getenv(name) + && !value.trim().is_empty() + { + return Some(value); + } + } + platforms.config_api_key(platform) +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(default)] pub struct HarnessConfig { @@ -998,7 +1090,7 @@ impl SandboxSettingsConfig { /// [suggestions] /// enabled = true /// ai_enabled = true -/// ai_model = "grok-build" +/// ai_model = "kimi-for-coding" /// debounce_ms = 50 /// ``` #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -1042,7 +1134,7 @@ impl SuggestionsConfig { None, ) .map(|r| r.value) - .unwrap_or_else(|| "grok-build".to_owned()) + .unwrap_or_else(|| crate::models::default_model().to_owned()) } } /// `[storage]` section from config.toml. @@ -1152,6 +1244,11 @@ pub struct Config { pub cli: CliConfig, #[serde(default, skip_serializing)] pub models: ModelsConfig, + /// `[platforms.]` — per-platform credentials for the fixed Kimi + /// platform registry (PRD F2). `skip_serializing` so API keys are never + /// re-emitted by any Config serialization. + #[serde(default, skip_serializing)] + pub platforms: PlatformsConfig, #[serde(default, skip_serializing)] pub harness: HarnessConfig, #[serde(default, skip_serializing)] @@ -1356,7 +1453,7 @@ pub struct Config { /// (`default_session_summary_model`) when unset; see `ModelOverrideConfig::resolve`. #[serde(skip)] pub session_summary_model: Option, - /// Image describe model (`grok-build` default via `ModelOverrideConfig::resolve`). + /// Image describe model (bundled default via `ModelOverrideConfig::resolve`). #[serde(skip)] pub image_description_model: Option, /// Next-prompt suggestion model pin (`env > [models] prompt_suggestion > @@ -1541,6 +1638,7 @@ impl Default for Config { paths: PathsConfig::default(), cli: CliConfig::default(), models: ModelsConfig::default(), + platforms: PlatformsConfig::default(), harness: HarnessConfig::default(), remote: RemoteConfig::default(), hub: HubConfig::default(), @@ -1678,6 +1776,7 @@ impl Config { } config.config_models = config_models; config.model_override_warnings = model_override_warnings; + config.platforms.warn_unknown_platforms(); if config.client_version.is_none() { config.client_version = Self::default().client_version; } @@ -2795,11 +2894,61 @@ pub fn resolve_model_list( } apply_global_extra_headers(&mut resolved, &cfg.models); apply_global_scalar_defaults(&mut resolved, &cfg.models); + apply_platform_credentials(&mut resolved, &cfg.platforms); for entry in resolved.values_mut() { entry.info.derive_reasoning_effort_fields(); } resolved } +/// Layer 8 of [`resolve_model_list`]: wire the fixed platform registry's +/// credentials into open-platform entries (PRD F2). Entries are recognized by +/// their `{platform_id}/{model_id}` catalog id. +/// +/// - `env_key` defaults to the platform's `KIGI_MOONSHOT_*` env names so an +/// env-provided key resolves at request time. +/// - a `[platforms.].api_key` from config.toml is stamped only when no +/// env name currently resolves, preserving env > config precedence +/// (`first_own_credential` checks `api_key` before `env_key`). +/// +/// A per-model `[model.*]` `api_key`/`env_key` always wins (stamped earlier; +/// this layer never overwrites). In-memory only: the models disk cache +/// persists the *pre-resolution* fetched entries, so config-file keys never +/// reach disk. Key values are never logged. +fn apply_platform_credentials( + resolved: &mut IndexMap, + platforms: &PlatformsConfig, +) { + for (key, entry) in resolved.iter_mut() { + let id = entry.info.id.as_deref().unwrap_or(key.as_str()); + let Some((platform, _)) = kigi_models::parse_managed_model_key(id) else { + continue; + }; + if platform.uses_oauth() { + continue; + } + if entry.env_key.is_none() { + entry.env_key = Some(EnvKeys::new(platform.api_key_env_names().iter().copied())); + } + let env_resolves = entry + .env_key + .as_ref() + .is_some_and(|k| k.resolve_value().is_some()); + if entry.api_key.is_none() + && !env_resolves + && let Some(config_key) = platforms.config_api_key(platform) + { + tracing::debug!( + model_key = %key, platform = platform.as_str(), + "stamped [platforms] config api_key onto open-platform entry" + ); + entry.api_key = Some(config_key); + } + // A credentialed open-platform entry is usable by API-key users. + if entry.has_own_credentials() { + entry.info.supported_in_api = true; + } + } +} /// Layer 6 of [`resolve_model_list`]: fold the global `[models].extra_headers` /// into every model as a base. The presence check is case-insensitive because /// the sampler lowers these into an `http::HeaderMap`, so a global `X-Foo` must @@ -2909,6 +3058,9 @@ struct DefaultModelJson { supports_reasoning_effort: bool, #[serde(default)] reasoning_efforts: Vec, + /// Kimi capability set (PRD F4), sourced per entry (see kigi-models docs). + #[serde(default)] + capabilities: Vec, /// When false, only OAuth users see this in the picker. #[serde(default = "default_true")] supported_in_api: bool, @@ -2943,14 +3095,28 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap { + endpoints.resolve_inference_base_url() + } + Some(open) => open.base_url(), + }; + let env_key = platform + .filter(|p| !p.uses_oauth()) + .map(|p| EnvKeys::new(p.api_key_env_names().iter().copied())); let context_window = m .context_window .unwrap_or_else(|| NonZeroU64::new(200_000).expect("200000 is non-zero")); let config = ModelEntryConfig { id: m.id, model: m.model, - base_url: endpoints.resolve_inference_base_url(), - api_base_url: Some(endpoints.xai_api_base_url.clone()), + base_url, + api_base_url: None, name: m.name, description: m.description, context_window, @@ -2965,7 +3131,7 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap IndexMap, + /// Kimi capability set (PRD F4); see [`ModelInfo::capabilities`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, /// Extra headers to send with requests to this model's endpoint. /// Useful for BYOK (Bring Your Own Key) scenarios. /// Example: { "x-anthropic-api-key" = "sk-ant-..." } @@ -3152,6 +3322,9 @@ pub struct ConfigModelOverride { pub reasoning_effort: Option, pub supports_reasoning_effort: Option, pub reasoning_efforts: Vec, + /// Kimi capability override; merges only when non-empty (cannot express + /// "override to empty", same as `reasoning_efforts`). + pub capabilities: Vec, pub supports_backend_search: Option, /// Aliases must be registered in `config_model_override_parse::ALIASES`; /// serde rejects a table that contains both spellings otherwise. @@ -3233,6 +3406,9 @@ impl ConfigModelOverride { if !self.reasoning_efforts.is_empty() { entry.info.reasoning_efforts = self.reasoning_efforts.clone(); } + if !self.capabilities.is_empty() { + entry.info.capabilities = self.capabilities.clone(); + } if let Some(v) = self.supports_backend_search { entry.info.supports_backend_search = v; } @@ -3318,6 +3494,11 @@ pub struct ModelInfo { /// Per-model reasoning-effort menu (source of truth); legacy fields derived from it. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reasoning_efforts: Vec, + /// Kimi capability set derived from the `/models` listing (PRD F4); + /// see [`kigi_models::derive_capabilities`]. Empty when the source + /// (bundled JSON, `[model.*]`, remote) declared none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, pub supports_backend_search: bool, /// Per-model config for the `x-compactions-remaining` header; `None` disables it. pub compactions_remaining: Option, @@ -3362,6 +3543,7 @@ impl ModelInfo { reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -3397,6 +3579,7 @@ impl ModelInfo { reasoning_effort: entry.reasoning_effort, supports_reasoning_effort: entry.supports_reasoning_effort, reasoning_efforts: entry.reasoning_efforts.clone(), + capabilities: entry.capabilities.clone(), supports_backend_search: entry.supports_backend_search, compactions_remaining: entry.compactions_remaining, compaction_at_tokens: entry.compaction_at_tokens, @@ -4036,6 +4219,7 @@ pub fn resolve_aux_model_sampling_config( reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -4248,6 +4432,7 @@ fn resolve_hidden_default_web_search_sampling_config( reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -4415,6 +4600,9 @@ mod tests { use super::*; use kigi_test_support::EnvGuard; use serial_test::serial; + /// Catalog key of the bundled fallback default (`default_models.json`): + /// `{platform_id}/{model_id}` for `crate::models::default_model()`. + const BUNDLED_DEFAULT_KEY: &str = "kimi-code/kimi-for-coding"; #[test] fn main_cli_tools_override_preserves_profile_injection_policy() { let overrides = CliAgentOverrides { @@ -4850,6 +5038,7 @@ reasoning_effort = "low" reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -5802,6 +5991,7 @@ reasoning_effort = "low" reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -5961,6 +6151,7 @@ reasoning_effort = "low" reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -6412,6 +6603,7 @@ reasoning_effort = "low" reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -6592,30 +6784,39 @@ reasoning_effort = "low" fn e2e_default_model_with_session_routes_to_proxy() { let (_, models) = resolve_models_from_toml("", None); let model = models - .get(crate::models::default_model()) + .get(BUNDLED_DEFAULT_KEY) .expect("default model should exist"); let sampling = resolve_sampling(model, Some("session-token-123")); assert_eq!(sampling.api_key.as_deref(), Some("session-token-123")); assert_eq!( sampling.base_url, "https://api.kimi.com/coding/v1", - "session auth should route to the subscription endpoint, not api.x.ai" + "session auth should route to the subscription endpoint" + ); + assert_eq!( + sampling.model, "kimi-for-coding", + "wire slug, not catalog key" ); } + /// F2 acceptance seam: with ONLY a moonshot API key configured (no + /// subscription login), the bundled open-platform entry resolves usable + /// credentials routed at the moonshot base — nothing platform-specific is + /// left for the sampler (F3). #[test] #[serial] - fn e2e_default_model_with_external_api_key_routes_to_api_xai() { + fn e2e_moonshot_env_key_routes_to_moonshot_base() { let (_, models) = resolve_models_from_toml("", None); let model = models - .get(crate::models::default_model()) - .expect("default model should exist"); - unsafe { std::env::set_var("XAI_API_KEY", "xai-external-key") }; + .get("moonshot-ai/kimi-k2-turbo-preview") + .expect("bundled moonshot fallback entry should exist"); + unsafe { std::env::set_var("KIGI_MOONSHOT_API_KEY", "sk-moonshot-generic") }; let sampling = resolve_sampling(model, None); - assert_eq!(sampling.api_key.as_deref(), Some("xai-external-key")); + assert_eq!(sampling.api_key.as_deref(), Some("sk-moonshot-generic")); assert_eq!( - sampling.base_url, "https://api.x.ai/v1", - "external API key should route to api.x.ai via api_base_url" + sampling.base_url, "https://api.moonshot.ai/v1", + "moonshot key must route to the open-platform base" ); - unsafe { std::env::remove_var("XAI_API_KEY") }; + assert_eq!(sampling.model, "kimi-k2-turbo-preview"); + unsafe { std::env::remove_var("KIGI_MOONSHOT_API_KEY") }; } #[test] fn e2e_user_config_overrides_prefetched_model() { @@ -6711,7 +6912,7 @@ reasoning_effort = "low" let (_, models) = resolve_models_from_toml( &format!( r#" - [model.acme-grok] + [model.acme-kimi] model = "{dm}" base_url = "https://inference.example.com/v1" context_window = 200000 @@ -6720,13 +6921,16 @@ reasoning_effort = "low" ), None, ); - assert!(models.contains_key(dm), "default entry should still exist"); assert!( - models.contains_key("acme-grok"), + models.contains_key(BUNDLED_DEFAULT_KEY), + "default entry should still exist" + ); + assert!( + models.contains_key("acme-kimi"), "user entry with different key should also exist" ); - let default = models.get(dm).unwrap(); - let user = models.get("acme-grok").unwrap(); + let default = models.get(BUNDLED_DEFAULT_KEY).unwrap(); + let user = models.get("acme-kimi").unwrap(); assert_eq!(default.info.model, user.info.model, "same model field"); assert_ne!( default.info.base_url, user.info.base_url, @@ -6770,7 +6974,7 @@ reasoning_effort = "low" let cfg = Config::default(); let resolved = resolve_model_list(&cfg, None); assert!( - resolved.contains_key(crate::models::default_model()), + resolved.contains_key(BUNDLED_DEFAULT_KEY), "default model should be present when using default endpoint" ); } @@ -6814,30 +7018,24 @@ reasoning_effort = "low" } #[test] fn e2e_enterprise_endpoints_plus_partial_model_override() { - let dm = crate::models::default_model(); let (_, models) = resolve_models_from_toml( &format!( r#" [endpoints] cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1" - xai_api_base_url = "https://enterprise-api.acme.com/v1" - [model."{dm}"] + [model."{BUNDLED_DEFAULT_KEY}"] api_key = "acme-api-key" "#, ), None, ); - let model = models.get(dm).expect("model should exist"); + let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist"); assert_eq!( model.info.base_url, "https://enterprise-proxy.acme.com/v1", "base_url must inherit from [endpoints], not stale default" ); assert_eq!(model.api_key.as_deref(), Some("acme-api-key")); - assert_eq!( - model.api_base_url.as_deref(), - Some("https://enterprise-api.acme.com/v1"), - ); let sampling = resolve_sampling(model, Some("session-token")); assert_eq!( sampling.api_key.as_deref(), @@ -6855,22 +7053,20 @@ reasoning_effort = "low" r#" [endpoints] cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1" - xai_api_base_url = "https://enterprise-api.acme.com/v1" "#, None, ); - let model = models - .get(crate::models::default_model()) - .expect("model should exist"); + let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist"); assert_eq!( model.info.base_url, "https://enterprise-proxy.acme.com/v1", "default model should use enterprise cli_chat_proxy_base_url" ); - assert_eq!( - model.api_base_url.as_deref(), - Some("https://enterprise-api.acme.com/v1"), - "default model should use enterprise xai_api_base_url" - ); + // The open-platform fallback entries keep their fixed moonshot bases; + // only the subscription entry follows the proxy override. + let moonshot = models + .get("moonshot-cn/kimi-k2-turbo-preview") + .expect("bundled moonshot entry should exist"); + assert_eq!(moonshot.info.base_url, "https://api.moonshot.cn/v1"); } /// Unset every env var that `EndpointsConfig::default()` reads for endpoints, /// so the cli-chat-proxy resolver tests below are deterministic regardless of @@ -9496,6 +9692,7 @@ default = "grok-4.5" reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -9512,7 +9709,6 @@ default = "grok-4.5" } #[test] fn global_extra_headers_apply_to_model_without_override() { - let dm = crate::models::default_model(); let (_, models) = resolve_models_from_toml( r#" [models] @@ -9520,7 +9716,9 @@ default = "grok-4.5" "#, None, ); - let model = models.get(dm).expect("default model should exist"); + let model = models + .get(BUNDLED_DEFAULT_KEY) + .expect("default model should exist"); assert_eq!( model .info @@ -9771,11 +9969,11 @@ default = "grok-4.5" fn resolve_model_list_inherits_context_window_from_default_when_prefetched_has_fallback() { let cfg = Config::default(); let default_cw = DEFAULT_CONTEXT_WINDOW; - let entry = prefetch_model_entry("grok-build", default_cw, ApiBackend::default()); + let entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, default_cw, ApiBackend::default()); let mut prefetched = IndexMap::new(); - prefetched.insert("grok-build".to_owned(), entry); + prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry); let resolved = resolve_model_list(&cfg, Some(prefetched)); - let entry = resolved.get("grok-build").expect("model must exist"); + let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist"); assert_ne!( entry.info.context_window.get(), default_cw, @@ -9786,11 +9984,11 @@ default = "grok-4.5" fn resolve_model_list_does_not_override_explicitly_set_context_window() { let cfg = Config::default(); let explicit_cw = 65_536; - let entry = prefetch_model_entry("grok-build", explicit_cw, ApiBackend::default()); + let entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, explicit_cw, ApiBackend::default()); let mut prefetched = IndexMap::new(); - prefetched.insert("grok-build".to_owned(), entry); + prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry); let resolved = resolve_model_list(&cfg, Some(prefetched)); - let entry = resolved.get("grok-build").expect("model must exist"); + let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist"); assert_eq!( entry.info.context_window.get(), explicit_cw, @@ -9847,21 +10045,21 @@ default = "grok-4.5" let cfg = Config::default(); let mut defs = default_model_entries(&EndpointsConfig::default()); let mut p = IndexMap::new(); - if let Some(e) = defs.shift_remove("grok-build") { - p.insert("grok-build".to_string(), e); + if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) { + p.insert(BUNDLED_DEFAULT_KEY.to_string(), e); } let resolved = resolve_model_list(&cfg, Some(p)); - assert!(resolved.contains_key("grok-build")); + assert!(resolved.contains_key(BUNDLED_DEFAULT_KEY)); let no_p = resolve_model_list(&cfg, None); - assert!(no_p.contains_key("grok-build")); + assert!(no_p.contains_key(BUNDLED_DEFAULT_KEY)); } #[test] fn resolve_model_list_prefetch_visibility_matches_auth_and_server_list() { let cfg = Config::default(); let mut defs = default_model_entries(&EndpointsConfig::default()); let mut p = IndexMap::new(); - if let Some(e) = defs.shift_remove("grok-build") { - p.insert("grok-build".to_string(), e); + if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) { + p.insert(BUNDLED_DEFAULT_KEY.to_string(), e); } let resolved = resolve_model_list(&cfg, Some(p)); let sess: Vec<_> = resolved @@ -9873,7 +10071,10 @@ default = "grok-4.5" .filter(|e| e.visible_for_auth(false)) .collect(); assert_eq!(sess.len(), 1); - assert!(api.is_empty()); + assert!( + api.is_empty(), + "the subscription entry (supported_in_api=false) must stay hidden from API-key users" + ); } #[test] fn resolve_model_list_keeps_prefetch_only_entries_and_prunes_defaults() { @@ -9883,17 +10084,17 @@ default = "grok-4.5" p.insert("secret-xyz".to_string(), e); let resolved = resolve_model_list(&cfg, Some(p)); assert!(resolved.contains_key("secret-xyz")); - assert!(!resolved.contains_key("grok-build")); + assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY)); } #[test] fn resolve_model_list_prefetch_replaces_bundled_entirely() { let cfg = Config::default(); let mut p = IndexMap::new(); - let e = prefetch_model_entry("grok-4.5", 500_000, ApiBackend::Responses); - p.insert("grok-4.5".to_string(), e); + let e = prefetch_model_entry("kimi-fresh", 500_000, ApiBackend::Responses); + p.insert("kimi-fresh".to_string(), e); let resolved = resolve_model_list(&cfg, Some(p)); - assert!(resolved.contains_key("grok-4.5")); - assert!(!resolved.contains_key("grok-build")); + assert!(resolved.contains_key("kimi-fresh")); + assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY)); } #[test] fn resolve_model_list_empty_prefetch_yields_empty_base() { @@ -9901,15 +10102,16 @@ default = "grok-4.5" let resolved = resolve_model_list(&cfg, Some(IndexMap::new())); assert!(resolved.is_empty()); } - /// Regression: enterprise managed config aliases grok-build to their own - /// endpoint with env_key. The bundled grok-build has supported_in_api=false. - /// The config overlay must be visible to API-key users (env_key = BYOK). + /// Regression: enterprise managed config aliases the bundled subscription + /// entry to their own endpoint with env_key. The bundled entry has + /// supported_in_api=false. The config overlay must be visible to API-key + /// users (env_key = BYOK). #[test] fn byok_config_overlay_visible_to_api_key_users() { let raw: toml::Value = toml::from_str( r#" - [model.grok-build] - model = "grok-4.5" + [model."kimi-code/kimi-for-coding"] + model = "kimi-for-coding" base_url = "https://inference.company.com/v1" env_key = "COMPANY_TOKEN" "#, @@ -9917,7 +10119,9 @@ default = "grok-4.5" .unwrap(); let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse"); let resolved = resolve_model_list(&cfg, None); - let entry = resolved.get("grok-build").expect("grok-build must exist"); + let entry = resolved + .get(BUNDLED_DEFAULT_KEY) + .expect("bundled default must exist"); assert!( entry.visible_for_auth(false), "BYOK config entry must be visible to API-key users — \ @@ -9930,19 +10134,83 @@ default = "grok-4.5" fn plain_config_overlay_preserves_bundled_visibility() { let raw: toml::Value = toml::from_str( r#" - [model.grok-build] + [model."kimi-code/kimi-for-coding"] context_window = 300000 "#, ) .unwrap(); let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse"); let resolved = resolve_model_list(&cfg, None); - let entry = resolved.get("grok-build").expect("grok-build must exist"); + let entry = resolved + .get(BUNDLED_DEFAULT_KEY) + .expect("bundled default must exist"); assert!( !entry.visible_for_auth(false), "non-BYOK config overlay must preserve bundled supported_in_api=false" ); } + /// PRD F2: a `[platforms.].api_key` from config.toml is stamped onto + /// that platform's catalog entries (in-memory only), making them usable + /// and API-key-visible — and only onto that platform. + #[test] + #[serial] + fn platforms_config_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 raw: toml::Value = toml::from_str( + r#" + [platforms.moonshot-cn] + api_key = "sk-from-config" + "#, + ) + .unwrap(); + let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse"); + let resolved = resolve_model_list(&cfg, None); + + 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-config")); + assert!( + cn.has_own_credentials(), + "config key must make the entry sampleable (F2 acceptance)" + ); + assert!( + cn.visible_for_auth(false), + "credentialed open-platform entry must be visible to API-key users" + ); + + 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" + ); + + let code = resolved + .get("kimi-code/kimi-for-coding") + .expect("bundled subscription entry"); + assert!( + code.api_key.is_none() && code.env_key.is_none(), + "the OAuth platform takes no API key" + ); + } + /// F2 acceptance: with ONLY a moonshot key (env), the api-key auth method + /// is advertised (no login screen) because the catalog has a credentialed + /// entry. + #[test] + #[serial] + fn moonshot_env_key_advertises_api_key_auth_method() { + let _gen = EnvGuard::set(kigi_models::MOONSHOT_API_KEY_ENV, "sk-only-moonshot"); + let cfg = Config::default(); + let models = resolve_model_list(&cfg, None); + assert!( + crate::agent::auth_method::should_advertise_xai_api_key(models.values()), + "a moonshot env key alone must advertise the API-key auth method" + ); + } #[test] #[serial] fn mcp_liveness_watchers_default_is_true() { diff --git a/crates/codegen/kigi-shell/src/agent/config_model_override_parse.rs b/crates/codegen/kigi-shell/src/agent/config_model_override_parse.rs index 506d1bf..6b3a4d3 100644 --- a/crates/codegen/kigi-shell/src/agent/config_model_override_parse.rs +++ b/crates/codegen/kigi-shell/src/agent/config_model_override_parse.rs @@ -531,6 +531,7 @@ mod tests { description: Some("Deep reasoning".to_string()), default: true, }], + capabilities: vec![kigi_models::ModelCapability::Thinking], supports_backend_search: Some(false), compactions_remaining: Some(CompactionsRemaining::Fixed(1)), compaction_at_tokens: Some(CompactionAtTokens::Fixed(100_000)), diff --git a/crates/codegen/kigi-shell/src/agent/models.rs b/crates/codegen/kigi-shell/src/agent/models.rs index c9da5be..e35b6a6 100644 --- a/crates/codegen/kigi-shell/src/agent/models.rs +++ b/crates/codegen/kigi-shell/src/agent/models.rs @@ -18,40 +18,32 @@ use kigi_sampling_types::{ReasoningEffort, ReasoningEffortOption}; // ── Auth method for model fetching ────────────────────────────────────────── -/// Credential for `/v1/models` fetching. +/// How the model catalog is fetched (PRD F4). The old xAI tier-gated proxy +/// fetch is gone; there are exactly two shapes now. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ModelFetchAuth { - Session, - ApiKey, - Deployment, + /// Fixed platform registry: `kimi-code` via the F1 OAuth bearer plus the + /// Moonshot open platforms via configured API keys. + Platforms, + /// `KIGI_MODELS_BASE_URL` / `models_list_url` BYOK escape hatch: a single + /// OpenAI-compatible listing. CustomEndpoint, } impl ModelFetchAuth { - /// custom_endpoint > session > deployment > API key. - /// - /// A `deployment_key` outranks an ambient `XAI_API_KEY` so a stray env key - /// can't redirect model fetching from the deployment's entitlement-gated - /// proxy to a raw `/v1/models` endpoint that lists the full model registry. - pub(crate) fn resolve(endpoints: &config::EndpointsConfig, has_cached_session: bool) -> Self { + /// Custom endpoint when configured, else the platform registry. + pub(crate) fn resolve(endpoints: &config::EndpointsConfig) -> Self { if endpoints.has_custom_endpoint() { Self::CustomEndpoint - } else if has_cached_session { - Self::Session - } else if endpoints.deployment_key.is_some() { - Self::Deployment - } else if crate::agent::auth_method::has_xai_api_key_env() { - Self::ApiKey } else { - Self::Session + Self::Platforms } } fn cache_auth_method(&self) -> CacheAuthMethod { match self { - Self::CustomEndpoint | Self::ApiKey => CacheAuthMethod::ApiKey, - Self::Session => CacheAuthMethod::Session, - Self::Deployment => CacheAuthMethod::Deployment, + Self::CustomEndpoint => CacheAuthMethod::ApiKey, + Self::Platforms => CacheAuthMethod::Platforms, } } } @@ -59,9 +51,77 @@ impl ModelFetchAuth { #[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq, Clone, Debug)] #[serde(rename_all = "snake_case")] enum CacheAuthMethod { - Session, ApiKey, - Deployment, + Platforms, +} + +/// Resolved open-platform API keys (PRD F2): platform-scoped env > +/// generic `KIGI_MOONSHOT_API_KEY` env > `[platforms.*]` config. +/// +/// SECURITY: values are secrets — the manual `Debug` impl prints presence +/// only, and nothing here may be logged or persisted. +#[derive(Clone, Default)] +pub(crate) struct PlatformApiKeys { + moonshot_cn: Option, + moonshot_ai: Option, +} + +impl std::fmt::Debug for PlatformApiKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformApiKeys") + .field("moonshot_cn", &self.moonshot_cn.is_some()) + .field("moonshot_ai", &self.moonshot_ai.is_some()) + .finish() + } +} + +impl PlatformApiKeys { + pub(crate) fn resolve(platforms: &config::PlatformsConfig) -> Self { + Self { + moonshot_cn: config::resolve_platform_api_key( + kigi_models::PlatformId::MoonshotCn, + platforms, + ), + moonshot_ai: config::resolve_platform_api_key( + kigi_models::PlatformId::MoonshotAi, + platforms, + ), + } + } + + /// Resolve from the effective on-disk config (startup paths that have no + /// parsed `Config` yet). + pub(crate) fn resolve_from_effective_config() -> Self { + let platforms = crate::config::load_effective_config() + .ok() + .and_then(|raw| raw.get("platforms").cloned()) + .and_then(|v| v.try_into::().ok()) + .unwrap_or_default(); + Self::resolve(&platforms) + } + + pub(crate) fn key_for(&self, platform: kigi_models::PlatformId) -> Option<&str> { + match platform { + kigi_models::PlatformId::KimiCode => None, + kigi_models::PlatformId::MoonshotCn => self.moonshot_cn.as_deref(), + kigi_models::PlatformId::MoonshotAi => self.moonshot_ai.as_deref(), + } + } + + /// Any open-platform key configured? Drives "should we prefetch without a + /// session" and the F2 acceptance path (moonshot key only, no login). + pub(crate) fn any(&self) -> bool { + self.moonshot_cn.is_some() || self.moonshot_ai.is_some() + } + + /// Test-only constructor (fields are private to this module). + #[cfg(test)] + pub(crate) fn test_keys(cn: Option<&str>, ai: Option<&str>) -> Self { + Self { + moonshot_cn: cn.map(str::to_owned), + moonshot_ai: ai.map(str::to_owned), + } + } } pub(crate) fn task_model_error_for_catalog( @@ -170,8 +230,7 @@ impl ModelsManager { auth_manager: Arc, cfg: config::Config, ) -> Self { - let has_session = auth_manager.current_or_expired().is_some(); - let fetch_auth = ModelFetchAuth::resolve(&cfg.endpoints, has_session); + let fetch_auth = ModelFetchAuth::resolve(&cfg.endpoints); let current_reasoning_effort = cfg.models.default_reasoning_effort; Self { inner: Arc::new(Inner { @@ -224,13 +283,19 @@ impl ModelsManager { let is_session_auth = auth_manager .current_or_expired() .is_some_and(|a| a.is_session_auth()); - let fetch_auth = ModelFetchAuth::resolve(&cfg.endpoints, has_session); + let fetch_auth = ModelFetchAuth::resolve(&cfg.endpoints); let prefetched_models = prefetched_models.or_else(|| { let cache = ModelsCacheManager::new(); + let platform_keys = PlatformApiKeys::resolve(&cfg.platforms); cache .load_fresh( &fetch_auth.cache_auth_method(), - &crate::remote::models_list_url(&cfg.endpoints, fetch_auth), + &crate::remote::models_fetch_origin( + &cfg.endpoints, + fetch_auth, + has_session, + &platform_keys, + ), ) .map(|c| c.models) }); @@ -298,9 +363,7 @@ impl ModelsManager { ) }; let new_preferred = new_config.models.default.clone(); - let has_session = self.inner.auth_manager.current_or_expired().is_some(); - *self.inner.fetch_auth.write() = - ModelFetchAuth::resolve(&new_config.endpoints, has_session); + *self.inner.fetch_auth.write() = ModelFetchAuth::resolve(&new_config.endpoints); *self.inner.cfg.write() = new_config.clone(); // Recompute the prompt-block flag so a corrective reload unblocks. if has_real_catalog { @@ -436,6 +499,23 @@ impl ModelsManager { self.inner.models.write().insert(id.into(), entry); } + /// Kimi capability set for `model_id` from the live catalog (PRD F4). + /// Empty when the model is unknown or declared no capabilities. + pub fn model_capabilities(&self, model_id: &str) -> Vec { + let models = self.inner.models.read(); + resolve_catalog_key(&models, &acp::ModelId::new(model_id)) + .and_then(|key| models.get(key.0.as_ref())) + .map(|e| e.info().capabilities.clone()) + .unwrap_or_default() + } + + /// PRD F4: thinking defaults ON iff the model's capabilities include + /// `thinking` or `always_thinking`. This is the F3 seam for the sampler's + /// thinking toggle; unknown models default OFF. + pub fn model_default_thinking(&self, model_id: &str) -> bool { + kigi_models::default_thinking_enabled(&self.model_capabilities(model_id)) + } + pub fn current_reasoning_effort(&self) -> Option { *self.inner.current_reasoning_effort.read() } @@ -592,11 +672,13 @@ impl ModelsManager { pub async fn on_auth_changed(&self) { let config = self.inner.cfg.read().clone(); self.inner.cache.invalidate(); - let has_session = self.inner.auth_manager.current_or_expired().is_some(); - let fetch_auth = ModelFetchAuth::resolve(&config.endpoints, has_session); + let fetch_auth = ModelFetchAuth::resolve(&config.endpoints); *self.inner.fetch_auth.write() = fetch_auth; + // With no session, no open-platform key, and no custom endpoint there + // is nothing to fetch from: wipe the previous identity's catalog. if self.inner.auth_manager.current_or_expired().is_none() - && fetch_auth == ModelFetchAuth::Session + && fetch_auth == ModelFetchAuth::Platforms + && !PlatformApiKeys::resolve(&config.platforms).any() { self.clear(); return; @@ -964,9 +1046,14 @@ impl ModelsManager { /// Disk-cache origin key for this manager's current endpoints/auth shape /// (see [`ModelsCache::origin`]). fn cache_origin(&self) -> String { - let endpoints = self.inner.cfg.read().endpoints.clone(); + let (endpoints, platforms) = { + let cfg = self.inner.cfg.read(); + (cfg.endpoints.clone(), cfg.platforms.clone()) + }; let fetch_auth = *self.inner.fetch_auth.read(); - crate::remote::models_list_url(&endpoints, fetch_auth) + let has_oauth = self.inner.auth_manager.current_or_expired().is_some(); + let platform_keys = PlatformApiKeys::resolve(&platforms); + crate::remote::models_fetch_origin(&endpoints, fetch_auth, has_oauth, &platform_keys) } fn try_load_cache(&self) -> bool { @@ -993,14 +1080,10 @@ impl ModelsManager { return; } let cfg = self.inner.cfg.read().clone(); - let endpoints = cfg.endpoints.clone(); - let fetch_auth = *self.inner.fetch_auth.read(); - let auth_manager = self.inner.auth_manager.clone(); let mgr = self.clone(); tokio::task::spawn(async move { - let auth = auth_manager.auth().await.ok(); - let new_prefetched = fetch_models_async(endpoints, auth, fetch_auth).await; + let new_prefetched = mgr.fetch_catalog_with_oauth_retry(&cfg).await; if !mgr.apply_refresh_result(&cfg, new_prefetched, new_etag) { return; } @@ -1009,6 +1092,42 @@ impl ModelsManager { }); } + /// Fetch the catalog; on an OAuth-platform 401, force a token refresh via + /// the 401-recovery state machine and retry ONCE with the rotated bearer + /// (port of kimi-cli `refresh_managed_models`' 401 retry). + async fn fetch_catalog_with_oauth_retry( + &self, + cfg: &config::Config, + ) -> Option> { + let endpoints = cfg.endpoints.clone(); + let fetch_auth = *self.inner.fetch_auth.read(); + let platform_keys = PlatformApiKeys::resolve(&cfg.platforms); + let auth = self.inner.auth_manager.auth().await.ok(); + let outcome = + fetch_models_async(endpoints.clone(), auth, fetch_auth, platform_keys.clone()).await; + if outcome.models.is_some() { + return outcome.models; + } + if !outcome.oauth_unauthorized { + return None; + } + kigi_log::unified_log::warn( + "model catalog: OAuth platform returned 401; forcing token refresh and retrying once", + None, + None, + ); + if !self.inner.auth_manager.try_recover_unauthorized().await { + tracing::warn!("model catalog: token refresh after 401 failed; giving up"); + return None; + } + let auth = self.inner.auth_manager.auth().await.ok(); + let retry = fetch_models_async(endpoints, auth, fetch_auth, platform_keys).await; + if retry.oauth_unauthorized { + tracing::warn!("model catalog: still unauthorized after token refresh"); + } + retry.models + } + /// Fetch models, rebuild state, and notify clients. fn do_refresh(&self, new_etag: Option, strategy: RefreshStrategy) { match strategy { @@ -1061,8 +1180,7 @@ impl ModelsManager { tracing::info!("model catalog refresh skipped: remote_fetch disabled"); return; } - let auth = self.inner.auth_manager.auth().await.ok(); - let has_auth = auth.is_some(); + let has_auth = self.inner.auth_manager.current_or_expired().is_some(); let fetch_auth = *self.inner.fetch_auth.read(); let cfg = self.inner.cfg.read().clone(); kigi_log::unified_log::info( @@ -1073,7 +1191,7 @@ impl ModelsManager { "fetch_auth": format!("{fetch_auth:?}"), })), ); - let new_prefetched = fetch_models_async(cfg.endpoints.clone(), auth, fetch_auth).await; + let new_prefetched = self.fetch_catalog_with_oauth_retry(&cfg).await; let success = self.apply_refresh_result(&cfg, new_prefetched, None); if success { kigi_log::unified_log::info( @@ -1245,8 +1363,14 @@ struct ModelsCacheManager { impl ModelsCacheManager { fn new() -> Self { + // `KIGI_MODELS_CACHE_DIR` re-homes the cache file; primarily a seam + // for tests (the unit-test process shares one `kigi_home()` OnceLock) + // and for e2e runs that must not touch the real profile. + let dir = std::env::var("KIGI_MODELS_CACHE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| crate::util::kigi_home::kigi_home()); Self { - path: crate::util::kigi_home::kigi_home().join(MODELS_CACHE_FILE), + path: dir.join(MODELS_CACHE_FILE), ttl: CACHE_TTL, } } @@ -1258,6 +1382,40 @@ impl ModelsCacheManager { expected_auth: &CacheAuthMethod, expected_origin: &str, ) -> Option { + let cache = self.load_matching(expected_auth, expected_origin)?; + if !cache.is_fresh(self.ttl) { + tracing::debug!("models cache is stale"); + return None; + } + tracing::debug!(count = cache.models.len(), "loaded models from disk cache"); + Some(CacheResult { + models: cache.models, + etag: cache.etag, + }) + } + + /// Last-resort cache read after a FAILED sync (PRD F4: "sync failure → + /// use last cache"): same version/auth/origin guards as [`Self::load_fresh`] + /// but ignores the TTL — a stale catalog from the same fetch plan beats + /// the bundled offline table. + fn load_ignoring_ttl( + &self, + expected_auth: &CacheAuthMethod, + expected_origin: &str, + ) -> Option { + let cache = self.load_matching(expected_auth, expected_origin)?; + Some(CacheResult { + models: cache.models, + etag: cache.etag, + }) + } + + /// Shared read + version/auth/origin guards (no TTL check). + fn load_matching( + &self, + expected_auth: &CacheAuthMethod, + expected_origin: &str, + ) -> Option { let data = std::fs::read(&self.path).ok()?; let cache: ModelsCache = serde_json::from_slice(&data).ok()?; if cache.grok_version.as_deref() != Some(kigi_version::VERSION) { @@ -1276,15 +1434,7 @@ impl ModelsCacheManager { ); return None; } - if !cache.is_fresh(self.ttl) { - tracing::debug!("models cache is stale"); - return None; - } - tracing::debug!(count = cache.models.len(), "loaded models from disk cache"); - Some(CacheResult { - models: cache.models, - etag: cache.etag, - }) + Some(cache) } /// Sync; see `load_fresh` note. @@ -1372,13 +1522,10 @@ impl ModelsCacheManager { /// Build the prefetched model map from a flat list of entries. /// /// Each entry is keyed by its `id` field (falling back to the `model` slug -/// when `id` is absent). This lets A/B experiments that share the same -/// routing slug (e.g. "Auto" and "Grok Build" both route to `grok-build`) -/// coexist in the catalog without collision. -fn build_prefetched_map( - models: Vec, - api_base_url_override: Option, -) -> IndexMap { +/// when `id` is absent). Platform-registry entries carry +/// `{platform_id}/{model_id}` ids (PRD F4 managed keys), so the same bare +/// model id can coexist for several platforms without collision. +fn build_prefetched_map(models: Vec) -> IndexMap { let mut map: IndexMap = IndexMap::with_capacity(models.len()); for m in models { let key = m.id.clone().unwrap_or_else(|| m.model.clone()); @@ -1386,26 +1533,46 @@ fn build_prefetched_map( let entry = ModelEntry { info, api_key: None, - env_key: None, - api_base_url: m.api_base_url.clone().or(api_base_url_override.clone()), + // Env-var NAMES only (open-platform key lookup); never values. + env_key: m.env_key.clone(), + api_base_url: m.api_base_url.clone(), }; map.insert(key, entry); } map } +/// Outcome of a gated catalog fetch. `oauth_unauthorized` survives total +/// failure so the async layer can force a token refresh and retry. +pub(crate) struct ModelsFetchOutcome { + pub models: Option>, + pub oauth_unauthorized: bool, +} + +impl ModelsFetchOutcome { + fn failed(oauth_unauthorized: bool) -> Self { + Self { + models: None, + oauth_unauthorized, + } + } +} + /// Fetch remote models. Checks disk cache first; persists after fetch. pub(crate) fn prefetch_models_blocking( endpoints: &config::EndpointsConfig, auth: Option<&KimiAuth>, fetch_auth: ModelFetchAuth, + platform_keys: &PlatformApiKeys, ) -> Option> { prefetch_models_blocking_gated( endpoints, auth, fetch_auth, + platform_keys, crate::util::config::resolve_remote_fetch_enabled(), ) + .models } /// Blocking models + `/v1/settings` prefetch pair, shared by the early @@ -1416,13 +1583,21 @@ pub(crate) fn prefetch_models_and_settings_blocking( endpoints: &config::EndpointsConfig, auth: Option<&KimiAuth>, fetch_auth: ModelFetchAuth, + platform_keys: &PlatformApiKeys, ) -> ( Option>, Option, ) { let remote_fetch_enabled = crate::util::config::resolve_remote_fetch_enabled(); - let models = prefetch_models_blocking_gated(endpoints, auth, fetch_auth, remote_fetch_enabled); - // Settings need a grok.com session; skip for BYOK. + let models = prefetch_models_blocking_gated( + endpoints, + auth, + fetch_auth, + platform_keys, + remote_fetch_enabled, + ) + .models; + // Settings need a subscription session; skip for API-key-only setups. let settings = match auth { Some(auth) if remote_fetch_enabled => { let _timer = crate::instrumentation_timer!("startup.early_settings_fetch"); @@ -1443,14 +1618,24 @@ fn prefetch_models_blocking_gated( endpoints: &config::EndpointsConfig, auth: Option<&KimiAuth>, fetch_auth: ModelFetchAuth, + platform_keys: &PlatformApiKeys, remote_fetch_enabled: bool, -) -> Option> { +) -> ModelsFetchOutcome { let cache_auth = fetch_auth.cache_auth_method(); - // Same URL the fetch below will hit — the cache is only valid for it. - let cache_origin = crate::remote::models_list_url(endpoints, fetch_auth); + // Same fetch plan the network path below executes — the cache is only + // valid for it. + let cache_origin = + crate::remote::models_fetch_origin(endpoints, fetch_auth, auth.is_some(), platform_keys); let cache = ModelsCacheManager::new(); if let Some(cached) = cache.load_fresh(&cache_auth, &cache_origin) { - return Some(cached.models); + tracing::info!( + count = cached.models.len(), + "model sync: serving fresh disk cache" + ); + return ModelsFetchOutcome { + models: Some(cached.models), + oauth_unauthorized: false, + }; } // Every catalog fetch in the product funnels through here, so this single @@ -1458,37 +1643,66 @@ fn prefetch_models_blocking_gated( // (leader, headless, stdio, server). Cache above is local and stays usable. if !remote_fetch_enabled { tracing::info!("models fetch skipped: remote_fetch disabled"); - return None; + return ModelsFetchOutcome::failed(false); } let _timer = crate::instrumentation_timer!("startup.fetch_models_blocking"); - match fetch_models_blocking(endpoints, auth, fetch_auth) { - Ok(FetchModelsResult { models, etag }) if !models.is_empty() => { - let api_base_url_override = match fetch_auth { - ModelFetchAuth::ApiKey => Some(endpoints.xai_api_base_url.clone()), - _ => None, - }; - let map = build_prefetched_map(models, api_base_url_override); + match fetch_models_blocking(endpoints, auth, fetch_auth, platform_keys) { + Ok(FetchModelsResult { + models, + etag, + oauth_unauthorized, + }) if !models.is_empty() => { + let map = build_prefetched_map(models); // NOTE: inheriting context_window / agent_type / api_backend // from hardcoded defaults is handled centrally in // `resolve_model_list` (config.rs), not here. Don't re-add it. - tracing::info!(count = map.len(), etag = ?etag, "Prefetched models"); + tracing::info!(count = map.len(), etag = ?etag, "model sync: fetched catalog"); cache.persist(&map, etag.as_deref(), cache_auth, &cache_origin); - Some(map) + ModelsFetchOutcome { + models: Some(map), + oauth_unauthorized, + } } - Ok(FetchModelsResult { .. }) => { - tracing::warn!("Models endpoint returned empty list"); - None + Ok(FetchModelsResult { + oauth_unauthorized, .. + }) => { + tracing::warn!(oauth_unauthorized, "model sync: no models fetched"); + stale_cache_or_failure(&cache, &cache_auth, &cache_origin, oauth_unauthorized) } Err(e) => { - tracing::warn!("Failed to fetch models: {:?}", e); - None + tracing::warn!("model sync failed: {e:?}"); + stale_cache_or_failure(&cache, &cache_auth, &cache_origin, false) } } } +/// PRD F4 failure ladder: sync failed → last (possibly stale) cache for the +/// same fetch plan; no usable cache → the caller falls back to the bundled +/// offline table. `oauth_unauthorized` is preserved either way so the async +/// 401 refresh-retry still fires (a stale cache must not mask a dead token). +fn stale_cache_or_failure( + cache: &ModelsCacheManager, + cache_auth: &CacheAuthMethod, + cache_origin: &str, + oauth_unauthorized: bool, +) -> ModelsFetchOutcome { + if let Some(cached) = cache.load_ignoring_ttl(cache_auth, cache_origin) { + tracing::warn!( + count = cached.models.len(), + "model sync failed; serving last cached catalog (may be stale)" + ); + return ModelsFetchOutcome { + models: Some(cached.models), + oauth_unauthorized, + }; + } + tracing::warn!("model sync failed and no usable cache; falling back to bundled catalog"); + ModelsFetchOutcome::failed(oauth_unauthorized) +} + /// Startup prefetch result: models + remote settings. pub struct EarlyPrefetchResult { pub models: Option>, @@ -1502,6 +1716,7 @@ struct PrefetchEnv { auth: Option, endpoints: config::EndpointsConfig, model_fetch_auth: ModelFetchAuth, + platform_keys: PlatformApiKeys, } fn resolve_prefetch_env_with_auth(auth: Option) -> Option { @@ -1516,6 +1731,7 @@ fn resolve_prefetch_env_with_auth(auth: Option) -> Option resolve_prefetch_env_from_parts( auth, endpoints, + PlatformApiKeys::resolve_from_effective_config(), crate::util::config::resolve_remote_fetch_enabled(), ) } @@ -1525,12 +1741,16 @@ fn resolve_prefetch_env_with_auth(auth: Option) -> Option /// /// `remote_fetch_enabled = false` wins over every credential shape AND over /// `has_custom_endpoint()` (which otherwise forces the prefetch to run): the -/// explicit off switch must hold even when a stray login, `XAI_API_KEY`, or -/// `deployment_key` would re-arm the prefetch — and with it the `/v1/settings` -/// fetch and the deployment-config sync on the prefetch thread. +/// explicit off switch must hold even when a stray login, a platform API key, +/// or a `deployment_key` would re-arm the prefetch — and with it the +/// `/v1/settings` fetch and the deployment-config sync on the prefetch thread. +/// +/// PRD F2 acceptance: a moonshot API key alone (no subscription login) must +/// arm the prefetch so the catalog syncs on startup. fn resolve_prefetch_env_from_parts( auth: Option, endpoints: config::EndpointsConfig, + platform_keys: PlatformApiKeys, remote_fetch_enabled: bool, ) -> Option { if !remote_fetch_enabled { @@ -1538,12 +1758,9 @@ fn resolve_prefetch_env_from_parts( return None; } - let model_fetch_auth = ModelFetchAuth::resolve(&endpoints, auth.is_some()); + let model_fetch_auth = ModelFetchAuth::resolve(&endpoints); - if auth.is_none() - && !endpoints.has_custom_endpoint() - && model_fetch_auth == ModelFetchAuth::Session - { + if auth.is_none() && !endpoints.has_custom_endpoint() && !platform_keys.any() { return None; } @@ -1551,6 +1768,7 @@ fn resolve_prefetch_env_from_parts( auth, endpoints, model_fetch_auth, + platform_keys, }) } @@ -1591,6 +1809,7 @@ fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle { &env.endpoints, env.auth.as_ref(), env.model_fetch_auth, + &env.platform_keys, ); if (env.endpoints.deployment_key.is_some() || crate::managed_config::has_active_team_auth()) && crate::config::is_managed_config_stale_for( @@ -1968,17 +2187,28 @@ pub(crate) fn validate_selectable( Ok(()) } -/// Async wrapper around `prefetch_models_blocking`. +/// Async wrapper around the gated blocking fetch. Keeps the +/// `oauth_unauthorized` signal so callers can drive the 401 refresh-retry. pub(crate) async fn fetch_models_async( endpoints: config::EndpointsConfig, auth: Option, fetch_auth: ModelFetchAuth, -) -> Option> { + platform_keys: PlatformApiKeys, +) -> ModelsFetchOutcome { tokio::task::spawn_blocking(move || { - prefetch_models_blocking(&endpoints, auth.as_ref(), fetch_auth) + prefetch_models_blocking_gated( + &endpoints, + auth.as_ref(), + fetch_auth, + &platform_keys, + crate::util::config::resolve_remote_fetch_enabled(), + ) }) .await - .unwrap_or(None) + .unwrap_or_else(|e| { + tracing::warn!("model fetch task panicked/cancelled: {e}"); + ModelsFetchOutcome::failed(false) + }) } #[cfg(test)] @@ -2891,10 +3121,10 @@ mod tests { let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let current = mgr.inner.fetch_auth.read().cache_auth_method(); - let other = if current == CacheAuthMethod::Session { + let other = if current == CacheAuthMethod::Platforms { CacheAuthMethod::ApiKey } else { - CacheAuthMethod::Session + CacheAuthMethod::Platforms }; cache.persist( &make_prefetched(&["grok-other-auth"]), @@ -3112,113 +3342,119 @@ mod tests { ); } - // ── ModelFetchAuth::resolve priority tests ────────────────────── + // ── ModelFetchAuth::resolve + PlatformApiKeys tests ───────────── use kigi_test_support::EnvGuard; use serial_test::serial; + fn keys(cn: Option<&str>, ai: Option<&str>) -> PlatformApiKeys { + PlatformApiKeys { + moonshot_cn: cn.map(str::to_owned), + moonshot_ai: ai.map(str::to_owned), + } + } + #[test] - #[serial] - fn resolve_custom_endpoint_always_wins() { - let _key = EnvGuard::set("XAI_API_KEY", "test-key"); + fn resolve_custom_endpoint_wins_over_platforms() { let endpoints = config::EndpointsConfig { models_base_url: Some("https://custom.example.com".to_owned()), ..config::EndpointsConfig::default() }; assert_eq!( - ModelFetchAuth::resolve(&endpoints, true), + ModelFetchAuth::resolve(&endpoints), ModelFetchAuth::CustomEndpoint, ); assert_eq!( - ModelFetchAuth::resolve(&endpoints, false), - ModelFetchAuth::CustomEndpoint, + ModelFetchAuth::resolve(&config::EndpointsConfig::default()), + ModelFetchAuth::Platforms, ); } + /// Platform API keys resolve env > config, platform-scoped > generic, and + /// values never come from unknown `[platforms.*]` tables. #[test] #[serial] - fn resolve_cached_session_wins_over_api_key() { - let _key = EnvGuard::set("XAI_API_KEY", "test-key"); - let endpoints = config::EndpointsConfig::default(); - assert_eq!( - ModelFetchAuth::resolve(&endpoints, true), - ModelFetchAuth::Session, - "cached session should take priority over API key", + fn platform_api_keys_env_beats_config_and_generic_fallback_applies() { + let mut platforms = config::PlatformsConfig::default(); + platforms.entries.insert( + "moonshot-cn".into(), + config::PlatformCredentialConfig { + api_key: Some("cfg-cn".into()), + }, ); - } - - #[test] - #[serial] - fn resolve_api_key_used_when_no_session() { - let _key = EnvGuard::set("XAI_API_KEY", "test-key"); - let endpoints = config::EndpointsConfig::default(); - assert_eq!( - ModelFetchAuth::resolve(&endpoints, false), - ModelFetchAuth::ApiKey, - "API key should be used when no cached session exists", + platforms.entries.insert( + "moonshot-ai".into(), + config::PlatformCredentialConfig { + api_key: Some("cfg-ai".into()), + }, ); - } - #[test] - #[serial] - fn resolve_falls_back_to_session_when_nothing_set() { - let _unset = EnvGuard::unset("XAI_API_KEY"); - let _unset_legacy = EnvGuard::unset("KIGI_CODE_XAI_API_KEY"); - let endpoints = config::EndpointsConfig::default(); - assert_eq!( - ModelFetchAuth::resolve(&endpoints, false), - ModelFetchAuth::Session, - "should fall back to Session when nothing else is configured", - ); - } - - #[test] - #[serial] - fn resolve_deployment_key_when_no_session_or_api_key() { - let _unset = EnvGuard::unset("XAI_API_KEY"); - let _unset_legacy = EnvGuard::unset("KIGI_CODE_XAI_API_KEY"); - let endpoints = config::EndpointsConfig { - deployment_key: Some("deploy-key".to_owned()), - ..config::EndpointsConfig::default() + // Injected env: scoped name set for cn only, generic set for both. + let getenv = |name: &str| match name { + "KIGI_MOONSHOT_CN_API_KEY" => Some("env-cn".to_string()), + "KIGI_MOONSHOT_API_KEY" => Some("env-generic".to_string()), + _ => None, }; assert_eq!( - ModelFetchAuth::resolve(&endpoints, false), - ModelFetchAuth::Deployment, + config::resolve_platform_api_key_with( + kigi_models::PlatformId::MoonshotCn, + &platforms, + getenv, + ) + .as_deref(), + Some("env-cn"), + "platform-scoped env must win over generic env and config", + ); + assert_eq!( + config::resolve_platform_api_key_with( + kigi_models::PlatformId::MoonshotAi, + &platforms, + getenv, + ) + .as_deref(), + Some("env-generic"), + "generic env must win over config when scoped env is unset", + ); + // No env at all → config file key. + assert_eq!( + config::resolve_platform_api_key_with( + kigi_models::PlatformId::MoonshotAi, + &platforms, + |_| None, + ) + .as_deref(), + Some("cfg-ai"), + ); + // The OAuth platform never resolves an API key. + assert_eq!( + config::resolve_platform_api_key_with( + kigi_models::PlatformId::KimiCode, + &platforms, + getenv, + ), + None, ); } - /// `deployment_key` outranks a stray `XAI_API_KEY`, but session wins over both. + /// The `Debug` impl for [`PlatformApiKeys`] must print presence only. #[test] - #[serial] - fn resolve_deployment_key_outranks_ambient_api_key() { - let _key = EnvGuard::set("XAI_API_KEY", "stray-env-key"); - let endpoints = config::EndpointsConfig { - deployment_key: Some("deploy-key".to_owned()), - ..config::EndpointsConfig::default() - }; - assert_eq!( - ModelFetchAuth::resolve(&endpoints, false), - ModelFetchAuth::Deployment, - "managed deployment_key should outrank an ambient XAI_API_KEY", - ); - assert_eq!( - ModelFetchAuth::resolve(&endpoints, true), - ModelFetchAuth::Session, - "an active session should still win over a managed deployment", + fn platform_api_keys_debug_never_leaks_values() { + let dbg = format!("{:?}", keys(Some("sk-super-secret"), None)); + assert!( + !dbg.contains("sk-super-secret"), + "debug leaked a key: {dbg}" ); + assert!(dbg.contains("true") && dbg.contains("false")); } // ── remote_fetch gate: resolve_prefetch_env_from_parts ─────────── /// remote_fetch=false must return `None` against every re-arming shape at - /// once — session auth, ambient `XAI_API_KEY`, `deployment_key`, AND a - /// custom models endpoint (which normally forces the prefetch to run). + /// once — session auth, a moonshot platform key, AND a custom models + /// endpoint (which normally forces the prefetch to run). #[test] - #[serial] fn prefetch_env_none_when_remote_fetch_disabled_despite_credentials() { - let _key = EnvGuard::set("XAI_API_KEY", "stray-env-key"); let endpoints = config::EndpointsConfig { - deployment_key: Some("deploy-key".to_owned()), models_base_url: Some("https://custom.example.com".to_owned()), ..config::EndpointsConfig::default() }; @@ -3226,32 +3462,42 @@ mod tests { resolve_prefetch_env_from_parts( Some(KimiAuth::test_default()), endpoints.clone(), + keys(Some("sk-cn"), None), false, ) .is_none(), "session auth must not re-arm the prefetch when remote_fetch is off", ); assert!( - resolve_prefetch_env_from_parts(None, endpoints, false).is_none(), - "API key / deployment key / custom endpoint must not re-arm it either", + resolve_prefetch_env_from_parts(None, endpoints, keys(Some("sk-cn"), None), false) + .is_none(), + "platform key / custom endpoint must not re-arm it either", ); } - /// Inverse sanity: with remote_fetch enabled the same credential shapes DO - /// arm the prefetch, and the credential-less default still doesn't. + /// Inverse sanity: with remote_fetch enabled a moonshot key alone (the F2 + /// acceptance shape: no subscription login) DOES arm the prefetch, and the + /// credential-less default still doesn't. #[test] - #[serial] fn prefetch_env_resolves_when_remote_fetch_enabled() { - let _unset = EnvGuard::unset("XAI_API_KEY"); - let _unset_legacy = EnvGuard::unset("KIGI_CODE_XAI_API_KEY"); - let endpoints = config::EndpointsConfig { - deployment_key: Some("deploy-key".to_owned()), - ..config::EndpointsConfig::default() - }; - assert!(resolve_prefetch_env_from_parts(None, endpoints, true).is_some()); + let env = resolve_prefetch_env_from_parts( + None, + config::EndpointsConfig::default(), + keys(None, Some("sk-ai")), + true, + ); assert!( - resolve_prefetch_env_from_parts(None, config::EndpointsConfig::default(), true) - .is_none(), + env.is_some(), + "a moonshot API key alone must arm the startup model sync (PRD F2)", + ); + assert!( + resolve_prefetch_env_from_parts( + None, + config::EndpointsConfig::default(), + PlatformApiKeys::default(), + true, + ) + .is_none(), "no credentials and no custom endpoint must stay a no-prefetch launch", ); } @@ -3383,6 +3629,7 @@ mod tests { reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, @@ -3405,7 +3652,7 @@ mod tests { Some("Grok Fast"), ), ]; - let map = build_prefetched_map(entries, None); + let map = build_prefetched_map(entries); assert_eq!(map.len(), 3, "all three entries should survive"); assert!(map.contains_key("auto")); @@ -3425,7 +3672,7 @@ mod tests { make_entry_config("model-a", Some("Model A")), make_entry_config("model-b", Some("Model B")), ]; - let map = build_prefetched_map(entries, None); + let map = build_prefetched_map(entries); assert_eq!(map.len(), 2); assert!(map.contains_key("model-a")); @@ -3439,7 +3686,7 @@ mod tests { make_entry_config_with_id(Some("grok-build"), "grok-build", Some("First")), make_entry_config_with_id(Some("grok-build"), "grok-build", Some("Second")), ]; - let map = build_prefetched_map(entries, None); + let map = build_prefetched_map(entries); assert_eq!(map.len(), 1, "duplicate id: second overwrites first"); assert_eq!(map["grok-build"].info.name.as_deref(), Some("Second")); @@ -3472,7 +3719,7 @@ mod tests { "grok-build", Some("Grok Build"), )]; - let map = build_prefetched_map(entries, None); + let map = build_prefetched_map(entries); assert_eq!(map.len(), 1); assert!(map.contains_key("grok-build")); @@ -3597,4 +3844,395 @@ mod tests { }) .collect() } + + // ── PRD F2/F4 wiremock suite ───────────────────────────────────── + + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn f4_listing() -> serde_json::Value { + serde_json::json!({ + "data": [ + { + "id": "kimi-for-coding", + "context_length": 262144, + "supports_reasoning": true, + "supports_image_in": true, + "supports_video_in": false, + "display_name": "k2.6-code-preview" + }, + { "id": "kimi-latest", "context_length": 131072 } + ] + }) + } + + fn proxied_endpoints(server_uri: &str) -> config::EndpointsConfig { + config::EndpointsConfig { + cli_chat_proxy_base_url: Some(server_uri.to_string()), + models_base_url: None, + models_list_url: None, + ..config::EndpointsConfig::default() + } + } + + /// Happy path: `GET {base}/models` with the OAuth bearer, F4 wire shape + /// → managed `{platform_id}/{model_id}` keys, display_name → name, + /// context_length → context_window, derived capabilities, etag captured, + /// and NO credential material on the raw entries (cache safety). + #[tokio::test] + async fn wiremock_platforms_fetch_happy_path_maps_f4_contract() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/models")) + .and(header("Authorization", "Bearer oauth-token")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("etag", "\"e1\"") + .set_body_json(f4_listing()), + ) + .expect(1) + .mount(&server) + .await; + + let endpoints = proxied_endpoints(&server.uri()); + let auth = KimiAuth { + key: "oauth-token".into(), + ..KimiAuth::test_default() + }; + let result = tokio::task::spawn_blocking(move || { + crate::remote::fetch_models_blocking( + &endpoints, + Some(&auth), + ModelFetchAuth::Platforms, + &PlatformApiKeys::default(), + ) + }) + .await + .unwrap() + .expect("fetch should succeed"); + + assert!(!result.oauth_unauthorized); + assert_eq!(result.etag.as_deref(), Some("\"e1\"")); + let map = build_prefetched_map(result.models); + assert_eq!( + map.keys().collect::>(), + vec!["kimi-code/kimi-for-coding", "kimi-code/kimi-latest"], + "entries must be keyed {{platform_id}}/{{model_id}} in server order" + ); + let entry = map.get("kimi-code/kimi-for-coding").unwrap(); + assert_eq!(entry.info.model, "kimi-for-coding"); + assert_eq!(entry.info.name.as_deref(), Some("k2.6-code-preview")); + assert_eq!(entry.info.context_window.get(), 262_144); + assert_eq!( + entry.info.capabilities, + vec![ + kigi_models::ModelCapability::Thinking, + kigi_models::ModelCapability::ImageIn + ], + "capabilities must derive from the wire flags" + ); + assert!( + !entry.info.supported_in_api, + "subscription models are OAuth-only" + ); + assert!( + entry.api_key.is_none() && entry.env_key.is_none(), + "no credential material on OAuth-platform entries" + ); + // Missing display_name falls back to the id; missing flags default off. + let second = map.get("kimi-code/kimi-latest").unwrap(); + assert_eq!(second.info.name.as_deref(), Some("kimi-latest")); + assert!(second.info.capabilities.is_empty()); + assert_eq!(second.info.context_window.get(), 131_072); + } + + /// Moonshot open-platform fetch: `kimi-k` prefix filter applied, entries + /// carry env-var NAMES (never key values), and route at the platform base. + #[tokio::test] + #[serial] + async fn wiremock_moonshot_fetch_filters_prefix_and_stamps_env_key_names() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/models")) + .and(header("Authorization", "Bearer sk-cn-secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [ + { "id": "kimi-k2-turbo-preview", "context_length": 262144 }, + { "id": "moonshot-v1-8k", "context_length": 8192 } + ] + }))) + .expect(1) + .mount(&server) + .await; + let _base = EnvGuard::set(kigi_models::MOONSHOT_CN_BASE_URL_ENV, server.uri()); + + let endpoints = config::EndpointsConfig::default(); + let keys = PlatformApiKeys::test_keys(Some("sk-cn-secret"), None); + let result = tokio::task::spawn_blocking(move || { + crate::remote::fetch_models_blocking(&endpoints, None, ModelFetchAuth::Platforms, &keys) + }) + .await + .unwrap() + .expect("moonshot-only fetch should succeed without any OAuth session"); + + let map = build_prefetched_map(result.models); + assert_eq!( + map.keys().collect::>(), + vec!["moonshot-cn/kimi-k2-turbo-preview"], + "non-kimi-k ids must be filtered out" + ); + let entry = map.get("moonshot-cn/kimi-k2-turbo-preview").unwrap(); + assert_eq!(entry.info.base_url, server.uri()); + assert!( + entry.api_key.is_none(), + "fetched entries must never embed key values (they are persisted to disk)" + ); + assert_eq!( + entry.env_key.as_ref().map(|k| k.names()), + Some(vec!["KIGI_MOONSHOT_CN_API_KEY", "KIGI_MOONSHOT_API_KEY"]), + "entries carry the env-key NAMES for request-time resolution" + ); + // kimi-k2 prefix rule. + assert_eq!( + entry.info.capabilities, + vec![ + kigi_models::ModelCapability::Thinking, + kigi_models::ModelCapability::ImageIn, + kigi_models::ModelCapability::VideoIn + ] + ); + assert!(entry.info.supported_in_api); + } + + /// Port of kimi-cli `refresh_managed_models`' 401 handling: an OAuth 401 + /// forces one token refresh and one retry with the rotated bearer. + #[tokio::test] + #[serial] + async fn wiremock_oauth_401_forces_refresh_and_retries_once() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_test_writer() + .try_init(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/models")) + .and(header("Authorization", "Bearer stale-token")) + .respond_with(ResponseTemplate::new(401)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/models")) + .and(header("Authorization", "Bearer fresh-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(f4_listing())) + .expect(1) + .mount(&server) + .await; + + let cache_dir = tempfile::TempDir::new().unwrap(); + let _cache = EnvGuard::set("KIGI_MODELS_CACHE_DIR", cache_dir.path().to_str().unwrap()); + + struct SwapRefresher; + #[async_trait::async_trait] + impl crate::auth::refresh::TokenRefresher for SwapRefresher { + async fn refresh( + &self, + _r: crate::auth::manager::RefreshReason, + ) -> crate::auth::refresh::RefreshOutcome { + crate::auth::refresh::RefreshOutcome::Success(Box::new(KimiAuth { + key: "fresh-token".into(), + refresh_token: Some("rt-2".into()), + expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ..KimiAuth::test_default() + })) + } + } + + let auth_dir = tempfile::TempDir::new().unwrap(); + let auth_manager = Arc::new(AuthManager::new(auth_dir.path(), KimiCodeConfig::default())); + auth_manager.hot_swap(KimiAuth { + key: "stale-token".into(), + refresh_token: Some("rt-1".into()), + // Minted long ago: the recovery state machine skips the refresh + // for freshly-minted tokens (refresh-storm grace). + create_time: chrono::Utc::now() - chrono::Duration::hours(2), + expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ..KimiAuth::test_default() + }); + auth_manager.set_refresher(Arc::new(SwapRefresher)); + + let mut cfg = config::Config::default(); + cfg.endpoints.cli_chat_proxy_base_url = Some(server.uri()); + let mgr = ModelsManager::new( + None, + IndexMap::new(), + acp::ModelId::new("default"), + auth_manager.clone(), + cfg.clone(), + ); + + let models = mgr + .fetch_catalog_with_oauth_retry(&cfg) + .await + .expect("401 must trigger refresh + one retry with the rotated bearer"); + assert!(models.contains_key("kimi-code/kimi-for-coding")); + assert_eq!( + auth_manager.current().expect("refreshed").key, + "fresh-token", + "the 401 recovery must have rotated the bearer" + ); + assert_eq!( + server.received_requests().await.unwrap().len(), + 2, + "exactly one retry after the refresh" + ); + } + + /// PRD F4 failure ladder: sync failure → last cache (even stale, same + /// fetch plan only); no cache → the bundled offline table; and a FRESH + /// cache short-circuits the network entirely. + #[test] + #[serial] + fn sync_failure_uses_last_cache_then_bundled_table() { + let cache_dir = tempfile::TempDir::new().unwrap(); + let _cache = EnvGuard::set("KIGI_MODELS_CACHE_DIR", cache_dir.path().to_str().unwrap()); + // Unroutable server: every fetch fails fast with connection refused. + let endpoints = proxied_endpoints("http://127.0.0.1:9"); + let keys = PlatformApiKeys::default(); + let auth = KimiAuth { + key: "tok".into(), + ..KimiAuth::test_default() + }; + + // 1. No cache at all → fetch failure → no models: callers resolve the + // bundled offline table. + let outcome = prefetch_models_blocking_gated( + &endpoints, + Some(&auth), + ModelFetchAuth::Platforms, + &keys, + true, + ); + assert!(outcome.models.is_none(), "no cache and no network → None"); + let bundled = resolve_model_catalog(&config::Config::default(), None); + assert!(bundled.contains_key("kimi-code/kimi-for-coding")); + assert!(bundled.contains_key("moonshot-cn/kimi-k2-thinking-turbo")); + assert!(bundled.contains_key("moonshot-ai/kimi-k2-turbo-preview")); + + // 2. A STALE cache for the same fetch plan is served on sync failure. + let origin = + crate::remote::models_fetch_origin(&endpoints, ModelFetchAuth::Platforms, true, &keys); + let cache = ModelsCacheManager::new(); + let stale = ModelsCache { + fetched_at: Utc::now() - ChronoDuration::seconds(86_400), + grok_version: Some(kigi_version::VERSION.to_string()), + auth_method: Some(CacheAuthMethod::Platforms), + origin: Some(origin), + etag: None, + models: make_prefetched(&["kimi-code/cached-model"]), + }; + cache.atomic_write(&stale); + let outcome = prefetch_models_blocking_gated( + &endpoints, + Some(&auth), + ModelFetchAuth::Platforms, + &keys, + true, + ); + let models = outcome + .models + .expect("stale cache must beat the bundled table on sync failure"); + assert!(models.contains_key("kimi-code/cached-model")); + + // 3. A FRESH cache short-circuits the network (offline continuity). + let fresh = ModelsCache { + fetched_at: Utc::now(), + ..stale + }; + cache.atomic_write(&fresh); + let outcome = prefetch_models_blocking_gated( + &endpoints, + Some(&auth), + ModelFetchAuth::Platforms, + &keys, + true, + ); + assert!( + outcome + .models + .expect("fresh cache must serve without network") + .contains_key("kimi-code/cached-model") + ); + } + + /// PRD F4 default-thinking seam: capabilities from the catalog drive the + /// thinking default (thinking/always_thinking → on; else off). + #[test] + fn model_default_thinking_follows_capabilities() { + let mgr = test_manager(); + let mut thinking = ModelEntry { + info: config::ModelInfo::fallback("kimi-thinking-x"), + api_key: None, + env_key: None, + api_base_url: None, + }; + thinking.info.capabilities = vec![kigi_models::ModelCapability::AlwaysThinking]; + mgr.insert_test_entry("kimi-code/kimi-thinking-x", thinking); + let mut plain = ModelEntry { + info: config::ModelInfo::fallback("kimi-plain-x"), + api_key: None, + env_key: None, + api_base_url: None, + }; + plain.info.capabilities = vec![kigi_models::ModelCapability::ImageIn]; + mgr.insert_test_entry("kimi-code/kimi-plain-x", plain); + + assert!(mgr.model_default_thinking("kimi-code/kimi-thinking-x")); + // Routing-slug lookup resolves to the catalog key too. + assert!(mgr.model_default_thinking("kimi-thinking-x")); + assert!(!mgr.model_default_thinking("kimi-code/kimi-plain-x")); + assert!(!mgr.model_default_thinking("unknown-model")); + } + + /// A cache written for a DIFFERENT fetch plan (different platform set) + /// must not be served — not even as the stale last resort. + #[test] + #[serial] + fn stale_cache_from_other_fetch_plan_is_not_served() { + let cache_dir = tempfile::TempDir::new().unwrap(); + let _cache = EnvGuard::set("KIGI_MODELS_CACHE_DIR", cache_dir.path().to_str().unwrap()); + let endpoints = proxied_endpoints("http://127.0.0.1:9"); + // Cache written when a moonshot key was ALSO configured... + let with_key_origin = crate::remote::models_fetch_origin( + &endpoints, + ModelFetchAuth::Platforms, + true, + &PlatformApiKeys::test_keys(Some("sk"), None), + ); + let cache = ModelsCacheManager::new(); + cache.atomic_write(&ModelsCache { + fetched_at: Utc::now() - ChronoDuration::seconds(86_400), + grok_version: Some(kigi_version::VERSION.to_string()), + auth_method: Some(CacheAuthMethod::Platforms), + origin: Some(with_key_origin), + etag: None, + models: make_prefetched(&["moonshot-cn/poisoned"]), + }); + // ... must be a miss for an OAuth-only plan. + let auth = KimiAuth { + key: "tok".into(), + ..KimiAuth::test_default() + }; + let outcome = prefetch_models_blocking_gated( + &endpoints, + Some(&auth), + ModelFetchAuth::Platforms, + &PlatformApiKeys::default(), + true, + ); + assert!( + outcome.models.is_none(), + "an origin-mismatched cache must never be adopted" + ); + } } diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs index a3d2404..e7bbb1c 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs @@ -1368,12 +1368,20 @@ impl acp::Agent for MvpAgent { .take(10).collect::< Vec < _ >> (), "load_session: restoring persisted model (debug)" ); - let is_grok_build = persisted_model.0.starts_with("grok-build"); - let same_family_fallback = if is_grok_build { - available.keys().find(|id| id.0.starts_with("grok-build")).cloned() - } else { - available.keys().find(|id| !id.0.starts_with("grok-build")).cloned() - }; + // "Same family" = same platform: catalog keys are + // `{platform_id}/{model_id}` (PRD F4), so prefer a replacement from + // the platform the persisted model belonged to (its credentials are + // known-good) before falling back across platforms. + let persisted_platform = + kigi_models::parse_managed_model_key(persisted_model.0.as_ref()).map(|(p, _)| p); + let same_family_fallback = available + .keys() + .find(|id| { + kigi_models::parse_managed_model_key(id.0.as_ref()).map(|(p, _)| p) + == persisted_platform + }) + .cloned() + .or_else(|| available.keys().next().cloned()); let selectable_catalog_key = selectable_catalog_key_for_persisted( &models, &available, diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/tests.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/tests.rs index 0ac7e34..23bc959 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/tests.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/tests.rs @@ -1675,6 +1675,7 @@ fn find_model_by_id_prefers_key_then_falls_back_to_slug() { reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, diff --git a/crates/codegen/kigi-shell/src/agent/server.rs b/crates/codegen/kigi-shell/src/agent/server.rs index 5a24177..7b0738d 100644 --- a/crates/codegen/kigi-shell/src/agent/server.rs +++ b/crates/codegen/kigi-shell/src/agent/server.rs @@ -166,13 +166,19 @@ async fn handle_connection(ws: WebSocket, state: Arc, peer_addr: So .spawn(move || { // Prefetch models before creating the runtime (blocking is OK here) let auth = agent_config.create_auth_manager().current(); - let fetch_auth = - ModelFetchAuth::resolve(&agent_config.endpoints, auth.is_some()); + let fetch_auth = ModelFetchAuth::resolve(&agent_config.endpoints); + let platform_keys = + crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms); let prefetched_models = if auth.is_some() || agent_config.endpoints.has_custom_endpoint() - || fetch_auth != ModelFetchAuth::Session + || platform_keys.any() { - prefetch_models_blocking(&agent_config.endpoints, auth.as_ref(), fetch_auth) + prefetch_models_blocking( + &agent_config.endpoints, + auth.as_ref(), + fetch_auth, + &platform_keys, + ) } else { None }; diff --git a/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs b/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs index 16aebf5..5ba5190 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs @@ -3076,6 +3076,7 @@ fn test_model_entry(model_id: &str) -> crate::agent::config::ModelEntry { reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), + capabilities: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, diff --git a/crates/codegen/kigi-shell/src/bin/trace_classify.rs b/crates/codegen/kigi-shell/src/bin/trace_classify.rs index d863b77..1f39423 100644 --- a/crates/codegen/kigi-shell/src/bin/trace_classify.rs +++ b/crates/codegen/kigi-shell/src/bin/trace_classify.rs @@ -6,7 +6,7 @@ //! cargo run --bin trace_classify -- \ //! --trace /path/to/trace--all-turns.json \ //! [--output out.jsonl] \ -//! [--model grok-4.5] \ +//! [--model kimi-for-coding] \ //! [--api-base-url https://api.x.ai/v1] \ //! [--api-key | $XAI_API_KEY | /auth.json] \ //! [--min-confidence 0.7] \ @@ -44,7 +44,7 @@ struct Cli { /// Model the classifier sampler calls. Must be a model the API key /// has access to. - #[arg(long, default_value = "grok-4.5")] + #[arg(long, default_value = "kimi-for-coding")] model: String, /// Sampler base URL. diff --git a/crates/codegen/kigi-shell/src/config/mod.rs b/crates/codegen/kigi-shell/src/config/mod.rs index 06ada56..8c31f45 100644 --- a/crates/codegen/kigi-shell/src/config/mod.rs +++ b/crates/codegen/kigi-shell/src/config/mod.rs @@ -537,7 +537,7 @@ pub struct ModelOverrideConfig { pub web_search: String, /// `None` = current model. pub session_summary: Option, - /// Compiled default (`grok-build`) when unset locally, remotely, and via env. + /// Compiled default (`kigi_models::default_model()`) when unset locally, remotely, and via env. pub image_description: Option, /// Next-prompt suggestion model pin. Unlike the other overrides this does /// NOT fill a compiled default — see [`PromptSuggestModelPin`]. @@ -561,10 +561,9 @@ impl Default for ModelOverrideConfig { /// Unlike the other auxiliary overrides this does not collapse to a plain /// model string: the consumer (`handle_suggest_prompt`) must distinguish /// an explicit pin from "unpinned" (where the client hint and the built-in -/// `grok-build-0.1` default apply), and whether the pin came from the env +/// default apply), and whether the pin came from the env /// escape hatch. Every effective model except an env pin is catalog-guarded — -/// when the model is not in the shell's catalog (e.g. `grok-build-0.1` for -/// OAuth users, whose catalogs exclude it) the per-turn suggestion request is +/// when the model is not in the shell's catalog the per-turn suggestion request is /// skipped entirely rather than fired doomed. The env pin is deliberately /// exempt so `KIGI_PROMPT_SUGGESTIONS_MODEL` keeps working for models a /// catalog does not list (mirrors the pager, which forwards the env value @@ -596,7 +595,7 @@ fn non_empty_model_override(value: Option<&str>) -> Option { impl ModelOverrideConfig { /// CLI flag > env var > config.toml > remote settings > compiled default. /// `image_description` and `session_summary` always resolve to `Some(_)` - /// (default `grok-build`), never the session model. + /// (the bundled default model), never the session model. /// `prompt_suggestion` resolves to a [`PromptSuggestModelPin`] instead of /// a model string (no CLI flag; the default and the catalog guard live at /// the consumer, `handle_suggest_prompt`). diff --git a/crates/codegen/kigi-shell/src/extensions/suggest/mod.rs b/crates/codegen/kigi-shell/src/extensions/suggest/mod.rs index 6401927..9185b6b 100644 --- a/crates/codegen/kigi-shell/src/extensions/suggest/mod.rs +++ b/crates/codegen/kigi-shell/src/extensions/suggest/mod.rs @@ -193,10 +193,10 @@ struct SuggestPromptRequest { #[serde(default)] session_id: Option, /// Client hint for the suggestion model (the pager sends its env - /// override, or `grok-build-0.1` when its catalog offers it). One tier + /// override, or the bundled default when its catalog offers it). One tier /// of the shell-side resolution in /// `prompt_suggest::effective_suggest_model`: env > config.toml > remote - /// > this hint > `grok-build-0.1` default, catalog-guarded (a + /// > this hint > bundled default, catalog-guarded (a /// non-sampleable effective model skips the request; the session model /// is never used). #[serde(default)] diff --git a/crates/codegen/kigi-shell/src/remote/client.rs b/crates/codegen/kigi-shell/src/remote/client.rs index d9be243..06514a4 100644 --- a/crates/codegen/kigi-shell/src/remote/client.rs +++ b/crates/codegen/kigi-shell/src/remote/client.rs @@ -655,97 +655,115 @@ pub(crate) const DEFAULT_CONTEXT_WINDOW: u64 = 256_000; struct ModelsResponse { data: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EndpointAuth { - ApiKey, - Session, -} -struct ListModelsEndpoint { - url: String, - auth: EndpointAuth, -} -/// The `/v1/models` URL [`fetch_models_blocking`] hits for this -/// endpoints/auth shape. Doubles as the models disk-cache origin key: cached -/// entries embed absolute `base_url`s from the backend that served them, so a -/// catalog fetched from one backend (env override, another deployment, a -/// test's mock server) must be a cache miss for any other backend. -pub(crate) fn models_list_url( +/// The models-fetch origin key for this endpoints/auth shape. Used as the +/// models disk-cache origin: cached entries embed absolute `base_url`s from +/// the backend(s) that served them, so a catalog fetched against one fetch +/// plan (env override, different set of platform credentials, a test's mock +/// server) must be a cache miss for any other. Encodes URLs and enabled +/// platform NAMES only — never credential values. +pub(crate) fn models_fetch_origin( endpoints: &crate::agent::config::EndpointsConfig, fetch_auth: crate::agent::models::ModelFetchAuth, + has_oauth: bool, + platform_keys: &crate::agent::models::PlatformApiKeys, ) -> String { - ListModelsEndpoint::from_endpoints(endpoints, fetch_auth).url -} -impl ListModelsEndpoint { - fn from_endpoints( - endpoints: &crate::agent::config::EndpointsConfig, - fetch_auth: crate::agent::models::ModelFetchAuth, - ) -> Self { - if endpoints.has_custom_endpoint() { - Self { - url: endpoints.resolve_models_list_url(), - auth: EndpointAuth::ApiKey, - } - } else if fetch_auth == crate::agent::models::ModelFetchAuth::ApiKey { - Self { - url: format!("{}/models", endpoints.xai_api_base_url), - auth: EndpointAuth::ApiKey, - } - } else { - Self { - url: endpoints.resolve_models_list_url(), - auth: EndpointAuth::Session, - } + match fetch_auth { + crate::agent::models::ModelFetchAuth::CustomEndpoint => endpoints.resolve_models_list_url(), + crate::agent::models::ModelFetchAuth::Platforms => { + let parts: Vec = enabled_platforms(has_oauth, platform_keys) + .into_iter() + .map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints))) + .collect(); + format!("platforms[{}]", parts.join(";")) } } } -/// Fetch models from an OpenAI-compatible `/v1/models` endpoint. -/// Fetch result: model entries + optional etag from response. +/// The platforms with usable credentials, in registry order (kimi-code first +/// so "default model = first list item" favors the subscription). +fn enabled_platforms( + has_oauth: bool, + platform_keys: &crate::agent::models::PlatformApiKeys, +) -> Vec { + kigi_models::PlatformId::ALL + .into_iter() + .filter(|p| { + if p.uses_oauth() { + has_oauth + } else { + platform_keys.key_for(*p).is_some() + } + }) + .collect() +} +/// `{base}/models` for one platform. The subscription platform resolves its +/// base through the endpoints config (`cli_chat_proxy_base_url` override, +/// else `KIGI_CODE_BASE_URL` / production default via kigi-env); the open +/// platforms use their fixed bases. +fn platform_models_url( + platform: kigi_models::PlatformId, + endpoints: &crate::agent::config::EndpointsConfig, +) -> String { + let base = if platform.uses_oauth() { + endpoints.proxy_url() + } else { + platform.base_url() + }; + format!("{}/models", base.trim_end_matches('/')) +} +/// Fetch result: model entries + optional etag from the subscription platform. pub struct FetchModelsResult { pub models: Vec, pub etag: Option, + /// The OAuth platform answered 401. The async layer forces a token + /// refresh and retries once (port of kimi-cli `refresh_managed_models`). + pub oauth_unauthorized: bool, } +/// Fetch the model catalog (PRD F4). +/// +/// - Custom endpoint mode (`KIGI_MODELS_BASE_URL` / `models_list_url`): a +/// single OpenAI-compatible listing fetched with the BYOK key or session +/// bearer, parsed leniently ([`parse_remote_model_value`]). +/// - Otherwise, the fixed platform registry: `GET {base}/models` with +/// `Authorization: Bearer ` per enabled platform, +/// parsed per the F4 wire contract with capability derivation and the +/// `kimi-k` prefix filter for the open platforms. +/// +/// Succeeds when at least one platform delivers; per-platform failures are +/// logged (status codes only, never credentials). pub(crate) fn fetch_models_blocking( endpoints: &crate::agent::config::EndpointsConfig, auth: Option<&KimiAuth>, fetch_auth: crate::agent::models::ModelFetchAuth, + platform_keys: &crate::agent::models::PlatformApiKeys, ) -> Result { - let client = crate::http::shared_blocking_client(); - let source = ListModelsEndpoint::from_endpoints(endpoints, fetch_auth); - let inference_base_url = endpoints.resolve_inference_base_url(); - tracing::info!("Fetching models from {}", source.url); - let mut request = client.get(&source.url); - match source.auth { - EndpointAuth::ApiKey => { - let api_key = crate::agent::auth_method::read_xai_api_key_env() - .or_else(|_| { - auth.map(|a| a.key.clone()) - .ok_or(std::env::VarError::NotPresent) - }) - .map_err(|_| { - BackendError::Auth( - "No API key for custom models endpoint. Set XAI_API_KEY.".into(), - ) - })?; - request = request.header("Authorization", format!("Bearer {}", api_key)); + match fetch_auth { + crate::agent::models::ModelFetchAuth::CustomEndpoint => { + fetch_custom_endpoint_models_blocking(endpoints, auth) } - EndpointAuth::Session => { - let auth = auth.ok_or_else(|| { - BackendError::Auth("No auth credentials for cli-chat-proxy".into()) - })?; - request = request - .header("Authorization", format!("Bearer {}", auth.key)) - .header("X-XAI-Token-Auth", "xai-grok-cli") - .header("x-userid", &auth.user_id) - .header("x-grok-client-version", kigi_version::VERSION) - .header( - crate::http::CLIENT_MODE_HEADER, - crate::http::process_client_mode(), - ); - if let Some(email) = &auth.email { - request = request.header("x-email", email); - } + crate::agent::models::ModelFetchAuth::Platforms => { + fetch_platform_models_blocking(endpoints, auth, platform_keys) } } +} +fn fetch_custom_endpoint_models_blocking( + endpoints: &crate::agent::config::EndpointsConfig, + auth: Option<&KimiAuth>, +) -> Result { + let client = crate::http::shared_blocking_client(); + let url = endpoints.resolve_models_list_url(); + let inference_base_url = endpoints.resolve_inference_base_url(); + tracing::info!("Fetching models from custom endpoint {}", url); + let api_key = crate::agent::auth_method::read_xai_api_key_env() + .or_else(|_| { + auth.map(|a| a.key.clone()) + .ok_or(std::env::VarError::NotPresent) + }) + .map_err(|_| { + BackendError::Auth("No API key for custom models endpoint. Set XAI_API_KEY.".into()) + })?; + let request = client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)); let response = request.send()?; if !response.status().is_success() { let status = response.status().as_u16(); @@ -759,11 +777,7 @@ pub(crate) fn fetch_models_blocking( .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let models_response: ModelsResponse = response.json()?; - tracing::info!( - "Fetched {} models from {}", - models_response.data.len(), - source.url - ); + tracing::info!("Fetched {} models from {}", models_response.data.len(), url); let mut models = Vec::with_capacity(models_response.data.len()); for (idx, value) in models_response.data.into_iter().enumerate() { match parse_remote_model_value(&value, &inference_base_url) { @@ -776,7 +790,201 @@ pub(crate) fn fetch_models_blocking( } } } - Ok(FetchModelsResult { models, etag }) + Ok(FetchModelsResult { + models, + etag, + oauth_unauthorized: false, + }) +} +/// Registry fetch across all platforms with usable credentials. +fn fetch_platform_models_blocking( + endpoints: &crate::agent::config::EndpointsConfig, + auth: Option<&KimiAuth>, + platform_keys: &crate::agent::models::PlatformApiKeys, +) -> Result { + let enabled = enabled_platforms(auth.is_some(), platform_keys); + if enabled.is_empty() { + return Err(BackendError::Auth( + "No platform credentials: log in with `kigi login` or configure a moonshot API key \ + (KIGI_MOONSHOT_API_KEY or [platforms.*] in ~/.kigi/config.toml)." + .into(), + )); + } + + let mut models = Vec::new(); + let mut etag = None; + let mut oauth_unauthorized = false; + let mut successes = 0usize; + let mut last_error: Option = None; + for platform in &enabled { + let bearer = if platform.uses_oauth() { + auth.map(|a| a.key.clone()) + .expect("enabled_platforms gated on auth presence") + } else { + platform_keys + .key_for(*platform) + .expect("enabled_platforms gated on key presence") + .to_owned() + }; + match fetch_one_platform_models(*platform, endpoints, &bearer) { + Ok((platform_models, platform_etag)) => { + tracing::info!( + platform = platform.as_str(), + count = platform_models.len(), + "platform models fetch succeeded" + ); + successes += 1; + if platform.uses_oauth() { + etag = platform_etag; + } + models.extend(platform_models); + } + Err(e) => { + if platform.uses_oauth() + && matches!(&e, BackendError::RequestFailed { status: 401, .. }) + { + oauth_unauthorized = true; + } + tracing::warn!( + platform = platform.as_str(), + error = %e, + "platform models fetch failed" + ); + last_error = Some(e); + } + } + } + + if successes == 0 { + // All enabled platforms failed. When the failure includes an OAuth + // 401, return `Ok` with the flag set (and no models) so the async + // layer can force a token refresh and retry — an `Err` would drop + // the signal. Non-401 failures propagate as the last error. + if oauth_unauthorized { + return Ok(FetchModelsResult { + models: Vec::new(), + etag: None, + oauth_unauthorized: true, + }); + } + return Err(last_error.unwrap_or_else(|| { + BackendError::Auth("no platform models fetch was attempted".into()) + })); + } + Ok(FetchModelsResult { + models, + etag, + oauth_unauthorized, + }) +} +/// `GET {base}/models` for one platform (PRD F4 wire contract): +/// `Authorization: Bearer ` → `{data:[{id, context_length, +/// supports_reasoning, supports_image_in, supports_video_in, display_name?}]}`. +/// Applies the platform's `kimi-k` prefix filter and capability derivation, +/// and keys each entry `{platform_id}/{model_id}`. +fn fetch_one_platform_models( + platform: kigi_models::PlatformId, + endpoints: &crate::agent::config::EndpointsConfig, + bearer: &str, +) -> Result<(Vec, Option), BackendError> { + let client = crate::http::shared_blocking_client(); + let url = platform_models_url(platform, endpoints); + tracing::info!(platform = platform.as_str(), url = %url, "fetching platform models"); + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer)) + .send()?; + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().unwrap_or_default(); + return Err(BackendError::RequestFailed { status, body }); + } + let etag = response + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let listing: kigi_models::WireModelsResponse = response.json()?; + let total = listing.data.len(); + let filtered = kigi_models::filter_allowed_models(platform, listing.data); + if filtered.len() != total { + tracing::info!( + platform = platform.as_str(), + total, + kept = filtered.len(), + "applied platform model-prefix filter" + ); + } + let base_url = if platform.uses_oauth() { + endpoints.proxy_url() + } else { + platform.base_url() + }; + let models = filtered + .into_iter() + .map(|wire| platform_wire_model_to_entry(platform, wire, &base_url)) + .collect(); + Ok((models, etag)) +} +/// Map one F4 wire model to a catalog entry config. +/// +/// SECURITY: the entry carries only env-var NAMES (`env_key`) for the open +/// platforms — never key values — because raw fetched entries are persisted +/// to the models disk cache. Config-file keys are stamped in-memory later by +/// `resolve_model_list`'s platform-credentials layer. +fn platform_wire_model_to_entry( + platform: kigi_models::PlatformId, + wire: kigi_models::WireModel, + base_url: &str, +) -> crate::agent::config::ModelEntryConfig { + let capabilities = wire.capabilities(); + let context_window = std::num::NonZeroU64::new(wire.context_length).unwrap_or_else(|| { + tracing::debug!( + model = %wire.id, + default = DEFAULT_CONTEXT_WINDOW, + "platform model missing context_length; using default" + ); + std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW).expect("non-zero") + }); + let env_key = (!platform.uses_oauth()) + .then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied())); + crate::agent::config::ModelEntryConfig { + id: Some(platform.managed_model_key(&wire.id)), + name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())), + model: wire.id, + base_url: base_url.to_owned(), + description: None, + max_completion_tokens: None, + temperature: None, + top_p: None, + api_key: None, + env_key, + api_backend: Default::default(), + auth_scheme: None, + reasoning_effort: None, + supports_reasoning_effort: false, + reasoning_efforts: Vec::new(), + capabilities, + extra_headers: IndexMap::new(), + context_window, + auto_compact_threshold_percent: None, + system_prompt_label: None, + api_base_url: None, + use_concise: false, + agent_type: crate::agent::config::default_agent_type(), + inference_idle_timeout_secs: None, + max_retries: None, + hidden: false, + // Subscription models require the OAuth session; open-platform + // models are usable by API-key users. + supported_in_api: !platform.uses_oauth(), + supports_backend_search: false, + compactions_remaining: None, + compaction_at_tokens: None, + show_model_fingerprint: false, + stream_tool_calls: None, + laziness_detector: Default::default(), + } } /// Parse a single model entry from the /models-v2 response. /// Used by both initial model fetch and session-resume metadata refresh. @@ -882,6 +1090,12 @@ pub fn parse_remote_model_value( .and_then(|v| v.as_array()) .map(|arr| kigi_sampling_types::parse_reasoning_effort_options(arr)) .unwrap_or_default(), + capabilities: obj + .get("capabilities") + .and_then(|v| { + serde_json::from_value::>(v.clone()).ok() + }) + .unwrap_or_default(), supports_backend_search: obj .get("supportsBackendSearch") .or_else(|| obj.get("supports_backend_search")) @@ -1749,43 +1963,81 @@ mod tests { "https://registry.acme.com/api/list-models" ); } - /// INVARIANT: the `/models` fetch URL + auth scheme match the auth mode — - /// Session/Deployment → cli-chat-proxy (Session auth), never the inference host; - /// ApiKey → `xai_api_base_url` (ApiKey, public default when unset); a custom - /// models endpoint → that URL verbatim. + /// INVARIANT: each platform's `/models` URL matches its registry base — + /// kimi-code → the subscription proxy (config override respected, else the + /// kigi-env default), moonshot platforms → their fixed bases — and the + /// cache-origin key encodes the enabled fetch plan without any secrets. #[test] #[serial_test::serial] - fn models_fetch_endpoint_matches_auth_mode() { + fn platform_models_urls_and_fetch_origin() { use crate::agent::config::EndpointsConfig; - use crate::agent::models::ModelFetchAuth; + use crate::agent::models::{ModelFetchAuth, PlatformApiKeys}; for k in [ "KIGI_CLI_CHAT_PROXY_BASE_URL", - "KIGI_XAI_API_BASE_URL", + "KIGI_CODE_BASE_URL", "KIGI_MODELS_LIST_URL", ] { unsafe { std::env::remove_var(k) }; } - let cfg = EndpointsConfig::from_config_value( + let cfg = EndpointsConfig::from_config_value(&toml::Value::Table(Default::default())); + assert_eq!( + platform_models_url(kigi_models::PlatformId::KimiCode, &cfg), + "https://api.kimi.com/coding/v1/models" + ); + assert_eq!( + platform_models_url(kigi_models::PlatformId::MoonshotCn, &cfg), + "https://api.moonshot.cn/v1/models" + ); + assert_eq!( + platform_models_url(kigi_models::PlatformId::MoonshotAi, &cfg), + "https://api.moonshot.ai/v1/models" + ); + // Proxy override re-points the subscription platform only. + let proxied = EndpointsConfig::from_config_value( &toml::from_str( r#"[endpoints] - xai_api_base_url = "https://inference.acme-corp.example/xai/v1""#, + cli_chat_proxy_base_url = "https://proxy.acme.example/v1""#, ) .unwrap(), ); - let session = ListModelsEndpoint::from_endpoints(&cfg, ModelFetchAuth::Session); - assert_eq!(session.url, "https://api.kimi.com/coding/v1/models"); - assert_eq!(session.auth, EndpointAuth::Session); - let deployment = ListModelsEndpoint::from_endpoints(&cfg, ModelFetchAuth::Deployment); - assert_eq!(deployment.url, "https://api.kimi.com/coding/v1/models"); - assert_eq!(deployment.auth, EndpointAuth::Session); - let api = ListModelsEndpoint::from_endpoints(&cfg, ModelFetchAuth::ApiKey); - assert_eq!(api.url, "https://inference.acme-corp.example/xai/v1/models"); - assert_eq!(api.auth, EndpointAuth::ApiKey); - let default = EndpointsConfig::from_config_value(&toml::Value::Table(Default::default())); assert_eq!( - ListModelsEndpoint::from_endpoints(&default, ModelFetchAuth::ApiKey).url, - "https://api.x.ai/v1/models" + platform_models_url(kigi_models::PlatformId::KimiCode, &proxied), + "https://proxy.acme.example/v1/models" ); + assert_eq!( + platform_models_url(kigi_models::PlatformId::MoonshotCn, &proxied), + "https://api.moonshot.cn/v1/models" + ); + + // Origin key: OAuth-only plan lists kimi-code only; adding a moonshot + // key changes the plan (→ cache miss); the key VALUE never appears. + let oauth_only = models_fetch_origin( + &cfg, + ModelFetchAuth::Platforms, + true, + &PlatformApiKeys::default(), + ); + assert_eq!( + oauth_only, + "platforms[kimi-code=https://api.kimi.com/coding/v1/models]" + ); + let with_cn = models_fetch_origin( + &cfg, + ModelFetchAuth::Platforms, + true, + &crate::agent::models::PlatformApiKeys::test_keys(Some("sk-secret-cn"), None), + ); + assert_ne!( + oauth_only, with_cn, + "enabling a platform must change the origin" + ); + assert!(with_cn.contains("moonshot-cn=https://api.moonshot.cn/v1/models")); + assert!( + !with_cn.contains("sk-secret-cn"), + "origin key must never embed credential values" + ); + + // Custom endpoint mode → the explicit list URL verbatim. let custom = EndpointsConfig::from_config_value( &toml::from_str( r#"[endpoints] @@ -1793,9 +2045,15 @@ mod tests { ) .unwrap(), ); - let ep = ListModelsEndpoint::from_endpoints(&custom, ModelFetchAuth::Session); - assert_eq!(ep.url, "https://models.acme.com/v1/models"); - assert_eq!(ep.auth, EndpointAuth::ApiKey); + assert_eq!( + models_fetch_origin( + &custom, + ModelFetchAuth::CustomEndpoint, + false, + &PlatformApiKeys::default(), + ), + "https://models.acme.com/v1/models" + ); } /// REGRESSION: `grok setup` must send the deployment key to /// the proxy, never the inference endpoint. diff --git a/crates/codegen/kigi-shell/src/remote/mod.rs b/crates/codegen/kigi-shell/src/remote/mod.rs index e97cda4..b46663d 100644 --- a/crates/codegen/kigi-shell/src/remote/mod.rs +++ b/crates/codegen/kigi-shell/src/remote/mod.rs @@ -27,7 +27,7 @@ pub use client::{ BackendClient, BackendError, FetchModelsResult, FetchedBundle, fetch_bundle, fetch_login_device_flow, fetch_settings_blocking, fetch_subagent_bundle, share_url, }; -pub(crate) use client::{DEFAULT_CONTEXT_WINDOW, fetch_models_blocking, models_list_url}; +pub(crate) use client::{DEFAULT_CONTEXT_WINDOW, fetch_models_blocking, models_fetch_origin}; pub use conversations_client::{ ConvError, ConvQuery, Conversation, ConversationsClient, ListConversationsPage, UpdateConversationBody, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/memory_dream.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/memory_dream.rs index c442e8c..f6bb4a9 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/memory_dream.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/memory_dream.rs @@ -655,7 +655,7 @@ impl SessionActor { let request = ConversationRequest { items, tools: vec![], - model: Some("grok-build".to_owned()), + model: Some(crate::models::default_model().to_owned()), temperature: Some(0.3), max_output_tokens: Some(1024), ..Default::default() diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/recap.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/recap.rs index 22d86ed..4c62c55 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/recap.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/recap.rs @@ -507,7 +507,7 @@ impl SessionActor { let model = match model_override { Some(m) => m.to_owned(), - None => "grok-build".to_owned(), + None => crate::models::default_model().to_owned(), }; let request = ConversationRequest { @@ -569,10 +569,10 @@ impl SessionActor { /// (`KIGI_PROMPT_SUGGESTIONS_MODEL`) > `[models] prompt_suggestion` /// (config.toml) > remote `prompt_suggestion_model` (remote settings) > /// (config.toml) > remote `prompt_suggestion_model` (remote settings) > - /// [`prompt_suggest::DEFAULT_SUGGEST_MODEL`] (`grok-build-0.1`). Every + /// [`prompt_suggest::default_suggest_model`]. Every /// tier except env is catalog-guarded against this shell's own model /// catalog — when the effective model is not sampleable here (e.g. - /// `grok-build-0.1` for OAuth users) the request is **skipped + /// a model the catalog does not offer) the request is **skipped /// entirely** instead of fired doomed. The session model is never used: /// a per-turn background call must stay on the small model. /// Temperature, max_output_tokens, and diff --git a/crates/codegen/kigi-shell/src/session/acp_types.rs b/crates/codegen/kigi-shell/src/session/acp_types.rs index 36926eb..5e5254b 100644 --- a/crates/codegen/kigi-shell/src/session/acp_types.rs +++ b/crates/codegen/kigi-shell/src/session/acp_types.rs @@ -521,7 +521,8 @@ pub struct SessionInfoData { /// Whether this model slug supports showing checkpoint identity (resolved model ID, fingerprint). pub fn is_coding_model_slug(model: &str) -> bool { - matches!(model, "grok-build" | "grok-4.5") + model == kigi_models::PlatformId::KimiCode.managed_model_key(crate::models::default_model()) + || model == crate::models::default_model() } /// Display gate for the model fingerprint: server/catalog opt-in OR the built-in coding-slug default. @@ -626,9 +627,13 @@ mod tests { fn should_show_model_fingerprint_truth_table() { // Catalog opt-in shows the fingerprint even for a non-coding slug. assert!(should_show_model_fingerprint(true, "non-coding")); - // Coding slugs always show, even without the catalog flag. - assert!(should_show_model_fingerprint(false, "grok-build")); - assert!(should_show_model_fingerprint(false, "grok-4.5")); + // The default coding model always shows, by slug or managed key, + // even without the catalog flag. + assert!(should_show_model_fingerprint(false, "kimi-for-coding")); + assert!(should_show_model_fingerprint( + false, + "kimi-code/kimi-for-coding" + )); // Non-coding slug without the flag stays hidden. assert!(!should_show_model_fingerprint(false, "some-other")); } diff --git a/crates/codegen/kigi-shell/src/session/helpers/prompt_suggest.rs b/crates/codegen/kigi-shell/src/session/helpers/prompt_suggest.rs index ec2dd34..e855097 100644 --- a/crates/codegen/kigi-shell/src/session/helpers/prompt_suggest.rs +++ b/crates/codegen/kigi-shell/src/session/helpers/prompt_suggest.rs @@ -5,7 +5,7 @@ //! the empty prompt input; Tab accepts it. Modelled on common coding-agent //! prompt suggestion features, but instead of replaying the full conversation prefix //! it sends a *compact text-only transcript* — the call always routes to a -//! small dedicated model (configurable, [`DEFAULT_SUGGEST_MODEL`] by +//! dedicated model (configurable, [`default_suggest_model`] by //! default, never the session model — see [`effective_suggest_model`]), //! where the parent session's prompt cache would not apply anyway, so a //! small request wins on both cost and latency. @@ -20,20 +20,20 @@ use crate::session::helpers::chat::floor_char_boundary; /// Model used for suggestion calls when nothing pins one (no env / /// `[models] prompt_suggestion` / remote setting / client hint — see -/// [`effective_suggest_model`]). Suggestion requests must stay on a small, -/// fast model: falling back to the session model would multiply the per-turn -/// cost of the feature and add reasoning-model latency for a throwaway -/// prediction. -pub(crate) const DEFAULT_SUGGEST_MODEL: &str = "grok-build-0.1"; +/// [`effective_suggest_model`]). The Kimi catalog has no dedicated small +/// suggestion model, so this is the bundled default coding model; the +/// catalog guard still controls whether the request fires at all. +pub(crate) fn default_suggest_model() -> &'static str { + crate::models::default_model() +} /// Resolve the model for one suggestion request, or `None` to skip the /// request entirely (controlled disable). /// /// Precedence: env pin > config.toml/remote pin > client hint (the request's -/// `model` param) > [`DEFAULT_SUGGEST_MODEL`]. Every tier except the env pin -/// is catalog-guarded via `in_catalog`: [`DEFAULT_SUGGEST_MODEL`] -/// (`grok-build-0.1`) is API-key-only and excluded from OAuth catalogs, so -/// firing it (or any unavailable pin) would send a doomed per-turn request +/// `model` param) > [`default_suggest_model`]. Every tier except the env pin +/// is catalog-guarded via `in_catalog`: firing an unavailable pin or default +/// would send a doomed per-turn request /// that can never render ghost text. Skipping keeps the per-turn cost at /// zero; deliberately NOT a session-model fallback — a per-turn background /// call must stay on a small cheap model. The env pin bypasses the guard so @@ -48,7 +48,7 @@ pub(crate) fn effective_suggest_model( let (model, catalog_guarded) = match pin { PromptSuggestModelPin::Env(m) => (m.as_str(), false), PromptSuggestModelPin::Pinned(m) => (m.as_str(), true), - PromptSuggestModelPin::Unpinned => (client_hint.unwrap_or(DEFAULT_SUGGEST_MODEL), true), + PromptSuggestModelPin::Unpinned => (client_hint.unwrap_or(default_suggest_model()), true), }; if catalog_guarded && !in_catalog(model) { return None; @@ -324,9 +324,9 @@ mod tests { // No pin, no hint: the built-in default fires only when this shell's // catalog can sample it. assert_eq!( - effective_suggest_model(&Pin::Unpinned, None, |m| m == DEFAULT_SUGGEST_MODEL) + effective_suggest_model(&Pin::Unpinned, None, |m| m == default_suggest_model()) .as_deref(), - Some(DEFAULT_SUGGEST_MODEL) + Some(default_suggest_model()) ); // OAuth catalogs exclude grok-build-0.1 → skip the request entirely, // never a doomed call (and never the session model). @@ -349,9 +349,9 @@ mod tests { ); // Blank hints are ignored: the default tier applies. assert_eq!( - effective_suggest_model(&Pin::Unpinned, Some(" "), |m| m == DEFAULT_SUGGEST_MODEL) + effective_suggest_model(&Pin::Unpinned, Some(" "), |m| m == default_suggest_model()) .as_deref(), - Some(DEFAULT_SUGGEST_MODEL) + Some(default_suggest_model()) ); } diff --git a/crates/codegen/kigi-tui/src/app/effects/tests.rs b/crates/codegen/kigi-tui/src/app/effects/tests.rs index e11d3f4..990dbc3 100644 --- a/crates/codegen/kigi-tui/src/app/effects/tests.rs +++ b/crates/codegen/kigi-tui/src/app/effects/tests.rs @@ -1805,7 +1805,7 @@ fn format_session_info_hides_model_hash_for_noncoding_without_flag() { } #[test] fn format_session_info_shows_model_hash_for_coding_slug_without_flag() { - let mut info = make_session_info("grok-build", None, 1000, 10000); + let mut info = make_session_info("kimi-for-coding", None, 1000, 10000); info.data.model_fingerprint = Some("abc123".into()); info.data.show_model_fingerprint = false; let text = format_session_info(&info, None, false); diff --git a/crates/codegen/kigi-tui/src/views/prompt_suggestion.rs b/crates/codegen/kigi-tui/src/views/prompt_suggestion.rs index f1306f9..37bc4b8 100644 --- a/crates/codegen/kigi-tui/src/views/prompt_suggestion.rs +++ b/crates/codegen/kigi-tui/src/views/prompt_suggestion.rs @@ -26,11 +26,13 @@ pub const PROMPT_SUGGESTIONS_ENV: &str = "KIGI_PROMPT_SUGGESTIONS"; /// `KIGI_PROMPT_SUGGESTIONS_MODEL=`. pub const PROMPT_SUGGESTIONS_MODEL_ENV: &str = "KIGI_PROMPT_SUGGESTIONS_MODEL"; -/// Preferred model for suggestion calls when the server catalog offers it -/// (cheap + fast). The session model is never used: when this is absent -/// from the catalog the request carries no model hint and the shell -/// resolves (or skips) it — see [`resolve_model`]. -pub const PREFERRED_SUGGESTION_MODEL: &str = "grok-build-0.1"; +/// Preferred model for suggestion calls when the server catalog offers it. +/// The session model is never used: when this is absent from the catalog the +/// request carries no model hint and the shell resolves (or skips) it — see +/// [`resolve_model`]. +pub fn preferred_suggestion_model() -> &'static str { + kigi_shell::models::default_model() +} /// Controller for the predicted-next-prompt ghost text. #[derive(Debug, Default)] @@ -165,11 +167,11 @@ pub fn resolve_model(models: &crate::acp::model_state::ModelState) -> Option