diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index ad3aa2e..0ff0a52 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -632,6 +632,63 @@ const XAI_SPEC: PlatformSpec = PlatformSpec { restrict_to_enriched: true, }; +pub const QWEN_TOKEN_PLAN_BASE_URL_ENV: &str = "KIGI_QWEN_TOKEN_PLAN_BASE_URL"; +const QWEN_TOKEN_PLAN_SPEC: PlatformSpec = PlatformSpec { + id: "qwen-token-plan", + display_name: "Qwen Token Plan", + base_url: BaseUrlSource::EnvOr { + env: QWEN_TOKEN_PLAN_BASE_URL_ENV, + default: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["QWEN_TOKEN_PLAN_API_KEY"], + vendor: "Alibaba", + console_host: Some("modelstudio.console.alibabacloud.com"), + login_label: Some("Qwen Token Plan (API key)"), + models_dev_id: Some("alibaba-token-plan"), + // DashScope compatible-mode /models is auth-gated (no wire metadata), so + // take context/limits from the models.dev "alibaba-token-plan" snapshot; + // restrict to tool-calling chat models to drop the qwen-image / wan image + // generators the token plan also lists. + wire_serves_metadata: false, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + // /models requires auth (401 without a key), so it doubles as the key + // validator. + key_validation_path: None, + strip_listing_id_prefix: None, + restrict_to_enriched: true, +}; + +pub const QWEN_TOKEN_PLAN_CN_BASE_URL_ENV: &str = "KIGI_QWEN_TOKEN_PLAN_CN_BASE_URL"; +const QWEN_TOKEN_PLAN_CN_SPEC: PlatformSpec = PlatformSpec { + id: "qwen-token-plan-cn", + display_name: "Qwen Token Plan (China)", + base_url: BaseUrlSource::EnvOr { + env: QWEN_TOKEN_PLAN_CN_BASE_URL_ENV, + default: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["QWEN_TOKEN_PLAN_CN_API_KEY"], + vendor: "Alibaba", + console_host: Some("bailian.console.aliyun.com"), + login_label: Some("Qwen Token Plan China (API key)"), + models_dev_id: Some("alibaba-token-plan-cn"), + // China endpoint; same DashScope compatible-mode shape as the global 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)] @@ -668,12 +725,16 @@ pub enum PlatformId { Vercel, /// xAI Grok platform (API key, OpenAI-compatible ChatCompletions). Xai, + /// Alibaba Qwen Token Plan, global (API key, DashScope compatible-mode). + QwenTokenPlan, + /// Alibaba Qwen Token Plan, China (API key, DashScope compatible-mode). + QwenTokenPlanCn, } 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; 16] = [ + pub const ALL: [PlatformId; 18] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -690,6 +751,8 @@ impl PlatformId { Self::Nvidia, Self::Vercel, Self::Xai, + Self::QwenTokenPlan, + Self::QwenTokenPlanCn, ]; /// The registry row backing this platform (single source of per-platform @@ -712,6 +775,8 @@ impl PlatformId { Self::Nvidia => &NVIDIA_SPEC, Self::Vercel => &VERCEL_SPEC, Self::Xai => &XAI_SPEC, + Self::QwenTokenPlan => &QWEN_TOKEN_PLAN_SPEC, + Self::QwenTokenPlanCn => &QWEN_TOKEN_PLAN_CN_SPEC, } } @@ -1468,9 +1533,11 @@ mod tests { PlatformId::Nvidia => 13, PlatformId::Vercel => 14, PlatformId::Xai => 15, + PlatformId::QwenTokenPlan => 16, + PlatformId::QwenTokenPlanCn => 17, } } - const VARIANT_COUNT: usize = 16; // update together with `ordinal` + const VARIANT_COUNT: usize = 18; // 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 256c99b..f7159c0 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -620,7 +620,9 @@ mod tests { "cerebras", "nvidia", "vercel-ai-gateway", - "xai" + "xai", + "qwen-token-plan", + "qwen-token-plan-cn" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -659,7 +661,9 @@ mod tests { "cerebras", "nvidia", "vercel-ai-gateway", - "xai" + "xai", + "qwen-token-plan", + "qwen-token-plan-cn" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -691,7 +695,9 @@ mod tests { "cerebras", "nvidia", "vercel-ai-gateway", - "xai" + "xai", + "qwen-token-plan", + "qwen-token-plan-cn" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -726,7 +732,9 @@ mod tests { "cerebras", "nvidia", "vercel-ai-gateway", - "xai" + "xai", + "qwen-token-plan", + "qwen-token-plan-cn" ] ); assert_eq!(default_id(&built), None); @@ -1006,4 +1014,56 @@ mod tests { .await .expect("200 from /models must validate the key"); } + + /// Qwen Token Plan (global): DashScope /models is auth-gated (401 for a bad + /// key), so it validates the key; the error names the Model Studio console. + #[tokio::test] + #[serial] + async fn qwen_token_plan_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::QWEN_TOKEN_PLAN_BASE_URL_ENV, &server.uri()); + let err = + authenticate_platform_api_key(kigi_models::PlatformId::QwenTokenPlan, Some("qtp-bad")) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for qwen-token-plan \u{2014} check your key on \ + modelstudio.console.alibabacloud.com" + ); + } + + /// Qwen Token Plan (China): distinct base URL + console host; same + /// auth-gated /models validation. + #[tokio::test] + #[serial] + async fn qwen_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::QWEN_TOKEN_PLAN_CN_BASE_URL_ENV, &server.uri()); + let err = authenticate_platform_api_key( + kigi_models::PlatformId::QwenTokenPlanCn, + Some("qtp-cn-bad"), + ) + .await + .expect_err("a 401 from /models must reject the key"); + assert_eq!( + err.message, + "Invalid API key for qwen-token-plan-cn \u{2014} check your key on \ + bailian.console.aliyun.com" + ); + } } diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 0eff2d2..da4048c 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -2084,6 +2084,97 @@ mod tests { ); } + /// Qwen-Token-Plan e2e: DashScope compatible-mode /models is auth-gated and + /// minimal (ids only), so enrichment supplies context; the restriction drops + /// the non-tool-calling qwen-image / wan generators the token plan lists; + /// bare id round-trips under the platform key; Passthrough dialect. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn qwen_token_plan_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( + // DashScope /models is minimal: id/object only, NO context. + serde_json::json!({ "data": [ + { "id": "qwen3.7-max", "object": "model" }, + { "id": "qwen-image-2.0", "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!({ "alibaba-token-plan": { "models": { + "qwen3.7-max": { + "limit": {"context": 1000000, "output": 32768}, + "tool_call": true + }, + // present in enrichment too, but not tool-calling → dropped. + "qwen-image-2.0": { "limit": {"context": 8192} } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::QWEN_TOKEN_PLAN_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::QwenTokenPlan, + "qtp-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!["qwen-token-plan/qwen3.7-max"], + "qwen-image (not tool-calling) dropped; bare id kept under the platform key" + ); + let entry = &result.models[0]; + assert_eq!( + entry.context_window.get(), + 1_000_000, + "context comes from enrichment (the wire listing carries none)" + ); + assert_eq!(entry.max_completion_tokens, Some(32_768)); + assert_eq!(entry.model, "qwen3.7-max", "the bare id rides the wire"); + let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); + let creds = crate::agent::config::ResolvedCredentials { + api_key: Some("qtp-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 a80193a..84d4a67 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(), 17, "16 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 19, "18 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 {:?}", @@ -7006,7 +7006,21 @@ pub(crate) mod tests { label: "xAI (Grok) (API key)".into(), } ); - assert_eq!(items[16], PendingMenuItem::Quit); + assert_eq!( + items[16], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan), + label: "Qwen Token Plan (API key)".into(), + } + ); + assert_eq!( + items[17], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn), + label: "Qwen Token Plan China (API key)".into(), + } + ); + assert_eq!(items[18], 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 { @@ -7017,7 +7031,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 17, + 19, "xai.api_key / cached_token must not add rows" ); }