fix(provider): clear the shadowed legacy root model with the root default

Review finding on #6693: moving `default_text_model` onto the outgoing
route's leaf removed only that key. `Config::load` then copies the legacy
root `model` into `default_text_model` whenever the latter is unset, so a
stale legacy value that the root default had been shadowing on every route
came back on the incoming route. With
`model = "deepseek-v4-flash"`, `default_text_model = "gpui-fixture"`,
`provider = "openai"`, switching to openrouter resolved
`deepseek/deepseek-v4-flash`.

Fix: every place the route writer removes the root alias now goes through
`unset_root_model_aliases`, which clears `default_text_model` and the legacy
root `model` together (the legacy key resolved on no route while the alias
existed). `root_model_alias_owned_by_outgoing` also treats a legacy-only
root `model` as the alias, the same fallback `default_model` applies, so a
legacy-only choice moves onto the outgoing leaf instead of being inherited.
The switch-away-and-back persistence test now expects that move for the
legacy `model` key as well.

The TUI session-local switch needed no change: it writes the resolved model
onto the incoming leaf in memory, which shadows the legacy value.

Tests (focused, CARGO_BUILD_JOBS=4):
- new: config_persistence::tests::moving_root_default_off_the_root_does_not_resurrect_a_shadowed_legacy_model
  and runtime_api::tests::switch_provider_does_not_resurrect_a_shadowed_legacy_root_model
  fail without the fix (openrouter body model "deepseek/deepseek-v4-flash"):
  test result: FAILED. 6 passed; 2 failed; 0 ignored; 0 measured; 13796 filtered out
- with the fix, filters resurrect config_persistence default_text_model alias
  route_save save_route provider_picker route_preferences provider_switch
  switch_provider root_default legacy_model:
  test result: ok. 477 passed; 0 failed; 0 ignored; 0 measured; 13326 filtered out
- filters config::tests model_inventory route_runtime runtime_api::tests::get_config provider:
  test result: ok. 1303 passed; 0 failed; 1 ignored; 0 measured; 12499 filtered out

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-27 20:34:57 -07:00
1 parent 002bbe9616
commit eb48db7588
5 files changed
+129 -10

No files matched your search

