Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions crates/jcode-provider-metadata/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/auth/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
3 changes: 3 additions & 0 deletions src/cli/args/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/report_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ pub(super) fn list_cli_providers() -> Vec<ProviderListEntry> {
ProviderChoice::Opencode,
ProviderChoice::OpencodeGo,
ProviderChoice::Zai,
ProviderChoice::Bigmodel,
ProviderChoice::Kimi,
ProviderChoice::Groq,
ProviderChoice::Mistral,
Expand Down
8 changes: 8 additions & 0 deletions src/cli/provider_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1329,6 +1336,7 @@ async fn init_provider_with_options(
ProviderChoice::Opencode
| ProviderChoice::OpencodeGo
| ProviderChoice::Zai
| ProviderChoice::Bigmodel
| ProviderChoice::Ai302
| ProviderChoice::Baseten
| ProviderChoice::Cortecs
Expand Down
1 change: 1 addition & 0 deletions src/cli/provider_init_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
18 changes: 17 additions & 1 deletion src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = Vec::new();
let mut failover_reason: Option<String> = None;
let (estimated_input_chars, estimated_input_tokens) =
Expand Down Expand Up @@ -798,6 +800,20 @@ impl MultiProvider {

self.set_model(model)
}

pub(super) fn config_default_provider_lock_for_active(
active: ActiveProvider,
) -> Option<ActiveProvider> {
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 {
Expand Down
16 changes: 13 additions & 3 deletions src/provider/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
Expand All @@ -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())
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
85 changes: 83 additions & 2 deletions src/provider/openrouter_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions src/provider/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
));
}
Expand Down
Loading