M1/F2+F4: platform registry, Moonshot API-key channel, dynamic model sync

F2 — fixed three-platform registry in kigi-models: kimi-code
(subscription, OAuth bearer, base kigi_env::coding_api_base_url()),
moonshot-cn (https://api.moonshot.cn/v1), moonshot-ai
(https://api.moonshot.ai/v1) with kimi-k model-prefix filtering.
Moonshot API keys via KIGI_MOONSHOT_CN_API_KEY / KIGI_MOONSHOT_AI_API_KEY
(+ KIGI_MOONSHOT_API_KEY shared fallback) or ~/.kigi/config.toml;
values redacted from logs/display.

F4 — model catalog now syncs from GET {base}/models (Bearer auth,
wire shape per official kimi-cli: id/context_length/supports_reasoning/
supports_image_in/supports_video_in/display_name) with the official
capability-derivation rules (thinking / always_thinking-in-name /
kimi-k2 implicit set). Managed keys {platform_id}/{model_id}; default
model = first list entry; default thinking iff capabilities contain
thinking/always_thinking. Sync failure → last cache; no cache →
built-in fallback table seeded from ids sourced in official kimi-cli
(kimi-for-coding, kimi-k2-turbo-preview, kimi-k2-thinking-turbo).
401 during sync forces one token refresh and retries.

Model resolution priority preserved: CLI > env > config > server >
fallback. Grok model artifacts (grok-4*/grok-build catalog, tier
gating remnants) removed from non-test code.

All first-party endpoints re-verified live: device_authorization mints
real codes; /models on all three platforms answers with real API auth
errors when unauthenticated.

Gates: check/clippy --all-targets 0/0, fmt clean, deny ok,
kigi-shell lib 5136 green, kigi-tui lib 6819 green, kigi-models 8.
This commit is contained in:
2026-07-17 09:23:44 -04:00
parent 021b82443d
commit fe1f885bb3
25 changed files with 2148 additions and 430 deletions
+336 -68
View File
@@ -854,7 +854,7 @@ pub struct ModelsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub image_description: Option<String>,
/// 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<String>,
@@ -900,6 +900,98 @@ pub struct ModelsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_tool_calls: Option<bool>,
}
/// `[platforms.<id>]` 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<String, PlatformCredentialConfig>,
}
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<String> {
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.<id>]` 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::<Vec<_>>(),
"[platforms.{id}] does not match any registry platform; its api_key is ignored"
);
}
}
}
}
/// One `[platforms.<id>]` 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<String>,
}
/// Resolve the API key for an open-platform registry entry:
/// platform-scoped env > generic `KIGI_MOONSHOT_API_KEY` env > config file.
/// `None` for the OAuth platform and when nothing is configured.
/// The returned value must never be logged.
pub(crate) fn resolve_platform_api_key(
platform: kigi_models::PlatformId,
platforms: &PlatformsConfig,
) -> Option<String> {
resolve_platform_api_key_with(platform, platforms, |name| std::env::var(name).ok())
}
/// Testable core of [`resolve_platform_api_key`] with an injected getenv.
pub(crate) fn resolve_platform_api_key_with(
platform: kigi_models::PlatformId,
platforms: &PlatformsConfig,
mut getenv: impl FnMut(&str) -> Option<String>,
) -> Option<String> {
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.<id>]` — 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<String>,
/// Image describe model (`grok-build` default via `ModelOverrideConfig::resolve`).
/// Image describe model (bundled default via `ModelOverrideConfig::resolve`).
#[serde(skip)]
pub image_description_model: Option<String>,
/// 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.<id>].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<String, ModelEntry>,
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<ReasoningEffortOption>,
/// Kimi capability set (PRD F4), sourced per entry (see kigi-models docs).
#[serde(default)]
capabilities: Vec<kigi_models::ModelCapability>,
/// 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<String, ModelEntryCon
m.id
);
let key = m.id.clone().unwrap_or_else(|| m.model.clone());
// Bundled entries are keyed `{platform_id}/{model_id}` (PRD F4);
// each routes to its platform's base URL. The subscription
// platform honors the endpoint overrides; the open platforms get
// their env-key names so a moonshot key is usable offline.
let platform = kigi_models::parse_managed_model_key(&key).map(|(p, _)| p);
let base_url = match platform {
Some(kigi_models::PlatformId::KimiCode) | None => {
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<String, ModelEntryCon
inference_idle_timeout_secs: m.inference_idle_timeout_secs,
max_retries: None,
api_key: None,
env_key: None,
env_key,
extra_headers: IndexMap::new(),
use_concise: false,
hidden: m.hidden,
@@ -2973,6 +3139,7 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
reasoning_effort: m.reasoning_effort,
supports_reasoning_effort: m.supports_reasoning_effort,
reasoning_efforts: m.reasoning_efforts,
capabilities: m.capabilities,
supports_backend_search: m.supports_backend_search,
compactions_remaining: m.compactions_remaining,
compaction_at_tokens: m.compaction_at_tokens,
@@ -3028,6 +3195,9 @@ pub struct ModelEntryConfig {
/// above are derived from this list when it is non-empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasoning_efforts: Vec<ReasoningEffortOption>,
/// Kimi capability set (PRD F4); see [`ModelInfo::capabilities`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<kigi_models::ModelCapability>,
/// 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<ReasoningEffort>,
pub supports_reasoning_effort: Option<bool>,
pub reasoning_efforts: Vec<ReasoningEffortOption>,
/// Kimi capability override; merges only when non-empty (cannot express
/// "override to empty", same as `reasoning_efforts`).
pub capabilities: Vec<kigi_models::ModelCapability>,
pub supports_backend_search: Option<bool>,
/// 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<ReasoningEffortOption>,
/// 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<kigi_models::ModelCapability>,
pub supports_backend_search: bool,
/// Per-model config for the `x-compactions-remaining` header; `None` disables it.
pub compactions_remaining: Option<CompactionsRemaining>,
@@ -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.<id>].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() {