Files
Kigi-CLI/crates/codegen/kigi-models/src/lib.rs
T
ZacharyZhang-NY 23e94939c0 Add OpenAI platform: live model fetching with enrichment (provider 1)
The 4th registry row: id "openai", OPENAI_API_KEY env > auth.json
"openai" scope (login picker/paste/validation all registry-generic —
zero TUI changes needed, pinned by the picker test), base
https://api.openai.com/v1 with KIGI_OPENAI_BASE_URL override, Responses
dialect via the new PlatformWireApi spec field, enrichment-backed
metadata (wire_serves_metadata=false).

OpenAI's GET /v1/models returns bare ids and is polluted with
tts/whisper/embeddings entries: the listing is restricted to
enrichment-known TOOL-CALLING models (review caught that membership
alone admitted models.dev-known embeddings models, which would 400 on
every agentic request; dropped ids are debug-logged for launch-day
diagnosability). Context windows, effort menus, display names, and
thinking capability come from the enrichment pipeline — wiremock e2e
pins the full contract: polluted live listing + models.dev →
one Responses-backed chat model with a 400k documented context window.

Responses max-effort wiring (closes the P0c-1 debt): canonical effort
rides a CreateResponseWrapper sidecar and patch_reasoning_effort writes
it onto the serialized body at both send sites (all seven levels pinned,
xhigh/max distinct, summary preserved); normalize_effort_echo drops
echoes async-openai's typed enum cannot represent at both the non-stream
and SSE parse seams; the dead typed to_responses_api converter is
deleted. Kimi/moonshot stay byte-identical (ChatCompletions untouched,
wire_api maps to the same default; kimi wire tests green).

kimi-import now recognizes ANY registry platform host as built-in
(was hardcoded moonshot), covering openai and future rows.
2026-07-21 05:04:55 -04:00

872 lines
32 KiB
Rust

