From 010f6c3be3c8f00096a1ce4640e30213ae68824b 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:32:58 -0400 Subject: [PATCH] feat(providers): add Z.AI coding plan (global + China) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Providers 17-18 (20th & 21st registry variants), sourced authoritatively from Pi (earendil-works/pi), the open-source agent whose provider list is being mirrored. Pi's zai.ts / zai-coding-cn.ts use plain openAICompletionsApi (NO special thinking dialect — overturns the matrix's 'thinking:{type} → new dialect' concern), so Kigi maps them to Passthrough. Global: api.z.ai/api/ coding/paas/v4, ZAI_API_KEY, models.dev zai-coding-plan. China (Zhipu BigModel): open.bigmodel.cn/api/coding/paas/v4, ZAI_CODING_CN_API_KEY, models.dev zhipuai-coding-plan. Both Bearer + OpenAI listing + ChatCompletions + restrict_to_enriched; /models is auth-gated → validator. id-match is PROVEN (not just assumed like Qwen): Pi's static model ids [glm-4.5-air, glm-4.7, glm-5-turbo, glm-5.1, glm-5.2, glm-5v-turbo] are byte-identical to the models.dev zai-coding-plan keys, all tool_call=true, so restrict keeps every model with no silent-empty risk. Review found no defects. GLM thinking is not lost (reasoning_content is parsed regardless of dialect). Tests: e2e proves enrichment-supplied context + non-vacuous restrict (a non-enriched wire model is dropped) + Passthrough; both variants' validation tests hit /models and assert the per-variant console host (z.ai vs open.bigmodel.cn). Registry at 21; picker 22 rows. Also fixes the welcome login-picker test for the now-taller menu (renders at a taller viewport to verify content coverage) and logs the real menu-overflow UX debt: the picker clips rows past the fold with no scroll (q/l shortcuts still work; only shown when unauthenticated) — deferred to its own cycle. --- crates/codegen/kigi-models/src/lib.rs | 70 ++++++++++++++- .../kigi-shell/src/agent/auth_method.rs | 61 ++++++++++++- .../kigi-shell/src/agent/models_fetch.rs | 88 +++++++++++++++++++ crates/codegen/kigi-tui/src/app/app_view.rs | 20 ++++- .../codegen/kigi-tui/src/views/welcome/mod.rs | 15 +++- 5 files changed, 243 insertions(+), 11 deletions(-) diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index e77a9b9..a42a064 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -716,6 +716,62 @@ const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec { strip_listing_id_prefix: None, }; +pub const ZAI_BASE_URL_ENV: &str = "KIGI_ZAI_BASE_URL"; +const ZAI_SPEC: PlatformSpec = PlatformSpec { + id: "zai", + display_name: "Z.AI", + // Z.AI coding plan (GLM). Per Pi (earendil-works/pi) this is plain + // OpenAI-compatible chat completions — no special thinking dialect. + base_url: BaseUrlSource::EnvOr { + env: ZAI_BASE_URL_ENV, + default: "https://api.z.ai/api/coding/paas/v4", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["ZAI_API_KEY"], + vendor: "Z.AI", + console_host: Some("z.ai"), + login_label: Some("Z.AI (API key)"), + models_dev_id: Some("zai-coding-plan"), + // /models auth-gated → validator; enrichment from models.dev; restrict to + // tool-calling GLM chat models. Live ids match the snapshot keys + // (glm-4.7, glm-5-turbo, glm-5.2, ...) 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 ZAI_CODING_CN_BASE_URL_ENV: &str = "KIGI_ZAI_CODING_CN_BASE_URL"; +const ZAI_CODING_CN_SPEC: PlatformSpec = PlatformSpec { + id: "zai-coding-cn", + display_name: "Z.AI Coding (China)", + // Zhipu/BigModel-hosted CN coding plan; same OpenAI-compatible shape. + base_url: BaseUrlSource::EnvOr { + env: ZAI_CODING_CN_BASE_URL_ENV, + default: "https://open.bigmodel.cn/api/coding/paas/v4", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["ZAI_CODING_CN_API_KEY"], + vendor: "Z.AI", + console_host: Some("open.bigmodel.cn"), + login_label: Some("Z.AI Coding China (API key)"), + models_dev_id: Some("zhipuai-coding-plan"), + 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)] @@ -758,12 +814,16 @@ pub enum PlatformId { QwenTokenPlanCn, /// Kimi For Coding via a static KIMI_API_KEY (same endpoint as `KimiCode`). KimiCoding, + /// Z.AI coding plan, global (API key, OpenAI-compatible GLM). + Zai, + /// Z.AI coding plan, China / Zhipu BigModel (API key, OpenAI-compatible). + ZaiCodingCn, } 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; 19] = [ + pub const ALL: [PlatformId; 21] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -783,6 +843,8 @@ impl PlatformId { Self::QwenTokenPlan, Self::QwenTokenPlanCn, Self::KimiCoding, + Self::Zai, + Self::ZaiCodingCn, ]; /// The registry row backing this platform (single source of per-platform @@ -808,6 +870,8 @@ impl PlatformId { Self::QwenTokenPlan => &QWEN_TOKEN_PLAN_SPEC, Self::QwenTokenPlanCn => &QWEN_TOKEN_PLAN_CN_SPEC, Self::KimiCoding => &KIMI_CODING_SPEC, + Self::Zai => &ZAI_SPEC, + Self::ZaiCodingCn => &ZAI_CODING_CN_SPEC, } } @@ -1567,9 +1631,11 @@ mod tests { PlatformId::QwenTokenPlan => 16, PlatformId::QwenTokenPlanCn => 17, PlatformId::KimiCoding => 18, + PlatformId::Zai => 19, + PlatformId::ZaiCodingCn => 20, } } - const VARIANT_COUNT: usize = 19; // update together with `ordinal` + const VARIANT_COUNT: usize = 21; // 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 7b4993c..59bd821 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -623,7 +623,9 @@ mod tests { "xai", "qwen-token-plan", "qwen-token-plan-cn", - "kimi-coding" + "kimi-coding", + "zai", + "zai-coding-cn" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -665,7 +667,9 @@ mod tests { "xai", "qwen-token-plan", "qwen-token-plan-cn", - "kimi-coding" + "kimi-coding", + "zai", + "zai-coding-cn" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -700,7 +704,9 @@ mod tests { "xai", "qwen-token-plan", "qwen-token-plan-cn", - "kimi-coding" + "kimi-coding", + "zai", + "zai-coding-cn" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -738,7 +744,9 @@ mod tests { "xai", "qwen-token-plan", "qwen-token-plan-cn", - "kimi-coding" + "kimi-coding", + "zai", + "zai-coding-cn" ] ); assert_eq!(default_id(&built), None); @@ -1094,4 +1102,49 @@ mod tests { "Invalid API key for kimi-coding \u{2014} check your key on www.kimi.com" ); } + + /// Z.AI (global): /models is auth-gated (401 for a bad key) → validator. + #[tokio::test] + #[serial] + async fn zai_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::ZAI_BASE_URL_ENV, &server.uri()); + let err = authenticate_platform_api_key(kigi_models::PlatformId::Zai, Some("zai-bad")) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for zai \u{2014} check your key on z.ai" + ); + } + + /// Z.AI Coding CN: distinct base URL (Zhipu BigModel) + console host. + #[tokio::test] + #[serial] + async fn zai_coding_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::ZAI_CODING_CN_BASE_URL_ENV, &server.uri()); + let err = + authenticate_platform_api_key(kigi_models::PlatformId::ZaiCodingCn, Some("zai-cn-bad")) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for zai-coding-cn \u{2014} check your key on open.bigmodel.cn" + ); + } } diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index ab1dfdd..3a493be 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -2260,6 +2260,94 @@ mod tests { assert_eq!(cfg.chat_compat, kigi_sampling_types::ChatCompat::Kimi); } + /// Z.AI-cycle e2e: OpenAI-compatible GLM coding plan (per Pi, plain + /// completions — no thinking dialect). /models is auth-gated + minimal, so + /// enrichment supplies context; restrict drops any wire model absent from + /// the models.dev "zai-coding-plan" snapshot; bare id round-trips under the + /// zai key; Passthrough dialect. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn zai_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": "glm-5.2", "object": "model" }, + // not in the enrichment snapshot → dropped by restrict. + { "id": "glm-experimental-unlisted", "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!({ "zai-coding-plan": { "models": { + "glm-5.2": { + "limit": {"context": 200000, "output": 128000}, + "tool_call": true + } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = + kigi_test_support::EnvGuard::set(kigi_models::ZAI_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::Zai, + "zai-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!["zai/glm-5.2"], + "the non-enriched wire model is dropped by restrict_to_enriched" + ); + let entry = &result.models[0]; + assert_eq!( + entry.context_window.get(), + 200_000, + "context comes from enrichment (the wire listing carries none)" + ); + assert_eq!(entry.max_completion_tokens, Some(128_000)); + assert_eq!(entry.model, "glm-5.2"); + let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); + let creds = crate::agent::config::ResolvedCredentials { + api_key: Some("zai-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 db3facc..8d7c915 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(), 20, "19 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 22, "21 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 {:?}", @@ -7027,7 +7027,21 @@ pub(crate) mod tests { label: "Kimi For Coding (API key)".into(), } ); - assert_eq!(items[19], PendingMenuItem::Quit); + assert_eq!( + items[19], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Zai), + label: "Z.AI (API key)".into(), + } + ); + assert_eq!( + items[20], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn), + label: "Z.AI Coding China (API key)".into(), + } + ); + assert_eq!(items[21], 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 { @@ -7038,7 +7052,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 20, + 22, "xai.api_key / cached_token must not add rows" ); } diff --git a/crates/codegen/kigi-tui/src/views/welcome/mod.rs b/crates/codegen/kigi-tui/src/views/welcome/mod.rs index b3be894..a7aaeec 100644 --- a/crates/codegen/kigi-tui/src/views/welcome/mod.rs +++ b/crates/codegen/kigi-tui/src/views/welcome/mod.rs @@ -2070,7 +2070,16 @@ mod tests { } fn render_done_text(params: &WelcomeRenderParams<'_>) -> String { - let area = Rect::new(0, 0, 100, 40); + render_done_text_h(params, 40) + } + + /// Render at an explicit height. The login picker lists one row per + /// advertised platform; with ~20 API-key providers the menu no longer + /// fits a 40-row viewport, so the full-menu content test needs more rows + /// to verify every label renders. (On-terminal, rows past the fold are + /// clipped — the `q`/`l` shortcuts still work; see the menu-scroll debt.) + fn render_done_text_h(params: &WelcomeRenderParams<'_>, height: u16) -> String { + let area = Rect::new(0, 0, 100, height); let mut buf = Buffer::empty(area); let mut prompt = PromptWidget::new(); let mut picker = PickerState::default(); @@ -2093,7 +2102,9 @@ mod tests { let trust = TrustState::Done; let mut params = render_params(&auth, &trust, None); params.auth_methods = &built.methods; - let text = render_done_text(¶ms); + // Tall viewport so every advertised login row renders (the picker now + // lists ~20 platforms); this asserts content coverage, not fit. + let text = render_done_text_h(¶ms, 72); assert!(text.contains("Kimi Code (OAuth)"), "{text}"); assert!( text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),