返回 CodeWhale
model_routing.rs
根目录 / crates / tui / src / model_routing.rs
1 //! Model selection and auto-routing.
2 //!
3 //! The CLI, TUI, runtime threads, subagents, and command handlers all need
4 //! this behavior, so it intentionally lives outside the command tree.
5
6 use std::time::Duration;
7
8 use anyhow::Result;
9 use serde::{Deserialize, Serialize};
10
11 use crate::client::DeepSeekClient;
12 use crate::config::{ApiProvider, Config, normalize_model_name_for_provider};
13 use crate::llm_client::LlmClient;
14 use crate::model_inventory::ModelInventory;
15 use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt};
16 use crate::tui::app::ReasoningEffort;
17
18 /// Big/cheap model pair the auto-router may choose between for the active
19 /// provider (#3018).
20 ///
21 /// `cheap == None` means the provider has no known cheap tier: heuristics
22 /// stay on the current model (only thinking effort varies) and the network
23 /// router is skipped entirely (#1549).
24 #[derive(Debug, Clone, PartialEq, Eq)]
25 pub(crate) struct RouterCandidates {
26 pub(crate) big: String,
27 pub(crate) cheap: Option<String>,
28 }
29
30 impl RouterCandidates {
31 pub(crate) fn deepseek() -> Self {
32 Self {
33 big: "deepseek-v4-pro".to_string(),
34 cheap: Some("deepseek-v4-flash".to_string()),
35 }
36 }
37
38 /// The cheap-tier id, falling back to `big` when no cheap tier exists.
39 pub(crate) fn cheap_or_big(&self) -> &str {
40 self.cheap.as_deref().unwrap_or(&self.big)
41 }
42 }
43
44 /// Return a provider-owned strong/fast pair for model families whose catalog
45 /// exposes more than one tier. The ids here are deliberately explicit: a
46 /// model name alone is not evidence that another provider can serve its
47 /// sibling, so unknown providers and unknown families remain single-tier.
48 fn catalog_family_candidates(
49 provider: ApiProvider,
50 current_model: &str,
51 ) -> Option<RouterCandidates> {
52 let normalized = normalize_model_name_for_provider(provider, current_model)
53 .unwrap_or_else(|| current_model.trim().to_string());
54 let lower = normalized.to_ascii_lowercase();
55
56 let cheap = match provider {
57 ApiProvider::Openai | ApiProvider::OpenaiCodex
58 if matches!(lower.as_str(), "gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra") =>
59 {
60 Some("gpt-5.6-luna".to_string())
61 }
62 ApiProvider::Anthropic
63 if matches!(
64 lower.as_str(),
65 "claude-opus-4-8" | "claude-sonnet-4-6" | "claude-sonnet-5"
66 ) =>
67 {
68 Some("claude-haiku-4-5".to_string())
69 }
70 ApiProvider::XiaomiMimo if lower == "mimo-v2.5-pro" => Some("mimo-v2.5".to_string()),
71 ApiProvider::Arcee
72 if matches!(
73 lower.as_str(),
74 "trinity-large-thinking" | "trinity-large-preview"
75 ) =>
76 {
77 Some("trinity-mini".to_string())
78 }
79 ApiProvider::Moonshot if lower == "kimi-k2.7-code" => Some("kimi-k2.6".to_string()),
80 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic if lower == "minimax-m2.7" => {
81 Some("MiniMax-M2.7-highspeed".to_string())
82 }
83 ApiProvider::OpencodeGo if lower == "kimi-k3" => Some("kimi-k2.7-code".to_string()),
84 ApiProvider::Openrouter
85 if lower == "qwen/qwen3.6-max-preview"
86 || lower == "qwen/qwen3.6-plus"
87 || lower == "qwen/qwen3.6-27b"
88 || lower == "qwen/qwen3.6-35b-a3b" =>
89 {
90 Some("qwen/qwen3.6-flash".to_string())
91 }
92 ApiProvider::Openrouter if lower == "xiaomi/mimo-v2.5-pro" => {
93 Some("xiaomi/mimo-v2.5".to_string())
94 }
95 ApiProvider::Openrouter
96 if matches!(
97 lower.as_str(),
98 "arcee-ai/trinity-large-thinking" | "arcee-ai/trinity-large-preview"
99 ) =>
100 {
101 Some("arcee-ai/trinity-mini".to_string())
102 }
103 ApiProvider::Openrouter if lower == "moonshotai/kimi-k2.7-code" => {
104 Some("moonshotai/kimi-k2.6".to_string())
105 }
106 ApiProvider::Openrouter
107 if lower == "anthropic/claude-opus-4-8"
108 || lower == "anthropic/claude-sonnet-4-6"
109 || lower == "anthropic/claude-sonnet-5" =>
110 {
111 Some("anthropic/claude-haiku-4-5".to_string())
112 }
113 _ => None,
114 }?;
115
116 Some(RouterCandidates {
117 big: normalized,
118 cheap: Some(cheap),
119 })
120 }
121
122 /// Derive the auto-router's candidate pair for the active provider (#3018).
123 ///
124 /// DeepSeek providers route between the canonical pro/flash pair. Hosted
125 /// routes with known wire ids for that pair (NVIDIA NIM, OpenRouter, Novita,
126 /// SiliconFlow, SGLang, vLLM, Wanjie Ark, Volcengine) use their provider
127 /// spellings. Every other provider has no known cheap tier: `big` is the
128 /// session model and `cheap` is `None`, so auto mode never fabricates a
129 /// DeepSeek id for a provider that cannot serve it.
130 pub(crate) fn provider_router_candidates(
131 provider: crate::config::ApiProvider,
132 current_model: &str,
133 ) -> RouterCandidates {
134 use crate::config::ApiProvider;
135 if let Some(candidates) = catalog_family_candidates(provider, current_model) {
136 return candidates;
137 }
138
139 if provider == ApiProvider::Zai {
140 let normalized = crate::config::normalize_model_name_for_provider(provider, current_model)
141 .unwrap_or_else(|| current_model.to_string());
142 return RouterCandidates {
143 // GLM-5.2 (the default) routes faster/explore children to GLM-5-Turbo,
144 // the same-family fast sibling; GLM-5.3 inherits that pairing without
145 // taking it away from 5.2. GLM-5.1 and GLM-5-Turbo itself have no
146 // cheaper tier and keep children on the parent model.
147 cheap: if normalized == crate::config::ZAI_GLM_5_2_MODEL
148 || normalized == crate::config::ZAI_GLM_5_3_MODEL
149 {
150 Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string())
151 } else {
152 None
153 },
154 big: normalized,
155 };
156 }
157
158 if provider == ApiProvider::Openrouter
159 && let Some(normalized) =
160 crate::config::normalize_model_name_for_provider(provider, current_model)
161 && matches!(
162 normalized.as_str(),
163 crate::config::OPENROUTER_GLM_5_1_MODEL
164 | crate::config::OPENROUTER_GLM_5_2_MODEL
165 | crate::config::OPENROUTER_GLM_5_3_MODEL
166 | crate::config::OPENROUTER_GLM_5_TURBO_MODEL
167 )
168 {
169 return RouterCandidates {
170 // z-ai/glm-5.2 and z-ai/glm-5.3 route faster children to
171 // z-ai/glm-5-turbo; the 5.1 and turbo ids have no cheaper tier and
172 // keep children on parent.
173 cheap: if normalized == crate::config::OPENROUTER_GLM_5_2_MODEL
174 || normalized == crate::config::OPENROUTER_GLM_5_3_MODEL
175 {
176 Some(crate::config::OPENROUTER_GLM_5_TURBO_MODEL.to_string())
177 } else {
178 None
179 },
180 big: normalized,
181 };
182 }
183
184 match provider {
185 ApiProvider::Deepseek | ApiProvider::DeepseekCN => RouterCandidates::deepseek(),
186 ApiProvider::NvidiaNim
187 | ApiProvider::Openrouter
188 | ApiProvider::Novita
189 | ApiProvider::Siliconflow
190 | ApiProvider::SiliconflowCn
191 | ApiProvider::Sglang
192 | ApiProvider::Vllm
193 | ApiProvider::WanjieArk
194 if current_model.to_ascii_lowercase().contains("deepseek") =>
195 {
196 RouterCandidates {
197 big: crate::config::wire_model_for_provider(provider, "deepseek-v4-pro"),
198 cheap: Some(crate::config::wire_model_for_provider(
199 provider,
200 "deepseek-v4-flash",
201 )),
202 }
203 }
204 ApiProvider::Volcengine if current_model.to_ascii_lowercase().contains("deepseek") => {
205 RouterCandidates {
206 big: crate::config::DEFAULT_VOLCENGINE_MODEL.to_string(),
207 cheap: Some(crate::config::DEFAULT_VOLCENGINE_FLASH_MODEL.to_string()),
208 }
209 }
210 _ => RouterCandidates {
211 big: current_model.to_string(),
212 cheap: None,
213 },
214 }
215 }
216
217 /// Auto-select a model based on request complexity.
218 ///
219 /// Short messages (<100 chars) go to the cheap tier. Long messages and
220 /// requests with complex keywords go to the big tier. The fallback is cheap.
221 /// This DeepSeek-candidate wrapper keeps legacy callers and tests intact;
222 /// provider-aware callers use [`auto_model_heuristic_for_candidates`].
223 pub(crate) fn auto_model_heuristic(input: &str, current_model: &str) -> String {
224 auto_model_heuristic_for_candidates(input, current_model, &RouterCandidates::deepseek())
225 }
226
227 /// Candidate-aware variant of [`auto_model_heuristic`] (#3018).
228 pub(crate) fn auto_model_heuristic_for_candidates(
229 input: &str,
230 current_model: &str,
231 candidates: &RouterCandidates,
232 ) -> String {
233 auto_model_heuristic_with_bias_for_candidates(input, current_model, false, candidates).model
234 }
235
236 #[cfg(test)]
237 fn auto_model_heuristic_with_bias(input: &str, current_model: &str, cost_saving: bool) -> String {
238 auto_model_heuristic_with_bias_for_candidates(
239 input,
240 current_model,
241 cost_saving,
242 &RouterCandidates::deepseek(),
243 )
244 .model
245 }
246
247 #[derive(Debug, Clone, PartialEq, Eq)]
248 struct AutoRouteHeuristicDecision {
249 model: String,
250 reason: AutoRouteHeuristicReason,
251 }
252
253 fn auto_model_heuristic_with_bias_for_candidates(
254 input: &str,
255 _current_model: &str,
256 cost_saving: bool,
257 candidates: &RouterCandidates,
258 ) -> AutoRouteHeuristicDecision {
259 let len = input.chars().count();
260 let lower = input.to_lowercase();
261 let borderline_pro_keywords: &[&str] = &[
262 "implement",
263 "analyze",
264 "\u{5b9e}\u{73b0}",
265 "\u{5206}\u{6790}",
266 "\u{5be6}\u{73fe}",
267 ];
268 let strong_match = COMPLEX_KEYWORDS
269 .iter()
270 .any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw));
271 let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw));
272 let pro_match = strong_match || (!cost_saving && borderline_match);
273 if pro_match {
274 return AutoRouteHeuristicDecision {
275 model: candidates.big.clone(),
276 reason: AutoRouteHeuristicReason::ComplexRequest,
277 };
278 }
279 if len < 100 {
280 return AutoRouteHeuristicDecision {
281 model: candidates.cheap_or_big().to_string(),
282 reason: if cost_saving && borderline_match {
283 AutoRouteHeuristicReason::CostSavingPolicy
284 } else {
285 AutoRouteHeuristicReason::ShortRequest
286 },
287 };
288 }
289 let long_threshold = if cost_saving { 1_000 } else { 500 };
290 if len > long_threshold {
291 return AutoRouteHeuristicDecision {
292 model: candidates.big.clone(),
293 reason: AutoRouteHeuristicReason::LongRequest,
294 };
295 }
296
297 AutoRouteHeuristicDecision {
298 model: candidates.cheap_or_big().to_string(),
299 reason: if cost_saving && borderline_match {
300 AutoRouteHeuristicReason::CostSavingPolicy
301 } else {
302 AutoRouteHeuristicReason::RoutineRequest
303 },
304 }
305 }
306
307 const COMPLEX_KEYWORDS: &[&str] = &[
308 "refactor",
309 "architecture",
310 "design",
311 "debug",
312 "security",
313 "review",
314 "audit",
315 "migrate",
316 "optimize",
317 "rewrite",
318 "implement",
319 "analyze",
320 "\u{91cd}\u{6784}",
321 "\u{67b6}\u{6784}",
322 "\u{8bbe}\u{8ba1}",
323 "\u{8c03}\u{8bd5}",
324 "\u{5b89}\u{5168}",
325 "\u{5ba1}\u{67e5}",
326 "\u{5ba1}\u{8ba1}",
327 "\u{8fc1}\u{79fb}",
328 "\u{4f18}\u{5316}",
329 "\u{91cd}\u{5199}",
330 "\u{5b9e}\u{73b0}",
331 "\u{5206}\u{6790}",
332 "\u{91cd}\u{69cb}",
333 "\u{67b6}\u{69cb}",
334 "\u{8a2d}\u{8a08}",
335 "\u{8abf}\u{8a66}",
336 "\u{5be9}\u{67e5}",
337 "\u{5be9}\u{8a08}",
338 "\u{9077}\u{79fb}",
339 "\u{512a}\u{5316}",
340 "\u{91cd}\u{5beb}",
341 "\u{5be6}\u{73fe}",
342 ];
343
344 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
345 pub(crate) enum AutoRouteSource {
346 FlashRouter,
347 Heuristic,
348 }
349
350 impl AutoRouteSource {
351 #[must_use]
352 pub(crate) fn label(self) -> &'static str {
353 match self {
354 AutoRouteSource::FlashRouter => "classifier",
355 AutoRouteSource::Heuristic => "heuristic",
356 }
357 }
358 }
359
360 /// Provider-safe tier reported for the concrete Auto route.
361 ///
362 /// `Selected` is deliberately neutral: a classifier may choose a runnable
363 /// inventory model that is not part of a known strong/fast pair, and the UI
364 /// must not invent a tier from the model id.
365 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
366 #[serde(rename_all = "snake_case")]
367 pub(crate) enum AutoRouteTier {
368 Strong,
369 Fast,
370 Only,
371 Selected,
372 }
373
374 impl AutoRouteTier {
375 #[must_use]
376 pub(crate) fn label(self) -> &'static str {
377 match self {
378 Self::Strong => "strong",
379 Self::Fast => "fast",
380 Self::Only => "only model",
381 Self::Selected => "selected",
382 }
383 }
384 }
385
386 /// Scope from which the concrete Auto route was selected.
387 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388 #[serde(rename_all = "snake_case")]
389 pub(crate) enum AutoRouteScope {
390 /// The network classifier could choose any runnable provider/model pair in
391 /// the redacted inventory. Only reachable under the persisted
392 /// `[auto] cross_provider = true` opt-in (#4411).
393 RunnableProviders,
394 /// The network classifier saw only the active provider's runnable routes —
395 /// the default Auto scope (#4411).
396 ActiveProvider,
397 /// The provider-aware local heuristic selected within one resolved route.
398 ResolvedProvider,
399 }
400
401 impl AutoRouteScope {
402 #[must_use]
403 pub(crate) fn label(self) -> &'static str {
404 match self {
405 Self::RunnableProviders => "runnable providers",
406 Self::ActiveProvider => "active provider only",
407 Self::ResolvedProvider => "resolved provider",
408 }
409 }
410 }
411
412 /// Non-secret data path used to make an Auto decision.
413 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414 #[serde(rename_all = "snake_case")]
415 pub(crate) enum AutoRouteDataPath {
416 LocalHeuristic,
417 Classifier {
418 provider: ApiProvider,
419 model: String,
420 },
421 }
422
423 impl AutoRouteDataPath {
424 #[must_use]
425 pub(crate) fn label(&self) -> String {
426 match self {
427 Self::LocalHeuristic => "local only (no router request)".to_string(),
428 Self::Classifier { provider, model } => format!(
429 "latest request + bounded recent context -> {} / {model}",
430 provider.display_name()
431 ),
432 }
433 }
434 }
435
436 /// Local signal that selected the provider-safe strong/fast candidate.
437 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
438 #[serde(rename_all = "snake_case")]
439 pub(crate) enum AutoRouteHeuristicReason {
440 ComplexRequest,
441 ShortRequest,
442 LongRequest,
443 CostSavingPolicy,
444 RoutineRequest,
445 NoFastSibling,
446 NoRunnableCandidate,
447 }
448
449 impl AutoRouteHeuristicReason {
450 #[must_use]
451 fn label(self) -> &'static str {
452 match self {
453 Self::ComplexRequest => "complex request",
454 Self::ShortRequest => "short request",
455 Self::LongRequest => "long request",
456 Self::CostSavingPolicy => "cost-saving policy",
457 Self::RoutineRequest => "routine request",
458 Self::NoFastSibling => "no runnable fast sibling",
459 Self::NoRunnableCandidate => "no runnable inventory candidate",
460 }
461 }
462 }
463
464 /// Why the route was selected. Classifier failures are intentionally
465 /// collapsed to a non-secret reason; provider errors and response bodies must
466 /// never enter diagnostics.
467 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
468 #[serde(rename_all = "snake_case")]
469 pub(crate) enum AutoRouteReason {
470 ClassifierRecommendation,
471 LocalHeuristic(AutoRouteHeuristicReason),
472 ClassifierFallback(AutoRouteHeuristicReason),
473 }
474
475 impl AutoRouteReason {
476 #[must_use]
477 pub(crate) fn label(self) -> String {
478 match self {
479 Self::ClassifierRecommendation => "classifier recommendation".to_string(),
480 Self::LocalHeuristic(reason) => format!("local heuristic: {}", reason.label()),
481 Self::ClassifierFallback(reason) => {
482 format!("classifier fallback: {}", reason.label())
483 }
484 }
485 }
486 }
487
488 /// Effective provider-scoped model pair used to classify the selected tier.
489 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490 pub(crate) struct AutoRoutePair {
491 pub(crate) strong: String,
492 pub(crate) fast: Option<String>,
493 }
494
495 /// Per-turn Auto routing diagnostics. Provider/model identity remains owned by
496 /// the authoritative runtime `TurnRoute`; this receipt only records how the
497 /// concrete route was chosen.
498 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499 pub(crate) struct AutoRouteReceipt {
500 pub(crate) tier: AutoRouteTier,
501 pub(crate) pair: AutoRoutePair,
502 pub(crate) scope: AutoRouteScope,
503 pub(crate) data_path: AutoRouteDataPath,
504 pub(crate) reason: AutoRouteReason,
505 }
506
507 #[derive(Debug, Clone, PartialEq, Eq)]
508 pub(crate) struct AutoRouteSelection {
509 pub(crate) provider: ApiProvider,
510 pub(crate) model: String,
511 pub(crate) reasoning_effort: Option<ReasoningEffort>,
512 pub(crate) source: AutoRouteSource,
513 /// Present for Auto decisions; explicit inventory lookups intentionally do
514 /// not pretend to be Auto routing receipts.
515 pub(crate) receipt: Option<AutoRouteReceipt>,
516 }
517
518 fn extract_first_json_object(raw: &str) -> Option<&str> {
519 let start = raw.find('{')?;
520 let end = raw.rfind('}')?;
521 (end >= start).then_some(&raw[start..=end])
522 }
523
524 fn parse_auto_route_reasoning_effort(effort: &str) -> Option<ReasoningEffort> {
525 ReasoningEffort::parse_strict(effort).ok()
526 }
527
528 #[must_use]
529 pub(crate) fn normalize_auto_route_effort_for_provider(
530 provider: ApiProvider,
531 effort: ReasoningEffort,
532 ) -> ReasoningEffort {
533 if provider == ApiProvider::OpenaiCodex {
534 return effort.normalize_for_provider(provider);
535 }
536 match effort {
537 ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High,
538 other => other,
539 }
540 }
541
542 /// Select the reasoning request that accompanies an Auto-model route.
543 ///
544 /// Model routing and reasoning routing are independent. An explicit fixed
545 /// preference wins over the classifier's suggestion; an absent preference or
546 /// explicit `Auto` keeps reasoning under per-prompt control.
547 #[must_use]
548 pub(crate) fn resolve_auto_model_reasoning(
549 preference: Option<ReasoningEffort>,
550 routed: Option<ReasoningEffort>,
551 ) -> (Option<ReasoningEffort>, bool) {
552 match preference {
553 Some(
554 effort @ (ReasoningEffort::Off
555 | ReasoningEffort::Minimal
556 | ReasoningEffort::Low
557 | ReasoningEffort::Medium
558 | ReasoningEffort::High
559 | ReasoningEffort::XHigh
560 | ReasoningEffort::Ultra
561 | ReasoningEffort::Max),
562 ) => (Some(effort), false),
563 None | Some(ReasoningEffort::Auto) => (routed, true),
564 }
565 }
566
567 /// Route-aware equivalent of [`normalize_auto_route_effort_for_provider`].
568 /// The inventory knows the selected provider/model, and the route resolver
569 /// supplies the endpoint needed to distinguish Kimi Code's official bare-K3
570 /// contract from generic Moonshot.
571 #[must_use]
572 pub(crate) fn normalize_auto_route_effort_for_configured_route(
573 config: &Config,
574 provider: ApiProvider,
575 model: &str,
576 effort: ReasoningEffort,
577 ) -> ReasoningEffort {
578 crate::route_runtime::resolve_runtime_route(config, provider, Some(model))
579 .map(|route| {
580 effort.normalize_for_route(provider, &route.candidate.endpoint().base_url, &route.model)
581 })
582 .unwrap_or_else(|_| normalize_auto_route_effort_for_provider(provider, effort))
583 }
584
585 fn normalize_auto_route_selection_for_config(
586 config: &Config,
587 mut selection: AutoRouteSelection,
588 ) -> AutoRouteSelection {
589 selection.reasoning_effort = selection.reasoning_effort.map(|effort| {
590 normalize_auto_route_effort_for_configured_route(
591 config,
592 selection.provider,
593 &selection.model,
594 effort,
595 )
596 });
597 selection
598 }
599
600 #[derive(Debug, Clone, PartialEq, Eq)]
601 struct InventoryAutoRouteRecommendation {
602 provider: ApiProvider,
603 model: String,
604 reasoning_effort: Option<ReasoningEffort>,
605 }
606
607 pub(crate) async fn resolve_auto_route_with_inventory(
608 config: &Config,
609 latest_request: &str,
610 recent_context: &str,
611 selected_model_mode: &str,
612 selected_thinking_mode: &str,
613 ) -> Result<AutoRouteSelection> {
614 resolve_auto_route_with_inventory_for_session(
615 config,
616 latest_request,
617 recent_context,
618 "agent",
619 selected_model_mode,
620 selected_thinking_mode,
621 )
622 .await
623 }
624
625 pub(crate) async fn resolve_auto_route_with_inventory_for_session(
626 config: &Config,
627 latest_request: &str,
628 recent_context: &str,
629 session_mode: &str,
630 selected_model_mode: &str,
631 selected_thinking_mode: &str,
632 ) -> Result<AutoRouteSelection> {
633 resolve_auto_route_with_inventory_for_session_and_cache_policy(
634 config,
635 latest_request,
636 recent_context,
637 session_mode,
638 selected_model_mode,
639 selected_thinking_mode,
640 true,
641 )
642 .await
643 }
644
645 pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_policy(
646 config: &Config,
647 latest_request: &str,
648 recent_context: &str,
649 session_mode: &str,
650 selected_model_mode: &str,
651 selected_thinking_mode: &str,
652 allow_response_cache: bool,
653 ) -> Result<AutoRouteSelection> {
654 let inventory = ModelInventory::from_config(config);
655 if !inventory.router_available {
656 // Fall back to heuristic-only auto routing when the flash router
657 // is unavailable (e.g. non-DeepSeek providers like wanjie-ark).
658 return Ok(normalize_auto_route_selection_for_config(
659 config,
660 auto_route_from_inventory_heuristic(config, latest_request, &inventory),
661 ));
662 }
663
664 let heuristic = auto_route_from_inventory_heuristic(config, latest_request, &inventory);
665 if cfg!(test) {
666 return Ok(normalize_auto_route_selection_for_config(config, heuristic));
667 }
668
669 let selection = match auto_route_inventory_recommendation(
670 config,
671 &inventory,
672 latest_request,
673 recent_context,
674 session_mode,
675 selected_model_mode,
676 selected_thinking_mode,
677 allow_response_cache,
678 )
679 .await
680 {
681 Ok(Some(recommendation)) => auto_route_from_classifier(&inventory, recommendation),
682 Ok(None) | Err(_) => auto_route_classifier_fallback(heuristic, &inventory),
683 };
684 Ok(normalize_auto_route_selection_for_config(config, selection))
685 }
686
687 pub(crate) fn resolve_explicit_route_with_inventory(
688 config: &Config,
689 requested_model: &str,
690 ) -> Option<AutoRouteSelection> {
691 let requested_model = requested_model.trim();
692 if requested_model.is_empty() || requested_model.eq_ignore_ascii_case("auto") {
693 return None;
694 }
695
696 let inventory = ModelInventory::from_config(config);
697 let active_provider = config.api_provider();
698
699 if let Some(candidate) = inventory.candidates.iter().find(|candidate| {
700 candidate.provider == active_provider
701 && explicit_model_matches_candidate(candidate, requested_model)
702 }) {
703 return Some(AutoRouteSelection {
704 provider: candidate.provider,
705 model: candidate.model.clone(),
706 reasoning_effort: config.reasoning_effort().map(|setting| {
707 normalize_auto_route_effort_for_configured_route(
708 config,
709 candidate.provider,
710 &candidate.model,
711 ReasoningEffort::from_setting(setting),
712 )
713 }),
714 source: AutoRouteSource::Heuristic,
715 receipt: None,
716 });
717 }
718
719 let mut matches = inventory
720 .candidates
721 .iter()
722 .filter(|candidate| explicit_model_matches_candidate(candidate, requested_model));
723 let candidate = matches.next()?;
724 if matches.next().is_some() {
725 return None;
726 }
727
728 Some(AutoRouteSelection {
729 provider: candidate.provider,
730 model: candidate.model.clone(),
731 reasoning_effort: config.reasoning_effort().map(|setting| {
732 normalize_auto_route_effort_for_configured_route(
733 config,
734 candidate.provider,
735 &candidate.model,
736 ReasoningEffort::from_setting(setting),
737 )
738 }),
739 source: AutoRouteSource::Heuristic,
740 receipt: None,
741 })
742 }
743
744 pub(crate) fn explicit_route_candidate_providers(
745 config: &Config,
746 requested_model: &str,
747 ) -> Vec<ApiProvider> {
748 let requested_model = requested_model.trim();
749 if requested_model.is_empty() || requested_model.eq_ignore_ascii_case("auto") {
750 return Vec::new();
751 }
752
753 let inventory = ModelInventory::from_config(config);
754 let mut providers = Vec::new();
755 for candidate in inventory
756 .candidates
757 .iter()
758 .filter(|candidate| explicit_model_matches_candidate(candidate, requested_model))
759 {
760 if !providers.contains(&candidate.provider) {
761 providers.push(candidate.provider);
762 }
763 }
764 providers
765 }
766
767 fn explicit_model_matches_candidate(
768 candidate: &crate::model_inventory::ModelRouteCandidate,
769 requested_model: &str,
770 ) -> bool {
771 candidate.model.eq_ignore_ascii_case(requested_model)
772 || normalize_model_name_for_provider(candidate.provider, requested_model)
773 .is_some_and(|model| candidate.model.eq_ignore_ascii_case(&model))
774 }
775
776 fn auto_route_from_inventory_heuristic(
777 config: &Config,
778 latest_request: &str,
779 inventory: &ModelInventory,
780 ) -> AutoRouteSelection {
781 let Some(active) = inventory.active_default() else {
782 let model = config.default_model();
783 return AutoRouteSelection {
784 provider: config.api_provider(),
785 receipt: Some(auto_route_receipt(
786 inventory,
787 config.api_provider(),
788 &model,
789 AutoRouteScope::ResolvedProvider,
790 AutoRouteDataPath::LocalHeuristic,
791 AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate),
792 )),
793 model,
794 reasoning_effort: Some(crate::auto_reasoning::select(false, latest_request)),
795 source: AutoRouteSource::Heuristic,
796 };
797 };
798 // Use the candidates' cheap/big info for complexity-based routing.
799 let router_candidates = provider_router_candidates(active.provider, &active.model);
800 let fast_is_runnable = router_candidates.cheap.as_deref().is_some_and(|model| {
801 inventory
802 .candidate(active.provider, model)
803 .is_some_and(|candidate| candidate.readiness.can_attempt())
804 });
805 let decision = if fast_is_runnable {
806 auto_model_heuristic_with_bias_for_candidates(
807 latest_request,
808 &active.model,
809 config.auto_cost_saving(),
810 &router_candidates,
811 )
812 } else {
813 AutoRouteHeuristicDecision {
814 model: active.model.clone(),
815 reason: AutoRouteHeuristicReason::NoFastSibling,
816 }
817 };
818 AutoRouteSelection {
819 provider: active.provider,
820 receipt: Some(auto_route_receipt(
821 inventory,
822 active.provider,
823 &decision.model,
824 AutoRouteScope::ResolvedProvider,
825 AutoRouteDataPath::LocalHeuristic,
826 AutoRouteReason::LocalHeuristic(decision.reason),
827 )),
828 model: decision.model,
829 reasoning_effort: Some(crate::auto_reasoning::select(false, latest_request)),
830 source: AutoRouteSource::Heuristic,
831 }
832 }
833
834 fn auto_route_from_classifier(
835 inventory: &ModelInventory,
836 recommendation: InventoryAutoRouteRecommendation,
837 ) -> AutoRouteSelection {
838 let data_path = AutoRouteDataPath::Classifier {
839 provider: inventory.router_provider,
840 model: inventory.router_model.to_string(),
841 };
842 // Report the scope the classifier actually had, not the widest one it
843 // could ever have (#4411).
844 let scope = if inventory.cross_provider_auto {
845 AutoRouteScope::RunnableProviders
846 } else {
847 AutoRouteScope::ActiveProvider
848 };
849 AutoRouteSelection {
850 provider: recommendation.provider,
851 receipt: Some(auto_route_receipt(
852 inventory,
853 recommendation.provider,
854 &recommendation.model,
855 scope,
856 data_path,
857 AutoRouteReason::ClassifierRecommendation,
858 )),
859 model: recommendation.model,
860 reasoning_effort: recommendation.reasoning_effort,
861 source: AutoRouteSource::FlashRouter,
862 }
863 }
864
865 fn auto_route_classifier_fallback(
866 mut heuristic: AutoRouteSelection,
867 inventory: &ModelInventory,
868 ) -> AutoRouteSelection {
869 if let Some(receipt) = heuristic.receipt.as_mut() {
870 let heuristic_reason = match receipt.reason {
871 AutoRouteReason::LocalHeuristic(reason)
872 | AutoRouteReason::ClassifierFallback(reason) => reason,
873 AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::RoutineRequest,
874 };
875 receipt.data_path = AutoRouteDataPath::Classifier {
876 provider: inventory.router_provider,
877 model: inventory.router_model.to_string(),
878 };
879 receipt.reason = AutoRouteReason::ClassifierFallback(heuristic_reason);
880 }
881 heuristic
882 }
883
884 fn auto_route_receipt(
885 inventory: &ModelInventory,
886 provider: ApiProvider,
887 selected_model: &str,
888 scope: AutoRouteScope,
889 data_path: AutoRouteDataPath,
890 reason: AutoRouteReason,
891 ) -> AutoRouteReceipt {
892 let pair = auto_route_pair(inventory, provider, selected_model);
893 let tier = if pair
894 .fast
895 .as_deref()
896 .is_some_and(|fast| fast.eq_ignore_ascii_case(selected_model))
897 {
898 AutoRouteTier::Fast
899 } else if pair.strong.eq_ignore_ascii_case(selected_model) {
900 if pair.fast.is_some() {
901 AutoRouteTier::Strong
902 } else {
903 AutoRouteTier::Only
904 }
905 } else {
906 AutoRouteTier::Selected
907 };
908 AutoRouteReceipt {
909 tier,
910 pair,
911 scope,
912 data_path,
913 reason,
914 }
915 }
916
917 fn auto_route_pair(
918 inventory: &ModelInventory,
919 provider: ApiProvider,
920 selected_model: &str,
921 ) -> AutoRoutePair {
922 // A provider can expose several unrelated model families. Derive the pair
923 // from a runnable candidate that actually contains the selected model,
924 // preferring a cheap-tier match before a strong-tier match. Falling back
925 // to the provider default would report a truthful provider with a false
926 // model family (for example OpenRouter GLM reported as DeepSeek).
927 let matching_pair = inventory
928 .candidates
929 .iter()
930 .filter(|candidate| candidate.provider == provider && candidate.readiness.can_attempt())
931 .map(|candidate| provider_router_candidates(provider, &candidate.model))
932 .find(|pair| {
933 pair.cheap
934 .as_deref()
935 .is_some_and(|fast| fast.eq_ignore_ascii_case(selected_model))
936 })
937 .or_else(|| {
938 inventory
939 .candidates
940 .iter()
941 .filter(|candidate| {
942 candidate.provider == provider && candidate.readiness.can_attempt()
943 })
944 .map(|candidate| provider_router_candidates(provider, &candidate.model))
945 .find(|pair| pair.big.eq_ignore_ascii_case(selected_model))
946 });
947 let Some(candidates) = matching_pair else {
948 return AutoRoutePair {
949 strong: selected_model.to_string(),
950 fast: None,
951 };
952 };
953 let Some(strong) = inventory
954 .candidate(provider, &candidates.big)
955 .filter(|candidate| candidate.readiness.can_attempt())
956 .map(|candidate| candidate.model.clone())
957 else {
958 return AutoRoutePair {
959 strong: selected_model.to_string(),
960 fast: None,
961 };
962 };
963 let fast = candidates.cheap.as_deref().and_then(|model| {
964 inventory
965 .candidate(provider, model)
966 .filter(|candidate| candidate.readiness.can_attempt())
967 .map(|candidate| candidate.model.clone())
968 });
969 AutoRoutePair { strong, fast }
970 }
971
972 #[allow(clippy::too_many_arguments)]
973 async fn auto_route_inventory_recommendation(
974 config: &Config,
975 inventory: &ModelInventory,
976 latest_request: &str,
977 recent_context: &str,
978 session_mode: &str,
979 selected_model_mode: &str,
980 selected_thinking_mode: &str,
981 allow_response_cache: bool,
982 ) -> Result<Option<InventoryAutoRouteRecommendation>> {
983 let mut router_config = config.clone();
984 // The classifier runs on the inventory's router route: the explicit
985 // [auto.router] route when configured, else the DeepSeek flash default.
986 router_config.provider = Some(inventory.router_provider.as_str().to_string());
987 router_config.default_text_model = Some(inventory.router_model.clone());
988
989 let client = DeepSeekClient::new(&router_config)?;
990 let router_system = inventory_auto_router_system_prompt(inventory, config.auto_cost_saving());
991 let router_prompt = classifier_prompt(
992 &client,
993 latest_request,
994 recent_context,
995 session_mode,
996 selected_model_mode,
997 selected_thinking_mode,
998 );
999 let request = MessageRequest {
1000 model: inventory.router_model.to_string(),
1001 messages: vec![Message {
1002 role: "user".to_string(),
1003 content: vec![ContentBlock::Text {
1004 text: router_prompt,
1005 cache_control: None,
1006 }],
1007 }],
1008 max_tokens: 128,
1009 system: Some(SystemPrompt::Text(router_system)),
1010 tools: None,
1011 tool_choice: None,
1012 metadata: None,
1013 thinking: None,
1014 reasoning_effort: Some(
1015 inventory
1016 .router_thinking
1017 .clone()
1018 .unwrap_or_else(|| "off".to_string()),
1019 ),
1020 stream: Some(false),
1021 temperature: Some(0.0),
1022 top_p: None,
1023 };
1024
1025 let response = if allow_response_cache {
1026 tokio::time::timeout(Duration::from_secs(4), client.create_message(request)).await??
1027 } else {
1028 tokio::time::timeout(
1029 Duration::from_secs(4),
1030 client.create_message_without_response_cache(request),
1031 )
1032 .await??
1033 };
1034 Ok(parse_inventory_auto_route_recommendation(
1035 &message_response_text(&response),
1036 inventory,
1037 ))
1038 }
1039
1040 fn inventory_auto_router_system_prompt(inventory: &ModelInventory, cost_saving: bool) -> String {
1041 let mut prompt = if inventory.cross_provider_auto {
1042 String::new()
1043 } else {
1044 // The inventory JSON below is already scoped to the active provider
1045 // (#4411); say so, so the classifier does not try to name one it was
1046 // never shown.
1047 format!(
1048 "Auto routing is scoped to the active provider `{}`. Every model in the inventory \
1049 below belongs to it; never select another provider.\n\n",
1050 inventory.active_provider.as_str()
1051 )
1052 };
1053 prompt.push_str(&format!(
1054 "You are the codewhale model-routing classifier. Return only compact JSON: \
1055 {{\"provider\":\"<provider>\",\"model\":\"<model>\",\"thinking\":\"off|high|max\"}}.\n\
1056 Choose only provider/model pairs present in the inventory JSON. Use off only for trivial no-tool answers, \
1057 high for ordinary reasoning, and max for agentic, coding, multi-file, release, architecture, debugging, \
1058 security, tool-heavy, or uncertain work.\n\nInventory JSON:\n{}",
1059 inventory.router_context_json()
1060 ));
1061
1062 if cost_saving {
1063 let active_pair = inventory.active_default().and_then(|active| {
1064 let candidates = provider_router_candidates(active.provider, &active.model);
1065 let fast = candidates.cheap.as_deref()?;
1066 (inventory
1067 .candidate(active.provider, &candidates.big)
1068 .is_some_and(|candidate| candidate.readiness.can_attempt())
1069 && inventory
1070 .candidate(active.provider, fast)
1071 .is_some_and(|candidate| candidate.readiness.can_attempt()))
1072 .then_some((active.provider, candidates.big, fast.to_string()))
1073 });
1074
1075 if let Some((provider, strong, fast)) = active_pair {
1076 prompt.push_str(&format!(
1077 "\n\nCost-saving mode is ON. For the active provider `{}`, `{fast}` is the fast tier \
1078 and `{strong}` is the strong tier. Prefer `{fast}` for ambiguous, routine, or single-step work. \
1079 Select `{strong}` only when the request is unmistakably agentic, multi-step, architecture/design, \
1080 security review, debugging, or otherwise clearly beyond the fast tier. Keep the selected model paired \
1081 with provider `{}`.",
1082 provider.as_str(),
1083 provider.as_str()
1084 ));
1085 } else {
1086 prompt.push_str(
1087 "\n\nCost-saving mode is ON, but the active provider has no known runnable fast sibling. \
1088 Do not invent a model or cross-provider downgrade solely to save cost.",
1089 );
1090 }
1091 }
1092
1093 prompt
1094 }
1095
1096 fn parse_inventory_auto_route_recommendation(
1097 raw: &str,
1098 inventory: &ModelInventory,
1099 ) -> Option<InventoryAutoRouteRecommendation> {
1100 let json = extract_first_json_object(raw)?;
1101 let value: serde_json::Value = serde_json::from_str(json).ok()?;
1102 let provider = value
1103 .get("provider")
1104 .and_then(serde_json::Value::as_str)
1105 .and_then(ApiProvider::parse)?;
1106 // Defense in depth for #4411: the payload already hides other providers,
1107 // but a hallucinated (or stale) provider must not become a route either.
1108 if !inventory.auto_scope_allows(provider) {
1109 return None;
1110 }
1111 let model = value.get("model").and_then(serde_json::Value::as_str)?;
1112 let candidate = inventory
1113 .candidate(provider, model)
1114 .filter(|candidate| candidate.readiness.can_attempt())?;
1115 let reasoning_effort = value
1116 .get("thinking")
1117 .or_else(|| value.get("reasoning_effort"))
1118 .or_else(|| value.get("effort"))
1119 .and_then(serde_json::Value::as_str)
1120 .and_then(parse_auto_route_reasoning_effort);
1121
1122 Some(InventoryAutoRouteRecommendation {
1123 provider,
1124 model: candidate.model.clone(),
1125 reasoning_effort,
1126 })
1127 }
1128
1129 fn auto_route_prompt(
1130 latest_request: &str,
1131 recent_context: &str,
1132 session_mode: &str,
1133 selected_model_mode: &str,
1134 selected_thinking_mode: &str,
1135 ) -> String {
1136 format!(
1137 "Session mode: {}\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.",
1138 session_mode,
1139 selected_model_mode,
1140 selected_thinking_mode,
1141 if recent_context.trim().is_empty() {
1142 "No prior context."
1143 } else {
1144 recent_context
1145 },
1146 truncate_for_auto_router(latest_request, 4_000)
1147 )
1148 }
1149
1150 fn classifier_prompt(
1151 client: &DeepSeekClient,
1152 latest_request: &str,
1153 recent_context: &str,
1154 session_mode: &str,
1155 selected_model_mode: &str,
1156 selected_thinking_mode: &str,
1157 ) -> String {
1158 client.redact_model_bound_text(&auto_route_prompt(
1159 latest_request,
1160 recent_context,
1161 session_mode,
1162 selected_model_mode,
1163 selected_thinking_mode,
1164 ))
1165 }
1166
1167 fn message_response_text(response: &MessageResponse) -> String {
1168 let mut out = String::new();
1169 for block in &response.content {
1170 match block {
1171 ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => {
1172 append_router_text(&mut out, text);
1173 }
1174 ContentBlock::Thinking { thinking, .. } => {
1175 append_router_text(&mut out, thinking);
1176 }
1177 ContentBlock::ToolUse { name, .. } => {
1178 append_router_text(&mut out, &format!("[tool call: {name}]"));
1179 }
1180 _ => {}
1181 }
1182 }
1183 out
1184 }
1185
1186 fn append_router_text(out: &mut String, text: &str) {
1187 if !out.is_empty() {
1188 out.push('\n');
1189 }
1190 out.push_str(text);
1191 }
1192
1193 fn truncate_for_auto_router(text: &str, max_chars: usize) -> String {
1194 let mut chars = text.chars();
1195 let truncated: String = chars.by_ref().take(max_chars).collect();
1196 if chars.next().is_some() {
1197 format!("{truncated}...")
1198 } else {
1199 truncated
1200 }
1201 }
1202
1203 #[cfg(test)]
1204 mod tests {
1205 use super::*;
1206
1207 #[test]
1208 fn auto_model_reasoning_keeps_model_and_thinking_choices_independent() {
1209 assert_eq!(
1210 resolve_auto_model_reasoning(Some(ReasoningEffort::Low), Some(ReasoningEffort::Max)),
1211 (Some(ReasoningEffort::Low), false)
1212 );
1213 assert_eq!(
1214 resolve_auto_model_reasoning(Some(ReasoningEffort::Auto), Some(ReasoningEffort::Max)),
1215 (Some(ReasoningEffort::Max), true)
1216 );
1217 assert_eq!(
1218 resolve_auto_model_reasoning(None, Some(ReasoningEffort::High)),
1219 (Some(ReasoningEffort::High), true)
1220 );
1221 }
1222
1223 #[test]
1224 fn auto_model_heuristic_chinese_keywords_route_to_pro() {
1225 for msg in [
1226 "\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}",
1227 "\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}",
1228 "\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}",
1229 "\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}",
1230 "\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}",
1231 "\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}",
1232 "\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}",
1233 ] {
1234 assert_eq!(
1235 auto_model_heuristic(msg, "auto"),
1236 "deepseek-v4-pro",
1237 "expected Pro for `{msg}`",
1238 );
1239 }
1240 }
1241
1242 #[test]
1243 fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() {
1244 for msg in [
1245 "\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}",
1246 "\u{67b6}\u{69cb}\u{8a2d}\u{8a08}",
1247 "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}",
1248 "\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}",
1249 "\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}",
1250 "\u{512a}\u{5316}\u{6027}\u{80fd}",
1251 "\u{91cd}\u{5beb}\u{4ee3}\u{78bc}",
1252 "\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}",
1253 ] {
1254 assert_eq!(
1255 auto_model_heuristic(msg, "auto"),
1256 "deepseek-v4-pro",
1257 "expected Pro for `{msg}`",
1258 );
1259 }
1260 }
1261
1262 #[test]
1263 fn auto_model_heuristic_short_chinese_chat_stays_on_flash() {
1264 assert_eq!(
1265 auto_model_heuristic("\u{4f60}\u{597d}", "auto"),
1266 "deepseek-v4-flash",
1267 );
1268 }
1269
1270 #[test]
1271 fn auto_route_prompt_uses_current_session_mode() {
1272 let prompt = auto_route_prompt(
1273 "Please explain the change before editing files.",
1274 "No prior context.",
1275 "plan",
1276 "auto",
1277 "auto",
1278 );
1279
1280 assert!(
1281 prompt.starts_with("Session mode: plan\n"),
1282 "auto-route prompt should reflect the active session mode, got: {prompt}"
1283 );
1284 }
1285
1286 #[test]
1287 fn classifier_prompt_redacts_secret_after_tool_result_flattening() {
1288 let secret = "cw-router-secret-should-never-leave-process";
1289 let config = Config {
1290 api_key: Some(secret.to_string()),
1291 ..Default::default()
1292 };
1293 let client = DeepSeekClient::new(&config).expect("classifier client");
1294 // `recent_auto_router_context` converts ToolResult blocks into ordinary
1295 // text before this boundary. Exercise that exact flattened shape.
1296 let recent_context = format!("assistant: [tool result] token={secret}");
1297
1298 let prompt = classifier_prompt(
1299 &client,
1300 "continue the investigation",
1301 &recent_context,
1302 "agent",
1303 "auto",
1304 "auto",
1305 );
1306
1307 assert!(
1308 !prompt.contains(secret),
1309 "flattened tool-result secret leaked"
1310 );
1311 assert!(
1312 prompt.contains(codewhale_config::persistence::REDACTED),
1313 "secret should be visibly redacted"
1314 );
1315 assert!(prompt.contains("continue the investigation"));
1316 }
1317
1318 #[test]
1319 fn inventory_auto_router_prompt_names_cost_saving_zai_pair() {
1320 let _env_lock = crate::test_support::lock_test_env();
1321 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
1322 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1323 let config = Config {
1324 provider: Some("zai".to_string()),
1325 ..Default::default()
1326 };
1327 let inventory = ModelInventory::from_config(&config);
1328
1329 let balanced = inventory_auto_router_system_prompt(&inventory, false);
1330 let cost_saving = inventory_auto_router_system_prompt(&inventory, true);
1331
1332 assert!(!balanced.contains("Cost-saving mode is ON"));
1333 assert!(
1334 cost_saving.contains(
1335 "For the active provider `zai`, `GLM-5-Turbo` is the fast tier and `GLM-5.2` is the strong tier"
1336 ),
1337 "cost-saving classifier policy must name the provider-safe pair: {cost_saving}"
1338 );
1339 assert!(
1340 cost_saving.contains("Keep the selected model paired with provider `zai`"),
1341 "cost-saving policy must preserve provider/model validation: {cost_saving}"
1342 );
1343 }
1344
1345 #[test]
1346 fn auto_route_effort_normalization_is_provider_aware() {
1347 assert_eq!(
1348 normalize_auto_route_effort_for_provider(ApiProvider::Deepseek, ReasoningEffort::Low),
1349 ReasoningEffort::High
1350 );
1351 assert_eq!(
1352 normalize_auto_route_effort_for_provider(
1353 ApiProvider::Deepseek,
1354 ReasoningEffort::Medium
1355 ),
1356 ReasoningEffort::High
1357 );
1358 assert_eq!(
1359 normalize_auto_route_effort_for_provider(
1360 ApiProvider::OpenaiCodex,
1361 ReasoningEffort::Low
1362 ),
1363 ReasoningEffort::Low
1364 );
1365 assert_eq!(
1366 normalize_auto_route_effort_for_provider(
1367 ApiProvider::OpenaiCodex,
1368 ReasoningEffort::Medium
1369 ),
1370 ReasoningEffort::Medium
1371 );
1372 assert_eq!(
1373 normalize_auto_route_effort_for_provider(
1374 ApiProvider::OpenaiCodex,
1375 ReasoningEffort::Off
1376 ),
1377 ReasoningEffort::Low
1378 );
1379 }
1380
1381 #[test]
1382 fn configured_route_effort_normalizer_keeps_kimi_code_low_medium_local() {
1383 let mut config = Config {
1384 provider: Some("moonshot".to_string()),
1385 providers: Some(crate::config::ProvidersConfig {
1386 moonshot: crate::config::ProviderConfig {
1387 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1388 model: Some("k3".to_string()),
1389 ..Default::default()
1390 },
1391 ..Default::default()
1392 }),
1393 ..Default::default()
1394 };
1395 assert_eq!(
1396 normalize_auto_route_effort_for_configured_route(
1397 &config,
1398 ApiProvider::Moonshot,
1399 "k3",
1400 ReasoningEffort::Low,
1401 ),
1402 ReasoningEffort::Low
1403 );
1404 assert_eq!(
1405 normalize_auto_route_effort_for_configured_route(
1406 &config,
1407 ApiProvider::Moonshot,
1408 "k3",
1409 ReasoningEffort::Medium,
1410 ),
1411 ReasoningEffort::Medium
1412 );
1413
1414 config
1415 .providers
1416 .as_mut()
1417 .expect("providers")
1418 .moonshot
1419 .base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
1420 assert_eq!(
1421 normalize_auto_route_effort_for_configured_route(
1422 &config,
1423 ApiProvider::Moonshot,
1424 "k3",
1425 ReasoningEffort::Low,
1426 ),
1427 ReasoningEffort::High
1428 );
1429 }
1430
1431 #[test]
1432 fn inventory_auto_route_recommendation_requires_runnable_pair() {
1433 let _env_lock = crate::test_support::lock_test_env();
1434 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1435 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1436 let config = Config {
1437 provider: Some("zai".to_string()),
1438 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
1439 ..Default::default()
1440 };
1441 let inventory = ModelInventory::from_config(&config);
1442
1443 let route = parse_inventory_auto_route_recommendation(
1444 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
1445 &inventory,
1446 )
1447 .expect("valid inventory route should parse");
1448 assert_eq!(route.provider, ApiProvider::Zai);
1449 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
1450 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Max));
1451
1452 assert!(
1453 parse_inventory_auto_route_recommendation(
1454 r#"{"provider":"zai","model":"deepseek-v4-pro","thinking":"max"}"#,
1455 &inventory,
1456 )
1457 .is_none(),
1458 "router must not pair a DeepSeek model with the Z.ai provider"
1459 );
1460
1461 let wrapped = parse_inventory_auto_route_recommendation(
1462 r#"route: {"provider":"zai","model":"GLM-5-Turbo","reasoning_effort":"medium"}"#,
1463 &inventory,
1464 )
1465 .expect("wrapped inventory route should parse");
1466 assert_eq!(wrapped.provider, ApiProvider::Zai);
1467 assert_eq!(wrapped.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
1468 // Parsing is strict and literal; the historic Medium->High coercion
1469 // is applied downstream by normalize_auto_route_selection_for_config
1470 // so route-specific contracts (Kimi Code K3) can keep Medium.
1471 assert_eq!(wrapped.reasoning_effort, Some(ReasoningEffort::Medium));
1472 }
1473
1474 #[test]
1475 fn inventory_auto_route_recommendation_rejects_unready_candidate() {
1476 let _env_lock = crate::test_support::lock_test_env();
1477 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1478 let config = Config {
1479 provider: Some("zai".to_string()),
1480 ..Default::default()
1481 };
1482 let mut inventory = ModelInventory::from_config(&config);
1483 let candidate = inventory
1484 .candidates
1485 .iter_mut()
1486 .find(|candidate| {
1487 candidate.provider == ApiProvider::Zai
1488 && candidate.model == crate::config::ZAI_GLM_5_2_MODEL
1489 })
1490 .expect("Z.ai strong candidate");
1491 candidate.readiness = crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute;
1492
1493 assert!(
1494 parse_inventory_auto_route_recommendation(
1495 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
1496 &inventory,
1497 )
1498 .is_none(),
1499 "classifier output must not revive an unsupported route"
1500 );
1501 }
1502
1503 #[test]
1504 fn inventory_auto_route_recommendation_accepts_wanjie_v4_ids() {
1505 let _env_lock = crate::test_support::lock_test_env();
1506 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1507 let _wanjie = crate::test_support::EnvVarGuard::set("WANJIE_ARK_API_KEY", "wanjie-key");
1508 let config = Config {
1509 provider: Some("wanjie-ark".to_string()),
1510 ..Default::default()
1511 };
1512 let inventory = ModelInventory::from_config(&config);
1513
1514 let route = parse_inventory_auto_route_recommendation(
1515 r#"{"provider":"wanjie-ark","model":"deepseek-v4-pro","thinking":"max"}"#,
1516 &inventory,
1517 )
1518 .expect("Wanjie V4 Pro inventory route should parse");
1519 assert_eq!(route.provider, ApiProvider::WanjieArk);
1520 assert_eq!(route.model, "deepseek-v4-pro");
1521 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Max));
1522
1523 let route = parse_inventory_auto_route_recommendation(
1524 r#"{"provider":"wanjie-ark","model":"deepseek-v4-flash","thinking":"off"}"#,
1525 &inventory,
1526 )
1527 .expect("Wanjie V4 Flash inventory route should parse");
1528 assert_eq!(route.provider, ApiProvider::WanjieArk);
1529 assert_eq!(route.model, "deepseek-v4-flash");
1530 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Off));
1531 }
1532
1533 #[test]
1534 fn explicit_route_to_nonactive_provider_uses_that_providers_effort() {
1535 // Active provider is DeepSeek (whose effort floor is low/medium), but the
1536 // explicit model `GLM-5.2` only routes to Z.ai. The resolved effort must
1537 // be normalized for Z.ai — not left at DeepSeek's raw `low` setting.
1538 let _env_lock = crate::test_support::lock_test_env();
1539 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1540 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1541 let config = Config {
1542 provider: Some("deepseek".to_string()),
1543 reasoning_effort: Some("low".to_string()),
1544 ..Default::default()
1545 };
1546
1547 let route = resolve_explicit_route_with_inventory(&config, "GLM-5.2")
1548 .expect("explicit GLM route should resolve to its provider");
1549
1550 assert_eq!(
1551 route.provider,
1552 ApiProvider::Zai,
1553 "GLM-5.2 must route to Z.ai, not the active DeepSeek provider"
1554 );
1555 assert_eq!(
1556 route.reasoning_effort,
1557 Some(ReasoningEffort::High),
1558 "low must be normalized up to high for the Z.ai route, not passed through"
1559 );
1560
1561 // GLM-5.3 is a first-class peer: same provider ownership, same effort
1562 // normalization, and it must resolve to its own id (not fold into the
1563 // GLM-5.2 default).
1564 let route_53 = resolve_explicit_route_with_inventory(&config, "GLM-5.3")
1565 .expect("explicit GLM-5.3 route should resolve to its provider");
1566 assert_eq!(
1567 route_53.provider,
1568 ApiProvider::Zai,
1569 "GLM-5.3 must route to Z.ai, not the active DeepSeek provider"
1570 );
1571 assert_eq!(
1572 route_53.model,
1573 crate::config::ZAI_GLM_5_3_MODEL,
1574 "GLM-5.3 must keep its own id, not fall back to the GLM-5.2 default"
1575 );
1576 assert_eq!(route_53.reasoning_effort, Some(ReasoningEffort::High));
1577 }
1578
1579 #[tokio::test]
1580 #[allow(clippy::await_holding_lock)]
1581 async fn inventory_auto_route_resolves_active_authenticated_provider() {
1582 let _env_lock = crate::test_support::lock_test_env();
1583 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1584 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1585 let config = Config {
1586 provider: Some("zai".to_string()),
1587 ..Default::default()
1588 };
1589
1590 let route =
1591 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
1592 .await
1593 .expect("inventory route should resolve with authenticated active provider");
1594
1595 assert_eq!(route.provider, ApiProvider::Zai);
1596 assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
1597 assert_eq!(route.source, AutoRouteSource::Heuristic);
1598 let receipt = route.receipt.expect("Auto route receipt");
1599 assert_eq!(receipt.tier, AutoRouteTier::Fast);
1600 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
1601 assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic);
1602 assert_eq!(
1603 receipt.reason,
1604 AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::ShortRequest)
1605 );
1606 assert_eq!(receipt.pair.strong, crate::config::ZAI_GLM_5_2_MODEL);
1607 assert_eq!(
1608 receipt.pair.fast.as_deref(),
1609 Some(crate::config::ZAI_GLM_5_TURBO_MODEL)
1610 );
1611 }
1612
1613 #[test]
1614 fn classifier_receipt_discloses_active_provider_scope_and_data_path() {
1615 let _env_lock = crate::test_support::lock_test_env();
1616 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1617 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1618 let config = Config {
1619 provider: Some("zai".to_string()),
1620 ..Default::default()
1621 };
1622 let inventory = ModelInventory::from_config(&config);
1623 let recommendation = parse_inventory_auto_route_recommendation(
1624 r#"{"provider":"zai","model":"GLM-5-Turbo","thinking":"off"}"#,
1625 &inventory,
1626 )
1627 .expect("runnable classifier recommendation");
1628
1629 let route = auto_route_from_classifier(&inventory, recommendation);
1630
1631 assert_eq!(route.provider, ApiProvider::Zai);
1632 assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
1633 assert_eq!(route.source, AutoRouteSource::FlashRouter);
1634 let receipt = route.receipt.expect("classifier receipt");
1635 assert_eq!(receipt.tier, AutoRouteTier::Fast);
1636 // #4411: the classifier only saw Z.ai routes, so the receipt says so
1637 // instead of claiming the wider runnable-providers scope.
1638 assert_eq!(receipt.scope, AutoRouteScope::ActiveProvider);
1639 assert_eq!(
1640 receipt.data_path,
1641 AutoRouteDataPath::Classifier {
1642 provider: ApiProvider::Deepseek,
1643 model: "deepseek-v4-flash".to_string(),
1644 }
1645 );
1646 assert_eq!(receipt.reason, AutoRouteReason::ClassifierRecommendation);
1647 }
1648
1649 #[test]
1650 fn classifier_recommendation_for_another_provider_is_refused_by_default() {
1651 // #4411: the payload never named DeepSeek, but a classifier can still
1652 // emit one. The recommendation must not become a route unless the
1653 // persisted cross-provider opt-in is set.
1654 let _env_lock = crate::test_support::lock_test_env();
1655 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1656 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1657 let scoped = Config {
1658 provider: Some("zai".to_string()),
1659 ..Default::default()
1660 };
1661 let scoped_inventory = ModelInventory::from_config(&scoped);
1662 let raw = r#"{"provider":"deepseek","model":"deepseek-v4-flash","thinking":"off"}"#;
1663
1664 assert!(
1665 parse_inventory_auto_route_recommendation(raw, &scoped_inventory).is_none(),
1666 "cross-provider classifier output must be refused by default"
1667 );
1668 // The same inventory still accepts an in-scope active-provider route.
1669 assert!(
1670 parse_inventory_auto_route_recommendation(
1671 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
1672 &scoped_inventory,
1673 )
1674 .is_some()
1675 );
1676
1677 let opted_in = Config {
1678 auto: Some(crate::config::AutoConfig {
1679 cost_saving: None,
1680 cross_provider: Some(true),
1681 router: None,
1682 }),
1683 ..scoped.clone()
1684 };
1685 let opted_in_route =
1686 parse_inventory_auto_route_recommendation(raw, &ModelInventory::from_config(&opted_in))
1687 .expect("opt-in admits the cross-provider recommendation");
1688 assert_eq!(opted_in_route.provider, ApiProvider::Deepseek);
1689 }
1690
1691 #[test]
1692 fn classifier_prompt_declares_active_provider_scope_by_default() {
1693 let _env_lock = crate::test_support::lock_test_env();
1694 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1695 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1696 let config = Config {
1697 provider: Some("zai".to_string()),
1698 ..Default::default()
1699 };
1700
1701 let prompt =
1702 inventory_auto_router_system_prompt(&ModelInventory::from_config(&config), false);
1703
1704 assert!(
1705 prompt.contains("Auto routing is scoped to the active provider `zai`"),
1706 "{prompt}"
1707 );
1708 assert!(!prompt.contains("deepseek"), "{prompt}");
1709 }
1710
1711 #[tokio::test]
1712 #[allow(clippy::await_holding_lock)]
1713 async fn active_provider_strong_fast_selection_survives_scoping() {
1714 // Same-provider tier selection is the behavior scoping must not
1715 // break: a complex request still reaches the active provider's strong
1716 // tier, a trivial one still reaches its fast tier (#4411).
1717 let _env_lock = crate::test_support::lock_test_env();
1718 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
1719 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1720 let config = Config {
1721 provider: Some("zai".to_string()),
1722 ..Default::default()
1723 };
1724
1725 let strong = resolve_auto_route_with_inventory(
1726 &config,
1727 "refactor the routing module and audit its security boundaries",
1728 "",
1729 "auto",
1730 "auto",
1731 )
1732 .await
1733 .expect("strong-tier route");
1734 assert_eq!(strong.provider, ApiProvider::Zai);
1735 assert_eq!(strong.model, crate::config::ZAI_GLM_5_2_MODEL);
1736 let strong_receipt = strong.receipt.expect("strong receipt");
1737 assert_eq!(strong_receipt.tier, AutoRouteTier::Strong);
1738 assert_eq!(strong_receipt.scope, AutoRouteScope::ResolvedProvider);
1739
1740 let fast = resolve_auto_route_with_inventory(&config, "hi", "", "auto", "auto")
1741 .await
1742 .expect("fast-tier route");
1743 assert_eq!(fast.provider, ApiProvider::Zai);
1744 assert_eq!(fast.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
1745 assert_eq!(
1746 fast.receipt.expect("fast receipt").tier,
1747 AutoRouteTier::Fast
1748 );
1749 }
1750
1751 #[test]
1752 fn config_auto_cross_provider_defaults_to_false() {
1753 assert!(!Config::default().auto_cross_provider());
1754 let opted_in = Config {
1755 auto: Some(crate::config::AutoConfig {
1756 cost_saving: None,
1757 cross_provider: Some(true),
1758 router: None,
1759 }),
1760 ..Default::default()
1761 };
1762 assert!(opted_in.auto_cross_provider());
1763 }
1764
1765 #[test]
1766 fn classifier_receipt_never_reports_openrouter_default_for_another_family() {
1767 let _env_lock = crate::test_support::lock_test_env();
1768 let _openrouter =
1769 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "openrouter-key");
1770 let config = Config {
1771 provider: Some("openrouter".to_string()),
1772 ..Default::default()
1773 };
1774 let inventory = ModelInventory::from_config(&config);
1775 let recommendation = parse_inventory_auto_route_recommendation(
1776 r#"{"provider":"openrouter","model":"z-ai/glm-5.2","thinking":"max"}"#,
1777 &inventory,
1778 )
1779 .expect("runnable non-default OpenRouter family");
1780
1781 let route = auto_route_from_classifier(&inventory, recommendation);
1782 let receipt = route.receipt.expect("classifier receipt");
1783
1784 assert_eq!(route.model, crate::config::OPENROUTER_GLM_5_2_MODEL);
1785 assert_eq!(receipt.pair.strong, crate::config::OPENROUTER_GLM_5_2_MODEL);
1786 assert_ne!(
1787 receipt.pair.fast.as_deref(),
1788 Some(crate::config::DEFAULT_OPENROUTER_FLASH_MODEL),
1789 "a GLM selection must not be described as the DeepSeek default pair"
1790 );
1791 assert!(matches!(
1792 receipt.tier,
1793 AutoRouteTier::Strong | AutoRouteTier::Only
1794 ));
1795 }
1796
1797 #[test]
1798 fn classifier_fallback_preserves_attempted_data_path_without_error_text() {
1799 let _env_lock = crate::test_support::lock_test_env();
1800 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1801 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1802 let config = Config {
1803 provider: Some("zai".to_string()),
1804 ..Default::default()
1805 };
1806 let inventory = ModelInventory::from_config(&config);
1807 let heuristic = auto_route_from_inventory_heuristic(&config, "quick status", &inventory);
1808
1809 let route = auto_route_classifier_fallback(heuristic, &inventory);
1810
1811 assert_eq!(route.source, AutoRouteSource::Heuristic);
1812 let receipt = route.receipt.expect("fallback receipt");
1813 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
1814 assert!(matches!(
1815 receipt.data_path,
1816 AutoRouteDataPath::Classifier {
1817 provider: ApiProvider::Deepseek,
1818 ref model,
1819 } if model == "deepseek-v4-flash"
1820 ));
1821 assert_eq!(
1822 receipt.reason,
1823 AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::ShortRequest)
1824 );
1825 assert!(!receipt.reason.label().contains("secret-provider-error"));
1826 }
1827
1828 #[tokio::test]
1829 #[allow(clippy::await_holding_lock)]
1830 async fn inventory_auto_route_never_falls_back_across_providers_by_default() {
1831 // #4411: the active provider has no usable credential, but another
1832 // provider does. Auto must stay on the active provider and report a
1833 // no-runnable-candidate heuristic instead of silently spending the
1834 // other provider's key.
1835 let _env_lock = crate::test_support::lock_test_env();
1836 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1837 let _zai = crate::test_support::EnvVarGuard::remove("ZAI_API_KEY");
1838 let config = Config {
1839 provider: Some("zai".to_string()),
1840 ..Default::default()
1841 };
1842
1843 let route =
1844 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
1845 .await
1846 .expect("inventory route should resolve without leaving the active provider");
1847
1848 assert_eq!(route.provider, ApiProvider::Zai);
1849 assert_ne!(route.provider, ApiProvider::Deepseek);
1850 assert_eq!(route.source, AutoRouteSource::Heuristic);
1851 let receipt = route.receipt.expect("Auto route receipt");
1852 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
1853 assert_eq!(
1854 receipt.reason,
1855 AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate)
1856 );
1857 }
1858
1859 #[tokio::test]
1860 #[allow(clippy::await_holding_lock)]
1861 async fn inventory_auto_route_crosses_providers_only_under_persisted_opt_in() {
1862 // The same configuration as above, plus the persisted
1863 // `[auto] cross_provider = true` opt-in (#4411).
1864 let _env_lock = crate::test_support::lock_test_env();
1865 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1866 let _zai = crate::test_support::EnvVarGuard::remove("ZAI_API_KEY");
1867 let config = Config {
1868 provider: Some("zai".to_string()),
1869 auto: Some(crate::config::AutoConfig {
1870 cost_saving: None,
1871 cross_provider: Some(true),
1872 router: None,
1873 }),
1874 ..Default::default()
1875 };
1876
1877 let route =
1878 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
1879 .await
1880 .expect("opted-in route should fall back to an authenticated provider");
1881
1882 assert_eq!(route.provider, ApiProvider::Deepseek);
1883 assert_eq!(route.model, "deepseek-v4-flash");
1884 assert_eq!(route.source, AutoRouteSource::Heuristic);
1885 }
1886
1887 #[tokio::test]
1888 #[allow(clippy::await_holding_lock)]
1889 async fn inventory_auto_route_cost_saving_changes_borderline_zai_route() {
1890 let _env_lock = crate::test_support::lock_test_env();
1891 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
1892 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1893 let balanced = Config {
1894 provider: Some("zai".to_string()),
1895 ..Default::default()
1896 };
1897 let cost_saving = Config {
1898 auto: Some(crate::config::AutoConfig {
1899 cost_saving: Some(true),
1900 cross_provider: None,
1901 router: None,
1902 }),
1903 ..balanced.clone()
1904 };
1905
1906 let balanced_route = resolve_auto_route_with_inventory(
1907 &balanced,
1908 "Please implement a binary search",
1909 "",
1910 "auto",
1911 "auto",
1912 )
1913 .await
1914 .expect("balanced Auto route should resolve");
1915 let cost_saving_route = resolve_auto_route_with_inventory(
1916 &cost_saving,
1917 "Please implement a binary search",
1918 "",
1919 "auto",
1920 "auto",
1921 )
1922 .await
1923 .expect("cost-saving Auto route should resolve");
1924
1925 assert_eq!(balanced_route.provider, ApiProvider::Zai);
1926 assert_eq!(balanced_route.model, crate::config::ZAI_GLM_5_2_MODEL);
1927 assert_eq!(cost_saving_route.provider, ApiProvider::Zai);
1928 assert_eq!(
1929 cost_saving_route.model,
1930 crate::config::ZAI_GLM_5_TURBO_MODEL
1931 );
1932 assert_eq!(cost_saving_route.source, AutoRouteSource::Heuristic);
1933 assert_eq!(
1934 balanced_route
1935 .receipt
1936 .as_ref()
1937 .map(|receipt| (receipt.tier, receipt.reason)),
1938 Some((
1939 AutoRouteTier::Strong,
1940 AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::ComplexRequest),
1941 ))
1942 );
1943 assert_eq!(
1944 cost_saving_route
1945 .receipt
1946 .as_ref()
1947 .map(|receipt| (receipt.tier, receipt.reason)),
1948 Some((
1949 AutoRouteTier::Fast,
1950 AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::CostSavingPolicy),
1951 ))
1952 );
1953 }
1954
1955 #[tokio::test]
1956 #[allow(clippy::await_holding_lock)]
1957 async fn inventory_auto_route_uses_wanjie_v4_pair_without_deepseek_router() {
1958 let _env_lock = crate::test_support::lock_test_env();
1959 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
1960 let _wanjie = crate::test_support::EnvVarGuard::set("WANJIE_ARK_API_KEY", "wanjie-key");
1961 let config = Config {
1962 provider: Some("wanjie-ark".to_string()),
1963 default_text_model: Some("auto".to_string()),
1964 ..Default::default()
1965 };
1966
1967 let route =
1968 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
1969 .await
1970 .expect("heuristic-only Wanjie route should resolve");
1971 assert_eq!(route.provider, ApiProvider::WanjieArk);
1972 assert_eq!(route.model, "deepseek-v4-flash");
1973 assert_eq!(route.source, AutoRouteSource::Heuristic);
1974
1975 let route = resolve_auto_route_with_inventory(
1976 &config,
1977 "please refactor this architecture",
1978 "",
1979 "auto",
1980 "auto",
1981 )
1982 .await
1983 .expect("complex Wanjie route should resolve");
1984 assert_eq!(route.provider, ApiProvider::WanjieArk);
1985 assert_eq!(route.model, "deepseek-v4-pro");
1986 assert_eq!(route.source, AutoRouteSource::Heuristic);
1987 }
1988
1989 #[tokio::test]
1990 #[allow(clippy::await_holding_lock)]
1991 async fn inventory_auto_route_uses_volcengine_v4_pair_without_deepseek_router() {
1992 let _env_lock = crate::test_support::lock_test_env();
1993 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
1994 let _volcengine =
1995 crate::test_support::EnvVarGuard::set("VOLCENGINE_API_KEY", "volcengine-key");
1996 let config = Config {
1997 provider: Some("volcengine".to_string()),
1998 default_text_model: Some("auto".to_string()),
1999 ..Default::default()
2000 };
2001
2002 let route =
2003 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
2004 .await
2005 .expect("heuristic-only Volcengine route should resolve");
2006 assert_eq!(route.provider, ApiProvider::Volcengine);
2007 assert_eq!(route.model, "DeepSeek-V4-Flash");
2008 assert_eq!(route.source, AutoRouteSource::Heuristic);
2009
2010 let route = resolve_auto_route_with_inventory(
2011 &config,
2012 "please refactor this architecture",
2013 "",
2014 "auto",
2015 "auto",
2016 )
2017 .await
2018 .expect("complex Volcengine route should resolve");
2019 assert_eq!(route.provider, ApiProvider::Volcengine);
2020 assert_eq!(route.model, "DeepSeek-V4-Pro");
2021 assert_eq!(route.source, AutoRouteSource::Heuristic);
2022 }
2023
2024 #[test]
2025 fn auto_heuristic_default_routes_implement_to_pro() {
2026 assert_eq!(
2027 auto_model_heuristic_with_bias("Please implement a binary search", "auto", false),
2028 "deepseek-v4-pro"
2029 );
2030 }
2031
2032 #[test]
2033 fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() {
2034 assert_eq!(
2035 auto_model_heuristic_with_bias("Please implement a binary search", "auto", true),
2036 "deepseek-v4-flash"
2037 );
2038 assert_eq!(
2039 auto_model_heuristic_with_bias("analyze this snippet", "auto", true),
2040 "deepseek-v4-flash"
2041 );
2042 }
2043
2044 #[test]
2045 fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() {
2046 for kw in [
2047 "refactor",
2048 "architecture",
2049 "design",
2050 "debug",
2051 "security",
2052 "review",
2053 "audit",
2054 "migrate",
2055 "optimize",
2056 "rewrite",
2057 ] {
2058 let req = format!("Please {kw} this module");
2059 assert_eq!(
2060 auto_model_heuristic_with_bias(&req, "auto", true),
2061 "deepseek-v4-pro",
2062 "expected Pro for strong keyword `{kw}` even in cost-saving mode"
2063 );
2064 }
2065 }
2066
2067 #[test]
2068 fn auto_heuristic_cost_saving_raises_long_message_threshold() {
2069 let body = "filler sentence. ".repeat(40);
2070 assert_eq!(
2071 auto_model_heuristic_with_bias(&body, "auto", false),
2072 "deepseek-v4-pro"
2073 );
2074 assert_eq!(
2075 auto_model_heuristic_with_bias(&body, "auto", true),
2076 "deepseek-v4-flash"
2077 );
2078 }
2079
2080 #[test]
2081 fn provider_router_candidates_cover_known_provider_classes() {
2082 use crate::config::ApiProvider;
2083
2084 let deepseek = provider_router_candidates(ApiProvider::Deepseek, "deepseek-v4-pro");
2085 assert_eq!(deepseek.big, "deepseek-v4-pro");
2086 assert_eq!(deepseek.cheap.as_deref(), Some("deepseek-v4-flash"));
2087
2088 let openrouter =
2089 provider_router_candidates(ApiProvider::Openrouter, "deepseek/deepseek-v4-pro");
2090 assert_eq!(openrouter.big, "deepseek/deepseek-v4-pro");
2091 assert_eq!(
2092 openrouter.cheap.as_deref(),
2093 Some("deepseek/deepseek-v4-flash")
2094 );
2095
2096 let wanjie = provider_router_candidates(ApiProvider::WanjieArk, "deepseek-reasoner");
2097 assert_eq!(wanjie.big, "deepseek-v4-pro");
2098 assert_eq!(wanjie.cheap.as_deref(), Some("deepseek-v4-flash"));
2099
2100 let volcengine = provider_router_candidates(ApiProvider::Volcengine, "DeepSeek-V4-Pro");
2101 assert_eq!(volcengine.big, "DeepSeek-V4-Pro");
2102 assert_eq!(volcengine.cheap.as_deref(), Some("DeepSeek-V4-Flash"));
2103
2104 let zai = provider_router_candidates(ApiProvider::Zai, "GLM-5.2");
2105 assert_eq!(zai.big, "GLM-5.2");
2106 // GLM-5.2 faster/explore children route to GLM-5-Turbo (same-family fast
2107 // sibling), not back down to GLM-5.1.
2108 assert_eq!(zai.cheap.as_deref(), Some("GLM-5-Turbo"));
2109
2110 let openrouter_glm = provider_router_candidates(ApiProvider::Openrouter, "z-ai/glm-5.2");
2111 assert_eq!(openrouter_glm.big, "z-ai/glm-5.2");
2112 assert_eq!(openrouter_glm.cheap.as_deref(), Some("z-ai/glm-5-turbo"));
2113
2114 // GLM-5.3 inherits the same fast sibling without displacing GLM-5.2's.
2115 let zai_53 = provider_router_candidates(ApiProvider::Zai, "GLM-5.3");
2116 assert_eq!(zai_53.big, "GLM-5.3");
2117 assert_eq!(zai_53.cheap.as_deref(), Some("GLM-5-Turbo"));
2118
2119 let openrouter_glm_53 = provider_router_candidates(ApiProvider::Openrouter, "z-ai/glm-5.3");
2120 assert_eq!(openrouter_glm_53.big, "z-ai/glm-5.3");
2121 assert_eq!(openrouter_glm_53.cheap.as_deref(), Some("z-ai/glm-5-turbo"));
2122
2123 // GLM-5.1 has no cheaper tier; faster children stay on the parent.
2124 let zai_51 = provider_router_candidates(ApiProvider::Zai, "GLM-5.1");
2125 assert_eq!(zai_51.big, "GLM-5.1");
2126 assert_eq!(zai_51.cheap, None);
2127
2128 // GLM-5-Turbo is itself the cheap tier; no further downgrade.
2129 let zai_turbo = provider_router_candidates(ApiProvider::Zai, "GLM-5-Turbo");
2130 assert_eq!(zai_turbo.big, "GLM-5-Turbo");
2131 assert_eq!(zai_turbo.cheap, None);
2132
2133 // Providers without a known cheap tier: big = session model, no cheap.
2134 let ollama = provider_router_candidates(ApiProvider::Ollama, "qwen3:32b");
2135 assert_eq!(ollama.big, "qwen3:32b");
2136 assert_eq!(ollama.cheap, None);
2137
2138 let moonshot = provider_router_candidates(ApiProvider::Moonshot, "kimi-k2.6");
2139 assert_eq!(moonshot.big, "kimi-k2.6");
2140 assert_eq!(moonshot.cheap, None);
2141 }
2142
2143 #[test]
2144 fn provider_router_candidates_cover_catalog_fast_siblings() {
2145 use crate::config::ApiProvider;
2146
2147 let cases = [
2148 (ApiProvider::OpenaiCodex, "gpt-5.6-sol", "gpt-5.6-luna"),
2149 (
2150 ApiProvider::Anthropic,
2151 "claude-sonnet-4-6",
2152 "claude-haiku-4-5",
2153 ),
2154 (ApiProvider::XiaomiMimo, "mimo-v2.5-pro", "mimo-v2.5"),
2155 (ApiProvider::Arcee, "trinity-large-thinking", "trinity-mini"),
2156 (ApiProvider::Moonshot, "kimi-k2.7-code", "kimi-k2.6"),
2157 (
2158 ApiProvider::Minimax,
2159 "MiniMax-M2.7",
2160 "MiniMax-M2.7-highspeed",
2161 ),
2162 (ApiProvider::OpencodeGo, "kimi-k3", "kimi-k2.7-code"),
2163 (
2164 ApiProvider::Openrouter,
2165 "qwen/qwen3.6-max-preview",
2166 "qwen/qwen3.6-flash",
2167 ),
2168 (
2169 ApiProvider::Openrouter,
2170 "anthropic/claude-sonnet-4-6",
2171 "anthropic/claude-haiku-4-5",
2172 ),
2173 ];
2174
2175 for (provider, strong, fast) in cases {
2176 let candidates = provider_router_candidates(provider, strong);
2177 assert_eq!(candidates.big, strong);
2178 assert_eq!(candidates.cheap.as_deref(), Some(fast));
2179 assert_eq!(
2180 provider_router_candidates(provider, fast).cheap,
2181 None,
2182 "already-fast model must not downgrade again: {provider:?}/{fast}"
2183 );
2184 }
2185
2186 for (provider, model) in [
2187 (ApiProvider::Ollama, "qwen3:32b"),
2188 (ApiProvider::Custom, "gpt-5.6-sol"),
2189 (ApiProvider::OpenaiCodex, "gpt-5.6-luna"),
2190 ] {
2191 assert_eq!(provider_router_candidates(provider, model).cheap, None);
2192 }
2193 }
2194
2195 #[test]
2196 fn heuristic_without_cheap_tier_always_returns_current_model() {
2197 // #3018 AC: Ollama + auto must never fabricate a DeepSeek id.
2198 let candidates = RouterCandidates {
2199 big: "qwen3:32b".to_string(),
2200 cheap: None,
2201 };
2202 for cost_saving in [false, true] {
2203 for prompt in [
2204 "hi",
2205 "please refactor the auth module for security",
2206 &"long filler sentence. ".repeat(60),
2207 ] {
2208 let model = auto_model_heuristic_with_bias_for_candidates(
2209 prompt,
2210 "qwen3:32b",
2211 cost_saving,
2212 &candidates,
2213 )
2214 .model;
2215 assert_eq!(model, "qwen3:32b", "prompt {prompt:?}");
2216 }
2217 }
2218 }
2219
2220 #[test]
2221 fn config_auto_cost_saving_defaults_to_false() {
2222 let cfg = Config::default();
2223 assert!(!cfg.auto_cost_saving());
2224 }
2225
2226 #[test]
2227 fn config_auto_cost_saving_reads_table() {
2228 let cfg = Config {
2229 auto: Some(crate::config::AutoConfig {
2230 cost_saving: Some(true),
2231 cross_provider: None,
2232 router: None,
2233 }),
2234 ..Default::default()
2235 };
2236 assert!(cfg.auto_cost_saving());
2237 }
2238 }
2239
2239 lines RUST