fix(#2338): prevent known model + Auto effort from falling through to auto row

The whale-route fallback in the picker constructor used show_custom_model_row
as the gate for selecting the 'auto' vs custom row, but a known DeepSeek model
(e.g. v4-pro) paired with ReasoningEffort::Auto would not match any whale route
yet still have show_custom_model_row=false — silently landing on the auto row
and replacing the explicit model with 'auto' on apply.

Key the fallback on whether the initial model is actually 'auto' instead.
When a whale-route fallback selects the custom row, ensure show_custom_model_row
is set to true so the row is visible in the picker UI.

Also:
- Add regression test: known-model + Auto effort must not fall to auto row.
- Clean up picker_auto_model_forces_auto_effort_on_apply: remove manual
  mutations of selected_model_idx / selected_effort_idx which whale-route
  mode never reads.
- Rename Porpoise → Beluga per #2016, which excludes porpoises from the
  user-facing whale pool.
This commit is contained in:
Justin Gao
2026-05-31 13:42:57 +08:00
parent 2ed13999b3
commit 6df08a3dc2
2 changed files with 42 additions and 22 deletions
+36 -16
View File
@@ -2,7 +2,7 @@
//!
//! For DeepSeek providers the picker shows whale-sized routes — model + effort
//! combinations sorted largest → fastest with friendly whale-species labels
//! (Blue Whale, Fin Whale, …, Porpoise). A single ↑/↓ selection sets both
//! (Blue Whale, Fin Whale, …, Beluga). A single ↑/↓ selection sets both
//! model and effort at once. The "auto" option is always available; custom
//! (unrecognised) model ids appear as a separate row.
//!
@@ -110,23 +110,30 @@ impl ModelPickerView {
// When showing whale routes, find the matching route by position in the array
// (not by sort_order, which happens to match today but is semantically wrong).
let selected_route_idx = if show_whale_routes {
WHALE_ROUTES
let (selected_route_idx, show_custom_model_row) = if show_whale_routes {
let idx = WHALE_ROUTES
.iter()
.position(|r| {
r.model.eq_ignore_ascii_case(&initial_model) && r.effort == normalized
})
.unwrap_or_else(|| {
// No matching whale route — fall back to "auto" (standard model)
// or the custom row (unrecognized model).
if show_custom_model_row {
WHALE_ROUTES.len() + 1 // custom model row
} else {
// No matching whale route — key the fallback on whether the
// current model is actually "auto", not on show_custom_model_row.
// Otherwise a known DeepSeek model (e.g. v4-pro) paired with
// ReasoningEffort::Auto silently falls through to the "auto" row
// and replaces the explicit model on apply.
if initial_model.eq_ignore_ascii_case("auto") {
WHALE_ROUTES.len() // "auto" row
} else {
WHALE_ROUTES.len() + 1 // custom model row
}
})
});
// When the whale-route fallback selected the custom row, ensure it is
// visible so the user can see their current model in the picker.
let show_custom = show_custom_model_row || idx == WHALE_ROUTES.len() + 1;
(idx, show_custom)
} else {
0
(0, show_custom_model_row)
};
Self {
@@ -621,12 +628,7 @@ mod tests {
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Off;
let mut view = ModelPickerView::new(&app);
view.selected_model_idx = 0;
view.selected_effort_idx = PICKER_EFFORTS
.iter()
.position(|effort| *effort == ReasoningEffort::Max)
.expect("max effort row");
let view = ModelPickerView::new(&app);
assert_eq!(view.resolved_model(), "auto");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
@@ -747,6 +749,24 @@ mod tests {
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
}
#[test]
fn whale_routes_known_model_auto_effort_does_not_fall_to_auto() {
// Regression: a known DeepSeek model paired with ReasoningEffort::Auto
// must NOT fall through to the "auto" row — that would silently replace
// the explicit model with "auto" on apply.
let (mut app, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Auto;
let view = ModelPickerView::new(&app);
// Should fall to custom row (WHALE_ROUTES.len() + 1), not auto row.
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1);
assert_eq!(view.resolved_model(), "deepseek-v4-pro");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
// The custom row must be visible so the user sees their current model.
assert!(view.show_custom_model_row);
}
#[test]
fn whale_routes_auto_effort_maps_to_fallback_row() {
let (mut app, _lock) = create_test_app();
+6 -6
View File
@@ -12,7 +12,7 @@
//! 3. Sperm Whale — Pro + no thinking
//! 4. Humpback — Flash + max thinking
//! 5. Minke Whale — Flash + high thinking
//! 6. Porpoise — Flash + no thinking (smallest, fastest)
//! 6. Beluga — Flash + no thinking (smallest, fastest)
//!
//! Unknown or non-DeepSeek models fall back to the raw model id without
//! fake whale labeling.
@@ -80,7 +80,7 @@ pub const WHALE_ROUTES: &[WhaleRoute] = &[
description: "Fast model, moderate reasoning — tool execution, read-only scouting",
},
WhaleRoute {
label: "Porpoise",
label: "Beluga",
model: "deepseek-v4-flash",
effort: ReasoningEffort::Off,
sort_order: 5,
@@ -135,10 +135,10 @@ mod tests {
}
#[test]
fn lookup_porpoise_for_flash_off() {
fn lookup_beluga_for_flash_off() {
let route = WhaleRoute::for_model_effort("deepseek-v4-flash", ReasoningEffort::Off)
.expect("porpoise route exists");
assert_eq!(route.label, "Porpoise");
.expect("beluga route exists");
assert_eq!(route.label, "Beluga");
assert_eq!(route.sort_order, 5);
}
@@ -163,7 +163,7 @@ mod tests {
#[test]
fn by_sort_order_finds_correct_routes() {
assert_eq!(WhaleRoute::by_sort_order(0).unwrap().label, "Blue Whale");
assert_eq!(WhaleRoute::by_sort_order(5).unwrap().label, "Porpoise");
assert_eq!(WhaleRoute::by_sort_order(5).unwrap().label, "Beluga");
assert!(WhaleRoute::by_sort_order(99).is_none());
}