返回 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::CodewhaleClient;
12 use crate::config::{ApiProvider, Config, normalize_model_name_for_provider};
13 use crate::cost_status::{
14 EffectiveRouteEnvelope, EffectiveRouteUsage, RuntimeUsageDropRecord, RuntimeUsageRecord,
15 };
16 use crate::llm_client::LlmClient;
17 use crate::model_inventory::ModelInventory;
18 use crate::reasoning_preference::ReasoningEffort;
19 use codewhale_models::Role;
20 use codewhale_models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt};
21
22 /// Big/cheap model pair the auto-router may choose between for the active
23 /// provider (#3018).
24 ///
25 /// `cheap == None` means the provider has no known cheap tier: the local
26 /// fallback stays on the current model (only thinking effort varies) and the
27 /// network router is skipped entirely (#1549).
28 #[derive(Debug, Clone, PartialEq, Eq)]
29 pub(crate) struct RouterCandidates {
30 pub(crate) big: String,
31 pub(crate) cheap: Option<String>,
32 }
33
34 impl RouterCandidates {
35 pub(crate) fn deepseek() -> Self {
36 Self {
37 big: "deepseek-v4-pro".to_string(),
38 cheap: Some("deepseek-v4-flash".to_string()),
39 }
40 }
41 }
42
43 /// Return a provider-owned strong/fast pair for model families whose catalog
44 /// exposes more than one tier. The ids here are deliberately explicit: a
45 /// model name alone is not evidence that another provider can serve its
46 /// sibling, so unknown providers and unknown families remain single-tier.
47 fn catalog_family_candidates(
48 provider: ApiProvider,
49 current_model: &str,
50 ) -> Option<RouterCandidates> {
51 let normalized = normalize_model_name_for_provider(provider, current_model)
52 .unwrap_or_else(|| current_model.trim().to_string());
53 let lower = normalized.to_ascii_lowercase();
54
55 let cheap = match provider {
56 ApiProvider::Openai | ApiProvider::OpenaiCodex
57 if matches!(lower.as_str(), "gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra") =>
58 {
59 Some("gpt-5.6-luna".to_string())
60 }
61 ApiProvider::Anthropic
62 if matches!(
63 lower.as_str(),
64 "claude-opus-4-8" | "claude-sonnet-4-6" | "claude-sonnet-5"
65 ) =>
66 {
67 Some("claude-haiku-4-5".to_string())
68 }
69 ApiProvider::XiaomiMimo if lower == "mimo-v2.5-pro" => Some("mimo-v2.5".to_string()),
70 ApiProvider::Arcee
71 if matches!(
72 lower.as_str(),
73 "trinity-large-thinking" | "trinity-large-preview"
74 ) =>
75 {
76 Some("trinity-mini".to_string())
77 }
78 ApiProvider::Moonshot if lower == "kimi-k2.7-code" => Some("kimi-k2.6".to_string()),
79 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic if lower == "minimax-m2.7" => {
80 Some("MiniMax-M2.7-highspeed".to_string())
81 }
82 ApiProvider::OpencodeGo if lower == "kimi-k3" => Some("kimi-k2.7-code".to_string()),
83 ApiProvider::Openrouter
84 if lower == "qwen/qwen3.6-max-preview"
85 || lower == "qwen/qwen3.6-plus"
86 || lower == "qwen/qwen3.6-27b"
87 || lower == "qwen/qwen3.6-35b-a3b" =>
88 {
89 Some("qwen/qwen3.6-flash".to_string())
90 }
91 ApiProvider::Openrouter if lower == "xiaomi/mimo-v2.5-pro" => {
92 Some("xiaomi/mimo-v2.5".to_string())
93 }
94 ApiProvider::Openrouter
95 if matches!(
96 lower.as_str(),
97 "arcee-ai/trinity-large-thinking" | "arcee-ai/trinity-large-preview"
98 ) =>
99 {
100 Some("arcee-ai/trinity-mini".to_string())
101 }
102 ApiProvider::Openrouter if lower == "moonshotai/kimi-k2.7-code" => {
103 Some("moonshotai/kimi-k2.6".to_string())
104 }
105 ApiProvider::Openrouter
106 if lower == "anthropic/claude-opus-4-8"
107 || lower == "anthropic/claude-sonnet-4-6"
108 || lower == "anthropic/claude-sonnet-5" =>
109 {
110 Some("anthropic/claude-haiku-4-5".to_string())
111 }
112 _ => None,
113 }?;
114
115 Some(RouterCandidates {
116 big: normalized,
117 cheap: Some(cheap),
118 })
119 }
120
121 /// Derive the auto-router's candidate pair for the active provider (#3018).
122 ///
123 /// DeepSeek providers route between the canonical pro/flash pair. Hosted
124 /// routes with known wire ids for that pair (NVIDIA NIM, OpenRouter, Novita,
125 /// SiliconFlow, SGLang, vLLM, Wanjie Ark, Volcengine) use their provider
126 /// spellings. Every other provider has no known cheap tier: `big` is the
127 /// session model and `cheap` is `None`, so auto mode never fabricates a
128 /// DeepSeek id for a provider that cannot serve it.
129 pub(crate) fn provider_router_candidates(
130 provider: crate::config::ApiProvider,
131 current_model: &str,
132 ) -> RouterCandidates {
133 use crate::config::ApiProvider;
134 if let Some(candidates) = catalog_family_candidates(provider, current_model) {
135 return candidates;
136 }
137
138 if provider == ApiProvider::Zai {
139 let normalized = crate::config::normalize_model_name_for_provider(provider, current_model)
140 .unwrap_or_else(|| current_model.to_string());
141 return RouterCandidates {
142 // GLM-5.3 routes faster/explore children to GLM-5.3-Flash.
143 // GLM-5.2 still uses GLM-5-Turbo. Flash, Turbo, and 5.1 have no
144 // cheaper tier and keep children on the parent model.
145 cheap: if normalized == crate::config::ZAI_GLM_5_3_MODEL {
146 Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL.to_string())
147 } else if normalized == crate::config::ZAI_GLM_5_2_MODEL {
148 Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string())
149 } else {
150 None
151 },
152 big: normalized,
153 };
154 }
155
156 if provider == ApiProvider::Openrouter
157 && let Some(normalized) =
158 crate::config::normalize_model_name_for_provider(provider, current_model)
159 && matches!(
160 normalized.as_str(),
161 crate::config::OPENROUTER_GLM_5_1_MODEL
162 | crate::config::OPENROUTER_GLM_5_2_MODEL
163 | crate::config::OPENROUTER_GLM_5_3_MODEL
164 | crate::config::OPENROUTER_GLM_5_3_FLASH_MODEL
165 | crate::config::OPENROUTER_GLM_5_TURBO_MODEL
166 )
167 {
168 return RouterCandidates {
169 // z-ai/glm-5.3 routes faster children to z-ai/glm-5.3-flash;
170 // z-ai/glm-5.2 still uses z-ai/glm-5-turbo. Flash, turbo, and 5.1
171 // have no cheaper tier and keep children on parent.
172 cheap: if normalized == crate::config::OPENROUTER_GLM_5_3_MODEL {
173 Some(crate::config::OPENROUTER_GLM_5_3_FLASH_MODEL.to_string())
174 } else if normalized == crate::config::OPENROUTER_GLM_5_2_MODEL {
175 Some(crate::config::OPENROUTER_GLM_5_TURBO_MODEL.to_string())
176 } else {
177 None
178 },
179 big: normalized,
180 };
181 }
182
183 match provider {
184 ApiProvider::Deepseek | ApiProvider::DeepseekCN => RouterCandidates::deepseek(),
185 ApiProvider::NvidiaNim
186 | ApiProvider::Openrouter
187 | ApiProvider::Novita
188 | ApiProvider::Siliconflow
189 | ApiProvider::SiliconflowCn
190 | ApiProvider::Sglang
191 | ApiProvider::Vllm
192 | ApiProvider::WanjieArk
193 if current_model.to_ascii_lowercase().contains("deepseek") =>
194 {
195 RouterCandidates {
196 big: crate::config::wire_model_for_provider(provider, "deepseek-v4-pro"),
197 cheap: Some(crate::config::wire_model_for_provider(
198 provider,
199 "deepseek-v4-flash",
200 )),
201 }
202 }
203 ApiProvider::Volcengine if current_model.to_ascii_lowercase().contains("deepseek") => {
204 RouterCandidates {
205 big: crate::config::DEFAULT_VOLCENGINE_MODEL.to_string(),
206 cheap: Some(crate::config::DEFAULT_VOLCENGINE_FLASH_MODEL.to_string()),
207 }
208 }
209 _ => RouterCandidates {
210 big: current_model.to_string(),
211 cheap: None,
212 },
213 }
214 }
215
216 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
217 pub(crate) enum AutoRouteSource {
218 FlashRouter,
219 Heuristic,
220 }
221
222 impl AutoRouteSource {
223 #[must_use]
224 pub(crate) fn label(self) -> &'static str {
225 match self {
226 AutoRouteSource::FlashRouter => "classifier",
227 AutoRouteSource::Heuristic => "heuristic",
228 }
229 }
230 }
231
232 /// Provider-safe tier reported for the concrete Auto route.
233 ///
234 /// `Selected` is deliberately neutral: a classifier may choose a runnable
235 /// inventory model that is not part of a known strong/fast pair, and the UI
236 /// must not invent a tier from the model id.
237 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238 #[serde(rename_all = "snake_case")]
239 pub(crate) enum AutoRouteTier {
240 Strong,
241 Fast,
242 Only,
243 Selected,
244 }
245
246 impl AutoRouteTier {
247 #[must_use]
248 pub(crate) fn label(self) -> &'static str {
249 match self {
250 Self::Strong => "strong",
251 Self::Fast => "fast",
252 Self::Only => "only model",
253 Self::Selected => "selected",
254 }
255 }
256 }
257
258 /// Scope from which the concrete Auto route was selected.
259 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260 #[serde(rename_all = "snake_case")]
261 pub(crate) enum AutoRouteScope {
262 /// The network classifier could choose any runnable provider/model pair in
263 /// the redacted inventory. Only reachable under the persisted
264 /// `[auto] cross_provider = true` opt-in (#4411).
265 RunnableProviders,
266 /// The network classifier saw only the active provider's runnable routes —
267 /// the default Auto scope (#4411).
268 ActiveProvider,
269 /// The local declared fallback selected within one resolved route.
270 ResolvedProvider,
271 }
272
273 impl AutoRouteScope {
274 #[must_use]
275 pub(crate) fn label(self) -> &'static str {
276 match self {
277 Self::RunnableProviders => "runnable providers",
278 Self::ActiveProvider => "active provider only",
279 Self::ResolvedProvider => "resolved provider",
280 }
281 }
282 }
283
284 /// Non-secret data path used to make an Auto decision.
285 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286 #[serde(rename_all = "snake_case")]
287 pub(crate) enum AutoRouteDataPath {
288 LocalHeuristic,
289 Classifier {
290 provider: ApiProvider,
291 model: String,
292 },
293 }
294
295 impl AutoRouteDataPath {
296 #[must_use]
297 pub(crate) fn label(&self) -> String {
298 match self {
299 Self::LocalHeuristic => "local only (no router request)".to_string(),
300 Self::Classifier { provider, model } => format!(
301 "latest request + bounded recent context -> {} / {model}",
302 provider.display_name()
303 ),
304 }
305 }
306 }
307
308 /// Local signal that selected the provider-safe strong/fast candidate.
309 ///
310 /// Since the #6290 rework the local fallback never judges request content:
311 /// without the flash classifier there is no per-request signal, so the route
312 /// is the configured default (or the runnable fast sibling under the explicit
313 /// `[auto] cost_saving` opt-in). The content-derived variants below are never
314 /// constructed for new routes; they are retained so saved sessions from before
315 /// the rework still deserialize.
316 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317 #[serde(rename_all = "snake_case")]
318 pub(crate) enum AutoRouteHeuristicReason {
319 /// Legacy: the deleted keyword/length classifier judged the request
320 /// complex. Retained for saved-session serde compat only.
321 ComplexRequest,
322 /// Legacy: the deleted length rule judged the request short. Retained
323 /// for saved-session serde compat only.
324 ShortRequest,
325 /// Legacy: the deleted length rule judged the request long. Retained
326 /// for saved-session serde compat only.
327 LongRequest,
328 CostSavingPolicy,
329 /// Legacy: the deleted classifier judged the request routine. Retained
330 /// for saved-session serde compat only.
331 RoutineRequest,
332 NoFastSibling,
333 NoRunnableCandidate,
334 /// The configured default model: no classifier was available and no
335 /// content signal was consulted.
336 DeclaredDefault,
337 }
338
339 impl AutoRouteHeuristicReason {
340 #[must_use]
341 fn label(self) -> &'static str {
342 match self {
343 Self::ComplexRequest => "complex request",
344 Self::ShortRequest => "short request",
345 Self::LongRequest => "long request",
346 Self::CostSavingPolicy => "cost-saving policy",
347 Self::RoutineRequest => "routine request",
348 Self::NoFastSibling => "no runnable fast sibling",
349 Self::NoRunnableCandidate => "no runnable inventory candidate",
350 Self::DeclaredDefault => "configured default (no classifier)",
351 }
352 }
353 }
354
355 /// Why the route was selected. Classifier failures are intentionally
356 /// collapsed to a non-secret reason; provider errors and response bodies must
357 /// never enter diagnostics.
358 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359 #[serde(rename_all = "snake_case")]
360 pub(crate) enum AutoRouteReason {
361 ClassifierRecommendation,
362 /// The `local_heuristic` alias keeps sessions saved before the #6290
363 /// rework loadable; new sessions persist `local_fallback`.
364 #[serde(alias = "local_heuristic")]
365 LocalFallback(AutoRouteHeuristicReason),
366 ClassifierFallback(AutoRouteHeuristicReason),
367 }
368
369 impl AutoRouteReason {
370 #[must_use]
371 pub(crate) fn label(self) -> String {
372 match self {
373 Self::ClassifierRecommendation => "classifier recommendation".to_string(),
374 Self::LocalFallback(reason) => format!("local fallback: {}", reason.label()),
375 Self::ClassifierFallback(reason) => {
376 format!("classifier fallback: {}", reason.label())
377 }
378 }
379 }
380 }
381
382 /// Effective provider-scoped model pair used to classify the selected tier.
383 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384 pub(crate) struct AutoRoutePair {
385 pub(crate) strong: String,
386 pub(crate) fast: Option<String>,
387 }
388
389 /// Per-turn Auto routing diagnostics. Provider/model identity remains owned by
390 /// the authoritative runtime `TurnRoute`; this receipt only records how the
391 /// concrete route was chosen.
392 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393 pub(crate) struct AutoRouteReceipt {
394 pub(crate) tier: AutoRouteTier,
395 pub(crate) pair: AutoRoutePair,
396 pub(crate) scope: AutoRouteScope,
397 pub(crate) data_path: AutoRouteDataPath,
398 pub(crate) reason: AutoRouteReason,
399 }
400
401 #[derive(Debug, Clone, PartialEq, Eq)]
402 pub(crate) struct AutoRouteSelection {
403 pub(crate) provider: ApiProvider,
404 pub(crate) model: String,
405 pub(crate) reasoning_effort: Option<ReasoningEffort>,
406 pub(crate) source: AutoRouteSource,
407 /// Present for Auto decisions; explicit inventory lookups intentionally do
408 /// not pretend to be Auto routing receipts.
409 pub(crate) receipt: Option<AutoRouteReceipt>,
410 /// Provider calls made to choose this route. These are deliberately kept
411 /// separate from the selected parent route: a classifier may run on a
412 /// different provider/model/quote, so pricing it under the eventual turn
413 /// would double-charge the parent and lose the classifier's real route.
414 ///
415 /// Auto currently admits at most one classifier request per selection.
416 pub(crate) routed_usage: Vec<RuntimeUsageRecord>,
417 /// Exact frozen routes for admitted classifier calls whose provider
418 /// response omitted usage. The count below remains authoritative and may
419 /// exceed this bounded vector after overflow.
420 pub(crate) routed_usage_drop_records: Vec<RuntimeUsageDropRecord>,
421 /// Classifier requests admitted to dispatch whose response usage could not
422 /// be recovered (timeout/transport failure). Consumers must surface this
423 /// as incomplete coverage rather than silently treating it as zero spend.
424 pub(crate) routed_usage_dropped_records: u64,
425 }
426
427 fn extract_first_json_object(raw: &str) -> Option<&str> {
428 let start = raw.find('{')?;
429 let end = raw.rfind('}')?;
430 (end >= start).then_some(&raw[start..=end])
431 }
432
433 fn parse_auto_route_reasoning_effort(effort: &str) -> Option<ReasoningEffort> {
434 ReasoningEffort::parse_strict(effort).ok()
435 }
436
437 /// Normalize an Auto-route effort when only the provider is known.
438 ///
439 /// This delegates to the one authoritative normalizer,
440 /// [`ReasoningEffort::normalize_for_route`], with an unresolved route (empty
441 /// endpoint and wire model), so the Auto path cannot carry a second copy of
442 /// the historic `low | medium -> high` provider collapse (Slice 4, D2).
443 #[must_use]
444 pub(crate) fn normalize_auto_route_effort_for_provider(
445 provider: ApiProvider,
446 effort: ReasoningEffort,
447 ) -> ReasoningEffort {
448 effort.normalize_for_route(provider, "", "")
449 }
450
451 /// Select the reasoning request that accompanies an Auto-model route.
452 ///
453 /// Model routing and reasoning routing are independent. An explicit fixed
454 /// preference wins over the classifier's suggestion; an absent preference or
455 /// explicit `Auto` keeps reasoning under per-prompt control.
456 #[must_use]
457 pub(crate) fn resolve_auto_model_reasoning(
458 preference: Option<ReasoningEffort>,
459 routed: Option<ReasoningEffort>,
460 ) -> (Option<ReasoningEffort>, bool) {
461 match preference {
462 Some(
463 effort @ (ReasoningEffort::Off
464 | ReasoningEffort::Minimal
465 | ReasoningEffort::Low
466 | ReasoningEffort::Medium
467 | ReasoningEffort::High
468 | ReasoningEffort::XHigh
469 | ReasoningEffort::Ultra
470 | ReasoningEffort::Max),
471 ) => (Some(effort), false),
472 None | Some(ReasoningEffort::Auto) => (routed, true),
473 }
474 }
475
476 /// Route-aware equivalent of [`normalize_auto_route_effort_for_provider`].
477 /// The inventory knows the selected provider/model, and the route resolver
478 /// supplies the endpoint needed to distinguish Kimi Code's official bare-K3
479 /// contract from generic Moonshot.
480 #[must_use]
481 pub(crate) fn normalize_auto_route_effort_for_configured_route(
482 config: &Config,
483 provider: ApiProvider,
484 model: &str,
485 effort: ReasoningEffort,
486 ) -> ReasoningEffort {
487 crate::route_runtime::resolve_runtime_route(config, provider, Some(model))
488 .map(|route| {
489 effort.normalize_for_route(provider, &route.candidate.endpoint().base_url, &route.model)
490 })
491 .unwrap_or_else(|_| normalize_auto_route_effort_for_provider(provider, effort))
492 }
493
494 fn normalize_auto_route_selection_for_config(
495 config: &Config,
496 mut selection: AutoRouteSelection,
497 ) -> AutoRouteSelection {
498 selection.reasoning_effort = selection.reasoning_effort.map(|effort| {
499 normalize_auto_route_effort_for_configured_route(
500 config,
501 selection.provider,
502 &selection.model,
503 effort,
504 )
505 });
506 selection
507 }
508
509 #[derive(Debug, Clone, PartialEq, Eq)]
510 struct InventoryAutoRouteRecommendation {
511 provider: ApiProvider,
512 model: String,
513 reasoning_effort: Option<ReasoningEffort>,
514 }
515
516 /// One provider-backed classifier attempt. A provider-success response always
517 /// reaches this shape before its content is interpreted, so invalid JSON and
518 /// provider-declared incomplete output retain their exact routed usage.
519 #[derive(Debug, Clone, PartialEq, Eq)]
520 struct InventoryAutoRouteAttempt {
521 recommendation: Option<InventoryAutoRouteRecommendation>,
522 routed_usage: Vec<RuntimeUsageRecord>,
523 routed_usage_drop_records: Vec<RuntimeUsageDropRecord>,
524 routed_usage_dropped_records: u64,
525 }
526
527 pub(crate) async fn resolve_auto_route_with_inventory(
528 config: &Config,
529 latest_request: &str,
530 recent_context: &str,
531 selected_model_mode: &str,
532 selected_thinking_mode: &str,
533 ) -> Result<AutoRouteSelection> {
534 resolve_auto_route_with_inventory_for_session(
535 config,
536 latest_request,
537 recent_context,
538 "agent",
539 selected_model_mode,
540 selected_thinking_mode,
541 )
542 .await
543 }
544
545 pub(crate) async fn resolve_auto_route_with_inventory_for_session(
546 config: &Config,
547 latest_request: &str,
548 recent_context: &str,
549 session_mode: &str,
550 selected_model_mode: &str,
551 selected_thinking_mode: &str,
552 ) -> Result<AutoRouteSelection> {
553 resolve_auto_route_with_inventory_for_session_and_cache_policy(
554 config,
555 latest_request,
556 recent_context,
557 session_mode,
558 selected_model_mode,
559 selected_thinking_mode,
560 true,
561 )
562 .await
563 }
564
565 pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_policy(
566 config: &Config,
567 latest_request: &str,
568 recent_context: &str,
569 session_mode: &str,
570 selected_model_mode: &str,
571 selected_thinking_mode: &str,
572 allow_response_cache: bool,
573 ) -> Result<AutoRouteSelection> {
574 let inventory = ModelInventory::from_config(config);
575 if !inventory.router_available {
576 // Fall back to declared-default auto routing when the flash router
577 // is unavailable (e.g. non-DeepSeek providers like wanjie-ark).
578 return Ok(normalize_auto_route_selection_for_config(
579 config,
580 auto_route_declared_fallback(config, &inventory),
581 ));
582 }
583
584 let fallback = auto_route_declared_fallback(config, &inventory);
585 if cfg!(test) {
586 return Ok(normalize_auto_route_selection_for_config(config, fallback));
587 }
588
589 let selection = match auto_route_inventory_recommendation(
590 config,
591 &inventory,
592 latest_request,
593 recent_context,
594 session_mode,
595 selected_model_mode,
596 selected_thinking_mode,
597 allow_response_cache,
598 )
599 .await
600 {
601 Ok(attempt) => auto_route_from_classifier_attempt(fallback, &inventory, attempt),
602 // Client construction/preparation failed before a provider request was
603 // admitted. There is no provider usage to invent and no dropped
604 // response receipt to claim.
605 Err(_) => auto_route_classifier_fallback(fallback, &inventory),
606 };
607 Ok(normalize_auto_route_selection_for_config(config, selection))
608 }
609
610 pub(crate) fn resolve_explicit_route_with_inventory(
611 config: &Config,
612 requested_model: &str,
613 ) -> Option<AutoRouteSelection> {
614 let requested_model = requested_model.trim();
615 if requested_model.is_empty() || requested_model.eq_ignore_ascii_case("auto") {
616 return None;
617 }
618
619 let inventory = ModelInventory::from_config(config);
620 let active_provider = config.api_provider();
621
622 if let Some(candidate) = inventory.candidates.iter().find(|candidate| {
623 candidate.provider == active_provider
624 && explicit_model_matches_candidate(candidate, requested_model)
625 }) {
626 return Some(AutoRouteSelection {
627 provider: candidate.provider,
628 model: candidate.model.clone(),
629 reasoning_effort: config.reasoning_effort().map(|setting| {
630 normalize_auto_route_effort_for_configured_route(
631 config,
632 candidate.provider,
633 &candidate.model,
634 ReasoningEffort::from_setting(setting),
635 )
636 }),
637 source: AutoRouteSource::Heuristic,
638 receipt: None,
639 routed_usage: Vec::new(),
640 routed_usage_drop_records: Vec::new(),
641 routed_usage_dropped_records: 0,
642 });
643 }
644
645 let mut matches = inventory
646 .candidates
647 .iter()
648 .filter(|candidate| explicit_model_matches_candidate(candidate, requested_model));
649 let candidate = matches.next()?;
650 if matches.next().is_some() {
651 return None;
652 }
653
654 Some(AutoRouteSelection {
655 provider: candidate.provider,
656 model: candidate.model.clone(),
657 reasoning_effort: config.reasoning_effort().map(|setting| {
658 normalize_auto_route_effort_for_configured_route(
659 config,
660 candidate.provider,
661 &candidate.model,
662 ReasoningEffort::from_setting(setting),
663 )
664 }),
665 source: AutoRouteSource::Heuristic,
666 receipt: None,
667 routed_usage: Vec::new(),
668 routed_usage_drop_records: Vec::new(),
669 routed_usage_dropped_records: 0,
670 })
671 }
672
673 pub(crate) fn explicit_route_candidate_providers(
674 config: &Config,
675 requested_model: &str,
676 ) -> Vec<ApiProvider> {
677 let requested_model = requested_model.trim();
678 if requested_model.is_empty() || requested_model.eq_ignore_ascii_case("auto") {
679 return Vec::new();
680 }
681
682 let inventory = ModelInventory::from_config(config);
683 let mut providers = Vec::new();
684 for candidate in inventory
685 .candidates
686 .iter()
687 .filter(|candidate| explicit_model_matches_candidate(candidate, requested_model))
688 {
689 if !providers.contains(&candidate.provider) {
690 providers.push(candidate.provider);
691 }
692 }
693 providers
694 }
695
696 fn explicit_model_matches_candidate(
697 candidate: &crate::model_inventory::ModelRouteCandidate,
698 requested_model: &str,
699 ) -> bool {
700 candidate.model.eq_ignore_ascii_case(requested_model)
701 || normalize_model_name_for_provider(candidate.provider, requested_model)
702 .is_some_and(|model| candidate.model.eq_ignore_ascii_case(&model))
703 }
704
705 /// Declared local fallback for Auto routing when the flash classifier is
706 /// unavailable (or fails): the configured default model.
707 ///
708 /// There is no per-request signal here by design. Until the #6290 rework this
709 /// guessed cheap-vs-big from request wording (`COMPLEX_KEYWORDS` plus
710 /// char-length thresholds) — host-side semantic determinism that made cost
711 /// and quality depend on vocabulary. The only content-blind override is the
712 /// explicit `[auto] cost_saving` opt-in, which pins the runnable fast
713 /// sibling; providers without one stay on the default.
714 fn auto_route_declared_fallback(config: &Config, inventory: &ModelInventory) -> AutoRouteSelection {
715 let Some(active) = inventory.active_default() else {
716 let model = config.default_model();
717 return AutoRouteSelection {
718 provider: config.api_provider(),
719 receipt: Some(auto_route_receipt(
720 inventory,
721 config.api_provider(),
722 &model,
723 AutoRouteScope::ResolvedProvider,
724 AutoRouteDataPath::LocalHeuristic,
725 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate),
726 )),
727 model,
728 reasoning_effort: Some(crate::auto_reasoning::select()),
729 source: AutoRouteSource::Heuristic,
730 routed_usage: Vec::new(),
731 routed_usage_drop_records: Vec::new(),
732 routed_usage_dropped_records: 0,
733 };
734 };
735 let router_candidates = provider_router_candidates(active.provider, &active.model);
736 let runnable_fast = router_candidates.cheap.as_deref().filter(|model| {
737 inventory
738 .candidate(active.provider, model)
739 .is_some_and(|candidate| candidate.readiness.can_attempt())
740 });
741 let (model, reason) = if config.auto_cost_saving() {
742 match runnable_fast {
743 Some(cheap) => (
744 cheap.to_string(),
745 AutoRouteHeuristicReason::CostSavingPolicy,
746 ),
747 None => (
748 active.model.clone(),
749 AutoRouteHeuristicReason::NoFastSibling,
750 ),
751 }
752 } else {
753 (
754 active.model.clone(),
755 AutoRouteHeuristicReason::DeclaredDefault,
756 )
757 };
758 AutoRouteSelection {
759 provider: active.provider,
760 receipt: Some(auto_route_receipt(
761 inventory,
762 active.provider,
763 &model,
764 AutoRouteScope::ResolvedProvider,
765 AutoRouteDataPath::LocalHeuristic,
766 AutoRouteReason::LocalFallback(reason),
767 )),
768 model,
769 reasoning_effort: Some(crate::auto_reasoning::select()),
770 source: AutoRouteSource::Heuristic,
771 routed_usage: Vec::new(),
772 routed_usage_drop_records: Vec::new(),
773 routed_usage_dropped_records: 0,
774 }
775 }
776
777 fn auto_route_from_classifier(
778 inventory: &ModelInventory,
779 recommendation: InventoryAutoRouteRecommendation,
780 ) -> AutoRouteSelection {
781 let data_path = AutoRouteDataPath::Classifier {
782 provider: inventory.router_provider,
783 model: inventory.router_model.to_string(),
784 };
785 // Report the scope the classifier actually had, not the widest one it
786 // could ever have (#4411).
787 let scope = if inventory.cross_provider_auto {
788 AutoRouteScope::RunnableProviders
789 } else {
790 AutoRouteScope::ActiveProvider
791 };
792 AutoRouteSelection {
793 provider: recommendation.provider,
794 receipt: Some(auto_route_receipt(
795 inventory,
796 recommendation.provider,
797 &recommendation.model,
798 scope,
799 data_path,
800 AutoRouteReason::ClassifierRecommendation,
801 )),
802 model: recommendation.model,
803 reasoning_effort: recommendation.reasoning_effort,
804 source: AutoRouteSource::FlashRouter,
805 routed_usage: Vec::new(),
806 routed_usage_drop_records: Vec::new(),
807 routed_usage_dropped_records: 0,
808 }
809 }
810
811 fn auto_route_from_classifier_attempt(
812 fallback: AutoRouteSelection,
813 inventory: &ModelInventory,
814 attempt: InventoryAutoRouteAttempt,
815 ) -> AutoRouteSelection {
816 let InventoryAutoRouteAttempt {
817 recommendation,
818 routed_usage,
819 routed_usage_drop_records,
820 routed_usage_dropped_records,
821 } = attempt;
822 let mut selection = recommendation.map_or_else(
823 || auto_route_classifier_fallback(fallback, inventory),
824 |recommendation| auto_route_from_classifier(inventory, recommendation),
825 );
826 selection.routed_usage = routed_usage;
827 selection.routed_usage_drop_records = routed_usage_drop_records;
828 selection.routed_usage_dropped_records = routed_usage_dropped_records;
829 selection
830 }
831
832 fn auto_route_classifier_fallback(
833 mut fallback: AutoRouteSelection,
834 inventory: &ModelInventory,
835 ) -> AutoRouteSelection {
836 if let Some(receipt) = fallback.receipt.as_mut() {
837 let fallback_reason = match receipt.reason {
838 AutoRouteReason::LocalFallback(reason)
839 | AutoRouteReason::ClassifierFallback(reason) => reason,
840 AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::DeclaredDefault,
841 };
842 receipt.data_path = AutoRouteDataPath::Classifier {
843 provider: inventory.router_provider,
844 model: inventory.router_model.to_string(),
845 };
846 receipt.reason = AutoRouteReason::ClassifierFallback(fallback_reason);
847 }
848 fallback
849 }
850
851 fn auto_route_receipt(
852 inventory: &ModelInventory,
853 provider: ApiProvider,
854 selected_model: &str,
855 scope: AutoRouteScope,
856 data_path: AutoRouteDataPath,
857 reason: AutoRouteReason,
858 ) -> AutoRouteReceipt {
859 let pair = auto_route_pair(inventory, provider, selected_model);
860 let tier = if pair
861 .fast
862 .as_deref()
863 .is_some_and(|fast| fast.eq_ignore_ascii_case(selected_model))
864 {
865 AutoRouteTier::Fast
866 } else if pair.strong.eq_ignore_ascii_case(selected_model) {
867 if pair.fast.is_some() {
868 AutoRouteTier::Strong
869 } else {
870 AutoRouteTier::Only
871 }
872 } else {
873 AutoRouteTier::Selected
874 };
875 AutoRouteReceipt {
876 tier,
877 pair,
878 scope,
879 data_path,
880 reason,
881 }
882 }
883
884 fn auto_route_pair(
885 inventory: &ModelInventory,
886 provider: ApiProvider,
887 selected_model: &str,
888 ) -> AutoRoutePair {
889 // A provider can expose several unrelated model families. Derive the pair
890 // from a runnable candidate that actually contains the selected model,
891 // preferring a cheap-tier match before a strong-tier match. Falling back
892 // to the provider default would report a truthful provider with a false
893 // model family (for example OpenRouter GLM reported as DeepSeek).
894 let matching_pair = inventory
895 .candidates
896 .iter()
897 .filter(|candidate| candidate.provider == provider && candidate.readiness.can_attempt())
898 .map(|candidate| provider_router_candidates(provider, &candidate.model))
899 .find(|pair| {
900 pair.cheap
901 .as_deref()
902 .is_some_and(|fast| fast.eq_ignore_ascii_case(selected_model))
903 })
904 .or_else(|| {
905 inventory
906 .candidates
907 .iter()
908 .filter(|candidate| {
909 candidate.provider == provider && candidate.readiness.can_attempt()
910 })
911 .map(|candidate| provider_router_candidates(provider, &candidate.model))
912 .find(|pair| pair.big.eq_ignore_ascii_case(selected_model))
913 });
914 let Some(candidates) = matching_pair else {
915 return AutoRoutePair {
916 strong: selected_model.to_string(),
917 fast: None,
918 };
919 };
920 let Some(strong) = inventory
921 .candidate(provider, &candidates.big)
922 .filter(|candidate| candidate.readiness.can_attempt())
923 .map(|candidate| candidate.model.clone())
924 else {
925 return AutoRoutePair {
926 strong: selected_model.to_string(),
927 fast: None,
928 };
929 };
930 let fast = candidates.cheap.as_deref().and_then(|model| {
931 inventory
932 .candidate(provider, model)
933 .filter(|candidate| candidate.readiness.can_attempt())
934 .map(|candidate| candidate.model.clone())
935 });
936 AutoRoutePair { strong, fast }
937 }
938
939 fn auto_route_usage_has_reported_data(usage: &codewhale_models::Usage) -> bool {
940 usage.input_tokens > 0
941 || usage.output_tokens > 0
942 || usage.prompt_cache_hit_tokens.is_some()
943 || usage.prompt_cache_miss_tokens.is_some()
944 || usage.prompt_cache_write_tokens.is_some()
945 || usage.reasoning_tokens.is_some()
946 || usage.reasoning_replay_tokens.is_some()
947 || usage.server_tool_use.is_some()
948 }
949
950 /// Stable, persistence-safe identity for one classifier response. The raw
951 /// provider response id is hashed with the frozen dispatch route and instant;
952 /// neither it nor any custom route label crosses into telemetry/persistence.
953 fn auto_route_usage_source_id(route: &EffectiveRouteEnvelope, response_id: &str) -> String {
954 use sha2::{Digest as _, Sha256};
955
956 let mut digest = Sha256::new();
957 let dispatched_at = route.dispatched_at.to_rfc3339();
958 for part in [
959 b"codewhale:auto-route-classifier:v1".as_slice(),
960 route.provider.as_str().as_bytes(),
961 route.provider_identity.as_bytes(),
962 route.model.as_bytes(),
963 route
964 .endpoint_fingerprint
965 .as_deref()
966 .unwrap_or_default()
967 .as_bytes(),
968 dispatched_at.as_bytes(),
969 response_id.as_bytes(),
970 ] {
971 digest.update((part.len() as u64).to_le_bytes());
972 digest.update(part);
973 }
974 format!(
975 "auto-router:{}",
976 crate::hashing::hex_bytes(digest.finalize())
977 )
978 }
979
980 fn auto_route_attempt_from_response(
981 request_route: EffectiveRouteEnvelope,
982 response: &MessageResponse,
983 inventory: &ModelInventory,
984 ) -> InventoryAutoRouteAttempt {
985 // All-zero usage cannot price a routed segment. The dispatch caller owns
986 // the stronger cache/provenance context and must explicitly classify this
987 // as either a proven cache replay or missing provider billing evidence.
988 let routed_usage = auto_route_usage_has_reported_data(&response.usage)
989 .then(|| RuntimeUsageRecord {
990 source_id: auto_route_usage_source_id(&request_route, &response.id),
991 usage: EffectiveRouteUsage {
992 route: request_route.sanitized_for_persistence(),
993 usage: response.usage.clone(),
994 },
995 })
996 .into_iter()
997 .collect();
998 let recommendation =
999 (!codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()))
1000 .then(|| {
1001 parse_inventory_auto_route_recommendation(
1002 &message_response_text(response),
1003 inventory,
1004 )
1005 })
1006 .flatten();
1007 InventoryAutoRouteAttempt {
1008 recommendation,
1009 routed_usage,
1010 routed_usage_drop_records: Vec::new(),
1011 routed_usage_dropped_records: 0,
1012 }
1013 }
1014
1015 fn auto_route_attempt_from_provider_response(
1016 request_route: EffectiveRouteEnvelope,
1017 response: &MessageResponse,
1018 inventory: &ModelInventory,
1019 ) -> InventoryAutoRouteAttempt {
1020 let drop_route = request_route.sanitized_for_persistence();
1021 let mut attempt = auto_route_attempt_from_response(request_route, response, inventory);
1022 // This classifier request is currently not cacheable (temperature is
1023 // provider-default, not the deterministic Some(0.0) cache contract), so a
1024 // decoded all-zero response is missing provider billing evidence even when
1025 // the caller permits response-cache use. Do not silently reinterpret the
1026 // policy boolean as cache-hit provenance.
1027 if attempt.routed_usage.is_empty() {
1028 attempt.routed_usage_drop_records = vec![RuntimeUsageDropRecord {
1029 source_id: auto_route_usage_source_id(
1030 &drop_route,
1031 &format!("missing-usage:{}", response.id),
1032 ),
1033 route: drop_route,
1034 }];
1035 attempt.routed_usage_dropped_records = 1;
1036 }
1037 attempt
1038 }
1039
1040 fn auto_route_attempt_with_dropped_response(
1041 request_route: EffectiveRouteEnvelope,
1042 ) -> InventoryAutoRouteAttempt {
1043 let request_route = request_route.sanitized_for_persistence();
1044 InventoryAutoRouteAttempt {
1045 recommendation: None,
1046 routed_usage: Vec::new(),
1047 routed_usage_drop_records: vec![RuntimeUsageDropRecord {
1048 source_id: auto_route_usage_source_id(&request_route, "transport-error"),
1049 route: request_route,
1050 }],
1051 routed_usage_dropped_records: 1,
1052 }
1053 }
1054
1055 /// Prove that the deterministic request seam accepts this classifier request
1056 /// before capturing a quote or entering any provider permit/network path.
1057 fn preflight_auto_route_request(client: &CodewhaleClient, request: &MessageRequest) -> Result<()> {
1058 client.prepare_outbound_request(request.clone(), false)?;
1059 Ok(())
1060 }
1061
1062 #[allow(clippy::too_many_arguments)]
1063 async fn auto_route_inventory_recommendation(
1064 config: &Config,
1065 inventory: &ModelInventory,
1066 latest_request: &str,
1067 recent_context: &str,
1068 session_mode: &str,
1069 selected_model_mode: &str,
1070 selected_thinking_mode: &str,
1071 allow_response_cache: bool,
1072 ) -> Result<InventoryAutoRouteAttempt> {
1073 let mut router_config = config.clone();
1074 // The classifier runs on the inventory's router route: the explicit
1075 // [auto.router] route when configured, else the DeepSeek flash default.
1076 router_config.provider = Some(inventory.router_provider.as_str().to_string());
1077 router_config.default_text_model = Some(inventory.router_model.clone());
1078
1079 let client = CodewhaleClient::new(&router_config)?;
1080 let router_system = inventory_auto_router_system_prompt(inventory, config.auto_cost_saving());
1081 let router_prompt = classifier_prompt(
1082 &client,
1083 latest_request,
1084 recent_context,
1085 session_mode,
1086 selected_model_mode,
1087 selected_thinking_mode,
1088 );
1089 let max_tokens = client.effective_max_output_tokens(&inventory.router_model);
1090 let request = MessageRequest {
1091 model: inventory.router_model.to_string(),
1092 messages: vec![Message {
1093 role: Role::User,
1094 content: vec![ContentBlock::Text {
1095 text: router_prompt,
1096 cache_control: None,
1097 }],
1098 }],
1099 max_tokens,
1100 system: Some(SystemPrompt::Text(router_system)),
1101 tools: None,
1102 tool_choice: None,
1103 metadata: None,
1104 thinking: None,
1105 reasoning_effort: Some(
1106 inventory
1107 .router_thinking
1108 .clone()
1109 .unwrap_or_else(|| "off".to_string()),
1110 ),
1111 stream: Some(false),
1112 temperature: None,
1113 top_p: None,
1114 };
1115
1116 // Freeze pricing at the last application seam before the provider future
1117 // starts. Prompt shaping above may be slow and may overlap a catalog
1118 // refresh; completion-time mutable catalog state must never reprice this
1119 // already-admitted classifier request.
1120 preflight_auto_route_request(&client, &request)?;
1121 let request_route =
1122 client.effective_route_envelope(&inventory.router_model, chrono::Utc::now());
1123 let response = if allow_response_cache {
1124 tokio::time::timeout(
1125 Duration::from_secs(inventory.router_timeout_secs),
1126 client.create_message(request),
1127 )
1128 .await
1129 } else {
1130 tokio::time::timeout(
1131 Duration::from_secs(inventory.router_timeout_secs),
1132 client.create_message_without_response_cache(request),
1133 )
1134 .await
1135 };
1136 let response = match response {
1137 Ok(Ok(response)) => response,
1138 // The request crossed Codewhale's dispatch boundary, but no exact
1139 // provider usage came back. Preserve the fallback while explicitly
1140 // failing cost coverage closed.
1141 Ok(Err(_)) => return Ok(auto_route_attempt_with_dropped_response(request_route)),
1142 // The local deadline cancels the future and can fire while the request
1143 // is still waiting on an application/provider permit. With no response
1144 // evidence we must not invent a provider call or a missing-usage
1145 // receipt. Transport errors returned by the client remain the
1146 // conservative explicit-dropped path above.
1147 Err(_) => {
1148 return Ok(InventoryAutoRouteAttempt {
1149 recommendation: None,
1150 routed_usage: Vec::new(),
1151 routed_usage_drop_records: Vec::new(),
1152 routed_usage_dropped_records: 0,
1153 });
1154 }
1155 };
1156 Ok(auto_route_attempt_from_provider_response(
1157 request_route,
1158 &response,
1159 inventory,
1160 ))
1161 }
1162
1163 fn inventory_auto_router_system_prompt(inventory: &ModelInventory, cost_saving: bool) -> String {
1164 let mut prompt = if inventory.cross_provider_auto {
1165 String::new()
1166 } else {
1167 // The inventory JSON below is already scoped to the active provider
1168 // (#4411); say so, so the classifier does not try to name one it was
1169 // never shown.
1170 format!(
1171 "Auto routing is scoped to the active provider `{}`. Every model in the inventory \
1172 below belongs to it; never select another provider.\n\n",
1173 inventory.active_provider.as_str()
1174 )
1175 };
1176 prompt.push_str(&format!(
1177 "You are the codewhale model-routing classifier. Return only compact JSON: \
1178 {{\"provider\":\"<provider>\",\"model\":\"<model>\",\"thinking\":\"off|high|max\"}}.\n\
1179 Choose only provider/model pairs present in the inventory JSON. Use off only for trivial no-tool answers, \
1180 high for ordinary reasoning, and max for agentic, coding, multi-file, release, architecture, debugging, \
1181 security, tool-heavy, or uncertain work.\n\nInventory JSON:\n{}",
1182 inventory.router_context_json()
1183 ));
1184
1185 if cost_saving {
1186 let active_pair = inventory.active_default().and_then(|active| {
1187 let candidates = provider_router_candidates(active.provider, &active.model);
1188 let fast = candidates.cheap.as_deref()?;
1189 (inventory
1190 .candidate(active.provider, &candidates.big)
1191 .is_some_and(|candidate| candidate.readiness.can_attempt())
1192 && inventory
1193 .candidate(active.provider, fast)
1194 .is_some_and(|candidate| candidate.readiness.can_attempt()))
1195 .then_some((active.provider, candidates.big, fast.to_string()))
1196 });
1197
1198 if let Some((provider, strong, fast)) = active_pair {
1199 prompt.push_str(&format!(
1200 "\n\nCost-saving mode is ON. For the active provider `{}`, `{fast}` is the fast tier \
1201 and `{strong}` is the strong tier. Prefer `{fast}` for ambiguous, routine, or single-step work. \
1202 Select `{strong}` only when the request is unmistakably agentic, multi-step, architecture/design, \
1203 security review, debugging, or otherwise clearly beyond the fast tier. Keep the selected model paired \
1204 with provider `{}`.",
1205 provider.as_str(),
1206 provider.as_str()
1207 ));
1208 } else {
1209 prompt.push_str(
1210 "\n\nCost-saving mode is ON, but the active provider has no known runnable fast sibling. \
1211 Do not invent a model or cross-provider downgrade solely to save cost.",
1212 );
1213 }
1214 }
1215
1216 prompt
1217 }
1218
1219 fn parse_inventory_auto_route_recommendation(
1220 raw: &str,
1221 inventory: &ModelInventory,
1222 ) -> Option<InventoryAutoRouteRecommendation> {
1223 let json = extract_first_json_object(raw)?;
1224 let value: serde_json::Value = serde_json::from_str(json).ok()?;
1225 let provider = value
1226 .get("provider")
1227 .and_then(serde_json::Value::as_str)
1228 .and_then(ApiProvider::parse)?;
1229 // Defense in depth for #4411: the payload already hides other providers,
1230 // but a hallucinated (or stale) provider must not become a route either.
1231 if !inventory.auto_scope_allows(provider) {
1232 return None;
1233 }
1234 let model = value.get("model").and_then(serde_json::Value::as_str)?;
1235 let candidate = inventory
1236 .candidate(provider, model)
1237 .filter(|candidate| candidate.readiness.can_attempt())?;
1238 let reasoning_effort = value
1239 .get("thinking")
1240 .or_else(|| value.get("reasoning_effort"))
1241 .or_else(|| value.get("effort"))
1242 .and_then(serde_json::Value::as_str)
1243 .and_then(parse_auto_route_reasoning_effort);
1244
1245 Some(InventoryAutoRouteRecommendation {
1246 provider,
1247 model: candidate.model.clone(),
1248 reasoning_effort,
1249 })
1250 }
1251
1252 fn auto_route_prompt(
1253 latest_request: &str,
1254 recent_context: &str,
1255 session_mode: &str,
1256 selected_model_mode: &str,
1257 selected_thinking_mode: &str,
1258 ) -> String {
1259 format!(
1260 "Session mode: {}\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.",
1261 session_mode,
1262 selected_model_mode,
1263 selected_thinking_mode,
1264 if recent_context.trim().is_empty() {
1265 "No prior context."
1266 } else {
1267 recent_context
1268 },
1269 truncate_for_auto_router(latest_request, 4_000)
1270 )
1271 }
1272
1273 fn classifier_prompt(
1274 client: &CodewhaleClient,
1275 latest_request: &str,
1276 recent_context: &str,
1277 session_mode: &str,
1278 selected_model_mode: &str,
1279 selected_thinking_mode: &str,
1280 ) -> String {
1281 client.redact_model_bound_text(&auto_route_prompt(
1282 latest_request,
1283 recent_context,
1284 session_mode,
1285 selected_model_mode,
1286 selected_thinking_mode,
1287 ))
1288 }
1289
1290 fn message_response_text(response: &MessageResponse) -> String {
1291 let mut out = String::new();
1292 for block in &response.content {
1293 match block {
1294 ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => {
1295 append_router_text(&mut out, text);
1296 }
1297 ContentBlock::Thinking { thinking, .. } => {
1298 append_router_text(&mut out, thinking);
1299 }
1300 ContentBlock::ToolUse { name, .. } => {
1301 append_router_text(&mut out, &format!("[tool call: {name}]"));
1302 }
1303 _ => {}
1304 }
1305 }
1306 out
1307 }
1308
1309 fn append_router_text(out: &mut String, text: &str) {
1310 if !out.is_empty() {
1311 out.push('\n');
1312 }
1313 out.push_str(text);
1314 }
1315
1316 fn truncate_for_auto_router(text: &str, max_chars: usize) -> String {
1317 let mut chars = text.chars();
1318 let truncated: String = chars.by_ref().take(max_chars).collect();
1319 if chars.next().is_some() {
1320 format!("{truncated}...")
1321 } else {
1322 truncated
1323 }
1324 }
1325
1326 #[cfg(test)]
1327 mod tests {
1328 use super::*;
1329
1330 struct ProviderCatalogReset;
1331
1332 impl Drop for ProviderCatalogReset {
1333 fn drop(&mut self) {
1334 crate::provider_catalog_live::reset_cache_for_test();
1335 crate::provider_lake::clear_live_snapshot();
1336 }
1337 }
1338
1339 fn priced_openrouter_delta(
1340 model: &str,
1341 fingerprint: &str,
1342 fetched_at: u64,
1343 input: f64,
1344 output: f64,
1345 ) -> codewhale_config::catalog::ProviderCatalogDelta {
1346 use codewhale_config::catalog::{CatalogOffering, CatalogSource, ProviderCatalogDelta};
1347
1348 ProviderCatalogDelta {
1349 provider: ApiProvider::Openrouter.as_str().to_string(),
1350 base_url_fingerprint: fingerprint.to_string(),
1351 fetched_at,
1352 offerings: vec![CatalogOffering {
1353 provider: ApiProvider::Openrouter.as_str().to_string(),
1354 wire_model_id: model.to_string(),
1355 endpoint_key: "chat".to_string(),
1356 source: CatalogSource::Live {
1357 base_url_fingerprint: fingerprint.to_string(),
1358 fetched_at,
1359 },
1360 cost: Some(codewhale_config::models_dev::ModelsDevCost {
1361 input: Some(input),
1362 output: Some(output),
1363 cache_read: Some(input / 2.0),
1364 cache_write: None,
1365 }),
1366 ..CatalogOffering::default()
1367 }],
1368 }
1369 }
1370
1371 fn classifier_response(
1372 id: &str,
1373 text: &str,
1374 stop_reason: &str,
1375 usage: codewhale_models::Usage,
1376 ) -> MessageResponse {
1377 MessageResponse {
1378 id: id.to_string(),
1379 r#type: "message".to_string(),
1380 role: "assistant".to_string(),
1381 content: vec![ContentBlock::Text {
1382 text: text.to_string(),
1383 cache_control: None,
1384 }],
1385 model: "router-response-alias-must-not-price".to_string(),
1386 stop_reason: Some(stop_reason.to_string()),
1387 stop_sequence: None,
1388 container: None,
1389 usage,
1390 }
1391 }
1392
1393 #[test]
1394 fn classifier_semantic_fallbacks_keep_exact_quotes_and_replay_once() {
1395 let _env_lock = crate::test_support::lock_test_env();
1396 let _live = crate::provider_lake::lock_live_snapshot();
1397 let home = tempfile::tempdir().expect("test home");
1398 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1399 let _reset = ProviderCatalogReset;
1400 crate::provider_catalog_live::reset_cache_for_test();
1401 crate::provider_lake::clear_live_snapshot();
1402
1403 let model = "synthetic/openrouter-auto-classifier";
1404 let config = Config {
1405 provider: Some("openrouter".to_string()),
1406 providers: Some(crate::config::ProvidersConfig {
1407 openrouter: crate::config::ProviderConfig {
1408 api_key: Some("test-openrouter-key".to_string()),
1409 base_url: Some(crate::config::DEFAULT_OPENROUTER_BASE_URL.to_string()),
1410 model: Some(model.to_string()),
1411 ..Default::default()
1412 },
1413 ..Default::default()
1414 }),
1415 auto: Some(crate::config::AutoConfig {
1416 cost_saving: None,
1417 cross_provider: None,
1418 router: Some(crate::config::AutoRouterConfig {
1419 provider: Some("openrouter".to_string()),
1420 model: Some(model.to_string()),
1421 thinking: Some("off".to_string()),
1422 timeout_secs: None,
1423 }),
1424 }),
1425 ..Default::default()
1426 };
1427 let inventory = ModelInventory::from_config(&config);
1428 assert!(
1429 inventory
1430 .candidate(ApiProvider::Openrouter, model)
1431 .is_some()
1432 );
1433 let client = CodewhaleClient::new(&config).expect("OpenRouter classifier client");
1434 let fingerprint = codewhale_config::catalog::base_url_fingerprint(
1435 crate::config::DEFAULT_OPENROUTER_BASE_URL,
1436 );
1437 let first_at = chrono::Utc::now();
1438 let fetched_at = u64::try_from(first_at.timestamp()).expect("nonnegative timestamp");
1439
1440 crate::provider_catalog_live::record_success(priced_openrouter_delta(
1441 model,
1442 &fingerprint,
1443 fetched_at,
1444 1.0,
1445 4.0,
1446 ));
1447 let first_route = client.effective_route_envelope(model, first_at);
1448 let valid = auto_route_attempt_from_response(
1449 first_route,
1450 &classifier_response(
1451 "same-provider-response-id",
1452 &format!(r#"{{"provider":"openrouter","model":"{model}","thinking":"off"}}"#),
1453 "stop",
1454 codewhale_models::Usage {
1455 input_tokens: 10,
1456 output_tokens: 2,
1457 prompt_cache_hit_tokens: Some(3),
1458 ..Default::default()
1459 },
1460 ),
1461 &inventory,
1462 );
1463
1464 // Replace the live row in the same Unix second. The first routed
1465 // record must keep its old immutable revision and the new attempt must
1466 // freeze a distinct one at its own dispatch boundary.
1467 crate::provider_catalog_live::record_success(priced_openrouter_delta(
1468 model,
1469 &fingerprint,
1470 fetched_at,
1471 9.0,
1472 19.0,
1473 ));
1474 let second_at = first_at + chrono::Duration::nanoseconds(1);
1475 let invalid = auto_route_attempt_from_response(
1476 client.effective_route_envelope(model, second_at),
1477 &classifier_response(
1478 "same-provider-response-id",
1479 "not valid route json",
1480 "stop",
1481 codewhale_models::Usage {
1482 input_tokens: 11,
1483 output_tokens: 3,
1484 ..Default::default()
1485 },
1486 ),
1487 &inventory,
1488 );
1489 let incomplete = auto_route_attempt_from_response(
1490 client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(1)),
1491 &classifier_response(
1492 "same-provider-response-id",
1493 &format!(r#"{{"provider":"openrouter","model":"{model}"}}"#),
1494 "length",
1495 codewhale_models::Usage {
1496 input_tokens: 12,
1497 output_tokens: 4,
1498 ..Default::default()
1499 },
1500 ),
1501 &inventory,
1502 );
1503 let missing_usage_route =
1504 client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(2));
1505 let missing_usage = auto_route_attempt_from_provider_response(
1506 missing_usage_route.clone(),
1507 &classifier_response(
1508 "missing-usage-response-id",
1509 "not valid route json",
1510 "stop",
1511 codewhale_models::Usage::default(),
1512 ),
1513 &inventory,
1514 );
1515 assert!(missing_usage.routed_usage.is_empty());
1516 assert_eq!(missing_usage.routed_usage_dropped_records, 1);
1517 assert_eq!(missing_usage.routed_usage_drop_records.len(), 1);
1518 assert_eq!(
1519 missing_usage.routed_usage_drop_records[0].route,
1520 missing_usage_route.sanitized_for_persistence()
1521 );
1522 assert!(
1523 missing_usage.routed_usage_drop_records[0]
1524 .source_id
1525 .starts_with("auto-router:")
1526 );
1527 assert!(
1528 !missing_usage.routed_usage_drop_records[0]
1529 .source_id
1530 .contains("missing-usage-response-id")
1531 );
1532
1533 let transport = auto_route_attempt_with_dropped_response(
1534 client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(3)),
1535 );
1536 assert_eq!(transport.routed_usage_dropped_records, 1);
1537 assert_eq!(transport.routed_usage_drop_records.len(), 1);
1538 assert!(transport.routed_usage.is_empty());
1539
1540 let fallback = auto_route_declared_fallback(&config, &inventory);
1541 let valid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, valid);
1542 let invalid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, invalid);
1543 let incomplete = auto_route_from_classifier_attempt(fallback, &inventory, incomplete);
1544 assert_eq!(valid.source, AutoRouteSource::FlashRouter);
1545 for fallback in [&invalid, &incomplete] {
1546 assert_eq!(fallback.source, AutoRouteSource::Heuristic);
1547 assert!(matches!(
1548 fallback.receipt.as_ref().map(|receipt| receipt.reason),
1549 Some(AutoRouteReason::ClassifierFallback(_))
1550 ));
1551 assert_eq!(fallback.routed_usage.len(), 1);
1552 assert_eq!(fallback.routed_usage_dropped_records, 0);
1553 }
1554 assert_eq!(valid.routed_usage.len(), 1);
1555 assert_eq!(valid.routed_usage[0].usage.usage.input_tokens, 10);
1556 assert_eq!(
1557 valid.routed_usage[0].usage.usage.prompt_cache_hit_tokens,
1558 Some(3)
1559 );
1560
1561 let first_quote = valid.routed_usage[0]
1562 .usage
1563 .route
1564 .provider_live_pricing
1565 .as_ref()
1566 .expect("first exact quote");
1567 let second_quote = invalid.routed_usage[0]
1568 .usage
1569 .route
1570 .provider_live_pricing
1571 .as_ref()
1572 .expect("replacement exact quote");
1573 assert_ne!(first_quote.catalog_revision, second_quote.catalog_revision);
1574 assert_eq!(first_quote.input_per_million.as_deref(), Some("1"));
1575 assert_eq!(second_quote.input_per_million.as_deref(), Some("9"));
1576
1577 let records = valid
1578 .routed_usage
1579 .iter()
1580 .chain(&invalid.routed_usage)
1581 .chain(&incomplete.routed_usage)
1582 .cloned()
1583 .collect::<Vec<_>>();
1584 assert_eq!(records.len(), 3);
1585 assert!(records.iter().all(|record| {
1586 record.source_id.starts_with("auto-router:")
1587 && record.source_id.len() == "auto-router:".len() + 64
1588 && !record.source_id.contains("same-provider-response-id")
1589 }));
1590
1591 // Exercise the canonical sink exactly as selection consumers do:
1592 // replaying any record cannot add parent-route spend or a second
1593 // routed segment, while distinct dispatches remain distinct.
1594 let _cost_scope = crate::cost_status::test_scope();
1595 let owner = "auto-router-selection-test-owner";
1596 crate::cost_status::register_interactive_runtime_usage_sink(
1597 owner,
1598 crate::cost_status::scope_token(),
1599 );
1600 let lease = crate::cost_status::acquire_runtime_usage_lease(owner)
1601 .expect("runtime usage owner lease");
1602 for record in &records {
1603 for _ in 0..2 {
1604 crate::cost_status::report_effective_route_for_runtime(
1605 crate::cost_status::scope_token(),
1606 Some(lease.owner()),
1607 &record.source_id,
1608 &record.usage.route,
1609 &record.usage.usage,
1610 );
1611 }
1612 }
1613 crate::cost_status::finish_runtime_usage_owner(owner);
1614 drop(lease);
1615 let pending = crate::cost_status::drain();
1616 assert_eq!(pending.usage_source_fingerprints.len(), records.len());
1617 assert_eq!(pending.priced_turns, records.len() as u32);
1618 assert_eq!(pending.unpriced_turns, 0);
1619 }
1620
1621 #[test]
1622 fn auto_model_reasoning_keeps_model_and_thinking_choices_independent() {
1623 assert_eq!(
1624 resolve_auto_model_reasoning(Some(ReasoningEffort::Low), Some(ReasoningEffort::Max)),
1625 (Some(ReasoningEffort::Low), false)
1626 );
1627 assert_eq!(
1628 resolve_auto_model_reasoning(Some(ReasoningEffort::Auto), Some(ReasoningEffort::Max)),
1629 (Some(ReasoningEffort::Max), true)
1630 );
1631 assert_eq!(
1632 resolve_auto_model_reasoning(None, Some(ReasoningEffort::High)),
1633 (Some(ReasoningEffort::High), true)
1634 );
1635 }
1636
1637 #[test]
1638 fn auto_route_prompt_uses_current_session_mode() {
1639 let prompt = auto_route_prompt(
1640 "Please explain the change before editing files.",
1641 "No prior context.",
1642 "plan",
1643 "auto",
1644 "auto",
1645 );
1646
1647 assert!(
1648 prompt.starts_with("Session mode: plan\n"),
1649 "auto-route prompt should reflect the active session mode, got: {prompt}"
1650 );
1651 }
1652
1653 #[test]
1654 fn classifier_prompt_redacts_secret_after_tool_result_flattening() {
1655 let secret = "cw-router-secret-should-never-leave-process";
1656 let config = Config {
1657 api_key: Some(secret.to_string()),
1658 ..Default::default()
1659 };
1660 let client = CodewhaleClient::new(&config).expect("classifier client");
1661 // `recent_auto_router_context` converts ToolResult blocks into ordinary
1662 // text before this boundary. Exercise that exact flattened shape.
1663 let recent_context = format!("assistant: [tool result] token={secret}");
1664
1665 let prompt = classifier_prompt(
1666 &client,
1667 "continue the investigation",
1668 &recent_context,
1669 "agent",
1670 "auto",
1671 "auto",
1672 );
1673
1674 assert!(
1675 !prompt.contains(secret),
1676 "flattened tool-result secret leaked"
1677 );
1678 assert!(
1679 prompt.contains(codewhale_config::persistence::REDACTED),
1680 "secret should be visibly redacted"
1681 );
1682 assert!(prompt.contains("continue the investigation"));
1683 }
1684
1685 #[test]
1686 fn inventory_auto_router_prompt_names_cost_saving_zai_pair() {
1687 let _env_lock = crate::test_support::lock_test_env();
1688 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
1689 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1690 let config = Config {
1691 provider: Some("zai".to_string()),
1692 ..Default::default()
1693 };
1694 let inventory = ModelInventory::from_config(&config);
1695
1696 let balanced = inventory_auto_router_system_prompt(&inventory, false);
1697 let cost_saving = inventory_auto_router_system_prompt(&inventory, true);
1698
1699 assert!(!balanced.contains("Cost-saving mode is ON"));
1700 assert!(
1701 cost_saving.contains(
1702 "For the active provider `zai`, `GLM-5.3-Flash` is the fast tier and `GLM-5.3` is the strong tier"
1703 ),
1704 "cost-saving classifier policy must name the provider-safe pair: {cost_saving}"
1705 );
1706 assert!(
1707 cost_saving.contains("Keep the selected model paired with provider `zai`"),
1708 "cost-saving policy must preserve provider/model validation: {cost_saving}"
1709 );
1710 }
1711
1712 #[test]
1713 fn auto_route_effort_normalization_is_provider_aware() {
1714 // Slice 4, D2: the Auto path delegates to the canonical route
1715 // normalizer with an unresolved route. Two providers where the deleted
1716 // local copy disagreed with the authority are pinned explicitly.
1717 assert_eq!(
1718 normalize_auto_route_effort_for_provider(ApiProvider::Deepseek, ReasoningEffort::Low),
1719 ReasoningEffort::Low,
1720 "first-party DeepSeek documents low|high|max, so the canonical normalizer keeps low"
1721 );
1722 assert_eq!(
1723 normalize_auto_route_effort_for_provider(
1724 ApiProvider::Deepseek,
1725 ReasoningEffort::Medium
1726 ),
1727 ReasoningEffort::High
1728 );
1729 assert_eq!(
1730 normalize_auto_route_effort_for_provider(
1731 ApiProvider::OllamaCloud,
1732 ReasoningEffort::Minimal
1733 ),
1734 ReasoningEffort::Low,
1735 "OllamaCloud folds the Codewhale-only `minimal` spelling onto low"
1736 );
1737 assert_eq!(
1738 normalize_auto_route_effort_for_provider(
1739 ApiProvider::OllamaCloud,
1740 ReasoningEffort::Ultra
1741 ),
1742 ReasoningEffort::Max
1743 );
1744 // A provider with no exact-route rule keeps the historic collapse.
1745 assert_eq!(
1746 normalize_auto_route_effort_for_provider(ApiProvider::Moonshot, ReasoningEffort::Low),
1747 ReasoningEffort::High
1748 );
1749 assert_eq!(
1750 normalize_auto_route_effort_for_provider(ApiProvider::Moonshot, ReasoningEffort::Auto),
1751 ReasoningEffort::Auto
1752 );
1753 assert_eq!(
1754 normalize_auto_route_effort_for_provider(ApiProvider::Moonshot, ReasoningEffort::Max),
1755 ReasoningEffort::Max
1756 );
1757 // Codex keeps its provider-level mapping (off -> low, auto -> medium).
1758 assert_eq!(
1759 normalize_auto_route_effort_for_provider(
1760 ApiProvider::OpenaiCodex,
1761 ReasoningEffort::Low
1762 ),
1763 ReasoningEffort::Low
1764 );
1765 assert_eq!(
1766 normalize_auto_route_effort_for_provider(
1767 ApiProvider::OpenaiCodex,
1768 ReasoningEffort::Medium
1769 ),
1770 ReasoningEffort::Medium
1771 );
1772 assert_eq!(
1773 normalize_auto_route_effort_for_provider(
1774 ApiProvider::OpenaiCodex,
1775 ReasoningEffort::Off
1776 ),
1777 ReasoningEffort::Low
1778 );
1779 }
1780
1781 #[test]
1782 fn configured_route_effort_normalizer_keeps_kimi_code_low_medium_local() {
1783 let mut config = Config {
1784 provider: Some("moonshot".to_string()),
1785 providers: Some(crate::config::ProvidersConfig {
1786 moonshot: crate::config::ProviderConfig {
1787 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1788 model: Some("k3".to_string()),
1789 ..Default::default()
1790 },
1791 ..Default::default()
1792 }),
1793 ..Default::default()
1794 };
1795 assert_eq!(
1796 normalize_auto_route_effort_for_configured_route(
1797 &config,
1798 ApiProvider::Moonshot,
1799 "k3",
1800 ReasoningEffort::Low,
1801 ),
1802 ReasoningEffort::Low
1803 );
1804 assert_eq!(
1805 normalize_auto_route_effort_for_configured_route(
1806 &config,
1807 ApiProvider::Moonshot,
1808 "k3",
1809 ReasoningEffort::Medium,
1810 ),
1811 ReasoningEffort::Medium
1812 );
1813
1814 config
1815 .providers
1816 .as_mut()
1817 .expect("providers")
1818 .moonshot
1819 .base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
1820 assert_eq!(
1821 normalize_auto_route_effort_for_configured_route(
1822 &config,
1823 ApiProvider::Moonshot,
1824 "k3",
1825 ReasoningEffort::Low,
1826 ),
1827 ReasoningEffort::High
1828 );
1829 }
1830
1831 #[test]
1832 fn inventory_auto_route_recommendation_requires_runnable_pair() {
1833 let _env_lock = crate::test_support::lock_test_env();
1834 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1835 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1836 let config = Config {
1837 provider: Some("zai".to_string()),
1838 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
1839 ..Default::default()
1840 };
1841 let inventory = ModelInventory::from_config(&config);
1842
1843 let route = parse_inventory_auto_route_recommendation(
1844 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
1845 &inventory,
1846 )
1847 .expect("valid inventory route should parse");
1848 assert_eq!(route.provider, ApiProvider::Zai);
1849 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
1850 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Max));
1851
1852 assert!(
1853 parse_inventory_auto_route_recommendation(
1854 r#"{"provider":"zai","model":"deepseek-v4-pro","thinking":"max"}"#,
1855 &inventory,
1856 )
1857 .is_none(),
1858 "router must not pair a DeepSeek model with the Z.ai provider"
1859 );
1860
1861 let wrapped = parse_inventory_auto_route_recommendation(
1862 r#"route: {"provider":"zai","model":"GLM-5-Turbo","reasoning_effort":"medium"}"#,
1863 &inventory,
1864 )
1865 .expect("wrapped inventory route should parse");
1866 assert_eq!(wrapped.provider, ApiProvider::Zai);
1867 assert_eq!(wrapped.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
1868 // Parsing is strict and literal; the historic Medium->High coercion
1869 // is applied downstream by normalize_auto_route_selection_for_config
1870 // so route-specific contracts (Kimi Code K3) can keep Medium.
1871 assert_eq!(wrapped.reasoning_effort, Some(ReasoningEffort::Medium));
1872 }
1873
1874 #[test]
1875 fn inventory_auto_route_recommendation_rejects_unready_candidate() {
1876 let _env_lock = crate::test_support::lock_test_env();
1877 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1878 let config = Config {
1879 provider: Some("zai".to_string()),
1880 ..Default::default()
1881 };
1882 let mut inventory = ModelInventory::from_config(&config);
1883 let candidate = inventory
1884 .candidates
1885 .iter_mut()
1886 .find(|candidate| {
1887 candidate.provider == ApiProvider::Zai
1888 && candidate.model == crate::config::ZAI_GLM_5_2_MODEL
1889 })
1890 .expect("Z.ai strong candidate");
1891 candidate.readiness = crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute;
1892
1893 assert!(
1894 parse_inventory_auto_route_recommendation(
1895 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
1896 &inventory,
1897 )
1898 .is_none(),
1899 "classifier output must not revive an unsupported route"
1900 );
1901 }
1902
1903 #[test]
1904 fn inventory_auto_route_recommendation_accepts_wanjie_v4_ids() {
1905 let _env_lock = crate::test_support::lock_test_env();
1906 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1907 let _wanjie = crate::test_support::EnvVarGuard::set("WANJIE_ARK_API_KEY", "wanjie-key");
1908 let config = Config {
1909 provider: Some("wanjie-ark".to_string()),
1910 ..Default::default()
1911 };
1912 let inventory = ModelInventory::from_config(&config);
1913
1914 let route = parse_inventory_auto_route_recommendation(
1915 r#"{"provider":"wanjie-ark","model":"deepseek-v4-pro","thinking":"max"}"#,
1916 &inventory,
1917 )
1918 .expect("Wanjie V4 Pro inventory route should parse");
1919 assert_eq!(route.provider, ApiProvider::WanjieArk);
1920 assert_eq!(route.model, "deepseek-v4-pro");
1921 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Max));
1922
1923 let route = parse_inventory_auto_route_recommendation(
1924 r#"{"provider":"wanjie-ark","model":"deepseek-v4-flash","thinking":"off"}"#,
1925 &inventory,
1926 )
1927 .expect("Wanjie V4 Flash inventory route should parse");
1928 assert_eq!(route.provider, ApiProvider::WanjieArk);
1929 assert_eq!(route.model, "deepseek-v4-flash");
1930 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Off));
1931 }
1932
1933 #[test]
1934 fn explicit_route_to_nonactive_provider_uses_that_providers_effort() {
1935 // Active provider is DeepSeek (whose effort floor is low/medium), but the
1936 // explicit model `GLM-5.2` only routes to Z.ai. The resolved effort must
1937 // be normalized for Z.ai — not left at DeepSeek's raw `low` setting.
1938 let _env_lock = crate::test_support::lock_test_env();
1939 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1940 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1941 let config = Config {
1942 provider: Some("deepseek".to_string()),
1943 reasoning_effort: Some("low".to_string()),
1944 ..Default::default()
1945 };
1946
1947 let route = resolve_explicit_route_with_inventory(&config, "GLM-5.2")
1948 .expect("explicit GLM route should resolve to its provider");
1949
1950 assert_eq!(
1951 route.provider,
1952 ApiProvider::Zai,
1953 "GLM-5.2 must route to Z.ai, not the active DeepSeek provider"
1954 );
1955 assert_eq!(
1956 route.reasoning_effort,
1957 Some(ReasoningEffort::High),
1958 "low must be normalized up to high for the Z.ai route, not passed through"
1959 );
1960
1961 // GLM-5.3 is the default and a first-class route: same provider
1962 // ownership, same effort normalization, and it resolves to its own id.
1963 let route_53 = resolve_explicit_route_with_inventory(&config, "GLM-5.3")
1964 .expect("explicit GLM-5.3 route should resolve to its provider");
1965 assert_eq!(
1966 route_53.provider,
1967 ApiProvider::Zai,
1968 "GLM-5.3 must route to Z.ai, not the active DeepSeek provider"
1969 );
1970 assert_eq!(
1971 route_53.model,
1972 crate::config::ZAI_GLM_5_3_MODEL,
1973 "GLM-5.3 must resolve to its own id"
1974 );
1975 assert_eq!(route_53.reasoning_effort, Some(ReasoningEffort::High));
1976 }
1977
1978 #[tokio::test]
1979 #[allow(clippy::await_holding_lock)]
1980 async fn inventory_auto_route_resolves_active_authenticated_provider() {
1981 let _env_lock = crate::test_support::lock_test_env();
1982 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1983 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1984 let config = Config {
1985 provider: Some("zai".to_string()),
1986 ..Default::default()
1987 };
1988
1989 // #6290 rework: without the flash classifier there is no
1990 // per-request signal, so every wording resolves the same declared
1991 // default — the short chat and the complex ask below must agree.
1992 for prompt in [
1993 "quick status check",
1994 "please refactor this architecture and audit its security boundaries",
1995 ] {
1996 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
1997 .await
1998 .expect("inventory route should resolve with authenticated active provider");
1999
2000 assert_eq!(route.provider, ApiProvider::Zai);
2001 assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL);
2002 assert_eq!(route.source, AutoRouteSource::Heuristic);
2003 let receipt = route.receipt.expect("Auto route receipt");
2004 assert_eq!(receipt.tier, AutoRouteTier::Strong);
2005 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
2006 assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic);
2007 assert_eq!(
2008 receipt.reason,
2009 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
2010 "prompt {prompt:?} must take the declared default, not a content judgment"
2011 );
2012 assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL);
2013 assert_eq!(
2014 receipt.pair.fast.as_deref(),
2015 Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL)
2016 );
2017 }
2018 }
2019
2020 #[test]
2021 fn classifier_receipt_discloses_active_provider_scope_and_data_path() {
2022 let _env_lock = crate::test_support::lock_test_env();
2023 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2024 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2025 let config = Config {
2026 provider: Some("zai".to_string()),
2027 ..Default::default()
2028 };
2029 let inventory = ModelInventory::from_config(&config);
2030 let recommendation = parse_inventory_auto_route_recommendation(
2031 r#"{"provider":"zai","model":"GLM-5-Turbo","thinking":"off"}"#,
2032 &inventory,
2033 )
2034 .expect("runnable classifier recommendation");
2035
2036 let route = auto_route_from_classifier(&inventory, recommendation);
2037
2038 assert_eq!(route.provider, ApiProvider::Zai);
2039 assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
2040 assert_eq!(route.source, AutoRouteSource::FlashRouter);
2041 let receipt = route.receipt.expect("classifier receipt");
2042 assert_eq!(receipt.tier, AutoRouteTier::Fast);
2043 // #4411: the classifier only saw Z.ai routes, so the receipt says so
2044 // instead of claiming the wider runnable-providers scope.
2045 assert_eq!(receipt.scope, AutoRouteScope::ActiveProvider);
2046 assert_eq!(
2047 receipt.data_path,
2048 AutoRouteDataPath::Classifier {
2049 provider: ApiProvider::Deepseek,
2050 model: "deepseek-v4-flash".to_string(),
2051 }
2052 );
2053 assert_eq!(receipt.reason, AutoRouteReason::ClassifierRecommendation);
2054 }
2055
2056 #[test]
2057 fn classifier_recommendation_for_another_provider_is_refused_by_default() {
2058 // #4411: the payload never named DeepSeek, but a classifier can still
2059 // emit one. The recommendation must not become a route unless the
2060 // persisted cross-provider opt-in is set.
2061 let _env_lock = crate::test_support::lock_test_env();
2062 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2063 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2064 let scoped = Config {
2065 provider: Some("zai".to_string()),
2066 ..Default::default()
2067 };
2068 let scoped_inventory = ModelInventory::from_config(&scoped);
2069 let raw = r#"{"provider":"deepseek","model":"deepseek-v4-flash","thinking":"off"}"#;
2070
2071 assert!(
2072 parse_inventory_auto_route_recommendation(raw, &scoped_inventory).is_none(),
2073 "cross-provider classifier output must be refused by default"
2074 );
2075 // The same inventory still accepts an in-scope active-provider route.
2076 assert!(
2077 parse_inventory_auto_route_recommendation(
2078 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
2079 &scoped_inventory,
2080 )
2081 .is_some()
2082 );
2083
2084 let opted_in = Config {
2085 auto: Some(crate::config::AutoConfig {
2086 cost_saving: None,
2087 cross_provider: Some(true),
2088 router: None,
2089 }),
2090 ..scoped.clone()
2091 };
2092 let opted_in_route =
2093 parse_inventory_auto_route_recommendation(raw, &ModelInventory::from_config(&opted_in))
2094 .expect("opt-in admits the cross-provider recommendation");
2095 assert_eq!(opted_in_route.provider, ApiProvider::Deepseek);
2096 }
2097
2098 #[test]
2099 fn classifier_prompt_declares_active_provider_scope_by_default() {
2100 let _env_lock = crate::test_support::lock_test_env();
2101 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2102 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2103 let config = Config {
2104 provider: Some("zai".to_string()),
2105 ..Default::default()
2106 };
2107
2108 let prompt =
2109 inventory_auto_router_system_prompt(&ModelInventory::from_config(&config), false);
2110
2111 assert!(
2112 prompt.contains("Auto routing is scoped to the active provider `zai`"),
2113 "{prompt}"
2114 );
2115 assert!(!prompt.contains("deepseek"), "{prompt}");
2116 }
2117
2118 #[tokio::test]
2119 #[allow(clippy::await_holding_lock)]
2120 async fn active_provider_declared_default_survives_scoping() {
2121 // #4411: scoping keeps Auto on the active provider. #6290 rework:
2122 // without the flash classifier there is no per-request tier signal,
2123 // so both wordings resolve the same declared default on Zai.
2124 let _env_lock = crate::test_support::lock_test_env();
2125 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
2126 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2127 let config = Config {
2128 provider: Some("zai".to_string()),
2129 ..Default::default()
2130 };
2131
2132 for prompt in [
2133 "refactor the routing module and audit its security boundaries",
2134 "hi",
2135 ] {
2136 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
2137 .await
2138 .expect("scoped Auto route");
2139 assert_eq!(route.provider, ApiProvider::Zai);
2140 assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL);
2141 let receipt = route.receipt.expect("scoped receipt");
2142 assert_eq!(receipt.tier, AutoRouteTier::Strong);
2143 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
2144 assert_eq!(
2145 receipt.reason,
2146 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
2147 "prompt {prompt:?}"
2148 );
2149 }
2150 }
2151
2152 #[test]
2153 fn config_auto_cross_provider_defaults_to_false() {
2154 assert!(!Config::default().auto_cross_provider());
2155 let opted_in = Config {
2156 auto: Some(crate::config::AutoConfig {
2157 cost_saving: None,
2158 cross_provider: Some(true),
2159 router: None,
2160 }),
2161 ..Default::default()
2162 };
2163 assert!(opted_in.auto_cross_provider());
2164 }
2165
2166 #[test]
2167 fn classifier_receipt_never_reports_openrouter_default_for_another_family() {
2168 let _env_lock = crate::test_support::lock_test_env();
2169 let _openrouter =
2170 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "openrouter-key");
2171 let config = Config {
2172 provider: Some("openrouter".to_string()),
2173 ..Default::default()
2174 };
2175 let inventory = ModelInventory::from_config(&config);
2176 let recommendation = parse_inventory_auto_route_recommendation(
2177 r#"{"provider":"openrouter","model":"z-ai/glm-5.2","thinking":"max"}"#,
2178 &inventory,
2179 )
2180 .expect("runnable non-default OpenRouter family");
2181
2182 let route = auto_route_from_classifier(&inventory, recommendation);
2183 let receipt = route.receipt.expect("classifier receipt");
2184
2185 assert_eq!(route.model, crate::config::OPENROUTER_GLM_5_2_MODEL);
2186 assert_eq!(receipt.pair.strong, crate::config::OPENROUTER_GLM_5_2_MODEL);
2187 assert_ne!(
2188 receipt.pair.fast.as_deref(),
2189 Some(crate::config::DEFAULT_OPENROUTER_FLASH_MODEL),
2190 "a GLM selection must not be described as the DeepSeek default pair"
2191 );
2192 assert!(matches!(
2193 receipt.tier,
2194 AutoRouteTier::Strong | AutoRouteTier::Only
2195 ));
2196 }
2197
2198 #[test]
2199 fn classifier_fallback_preserves_attempted_data_path_without_error_text() {
2200 let _env_lock = crate::test_support::lock_test_env();
2201 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2202 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2203 let config = Config {
2204 provider: Some("zai".to_string()),
2205 ..Default::default()
2206 };
2207 let inventory = ModelInventory::from_config(&config);
2208 let fallback = auto_route_declared_fallback(&config, &inventory);
2209
2210 let route = auto_route_classifier_fallback(fallback, &inventory);
2211
2212 assert_eq!(route.source, AutoRouteSource::Heuristic);
2213 let receipt = route.receipt.expect("fallback receipt");
2214 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
2215 assert!(matches!(
2216 receipt.data_path,
2217 AutoRouteDataPath::Classifier {
2218 provider: ApiProvider::Deepseek,
2219 ref model,
2220 } if model == "deepseek-v4-flash"
2221 ));
2222 assert_eq!(
2223 receipt.reason,
2224 AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::DeclaredDefault)
2225 );
2226 assert!(!receipt.reason.label().contains("secret-provider-error"));
2227 }
2228
2229 #[test]
2230 fn pre_rework_receipt_shape_still_deserializes() {
2231 // Sessions saved before the #6290 rework persist
2232 // `local_heuristic` + content-derived reasons. They must keep
2233 // loading: the wrapper arrives via serde alias, the legacy reasons
2234 // are retained variants.
2235 let reason: AutoRouteReason =
2236 serde_json::from_str(r#"{"local_heuristic":"complex_request"}"#)
2237 .expect("pre-rework receipt reason deserializes");
2238 assert_eq!(
2239 reason,
2240 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::ComplexRequest)
2241 );
2242 let receipt: AutoRouteReceipt = serde_json::from_str(
2243 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"}}"#,
2244 )
2245 .expect("pre-rework receipt deserializes");
2246 assert_eq!(receipt.reason.label(), "local fallback: short request");
2247 }
2248
2249 #[tokio::test]
2250 #[allow(clippy::await_holding_lock)]
2251 async fn inventory_auto_route_never_falls_back_across_providers_by_default() {
2252 // #4411: the active provider has no usable credential, but another
2253 // provider does. Auto must stay on the active provider and report a
2254 // no-runnable-candidate fallback instead of silently spending the
2255 // other provider's key.
2256 let _env_lock = crate::test_support::lock_test_env();
2257 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2258 let _zai = crate::test_support::EnvVarGuard::remove("ZAI_API_KEY");
2259 let config = Config {
2260 provider: Some("zai".to_string()),
2261 ..Default::default()
2262 };
2263
2264 let route =
2265 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
2266 .await
2267 .expect("inventory route should resolve without leaving the active provider");
2268
2269 assert_eq!(route.provider, ApiProvider::Zai);
2270 assert_ne!(route.provider, ApiProvider::Deepseek);
2271 assert_eq!(route.source, AutoRouteSource::Heuristic);
2272 let receipt = route.receipt.expect("Auto route receipt");
2273 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
2274 assert_eq!(
2275 receipt.reason,
2276 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate)
2277 );
2278 }
2279
2280 #[tokio::test]
2281 #[allow(clippy::await_holding_lock)]
2282 async fn inventory_auto_route_crosses_providers_only_under_persisted_opt_in() {
2283 // The same configuration as above, plus the persisted
2284 // `[auto] cross_provider = true` opt-in (#4411).
2285 let _env_lock = crate::test_support::lock_test_env();
2286 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2287 let _zai = crate::test_support::EnvVarGuard::remove("ZAI_API_KEY");
2288 let config = Config {
2289 provider: Some("zai".to_string()),
2290 auto: Some(crate::config::AutoConfig {
2291 cost_saving: None,
2292 cross_provider: Some(true),
2293 router: None,
2294 }),
2295 ..Default::default()
2296 };
2297
2298 let route =
2299 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
2300 .await
2301 .expect("opted-in route should fall back to an authenticated provider");
2302
2303 assert_eq!(route.provider, ApiProvider::Deepseek);
2304 assert_eq!(route.model, "deepseek-flash");
2305 assert_eq!(route.source, AutoRouteSource::Heuristic);
2306 }
2307
2308 #[tokio::test]
2309 #[allow(clippy::await_holding_lock)]
2310 async fn inventory_auto_route_cost_saving_pins_fast_sibling() {
2311 let _env_lock = crate::test_support::lock_test_env();
2312 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
2313 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2314 let balanced = Config {
2315 provider: Some("zai".to_string()),
2316 ..Default::default()
2317 };
2318 let cost_saving = Config {
2319 auto: Some(crate::config::AutoConfig {
2320 cost_saving: Some(true),
2321 cross_provider: None,
2322 router: None,
2323 }),
2324 ..balanced.clone()
2325 };
2326
2327 let balanced_route = resolve_auto_route_with_inventory(
2328 &balanced,
2329 "Please implement a binary search",
2330 "",
2331 "auto",
2332 "auto",
2333 )
2334 .await
2335 .expect("balanced Auto route should resolve");
2336 let cost_saving_route = resolve_auto_route_with_inventory(
2337 &cost_saving,
2338 "Please implement a binary search",
2339 "",
2340 "auto",
2341 "auto",
2342 )
2343 .await
2344 .expect("cost-saving Auto route should resolve");
2345
2346 assert_eq!(balanced_route.provider, ApiProvider::Zai);
2347 assert_eq!(balanced_route.model, crate::config::DEFAULT_ZAI_MODEL);
2348 assert_eq!(cost_saving_route.provider, ApiProvider::Zai);
2349 assert_eq!(
2350 cost_saving_route.model,
2351 crate::config::ZAI_GLM_5_3_FLASH_MODEL
2352 );
2353 assert_eq!(cost_saving_route.source, AutoRouteSource::Heuristic);
2354 assert_eq!(
2355 balanced_route
2356 .receipt
2357 .as_ref()
2358 .map(|receipt| (receipt.tier, receipt.reason)),
2359 Some((
2360 AutoRouteTier::Strong,
2361 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
2362 ))
2363 );
2364 assert_eq!(
2365 cost_saving_route
2366 .receipt
2367 .as_ref()
2368 .map(|receipt| (receipt.tier, receipt.reason)),
2369 Some((
2370 AutoRouteTier::Fast,
2371 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::CostSavingPolicy),
2372 ))
2373 );
2374 }
2375
2376 #[tokio::test]
2377 #[allow(clippy::await_holding_lock)]
2378 async fn inventory_auto_route_uses_wanjie_v4_pair_without_deepseek_router() {
2379 let _env_lock = crate::test_support::lock_test_env();
2380 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
2381 let _wanjie = crate::test_support::EnvVarGuard::set("WANJIE_ARK_API_KEY", "wanjie-key");
2382 let config = Config {
2383 provider: Some("wanjie-ark".to_string()),
2384 default_text_model: Some("auto".to_string()),
2385 ..Default::default()
2386 };
2387
2388 // #6290 rework: no classifier, no content signal — both wordings
2389 // take the declared Wanjie default.
2390 for prompt in ["quick status check", "please refactor this architecture"] {
2391 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
2392 .await
2393 .expect("declared-default Wanjie route should resolve");
2394 assert_eq!(route.provider, ApiProvider::WanjieArk);
2395 assert_eq!(route.model, "deepseek-reasoner");
2396 assert_eq!(route.source, AutoRouteSource::Heuristic);
2397 }
2398 }
2399
2400 #[tokio::test]
2401 #[allow(clippy::await_holding_lock)]
2402 async fn inventory_auto_route_uses_volcengine_v4_pair_without_deepseek_router() {
2403 let _env_lock = crate::test_support::lock_test_env();
2404 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
2405 let _volcengine =
2406 crate::test_support::EnvVarGuard::set("VOLCENGINE_API_KEY", "volcengine-key");
2407 let config = Config {
2408 provider: Some("volcengine".to_string()),
2409 default_text_model: Some("auto".to_string()),
2410 ..Default::default()
2411 };
2412
2413 // #6290 rework: no classifier, no content signal — both wordings
2414 // take the declared Volcengine default.
2415 for prompt in ["quick status check", "please refactor this architecture"] {
2416 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
2417 .await
2418 .expect("declared-default Volcengine route should resolve");
2419 assert_eq!(route.provider, ApiProvider::Volcengine);
2420 assert_eq!(route.model, "deepseek-v4-pro");
2421 assert_eq!(route.source, AutoRouteSource::Heuristic);
2422 }
2423 }
2424
2425 #[test]
2426 fn provider_router_candidates_cover_known_provider_classes() {
2427 use crate::config::ApiProvider;
2428
2429 let deepseek = provider_router_candidates(ApiProvider::Deepseek, "deepseek-v4-pro");
2430 assert_eq!(deepseek.big, "deepseek-v4-pro");
2431 assert_eq!(deepseek.cheap.as_deref(), Some("deepseek-v4-flash"));
2432
2433 let openrouter =
2434 provider_router_candidates(ApiProvider::Openrouter, "deepseek/deepseek-v4-pro");
2435 assert_eq!(openrouter.big, "deepseek/deepseek-v4-pro");
2436 assert_eq!(
2437 openrouter.cheap.as_deref(),
2438 Some("deepseek/deepseek-v4-flash")
2439 );
2440
2441 let wanjie = provider_router_candidates(ApiProvider::WanjieArk, "deepseek-reasoner");
2442 assert_eq!(wanjie.big, "deepseek-v4-pro");
2443 assert_eq!(wanjie.cheap.as_deref(), Some("deepseek-v4-flash"));
2444
2445 let volcengine = provider_router_candidates(ApiProvider::Volcengine, "DeepSeek-V4-Pro");
2446 assert_eq!(volcengine.big, "DeepSeek-V4-Pro");
2447 assert_eq!(volcengine.cheap.as_deref(), Some("DeepSeek-V4-Flash"));
2448
2449 let zai = provider_router_candidates(ApiProvider::Zai, "GLM-5.2");
2450 assert_eq!(zai.big, "GLM-5.2");
2451 // GLM-5.2 faster/explore children route to GLM-5-Turbo (same-family fast
2452 // sibling), not back down to GLM-5.1.
2453 assert_eq!(zai.cheap.as_deref(), Some("GLM-5-Turbo"));
2454
2455 let openrouter_glm = provider_router_candidates(ApiProvider::Openrouter, "z-ai/glm-5.2");
2456 assert_eq!(openrouter_glm.big, "z-ai/glm-5.2");
2457 assert_eq!(openrouter_glm.cheap.as_deref(), Some("z-ai/glm-5-turbo"));
2458
2459 // GLM-5.3's fast sibling is Flash; GLM-5.2 still uses Turbo.
2460 let zai_53 = provider_router_candidates(ApiProvider::Zai, "GLM-5.3");
2461 assert_eq!(zai_53.big, "GLM-5.3");
2462 assert_eq!(zai_53.cheap.as_deref(), Some("GLM-5.3-Flash"));
2463
2464 let openrouter_glm_53 = provider_router_candidates(ApiProvider::Openrouter, "z-ai/glm-5.3");
2465 assert_eq!(openrouter_glm_53.big, "z-ai/glm-5.3");
2466 assert_eq!(
2467 openrouter_glm_53.cheap.as_deref(),
2468 Some("z-ai/glm-5.3-flash")
2469 );
2470
2471 let zai_flash = provider_router_candidates(ApiProvider::Zai, "GLM-5.3-Flash");
2472 assert_eq!(zai_flash.big, "GLM-5.3-Flash");
2473 assert_eq!(zai_flash.cheap, None);
2474
2475 // GLM-5.1 has no cheaper tier; faster children stay on the parent.
2476 let zai_51 = provider_router_candidates(ApiProvider::Zai, "GLM-5.1");
2477 assert_eq!(zai_51.big, "GLM-5.1");
2478 assert_eq!(zai_51.cheap, None);
2479
2480 // GLM-5-Turbo is itself the cheap tier; no further downgrade.
2481 let zai_turbo = provider_router_candidates(ApiProvider::Zai, "GLM-5-Turbo");
2482 assert_eq!(zai_turbo.big, "GLM-5-Turbo");
2483 assert_eq!(zai_turbo.cheap, None);
2484
2485 // Providers without a known cheap tier: big = session model, no cheap.
2486 let ollama = provider_router_candidates(ApiProvider::Ollama, "qwen3:32b");
2487 assert_eq!(ollama.big, "qwen3:32b");
2488 assert_eq!(ollama.cheap, None);
2489
2490 let moonshot = provider_router_candidates(ApiProvider::Moonshot, "kimi-k2.6");
2491 assert_eq!(moonshot.big, "kimi-k2.6");
2492 assert_eq!(moonshot.cheap, None);
2493 }
2494
2495 #[test]
2496 fn provider_router_candidates_cover_catalog_fast_siblings() {
2497 use crate::config::ApiProvider;
2498
2499 let cases = [
2500 (ApiProvider::OpenaiCodex, "gpt-5.6-sol", "gpt-5.6-luna"),
2501 (
2502 ApiProvider::Anthropic,
2503 "claude-sonnet-4-6",
2504 "claude-haiku-4-5",
2505 ),
2506 (ApiProvider::XiaomiMimo, "mimo-v2.5-pro", "mimo-v2.5"),
2507 (ApiProvider::Arcee, "trinity-large-thinking", "trinity-mini"),
2508 (ApiProvider::Moonshot, "kimi-k2.7-code", "kimi-k2.6"),
2509 (
2510 ApiProvider::Minimax,
2511 "MiniMax-M2.7",
2512 "MiniMax-M2.7-highspeed",
2513 ),
2514 (ApiProvider::OpencodeGo, "kimi-k3", "kimi-k2.7-code"),
2515 (
2516 ApiProvider::Openrouter,
2517 "qwen/qwen3.6-max-preview",
2518 "qwen/qwen3.6-flash",
2519 ),
2520 (
2521 ApiProvider::Openrouter,
2522 "anthropic/claude-sonnet-4-6",
2523 "anthropic/claude-haiku-4-5",
2524 ),
2525 ];
2526
2527 for (provider, strong, fast) in cases {
2528 let candidates = provider_router_candidates(provider, strong);
2529 assert_eq!(candidates.big, strong);
2530 assert_eq!(candidates.cheap.as_deref(), Some(fast));
2531 assert_eq!(
2532 provider_router_candidates(provider, fast).cheap,
2533 None,
2534 "already-fast model must not downgrade again: {provider:?}/{fast}"
2535 );
2536 }
2537
2538 for (provider, model) in [
2539 (ApiProvider::Ollama, "qwen3:32b"),
2540 (ApiProvider::Custom, "gpt-5.6-sol"),
2541 (ApiProvider::OpenaiCodex, "gpt-5.6-luna"),
2542 ] {
2543 assert_eq!(provider_router_candidates(provider, model).cheap, None);
2544 }
2545 }
2546
2547 #[tokio::test]
2548 #[allow(clippy::await_holding_lock)]
2549 async fn declared_fallback_without_cheap_tier_stays_on_default_model() {
2550 // #3018 AC: Ollama + auto must never fabricate a DeepSeek id. The
2551 // declared fallback returns the configured default verbatim, so no
2552 // sibling id can be invented regardless of request wording.
2553 let _env_lock = crate::test_support::lock_test_env();
2554 let config = Config {
2555 provider: Some("ollama".to_string()),
2556 ..Default::default()
2557 };
2558 for prompt in ["hi", "please refactor the auth module for security"] {
2559 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
2560 .await
2561 .expect("ollama Auto route should resolve");
2562 assert_eq!(route.provider, ApiProvider::Ollama);
2563 assert_eq!(route.model, config.default_model());
2564 assert!(
2565 !route.model.to_ascii_lowercase().contains("deepseek"),
2566 "no DeepSeek id may be fabricated: {}",
2567 route.model
2568 );
2569 }
2570 }
2571
2572 #[test]
2573 fn config_auto_cost_saving_defaults_to_false() {
2574 let cfg = Config::default();
2575 assert!(!cfg.auto_cost_saving());
2576 }
2577
2578 #[test]
2579 fn config_auto_cost_saving_reads_table() {
2580 let cfg = Config {
2581 auto: Some(crate::config::AutoConfig {
2582 cost_saving: Some(true),
2583 cross_provider: None,
2584 router: None,
2585 }),
2586 ..Default::default()
2587 };
2588 assert!(cfg.auto_cost_saving());
2589 }
2590 }
2591
2591 lines RUST