diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 4279a4984..039f5826d 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -117,6 +117,46 @@ pub async fn switch_provider( } fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result { + if matches!(app_type, AppType::GrokBuild) { + // 官方登录态(live 语法合法且无自定义模型表)+ 用户手动导入: + // 导入的正确结果是让 Grok Official 成为当前供应商,而非报错。 + // 只挂在命令层 = 只有手动动作可达;启动自动导入走 service 层、 + // 官方态照旧报错静默跳过,删掉的官方条目不会被重启复活 + //(全项目惯例:启动自动导入只产出 default,从不产出官方条目)。 + if let Ok(settings) = crate::grok_config::read_grok_live_settings() { + let config = settings + .get("config") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if crate::grok_config::is_official_live_config(config) { + state.db.ensure_official_seed_by_id( + crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID, + AppType::GrokBuild, + )?; + state.db.set_current_provider( + app_type.as_str(), + crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID, + )?; + crate::settings::set_current_provider( + &app_type, + Some(crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID), + )?; + return Ok(true); + } + } + + // Safety net: 与 claude-desktop 导入同语义 —— 用户主动点导入是"重新 + // 整理该表"的隐式信号,把官方入口补回来。覆盖导入必然失败的场景 + //(live 文件缺失 / TOML 语法错误 / 残缺的自定义配置),避免 + // "报错 + 空列表"死胡同。失败只 warn,不影响导入主流程。 + if let Err(e) = state.db.ensure_official_seed_by_id( + crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID, + AppType::GrokBuild, + ) { + log::warn!("Failed to ensure grokbuild-official seed during import: {e}"); + } + } + let imported = ProviderService::import_default_config(state, app_type.clone())?; if imported { @@ -246,6 +286,17 @@ pub fn ensure_codex_official_provider(state: State<'_, AppState>) -> Result) -> Result { + state + .db + .ensure_official_seed_by_id( + crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID, + AppType::GrokBuild, + ) + .map_err(|e| e.to_string()) +} + fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool { let Some(env) = provider .settings_config diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 1d8ab4d10..f64154b06 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -712,6 +712,7 @@ mod ensure_official_seed_tests { use crate::app_config::AppType; use crate::database::{ Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID, + GROKBUILD_OFFICIAL_PROVIDER_ID, }; #[test] @@ -790,6 +791,26 @@ mod ensure_official_seed_tests { assert_eq!(provider.settings_config["auth"], serde_json::json!({})); } + #[test] + fn ensure_recreates_grokbuild_official_seed_after_deletion() { + let db = Database::memory().expect("memory db"); + db.init_default_official_providers().expect("seed"); + db.delete_provider(AppType::GrokBuild.as_str(), GROKBUILD_OFFICIAL_PROVIDER_ID) + .expect("delete Grok Build official"); + + let inserted = db + .ensure_official_seed_by_id(GROKBUILD_OFFICIAL_PROVIDER_ID, AppType::GrokBuild) + .expect("ensure Grok Build official"); + assert!(inserted); + let provider = db + .get_provider_by_id(GROKBUILD_OFFICIAL_PROVIDER_ID, AppType::GrokBuild.as_str()) + .expect("query") + .expect("Grok Build official restored"); + assert_eq!(provider.category.as_deref(), Some("official")); + // 空 config:切换时不注入自定义模型表,Grok CLI 回落到自带 OAuth 登录 + assert_eq!(provider.settings_config["config"], serde_json::json!("")); + } + #[test] fn ensure_rejects_unknown_seed() { let db = Database::memory().expect("memory db"); diff --git a/src-tauri/src/database/dao/providers_seed.rs b/src-tauri/src/database/dao/providers_seed.rs index 8091a1031..ca191a8a8 100644 --- a/src-tauri/src/database/dao/providers_seed.rs +++ b/src-tauri/src/database/dao/providers_seed.rs @@ -7,11 +7,13 @@ //! - `src/config/claudeProviderPresets.ts`("Claude Official") //! - `src/config/codexProviderPresets.ts`("OpenAI Official") //! - `src/config/geminiProviderPresets.ts`("Google Official") +//! - `src/components/providers/forms/GrokBuildProviderForm.tsx`("Grok Official") use crate::app_config::AppType; pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official"; pub(crate) const CODEX_OFFICIAL_PROVIDER_ID: &str = "codex-official"; +pub(crate) const GROKBUILD_OFFICIAL_PROVIDER_ID: &str = "grokbuild-official"; /// 单条官方供应商种子定义。 pub(crate) struct OfficialProviderSeed { @@ -69,6 +71,16 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[ // 空 env + 空 config 让用户走 Google OAuth settings_config_json: r#"{"env":{},"config":{}}"#, }, + OfficialProviderSeed { + id: GROKBUILD_OFFICIAL_PROVIDER_ID, + app_type: AppType::GrokBuild, + name: "Grok Official", + website_url: "https://x.ai/grok", + icon: "grok", + icon_color: "currentColor", + // 空 config = 不写自定义模型表,Grok CLI 回落到自带的 xAI OAuth 登录 + settings_config_json: r#"{"config":""}"#, + }, ]; /// 判断给定的 provider id 是否属于内置官方种子。 @@ -92,4 +104,17 @@ mod tests { assert_eq!(seed.app_type, AppType::ClaudeDesktop); assert!(is_official_seed_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID)); } + + #[test] + fn official_seeds_include_grokbuild() { + let seed = OFFICIAL_SEEDS + .iter() + .find(|seed| seed.id == GROKBUILD_OFFICIAL_PROVIDER_ID) + .expect("grok build official seed"); + + assert_eq!(seed.app_type, AppType::GrokBuild); + assert!(is_official_seed_id(GROKBUILD_OFFICIAL_PROVIDER_ID)); + // 空 config = 官方登录态:切换时不注入自定义模型表 + assert_eq!(seed.settings_config_json, r#"{"config":""}"#); + } } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index f4f981c91..ca946dd31 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -34,6 +34,7 @@ mod tests; // DAO 类型导出供外部使用 pub(crate) use dao::providers_seed::{ is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID, + GROKBUILD_OFFICIAL_PROVIDER_ID, }; pub(crate) use dao::proxy::{ validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST, diff --git a/src-tauri/src/grok_config.rs b/src-tauri/src/grok_config.rs index 687eabe2c..f0ab93864 100644 --- a/src-tauri/src/grok_config.rs +++ b/src-tauri/src/grok_config.rs @@ -59,6 +59,42 @@ fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option Result<(), AppError> { + if config_toml.trim().is_empty() { + return Ok(()); + } + config_toml + .parse::() + .map(|_| ()) + .map_err(|error| { + AppError::localized( + "provider.grokbuild.config.invalid_toml", + format!("Grok Build config.toml 格式错误: {error}"), + format!("Invalid Grok Build config.toml: {error}"), + ) + }) +} + +/// Whether a live config document represents the official login state. +/// +/// 官方态 = 语法合法且完全没有自定义模型痕迹(无 `[models]` 也无 `[model.*]`, +/// 允许 `[mcp_servers]` 等其它内容)。只要出现过任一自定义键就返回 false, +/// 让残缺的自定义配置继续走 `validate_config_toml` 报出真实错误, +/// 而不是被误判成官方态静默吞掉。语法不合法同样返回 false。 +pub fn is_official_live_config(config_toml: &str) -> bool { + let Ok(document) = config_toml.parse::() else { + return false; + }; + document + .as_table() + .is_some_and(|root| !root.contains_key("models") && !root.contains_key("model")) +} + /// Validate the provider-owned Grok Build TOML document. pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> { let document = config_toml.parse::().map_err(|error| { @@ -305,6 +341,11 @@ pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(), Ok(()) } +/// Read the live `~/.grok/config.toml` as a provider settings snapshot. +/// +/// 只做 TOML 语法校验:live 处于官方态(无自定义模型表)时同样需要能被 +/// 读取,供切换回填与界面展示使用。需要"完整自定义模型配置"的导入路径 +/// 由调用方自行叠加 `validate_config_toml`。 pub fn read_grok_live_settings() -> Result { let path = get_grok_config_path(); if !path.exists() { @@ -316,7 +357,7 @@ pub fn read_grok_live_settings() -> Result { } let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?; - validate_config_toml(&config)?; + validate_config_toml_syntax(&config)?; Ok(json!({ "config": config })) } @@ -339,9 +380,20 @@ pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> { ) })?; + // 官方条目不注入自定义模型表:按快照原样写回(首次为空文件), + // Grok CLI 回落到官方内置模型 + 自带 OAuth 登录;MCP 投影随后由 + // 切换流程重新补写。非官方供应商必须携带完整的自定义模型配置。 + if provider.category.as_deref() != Some("official") { + validate_config_toml(config)?; + } + write_grok_live_settings(&json!({ "config": config })) } +/// Raw live-file writer, mirroring `read_grok_live_settings` (syntax-only). +/// +/// 代理接管的备份/恢复也走这里:官方态 live(无自定义模型表)必须可以 +/// 原样写回。完整形状校验由 `write_grok_provider_live` 的非官方分支负责。 pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> { let config = settings .get("config") @@ -353,7 +405,7 @@ pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> { "Grok Build configuration is missing the config field", ) })?; - validate_config_toml(config)?; + validate_config_toml_syntax(config)?; write_text_file(&get_grok_config_path(), config) } @@ -397,6 +449,32 @@ context_window = 500000 validate_config_toml(valid_env_key_config()).expect("valid env_key configuration"); } + #[test] + fn syntax_validation_accepts_official_snapshots() { + validate_config_toml_syntax("").expect("empty official snapshot"); + validate_config_toml_syntax("[mcp_servers.echo]\ncommand = \"echo\"\n") + .expect("official-mode config without model tables"); + assert!(validate_config_toml_syntax("not = [valid").is_err()); + } + + #[test] + fn official_live_config_detection() { + // 官方态:完全没有自定义模型痕迹 + assert!(is_official_live_config("")); + assert!(is_official_live_config(" \n# comment only\n")); + assert!(is_official_live_config( + "[mcp_servers.echo]\ncommand = \"echo\"\n" + )); + + // 出现过任一自定义键(哪怕残缺)都不是官方态,交给强校验报错 + assert!(!is_official_live_config(valid_config())); + assert!(!is_official_live_config("[models]\ndefault = \"x\"\n")); + assert!(!is_official_live_config("[model.x]\nmodel = \"x\"\n")); + + // 语法不合法不是官方态 + assert!(!is_official_live_config("not = [valid")); + } + #[test] fn rejects_missing_selected_model_table() { let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n") @@ -479,6 +557,52 @@ context_window = 500000 validate_config_toml(config).expect("stripped config remains valid"); } + #[test] + #[serial] + fn official_provider_roundtrips_without_custom_model_tables() { + let temp = TempDir::new().expect("temp dir"); + let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + + // 官方条目:空 config 可写(清掉自定义模型表,交还 Grok CLI 官方登录) + let mut official = Provider::with_id( + "grokbuild-official".to_string(), + "Grok Official".to_string(), + json!({ "config": "" }), + None, + ); + official.category = Some("official".to_string()); + write_grok_provider_live(&official).expect("official empty config is writable"); + assert_eq!( + fs::read_to_string(get_grok_config_path()).expect("read config"), + "" + ); + + // 官方态 live(如 MCP 投影补写后)无自定义模型表,读取与原样写回都必须可用 + let official_live = "[mcp_servers.echo]\ncommand = \"echo\"\n"; + write_grok_live_settings(&json!({ "config": official_live })) + .expect("official-mode live is writable for backup restore"); + let settings = read_grok_live_settings().expect("official-mode live is readable"); + assert_eq!( + settings.get("config").and_then(Value::as_str), + Some(official_live) + ); + + // 非官方供应商仍要求完整的自定义模型配置 + let custom = Provider::with_id( + "custom".to_string(), + "Custom".to_string(), + json!({ "config": "" }), + None, + ); + assert!(write_grok_provider_live(&custom).is_err()); + + match original_test_home { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + #[test] #[serial] fn writes_and_reads_live_config() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 31739f9ce..0a9b811e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,6 +47,7 @@ pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; pub use database::{Database, Profile}; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; pub use error::AppError; +pub use grok_config::get_grok_config_path; pub use mcp::{ import_from_claude, import_from_codex, import_from_gemini, import_from_grokbuild, remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini, @@ -1316,6 +1317,7 @@ pub fn run() { commands::import_claude_desktop_providers_from_claude, commands::ensure_claude_desktop_official_provider, commands::ensure_codex_official_provider, + commands::ensure_grokbuild_official_provider, commands::get_claude_config_status, commands::get_config_status, commands::get_claude_code_config_path, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 92b8b387c..4d26e5caf 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -1459,6 +1459,16 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result crate::codex_config::read_codex_live_settings()?, AppType::GrokBuild => { let mut settings = crate::grok_config::read_grok_live_settings()?; + let config = settings + .get("config") + .and_then(Value::as_str) + .unwrap_or_default(); + // 官方登录态(无自定义模型表)在这里必须报错:本函数也被启动 + // 自动导入调用,而全项目惯例是"启动自动导入只产出 default, + // 从不产出官方条目"——否则删掉的官方条目每次重启都会复活。 + // 官方态的成功导入(补官方条目并激活)只挂在手动导入的命令层 + // (`import_default_config_internal`)。 + crate::grok_config::validate_config_toml(config)?; crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings)?; settings } @@ -1560,6 +1570,20 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { // OpenCode uses a different config structure: { npm, options, models } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 2aa771ce5..175f0b939 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1502,6 +1502,19 @@ impl ProxyService { Ok((proxy_url, proxy_codex_base_url)) } + /// Grok Build live 是否具备可接管的自定义模型表。 + /// + /// 官方态 live(Grok CLI 自带 OAuth 登录、无 `[model.*]` 表)没有注入 + /// 占位符的落点,且官方供应商本就禁止经代理接管(封号风险),调用方 + /// 应跳过接管或直接报错。 + fn grok_live_config_supports_takeover(config: &Value) -> bool { + config + .get("config") + .and_then(Value::as_str) + .and_then(crate::grok_config::extract_model_config) + .is_some() + } + fn apply_grok_takeover_fields(config: &mut Value, proxy_base_url: &str) -> Result<(), String> { let config_toml = config .get("config") @@ -1573,9 +1586,13 @@ impl ProxyService { // Grok Build: keep its own provider namespace while reusing Responses forwarding. if let Ok(mut live_config) = self.read_grok_live() { - Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; - self.write_grok_live(&live_config)?; - log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}"); + if Self::grok_live_config_supports_takeover(&live_config) { + Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; + self.write_grok_live(&live_config)?; + log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}"); + } else { + log::info!("Grok Build Live 处于官方登录态(无自定义模型表),跳过代理接管"); + } } Ok(()) @@ -1630,6 +1647,14 @@ impl ProxyService { } AppType::GrokBuild => { let mut live_config = self.read_grok_live()?; + if !Self::grok_live_config_supports_takeover(&live_config) { + return Err( + "Grok Build 当前为官方登录态(无自定义模型表),官方供应商不支持代理接管 \ + (Grok Build is using the official login without a custom model table; \ + official providers cannot be taken over by the proxy)" + .to_string(), + ); + } Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; self.write_grok_live(&live_config)?; log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}"); @@ -1701,8 +1726,14 @@ impl ProxyService { } AppType::GrokBuild => { if let Ok(mut live_config) = self.read_grok_live() { - Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; - let _ = self.write_grok_live(&live_config); + if Self::grok_live_config_supports_takeover(&live_config) { + Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; + let _ = self.write_grok_live(&live_config); + } else { + log::info!( + "Grok Build Live 处于官方登录态(无自定义模型表),跳过代理接管" + ); + } } } _ => {} diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index c533b0f38..94c972c9c 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -4,9 +4,9 @@ use std::fs; use serde_json::json; use cc_switch_lib::{ - get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path, + get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path, get_grok_config_path, import_default_config_test_hook, read_claude_mcp_config, update_settings, AppError, - AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig, + AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig, ProviderService, }; #[path = "support.rs"] @@ -68,6 +68,185 @@ fn import_default_config_claude_persists_provider() { ); } +#[test] +fn import_default_config_grokbuild_seeds_official_alongside_default() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let config_path = get_grok_config_path(); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).expect("create grok config dir"); + } + fs::write( + &config_path, + r#"[models] +default = "grok-4.5" + +[model."grok-4.5"] +model = "grok-4.5" +base_url = "https://example.com/v1" +name = "Example" +api_key = "secret" +api_backend = "responses" +context_window = 500000 +"#, + ) + .expect("seed grok config.toml"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::GrokBuild); + let state = create_test_state_with_config(&config).expect("create test state"); + + import_default_config_test_hook(&state, AppType::GrokBuild) + .expect("import default config succeeds"); + + let providers = state + .db + .get_all_providers(AppType::GrokBuild.as_str()) + .expect("get all providers"); + assert!( + providers.get("default").is_some(), + "live imported as default" + ); + + // 初次导入已有配置时应同时补出官方入口(其它应用靠首启动主播种, + // grokbuild 种子晚于该 flag,挂在导入动作上) + let official = providers + .get("grokbuild-official") + .expect("official seed ensured alongside import"); + assert_eq!(official.category.as_deref(), Some("official")); + + // 激活的仍是导入的原配置,官方入口只是备选 + let current_id = state + .db + .get_current_provider(AppType::GrokBuild.as_str()) + .expect("get current provider"); + assert_eq!(current_id.as_deref(), Some("default")); +} + +#[test] +fn import_default_config_grokbuild_official_live_imports_official_as_current() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + // 官方登录态的 live:无自定义模型表(允许 MCP 等其它内容)。 + // 导入的正确结果 = Grok Official 成为当前供应商,而非报错。 + let config_path = get_grok_config_path(); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).expect("create grok config dir"); + } + fs::write(&config_path, "[mcp_servers.echo]\ncommand = \"echo\"\n") + .expect("seed official-mode grok config.toml"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::GrokBuild); + let state = create_test_state_with_config(&config).expect("create test state"); + + let imported = import_default_config_test_hook(&state, AppType::GrokBuild) + .expect("official-mode live imports as the official provider"); + assert!(imported, "official-mode import should report success"); + + let providers = state + .db + .get_all_providers(AppType::GrokBuild.as_str()) + .expect("get all providers"); + let official = providers + .get("grokbuild-official") + .expect("official entry ensured by import"); + assert_eq!(official.category.as_deref(), Some("official")); + assert!( + providers.get("default").is_none(), + "official-mode live must not be imported as a custom default" + ); + + let current_id = state + .db + .get_current_provider(AppType::GrokBuild.as_str()) + .expect("get current provider"); + assert_eq!( + current_id.as_deref(), + Some("grokbuild-official"), + "official entry should become current to mirror the live state" + ); +} + +#[test] +fn startup_import_grokbuild_official_live_does_not_resurrect_official() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + // 启动自动导入走 service 层(lib.rs 启动循环直接调用它):官方态 live + // 必须报错且不产出任何条目——全项目惯例是启动自动导入只产出 default、 + // 从不产出官方条目,否则删掉的官方条目每次重启都会复活。 + // 官方态的成功导入只挂在手动导入的命令层。 + let config_path = get_grok_config_path(); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).expect("create grok config dir"); + } + fs::write(&config_path, "").expect("seed empty official-mode grok config.toml"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::GrokBuild); + let state = create_test_state_with_config(&config).expect("create test state"); + + ProviderService::import_default_config(&state, AppType::GrokBuild) + .expect_err("startup auto-import must not import official-mode live"); + + let providers = state + .db + .get_all_providers(AppType::GrokBuild.as_str()) + .expect("get all providers"); + assert!( + providers.is_empty(), + "startup auto-import must not create any provider from official-mode live" + ); +} + +#[test] +fn import_default_config_grokbuild_broken_custom_live_still_errors() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + // 有自定义痕迹但残缺([models] 存在、缺 [model.*]):必须报真实错误, + // 不能被误判成官方态静默吞掉;官方入口仍由命令层前置 ensure 补出。 + let config_path = get_grok_config_path(); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).expect("create grok config dir"); + } + fs::write(&config_path, "[models]\ndefault = \"grok-4.5\"\n") + .expect("seed broken custom grok config.toml"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::GrokBuild); + let state = create_test_state_with_config(&config).expect("create test state"); + + import_default_config_test_hook(&state, AppType::GrokBuild) + .expect_err("broken custom config should surface a validation error"); + + let providers = state + .db + .get_all_providers(AppType::GrokBuild.as_str()) + .expect("get all providers"); + assert!( + providers.get("grokbuild-official").is_some(), + "official entry still appears via the pre-import ensure" + ); + assert!(providers.get("default").is_none(), "nothing was imported"); + let current_id = state + .db + .get_current_provider(AppType::GrokBuild.as_str()) + .expect("get current provider"); + assert_ne!( + current_id.as_deref(), + Some("grokbuild-official"), + "failed import must not silently activate the official entry" + ); +} + #[test] fn import_default_config_without_live_file_returns_error() { use support::create_test_state; diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 10af95859..b35c2cd99 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -20,6 +20,7 @@ import { geminiProviderPresets } from "@/config/geminiProviderPresets"; import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets"; import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; import { extractGrokBuildBaseUrl } from "@/utils/grokBuildConfig"; +import { GROKBUILD_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities"; import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; @@ -33,6 +34,7 @@ interface AddProviderDialogProps { suggestedDefaults?: OpenClawSuggestedDefaults; ensureClaudeDesktopOfficialSeed?: boolean; ensureCodexOfficialSeed?: boolean; + ensureGrokBuildOfficialSeed?: boolean; }, ) => Promise | void; } @@ -120,6 +122,7 @@ export function AddProviderDialog({ suggestedDefaults?: OpenClawSuggestedDefaults; ensureClaudeDesktopOfficialSeed?: boolean; ensureCodexOfficialSeed?: boolean; + ensureGrokBuildOfficialSeed?: boolean; } = { name: values.name.trim(), notes: values.notes?.trim() || undefined, @@ -149,6 +152,12 @@ export function AddProviderDialog({ preset?.category === "official"; } + if (appId === "grokbuild" && values.presetId) { + providerData.ensureGrokBuildOfficialSeed = + values.presetCategory === "official" && + values.presetId === GROKBUILD_OFFICIAL_PROVIDER_ID; + } + // OpenCode/OpenClaw: pass providerKey for ID generation if ( (appId === "opencode" || appId === "openclaw" || appId === "hermes") && diff --git a/src/components/providers/forms/GrokBuildProviderForm.tsx b/src/components/providers/forms/GrokBuildProviderForm.tsx index b653bebef..02dab4072 100644 --- a/src/components/providers/forms/GrokBuildProviderForm.tsx +++ b/src/components/providers/forms/GrokBuildProviderForm.tsx @@ -49,15 +49,41 @@ import { validateGrokBuildConfig, } from "@/utils/grokBuildConfig"; import { resolveProviderIcon } from "@/utils/providerIcon"; +import { GROKBUILD_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities"; type GrokBuildProviderFormProps = Omit; +// 官方条目与后端 seed(providers_seed.rs 的 "Grok Official")对应: +// 空 config = 不写自定义模型表,Grok CLI 回落到自带的 xAI OAuth 登录。 +// 预设 id 复用固定 provider id,AddProviderDialog 据此走 ensure seed 流程。 +const grokOfficialPreset: CodexProviderPreset = { + name: "Grok Official", + websiteUrl: "https://x.ai/grok", + isOfficial: true, + category: "official", + auth: {}, + config: "", + icon: "grok", + iconColor: "currentColor", +}; + const grokPresetEntries: Array<{ id: string; preset: CodexProviderPreset; -}> = codexProviderPresets - .map((preset, index) => ({ id: `grokbuild-${index}`, preset })) - .filter(({ preset }) => preset.category !== "official" && !preset.isOfficial); +}> = [ + { id: GROKBUILD_OFFICIAL_PROVIDER_ID, preset: grokOfficialPreset }, + ...codexProviderPresets + .map((preset, index) => ({ id: `grokbuild-${index}`, preset })) + .filter( + // 托管 OAuth 预设(如 "xAI (Grok) OAuth")只在 Codex 表单接线; + // Grok CLI 原生支持订阅登录,这里选中只会产出无 key 的坏配置,故排除。 + ({ preset }) => + preset.category !== "official" && + !preset.isOfficial && + !preset.providerType && + !preset.requiresOAuth, + ), +]; export const grokApiBackendFromApiFormat = (format: CodexApiFormat): string => { if (format === "openai_chat") return "chat_completions"; @@ -233,6 +259,20 @@ export function GrokBuildProviderForm({ return; } + if (presetId === GROKBUILD_OFFICIAL_PROVIDER_ID) { + // 官方登录:无 API Key / 地址 / 模型表可填,提交走 ensure seed 流程 + form.setValue("name", grokOfficialPreset.name); + form.setValue("websiteUrl", grokOfficialPreset.websiteUrl); + form.setValue("icon", grokOfficialPreset.icon ?? ""); + form.setValue("iconColor", grokOfficialPreset.iconColor ?? ""); + setCategory("official"); + setIsPartner(false); + setPartnerPromotionKey(undefined); + setPresetEndpoints([]); + setRawConfig(""); + return; + } + const entry = grokPresetEntries.find( (candidate) => candidate.id === presetId, ); @@ -292,6 +332,24 @@ export function GrokBuildProviderForm({ const handleSubmit = async (values: ProviderFormData) => { const name = values.name.trim(); + + // 官方条目:config 快照原样透传(新增时为空),不做自定义模型字段校验, + // 也不重建 config —— 新增走 ensure seed,编辑只允许改名称/图标等元信息。 + if (category === "official") { + await onSubmit({ + ...values, + name, + websiteUrl: values.websiteUrl?.trim() ?? "", + notes: values.notes?.trim() ?? "", + settingsConfig: JSON.stringify({ config: rawConfig }), + presetId: selectedPresetId ?? undefined, + presetCategory: "official", + isPartner: false, + meta: initialData?.meta, + }); + return; + } + const parsedContextWindow = Number.parseInt(contextWindow, 10); const envKey = parseGrokBuildConfig(rawConfig).envKey?.trim(); if ( @@ -411,141 +469,145 @@ export function GrokBuildProviderForm({ -
- - - {t("grokBuild.profile", { defaultValue: "客户端模型档位" })} - - { - const value = event.target.value; - setProfile(value); - syncStructuredConfig({ model: value }); + {category !== "official" && ( + <> +
+ + + {t("grokBuild.profile", { defaultValue: "客户端模型档位" })} + + { + const value = event.target.value; + setProfile(value); + syncStructuredConfig({ model: value }); + }} + placeholder="grok-4.5" + autoComplete="off" + /> + + + + + {t("grokBuild.apiBackend", { defaultValue: "API Backend" })} + + { + const value = event.target.value; + setApiBackend(value); + syncStructuredConfig({ apiBackend: value }); + }} + placeholder="responses" + autoComplete="off" + /> + + + + + {t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })} + + { + const value = event.target.value; + setContextWindow(value); + syncStructuredConfig({ + contextWindow: Number.parseInt(value, 10), + }); + }} + /> + +
+ + { + setApiKey(value); + syncStructuredConfig({ apiKey: value }); }} - placeholder="grok-4.5" - autoComplete="off" - /> -
- - - - {t("grokBuild.apiBackend", { defaultValue: "API Backend" })} - - { - const value = event.target.value; - setApiBackend(value); - syncStructuredConfig({ apiBackend: value }); + category={category} + shouldShowApiKeyLink={Boolean(websiteUrl)} + websiteUrl={websiteUrl} + isPartner={isPartner} + partnerPromotionKey={partnerPromotionKey} + shouldShowSpeedTest + codexBaseUrl={baseUrl} + onBaseUrlChange={(value) => { + setBaseUrl(value); + syncStructuredConfig({ baseUrl: value }); }} - placeholder="responses" - autoComplete="off" - /> - - - - - {t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })} - - { - const value = event.target.value; - setContextWindow(value); - syncStructuredConfig({ - contextWindow: Number.parseInt(value, 10), - }); + isFullUrl={isFullUrl} + onFullUrlChange={setIsFullUrl} + isEndpointModalOpen={isEndpointModalOpen} + onEndpointModalToggle={setIsEndpointModalOpen} + onCustomEndpointsChange={setDraftCustomEndpoints} + autoSelect={endpointAutoSelect} + onAutoSelectChange={setEndpointAutoSelect} + codexModel={upstreamModel} + onModelChange={(value) => { + setUpstreamModel(value); + syncStructuredConfig({ upstreamModel: value }); }} + apiFormat={apiFormat} + onApiFormatChange={(value) => { + const backend = grokApiBackendFromApiFormat(value); + setApiFormat(value); + setApiBackend(backend); + syncStructuredConfig({ apiBackend: backend }); + }} + anthropicAuthField={anthropicAuthField} + onAnthropicAuthFieldChange={setAnthropicAuthField} + impersonateClaudeCode={impersonateClaudeCode} + onImpersonateClaudeCodeChange={setImpersonateClaudeCode} + maxOutputTokens={maxOutputTokens} + onMaxOutputTokensChange={setMaxOutputTokens} + codexChatReasoning={codexChatReasoning} + onCodexChatReasoningChange={setCodexChatReasoning} + promptCacheRouting={promptCacheRouting} + onPromptCacheRoutingChange={setPromptCacheRouting} + speedTestEndpoints={speedTestEndpoints} + customUserAgent={customUserAgent} + onCustomUserAgentChange={setCustomUserAgent} + localProxyHeadersOverride={headersOverride} + onLocalProxyHeadersOverrideChange={setHeadersOverride} + localProxyBodyOverride={bodyOverride} + onLocalProxyBodyOverrideChange={setBodyOverride} /> - -
- { - setApiKey(value); - syncStructuredConfig({ apiKey: value }); - }} - category={category} - shouldShowApiKeyLink={Boolean(websiteUrl)} - websiteUrl={websiteUrl} - isPartner={isPartner} - partnerPromotionKey={partnerPromotionKey} - shouldShowSpeedTest - codexBaseUrl={baseUrl} - onBaseUrlChange={(value) => { - setBaseUrl(value); - syncStructuredConfig({ baseUrl: value }); - }} - isFullUrl={isFullUrl} - onFullUrlChange={setIsFullUrl} - isEndpointModalOpen={isEndpointModalOpen} - onEndpointModalToggle={setIsEndpointModalOpen} - onCustomEndpointsChange={setDraftCustomEndpoints} - autoSelect={endpointAutoSelect} - onAutoSelectChange={setEndpointAutoSelect} - codexModel={upstreamModel} - onModelChange={(value) => { - setUpstreamModel(value); - syncStructuredConfig({ upstreamModel: value }); - }} - apiFormat={apiFormat} - onApiFormatChange={(value) => { - const backend = grokApiBackendFromApiFormat(value); - setApiFormat(value); - setApiBackend(backend); - syncStructuredConfig({ apiBackend: backend }); - }} - anthropicAuthField={anthropicAuthField} - onAnthropicAuthFieldChange={setAnthropicAuthField} - impersonateClaudeCode={impersonateClaudeCode} - onImpersonateClaudeCodeChange={setImpersonateClaudeCode} - maxOutputTokens={maxOutputTokens} - onMaxOutputTokensChange={setMaxOutputTokens} - codexChatReasoning={codexChatReasoning} - onCodexChatReasoningChange={setCodexChatReasoning} - promptCacheRouting={promptCacheRouting} - onPromptCacheRoutingChange={setPromptCacheRouting} - speedTestEndpoints={speedTestEndpoints} - customUserAgent={customUserAgent} - onCustomUserAgentChange={setCustomUserAgent} - localProxyHeadersOverride={headersOverride} - onLocalProxyHeadersOverrideChange={setHeadersOverride} - localProxyBodyOverride={bodyOverride} - onLocalProxyBodyOverrideChange={setBodyOverride} - /> - -
- - {t("grokBuild.rawConfig", { defaultValue: "config.toml" })} - - - {rawConfigError && ( -

- {t("grokBuild.invalidToml", { - error: rawConfigError, - defaultValue: `Invalid config.toml: ${rawConfigError}`, - })} -

- )} -
+
+ + {t("grokBuild.rawConfig", { defaultValue: "config.toml" })} + + + {rawConfigError && ( +

+ {t("grokBuild.invalidToml", { + error: rawConfigError, + defaultValue: `Invalid config.toml: ${rawConfigError}`, + })} +

+ )} +
+ + )} { const enhanced = injectCodingPlanUsageScript(activeApp, provider); diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index d7bcc1270..5f2e1828f 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -107,6 +107,10 @@ export const providersApi = { return await invoke("ensure_codex_official_provider"); }, + async ensureGrokBuildOfficialProvider(): Promise { + return await invoke("ensure_grokbuild_official_provider"); + }, + async getClaudeDesktopStatus(): Promise { return await invoke("get_claude_desktop_status"); }, diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 1e4e6e32d..29a08b7a1 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -10,7 +10,10 @@ import { generateUUID } from "@/utils/uuid"; import { openclawKeys } from "@/hooks/useOpenClaw"; import { invalidateHermesProviderCaches } from "@/hooks/useHermes"; import { usageKeys } from "@/lib/query/usage"; -import { CODEX_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities"; +import { + CODEX_OFFICIAL_PROVIDER_ID, + GROKBUILD_OFFICIAL_PROVIDER_ID, +} from "@/utils/providerCapabilities"; export const useAddProviderMutation = (appId: AppId) => { const queryClient = useQueryClient(); @@ -23,6 +26,7 @@ export const useAddProviderMutation = (appId: AppId) => { addToLive?: boolean; ensureClaudeDesktopOfficialSeed?: boolean; ensureCodexOfficialSeed?: boolean; + ensureGrokBuildOfficialSeed?: boolean; }, ) => { const { @@ -30,6 +34,7 @@ export const useAddProviderMutation = (appId: AppId) => { addToLive, ensureClaudeDesktopOfficialSeed, ensureCodexOfficialSeed, + ensureGrokBuildOfficialSeed, ...rest } = providerInput; @@ -53,6 +58,16 @@ export const useAddProviderMutation = (appId: AppId) => { return officialProvider; } + if (appId === "grokbuild" && ensureGrokBuildOfficialSeed) { + await providersApi.ensureGrokBuildOfficialProvider(); + const providers = await providersApi.getAll(appId); + const officialProvider = providers[GROKBUILD_OFFICIAL_PROVIDER_ID]; + if (!officialProvider) { + throw new Error("Grok Build official provider was not created"); + } + return officialProvider; + } + let id: string; if (appId === "opencode" || appId === "openclaw" || appId === "hermes") { diff --git a/src/utils/providerCapabilities.ts b/src/utils/providerCapabilities.ts index 6addf530b..aa8d3786c 100644 --- a/src/utils/providerCapabilities.ts +++ b/src/utils/providerCapabilities.ts @@ -8,6 +8,7 @@ import { } from "@/utils/providerConfigUtils"; export const CODEX_OFFICIAL_PROVIDER_ID = "codex-official"; +export const GROKBUILD_OFFICIAL_PROVIDER_ID = "grokbuild-official"; /** Keep the UI capability rule aligned with the Rust takeover policy. */ export function supportsOfficialProxyTakeover(