From bc4e76db96b0ffd6bb0e8006364e7359ca3cb914 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 20:21:26 -0400 Subject: [PATCH] feat(providers): add MiniMax (global + China) via Anthropic Messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Providers 21-22 (24th & 25th registry variants), sourced from Pi (earendil-works/pi). Pi drives MiniMax through its Anthropic-COMPATIBLE surface (baseUrl .../anthropic), so Kigi reuses the existing Anthropic Messages machinery (wire_api=Messages, listing=Anthropic, key_header=XApiKey x-api-key+anthropic-version) rather than the OpenAI path. Global: api.minimax.io/anthropic, MINIMAX_API_KEY, models.dev minimax. China: api.minimaxi.com/anthropic, MINIMAX_CN_API_KEY, models.dev minimax-cn. The base carries the /v1 suffix (.../anthropic/v1) since Kigi appends bare paths → listing .../anthropic/v1/models?limit=1000, inference .../anthropic/v1/messages (matches the live-probed x-api-key-gated endpoint). restrict_to_enriched=FALSE: the 7 MiniMax-M* models are clean (no pollution) and restrict would drop launch-day models not yet in models.dev. Also HARDENS parse_anthropic_listing to tolerate a bare array in addition to the {data:[...]} envelope (mirrors parse_openai_listing's sniff that Together taught us) — so MiniMax's Anthropic-compatible /models can't silently empty the catalog if it serves a bare array. A bare object without data still errors. Review found no defects (5 areas CONFIRMED incl. the /v1 non-doubling, the additive parser change, restrict=false rationale). Residual (logged): the live 200 body / anthropic-version acceptance is unverifiable without a key; the bare-array tolerance + restrict=false hedge most shapes. Tests: e2e mocks the x-api-key-gated Anthropic listing (proves the auth header + enrichment + keying under minimax/); both validation tests reject 401 with the per-variant console host; new parser test covers envelope + bare array + bare-object-errors. Registry at 25; picker 26 rows. --- crates/codegen/kigi-models/src/lib.rs | 122 ++++++++++++++++-- .../kigi-shell/src/agent/auth_method.rs | 63 ++++++++- .../kigi-shell/src/agent/models_fetch.rs | 83 ++++++++++++ crates/codegen/kigi-tui/src/app/app_view.rs | 20 ++- 4 files changed, 269 insertions(+), 19 deletions(-) diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index ffed24a..a340664 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -828,6 +828,65 @@ const XIAOMI_TOKEN_PLAN_CN_SPEC: PlatformSpec = PlatformSpec { restrict_to_enriched: true, }; +pub const MINIMAX_BASE_URL_ENV: &str = "KIGI_MINIMAX_BASE_URL"; +const MINIMAX_SPEC: PlatformSpec = PlatformSpec { + id: "minimax", + display_name: "MiniMax", + // Per Pi, MiniMax is driven through its Anthropic-compatible surface + // (baseUrl .../anthropic). Kigi appends bare paths, so the base carries + // the /v1 suffix: listing → .../anthropic/v1/models?limit=1000, inference + // → .../anthropic/v1/messages. Reuses the Anthropic Messages machinery. + base_url: BaseUrlSource::EnvOr { + env: MINIMAX_BASE_URL_ENV, + default: "https://api.minimax.io/anthropic/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["MINIMAX_API_KEY"], + vendor: "MiniMax", + console_host: Some("platform.minimax.io"), + login_label: Some("MiniMax (API key)"), + models_dev_id: Some("minimax"), + // Anthropic listing (x-api-key + anthropic-version) serves the id list; + // enrichment fills context. Catalog is clean (7 MiniMax-M* chat models), + // so no restriction — and restrict=false keeps launch-day models that + // models.dev has not indexed yet. + wire_serves_metadata: false, + wire_api: PlatformWireApi::Messages, + listing: ListingDialect::Anthropic, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::XApiKey, + // /anthropic/v1/models requires x-api-key (401 without), so it validates. + key_validation_path: None, + strip_listing_id_prefix: None, + restrict_to_enriched: false, +}; + +pub const MINIMAX_CN_BASE_URL_ENV: &str = "KIGI_MINIMAX_CN_BASE_URL"; +const MINIMAX_CN_SPEC: PlatformSpec = PlatformSpec { + id: "minimax-cn", + display_name: "MiniMax (China)", + base_url: BaseUrlSource::EnvOr { + env: MINIMAX_CN_BASE_URL_ENV, + default: "https://api.minimaxi.com/anthropic/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["MINIMAX_CN_API_KEY"], + vendor: "MiniMax", + console_host: Some("platform.minimaxi.com"), + login_label: Some("MiniMax China (API key)"), + models_dev_id: Some("minimax-cn"), + wire_serves_metadata: false, + wire_api: PlatformWireApi::Messages, + listing: ListingDialect::Anthropic, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::XApiKey, + key_validation_path: None, + strip_listing_id_prefix: None, + restrict_to_enriched: false, +}; + /// 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)] @@ -878,12 +937,16 @@ pub enum PlatformId { Xiaomi, /// Xiaomi Token Plan, China (API key, OpenAI-compatible). XiaomiTokenPlanCn, + /// MiniMax, global (API key, Anthropic-compatible Messages). + Minimax, + /// MiniMax, China (API key, Anthropic-compatible Messages). + MinimaxCn, } 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; 23] = [ + pub const ALL: [PlatformId; 25] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -907,6 +970,8 @@ impl PlatformId { Self::ZaiCodingCn, Self::Xiaomi, Self::XiaomiTokenPlanCn, + Self::Minimax, + Self::MinimaxCn, ]; /// The registry row backing this platform (single source of per-platform @@ -936,6 +1001,8 @@ impl PlatformId { Self::ZaiCodingCn => &ZAI_CODING_CN_SPEC, Self::Xiaomi => &XIAOMI_SPEC, Self::XiaomiTokenPlanCn => &XIAOMI_TOKEN_PLAN_CN_SPEC, + Self::Minimax => &MINIMAX_SPEC, + Self::MinimaxCn => &MINIMAX_CN_SPEC, } } @@ -1219,16 +1286,24 @@ struct AnthropicSupported { /// order. `has_more: true` (impossible under `?limit=1000` for Anthropic's /// catalog size) warns rather than silently truncating. pub fn parse_anthropic_listing(json: &str) -> Result, serde_json::Error> { - let listing: AnthropicListing = serde_json::from_str(json)?; - if listing.has_more { - tracing::warn!( - fetched = listing.data.len(), - "anthropic /models reports more pages beyond limit=1000; \ - listing may be incomplete" - ); - } - Ok(listing - .data + // Sniff the top-level shape. Anthropic itself serves the {data:[...]} + // envelope, but Anthropic-COMPATIBLE surfaces (e.g. MiniMax's /anthropic + // endpoint) may serve a bare array — accept both so such a provider isn't + // silently emptied (mirrors `parse_openai_listing`'s tolerance). + let data: Vec = if json.trim_start().starts_with('[') { + serde_json::from_str::>(json)? + } else { + let listing: AnthropicListing = serde_json::from_str(json)?; + if listing.has_more { + tracing::warn!( + fetched = listing.data.len(), + "anthropic /models reports more pages beyond limit=1000; \ + listing may be incomplete" + ); + } + listing.data + }; + Ok(data .into_iter() .filter(|m| { let keep = !m.id.is_empty(); @@ -1597,6 +1672,27 @@ mod tests { ); } + /// Anthropic-COMPATIBLE surfaces (e.g. MiniMax's /anthropic endpoint) may + /// serve a bare array instead of the `{data:[...]}` envelope; the parser + /// accepts both so such a provider isn't silently emptied. A bare object + /// (not an array, no `data`) still errors. + #[test] + fn anthropic_listing_accepts_envelope_and_bare_array() { + let envelope = + serde_json::json!({ "data": [ { "id": "MiniMax-M2.5", "max_input_tokens": 204_800 } ] }) + .to_string(); + let bare = serde_json::json!([ { "id": "MiniMax-M2.5", "max_input_tokens": 204_800 } ]) + .to_string(); + for json in [&envelope, &bare] { + let models = parse_anthropic_listing(json).expect("both shapes parse"); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "MiniMax-M2.5"); + assert_eq!(models[0].context_length, 204_800); + } + // A bare object without `data` is still a contract violation. + assert!(parse_anthropic_listing(r#"{"foo":1}"#).is_err()); + } + /// The OpenAI listing parser accepts both the standard envelope and a /// bare top-level array (Together AI serves the bare form). #[test] @@ -1699,9 +1795,11 @@ mod tests { PlatformId::ZaiCodingCn => 20, PlatformId::Xiaomi => 21, PlatformId::XiaomiTokenPlanCn => 22, + PlatformId::Minimax => 23, + PlatformId::MinimaxCn => 24, } } - const VARIANT_COUNT: usize = 23; // update together with `ordinal` + const VARIANT_COUNT: usize = 25; // 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 dbf1130..1e5152a 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -627,7 +627,9 @@ mod tests { "zai", "zai-coding-cn", "xiaomi", - "xiaomi-token-plan-cn" + "xiaomi-token-plan-cn", + "minimax", + "minimax-cn" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -673,7 +675,9 @@ mod tests { "zai", "zai-coding-cn", "xiaomi", - "xiaomi-token-plan-cn" + "xiaomi-token-plan-cn", + "minimax", + "minimax-cn" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -712,7 +716,9 @@ mod tests { "zai", "zai-coding-cn", "xiaomi", - "xiaomi-token-plan-cn" + "xiaomi-token-plan-cn", + "minimax", + "minimax-cn" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -754,7 +760,9 @@ mod tests { "zai", "zai-coding-cn", "xiaomi", - "xiaomi-token-plan-cn" + "xiaomi-token-plan-cn", + "minimax", + "minimax-cn" ] ); assert_eq!(default_id(&built), None); @@ -1205,4 +1213,51 @@ mod tests { "Invalid API key for xiaomi-token-plan-cn \u{2014} check your key on xiaomimimo.com" ); } + + /// MiniMax (global): Anthropic-compatible /models is x-api-key-gated (401 + /// for a bad key) → validator. Confirms the XApiKey header path is used. + #[tokio::test] + #[serial] + async fn minimax_validates_against_models_and_rejects_bad_key() { + use wiremock::matchers::{header, method, path}; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(method("GET")) + .and(path("/models")) + .and(header("x-api-key", "mm-bad")) + .respond_with(wiremock::ResponseTemplate::new(401)) + .expect(1) + .mount(&server) + .await; + let _base = EnvGuard::set(kigi_models::MINIMAX_BASE_URL_ENV, &server.uri()); + let err = authenticate_platform_api_key(kigi_models::PlatformId::Minimax, Some("mm-bad")) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for minimax \u{2014} check your key on platform.minimax.io" + ); + } + + /// MiniMax (China): distinct base URL + console host. + #[tokio::test] + #[serial] + async fn minimax_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::MINIMAX_CN_BASE_URL_ENV, &server.uri()); + let err = + authenticate_platform_api_key(kigi_models::PlatformId::MinimaxCn, Some("mm-cn-bad")) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for minimax-cn \u{2014} check your key on platform.minimaxi.com" + ); + } } diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index c4a4dc0..846936b 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -2438,6 +2438,89 @@ mod tests { ); } + /// MiniMax e2e: Pi drives MiniMax through its Anthropic-compatible surface, + /// so Kigi uses the Anthropic listing (x-api-key + anthropic-version, + /// ?limit=1000) + Messages wire. /anthropic/v1/models is minimal, so + /// enrichment supplies context; restrict=false keeps the clean MiniMax-M* + /// catalog; id round-trips under the minimax key. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn minimax_anthropic_listing_enriches_and_keys_under_platform() { + let platform_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + // x-api-key auth (Anthropic key header) must be present. + .and(wiremock::matchers::header("x-api-key", "mm-1")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + // Anthropic listing envelope; minimal (id only) → enrichment fills. + serde_json::json!({ "data": [ + { "id": "MiniMax-M2.5", "type": "model" } + ], "has_more": false }), + )) + .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!({ "minimax": { "models": { + "MiniMax-M2.5": { + "limit": {"context": 204800, "output": 131072}, + "tool_call": true + } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::MINIMAX_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::Minimax, + "mm-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!["minimax/MiniMax-M2.5"], + "the Anthropic-listed model is keyed under the minimax platform" + ); + let entry = &result.models[0]; + assert_eq!( + entry.context_window.get(), + 204_800, + "context comes from enrichment (the wire listing carries none)" + ); + assert_eq!(entry.max_completion_tokens, Some(131_072)); + assert_eq!(entry.model, "MiniMax-M2.5"); + assert_eq!( + kigi_models::parse_managed_model_key(entry.id.as_deref().unwrap()), + Some((kigi_models::PlatformId::Minimax, "MiniMax-M2.5")), + ); + } + #[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 915b650..8985834 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(), 24, "23 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 26, "25 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 {:?}", @@ -7055,7 +7055,21 @@ pub(crate) mod tests { label: "Xiaomi Token Plan China (API key)".into(), } ); - assert_eq!(items[23], PendingMenuItem::Quit); + assert_eq!( + items[23], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Minimax), + label: "MiniMax (API key)".into(), + } + ); + assert_eq!( + items[24], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn), + label: "MiniMax China (API key)".into(), + } + ); + assert_eq!(items[25], 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 { @@ -7066,7 +7080,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 24, + 26, "xai.api_key / cached_token must not add rows" ); }