fix(grokbuild): keep provider model table selected when backfilling live config

Grok Build's /settings "Default model" rewrites models.default in
~/.grok/config.toml. Selecting a built-in model while a third-party
provider was active left the provider's own [model.*] table
unreferenced; the switch backfill stored that config verbatim, and
switching back then failed validation with "missing [model.\"...\"]".

When models.default is missing or dangling during backfill and the
provider's original default table is still present, restore it.
This commit is contained in:
Jason committed 2026-09-26 21:51:48 +08:00
1 parent 1ee2fdc3a7
commit f33f9c41cb
3 files changed
+178

No files matched your search

+94
View File
@@ -305,6 +305,51 @@ pub fn base_url_matches(config_toml: &str, predicate: impl FnOnce(&str) -> bool)
extract_model_config(config_toml).is_some_and(|config| predicate(&config.base_url))
}
/// Re-select the provider's own model table in a live config being backfilled.
///
/// Grok Build 的 `/settings` → Default model 会改写 live 的 `models.default`
/// (例如改成内置的 grok-4.6,或选 "no override" 清空),供应商自己的
/// `[model.*]` 表随之没人指向。回填若原样存进供应商行,下次切回时过不了
/// `validate_config_toml`,这个供应商就切不回去。live 的 `models.default`
/// 缺失或指不到表、而供应商行原来的默认模型表还在 live 里时,换回行里的值;
/// 其余情况(含官方态)返回 None,保持原样。
pub fn restore_provider_default_model(live_config: &str, provider_config: &str) -> Option<String> {
let provider_default = provider_config
.parse::<toml::Value>()
.ok()?
.get("models")?
.get("default")?
.as_str()?
.trim()
.to_string();
if provider_default.is_empty() {
return None;
}
let mut document = live_config.parse::<toml_edit::DocumentMut>().ok()?;
let has_model_table = |document: &toml_edit::DocumentMut, name: &str| {
document
.get("model")
.and_then(|models| models.get(name))
.is_some_and(toml_edit::Item::is_table_like)
};
let live_default_resolves = document
.get("models")
.and_then(|models| models.get("default"))
.and_then(toml_edit::Item::as_str)
.is_some_and(|name| has_model_table(&document, name.trim()));
if live_default_resolves || !has_model_table(&document, &provider_default) {
return None;
}
document
.entry("models")
.or_insert(toml_edit::table())
.as_table_like_mut()?
.insert("default", toml_edit::value(provider_default));
Some(document.to_string())
}
/// 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> {
@@ -484,6 +529,55 @@ context_window = 500000
assert!(error.to_string().contains("model"));
}
#[test]
fn backfill_restores_provider_default_after_client_selected_builtin_model() {
let live = valid_config().replace("default = \"grok-4.5\"", "default = \"grok-4.6\"");
let restored =
restore_provider_default_model(&live, valid_config()).expect("default restored");
assert_eq!(restored, valid_config());
validate_config_toml(&restored).expect("restored config validates");
}
#[test]
fn backfill_restores_provider_default_after_client_cleared_it() {
for live in [
valid_config().replace("default = \"grok-4.5\"\n", ""),
valid_config().replace("[models]\ndefault = \"grok-4.5\"\n\n", ""),
] {
let restored =
restore_provider_default_model(&live, valid_config()).expect("default restored");
validate_config_toml(&restored).expect("restored config validates");
assert_eq!(
extract_model_config(&restored).map(|config| config.profile),
Some("grok-4.5".to_string())
);
}
}
#[test]
fn backfill_leaves_live_default_alone_when_it_resolves_or_cannot_be_repaired() {
assert_eq!(
restore_provider_default_model(valid_config(), valid_config()),
None
);
let own_table = format!(
"{}\n[model.mine]\nmodel = \"m\"\n",
valid_config().replace("default = \"grok-4.5\"", "default = \"mine\"")
);
assert_eq!(
restore_provider_default_model(&own_table, valid_config()),
None
);
let builtin_default = "[models]\ndefault = \"grok-4.6\"\n";
assert_eq!(restore_provider_default_model(builtin_default, ""), None);
assert_eq!(
restore_provider_default_model(builtin_default, valid_config()),
None
);
}
#[test]
fn rejects_config_without_api_key_or_env_key() {
let config = valid_config().replace("api_key = \"secret\"\n", "");
+19
View File
@@ -1073,6 +1073,25 @@ fn restore_live_settings_for_provider_backfill(
provider.id
);
}
let restored = match (
settings.get("config").and_then(Value::as_str),
provider
.settings_config
.get("config")
.and_then(Value::as_str),
) {
(Some(live_config), Some(provider_config)) => {
crate::grok_config::restore_provider_default_model(live_config, provider_config)
}
_ => None,
};
if let Some(config) = restored {
log::info!(
"Grok Build models.default no longer selected provider '{}' table; restored it while backfilling",
provider.id
);
settings["config"] = Value::String(config);
}
return settings;
}
if !matches!(app_type, AppType::Codex) {
+65
View File
@@ -94,6 +94,71 @@ fn grokbuild_import_and_switch_write_live_config() {
);
}
/// Grok Build's `/settings` → Default model rewrites `models.default` in the live
/// config. Picking a built-in model while a third-party provider is active leaves
/// the provider's own `[model.*]` table unreferenced; switching away and back must
/// still work.
#[test]
fn grokbuild_switch_back_after_client_changed_default_model() {
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 state = create_test_state().expect("create test state");
let relay_a = grokbuild_config("RelayA", "https://a.example/v1", "key-a");
let relay_b = grokbuild_config("RelayB", "https://b.example/v1", "key-b");
for (id, name, config) in [("a", "RelayA", &relay_a), ("b", "RelayB", &relay_b)] {
state
.db
.save_provider(
AppType::GrokBuild.as_str(),
&Provider::with_id(
id.to_string(),
name.to_string(),
json!({ "config": config }),
None,
),
)
.expect("save Grok Build provider");
}
switch_provider_test_hook(&state, AppType::GrokBuild, "a").expect("switch to provider a");
assert_eq!(
std::fs::read_to_string(&live_path).expect("read live after switching to a"),
relay_a
);
// Simulate Grok's `/settings` → Default model picking the built-in grok-4.6.
let client_edited = relay_a.replace("default = \"grok-4.5\"", "default = \"grok-4.6\"");
std::fs::write(&live_path, &client_edited).expect("simulate client edit");
switch_provider_test_hook(&state, AppType::GrokBuild, "b").expect("switch to provider b");
let backfilled_a = state
.db
.get_provider_by_id("a", AppType::GrokBuild.as_str())
.expect("query provider a")
.expect("provider a exists")
.settings_config
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
.unwrap_or_default();
switch_provider_test_hook(&state, AppType::GrokBuild, "a").unwrap_or_else(|err| {
panic!("switching back to provider a failed: {err}\nbackfilled row a:\n{backfilled_a}")
});
let live = std::fs::read_to_string(&live_path).expect("read live after switching back");
assert!(
live.contains("default = \"grok-4.5\"") && live.contains("https://a.example/v1"),
"live should select provider a's own model table again, got:\n{live}"
);
}
#[test]
fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
let _guard = test_mutex().lock().expect("acquire test mutex");