refactor(routing): Auto fallback is the declared default, not a content heuristic (determinism audit batch 2, #2)

Without the flash classifier the router guessed cheap-vs-big from request
wording (COMPLEX_KEYWORDS plus char-length thresholds) — host-side semantic
determinism that made cost and quality depend on vocabulary. The fallback is
now declared: the configured default model, with the explicit [auto]
cost_saving opt-in pinning the runnable fast sibling. Request text is never
inspected. The flash-classifier path is untouched.

Receipts stay honest: new routes record LocalFallback(DeclaredDefault);
content-derived reasons are never constructed again but retained so saved
sessions deserialize (plus a serde alias on the renamed wrapper and a test
pinning the pre-rework shape). TurnRoutingSource gains auto-local-fallback;
the now-unused display_text planner input is deleted with its 4 call sites.

Tests: model_routing 32 passed 0 failed; planner/receipt/preview neighbors
44 passed 0 failed; tools::subagent 672 passed with only the known
pre-existing 5305 failure (fails on clean HEAD too). Clippy --all-targets,
fmt, dead-code budget clean.
This commit is contained in:
CodeWhale Bot committed 2026-09-17 16:20:13 -07:00
1 parent bf1b20943e
commit 72b028e5ee
10 files changed
+214 -425

No files matched your search

@@ -115,8 +115,8 @@ fn save_preserves_latest_auto_route_receipt() {
},
scope: crate::model_routing::AutoRouteScope::ResolvedProvider,
data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic,
reason: crate::model_routing::AutoRouteReason::LocalHeuristic(
crate::model_routing::AutoRouteHeuristicReason::ShortRequest,
reason: crate::model_routing::AutoRouteReason::LocalFallback(
crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault,
),
};
app.set_model_selection("auto".to_string());
+1 -1
View File
@@ -4754,7 +4754,7 @@ impl Config {
/// Return `true` only when `[auto] cross_provider = true` is persisted in
/// config (#4411). Auto mode otherwise stays on the active provider: the
/// classifier never sees other providers' routes, and the local heuristic
/// classifier never sees other providers' routes, and the local fallback
/// never selects one. There is no interactive toggle — enabling
/// cross-provider Auto is an explicit, durable config edit.
#[must_use]
@@ -792,7 +792,6 @@ async fn plan_with_reasoning(
reasoning_effort,
mode: AppMode::Agent,
content: prompt,
display_text: prompt,
auto_router_context: "",
should_auto_resolve: auto_model,
allow_auto_router_response_cache: false,
+191 -394
View File
@@ -22,9 +22,9 @@ use codewhale_models::{ContentBlock, Message, MessageRequest, MessageResponse, S
/// Big/cheap model pair the auto-router may choose between for the active
/// provider (#3018).
///
/// `cheap == None` means the provider has no known cheap tier: heuristics
/// stay on the current model (only thinking effort varies) and the network
/// router is skipped entirely (#1549).
/// `cheap == None` means the provider has no known cheap tier: the local
/// fallback stays on the current model (only thinking effort varies) and the
/// network router is skipped entirely (#1549).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RouterCandidates {
pub(crate) big: String,
@@ -38,11 +38,6 @@ impl RouterCandidates {
cheap: Some("deepseek-v4-flash".to_string()),
}
}
/// The cheap-tier id, falling back to `big` when no cheap tier exists.
pub(crate) fn cheap_or_big(&self) -> &str {
self.cheap.as_deref().unwrap_or(&self.big)
}
}
/// Return a provider-owned strong/fast pair for model families whose catalog
@@ -218,133 +213,6 @@ pub(crate) fn provider_router_candidates(
}
}
/// Auto-select a model based on request complexity.
///
/// Short messages (<100 chars) go to the cheap tier. Long messages and
/// requests with complex keywords go to the big tier. The fallback is cheap.
/// This DeepSeek-candidate wrapper keeps legacy callers and tests intact;
/// provider-aware callers use [`auto_model_heuristic_for_candidates`].
pub(crate) fn auto_model_heuristic(input: &str, current_model: &str) -> String {
auto_model_heuristic_for_candidates(input, current_model, &RouterCandidates::deepseek())
}
/// Candidate-aware variant of [`auto_model_heuristic`] (#3018).
pub(crate) fn auto_model_heuristic_for_candidates(
input: &str,
current_model: &str,
candidates: &RouterCandidates,
) -> String {
auto_model_heuristic_with_bias_for_candidates(input, current_model, false, candidates).model
}
#[cfg(test)]
fn auto_model_heuristic_with_bias(input: &str, current_model: &str, cost_saving: bool) -> String {
auto_model_heuristic_with_bias_for_candidates(
input,
current_model,
cost_saving,
&RouterCandidates::deepseek(),
)
.model
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AutoRouteHeuristicDecision {
model: String,
reason: AutoRouteHeuristicReason,
}
fn auto_model_heuristic_with_bias_for_candidates(
input: &str,
_current_model: &str,
cost_saving: bool,
candidates: &RouterCandidates,
) -> AutoRouteHeuristicDecision {
let len = input.chars().count();
let lower = input.to_lowercase();
let borderline_pro_keywords: &[&str] = &[
"implement",
"analyze",
"\u{5b9e}\u{73b0}",
"\u{5206}\u{6790}",
"\u{5be6}\u{73fe}",
];
let strong_match = COMPLEX_KEYWORDS
.iter()
.any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw));
let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw));
let pro_match = strong_match || (!cost_saving && borderline_match);
if pro_match {
return AutoRouteHeuristicDecision {
model: candidates.big.clone(),
reason: AutoRouteHeuristicReason::ComplexRequest,
};
}
if len < 100 {
return AutoRouteHeuristicDecision {
model: candidates.cheap_or_big().to_string(),
reason: if cost_saving && borderline_match {
AutoRouteHeuristicReason::CostSavingPolicy
} else {
AutoRouteHeuristicReason::ShortRequest
},
};
}
let long_threshold = if cost_saving { 1_000 } else { 500 };
if len > long_threshold {
return AutoRouteHeuristicDecision {
model: candidates.big.clone(),
reason: AutoRouteHeuristicReason::LongRequest,
};
}
AutoRouteHeuristicDecision {
model: candidates.cheap_or_big().to_string(),
reason: if cost_saving && borderline_match {
AutoRouteHeuristicReason::CostSavingPolicy
} else {
AutoRouteHeuristicReason::RoutineRequest
},
}
}
const COMPLEX_KEYWORDS: &[&str] = &[
"refactor",
"architecture",
"design",
"debug",
"security",
"review",
"audit",
"migrate",
"optimize",
"rewrite",
"implement",
"analyze",
"\u{91cd}\u{6784}",
"\u{67b6}\u{6784}",
"\u{8bbe}\u{8ba1}",
"\u{8c03}\u{8bd5}",
"\u{5b89}\u{5168}",
"\u{5ba1}\u{67e5}",
"\u{5ba1}\u{8ba1}",
"\u{8fc1}\u{79fb}",
"\u{4f18}\u{5316}",
"\u{91cd}\u{5199}",
"\u{5b9e}\u{73b0}",
"\u{5206}\u{6790}",
"\u{91cd}\u{69cb}",
"\u{67b6}\u{69cb}",
"\u{8a2d}\u{8a08}",
"\u{8abf}\u{8a66}",
"\u{5be9}\u{67e5}",
"\u{5be9}\u{8a08}",
"\u{9077}\u{79fb}",
"\u{512a}\u{5316}",
"\u{91cd}\u{5beb}",
"\u{5be6}\u{73fe}",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutoRouteSource {
FlashRouter,
@@ -398,7 +266,7 @@ pub(crate) enum AutoRouteScope {
/// The network classifier saw only the active provider's runnable routes —
/// the default Auto scope (#4411).
ActiveProvider,
/// The provider-aware local heuristic selected within one resolved route.
/// The local declared fallback selected within one resolved route.
ResolvedProvider,
}
@@ -438,16 +306,34 @@ impl AutoRouteDataPath {
}
/// Local signal that selected the provider-safe strong/fast candidate.
///
/// Since the #6290 rework the local fallback never judges request content:
/// without the flash classifier there is no per-request signal, so the route
/// is the configured default (or the runnable fast sibling under the explicit
/// `[auto] cost_saving` opt-in). The content-derived variants below are never
/// constructed for new routes; they are retained so saved sessions from before
/// the rework still deserialize.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AutoRouteHeuristicReason {
/// Legacy: the deleted keyword/length classifier judged the request
/// complex. Retained for saved-session serde compat only.
ComplexRequest,
/// Legacy: the deleted length rule judged the request short. Retained
/// for saved-session serde compat only.
ShortRequest,
/// Legacy: the deleted length rule judged the request long. Retained
/// for saved-session serde compat only.
LongRequest,
CostSavingPolicy,
/// Legacy: the deleted classifier judged the request routine. Retained
/// for saved-session serde compat only.
RoutineRequest,
NoFastSibling,
NoRunnableCandidate,
/// The configured default model: no classifier was available and no
/// content signal was consulted.
DeclaredDefault,
}
impl AutoRouteHeuristicReason {
@@ -461,6 +347,7 @@ impl AutoRouteHeuristicReason {
Self::RoutineRequest => "routine request",
Self::NoFastSibling => "no runnable fast sibling",
Self::NoRunnableCandidate => "no runnable inventory candidate",
Self::DeclaredDefault => "configured default (no classifier)",
}
}
}
@@ -472,7 +359,10 @@ impl AutoRouteHeuristicReason {
#[serde(rename_all = "snake_case")]
pub(crate) enum AutoRouteReason {
ClassifierRecommendation,
LocalHeuristic(AutoRouteHeuristicReason),
/// The `local_heuristic` alias keeps sessions saved before the #6290
/// rework loadable; new sessions persist `local_fallback`.
#[serde(alias = "local_heuristic")]
LocalFallback(AutoRouteHeuristicReason),
ClassifierFallback(AutoRouteHeuristicReason),
}
@@ -481,7 +371,7 @@ impl AutoRouteReason {
pub(crate) fn label(self) -> String {
match self {
Self::ClassifierRecommendation => "classifier recommendation".to_string(),
Self::LocalHeuristic(reason) => format!("local heuristic: {}", reason.label()),
Self::LocalFallback(reason) => format!("local fallback: {}", reason.label()),
Self::ClassifierFallback(reason) => {
format!("classifier fallback: {}", reason.label())
}
@@ -683,17 +573,17 @@ pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_poli
) -> Result<AutoRouteSelection> {
let inventory = ModelInventory::from_config(config);
if !inventory.router_available {
// Fall back to heuristic-only auto routing when the flash router
// Fall back to declared-default auto routing when the flash router
// is unavailable (e.g. non-DeepSeek providers like wanjie-ark).
return Ok(normalize_auto_route_selection_for_config(
config,
auto_route_from_inventory_heuristic(config, latest_request, &inventory),
auto_route_declared_fallback(config, &inventory),
));
}
let heuristic = auto_route_from_inventory_heuristic(config, latest_request, &inventory);
let fallback = auto_route_declared_fallback(config, &inventory);
if cfg!(test) {
return Ok(normalize_auto_route_selection_for_config(config, heuristic));
return Ok(normalize_auto_route_selection_for_config(config, fallback));
}
let selection = match auto_route_inventory_recommendation(
@@ -708,11 +598,11 @@ pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_poli
)
.await
{
Ok(attempt) => auto_route_from_classifier_attempt(heuristic, &inventory, attempt),
Ok(attempt) => auto_route_from_classifier_attempt(fallback, &inventory, attempt),
// Client construction/preparation failed before a provider request was
// admitted. There is no provider usage to invent and no dropped
// response receipt to claim.
Err(_) => auto_route_classifier_fallback(heuristic, &inventory),
Err(_) => auto_route_classifier_fallback(fallback, &inventory),
};
Ok(normalize_auto_route_selection_for_config(config, selection))
}
@@ -812,11 +702,16 @@ fn explicit_model_matches_candidate(
.is_some_and(|model| candidate.model.eq_ignore_ascii_case(&model))
}
fn auto_route_from_inventory_heuristic(
config: &Config,
latest_request: &str,
inventory: &ModelInventory,
) -> AutoRouteSelection {
/// Declared local fallback for Auto routing when the flash classifier is
/// unavailable (or fails): the configured default model.
///
/// There is no per-request signal here by design. Until the #6290 rework this
/// guessed cheap-vs-big from request wording (`COMPLEX_KEYWORDS` plus
/// char-length thresholds) — host-side semantic determinism that made cost
/// and quality depend on vocabulary. The only content-blind override is the
/// explicit `[auto] cost_saving` opt-in, which pins the runnable fast
/// sibling; providers without one stay on the default.
fn auto_route_declared_fallback(config: &Config, inventory: &ModelInventory) -> AutoRouteSelection {
let Some(active) = inventory.active_default() else {
let model = config.default_model();
return AutoRouteSelection {
@@ -827,7 +722,7 @@ fn auto_route_from_inventory_heuristic(
&model,
AutoRouteScope::ResolvedProvider,
AutoRouteDataPath::LocalHeuristic,
AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate),
AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate),
)),
model,
reasoning_effort: Some(crate::auto_reasoning::select()),
@@ -837,37 +732,40 @@ fn auto_route_from_inventory_heuristic(
routed_usage_dropped_records: 0,
};
};
// Use the candidates' cheap/big info for complexity-based routing.
let router_candidates = provider_router_candidates(active.provider, &active.model);
let fast_is_runnable = router_candidates.cheap.as_deref().is_some_and(|model| {
let runnable_fast = router_candidates.cheap.as_deref().filter(|model| {
inventory
.candidate(active.provider, model)
.is_some_and(|candidate| candidate.readiness.can_attempt())
});
let decision = if fast_is_runnable {
auto_model_heuristic_with_bias_for_candidates(
latest_request,
&active.model,
config.auto_cost_saving(),
&router_candidates,
)
} else {
AutoRouteHeuristicDecision {
model: active.model.clone(),
reason: AutoRouteHeuristicReason::NoFastSibling,
let (model, reason) = if config.auto_cost_saving() {
match runnable_fast {
Some(cheap) => (
cheap.to_string(),
AutoRouteHeuristicReason::CostSavingPolicy,
),
None => (
active.model.clone(),
AutoRouteHeuristicReason::NoFastSibling,
),
}
} else {
(
active.model.clone(),
AutoRouteHeuristicReason::DeclaredDefault,
)
};
AutoRouteSelection {
provider: active.provider,
receipt: Some(auto_route_receipt(
inventory,
active.provider,
&decision.model,
&model,
AutoRouteScope::ResolvedProvider,
AutoRouteDataPath::LocalHeuristic,
AutoRouteReason::LocalHeuristic(decision.reason),
AutoRouteReason::LocalFallback(reason),
)),
model: decision.model,
model,
reasoning_effort: Some(crate::auto_reasoning::select()),
source: AutoRouteSource::Heuristic,
routed_usage: Vec::new(),
@@ -911,7 +809,7 @@ fn auto_route_from_classifier(
}
fn auto_route_from_classifier_attempt(
heuristic: AutoRouteSelection,
fallback: AutoRouteSelection,
inventory: &ModelInventory,
attempt: InventoryAutoRouteAttempt,
) -> AutoRouteSelection {
@@ -922,7 +820,7 @@ fn auto_route_from_classifier_attempt(
routed_usage_dropped_records,
} = attempt;
let mut selection = recommendation.map_or_else(
|| auto_route_classifier_fallback(heuristic, inventory),
|| auto_route_classifier_fallback(fallback, inventory),
|recommendation| auto_route_from_classifier(inventory, recommendation),
);
selection.routed_usage = routed_usage;
@@ -932,22 +830,22 @@ fn auto_route_from_classifier_attempt(
}
fn auto_route_classifier_fallback(
mut heuristic: AutoRouteSelection,
mut fallback: AutoRouteSelection,
inventory: &ModelInventory,
) -> AutoRouteSelection {
if let Some(receipt) = heuristic.receipt.as_mut() {
let heuristic_reason = match receipt.reason {
AutoRouteReason::LocalHeuristic(reason)
if let Some(receipt) = fallback.receipt.as_mut() {
let fallback_reason = match receipt.reason {
AutoRouteReason::LocalFallback(reason)
| AutoRouteReason::ClassifierFallback(reason) => reason,
AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::RoutineRequest,
AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::DeclaredDefault,
};
receipt.data_path = AutoRouteDataPath::Classifier {
provider: inventory.router_provider,
model: inventory.router_model.to_string(),
};
receipt.reason = AutoRouteReason::ClassifierFallback(heuristic_reason);
receipt.reason = AutoRouteReason::ClassifierFallback(fallback_reason);
}
heuristic
fallback
}
fn auto_route_receipt(
@@ -1639,10 +1537,10 @@ mod tests {
assert_eq!(transport.routed_usage_drop_records.len(), 1);
assert!(transport.routed_usage.is_empty());
let heuristic = auto_route_from_inventory_heuristic(&config, "quick status", &inventory);
let valid = auto_route_from_classifier_attempt(heuristic.clone(), &inventory, valid);
let invalid = auto_route_from_classifier_attempt(heuristic.clone(), &inventory, invalid);
let incomplete = auto_route_from_classifier_attempt(heuristic, &inventory, incomplete);
let fallback = auto_route_declared_fallback(&config, &inventory);
let valid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, valid);
let invalid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, invalid);
let incomplete = auto_route_from_classifier_attempt(fallback, &inventory, incomplete);
assert_eq!(valid.source, AutoRouteSource::FlashRouter);
for fallback in [&invalid, &incomplete] {
assert_eq!(fallback.source, AutoRouteSource::Heuristic);
@@ -1736,53 +1634,6 @@ mod tests {
);
}
#[test]
fn auto_model_heuristic_chinese_keywords_route_to_pro() {
for msg in [
"\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}",
"\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}",
"\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}",
"\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}",
"\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}",
"\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}",
"\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}",
] {
assert_eq!(
auto_model_heuristic(msg, "auto"),
"deepseek-v4-pro",
"expected Pro for `{msg}`",
);
}
}
#[test]
fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() {
for msg in [
"\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}",
"\u{67b6}\u{69cb}\u{8a2d}\u{8a08}",
"\u{4ee3}\u{78bc}\u{8abf}\u{8a66}",
"\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}",
"\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}",
"\u{512a}\u{5316}\u{6027}\u{80fd}",
"\u{91cd}\u{5beb}\u{4ee3}\u{78bc}",
"\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}",
] {
assert_eq!(
auto_model_heuristic(msg, "auto"),
"deepseek-v4-pro",
"expected Pro for `{msg}`",
);
}
}
#[test]
fn auto_model_heuristic_short_chinese_chat_stays_on_flash() {
assert_eq!(
auto_model_heuristic("\u{4f60}\u{597d}", "auto"),
"deepseek-v4-flash",
);
}
#[test]
fn auto_route_prompt_uses_current_session_mode() {
let prompt = auto_route_prompt(
@@ -2135,27 +1986,35 @@ mod tests {
..Default::default()
};
let route =
resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
// #6290 rework: without the flash classifier there is no
// per-request signal, so every wording resolves the same declared
// default — the short chat and the complex ask below must agree.
for prompt in [
"quick status check",
"please refactor this architecture and audit its security boundaries",
] {
let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
.await
.expect("inventory route should resolve with authenticated active provider");
assert_eq!(route.provider, ApiProvider::Zai);
assert_eq!(route.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL);
assert_eq!(route.source, AutoRouteSource::Heuristic);
let receipt = route.receipt.expect("Auto route receipt");
assert_eq!(receipt.tier, AutoRouteTier::Fast);
assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic);
assert_eq!(
receipt.reason,
AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::ShortRequest)
);
assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL);
assert_eq!(
receipt.pair.fast.as_deref(),
Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL)
);
assert_eq!(route.provider, ApiProvider::Zai);
assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL);
assert_eq!(route.source, AutoRouteSource::Heuristic);
let receipt = route.receipt.expect("Auto route receipt");
assert_eq!(receipt.tier, AutoRouteTier::Strong);
assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic);
assert_eq!(
receipt.reason,
AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
"prompt {prompt:?} must take the declared default, not a content judgment"
);
assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL);
assert_eq!(
receipt.pair.fast.as_deref(),
Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL)
);
}
}
#[test]
@@ -2258,10 +2117,10 @@ mod tests {
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn active_provider_strong_fast_selection_survives_scoping() {
// Same-provider tier selection is the behavior scoping must not
// break: a complex request still reaches the active provider's strong
// tier, a trivial one still reaches its fast tier (#4411).
async fn active_provider_declared_default_survives_scoping() {
// #4411: scoping keeps Auto on the active provider. #6290 rework:
// without the flash classifier there is no per-request tier signal,
// so both wordings resolve the same declared default on Zai.
let _env_lock = crate::test_support::lock_test_env();
let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
@@ -2270,30 +2129,24 @@ mod tests {
..Default::default()
};
let strong = resolve_auto_route_with_inventory(
&config,
for prompt in [
"refactor the routing module and audit its security boundaries",
"",
"auto",
"auto",
)
.await
.expect("strong-tier route");
assert_eq!(strong.provider, ApiProvider::Zai);
assert_eq!(strong.model, crate::config::DEFAULT_ZAI_MODEL);
let strong_receipt = strong.receipt.expect("strong receipt");
assert_eq!(strong_receipt.tier, AutoRouteTier::Strong);
assert_eq!(strong_receipt.scope, AutoRouteScope::ResolvedProvider);
let fast = resolve_auto_route_with_inventory(&config, "hi", "", "auto", "auto")
.await
.expect("fast-tier route");
assert_eq!(fast.provider, ApiProvider::Zai);
assert_eq!(fast.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL);
assert_eq!(
fast.receipt.expect("fast receipt").tier,
AutoRouteTier::Fast
);
"hi",
] {
let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
.await
.expect("scoped Auto route");
assert_eq!(route.provider, ApiProvider::Zai);
assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL);
let receipt = route.receipt.expect("scoped receipt");
assert_eq!(receipt.tier, AutoRouteTier::Strong);
assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
assert_eq!(
receipt.reason,
AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
"prompt {prompt:?}"
);
}
}
#[test]
@@ -2352,9 +2205,9 @@ mod tests {
..Default::default()
};
let inventory = ModelInventory::from_config(&config);
let heuristic = auto_route_from_inventory_heuristic(&config, "quick status", &inventory);
let fallback = auto_route_declared_fallback(&config, &inventory);
let route = auto_route_classifier_fallback(heuristic, &inventory);
let route = auto_route_classifier_fallback(fallback, &inventory);
assert_eq!(route.source, AutoRouteSource::Heuristic);
let receipt = route.receipt.expect("fallback receipt");
@@ -2368,17 +2221,37 @@ mod tests {
));
assert_eq!(
receipt.reason,
AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::ShortRequest)
AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::DeclaredDefault)
);
assert!(!receipt.reason.label().contains("secret-provider-error"));
}
#[test]
fn pre_rework_receipt_shape_still_deserializes() {
// Sessions saved before the #6290 rework persist
// `local_heuristic` + content-derived reasons. They must keep
// loading: the wrapper arrives via serde alias, the legacy reasons
// are retained variants.
let reason: AutoRouteReason =
serde_json::from_str(r#"{"local_heuristic":"complex_request"}"#)
.expect("pre-rework receipt reason deserializes");
assert_eq!(
reason,
AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::ComplexRequest)
);
let receipt: AutoRouteReceipt = serde_json::from_str(
r#"{"tier":"fast","pair":{"strong":"GLM-5.3","fast":"GLM-5.3-Flash"},"scope":"resolved_provider","data_path":"local_heuristic","reason":{"local_heuristic":"short_request"}}"#,
)
.expect("pre-rework receipt deserializes");
assert_eq!(receipt.reason.label(), "local fallback: short request");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn inventory_auto_route_never_falls_back_across_providers_by_default() {
// #4411: the active provider has no usable credential, but another
// provider does. Auto must stay on the active provider and report a
// no-runnable-candidate heuristic instead of silently spending the
// no-runnable-candidate fallback instead of silently spending the
// other provider's key.
let _env_lock = crate::test_support::lock_test_env();
let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
@@ -2400,7 +2273,7 @@ mod tests {
assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
assert_eq!(
receipt.reason,
AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate)
AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate)
);
}
@@ -2428,13 +2301,13 @@ mod tests {
.expect("opted-in route should fall back to an authenticated provider");
assert_eq!(route.provider, ApiProvider::Deepseek);
assert_eq!(route.model, "deepseek-v4-flash");
assert_eq!(route.model, "deepseek-flash");
assert_eq!(route.source, AutoRouteSource::Heuristic);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn inventory_auto_route_cost_saving_changes_borderline_zai_route() {
async fn inventory_auto_route_cost_saving_pins_fast_sibling() {
let _env_lock = crate::test_support::lock_test_env();
let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
@@ -2485,7 +2358,7 @@ mod tests {
.map(|receipt| (receipt.tier, receipt.reason)),
Some((
AutoRouteTier::Strong,
AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::ComplexRequest),
AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
))
);
assert_eq!(
@@ -2495,7 +2368,7 @@ mod tests {
.map(|receipt| (receipt.tier, receipt.reason)),
Some((
AutoRouteTier::Fast,
AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::CostSavingPolicy),
AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::CostSavingPolicy),
))
);
}
@@ -2512,26 +2385,16 @@ mod tests {
..Default::default()
};
let route =
resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
// #6290 rework: no classifier, no content signal — both wordings
// take the declared Wanjie default.
for prompt in ["quick status check", "please refactor this architecture"] {
let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
.await
.expect("heuristic-only Wanjie route should resolve");
assert_eq!(route.provider, ApiProvider::WanjieArk);
assert_eq!(route.model, "deepseek-v4-flash");
assert_eq!(route.source, AutoRouteSource::Heuristic);
let route = resolve_auto_route_with_inventory(
&config,
"please refactor this architecture",
"",
"auto",
"auto",
)
.await
.expect("complex Wanjie route should resolve");
assert_eq!(route.provider, ApiProvider::WanjieArk);
assert_eq!(route.model, "deepseek-v4-pro");
assert_eq!(route.source, AutoRouteSource::Heuristic);
.expect("declared-default Wanjie route should resolve");
assert_eq!(route.provider, ApiProvider::WanjieArk);
assert_eq!(route.model, "deepseek-reasoner");
assert_eq!(route.source, AutoRouteSource::Heuristic);
}
}
#[tokio::test]
@@ -2547,84 +2410,18 @@ mod tests {
..Default::default()
};
let route =
resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
// #6290 rework: no classifier, no content signal — both wordings
// take the declared Volcengine default.
for prompt in ["quick status check", "please refactor this architecture"] {
let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
.await
.expect("heuristic-only Volcengine route should resolve");
assert_eq!(route.provider, ApiProvider::Volcengine);
assert_eq!(route.model, "DeepSeek-V4-Flash");
assert_eq!(route.source, AutoRouteSource::Heuristic);
let route = resolve_auto_route_with_inventory(
&config,
"please refactor this architecture",
"",
"auto",
"auto",
)
.await
.expect("complex Volcengine route should resolve");
assert_eq!(route.provider, ApiProvider::Volcengine);
assert_eq!(route.model, "DeepSeek-V4-Pro");
assert_eq!(route.source, AutoRouteSource::Heuristic);
}
#[test]
fn auto_heuristic_default_routes_implement_to_pro() {
assert_eq!(
auto_model_heuristic_with_bias("Please implement a binary search", "auto", false),
"deepseek-v4-pro"
);
}
#[test]
fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() {
assert_eq!(
auto_model_heuristic_with_bias("Please implement a binary search", "auto", true),
"deepseek-v4-flash"
);
assert_eq!(
auto_model_heuristic_with_bias("analyze this snippet", "auto", true),
"deepseek-v4-flash"
);
}
#[test]
fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() {
for kw in [
"refactor",
"architecture",
"design",
"debug",
"security",
"review",
"audit",
"migrate",
"optimize",
"rewrite",
] {
let req = format!("Please {kw} this module");
assert_eq!(
auto_model_heuristic_with_bias(&req, "auto", true),
"deepseek-v4-pro",
"expected Pro for strong keyword `{kw}` even in cost-saving mode"
);
.expect("declared-default Volcengine route should resolve");
assert_eq!(route.provider, ApiProvider::Volcengine);
assert_eq!(route.model, "deepseek-v4-pro");
assert_eq!(route.source, AutoRouteSource::Heuristic);
}
}
#[test]
fn auto_heuristic_cost_saving_raises_long_message_threshold() {
let body = "filler sentence. ".repeat(40);
assert_eq!(
auto_model_heuristic_with_bias(&body, "auto", false),
"deepseek-v4-pro"
);
assert_eq!(
auto_model_heuristic_with_bias(&body, "auto", true),
"deepseek-v4-flash"
);
}
#[test]
fn provider_router_candidates_cover_known_provider_classes() {
use crate::config::ApiProvider;
@@ -2747,28 +2544,28 @@ mod tests {
}
}
#[test]
fn heuristic_without_cheap_tier_always_returns_current_model() {
// #3018 AC: Ollama + auto must never fabricate a DeepSeek id.
let candidates = RouterCandidates {
big: "qwen3:32b".to_string(),
cheap: None,
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn declared_fallback_without_cheap_tier_stays_on_default_model() {
// #3018 AC: Ollama + auto must never fabricate a DeepSeek id. The
// declared fallback returns the configured default verbatim, so no
// sibling id can be invented regardless of request wording.
let _env_lock = crate::test_support::lock_test_env();
let config = Config {
provider: Some("ollama".to_string()),
..Default::default()
};
for cost_saving in [false, true] {
for prompt in [
"hi",
"please refactor the auth module for security",
&"long filler sentence. ".repeat(60),
] {
let model = auto_model_heuristic_with_bias_for_candidates(
prompt,
"qwen3:32b",
cost_saving,
&candidates,
)
.model;
assert_eq!(model, "qwen3:32b", "prompt {prompt:?}");
}
for prompt in ["hi", "please refactor the auth module for security"] {
let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
.await
.expect("ollama Auto route should resolve");
assert_eq!(route.provider, ApiProvider::Ollama);
assert_eq!(route.model, config.default_model());
assert!(
!route.model.to_ascii_lowercase().contains("deepseek"),
"no DeepSeek id may be fabricated: {}",
route.model
);
}
}
+4 -4
View File
@@ -1584,10 +1584,10 @@ pub struct App {
pub configured_models: Vec<codewhale_config::catalog::configured::ConfiguredModel>,
/// Exact provider/model pins loaded from settings, in user order.
pub pinned_models: Vec<crate::settings::PinnedModel>,
/// When true, the model is auto-selected based on request complexity
/// rather than using a fixed model. The `/model auto` command sets this.
/// `dispatch_user_message` calls `auto_model_heuristic` to resolve the
/// effective model for each outbound message.
/// When true, the model is auto-selected rather than using a fixed
/// model. The `/model auto` command sets this. The flash classifier
/// picks the per-turn model when available; otherwise the configured
/// default model is used (no request-content signal).
pub auto_model: bool,
/// Last concrete model chosen while `auto_model` is active.
pub last_effective_model: Option<String>,
+2 -2
View File
@@ -558,8 +558,8 @@ fn auto_reasoning_change_invalidates_the_previous_route_and_receipt() {
},
scope: crate::model_routing::AutoRouteScope::ResolvedProvider,
data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic,
reason: crate::model_routing::AutoRouteReason::LocalHeuristic(
crate::model_routing::AutoRouteHeuristicReason::ComplexRequest,
reason: crate::model_routing::AutoRouteReason::LocalFallback(
crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault,
),
});
app.last_effective_reasoning_effort =
-1
View File
@@ -765,7 +765,6 @@ pub(crate) async fn spawned_dispatch_inner(
reasoning_effort: prepare.reasoning_effort,
mode: prepare.mode,
content: &prepare.content,
display_text: &prepare.message.display,
auto_router_context: &prepare.auto_router_context,
should_auto_resolve: prepare.should_auto_resolve,
allow_auto_router_response_cache: true,
-1
View File
@@ -837,7 +837,6 @@ pub(crate) async fn build_preview_request_inputs(
reasoning_effort: app.reasoning_effort,
mode: app.mode,
content: &content,
display_text: &prompt,
auto_router_context: &auto_router::recent_auto_router_context(&app.api_messages),
should_auto_resolve: false,
allow_auto_router_response_cache: false,
+3 -3
View File
@@ -16004,8 +16004,8 @@ fn turn_started_route_is_captured_before_cancel_suppression() {
},
scope: crate::model_routing::AutoRouteScope::ResolvedProvider,
data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic,
reason: crate::model_routing::AutoRouteReason::LocalHeuristic(
crate::model_routing::AutoRouteHeuristicReason::ShortRequest,
reason: crate::model_routing::AutoRouteReason::LocalFallback(
crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault,
),
});
let created_at = chrono::Utc::now();
@@ -20950,7 +20950,7 @@ fn auto_route_receipt_survives_session_snapshot_and_restore() {
},
scope: crate::model_routing::AutoRouteScope::ResolvedProvider,
data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic,
reason: crate::model_routing::AutoRouteReason::LocalHeuristic(
reason: crate::model_routing::AutoRouteReason::LocalFallback(
crate::model_routing::AutoRouteHeuristicReason::ComplexRequest,
),
};
+11 -16
View File
@@ -41,9 +41,6 @@ pub(crate) struct TurnRoutePlanRequest<'a> {
/// Model-facing content of the next user message (file mentions and skill
/// wrapping already resolved). This is what the auto router classifies.
pub(crate) content: &'a str,
/// The user's display text, used by the heuristic and auto-reasoning
/// fallbacks exactly as production does.
pub(crate) display_text: &'a str,
pub(crate) auto_router_context: &'a str,
pub(crate) should_auto_resolve: bool,
/// Production dispatch may use the deterministic response cache for the
@@ -88,8 +85,9 @@ pub(crate) enum TurnRoutingSource {
ActiveFixedRoute,
/// Auto model routing used its provider-backed classifier.
AutoProviderClassifier,
/// Auto model routing used the local deterministic fallback heuristic.
AutoLocalHeuristic,
/// Auto model routing fell back to the local declared default (no
/// classifier signal; request wording never inspected).
AutoLocalFallback,
}
impl TurnRoutingSource {
@@ -97,7 +95,7 @@ impl TurnRoutingSource {
match self {
Self::ActiveFixedRoute => "active-fixed-route",
Self::AutoProviderClassifier => "auto-provider-classifier",
Self::AutoLocalHeuristic => "auto-local-heuristic",
Self::AutoLocalFallback => "auto-local-fallback",
}
}
}
@@ -170,13 +168,14 @@ pub(crate) async fn plan_turn_route(
.map(|selection| selection.provider)
.unwrap_or(request.api_provider);
// Without an Auto selection there is no per-request signal, so the
// route is the configured model — the same declared default the local
// fallback uses. Request wording is never inspected (#6290 rework).
let effective_model = if request.auto_model {
auto_selection
.as_ref()
.map(|selection| selection.model.clone())
.unwrap_or_else(|| {
crate::model_routing::auto_model_heuristic(request.display_text, request.app_model)
})
.unwrap_or_else(|| request.app_model.to_string())
} else {
request.app_model.to_string()
};
@@ -268,7 +267,7 @@ pub(crate) async fn plan_turn_route(
// Model selection and reasoning selection are independent. A fixed
// reasoning preference survives auto model routing and is normalized
// against the concrete route below; only an explicit `auto` delegates the
// tier to the classifier/heuristic.
// tier to the classifier/declared fallback.
let auto_controls_reasoning = request.reasoning_effort == ReasoningEffort::Auto;
let selected_reasoning_effort = if auto_controls_reasoning {
Some(
@@ -295,7 +294,7 @@ pub(crate) async fn plan_turn_route(
} else if auto_selection.is_some() {
TurnRoutingSource::AutoProviderClassifier
} else {
TurnRoutingSource::AutoLocalHeuristic
TurnRoutingSource::AutoLocalFallback
};
Ok(PlannedTurnRoute {
@@ -405,7 +404,6 @@ mod tests {
reasoning_effort: ReasoningEffort::Low,
mode: AppMode::Agent,
content: "explain this function",
display_text: "explain this function",
auto_router_context: "",
should_auto_resolve: false,
allow_auto_router_response_cache: false,
@@ -417,10 +415,7 @@ mod tests {
.await
.expect("plan auto-model turn");
assert_eq!(
planned.routing_source,
TurnRoutingSource::AutoLocalHeuristic
);
assert_eq!(planned.routing_source, TurnRoutingSource::AutoLocalFallback);
assert!(!planned.auto_controls_reasoning);
assert_eq!(planned.selected_reasoning_effort, None);
// First-party DeepSeek routes carry low as the real wire tier