//! Kimi model catalog primitives (PRD F2/F4).
//!
//! This crate owns:
//! - the compiled-in platform registry ([`PlatformId`] + its spec rows): the
//! Kimi Code subscription channel plus the API-key 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 > server-delivered > these defaults
use std::sync::LazyLock;
pub mod enrichment;
// ── 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(),
}
}
/// Inference dialect a platform speaks. Leaf-safe mirror of the sampler's
/// `ApiBackend` (kigi-models must stay dependency-light); the shell maps it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlatformWireApi {
ChatCompletions,
Responses,
Messages,
}
/// Where a platform's base URL is resolved from.
enum BaseUrlSource {
/// The Kimi Code subscription base, owned by `kigi_env`
/// (honors `KIGI_CODE_BASE_URL`).
KigiEnvCoding,
/// Fixed compiled default with a dev/test env-var override.
EnvOr {
env: &'static str,
default: &'static str,
},
}
/// One row of the platform registry. All per-platform data lives in these
/// rows; [`PlatformId`] methods only read fields. Adding a platform touches
/// exactly four sites in this file: the enum variant, the `ALL` entry, the
/// `spec()` arm, and the row (plus quirk code where a provider deviates).
/// The `spec()` arm is compiler-enforced; the `ALL` entry is enforced by the
/// `all_covers_every_variant` test — a variant missing from `ALL` would
/// otherwise be silently unparseable and excluded from model sync.
struct PlatformSpec {
/// Wire id (auth method id, managed-model-key prefix, config key, and —
/// for API-key platforms — the auth.json scope the key is stored under).
id: &'static str,
display_name: &'static str,
base_url: BaseUrlSource,
/// True for OAuth-bearer subscription channels.
uses_oauth: bool,
/// Model-id prefixes admitted from this platform's `/models` listing.
/// `None` = no filtering (listing served pre-filtered).
allowed_model_prefixes: Option<&'static [&'static str]>,
/// Env var names holding this platform's API key, in precedence order
/// (first set, non-blank value wins). Empty for OAuth channels.
///
/// SECURITY: the *values* behind these names must never be logged.
api_key_envs: &'static [&'static str],
/// Short vendor word for login copy ("Paste your {vendor} API key").
vendor: &'static str,
/// Where the user gets an API key (login copy + key-validation errors).
/// `None` for OAuth channels.
console_host: Option<&'static str>,
/// Interactive login-picker label. `None` = fall back to `display_name`.
login_label: Option<&'static str>,
/// This platform's provider id on models.dev, for metadata enrichment.
/// `None` = not covered there (enrichment silently skips).
models_dev_id: Option<&'static str>,
/// True when the platform's `/models` listing itself serves context
/// window / thinking metadata — enrichment (and its network refresh) is
/// skipped entirely for such platforms.
wire_serves_metadata: bool,
/// Inference dialect (mapped to the sampler backend by the shell).
wire_api: PlatformWireApi,
/// Restrict the live listing to models the enrichment catalog knows —
/// for providers whose `/models` is polluted with non-chat entries
/// (tts/embeddings/image). Availability still requires the LIVE listing;
/// this only drops listing noise, never adds models.
restrict_to_enriched: bool,
}
const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
id: "kimi-code",
display_name: "Kimi Code",
base_url: BaseUrlSource::KigiEnvCoding,
uses_oauth: true,
allowed_model_prefixes: None,
api_key_envs: &[],
vendor: "Kimi",
console_host: None,
login_label: None,
models_dev_id: Some("kimi-for-coding"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
restrict_to_enriched: false,
};
const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
id: "moonshot-cn",
display_name: "Moonshot AI Open Platform (moonshot.cn)",
base_url: BaseUrlSource::EnvOr {
env: MOONSHOT_CN_BASE_URL_ENV,
default: "https://api.moonshot.cn/v1",
},
uses_oauth: false,
allowed_model_prefixes: Some(&["kimi-k"]),
api_key_envs: &[MOONSHOT_CN_API_KEY_ENV, MOONSHOT_API_KEY_ENV],
vendor: "Moonshot",
console_host: Some("platform.moonshot.cn"),
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
models_dev_id: Some("moonshotai-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
restrict_to_enriched: false,
};
const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
id: "moonshot-ai",
display_name: "Moonshot AI Open Platform (moonshot.ai)",
base_url: BaseUrlSource::EnvOr {
env: MOONSHOT_AI_BASE_URL_ENV,
default: "https://api.moonshot.ai/v1",
},
uses_oauth: false,
allowed_model_prefixes: Some(&["kimi-k"]),
api_key_envs: &[MOONSHOT_AI_API_KEY_ENV, MOONSHOT_API_KEY_ENV],
vendor: "Moonshot",
console_host: Some("platform.moonshot.ai"),
login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.ai)"),
models_dev_id: Some("moonshotai"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
restrict_to_enriched: false,
};
/// Base-URL override for OpenAI (dev/test escape hatch).
pub const OPENAI_BASE_URL_ENV: &str = "KIGI_OPENAI_BASE_URL";
const OPENAI_SPEC: PlatformSpec = PlatformSpec {
id: "openai",
display_name: "OpenAI",
base_url: BaseUrlSource::EnvOr {
env: OPENAI_BASE_URL_ENV,
default: "https://api.openai.com/v1",
},
uses_oauth: false,
allowed_model_prefixes: None,
api_key_envs: &["OPENAI_API_KEY"],
vendor: "OpenAI",
console_host: Some("platform.openai.com"),
login_label: Some("OpenAI (API key)"),
models_dev_id: Some("openai"),
// GET /v1/models returns bare ids only (no context/thinking metadata)
// and is polluted with tts/embeddings/image entries.
wire_serves_metadata: false,
wire_api: PlatformWireApi::Responses,
restrict_to_enriched: true,
};
/// The platform registry. Platforms are compiled-in spec rows; 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,
/// OpenAI platform API (API key, Responses dialect).
OpenAi,
}
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; 4] = [
Self::KimiCode,
Self::MoonshotCn,
Self::MoonshotAi,
Self::OpenAi,
];
/// The registry row backing this platform (single source of per-platform
/// data; every accessor below reads it).
const fn spec(self) -> &'static PlatformSpec {
match self {
Self::KimiCode => &KIMI_CODE_SPEC,
Self::MoonshotCn => &MOONSHOT_CN_SPEC,
Self::MoonshotAi => &MOONSHOT_AI_SPEC,
Self::OpenAi => &OPENAI_SPEC,
}
}
pub fn as_str(self) -> &'static str {
self.spec().id
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.spec().id == s)
}
pub fn display_name(self) -> &'static str {
self.spec().display_name
}
/// Inference/model-listing base URL. The subscription base honors the
/// `KIGI_CODE_BASE_URL` override via [`kigi_env::coding_api_base_url`];
/// API-key platform bases are fixed in production, with a per-platform
/// env var (e.g. `KIGI_MOONSHOT_{CN,AI}_BASE_URL`) as dev/test override.
pub fn base_url(self) -> String {
match self.spec().base_url {
BaseUrlSource::KigiEnvCoding => kigi_env::coding_api_base_url(),
BaseUrlSource::EnvOr { env, default } => env_or(env, default),
}
}
/// True for OAuth-bearer subscription channels.
pub fn uses_oauth(self) -> bool {
self.spec().uses_oauth
}
/// 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]> {
self.spec().allowed_model_prefixes
}
/// 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] {
self.spec().api_key_envs
}
/// 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())
}
/// Short vendor word for login copy ("Paste your {vendor} API key").
pub fn vendor(self) -> &'static str {
self.spec().vendor
}
/// Console host where the user obtains an API key, for login copy and
/// key-validation errors. `None` for OAuth channels.
pub fn console_host(self) -> Option<&'static str> {
self.spec().console_host
}
/// Label for the interactive login picker (falls back to the display
/// name when the row doesn't override it).
pub fn login_label(self) -> &'static str {
self.spec().login_label.unwrap_or(self.spec().display_name)
}
/// This platform's provider id on models.dev (metadata enrichment).
pub fn models_dev_id(self) -> Option<&'static str> {
self.spec().models_dev_id
}
/// True when the live `/models` wire serves metadata itself — enrichment
/// and its network refresh are skipped for such platforms.
pub fn wire_serves_metadata(self) -> bool {
self.spec().wire_serves_metadata
}
/// Inference dialect this platform speaks (shell maps to `ApiBackend`).
pub fn wire_api(self) -> PlatformWireApi {
self.spec().wire_api
}
/// Restrict the live listing to enrichment-known models (drops non-chat
/// listing noise on polluted providers). Never adds models.
pub fn restrict_to_enriched(self) -> bool {
self.spec().restrict_to_enriched
}
}
/// 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>,
/// `"only"` marks always-thinking models (thinking cannot be disabled).
/// Verified against the live `api.kimi.com/coding/v1/models` response.
#[serde(default)]
pub supports_thinking_type: Option<String>,
/// Selectable thinking-effort levels, present only on models that offer
/// them (e.g. K3). Verified against the live `/models` response.
#[serde(default)]
pub think_efforts: Option<WireThinkEfforts>,
}
/// The `think_efforts` object of a `/models` entry. Live wire shape
/// (api.kimi.com, 2026-07):
/// `{"support": true, "valid_efforts": ["low", "high", "max"],
/// "default_effort": "max"}`.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct WireThinkEfforts {
#[serde(default)]
pub support: bool,
#[serde(default)]
pub valid_efforts: Vec<String>,
#[serde(default)]
pub default_effort: 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
///
/// On top of that, the live wire's `supports_thinking_type: "only"`
/// marks a model whose thinking cannot be disabled → always_thinking.
///
/// Returned sorted + deduplicated ([`ModelCapability`]'s `Ord`).
pub fn capabilities(&self) -> Vec<ModelCapability> {
let mut caps = derive_capabilities(
&self.id,
self.supports_reasoning,
self.supports_image_in,
self.supports_video_in,
);
if self.supports_thinking_type.as_deref() == Some("only") {
for cap in [ModelCapability::Thinking, ModelCapability::AlwaysThinking] {
if !caps.contains(&cap) {
caps.push(cap);
}
}
caps.sort();
}
caps
}
}
/// 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)]
struct DefaultModels {
default: String,
/// Falls back to `default` if not specified in JSON.
image_description: Option<String>,
/// Falls back to `default` if not specified in JSON.
session_summary: Option<String>,
models: Vec<DefaultModelEntry>,
}
#[derive(serde::Deserialize)]
struct DefaultModelEntry {
model: String,
}
static DEFAULTS: LazyLock<DefaultModels> = LazyLock::new(|| {
let defaults: DefaultModels = serde_json::from_str(DEFAULT_MODELS_JSON)
.expect("default_models.json: invalid JSON or missing 'default' field");
// Baked-in JSON — a mismatch here is a developer error, not a runtime condition.
let model_ids: Vec<&str> = defaults.models.iter().map(|m| m.model.as_str()).collect();
assert!(
model_ids.contains(&defaults.default.as_str()),
"default_models.json: 'default' is '{}' but 'models' array only has {model_ids:?}",
defaults.default,
);
defaults
});
/// Primary model for coding tasks and general fallback.
pub fn default_model() -> &'static str {
&DEFAULTS.default
}
/// Model for image describe. Falls back to default model.
pub fn default_image_description_model() -> &'static str {
DEFAULTS
.image_description
.as_deref()
.unwrap_or(&DEFAULTS.default)
}
/// Model for session title generation. Falls back to default model.
pub fn default_session_summary_model() -> &'static str {
DEFAULTS
.session_summary
.as_deref()
.unwrap_or(&DEFAULTS.default)
}
#[cfg(test)]
mod tests {
use super::*;
/// Mirror of the live `api.kimi.com/coding/v1/models` K3 entry
/// (fetched 2026-07-17): `supports_thinking_type: "only"` plus a
/// `think_efforts` block with low/high/max and a max default.
#[test]
fn wire_model_parses_live_k3_think_efforts() {
let json = serde_json::json!({
"id": "k3",
"created": 1_761_264_000,
"object": "model",
"display_name": "K3",
"type": "model",
"context_length": 1_048_576,
"supports_reasoning": true,
"supports_image_in": true,
"supports_video_in": true,
"supports_thinking_type": "only",
"think_efforts": {
"support": true,
"valid_efforts": ["low", "high", "max"],
"default_effort": "max"
}
});
let wire: WireModel = serde_json::from_value(json).unwrap();
let efforts = wire.think_efforts.as_ref().unwrap();
assert!(efforts.support);
assert_eq!(efforts.valid_efforts, ["low", "high", "max"]);
assert_eq!(efforts.default_effort.as_deref(), Some("max"));
// "only" thinking type forces always_thinking on top of the
// supports_reasoning-derived thinking capability.
let caps = wire.capabilities();
assert!(caps.contains(&ModelCapability::Thinking));
assert!(caps.contains(&ModelCapability::AlwaysThinking));
}
/// The K2.7 entries carry `supports_thinking_type: "only"` but no
/// `think_efforts` — always-thinking without selectable levels.
#[test]
fn wire_model_without_think_efforts_still_always_thinking() {
let json = serde_json::json!({
"id": "kimi-for-coding",
"context_length": 262_144,
"supports_reasoning": true,
"supports_image_in": true,
"supports_video_in": true,
"supports_thinking_type": "only"
});
let wire: WireModel = serde_json::from_value(json).unwrap();
assert!(wire.think_efforts.is_none());
let caps = wire.capabilities();
assert!(caps.contains(&ModelCapability::AlwaysThinking));
// Sorted + deduplicated invariant holds after the "only" injection.
let mut sorted = caps.clone();
sorted.sort();
sorted.dedup();
assert_eq!(caps, sorted);
}
#[test]
fn platform_ids_round_trip() {
for p in PlatformId::ALL {
assert_eq!(PlatformId::parse(p.as_str()), Some(p));
}
assert_eq!(PlatformId::parse("not-a-platform"), None);
}
/// A variant missing from `ALL` compiles fine (`ALL`'s length is a plain
/// literal) but is silently unparseable and excluded from model sync.
/// The exhaustive match below fails compilation when a variant is added,
/// forcing this test — and with it the `ALL` entry — to be updated.
#[test]
fn all_covers_every_variant() {
fn ordinal(p: PlatformId) -> usize {
match p {
PlatformId::KimiCode => 0,
PlatformId::MoonshotCn => 1,
PlatformId::MoonshotAi => 2,
PlatformId::OpenAi => 3,
}
}
const VARIANT_COUNT: usize = 4; // update together with `ordinal`
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable();
seen.dedup();
assert_eq!(
seen.len(),
PlatformId::ALL.len(),
"duplicate variant in ALL"
);
assert_eq!(
seen.len(),
VARIANT_COUNT,
"PlatformId::ALL must contain every variant"
);
}
/// Row-shape invariants the login UI and key resolution rely on:
/// API-key platforms carry a console host (paste-box copy) and at least
/// one key env var (missing-key error names it); OAuth channels carry
/// neither key envs nor a console host requirement.
#[test]
fn api_key_rows_carry_console_host_and_env_names() {
for p in PlatformId::ALL {
if p.uses_oauth() {
assert!(
p.api_key_env_names().is_empty(),
"{}: OAuth platforms take no key envs",
p.as_str()
);
} else {
assert!(
p.console_host().is_some(),
"{}: API-key platforms must name their console host",
p.as_str()
);
assert!(
!p.api_key_env_names().is_empty(),
"{}: API-key platforms must name at least one key env",
p.as_str()
);
assert!(
!p.vendor().is_empty(),
"{}: API-key platforms must set a vendor word",
p.as_str()
);
}
}
}
/// `parse` resolves by scanning spec rows, so duplicate ids would
/// silently shadow a platform. Pin uniqueness as rows are added.
#[test]
fn platform_spec_ids_unique() {
let mut ids: Vec<&str> = PlatformId::ALL.iter().map(|p| p.as_str()).collect();
ids.sort_unstable();
let before = ids.len();
ids.dedup();
assert_eq!(ids.len(), before, "duplicate platform spec id");
}
#[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("not-a-platform/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,
supports_thinking_type: None,
think_efforts: 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,
supports_thinking_type: None,
think_efforts: 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_image_description_model(), "kimi-for-coding");
assert_eq!(default_session_summary_model(), "kimi-for-coding");
// No kigi remnants in the embedded fallback.
assert!(!DEFAULT_MODELS_JSON.contains("kigi"));
}
}