diff --git a/src-tauri/crates/agent-core/src/core/providers/factory.rs b/src-tauri/crates/agent-core/src/core/providers/factory.rs index 824f8ca48c..ceb3aba876 100644 --- a/src-tauri/crates/agent-core/src/core/providers/factory.rs +++ b/src-tauri/crates/agent-core/src/core/providers/factory.rs @@ -281,6 +281,7 @@ fn api_key_model_type_for_spec(spec: &ProviderSpec) -> Option { match spec.name { provider_id::ANTHROPIC => Some(ModelType::AnthropicApi), provider_id::OPENAI => Some(ModelType::OpenaiApi), + provider_id::ATLASCLOUD => Some(ModelType::AtlascloudApi), provider_id::DEEPSEEK => Some(ModelType::DeepseekApi), provider_id::GEMINI => Some(ModelType::GeminiApi), provider_id::GROQ => Some(ModelType::GroqApi), @@ -308,6 +309,7 @@ fn spec_for_model_type(model_type: &ModelType) -> Option<&'static ProviderSpec> let provider_name = match model_type { ModelType::AnthropicApi | ModelType::AzureAnthropicApi => provider_id::ANTHROPIC, ModelType::Codex | ModelType::OpenaiApi => provider_id::OPENAI, + ModelType::AtlascloudApi => provider_id::ATLASCLOUD, ModelType::GeminiApi => provider_id::GEMINI, ModelType::MoonshotApi => provider_id::MOONSHOT, ModelType::DeepseekApi => provider_id::DEEPSEEK, @@ -982,6 +984,7 @@ mod tests { provider_id::OPENCODE, provider_id::ANTHROPIC, provider_id::OPENAI, + provider_id::ATLASCLOUD, provider_id::DEEPSEEK, provider_id::GEMINI, provider_id::GROQ, @@ -1009,6 +1012,7 @@ mod tests { const KEYMAP_COVERED: &[&str] = &[ provider_id::ANTHROPIC, provider_id::OPENAI, + provider_id::ATLASCLOUD, provider_id::DEEPSEEK, provider_id::GEMINI, provider_id::GROQ, @@ -1184,6 +1188,7 @@ mod tests { ModelType::CherryinApi, ModelType::BedrockApi, ModelType::CustomApi, + ModelType::AtlascloudApi, ] { let spec = spec_for_model_type(&model_type).unwrap_or_else(|| { panic!("{model_type:?} has no ProviderSpec in the agent-core registry") diff --git a/src-tauri/crates/agent-core/src/core/providers/registry.rs b/src-tauri/crates/agent-core/src/core/providers/registry.rs index eeb8337127..ac3c753a2f 100644 --- a/src-tauri/crates/agent-core/src/core/providers/registry.rs +++ b/src-tauri/crates/agent-core/src/core/providers/registry.rs @@ -27,6 +27,7 @@ pub mod provider_id { // Standard providers pub const ANTHROPIC: &str = "anthropic"; pub const OPENAI: &str = "openai"; + pub const ATLASCLOUD: &str = "atlascloud"; pub const DEEPSEEK: &str = "deepseek"; pub const GEMINI: &str = "gemini"; pub const GROQ: &str = "groq"; @@ -150,6 +151,17 @@ pub static PROVIDERS: &[ProviderSpec] = &[ is_local: false, env_key: Some("OPENAI_API_KEY"), }, + ProviderSpec { + name: provider_id::ATLASCLOUD, + display_name: "Atlas Cloud", + keywords: &[], + litellm_prefix: None, + skip_prefixes: &["atlascloud/"], + default_api_base: Some("https://api.atlascloud.ai/v1"), + default_anthropic_api_base: None, + is_local: false, + env_key: Some("ATLASCLOUD_API_KEY"), + }, ProviderSpec { name: provider_id::DEEPSEEK, display_name: "DeepSeek", diff --git a/src-tauri/crates/key-vault/src/commands/registry/data/api_providers.rs b/src-tauri/crates/key-vault/src/commands/registry/data/api_providers.rs index 794cc56823..5ae6404b76 100644 --- a/src-tauri/crates/key-vault/src/commands/registry/data/api_providers.rs +++ b/src-tauri/crates/key-vault/src/commands/registry/data/api_providers.rs @@ -15,6 +15,17 @@ pub(crate) fn api_provider_registry() -> Vec { popular: true, supports_rust_agents: true, }, + ApiProviderEntry { + name: "atlascloud_api", + display_name: "Atlas Cloud", + description: "Atlas Cloud OpenAI-compatible models via API", + brand_color: "#111827", + docs_url: "https://www.atlascloud.ai/console/api-keys", + icon_provider: "atlascloud", + paired_cli_agent: None, + popular: false, + supports_rust_agents: true, + }, ApiProviderEntry { name: "anthropic_api", display_name: "Anthropic", diff --git a/src-tauri/crates/key-vault/src/commands/registry/data/cli_agents.rs b/src-tauri/crates/key-vault/src/commands/registry/data/cli_agents.rs index 24d0cf7e03..5f9ecc2eed 100644 --- a/src-tauri/crates/key-vault/src/commands/registry/data/cli_agents.rs +++ b/src-tauri/crates/key-vault/src/commands/registry/data/cli_agents.rs @@ -126,6 +126,7 @@ pub(crate) fn cli_agent_registry() -> Vec { has_subscription_plan: true, compatible_api_providers: &[ "openai_api", + "atlascloud_api", "openrouter_api", "azure_openai_api", "deepseek_api", @@ -221,6 +222,7 @@ pub(crate) fn cli_agent_registry() -> Vec { compatible_api_providers: &[ "anthropic_api", "openai_api", + "atlascloud_api", "gemini_api", "openrouter_api", "groq_api", diff --git a/src-tauri/crates/key-vault/src/commands/registry/mod.rs b/src-tauri/crates/key-vault/src/commands/registry/mod.rs index 0ed4fec7fe..707e7e5705 100644 --- a/src-tauri/crates/key-vault/src/commands/registry/mod.rs +++ b/src-tauri/crates/key-vault/src/commands/registry/mod.rs @@ -160,6 +160,8 @@ mod compatibility_tests { #[test] fn compatibility_comes_from_the_central_cli_registry() { assert!(is_cli_provider_compatible("codex", "openai_api")); + assert!(is_cli_provider_compatible("codex", "atlascloud_api")); + assert!(is_cli_provider_compatible("opencode", "atlascloud_api")); assert!(is_cli_provider_compatible("claude_code", "anthropic_api")); assert!(!is_cli_provider_compatible("unknown", "openai_api")); assert_eq!(cli_agent_display_name("opencode"), Some("OpenCode")); diff --git a/src-tauri/crates/key-vault/src/commands/validate.rs b/src-tauri/crates/key-vault/src/commands/validate.rs index 4a73cf002b..1650337fa7 100644 --- a/src-tauri/crates/key-vault/src/commands/validate.rs +++ b/src-tauri/crates/key-vault/src/commands/validate.rs @@ -230,7 +230,7 @@ pub async fn run_validate_key( // OpenAI-compatible API providers (use OpenAI validator with provider's base URL). // Providers that also speak the Anthropic protocol declare an Anthropic // endpoint in the provider registry and route through it below. - "deepseek_api" | "groq_api" | "xai_api" | "zhipu_api" | "dashscope_api" + "atlascloud_api" | "deepseek_api" | "groq_api" | "xai_api" | "zhipu_api" | "dashscope_api" | "moonshot_api" | "minimax_api" | "longcat_api" | "openrouter_api" | "zenmux_api" | "siliconflow_api" | "modelscope_api" | "aihubmix_api" | "cherryin_api" | "bedrock_api" | "custom_api" | "vllm_api" | "orgii_orchestrator" | "orgii" => { @@ -258,7 +258,7 @@ pub async fn run_validate_key( } _ => Err(format!( - "Unknown agent type: {}. Supported: copilot, cursor_cli, openai, anthropic, google, codex, claude_code, kiro, opencode, openai_api, anthropic_api, gemini_api, deepseek_api, groq_api, xai_api, zhipu_api, dashscope_api, moonshot_api, minimax_api, longcat_api, openrouter_api, zenmux_api, siliconflow_api, modelscope_api, aihubmix_api, cherryin_api, bedrock_api, custom_api, vllm_api, azure_openai_api, azure_anthropic_api", + "Unknown agent type: {}. Supported: copilot, cursor_cli, openai, anthropic, google, codex, claude_code, kiro, opencode, openai_api, atlascloud_api, anthropic_api, gemini_api, deepseek_api, groq_api, xai_api, zhipu_api, dashscope_api, moonshot_api, minimax_api, longcat_api, openrouter_api, zenmux_api, siliconflow_api, modelscope_api, aihubmix_api, cherryin_api, bedrock_api, custom_api, vllm_api, azure_openai_api, azure_anthropic_api", agent_type )), } @@ -406,9 +406,9 @@ pub fn validate_token_format(agent_type: String, token: String) -> Result<(bool, } // OpenAI-compatible providers: just verify non-empty and reasonable length - "deepseek_api" | "groq_api" | "xai_api" | "zhipu_api" | "dashscope_api" - | "moonshot_api" | "minimax_api" | "longcat_api" | "openrouter_api" | "zenmux_api" - | "vllm_api" | "orgii_orchestrator" | "orgii" => { + "atlascloud_api" | "deepseek_api" | "groq_api" | "xai_api" | "zhipu_api" + | "dashscope_api" | "moonshot_api" | "minimax_api" | "longcat_api" | "openrouter_api" + | "zenmux_api" | "vllm_api" | "orgii_orchestrator" | "orgii" => { if token.is_empty() { Ok((false, "API key is required".to_string())) } else if token.len() < 8 { @@ -458,6 +458,7 @@ pub async fn fetch_key_quota( | "kiro" | "openai_api" | "anthropic_api" + | "atlascloud_api" | "gemini_api" | "deepseek_api" | "groq_api" @@ -914,6 +915,10 @@ mod tests { default_base_url_for_provider("xai_api"), Some("https://api.x.ai".to_string()) ); + assert_eq!( + default_base_url_for_provider("atlascloud_api"), + Some("https://api.atlascloud.ai".to_string()) + ); } #[test] @@ -1044,6 +1049,7 @@ mod tests { "deepseek_api", "groq_api", "xai_api", + "atlascloud_api", "zhipu_api", "dashscope_api", "moonshot_api", @@ -1067,6 +1073,7 @@ mod tests { "deepseek_api", "groq_api", "xai_api", + "atlascloud_api", "zhipu_api", "dashscope_api", "moonshot_api", @@ -1091,6 +1098,7 @@ mod tests { "deepseek_api", "groq_api", "xai_api", + "atlascloud_api", "longcat_api", "orgii_orchestrator", "orgii", diff --git a/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs b/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs index ff92fc89ca..cfec475510 100644 --- a/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs +++ b/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs @@ -368,6 +368,7 @@ impl KeyService { // API key providers: store api_key under the provider's env var name ModelType::AnthropicApi | ModelType::OpenaiApi + | ModelType::AtlascloudApi | ModelType::DeepseekApi | ModelType::GeminiApi | ModelType::GroqApi @@ -400,6 +401,7 @@ impl KeyService { let env_key = match agent_type { ModelType::AnthropicApi => "ANTHROPIC_API_KEY", ModelType::OpenaiApi => "OPENAI_API_KEY", + ModelType::AtlascloudApi => "ATLASCLOUD_API_KEY", ModelType::DeepseekApi => "DEEPSEEK_API_KEY", ModelType::GeminiApi => "GEMINI_API_KEY", ModelType::GroqApi => "GROQ_API_KEY", @@ -534,6 +536,7 @@ impl KeyService { // API key providers — must mirror the list in get_env_for_agent ModelType::AnthropicApi | ModelType::OpenaiApi + | ModelType::AtlascloudApi | ModelType::DeepseekApi | ModelType::GeminiApi | ModelType::GroqApi @@ -565,6 +568,7 @@ impl KeyService { let env_key = match agent_type { ModelType::AnthropicApi => "ANTHROPIC_API_KEY", ModelType::OpenaiApi => "OPENAI_API_KEY", + ModelType::AtlascloudApi => "ATLASCLOUD_API_KEY", ModelType::DeepseekApi => "DEEPSEEK_API_KEY", ModelType::GeminiApi => "GEMINI_API_KEY", ModelType::GroqApi => "GROQ_API_KEY", diff --git a/src-tauri/crates/key-vault/src/key_store/tests/model_type_tests.rs b/src-tauri/crates/key-vault/src/key_store/tests/model_type_tests.rs index 65858942e2..7e24f4e33e 100644 --- a/src-tauri/crates/key-vault/src/key_store/tests/model_type_tests.rs +++ b/src-tauri/crates/key-vault/src/key_store/tests/model_type_tests.rs @@ -35,6 +35,7 @@ fn api_providers_are_api_key_providers() { let api_types = [ ModelType::AnthropicApi, ModelType::OpenaiApi, + ModelType::AtlascloudApi, ModelType::DeepseekApi, ModelType::GeminiApi, ModelType::GroqApi, @@ -128,6 +129,7 @@ fn as_str_round_trips_through_from_str() { ModelType::TraeCli, ModelType::AnthropicApi, ModelType::OpenaiApi, + ModelType::AtlascloudApi, ModelType::DeepseekApi, ModelType::GeminiApi, ModelType::GroqApi, diff --git a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs index 4436c19263..cda64d0a07 100644 --- a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs @@ -1228,6 +1228,29 @@ fn test_cross_type_env_zenmux_as_codex_uses_openai_endpoint() { assert!(!env.contains_key("ZENMUX_API_KEY")); } +#[test] +fn test_atlascloud_provider_exports_canonical_environment() { + let temp_dir = tempdir().unwrap(); + let service = KeyService::new(Some(temp_dir.path().to_path_buf())); + + let mut atlas_key = ModelKey::new(ModelType::AtlascloudApi); + atlas_key.api_key = Some("atlas-test-key".to_string()); + atlas_key.base_url = Some("https://api.atlascloud.ai/v1".to_string()); + let key_id = atlas_key.id.clone(); + service.save_key(atlas_key).unwrap(); + + let env = service.get_env_for_agent(&ModelType::AtlascloudApi, Some(&key_id)); + assert_eq!( + env.get("ATLASCLOUD_API_KEY").map(String::as_str), + Some("atlas-test-key") + ); + assert_eq!( + env.get("ATLASCLOUD_BASE_URL").map(String::as_str), + Some("https://api.atlascloud.ai/v1") + ); + assert!(!env.contains_key("ATLASCLOUD_API_BASE")); +} + #[test] fn test_cross_type_enabled_models_first_wins() { // Regression guard: when a key has both `available_models` (raw probe diff --git a/src-tauri/crates/key-vault/src/key_store/types.rs b/src-tauri/crates/key-vault/src/key_store/types.rs index c351cc8aed..84806c308a 100644 --- a/src-tauri/crates/key-vault/src/key_store/types.rs +++ b/src-tauri/crates/key-vault/src/key_store/types.rs @@ -111,6 +111,7 @@ pub enum ModelType { // Direct API key providers AnthropicApi, OpenaiApi, + AtlascloudApi, DeepseekApi, GeminiApi, GroqApi, @@ -178,6 +179,7 @@ impl ModelType { // API key providers ModelType::AnthropicApi => "anthropic_api", ModelType::OpenaiApi => "openai_api", + ModelType::AtlascloudApi => "atlascloud_api", ModelType::DeepseekApi => "deepseek_api", ModelType::GeminiApi => "gemini_api", ModelType::GroqApi => "groq_api", @@ -239,6 +241,7 @@ impl ModelType { // API key providers "anthropic_api" | "anthropic" => Some(ModelType::AnthropicApi), "openai_api" | "openai" => Some(ModelType::OpenaiApi), + "atlascloud_api" | "atlascloud" | "atlas_cloud" => Some(ModelType::AtlascloudApi), "deepseek_api" | "deepseek" => Some(ModelType::DeepseekApi), "gemini_api" | "gemini" | "google" => Some(ModelType::GeminiApi), "groq_api" | "groq" => Some(ModelType::GroqApi), diff --git a/src-tauri/crates/key-vault/src/provider_config.rs b/src-tauri/crates/key-vault/src/provider_config.rs index f81abe8a7b..9bb12bd7f8 100644 --- a/src-tauri/crates/key-vault/src/provider_config.rs +++ b/src-tauri/crates/key-vault/src/provider_config.rs @@ -357,6 +357,12 @@ pub fn get_provider_config(model_type: &str) -> ProviderConfig { true, Some("https://api.openai.com/v1"), ), + "atlascloud_api" => ProviderConfig::new( + "ATLASCLOUD_API_KEY", + Some("ATLASCLOUD_BASE_URL"), + true, + Some("https://api.atlascloud.ai/v1"), + ), "deepseek_api" => ProviderConfig::new( "DEEPSEEK_API_KEY", None, @@ -530,6 +536,7 @@ pub fn get_all_provider_configs() -> Vec<(String, ProviderConfig)> { // API providers "anthropic_api", "openai_api", + "atlascloud_api", "deepseek_api", "gemini_api", "groq_api", @@ -574,6 +581,23 @@ mod tests { ); } + #[test] + fn test_get_provider_config_atlascloud() { + let config = get_provider_config("atlascloud_api"); + assert_eq!(config.api_key_env_var, "ATLASCLOUD_API_KEY"); + assert_eq!( + config.base_url_env_var, + Some("ATLASCLOUD_BASE_URL".to_string()) + ); + assert!(config.supports_base_url); + assert_eq!( + config.default_base_url, + Some("https://api.atlascloud.ai/v1".to_string()) + ); + assert_eq!(config.supported_protocols, vec!["openai"]); + assert_eq!(config.default_protocol, "openai"); + } + #[test] fn test_get_provider_config_case_insensitive() { let config = get_provider_config("OPENAI_API"); @@ -635,6 +659,7 @@ mod tests { // Should have at least the main providers assert!(configs.iter().any(|(k, _)| k == "openai_api")); assert!(configs.iter().any(|(k, _)| k == "anthropic_api")); + assert!(configs.iter().any(|(k, _)| k == "atlascloud_api")); assert!(configs.iter().any(|(k, _)| k == "zenmux_api")); assert!(configs.iter().any(|(k, _)| k == "cursor_cli")); } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs index ae90b0ca49..5e66b3361d 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs @@ -22,6 +22,10 @@ use super::oauth_setup::write_codex_cli_auth_file; const OPENCODE_ZENMUX_PROVIDER_ID: &str = "zenmux"; const OPENCODE_ZENMUX_BASE_URL: &str = "https://zenmux.ai/api/v1"; const OPENCODE_DEFAULT_ZENMUX_MODEL: &str = "deepseek/deepseek-chat"; +const ATLASCLOUD_PROVIDER_ID: &str = "atlascloud"; +const ATLASCLOUD_CODEX_PROVIDER_ID: &str = "atlas_coding_plan"; +const ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1"; +const ATLASCLOUD_DEFAULT_MODEL: &str = "zai-org/glm-5.1"; const OPENCODE_ZENMUX_MODEL_IDS: &[&str] = &[ "inclusionai/ling-1t", "inclusionai/ring-1t", @@ -55,6 +59,15 @@ pub(super) fn opencode_zenmux_model_id( .to_string() } +pub(super) fn atlascloud_model_id(session_model: Option<&str>, selected_key: &ModelKey) -> String { + session_model + .filter(|value| !value.trim().is_empty()) + .or_else(|| selected_key.enabled_models.first().map(String::as_str)) + .or_else(|| selected_key.available_models.first().map(String::as_str)) + .unwrap_or(ATLASCLOUD_DEFAULT_MODEL) + .to_string() +} + fn opencode_zenmux_config_payload(model_id: &str) -> serde_json::Value { let mut models = serde_json::Map::new(); for model in OPENCODE_ZENMUX_MODEL_IDS { @@ -121,6 +134,115 @@ pub(super) fn setup_opencode_zenmux_profile( Ok(()) } +fn opencode_atlascloud_config_payload(model_id: &str, base_url: &str) -> serde_json::Value { + serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "provider": { + ATLASCLOUD_PROVIDER_ID: { + "npm": "@ai-sdk/openai-compatible", + "name": "atlascloud", + "options": { + "baseURL": base_url, + "apiKey": "{env:ATLASCLOUD_API_KEY}" + }, + "models": { + model_id: { + "name": model_id + } + } + } + }, + "model": format!("{}/{}", ATLASCLOUD_PROVIDER_ID, model_id), + "small_model": format!("{}/{}", ATLASCLOUD_PROVIDER_ID, model_id) + }) +} + +pub(super) fn setup_opencode_atlascloud_profile( + profile_home: &Path, + selected_key: &ModelKey, + session_model: Option<&str>, +) -> Result<(), String> { + let api_key = selected_key + .api_key + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "OpenCode Atlas Cloud session requires an API key".to_string())?; + let model_id = atlascloud_model_id(session_model, selected_key); + let base_url = selected_key + .base_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(ATLASCLOUD_BASE_URL); + let config_dir = profile_home.join(".config").join("opencode"); + let data_dir = profile_home.join(".local").join("share").join("opencode"); + + std::fs::create_dir_all(&config_dir) + .map_err(|err| format!("Failed to create OpenCode config dir: {}", err))?; + std::fs::create_dir_all(&data_dir) + .map_err(|err| format!("Failed to create OpenCode data dir: {}", err))?; + + let config_bytes = + serde_json::to_vec_pretty(&opencode_atlascloud_config_payload(&model_id, base_url)) + .map_err(|err| err.to_string())?; + std::fs::write(config_dir.join("opencode.json"), config_bytes) + .map_err(|err| format!("Failed to write OpenCode config: {}", err))?; + + let auth_bytes = serde_json::to_vec_pretty(&serde_json::json!({ + ATLASCLOUD_PROVIDER_ID: { + "type": "api", + "key": api_key + } + })) + .map_err(|err| err.to_string())?; + std::fs::write(data_dir.join("auth.json"), auth_bytes) + .map_err(|err| format!("Failed to write OpenCode auth: {}", err))?; + + Ok(()) +} + +pub(super) fn setup_codex_atlascloud_profile( + profile_home: &Path, + selected_key: &ModelKey, + session_model: Option<&str>, +) -> Result<(), String> { + let api_key = selected_key + .api_key + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Codex Atlas Cloud session requires an API key".to_string())?; + let model_id = atlascloud_model_id(session_model, selected_key); + let base_url = selected_key + .base_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(ATLASCLOUD_BASE_URL); + let quoted_model = serde_json::to_string(&model_id).map_err(|err| err.to_string())?; + let quoted_base_url = serde_json::to_string(base_url).map_err(|err| err.to_string())?; + let config = format!( + "model_provider = \"{ATLASCLOUD_CODEX_PROVIDER_ID}\"\n\ + model = {quoted_model}\n\n\ + [model_providers.{ATLASCLOUD_CODEX_PROVIDER_ID}]\n\ + name = \"atlascloud\"\n\ + base_url = {quoted_base_url}\n\ + wire_api = \"chat\"\n\ + requires_openai_auth = true\n" + ); + + std::fs::create_dir_all(profile_home) + .map_err(|err| format!("Failed to create Codex profile dir: {}", err))?; + std::fs::write(profile_home.join("config.toml"), config) + .map_err(|err| format!("Failed to write Codex config: {}", err))?; + + let auth_bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "OPENAI_API_KEY": api_key + })) + .map_err(|err| err.to_string())?; + std::fs::write(profile_home.join("auth.json"), auth_bytes) + .map_err(|err| format!("Failed to write Codex auth: {}", err))?; + + Ok(()) +} + /// Start the per-session MITM proxy and point the child's proxy/cert env at it. /// Called only when the session uses a hosted key on a MITM-requiring agent. pub(super) async fn start_session_mitm_proxy( @@ -232,20 +354,50 @@ pub(super) fn configure_agent_profile( codex_home.to_string_lossy().to_string(), ); write_codex_cli_auth_file(account_id, env_vars); + if selected_key.is_some_and(|key| key.model_type == ModelType::AtlascloudApi) { + let selected_key = selected_key + .ok_or_else(|| "Codex Atlas Cloud session requires a selected key".to_string())?; + setup_codex_atlascloud_profile(&codex_home, selected_key, session.model.as_deref()) + .map_err(|err| format!("Failed to setup Codex Atlas Cloud profile: {}", err))?; + } } if matches!(agent, ModelType::OpenCode) && session.key_source == KeySource::OwnKey - && selected_key.is_some_and(|key| key.model_type == ModelType::ZenmuxApi) + && selected_key.is_some_and(|key| { + matches!( + &key.model_type, + ModelType::ZenmuxApi | ModelType::AtlascloudApi + ) + }) { let Some(account_id) = account_id else { - return Err("OpenCode ZenMux own-key session requires account_id".to_string()); + return Err("OpenCode provider session requires account_id".to_string()); }; let selected_key = selected_key - .ok_or_else(|| "OpenCode ZenMux session requires a selected ZenMux key".to_string())?; + .ok_or_else(|| "OpenCode provider session requires a selected key".to_string())?; let opencode_home = app_paths::opencode_cli_profile_dir(account_id); - setup_opencode_zenmux_profile(&opencode_home, selected_key, session.model.as_deref()) - .map_err(|err| format!("Failed to setup OpenCode ZenMux profile: {}", err))?; + let (provider_name, api_key_env) = match &selected_key.model_type { + ModelType::ZenmuxApi => { + setup_opencode_zenmux_profile( + &opencode_home, + selected_key, + session.model.as_deref(), + ) + .map_err(|err| format!("Failed to setup OpenCode ZenMux profile: {}", err))?; + ("ZenMux", "ZENMUX_API_KEY") + } + ModelType::AtlascloudApi => { + setup_opencode_atlascloud_profile( + &opencode_home, + selected_key, + session.model.as_deref(), + ) + .map_err(|err| format!("Failed to setup OpenCode Atlas Cloud profile: {}", err))?; + ("Atlas Cloud", "ATLASCLOUD_API_KEY") + } + _ => unreachable!("OpenCode managed profile guard only accepts ZenMux or Atlas Cloud"), + }; let home_path = opencode_home.to_string_lossy().to_string(); let config_home = opencode_home.join(".config").to_string_lossy().to_string(); @@ -255,12 +407,16 @@ pub(super) fn configure_agent_profile( .to_string_lossy() .to_string(); - tracing::info!("[CodeSession] OpenCode ZenMux HOME={}", home_path); + tracing::info!( + "[CodeSession] OpenCode {} HOME={}", + provider_name, + home_path + ); env_vars.insert("HOME".to_string(), home_path); env_vars.insert("XDG_CONFIG_HOME".to_string(), config_home); env_vars.insert("XDG_DATA_HOME".to_string(), data_home); if let Some(api_key) = selected_key.api_key.as_deref() { - env_vars.insert("ZENMUX_API_KEY".to_string(), api_key.to_string()); + env_vars.insert(api_key_env.to_string(), api_key.to_string()); } } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session.rs b/src-tauri/src/agent_sessions/cli/session_runner/session.rs index fac067d77d..3ea434bff4 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session.rs @@ -37,6 +37,19 @@ mod transport_standard; use skills_resolve::resolve_sde_skills; use spawn_retry::{is_transient_spawn_error, SPAWN_RETRY_ATTEMPTS, SPAWN_RETRY_BASE_DELAY_MS}; +fn resolve_session_model<'a>( + agent: &ModelType, + key_model_type: Option<&ModelType>, + session_model: Option<&'a str>, +) -> Option<&'a str> { + let is_cross_type_key = key_model_type.is_some_and(|key_type| key_type != agent); + if is_cross_type_key && matches!(agent, ModelType::ClaudeCode) { + None + } else { + session_model + } +} + /// Run a code session: spawn CLI, parse stdout, broadcast events. /// /// This is spawned as a background Tokio task. @@ -63,10 +76,10 @@ pub async fn run_session( cli_agent_type_str ) })?; - // When using a cross-type compatible key (e.g. moonshot_api key for claude_code), - // the model override is injected via ANTHROPIC_MODEL / ANTHROPIC_DEFAULT_*_MODEL env vars - // in agent_env_builder. Passing --model with a provider-specific name (e.g. "kimi-for-coding") - // triggers Claude Code's model validation and fails. Skip --model in that case. + // Claude Code cross-type providers inject their model through ANTHROPIC_MODEL + // and reject provider-specific values passed to --model. Other compatible + // agents retain the user's selected model; their generated provider profile + // handles the routing. let mut selected_key = session .account_id .as_deref() @@ -87,12 +100,7 @@ pub async fn run_session( } } let key_model_type = selected_key.as_ref().map(|key| key.model_type.clone()); - let is_cross_type_key = key_model_type.as_ref().is_some_and(|kt| kt != &agent); - let model = if is_cross_type_key { - None - } else { - session.model.as_deref() - }; + let model = resolve_session_model(&agent, key_model_type.as_ref(), session.model.as_deref()); let repo_path = session.repo_path.as_deref(); let account_id = session.account_id.as_deref(); diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs index 94b7fffb86..932fb619fb 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs @@ -1,4 +1,7 @@ -use super::super::env_setup::{opencode_zenmux_model_id, setup_opencode_zenmux_profile}; +use super::super::env_setup::{ + atlascloud_model_id, opencode_zenmux_model_id, setup_codex_atlascloud_profile, + setup_opencode_atlascloud_profile, setup_opencode_zenmux_profile, +}; use super::super::input_assembly::cli_exec_mode_bridge; use super::super::oauth_setup::{is_api_overloaded_message, is_retryable_overloaded_chunk}; use super::super::plan_approval::{ @@ -93,6 +96,88 @@ fn setup_opencode_zenmux_profile_writes_config_and_auth() { assert_eq!(auth["zenmux"]["key"].as_str(), Some("sk-ai-v1-test")); } +#[test] +fn atlascloud_model_id_prefers_session_model() { + let mut key = ModelKey::new(ModelType::AtlascloudApi); + key.enabled_models = vec!["zai-org/glm-5.1".to_string()]; + + assert_eq!( + atlascloud_model_id(Some("deepseek-ai/deepseek-v3.2"), &key), + "deepseek-ai/deepseek-v3.2" + ); +} + +#[test] +fn setup_codex_atlascloud_profile_writes_provider_model_and_auth() { + let temp_dir = tempfile::tempdir().expect("temp Codex profile"); + let mut key = ModelKey::new(ModelType::AtlascloudApi); + key.api_key = Some("atlas-test-key".to_string()); + key.enabled_models = vec!["zai-org/glm-5.1".to_string()]; + + setup_codex_atlascloud_profile(temp_dir.path(), &key, None).expect("setup profile"); + + let config = std::fs::read_to_string(temp_dir.path().join("config.toml")).expect("read config"); + assert!(config.contains("model_provider = \"atlas_coding_plan\"")); + assert!(config.contains("model = \"zai-org/glm-5.1\"")); + assert!(config.contains("[model_providers.atlas_coding_plan]")); + assert!(config.contains("base_url = \"https://api.atlascloud.ai/v1\"")); + assert!(config.contains("wire_api = \"chat\"")); + assert!(config.contains("requires_openai_auth = true")); + + let auth = read_json(&temp_dir.path().join("auth.json")); + assert_eq!(auth["OPENAI_API_KEY"].as_str(), Some("atlas-test-key")); +} + +#[test] +fn setup_opencode_atlascloud_profile_writes_config_and_auth() { + let temp_dir = tempfile::tempdir().expect("temp OpenCode profile"); + let mut key = ModelKey::new(ModelType::AtlascloudApi); + key.api_key = Some("atlas-test-key".to_string()); + key.enabled_models = vec!["zai-org/glm-5.1".to_string()]; + + setup_opencode_atlascloud_profile(temp_dir.path(), &key, None).expect("setup profile"); + + let config = read_json(&temp_dir.path().join(".config/opencode/opencode.json")); + assert_eq!( + config["provider"]["atlascloud"]["npm"].as_str(), + Some("@ai-sdk/openai-compatible") + ); + assert_eq!( + config["provider"]["atlascloud"]["options"]["baseURL"].as_str(), + Some("https://api.atlascloud.ai/v1") + ); + assert_eq!( + config["provider"]["atlascloud"]["options"]["apiKey"].as_str(), + Some("{env:ATLASCLOUD_API_KEY}") + ); + assert_eq!(config["model"].as_str(), Some("atlascloud/zai-org/glm-5.1")); + assert!(config["provider"]["atlascloud"]["models"]["zai-org/glm-5.1"].is_object()); + + let auth = read_json(&temp_dir.path().join(".local/share/opencode/auth.json")); + assert_eq!(auth["atlascloud"]["type"].as_str(), Some("api")); + assert_eq!(auth["atlascloud"]["key"].as_str(), Some("atlas-test-key")); +} + +#[test] +fn cross_type_atlascloud_model_is_preserved_for_codex() { + assert_eq!( + resolve_session_model( + &ModelType::Codex, + Some(&ModelType::AtlascloudApi), + Some("zai-org/glm-5.1"), + ), + Some("zai-org/glm-5.1") + ); + assert_eq!( + resolve_session_model( + &ModelType::ClaudeCode, + Some(&ModelType::AtlascloudApi), + Some("zai-org/glm-5.1"), + ), + None + ); +} + #[test] fn cli_plan_mode_bridge_preserves_side_chat_semantics() { let bridge = cli_exec_mode_bridge(Some("plan")).expect("plan bridge"); diff --git a/src/api/tauri/rpc/schemas/validationEnums.ts b/src/api/tauri/rpc/schemas/validationEnums.ts index fc571971d2..472d8522b0 100644 --- a/src/api/tauri/rpc/schemas/validationEnums.ts +++ b/src/api/tauri/rpc/schemas/validationEnums.ts @@ -84,6 +84,7 @@ export const CliAgentTypeSchema = z.union([ export const ApiProviderTypeSchema = z.union([ z.literal("anthropic_api"), z.literal("openai_api"), + z.literal("atlascloud_api"), z.literal("deepseek_api"), z.literal("gemini_api"), z.literal("groq_api"), diff --git a/src/assets/modelIcons/atlascloud.svg b/src/assets/modelIcons/atlascloud.svg new file mode 100644 index 0000000000..e4b13468c0 --- /dev/null +++ b/src/assets/modelIcons/atlascloud.svg @@ -0,0 +1 @@ +Atlas Cloud diff --git a/src/assets/providers/index.ts b/src/assets/providers/index.ts index 8314adf7e4..c1b536465b 100644 --- a/src/assets/providers/index.ts +++ b/src/assets/providers/index.ts @@ -48,6 +48,7 @@ export const AGENT_TYPE_LIST: CliAgentType[] = [ /** API key provider types in alphabetical order by label */ export const API_KEY_PROVIDER_LIST: ApiProviderType[] = [ "anthropic_api", // Anthropic + "atlascloud_api", // Atlas Cloud "azure_anthropic_api", // Azure Anthropic "azure_openai_api", // Azure OpenAI "deepseek_api", // DeepSeek diff --git a/src/components/ModelIcon/config.ts b/src/components/ModelIcon/config.ts index fa394d2e76..ae636f2b6b 100644 --- a/src/components/ModelIcon/config.ts +++ b/src/components/ModelIcon/config.ts @@ -15,6 +15,7 @@ import AiderIcon from "@src/assets/modelIcons/aider.svg"; import AiHubMixIcon from "@src/assets/modelIcons/aihubmix.svg"; import AmpIcon from "@src/assets/modelIcons/amp.svg"; import AntigravityIcon from "@src/assets/modelIcons/antigravity.svg"; +import AtlasCloudIcon from "@src/assets/modelIcons/atlascloud.svg"; import AugmentIcon from "@src/assets/modelIcons/augment.svg"; import AutoHandIcon from "@src/assets/modelIcons/autohand.svg"; import AWSIcon from "@src/assets/modelIcons/aws.svg"; @@ -90,6 +91,7 @@ import ZhipuIcon from "@src/assets/modelIcons/zhipu.svg"; */ export type IconProvider = | "openai" + | "atlascloud" | "codex" | "aws" | "azure" @@ -183,6 +185,7 @@ export const ICON_MAP: Record< kiro: KiroIcon, // OpenAI-related openai: OpenAIIcon, + atlascloud: AtlasCloudIcon, codex: OpenAIIcon, // Anthropic claude: ClaudeIcon, @@ -256,6 +259,7 @@ export const ICON_MAP: Record< /** Active icon providers available for user selection (excludes unknown + inactive agents) */ export const SELECTABLE_ICON_PROVIDERS: IconProvider[] = [ "openai", + "atlascloud", "claude", "claude_code", "gemini", @@ -367,6 +371,7 @@ const MODEL_TYPE_TO_ICON: Record = { // API key providers anthropic_api: "claude", openai_api: "openai", + atlascloud_api: "atlascloud", deepseek_api: "deepseek", gemini_api: "gemini", groq_api: "groq", diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts index f37031583a..b49598edae 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts @@ -169,6 +169,7 @@ const PRIMARY_PROVIDER_KEYS = new Set([ "modelscope_api", "aihubmix_api", "cherryin_api", + "atlascloud_api", "bedrock_api", "custom_api", "azure_openai_api",