mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
002bbe9616
commit
eb48db7588
5 files changed
+129
-10
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user