mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(grokbuild): add first-class Grok Build support (#5453)
* feat(grokbuild): add backend integration * feat(grokbuild): add provider and management UI * test(grokbuild): cover configuration and integrations * fix(grokbuild): address backend review feedback * fix(grokbuild): complete UI review feedback * feat(grokbuild): add CLI lifecycle management * fix(grokbuild): align provider icon fallback * test(grokbuild): cover provider icon persistence
This commit is contained in:
@@ -14,6 +14,8 @@ pub struct McpApps {
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
@@ -26,6 +28,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
AppType::Hermes => self.hermes,
|
||||
@@ -39,6 +42,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::GrokBuild => self.grokbuild = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
@@ -58,6 +62,9 @@ impl McpApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.grokbuild {
|
||||
apps.push(AppType::GrokBuild);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
@@ -69,7 +76,12 @@ impl McpApps {
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
!self.claude
|
||||
&& !self.codex
|
||||
&& !self.gemini
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +95,8 @@ pub struct SkillApps {
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
@@ -95,6 +109,7 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
@@ -108,6 +123,7 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::GrokBuild => self.grokbuild = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
@@ -127,6 +143,9 @@ impl SkillApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.grokbuild {
|
||||
apps.push(AppType::GrokBuild);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
@@ -138,7 +157,12 @@ impl SkillApps {
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
!self.claude
|
||||
&& !self.codex
|
||||
&& !self.gemini
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
@@ -271,6 +295,8 @@ pub struct McpRoot {
|
||||
pub codex: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub gemini: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub grokbuild: McpConfig,
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
@@ -292,6 +318,7 @@ impl Default for McpRoot {
|
||||
claude_desktop: McpConfig::default(),
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
grokbuild: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
openclaw: McpConfig::default(),
|
||||
hermes: McpConfig::default(),
|
||||
@@ -323,6 +350,8 @@ pub struct PromptRoot {
|
||||
#[serde(default)]
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub grokbuild: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub openclaw: PromptConfig,
|
||||
@@ -348,6 +377,7 @@ pub enum AppType {
|
||||
ClaudeDesktop,
|
||||
Codex,
|
||||
Gemini,
|
||||
GrokBuild,
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
Hermes,
|
||||
@@ -360,6 +390,7 @@ impl AppType {
|
||||
AppType::ClaudeDesktop => "claude-desktop",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::GrokBuild => "grokbuild",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
AppType::Hermes => "hermes",
|
||||
@@ -384,6 +415,7 @@ impl AppType {
|
||||
AppType::ClaudeDesktop,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::GrokBuild,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
@@ -402,13 +434,14 @@ impl FromStr for AppType {
|
||||
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"grokbuild" | "grok-build" | "grok_build" | "grok" => Ok(AppType::GrokBuild),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"openclaw" => Ok(AppType::OpenClaw),
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -444,6 +477,7 @@ impl CommonConfigSnippets {
|
||||
AppType::ClaudeDesktop => None,
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::GrokBuild => None,
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
AppType::Hermes => self.hermes.as_ref(),
|
||||
@@ -457,6 +491,7 @@ impl CommonConfigSnippets {
|
||||
AppType::ClaudeDesktop => {}
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::GrokBuild => {}
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
AppType::Hermes => self.hermes = snippet,
|
||||
@@ -500,6 +535,7 @@ impl Default for MultiAppConfig {
|
||||
apps.insert("claude-desktop".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("grokbuild".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("openclaw".to_string(), ProviderManager::default());
|
||||
apps.insert("hermes".to_string(), ProviderManager::default());
|
||||
@@ -662,6 +698,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::GrokBuild => &self.mcp.grokbuild,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
AppType::OpenClaw => &self.mcp.openclaw,
|
||||
AppType::Hermes => &self.mcp.hermes,
|
||||
@@ -675,6 +712,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::GrokBuild => &mut self.mcp.grokbuild,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
AppType::OpenClaw => &mut self.mcp.openclaw,
|
||||
AppType::Hermes => &mut self.mcp.hermes,
|
||||
@@ -691,6 +729,7 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::GrokBuild)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
|
||||
@@ -714,6 +753,7 @@ impl MultiAppConfig {
|
||||
|| !self.prompts.claude_desktop.prompts.is_empty()
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.grokbuild.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
|| !self.prompts.openclaw.prompts.is_empty()
|
||||
|| !self.prompts.hermes.prompts.is_empty()
|
||||
@@ -728,6 +768,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::GrokBuild,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
@@ -801,6 +842,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::GrokBuild => &mut config.prompts.grokbuild.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
|
||||
AppType::Hermes => &mut config.prompts.hermes.prompts,
|
||||
@@ -843,6 +885,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::GrokBuild => continue,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
|
||||
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
|
||||
@@ -1133,6 +1176,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn auto_imports_grokbuild_prompt_on_first_launch() {
|
||||
let _home = TempHome::new();
|
||||
write_prompt_file(AppType::GrokBuild, "# Grok Build Prompt\n\nTest content");
|
||||
|
||||
let config = MultiAppConfig::load().expect("load config");
|
||||
|
||||
assert_eq!(config.prompts.grokbuild.prompts.len(), 1);
|
||||
let prompt = config
|
||||
.prompts
|
||||
.grokbuild
|
||||
.prompts
|
||||
.values()
|
||||
.next()
|
||||
.expect("grokbuild prompt exists");
|
||||
assert!(prompt.enabled, "grokbuild prompt should be enabled");
|
||||
assert_eq!(prompt.content, "# Grok Build Prompt\n\nTest content");
|
||||
assert_eq!(
|
||||
prompt.description,
|
||||
Some("Automatically imported on first launch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn auto_imports_all_three_apps_prompts() {
|
||||
|
||||
@@ -99,6 +99,15 @@ pub async fn get_config_status(
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let config_path = crate::grok_config::get_grok_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::grok_config::get_grok_config_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
let config_path = crate::opencode_config::get_opencode_config_path();
|
||||
let exists = config_path.exists();
|
||||
@@ -143,6 +152,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
@@ -160,6 +170,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
|
||||
@@ -111,8 +111,8 @@ pub struct ToolVersion {
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
const VALID_TOOLS: [&str; 6] = [
|
||||
"claude", "codex", "gemini", "opencode", "openclaw", "hermes",
|
||||
const VALID_TOOLS: [&str; 7] = [
|
||||
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@@ -424,6 +424,7 @@ fn tool_display_name(tool: &str) -> &'static str {
|
||||
"claude" => "Claude Code",
|
||||
"codex" => "Codex",
|
||||
"gemini" => "Gemini CLI",
|
||||
"grok" => "Grok Build",
|
||||
"opencode" => "OpenCode",
|
||||
"openclaw" => "OpenClaw",
|
||||
"hermes" => "Hermes",
|
||||
@@ -492,6 +493,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
|
||||
"claude" => Some("npm i -g @anthropic-ai/claude-code@latest"),
|
||||
"codex" => Some("npm i -g @openai/codex@latest"),
|
||||
"gemini" => Some("npm i -g @google/gemini-cli@latest"),
|
||||
"grok" => Some("npm i -g @xai-official/grok@latest"),
|
||||
"opencode" => Some("npm i -g opencode-ai@latest"),
|
||||
"openclaw" => Some("npm i -g openclaw@latest"),
|
||||
_ => None,
|
||||
@@ -762,6 +764,7 @@ async fn get_single_tool_version_impl(
|
||||
}
|
||||
"codex" => fetch_npm_latest_for_tool(&client, "@openai/codex", tool, local).await,
|
||||
"gemini" => fetch_npm_latest_for_tool(&client, "@google/gemini-cli", tool, local).await,
|
||||
"grok" => fetch_npm_latest_for_tool(&client, "@xai-official/grok", tool, local).await,
|
||||
"opencode" => {
|
||||
if let Some(version) =
|
||||
fetch_npm_latest_for_tool(&client, "opencode-ai", tool, local).await
|
||||
@@ -1945,6 +1948,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
|
||||
"claude" => Some("@anthropic-ai/claude-code"),
|
||||
"codex" => Some("@openai/codex"),
|
||||
"gemini" => Some("@google/gemini-cli"),
|
||||
"grok" => Some("@xai-official/grok"),
|
||||
"opencode" => Some("opencode-ai"),
|
||||
"openclaw" => Some("openclaw"),
|
||||
_ => None,
|
||||
@@ -2257,7 +2261,8 @@ fn package_manager_anchored_command_from_paths(
|
||||
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
|
||||
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`;
|
||||
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
|
||||
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。
|
||||
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI / Grok Build) → 锚定到
|
||||
/// "那处 bin 目录的 npm"。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
|
||||
let real_lower = real_target.to_ascii_lowercase();
|
||||
@@ -2553,6 +2558,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
"codex" => crate::settings::get_codex_override_dir(),
|
||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||
"grok" => crate::settings::get_grok_override_dir(),
|
||||
"opencode" => crate::settings::get_opencode_override_dir(),
|
||||
"openclaw" => crate::settings::get_openclaw_override_dir(),
|
||||
"hermes" => crate::settings::get_hermes_override_dir(),
|
||||
@@ -3660,6 +3666,35 @@ mod tests {
|
||||
assert_eq!(extract_version("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_lifecycle_metadata_is_consistent() {
|
||||
let requested = vec!["unsupported".to_string(), "grok".to_string()];
|
||||
assert_eq!(normalize_requested_tools(&requested), vec!["grok"]);
|
||||
assert_eq!(tool_display_name("grok"), "Grok Build");
|
||||
assert_eq!(npm_package_for("grok"), Some("@xai-official/grok"));
|
||||
assert_eq!(
|
||||
npm_install_command_for("grok"),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
assert_eq!(official_update_args("grok"), None);
|
||||
|
||||
for shell in [
|
||||
LifecycleCommandShell::Posix,
|
||||
LifecycleCommandShell::WindowsBatch,
|
||||
] {
|
||||
assert_eq!(
|
||||
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Install, shell,)
|
||||
.as_deref(),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
assert_eq!(
|
||||
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Update, shell,)
|
||||
.as_deref(),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_semver() {
|
||||
use std::cmp::Ordering;
|
||||
@@ -3903,6 +3938,18 @@ mod tests {
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_windows_anchors_to_sibling_npm() {
|
||||
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "grok.cmd", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
|
||||
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
|
||||
let expected = format!(
|
||||
"{} i -g @xai-official/grok@latest",
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
|
||||
// sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
|
||||
@@ -4223,6 +4270,9 @@ mod tests {
|
||||
let codex =
|
||||
wsl_tool_action_shell_command("codex", ToolLifecycleAction::Install).unwrap();
|
||||
assert_eq!(codex, "npm i -g @openai/codex@latest");
|
||||
|
||||
let grok = wsl_tool_action_shell_command("grok", ToolLifecycleAction::Install).unwrap();
|
||||
assert_eq!(grok, "npm i -g @xai-official/grok@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4383,6 +4433,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_nvm_anchors_to_npm_without_cli_update() {
|
||||
let cmd = anchored_command_from_paths(
|
||||
"grok",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/grok",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@xai-official/grok/bin/grok",
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @xai-official/grok@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_nvm_anchors_to_that_npm() {
|
||||
// Codex 不走 self-update(`codex update` 在 npm 安装上只是裸 `npm install -g`,
|
||||
@@ -4833,6 +4898,12 @@ mod tests {
|
||||
assert_eq!(cmd, "npm i -g @google/gemini-cli@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_install_keeps_static_npm() {
|
||||
let cmd = install_command_for("grok");
|
||||
assert_eq!(cmd, "npm i -g @xai-official/grok@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openclaw_install_keeps_static_npm() {
|
||||
let cmd = install_command_for("openclaw");
|
||||
@@ -4855,6 +4926,11 @@ mod tests {
|
||||
"npm i -g @google/gemini-cli@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("gemini").contains("gemini update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("grok"),
|
||||
"npm i -g @xai-official/grok@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("grok").contains("grok update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("opencode"),
|
||||
"opencode upgrade || npm i -g opencode-ai@latest"
|
||||
|
||||
@@ -23,6 +23,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
|
||||
if takeover.claude
|
||||
|| takeover.codex
|
||||
|| takeover.gemini
|
||||
|| takeover.grokbuild
|
||||
|| takeover.opencode
|
||||
|| takeover.openclaw
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ impl Database {
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -30,8 +30,9 @@ impl Database {
|
||||
let enabled_claude: bool = row.get(7)?;
|
||||
let enabled_codex: bool = row.get(8)?;
|
||||
let enabled_gemini: bool = row.get(9)?;
|
||||
let enabled_opencode: bool = row.get(10)?;
|
||||
let enabled_hermes: bool = row.get(11)?;
|
||||
let enabled_grokbuild: bool = row.get(10)?;
|
||||
let enabled_opencode: bool = row.get(11)?;
|
||||
let enabled_hermes: bool = row.get(12)?;
|
||||
|
||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
@@ -46,6 +47,7 @@ impl Database {
|
||||
claude: enabled_claude,
|
||||
codex: enabled_codex,
|
||||
gemini: enabled_gemini,
|
||||
grokbuild: enabled_grokbuild,
|
||||
opencode: enabled_opencode,
|
||||
hermes: enabled_hermes,
|
||||
},
|
||||
@@ -72,8 +74,8 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mcp_servers (
|
||||
id, name, server_config, description, homepage, docs, tags,
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
@@ -88,6 +90,7 @@ impl Database {
|
||||
server.apps.claude,
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
server.apps.grokbuild,
|
||||
server.apps.opencode,
|
||||
server.apps.hermes,
|
||||
],
|
||||
|
||||
@@ -328,6 +328,7 @@ impl Database {
|
||||
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
|
||||
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
"grokbuild" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
|
||||
};
|
||||
|
||||
@@ -398,6 +399,18 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// grokbuild: Responses protocol, same timeout defaults as Codex.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -43,12 +43,13 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
},
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -67,8 +68,8 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -87,12 +88,13 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
},
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -109,9 +111,9 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
|
||||
installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -124,6 +126,7 @@ impl Database {
|
||||
skill.apps.claude,
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.apps.grokbuild,
|
||||
skill.apps.opencode,
|
||||
skill.apps.hermes,
|
||||
skill.installed_at,
|
||||
@@ -157,8 +160,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
|
||||
@@ -52,7 +52,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 13;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 15;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -65,7 +65,8 @@ impl Database {
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
@@ -93,6 +94,7 @@ impl Database {
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -122,7 +124,7 @@ impl Database {
|
||||
|
||||
// 8. Proxy Config 表(三行结构,app_type 主键)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -169,6 +171,15 @@ impl Database {
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 9. Provider Health 表
|
||||
@@ -485,6 +496,16 @@ impl Database {
|
||||
Self::migrate_v12_to_v13(conn)?;
|
||||
Self::set_user_version(conn, 13)?;
|
||||
}
|
||||
13 => {
|
||||
log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)");
|
||||
Self::migrate_v13_to_v14(conn)?;
|
||||
Self::set_user_version(conn, 14)?;
|
||||
}
|
||||
14 => {
|
||||
log::info!("迁移数据库从 v14 到 v15(Skills/MCP 添加 Grok Build 支持)");
|
||||
Self::migrate_v14_to_v15(conn)?;
|
||||
Self::set_user_version(conn, 15)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -798,12 +819,25 @@ impl Database {
|
||||
old_cb.3,
|
||||
old_cb.4,
|
||||
),
|
||||
(
|
||||
"grokbuild",
|
||||
false,
|
||||
false,
|
||||
3,
|
||||
old_config.4,
|
||||
old_config.5,
|
||||
old_cb.0,
|
||||
old_cb.1,
|
||||
old_cb.2,
|
||||
old_cb.3,
|
||||
old_cb.4,
|
||||
),
|
||||
];
|
||||
|
||||
// 创建新表
|
||||
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
|
||||
conn.execute("CREATE TABLE proxy_config_new (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -814,6 +848,7 @@ impl Database {
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
live_takeover_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", [])?;
|
||||
|
||||
@@ -1354,6 +1389,127 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v13 -> v14: allow Grok Build to own an independent proxy configuration row.
|
||||
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
|
||||
if !Self::table_exists(conn, "proxy_config")? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.execute("DROP TABLE IF EXISTS proxy_config_v14", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"CREATE TABLE proxy_config_v14 (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721,
|
||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
|
||||
non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4,
|
||||
circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
||||
circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
live_takeover_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let copied_columns = [
|
||||
("app_type", "'claude'"),
|
||||
("proxy_enabled", "0"),
|
||||
("listen_address", "'127.0.0.1'"),
|
||||
("listen_port", "15721"),
|
||||
("enable_logging", "1"),
|
||||
("enabled", "0"),
|
||||
("auto_failover_enabled", "0"),
|
||||
("max_retries", "3"),
|
||||
("streaming_first_byte_timeout", "60"),
|
||||
("streaming_idle_timeout", "120"),
|
||||
("non_streaming_timeout", "600"),
|
||||
("circuit_failure_threshold", "4"),
|
||||
("circuit_success_threshold", "2"),
|
||||
("circuit_timeout_seconds", "60"),
|
||||
("circuit_error_rate_threshold", "0.6"),
|
||||
("circuit_min_requests", "10"),
|
||||
("default_cost_multiplier", "'1'"),
|
||||
("pricing_model_source", "'response'"),
|
||||
("live_takeover_active", "0"),
|
||||
("created_at", "datetime('now')"),
|
||||
("updated_at", "datetime('now')"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(column, fallback)| {
|
||||
Self::has_column(conn, "proxy_config", column).map(|exists| {
|
||||
if exists {
|
||||
format!("\"{column}\"")
|
||||
} else {
|
||||
fallback.into()
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, AppError>>()?
|
||||
.join(", ");
|
||||
|
||||
let copy_sql = format!(
|
||||
"INSERT INTO proxy_config_v14 (
|
||||
app_type, proxy_enabled, listen_address, listen_port, enable_logging,
|
||||
enabled, auto_failover_enabled, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests,
|
||||
default_cost_multiplier, pricing_model_source, live_takeover_active,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT {copied_columns} FROM proxy_config"
|
||||
);
|
||||
conn.execute(©_sql, [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute("DROP TABLE proxy_config", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v14 -> v15: persist Grok Build enablement for unified Skills and MCP.
|
||||
fn migrate_v14_to_v15(conn: &Connection) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "mcp_servers")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"mcp_servers",
|
||||
"enabled_grokbuild",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
if Self::table_exists(conn, "skills")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_grokbuild",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -2766,7 +2922,7 @@ mod tests {
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, 13);
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
&conn,
|
||||
"proxy_request_logs",
|
||||
@@ -2787,4 +2943,82 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Database::create_tables_on_conn(&conn)?;
|
||||
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
|
||||
[],
|
||||
)?;
|
||||
Database::set_user_version(&conn, 13)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
let grok_rows: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(grok_rows, 1);
|
||||
let codex_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled, max_retries FROM proxy_config WHERE app_type = 'codex'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(codex_values, (1, 9));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v14_to_v15_adds_grokbuild_skill_and_mcp_flags() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||||
);",
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO mcp_servers (id, enabled_codex) VALUES ('mcp-1', 1)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO skills (id, enabled_codex) VALUES ('skill-1', 1)",
|
||||
[],
|
||||
)?;
|
||||
Database::set_user_version(&conn, 14)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
&conn,
|
||||
"mcp_servers",
|
||||
"enabled_grokbuild"
|
||||
)?);
|
||||
assert!(Database::has_column(&conn, "skills", "enabled_grokbuild")?);
|
||||
let mcp_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled_codex, enabled_grokbuild FROM mcp_servers WHERE id = 'mcp-1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
let skill_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled_codex, enabled_grokbuild FROM skills WHERE id = 'skill-1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(mcp_values, (1, 0));
|
||||
assert_eq!(skill_values, (1, 0));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +517,7 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||
.expect("count rows");
|
||||
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
|
||||
assert_eq!(count, 4, "per-app proxy_config should have 4 rows");
|
||||
|
||||
// 新结构下应能按 app_type 查询
|
||||
let _: i32 = conn
|
||||
@@ -657,7 +657,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
let proxy_rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||
.expect("count proxy_config rows");
|
||||
assert_eq!(proxy_rows, 3);
|
||||
assert_eq!(proxy_rows, 4);
|
||||
|
||||
// model_pricing 应具备默认数据(迁移时会 seed)
|
||||
let pricing_rows: i64 = conn
|
||||
|
||||
@@ -101,17 +101,7 @@ pub fn import_mcp_from_deeplink(
|
||||
// Server exists - merge apps only, keep other fields unchanged
|
||||
log::info!("MCP server '{id}' already exists, merging apps only");
|
||||
|
||||
let mut merged_apps = existing.apps.clone();
|
||||
// Merge new apps into existing apps
|
||||
if target_apps.claude {
|
||||
merged_apps.claude = true;
|
||||
}
|
||||
if target_apps.codex {
|
||||
merged_apps.codex = true;
|
||||
}
|
||||
if target_apps.gemini {
|
||||
merged_apps.gemini = true;
|
||||
}
|
||||
let merged_apps = merge_mcp_apps(&existing.apps, &target_apps);
|
||||
|
||||
McpServer {
|
||||
id: existing.id.clone(),
|
||||
@@ -166,6 +156,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
};
|
||||
@@ -175,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
"claude" => apps.claude = true,
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"grokbuild" | "grok" => apps.grokbuild = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
"openclaw" => {
|
||||
// OpenClaw doesn't support MCP, ignore silently
|
||||
@@ -197,3 +189,40 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
fn merge_mcp_apps(existing: &McpApps, target: &McpApps) -> McpApps {
|
||||
let mut merged = existing.clone();
|
||||
for app in target.enabled_apps() {
|
||||
merged.set_enabled_for(&app, true);
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enabled_apps_merge_covers_every_supported_mcp_client() {
|
||||
let existing = McpApps {
|
||||
claude: true,
|
||||
..McpApps::default()
|
||||
};
|
||||
let target = McpApps {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
hermes: true,
|
||||
..McpApps::default()
|
||||
};
|
||||
let merged = merge_mcp_apps(&existing, &target);
|
||||
|
||||
assert!(merged.claude);
|
||||
assert!(merged.codex);
|
||||
assert!(merged.gemini);
|
||||
assert!(merged.grokbuild);
|
||||
assert!(merged.opencode);
|
||||
assert!(merged.hermes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -262,10 +262,17 @@ fn parse_mcp_deeplink(
|
||||
let trimmed = app.trim();
|
||||
if !matches!(
|
||||
trimmed,
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "grok"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::GrokBuild => build_grokbuild_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw => build_additive_app_settings(request),
|
||||
AppType::Hermes => build_hermes_settings(request),
|
||||
@@ -444,6 +445,36 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
json!({ "env": env })
|
||||
}
|
||||
|
||||
fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let model = request
|
||||
.model
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(crate::grok_config::DEFAULT_MODEL)
|
||||
.trim();
|
||||
let name = request
|
||||
.name
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("custom")
|
||||
.trim();
|
||||
let endpoint = get_primary_endpoint(request).trim().to_string();
|
||||
let api_key = request.api_key.as_deref().unwrap_or("").trim();
|
||||
|
||||
let model_value = toml_edit::Value::from(model).to_string();
|
||||
let name_value = toml_edit::Value::from(name).to_string();
|
||||
let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string();
|
||||
let api_key_value = toml_edit::Value::from(api_key).to_string();
|
||||
|
||||
json!({
|
||||
"config": format!(
|
||||
"[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n",
|
||||
crate::grok_config::DEFAULT_API_BACKEND,
|
||||
crate::grok_config::DEFAULT_CONTEXT_WINDOW,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build OpenCode settings configuration
|
||||
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
@@ -600,6 +631,7 @@ pub fn parse_and_merge_config(
|
||||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||
"grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?,
|
||||
// Additive mode apps use JSON config directly; pass through as-is
|
||||
"openclaw" | "opencode" | "hermes" => {
|
||||
merge_additive_config(&mut merged, &config_value)?;
|
||||
@@ -774,6 +806,56 @@ fn merge_gemini_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_grokbuild_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
|
||||
config_toml.to_string()
|
||||
} else {
|
||||
let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| {
|
||||
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
|
||||
})?;
|
||||
toml::to_string(&toml_value).map_err(|error| {
|
||||
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
|
||||
})?
|
||||
};
|
||||
let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| {
|
||||
AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string())
|
||||
})?;
|
||||
|
||||
if request
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
request.api_key = model.api_key.or_else(|| {
|
||||
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
|
||||
});
|
||||
}
|
||||
if request
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
request.endpoint = Some(model.base_url);
|
||||
}
|
||||
if request.model.is_none() {
|
||||
request.model = Some(model.model);
|
||||
}
|
||||
if request
|
||||
.homepage
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
if let Some(endpoint) = request.endpoint.as_deref() {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
|
||||
///
|
||||
/// These apps use JSON config directly, so we only extract common fields
|
||||
|
||||
@@ -87,6 +87,39 @@ fn test_parse_deeplink_with_notes() {
|
||||
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_provider() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
let url = "ccswitch://v1/import?resource=provider&app=grokbuild&name=Grok%20Relay&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=secret&model=grok-4.5";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(request.app.as_deref(), Some("grokbuild"));
|
||||
assert_eq!(request.name.as_deref(), Some("Grok Relay"));
|
||||
assert_eq!(
|
||||
request.endpoint.as_deref(),
|
||||
Some("https://api.example.com/v1")
|
||||
);
|
||||
assert_eq!(request.api_key.as_deref(), Some("secret"));
|
||||
assert_eq!(request.model.as_deref(), Some("grok-4.5"));
|
||||
|
||||
let provider = build_provider_from_request(&AppType::GrokBuild, &request).unwrap();
|
||||
let config = provider.settings_config["config"].as_str().unwrap();
|
||||
let document = config.parse::<toml::Value>().unwrap();
|
||||
let model = &document["model"]["grok-4.5"];
|
||||
|
||||
assert_eq!(document["models"]["default"].as_str(), Some("grok-4.5"));
|
||||
assert_eq!(
|
||||
model["base_url"].as_str(),
|
||||
Some("https://api.example.com/v1")
|
||||
);
|
||||
assert_eq!(model["name"].as_str(), Some("Grok Relay"));
|
||||
assert_eq!(model["api_key"].as_str(), Some("secret"));
|
||||
assert_eq!(model["api_backend"].as_str(), Some("responses"));
|
||||
assert_eq!(model["context_window"].as_integer(), Some(500_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_scheme() {
|
||||
let url = "https://v1/import?resource=provider&app=claude&name=Test";
|
||||
@@ -500,6 +533,38 @@ experimental_bearer_token = "sk-rightcode"
|
||||
assert_eq!(merged.model, Some("gpt-5-codex".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_grokbuild() {
|
||||
let config_toml = r#"[models]
|
||||
default = "grok-profile"
|
||||
|
||||
[model."grok-profile"]
|
||||
model = "grok-upstream"
|
||||
base_url = "https://grok.example/v1"
|
||||
name = "Grok Relay"
|
||||
api_key = "sk-grok"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#;
|
||||
let config_json = serde_json::json!({ "config": config_toml }).to_string();
|
||||
let request = DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: Some("grokbuild".to_string()),
|
||||
name: Some("Grok Relay".to_string()),
|
||||
config: Some(BASE64_STANDARD.encode(config_json.as_bytes())),
|
||||
config_format: Some("json".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged = parse_and_merge_config(&request).expect("merge Grok Build config");
|
||||
|
||||
assert_eq!(merged.api_key.as_deref(), Some("sk-grok"));
|
||||
assert_eq!(merged.endpoint.as_deref(), Some("https://grok.example/v1"));
|
||||
assert_eq!(merged.model.as_deref(), Some("grok-upstream"));
|
||||
assert_eq!(merged.homepage.as_deref(), Some("https://grok.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_url_override() {
|
||||
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
|
||||
@@ -709,6 +774,11 @@ fn test_parse_mcp_apps() {
|
||||
assert!(!apps.codex);
|
||||
assert!(apps.gemini);
|
||||
|
||||
let apps = parse_mcp_apps("grokbuild,opencode,hermes").unwrap();
|
||||
assert!(apps.grokbuild);
|
||||
assert!(apps.opencode);
|
||||
assert!(apps.hermes);
|
||||
|
||||
let err = parse_mcp_apps("invalid").unwrap_err();
|
||||
assert!(err.to_string().contains("Invalid app"));
|
||||
}
|
||||
@@ -731,6 +801,18 @@ fn test_parse_prompt_deeplink() {
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_prompt_deeplink() {
|
||||
let content_b64 = BASE64_STANDARD.encode("Grok instructions");
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=prompt&app=grokbuild&name=test&content={content_b64}"
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).expect("parse Grok Build prompt deeplink");
|
||||
|
||||
assert_eq!(request.app.as_deref(), Some("grokbuild"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_deeplink() {
|
||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||
@@ -747,6 +829,19 @@ fn test_parse_mcp_deeplink() {
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_mcp_deeplink() {
|
||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||
let config_b64 = BASE64_STANDARD.encode(config);
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=mcp&apps=grokbuild&config={config_b64}&enabled=true"
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).expect("parse Grok Build MCP deeplink");
|
||||
|
||||
assert_eq!(request.apps.as_deref(), Some("grokbuild"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_deeplink() {
|
||||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{get_home_dir, write_text_file};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
|
||||
pub const DEFAULT_MODEL: &str = "grok-4.5";
|
||||
pub const DEFAULT_API_BACKEND: &str = "responses";
|
||||
pub const DEFAULT_CONTEXT_WINDOW: i64 = 500_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GrokModelConfig {
|
||||
pub profile: String,
|
||||
pub model: String,
|
||||
pub base_url: String,
|
||||
pub name: String,
|
||||
pub api_key: Option<String>,
|
||||
pub env_key: Option<String>,
|
||||
pub api_backend: String,
|
||||
pub context_window: i64,
|
||||
}
|
||||
|
||||
/// Grok Build configuration directory (`~/.grok`).
|
||||
pub fn get_grok_config_dir() -> PathBuf {
|
||||
crate::settings::get_grok_override_dir().unwrap_or_else(|| get_home_dir().join(".grok"))
|
||||
}
|
||||
|
||||
/// Grok Build live configuration path (`~/.grok/config.toml`).
|
||||
pub fn get_grok_config_path() -> PathBuf {
|
||||
get_grok_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
fn required_non_empty_string<'a>(
|
||||
table: &'a toml::value::Table,
|
||||
key: &str,
|
||||
) -> Result<&'a str, AppError> {
|
||||
table
|
||||
.get(key)
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.field.missing",
|
||||
format!("Grok Build 配置缺少有效的 {key} 字段"),
|
||||
format!("Grok Build configuration is missing a valid {key} field"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option<String> {
|
||||
table
|
||||
.get(key)
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
/// Validate the provider-owned Grok Build TOML document.
|
||||
pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> {
|
||||
let document = config_toml.parse::<toml::Value>().map_err(|error| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.invalid_toml",
|
||||
format!("Grok Build config.toml 格式错误: {error}"),
|
||||
format!("Invalid Grok Build config.toml: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let root = document.as_table().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.not_table",
|
||||
"Grok Build 配置必须是 TOML 表结构",
|
||||
"Grok Build configuration must be a TOML table",
|
||||
)
|
||||
})?;
|
||||
let models = root
|
||||
.get("models")
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.models.missing",
|
||||
"Grok Build 配置缺少 [models]",
|
||||
"Grok Build configuration is missing [models]",
|
||||
)
|
||||
})?;
|
||||
let default_model = required_non_empty_string(models, "default")?;
|
||||
let model_entries = root
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.model.missing",
|
||||
"Grok Build 配置缺少 [model.<name>]",
|
||||
"Grok Build configuration is missing [model.<name>]",
|
||||
)
|
||||
})?;
|
||||
let selected_model = model_entries
|
||||
.get(default_model)
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
|
||||
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
|
||||
)
|
||||
})?;
|
||||
|
||||
required_non_empty_string(selected_model, "model")?;
|
||||
required_non_empty_string(selected_model, "base_url")?;
|
||||
required_non_empty_string(selected_model, "name")?;
|
||||
if optional_non_empty_string(selected_model, "api_key").is_none()
|
||||
&& optional_non_empty_string(selected_model, "env_key").is_none()
|
||||
{
|
||||
return Err(AppError::localized(
|
||||
"provider.grokbuild.credentials.missing",
|
||||
"Grok Build 配置缺少有效的 api_key 或 env_key 字段",
|
||||
"Grok Build configuration is missing a valid api_key or env_key field",
|
||||
));
|
||||
}
|
||||
required_non_empty_string(selected_model, "api_backend")?;
|
||||
|
||||
selected_model
|
||||
.get("context_window")
|
||||
.and_then(toml::Value::as_integer)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.context_window.invalid",
|
||||
"Grok Build context_window 必须是正整数",
|
||||
"Grok Build context_window must be a positive integer",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
|
||||
let document = config_toml.parse::<toml::Value>().ok()?;
|
||||
let root = document.as_table()?;
|
||||
let default_model = root
|
||||
.get("models")?
|
||||
.as_table()?
|
||||
.get("default")?
|
||||
.as_str()?
|
||||
.trim();
|
||||
let selected_model = root
|
||||
.get("model")?
|
||||
.as_table()?
|
||||
.get(default_model)?
|
||||
.as_table()?;
|
||||
Some(GrokModelConfig {
|
||||
profile: default_model.to_string(),
|
||||
model: selected_model.get("model")?.as_str()?.trim().to_string(),
|
||||
base_url: selected_model
|
||||
.get("base_url")?
|
||||
.as_str()?
|
||||
.trim_end_matches('/')
|
||||
.to_string(),
|
||||
name: selected_model.get("name")?.as_str()?.trim().to_string(),
|
||||
api_key: optional_non_empty_string(selected_model, "api_key"),
|
||||
env_key: optional_non_empty_string(selected_model, "env_key"),
|
||||
api_backend: selected_model
|
||||
.get("api_backend")?
|
||||
.as_str()?
|
||||
.trim()
|
||||
.to_string(),
|
||||
context_window: selected_model.get("context_window")?.as_integer()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
||||
let config = extract_model_config(config_toml)?;
|
||||
let api_key = config
|
||||
.api_key
|
||||
.or_else(|| {
|
||||
config
|
||||
.env_key
|
||||
.as_deref()
|
||||
.and_then(|key| std::env::var(key).ok())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.or_else(|| {
|
||||
std::env::var("XAI_API_KEY")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})?;
|
||||
Some((config.base_url, api_key))
|
||||
}
|
||||
|
||||
pub fn extract_inline_api_key(config_toml: &str) -> Option<String> {
|
||||
extract_model_config(config_toml)?.api_key
|
||||
}
|
||||
|
||||
pub fn extract_base_url(config_toml: &str) -> Option<String> {
|
||||
Some(extract_model_config(config_toml)?.base_url)
|
||||
}
|
||||
|
||||
fn update_selected_model_string(
|
||||
config_toml: &str,
|
||||
field: &str,
|
||||
value: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let mut document = config_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|error| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.invalid_toml",
|
||||
format!("Grok Build config.toml 格式错误: {error}"),
|
||||
format!("Invalid Grok Build config.toml: {error}"),
|
||||
)
|
||||
})?;
|
||||
let default_model = document
|
||||
.get("models")
|
||||
.and_then(|item| item.get("default"))
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|model| !model.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
"Grok Build 配置缺少 models.default",
|
||||
"Grok Build configuration is missing models.default",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let selected_model = document
|
||||
.get_mut("model")
|
||||
.and_then(|item| item.get_mut(&default_model))
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
|
||||
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
|
||||
)
|
||||
})?;
|
||||
selected_model.insert(field, toml_edit::value(value));
|
||||
Ok(document.to_string())
|
||||
}
|
||||
|
||||
pub fn apply_proxy_takeover(
|
||||
config_toml: &str,
|
||||
proxy_base_url: &str,
|
||||
token_placeholder: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
|
||||
update_selected_model_string(&updated, "api_key", token_placeholder)
|
||||
}
|
||||
|
||||
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
|
||||
update_selected_model_string(config_toml, "api_key", api_key)
|
||||
}
|
||||
|
||||
pub fn has_proxy_placeholder(config_toml: &str, token_placeholder: &str) -> bool {
|
||||
extract_model_config(config_toml)
|
||||
.and_then(|config| config.api_key)
|
||||
.is_some_and(|api_key| api_key == token_placeholder)
|
||||
}
|
||||
|
||||
pub fn base_url_matches(config_toml: &str, predicate: impl FnOnce(&str) -> bool) -> bool {
|
||||
extract_model_config(config_toml).is_some_and(|config| predicate(&config.base_url))
|
||||
}
|
||||
|
||||
/// Remove MCP projections from a provider-owned Grok Build settings snapshot.
|
||||
/// MCP servers are owned by the database and projected into live config.toml.
|
||||
pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(), AppError> {
|
||||
let Some(config_text) = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !config_text.contains("mcp") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut document = config_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|error| AppError::Message(format!("Invalid Grok Build config.toml: {error}")))?;
|
||||
let mut changed = document.as_table_mut().remove("mcp_servers").is_some();
|
||||
if let Some(mcp_table) = document
|
||||
.get_mut("mcp")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
{
|
||||
if mcp_table.remove("servers").is_some() {
|
||||
changed = true;
|
||||
}
|
||||
if mcp_table.is_empty() {
|
||||
document.as_table_mut().remove("mcp");
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if let Some(object) = settings.as_object_mut() {
|
||||
object.insert("config".to_string(), Value::String(document.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_grok_live_settings() -> Result<Value, AppError> {
|
||||
let path = get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"grokbuild.config.missing",
|
||||
"Grok Build 配置文件不存在",
|
||||
"Grok Build configuration file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
|
||||
validate_config_toml(&config)?;
|
||||
Ok(json!({ "config": config }))
|
||||
}
|
||||
|
||||
pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.settings.not_object",
|
||||
"Grok Build 配置必须是 JSON 对象",
|
||||
"Grok Build configuration must be a JSON object",
|
||||
)
|
||||
})?;
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
|
||||
write_grok_live_settings(&json!({ "config": config }))
|
||||
}
|
||||
|
||||
pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
validate_config_toml(config)?;
|
||||
write_text_file(&get_grok_config_path(), config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn valid_config() -> &'static str {
|
||||
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
|
||||
"#
|
||||
}
|
||||
|
||||
fn valid_env_key_config() -> &'static str {
|
||||
r#"[models]
|
||||
default = "grok-env"
|
||||
|
||||
[model."grok-env"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://example.com/v1"
|
||||
name = "Example Env"
|
||||
env_key = "GROK_TEST_API_KEY"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_expected_config_shape() {
|
||||
validate_config_toml(valid_config()).expect("valid Grok Build config");
|
||||
validate_config_toml(valid_env_key_config()).expect("valid env_key configuration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_selected_model_table() {
|
||||
let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n")
|
||||
.expect_err("missing model table should fail");
|
||||
assert!(error.to_string().contains("model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_config_without_api_key_or_env_key() {
|
||||
let config = valid_config().replace("api_key = \"secret\"\n", "");
|
||||
let error = validate_config_toml(&config).expect_err("credentials should be required");
|
||||
assert!(error.to_string().contains("api_key"));
|
||||
assert!(error.to_string().contains("env_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_selected_model_and_updates_takeover_fields() {
|
||||
let selected = extract_model_config(valid_config()).expect("selected model");
|
||||
assert_eq!(selected.profile, "grok-4.5");
|
||||
assert_eq!(selected.model, "grok-4.5");
|
||||
assert_eq!(selected.base_url, "https://example.com/v1");
|
||||
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_config(),
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
.expect("takeover config");
|
||||
let selected = extract_model_config(&updated).expect("updated selected model");
|
||||
assert_eq!(selected.base_url, "http://127.0.0.1:15721/grokbuild/v1");
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
assert!(has_proxy_placeholder(&updated, "PROXY_MANAGED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_env_key_config(),
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
.expect("takeover config");
|
||||
let selected = extract_model_config(&updated).expect("updated selected model");
|
||||
|
||||
assert_eq!(selected.profile, "grok-env");
|
||||
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolves_api_key_from_configured_environment_variable() {
|
||||
let original = std::env::var_os("GROK_TEST_API_KEY");
|
||||
std::env::set_var("GROK_TEST_API_KEY", "env-secret");
|
||||
|
||||
let credentials = extract_credentials(valid_env_key_config()).expect("credentials");
|
||||
|
||||
assert_eq!(credentials.0, "https://example.com/v1");
|
||||
assert_eq!(credentials.1, "env-secret");
|
||||
match original {
|
||||
Some(value) => std::env::set_var("GROK_TEST_API_KEY", value),
|
||||
None => std::env::remove_var("GROK_TEST_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_projected_mcp_servers_without_touching_model_config() {
|
||||
let mut settings = json!({
|
||||
"config": format!(
|
||||
"{}\n[mcp_servers.echo]\ncommand = \"echo\"\n",
|
||||
valid_config()
|
||||
)
|
||||
});
|
||||
|
||||
strip_grok_mcp_servers_from_settings(&mut settings).expect("strip MCP servers");
|
||||
|
||||
let config = settings.get("config").and_then(Value::as_str).unwrap();
|
||||
assert!(!config.contains("mcp_servers"));
|
||||
assert!(config.contains("model = \"grok-4.5\""));
|
||||
validate_config_toml(config).expect("stripped config remains valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn writes_and_reads_live_config() {
|
||||
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());
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"grok".to_string(),
|
||||
"Example".to_string(),
|
||||
json!({ "config": valid_config() }),
|
||||
None,
|
||||
);
|
||||
write_grok_provider_live(&provider).expect("write live config");
|
||||
|
||||
let path = get_grok_config_path();
|
||||
assert_eq!(path, temp.path().join(".grok").join("config.toml"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(path).expect("read config"),
|
||||
valid_config()
|
||||
);
|
||||
assert_eq!(
|
||||
read_grok_live_settings()
|
||||
.expect("read live settings")
|
||||
.get("config")
|
||||
.and_then(Value::as_str),
|
||||
Some(valid_config())
|
||||
);
|
||||
|
||||
match original_test_home {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-13
@@ -14,6 +14,7 @@ mod deeplink;
|
||||
mod error;
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod grok_config;
|
||||
pub mod hermes_config;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
@@ -47,10 +48,11 @@ pub use database::{Database, Profile};
|
||||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||
pub use error::AppError;
|
||||
pub use mcp::{
|
||||
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
|
||||
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
|
||||
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
|
||||
sync_single_server_to_codex, sync_single_server_to_gemini,
|
||||
import_from_claude, import_from_codex, import_from_gemini, import_from_grokbuild,
|
||||
remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini,
|
||||
remove_server_from_grokbuild, sync_enabled_to_claude, sync_enabled_to_codex,
|
||||
sync_enabled_to_gemini, sync_single_server_to_claude, sync_single_server_to_codex,
|
||||
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
|
||||
};
|
||||
pub use prompt::Prompt;
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
@@ -783,6 +785,14 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_grokbuild(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from Grok Build");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No Grok Build MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Grok Build MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
|
||||
@@ -808,6 +818,7 @@ pub fn run() {
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
crate::app_config::AppType::GrokBuild,
|
||||
crate::app_config::AppType::OpenCode,
|
||||
crate::app_config::AppType::OpenClaw,
|
||||
crate::app_config::AppType::Hermes,
|
||||
@@ -1740,16 +1751,25 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
||||
///
|
||||
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
|
||||
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||
let mut apps_to_restore = Vec::new();
|
||||
for app_type in ["claude", "codex", "gemini"] {
|
||||
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
|
||||
if config.enabled {
|
||||
apps_to_restore.push(app_type);
|
||||
}
|
||||
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
|
||||
|
||||
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
|
||||
let mut apps = Vec::new();
|
||||
for app_type in PROXY_STARTUP_APP_TYPES {
|
||||
if db
|
||||
.get_proxy_config_for_app(app_type)
|
||||
.await
|
||||
.is_ok_and(|config| config.enabled)
|
||||
{
|
||||
apps.push(app_type);
|
||||
}
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||
let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await;
|
||||
|
||||
if apps_to_restore.is_empty() {
|
||||
log::debug!("启动时无需恢复代理状态");
|
||||
@@ -2067,7 +2087,8 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_exit_request, ExitRequestAction};
|
||||
use super::{classify_exit_request, enabled_proxy_apps_on_startup, ExitRequestAction};
|
||||
use crate::database::Database;
|
||||
|
||||
#[test]
|
||||
fn no_code_keeps_app_alive_in_tray() {
|
||||
@@ -2093,4 +2114,21 @@ mod tests {
|
||||
ExitRequestAction::CleanupAndExit
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_restore_includes_enabled_grokbuild_route() {
|
||||
let db = Database::memory().expect("initialize database");
|
||||
let mut config = db
|
||||
.get_proxy_config_for_app("grokbuild")
|
||||
.await
|
||||
.expect("read Grok Build proxy config");
|
||||
config.enabled = true;
|
||||
db.update_proxy_config_for_app(config)
|
||||
.await
|
||||
.expect("enable Grok Build proxy config");
|
||||
|
||||
let apps = enabled_proxy_apps_on_startup(&db).await;
|
||||
|
||||
assert_eq!(apps, vec!["grokbuild"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -556,7 +557,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
|
||||
/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
|
||||
/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
|
||||
/// 3. 其他未知字段使用通用转换器尝试转换
|
||||
fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
use toml_edit::{Array, Item, Table};
|
||||
|
||||
let mut t = Table::new();
|
||||
|
||||
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Grok Build MCP synchronization and import.
|
||||
//!
|
||||
//! Grok Build uses the same top-level `[mcp_servers]` TOML layout as Codex,
|
||||
//! stored alongside its model configuration in `~/.grok/config.toml`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::codex::json_server_to_toml_table;
|
||||
use super::validation::validate_server_spec;
|
||||
|
||||
fn should_sync_grokbuild_mcp() -> bool {
|
||||
crate::grok_config::get_grok_config_dir().exists()
|
||||
}
|
||||
|
||||
fn read_config_text() -> Result<String, AppError> {
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
|
||||
}
|
||||
|
||||
fn json_server_to_grokbuild_toml_table(server_spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
let mut table = json_server_to_toml_table(server_spec)?;
|
||||
// Grok infers transport from `command` or `url` and uses `headers`, while
|
||||
// Codex writes an explicit `type` plus `http_headers`.
|
||||
table.remove("type");
|
||||
if let Some(headers) = table.remove("http_headers") {
|
||||
table.insert("headers", headers);
|
||||
}
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
fn toml_server_to_json(entry: &toml::value::Table) -> Value {
|
||||
fn convert(value: &toml::Value) -> Option<Value> {
|
||||
match value {
|
||||
toml::Value::String(value) => Some(json!(value)),
|
||||
toml::Value::Integer(value) => Some(json!(value)),
|
||||
toml::Value::Float(value) => Some(json!(value)),
|
||||
toml::Value::Boolean(value) => Some(json!(value)),
|
||||
toml::Value::Datetime(value) => Some(json!(value.to_string())),
|
||||
toml::Value::Array(values) => Some(Value::Array(
|
||||
values.iter().filter_map(convert).collect::<Vec<_>>(),
|
||||
)),
|
||||
toml::Value::Table(values) => Some(Value::Object(
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|(key, value)| convert(value).map(|value| (key.clone(), value)))
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut spec = serde_json::Map::new();
|
||||
for (key, value) in entry {
|
||||
let output_key = if key == "http_headers" {
|
||||
"headers"
|
||||
} else {
|
||||
key
|
||||
};
|
||||
if let Some(value) = convert(value) {
|
||||
spec.insert(output_key.to_string(), value);
|
||||
}
|
||||
}
|
||||
let default_type = if spec.contains_key("url") {
|
||||
"http"
|
||||
} else {
|
||||
"stdio"
|
||||
};
|
||||
spec.entry("type".to_string())
|
||||
.or_insert_with(|| json!(default_type));
|
||||
Value::Object(spec)
|
||||
}
|
||||
|
||||
pub fn import_from_grokbuild(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let text = read_config_text()?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let root: toml::Table = toml::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.grok/config.toml 失败: {e}")))?;
|
||||
let Some(entries) = root.get("mcp_servers").and_then(toml::Value::as_table) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let servers = config
|
||||
.mcp
|
||||
.servers
|
||||
.get_or_insert_with(std::collections::HashMap::new);
|
||||
let mut changed = 0;
|
||||
for (id, entry) in entries {
|
||||
let Some(entry) = entry.as_table() else {
|
||||
continue;
|
||||
};
|
||||
let spec = toml_server_to_json(entry);
|
||||
if let Err(error) = validate_server_spec(&spec) {
|
||||
log::warn!("跳过无效 Grok Build MCP 项 '{id}': {error}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
if !existing.apps.grokbuild {
|
||||
existing.apps.grokbuild = true;
|
||||
changed += 1;
|
||||
}
|
||||
} else {
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec,
|
||||
apps: McpApps {
|
||||
grokbuild: true,
|
||||
..Default::default()
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn sync_single_server_to_grokbuild(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_grokbuild_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
use toml_edit::Item;
|
||||
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
let text = read_config_text()?;
|
||||
let mut doc = if text.trim().is_empty() {
|
||||
toml_edit::DocumentMut::new()
|
||||
} else {
|
||||
text.parse::<toml_edit::DocumentMut>().map_err(|e| {
|
||||
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_grokbuild_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let text = read_config_text()?;
|
||||
let mut doc = match text.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(error) => {
|
||||
log::warn!("解析 Grok Build config.toml 失败: {error},跳过删除操作");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Some(servers) = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_mut)
|
||||
{
|
||||
servers.remove(id);
|
||||
}
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_http_headers_to_unified_headers() {
|
||||
let entry: toml::value::Table = toml::from_str(
|
||||
r#"type = "http"
|
||||
url = "https://example.com/mcp"
|
||||
http_headers = { Authorization = "Bearer token" }
|
||||
"#,
|
||||
)
|
||||
.expect("parse table");
|
||||
let spec = toml_server_to_json(&entry);
|
||||
assert_eq!(spec["type"], "http");
|
||||
assert_eq!(spec["headers"]["Authorization"], "Bearer token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_grokbuild_remote_server_without_codex_only_fields() {
|
||||
let table = json_server_to_grokbuild_toml_table(&json!({
|
||||
"type": "http",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer token" }
|
||||
}))
|
||||
.expect("convert server");
|
||||
|
||||
assert!(!table.contains_key("type"));
|
||||
assert!(!table.contains_key("http_headers"));
|
||||
assert_eq!(
|
||||
table
|
||||
.get("headers")
|
||||
.and_then(toml_edit::Item::as_table)
|
||||
.and_then(|headers| headers.get("Authorization"))
|
||||
.and_then(toml_edit::Item::as_str),
|
||||
Some("Bearer token")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -315,6 +315,7 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: true,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod grokbuild;
|
||||
mod hermes;
|
||||
mod opencode;
|
||||
mod validation;
|
||||
@@ -30,6 +31,9 @@ pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
pub use grokbuild::{
|
||||
import_from_grokbuild, remove_server_from_grokbuild, sync_single_server_to_grokbuild,
|
||||
};
|
||||
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
|
||||
pub use opencode::{
|
||||
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||
|
||||
@@ -258,6 +258,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: true,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -12,9 +12,9 @@ use crate::opencode_config::get_opencode_dir;
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.prompts_unsupported",
|
||||
"Claude Desktop 暂不支持 Prompts",
|
||||
"Claude Desktop does not support Prompts",
|
||||
"app.prompts_unsupported",
|
||||
"当前应用暂不支持 Prompts",
|
||||
"This app does not support Prompts",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
AppType::Gemini => get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
@@ -32,7 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
|
||||
@@ -164,6 +164,11 @@ impl Provider {
|
||||
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
(base_url, api_key)
|
||||
}
|
||||
AppType::GrokBuild => settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::grok_config::extract_credentials)
|
||||
.unwrap_or_default(),
|
||||
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
||||
AppType::Hermes => (
|
||||
str_at(settings.get("base_url")),
|
||||
|
||||
@@ -1142,9 +1142,9 @@ impl RequestForwarder {
|
||||
// Codex upstream conversion mode — computed early because the [1m]-suffix strip
|
||||
// below must be skipped on the Anthropic path (the marker has to survive to
|
||||
// catalog matching and to the transform's own strip+beta detection).
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
|
||||
let codex_official_auth_passthrough = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::is_codex_official_provider(provider);
|
||||
@@ -1168,6 +1168,13 @@ impl RequestForwarder {
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// Grok Build exposes a stable client-side model profile in config.toml.
|
||||
// Route requests to the provider's real upstream model before applying
|
||||
// the optional Responses -> Chat/Anthropic bridge.
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
|
||||
}
|
||||
|
||||
if is_copilot {
|
||||
mapped_body =
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
@@ -1511,7 +1518,7 @@ impl RequestForwarder {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
if matches!(app_type, AppType::Codex | AppType::GrokBuild) {
|
||||
self.apply_media_prevention(&mut request_body, provider);
|
||||
}
|
||||
|
||||
|
||||
@@ -758,6 +758,30 @@ pub async fn handle_chat_completions(
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await
|
||||
}
|
||||
|
||||
pub async fn handle_grokbuild_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::GrokBuild,
|
||||
"Grok Build",
|
||||
"grokbuild",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_responses_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -774,7 +798,7 @@ pub async fn handle_responses(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
@@ -786,7 +810,7 @@ pub async fn handle_responses(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
&app_type,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
@@ -849,6 +873,30 @@ pub async fn handle_responses(
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await
|
||||
}
|
||||
|
||||
pub async fn handle_grokbuild_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_compact_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::GrokBuild,
|
||||
"Grok Build",
|
||||
"grokbuild",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_responses_compact_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -865,7 +913,7 @@ pub async fn handle_responses_compact(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
@@ -877,7 +925,7 @@ pub async fn handle_responses_compact(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
&app_type,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
|
||||
@@ -250,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_codex_model_from_toml)
|
||||
.and_then(|config| {
|
||||
crate::grok_config::extract_model_config(config)
|
||||
.map(|model| model.model)
|
||||
.or_else(|| extract_codex_model_from_toml(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -621,6 +625,9 @@ impl CodexAdapter {
|
||||
}
|
||||
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some((_, key)) = crate::grok_config::extract_credentials(config_str) {
|
||||
return Some(key);
|
||||
}
|
||||
if let Some(key) =
|
||||
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
|
||||
{
|
||||
@@ -675,6 +682,9 @@ impl ProviderAdapter for CodexAdapter {
|
||||
|
||||
// 尝试解析 TOML 字符串格式
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some(url) = crate::grok_config::extract_base_url(config_str) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
if let Some(start) = config_str.find("base_url = \"") {
|
||||
let rest = &config_str[start + 12..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
@@ -796,6 +806,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_build_toml_exposes_upstream_credentials_and_model() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"config": r#"
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "upstream-grok-model"
|
||||
base_url = "https://relay.example.com/v1/"
|
||||
name = "Example Relay"
|
||||
api_key = "grok-secret"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
adapter.extract_base_url(&provider).unwrap(),
|
||||
"https://relay.example.com/v1"
|
||||
);
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "grok-secret");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
assert_eq!(
|
||||
codex_provider_upstream_model(&provider).as_deref(),
|
||||
Some("upstream-grok-model")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
|
||||
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
|
||||
|
||||
@@ -192,10 +192,8 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex-like type
|
||||
ProviderType::Codex
|
||||
}
|
||||
AppType::GrokBuild => ProviderType::Codex,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,10 +244,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
AppType::GrokBuild => Box::new(CodexAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,12 @@ impl ProxyServer {
|
||||
.route("/v1/responses", post(handlers::handle_responses))
|
||||
.route("/v1/v1/responses", post(handlers::handle_responses))
|
||||
.route("/codex/v1/responses", post(handlers::handle_responses))
|
||||
// Grok Build uses the Responses protocol but has an independent
|
||||
// provider namespace and failover queue.
|
||||
.route(
|
||||
"/grokbuild/v1/responses",
|
||||
post(handlers::handle_grokbuild_responses),
|
||||
)
|
||||
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
|
||||
.route(
|
||||
"/responses/compact",
|
||||
@@ -344,6 +350,10 @@ impl ProxyServer {
|
||||
"/codex/v1/responses/compact",
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
.route(
|
||||
"/grokbuild/v1/responses/compact",
|
||||
post(handlers::handle_grokbuild_responses_compact),
|
||||
)
|
||||
// Gemini API (支持带前缀和不带前缀)
|
||||
//
|
||||
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
|
||||
|
||||
@@ -113,6 +113,7 @@ pub struct ProxyTakeoverStatus {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub grokbuild: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ impl CostCalculator {
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
|
||||
Self::calculate_with_cache_semantics(
|
||||
usage,
|
||||
pricing,
|
||||
@@ -211,6 +211,25 @@ mod tests {
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_does_not_double_bill_cached_input() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 600,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
let pricing = ModelPricing::from_strings("10", "0", "1", "0").unwrap();
|
||||
|
||||
let cost = CostCalculator::calculate_for_app("grokbuild", &usage, &pricing, Decimal::ONE);
|
||||
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.004").unwrap());
|
||||
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.0006").unwrap());
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.0046").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_multiplier() {
|
||||
let usage = TokenUsage {
|
||||
|
||||
@@ -71,11 +71,12 @@ impl<'a> UsageLogger<'a> {
|
||||
};
|
||||
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
let input_token_semantics =
|
||||
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
@@ -459,4 +460,39 @@ mod tests {
|
||||
assert_eq!(error, Some("Internal Server Error".to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_logs_total_input_token_semantics() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let logger = UsageLogger::new(&db);
|
||||
let log = RequestLog {
|
||||
request_id: "grok-semantics".to_string(),
|
||||
provider_id: "grok-provider".to_string(),
|
||||
app_type: "grokbuild".to_string(),
|
||||
model: "grok-4.5".to_string(),
|
||||
request_model: "grok-4.5".to_string(),
|
||||
pricing_model: String::new(),
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms: 1,
|
||||
first_token_ms: None,
|
||||
status_code: 200,
|
||||
error_message: None,
|
||||
session_id: None,
|
||||
provider_type: Some("grokbuild".to_string()),
|
||||
is_streaming: false,
|
||||
cost_multiplier: "1".to_string(),
|
||||
};
|
||||
|
||||
logger.log_request(&log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let semantics: i64 = conn.query_row(
|
||||
"SELECT input_token_semantics FROM proxy_request_logs WHERE request_id = 'grok-semantics'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ impl ConfigService {
|
||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::GrokBuild)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -125,6 +126,7 @@ impl ConfigService {
|
||||
// Claude Desktop 3P profiles are managed by claude_desktop_config.
|
||||
}
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::GrokBuild => crate::grok_config::write_grok_provider_live(&provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
|
||||
@@ -37,6 +37,9 @@ impl McpService {
|
||||
if prev_apps.gemini && !server.apps.gemini {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
||||
}
|
||||
if prev_apps.grokbuild && !server.apps.grokbuild {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::GrokBuild)?;
|
||||
}
|
||||
if prev_apps.opencode && !server.apps.opencode {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
|
||||
}
|
||||
@@ -122,6 +125,13 @@ impl McpService {
|
||||
AppType::Gemini => {
|
||||
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
mcp::sync_single_server_to_grokbuild(
|
||||
&Default::default(),
|
||||
&server.id,
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
mcp::sync_single_server_to_opencode(
|
||||
&Default::default(),
|
||||
@@ -162,6 +172,7 @@ impl McpService {
|
||||
}
|
||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||
AppType::GrokBuild => mcp::remove_server_from_grokbuild(id)?,
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
@@ -393,6 +404,32 @@ impl McpService {
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 Grok Build 的 `[mcp_servers]` 导入 MCP。
|
||||
pub fn import_from_grokbuild(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut temp_config = crate::app_config::MultiAppConfig::default();
|
||||
let count = crate::mcp::import_from_grokbuild(&mut temp_config)?;
|
||||
let mut new_count = 0;
|
||||
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
let mut existing = state.db.get_all_mcp_servers()?;
|
||||
for server in servers.values() {
|
||||
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||
let mut merged = existing_server.clone();
|
||||
merged.apps.grokbuild = true;
|
||||
merged
|
||||
} else {
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 OpenCode 导入 MCP(v3.9.2+ 新增)
|
||||
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
|
||||
// 创建临时 MultiAppConfig 用于导入
|
||||
@@ -479,10 +516,11 @@ impl McpService {
|
||||
let mut total = 0;
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
let results: [(&str, Result<usize, AppError>); 5] = [
|
||||
let results: [(&str, Result<usize, AppError>); 6] = [
|
||||
("claude", Self::import_from_claude(state)),
|
||||
("codex", Self::import_from_codex(state)),
|
||||
("gemini", Self::import_from_gemini(state)),
|
||||
("grokbuild", Self::import_from_grokbuild(state)),
|
||||
("opencode", Self::import_from_opencode(state)),
|
||||
("hermes", Self::import_from_hermes(state)),
|
||||
];
|
||||
|
||||
@@ -523,7 +523,11 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,9 +597,11 @@ pub(crate) fn remove_common_config_from_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,9 +656,11 @@ fn apply_common_config_to_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,6 +833,16 @@ fn restore_live_settings_for_provider_backfill(
|
||||
strip_injected_kimi_for_coding_context_defaults(&mut settings, provider);
|
||||
return settings;
|
||||
}
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
let mut settings = live_settings;
|
||||
if let Err(err) = crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings) {
|
||||
log::warn!(
|
||||
"Failed to strip Grok Build mcp_servers while backfilling '{}': {err}",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
if !matches!(app_type, AppType::Codex) {
|
||||
return live_settings;
|
||||
}
|
||||
@@ -1037,6 +1055,9 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
write_gemini_live(provider)?;
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
crate::grok_config::write_grok_provider_live(provider)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - write provider to config
|
||||
use crate::opencode_config;
|
||||
@@ -1367,6 +1388,7 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
AppType::GrokBuild => crate::grok_config::read_grok_live_settings(),
|
||||
AppType::OpenClaw => {
|
||||
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
|
||||
|
||||
@@ -1435,6 +1457,11 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
|
||||
AppType::GrokBuild => {
|
||||
let mut settings = crate::grok_config::read_grok_live_settings()?;
|
||||
crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings)?;
|
||||
settings
|
||||
}
|
||||
AppType::Claude => {
|
||||
let settings_path = get_claude_settings_path();
|
||||
if !settings_path.exists() {
|
||||
@@ -2536,4 +2563,32 @@ base_url = "https://a.example/v1"
|
||||
"non-MCP content must survive the strip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_switch_backfill_strips_synced_mcp_servers() {
|
||||
let provider = Provider::with_id(
|
||||
"grok".to_string(),
|
||||
"Grok".to_string(),
|
||||
json!({
|
||||
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let live_settings = json!({
|
||||
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n\n[mcp_servers.echo]\ncommand = \"echo\"\n"
|
||||
});
|
||||
|
||||
let result = restore_live_settings_for_provider_backfill(
|
||||
&AppType::GrokBuild,
|
||||
&provider,
|
||||
live_settings,
|
||||
);
|
||||
let config_text = result
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.expect("config text");
|
||||
|
||||
assert!(!config_text.contains("mcp_servers"));
|
||||
assert!(config_text.contains("model = \"grok-4.5\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2527,14 +2527,16 @@ impl ProviderService {
|
||||
// restore backup. Serialize them per app, then decide from the locked
|
||||
// current state so a just-started takeover cannot be overwritten by a
|
||||
// normal live write.
|
||||
let _switch_guard =
|
||||
if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {
|
||||
Some(futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _switch_guard = if matches!(
|
||||
app_type,
|
||||
AppType::Claude | AppType::Codex | AppType::Gemini | AppType::GrokBuild
|
||||
) {
|
||||
Some(futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Backup or live placeholders mean the live file is owned by proxy
|
||||
// takeover, even if the proxy server is temporarily stopped or is in the
|
||||
@@ -2993,6 +2995,7 @@ impl ProviderService {
|
||||
AppType::ClaudeDesktop => Ok(String::new()),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::GrokBuild => Ok(String::new()),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||||
@@ -3009,6 +3012,7 @@ impl ProviderService {
|
||||
AppType::ClaudeDesktop => Ok(String::new()),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::GrokBuild => Ok(String::new()),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||||
@@ -3479,6 +3483,26 @@ impl ProviderService {
|
||||
use crate::gemini_config::validate_gemini_settings;
|
||||
validate_gemini_settings(&provider.settings_config)?
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.settings.not_object",
|
||||
"Grok Build 配置必须是 JSON 对象",
|
||||
"Grok Build configuration must be a JSON object",
|
||||
)
|
||||
})?;
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
crate::grok_config::validate_config_toml(config)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses a different config structure: { npm, options, models }
|
||||
// Basic validation - must be an object
|
||||
@@ -3575,6 +3599,28 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let config_toml = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
let (base_url, api_key) = crate::grok_config::extract_credentials(config_toml)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.credentials.missing",
|
||||
"Grok Build 配置缺少 Base URL 或 API Key",
|
||||
"Grok Build configuration is missing the base URL or API key",
|
||||
)
|
||||
})?;
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
let credentials =
|
||||
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
|
||||
|
||||
+678
-47
File diff suppressed because it is too large
Load Diff
@@ -511,6 +511,11 @@ impl SkillService {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
if let Some(custom) = crate::settings::get_grok_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
if let Some(custom) = crate::settings::get_opencode_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
@@ -538,6 +543,7 @@ impl SkillService {
|
||||
AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"),
|
||||
AppType::Codex => home.join(".codex").join("skills"),
|
||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||
AppType::GrokBuild => home.join(".grok").join("skills"),
|
||||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||
AppType::OpenClaw => home.join(".openclaw").join("skills"),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
/// style provider not added here) shows up loudly as a too-low cache hit
|
||||
/// rate, which is easier to catch than the silent over-deduction that
|
||||
/// would happen with the opposite default.
|
||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"];
|
||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
|
||||
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
|
||||
@@ -93,6 +93,7 @@ mod tests {
|
||||
assert!(!sql.contains("."));
|
||||
assert!(sql.contains("'codex'"));
|
||||
assert!(sql.contains("'gemini'"));
|
||||
assert!(sql.contains("'grokbuild'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -112,6 +113,13 @@ mod tests {
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Grok Build uses OpenAI Responses semantics too.
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
|
||||
VALUES ('grok-1', 'grokbuild', 700, 250)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Claude row: Anthropic semantics — input_tokens already excludes cache.
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
|
||||
@@ -123,8 +131,8 @@ mod tests {
|
||||
let expr = fresh_input_sql("l");
|
||||
let sql = format!("SELECT COALESCE(SUM({expr}), 0) FROM proxy_request_logs l");
|
||||
let total: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
|
||||
// Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged.
|
||||
assert_eq!(total, 400 + 500 + 200);
|
||||
// Codex: 400; Gemini: 500; Grok Build: 450; Claude: 200 unchanged.
|
||||
assert_eq!(total, 400 + 500 + 450 + 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@ pub mod terminal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, hermes, openclaw, opencode};
|
||||
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -56,13 +56,14 @@ pub struct DeleteSessionOutcome {
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| {
|
||||
let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| {
|
||||
let h1 = s.spawn(codex::scan_sessions);
|
||||
let h2 = s.spawn(claude::scan_sessions);
|
||||
let h3 = s.spawn(opencode::scan_sessions);
|
||||
let h4 = s.spawn(openclaw::scan_sessions);
|
||||
let h5 = s.spawn(gemini::scan_sessions);
|
||||
let h6 = s.spawn(hermes::scan_sessions);
|
||||
let h7 = s.spawn(grokbuild::scan_sessions);
|
||||
(
|
||||
h1.join().unwrap_or_default(),
|
||||
h2.join().unwrap_or_default(),
|
||||
@@ -70,6 +71,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
h4.join().unwrap_or_default(),
|
||||
h5.join().unwrap_or_default(),
|
||||
h6.join().unwrap_or_default(),
|
||||
h7.join().unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -80,6 +82,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
sessions.extend(r4);
|
||||
sessions.extend(r5);
|
||||
sessions.extend(r6);
|
||||
sessions.extend(r7);
|
||||
|
||||
sessions.sort_by(|a, b| {
|
||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||
@@ -106,6 +109,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
||||
"opencode" => opencode::load_messages(path),
|
||||
"openclaw" => openclaw::load_messages(path),
|
||||
"gemini" => gemini::load_messages(path),
|
||||
"grokbuild" => grokbuild::load_messages(path),
|
||||
"hermes" => hermes::load_messages(path),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
@@ -165,6 +169,9 @@ fn delete_session_with_roots(
|
||||
openclaw::delete_session(&validated_root, &validated_source, session_id)
|
||||
}
|
||||
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
|
||||
"grokbuild" => {
|
||||
grokbuild::delete_session(&validated_root, &validated_source, session_id)
|
||||
}
|
||||
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
@@ -194,6 +201,7 @@ fn provider_roots(provider_id: &str) -> Result<Vec<PathBuf>, String> {
|
||||
"opencode" => vec![opencode::get_opencode_data_dir()],
|
||||
"openclaw" => vec![crate::openclaw_config::get_openclaw_dir().join("agents")],
|
||||
"gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")],
|
||||
"grokbuild" => grokbuild::session_roots(),
|
||||
"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],
|
||||
_ => return Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, truncate_summary, TITLE_MAX_CHARS};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GrokSessionInfo {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GrokSessionSummary {
|
||||
info: GrokSessionInfo,
|
||||
#[serde(default)]
|
||||
session_summary: Option<String>,
|
||||
#[serde(default)]
|
||||
generated_title: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Value>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Value>,
|
||||
#[serde(default)]
|
||||
last_active_at: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn session_roots() -> Vec<PathBuf> {
|
||||
let config_dir = crate::grok_config::get_grok_config_dir();
|
||||
vec![
|
||||
config_dir.join("sessions"),
|
||||
config_dir.join("archived_sessions"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let mut summaries = Vec::new();
|
||||
for root in session_roots() {
|
||||
collect_summary_files(&root, &mut summaries);
|
||||
}
|
||||
summaries
|
||||
.into_iter()
|
||||
.filter_map(|path| parse_summary(&path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let session_dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
|
||||
let chat_path = session_dir.join("chat_history.jsonl");
|
||||
let file = File::open(&chat_path)
|
||||
.map_err(|e| format!("Failed to open Grok Build chat history: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let Ok(value) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let role = match kind {
|
||||
"system" | "user" | "assistant" | "tool" => kind,
|
||||
// Reasoning records can contain encrypted/internal state and are not
|
||||
// conversation messages shown by Grok's own history view.
|
||||
_ => continue,
|
||||
};
|
||||
let content = value.get("content").map(extract_text).unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ts = value
|
||||
.get("timestamp")
|
||||
.or_else(|| value.get("ts"))
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
messages.push(SessionMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
ts,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
if !path.starts_with(root) {
|
||||
return Err(format!(
|
||||
"Grok Build session source is outside the session root: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some("summary.json") {
|
||||
return Err(format!(
|
||||
"Unexpected Grok Build session source: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let summary = read_summary(path)?;
|
||||
if summary.info.id != session_id {
|
||||
return Err(format!(
|
||||
"Grok Build session ID mismatch: expected {session_id}, found {}",
|
||||
summary.info.id
|
||||
));
|
||||
}
|
||||
let session_dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
|
||||
if session_dir == root || !session_dir.starts_with(root) {
|
||||
return Err(format!(
|
||||
"Refusing to delete Grok Build session directory outside its root: {}",
|
||||
session_dir.display()
|
||||
));
|
||||
}
|
||||
if session_dir.file_name().and_then(|name| name.to_str()) != Some(session_id) {
|
||||
return Err(format!(
|
||||
"Grok Build session directory does not match session ID: {}",
|
||||
session_dir.display()
|
||||
));
|
||||
}
|
||||
std::fs::remove_dir_all(session_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Grok Build session directory {}: {e}",
|
||||
session_dir.display()
|
||||
)
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn collect_summary_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_summary_files(&path, files);
|
||||
} else if path.file_name().and_then(|name| name.to_str()) == Some("summary.json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_summary(path: &Path) -> Result<GrokSessionSummary, String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read Grok Build session summary: {e}"))?;
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|e| format!("Failed to parse Grok Build session summary: {e}"))
|
||||
}
|
||||
|
||||
fn parse_summary(path: &Path) -> Option<SessionMeta> {
|
||||
let summary = read_summary(path).ok()?;
|
||||
let session_id = summary.info.id;
|
||||
let title = summary
|
||||
.generated_title
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
summary
|
||||
.session_summary
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.map(|value| truncate_summary(value, TITLE_MAX_CHARS));
|
||||
let session_summary = summary
|
||||
.session_summary
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| truncate_summary(value, 160));
|
||||
let created_at = summary.created_at.as_ref().and_then(parse_timestamp_to_ms);
|
||||
let last_active_at = summary
|
||||
.last_active_at
|
||||
.as_ref()
|
||||
.or(summary.updated_at.as_ref())
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: "grokbuild".to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title,
|
||||
summary: session_summary,
|
||||
project_dir: summary.info.cwd,
|
||||
created_at,
|
||||
last_active_at,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
resume_command: Some(format!("grok --resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn scans_native_grokbuild_session_layout() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions_dir = temp.path().join("sessions");
|
||||
let session_id = "019f6af2-18b0-7673-958e-d25be650e172";
|
||||
let session_dir = sessions_dir.join("encoded-project").join(session_id);
|
||||
std::fs::create_dir_all(&session_dir).expect("create session dir");
|
||||
std::fs::write(
|
||||
session_dir.join("summary.json"),
|
||||
format!(
|
||||
r#"{{"info":{{"id":"{session_id}","cwd":"C:/work"}},"session_summary":"hello grok","generated_title":"Grok session","created_at":"2026-07-16T12:00:00Z","last_active_at":"2026-07-16T12:00:01Z"}}"#
|
||||
),
|
||||
)
|
||||
.expect("write summary");
|
||||
let mut files = Vec::new();
|
||||
collect_summary_files(&sessions_dir, &mut files);
|
||||
let sessions = files
|
||||
.iter()
|
||||
.filter_map(|path| parse_summary(path))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].provider_id, "grokbuild");
|
||||
assert_eq!(sessions[0].session_id, session_id);
|
||||
assert_eq!(sessions[0].title.as_deref(), Some("Grok session"));
|
||||
let expected_resume = format!("grok --resume {session_id}");
|
||||
assert_eq!(
|
||||
sessions[0].resume_command.as_deref(),
|
||||
Some(expected_resume.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_native_grokbuild_chat_history() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let summary_path = temp.path().join("summary.json");
|
||||
std::fs::write(&summary_path, "{}").expect("write summary placeholder");
|
||||
std::fs::write(
|
||||
temp.path().join("chat_history.jsonl"),
|
||||
concat!(
|
||||
"{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}\n",
|
||||
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"private\"}]}\n",
|
||||
"{\"type\":\"assistant\",\"content\":\"Hi there\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write chat history");
|
||||
|
||||
let messages = load_messages(&summary_path).expect("load messages");
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert_eq!(messages[0].content, "hello");
|
||||
assert_eq!(messages[1].content, "Hi there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_only_the_matching_session_directory() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let root = temp.path().join("sessions");
|
||||
let session_id = "session-to-delete";
|
||||
let session_dir = root.join("project").join(session_id);
|
||||
let sibling_dir = root.join("project").join("session-to-keep");
|
||||
std::fs::create_dir_all(&session_dir).expect("create session directory");
|
||||
std::fs::create_dir_all(&sibling_dir).expect("create sibling directory");
|
||||
let summary_path = session_dir.join("summary.json");
|
||||
std::fs::write(
|
||||
&summary_path,
|
||||
format!(r#"{{"info":{{"id":"{session_id}"}}}}"#),
|
||||
)
|
||||
.expect("write summary");
|
||||
std::fs::write(sibling_dir.join("keep.txt"), "keep").expect("write sibling file");
|
||||
|
||||
let deleted = delete_session(&root, &summary_path, session_id).expect("delete session");
|
||||
|
||||
assert!(deleted);
|
||||
assert!(!session_dir.exists());
|
||||
assert!(sibling_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_rejects_remove_dir_all_target_outside_root() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let root = temp.path().join("sessions");
|
||||
let outside_dir = temp.path().join("outside").join("session-outside");
|
||||
std::fs::create_dir_all(&root).expect("create root");
|
||||
std::fs::create_dir_all(&outside_dir).expect("create outside directory");
|
||||
let summary_path = outside_dir.join("summary.json");
|
||||
std::fs::write(&summary_path, r#"{"info":{"id":"session-outside"}}"#)
|
||||
.expect("write summary");
|
||||
|
||||
let error = delete_session(&root, &summary_path, "session-outside")
|
||||
.expect_err("outside path must be rejected");
|
||||
|
||||
assert!(error.contains("outside the session root"));
|
||||
assert!(outside_dir.exists());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod gemini;
|
||||
pub mod grokbuild;
|
||||
pub mod hermes;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct VisibleApps {
|
||||
#[serde(default = "default_true")]
|
||||
pub gemini: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub opencode: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub openclaw: bool,
|
||||
@@ -53,6 +55,7 @@ impl Default for VisibleApps {
|
||||
claude_desktop: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false, // 默认不显示,需用户手动启用
|
||||
@@ -68,6 +71,7 @@ impl VisibleApps {
|
||||
AppType::ClaudeDesktop => self.claude_desktop,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => self.openclaw,
|
||||
AppType::Hermes => self.hermes,
|
||||
@@ -411,6 +415,8 @@ pub struct AppSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub grok_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openclaw_config_dir: Option<String>,
|
||||
@@ -430,6 +436,9 @@ pub struct AppSettings {
|
||||
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_gemini: Option<String>,
|
||||
/// 当前 Grok Build 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_grokbuild: Option<String>,
|
||||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_opencode: Option<String>,
|
||||
@@ -520,6 +529,7 @@ impl Default for AppSettings {
|
||||
claude_config_dir: None,
|
||||
codex_config_dir: None,
|
||||
gemini_config_dir: None,
|
||||
grok_config_dir: None,
|
||||
opencode_config_dir: None,
|
||||
openclaw_config_dir: None,
|
||||
hermes_config_dir: None,
|
||||
@@ -527,6 +537,7 @@ impl Default for AppSettings {
|
||||
current_provider_claude_desktop: None,
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
current_provider_grokbuild: None,
|
||||
current_provider_opencode: None,
|
||||
current_provider_openclaw: None,
|
||||
current_provider_hermes: None,
|
||||
@@ -575,6 +586,13 @@ impl AppSettings {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.grok_config_dir = self
|
||||
.grok_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.opencode_config_dir = self
|
||||
.opencode_config_dir
|
||||
.as_ref()
|
||||
@@ -883,6 +901,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_grok_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
.grok_config_dir
|
||||
.as_ref()
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_opencode_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
@@ -940,6 +966,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(),
|
||||
AppType::Codex => settings.current_provider_codex.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||||
AppType::GrokBuild => settings.current_provider_grokbuild.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
|
||||
AppType::Hermes => settings.current_provider_hermes.clone(),
|
||||
@@ -957,6 +984,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
||||
AppType::ClaudeDesktop => settings.current_provider_claude_desktop = id_owned.clone(),
|
||||
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
|
||||
AppType::GrokBuild => settings.current_provider_grokbuild = id_owned.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
|
||||
AppType::Hermes => settings.current_provider_hermes = id_owned.clone(),
|
||||
|
||||
+22
-2
@@ -118,7 +118,7 @@ pub struct TrayAppSection {
|
||||
pub const AUTO_SUFFIX: &str = "auto";
|
||||
pub const TRAY_ID: &str = "cc-switch";
|
||||
|
||||
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
pub const TRAY_SECTIONS: [TrayAppSection; 4] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
@@ -140,6 +140,13 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
header_label: "Gemini",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::GrokBuild,
|
||||
prefix: "grokbuild_",
|
||||
empty_id: "grokbuild_empty",
|
||||
header_label: "Grok Build",
|
||||
log_name: "Grok Build",
|
||||
},
|
||||
];
|
||||
|
||||
/// 配色阈值(与前端 `utilizationColor` 语义一致)。
|
||||
@@ -1072,7 +1079,8 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
|
||||
use super::{format_script_summary, format_subscription_summary, TRAY_ID, TRAY_SECTIONS};
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
|
||||
@@ -1086,6 +1094,18 @@ mod tests {
|
||||
assert_ne!(TRAY_ID, "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_sections_include_grokbuild_provider_switching() {
|
||||
let section = TRAY_SECTIONS
|
||||
.iter()
|
||||
.find(|section| section.app_type == AppType::GrokBuild)
|
||||
.expect("Grok Build tray section should exist");
|
||||
|
||||
assert_eq!(section.prefix, "grokbuild_");
|
||||
assert_eq!(section.empty_id, "grokbuild_empty");
|
||||
assert_eq!(section.header_label, "Grok Build");
|
||||
}
|
||||
|
||||
fn make_quota(tool: &str, success: bool, tiers: Vec<QuotaTier>) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: tool.to_string(),
|
||||
|
||||
@@ -6,6 +6,14 @@ use cc_switch_lib::AppType;
|
||||
fn parse_known_apps_case_insensitive_and_trim() {
|
||||
assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude)));
|
||||
assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex)));
|
||||
assert!(matches!(
|
||||
AppType::from_str("grokbuild"),
|
||||
Ok(AppType::GrokBuild)
|
||||
));
|
||||
assert!(matches!(
|
||||
AppType::from_str("Grok-Build"),
|
||||
Ok(AppType::GrokBuild)
|
||||
));
|
||||
assert!(matches!(
|
||||
AppType::from_str(" ClAuDe \n"),
|
||||
Ok(AppType::Claude)
|
||||
|
||||
@@ -748,6 +748,7 @@ command = "echo"
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -877,6 +878,7 @@ fn import_from_claude_merges_into_config() {
|
||||
claude: false, // 初始未启用
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -227,6 +227,7 @@ command = "echo"
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -371,6 +372,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -436,6 +438,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -481,6 +484,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -515,6 +519,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -648,6 +653,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -703,6 +709,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -758,6 +765,7 @@ fn explicit_default_claude_dir_keeps_default_split_mcp_path() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -814,6 +822,7 @@ fn custom_claude_dir_writes_mcp_inside_config_dir() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -893,6 +902,7 @@ fn custom_claude_dir_sync_does_not_copy_default_profile() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1032,6 +1042,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1054,6 +1065,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,81 @@ fn settings_path(home: &Path) -> PathBuf {
|
||||
home.join(".cc-switch").join("settings.json")
|
||||
}
|
||||
|
||||
fn grokbuild_config(name: &str, endpoint: &str, api_key: &str) -> String {
|
||||
format!(
|
||||
r#"[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
base_url = "{endpoint}"
|
||||
name = "{name}"
|
||||
api_key = "{api_key}"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_import_and_switch_write_live_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let live_path = home.join(".grok").join("config.toml");
|
||||
std::fs::create_dir_all(live_path.parent().expect("grok config dir"))
|
||||
.expect("create grok config dir");
|
||||
let imported_config = grokbuild_config("Imported", "https://old.example/v1", "old-key");
|
||||
std::fs::write(&live_path, &imported_config).expect("seed Grok Build config");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
import_default_config_test_hook(&state, AppType::GrokBuild)
|
||||
.expect("import Grok Build default provider");
|
||||
|
||||
let imported = state
|
||||
.db
|
||||
.get_provider_by_id("default", AppType::GrokBuild.as_str())
|
||||
.expect("query imported provider")
|
||||
.expect("imported provider exists");
|
||||
assert_eq!(
|
||||
imported
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some(imported_config.as_str())
|
||||
);
|
||||
|
||||
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
|
||||
state
|
||||
.db
|
||||
.save_provider(
|
||||
AppType::GrokBuild.as_str(),
|
||||
&Provider::with_id(
|
||||
"relay".to_string(),
|
||||
"Relay".to_string(),
|
||||
json!({ "config": next_config }),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.expect("save second Grok Build provider");
|
||||
|
||||
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
|
||||
.expect("switch Grok Build provider");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&live_path).expect("read switched Grok Build config"),
|
||||
next_config
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_provider(AppType::GrokBuild.as_str())
|
||||
.expect("read Grok Build current provider")
|
||||
.as_deref(),
|
||||
Some("relay")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -300,6 +375,7 @@ command = "say"
|
||||
claude: false,
|
||||
codex: true, // 启用 Codex
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -164,6 +164,7 @@ command = "say"
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -934,6 +935,7 @@ fn reapply_codex_official_live_resyncs_mcp_servers() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1032,6 +1034,7 @@ fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1124,6 +1127,7 @@ fn switch_codex_projects_mcp_despite_broken_claude_json() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1187,6 +1191,7 @@ fn sync_all_enabled_reports_broken_app_but_projects_the_rest() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ pub fn reset_test_fs() {
|
||||
".codex",
|
||||
".cc-switch",
|
||||
".gemini",
|
||||
".grok",
|
||||
".config",
|
||||
".openclaw",
|
||||
"profiles",
|
||||
|
||||
Reference in New Issue
Block a user