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:
Thefool
2026-07-17 15:50:50 +08:00
committed by GitHub
parent f6e37ed994
commit 1c0ee0c58a
112 changed files with 4530 additions and 246 deletions
+2
View File
@@ -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, &current_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
+39 -1
View 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 导入 MCPv3.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)),
];
+62 -7
View File
@@ -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\""));
}
}
+54 -8
View File
@@ -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)?;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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"),
+11 -3
View File
@@ -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]