diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index 02457d729..f04f9d170 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -172,6 +172,17 @@ pub const ZAI_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile { requires_api_key: true, }; +pub const BIGMODEL_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile { + id: "bigmodel", + display_name: "Zhipu BigModel", + api_base: "https://open.bigmodel.cn/api/paas/v4", + api_key_env: "ZHIPU_API_KEY", + env_file: "bigmodel.env", + setup_url: "https://bigmodel.cn/dev/howuse/model", + default_model: Some("glm-5.1"), + requires_api_key: true, +}; + pub const KIMI_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile { id: "kimi", display_name: "Kimi Code", @@ -484,10 +495,11 @@ pub const OPENAI_COMPAT_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfi requires_api_key: true, }; -const OPENAI_COMPAT_PROFILES: [OpenAiCompatibleProfile; 31] = [ +const OPENAI_COMPAT_PROFILES: [OpenAiCompatibleProfile; 32] = [ OPENCODE_PROFILE, OPENCODE_GO_PROFILE, ZAI_PROFILE, + BIGMODEL_PROFILE, KIMI_PROFILE, CHUTES_PROFILE, CEREBRAS_PROFILE, @@ -666,6 +678,19 @@ pub const ZAI_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor order: LoginProviderSurfaceOrder::new(Some(7), Some(6), Some(7), Some(6), Some(6)), }; +pub const BIGMODEL_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor { + id: "bigmodel", + display_name: "Zhipu BigModel", + auth_kind: LoginProviderAuthKind::ApiKey, + auth_state_key: LoginProviderAuthStateKey::OpenRouterLike, + auth_status_method: "API key", + aliases: &["bigmodel-cn", "zhipu-cn", "glm-cn"], + menu_detail: "API key, mainland China endpoint", + recommended: false, + target: LoginProviderTarget::OpenAiCompatible(BIGMODEL_PROFILE), + order: LoginProviderSurfaceOrder::new(Some(8), Some(7), Some(8), Some(7), Some(7)), +}; + pub const KIMI_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor { id: "kimi", display_name: "Kimi Code", @@ -1101,7 +1126,7 @@ pub const GOOGLE_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescript order: LoginProviderSurfaceOrder::new(Some(13), None, None, None, None), }; -const LOGIN_PROVIDERS: [LoginProviderDescriptor; 44] = [ +const LOGIN_PROVIDERS: [LoginProviderDescriptor; 45] = [ AUTO_IMPORT_LOGIN_PROVIDER, CLAUDE_LOGIN_PROVIDER, OPENAI_LOGIN_PROVIDER, @@ -1113,6 +1138,7 @@ const LOGIN_PROVIDERS: [LoginProviderDescriptor; 44] = [ OPENCODE_LOGIN_PROVIDER, OPENCODE_GO_LOGIN_PROVIDER, ZAI_LOGIN_PROVIDER, + BIGMODEL_LOGIN_PROVIDER, KIMI_LOGIN_PROVIDER, CHUTES_LOGIN_PROVIDER, CEREBRAS_LOGIN_PROVIDER, @@ -1423,6 +1449,10 @@ mod tests { resolve_login_provider("zhipu").map(|provider| provider.id), Some("zai") ); + assert_eq!( + resolve_login_provider("zhipu-cn").map(|provider| provider.id), + Some("bigmodel") + ); assert_eq!( resolve_login_provider("kimi").map(|provider| provider.id), Some("kimi") diff --git a/src/auth/external.rs b/src/auth/external.rs index 35de65599..a8a5fcb7e 100644 --- a/src/auth/external.rs +++ b/src/auth/external.rs @@ -372,7 +372,7 @@ fn provider_keys_for_env(env_key: &str) -> &'static [&'static str] { "XAI_API_KEY" => &["xai"], "OPENROUTER_API_KEY" => &["openrouter"], "AI_GATEWAY_API_KEY" => &["vercel-ai-gateway"], - "ZHIPU_API_KEY" | "ZAI_API_KEY" => &["zai"], + "ZHIPU_API_KEY" | "ZAI_API_KEY" => &["bigmodel", "zai"], "OPENCODE_API_KEY" => &["opencode"], "OPENCODE_GO_API_KEY" => &["opencode-go", "opencode"], "HF_TOKEN" => &["huggingface"], diff --git a/src/cli/args.rs b/src/cli/args.rs index c65382ac7..a54a8dcbf 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -31,7 +31,7 @@ pub(crate) enum ProviderAuthArg { #[command(version = env!("JCODE_VERSION"))] #[command(about = "J-Code: A coding agent using Claude Max or ChatGPT Pro subscriptions")] pub(crate) struct Args { - /// Provider to use (jcode, claude, openai, openai-api, openrouter, azure, opencode, opencode-go, zai, 302ai, baseten, cortecs, comtegra, deepseek, fpt, firmware, huggingface, moonshotai, nebius, scaleway, stackit, groq, mistral, perplexity, togetherai, deepinfra, xai, nvidia-nim, lmstudio, ollama, chutes, cerebras, alibaba-coding-plan, openai-compatible, cursor, copilot, gemini, antigravity, google, or auto-detect) + /// Provider to use (jcode, claude, openai, openai-api, openrouter, azure, opencode, opencode-go, zai, bigmodel, 302ai, baseten, cortecs, comtegra, deepseek, fpt, firmware, huggingface, moonshotai, nebius, scaleway, stackit, groq, mistral, perplexity, togetherai, deepinfra, xai, nvidia-nim, lmstudio, ollama, chutes, cerebras, alibaba-coding-plan, openai-compatible, cursor, copilot, gemini, antigravity, google, or auto-detect) #[arg(short, long, default_value = "auto", global = true)] pub(crate) provider: ProviderChoice, diff --git a/src/cli/args/tests.rs b/src/cli/args/tests.rs index adca496c2..b2f543598 100644 --- a/src/cli/args/tests.rs +++ b/src/cli/args/tests.rs @@ -6,6 +6,9 @@ fn test_provider_choice_aliases_parse() { let args = Args::try_parse_from(["jcode", "--provider", "z.ai", "run", "smoke"]).unwrap(); assert_eq!(args.provider, ProviderChoice::Zai); + let args = Args::try_parse_from(["jcode", "--provider", "zhipu-cn", "run", "smoke"]).unwrap(); + assert_eq!(args.provider, ProviderChoice::Bigmodel); + let args = Args::try_parse_from(["jcode", "--provider", "kimi-for-coding", "run", "smoke"]).unwrap(); assert_eq!(args.provider, ProviderChoice::Kimi); diff --git a/src/cli/commands/report_info.rs b/src/cli/commands/report_info.rs index 8d2cb6a45..a6a6d5cd7 100644 --- a/src/cli/commands/report_info.rs +++ b/src/cli/commands/report_info.rs @@ -568,6 +568,7 @@ pub(super) fn list_cli_providers() -> Vec { ProviderChoice::Opencode, ProviderChoice::OpencodeGo, ProviderChoice::Zai, + ProviderChoice::Bigmodel, ProviderChoice::Kimi, ProviderChoice::Groq, ProviderChoice::Mistral, diff --git a/src/cli/provider_init.rs b/src/cli/provider_init.rs index 09616f367..d24187411 100644 --- a/src/cli/provider_init.rs +++ b/src/cli/provider_init.rs @@ -50,6 +50,8 @@ pub enum ProviderChoice { OpencodeGo, #[value(alias = "z.ai", alias = "z-ai", alias = "zai-coding")] Zai, + #[value(alias = "bigmodel-cn", alias = "zhipu-cn", alias = "glm-cn")] + Bigmodel, #[value( alias = "kimi-code", alias = "kimi-coding", @@ -130,6 +132,7 @@ impl ProviderChoice { Self::Opencode => "opencode", Self::OpencodeGo => "opencode-go", Self::Zai => "zai", + Self::Bigmodel => "bigmodel", Self::Kimi => "kimi", Self::Ai302 => "302ai", Self::Baseten => "baseten", @@ -214,6 +217,10 @@ const PROVIDER_CHOICE_LOGIN_PROVIDERS: &[(ProviderChoice, LoginProviderDescripto ProviderChoice::Zai, crate::provider_catalog::ZAI_LOGIN_PROVIDER, ), + ( + ProviderChoice::Bigmodel, + crate::provider_catalog::BIGMODEL_LOGIN_PROVIDER, + ), ( ProviderChoice::Kimi, crate::provider_catalog::KIMI_LOGIN_PROVIDER, @@ -1329,6 +1336,7 @@ async fn init_provider_with_options( ProviderChoice::Opencode | ProviderChoice::OpencodeGo | ProviderChoice::Zai + | ProviderChoice::Bigmodel | ProviderChoice::Ai302 | ProviderChoice::Baseten | ProviderChoice::Cortecs diff --git a/src/cli/provider_init_tests.rs b/src/cli/provider_init_tests.rs index ddc8b81cd..3aaae8654 100644 --- a/src/cli/provider_init_tests.rs +++ b/src/cli/provider_init_tests.rs @@ -31,6 +31,7 @@ fn test_provider_choice_arg_values() { assert_eq!(ProviderChoice::Opencode.as_arg_value(), "opencode"); assert_eq!(ProviderChoice::OpencodeGo.as_arg_value(), "opencode-go"); assert_eq!(ProviderChoice::Zai.as_arg_value(), "zai"); + assert_eq!(ProviderChoice::Bigmodel.as_arg_value(), "bigmodel"); assert_eq!(ProviderChoice::Groq.as_arg_value(), "groq"); assert_eq!(ProviderChoice::Mistral.as_arg_value(), "mistral"); assert_eq!(ProviderChoice::Perplexity.as_arg_value(), "perplexity"); diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 13d72a692..3fe2ab3aa 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -252,7 +252,9 @@ impl MultiProvider { } else { detected_active }; - let sequence = Self::fallback_sequence_for(active, self.forced_provider); + let config_provider_lock = Self::config_default_provider_lock_for_active(active); + let sequence = + Self::fallback_sequence_for(active, self.forced_provider.or(config_provider_lock)); let mut notes: Vec = Vec::new(); let mut failover_reason: Option = None; let (estimated_input_chars, estimated_input_tokens) = @@ -798,6 +800,20 @@ impl MultiProvider { self.set_model(model) } + + pub(super) fn config_default_provider_lock_for_active( + active: ActiveProvider, + ) -> Option { + let cfg = crate::config::config(); + let pref = cfg + .provider + .default_provider + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())?; + let selection = Self::resolve_config_provider_selection(pref, cfg)?; + (selection.active_provider() == active).then_some(active) + } } impl Default for MultiProvider { diff --git a/src/provider/openrouter.rs b/src/provider/openrouter.rs index 47ab7906f..d09d6ae23 100644 --- a/src/provider/openrouter.rs +++ b/src/provider/openrouter.rs @@ -178,6 +178,7 @@ fn configured_env_file_name() -> String { } fn load_named_profile_api_key( + profile_name: &str, env_key: &str, profile: &crate::config::NamedProviderConfig, ) -> Option { @@ -190,6 +191,13 @@ fn load_named_profile_api_key( return load_api_key_from_env_or_config(env_key, env_file); } + if let Some(builtin_profile) = openai_compatible_profile_by_id(profile_name) { + let resolved = resolve_openai_compatible_profile(builtin_profile); + if resolved.api_key_env == env_key { + return load_api_key_from_env_or_config(env_key, &resolved.env_file); + } + } + std::env::var(env_key) .ok() .map(|key| key.trim().to_string()) @@ -309,11 +317,9 @@ fn is_coding_agent_api_base(api_base: &str) -> bool { return false; }; let host = url.host_str().unwrap_or_default(); - let path = url.path().trim_end_matches('/'); is_kimi_coding_api_base(api_base) || host == "coding.dashscope.aliyuncs.com" || host == "coding-intl.dashscope.aliyuncs.com" - || (host == "api.z.ai" && path.starts_with("/api/coding/paas")) } fn is_kimi_model_name(model: &str) -> bool { @@ -715,7 +721,7 @@ impl OpenRouterProvider { .filter(|v| !v.is_empty()); let key_label = key_env.unwrap_or("inline api_key").to_string(); let key = key_env - .and_then(|name| load_named_profile_api_key(name, profile)) + .and_then(|name| load_named_profile_api_key(profile_name, name, profile)) .or_else(|| profile.api_key.clone()); let auth = match profile.auth { crate::config::NamedProviderAuth::None => ProviderAuth::None { @@ -930,6 +936,10 @@ impl OpenRouterProvider { } pub(crate) fn should_merge_static_models_with_live_catalog(&self) -> bool { + if matches!(self.profile_id.as_deref(), Some("bigmodel")) { + return true; + } + // Built-in OpenAI-compatible provider profiles use `static_models` as a // startup/pre-catalog fallback so `/model` is useful immediately after // login. Once a live `/models` catalog has been fetched, the live catalog diff --git a/src/provider/openrouter_tests.rs b/src/provider/openrouter_tests.rs index 187c273d7..8d42edadb 100644 --- a/src/provider/openrouter_tests.rs +++ b/src/provider/openrouter_tests.rs @@ -894,6 +894,60 @@ fn built_in_openai_compatible_static_models_drop_out_after_live_catalog() { ); } +#[test] +fn bigmodel_static_models_remain_visible_when_live_catalog_is_incomplete() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = TempDir::new().expect("create temp home"); + let _home = EnvVarGuard::set("HOME", temp.path()); + let _appdata = EnvVarGuard::set("APPDATA", temp.path().join("AppData").join("Roaming")); + let _namespace = EnvVarGuard::set( + "JCODE_OPENROUTER_CACHE_NAMESPACE", + "test-bigmodel-live-catalog-keeps-static-fallbacks", + ); + let (api_base, _request_rx) = spawn_single_response_models_server( + r#"{ + "object": "list", + "data": [ + {"id": "glm-4.5", "object": "model"} + ] + }"#, + ); + let provider = OpenRouterProvider { + api_base, + auth: ProviderAuth::AuthorizationBearer { + token: "sk-live-catalog".to_string(), + label: "ZHIPU_API_KEY".to_string(), + }, + supports_provider_features: false, + supports_model_catalog: true, + profile_id: Some("bigmodel".to_string()), + static_models: vec![ + "glm-4.5".to_string(), + "glm-5".to_string(), + "glm-5.1".to_string(), + ], + send_openrouter_headers: false, + ..make_custom_compatible_provider() + }; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + rt.block_on(provider.refresh_models()) + .expect("refresh fake model catalog"); + + let display = provider.available_models_display(); + assert!( + display.iter().any(|model| model == "glm-4.5"), + "live BigModel catalog model should remain visible: {display:?}" + ); + assert!( + display.iter().any(|model| model == "glm-5.1"), + "BigModel documented chat models should remain visible even when /models is incomplete: {display:?}" + ); +} + #[test] fn direct_openai_compatible_static_models_are_marked_as_fallback_before_live_catalog() { let provider = OpenRouterProvider { @@ -1005,6 +1059,33 @@ fn named_openai_compatible_loads_api_key_from_env_file() { .expect("provider should load key from env file"); } +#[test] +fn named_builtin_profile_without_env_file_uses_builtin_env_file() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = TempDir::new().expect("create temp dir"); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path()); + let _home = EnvVarGuard::set("HOME", temp.path()); + let _appdata = EnvVarGuard::set("APPDATA", temp.path().join("AppData").join("Roaming")); + let _namespace = EnvVarGuard::remove("JCODE_OPENROUTER_CACHE_NAMESPACE"); + let _api_key = EnvVarGuard::remove("ZHIPU_API_KEY"); + write_test_api_key( + &temp, + "bigmodel.env", + "ZHIPU_API_KEY", + "from-builtin-env-file", + ); + + let config = crate::config::NamedProviderConfig { + base_url: "https://open.bigmodel.cn/api/paas/v4".to_string(), + api_key_env: Some("ZHIPU_API_KEY".to_string()), + default_model: Some("glm-5.1".to_string()), + ..Default::default() + }; + + OpenRouterProvider::new_named_openai_compatible("bigmodel", &config) + .expect("built-in profile override should still load the built-in env file"); +} + #[test] fn custom_compatible_provider_preserves_claude_like_model_ids() { let provider = make_custom_compatible_provider(); @@ -1123,8 +1204,8 @@ fn test_kimi_coding_header_detection_matches_endpoint_and_model() { "https://coding-intl.dashscope.aliyuncs.com/v1", None, )); - assert!(should_send_kimi_coding_agent_headers( - "https://api.z.ai/api/coding/paas/v4", + assert!(!should_send_kimi_coding_agent_headers( + "https://open.bigmodel.cn/api/paas/v4", None, )); assert!(should_send_kimi_coding_agent_headers( diff --git a/src/provider/startup.rs b/src/provider/startup.rs index 086ff6421..88c82d8c4 100644 --- a/src/provider/startup.rs +++ b/src/provider/startup.rs @@ -281,14 +281,15 @@ impl MultiProvider { .unwrap_or_else(|| selection.display_label()) )); } else { + active = preferred; crate::logging::warn(&format!( - "Preferred provider '{}' is not configured, using auto-detected default", + "Preferred provider '{}' is not configured; keeping it selected so requests fail instead of falling back to another provider", pref )); } } else { crate::logging::warn(&format!( - "Unknown default_provider '{}' in config (expected: claude|openai|copilot|antigravity|gemini|cursor|bedrock|openrouter or an OpenAI-compatible profile such as deepseek|comtegra|zai|openai-compatible)", + "Unknown default_provider '{}' in config (expected: claude|openai|copilot|antigravity|gemini|cursor|bedrock|openrouter or an OpenAI-compatible profile such as deepseek|comtegra|zai|bigmodel|openai-compatible)", pref )); } diff --git a/src/provider/tests/fallback_failover.rs b/src/provider/tests/fallback_failover.rs index 08cc12a5a..b87d994ca 100644 --- a/src/provider/tests/fallback_failover.rs +++ b/src/provider/tests/fallback_failover.rs @@ -166,6 +166,60 @@ fn test_forced_provider_disables_cross_provider_fallback_sequence() { ); } +#[test] +fn test_config_default_provider_locks_cross_provider_fallback() { + with_clean_provider_test_env(|| { + let mut cfg = crate::config::Config::default(); + cfg.provider.default_provider = Some("zai".to_string()); + cfg.save().expect("save test config"); + + assert_eq!( + MultiProvider::config_default_provider_lock_for_active(ActiveProvider::OpenRouter), + Some(ActiveProvider::OpenRouter) + ); + assert_eq!( + MultiProvider::config_default_provider_lock_for_active(ActiveProvider::OpenAI), + None + ); + }); +} + +#[test] +fn test_unconfigured_config_default_profile_stays_active_instead_of_using_other_profile_key() { + with_clean_provider_test_env(|| { + let runtime = enter_test_runtime(); + let _enter = runtime.enter(); + + let mut cfg = crate::config::Config::default(); + cfg.provider.default_provider = Some("zai".to_string()); + cfg.provider.default_model = Some("glm-5.1".to_string()); + cfg.save().expect("save test config"); + + crate::env::set_var("KIMI_API_KEY", "test-kimi-key"); + crate::auth::AuthStatus::invalidate_cache(); + + let provider = MultiProvider::from_auth_status(crate::auth::AuthStatus::check_fast()); + + assert_eq!(provider.active_provider(), ActiveProvider::OpenRouter); + assert!( + provider.openrouter_provider().is_none(), + "missing Z.AI credentials must not initialize an OpenRouter/Kimi provider" + ); + assert_eq!( + std::env::var("JCODE_OPENROUTER_API_KEY_NAME") + .ok() + .as_deref(), + Some("ZHIPU_API_KEY") + ); + assert_eq!( + std::env::var("JCODE_OPENROUTER_CACHE_NAMESPACE") + .ok() + .as_deref(), + Some("zai") + ); + }); +} + #[test] fn test_set_model_rejects_cross_provider_without_creds() { let _guard = crate::storage::lock_test_env(); diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index 61931f1f1..64444c42f 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -212,6 +212,14 @@ pub fn openai_compatible_profile_static_models(profile: OpenAiCompatibleProfile) push("glm-4.7-flash"); push("glm-4.7-flashx"); } + "bigmodel" => { + push("glm-5.1"); + push("glm-5"); + push("glm-4.7"); + push("glm-4.7-flash"); + push("glm-4.7-flashx"); + push("glm-4.5"); + } "302ai" => { push("qwen3-235b-a22b-instruct-2507"); push("glm-4.7"); diff --git a/src/provider_catalog_tests.rs b/src/provider_catalog_tests.rs index 4c4de00ed..f67ba9c97 100644 --- a/src/provider_catalog_tests.rs +++ b/src/provider_catalog_tests.rs @@ -88,6 +88,11 @@ fn matrix_login_provider_aliases_resolve_to_canonical_ids() { fn auth_issue_profile_metadata_matches_direct_provider_endpoints() { assert_eq!(ZAI_PROFILE.api_base, "https://api.z.ai/api/coding/paas/v4"); assert_eq!(ZAI_PROFILE.default_model, Some("glm-4.5")); + assert_eq!( + BIGMODEL_PROFILE.api_base, + "https://open.bigmodel.cn/api/paas/v4" + ); + assert_eq!(BIGMODEL_PROFILE.default_model, Some("glm-5.1")); assert_eq!(DEEPSEEK_PROFILE.api_base, "https://api.deepseek.com"); assert_eq!(DEEPSEEK_PROFILE.default_model, Some("deepseek-v4-flash")); assert_eq!(DEEPSEEK_PROFILE.setup_url, "https://api-docs.deepseek.com/"); diff --git a/src/tui/account_picker_render.rs b/src/tui/account_picker_render.rs index d7d068905..46e95b0eb 100644 --- a/src/tui/account_picker_render.rs +++ b/src/tui/account_picker_render.rs @@ -246,6 +246,7 @@ pub(super) fn provider_style(provider_id: &str) -> Style { | "opencode" | "opencode-go" | "zai" + | "bigmodel" | "chutes" | "cerebras" | "alibaba-coding-plan" diff --git a/src/tui/login_picker.rs b/src/tui/login_picker.rs index 7c2c7e2d4..c8d3c4e04 100644 --- a/src/tui/login_picker.rs +++ b/src/tui/login_picker.rs @@ -629,6 +629,7 @@ fn provider_style(provider_id: &str) -> Style { | "opencode" | "opencode-go" | "zai" + | "bigmodel" | "chutes" | "cerebras" | "alibaba-coding-plan"