From c02b4b1ed75152591fa7966c6ed12501a86933a0 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 18:10:23 -0400 Subject: [PATCH] feat(providers): add Kimi For Coding via static KIMI_API_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider 16 (19th registry variant). Same endpoint + models + Kimi dialect as the existing OAuth kimi-code platform (api.kimi.com/coding/v1 via the KIGI_CODE_BASE_URL override), but authenticated with a static KIMI_API_KEY instead of the device flow — for users who have a Kimi For Coding key rather than an OAuth subscription. Bearer, OpenAI listing, ChatCompletions, ChatCompat::Kimi, wire_serves_metadata=true (Kimi /models self-serves context/thinking), restrict_to_enriched=false (clean 3-model catalog). /coding/v1/models is auth-gated (401) so it doubles as the validator. No collision: KIMI_API_KEY was previously unused (grep-verified), and the house BYOK reads only KIGI_API_KEY/XAI_API_KEY/legacy. kimi-code (OAuth) and kimi-coding (static key) are independently gated (OAuth-token vs key) and their models get distinct managed keys (kimi-code/k3 vs kimi-coding/k3) — a user with both simply sees each Kimi model twice; no dedup collision, no crash. Review (6 areas): no blocking defects; confirmed the spec correctly mirrors KIMI_CODE_SPEC (differing only in uses_oauth/api_key_envs/console_host/labels) and the coexistence is benign. Strengthened the e2e's dialect assertion (Kimi is the default ChatCompat, so it did not discriminate a parse failure) by also asserting parse_managed_model_key attributes the key to KimiCoding. Tests: e2e proves wire-served context (1_048_576 from the wire) with the models.dev fetch SKIPPED (all-wire-metadata provider, .expect(0)), bare-id round-trip under kimi-coding/, Kimi dialect; validation test rejects a 401 from /models. Registry at 19; picker 20 rows; snapshot already bundles kimi-for-coding. --- crates/codegen/kigi-models/src/lib.rs | 36 +++++++- .../kigi-shell/src/agent/auth_method.rs | 36 +++++++- .../kigi-shell/src/agent/models_fetch.rs | 85 +++++++++++++++++++ crates/codegen/kigi-tui/src/app/app_view.rs | 13 ++- 4 files changed, 161 insertions(+), 9 deletions(-) diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 0ff0a52..e77a9b9 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -689,6 +689,33 @@ const QWEN_TOKEN_PLAN_CN_SPEC: PlatformSpec = PlatformSpec { restrict_to_enriched: true, }; +const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec { + id: "kimi-coding", + display_name: "Kimi For Coding", + // Same endpoint as the OAuth `kimi-code` platform (KIGI_CODE_BASE_URL + // override), but authenticated with a static KIMI_API_KEY instead of the + // device flow. Kimi's /coding/v1/models serves its own metadata and the + // Kimi thinking dialect applies verbatim. + base_url: BaseUrlSource::KigiEnvCoding, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["KIMI_API_KEY"], + vendor: "Kimi", + console_host: Some("www.kimi.com"), + login_label: Some("Kimi For Coding (API key)"), + models_dev_id: Some("kimi-for-coding"), + wire_serves_metadata: true, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Kimi, + key_header: PlatformKeyHeader::Bearer, + // /coding/v1/models requires auth (401 without a key), so it doubles as + // the key validator. + key_validation_path: None, + restrict_to_enriched: false, + strip_listing_id_prefix: None, +}; + /// 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)] @@ -729,12 +756,14 @@ pub enum PlatformId { QwenTokenPlan, /// Alibaba Qwen Token Plan, China (API key, DashScope compatible-mode). QwenTokenPlanCn, + /// Kimi For Coding via a static KIMI_API_KEY (same endpoint as `KimiCode`). + KimiCoding, } 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; 18] = [ + pub const ALL: [PlatformId; 19] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -753,6 +782,7 @@ impl PlatformId { Self::Xai, Self::QwenTokenPlan, Self::QwenTokenPlanCn, + Self::KimiCoding, ]; /// The registry row backing this platform (single source of per-platform @@ -777,6 +807,7 @@ impl PlatformId { Self::Xai => &XAI_SPEC, Self::QwenTokenPlan => &QWEN_TOKEN_PLAN_SPEC, Self::QwenTokenPlanCn => &QWEN_TOKEN_PLAN_CN_SPEC, + Self::KimiCoding => &KIMI_CODING_SPEC, } } @@ -1535,9 +1566,10 @@ mod tests { PlatformId::Xai => 15, PlatformId::QwenTokenPlan => 16, PlatformId::QwenTokenPlanCn => 17, + PlatformId::KimiCoding => 18, } } - const VARIANT_COUNT: usize = 18; // update together with `ordinal` + const VARIANT_COUNT: usize = 19; // 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 f7159c0..7b4993c 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -622,7 +622,8 @@ mod tests { "vercel-ai-gateway", "xai", "qwen-token-plan", - "qwen-token-plan-cn" + "qwen-token-plan-cn", + "kimi-coding" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -663,7 +664,8 @@ mod tests { "vercel-ai-gateway", "xai", "qwen-token-plan", - "qwen-token-plan-cn" + "qwen-token-plan-cn", + "kimi-coding" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -697,7 +699,8 @@ mod tests { "vercel-ai-gateway", "xai", "qwen-token-plan", - "qwen-token-plan-cn" + "qwen-token-plan-cn", + "kimi-coding" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -734,7 +737,8 @@ mod tests { "vercel-ai-gateway", "xai", "qwen-token-plan", - "qwen-token-plan-cn" + "qwen-token-plan-cn", + "kimi-coding" ] ); assert_eq!(default_id(&built), None); @@ -1066,4 +1070,28 @@ mod tests { bailian.console.aliyun.com" ); } + + /// Kimi-For-Coding static key: /coding/v1/models requires auth (401 for a + /// bad key), so it validates the key. Base resolves via KIGI_CODE_BASE_URL. + #[tokio::test] + #[serial] + async fn kimi_coding_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_env::CODE_BASE_URL_ENV, &server.uri()); + let err = + authenticate_platform_api_key(kigi_models::PlatformId::KimiCoding, Some("kc-bad")) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for kimi-coding \u{2014} check your key on www.kimi.com" + ); + } } diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index da4048c..ab1dfdd 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -2175,6 +2175,91 @@ mod tests { ); } + /// Kimi-For-Coding static-key e2e: same endpoint + Kimi dialect as the OAuth + /// kimi-code platform, keyed by KIMI_API_KEY. Kimi's /models serves its own + /// metadata (wire_serves_metadata), so context comes from the WIRE and the + /// models.dev fetch is skipped entirely (all enabled platforms self-serve); + /// no restriction; Kimi dialect. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn kimi_coding_static_key_uses_wire_metadata_and_kimi_dialect() { + 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": "k3", "object": "model", "context_length": 1_048_576, + "supports_reasoning": true } + ]}), + )) + .expect(1) + .mount(&platform_server) + .await; + // Point models.dev at an ALWAYS-500 server: enrichment must NOT be + // fetched for an all-wire-metadata provider, so this is never hit. + 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(500)) + .expect(0) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = + kigi_test_support::EnvGuard::set(kigi_env::CODE_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::KimiCoding, + "kc-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!["kimi-coding/k3"], + "wire model kept under the platform key (no restriction)" + ); + let entry = &result.models[0]; + assert_eq!( + entry.context_window.get(), + 1_048_576, + "context comes from the WIRE (wire_serves_metadata); enrichment was skipped" + ); + assert_eq!(entry.model, "k3"); + // The managed key must parse back to KimiCoding — so the Kimi dialect + // below is a real attribution, not the default-dialect fallback that a + // failed parse would also yield. + assert_eq!( + kigi_models::parse_managed_model_key(entry.id.as_deref().unwrap()), + Some((kigi_models::PlatformId::KimiCoding, "k3")), + ); + let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); + let creds = crate::agent::config::ResolvedCredentials { + api_key: Some("kc-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::Kimi); + } + #[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 84d4a67..db3facc 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(), 19, "18 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 20, "19 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 {:?}", @@ -7020,7 +7020,14 @@ pub(crate) mod tests { label: "Qwen Token Plan China (API key)".into(), } ); - assert_eq!(items[18], PendingMenuItem::Quit); + assert_eq!( + items[18], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding), + label: "Kimi For Coding (API key)".into(), + } + ); + assert_eq!(items[19], 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 { @@ -7031,7 +7038,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 19, + 20, "xai.api_key / cached_token must not add rows" ); }