From 347311bfe59b5da53f3dea26098fdaa46e70ed91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 21 Jul 2026 19:55:51 -0400 Subject: [PATCH] feat(providers): add Xiaomi MiMo (global + China token plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Providers 19-20 (22nd & 23rd registry variants), sourced from Pi (earendil-works/pi). Pi's xiaomi.ts / xiaomi-token-plan-cn.ts use plain openAICompletionsApi (no thinking dialect) → Kigi Passthrough. Global: MiMo at api.xiaomimimo.com/v1, XIAOMI_API_KEY, models.dev xiaomi. China token plan: token-plan-cn.xiaomimimo.com/v1, XIAOMI_TOKEN_PLAN_CN_API_KEY, models.dev xiaomi-token-plan-cn. Both Bearer + OpenAI listing + ChatCompletions + restrict_to_enriched; /models auth-gated → validator. id-match proven vs Pi's static ids (mimo-v2.5-pro, mimo-v2-flash, ...). The CN token plan lists 4 mimo TTS models (tool_call=false); restrict's tool_call cut drops them, keeping only the 3 chat models — the e2e proves this non-vacuously with the real mimo-v2-tts. Review found no defects. Registry at 23; picker 24 rows (welcome picker verified rendering all rows at a taller viewport). --- crates/codegen/kigi-models/src/lib.rs | 70 ++++++++++++++- .../kigi-shell/src/agent/auth_method.rs | 66 +++++++++++++- .../kigi-shell/src/agent/models_fetch.rs | 90 +++++++++++++++++++ crates/codegen/kigi-tui/src/app/app_view.rs | 20 ++++- 4 files changed, 237 insertions(+), 9 deletions(-) diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index a42a064..ffed24a 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -772,6 +772,62 @@ const ZAI_CODING_CN_SPEC: PlatformSpec = PlatformSpec { restrict_to_enriched: true, }; +pub const XIAOMI_BASE_URL_ENV: &str = "KIGI_XIAOMI_BASE_URL"; +const XIAOMI_SPEC: PlatformSpec = PlatformSpec { + id: "xiaomi", + display_name: "Xiaomi MiMo", + // Xiaomi MiMo. Per Pi this is plain OpenAI-compatible chat completions. + base_url: BaseUrlSource::EnvOr { + env: XIAOMI_BASE_URL_ENV, + default: "https://api.xiaomimimo.com/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["XIAOMI_API_KEY"], + vendor: "Xiaomi", + console_host: Some("xiaomimimo.com"), + login_label: Some("Xiaomi MiMo (API key)"), + models_dev_id: Some("xiaomi"), + // /models auth-gated → validator; enrichment from models.dev; restrict to + // tool-calling chat models. Live ids match the snapshot keys (mimo-v2.5, + // mimo-v2-pro, ...) byte-for-byte (verified vs Pi). + wire_serves_metadata: false, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + key_validation_path: None, + strip_listing_id_prefix: None, + restrict_to_enriched: true, +}; + +pub const XIAOMI_TOKEN_PLAN_CN_BASE_URL_ENV: &str = "KIGI_XIAOMI_TOKEN_PLAN_CN_BASE_URL"; +const XIAOMI_TOKEN_PLAN_CN_SPEC: PlatformSpec = PlatformSpec { + id: "xiaomi-token-plan-cn", + display_name: "Xiaomi Token Plan (China)", + base_url: BaseUrlSource::EnvOr { + env: XIAOMI_TOKEN_PLAN_CN_BASE_URL_ENV, + default: "https://token-plan-cn.xiaomimimo.com/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["XIAOMI_TOKEN_PLAN_CN_API_KEY"], + vendor: "Xiaomi", + console_host: Some("xiaomimimo.com"), + login_label: Some("Xiaomi Token Plan China (API key)"), + models_dev_id: Some("xiaomi-token-plan-cn"), + // The CN token plan listing also carries mimo TTS models (tool_call=false) + // → restrict drops them, keeping only the tool-calling chat models. + wire_serves_metadata: false, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + key_validation_path: None, + strip_listing_id_prefix: None, + 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)] @@ -818,12 +874,16 @@ pub enum PlatformId { Zai, /// Z.AI coding plan, China / Zhipu BigModel (API key, OpenAI-compatible). ZaiCodingCn, + /// Xiaomi MiMo, global (API key, OpenAI-compatible). + Xiaomi, + /// Xiaomi Token Plan, China (API key, OpenAI-compatible). + XiaomiTokenPlanCn, } 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; 21] = [ + pub const ALL: [PlatformId; 23] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -845,6 +905,8 @@ impl PlatformId { Self::KimiCoding, Self::Zai, Self::ZaiCodingCn, + Self::Xiaomi, + Self::XiaomiTokenPlanCn, ]; /// The registry row backing this platform (single source of per-platform @@ -872,6 +934,8 @@ impl PlatformId { Self::KimiCoding => &KIMI_CODING_SPEC, Self::Zai => &ZAI_SPEC, Self::ZaiCodingCn => &ZAI_CODING_CN_SPEC, + Self::Xiaomi => &XIAOMI_SPEC, + Self::XiaomiTokenPlanCn => &XIAOMI_TOKEN_PLAN_CN_SPEC, } } @@ -1633,9 +1697,11 @@ mod tests { PlatformId::KimiCoding => 18, PlatformId::Zai => 19, PlatformId::ZaiCodingCn => 20, + PlatformId::Xiaomi => 21, + PlatformId::XiaomiTokenPlanCn => 22, } } - const VARIANT_COUNT: usize = 21; // update together with `ordinal` + const VARIANT_COUNT: usize = 23; // update together with `ordinal` let mut seen: Vec = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); seen.sort_unstable(); seen.dedup(); diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index 59bd821..dbf1130 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -625,7 +625,9 @@ mod tests { "qwen-token-plan-cn", "kimi-coding", "zai", - "zai-coding-cn" + "zai-coding-cn", + "xiaomi", + "xiaomi-token-plan-cn" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -669,7 +671,9 @@ mod tests { "qwen-token-plan-cn", "kimi-coding", "zai", - "zai-coding-cn" + "zai-coding-cn", + "xiaomi", + "xiaomi-token-plan-cn" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -706,7 +710,9 @@ mod tests { "qwen-token-plan-cn", "kimi-coding", "zai", - "zai-coding-cn" + "zai-coding-cn", + "xiaomi", + "xiaomi-token-plan-cn" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -746,7 +752,9 @@ mod tests { "qwen-token-plan-cn", "kimi-coding", "zai", - "zai-coding-cn" + "zai-coding-cn", + "xiaomi", + "xiaomi-token-plan-cn" ] ); assert_eq!(default_id(&built), None); @@ -1147,4 +1155,54 @@ mod tests { "Invalid API key for zai-coding-cn \u{2014} check your key on open.bigmodel.cn" ); } + + /// Xiaomi MiMo (global): /models is auth-gated (401 for a bad key). + #[tokio::test] + #[serial] + async fn xiaomi_validates_against_models_and_rejects_bad_key() { + use wiremock::matchers::{method, path}; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(method("GET")) + .and(path("/models")) + .respond_with(wiremock::ResponseTemplate::new(401)) + .expect(1) + .mount(&server) + .await; + let _base = EnvGuard::set(kigi_models::XIAOMI_BASE_URL_ENV, &server.uri()); + let err = authenticate_platform_api_key(kigi_models::PlatformId::Xiaomi, Some("xm-bad")) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for xiaomi \u{2014} check your key on xiaomimimo.com" + ); + } + + /// Xiaomi Token Plan CN: distinct base URL; same auth-gated validation. + #[tokio::test] + #[serial] + async fn xiaomi_token_plan_cn_validates_against_models_and_rejects_bad_key() { + use wiremock::matchers::{method, path}; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(method("GET")) + .and(path("/models")) + .respond_with(wiremock::ResponseTemplate::new(401)) + .expect(1) + .mount(&server) + .await; + let _base = EnvGuard::set( + kigi_models::XIAOMI_TOKEN_PLAN_CN_BASE_URL_ENV, + &server.uri(), + ); + let err = authenticate_platform_api_key( + kigi_models::PlatformId::XiaomiTokenPlanCn, + Some("xm-cn-bad"), + ) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for xiaomi-token-plan-cn \u{2014} check your key on xiaomimimo.com" + ); + } } diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 3a493be..c4a4dc0 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -2348,6 +2348,96 @@ mod tests { ); } + /// Xiaomi-cycle e2e: OpenAI-compatible MiMo (per Pi, plain completions). + /// /models is auth-gated + minimal; enrichment supplies context; restrict's + /// tool_call cut drops the mimo TTS models (tool_call=false) the token plan + /// lists; bare id round-trips under the xiaomi key; Passthrough. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn xiaomi_enriches_restricts_and_maps_passthrough() { + let platform_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "data": [ + { "id": "mimo-v2.5-pro", "object": "model" }, + // a TTS model (tool_call=false in enrichment) → dropped. + { "id": "mimo-v2-tts", "object": "model" } + ]}), + )) + .expect(1) + .mount(&platform_server) + .await; + let modelsdev_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api.json")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "xiaomi": { "models": { + "mimo-v2.5-pro": { + "limit": {"context": 256000, "output": 32768}, + "tool_call": true + }, + "mimo-v2-tts": { "limit": {"context": 8192}, "tool_call": false } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::XIAOMI_BASE_URL_ENV, + platform_server.uri(), + ); + let _mdev = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV, + format!("{}/api.json", modelsdev_server.uri()), + ); + let _mdev_cache = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV, + cache_dir.path(), + ); + let endpoints = crate::agent::config::EndpointsConfig::default(); + let keys = crate::agent::models::PlatformApiKeys::test_single( + kigi_models::PlatformId::Xiaomi, + "xm-1", + ); + let result = tokio::task::spawn_blocking(move || { + fetch_platform_models_blocking(&endpoints, None, &keys) + }) + .await + .unwrap() + .expect("fetch must succeed"); + assert_eq!( + result + .models + .iter() + .map(|m| m.id.as_deref().unwrap_or_default()) + .collect::>(), + vec!["xiaomi/mimo-v2.5-pro"], + "the TTS model (tool_call=false) is dropped by restrict_to_enriched" + ); + let entry = &result.models[0]; + assert_eq!( + entry.context_window.get(), + 256_000, + "context comes from enrichment (the wire listing carries none)" + ); + assert_eq!(entry.max_completion_tokens, Some(32_768)); + assert_eq!(entry.model, "mimo-v2.5-pro"); + let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); + let creds = crate::agent::config::ResolvedCredentials { + api_key: Some("xm-1".into()), + base_url: entry.base_url.clone(), + auth_type: kigi_chat_state::AuthType::ApiKey, + auth_scheme: Default::default(), + }; + let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None); + assert_eq!( + cfg.chat_compat, + kigi_sampling_types::ChatCompat::Passthrough + ); + } + #[test] fn get_env_keys_parses_strings_and_rejects_non_strings() { use crate::agent::config::EnvKeys; diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index 8d7c915..915b650 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -6894,7 +6894,7 @@ pub(crate) mod tests { #[test] fn pending_menu_items_lists_interactive_methods_plus_quit() { let items = pending_menu_items(&fresh_user_auth_methods(), None); - assert_eq!(items.len(), 22, "21 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 24, "23 login rows + Quit, got {items:?}"); assert!( matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), "row 0 must be the OAuth login, got {:?}", @@ -7041,7 +7041,21 @@ pub(crate) mod tests { label: "Z.AI Coding China (API key)".into(), } ); - assert_eq!(items[21], PendingMenuItem::Quit); + assert_eq!( + items[21], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi), + label: "Xiaomi MiMo (API key)".into(), + } + ); + assert_eq!( + items[22], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn), + label: "Xiaomi Token Plan China (API key)".into(), + } + ); + assert_eq!(items[23], PendingMenuItem::Quit); // The non-interactive methods must never appear as rows. let byok = kigi_shell::agent::auth_method::build_auth_methods( kigi_shell::agent::auth_method::AuthMethodsBuildInputs { @@ -7052,7 +7066,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 22, + 24, "xai.api_key / cached_token must not add rows" ); }