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
Generated
+1
View File
@@ -6132,6 +6132,7 @@ dependencies = [
name = "kigi-models" name = "kigi-models"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"kigi-env",
"serde", "serde",
"serde_json", "serde_json",
] ]
+40
View File
@@ -32,6 +32,46 @@ stores is touched.
Authentication and inference against Kimi Code (milestone M1), the Authentication and inference against Kimi Code (milestone M1), the
compatibility surface (M2), and release distribution (M3) are in progress. 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 ## Building from source
```sh ```sh
+2 -1
View File
@@ -3,9 +3,10 @@ license = "Apache-2.0"
name = "kigi-models" name = "kigi-models"
version.workspace = true version.workspace = true
edition.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] [dependencies]
kigi-env = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
+39 -11
View File
@@ -1,18 +1,46 @@
{ {
"default": "grok-build", "default": "kimi-for-coding",
"web_search": "grok-4.20-multi-agent",
"image_description": "grok-build",
"session_summary": "grok-build",
"models": [ "models": [
{ {
"model": "grok-build", "id": "kimi-code/kimi-for-coding",
"name": "Grok Build", "model": "kimi-for-coding",
"description": "Best for advanced coding tasks", "name": "Kimi for Coding",
"context_window": 500000, "description": "Kimi Code subscription coding model (offline fallback entry)",
"temperature": 0.7, "context_window": 262144,
"top_p": 0.95, "capabilities": ["thinking", "image_in", "video_in"],
"api_backend": "responses",
"supported_in_api": false "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"]
} }
] ]
} }
+463 -6
View File
@@ -1,14 +1,293 @@
//! Default model IDs loaded from `default_models.json` at runtime. //! Kimi model catalog primitives (PRD F2/F4).
//! Edit that JSON file to change them. //!
//! 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: //! 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; use std::sync::LazyLock;
/// The raw JSON, embedded at compile time. Re-exported through the // ── Platform registry (PRD F2) ──────────────────────────────────────────────
/// `kigi_shell::models` facade and consumed by `agent::config`, so it must
/// be `pub` (was `pub(crate)` when this lived inside the shell crate). /// 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<Self> {
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<String>,
}
/// `GET {base}/models` response envelope.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct WireModelsResponse {
pub data: Vec<WireModel>,
}
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<ModelCapability> {
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<ModelCapability> {
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<WireModel>) -> Vec<WireModel> {
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"); pub const DEFAULT_MODELS_JSON: &str = include_str!("../default_models.json");
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -68,3 +347,181 @@ pub fn default_session_summary_model() -> &'static str {
.as_deref() .as_deref()
.unwrap_or(&DEFAULTS.default) .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<_>>(),
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"));
}
}
+8 -4
View File
@@ -173,11 +173,12 @@ pub(crate) async fn run_auto_update_checker(
async fn prefetch_models(agent_config: &AgentConfig) -> Option<IndexMap<String, ModelEntry>> { async fn prefetch_models(agent_config: &AgentConfig) -> Option<IndexMap<String, ModelEntry>> {
let auth = agent_config.create_auth_manager().current(); let auth = agent_config.create_auth_manager().current();
let endpoints = agent_config.endpoints.clone(); 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 || { 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 .await
.ok() .ok()
@@ -617,7 +618,9 @@ pub async fn run_leader(
let auth_for_prefetch: Option<KimiAuth> = auth.clone(); let auth_for_prefetch: Option<KimiAuth> = auth.clone();
let endpoints_for_prefetch = agent_config.endpoints.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 // The shared pair helper owns the remote_fetch gate for both halves, so a
// disabled knob cannot block leader readiness on settings retries. // disabled knob cannot block leader readiness on settings retries.
let (prefetched_models, remote_settings) = tokio::task::spawn_blocking(move || { let (prefetched_models, remote_settings) = tokio::task::spawn_blocking(move || {
@@ -625,6 +628,7 @@ pub async fn run_leader(
&endpoints_for_prefetch, &endpoints_for_prefetch,
auth_for_prefetch.as_ref(), auth_for_prefetch.as_ref(),
fetch_auth_for_prefetch, fetch_auth_for_prefetch,
&platform_keys_for_prefetch,
) )
}) })
.await .await
+336 -68
View File
@@ -854,7 +854,7 @@ pub struct ModelsConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub image_description: Option<String>, pub image_description: Option<String>,
/// Model pin for next-prompt suggestions (tab-autocomplete ghost text). /// 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`. /// default with the catalog guard; see `ModelOverrideConfig::resolve`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub prompt_suggestion: Option<String>, pub prompt_suggestion: Option<String>,
@@ -900,6 +900,98 @@ pub struct ModelsConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub stream_tool_calls: Option<bool>, 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)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct HarnessConfig { pub struct HarnessConfig {
@@ -998,7 +1090,7 @@ impl SandboxSettingsConfig {
/// [suggestions] /// [suggestions]
/// enabled = true /// enabled = true
/// ai_enabled = true /// ai_enabled = true
/// ai_model = "grok-build" /// ai_model = "kimi-for-coding"
/// debounce_ms = 50 /// debounce_ms = 50
/// ``` /// ```
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -1042,7 +1134,7 @@ impl SuggestionsConfig {
None, None,
) )
.map(|r| r.value) .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. /// `[storage]` section from config.toml.
@@ -1152,6 +1244,11 @@ pub struct Config {
pub cli: CliConfig, pub cli: CliConfig,
#[serde(default, skip_serializing)] #[serde(default, skip_serializing)]
pub models: ModelsConfig, 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)] #[serde(default, skip_serializing)]
pub harness: HarnessConfig, pub harness: HarnessConfig,
#[serde(default, skip_serializing)] #[serde(default, skip_serializing)]
@@ -1356,7 +1453,7 @@ pub struct Config {
/// (`default_session_summary_model`) when unset; see `ModelOverrideConfig::resolve`. /// (`default_session_summary_model`) when unset; see `ModelOverrideConfig::resolve`.
#[serde(skip)] #[serde(skip)]
pub session_summary_model: Option<String>, 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)] #[serde(skip)]
pub image_description_model: Option<String>, pub image_description_model: Option<String>,
/// Next-prompt suggestion model pin (`env > [models] prompt_suggestion > /// Next-prompt suggestion model pin (`env > [models] prompt_suggestion >
@@ -1541,6 +1638,7 @@ impl Default for Config {
paths: PathsConfig::default(), paths: PathsConfig::default(),
cli: CliConfig::default(), cli: CliConfig::default(),
models: ModelsConfig::default(), models: ModelsConfig::default(),
platforms: PlatformsConfig::default(),
harness: HarnessConfig::default(), harness: HarnessConfig::default(),
remote: RemoteConfig::default(), remote: RemoteConfig::default(),
hub: HubConfig::default(), hub: HubConfig::default(),
@@ -1678,6 +1776,7 @@ impl Config {
} }
config.config_models = config_models; config.config_models = config_models;
config.model_override_warnings = model_override_warnings; config.model_override_warnings = model_override_warnings;
config.platforms.warn_unknown_platforms();
if config.client_version.is_none() { if config.client_version.is_none() {
config.client_version = Self::default().client_version; 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_extra_headers(&mut resolved, &cfg.models);
apply_global_scalar_defaults(&mut resolved, &cfg.models); apply_global_scalar_defaults(&mut resolved, &cfg.models);
apply_platform_credentials(&mut resolved, &cfg.platforms);
for entry in resolved.values_mut() { for entry in resolved.values_mut() {
entry.info.derive_reasoning_effort_fields(); entry.info.derive_reasoning_effort_fields();
} }
resolved 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` /// 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 /// 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 /// the sampler lowers these into an `http::HeaderMap`, so a global `X-Foo` must
@@ -2909,6 +3058,9 @@ struct DefaultModelJson {
supports_reasoning_effort: bool, supports_reasoning_effort: bool,
#[serde(default)] #[serde(default)]
reasoning_efforts: Vec<ReasoningEffortOption>, 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. /// When false, only OAuth users see this in the picker.
#[serde(default = "default_true")] #[serde(default = "default_true")]
supported_in_api: bool, supported_in_api: bool,
@@ -2943,14 +3095,28 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
m.id m.id
); );
let key = m.id.clone().unwrap_or_else(|| m.model.clone()); 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 let context_window = m
.context_window .context_window
.unwrap_or_else(|| NonZeroU64::new(200_000).expect("200000 is non-zero")); .unwrap_or_else(|| NonZeroU64::new(200_000).expect("200000 is non-zero"));
let config = ModelEntryConfig { let config = ModelEntryConfig {
id: m.id, id: m.id,
model: m.model, model: m.model,
base_url: endpoints.resolve_inference_base_url(), base_url,
api_base_url: Some(endpoints.xai_api_base_url.clone()), api_base_url: None,
name: m.name, name: m.name,
description: m.description, description: m.description,
context_window, context_window,
@@ -2965,7 +3131,7 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
inference_idle_timeout_secs: m.inference_idle_timeout_secs, inference_idle_timeout_secs: m.inference_idle_timeout_secs,
max_retries: None, max_retries: None,
api_key: None, api_key: None,
env_key: None, env_key,
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
use_concise: false, use_concise: false,
hidden: m.hidden, hidden: m.hidden,
@@ -2973,6 +3139,7 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
reasoning_effort: m.reasoning_effort, reasoning_effort: m.reasoning_effort,
supports_reasoning_effort: m.supports_reasoning_effort, supports_reasoning_effort: m.supports_reasoning_effort,
reasoning_efforts: m.reasoning_efforts, reasoning_efforts: m.reasoning_efforts,
capabilities: m.capabilities,
supports_backend_search: m.supports_backend_search, supports_backend_search: m.supports_backend_search,
compactions_remaining: m.compactions_remaining, compactions_remaining: m.compactions_remaining,
compaction_at_tokens: m.compaction_at_tokens, 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. /// above are derived from this list when it is non-empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasoning_efforts: Vec<ReasoningEffortOption>, 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. /// Extra headers to send with requests to this model's endpoint.
/// Useful for BYOK (Bring Your Own Key) scenarios. /// Useful for BYOK (Bring Your Own Key) scenarios.
/// Example: { "x-anthropic-api-key" = "sk-ant-..." } /// Example: { "x-anthropic-api-key" = "sk-ant-..." }
@@ -3152,6 +3322,9 @@ pub struct ConfigModelOverride {
pub reasoning_effort: Option<ReasoningEffort>, pub reasoning_effort: Option<ReasoningEffort>,
pub supports_reasoning_effort: Option<bool>, pub supports_reasoning_effort: Option<bool>,
pub reasoning_efforts: Vec<ReasoningEffortOption>, 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>, pub supports_backend_search: Option<bool>,
/// Aliases must be registered in `config_model_override_parse::ALIASES`; /// Aliases must be registered in `config_model_override_parse::ALIASES`;
/// serde rejects a table that contains both spellings otherwise. /// serde rejects a table that contains both spellings otherwise.
@@ -3233,6 +3406,9 @@ impl ConfigModelOverride {
if !self.reasoning_efforts.is_empty() { if !self.reasoning_efforts.is_empty() {
entry.info.reasoning_efforts = self.reasoning_efforts.clone(); 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 { if let Some(v) = self.supports_backend_search {
entry.info.supports_backend_search = v; 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. /// Per-model reasoning-effort menu (source of truth); legacy fields derived from it.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasoning_efforts: Vec<ReasoningEffortOption>, 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, pub supports_backend_search: bool,
/// Per-model config for the `x-compactions-remaining` header; `None` disables it. /// Per-model config for the `x-compactions-remaining` header; `None` disables it.
pub compactions_remaining: Option<CompactionsRemaining>, pub compactions_remaining: Option<CompactionsRemaining>,
@@ -3362,6 +3543,7 @@ impl ModelInfo {
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -3397,6 +3579,7 @@ impl ModelInfo {
reasoning_effort: entry.reasoning_effort, reasoning_effort: entry.reasoning_effort,
supports_reasoning_effort: entry.supports_reasoning_effort, supports_reasoning_effort: entry.supports_reasoning_effort,
reasoning_efforts: entry.reasoning_efforts.clone(), reasoning_efforts: entry.reasoning_efforts.clone(),
capabilities: entry.capabilities.clone(),
supports_backend_search: entry.supports_backend_search, supports_backend_search: entry.supports_backend_search,
compactions_remaining: entry.compactions_remaining, compactions_remaining: entry.compactions_remaining,
compaction_at_tokens: entry.compaction_at_tokens, compaction_at_tokens: entry.compaction_at_tokens,
@@ -4036,6 +4219,7 @@ pub fn resolve_aux_model_sampling_config(
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -4248,6 +4432,7 @@ fn resolve_hidden_default_web_search_sampling_config(
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -4415,6 +4600,9 @@ mod tests {
use super::*; use super::*;
use kigi_test_support::EnvGuard; use kigi_test_support::EnvGuard;
use serial_test::serial; 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] #[test]
fn main_cli_tools_override_preserves_profile_injection_policy() { fn main_cli_tools_override_preserves_profile_injection_policy() {
let overrides = CliAgentOverrides { let overrides = CliAgentOverrides {
@@ -4850,6 +5038,7 @@ reasoning_effort = "low"
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -5802,6 +5991,7 @@ reasoning_effort = "low"
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -5961,6 +6151,7 @@ reasoning_effort = "low"
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -6412,6 +6603,7 @@ reasoning_effort = "low"
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -6592,30 +6784,39 @@ reasoning_effort = "low"
fn e2e_default_model_with_session_routes_to_proxy() { fn e2e_default_model_with_session_routes_to_proxy() {
let (_, models) = resolve_models_from_toml("", None); let (_, models) = resolve_models_from_toml("", None);
let model = models let model = models
.get(crate::models::default_model()) .get(BUNDLED_DEFAULT_KEY)
.expect("default model should exist"); .expect("default model should exist");
let sampling = resolve_sampling(model, Some("session-token-123")); let sampling = resolve_sampling(model, Some("session-token-123"));
assert_eq!(sampling.api_key.as_deref(), Some("session-token-123")); assert_eq!(sampling.api_key.as_deref(), Some("session-token-123"));
assert_eq!( assert_eq!(
sampling.base_url, "https://api.kimi.com/coding/v1", 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] #[test]
#[serial] #[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 (_, models) = resolve_models_from_toml("", None);
let model = models let model = models
.get(crate::models::default_model()) .get("moonshot-ai/kimi-k2-turbo-preview")
.expect("default model should exist"); .expect("bundled moonshot fallback entry should exist");
unsafe { std::env::set_var("XAI_API_KEY", "xai-external-key") }; unsafe { std::env::set_var("KIGI_MOONSHOT_API_KEY", "sk-moonshot-generic") };
let sampling = resolve_sampling(model, None); 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!( assert_eq!(
sampling.base_url, "https://api.x.ai/v1", sampling.base_url, "https://api.moonshot.ai/v1",
"external API key should route to api.x.ai via api_base_url" "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] #[test]
fn e2e_user_config_overrides_prefetched_model() { fn e2e_user_config_overrides_prefetched_model() {
@@ -6711,7 +6912,7 @@ reasoning_effort = "low"
let (_, models) = resolve_models_from_toml( let (_, models) = resolve_models_from_toml(
&format!( &format!(
r#" r#"
[model.acme-grok] [model.acme-kimi]
model = "{dm}" model = "{dm}"
base_url = "https://inference.example.com/v1" base_url = "https://inference.example.com/v1"
context_window = 200000 context_window = 200000
@@ -6720,13 +6921,16 @@ reasoning_effort = "low"
), ),
None, None,
); );
assert!(models.contains_key(dm), "default entry should still exist");
assert!( 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" "user entry with different key should also exist"
); );
let default = models.get(dm).unwrap(); let default = models.get(BUNDLED_DEFAULT_KEY).unwrap();
let user = models.get("acme-grok").unwrap(); let user = models.get("acme-kimi").unwrap();
assert_eq!(default.info.model, user.info.model, "same model field"); assert_eq!(default.info.model, user.info.model, "same model field");
assert_ne!( assert_ne!(
default.info.base_url, user.info.base_url, default.info.base_url, user.info.base_url,
@@ -6770,7 +6974,7 @@ reasoning_effort = "low"
let cfg = Config::default(); let cfg = Config::default();
let resolved = resolve_model_list(&cfg, None); let resolved = resolve_model_list(&cfg, None);
assert!( assert!(
resolved.contains_key(crate::models::default_model()), resolved.contains_key(BUNDLED_DEFAULT_KEY),
"default model should be present when using default endpoint" "default model should be present when using default endpoint"
); );
} }
@@ -6814,30 +7018,24 @@ reasoning_effort = "low"
} }
#[test] #[test]
fn e2e_enterprise_endpoints_plus_partial_model_override() { fn e2e_enterprise_endpoints_plus_partial_model_override() {
let dm = crate::models::default_model();
let (_, models) = resolve_models_from_toml( let (_, models) = resolve_models_from_toml(
&format!( &format!(
r#" r#"
[endpoints] [endpoints]
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1" 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" api_key = "acme-api-key"
"#, "#,
), ),
None, None,
); );
let model = models.get(dm).expect("model should exist"); let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist");
assert_eq!( assert_eq!(
model.info.base_url, "https://enterprise-proxy.acme.com/v1", model.info.base_url, "https://enterprise-proxy.acme.com/v1",
"base_url must inherit from [endpoints], not stale default" "base_url must inherit from [endpoints], not stale default"
); );
assert_eq!(model.api_key.as_deref(), Some("acme-api-key")); 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")); let sampling = resolve_sampling(model, Some("session-token"));
assert_eq!( assert_eq!(
sampling.api_key.as_deref(), sampling.api_key.as_deref(),
@@ -6855,22 +7053,20 @@ reasoning_effort = "low"
r#" r#"
[endpoints] [endpoints]
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1" cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1"
xai_api_base_url = "https://enterprise-api.acme.com/v1"
"#, "#,
None, None,
); );
let model = models let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist");
.get(crate::models::default_model())
.expect("model should exist");
assert_eq!( assert_eq!(
model.info.base_url, "https://enterprise-proxy.acme.com/v1", model.info.base_url, "https://enterprise-proxy.acme.com/v1",
"default model should use enterprise cli_chat_proxy_base_url" "default model should use enterprise cli_chat_proxy_base_url"
); );
assert_eq!( // The open-platform fallback entries keep their fixed moonshot bases;
model.api_base_url.as_deref(), // only the subscription entry follows the proxy override.
Some("https://enterprise-api.acme.com/v1"), let moonshot = models
"default model should use enterprise xai_api_base_url" .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, /// Unset every env var that `EndpointsConfig::default()` reads for endpoints,
/// so the cli-chat-proxy resolver tests below are deterministic regardless of /// so the cli-chat-proxy resolver tests below are deterministic regardless of
@@ -9496,6 +9692,7 @@ default = "grok-4.5"
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -9512,7 +9709,6 @@ default = "grok-4.5"
} }
#[test] #[test]
fn global_extra_headers_apply_to_model_without_override() { fn global_extra_headers_apply_to_model_without_override() {
let dm = crate::models::default_model();
let (_, models) = resolve_models_from_toml( let (_, models) = resolve_models_from_toml(
r#" r#"
[models] [models]
@@ -9520,7 +9716,9 @@ default = "grok-4.5"
"#, "#,
None, 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!( assert_eq!(
model model
.info .info
@@ -9771,11 +9969,11 @@ default = "grok-4.5"
fn resolve_model_list_inherits_context_window_from_default_when_prefetched_has_fallback() { fn resolve_model_list_inherits_context_window_from_default_when_prefetched_has_fallback() {
let cfg = Config::default(); let cfg = Config::default();
let default_cw = DEFAULT_CONTEXT_WINDOW; 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(); 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 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!( assert_ne!(
entry.info.context_window.get(), entry.info.context_window.get(),
default_cw, default_cw,
@@ -9786,11 +9984,11 @@ default = "grok-4.5"
fn resolve_model_list_does_not_override_explicitly_set_context_window() { fn resolve_model_list_does_not_override_explicitly_set_context_window() {
let cfg = Config::default(); let cfg = Config::default();
let explicit_cw = 65_536; 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(); 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 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!( assert_eq!(
entry.info.context_window.get(), entry.info.context_window.get(),
explicit_cw, explicit_cw,
@@ -9847,21 +10045,21 @@ default = "grok-4.5"
let cfg = Config::default(); let cfg = Config::default();
let mut defs = default_model_entries(&EndpointsConfig::default()); let mut defs = default_model_entries(&EndpointsConfig::default());
let mut p = IndexMap::new(); let mut p = IndexMap::new();
if let Some(e) = defs.shift_remove("grok-build") { if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
p.insert("grok-build".to_string(), e); p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
} }
let resolved = resolve_model_list(&cfg, Some(p)); let resolved = resolve_model_list(&cfg, Some(p));
assert!(resolved.contains_key("grok-build")); assert!(resolved.contains_key(BUNDLED_DEFAULT_KEY));
let no_p = resolve_model_list(&cfg, None); let no_p = resolve_model_list(&cfg, None);
assert!(no_p.contains_key("grok-build")); assert!(no_p.contains_key(BUNDLED_DEFAULT_KEY));
} }
#[test] #[test]
fn resolve_model_list_prefetch_visibility_matches_auth_and_server_list() { fn resolve_model_list_prefetch_visibility_matches_auth_and_server_list() {
let cfg = Config::default(); let cfg = Config::default();
let mut defs = default_model_entries(&EndpointsConfig::default()); let mut defs = default_model_entries(&EndpointsConfig::default());
let mut p = IndexMap::new(); let mut p = IndexMap::new();
if let Some(e) = defs.shift_remove("grok-build") { if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
p.insert("grok-build".to_string(), e); p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
} }
let resolved = resolve_model_list(&cfg, Some(p)); let resolved = resolve_model_list(&cfg, Some(p));
let sess: Vec<_> = resolved let sess: Vec<_> = resolved
@@ -9873,7 +10071,10 @@ default = "grok-4.5"
.filter(|e| e.visible_for_auth(false)) .filter(|e| e.visible_for_auth(false))
.collect(); .collect();
assert_eq!(sess.len(), 1); 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] #[test]
fn resolve_model_list_keeps_prefetch_only_entries_and_prunes_defaults() { 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); p.insert("secret-xyz".to_string(), e);
let resolved = resolve_model_list(&cfg, Some(p)); let resolved = resolve_model_list(&cfg, Some(p));
assert!(resolved.contains_key("secret-xyz")); assert!(resolved.contains_key("secret-xyz"));
assert!(!resolved.contains_key("grok-build")); assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
} }
#[test] #[test]
fn resolve_model_list_prefetch_replaces_bundled_entirely() { fn resolve_model_list_prefetch_replaces_bundled_entirely() {
let cfg = Config::default(); let cfg = Config::default();
let mut p = IndexMap::new(); let mut p = IndexMap::new();
let e = prefetch_model_entry("grok-4.5", 500_000, ApiBackend::Responses); let e = prefetch_model_entry("kimi-fresh", 500_000, ApiBackend::Responses);
p.insert("grok-4.5".to_string(), e); p.insert("kimi-fresh".to_string(), e);
let resolved = resolve_model_list(&cfg, Some(p)); let resolved = resolve_model_list(&cfg, Some(p));
assert!(resolved.contains_key("grok-4.5")); assert!(resolved.contains_key("kimi-fresh"));
assert!(!resolved.contains_key("grok-build")); assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
} }
#[test] #[test]
fn resolve_model_list_empty_prefetch_yields_empty_base() { fn resolve_model_list_empty_prefetch_yields_empty_base() {
@@ -9901,15 +10102,16 @@ default = "grok-4.5"
let resolved = resolve_model_list(&cfg, Some(IndexMap::new())); let resolved = resolve_model_list(&cfg, Some(IndexMap::new()));
assert!(resolved.is_empty()); assert!(resolved.is_empty());
} }
/// Regression: enterprise managed config aliases grok-build to their own /// Regression: enterprise managed config aliases the bundled subscription
/// endpoint with env_key. The bundled grok-build has supported_in_api=false. /// entry to their own endpoint with env_key. The bundled entry has
/// The config overlay must be visible to API-key users (env_key = BYOK). /// supported_in_api=false. The config overlay must be visible to API-key
/// users (env_key = BYOK).
#[test] #[test]
fn byok_config_overlay_visible_to_api_key_users() { fn byok_config_overlay_visible_to_api_key_users() {
let raw: toml::Value = toml::from_str( let raw: toml::Value = toml::from_str(
r#" r#"
[model.grok-build] [model."kimi-code/kimi-for-coding"]
model = "grok-4.5" model = "kimi-for-coding"
base_url = "https://inference.company.com/v1" base_url = "https://inference.company.com/v1"
env_key = "COMPANY_TOKEN" env_key = "COMPANY_TOKEN"
"#, "#,
@@ -9917,7 +10119,9 @@ default = "grok-4.5"
.unwrap(); .unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse"); let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None); let resolved = resolve_model_list(&cfg, None);
let entry = resolved.get("grok-build").expect("grok-build must exist"); let entry = resolved
.get(BUNDLED_DEFAULT_KEY)
.expect("bundled default must exist");
assert!( assert!(
entry.visible_for_auth(false), entry.visible_for_auth(false),
"BYOK config entry must be visible to API-key users — \ "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() { fn plain_config_overlay_preserves_bundled_visibility() {
let raw: toml::Value = toml::from_str( let raw: toml::Value = toml::from_str(
r#" r#"
[model.grok-build] [model."kimi-code/kimi-for-coding"]
context_window = 300000 context_window = 300000
"#, "#,
) )
.unwrap(); .unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse"); let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None); let resolved = resolve_model_list(&cfg, None);
let entry = resolved.get("grok-build").expect("grok-build must exist"); let entry = resolved
.get(BUNDLED_DEFAULT_KEY)
.expect("bundled default must exist");
assert!( assert!(
!entry.visible_for_auth(false), !entry.visible_for_auth(false),
"non-BYOK config overlay must preserve bundled supported_in_api=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] #[test]
#[serial] #[serial]
fn mcp_liveness_watchers_default_is_true() { fn mcp_liveness_watchers_default_is_true() {
@@ -531,6 +531,7 @@ mod tests {
description: Some("Deep reasoning".to_string()), description: Some("Deep reasoning".to_string()),
default: true, default: true,
}], }],
capabilities: vec![kigi_models::ModelCapability::Thinking],
supports_backend_search: Some(false), supports_backend_search: Some(false),
compactions_remaining: Some(CompactionsRemaining::Fixed(1)), compactions_remaining: Some(CompactionsRemaining::Fixed(1)),
compaction_at_tokens: Some(CompactionAtTokens::Fixed(100_000)), compaction_at_tokens: Some(CompactionAtTokens::Fixed(100_000)),
File diff suppressed because it is too large Load Diff
@@ -1368,12 +1368,20 @@ impl acp::Agent for MvpAgent {
.take(10).collect::< Vec < _ >> (), .take(10).collect::< Vec < _ >> (),
"load_session: restoring persisted model (debug)" "load_session: restoring persisted model (debug)"
); );
let is_grok_build = persisted_model.0.starts_with("grok-build"); // "Same family" = same platform: catalog keys are
let same_family_fallback = if is_grok_build { // `{platform_id}/{model_id}` (PRD F4), so prefer a replacement from
available.keys().find(|id| id.0.starts_with("grok-build")).cloned() // the platform the persisted model belonged to (its credentials are
} else { // known-good) before falling back across platforms.
available.keys().find(|id| !id.0.starts_with("grok-build")).cloned() 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( let selectable_catalog_key = selectable_catalog_key_for_persisted(
&models, &models,
&available, &available,
@@ -1675,6 +1675,7 @@ fn find_model_by_id_prefers_key_then_falls_back_to_slug() {
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
+10 -4
View File
@@ -166,13 +166,19 @@ async fn handle_connection(ws: WebSocket, state: Arc<ServerState>, peer_addr: So
.spawn(move || { .spawn(move || {
// Prefetch models before creating the runtime (blocking is OK here) // Prefetch models before creating the runtime (blocking is OK here)
let auth = agent_config.create_auth_manager().current(); let auth = agent_config.create_auth_manager().current();
let fetch_auth = let fetch_auth = ModelFetchAuth::resolve(&agent_config.endpoints);
ModelFetchAuth::resolve(&agent_config.endpoints, auth.is_some()); let platform_keys =
crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms);
let prefetched_models = if auth.is_some() let prefetched_models = if auth.is_some()
|| agent_config.endpoints.has_custom_endpoint() || 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 { } else {
None None
}; };
@@ -3076,6 +3076,7 @@ fn test_model_entry(model_id: &str) -> crate::agent::config::ModelEntry {
reasoning_effort: None, reasoning_effort: None,
supports_reasoning_effort: false, supports_reasoning_effort: false,
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false, supports_backend_search: false,
compactions_remaining: None, compactions_remaining: None,
compaction_at_tokens: None, compaction_at_tokens: None,
@@ -6,7 +6,7 @@
//! cargo run --bin trace_classify -- \ //! cargo run --bin trace_classify -- \
//! --trace /path/to/trace-<id>-all-turns.json \ //! --trace /path/to/trace-<id>-all-turns.json \
//! [--output out.jsonl] \ //! [--output out.jsonl] \
//! [--model grok-4.5] \ //! [--model kimi-for-coding] \
//! [--api-base-url https://api.x.ai/v1] \ //! [--api-base-url https://api.x.ai/v1] \
//! [--api-key <key> | $XAI_API_KEY | <kigi-home>/auth.json] \ //! [--api-key <key> | $XAI_API_KEY | <kigi-home>/auth.json] \
//! [--min-confidence 0.7] \ //! [--min-confidence 0.7] \
@@ -44,7 +44,7 @@ struct Cli {
/// Model the classifier sampler calls. Must be a model the API key /// Model the classifier sampler calls. Must be a model the API key
/// has access to. /// has access to.
#[arg(long, default_value = "grok-4.5")] #[arg(long, default_value = "kimi-for-coding")]
model: String, model: String,
/// Sampler base URL. /// Sampler base URL.
+4 -5
View File
@@ -537,7 +537,7 @@ pub struct ModelOverrideConfig {
pub web_search: String, pub web_search: String,
/// `None` = current model. /// `None` = current model.
pub session_summary: Option<String>, pub session_summary: Option<String>,
/// 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<String>, pub image_description: Option<String>,
/// Next-prompt suggestion model pin. Unlike the other overrides this does /// Next-prompt suggestion model pin. Unlike the other overrides this does
/// NOT fill a compiled default — see [`PromptSuggestModelPin`]. /// 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 /// Unlike the other auxiliary overrides this does not collapse to a plain
/// model string: the consumer (`handle_suggest_prompt`) must distinguish /// model string: the consumer (`handle_suggest_prompt`) must distinguish
/// an explicit pin from "unpinned" (where the client hint and the built-in /// 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 — /// 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 /// when the model is not in the shell's catalog the per-turn suggestion request is
/// OAuth users, whose catalogs exclude it) the per-turn suggestion request is
/// skipped entirely rather than fired doomed. The env pin is deliberately /// skipped entirely rather than fired doomed. The env pin is deliberately
/// exempt so `KIGI_PROMPT_SUGGESTIONS_MODEL` keeps working for models a /// exempt so `KIGI_PROMPT_SUGGESTIONS_MODEL` keeps working for models a
/// catalog does not list (mirrors the pager, which forwards the env value /// 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<String> {
impl ModelOverrideConfig { impl ModelOverrideConfig {
/// CLI flag > env var > config.toml > remote settings > compiled default. /// CLI flag > env var > config.toml > remote settings > compiled default.
/// `image_description` and `session_summary` always resolve to `Some(_)` /// `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 /// `prompt_suggestion` resolves to a [`PromptSuggestModelPin`] instead of
/// a model string (no CLI flag; the default and the catalog guard live at /// a model string (no CLI flag; the default and the catalog guard live at
/// the consumer, `handle_suggest_prompt`). /// the consumer, `handle_suggest_prompt`).
@@ -193,10 +193,10 @@ struct SuggestPromptRequest {
#[serde(default)] #[serde(default)]
session_id: Option<String>, session_id: Option<String>,
/// Client hint for the suggestion model (the pager sends its env /// 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 /// of the shell-side resolution in
/// `prompt_suggest::effective_suggest_model`: env > config.toml > remote /// `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 /// non-sampleable effective model skips the request; the session model
/// is never used). /// is never used).
#[serde(default)] #[serde(default)]
+361 -103
View File
@@ -655,97 +655,115 @@ pub(crate) const DEFAULT_CONTEXT_WINDOW: u64 = 256_000;
struct ModelsResponse { struct ModelsResponse {
data: Vec<serde_json::Value>, data: Vec<serde_json::Value>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] /// The models-fetch origin key for this endpoints/auth shape. Used as the
enum EndpointAuth { /// models disk-cache origin: cached entries embed absolute `base_url`s from
ApiKey, /// the backend(s) that served them, so a catalog fetched against one fetch
Session, /// 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
struct ListModelsEndpoint { /// platform NAMES only — never credential values.
url: String, pub(crate) fn models_fetch_origin(
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(
endpoints: &crate::agent::config::EndpointsConfig, endpoints: &crate::agent::config::EndpointsConfig,
fetch_auth: crate::agent::models::ModelFetchAuth, fetch_auth: crate::agent::models::ModelFetchAuth,
has_oauth: bool,
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> String { ) -> String {
ListModelsEndpoint::from_endpoints(endpoints, fetch_auth).url match fetch_auth {
} crate::agent::models::ModelFetchAuth::CustomEndpoint => endpoints.resolve_models_list_url(),
impl ListModelsEndpoint { crate::agent::models::ModelFetchAuth::Platforms => {
fn from_endpoints( let parts: Vec<String> = enabled_platforms(has_oauth, platform_keys)
endpoints: &crate::agent::config::EndpointsConfig, .into_iter()
fetch_auth: crate::agent::models::ModelFetchAuth, .map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints)))
) -> Self { .collect();
if endpoints.has_custom_endpoint() { format!("platforms[{}]", parts.join(";"))
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,
}
} }
} }
} }
/// Fetch models from an OpenAI-compatible `/v1/models` endpoint. /// The platforms with usable credentials, in registry order (kimi-code first
/// Fetch result: model entries + optional etag from response. /// 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> {
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 struct FetchModelsResult {
pub models: Vec<crate::agent::config::ModelEntryConfig>, pub models: Vec<crate::agent::config::ModelEntryConfig>,
pub etag: Option<String>, pub etag: Option<String>,
/// 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 <oauth-token or api-key>` 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( pub(crate) fn fetch_models_blocking(
endpoints: &crate::agent::config::EndpointsConfig, endpoints: &crate::agent::config::EndpointsConfig,
auth: Option<&KimiAuth>, auth: Option<&KimiAuth>,
fetch_auth: crate::agent::models::ModelFetchAuth, fetch_auth: crate::agent::models::ModelFetchAuth,
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> Result<FetchModelsResult, BackendError> { ) -> Result<FetchModelsResult, BackendError> {
let client = crate::http::shared_blocking_client(); match fetch_auth {
let source = ListModelsEndpoint::from_endpoints(endpoints, fetch_auth); crate::agent::models::ModelFetchAuth::CustomEndpoint => {
let inference_base_url = endpoints.resolve_inference_base_url(); fetch_custom_endpoint_models_blocking(endpoints, auth)
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));
} }
EndpointAuth::Session => { crate::agent::models::ModelFetchAuth::Platforms => {
let auth = auth.ok_or_else(|| { fetch_platform_models_blocking(endpoints, auth, platform_keys)
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);
}
} }
} }
}
fn fetch_custom_endpoint_models_blocking(
endpoints: &crate::agent::config::EndpointsConfig,
auth: Option<&KimiAuth>,
) -> Result<FetchModelsResult, BackendError> {
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()?; let response = request.send()?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status().as_u16(); let status = response.status().as_u16();
@@ -759,11 +777,7 @@ pub(crate) fn fetch_models_blocking(
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.map(|s| s.to_string()); .map(|s| s.to_string());
let models_response: ModelsResponse = response.json()?; let models_response: ModelsResponse = response.json()?;
tracing::info!( tracing::info!("Fetched {} models from {}", models_response.data.len(), url);
"Fetched {} models from {}",
models_response.data.len(),
source.url
);
let mut models = Vec::with_capacity(models_response.data.len()); let mut models = Vec::with_capacity(models_response.data.len());
for (idx, value) in models_response.data.into_iter().enumerate() { for (idx, value) in models_response.data.into_iter().enumerate() {
match parse_remote_model_value(&value, &inference_base_url) { 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<FetchModelsResult, BackendError> {
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<BackendError> = 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 <token>` → `{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<crate::agent::config::ModelEntryConfig>, Option<String>), 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. /// Parse a single model entry from the /models-v2 response.
/// Used by both initial model fetch and session-resume metadata refresh. /// 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()) .and_then(|v| v.as_array())
.map(|arr| kigi_sampling_types::parse_reasoning_effort_options(arr)) .map(|arr| kigi_sampling_types::parse_reasoning_effort_options(arr))
.unwrap_or_default(), .unwrap_or_default(),
capabilities: obj
.get("capabilities")
.and_then(|v| {
serde_json::from_value::<Vec<kigi_models::ModelCapability>>(v.clone()).ok()
})
.unwrap_or_default(),
supports_backend_search: obj supports_backend_search: obj
.get("supportsBackendSearch") .get("supportsBackendSearch")
.or_else(|| obj.get("supports_backend_search")) .or_else(|| obj.get("supports_backend_search"))
@@ -1749,43 +1963,81 @@ mod tests {
"https://registry.acme.com/api/list-models" "https://registry.acme.com/api/list-models"
); );
} }
/// INVARIANT: the `/models` fetch URL + auth scheme match the auth mode — /// INVARIANT: each platform's `/models` URL matches its registry base —
/// Session/Deployment → cli-chat-proxy (Session auth), never the inference host; /// kimi-code → the subscription proxy (config override respected, else the
/// ApiKey → `xai_api_base_url` (ApiKey, public default when unset); a custom /// kigi-env default), moonshot platforms → their fixed bases — and the
/// models endpoint → that URL verbatim. /// cache-origin key encodes the enabled fetch plan without any secrets.
#[test] #[test]
#[serial_test::serial] #[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::config::EndpointsConfig;
use crate::agent::models::ModelFetchAuth; use crate::agent::models::{ModelFetchAuth, PlatformApiKeys};
for k in [ for k in [
"KIGI_CLI_CHAT_PROXY_BASE_URL", "KIGI_CLI_CHAT_PROXY_BASE_URL",
"KIGI_XAI_API_BASE_URL", "KIGI_CODE_BASE_URL",
"KIGI_MODELS_LIST_URL", "KIGI_MODELS_LIST_URL",
] { ] {
unsafe { std::env::remove_var(k) }; 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( &toml::from_str(
r#"[endpoints] r#"[endpoints]
xai_api_base_url = "https://inference.acme-corp.example/xai/v1""#, cli_chat_proxy_base_url = "https://proxy.acme.example/v1""#,
) )
.unwrap(), .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!( assert_eq!(
ListModelsEndpoint::from_endpoints(&default, ModelFetchAuth::ApiKey).url, platform_models_url(kigi_models::PlatformId::KimiCode, &proxied),
"https://api.x.ai/v1/models" "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( let custom = EndpointsConfig::from_config_value(
&toml::from_str( &toml::from_str(
r#"[endpoints] r#"[endpoints]
@@ -1793,9 +2045,15 @@ mod tests {
) )
.unwrap(), .unwrap(),
); );
let ep = ListModelsEndpoint::from_endpoints(&custom, ModelFetchAuth::Session); assert_eq!(
assert_eq!(ep.url, "https://models.acme.com/v1/models"); models_fetch_origin(
assert_eq!(ep.auth, EndpointAuth::ApiKey); &custom,
ModelFetchAuth::CustomEndpoint,
false,
&PlatformApiKeys::default(),
),
"https://models.acme.com/v1/models"
);
} }
/// REGRESSION: `grok setup` must send the deployment key to /// REGRESSION: `grok setup` must send the deployment key to
/// the proxy, never the inference endpoint. /// the proxy, never the inference endpoint.
+1 -1
View File
@@ -27,7 +27,7 @@ pub use client::{
BackendClient, BackendError, FetchModelsResult, FetchedBundle, fetch_bundle, BackendClient, BackendError, FetchModelsResult, FetchedBundle, fetch_bundle,
fetch_login_device_flow, fetch_settings_blocking, fetch_subagent_bundle, share_url, 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::{ pub use conversations_client::{
ConvError, ConvQuery, Conversation, ConversationsClient, ListConversationsPage, ConvError, ConvQuery, Conversation, ConversationsClient, ListConversationsPage,
UpdateConversationBody, UpdateConversationBody,
@@ -655,7 +655,7 @@ impl SessionActor {
let request = ConversationRequest { let request = ConversationRequest {
items, items,
tools: vec![], tools: vec![],
model: Some("grok-build".to_owned()), model: Some(crate::models::default_model().to_owned()),
temperature: Some(0.3), temperature: Some(0.3),
max_output_tokens: Some(1024), max_output_tokens: Some(1024),
..Default::default() ..Default::default()
@@ -507,7 +507,7 @@ impl SessionActor {
let model = match model_override { let model = match model_override {
Some(m) => m.to_owned(), Some(m) => m.to_owned(),
None => "grok-build".to_owned(), None => crate::models::default_model().to_owned(),
}; };
let request = ConversationRequest { let request = ConversationRequest {
@@ -569,10 +569,10 @@ impl SessionActor {
/// (`KIGI_PROMPT_SUGGESTIONS_MODEL`) > `[models] prompt_suggestion` /// (`KIGI_PROMPT_SUGGESTIONS_MODEL`) > `[models] prompt_suggestion`
/// (config.toml) > remote `prompt_suggestion_model` (remote settings) > /// (config.toml) > remote `prompt_suggestion_model` (remote settings) >
/// (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 /// tier except env is catalog-guarded against this shell's own model
/// catalog — when the effective model is not sampleable here (e.g. /// 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: /// entirely** instead of fired doomed. The session model is never used:
/// a per-turn background call must stay on the small model. /// a per-turn background call must stay on the small model.
/// Temperature, max_output_tokens, and /// Temperature, max_output_tokens, and
@@ -521,7 +521,8 @@ pub struct SessionInfoData {
/// Whether this model slug supports showing checkpoint identity (resolved model ID, fingerprint). /// Whether this model slug supports showing checkpoint identity (resolved model ID, fingerprint).
pub fn is_coding_model_slug(model: &str) -> bool { 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. /// 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() { fn should_show_model_fingerprint_truth_table() {
// Catalog opt-in shows the fingerprint even for a non-coding slug. // Catalog opt-in shows the fingerprint even for a non-coding slug.
assert!(should_show_model_fingerprint(true, "non-coding")); assert!(should_show_model_fingerprint(true, "non-coding"));
// Coding slugs always show, even without the catalog flag. // The default coding model always shows, by slug or managed key,
assert!(should_show_model_fingerprint(false, "grok-build")); // even without the catalog flag.
assert!(should_show_model_fingerprint(false, "grok-4.5")); 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. // Non-coding slug without the flag stays hidden.
assert!(!should_show_model_fingerprint(false, "some-other")); assert!(!should_show_model_fingerprint(false, "some-other"));
} }
@@ -5,7 +5,7 @@
//! the empty prompt input; Tab accepts it. Modelled on common coding-agent //! the empty prompt input; Tab accepts it. Modelled on common coding-agent
//! prompt suggestion features, but instead of replaying the full conversation prefix //! prompt suggestion features, but instead of replaying the full conversation prefix
//! it sends a *compact text-only transcript* — the call always routes to a //! 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`]), //! default, never the session model — see [`effective_suggest_model`]),
//! where the parent session's prompt cache would not apply anyway, so a //! where the parent session's prompt cache would not apply anyway, so a
//! small request wins on both cost and latency. //! 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 / /// Model used for suggestion calls when nothing pins one (no env /
/// `[models] prompt_suggestion` / remote setting / client hint — see /// `[models] prompt_suggestion` / remote setting / client hint — see
/// [`effective_suggest_model`]). Suggestion requests must stay on a small, /// [`effective_suggest_model`]). The Kimi catalog has no dedicated small
/// fast model: falling back to the session model would multiply the per-turn /// suggestion model, so this is the bundled default coding model; the
/// cost of the feature and add reasoning-model latency for a throwaway /// catalog guard still controls whether the request fires at all.
/// prediction. pub(crate) fn default_suggest_model() -> &'static str {
pub(crate) const DEFAULT_SUGGEST_MODEL: &str = "grok-build-0.1"; crate::models::default_model()
}
/// Resolve the model for one suggestion request, or `None` to skip the /// Resolve the model for one suggestion request, or `None` to skip the
/// request entirely (controlled disable). /// request entirely (controlled disable).
/// ///
/// Precedence: env pin > config.toml/remote pin > client hint (the request's /// Precedence: env pin > config.toml/remote pin > client hint (the request's
/// `model` param) > [`DEFAULT_SUGGEST_MODEL`]. Every tier except the env pin /// `model` param) > [`default_suggest_model`]. Every tier except the env pin
/// is catalog-guarded via `in_catalog`: [`DEFAULT_SUGGEST_MODEL`] /// is catalog-guarded via `in_catalog`: firing an unavailable pin or default
/// (`grok-build-0.1`) is API-key-only and excluded from OAuth catalogs, so /// would send a doomed per-turn request
/// firing it (or any unavailable pin) would send a doomed per-turn request
/// that can never render ghost text. Skipping keeps the per-turn cost at /// that can never render ghost text. Skipping keeps the per-turn cost at
/// zero; deliberately NOT a session-model fallback — a per-turn background /// 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 /// 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 { let (model, catalog_guarded) = match pin {
PromptSuggestModelPin::Env(m) => (m.as_str(), false), PromptSuggestModelPin::Env(m) => (m.as_str(), false),
PromptSuggestModelPin::Pinned(m) => (m.as_str(), true), 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) { if catalog_guarded && !in_catalog(model) {
return None; return None;
@@ -324,9 +324,9 @@ mod tests {
// No pin, no hint: the built-in default fires only when this shell's // No pin, no hint: the built-in default fires only when this shell's
// catalog can sample it. // catalog can sample it.
assert_eq!( 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(), .as_deref(),
Some(DEFAULT_SUGGEST_MODEL) Some(default_suggest_model())
); );
// OAuth catalogs exclude grok-build-0.1 → skip the request entirely, // OAuth catalogs exclude grok-build-0.1 → skip the request entirely,
// never a doomed call (and never the session model). // never a doomed call (and never the session model).
@@ -349,9 +349,9 @@ mod tests {
); );
// Blank hints are ignored: the default tier applies. // Blank hints are ignored: the default tier applies.
assert_eq!( 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(), .as_deref(),
Some(DEFAULT_SUGGEST_MODEL) Some(default_suggest_model())
); );
} }
@@ -1805,7 +1805,7 @@ fn format_session_info_hides_model_hash_for_noncoding_without_flag() {
} }
#[test] #[test]
fn format_session_info_shows_model_hash_for_coding_slug_without_flag() { 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.model_fingerprint = Some("abc123".into());
info.data.show_model_fingerprint = false; info.data.show_model_fingerprint = false;
let text = format_session_info(&info, None, false); let text = format_session_info(&info, None, false);
@@ -26,11 +26,13 @@ pub const PROMPT_SUGGESTIONS_ENV: &str = "KIGI_PROMPT_SUGGESTIONS";
/// `KIGI_PROMPT_SUGGESTIONS_MODEL=<model-id>`. /// `KIGI_PROMPT_SUGGESTIONS_MODEL=<model-id>`.
pub const PROMPT_SUGGESTIONS_MODEL_ENV: &str = "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 /// Preferred model for suggestion calls when the server catalog offers it.
/// (cheap + fast). The session model is never used: when this is absent /// The session model is never used: when this is absent from the catalog the
/// from the catalog the request carries no model hint and the shell /// request carries no model hint and the shell resolves (or skips) it — see
/// resolves (or skips) it — see [`resolve_model`]. /// [`resolve_model`].
pub const PREFERRED_SUGGESTION_MODEL: &str = "grok-build-0.1"; pub fn preferred_suggestion_model() -> &'static str {
kigi_shell::models::default_model()
}
/// Controller for the predicted-next-prompt ghost text. /// Controller for the predicted-next-prompt ghost text.
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -165,11 +167,11 @@ pub fn resolve_model(models: &crate::acp::model_state::ModelState) -> Option<Str
return Some(model); return Some(model);
} }
let preferred = let preferred =
agent_client_protocol::ModelId::new(std::sync::Arc::from(PREFERRED_SUGGESTION_MODEL)); agent_client_protocol::ModelId::new(std::sync::Arc::from(preferred_suggestion_model()));
models models
.available .available
.contains_key(&preferred) .contains_key(&preferred)
.then(|| PREFERRED_SUGGESTION_MODEL.to_owned()) .then(|| preferred_suggestion_model().to_owned())
} }
#[cfg(test)] #[cfg(test)]
@@ -205,7 +205,7 @@ pub struct IntraCompactionConfig {
/// Override order: agent field (non-blank) → service YAML (inter) / /// Override order: agent field (non-blank) → service YAML (inter) /
/// agent config → this constant. See crate-level docs on /// agent config → this constant. See crate-level docs on
/// [`crate::DEFAULT_COMPACTION_MODEL_NAME`]. /// [`crate::DEFAULT_COMPACTION_MODEL_NAME`].
pub const DEFAULT_COMPACTION_MODEL_NAME: &str = "grok-4.20"; pub const DEFAULT_COMPACTION_MODEL_NAME: &str = "kimi-for-coding";
impl IntraCompactionConfig { impl IntraCompactionConfig {
/// Agent field; blank/`None` → [`DEFAULT_COMPACTION_MODEL_NAME`]. /// Agent field; blank/`None` → [`DEFAULT_COMPACTION_MODEL_NAME`].