+2 -1
View File
@@ -35,7 +35,8 @@ quieter, and Fleet runs can be checked before they spend anything.
with the provider it belongs to. Switching away and back (`/provider` in the
TUI, or the desktop app's model chip) used to land on the provider's catalog
default such as `gpt-5.6`, and a pass-through provider switched to in between
inherited the other provider's model
inherited the other provider's model, or a stale legacy root `model` that the
root default had been shadowing
([#6693](https://github.com/Hmbown/Codewhale/pull/6693)).
### Contributors
+2 -1
View File
@@ -35,7 +35,8 @@ quieter, and Fleet runs can be checked before they spend anything.
with the provider it belongs to. Switching away and back (`/provider` in the
TUI, or the desktop app's model chip) used to land on the provider's catalog
default such as `gpt-5.6`, and a pass-through provider switched to in between
inherited the other provider's model
inherited the other provider's model, or a stale legacy root `model` that the
root default had been shadowing
([#6693](https://github.com/Hmbown/Codewhale/pull/6693)).
### Contributors
+12 -2
View File
@@ -6249,12 +6249,22 @@ impl Config {
/// and value: the choice belongs to the outgoing route and must follow it
/// onto its own leaf, rather than stay at the root where the incoming route
/// would inherit it and the outgoing route would forget it (falling back to
/// its catalog default on the way back).
/// its catalog default on the way back). The alias is `default_text_model`
/// or, when that is unset, the legacy root `model` it falls back to. A
/// persisted writer that moves it clears both roots
/// (`config_persistence::unset_root_model_aliases`); clearing only the
/// first would resurrect the second on the incoming route after reload.
pub(crate) fn root_model_alias_owned_by_outgoing(
&self,
incoming: &ProviderIdentity,
) -> Option<(ProviderIdentity, String)> {
let value = self.default_text_model.as_deref()?.trim();
// `default_model` reads the legacy root `model` whenever
// `default_text_model` is unset, so the effective alias is either one.
let value = self
.default_text_model
.as_deref()
.or(self.legacy_model.as_deref())?
.trim();
if value.is_empty()
|| value.eq_ignore_ascii_case("auto")
|| value.chars().any(char::is_control)
+61 -6
View File
@@ -287,14 +287,15 @@ pub(crate) fn reconcile_root_model_aliases(
outgoing.persisted_id().unwrap_or(&outgoing.key),
&value,
)?;
unset_document_value(doc, &["default_text_model"])?;
unset_root_model_aliases(doc)?;
return Ok(());
}
// Only `default_text_model` is read here. The legacy root `model` key is
// Below, only `default_text_model` is read. The legacy root `model` key is
// never what blocks a load: `Config::default_model` already refuses to
// route a foreign legacy value to a provider that cannot serve it, and
// `Config::validate` does not consult it, so relocating it would move a
// value nothing is asking about.
// value nothing is asking about. It is still cleared together with the
// alias that shadows it (`unset_root_model_aliases`).
const ROOT_KEY: &str = "default_text_model";
let Some(value) = doc
.get(ROOT_KEY)
@@ -323,7 +324,7 @@ pub(crate) fn reconcile_root_model_aliases(
// An empty or control-bearing alias names no saved model. Nothing to
// relocate, and clearing it loses nothing.
if value.trim().is_empty() || value.chars().any(char::is_control) {
unset_document_value(doc, &[ROOT_KEY])?;
unset_root_model_aliases(doc)?;
return Ok(());
}
// No leaf can hold this value. Keep the only copy of the user's choice
@@ -361,7 +362,20 @@ pub(crate) fn reconcile_root_model_aliases(
// The outgoing route either already saved its own choice or has just
// received this one, so the alias is now a shadowed duplicate that only
// blocks the incoming route.
unset_document_value(doc, &[ROOT_KEY])?;
unset_root_model_aliases(doc)?;
Ok(())
}
/// Clear the root model alias: `default_text_model` and the legacy root
/// `model` that `Config::default_model` falls back to when the former is
/// unset. While `default_text_model` is present the legacy key resolves on no
/// route, so removing only the alias would resurrect a stale legacy model on
/// the incoming route (a pass-through route would then use a model nobody
/// chose for it). When the legacy key *is* the alias, the caller has already
/// moved its value onto the outgoing route's leaf.
fn unset_root_model_aliases(doc: &mut toml_edit::DocumentMut) -> anyhow::Result<()> {
unset_document_value(doc, &["default_text_model"])?;
unset_document_value(doc, &["model"])?;
Ok(())
}
@@ -1839,6 +1853,47 @@ slot = 1
);
}
#[test]
fn moving_root_default_off_the_root_does_not_resurrect_a_shadowed_legacy_model() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _model_guard = ModelEnvGuard::new();
let path = home.path().join("config.toml");
// `default_text_model` shadows the legacy root `model` on every route.
// Moving the alias onto openai's leaf must not un-shadow the legacy
// value for the pass-through incoming route.
fs::write(
&path,
"route_preferences_version = 1\nprovider = 'openai'\nmodel = 'deepseek-v4-flash'\ndefault_text_model = 'gpui-fixture'\n",
)
.unwrap();
persist_provider_selection(Some(&path), ApiProvider::Openrouter, "openrouter", None)
.unwrap();
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(doc.get("default_text_model").is_none(), "{doc:?}");
assert!(doc.get("model").is_none(), "{doc:?}");
assert_eq!(
doc["providers"]["openai"]["model"].as_str(),
Some("gpui-fixture")
);
let switched = crate::config::Config::load(Some(path.clone()), None).unwrap();
assert_eq!(switched.api_provider(), ApiProvider::Openrouter);
// OpenRouter normalizes the stale value to `deepseek/deepseek-v4-flash`.
assert!(
!switched.default_model().contains("deepseek-v4-flash"),
"{}",
switched.default_model()
);
assert_ne!(switched.default_model(), "gpui-fixture");
persist_provider_selection(Some(&path), ApiProvider::Openai, "openai", None).unwrap();
let returned = crate::config::Config::load(Some(path.clone()), None).unwrap();
assert_eq!(returned.api_provider(), ApiProvider::Openai);
assert_eq!(returned.default_model(), "gpui-fixture");
}
#[test]
fn switching_provider_away_and_back_preserves_every_route_selection() {
use crate::test_support::{EnvVarGuard, lock_test_env};
@@ -1868,7 +1923,7 @@ slot = 1
// had no leaf; then the choice moves onto that leaf. Otherwise
// the alias is inert saved state and stays. Neither case may
// delete it.
if root_key == "default_text_model" && existing.is_none() {
if existing.is_none() {
assert!(doc.get(root_key).is_none(), "{doc:?}");
assert_eq!(
doc["providers"]["zai"]["model"].as_str(),
+52
View File
@@ -23471,3 +23471,55 @@ api_key = "sk-or-test"
handle.abort();
Ok(())
}
#[tokio::test]
async fn switch_provider_does_not_resurrect_a_shadowed_legacy_root_model() -> Result<()> {
// Review regression (#6693): the legacy root `model` was shadowed by
// `default_text_model`. Moving only the alias off the root let the
// pass-through openrouter route resolve `deepseek-v4-flash`.
let root = std::env::temp_dir().join(format!("codewhale-switch-legacy-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
r#"model = "deepseek-v4-flash"
default_text_model = "gpui-fixture"
provider = "openai"
[providers.openai]
api_key = "sk-test"
base_url = "http://127.0.0.1:9/v1"
[providers.openrouter]
api_key = "sk-or-test"
"#,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let (status, away) =
post_switch_provider(&client, &addr, "openrouter", &serde_json::json!({})).await;
assert_eq!(status, StatusCode::OK, "body: {away}");
// OpenRouter normalizes the stale value to `deepseek/deepseek-v4-flash`.
assert!(
!away["model"]
.as_str()
.unwrap_or_default()
.contains("deepseek-v4-flash"),
"body: {away}"
);
assert_ne!(away["model"].as_str(), Some("gpui-fixture"), "body: {away}");
let (status, back) =
post_switch_provider(&client, &addr, "openai", &serde_json::json!({})).await;
assert_eq!(status, StatusCode::OK, "body: {back}");
assert_eq!(back["model"].as_str(), Some("gpui-fixture"), "body: {back}");
handle.abort();
Ok(())
}