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:
Generated
+1
@@ -6132,6 +6132,7 @@ dependencies = [
|
||||
name = "kigi-models"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kigi-env",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -32,6 +32,46 @@ stores is touched.
|
||||
Authentication and inference against Kimi Code (milestone M1), the
|
||||
compatibility surface (M2), and release distribution (M3) are in progress.
|
||||
|
||||
## Providers and API keys
|
||||
|
||||
Kigi talks to a fixed three-platform registry:
|
||||
|
||||
| Platform id | Base URL | Auth |
|
||||
| ------------- | ------------------------------- | --------------------------- |
|
||||
| `kimi-code` | `https://api.kimi.com/coding/v1` | Kimi Code subscription OAuth (`kigi login`) |
|
||||
| `moonshot-cn` | `https://api.moonshot.cn/v1` | Moonshot open-platform API key |
|
||||
| `moonshot-ai` | `https://api.moonshot.ai/v1` | Moonshot open-platform API key |
|
||||
|
||||
Moonshot API keys come from the environment or `~/.kigi/config.toml`
|
||||
(environment wins; values are never logged):
|
||||
|
||||
```sh
|
||||
export KIGI_MOONSHOT_API_KEY=sk-... # applies to both open platforms
|
||||
export KIGI_MOONSHOT_CN_API_KEY=sk-... # platform-scoped, beats the generic name
|
||||
export KIGI_MOONSHOT_AI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
```toml
|
||||
# ~/.kigi/config.toml
|
||||
[platforms.moonshot-cn]
|
||||
api_key = "sk-..."
|
||||
|
||||
[platforms.moonshot-ai]
|
||||
api_key = "sk-..."
|
||||
```
|
||||
|
||||
On login and on startup Kigi syncs each configured platform's model list
|
||||
from `GET {base}/models` and shows the merged catalog in the model picker
|
||||
(catalog keys are `{platform_id}/{model_id}`). If the sync fails, the last
|
||||
cached catalog is used; with no cache, a small built-in fallback list
|
||||
applies. Model selection resolves as
|
||||
`--model` CLI flag > `KIGI_DEFAULT_MODEL` > `[models] default` in
|
||||
config.toml > server-delivered list > built-in fallback.
|
||||
|
||||
`KIGI_CODE_BASE_URL` re-points the subscription platform (useful for
|
||||
testing); `KIGI_MOONSHOT_CN_BASE_URL` / `KIGI_MOONSHOT_AI_BASE_URL` are the
|
||||
equivalent dev/test overrides for the open platforms.
|
||||
|
||||
## Building from source
|
||||
|
||||
```sh
|
||||
|
||||
@@ -3,9 +3,10 @@ license = "Apache-2.0"
|
||||
name = "kigi-models"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Default model IDs for the grok CLI, loaded from the embedded default_models.json."
|
||||
description = "Kimi platform registry, /models wire contract, capability derivation, and the bundled offline fallback catalog (default_models.json)."
|
||||
|
||||
[dependencies]
|
||||
kigi-env = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
{
|
||||
"default": "grok-build",
|
||||
"web_search": "grok-4.20-multi-agent",
|
||||
"image_description": "grok-build",
|
||||
"session_summary": "grok-build",
|
||||
"default": "kimi-for-coding",
|
||||
"models": [
|
||||
{
|
||||
"model": "grok-build",
|
||||
"name": "Grok Build",
|
||||
"description": "Best for advanced coding tasks",
|
||||
"context_window": 500000,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.95,
|
||||
"api_backend": "responses",
|
||||
"id": "kimi-code/kimi-for-coding",
|
||||
"model": "kimi-for-coding",
|
||||
"name": "Kimi for Coding",
|
||||
"description": "Kimi Code subscription coding model (offline fallback entry)",
|
||||
"context_window": 262144,
|
||||
"capabilities": ["thinking", "image_in", "video_in"],
|
||||
"supported_in_api": false
|
||||
},
|
||||
{
|
||||
"id": "moonshot-cn/kimi-k2-turbo-preview",
|
||||
"model": "kimi-k2-turbo-preview",
|
||||
"name": "Kimi K2 Turbo (moonshot.cn)",
|
||||
"description": "Moonshot open platform model (offline fallback entry)",
|
||||
"context_window": 262144,
|
||||
"capabilities": ["thinking", "image_in", "video_in"]
|
||||
},
|
||||
{
|
||||
"id": "moonshot-cn/kimi-k2-thinking-turbo",
|
||||
"model": "kimi-k2-thinking-turbo",
|
||||
"name": "Kimi K2 Thinking Turbo (moonshot.cn)",
|
||||
"description": "Moonshot open platform model (offline fallback entry)",
|
||||
"context_window": 262144,
|
||||
"capabilities": ["thinking", "always_thinking", "image_in", "video_in"]
|
||||
},
|
||||
{
|
||||
"id": "moonshot-ai/kimi-k2-turbo-preview",
|
||||
"model": "kimi-k2-turbo-preview",
|
||||
"name": "Kimi K2 Turbo (moonshot.ai)",
|
||||
"description": "Moonshot open platform model (offline fallback entry)",
|
||||
"context_window": 262144,
|
||||
"capabilities": ["thinking", "image_in", "video_in"]
|
||||
},
|
||||
{
|
||||
"id": "moonshot-ai/kimi-k2-thinking-turbo",
|
||||
"model": "kimi-k2-thinking-turbo",
|
||||
"name": "Kimi K2 Thinking Turbo (moonshot.ai)",
|
||||
"description": "Moonshot open platform model (offline fallback entry)",
|
||||
"context_window": 262144,
|
||||
"capabilities": ["thinking", "always_thinking", "image_in", "video_in"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,293 @@
|
||||
//! Default model IDs loaded from `default_models.json` at runtime.
|
||||
//! Edit that JSON file to change them.
|
||||
//! Kimi model catalog primitives (PRD F2/F4).
|
||||
//!
|
||||
//! This crate owns:
|
||||
//! - the fixed three-platform registry ([`PlatformId`]): the Kimi Code
|
||||
//! subscription channel plus the two Moonshot open platforms;
|
||||
//! - the `GET {base}/models` wire contract ([`WireModel`]) and the capability
|
||||
//! derivation ported from kimi-cli `auth/platforms.py`;
|
||||
//! - the managed catalog key format `{platform_id}/{model_id}`;
|
||||
//! - the bundled OFFLINE-LAST-RESORT fallback catalog
|
||||
//! (`default_models.json`), used only when the live `/models` sync fails
|
||||
//! AND no disk cache is usable. Every id in that file is sourced from
|
||||
//! kimi-cli 1.49.0 (see the module docs on [`DEFAULT_MODELS_JSON`]).
|
||||
//!
|
||||
//! At runtime each model is resolved via:
|
||||
//! CLI flag > ENV var > config.toml > remote settings > these defaults
|
||||
//! CLI flag > ENV var > config.toml > server-delivered > these defaults
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// The raw JSON, embedded at compile time. Re-exported through the
|
||||
/// `kigi_shell::models` facade and consumed by `agent::config`, so it must
|
||||
/// be `pub` (was `pub(crate)` when this lived inside the shell crate).
|
||||
// ── Platform registry (PRD F2) ──────────────────────────────────────────────
|
||||
|
||||
/// Env var holding the moonshot-cn API key (wins over the generic name).
|
||||
pub const MOONSHOT_CN_API_KEY_ENV: &str = "KIGI_MOONSHOT_CN_API_KEY";
|
||||
/// Env var holding the moonshot-ai API key (wins over the generic name).
|
||||
pub const MOONSHOT_AI_API_KEY_ENV: &str = "KIGI_MOONSHOT_AI_API_KEY";
|
||||
/// Generic moonshot API key env var, applied to BOTH open platforms when the
|
||||
/// platform-scoped name is unset.
|
||||
pub const MOONSHOT_API_KEY_ENV: &str = "KIGI_MOONSHOT_API_KEY";
|
||||
/// Base-URL override for moonshot-cn (dev/test escape hatch mirroring
|
||||
/// `KIGI_CODE_BASE_URL`; production uses the compiled default).
|
||||
pub const MOONSHOT_CN_BASE_URL_ENV: &str = "KIGI_MOONSHOT_CN_BASE_URL";
|
||||
/// Base-URL override for moonshot-ai (dev/test escape hatch mirroring
|
||||
/// `KIGI_CODE_BASE_URL`; production uses the compiled default).
|
||||
pub const MOONSHOT_AI_BASE_URL_ENV: &str = "KIGI_MOONSHOT_AI_BASE_URL";
|
||||
|
||||
/// Env override when set and non-blank, else the compiled default.
|
||||
fn env_or(var: &str, compiled: &str) -> String {
|
||||
match std::env::var(var) {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => compiled.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed platform registry. Kigi talks to exactly these three model
|
||||
/// providers; there is no dynamic provider registration (PRD F2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum PlatformId {
|
||||
/// Kimi Code subscription (OAuth bearer from the F1 device flow).
|
||||
KimiCode,
|
||||
/// Moonshot AI open platform, api.moonshot.cn (API key).
|
||||
MoonshotCn,
|
||||
/// Moonshot AI open platform, api.moonshot.ai (API key).
|
||||
MoonshotAi,
|
||||
}
|
||||
|
||||
impl PlatformId {
|
||||
/// All platforms, in catalog precedence order: the subscription channel
|
||||
/// first so "default model = first list item" favors it when present.
|
||||
pub const ALL: [PlatformId; 3] = [Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::KimiCode => "kimi-code",
|
||||
Self::MoonshotCn => "moonshot-cn",
|
||||
Self::MoonshotAi => "moonshot-ai",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<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");
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -68,3 +347,181 @@ pub fn default_session_summary_model() -> &'static str {
|
||||
.as_deref()
|
||||
.unwrap_or(&DEFAULTS.default)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn platform_ids_round_trip() {
|
||||
for p in PlatformId::ALL {
|
||||
assert_eq!(PlatformId::parse(p.as_str()), Some(p));
|
||||
}
|
||||
assert_eq!(PlatformId::parse("openai"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_base_urls() {
|
||||
assert_eq!(
|
||||
PlatformId::MoonshotCn.base_url(),
|
||||
"https://api.moonshot.cn/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
PlatformId::MoonshotAi.base_url(),
|
||||
"https://api.moonshot.ai/v1"
|
||||
);
|
||||
// Subscription base honors the env override.
|
||||
let _g = kigi_env::EnvVarGuard::set(kigi_env::CODE_BASE_URL_ENV, "https://mock.test/v1");
|
||||
assert_eq!(PlatformId::KimiCode.base_url(), "https://mock.test/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_model_key_format_and_parse() {
|
||||
let key = PlatformId::MoonshotCn.managed_model_key("kimi-k2-turbo-preview");
|
||||
assert_eq!(key, "moonshot-cn/kimi-k2-turbo-preview");
|
||||
assert_eq!(
|
||||
parse_managed_model_key(&key),
|
||||
Some((PlatformId::MoonshotCn, "kimi-k2-turbo-preview"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_managed_model_key("kimi-code/kimi-for-coding"),
|
||||
Some((PlatformId::KimiCode, "kimi-for-coding"))
|
||||
);
|
||||
// No prefix / unknown platform / empty model id → None.
|
||||
assert_eq!(parse_managed_model_key("kimi-for-coding"), None);
|
||||
assert_eq!(parse_managed_model_key("openai/gpt"), None);
|
||||
assert_eq!(parse_managed_model_key("moonshot-cn/"), None);
|
||||
}
|
||||
|
||||
/// Capability derivation table ported from kimi-cli platforms.py.
|
||||
#[test]
|
||||
fn capability_derivation_table() {
|
||||
use ModelCapability::*;
|
||||
let cases: &[(&str, bool, bool, bool, &[ModelCapability])] = &[
|
||||
// supports_reasoning only → thinking
|
||||
("some-model", true, false, false, &[Thinking]),
|
||||
// no flags, no name rules → empty
|
||||
("some-model", false, false, false, &[]),
|
||||
// "thinking" in id → thinking + always_thinking
|
||||
(
|
||||
"kimi-latest-thinking",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
&[Thinking, AlwaysThinking],
|
||||
),
|
||||
// image/video flags map directly
|
||||
("some-model", false, true, true, &[ImageIn, VideoIn]),
|
||||
// kimi-k2 prefix → thinking + image_in + video_in
|
||||
(
|
||||
"kimi-k2-turbo-preview",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
&[Thinking, ImageIn, VideoIn],
|
||||
),
|
||||
// kimi-k2 prefix + "thinking" in id → all four
|
||||
(
|
||||
"kimi-k2-thinking-turbo",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
&[Thinking, AlwaysThinking, ImageIn, VideoIn],
|
||||
),
|
||||
// Case-insensitive id rules (mirrors `.lower()` in platforms.py)
|
||||
(
|
||||
"Kimi-K2-Thinking",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
&[Thinking, AlwaysThinking, ImageIn, VideoIn],
|
||||
),
|
||||
];
|
||||
for (id, reasoning, image, video, want) in cases {
|
||||
let got = derive_capabilities(id, *reasoning, *image, *video);
|
||||
assert_eq!(&got, want, "capabilities for {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_thinking_from_capabilities() {
|
||||
use ModelCapability::*;
|
||||
assert!(default_thinking_enabled(&[Thinking]));
|
||||
assert!(default_thinking_enabled(&[AlwaysThinking]));
|
||||
assert!(default_thinking_enabled(&[Thinking, ImageIn]));
|
||||
assert!(!default_thinking_enabled(&[ImageIn, VideoIn]));
|
||||
assert!(!default_thinking_enabled(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonshot_prefix_filter_applies_only_to_open_platforms() {
|
||||
let listing = vec![
|
||||
WireModel {
|
||||
id: "kimi-k2-turbo-preview".into(),
|
||||
context_length: 262_144,
|
||||
supports_reasoning: false,
|
||||
supports_image_in: false,
|
||||
supports_video_in: false,
|
||||
display_name: None,
|
||||
},
|
||||
WireModel {
|
||||
id: "moonshot-v1-8k".into(),
|
||||
context_length: 8_192,
|
||||
supports_reasoning: false,
|
||||
supports_image_in: false,
|
||||
supports_video_in: false,
|
||||
display_name: None,
|
||||
},
|
||||
];
|
||||
let filtered = filter_allowed_models(PlatformId::MoonshotCn, listing.clone());
|
||||
assert_eq!(
|
||||
filtered.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,11 +173,12 @@ pub(crate) async fn run_auto_update_checker(
|
||||
async fn prefetch_models(agent_config: &AgentConfig) -> Option<IndexMap<String, ModelEntry>> {
|
||||
let auth = agent_config.create_auth_manager().current();
|
||||
let endpoints = agent_config.endpoints.clone();
|
||||
let fetch_auth = ModelFetchAuth::resolve(&endpoints, auth.is_some());
|
||||
let fetch_auth = ModelFetchAuth::resolve(&endpoints);
|
||||
let platform_keys = crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms);
|
||||
|
||||
if auth.is_some() || endpoints.has_custom_endpoint() || fetch_auth != ModelFetchAuth::Session {
|
||||
if auth.is_some() || endpoints.has_custom_endpoint() || platform_keys.any() {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
prefetch_models_blocking(&endpoints, auth.as_ref(), fetch_auth)
|
||||
prefetch_models_blocking(&endpoints, auth.as_ref(), fetch_auth, &platform_keys)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
@@ -617,7 +618,9 @@ pub async fn run_leader(
|
||||
|
||||
let auth_for_prefetch: Option<KimiAuth> = auth.clone();
|
||||
let endpoints_for_prefetch = agent_config.endpoints.clone();
|
||||
let fetch_auth_for_prefetch = ModelFetchAuth::resolve(&endpoints_for_prefetch, auth.is_some());
|
||||
let fetch_auth_for_prefetch = ModelFetchAuth::resolve(&endpoints_for_prefetch);
|
||||
let platform_keys_for_prefetch =
|
||||
crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms);
|
||||
// The shared pair helper owns the remote_fetch gate for both halves, so a
|
||||
// disabled knob cannot block leader readiness on settings retries.
|
||||
let (prefetched_models, remote_settings) = tokio::task::spawn_blocking(move || {
|
||||
@@ -625,6 +628,7 @@ pub async fn run_leader(
|
||||
&endpoints_for_prefetch,
|
||||
auth_for_prefetch.as_ref(),
|
||||
fetch_auth_for_prefetch,
|
||||
&platform_keys_for_prefetch,
|
||||
)
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -854,7 +854,7 @@ pub struct ModelsConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_description: Option<String>,
|
||||
/// Model pin for next-prompt suggestions (tab-autocomplete ghost text).
|
||||
/// Unset = remote pin, then the client hint / built-in `grok-build-0.1`
|
||||
/// Unset = remote pin, then the client hint / built-in bundled-model
|
||||
/// default with the catalog guard; see `ModelOverrideConfig::resolve`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_suggestion: Option<String>,
|
||||
@@ -900,6 +900,98 @@ pub struct ModelsConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_tool_calls: Option<bool>,
|
||||
}
|
||||
/// `[platforms.<id>]` section from config.toml (PRD F2): API keys for the
|
||||
/// fixed platform registry ([`kigi_models::PlatformId`]).
|
||||
///
|
||||
/// ```toml
|
||||
/// [platforms.moonshot-cn]
|
||||
/// api_key = "sk-..."
|
||||
///
|
||||
/// [platforms.moonshot-ai]
|
||||
/// api_key = "sk-..."
|
||||
/// ```
|
||||
///
|
||||
/// Env vars win over the config file:
|
||||
/// `KIGI_MOONSHOT_CN_API_KEY` / `KIGI_MOONSHOT_AI_API_KEY` (platform-scoped)
|
||||
/// then `KIGI_MOONSHOT_API_KEY` (both open platforms). The subscription
|
||||
/// platform (`kimi-code`) authenticates via OAuth and takes no API key.
|
||||
///
|
||||
/// SECURITY: key values are never logged and never re-serialized
|
||||
/// (`Config.platforms` is `skip_serializing`); only presence booleans may
|
||||
/// appear in diagnostics.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct PlatformsConfig {
|
||||
#[serde(flatten)]
|
||||
pub entries: IndexMap<String, PlatformCredentialConfig>,
|
||||
}
|
||||
|
||||
impl PlatformsConfig {
|
||||
/// The config-file API key for `platform`, blank-as-unset. Unknown
|
||||
/// platform ids in `[platforms.*]` are warned about at load
|
||||
/// ([`Self::warn_unknown_platforms`]) and never resolve.
|
||||
pub fn config_api_key(&self, platform: kigi_models::PlatformId) -> Option<String> {
|
||||
self.entries
|
||||
.get(platform.as_str())
|
||||
.and_then(|e| e.api_key.as_deref())
|
||||
.filter(|k| !k.trim().is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Warn (once per load) about `[platforms.<id>]` tables that don't name a
|
||||
/// registry platform, so a typo like `moonshot_cn` fails loudly instead of
|
||||
/// silently never matching. Key values are not logged.
|
||||
pub fn warn_unknown_platforms(&self) {
|
||||
for id in self.entries.keys() {
|
||||
if kigi_models::PlatformId::parse(id).is_none() {
|
||||
tracing::warn!(
|
||||
platform = %id,
|
||||
known = ?kigi_models::PlatformId::ALL
|
||||
.iter()
|
||||
.map(|p| p.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
"[platforms.{id}] does not match any registry platform; its api_key is ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `[platforms.<id>]` table.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PlatformCredentialConfig {
|
||||
/// API key for this platform. NEVER logged; never re-serialized.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve the API key for an open-platform registry entry:
|
||||
/// platform-scoped env > generic `KIGI_MOONSHOT_API_KEY` env > config file.
|
||||
/// `None` for the OAuth platform and when nothing is configured.
|
||||
/// The returned value must never be logged.
|
||||
pub(crate) fn resolve_platform_api_key(
|
||||
platform: kigi_models::PlatformId,
|
||||
platforms: &PlatformsConfig,
|
||||
) -> Option<String> {
|
||||
resolve_platform_api_key_with(platform, platforms, |name| std::env::var(name).ok())
|
||||
}
|
||||
|
||||
/// Testable core of [`resolve_platform_api_key`] with an injected getenv.
|
||||
pub(crate) fn resolve_platform_api_key_with(
|
||||
platform: kigi_models::PlatformId,
|
||||
platforms: &PlatformsConfig,
|
||||
mut getenv: impl FnMut(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
for name in platform.api_key_env_names() {
|
||||
if let Some(value) = getenv(name)
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
platforms.config_api_key(platform)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HarnessConfig {
|
||||
@@ -998,7 +1090,7 @@ impl SandboxSettingsConfig {
|
||||
/// [suggestions]
|
||||
/// enabled = true
|
||||
/// ai_enabled = true
|
||||
/// ai_model = "grok-build"
|
||||
/// ai_model = "kimi-for-coding"
|
||||
/// debounce_ms = 50
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
@@ -1042,7 +1134,7 @@ impl SuggestionsConfig {
|
||||
None,
|
||||
)
|
||||
.map(|r| r.value)
|
||||
.unwrap_or_else(|| "grok-build".to_owned())
|
||||
.unwrap_or_else(|| crate::models::default_model().to_owned())
|
||||
}
|
||||
}
|
||||
/// `[storage]` section from config.toml.
|
||||
@@ -1152,6 +1244,11 @@ pub struct Config {
|
||||
pub cli: CliConfig,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub models: ModelsConfig,
|
||||
/// `[platforms.<id>]` — per-platform credentials for the fixed Kimi
|
||||
/// platform registry (PRD F2). `skip_serializing` so API keys are never
|
||||
/// re-emitted by any Config serialization.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub platforms: PlatformsConfig,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub harness: HarnessConfig,
|
||||
#[serde(default, skip_serializing)]
|
||||
@@ -1356,7 +1453,7 @@ pub struct Config {
|
||||
/// (`default_session_summary_model`) when unset; see `ModelOverrideConfig::resolve`.
|
||||
#[serde(skip)]
|
||||
pub session_summary_model: Option<String>,
|
||||
/// Image describe model (`grok-build` default via `ModelOverrideConfig::resolve`).
|
||||
/// Image describe model (bundled default via `ModelOverrideConfig::resolve`).
|
||||
#[serde(skip)]
|
||||
pub image_description_model: Option<String>,
|
||||
/// Next-prompt suggestion model pin (`env > [models] prompt_suggestion >
|
||||
@@ -1541,6 +1638,7 @@ impl Default for Config {
|
||||
paths: PathsConfig::default(),
|
||||
cli: CliConfig::default(),
|
||||
models: ModelsConfig::default(),
|
||||
platforms: PlatformsConfig::default(),
|
||||
harness: HarnessConfig::default(),
|
||||
remote: RemoteConfig::default(),
|
||||
hub: HubConfig::default(),
|
||||
@@ -1678,6 +1776,7 @@ impl Config {
|
||||
}
|
||||
config.config_models = config_models;
|
||||
config.model_override_warnings = model_override_warnings;
|
||||
config.platforms.warn_unknown_platforms();
|
||||
if config.client_version.is_none() {
|
||||
config.client_version = Self::default().client_version;
|
||||
}
|
||||
@@ -2795,11 +2894,61 @@ pub fn resolve_model_list(
|
||||
}
|
||||
apply_global_extra_headers(&mut resolved, &cfg.models);
|
||||
apply_global_scalar_defaults(&mut resolved, &cfg.models);
|
||||
apply_platform_credentials(&mut resolved, &cfg.platforms);
|
||||
for entry in resolved.values_mut() {
|
||||
entry.info.derive_reasoning_effort_fields();
|
||||
}
|
||||
resolved
|
||||
}
|
||||
/// Layer 8 of [`resolve_model_list`]: wire the fixed platform registry's
|
||||
/// credentials into open-platform entries (PRD F2). Entries are recognized by
|
||||
/// their `{platform_id}/{model_id}` catalog id.
|
||||
///
|
||||
/// - `env_key` defaults to the platform's `KIGI_MOONSHOT_*` env names so an
|
||||
/// env-provided key resolves at request time.
|
||||
/// - a `[platforms.<id>].api_key` from config.toml is stamped only when no
|
||||
/// env name currently resolves, preserving env > config precedence
|
||||
/// (`first_own_credential` checks `api_key` before `env_key`).
|
||||
///
|
||||
/// A per-model `[model.*]` `api_key`/`env_key` always wins (stamped earlier;
|
||||
/// this layer never overwrites). In-memory only: the models disk cache
|
||||
/// persists the *pre-resolution* fetched entries, so config-file keys never
|
||||
/// reach disk. Key values are never logged.
|
||||
fn apply_platform_credentials(
|
||||
resolved: &mut IndexMap<String, ModelEntry>,
|
||||
platforms: &PlatformsConfig,
|
||||
) {
|
||||
for (key, entry) in resolved.iter_mut() {
|
||||
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||
let Some((platform, _)) = kigi_models::parse_managed_model_key(id) else {
|
||||
continue;
|
||||
};
|
||||
if platform.uses_oauth() {
|
||||
continue;
|
||||
}
|
||||
if entry.env_key.is_none() {
|
||||
entry.env_key = Some(EnvKeys::new(platform.api_key_env_names().iter().copied()));
|
||||
}
|
||||
let env_resolves = entry
|
||||
.env_key
|
||||
.as_ref()
|
||||
.is_some_and(|k| k.resolve_value().is_some());
|
||||
if entry.api_key.is_none()
|
||||
&& !env_resolves
|
||||
&& let Some(config_key) = platforms.config_api_key(platform)
|
||||
{
|
||||
tracing::debug!(
|
||||
model_key = %key, platform = platform.as_str(),
|
||||
"stamped [platforms] config api_key onto open-platform entry"
|
||||
);
|
||||
entry.api_key = Some(config_key);
|
||||
}
|
||||
// A credentialed open-platform entry is usable by API-key users.
|
||||
if entry.has_own_credentials() {
|
||||
entry.info.supported_in_api = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Layer 6 of [`resolve_model_list`]: fold the global `[models].extra_headers`
|
||||
/// into every model as a base. The presence check is case-insensitive because
|
||||
/// the sampler lowers these into an `http::HeaderMap`, so a global `X-Foo` must
|
||||
@@ -2909,6 +3058,9 @@ struct DefaultModelJson {
|
||||
supports_reasoning_effort: bool,
|
||||
#[serde(default)]
|
||||
reasoning_efforts: Vec<ReasoningEffortOption>,
|
||||
/// Kimi capability set (PRD F4), sourced per entry (see kigi-models docs).
|
||||
#[serde(default)]
|
||||
capabilities: Vec<kigi_models::ModelCapability>,
|
||||
/// When false, only OAuth users see this in the picker.
|
||||
#[serde(default = "default_true")]
|
||||
supported_in_api: bool,
|
||||
@@ -2943,14 +3095,28 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
|
||||
m.id
|
||||
);
|
||||
let key = m.id.clone().unwrap_or_else(|| m.model.clone());
|
||||
// Bundled entries are keyed `{platform_id}/{model_id}` (PRD F4);
|
||||
// each routes to its platform's base URL. The subscription
|
||||
// platform honors the endpoint overrides; the open platforms get
|
||||
// their env-key names so a moonshot key is usable offline.
|
||||
let platform = kigi_models::parse_managed_model_key(&key).map(|(p, _)| p);
|
||||
let base_url = match platform {
|
||||
Some(kigi_models::PlatformId::KimiCode) | None => {
|
||||
endpoints.resolve_inference_base_url()
|
||||
}
|
||||
Some(open) => open.base_url(),
|
||||
};
|
||||
let env_key = platform
|
||||
.filter(|p| !p.uses_oauth())
|
||||
.map(|p| EnvKeys::new(p.api_key_env_names().iter().copied()));
|
||||
let context_window = m
|
||||
.context_window
|
||||
.unwrap_or_else(|| NonZeroU64::new(200_000).expect("200000 is non-zero"));
|
||||
let config = ModelEntryConfig {
|
||||
id: m.id,
|
||||
model: m.model,
|
||||
base_url: endpoints.resolve_inference_base_url(),
|
||||
api_base_url: Some(endpoints.xai_api_base_url.clone()),
|
||||
base_url,
|
||||
api_base_url: None,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
context_window,
|
||||
@@ -2965,7 +3131,7 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
|
||||
inference_idle_timeout_secs: m.inference_idle_timeout_secs,
|
||||
max_retries: None,
|
||||
api_key: None,
|
||||
env_key: None,
|
||||
env_key,
|
||||
extra_headers: IndexMap::new(),
|
||||
use_concise: false,
|
||||
hidden: m.hidden,
|
||||
@@ -2973,6 +3139,7 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
|
||||
reasoning_effort: m.reasoning_effort,
|
||||
supports_reasoning_effort: m.supports_reasoning_effort,
|
||||
reasoning_efforts: m.reasoning_efforts,
|
||||
capabilities: m.capabilities,
|
||||
supports_backend_search: m.supports_backend_search,
|
||||
compactions_remaining: m.compactions_remaining,
|
||||
compaction_at_tokens: m.compaction_at_tokens,
|
||||
@@ -3028,6 +3195,9 @@ pub struct ModelEntryConfig {
|
||||
/// above are derived from this list when it is non-empty.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub reasoning_efforts: Vec<ReasoningEffortOption>,
|
||||
/// Kimi capability set (PRD F4); see [`ModelInfo::capabilities`].
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub capabilities: Vec<kigi_models::ModelCapability>,
|
||||
/// Extra headers to send with requests to this model's endpoint.
|
||||
/// Useful for BYOK (Bring Your Own Key) scenarios.
|
||||
/// Example: { "x-anthropic-api-key" = "sk-ant-..." }
|
||||
@@ -3152,6 +3322,9 @@ pub struct ConfigModelOverride {
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
pub supports_reasoning_effort: Option<bool>,
|
||||
pub reasoning_efforts: Vec<ReasoningEffortOption>,
|
||||
/// Kimi capability override; merges only when non-empty (cannot express
|
||||
/// "override to empty", same as `reasoning_efforts`).
|
||||
pub capabilities: Vec<kigi_models::ModelCapability>,
|
||||
pub supports_backend_search: Option<bool>,
|
||||
/// Aliases must be registered in `config_model_override_parse::ALIASES`;
|
||||
/// serde rejects a table that contains both spellings otherwise.
|
||||
@@ -3233,6 +3406,9 @@ impl ConfigModelOverride {
|
||||
if !self.reasoning_efforts.is_empty() {
|
||||
entry.info.reasoning_efforts = self.reasoning_efforts.clone();
|
||||
}
|
||||
if !self.capabilities.is_empty() {
|
||||
entry.info.capabilities = self.capabilities.clone();
|
||||
}
|
||||
if let Some(v) = self.supports_backend_search {
|
||||
entry.info.supports_backend_search = v;
|
||||
}
|
||||
@@ -3318,6 +3494,11 @@ pub struct ModelInfo {
|
||||
/// Per-model reasoning-effort menu (source of truth); legacy fields derived from it.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub reasoning_efforts: Vec<ReasoningEffortOption>,
|
||||
/// Kimi capability set derived from the `/models` listing (PRD F4);
|
||||
/// see [`kigi_models::derive_capabilities`]. Empty when the source
|
||||
/// (bundled JSON, `[model.*]`, remote) declared none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub capabilities: Vec<kigi_models::ModelCapability>,
|
||||
pub supports_backend_search: bool,
|
||||
/// Per-model config for the `x-compactions-remaining` header; `None` disables it.
|
||||
pub compactions_remaining: Option<CompactionsRemaining>,
|
||||
@@ -3362,6 +3543,7 @@ impl ModelInfo {
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -3397,6 +3579,7 @@ impl ModelInfo {
|
||||
reasoning_effort: entry.reasoning_effort,
|
||||
supports_reasoning_effort: entry.supports_reasoning_effort,
|
||||
reasoning_efforts: entry.reasoning_efforts.clone(),
|
||||
capabilities: entry.capabilities.clone(),
|
||||
supports_backend_search: entry.supports_backend_search,
|
||||
compactions_remaining: entry.compactions_remaining,
|
||||
compaction_at_tokens: entry.compaction_at_tokens,
|
||||
@@ -4036,6 +4219,7 @@ pub fn resolve_aux_model_sampling_config(
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -4248,6 +4432,7 @@ fn resolve_hidden_default_web_search_sampling_config(
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -4415,6 +4600,9 @@ mod tests {
|
||||
use super::*;
|
||||
use kigi_test_support::EnvGuard;
|
||||
use serial_test::serial;
|
||||
/// Catalog key of the bundled fallback default (`default_models.json`):
|
||||
/// `{platform_id}/{model_id}` for `crate::models::default_model()`.
|
||||
const BUNDLED_DEFAULT_KEY: &str = "kimi-code/kimi-for-coding";
|
||||
#[test]
|
||||
fn main_cli_tools_override_preserves_profile_injection_policy() {
|
||||
let overrides = CliAgentOverrides {
|
||||
@@ -4850,6 +5038,7 @@ reasoning_effort = "low"
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -5802,6 +5991,7 @@ reasoning_effort = "low"
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -5961,6 +6151,7 @@ reasoning_effort = "low"
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -6412,6 +6603,7 @@ reasoning_effort = "low"
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -6592,30 +6784,39 @@ reasoning_effort = "low"
|
||||
fn e2e_default_model_with_session_routes_to_proxy() {
|
||||
let (_, models) = resolve_models_from_toml("", None);
|
||||
let model = models
|
||||
.get(crate::models::default_model())
|
||||
.get(BUNDLED_DEFAULT_KEY)
|
||||
.expect("default model should exist");
|
||||
let sampling = resolve_sampling(model, Some("session-token-123"));
|
||||
assert_eq!(sampling.api_key.as_deref(), Some("session-token-123"));
|
||||
assert_eq!(
|
||||
sampling.base_url, "https://api.kimi.com/coding/v1",
|
||||
"session auth should route to the subscription endpoint, not api.x.ai"
|
||||
"session auth should route to the subscription endpoint"
|
||||
);
|
||||
assert_eq!(
|
||||
sampling.model, "kimi-for-coding",
|
||||
"wire slug, not catalog key"
|
||||
);
|
||||
}
|
||||
/// F2 acceptance seam: with ONLY a moonshot API key configured (no
|
||||
/// subscription login), the bundled open-platform entry resolves usable
|
||||
/// credentials routed at the moonshot base — nothing platform-specific is
|
||||
/// left for the sampler (F3).
|
||||
#[test]
|
||||
#[serial]
|
||||
fn e2e_default_model_with_external_api_key_routes_to_api_xai() {
|
||||
fn e2e_moonshot_env_key_routes_to_moonshot_base() {
|
||||
let (_, models) = resolve_models_from_toml("", None);
|
||||
let model = models
|
||||
.get(crate::models::default_model())
|
||||
.expect("default model should exist");
|
||||
unsafe { std::env::set_var("XAI_API_KEY", "xai-external-key") };
|
||||
.get("moonshot-ai/kimi-k2-turbo-preview")
|
||||
.expect("bundled moonshot fallback entry should exist");
|
||||
unsafe { std::env::set_var("KIGI_MOONSHOT_API_KEY", "sk-moonshot-generic") };
|
||||
let sampling = resolve_sampling(model, None);
|
||||
assert_eq!(sampling.api_key.as_deref(), Some("xai-external-key"));
|
||||
assert_eq!(sampling.api_key.as_deref(), Some("sk-moonshot-generic"));
|
||||
assert_eq!(
|
||||
sampling.base_url, "https://api.x.ai/v1",
|
||||
"external API key should route to api.x.ai via api_base_url"
|
||||
sampling.base_url, "https://api.moonshot.ai/v1",
|
||||
"moonshot key must route to the open-platform base"
|
||||
);
|
||||
unsafe { std::env::remove_var("XAI_API_KEY") };
|
||||
assert_eq!(sampling.model, "kimi-k2-turbo-preview");
|
||||
unsafe { std::env::remove_var("KIGI_MOONSHOT_API_KEY") };
|
||||
}
|
||||
#[test]
|
||||
fn e2e_user_config_overrides_prefetched_model() {
|
||||
@@ -6711,7 +6912,7 @@ reasoning_effort = "low"
|
||||
let (_, models) = resolve_models_from_toml(
|
||||
&format!(
|
||||
r#"
|
||||
[model.acme-grok]
|
||||
[model.acme-kimi]
|
||||
model = "{dm}"
|
||||
base_url = "https://inference.example.com/v1"
|
||||
context_window = 200000
|
||||
@@ -6720,13 +6921,16 @@ reasoning_effort = "low"
|
||||
),
|
||||
None,
|
||||
);
|
||||
assert!(models.contains_key(dm), "default entry should still exist");
|
||||
assert!(
|
||||
models.contains_key("acme-grok"),
|
||||
models.contains_key(BUNDLED_DEFAULT_KEY),
|
||||
"default entry should still exist"
|
||||
);
|
||||
assert!(
|
||||
models.contains_key("acme-kimi"),
|
||||
"user entry with different key should also exist"
|
||||
);
|
||||
let default = models.get(dm).unwrap();
|
||||
let user = models.get("acme-grok").unwrap();
|
||||
let default = models.get(BUNDLED_DEFAULT_KEY).unwrap();
|
||||
let user = models.get("acme-kimi").unwrap();
|
||||
assert_eq!(default.info.model, user.info.model, "same model field");
|
||||
assert_ne!(
|
||||
default.info.base_url, user.info.base_url,
|
||||
@@ -6770,7 +6974,7 @@ reasoning_effort = "low"
|
||||
let cfg = Config::default();
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
assert!(
|
||||
resolved.contains_key(crate::models::default_model()),
|
||||
resolved.contains_key(BUNDLED_DEFAULT_KEY),
|
||||
"default model should be present when using default endpoint"
|
||||
);
|
||||
}
|
||||
@@ -6814,30 +7018,24 @@ reasoning_effort = "low"
|
||||
}
|
||||
#[test]
|
||||
fn e2e_enterprise_endpoints_plus_partial_model_override() {
|
||||
let dm = crate::models::default_model();
|
||||
let (_, models) = resolve_models_from_toml(
|
||||
&format!(
|
||||
r#"
|
||||
[endpoints]
|
||||
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1"
|
||||
xai_api_base_url = "https://enterprise-api.acme.com/v1"
|
||||
|
||||
[model."{dm}"]
|
||||
[model."{BUNDLED_DEFAULT_KEY}"]
|
||||
api_key = "acme-api-key"
|
||||
"#,
|
||||
),
|
||||
None,
|
||||
);
|
||||
let model = models.get(dm).expect("model should exist");
|
||||
let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist");
|
||||
assert_eq!(
|
||||
model.info.base_url, "https://enterprise-proxy.acme.com/v1",
|
||||
"base_url must inherit from [endpoints], not stale default"
|
||||
);
|
||||
assert_eq!(model.api_key.as_deref(), Some("acme-api-key"));
|
||||
assert_eq!(
|
||||
model.api_base_url.as_deref(),
|
||||
Some("https://enterprise-api.acme.com/v1"),
|
||||
);
|
||||
let sampling = resolve_sampling(model, Some("session-token"));
|
||||
assert_eq!(
|
||||
sampling.api_key.as_deref(),
|
||||
@@ -6855,22 +7053,20 @@ reasoning_effort = "low"
|
||||
r#"
|
||||
[endpoints]
|
||||
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1"
|
||||
xai_api_base_url = "https://enterprise-api.acme.com/v1"
|
||||
"#,
|
||||
None,
|
||||
);
|
||||
let model = models
|
||||
.get(crate::models::default_model())
|
||||
.expect("model should exist");
|
||||
let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist");
|
||||
assert_eq!(
|
||||
model.info.base_url, "https://enterprise-proxy.acme.com/v1",
|
||||
"default model should use enterprise cli_chat_proxy_base_url"
|
||||
);
|
||||
assert_eq!(
|
||||
model.api_base_url.as_deref(),
|
||||
Some("https://enterprise-api.acme.com/v1"),
|
||||
"default model should use enterprise xai_api_base_url"
|
||||
);
|
||||
// The open-platform fallback entries keep their fixed moonshot bases;
|
||||
// only the subscription entry follows the proxy override.
|
||||
let moonshot = models
|
||||
.get("moonshot-cn/kimi-k2-turbo-preview")
|
||||
.expect("bundled moonshot entry should exist");
|
||||
assert_eq!(moonshot.info.base_url, "https://api.moonshot.cn/v1");
|
||||
}
|
||||
/// Unset every env var that `EndpointsConfig::default()` reads for endpoints,
|
||||
/// so the cli-chat-proxy resolver tests below are deterministic regardless of
|
||||
@@ -9496,6 +9692,7 @@ default = "grok-4.5"
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -9512,7 +9709,6 @@ default = "grok-4.5"
|
||||
}
|
||||
#[test]
|
||||
fn global_extra_headers_apply_to_model_without_override() {
|
||||
let dm = crate::models::default_model();
|
||||
let (_, models) = resolve_models_from_toml(
|
||||
r#"
|
||||
[models]
|
||||
@@ -9520,7 +9716,9 @@ default = "grok-4.5"
|
||||
"#,
|
||||
None,
|
||||
);
|
||||
let model = models.get(dm).expect("default model should exist");
|
||||
let model = models
|
||||
.get(BUNDLED_DEFAULT_KEY)
|
||||
.expect("default model should exist");
|
||||
assert_eq!(
|
||||
model
|
||||
.info
|
||||
@@ -9771,11 +9969,11 @@ default = "grok-4.5"
|
||||
fn resolve_model_list_inherits_context_window_from_default_when_prefetched_has_fallback() {
|
||||
let cfg = Config::default();
|
||||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||||
let entry = prefetch_model_entry("grok-build", default_cw, ApiBackend::default());
|
||||
let entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, default_cw, ApiBackend::default());
|
||||
let mut prefetched = IndexMap::new();
|
||||
prefetched.insert("grok-build".to_owned(), entry);
|
||||
prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry);
|
||||
let resolved = resolve_model_list(&cfg, Some(prefetched));
|
||||
let entry = resolved.get("grok-build").expect("model must exist");
|
||||
let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist");
|
||||
assert_ne!(
|
||||
entry.info.context_window.get(),
|
||||
default_cw,
|
||||
@@ -9786,11 +9984,11 @@ default = "grok-4.5"
|
||||
fn resolve_model_list_does_not_override_explicitly_set_context_window() {
|
||||
let cfg = Config::default();
|
||||
let explicit_cw = 65_536;
|
||||
let entry = prefetch_model_entry("grok-build", explicit_cw, ApiBackend::default());
|
||||
let entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, explicit_cw, ApiBackend::default());
|
||||
let mut prefetched = IndexMap::new();
|
||||
prefetched.insert("grok-build".to_owned(), entry);
|
||||
prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry);
|
||||
let resolved = resolve_model_list(&cfg, Some(prefetched));
|
||||
let entry = resolved.get("grok-build").expect("model must exist");
|
||||
let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist");
|
||||
assert_eq!(
|
||||
entry.info.context_window.get(),
|
||||
explicit_cw,
|
||||
@@ -9847,21 +10045,21 @@ default = "grok-4.5"
|
||||
let cfg = Config::default();
|
||||
let mut defs = default_model_entries(&EndpointsConfig::default());
|
||||
let mut p = IndexMap::new();
|
||||
if let Some(e) = defs.shift_remove("grok-build") {
|
||||
p.insert("grok-build".to_string(), e);
|
||||
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
|
||||
p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
|
||||
}
|
||||
let resolved = resolve_model_list(&cfg, Some(p));
|
||||
assert!(resolved.contains_key("grok-build"));
|
||||
assert!(resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
||||
let no_p = resolve_model_list(&cfg, None);
|
||||
assert!(no_p.contains_key("grok-build"));
|
||||
assert!(no_p.contains_key(BUNDLED_DEFAULT_KEY));
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_prefetch_visibility_matches_auth_and_server_list() {
|
||||
let cfg = Config::default();
|
||||
let mut defs = default_model_entries(&EndpointsConfig::default());
|
||||
let mut p = IndexMap::new();
|
||||
if let Some(e) = defs.shift_remove("grok-build") {
|
||||
p.insert("grok-build".to_string(), e);
|
||||
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
|
||||
p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
|
||||
}
|
||||
let resolved = resolve_model_list(&cfg, Some(p));
|
||||
let sess: Vec<_> = resolved
|
||||
@@ -9873,7 +10071,10 @@ default = "grok-4.5"
|
||||
.filter(|e| e.visible_for_auth(false))
|
||||
.collect();
|
||||
assert_eq!(sess.len(), 1);
|
||||
assert!(api.is_empty());
|
||||
assert!(
|
||||
api.is_empty(),
|
||||
"the subscription entry (supported_in_api=false) must stay hidden from API-key users"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_keeps_prefetch_only_entries_and_prunes_defaults() {
|
||||
@@ -9883,17 +10084,17 @@ default = "grok-4.5"
|
||||
p.insert("secret-xyz".to_string(), e);
|
||||
let resolved = resolve_model_list(&cfg, Some(p));
|
||||
assert!(resolved.contains_key("secret-xyz"));
|
||||
assert!(!resolved.contains_key("grok-build"));
|
||||
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_prefetch_replaces_bundled_entirely() {
|
||||
let cfg = Config::default();
|
||||
let mut p = IndexMap::new();
|
||||
let e = prefetch_model_entry("grok-4.5", 500_000, ApiBackend::Responses);
|
||||
p.insert("grok-4.5".to_string(), e);
|
||||
let e = prefetch_model_entry("kimi-fresh", 500_000, ApiBackend::Responses);
|
||||
p.insert("kimi-fresh".to_string(), e);
|
||||
let resolved = resolve_model_list(&cfg, Some(p));
|
||||
assert!(resolved.contains_key("grok-4.5"));
|
||||
assert!(!resolved.contains_key("grok-build"));
|
||||
assert!(resolved.contains_key("kimi-fresh"));
|
||||
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_empty_prefetch_yields_empty_base() {
|
||||
@@ -9901,15 +10102,16 @@ default = "grok-4.5"
|
||||
let resolved = resolve_model_list(&cfg, Some(IndexMap::new()));
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
/// Regression: enterprise managed config aliases grok-build to their own
|
||||
/// endpoint with env_key. The bundled grok-build has supported_in_api=false.
|
||||
/// The config overlay must be visible to API-key users (env_key = BYOK).
|
||||
/// Regression: enterprise managed config aliases the bundled subscription
|
||||
/// entry to their own endpoint with env_key. The bundled entry has
|
||||
/// supported_in_api=false. The config overlay must be visible to API-key
|
||||
/// users (env_key = BYOK).
|
||||
#[test]
|
||||
fn byok_config_overlay_visible_to_api_key_users() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[model.grok-build]
|
||||
model = "grok-4.5"
|
||||
[model."kimi-code/kimi-for-coding"]
|
||||
model = "kimi-for-coding"
|
||||
base_url = "https://inference.company.com/v1"
|
||||
env_key = "COMPANY_TOKEN"
|
||||
"#,
|
||||
@@ -9917,7 +10119,9 @@ default = "grok-4.5"
|
||||
.unwrap();
|
||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
let entry = resolved.get("grok-build").expect("grok-build must exist");
|
||||
let entry = resolved
|
||||
.get(BUNDLED_DEFAULT_KEY)
|
||||
.expect("bundled default must exist");
|
||||
assert!(
|
||||
entry.visible_for_auth(false),
|
||||
"BYOK config entry must be visible to API-key users — \
|
||||
@@ -9930,19 +10134,83 @@ default = "grok-4.5"
|
||||
fn plain_config_overlay_preserves_bundled_visibility() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[model.grok-build]
|
||||
[model."kimi-code/kimi-for-coding"]
|
||||
context_window = 300000
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
let entry = resolved.get("grok-build").expect("grok-build must exist");
|
||||
let entry = resolved
|
||||
.get(BUNDLED_DEFAULT_KEY)
|
||||
.expect("bundled default must exist");
|
||||
assert!(
|
||||
!entry.visible_for_auth(false),
|
||||
"non-BYOK config overlay must preserve bundled supported_in_api=false"
|
||||
);
|
||||
}
|
||||
/// PRD F2: a `[platforms.<id>].api_key` from config.toml is stamped onto
|
||||
/// that platform's catalog entries (in-memory only), making them usable
|
||||
/// and API-key-visible — and only onto that platform.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn platforms_config_key_stamps_matching_open_platform_entries() {
|
||||
let _cn = EnvGuard::unset(kigi_models::MOONSHOT_CN_API_KEY_ENV);
|
||||
let _ai = EnvGuard::unset(kigi_models::MOONSHOT_AI_API_KEY_ENV);
|
||||
let _gen = EnvGuard::unset(kigi_models::MOONSHOT_API_KEY_ENV);
|
||||
let raw: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[platforms.moonshot-cn]
|
||||
api_key = "sk-from-config"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
|
||||
let cn = resolved
|
||||
.get("moonshot-cn/kimi-k2-turbo-preview")
|
||||
.expect("bundled moonshot-cn entry");
|
||||
assert_eq!(cn.api_key.as_deref(), Some("sk-from-config"));
|
||||
assert!(
|
||||
cn.has_own_credentials(),
|
||||
"config key must make the entry sampleable (F2 acceptance)"
|
||||
);
|
||||
assert!(
|
||||
cn.visible_for_auth(false),
|
||||
"credentialed open-platform entry must be visible to API-key users"
|
||||
);
|
||||
|
||||
let ai = resolved
|
||||
.get("moonshot-ai/kimi-k2-turbo-preview")
|
||||
.expect("bundled moonshot-ai entry");
|
||||
assert!(
|
||||
ai.api_key.is_none(),
|
||||
"the cn key must not leak onto the ai platform"
|
||||
);
|
||||
|
||||
let code = resolved
|
||||
.get("kimi-code/kimi-for-coding")
|
||||
.expect("bundled subscription entry");
|
||||
assert!(
|
||||
code.api_key.is_none() && code.env_key.is_none(),
|
||||
"the OAuth platform takes no API key"
|
||||
);
|
||||
}
|
||||
/// F2 acceptance: with ONLY a moonshot key (env), the api-key auth method
|
||||
/// is advertised (no login screen) because the catalog has a credentialed
|
||||
/// entry.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn moonshot_env_key_advertises_api_key_auth_method() {
|
||||
let _gen = EnvGuard::set(kigi_models::MOONSHOT_API_KEY_ENV, "sk-only-moonshot");
|
||||
let cfg = Config::default();
|
||||
let models = resolve_model_list(&cfg, None);
|
||||
assert!(
|
||||
crate::agent::auth_method::should_advertise_xai_api_key(models.values()),
|
||||
"a moonshot env key alone must advertise the API-key auth method"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn mcp_liveness_watchers_default_is_true() {
|
||||
|
||||
@@ -531,6 +531,7 @@ mod tests {
|
||||
description: Some("Deep reasoning".to_string()),
|
||||
default: true,
|
||||
}],
|
||||
capabilities: vec![kigi_models::ModelCapability::Thinking],
|
||||
supports_backend_search: Some(false),
|
||||
compactions_remaining: Some(CompactionsRemaining::Fixed(1)),
|
||||
compaction_at_tokens: Some(CompactionAtTokens::Fixed(100_000)),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1368,12 +1368,20 @@ impl acp::Agent for MvpAgent {
|
||||
.take(10).collect::< Vec < _ >> (),
|
||||
"load_session: restoring persisted model (debug)"
|
||||
);
|
||||
let is_grok_build = persisted_model.0.starts_with("grok-build");
|
||||
let same_family_fallback = if is_grok_build {
|
||||
available.keys().find(|id| id.0.starts_with("grok-build")).cloned()
|
||||
} else {
|
||||
available.keys().find(|id| !id.0.starts_with("grok-build")).cloned()
|
||||
};
|
||||
// "Same family" = same platform: catalog keys are
|
||||
// `{platform_id}/{model_id}` (PRD F4), so prefer a replacement from
|
||||
// the platform the persisted model belonged to (its credentials are
|
||||
// known-good) before falling back across platforms.
|
||||
let persisted_platform =
|
||||
kigi_models::parse_managed_model_key(persisted_model.0.as_ref()).map(|(p, _)| p);
|
||||
let same_family_fallback = available
|
||||
.keys()
|
||||
.find(|id| {
|
||||
kigi_models::parse_managed_model_key(id.0.as_ref()).map(|(p, _)| p)
|
||||
== persisted_platform
|
||||
})
|
||||
.cloned()
|
||||
.or_else(|| available.keys().next().cloned());
|
||||
let selectable_catalog_key = selectable_catalog_key_for_persisted(
|
||||
&models,
|
||||
&available,
|
||||
|
||||
@@ -1675,6 +1675,7 @@ fn find_model_by_id_prefers_key_then_falls_back_to_slug() {
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
|
||||
@@ -166,13 +166,19 @@ async fn handle_connection(ws: WebSocket, state: Arc<ServerState>, peer_addr: So
|
||||
.spawn(move || {
|
||||
// Prefetch models before creating the runtime (blocking is OK here)
|
||||
let auth = agent_config.create_auth_manager().current();
|
||||
let fetch_auth =
|
||||
ModelFetchAuth::resolve(&agent_config.endpoints, auth.is_some());
|
||||
let fetch_auth = ModelFetchAuth::resolve(&agent_config.endpoints);
|
||||
let platform_keys =
|
||||
crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms);
|
||||
let prefetched_models = if auth.is_some()
|
||||
|| agent_config.endpoints.has_custom_endpoint()
|
||||
|| fetch_auth != ModelFetchAuth::Session
|
||||
|| platform_keys.any()
|
||||
{
|
||||
prefetch_models_blocking(&agent_config.endpoints, auth.as_ref(), fetch_auth)
|
||||
prefetch_models_blocking(
|
||||
&agent_config.endpoints,
|
||||
auth.as_ref(),
|
||||
fetch_auth,
|
||||
&platform_keys,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -3076,6 +3076,7 @@ fn test_model_entry(model_id: &str) -> crate::agent::config::ModelEntry {
|
||||
reasoning_effort: None,
|
||||
supports_reasoning_effort: false,
|
||||
reasoning_efforts: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! cargo run --bin trace_classify -- \
|
||||
//! --trace /path/to/trace-<id>-all-turns.json \
|
||||
//! [--output out.jsonl] \
|
||||
//! [--model grok-4.5] \
|
||||
//! [--model kimi-for-coding] \
|
||||
//! [--api-base-url https://api.x.ai/v1] \
|
||||
//! [--api-key <key> | $XAI_API_KEY | <kigi-home>/auth.json] \
|
||||
//! [--min-confidence 0.7] \
|
||||
@@ -44,7 +44,7 @@ struct Cli {
|
||||
|
||||
/// Model the classifier sampler calls. Must be a model the API key
|
||||
/// has access to.
|
||||
#[arg(long, default_value = "grok-4.5")]
|
||||
#[arg(long, default_value = "kimi-for-coding")]
|
||||
model: String,
|
||||
|
||||
/// Sampler base URL.
|
||||
|
||||
@@ -537,7 +537,7 @@ pub struct ModelOverrideConfig {
|
||||
pub web_search: String,
|
||||
/// `None` = current model.
|
||||
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>,
|
||||
/// Next-prompt suggestion model pin. Unlike the other overrides this does
|
||||
/// NOT fill a compiled default — see [`PromptSuggestModelPin`].
|
||||
@@ -561,10 +561,9 @@ impl Default for ModelOverrideConfig {
|
||||
/// Unlike the other auxiliary overrides this does not collapse to a plain
|
||||
/// model string: the consumer (`handle_suggest_prompt`) must distinguish
|
||||
/// an explicit pin from "unpinned" (where the client hint and the built-in
|
||||
/// `grok-build-0.1` default apply), and whether the pin came from the env
|
||||
/// default apply), and whether the pin came from the env
|
||||
/// escape hatch. Every effective model except an env pin is catalog-guarded —
|
||||
/// when the model is not in the shell's catalog (e.g. `grok-build-0.1` for
|
||||
/// OAuth users, whose catalogs exclude it) the per-turn suggestion request is
|
||||
/// when the model is not in the shell's catalog the per-turn suggestion request is
|
||||
/// skipped entirely rather than fired doomed. The env pin is deliberately
|
||||
/// exempt so `KIGI_PROMPT_SUGGESTIONS_MODEL` keeps working for models a
|
||||
/// catalog does not list (mirrors the pager, which forwards the env value
|
||||
@@ -596,7 +595,7 @@ fn non_empty_model_override(value: Option<&str>) -> Option<String> {
|
||||
impl ModelOverrideConfig {
|
||||
/// CLI flag > env var > config.toml > remote settings > compiled default.
|
||||
/// `image_description` and `session_summary` always resolve to `Some(_)`
|
||||
/// (default `grok-build`), never the session model.
|
||||
/// (the bundled default model), never the session model.
|
||||
/// `prompt_suggestion` resolves to a [`PromptSuggestModelPin`] instead of
|
||||
/// a model string (no CLI flag; the default and the catalog guard live at
|
||||
/// the consumer, `handle_suggest_prompt`).
|
||||
|
||||
@@ -193,10 +193,10 @@ struct SuggestPromptRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
/// Client hint for the suggestion model (the pager sends its env
|
||||
/// override, or `grok-build-0.1` when its catalog offers it). One tier
|
||||
/// override, or the bundled default when its catalog offers it). One tier
|
||||
/// of the shell-side resolution in
|
||||
/// `prompt_suggest::effective_suggest_model`: env > config.toml > remote
|
||||
/// > this hint > `grok-build-0.1` default, catalog-guarded (a
|
||||
/// > this hint > bundled default, catalog-guarded (a
|
||||
/// non-sampleable effective model skips the request; the session model
|
||||
/// is never used).
|
||||
#[serde(default)]
|
||||
|
||||
@@ -655,97 +655,115 @@ pub(crate) const DEFAULT_CONTEXT_WINDOW: u64 = 256_000;
|
||||
struct ModelsResponse {
|
||||
data: Vec<serde_json::Value>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EndpointAuth {
|
||||
ApiKey,
|
||||
Session,
|
||||
}
|
||||
struct ListModelsEndpoint {
|
||||
url: String,
|
||||
auth: EndpointAuth,
|
||||
}
|
||||
/// The `/v1/models` URL [`fetch_models_blocking`] hits for this
|
||||
/// endpoints/auth shape. Doubles as the models disk-cache origin key: cached
|
||||
/// entries embed absolute `base_url`s from the backend that served them, so a
|
||||
/// catalog fetched from one backend (env override, another deployment, a
|
||||
/// test's mock server) must be a cache miss for any other backend.
|
||||
pub(crate) fn models_list_url(
|
||||
/// The models-fetch origin key for this endpoints/auth shape. Used as the
|
||||
/// models disk-cache origin: cached entries embed absolute `base_url`s from
|
||||
/// the backend(s) that served them, so a catalog fetched against one fetch
|
||||
/// plan (env override, different set of platform credentials, a test's mock
|
||||
/// server) must be a cache miss for any other. Encodes URLs and enabled
|
||||
/// platform NAMES only — never credential values.
|
||||
pub(crate) fn models_fetch_origin(
|
||||
endpoints: &crate::agent::config::EndpointsConfig,
|
||||
fetch_auth: crate::agent::models::ModelFetchAuth,
|
||||
has_oauth: bool,
|
||||
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||||
) -> String {
|
||||
ListModelsEndpoint::from_endpoints(endpoints, fetch_auth).url
|
||||
match fetch_auth {
|
||||
crate::agent::models::ModelFetchAuth::CustomEndpoint => endpoints.resolve_models_list_url(),
|
||||
crate::agent::models::ModelFetchAuth::Platforms => {
|
||||
let parts: Vec<String> = enabled_platforms(has_oauth, platform_keys)
|
||||
.into_iter()
|
||||
.map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints)))
|
||||
.collect();
|
||||
format!("platforms[{}]", parts.join(";"))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ListModelsEndpoint {
|
||||
fn from_endpoints(
|
||||
endpoints: &crate::agent::config::EndpointsConfig,
|
||||
fetch_auth: crate::agent::models::ModelFetchAuth,
|
||||
) -> Self {
|
||||
if endpoints.has_custom_endpoint() {
|
||||
Self {
|
||||
url: endpoints.resolve_models_list_url(),
|
||||
auth: EndpointAuth::ApiKey,
|
||||
}
|
||||
} else if fetch_auth == crate::agent::models::ModelFetchAuth::ApiKey {
|
||||
Self {
|
||||
url: format!("{}/models", endpoints.xai_api_base_url),
|
||||
auth: EndpointAuth::ApiKey,
|
||||
}
|
||||
/// The platforms with usable credentials, in registry order (kimi-code first
|
||||
/// so "default model = first list item" favors the subscription).
|
||||
fn enabled_platforms(
|
||||
has_oauth: bool,
|
||||
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||||
) -> Vec<kigi_models::PlatformId> {
|
||||
kigi_models::PlatformId::ALL
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
if p.uses_oauth() {
|
||||
has_oauth
|
||||
} else {
|
||||
Self {
|
||||
url: endpoints.resolve_models_list_url(),
|
||||
auth: EndpointAuth::Session,
|
||||
}
|
||||
}
|
||||
platform_keys.key_for(*p).is_some()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Fetch models from an OpenAI-compatible `/v1/models` endpoint.
|
||||
/// Fetch result: model entries + optional etag from response.
|
||||
/// `{base}/models` for one platform. The subscription platform resolves its
|
||||
/// base through the endpoints config (`cli_chat_proxy_base_url` override,
|
||||
/// else `KIGI_CODE_BASE_URL` / production default via kigi-env); the open
|
||||
/// platforms use their fixed bases.
|
||||
fn platform_models_url(
|
||||
platform: kigi_models::PlatformId,
|
||||
endpoints: &crate::agent::config::EndpointsConfig,
|
||||
) -> String {
|
||||
let base = if platform.uses_oauth() {
|
||||
endpoints.proxy_url()
|
||||
} else {
|
||||
platform.base_url()
|
||||
};
|
||||
format!("{}/models", base.trim_end_matches('/'))
|
||||
}
|
||||
/// Fetch result: model entries + optional etag from the subscription platform.
|
||||
pub struct FetchModelsResult {
|
||||
pub models: Vec<crate::agent::config::ModelEntryConfig>,
|
||||
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(
|
||||
endpoints: &crate::agent::config::EndpointsConfig,
|
||||
auth: Option<&KimiAuth>,
|
||||
fetch_auth: crate::agent::models::ModelFetchAuth,
|
||||
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||||
) -> Result<FetchModelsResult, BackendError> {
|
||||
match fetch_auth {
|
||||
crate::agent::models::ModelFetchAuth::CustomEndpoint => {
|
||||
fetch_custom_endpoint_models_blocking(endpoints, auth)
|
||||
}
|
||||
crate::agent::models::ModelFetchAuth::Platforms => {
|
||||
fetch_platform_models_blocking(endpoints, auth, platform_keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fetch_custom_endpoint_models_blocking(
|
||||
endpoints: &crate::agent::config::EndpointsConfig,
|
||||
auth: Option<&KimiAuth>,
|
||||
) -> Result<FetchModelsResult, BackendError> {
|
||||
let client = crate::http::shared_blocking_client();
|
||||
let source = ListModelsEndpoint::from_endpoints(endpoints, fetch_auth);
|
||||
let url = endpoints.resolve_models_list_url();
|
||||
let inference_base_url = endpoints.resolve_inference_base_url();
|
||||
tracing::info!("Fetching models from {}", source.url);
|
||||
let mut request = client.get(&source.url);
|
||||
match source.auth {
|
||||
EndpointAuth::ApiKey => {
|
||||
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(),
|
||||
)
|
||||
BackendError::Auth("No API key for custom models endpoint. Set XAI_API_KEY.".into())
|
||||
})?;
|
||||
request = request.header("Authorization", format!("Bearer {}", api_key));
|
||||
}
|
||||
EndpointAuth::Session => {
|
||||
let auth = auth.ok_or_else(|| {
|
||||
BackendError::Auth("No auth credentials for cli-chat-proxy".into())
|
||||
})?;
|
||||
request = request
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header("X-XAI-Token-Auth", "xai-grok-cli")
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
);
|
||||
if let Some(email) = &auth.email {
|
||||
request = request.header("x-email", email);
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key));
|
||||
let response = request.send()?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
@@ -759,11 +777,7 @@ pub(crate) fn fetch_models_blocking(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let models_response: ModelsResponse = response.json()?;
|
||||
tracing::info!(
|
||||
"Fetched {} models from {}",
|
||||
models_response.data.len(),
|
||||
source.url
|
||||
);
|
||||
tracing::info!("Fetched {} models from {}", models_response.data.len(), url);
|
||||
let mut models = Vec::with_capacity(models_response.data.len());
|
||||
for (idx, value) in models_response.data.into_iter().enumerate() {
|
||||
match parse_remote_model_value(&value, &inference_base_url) {
|
||||
@@ -776,7 +790,201 @@ pub(crate) fn fetch_models_blocking(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(FetchModelsResult { models, etag })
|
||||
Ok(FetchModelsResult {
|
||||
models,
|
||||
etag,
|
||||
oauth_unauthorized: false,
|
||||
})
|
||||
}
|
||||
/// Registry fetch across all platforms with usable credentials.
|
||||
fn fetch_platform_models_blocking(
|
||||
endpoints: &crate::agent::config::EndpointsConfig,
|
||||
auth: Option<&KimiAuth>,
|
||||
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||||
) -> Result<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.
|
||||
/// Used by both initial model fetch and session-resume metadata refresh.
|
||||
@@ -882,6 +1090,12 @@ pub fn parse_remote_model_value(
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| kigi_sampling_types::parse_reasoning_effort_options(arr))
|
||||
.unwrap_or_default(),
|
||||
capabilities: obj
|
||||
.get("capabilities")
|
||||
.and_then(|v| {
|
||||
serde_json::from_value::<Vec<kigi_models::ModelCapability>>(v.clone()).ok()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
supports_backend_search: obj
|
||||
.get("supportsBackendSearch")
|
||||
.or_else(|| obj.get("supports_backend_search"))
|
||||
@@ -1749,43 +1963,81 @@ mod tests {
|
||||
"https://registry.acme.com/api/list-models"
|
||||
);
|
||||
}
|
||||
/// INVARIANT: the `/models` fetch URL + auth scheme match the auth mode —
|
||||
/// Session/Deployment → cli-chat-proxy (Session auth), never the inference host;
|
||||
/// ApiKey → `xai_api_base_url` (ApiKey, public default when unset); a custom
|
||||
/// models endpoint → that URL verbatim.
|
||||
/// INVARIANT: each platform's `/models` URL matches its registry base —
|
||||
/// kimi-code → the subscription proxy (config override respected, else the
|
||||
/// kigi-env default), moonshot platforms → their fixed bases — and the
|
||||
/// cache-origin key encodes the enabled fetch plan without any secrets.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn models_fetch_endpoint_matches_auth_mode() {
|
||||
fn platform_models_urls_and_fetch_origin() {
|
||||
use crate::agent::config::EndpointsConfig;
|
||||
use crate::agent::models::ModelFetchAuth;
|
||||
use crate::agent::models::{ModelFetchAuth, PlatformApiKeys};
|
||||
for k in [
|
||||
"KIGI_CLI_CHAT_PROXY_BASE_URL",
|
||||
"KIGI_XAI_API_BASE_URL",
|
||||
"KIGI_CODE_BASE_URL",
|
||||
"KIGI_MODELS_LIST_URL",
|
||||
] {
|
||||
unsafe { std::env::remove_var(k) };
|
||||
}
|
||||
let cfg = EndpointsConfig::from_config_value(
|
||||
let cfg = EndpointsConfig::from_config_value(&toml::Value::Table(Default::default()));
|
||||
assert_eq!(
|
||||
platform_models_url(kigi_models::PlatformId::KimiCode, &cfg),
|
||||
"https://api.kimi.com/coding/v1/models"
|
||||
);
|
||||
assert_eq!(
|
||||
platform_models_url(kigi_models::PlatformId::MoonshotCn, &cfg),
|
||||
"https://api.moonshot.cn/v1/models"
|
||||
);
|
||||
assert_eq!(
|
||||
platform_models_url(kigi_models::PlatformId::MoonshotAi, &cfg),
|
||||
"https://api.moonshot.ai/v1/models"
|
||||
);
|
||||
// Proxy override re-points the subscription platform only.
|
||||
let proxied = EndpointsConfig::from_config_value(
|
||||
&toml::from_str(
|
||||
r#"[endpoints]
|
||||
xai_api_base_url = "https://inference.acme-corp.example/xai/v1""#,
|
||||
cli_chat_proxy_base_url = "https://proxy.acme.example/v1""#,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let session = ListModelsEndpoint::from_endpoints(&cfg, ModelFetchAuth::Session);
|
||||
assert_eq!(session.url, "https://api.kimi.com/coding/v1/models");
|
||||
assert_eq!(session.auth, EndpointAuth::Session);
|
||||
let deployment = ListModelsEndpoint::from_endpoints(&cfg, ModelFetchAuth::Deployment);
|
||||
assert_eq!(deployment.url, "https://api.kimi.com/coding/v1/models");
|
||||
assert_eq!(deployment.auth, EndpointAuth::Session);
|
||||
let api = ListModelsEndpoint::from_endpoints(&cfg, ModelFetchAuth::ApiKey);
|
||||
assert_eq!(api.url, "https://inference.acme-corp.example/xai/v1/models");
|
||||
assert_eq!(api.auth, EndpointAuth::ApiKey);
|
||||
let default = EndpointsConfig::from_config_value(&toml::Value::Table(Default::default()));
|
||||
assert_eq!(
|
||||
ListModelsEndpoint::from_endpoints(&default, ModelFetchAuth::ApiKey).url,
|
||||
"https://api.x.ai/v1/models"
|
||||
platform_models_url(kigi_models::PlatformId::KimiCode, &proxied),
|
||||
"https://proxy.acme.example/v1/models"
|
||||
);
|
||||
assert_eq!(
|
||||
platform_models_url(kigi_models::PlatformId::MoonshotCn, &proxied),
|
||||
"https://api.moonshot.cn/v1/models"
|
||||
);
|
||||
|
||||
// Origin key: OAuth-only plan lists kimi-code only; adding a moonshot
|
||||
// key changes the plan (→ cache miss); the key VALUE never appears.
|
||||
let oauth_only = models_fetch_origin(
|
||||
&cfg,
|
||||
ModelFetchAuth::Platforms,
|
||||
true,
|
||||
&PlatformApiKeys::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
oauth_only,
|
||||
"platforms[kimi-code=https://api.kimi.com/coding/v1/models]"
|
||||
);
|
||||
let with_cn = models_fetch_origin(
|
||||
&cfg,
|
||||
ModelFetchAuth::Platforms,
|
||||
true,
|
||||
&crate::agent::models::PlatformApiKeys::test_keys(Some("sk-secret-cn"), None),
|
||||
);
|
||||
assert_ne!(
|
||||
oauth_only, with_cn,
|
||||
"enabling a platform must change the origin"
|
||||
);
|
||||
assert!(with_cn.contains("moonshot-cn=https://api.moonshot.cn/v1/models"));
|
||||
assert!(
|
||||
!with_cn.contains("sk-secret-cn"),
|
||||
"origin key must never embed credential values"
|
||||
);
|
||||
|
||||
// Custom endpoint mode → the explicit list URL verbatim.
|
||||
let custom = EndpointsConfig::from_config_value(
|
||||
&toml::from_str(
|
||||
r#"[endpoints]
|
||||
@@ -1793,9 +2045,15 @@ mod tests {
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let ep = ListModelsEndpoint::from_endpoints(&custom, ModelFetchAuth::Session);
|
||||
assert_eq!(ep.url, "https://models.acme.com/v1/models");
|
||||
assert_eq!(ep.auth, EndpointAuth::ApiKey);
|
||||
assert_eq!(
|
||||
models_fetch_origin(
|
||||
&custom,
|
||||
ModelFetchAuth::CustomEndpoint,
|
||||
false,
|
||||
&PlatformApiKeys::default(),
|
||||
),
|
||||
"https://models.acme.com/v1/models"
|
||||
);
|
||||
}
|
||||
/// REGRESSION: `grok setup` must send the deployment key to
|
||||
/// the proxy, never the inference endpoint.
|
||||
|
||||
@@ -27,7 +27,7 @@ pub use client::{
|
||||
BackendClient, BackendError, FetchModelsResult, FetchedBundle, fetch_bundle,
|
||||
fetch_login_device_flow, fetch_settings_blocking, fetch_subagent_bundle, share_url,
|
||||
};
|
||||
pub(crate) use client::{DEFAULT_CONTEXT_WINDOW, fetch_models_blocking, models_list_url};
|
||||
pub(crate) use client::{DEFAULT_CONTEXT_WINDOW, fetch_models_blocking, models_fetch_origin};
|
||||
pub use conversations_client::{
|
||||
ConvError, ConvQuery, Conversation, ConversationsClient, ListConversationsPage,
|
||||
UpdateConversationBody,
|
||||
|
||||
@@ -655,7 +655,7 @@ impl SessionActor {
|
||||
let request = ConversationRequest {
|
||||
items,
|
||||
tools: vec![],
|
||||
model: Some("grok-build".to_owned()),
|
||||
model: Some(crate::models::default_model().to_owned()),
|
||||
temperature: Some(0.3),
|
||||
max_output_tokens: Some(1024),
|
||||
..Default::default()
|
||||
|
||||
@@ -507,7 +507,7 @@ impl SessionActor {
|
||||
|
||||
let model = match model_override {
|
||||
Some(m) => m.to_owned(),
|
||||
None => "grok-build".to_owned(),
|
||||
None => crate::models::default_model().to_owned(),
|
||||
};
|
||||
|
||||
let request = ConversationRequest {
|
||||
@@ -569,10 +569,10 @@ impl SessionActor {
|
||||
/// (`KIGI_PROMPT_SUGGESTIONS_MODEL`) > `[models] prompt_suggestion`
|
||||
/// (config.toml) > remote `prompt_suggestion_model` (remote settings) >
|
||||
/// (config.toml) > remote `prompt_suggestion_model` (remote settings) >
|
||||
/// [`prompt_suggest::DEFAULT_SUGGEST_MODEL`] (`grok-build-0.1`). Every
|
||||
/// [`prompt_suggest::default_suggest_model`]. Every
|
||||
/// tier except env is catalog-guarded against this shell's own model
|
||||
/// catalog — when the effective model is not sampleable here (e.g.
|
||||
/// `grok-build-0.1` for OAuth users) the request is **skipped
|
||||
/// a model the catalog does not offer) the request is **skipped
|
||||
/// entirely** instead of fired doomed. The session model is never used:
|
||||
/// a per-turn background call must stay on the small model.
|
||||
/// Temperature, max_output_tokens, and
|
||||
|
||||
@@ -521,7 +521,8 @@ pub struct SessionInfoData {
|
||||
|
||||
/// Whether this model slug supports showing checkpoint identity (resolved model ID, fingerprint).
|
||||
pub fn is_coding_model_slug(model: &str) -> bool {
|
||||
matches!(model, "grok-build" | "grok-4.5")
|
||||
model == kigi_models::PlatformId::KimiCode.managed_model_key(crate::models::default_model())
|
||||
|| model == crate::models::default_model()
|
||||
}
|
||||
|
||||
/// Display gate for the model fingerprint: server/catalog opt-in OR the built-in coding-slug default.
|
||||
@@ -626,9 +627,13 @@ mod tests {
|
||||
fn should_show_model_fingerprint_truth_table() {
|
||||
// Catalog opt-in shows the fingerprint even for a non-coding slug.
|
||||
assert!(should_show_model_fingerprint(true, "non-coding"));
|
||||
// Coding slugs always show, even without the catalog flag.
|
||||
assert!(should_show_model_fingerprint(false, "grok-build"));
|
||||
assert!(should_show_model_fingerprint(false, "grok-4.5"));
|
||||
// The default coding model always shows, by slug or managed key,
|
||||
// even without the catalog flag.
|
||||
assert!(should_show_model_fingerprint(false, "kimi-for-coding"));
|
||||
assert!(should_show_model_fingerprint(
|
||||
false,
|
||||
"kimi-code/kimi-for-coding"
|
||||
));
|
||||
// Non-coding slug without the flag stays hidden.
|
||||
assert!(!should_show_model_fingerprint(false, "some-other"));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! the empty prompt input; Tab accepts it. Modelled on common coding-agent
|
||||
//! prompt suggestion features, but instead of replaying the full conversation prefix
|
||||
//! it sends a *compact text-only transcript* — the call always routes to a
|
||||
//! small dedicated model (configurable, [`DEFAULT_SUGGEST_MODEL`] by
|
||||
//! dedicated model (configurable, [`default_suggest_model`] by
|
||||
//! default, never the session model — see [`effective_suggest_model`]),
|
||||
//! where the parent session's prompt cache would not apply anyway, so a
|
||||
//! small request wins on both cost and latency.
|
||||
@@ -20,20 +20,20 @@ use crate::session::helpers::chat::floor_char_boundary;
|
||||
|
||||
/// Model used for suggestion calls when nothing pins one (no env /
|
||||
/// `[models] prompt_suggestion` / remote setting / client hint — see
|
||||
/// [`effective_suggest_model`]). Suggestion requests must stay on a small,
|
||||
/// fast model: falling back to the session model would multiply the per-turn
|
||||
/// cost of the feature and add reasoning-model latency for a throwaway
|
||||
/// prediction.
|
||||
pub(crate) const DEFAULT_SUGGEST_MODEL: &str = "grok-build-0.1";
|
||||
/// [`effective_suggest_model`]). The Kimi catalog has no dedicated small
|
||||
/// suggestion model, so this is the bundled default coding model; the
|
||||
/// catalog guard still controls whether the request fires at all.
|
||||
pub(crate) fn default_suggest_model() -> &'static str {
|
||||
crate::models::default_model()
|
||||
}
|
||||
|
||||
/// Resolve the model for one suggestion request, or `None` to skip the
|
||||
/// request entirely (controlled disable).
|
||||
///
|
||||
/// Precedence: env pin > config.toml/remote pin > client hint (the request's
|
||||
/// `model` param) > [`DEFAULT_SUGGEST_MODEL`]. Every tier except the env pin
|
||||
/// is catalog-guarded via `in_catalog`: [`DEFAULT_SUGGEST_MODEL`]
|
||||
/// (`grok-build-0.1`) is API-key-only and excluded from OAuth catalogs, so
|
||||
/// firing it (or any unavailable pin) would send a doomed per-turn request
|
||||
/// `model` param) > [`default_suggest_model`]. Every tier except the env pin
|
||||
/// is catalog-guarded via `in_catalog`: firing an unavailable pin or default
|
||||
/// would send a doomed per-turn request
|
||||
/// that can never render ghost text. Skipping keeps the per-turn cost at
|
||||
/// zero; deliberately NOT a session-model fallback — a per-turn background
|
||||
/// call must stay on a small cheap model. The env pin bypasses the guard so
|
||||
@@ -48,7 +48,7 @@ pub(crate) fn effective_suggest_model(
|
||||
let (model, catalog_guarded) = match pin {
|
||||
PromptSuggestModelPin::Env(m) => (m.as_str(), false),
|
||||
PromptSuggestModelPin::Pinned(m) => (m.as_str(), true),
|
||||
PromptSuggestModelPin::Unpinned => (client_hint.unwrap_or(DEFAULT_SUGGEST_MODEL), true),
|
||||
PromptSuggestModelPin::Unpinned => (client_hint.unwrap_or(default_suggest_model()), true),
|
||||
};
|
||||
if catalog_guarded && !in_catalog(model) {
|
||||
return None;
|
||||
@@ -324,9 +324,9 @@ mod tests {
|
||||
// No pin, no hint: the built-in default fires only when this shell's
|
||||
// catalog can sample it.
|
||||
assert_eq!(
|
||||
effective_suggest_model(&Pin::Unpinned, None, |m| m == DEFAULT_SUGGEST_MODEL)
|
||||
effective_suggest_model(&Pin::Unpinned, None, |m| m == default_suggest_model())
|
||||
.as_deref(),
|
||||
Some(DEFAULT_SUGGEST_MODEL)
|
||||
Some(default_suggest_model())
|
||||
);
|
||||
// OAuth catalogs exclude grok-build-0.1 → skip the request entirely,
|
||||
// never a doomed call (and never the session model).
|
||||
@@ -349,9 +349,9 @@ mod tests {
|
||||
);
|
||||
// Blank hints are ignored: the default tier applies.
|
||||
assert_eq!(
|
||||
effective_suggest_model(&Pin::Unpinned, Some(" "), |m| m == DEFAULT_SUGGEST_MODEL)
|
||||
effective_suggest_model(&Pin::Unpinned, Some(" "), |m| m == default_suggest_model())
|
||||
.as_deref(),
|
||||
Some(DEFAULT_SUGGEST_MODEL)
|
||||
Some(default_suggest_model())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1805,7 +1805,7 @@ fn format_session_info_hides_model_hash_for_noncoding_without_flag() {
|
||||
}
|
||||
#[test]
|
||||
fn format_session_info_shows_model_hash_for_coding_slug_without_flag() {
|
||||
let mut info = make_session_info("grok-build", None, 1000, 10000);
|
||||
let mut info = make_session_info("kimi-for-coding", None, 1000, 10000);
|
||||
info.data.model_fingerprint = Some("abc123".into());
|
||||
info.data.show_model_fingerprint = false;
|
||||
let text = format_session_info(&info, None, false);
|
||||
|
||||
@@ -26,11 +26,13 @@ pub const PROMPT_SUGGESTIONS_ENV: &str = "KIGI_PROMPT_SUGGESTIONS";
|
||||
/// `KIGI_PROMPT_SUGGESTIONS_MODEL=<model-id>`.
|
||||
pub const PROMPT_SUGGESTIONS_MODEL_ENV: &str = "KIGI_PROMPT_SUGGESTIONS_MODEL";
|
||||
|
||||
/// Preferred model for suggestion calls when the server catalog offers it
|
||||
/// (cheap + fast). The session model is never used: when this is absent
|
||||
/// from the catalog the request carries no model hint and the shell
|
||||
/// resolves (or skips) it — see [`resolve_model`].
|
||||
pub const PREFERRED_SUGGESTION_MODEL: &str = "grok-build-0.1";
|
||||
/// Preferred model for suggestion calls when the server catalog offers it.
|
||||
/// The session model is never used: when this is absent from the catalog the
|
||||
/// request carries no model hint and the shell resolves (or skips) it — see
|
||||
/// [`resolve_model`].
|
||||
pub fn preferred_suggestion_model() -> &'static str {
|
||||
kigi_shell::models::default_model()
|
||||
}
|
||||
|
||||
/// Controller for the predicted-next-prompt ghost text.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -165,11 +167,11 @@ pub fn resolve_model(models: &crate::acp::model_state::ModelState) -> Option<Str
|
||||
return Some(model);
|
||||
}
|
||||
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
|
||||
.available
|
||||
.contains_key(&preferred)
|
||||
.then(|| PREFERRED_SUGGESTION_MODEL.to_owned())
|
||||
.then(|| preferred_suggestion_model().to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -205,7 +205,7 @@ pub struct IntraCompactionConfig {
|
||||
/// Override order: agent field (non-blank) → service YAML (inter) /
|
||||
/// agent config → this constant. See crate-level docs on
|
||||
/// [`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 {
|
||||
/// Agent field; blank/`None` → [`DEFAULT_COMPACTION_MODEL_NAME`].
|
||||
|
||||
Reference in New Issue
Block a user