返回 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::collections::BTreeMap;
7 use std::time::{Duration, Instant};
8
9 use anyhow::Result;
10 use serde::{Deserialize, Serialize};
11
12 use crate::client::CodewhaleClient;
13 use crate::client::system_one::{DecisionRouterRoute, SystemOneAnswer, SystemOneResponse};
14 use crate::config::{
15 AutoRouterKind, Config, ProviderIdentity, ProviderKind, normalize_model_name_for_provider,
16 };
17 use crate::cost_status::{
18 EffectiveRouteEnvelope, EffectiveRouteUsage, RuntimeUsageDropRecord, RuntimeUsageRecord,
19 };
20 use crate::llm_client::LlmClient;
21 use crate::model_inventory::{ModelInventory, probability_bp};
22 use crate::reasoning_preference::ReasoningEffort;
23 use codewhale_models::Role;
24 use codewhale_models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt};
25
26 /// Big/cheap model pair the auto-router may choose between for the active
27 /// provider (#3018).
28 ///
29 /// `cheap == None` means the provider has no known cheap tier: the local
30 /// fallback stays on the current model (only thinking effort varies) and the
31 /// network router is skipped entirely (#1549).
32 #[derive(Debug, Clone, PartialEq, Eq)]
33 pub(crate) struct RouterCandidates {
34 pub(crate) big: String,
35 pub(crate) cheap: Option<String>,
36 }
37
38 impl RouterCandidates {
39 pub(crate) fn deepseek() -> Self {
40 Self {
41 big: "deepseek-v4-pro".to_string(),
42 cheap: Some("deepseek-v4-flash".to_string()),
43 }
44 }
45 }
46
47 /// Return a provider-owned strong/fast pair for model families whose catalog
48 /// exposes more than one tier. The ids here are deliberately explicit: a
49 /// model name alone is not evidence that another provider can serve its
50 /// sibling, so unknown providers and unknown families remain single-tier.
51 fn catalog_family_candidates(
52 provider: ProviderKind,
53 current_model: &str,
54 ) -> Option<RouterCandidates> {
55 let normalized = normalize_model_name_for_provider(provider, current_model)
56 .unwrap_or_else(|| current_model.trim().to_string());
57 let lower = normalized.to_ascii_lowercase();
58
59 let cheap = match provider {
60 ProviderKind::Openai | ProviderKind::OpenaiCodex
61 if matches!(lower.as_str(), "gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra") =>
62 {
63 Some("gpt-5.6-luna".to_string())
64 }
65 ProviderKind::Anthropic
66 if matches!(
67 lower.as_str(),
68 "claude-opus-4-8" | "claude-sonnet-4-6" | "claude-sonnet-5"
69 ) =>
70 {
71 Some("claude-haiku-4-5".to_string())
72 }
73 ProviderKind::XiaomiMimo if lower == "mimo-v2.5-pro" => Some("mimo-v2.5".to_string()),
74 ProviderKind::Arcee
75 if matches!(
76 lower.as_str(),
77 "trinity-large-thinking" | "trinity-large-preview"
78 ) =>
79 {
80 Some("trinity-mini".to_string())
81 }
82 ProviderKind::Moonshot if lower == "kimi-k2.7-code" => Some("kimi-k2.6".to_string()),
83 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic if lower == "minimax-m2.7" => {
84 Some("MiniMax-M2.7-highspeed".to_string())
85 }
86 ProviderKind::OpencodeGo if lower == "kimi-k3" => Some("kimi-k2.7-code".to_string()),
87 ProviderKind::Openrouter
88 if lower == "qwen/qwen3.6-max-preview"
89 || lower == "qwen/qwen3.6-plus"
90 || lower == "qwen/qwen3.6-27b"
91 || lower == "qwen/qwen3.6-35b-a3b" =>
92 {
93 Some("qwen/qwen3.6-flash".to_string())
94 }
95 ProviderKind::Openrouter if lower == "xiaomi/mimo-v2.5-pro" => {
96 Some("xiaomi/mimo-v2.5".to_string())
97 }
98 ProviderKind::Openrouter
99 if matches!(
100 lower.as_str(),
101 "arcee-ai/trinity-large-thinking" | "arcee-ai/trinity-large-preview"
102 ) =>
103 {
104 Some("arcee-ai/trinity-mini".to_string())
105 }
106 ProviderKind::Openrouter if lower == "moonshotai/kimi-k2.7-code" => {
107 Some("moonshotai/kimi-k2.6".to_string())
108 }
109 ProviderKind::Openrouter
110 if lower == "anthropic/claude-opus-4-8"
111 || lower == "anthropic/claude-sonnet-4-6"
112 || lower == "anthropic/claude-sonnet-5" =>
113 {
114 Some("anthropic/claude-haiku-4-5".to_string())
115 }
116 _ => None,
117 }?;
118
119 Some(RouterCandidates {
120 big: normalized,
121 cheap: Some(cheap),
122 })
123 }
124
125 /// Derive the auto-router's candidate pair for the active provider (#3018).
126 ///
127 /// DeepSeek providers route between the canonical pro/flash pair. Hosted
128 /// routes with known wire ids for that pair (NVIDIA NIM, OpenRouter, Novita,
129 /// SiliconFlow, SGLang, vLLM, Wanjie Ark, Volcengine) use their provider
130 /// spellings. Every other provider has no known cheap tier: `big` is the
131 /// session model and `cheap` is `None`, so auto mode never fabricates a
132 /// DeepSeek id for a provider that cannot serve it.
133 pub(crate) fn provider_router_candidates(
134 provider: crate::config::ProviderKind,
135 current_model: &str,
136 ) -> RouterCandidates {
137 use crate::config::ProviderKind;
138 if let Some(candidates) = catalog_family_candidates(provider, current_model) {
139 return candidates;
140 }
141
142 if provider == ProviderKind::Zai {
143 let normalized = crate::config::normalize_model_name_for_provider(provider, current_model)
144 .unwrap_or_else(|| current_model.to_string());
145 return RouterCandidates {
146 // GLM-5.3 routes faster/explore children to GLM-5.3-Flash.
147 // GLM-5.2 still uses GLM-5-Turbo. Flash, Turbo, and 5.1 have no
148 // cheaper tier and keep children on the parent model.
149 cheap: if normalized == crate::config::ZAI_GLM_5_3_MODEL {
150 Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL.to_string())
151 } else if normalized == crate::config::ZAI_GLM_5_2_MODEL {
152 Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string())
153 } else {
154 None
155 },
156 big: normalized,
157 };
158 }
159
160 if provider == ProviderKind::Openrouter
161 && let Some(normalized) =
162 crate::config::normalize_model_name_for_provider(provider, current_model)
163 && matches!(
164 normalized.as_str(),
165 crate::config::OPENROUTER_GLM_5_1_MODEL
166 | crate::config::OPENROUTER_GLM_5_2_MODEL
167 | crate::config::OPENROUTER_GLM_5_3_MODEL
168 | crate::config::OPENROUTER_GLM_5_3_FLASH_MODEL
169 | crate::config::OPENROUTER_GLM_5_TURBO_MODEL
170 )
171 {
172 return RouterCandidates {
173 // z-ai/glm-5.3 routes faster children to z-ai/glm-5.3-flash;
174 // z-ai/glm-5.2 still uses z-ai/glm-5-turbo. Flash, turbo, and 5.1
175 // have no cheaper tier and keep children on parent.
176 cheap: if normalized == crate::config::OPENROUTER_GLM_5_3_MODEL {
177 Some(crate::config::OPENROUTER_GLM_5_3_FLASH_MODEL.to_string())
178 } else if normalized == crate::config::OPENROUTER_GLM_5_2_MODEL {
179 Some(crate::config::OPENROUTER_GLM_5_TURBO_MODEL.to_string())
180 } else {
181 None
182 },
183 big: normalized,
184 };
185 }
186
187 match provider {
188 ProviderKind::Deepseek => RouterCandidates::deepseek(),
189 ProviderKind::NvidiaNim
190 | ProviderKind::Openrouter
191 | ProviderKind::Novita
192 | ProviderKind::Siliconflow
193 | ProviderKind::SiliconflowCN
194 | ProviderKind::Sglang
195 | ProviderKind::Vllm
196 | ProviderKind::WanjieArk
197 if current_model.to_ascii_lowercase().contains("deepseek") =>
198 {
199 RouterCandidates {
200 big: crate::config::wire_model_for_provider(provider, "deepseek-v4-pro"),
201 cheap: Some(crate::config::wire_model_for_provider(
202 provider,
203 "deepseek-v4-flash",
204 )),
205 }
206 }
207 ProviderKind::Volcengine if current_model.to_ascii_lowercase().contains("deepseek") => {
208 RouterCandidates {
209 big: crate::config::DEFAULT_VOLCENGINE_MODEL.to_string(),
210 cheap: Some(crate::config::DEFAULT_VOLCENGINE_FLASH_MODEL.to_string()),
211 }
212 }
213 _ => RouterCandidates {
214 big: current_model.to_string(),
215 cheap: None,
216 },
217 }
218 }
219
220 /// The loud half of `RouterCandidates::cheap == None`.
221 ///
222 /// `None` is a legitimate answer for a pair this router knows to be
223 /// single-tier, and a misconfiguration for a pair it has never heard of; both
224 /// land the child on the parent model at the parent's price. A caller that was
225 /// asked for a fast lane attaches this where the route is shown, so
226 /// `Faster`/`Auto` never resolves to full price without saying so.
227 #[must_use]
228 pub(crate) fn missing_fast_sibling_note(provider: ProviderKind, model: &str) -> String {
229 // The model id is bounded so an absurd route cannot crowd the receipt it
230 // travels on (`ChildRouteReceipt::fallback_note`, 1 KiB gate).
231 let model: String = model.trim().chars().take(64).collect();
232 format!(
233 "no cheap sibling for {}/{}; the child runs on the parent model at parent price — pin a child model to choose",
234 provider.as_str(),
235 model
236 )
237 }
238
239 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
240 pub(crate) enum AutoRouteSource {
241 FlashRouter,
242 Heuristic,
243 }
244
245 impl AutoRouteSource {
246 #[must_use]
247 pub(crate) fn label(self) -> &'static str {
248 match self {
249 AutoRouteSource::FlashRouter => "classifier",
250 AutoRouteSource::Heuristic => "heuristic",
251 }
252 }
253 }
254
255 /// Provider-safe tier reported for the concrete Auto route.
256 ///
257 /// `Selected` is deliberately neutral: a classifier may choose a runnable
258 /// inventory model that is not part of a known strong/fast pair, and the UI
259 /// must not invent a tier from the model id.
260 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261 #[serde(rename_all = "snake_case")]
262 pub(crate) enum AutoRouteTier {
263 Strong,
264 Fast,
265 Only,
266 Selected,
267 }
268
269 impl AutoRouteTier {
270 #[must_use]
271 pub(crate) fn label(self) -> &'static str {
272 match self {
273 Self::Strong => "strong",
274 Self::Fast => "fast",
275 Self::Only => "only model",
276 Self::Selected => "selected",
277 }
278 }
279 }
280
281 /// Scope from which the concrete Auto route was selected.
282 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283 #[serde(rename_all = "snake_case")]
284 pub(crate) enum AutoRouteScope {
285 /// The network classifier could choose any runnable provider/model pair in
286 /// the redacted inventory. Only reachable under the persisted
287 /// `[auto] cross_provider = true` opt-in (#4411).
288 RunnableProviders,
289 /// The network classifier saw only the active provider's runnable routes —
290 /// the default Auto scope (#4411).
291 ActiveProvider,
292 /// The local declared fallback selected within one resolved route.
293 ResolvedProvider,
294 }
295
296 impl AutoRouteScope {
297 #[must_use]
298 pub(crate) fn label(self) -> &'static str {
299 match self {
300 Self::RunnableProviders => "runnable providers",
301 Self::ActiveProvider => "active provider only",
302 Self::ResolvedProvider => "resolved provider",
303 }
304 }
305 }
306
307 /// Non-secret data path used to make an Auto decision.
308 #[derive(Debug, Clone, PartialEq, Eq)]
309 pub(crate) enum AutoRouteDataPath {
310 LocalHeuristic,
311 Classifier {
312 provider: codewhale_config::route::ProviderId,
313 provider_kind: ProviderKind,
314 model: String,
315 },
316 /// A System One decision model (#6525). Additive: older binaries cannot
317 /// read a session that persisted this variant.
318 Decision {
319 route: DecisionRouterRoute,
320 model: String,
321 },
322 }
323
324 // Persistence/source facts only. Deserializing this projection never admits a route.
325 #[derive(Serialize, Deserialize)]
326 #[serde(rename_all = "snake_case")]
327 enum AutoRouteDataPathWire {
328 LocalHeuristic,
329 Classifier {
330 provider: String,
331 model: String,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 provider_id: Option<String>,
334 },
335 Decision {
336 route: DecisionRouterRoute,
337 model: String,
338 },
339 }
340 impl Serialize for AutoRouteDataPath {
341 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
342 let wire = match self {
343 Self::LocalHeuristic => AutoRouteDataPathWire::LocalHeuristic,
344 Self::Decision { route, model } => AutoRouteDataPathWire::Decision {
345 route: *route,
346 model: model.clone(),
347 },
348 Self::Classifier {
349 provider,
350 provider_kind,
351 model,
352 } => {
353 let tag = codewhale_config::descriptors::tui_wire_tag_for_route(
354 *provider_kind,
355 provider.as_str(),
356 )
357 .ok_or_else(|| {
358 serde::ser::Error::custom("contradictory classifier source identity")
359 })?;
360 AutoRouteDataPathWire::Classifier {
361 provider: tag.into(),
362 model: model.clone(),
363 provider_id: (*provider_kind == ProviderKind::Custom
364 && provider.as_str() != "custom")
365 .then(|| provider.to_string()),
366 }
367 }
368 };
369 wire.serialize(serializer)
370 }
371 }
372 impl<'de> Deserialize<'de> for AutoRouteDataPath {
373 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
374 Ok(match AutoRouteDataPathWire::deserialize(deserializer)? {
375 AutoRouteDataPathWire::LocalHeuristic => Self::LocalHeuristic,
376 AutoRouteDataPathWire::Decision { route, model } => Self::Decision { route, model },
377 AutoRouteDataPathWire::Classifier {
378 provider,
379 model,
380 provider_id,
381 } => {
382 let row = codewhale_config::descriptors::compatibility_from_wire_tag(&provider)
383 .ok_or_else(|| {
384 serde::de::Error::custom("unknown classifier source identity")
385 })?;
386 let id = provider_id.as_deref().unwrap_or(row.id);
387 let kind = codewhale_config::descriptors::kind_from_tui_wire_tag(&provider, id)
388 .ok_or_else(|| {
389 serde::de::Error::custom("contradictory classifier source identity")
390 })?;
391 Self::Classifier {
392 provider: id.into(),
393 provider_kind: kind,
394 model,
395 }
396 }
397 })
398 }
399 }
400
401 impl AutoRouteDataPath {
402 #[must_use]
403 pub(crate) fn label(&self) -> String {
404 match self {
405 Self::LocalHeuristic => "local only (no router request)".to_string(),
406 Self::Classifier {
407 provider,
408 provider_kind,
409 model,
410 } => format!(
411 "latest request + bounded recent context -> {} / {model}",
412 codewhale_config::descriptors::compatibility_for_id(provider.as_str())
413 .filter(|row| row.kind == *provider_kind)
414 .map_or(provider.as_str(), |row| row.label)
415 ),
416 Self::Decision { route, model } => format!(
417 "latest request + bounded recent context -> {} / {model} (decision model)",
418 route.display_name()
419 ),
420 }
421 }
422 }
423
424 /// Local signal that selected the provider-safe strong/fast candidate.
425 ///
426 /// Since the #6290 rework the local fallback never judges request content:
427 /// without the flash classifier there is no per-request signal, so the route
428 /// is the configured default (or the runnable fast sibling under the explicit
429 /// `[auto] cost_saving` opt-in). The content-derived variants below are never
430 /// constructed for new routes; they are retained so saved sessions from before
431 /// the rework still deserialize.
432 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
433 #[serde(rename_all = "snake_case")]
434 pub(crate) enum AutoRouteHeuristicReason {
435 /// Legacy: the deleted keyword/length classifier judged the request
436 /// complex. Retained for saved-session serde compat only.
437 ComplexRequest,
438 /// Legacy: the deleted length rule judged the request short. Retained
439 /// for saved-session serde compat only.
440 ShortRequest,
441 /// Legacy: the deleted length rule judged the request long. Retained
442 /// for saved-session serde compat only.
443 LongRequest,
444 CostSavingPolicy,
445 /// Legacy: the deleted classifier judged the request routine. Retained
446 /// for saved-session serde compat only.
447 RoutineRequest,
448 NoFastSibling,
449 NoRunnableCandidate,
450 /// The configured default model: no classifier was available and no
451 /// content signal was consulted.
452 DeclaredDefault,
453 /// A decision router answered below `[auto.router] min_confidence`.
454 LowConfidence,
455 }
456
457 impl AutoRouteHeuristicReason {
458 #[must_use]
459 fn label(self) -> &'static str {
460 match self {
461 Self::ComplexRequest => "complex request",
462 Self::ShortRequest => "short request",
463 Self::LongRequest => "long request",
464 Self::CostSavingPolicy => "cost-saving policy",
465 Self::RoutineRequest => "routine request",
466 Self::NoFastSibling => "no runnable fast sibling",
467 Self::NoRunnableCandidate => "no runnable inventory candidate",
468 Self::DeclaredDefault => "configured default (no classifier)",
469 Self::LowConfidence => "decision confidence below threshold",
470 }
471 }
472 }
473
474 /// Why the route was selected. Classifier failures are intentionally
475 /// collapsed to a non-secret reason; provider errors and response bodies must
476 /// never enter diagnostics.
477 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478 #[serde(rename_all = "snake_case")]
479 pub(crate) enum AutoRouteReason {
480 ClassifierRecommendation,
481 /// The `local_heuristic` alias keeps sessions saved before the #6290
482 /// rework loadable; new sessions persist `local_fallback`.
483 #[serde(alias = "local_heuristic")]
484 LocalFallback(AutoRouteHeuristicReason),
485 ClassifierFallback(AutoRouteHeuristicReason),
486 }
487
488 impl AutoRouteReason {
489 #[must_use]
490 pub(crate) fn label(self) -> String {
491 match self {
492 Self::ClassifierRecommendation => "classifier recommendation".to_string(),
493 Self::LocalFallback(reason) => format!("local fallback: {}", reason.label()),
494 Self::ClassifierFallback(reason) => {
495 format!("classifier fallback: {}", reason.label())
496 }
497 }
498 }
499 }
500
501 /// Effective provider-scoped model pair used to classify the selected tier.
502 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503 pub(crate) struct AutoRoutePair {
504 pub(crate) strong: String,
505 pub(crate) fast: Option<String>,
506 }
507
508 /// Per-turn Auto routing diagnostics. Provider/model identity remains owned by
509 /// the authoritative runtime `TurnRoute`; this receipt only records how the
510 /// concrete route was chosen.
511 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
512 pub(crate) struct AutoRouteReceipt {
513 pub(crate) tier: AutoRouteTier,
514 pub(crate) pair: AutoRoutePair,
515 pub(crate) scope: AutoRouteScope,
516 pub(crate) data_path: AutoRouteDataPath,
517 pub(crate) reason: AutoRouteReason,
518 /// Decision-model evidence (#6525); absent for chat routers and for
519 /// sessions saved before it existed.
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub(crate) decision: Option<AutoRouteDecisionEvidence>,
522 /// Why a configured router did not produce this route. Set by both router
523 /// kinds, so a configured-but-failing router is shown as failing.
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub(crate) router_failure: Option<AutoRouterFailure>,
526 }
527
528 /// What a decision model answered and what it cost. Probabilities are basis
529 /// points (0..=10000) so the receipt stays `Eq` and never re-renders floats.
530 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531 pub struct AutoRouteDecisionEvidence {
532 /// The tier the decision model chose (`fast` | `strong`).
533 pub(crate) choice: String,
534 pub(crate) probabilities_bp: BTreeMap<String, u16>,
535 pub(crate) confidence_bp: u16,
536 pub(crate) min_confidence_bp: u16,
537 /// `[auto] cost_saving` kept the fast tier because the strong tier's
538 /// probability was below the cost-saving floor.
539 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
540 pub(crate) cost_saving_kept_fast: bool,
541 /// The reasoning effort the decision applied, if any.
542 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub(crate) thinking: Option<String>,
544 /// `usage.cost` exactly as the provider reported it (USD decimal).
545 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub(crate) provider_reported_cost_usd: Option<String>,
547 pub(crate) latency_ms: u64,
548 /// The dated model snapshot the provider echoed, when present.
549 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub(crate) response_model: Option<String>,
551 }
552
553 /// Non-secret failure class for a configured router. Provider error bodies
554 /// never enter this type.
555 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
556 #[serde(rename_all = "snake_case", tag = "kind")]
557 pub(crate) enum AutoRouterFailure {
558 /// Declared but unusable: missing key, unknown kind, or client setup.
559 NotRunnable,
560 Timeout,
561 Cancelled,
562 Http {
563 status: u16,
564 },
565 QuotaExhausted,
566 /// The provider rejected the request (model, size, or policy).
567 Rejected,
568 Transport,
569 /// The response could not be decoded or failed validation.
570 InvalidAnswer,
571 }
572
573 impl AutoRouterFailure {
574 #[must_use]
575 pub(crate) fn label(self) -> String {
576 match self {
577 Self::NotRunnable => {
578 "not runnable (check the router key and [auto.router])".to_string()
579 }
580 Self::Timeout => "timed out".to_string(),
581 Self::Cancelled => "cancelled".to_string(),
582 Self::Http { status } => format!("HTTP {status}"),
583 Self::QuotaExhausted => "provider quota or credits exhausted".to_string(),
584 Self::Rejected => "request rejected by the provider".to_string(),
585 Self::Transport => "network error".to_string(),
586 Self::InvalidAnswer => "invalid answer".to_string(),
587 }
588 }
589 }
590
591 #[derive(Debug, Clone, PartialEq, Eq)]
592 pub(crate) struct AutoRouteSelection {
593 pub(crate) provider: ProviderIdentity,
594 pub(crate) model: String,
595 pub(crate) reasoning_effort: Option<ReasoningEffort>,
596 pub(crate) source: AutoRouteSource,
597 /// Present for Auto decisions; explicit inventory lookups intentionally do
598 /// not pretend to be Auto routing receipts.
599 pub(crate) receipt: Option<AutoRouteReceipt>,
600 /// Provider calls made to choose this route. These are deliberately kept
601 /// separate from the selected parent route: a classifier may run on a
602 /// different provider/model/quote, so pricing it under the eventual turn
603 /// would double-charge the parent and lose the classifier's real route.
604 ///
605 /// Auto currently admits at most one classifier request per selection.
606 pub(crate) routed_usage: Vec<RuntimeUsageRecord>,
607 /// Exact frozen routes for admitted classifier calls whose provider
608 /// response omitted usage. The count below remains authoritative and may
609 /// exceed this bounded vector after overflow.
610 pub(crate) routed_usage_drop_records: Vec<RuntimeUsageDropRecord>,
611 /// Classifier requests admitted to dispatch whose response usage could not
612 /// be recovered (timeout/transport failure). Consumers must surface this
613 /// as incomplete coverage rather than silently treating it as zero spend.
614 pub(crate) routed_usage_dropped_records: u64,
615 }
616
617 fn extract_first_json_object(raw: &str) -> Option<&str> {
618 let start = raw.find('{')?;
619 let end = raw.rfind('}')?;
620 (end >= start).then_some(&raw[start..=end])
621 }
622
623 fn parse_auto_route_reasoning_effort(effort: &str) -> Option<ReasoningEffort> {
624 ReasoningEffort::parse_strict(effort).ok()
625 }
626
627 /// Normalize an Auto-route effort when only the provider is known.
628 ///
629 /// This delegates to the one authoritative normalizer,
630 /// [`ReasoningEffort::normalize_for_route`], with an unresolved route (empty
631 /// endpoint and wire model), so the Auto path cannot carry a second copy of
632 /// the historic `low | medium -> high` provider collapse (Slice 4, D2).
633 #[must_use]
634 pub(crate) fn normalize_auto_route_effort_for_provider(
635 provider: ProviderKind,
636 effort: ReasoningEffort,
637 ) -> ReasoningEffort {
638 effort.normalize_for_route(provider, "", "")
639 }
640
641 /// Select the reasoning request that accompanies an Auto-model route.
642 ///
643 /// Model routing and reasoning routing are independent. An explicit fixed
644 /// preference wins over the classifier's suggestion; an absent preference or
645 /// explicit `Auto` keeps reasoning under per-prompt control.
646 #[must_use]
647 pub(crate) fn resolve_auto_model_reasoning(
648 preference: Option<ReasoningEffort>,
649 routed: Option<ReasoningEffort>,
650 ) -> (Option<ReasoningEffort>, bool) {
651 match preference {
652 Some(
653 effort @ (ReasoningEffort::Off
654 | ReasoningEffort::Minimal
655 | ReasoningEffort::Low
656 | ReasoningEffort::Medium
657 | ReasoningEffort::High
658 | ReasoningEffort::XHigh
659 | ReasoningEffort::Ultra
660 | ReasoningEffort::Max),
661 ) => (Some(effort), false),
662 None | Some(ReasoningEffort::Auto) => (routed, true),
663 }
664 }
665
666 /// Route-aware equivalent of [`normalize_auto_route_effort_for_provider`].
667 /// The inventory knows the selected provider/model, and the route resolver
668 /// supplies the endpoint needed to distinguish Kimi Code's official bare-K3
669 /// contract from generic Moonshot.
670 #[must_use]
671 pub(crate) fn normalize_auto_route_effort_for_configured_route(
672 config: &Config,
673 identity: &ProviderIdentity,
674 model: &str,
675 effort: ReasoningEffort,
676 ) -> ReasoningEffort {
677 crate::route_runtime::resolve_runtime_route_for_identity(config, identity, Some(model))
678 .map(|route| {
679 effort.normalize_for_route(
680 identity.provider,
681 &route.candidate.endpoint().base_url,
682 &route.model,
683 )
684 })
685 .unwrap_or_else(|_| normalize_auto_route_effort_for_provider(identity.provider, effort))
686 }
687
688 fn normalize_auto_route_selection_for_config(
689 config: &Config,
690 mut selection: AutoRouteSelection,
691 ) -> AutoRouteSelection {
692 selection.reasoning_effort = selection.reasoning_effort.map(|effort| {
693 normalize_auto_route_effort_for_configured_route(
694 config,
695 &selection.provider,
696 &selection.model,
697 effort,
698 )
699 });
700 selection
701 }
702
703 #[derive(Debug, Clone, PartialEq, Eq)]
704 struct InventoryAutoRouteRecommendation {
705 provider: ProviderIdentity,
706 model: String,
707 reasoning_effort: Option<ReasoningEffort>,
708 }
709
710 /// One provider-backed classifier attempt. A provider-success response always
711 /// reaches this shape before its content is interpreted, so invalid JSON and
712 /// provider-declared incomplete output retain their exact routed usage.
713 #[derive(Debug, Clone, PartialEq, Eq)]
714 struct InventoryAutoRouteAttempt {
715 recommendation: Option<InventoryAutoRouteRecommendation>,
716 routed_usage: Vec<RuntimeUsageRecord>,
717 routed_usage_drop_records: Vec<RuntimeUsageDropRecord>,
718 routed_usage_dropped_records: u64,
719 /// Decision-model evidence, when a decision router answered.
720 decision: Option<AutoRouteDecisionEvidence>,
721 /// Why the router produced no usable recommendation.
722 failure: Option<AutoRouterFailure>,
723 /// Overrides the fallback reason when the router answered but its answer
724 /// was not acted on (low confidence).
725 fallback_reason: Option<AutoRouteHeuristicReason>,
726 }
727
728 impl InventoryAutoRouteAttempt {
729 fn failed(failure: AutoRouterFailure) -> Self {
730 Self {
731 recommendation: None,
732 routed_usage: Vec::new(),
733 routed_usage_drop_records: Vec::new(),
734 routed_usage_dropped_records: 0,
735 decision: None,
736 failure: Some(failure),
737 fallback_reason: None,
738 }
739 }
740 }
741
742 pub(crate) async fn resolve_auto_route_with_inventory(
743 config: &Config,
744 latest_request: &str,
745 recent_context: &str,
746 selected_model_mode: &str,
747 selected_thinking_mode: &str,
748 ) -> Result<AutoRouteSelection> {
749 resolve_auto_route_with_inventory_for_session(
750 config,
751 latest_request,
752 recent_context,
753 "agent",
754 selected_model_mode,
755 selected_thinking_mode,
756 )
757 .await
758 }
759
760 pub(crate) async fn resolve_auto_route_with_inventory_for_session(
761 config: &Config,
762 latest_request: &str,
763 recent_context: &str,
764 session_mode: &str,
765 selected_model_mode: &str,
766 selected_thinking_mode: &str,
767 ) -> Result<AutoRouteSelection> {
768 resolve_auto_route_with_inventory_for_session_and_cache_policy(
769 config,
770 latest_request,
771 recent_context,
772 session_mode,
773 selected_model_mode,
774 selected_thinking_mode,
775 true,
776 )
777 .await
778 }
779
780 pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_policy(
781 config: &Config,
782 latest_request: &str,
783 recent_context: &str,
784 session_mode: &str,
785 selected_model_mode: &str,
786 selected_thinking_mode: &str,
787 allow_response_cache: bool,
788 ) -> Result<AutoRouteSelection> {
789 let inventory = ModelInventory::from_config(config).map_err(anyhow::Error::msg)?;
790 if !inventory.router_available {
791 // Declared-default auto routing when no router is available. A router
792 // that was declared but cannot run is marked failing on the receipt.
793 return Ok(normalize_auto_route_selection_for_config(
794 config,
795 auto_route_without_router(config, &inventory),
796 ));
797 }
798
799 if cfg!(test) {
800 return Ok(normalize_auto_route_selection_for_config(
801 config,
802 auto_route_declared_fallback(config, &inventory),
803 ));
804 }
805
806 let selection = auto_route_via_router(
807 config,
808 &inventory,
809 latest_request,
810 recent_context,
811 session_mode,
812 selected_model_mode,
813 selected_thinking_mode,
814 allow_response_cache,
815 )
816 .await;
817 Ok(normalize_auto_route_selection_for_config(config, selection))
818 }
819
820 /// The local fallback when the router is not available, marking a declared
821 /// but unusable router as failing rather than silently ignoring it.
822 fn auto_route_without_router(config: &Config, inventory: &ModelInventory) -> AutoRouteSelection {
823 let mut selection = auto_route_declared_fallback(config, inventory);
824 if inventory.router_setup_issue.is_some()
825 && let Some(receipt) = selection.receipt.as_mut()
826 {
827 receipt.router_failure = Some(AutoRouterFailure::NotRunnable);
828 }
829 selection
830 }
831
832 /// Ask the configured router (either kind) for this turn's route. Callers
833 /// have already checked `inventory.router_available`. The `cfg!(test)`
834 /// short-circuit lives in the caller, so tests exercise this directly.
835 #[allow(clippy::too_many_arguments)]
836 async fn auto_route_via_router(
837 config: &Config,
838 inventory: &ModelInventory,
839 latest_request: &str,
840 recent_context: &str,
841 session_mode: &str,
842 selected_model_mode: &str,
843 selected_thinking_mode: &str,
844 allow_response_cache: bool,
845 ) -> AutoRouteSelection {
846 let fallback = auto_route_declared_fallback(config, inventory);
847 let attempt = match inventory.router_kind {
848 AutoRouterKind::Decision => {
849 // Options are tiers, not model ids: without a runnable strong/fast
850 // pair there is nothing to decide, so no request and no spend.
851 let Some(pair) = runnable_active_pair(inventory) else {
852 let mut selection = fallback;
853 if let Some(receipt) = selection.receipt.as_mut() {
854 receipt.reason =
855 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoFastSibling);
856 }
857 return selection;
858 };
859 auto_route_decision_recommendation(
860 config,
861 inventory,
862 &pair,
863 latest_request,
864 recent_context,
865 session_mode,
866 selected_thinking_mode,
867 )
868 .await
869 }
870 AutoRouterKind::Chat => {
871 auto_route_inventory_recommendation(
872 config,
873 inventory,
874 latest_request,
875 recent_context,
876 session_mode,
877 selected_model_mode,
878 selected_thinking_mode,
879 allow_response_cache,
880 )
881 .await
882 }
883 };
884 match attempt {
885 Ok(attempt) => auto_route_from_classifier_attempt(fallback, inventory, attempt),
886 // Client construction/preparation failed before a provider request was
887 // admitted. There is no provider usage to invent and no dropped
888 // response receipt to claim.
889 Err(_) => {
890 let mut selection = auto_route_classifier_fallback(fallback, inventory);
891 if let Some(receipt) = selection.receipt.as_mut() {
892 receipt.router_failure = Some(AutoRouterFailure::NotRunnable);
893 }
894 selection
895 }
896 }
897 }
898
899 /// Fixed synthetic request used by `/router` preset test calls.
900 pub(crate) const ROUTER_TEST_REQUEST: &str = "Rename the variable foo to bar in src/lib.rs";
901
902 /// One `/router` preset test call (#6525): exactly the per-turn routing path,
903 /// run once against a fixed synthetic request, with its wall-clock latency.
904 /// Returns `Err` with a non-secret reason whenever no request was sent, so
905 /// the setup view never reports a test that did not happen.
906 pub(crate) async fn test_auto_router(
907 config: &Config,
908 ) -> std::result::Result<(AutoRouteSelection, u64), String> {
909 let inventory = ModelInventory::from_config(config)?;
910 if !inventory.router_available {
911 return Err(inventory
912 .router_setup_issue
913 .map_or("router is not configured", |issue| issue.label())
914 .to_string());
915 }
916 // A decision router with no runnable strong/fast pair has nothing to
917 // decide and sends nothing (see `auto_route_via_router`).
918 if inventory.router_kind == AutoRouterKind::Decision
919 && runnable_active_pair(&inventory).is_none()
920 {
921 return Err(AutoRouteHeuristicReason::NoFastSibling.label().to_string());
922 }
923 let started = Instant::now();
924 let selection = auto_route_via_router(
925 config,
926 &inventory,
927 ROUTER_TEST_REQUEST,
928 "",
929 "agent",
930 "auto",
931 "auto",
932 false,
933 )
934 .await;
935 let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
936 // `NotRunnable` is set only when the client could not be built or the
937 // request failed preflight: nothing reached the network.
938 if let Some(failure @ AutoRouterFailure::NotRunnable) = selection
939 .receipt
940 .as_ref()
941 .and_then(|receipt| receipt.router_failure)
942 {
943 return Err(failure.label());
944 }
945 Ok((
946 normalize_auto_route_selection_for_config(config, selection),
947 latency_ms,
948 ))
949 }
950
951 pub(crate) fn resolve_explicit_route_with_inventory(
952 config: &Config,
953 requested_model: &str,
954 ) -> Option<AutoRouteSelection> {
955 let requested_model = requested_model.trim();
956 if requested_model.is_empty() || requested_model.eq_ignore_ascii_case("auto") {
957 return None;
958 }
959
960 let inventory = ModelInventory::from_config(config).ok()?;
961 let active_identity = &inventory.active_identity;
962
963 if let Some(candidate) = inventory.candidates.iter().find(|candidate| {
964 &candidate.identity == active_identity
965 && explicit_model_matches_candidate(candidate, requested_model)
966 }) {
967 return Some(AutoRouteSelection {
968 provider: candidate.identity.clone(),
969 model: candidate.model.clone(),
970 reasoning_effort: config.reasoning_effort().map(|setting| {
971 normalize_auto_route_effort_for_configured_route(
972 config,
973 &candidate.identity,
974 &candidate.model,
975 ReasoningEffort::from_setting(setting),
976 )
977 }),
978 source: AutoRouteSource::Heuristic,
979 receipt: None,
980 routed_usage: Vec::new(),
981 routed_usage_drop_records: Vec::new(),
982 routed_usage_dropped_records: 0,
983 });
984 }
985
986 let mut matches = inventory
987 .candidates
988 .iter()
989 .filter(|candidate| explicit_model_matches_candidate(candidate, requested_model));
990 let candidate = matches.next()?;
991 if matches.next().is_some() {
992 return None;
993 }
994
995 Some(AutoRouteSelection {
996 provider: candidate.identity.clone(),
997 model: candidate.model.clone(),
998 reasoning_effort: config.reasoning_effort().map(|setting| {
999 normalize_auto_route_effort_for_configured_route(
1000 config,
1001 &candidate.identity,
1002 &candidate.model,
1003 ReasoningEffort::from_setting(setting),
1004 )
1005 }),
1006 source: AutoRouteSource::Heuristic,
1007 receipt: None,
1008 routed_usage: Vec::new(),
1009 routed_usage_drop_records: Vec::new(),
1010 routed_usage_dropped_records: 0,
1011 })
1012 }
1013
1014 pub(crate) fn explicit_route_candidate_providers(
1015 config: &Config,
1016 requested_model: &str,
1017 ) -> Vec<ProviderIdentity> {
1018 let requested_model = requested_model.trim();
1019 if requested_model.is_empty() || requested_model.eq_ignore_ascii_case("auto") {
1020 return Vec::new();
1021 }
1022
1023 let Ok(inventory) = ModelInventory::from_config(config) else {
1024 return Vec::new();
1025 };
1026 let mut providers = Vec::new();
1027 for candidate in inventory
1028 .candidates
1029 .iter()
1030 .filter(|candidate| explicit_model_matches_candidate(candidate, requested_model))
1031 {
1032 if !providers.contains(&candidate.identity) {
1033 providers.push(candidate.identity.clone());
1034 }
1035 }
1036 providers
1037 }
1038
1039 fn explicit_model_matches_candidate(
1040 candidate: &crate::model_inventory::ModelRouteCandidate,
1041 requested_model: &str,
1042 ) -> bool {
1043 candidate.model.eq_ignore_ascii_case(requested_model)
1044 || normalize_model_name_for_provider(candidate.provider, requested_model)
1045 .is_some_and(|model| candidate.model.eq_ignore_ascii_case(&model))
1046 }
1047
1048 /// Declared local fallback for Auto routing when the flash classifier is
1049 /// unavailable (or fails): the configured default model.
1050 ///
1051 /// There is no per-request signal here by design. Until the #6290 rework this
1052 /// guessed cheap-vs-big from request wording (`COMPLEX_KEYWORDS` plus
1053 /// char-length thresholds) — host-side semantic determinism that made cost
1054 /// and quality depend on vocabulary. The only content-blind override is the
1055 /// explicit `[auto] cost_saving` opt-in, which pins the runnable fast
1056 /// sibling; providers without one stay on the default.
1057 fn auto_route_declared_fallback(config: &Config, inventory: &ModelInventory) -> AutoRouteSelection {
1058 let Some(active) = inventory.active_default() else {
1059 let model = config.default_model();
1060 return AutoRouteSelection {
1061 provider: inventory.active_identity.clone(),
1062 receipt: Some(auto_route_receipt(
1063 inventory,
1064 &inventory.active_identity,
1065 &model,
1066 AutoRouteScope::ResolvedProvider,
1067 AutoRouteDataPath::LocalHeuristic,
1068 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate),
1069 )),
1070 model,
1071 reasoning_effort: Some(crate::auto_reasoning::select()),
1072 source: AutoRouteSource::Heuristic,
1073 routed_usage: Vec::new(),
1074 routed_usage_drop_records: Vec::new(),
1075 routed_usage_dropped_records: 0,
1076 };
1077 };
1078 let router_candidates = provider_router_candidates(active.provider, &active.model);
1079 let runnable_fast = router_candidates.cheap.as_deref().filter(|model| {
1080 inventory
1081 .candidate(active.identity.key.as_str(), model)
1082 .is_some_and(|candidate| candidate.readiness.can_attempt())
1083 });
1084 let (model, reason) = if config.auto_cost_saving() {
1085 match runnable_fast {
1086 Some(cheap) => (
1087 cheap.to_string(),
1088 AutoRouteHeuristicReason::CostSavingPolicy,
1089 ),
1090 None => (
1091 active.model.clone(),
1092 AutoRouteHeuristicReason::NoFastSibling,
1093 ),
1094 }
1095 } else {
1096 (
1097 active.model.clone(),
1098 AutoRouteHeuristicReason::DeclaredDefault,
1099 )
1100 };
1101 AutoRouteSelection {
1102 provider: active.identity.clone(),
1103 receipt: Some(auto_route_receipt(
1104 inventory,
1105 &active.identity,
1106 &model,
1107 AutoRouteScope::ResolvedProvider,
1108 AutoRouteDataPath::LocalHeuristic,
1109 AutoRouteReason::LocalFallback(reason),
1110 )),
1111 model,
1112 reasoning_effort: Some(crate::auto_reasoning::select()),
1113 source: AutoRouteSource::Heuristic,
1114 routed_usage: Vec::new(),
1115 routed_usage_drop_records: Vec::new(),
1116 routed_usage_dropped_records: 0,
1117 }
1118 }
1119
1120 fn auto_route_from_classifier(
1121 inventory: &ModelInventory,
1122 recommendation: InventoryAutoRouteRecommendation,
1123 ) -> AutoRouteSelection {
1124 let data_path = router_data_path(inventory);
1125 // Report the scope the classifier actually had, not the widest one it
1126 // could ever have (#4411). A decision router only ever chooses a tier of
1127 // the active provider.
1128 let scope = if inventory.cross_provider_auto && inventory.router_decision_route.is_none() {
1129 AutoRouteScope::RunnableProviders
1130 } else {
1131 AutoRouteScope::ActiveProvider
1132 };
1133 AutoRouteSelection {
1134 provider: recommendation.provider.clone(),
1135 receipt: Some(auto_route_receipt(
1136 inventory,
1137 &recommendation.provider,
1138 &recommendation.model,
1139 scope,
1140 data_path,
1141 AutoRouteReason::ClassifierRecommendation,
1142 )),
1143 model: recommendation.model,
1144 reasoning_effort: recommendation.reasoning_effort,
1145 source: AutoRouteSource::FlashRouter,
1146 routed_usage: Vec::new(),
1147 routed_usage_drop_records: Vec::new(),
1148 routed_usage_dropped_records: 0,
1149 }
1150 }
1151
1152 fn auto_route_from_classifier_attempt(
1153 fallback: AutoRouteSelection,
1154 inventory: &ModelInventory,
1155 attempt: InventoryAutoRouteAttempt,
1156 ) -> AutoRouteSelection {
1157 let InventoryAutoRouteAttempt {
1158 recommendation,
1159 routed_usage,
1160 routed_usage_drop_records,
1161 routed_usage_dropped_records,
1162 decision,
1163 failure,
1164 fallback_reason,
1165 } = attempt;
1166 let mut selection = recommendation.map_or_else(
1167 || auto_route_classifier_fallback(fallback, inventory),
1168 |recommendation| auto_route_from_classifier(inventory, recommendation),
1169 );
1170 if let Some(receipt) = selection.receipt.as_mut() {
1171 if let (Some(reason), AutoRouteReason::ClassifierFallback(_)) =
1172 (fallback_reason, receipt.reason)
1173 {
1174 receipt.reason = AutoRouteReason::ClassifierFallback(reason);
1175 }
1176 receipt.decision = decision;
1177 receipt.router_failure = failure;
1178 }
1179 selection.routed_usage = routed_usage;
1180 selection.routed_usage_drop_records = routed_usage_drop_records;
1181 selection.routed_usage_dropped_records = routed_usage_dropped_records;
1182 selection
1183 }
1184
1185 fn auto_route_classifier_fallback(
1186 mut fallback: AutoRouteSelection,
1187 inventory: &ModelInventory,
1188 ) -> AutoRouteSelection {
1189 if let Some(receipt) = fallback.receipt.as_mut() {
1190 let fallback_reason = match receipt.reason {
1191 AutoRouteReason::LocalFallback(reason)
1192 | AutoRouteReason::ClassifierFallback(reason) => reason,
1193 AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::DeclaredDefault,
1194 };
1195 receipt.data_path = router_data_path(inventory);
1196 receipt.reason = AutoRouteReason::ClassifierFallback(fallback_reason);
1197 }
1198 fallback
1199 }
1200
1201 /// The non-secret data path of the configured router.
1202 fn router_data_path(inventory: &ModelInventory) -> AutoRouteDataPath {
1203 match inventory.router_decision_route {
1204 Some(route) => AutoRouteDataPath::Decision {
1205 route,
1206 model: inventory.router_model.to_string(),
1207 },
1208 None => AutoRouteDataPath::Classifier {
1209 provider: inventory.router_identity.as_ref().map_or_else(
1210 || inventory.router_provider.as_str().into(),
1211 |identity| identity.key.clone(),
1212 ),
1213 provider_kind: inventory.router_provider,
1214 model: inventory.router_model.to_string(),
1215 },
1216 }
1217 }
1218
1219 fn auto_route_receipt(
1220 inventory: &ModelInventory,
1221 identity: &ProviderIdentity,
1222 selected_model: &str,
1223 scope: AutoRouteScope,
1224 data_path: AutoRouteDataPath,
1225 reason: AutoRouteReason,
1226 ) -> AutoRouteReceipt {
1227 let pair = auto_route_pair(inventory, identity, selected_model);
1228 let tier = if pair
1229 .fast
1230 .as_deref()
1231 .is_some_and(|fast| fast.eq_ignore_ascii_case(selected_model))
1232 {
1233 AutoRouteTier::Fast
1234 } else if pair.strong.eq_ignore_ascii_case(selected_model) {
1235 if pair.fast.is_some() {
1236 AutoRouteTier::Strong
1237 } else {
1238 AutoRouteTier::Only
1239 }
1240 } else {
1241 AutoRouteTier::Selected
1242 };
1243 AutoRouteReceipt {
1244 tier,
1245 pair,
1246 scope,
1247 data_path,
1248 reason,
1249 decision: None,
1250 router_failure: None,
1251 }
1252 }
1253
1254 fn auto_route_pair(
1255 inventory: &ModelInventory,
1256 identity: &ProviderIdentity,
1257 selected_model: &str,
1258 ) -> AutoRoutePair {
1259 // A provider can expose several unrelated model families. Derive the pair
1260 // from a runnable candidate that actually contains the selected model,
1261 // preferring a cheap-tier match before a strong-tier match. Falling back
1262 // to the provider default would report a truthful provider with a false
1263 // model family (for example OpenRouter GLM reported as DeepSeek).
1264 let matching_pair = inventory
1265 .candidates
1266 .iter()
1267 .filter(|candidate| candidate.identity == *identity && candidate.readiness.can_attempt())
1268 .map(|candidate| provider_router_candidates(identity.provider, &candidate.model))
1269 .find(|pair| {
1270 pair.cheap
1271 .as_deref()
1272 .is_some_and(|fast| fast.eq_ignore_ascii_case(selected_model))
1273 })
1274 .or_else(|| {
1275 inventory
1276 .candidates
1277 .iter()
1278 .filter(|candidate| {
1279 candidate.identity == *identity && candidate.readiness.can_attempt()
1280 })
1281 .map(|candidate| provider_router_candidates(identity.provider, &candidate.model))
1282 .find(|pair| pair.big.eq_ignore_ascii_case(selected_model))
1283 });
1284 let Some(candidates) = matching_pair else {
1285 return AutoRoutePair {
1286 strong: selected_model.to_string(),
1287 fast: None,
1288 };
1289 };
1290 let Some(strong) = inventory
1291 .candidate(identity.key.as_str(), &candidates.big)
1292 .filter(|candidate| candidate.readiness.can_attempt())
1293 .map(|candidate| candidate.model.clone())
1294 else {
1295 return AutoRoutePair {
1296 strong: selected_model.to_string(),
1297 fast: None,
1298 };
1299 };
1300 let fast = candidates.cheap.as_deref().and_then(|model| {
1301 inventory
1302 .candidate(identity.key.as_str(), model)
1303 .filter(|candidate| candidate.readiness.can_attempt())
1304 .map(|candidate| candidate.model.clone())
1305 });
1306 AutoRoutePair { strong, fast }
1307 }
1308
1309 fn auto_route_usage_has_reported_data(usage: &codewhale_models::Usage) -> bool {
1310 usage.input_tokens > 0
1311 || usage.output_tokens > 0
1312 || usage.prompt_cache_hit_tokens.is_some()
1313 || usage.prompt_cache_miss_tokens.is_some()
1314 || usage.prompt_cache_write_tokens.is_some()
1315 || usage.reasoning_tokens.is_some()
1316 || usage.reasoning_replay_tokens.is_some()
1317 || usage.server_tool_use.is_some()
1318 }
1319
1320 /// Stable, persistence-safe identity for one classifier response. The raw
1321 /// provider response id is hashed with the frozen dispatch route and instant;
1322 /// neither it nor any custom route label crosses into telemetry/persistence.
1323 pub(crate) fn auto_route_usage_source_id(
1324 route: &EffectiveRouteEnvelope,
1325 response_id: &str,
1326 ) -> String {
1327 use sha2::{Digest as _, Sha256};
1328
1329 let mut digest = Sha256::new();
1330 let dispatched_at = route.dispatched_at.to_rfc3339();
1331 for part in [
1332 b"codewhale:auto-route-classifier:v1".as_slice(),
1333 route.provider.as_str().as_bytes(),
1334 route.provider_identity.as_bytes(),
1335 route.model.as_bytes(),
1336 route
1337 .endpoint_fingerprint
1338 .as_deref()
1339 .unwrap_or_default()
1340 .as_bytes(),
1341 dispatched_at.as_bytes(),
1342 response_id.as_bytes(),
1343 ] {
1344 digest.update((part.len() as u64).to_le_bytes());
1345 digest.update(part);
1346 }
1347 format!(
1348 "auto-router:{}",
1349 crate::hashing::hex_bytes(digest.finalize())
1350 )
1351 }
1352
1353 fn auto_route_attempt_from_response(
1354 request_route: EffectiveRouteEnvelope,
1355 response: &MessageResponse,
1356 inventory: &ModelInventory,
1357 ) -> InventoryAutoRouteAttempt {
1358 // All-zero usage cannot price a routed segment. The dispatch caller owns
1359 // the stronger cache/provenance context and must explicitly classify this
1360 // as either a proven cache replay or missing provider billing evidence.
1361 let routed_usage = auto_route_usage_has_reported_data(&response.usage)
1362 .then(|| RuntimeUsageRecord {
1363 source_id: auto_route_usage_source_id(&request_route, &response.id),
1364 usage: EffectiveRouteUsage {
1365 route: request_route.sanitized_for_persistence(),
1366 usage: response.usage.clone(),
1367 },
1368 })
1369 .into_iter()
1370 .collect();
1371 let recommendation =
1372 (!codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()))
1373 .then(|| {
1374 parse_inventory_auto_route_recommendation(
1375 &message_response_text(response),
1376 inventory,
1377 )
1378 })
1379 .flatten();
1380 InventoryAutoRouteAttempt {
1381 failure: recommendation
1382 .is_none()
1383 .then_some(AutoRouterFailure::InvalidAnswer),
1384 recommendation,
1385 routed_usage,
1386 routed_usage_drop_records: Vec::new(),
1387 routed_usage_dropped_records: 0,
1388 decision: None,
1389 fallback_reason: None,
1390 }
1391 }
1392
1393 fn auto_route_attempt_from_provider_response(
1394 request_route: EffectiveRouteEnvelope,
1395 response: &MessageResponse,
1396 inventory: &ModelInventory,
1397 ) -> InventoryAutoRouteAttempt {
1398 let drop_route = request_route.sanitized_for_persistence();
1399 let mut attempt = auto_route_attempt_from_response(request_route, response, inventory);
1400 // This classifier request is currently not cacheable (temperature is
1401 // provider-default, not the deterministic Some(0.0) cache contract), so a
1402 // decoded all-zero response is missing provider billing evidence even when
1403 // the caller permits response-cache use. Do not silently reinterpret the
1404 // policy boolean as cache-hit provenance.
1405 if attempt.routed_usage.is_empty() {
1406 attempt.routed_usage_drop_records = vec![RuntimeUsageDropRecord {
1407 reason: crate::cost_status::RuntimeUsageMissingReason::default(),
1408 source_id: auto_route_usage_source_id(
1409 &drop_route,
1410 &format!("missing-usage:{}", response.id),
1411 ),
1412 route: drop_route,
1413 }];
1414 attempt.routed_usage_dropped_records = 1;
1415 }
1416 attempt
1417 }
1418
1419 fn auto_route_attempt_with_dropped_response(
1420 request_route: EffectiveRouteEnvelope,
1421 failure: AutoRouterFailure,
1422 ) -> InventoryAutoRouteAttempt {
1423 let request_route = request_route.sanitized_for_persistence();
1424 InventoryAutoRouteAttempt {
1425 routed_usage_drop_records: vec![RuntimeUsageDropRecord {
1426 reason: crate::cost_status::RuntimeUsageMissingReason::default(),
1427 source_id: auto_route_usage_source_id(&request_route, "transport-error"),
1428 route: request_route,
1429 }],
1430 routed_usage_dropped_records: 1,
1431 ..InventoryAutoRouteAttempt::failed(failure)
1432 }
1433 }
1434
1435 /// Prove that the deterministic request seam accepts this classifier request
1436 /// before capturing a quote or entering any provider permit/network path.
1437 fn preflight_auto_route_request(client: &CodewhaleClient, request: &MessageRequest) -> Result<()> {
1438 client.prepare_outbound_request(request.clone(), false)?;
1439 Ok(())
1440 }
1441
1442 #[allow(clippy::too_many_arguments)]
1443 async fn auto_route_inventory_recommendation(
1444 config: &Config,
1445 inventory: &ModelInventory,
1446 latest_request: &str,
1447 recent_context: &str,
1448 session_mode: &str,
1449 selected_model_mode: &str,
1450 selected_thinking_mode: &str,
1451 allow_response_cache: bool,
1452 ) -> Result<InventoryAutoRouteAttempt> {
1453 let mut router_config = config.clone();
1454 // The classifier runs on the inventory's router route: the explicit
1455 // [auto.router] route when configured, else the DeepSeek flash default.
1456 let identity = inventory
1457 .router_identity
1458 .as_ref()
1459 .ok_or_else(|| anyhow::anyhow!("router identity was not admitted"))?;
1460 router_config
1461 .scope_to_provider_identity(identity)
1462 .map_err(anyhow::Error::msg)?;
1463 router_config.default_text_model = Some(inventory.router_model.clone());
1464
1465 let client = CodewhaleClient::new(&router_config)?;
1466 let router_system = inventory_auto_router_system_prompt(inventory, config.auto_cost_saving());
1467 let router_prompt = classifier_prompt(
1468 &client,
1469 latest_request,
1470 recent_context,
1471 session_mode,
1472 selected_model_mode,
1473 selected_thinking_mode,
1474 );
1475 let max_tokens = client.effective_max_output_tokens(&inventory.router_model);
1476 let request = MessageRequest {
1477 model: inventory.router_model.to_string(),
1478 messages: vec![Message {
1479 role: Role::User,
1480 content: vec![ContentBlock::Text {
1481 text: router_prompt,
1482 cache_control: None,
1483 }],
1484 }],
1485 max_tokens,
1486 system: Some(SystemPrompt::Text(router_system)),
1487 tools: None,
1488 tool_choice: None,
1489 metadata: None,
1490 thinking: None,
1491 reasoning_effort: Some(
1492 inventory
1493 .router_thinking
1494 .clone()
1495 .unwrap_or_else(|| "off".to_string()),
1496 ),
1497 stream: Some(false),
1498 temperature: None,
1499 top_p: None,
1500 };
1501
1502 // Freeze pricing at the last application seam before the provider future
1503 // starts. Prompt shaping above may be slow and may overlap a catalog
1504 // refresh; completion-time mutable catalog state must never reprice this
1505 // already-admitted classifier request.
1506 preflight_auto_route_request(&client, &request)?;
1507 let request_route =
1508 client.effective_route_envelope(&inventory.router_model, chrono::Utc::now());
1509 let response = if allow_response_cache {
1510 tokio::time::timeout(
1511 Duration::from_secs(inventory.router_timeout_secs),
1512 client.create_message(request),
1513 )
1514 .await
1515 } else {
1516 tokio::time::timeout(
1517 Duration::from_secs(inventory.router_timeout_secs),
1518 client.create_message_without_response_cache(request),
1519 )
1520 .await
1521 };
1522 let response = match response {
1523 Ok(Ok(response)) => response,
1524 // The request crossed Codewhale's dispatch boundary, but no exact
1525 // provider usage came back. Preserve the fallback while explicitly
1526 // failing cost coverage closed.
1527 Ok(Err(error)) => {
1528 return Ok(auto_route_attempt_with_dropped_response(
1529 request_route,
1530 crate::client::system_one::router_failure_from_error(&error),
1531 ));
1532 }
1533 // The local deadline cancels the future and can fire while the request
1534 // is still waiting on an application/provider permit. With no response
1535 // evidence we must not invent a provider call or a missing-usage
1536 // receipt. Transport errors returned by the client remain the
1537 // conservative explicit-dropped path above.
1538 Err(_) => {
1539 return Ok(InventoryAutoRouteAttempt::failed(
1540 AutoRouterFailure::Timeout,
1541 ));
1542 }
1543 };
1544 Ok(auto_route_attempt_from_provider_response(
1545 request_route,
1546 &response,
1547 inventory,
1548 ))
1549 }
1550
1551 /// The active provider's runnable strong/fast pair, when both tiers can run.
1552 #[derive(Debug, Clone, PartialEq, Eq)]
1553 struct ActiveTierPair {
1554 provider: ProviderIdentity,
1555 strong: String,
1556 fast: String,
1557 }
1558
1559 fn runnable_active_pair(inventory: &ModelInventory) -> Option<ActiveTierPair> {
1560 let active = inventory.active_default()?;
1561 let candidates = provider_router_candidates(active.provider, &active.model);
1562 let runnable = |model: &str| {
1563 inventory
1564 .candidate(active.identity.key.as_str(), model)
1565 .filter(|candidate| candidate.readiness.can_attempt())
1566 .map(|candidate| candidate.model.clone())
1567 };
1568 Some(ActiveTierPair {
1569 provider: active.identity.clone(),
1570 strong: runnable(&candidates.big)?,
1571 fast: runnable(candidates.cheap.as_deref()?)?,
1572 })
1573 }
1574
1575 // The decision model reads its criteria literally, so this wording is a
1576 // product surface: changing it changes routing. Pinned by a snapshot test.
1577 pub(crate) const DECISION_TIER_INSTRUCTIONS: &str =
1578 "Which model tier should handle the latest request in this coding-agent session?";
1579 pub(crate) const DECISION_FAST_WHAT: &str = "A fast, cheaper model. Right for questions, explanations, lookups, small single-file edits, formatting, and routine follow-ups.";
1580 pub(crate) const DECISION_FAST_NOT_FOR: &str = "Multi-step agentic work, debugging across files, architecture or design, security review, release work.";
1581 pub(crate) const DECISION_STRONG_WHAT: &str = "The strongest model. Right for multi-step agentic coding, multi-file changes, debugging, architecture or design, security review, release work, or anything the fast tier would likely get wrong.";
1582 pub(crate) const DECISION_STRONG_NOT_FOR: &str = "Trivial questions or one-line edits.";
1583 pub(crate) const DECISION_THINKING_INSTRUCTIONS: &str =
1584 "How much reasoning should the chosen model spend on the latest request?";
1585 pub(crate) const DECISION_THINKING_OFF: &str =
1586 "A trivial answer with no tools and no reasoning needed.";
1587 pub(crate) const DECISION_THINKING_HIGH: &str =
1588 "Ordinary reasoning: a normal coding or explanation task.";
1589 pub(crate) const DECISION_THINKING_MAX: &str =
1590 "Agentic, multi-file, debugging, architecture, security, release, or uncertain work.";
1591
1592 const DECISION_TIER_OPTIONS: [&str; 2] = ["fast", "strong"];
1593 const DECISION_THINKING_OPTIONS: [&str; 3] = ["off", "high", "max"];
1594 /// Under `[auto] cost_saving`, a `strong` decision needs at least this
1595 /// probability (basis points) or the turn stays on the fast tier.
1596 pub(crate) const COST_SAVING_STRONG_MIN_BP: u16 = 7_500;
1597
1598 /// The System One request: one `tier` choice and one `thinking` choice over
1599 /// the same redacted, bounded state.
1600 fn decision_request_body(
1601 client: &CodewhaleClient,
1602 model: &str,
1603 latest_request: &str,
1604 recent_context: &str,
1605 session_mode: &str,
1606 selected_thinking_mode: &str,
1607 ) -> serde_json::Value {
1608 let recent_context = if recent_context.trim().is_empty() {
1609 "No prior context."
1610 } else {
1611 recent_context
1612 };
1613 serde_json::json!({
1614 "model": model,
1615 "state": {
1616 "session_mode": client.redact_model_bound_text(session_mode),
1617 "selected_thinking_mode": client.redact_model_bound_text(selected_thinking_mode),
1618 "recent_context": client.redact_model_bound_text(recent_context),
1619 "latest_request": client
1620 .redact_model_bound_text(&truncate_for_auto_router(latest_request, 4_000)),
1621 },
1622 "questions": {
1623 "tier": {
1624 "type": "choice",
1625 "instructions": DECISION_TIER_INSTRUCTIONS,
1626 "criteria": {
1627 "fast": { "what": DECISION_FAST_WHAT, "not_for": DECISION_FAST_NOT_FOR },
1628 "strong": { "what": DECISION_STRONG_WHAT, "not_for": DECISION_STRONG_NOT_FOR },
1629 },
1630 },
1631 "thinking": {
1632 "type": "choice",
1633 "instructions": DECISION_THINKING_INSTRUCTIONS,
1634 "criteria": {
1635 "off": DECISION_THINKING_OFF,
1636 "high": DECISION_THINKING_HIGH,
1637 "max": DECISION_THINKING_MAX,
1638 },
1639 },
1640 },
1641 })
1642 }
1643
1644 #[allow(clippy::too_many_arguments)]
1645 async fn auto_route_decision_recommendation(
1646 config: &Config,
1647 inventory: &ModelInventory,
1648 pair: &ActiveTierPair,
1649 latest_request: &str,
1650 recent_context: &str,
1651 session_mode: &str,
1652 selected_thinking_mode: &str,
1653 ) -> Result<InventoryAutoRouteAttempt> {
1654 let route = inventory
1655 .router_decision_route
1656 .ok_or_else(|| anyhow::anyhow!("decision router has no route"))?;
1657 // Resolve environment/keyring credentials off the async worker.
1658 let decision_config = config.clone();
1659 let base_url = inventory.router_base_url.clone();
1660 #[cfg(test)]
1661 let ticket = crate::test_support::env_scope_ticket();
1662 let client = tokio::task::spawn_blocking(move || {
1663 #[cfg(test)]
1664 let _membership = crate::test_support::join_env_scope(ticket);
1665 CodewhaleClient::for_decision_route(&decision_config, route, base_url.as_deref())
1666 })
1667 .await??;
1668 let body = decision_request_body(
1669 &client,
1670 &inventory.router_model,
1671 latest_request,
1672 recent_context,
1673 session_mode,
1674 selected_thinking_mode,
1675 );
1676 // Both transports enter the existing usage ledger. TypeSafe carries its
1677 // own Custom/Unknown identity; no chat price or zero-cost claim is inherited.
1678 let request_route =
1679 Some(client.effective_route_envelope(&inventory.router_model, chrono::Utc::now()));
1680 let started = Instant::now();
1681 let dispatched = std::sync::atomic::AtomicBool::new(false);
1682 let outcome = tokio::time::timeout(
1683 Duration::from_secs(inventory.router_timeout_secs),
1684 client.system_one_decide(&body, &dispatched),
1685 )
1686 .await;
1687 let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
1688 Ok(match outcome {
1689 // A deadline that fired after the request was handed to the transport
1690 // may still be billed: record the coverage gap. One that fired while
1691 // waiting on a permit sent nothing and invents no receipt.
1692 Err(_) => match request_route {
1693 Some(route) if dispatched.load(std::sync::atomic::Ordering::Acquire) => {
1694 auto_route_attempt_with_dropped_response(route, AutoRouterFailure::Timeout)
1695 }
1696 _ => InventoryAutoRouteAttempt::failed(AutoRouterFailure::Timeout),
1697 },
1698 Ok(Err(failure)) => match request_route {
1699 Some(route) if dispatched.load(std::sync::atomic::Ordering::Acquire) => {
1700 auto_route_attempt_with_dropped_response(route, failure)
1701 }
1702 _ => InventoryAutoRouteAttempt::failed(failure),
1703 },
1704 Ok(Ok(response)) => decision_attempt_from_response(
1705 config.auto_cost_saving(),
1706 inventory,
1707 pair,
1708 request_route,
1709 &response,
1710 latency_ms,
1711 ),
1712 })
1713 }
1714
1715 /// A validated `choice` answer.
1716 pub(crate) struct ValidChoice {
1717 pub(crate) choice: String,
1718 pub(crate) probabilities_bp: BTreeMap<String, u16>,
1719 pub(crate) confidence_bp: u16,
1720 }
1721
1722 /// Validate one `choice` answer against the offered options. Any violation
1723 /// rejects the whole answer; nothing is repaired.
1724 pub(crate) fn validated_choice(
1725 answer: Option<&SystemOneAnswer>,
1726 options: &[&str],
1727 ) -> Option<ValidChoice> {
1728 let answer = answer?;
1729 if answer.kind != "choice" {
1730 return None;
1731 }
1732 let choice = answer.choice.as_deref()?;
1733 if !options.contains(&choice) || answer.probabilities.len() != options.len() {
1734 return None;
1735 }
1736 let mut sum = 0.0;
1737 let mut probabilities_bp = BTreeMap::new();
1738 for option in options {
1739 let value = (*answer.probabilities.get(*option)?)?;
1740 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1741 return None;
1742 }
1743 sum += value;
1744 probabilities_bp.insert((*option).to_string(), probability_bp(value));
1745 }
1746 if (sum - 1.0).abs() > 0.02 {
1747 return None;
1748 }
1749 let selected = (*answer.probabilities.get(choice)?)?;
1750 if answer
1751 .probabilities
1752 .values()
1753 .any(|probability| probability.is_some_and(|v| v > selected))
1754 {
1755 return None;
1756 }
1757 let confidence = answer.confidence?;
1758 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
1759 return None;
1760 }
1761 Some(ValidChoice {
1762 choice: choice.to_string(),
1763 probabilities_bp,
1764 confidence_bp: probability_bp(confidence),
1765 })
1766 }
1767
1768 /// One decision response settled through the existing routed-usage ledger.
1769 /// Malformed policy answers still carry provider usage; missing/all-zero usage
1770 /// remains an explicit coverage gap. The source identity is route-bound.
1771 pub(crate) fn decision_usage_batch(
1772 request_route: &EffectiveRouteEnvelope,
1773 response: &SystemOneResponse,
1774 ) -> crate::cost_status::RuntimeUsageBatch {
1775 let usage = codewhale_models::Usage {
1776 input_tokens: response.usage.as_ref().map_or(0, |u| u.input_tokens),
1777 output_tokens: response.usage.as_ref().map_or(0, |u| u.output_tokens),
1778 ..Default::default()
1779 };
1780 let source_id =
1781 auto_route_usage_source_id(request_route, response.id.as_deref().unwrap_or("systemone"));
1782 let route = request_route.sanitized_for_persistence();
1783 if response.usage.as_ref().is_some_and(|u| u.complete)
1784 && auto_route_usage_has_reported_data(&usage)
1785 {
1786 crate::cost_status::RuntimeUsageBatch {
1787 decisions: Vec::new(),
1788 records: vec![RuntimeUsageRecord {
1789 source_id,
1790 usage: EffectiveRouteUsage { route, usage },
1791 }],
1792 ..Default::default()
1793 }
1794 } else {
1795 crate::cost_status::RuntimeUsageBatch {
1796 decisions: Vec::new(),
1797 drop_records: vec![RuntimeUsageDropRecord {
1798 source_id,
1799 route,
1800 reason: crate::cost_status::RuntimeUsageMissingReason::default(),
1801 }],
1802 dropped_records: 1,
1803 ..Default::default()
1804 }
1805 }
1806 }
1807
1808 /// Turn a decoded System One response into a routing attempt. All policy is
1809 /// here, in code: the decision model does no arithmetic.
1810 fn decision_attempt_from_response(
1811 cost_saving: bool,
1812 inventory: &ModelInventory,
1813 pair: &ActiveTierPair,
1814 request_route: Option<EffectiveRouteEnvelope>,
1815 response: &SystemOneResponse,
1816 latency_ms: u64,
1817 ) -> InventoryAutoRouteAttempt {
1818 let mut attempt = InventoryAutoRouteAttempt {
1819 recommendation: None,
1820 routed_usage: Vec::new(),
1821 routed_usage_drop_records: Vec::new(),
1822 routed_usage_dropped_records: 0,
1823 decision: None,
1824 failure: None,
1825 fallback_reason: None,
1826 };
1827 if let Some(request_route) = request_route {
1828 let batch = decision_usage_batch(&request_route, response);
1829 attempt.routed_usage = batch.records;
1830 attempt.routed_usage_dropped_records = batch.dropped_records;
1831 attempt.routed_usage_drop_records = batch.drop_records;
1832 }
1833
1834 // Rejected answers still incurred spend. Keep bounded diagnostic evidence
1835 // before any policy return; valid tier evidence below replaces this shell.
1836 attempt.decision = Some(AutoRouteDecisionEvidence {
1837 choice: "invalid".to_string(),
1838 probabilities_bp: Default::default(),
1839 confidence_bp: 0,
1840 min_confidence_bp: inventory.router_min_confidence_bp,
1841 cost_saving_kept_fast: false,
1842 thinking: None,
1843 provider_reported_cost_usd: response
1844 .usage
1845 .as_ref()
1846 .and_then(|usage| usage.reported_cost()),
1847 latency_ms,
1848 response_model: response
1849 .model
1850 .as_deref()
1851 .map(|model| model.chars().take(128).collect()),
1852 });
1853 if response.answers_validated == Some(false) {
1854 attempt.failure = Some(AutoRouterFailure::InvalidAnswer);
1855 return attempt;
1856 }
1857 let Some(tier) = validated_choice(response.answers.get("tier"), &DECISION_TIER_OPTIONS) else {
1858 attempt.failure = Some(AutoRouterFailure::InvalidAnswer);
1859 return attempt;
1860 };
1861 let min_confidence_bp = inventory.router_min_confidence_bp;
1862 // An invalid or unsure `thinking` answer drops only the effort.
1863 let thinking = validated_choice(response.answers.get("thinking"), &DECISION_THINKING_OPTIONS)
1864 .filter(|thinking| thinking.confidence_bp >= min_confidence_bp)
1865 .map(|thinking| thinking.choice);
1866 let strong_bp = tier.probabilities_bp.get("strong").copied().unwrap_or(0);
1867 let cost_saving_kept_fast =
1868 cost_saving && tier.choice == "strong" && strong_bp < COST_SAVING_STRONG_MIN_BP;
1869 let acted = tier.confidence_bp >= min_confidence_bp;
1870 attempt.decision = Some(AutoRouteDecisionEvidence {
1871 choice: tier.choice.clone(),
1872 probabilities_bp: tier.probabilities_bp,
1873 confidence_bp: tier.confidence_bp,
1874 min_confidence_bp,
1875 cost_saving_kept_fast: acted && cost_saving_kept_fast,
1876 thinking: thinking.clone().filter(|_| acted),
1877 provider_reported_cost_usd: response
1878 .usage
1879 .as_ref()
1880 .and_then(|usage| usage.reported_cost()),
1881 latency_ms,
1882 response_model: response
1883 .model
1884 .as_deref()
1885 .map(|model| model.chars().take(128).collect()),
1886 });
1887 if !acted {
1888 attempt.fallback_reason = Some(AutoRouteHeuristicReason::LowConfidence);
1889 return attempt;
1890 }
1891 let model = if tier.choice == "strong" && !cost_saving_kept_fast {
1892 pair.strong.clone()
1893 } else {
1894 pair.fast.clone()
1895 };
1896 attempt.recommendation = Some(InventoryAutoRouteRecommendation {
1897 provider: pair.provider.clone(),
1898 model,
1899 reasoning_effort: thinking
1900 .as_deref()
1901 .and_then(parse_auto_route_reasoning_effort),
1902 });
1903 attempt
1904 }
1905
1906 fn inventory_auto_router_system_prompt(inventory: &ModelInventory, cost_saving: bool) -> String {
1907 let mut prompt = if inventory.cross_provider_auto {
1908 String::new()
1909 } else {
1910 // The inventory JSON below is already scoped to the active provider
1911 // (#4411); say so, so the classifier does not try to name one it was
1912 // never shown.
1913 format!(
1914 "Auto routing is scoped to the active provider `{}`. Every model in the inventory \
1915 below belongs to it; never select another provider.\n\n",
1916 inventory.active_identity.key.as_str()
1917 )
1918 };
1919 prompt.push_str(&format!(
1920 "You are the codewhale model-routing classifier. Return only compact JSON: \
1921 {{\"provider\":\"<provider>\",\"model\":\"<model>\",\"thinking\":\"off|high|max\"}}.\n\
1922 Choose only provider/model pairs present in the inventory JSON. Use off only for trivial no-tool answers, \
1923 high for ordinary reasoning, and max for agentic, coding, multi-file, release, architecture, debugging, \
1924 security, tool-heavy, or uncertain work.\n\nInventory JSON:\n{}",
1925 inventory.router_context_json()
1926 ));
1927
1928 if cost_saving {
1929 if let Some(ActiveTierPair {
1930 provider,
1931 strong,
1932 fast,
1933 }) = runnable_active_pair(inventory)
1934 {
1935 prompt.push_str(&format!(
1936 "\n\nCost-saving mode is ON. For the active provider `{}`, `{fast}` is the fast tier \
1937 and `{strong}` is the strong tier. Prefer `{fast}` for ambiguous, routine, or single-step work. \
1938 Select `{strong}` only when the request is unmistakably agentic, multi-step, architecture/design, \
1939 security review, debugging, or otherwise clearly beyond the fast tier. Keep the selected model paired \
1940 with provider `{}`.",
1941 provider.key.as_str(),
1942 provider.key.as_str()
1943 ));
1944 } else {
1945 prompt.push_str(
1946 "\n\nCost-saving mode is ON, but the active provider has no known runnable fast sibling. \
1947 Do not invent a model or cross-provider downgrade solely to save cost.",
1948 );
1949 }
1950 }
1951
1952 prompt
1953 }
1954
1955 fn parse_inventory_auto_route_recommendation(
1956 raw: &str,
1957 inventory: &ModelInventory,
1958 ) -> Option<InventoryAutoRouteRecommendation> {
1959 let json = extract_first_json_object(raw)?;
1960 let value: serde_json::Value = serde_json::from_str(json).ok()?;
1961 let name = value.get("provider").and_then(serde_json::Value::as_str)?;
1962 let model = value.get("model").and_then(serde_json::Value::as_str)?;
1963 // Match only identities offered in this captured inventory. A name cannot
1964 // create a route, borrow a brand's authority, or broaden the Auto scope.
1965 let mut matches = inventory.candidates.iter().filter(|candidate| {
1966 let offered = candidate.identity.key.as_str() == name
1967 || candidate.identity.compatibility().is_some_and(|row| {
1968 row.tui_wire_tag == name
1969 || row
1970 .selector_aliases
1971 .iter()
1972 .any(|alias| alias.eq_ignore_ascii_case(name))
1973 });
1974 offered
1975 && inventory.auto_scope_allows(candidate.identity.key.as_str())
1976 && candidate.readiness.can_attempt()
1977 && (candidate.model == model
1978 || (!candidate.user_declared && candidate.model.eq_ignore_ascii_case(model)))
1979 });
1980 let candidate = matches.next()?;
1981 if matches.next().is_some() {
1982 return None;
1983 }
1984 let reasoning_effort = value
1985 .get("thinking")
1986 .or_else(|| value.get("reasoning_effort"))
1987 .or_else(|| value.get("effort"))
1988 .and_then(serde_json::Value::as_str)
1989 .and_then(parse_auto_route_reasoning_effort);
1990 Some(InventoryAutoRouteRecommendation {
1991 provider: candidate.identity.clone(),
1992 model: candidate.model.clone(),
1993 reasoning_effort,
1994 })
1995 }
1996
1997 fn auto_route_prompt(
1998 latest_request: &str,
1999 recent_context: &str,
2000 session_mode: &str,
2001 selected_model_mode: &str,
2002 selected_thinking_mode: &str,
2003 ) -> String {
2004 format!(
2005 "Session mode: {}\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.",
2006 session_mode,
2007 selected_model_mode,
2008 selected_thinking_mode,
2009 if recent_context.trim().is_empty() {
2010 "No prior context."
2011 } else {
2012 recent_context
2013 },
2014 truncate_for_auto_router(latest_request, 4_000)
2015 )
2016 }
2017
2018 fn classifier_prompt(
2019 client: &CodewhaleClient,
2020 latest_request: &str,
2021 recent_context: &str,
2022 session_mode: &str,
2023 selected_model_mode: &str,
2024 selected_thinking_mode: &str,
2025 ) -> String {
2026 client.redact_model_bound_text(&auto_route_prompt(
2027 latest_request,
2028 recent_context,
2029 session_mode,
2030 selected_model_mode,
2031 selected_thinking_mode,
2032 ))
2033 }
2034
2035 fn message_response_text(response: &MessageResponse) -> String {
2036 let mut out = String::new();
2037 for block in &response.content {
2038 match block {
2039 ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => {
2040 append_router_text(&mut out, text);
2041 }
2042 ContentBlock::Thinking { thinking, .. } => {
2043 append_router_text(&mut out, thinking);
2044 }
2045 ContentBlock::ToolUse { name, .. } => {
2046 append_router_text(&mut out, &format!("[tool call: {name}]"));
2047 }
2048 _ => {}
2049 }
2050 }
2051 out
2052 }
2053
2054 fn append_router_text(out: &mut String, text: &str) {
2055 if !out.is_empty() {
2056 out.push('\n');
2057 }
2058 out.push_str(text);
2059 }
2060
2061 pub(crate) fn truncate_for_auto_router(text: &str, max_chars: usize) -> String {
2062 let mut chars = text.chars();
2063 let truncated: String = chars.by_ref().take(max_chars).collect();
2064 if chars.next().is_some() {
2065 format!("{truncated}...")
2066 } else {
2067 truncated
2068 }
2069 }
2070
2071 #[cfg(test)]
2072 mod tests {
2073 use super::*;
2074
2075 struct ProviderCatalogReset;
2076
2077 impl Drop for ProviderCatalogReset {
2078 fn drop(&mut self) {
2079 crate::provider_catalog_live::reset_cache_for_test();
2080 crate::provider_lake::clear_live_snapshot();
2081 }
2082 }
2083
2084 fn priced_openrouter_delta(
2085 model: &str,
2086 fingerprint: &str,
2087 fetched_at: u64,
2088 input: f64,
2089 output: f64,
2090 ) -> codewhale_config::catalog::ProviderCatalogDelta {
2091 use codewhale_config::catalog::{CatalogOffering, CatalogSource, ProviderCatalogDelta};
2092
2093 ProviderCatalogDelta {
2094 provider: ProviderKind::Openrouter.as_str().to_string(),
2095 base_url_fingerprint: fingerprint.to_string(),
2096 fetched_at,
2097 offerings: vec![CatalogOffering {
2098 provider: ProviderKind::Openrouter.as_str().to_string(),
2099 wire_model_id: model.to_string(),
2100 endpoint_key: "chat".to_string(),
2101 source: CatalogSource::Live {
2102 base_url_fingerprint: fingerprint.to_string(),
2103 fetched_at,
2104 },
2105 cost: Some(codewhale_config::models_dev::ModelsDevCost {
2106 input: Some(input),
2107 output: Some(output),
2108 cache_read: Some(input / 2.0),
2109 cache_write: None,
2110 }),
2111 ..CatalogOffering::default()
2112 }],
2113 }
2114 }
2115
2116 fn classifier_response(
2117 id: &str,
2118 text: &str,
2119 stop_reason: &str,
2120 usage: codewhale_models::Usage,
2121 ) -> MessageResponse {
2122 MessageResponse {
2123 id: id.to_string(),
2124 r#type: "message".to_string(),
2125 role: "assistant".to_string(),
2126 content: vec![ContentBlock::Text {
2127 text: text.to_string(),
2128 cache_control: None,
2129 }],
2130 model: "router-response-alias-must-not-price".to_string(),
2131 stop_reason: Some(stop_reason.to_string()),
2132 stop_sequence: None,
2133 container: None,
2134 usage,
2135 }
2136 }
2137
2138 #[test]
2139 fn classifier_semantic_fallbacks_keep_exact_quotes_and_replay_once() {
2140 let _env_lock = crate::test_support::lock_test_env();
2141 let _live = crate::provider_lake::lock_live_snapshot();
2142 let home = tempfile::tempdir().expect("test home");
2143 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2144 let _reset = ProviderCatalogReset;
2145 crate::provider_catalog_live::reset_cache_for_test();
2146 crate::provider_lake::clear_live_snapshot();
2147
2148 let model = "synthetic/openrouter-auto-classifier";
2149 let config = Config {
2150 provider: Some("openrouter".to_string()),
2151 providers: Some(crate::config::ProvidersConfig {
2152 openrouter: crate::config::ProviderConfig {
2153 api_key: Some("test-openrouter-key".to_string()),
2154 base_url: Some(crate::config::DEFAULT_OPENROUTER_BASE_URL.to_string()),
2155 model: Some(model.to_string()),
2156 ..Default::default()
2157 },
2158 ..Default::default()
2159 }),
2160 auto: Some(crate::config::AutoConfig {
2161 cost_saving: None,
2162 cross_provider: None,
2163 router: Some(crate::config::AutoRouterConfig {
2164 provider: Some("openrouter".to_string()),
2165 model: Some(model.to_string()),
2166 thinking: Some("off".to_string()),
2167 timeout_secs: None,
2168 ..Default::default()
2169 }),
2170 }),
2171 ..Default::default()
2172 };
2173 let inventory = ModelInventory::from_config(&config).unwrap();
2174 assert!(
2175 inventory
2176 .candidate(ProviderKind::Openrouter.as_str(), model)
2177 .is_some()
2178 );
2179 let client = CodewhaleClient::new(&config).expect("OpenRouter classifier client");
2180 let fingerprint = codewhale_config::catalog::base_url_fingerprint(
2181 crate::config::DEFAULT_OPENROUTER_BASE_URL,
2182 );
2183 let first_at = chrono::Utc::now();
2184 let fetched_at = u64::try_from(first_at.timestamp()).expect("nonnegative timestamp");
2185
2186 crate::provider_catalog_live::record_success(priced_openrouter_delta(
2187 model,
2188 &fingerprint,
2189 fetched_at,
2190 1.0,
2191 4.0,
2192 ));
2193 let first_route = client.effective_route_envelope(model, first_at);
2194 let valid = auto_route_attempt_from_response(
2195 first_route,
2196 &classifier_response(
2197 "same-provider-response-id",
2198 &format!(r#"{{"provider":"openrouter","model":"{model}","thinking":"off"}}"#),
2199 "stop",
2200 codewhale_models::Usage {
2201 input_tokens: 10,
2202 output_tokens: 2,
2203 prompt_cache_hit_tokens: Some(3),
2204 ..Default::default()
2205 },
2206 ),
2207 &inventory,
2208 );
2209
2210 // Replace the live row in the same Unix second. The first routed
2211 // record must keep its old immutable revision and the new attempt must
2212 // freeze a distinct one at its own dispatch boundary.
2213 crate::provider_catalog_live::record_success(priced_openrouter_delta(
2214 model,
2215 &fingerprint,
2216 fetched_at,
2217 9.0,
2218 19.0,
2219 ));
2220 let second_at = first_at + chrono::Duration::nanoseconds(1);
2221 let invalid = auto_route_attempt_from_response(
2222 client.effective_route_envelope(model, second_at),
2223 &classifier_response(
2224 "same-provider-response-id",
2225 "not valid route json",
2226 "stop",
2227 codewhale_models::Usage {
2228 input_tokens: 11,
2229 output_tokens: 3,
2230 ..Default::default()
2231 },
2232 ),
2233 &inventory,
2234 );
2235 let incomplete = auto_route_attempt_from_response(
2236 client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(1)),
2237 &classifier_response(
2238 "same-provider-response-id",
2239 &format!(r#"{{"provider":"openrouter","model":"{model}"}}"#),
2240 "length",
2241 codewhale_models::Usage {
2242 input_tokens: 12,
2243 output_tokens: 4,
2244 ..Default::default()
2245 },
2246 ),
2247 &inventory,
2248 );
2249 let missing_usage_route =
2250 client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(2));
2251 let missing_usage = auto_route_attempt_from_provider_response(
2252 missing_usage_route.clone(),
2253 &classifier_response(
2254 "missing-usage-response-id",
2255 "not valid route json",
2256 "stop",
2257 codewhale_models::Usage::default(),
2258 ),
2259 &inventory,
2260 );
2261 assert!(missing_usage.routed_usage.is_empty());
2262 assert_eq!(missing_usage.routed_usage_dropped_records, 1);
2263 assert_eq!(missing_usage.routed_usage_drop_records.len(), 1);
2264 assert_eq!(
2265 missing_usage.routed_usage_drop_records[0].route,
2266 missing_usage_route.sanitized_for_persistence()
2267 );
2268 assert!(
2269 missing_usage.routed_usage_drop_records[0]
2270 .source_id
2271 .starts_with("auto-router:")
2272 );
2273 assert!(
2274 !missing_usage.routed_usage_drop_records[0]
2275 .source_id
2276 .contains("missing-usage-response-id")
2277 );
2278
2279 let transport = auto_route_attempt_with_dropped_response(
2280 client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(3)),
2281 AutoRouterFailure::Transport,
2282 );
2283 assert_eq!(transport.routed_usage_dropped_records, 1);
2284 assert_eq!(transport.routed_usage_drop_records.len(), 1);
2285 assert!(transport.routed_usage.is_empty());
2286
2287 let fallback = auto_route_declared_fallback(&config, &inventory);
2288 let valid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, valid);
2289 let invalid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, invalid);
2290 let incomplete = auto_route_from_classifier_attempt(fallback, &inventory, incomplete);
2291 assert_eq!(valid.source, AutoRouteSource::FlashRouter);
2292 for fallback in [&invalid, &incomplete] {
2293 assert_eq!(fallback.source, AutoRouteSource::Heuristic);
2294 assert!(matches!(
2295 fallback.receipt.as_ref().map(|receipt| receipt.reason),
2296 Some(AutoRouteReason::ClassifierFallback(_))
2297 ));
2298 assert_eq!(fallback.routed_usage.len(), 1);
2299 assert_eq!(fallback.routed_usage_dropped_records, 0);
2300 }
2301 assert_eq!(valid.routed_usage.len(), 1);
2302 assert_eq!(valid.routed_usage[0].usage.usage.input_tokens, 10);
2303 assert_eq!(
2304 valid.routed_usage[0].usage.usage.prompt_cache_hit_tokens,
2305 Some(3)
2306 );
2307
2308 let first_quote = valid.routed_usage[0]
2309 .usage
2310 .route
2311 .provider_live_pricing
2312 .as_ref()
2313 .expect("first exact quote");
2314 let second_quote = invalid.routed_usage[0]
2315 .usage
2316 .route
2317 .provider_live_pricing
2318 .as_ref()
2319 .expect("replacement exact quote");
2320 assert_ne!(first_quote.catalog_revision, second_quote.catalog_revision);
2321 assert_eq!(first_quote.input_per_million.as_deref(), Some("1"));
2322 assert_eq!(second_quote.input_per_million.as_deref(), Some("9"));
2323
2324 let records = valid
2325 .routed_usage
2326 .iter()
2327 .chain(&invalid.routed_usage)
2328 .chain(&incomplete.routed_usage)
2329 .cloned()
2330 .collect::<Vec<_>>();
2331 assert_eq!(records.len(), 3);
2332 assert!(records.iter().all(|record| {
2333 record.source_id.starts_with("auto-router:")
2334 && record.source_id.len() == "auto-router:".len() + 64
2335 && !record.source_id.contains("same-provider-response-id")
2336 }));
2337
2338 // Exercise the canonical sink exactly as selection consumers do:
2339 // replaying any record cannot add parent-route spend or a second
2340 // routed segment, while distinct dispatches remain distinct.
2341 let _cost_scope = crate::cost_status::test_scope();
2342 let owner = "auto-router-selection-test-owner";
2343 crate::cost_status::register_interactive_runtime_usage_sink(
2344 owner,
2345 crate::cost_status::scope_token(),
2346 );
2347 let lease = crate::cost_status::acquire_runtime_usage_lease(owner)
2348 .expect("runtime usage owner lease");
2349 for record in &records {
2350 for _ in 0..2 {
2351 crate::cost_status::report_effective_route_for_runtime(
2352 crate::cost_status::scope_token(),
2353 Some(lease.owner()),
2354 &record.source_id,
2355 &record.usage.route,
2356 &record.usage.usage,
2357 );
2358 }
2359 }
2360 crate::cost_status::finish_runtime_usage_owner(owner);
2361 drop(lease);
2362 let pending = crate::cost_status::drain();
2363 assert_eq!(pending.usage_source_fingerprints.len(), records.len());
2364 assert_eq!(pending.priced_turns, records.len() as u32);
2365 assert_eq!(pending.unpriced_turns, 0);
2366 }
2367
2368 #[test]
2369 fn auto_model_reasoning_keeps_model_and_thinking_choices_independent() {
2370 assert_eq!(
2371 resolve_auto_model_reasoning(Some(ReasoningEffort::Low), Some(ReasoningEffort::Max)),
2372 (Some(ReasoningEffort::Low), false)
2373 );
2374 assert_eq!(
2375 resolve_auto_model_reasoning(Some(ReasoningEffort::Auto), Some(ReasoningEffort::Max)),
2376 (Some(ReasoningEffort::Max), true)
2377 );
2378 assert_eq!(
2379 resolve_auto_model_reasoning(None, Some(ReasoningEffort::High)),
2380 (Some(ReasoningEffort::High), true)
2381 );
2382 }
2383
2384 #[test]
2385 fn auto_route_prompt_uses_current_session_mode() {
2386 let prompt = auto_route_prompt(
2387 "Please explain the change before editing files.",
2388 "No prior context.",
2389 "plan",
2390 "auto",
2391 "auto",
2392 );
2393
2394 assert!(
2395 prompt.starts_with("Session mode: plan\n"),
2396 "auto-route prompt should reflect the active session mode, got: {prompt}"
2397 );
2398 }
2399
2400 #[test]
2401 fn classifier_prompt_redacts_secret_after_tool_result_flattening() {
2402 let secret = "cw-router-secret-should-never-leave-process";
2403 let config = Config {
2404 ..Default::default()
2405 }
2406 .with_legacy_root(Some(secret.to_string()), None);
2407 let client = CodewhaleClient::new(&config).expect("classifier client");
2408 // `recent_auto_router_context` converts ToolResult blocks into ordinary
2409 // text before this boundary. Exercise that exact flattened shape.
2410 let recent_context = format!("assistant: [tool result] token={secret}");
2411
2412 let prompt = classifier_prompt(
2413 &client,
2414 "continue the investigation",
2415 &recent_context,
2416 "agent",
2417 "auto",
2418 "auto",
2419 );
2420
2421 assert!(
2422 !prompt.contains(secret),
2423 "flattened tool-result secret leaked"
2424 );
2425 assert!(
2426 prompt.contains(codewhale_config::persistence::REDACTED),
2427 "secret should be visibly redacted"
2428 );
2429 assert!(prompt.contains("continue the investigation"));
2430 }
2431
2432 #[test]
2433 fn inventory_auto_router_prompt_names_cost_saving_zai_pair() {
2434 let _env_lock = crate::test_support::lock_test_env();
2435 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
2436 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2437 let config = Config {
2438 provider: Some("zai".to_string()),
2439 ..Default::default()
2440 };
2441 let inventory = ModelInventory::from_config(&config).unwrap();
2442
2443 let balanced = inventory_auto_router_system_prompt(&inventory, false);
2444 let cost_saving = inventory_auto_router_system_prompt(&inventory, true);
2445
2446 assert!(!balanced.contains("Cost-saving mode is ON"));
2447 assert!(
2448 cost_saving.contains(
2449 "For the active provider `zai`, `GLM-5.3-Flash` is the fast tier and `GLM-5.3` is the strong tier"
2450 ),
2451 "cost-saving classifier policy must name the provider-safe pair: {cost_saving}"
2452 );
2453 assert!(
2454 cost_saving.contains("Keep the selected model paired with provider `zai`"),
2455 "cost-saving policy must preserve provider/model validation: {cost_saving}"
2456 );
2457 }
2458
2459 #[test]
2460 fn auto_route_effort_normalization_is_provider_aware() {
2461 // Slice 4, D2: the Auto path delegates to the canonical route
2462 // normalizer with an unresolved route. Two providers where the deleted
2463 // local copy disagreed with the authority are pinned explicitly.
2464 assert_eq!(
2465 normalize_auto_route_effort_for_provider(ProviderKind::Deepseek, ReasoningEffort::Low),
2466 ReasoningEffort::Low,
2467 "first-party DeepSeek documents low|high|max, so the canonical normalizer keeps low"
2468 );
2469 assert_eq!(
2470 normalize_auto_route_effort_for_provider(
2471 ProviderKind::Deepseek,
2472 ReasoningEffort::Medium
2473 ),
2474 ReasoningEffort::High
2475 );
2476 assert_eq!(
2477 normalize_auto_route_effort_for_provider(
2478 ProviderKind::OllamaCloud,
2479 ReasoningEffort::Minimal
2480 ),
2481 ReasoningEffort::Low,
2482 "OllamaCloud folds the Codewhale-only `minimal` spelling onto low"
2483 );
2484 assert_eq!(
2485 normalize_auto_route_effort_for_provider(
2486 ProviderKind::OllamaCloud,
2487 ReasoningEffort::Ultra
2488 ),
2489 ReasoningEffort::Max
2490 );
2491 // A provider with no exact-route rule keeps the historic collapse.
2492 assert_eq!(
2493 normalize_auto_route_effort_for_provider(ProviderKind::Moonshot, ReasoningEffort::Low),
2494 ReasoningEffort::High
2495 );
2496 assert_eq!(
2497 normalize_auto_route_effort_for_provider(ProviderKind::Moonshot, ReasoningEffort::Auto),
2498 ReasoningEffort::Auto
2499 );
2500 assert_eq!(
2501 normalize_auto_route_effort_for_provider(ProviderKind::Moonshot, ReasoningEffort::Max),
2502 ReasoningEffort::Max
2503 );
2504 // Codex keeps its provider-level mapping (off -> low, auto -> medium).
2505 assert_eq!(
2506 normalize_auto_route_effort_for_provider(
2507 ProviderKind::OpenaiCodex,
2508 ReasoningEffort::Low
2509 ),
2510 ReasoningEffort::Low
2511 );
2512 assert_eq!(
2513 normalize_auto_route_effort_for_provider(
2514 ProviderKind::OpenaiCodex,
2515 ReasoningEffort::Medium
2516 ),
2517 ReasoningEffort::Medium
2518 );
2519 assert_eq!(
2520 normalize_auto_route_effort_for_provider(
2521 ProviderKind::OpenaiCodex,
2522 ReasoningEffort::Off
2523 ),
2524 ReasoningEffort::Low
2525 );
2526 }
2527
2528 #[test]
2529 fn configured_route_effort_normalizer_keeps_kimi_code_low_medium_local() {
2530 let mut config = Config {
2531 provider: Some("moonshot".to_string()),
2532 providers: Some(crate::config::ProvidersConfig {
2533 moonshot: crate::config::ProviderConfig {
2534 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
2535 model: Some("k3".to_string()),
2536 ..Default::default()
2537 },
2538 ..Default::default()
2539 }),
2540 ..Default::default()
2541 };
2542 assert_eq!(
2543 normalize_auto_route_effort_for_configured_route(
2544 &config,
2545 &(config).test_identity_for_kind(ProviderKind::Moonshot),
2546 "k3",
2547 ReasoningEffort::Low,
2548 ),
2549 ReasoningEffort::Low
2550 );
2551 assert_eq!(
2552 normalize_auto_route_effort_for_configured_route(
2553 &config,
2554 &(config).test_identity_for_kind(ProviderKind::Moonshot),
2555 "k3",
2556 ReasoningEffort::Medium,
2557 ),
2558 ReasoningEffort::Medium
2559 );
2560
2561 config
2562 .providers
2563 .as_mut()
2564 .expect("providers")
2565 .moonshot
2566 .base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
2567 assert_eq!(
2568 normalize_auto_route_effort_for_configured_route(
2569 &config,
2570 &(config).test_identity_for_kind(ProviderKind::Moonshot),
2571 "k3",
2572 ReasoningEffort::Low,
2573 ),
2574 ReasoningEffort::High
2575 );
2576 }
2577
2578 #[test]
2579 fn inventory_auto_route_recommendation_requires_runnable_pair() {
2580 let _env_lock = crate::test_support::lock_test_env();
2581 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2582 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2583 let config = Config {
2584 provider: Some("zai".to_string()),
2585 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
2586 ..Default::default()
2587 };
2588 let inventory = ModelInventory::from_config(&config).unwrap();
2589
2590 let route = parse_inventory_auto_route_recommendation(
2591 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
2592 &inventory,
2593 )
2594 .expect("valid inventory route should parse");
2595 assert_eq!(route.provider.provider, ProviderKind::Zai);
2596 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
2597 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Max));
2598
2599 assert!(
2600 parse_inventory_auto_route_recommendation(
2601 r#"{"provider":"zai","model":"deepseek-v4-pro","thinking":"max"}"#,
2602 &inventory,
2603 )
2604 .is_none(),
2605 "router must not pair a DeepSeek model with the Z.ai provider"
2606 );
2607
2608 let wrapped = parse_inventory_auto_route_recommendation(
2609 r#"route: {"provider":"zai","model":"GLM-5-Turbo","reasoning_effort":"medium"}"#,
2610 &inventory,
2611 )
2612 .expect("wrapped inventory route should parse");
2613 assert_eq!(wrapped.provider.provider, ProviderKind::Zai);
2614 assert_eq!(wrapped.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
2615 // Parsing is strict and literal; the historic Medium->High coercion
2616 // is applied downstream by normalize_auto_route_selection_for_config
2617 // so route-specific contracts (Kimi Code K3) can keep Medium.
2618 assert_eq!(wrapped.reasoning_effort, Some(ReasoningEffort::Medium));
2619 }
2620
2621 #[test]
2622 fn inventory_auto_route_recommendation_rejects_unready_candidate() {
2623 let _env_lock = crate::test_support::lock_test_env();
2624 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2625 let config = Config {
2626 provider: Some("zai".to_string()),
2627 ..Default::default()
2628 };
2629 let mut inventory = ModelInventory::from_config(&config).unwrap();
2630 let candidate = inventory
2631 .candidates
2632 .iter_mut()
2633 .find(|candidate| {
2634 candidate.provider == ProviderKind::Zai
2635 && candidate.model == crate::config::ZAI_GLM_5_2_MODEL
2636 })
2637 .expect("Z.ai strong candidate");
2638 candidate.readiness = crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute;
2639
2640 assert!(
2641 parse_inventory_auto_route_recommendation(
2642 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
2643 &inventory,
2644 )
2645 .is_none(),
2646 "classifier output must not revive an unsupported route"
2647 );
2648 }
2649
2650 #[test]
2651 fn inventory_auto_route_recommendation_accepts_wanjie_v4_ids() {
2652 let _env_lock = crate::test_support::lock_test_env();
2653 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2654 let _wanjie = crate::test_support::EnvVarGuard::set("WANJIE_ARK_API_KEY", "wanjie-key");
2655 let config = Config {
2656 provider: Some("wanjie-ark".to_string()),
2657 ..Default::default()
2658 };
2659 let inventory = ModelInventory::from_config(&config).unwrap();
2660
2661 let route = parse_inventory_auto_route_recommendation(
2662 r#"{"provider":"wanjie-ark","model":"deepseek-v4-pro","thinking":"max"}"#,
2663 &inventory,
2664 )
2665 .expect("Wanjie V4 Pro inventory route should parse");
2666 assert_eq!(route.provider.provider, ProviderKind::WanjieArk);
2667 assert_eq!(route.model, "deepseek-v4-pro");
2668 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Max));
2669
2670 let route = parse_inventory_auto_route_recommendation(
2671 r#"{"provider":"wanjie-ark","model":"deepseek-v4-flash","thinking":"off"}"#,
2672 &inventory,
2673 )
2674 .expect("Wanjie V4 Flash inventory route should parse");
2675 assert_eq!(route.provider.provider, ProviderKind::WanjieArk);
2676 assert_eq!(route.model, "deepseek-v4-flash");
2677 assert_eq!(route.reasoning_effort, Some(ReasoningEffort::Off));
2678 }
2679
2680 #[test]
2681 fn explicit_route_to_nonactive_provider_uses_that_providers_effort() {
2682 // Active provider is DeepSeek (whose effort floor is low/medium), but the
2683 // explicit model `GLM-5.2` only routes to Z.ai. The resolved effort must
2684 // be normalized for Z.ai — not left at DeepSeek's raw `low` setting.
2685 let _env_lock = crate::test_support::lock_test_env();
2686 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2687 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2688 let config = Config {
2689 provider: Some("deepseek".to_string()),
2690 reasoning_effort: Some("low".to_string()),
2691 ..Default::default()
2692 };
2693
2694 let route = resolve_explicit_route_with_inventory(&config, "GLM-5.2")
2695 .expect("explicit GLM route should resolve to its provider");
2696
2697 assert_eq!(
2698 route.provider.provider,
2699 ProviderKind::Zai,
2700 "GLM-5.2 must route to Z.ai, not the active DeepSeek provider"
2701 );
2702 assert_eq!(
2703 route.reasoning_effort,
2704 Some(ReasoningEffort::High),
2705 "low must be normalized up to high for the Z.ai route, not passed through"
2706 );
2707
2708 // GLM-5.3 is the default and a first-class route: same provider
2709 // ownership, same effort normalization, and it resolves to its own id.
2710 let route_53 = resolve_explicit_route_with_inventory(&config, "GLM-5.3")
2711 .expect("explicit GLM-5.3 route should resolve to its provider");
2712 assert_eq!(
2713 route_53.provider.provider,
2714 ProviderKind::Zai,
2715 "GLM-5.3 must route to Z.ai, not the active DeepSeek provider"
2716 );
2717 assert_eq!(
2718 route_53.model,
2719 crate::config::ZAI_GLM_5_3_MODEL,
2720 "GLM-5.3 must resolve to its own id"
2721 );
2722 // GLM-5.3 publishes its own ladder (low/high/max) instead of 5.2's, so
2723 // low reaches the wire (#6396).
2724 assert_eq!(route_53.reasoning_effort, Some(ReasoningEffort::Low));
2725 }
2726
2727 #[tokio::test]
2728 #[allow(clippy::await_holding_lock)]
2729 async fn inventory_auto_route_resolves_active_authenticated_provider() {
2730 let _env_lock = crate::test_support::lock_test_env();
2731 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2732 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2733 let config = Config {
2734 provider: Some("zai".to_string()),
2735 ..Default::default()
2736 };
2737
2738 // #6290 rework: without the flash classifier there is no
2739 // per-request signal, so every wording resolves the same declared
2740 // default — the short chat and the complex ask below must agree.
2741 for prompt in [
2742 "quick status check",
2743 "please refactor this architecture and audit its security boundaries",
2744 ] {
2745 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
2746 .await
2747 .expect("inventory route should resolve with authenticated active provider");
2748
2749 assert_eq!(route.provider.provider, ProviderKind::Zai);
2750 assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL);
2751 assert_eq!(route.source, AutoRouteSource::Heuristic);
2752 let receipt = route.receipt.expect("Auto route receipt");
2753 assert_eq!(receipt.tier, AutoRouteTier::Strong);
2754 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
2755 assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic);
2756 assert_eq!(
2757 receipt.reason,
2758 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
2759 "prompt {prompt:?} must take the declared default, not a content judgment"
2760 );
2761 assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL);
2762 assert_eq!(
2763 receipt.pair.fast.as_deref(),
2764 Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL)
2765 );
2766 }
2767 }
2768
2769 #[test]
2770 fn classifier_receipt_discloses_active_provider_scope_and_data_path() {
2771 let _env_lock = crate::test_support::lock_test_env();
2772 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2773 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2774 let config = Config {
2775 provider: Some("zai".to_string()),
2776 ..Default::default()
2777 };
2778 let inventory = ModelInventory::from_config(&config).unwrap();
2779 let recommendation = parse_inventory_auto_route_recommendation(
2780 r#"{"provider":"zai","model":"GLM-5-Turbo","thinking":"off"}"#,
2781 &inventory,
2782 )
2783 .expect("runnable classifier recommendation");
2784
2785 let route = auto_route_from_classifier(&inventory, recommendation);
2786
2787 assert_eq!(route.provider.provider, ProviderKind::Zai);
2788 assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
2789 assert_eq!(route.source, AutoRouteSource::FlashRouter);
2790 let receipt = route.receipt.expect("classifier receipt");
2791 assert_eq!(receipt.tier, AutoRouteTier::Fast);
2792 // #4411: the classifier only saw Z.ai routes, so the receipt says so
2793 // instead of claiming the wider runnable-providers scope.
2794 assert_eq!(receipt.scope, AutoRouteScope::ActiveProvider);
2795 assert_eq!(
2796 receipt.data_path,
2797 AutoRouteDataPath::Classifier {
2798 provider: "deepseek".into(),
2799 provider_kind: ProviderKind::Deepseek,
2800 model: "deepseek-v4-flash".to_string(),
2801 }
2802 );
2803 assert_eq!(receipt.reason, AutoRouteReason::ClassifierRecommendation);
2804 }
2805
2806 #[test]
2807 fn classifier_recommendation_for_another_provider_is_refused_by_default() {
2808 // #4411: the payload never named DeepSeek, but a classifier can still
2809 // emit one. The recommendation must not become a route unless the
2810 // persisted cross-provider opt-in is set.
2811 let _env_lock = crate::test_support::lock_test_env();
2812 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2813 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2814 let scoped = Config {
2815 provider: Some("zai".to_string()),
2816 ..Default::default()
2817 };
2818 let scoped_inventory = ModelInventory::from_config(&scoped).unwrap();
2819 let raw = r#"{"provider":"deepseek","model":"deepseek-v4-flash","thinking":"off"}"#;
2820
2821 assert!(
2822 parse_inventory_auto_route_recommendation(raw, &scoped_inventory).is_none(),
2823 "cross-provider classifier output must be refused by default"
2824 );
2825 // The same inventory still accepts an in-scope active-provider route.
2826 assert!(
2827 parse_inventory_auto_route_recommendation(
2828 r#"{"provider":"zai","model":"GLM-5.2","thinking":"max"}"#,
2829 &scoped_inventory,
2830 )
2831 .is_some()
2832 );
2833
2834 let opted_in = Config {
2835 auto: Some(crate::config::AutoConfig {
2836 cost_saving: None,
2837 cross_provider: Some(true),
2838 router: None,
2839 }),
2840 ..scoped.clone()
2841 };
2842 let opted_in_route = parse_inventory_auto_route_recommendation(
2843 raw,
2844 &ModelInventory::from_config(&opted_in).unwrap(),
2845 )
2846 .expect("opt-in admits the cross-provider recommendation");
2847 assert_eq!(opted_in_route.provider.provider, ProviderKind::Deepseek);
2848 }
2849
2850 #[test]
2851 fn classifier_prompt_declares_active_provider_scope_by_default() {
2852 let _env_lock = crate::test_support::lock_test_env();
2853 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2854 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2855 let config = Config {
2856 provider: Some("zai".to_string()),
2857 ..Default::default()
2858 };
2859
2860 let prompt = inventory_auto_router_system_prompt(
2861 &ModelInventory::from_config(&config).unwrap(),
2862 false,
2863 );
2864
2865 assert!(
2866 prompt.contains("Auto routing is scoped to the active provider `zai`"),
2867 "{prompt}"
2868 );
2869 assert!(!prompt.contains("deepseek"), "{prompt}");
2870 }
2871
2872 #[tokio::test]
2873 #[allow(clippy::await_holding_lock)]
2874 async fn active_provider_declared_default_survives_scoping() {
2875 // #4411: scoping keeps Auto on the active provider. #6290 rework:
2876 // without the flash classifier there is no per-request tier signal,
2877 // so both wordings resolve the same declared default on Zai.
2878 let _env_lock = crate::test_support::lock_test_env();
2879 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
2880 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2881 let config = Config {
2882 provider: Some("zai".to_string()),
2883 ..Default::default()
2884 };
2885
2886 for prompt in [
2887 "refactor the routing module and audit its security boundaries",
2888 "hi",
2889 ] {
2890 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
2891 .await
2892 .expect("scoped Auto route");
2893 assert_eq!(route.provider.provider, ProviderKind::Zai);
2894 assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL);
2895 let receipt = route.receipt.expect("scoped receipt");
2896 assert_eq!(receipt.tier, AutoRouteTier::Strong);
2897 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
2898 assert_eq!(
2899 receipt.reason,
2900 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
2901 "prompt {prompt:?}"
2902 );
2903 }
2904 }
2905
2906 #[test]
2907 fn config_auto_cross_provider_defaults_to_false() {
2908 assert!(!Config::default().auto_cross_provider());
2909 let opted_in = Config {
2910 auto: Some(crate::config::AutoConfig {
2911 cost_saving: None,
2912 cross_provider: Some(true),
2913 router: None,
2914 }),
2915 ..Default::default()
2916 };
2917 assert!(opted_in.auto_cross_provider());
2918 }
2919
2920 #[test]
2921 fn classifier_receipt_never_reports_openrouter_default_for_another_family() {
2922 let _env_lock = crate::test_support::lock_test_env();
2923 let _openrouter =
2924 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "openrouter-key");
2925 let config = Config {
2926 provider: Some("openrouter".to_string()),
2927 ..Default::default()
2928 };
2929 let inventory = ModelInventory::from_config(&config).unwrap();
2930 let recommendation = parse_inventory_auto_route_recommendation(
2931 r#"{"provider":"openrouter","model":"z-ai/glm-5.2","thinking":"max"}"#,
2932 &inventory,
2933 )
2934 .expect("runnable non-default OpenRouter family");
2935
2936 let route = auto_route_from_classifier(&inventory, recommendation);
2937 let receipt = route.receipt.expect("classifier receipt");
2938
2939 assert_eq!(route.model, crate::config::OPENROUTER_GLM_5_2_MODEL);
2940 assert_eq!(receipt.pair.strong, crate::config::OPENROUTER_GLM_5_2_MODEL);
2941 assert_ne!(
2942 receipt.pair.fast.as_deref(),
2943 Some(crate::config::DEFAULT_OPENROUTER_FLASH_MODEL),
2944 "a GLM selection must not be described as the DeepSeek default pair"
2945 );
2946 assert!(matches!(
2947 receipt.tier,
2948 AutoRouteTier::Strong | AutoRouteTier::Only
2949 ));
2950 }
2951
2952 #[test]
2953 fn classifier_fallback_preserves_attempted_data_path_without_error_text() {
2954 let _env_lock = crate::test_support::lock_test_env();
2955 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
2956 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2957 let config = Config {
2958 provider: Some("zai".to_string()),
2959 ..Default::default()
2960 };
2961 let inventory = ModelInventory::from_config(&config).unwrap();
2962 let fallback = auto_route_declared_fallback(&config, &inventory);
2963
2964 let route = auto_route_classifier_fallback(fallback, &inventory);
2965
2966 assert_eq!(route.source, AutoRouteSource::Heuristic);
2967 let receipt = route.receipt.expect("fallback receipt");
2968 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
2969 assert!(matches!(
2970 receipt.data_path,
2971 AutoRouteDataPath::Classifier {
2972 ref provider,
2973 ref model,
2974 ..
2975 } if provider.as_str() == "deepseek" && model == "deepseek-v4-flash"
2976 ));
2977 assert_eq!(
2978 receipt.reason,
2979 AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::DeclaredDefault)
2980 );
2981 assert!(!receipt.reason.label().contains("secret-provider-error"));
2982 }
2983
2984 #[test]
2985 fn pre_rework_receipt_shape_still_deserializes() {
2986 // Sessions saved before the #6290 rework persist
2987 // `local_heuristic` + content-derived reasons. They must keep
2988 // loading: the wrapper arrives via serde alias, the legacy reasons
2989 // are retained variants.
2990 let reason: AutoRouteReason =
2991 serde_json::from_str(r#"{"local_heuristic":"complex_request"}"#)
2992 .expect("pre-rework receipt reason deserializes");
2993 assert_eq!(
2994 reason,
2995 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::ComplexRequest)
2996 );
2997 let receipt: AutoRouteReceipt = serde_json::from_str(
2998 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"}}"#,
2999 )
3000 .expect("pre-rework receipt deserializes");
3001 assert_eq!(receipt.reason.label(), "local fallback: short request");
3002 }
3003
3004 #[tokio::test]
3005 #[allow(clippy::await_holding_lock)]
3006 async fn inventory_auto_route_never_falls_back_across_providers_by_default() {
3007 // #4411: the active provider has no usable credential, but another
3008 // provider does. Auto must stay on the active provider and report a
3009 // no-runnable-candidate fallback instead of silently spending the
3010 // other provider's key.
3011 let _env_lock = crate::test_support::lock_test_env();
3012 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
3013 let _zai = crate::test_support::EnvVarGuard::remove("ZAI_API_KEY");
3014 let config = Config {
3015 provider: Some("zai".to_string()),
3016 ..Default::default()
3017 };
3018
3019 let route =
3020 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
3021 .await
3022 .expect("inventory route should resolve without leaving the active provider");
3023
3024 assert_eq!(route.provider.provider, ProviderKind::Zai);
3025 assert_ne!(route.provider.provider, ProviderKind::Deepseek);
3026 assert_eq!(route.source, AutoRouteSource::Heuristic);
3027 let receipt = route.receipt.expect("Auto route receipt");
3028 assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider);
3029 assert_eq!(
3030 receipt.reason,
3031 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate)
3032 );
3033 }
3034
3035 #[tokio::test]
3036 #[allow(clippy::await_holding_lock)]
3037 async fn inventory_auto_route_crosses_providers_only_under_persisted_opt_in() {
3038 // The same configuration as above, plus the persisted
3039 // `[auto] cross_provider = true` opt-in (#4411).
3040 let _env_lock = crate::test_support::lock_test_env();
3041 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
3042 let _zai = crate::test_support::EnvVarGuard::remove("ZAI_API_KEY");
3043 let config = Config {
3044 provider: Some("zai".to_string()),
3045 auto: Some(crate::config::AutoConfig {
3046 cost_saving: None,
3047 cross_provider: Some(true),
3048 router: None,
3049 }),
3050 ..Default::default()
3051 };
3052
3053 let route =
3054 resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto")
3055 .await
3056 .expect("opted-in route should fall back to an authenticated provider");
3057
3058 assert_eq!(route.provider.provider, ProviderKind::Deepseek);
3059 assert_eq!(route.model, "deepseek-flash");
3060 assert_eq!(route.source, AutoRouteSource::Heuristic);
3061 }
3062
3063 #[tokio::test]
3064 #[allow(clippy::await_holding_lock)]
3065 async fn inventory_auto_route_cost_saving_pins_fast_sibling() {
3066 let _env_lock = crate::test_support::lock_test_env();
3067 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
3068 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
3069 let balanced = Config {
3070 provider: Some("zai".to_string()),
3071 ..Default::default()
3072 };
3073 let cost_saving = Config {
3074 auto: Some(crate::config::AutoConfig {
3075 cost_saving: Some(true),
3076 cross_provider: None,
3077 router: None,
3078 }),
3079 ..balanced.clone()
3080 };
3081
3082 let balanced_route = resolve_auto_route_with_inventory(
3083 &balanced,
3084 "Please implement a binary search",
3085 "",
3086 "auto",
3087 "auto",
3088 )
3089 .await
3090 .expect("balanced Auto route should resolve");
3091 let cost_saving_route = resolve_auto_route_with_inventory(
3092 &cost_saving,
3093 "Please implement a binary search",
3094 "",
3095 "auto",
3096 "auto",
3097 )
3098 .await
3099 .expect("cost-saving Auto route should resolve");
3100
3101 assert_eq!(balanced_route.provider.provider, ProviderKind::Zai);
3102 assert_eq!(balanced_route.model, crate::config::DEFAULT_ZAI_MODEL);
3103 assert_eq!(cost_saving_route.provider.provider, ProviderKind::Zai);
3104 assert_eq!(
3105 cost_saving_route.model,
3106 crate::config::ZAI_GLM_5_3_FLASH_MODEL
3107 );
3108 assert_eq!(cost_saving_route.source, AutoRouteSource::Heuristic);
3109 assert_eq!(
3110 balanced_route
3111 .receipt
3112 .as_ref()
3113 .map(|receipt| (receipt.tier, receipt.reason)),
3114 Some((
3115 AutoRouteTier::Strong,
3116 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault),
3117 ))
3118 );
3119 assert_eq!(
3120 cost_saving_route
3121 .receipt
3122 .as_ref()
3123 .map(|receipt| (receipt.tier, receipt.reason)),
3124 Some((
3125 AutoRouteTier::Fast,
3126 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::CostSavingPolicy),
3127 ))
3128 );
3129 }
3130
3131 #[tokio::test]
3132 #[allow(clippy::await_holding_lock)]
3133 async fn inventory_auto_route_uses_wanjie_v4_pair_without_deepseek_router() {
3134 let _env_lock = crate::test_support::lock_test_env();
3135 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
3136 let _wanjie = crate::test_support::EnvVarGuard::set("WANJIE_ARK_API_KEY", "wanjie-key");
3137 let config = Config {
3138 provider: Some("wanjie-ark".to_string()),
3139 default_text_model: Some("auto".to_string()),
3140 ..Default::default()
3141 };
3142
3143 // #6290 rework: no classifier, no content signal — both wordings
3144 // take the declared Wanjie default.
3145 for prompt in ["quick status check", "please refactor this architecture"] {
3146 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
3147 .await
3148 .expect("declared-default Wanjie route should resolve");
3149 assert_eq!(route.provider.provider, ProviderKind::WanjieArk);
3150 assert_eq!(route.model, "deepseek-reasoner");
3151 assert_eq!(route.source, AutoRouteSource::Heuristic);
3152 }
3153 }
3154
3155 #[tokio::test]
3156 #[allow(clippy::await_holding_lock)]
3157 async fn inventory_auto_route_uses_volcengine_v4_pair_without_deepseek_router() {
3158 let _env_lock = crate::test_support::lock_test_env();
3159 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
3160 let _volcengine =
3161 crate::test_support::EnvVarGuard::set("VOLCENGINE_API_KEY", "volcengine-key");
3162 let config = Config {
3163 provider: Some("volcengine".to_string()),
3164 default_text_model: Some("auto".to_string()),
3165 ..Default::default()
3166 };
3167
3168 // #6290 rework: no classifier, no content signal — both wordings
3169 // take the declared Volcengine default.
3170 for prompt in ["quick status check", "please refactor this architecture"] {
3171 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
3172 .await
3173 .expect("declared-default Volcengine route should resolve");
3174 assert_eq!(route.provider.provider, ProviderKind::Volcengine);
3175 assert_eq!(route.model, "deepseek-v4-pro");
3176 assert_eq!(route.source, AutoRouteSource::Heuristic);
3177 }
3178 }
3179
3180 #[test]
3181 fn provider_router_candidates_cover_known_provider_classes() {
3182 use crate::config::ProviderKind;
3183
3184 let deepseek = provider_router_candidates(ProviderKind::Deepseek, "deepseek-v4-pro");
3185 assert_eq!(deepseek.big, "deepseek-v4-pro");
3186 assert_eq!(deepseek.cheap.as_deref(), Some("deepseek-v4-flash"));
3187
3188 let openrouter =
3189 provider_router_candidates(ProviderKind::Openrouter, "deepseek/deepseek-v4-pro");
3190 assert_eq!(openrouter.big, "deepseek/deepseek-v4-pro");
3191 assert_eq!(
3192 openrouter.cheap.as_deref(),
3193 Some("deepseek/deepseek-v4-flash")
3194 );
3195
3196 let wanjie = provider_router_candidates(ProviderKind::WanjieArk, "deepseek-reasoner");
3197 assert_eq!(wanjie.big, "deepseek-v4-pro");
3198 assert_eq!(wanjie.cheap.as_deref(), Some("deepseek-v4-flash"));
3199
3200 let volcengine = provider_router_candidates(ProviderKind::Volcengine, "DeepSeek-V4-Pro");
3201 assert_eq!(volcengine.big, "DeepSeek-V4-Pro");
3202 assert_eq!(volcengine.cheap.as_deref(), Some("DeepSeek-V4-Flash"));
3203
3204 let zai = provider_router_candidates(ProviderKind::Zai, "GLM-5.2");
3205 assert_eq!(zai.big, "GLM-5.2");
3206 // GLM-5.2 faster/explore children route to GLM-5-Turbo (same-family fast
3207 // sibling), not back down to GLM-5.1.
3208 assert_eq!(zai.cheap.as_deref(), Some("GLM-5-Turbo"));
3209
3210 let openrouter_glm = provider_router_candidates(ProviderKind::Openrouter, "z-ai/glm-5.2");
3211 assert_eq!(openrouter_glm.big, "z-ai/glm-5.2");
3212 assert_eq!(openrouter_glm.cheap.as_deref(), Some("z-ai/glm-5-turbo"));
3213
3214 // GLM-5.3's fast sibling is Flash; GLM-5.2 still uses Turbo.
3215 let zai_53 = provider_router_candidates(ProviderKind::Zai, "GLM-5.3");
3216 assert_eq!(zai_53.big, "GLM-5.3");
3217 assert_eq!(zai_53.cheap.as_deref(), Some("GLM-5.3-Flash"));
3218
3219 let openrouter_glm_53 =
3220 provider_router_candidates(ProviderKind::Openrouter, "z-ai/glm-5.3");
3221 assert_eq!(openrouter_glm_53.big, "z-ai/glm-5.3");
3222 assert_eq!(
3223 openrouter_glm_53.cheap.as_deref(),
3224 Some("z-ai/glm-5.3-flash")
3225 );
3226
3227 let zai_flash = provider_router_candidates(ProviderKind::Zai, "GLM-5.3-Flash");
3228 assert_eq!(zai_flash.big, "GLM-5.3-Flash");
3229 assert_eq!(zai_flash.cheap, None);
3230
3231 // GLM-5.1 has no cheaper tier; faster children stay on the parent.
3232 let zai_51 = provider_router_candidates(ProviderKind::Zai, "GLM-5.1");
3233 assert_eq!(zai_51.big, "GLM-5.1");
3234 assert_eq!(zai_51.cheap, None);
3235
3236 // GLM-5-Turbo is itself the cheap tier; no further downgrade.
3237 let zai_turbo = provider_router_candidates(ProviderKind::Zai, "GLM-5-Turbo");
3238 assert_eq!(zai_turbo.big, "GLM-5-Turbo");
3239 assert_eq!(zai_turbo.cheap, None);
3240
3241 // Providers without a known cheap tier: big = session model, no cheap.
3242 let ollama = provider_router_candidates(ProviderKind::Ollama, "qwen3:32b");
3243 assert_eq!(ollama.big, "qwen3:32b");
3244 assert_eq!(ollama.cheap, None);
3245
3246 let moonshot = provider_router_candidates(ProviderKind::Moonshot, "kimi-k2.6");
3247 assert_eq!(moonshot.big, "kimi-k2.6");
3248 assert_eq!(moonshot.cheap, None);
3249 }
3250
3251 #[test]
3252 fn provider_router_candidates_cover_catalog_fast_siblings() {
3253 use crate::config::ProviderKind;
3254
3255 let cases = [
3256 (ProviderKind::OpenaiCodex, "gpt-5.6-sol", "gpt-5.6-luna"),
3257 (
3258 ProviderKind::Anthropic,
3259 "claude-sonnet-4-6",
3260 "claude-haiku-4-5",
3261 ),
3262 (ProviderKind::XiaomiMimo, "mimo-v2.5-pro", "mimo-v2.5"),
3263 (
3264 ProviderKind::Arcee,
3265 "trinity-large-thinking",
3266 "trinity-mini",
3267 ),
3268 (ProviderKind::Moonshot, "kimi-k2.7-code", "kimi-k2.6"),
3269 (
3270 ProviderKind::Minimax,
3271 "MiniMax-M2.7",
3272 "MiniMax-M2.7-highspeed",
3273 ),
3274 (ProviderKind::OpencodeGo, "kimi-k3", "kimi-k2.7-code"),
3275 (
3276 ProviderKind::Openrouter,
3277 "qwen/qwen3.6-max-preview",
3278 "qwen/qwen3.6-flash",
3279 ),
3280 (
3281 ProviderKind::Openrouter,
3282 "anthropic/claude-sonnet-4-6",
3283 "anthropic/claude-haiku-4-5",
3284 ),
3285 ];
3286
3287 for (provider, strong, fast) in cases {
3288 let candidates = provider_router_candidates(provider, strong);
3289 assert_eq!(candidates.big, strong);
3290 assert_eq!(candidates.cheap.as_deref(), Some(fast));
3291 assert_eq!(
3292 provider_router_candidates(provider, fast).cheap,
3293 None,
3294 "already-fast model must not downgrade again: {provider:?}/{fast}"
3295 );
3296 }
3297
3298 for (provider, model) in [
3299 (ProviderKind::Ollama, "qwen3:32b"),
3300 (ProviderKind::Custom, "gpt-5.6-sol"),
3301 (ProviderKind::OpenaiCodex, "gpt-5.6-luna"),
3302 ] {
3303 assert_eq!(provider_router_candidates(provider, model).cheap, None);
3304 }
3305 }
3306
3307 #[tokio::test]
3308 #[allow(clippy::await_holding_lock)]
3309 async fn declared_fallback_without_cheap_tier_stays_on_default_model() {
3310 // #3018 AC: Ollama + auto must never fabricate a DeepSeek id. The
3311 // declared fallback returns the configured default verbatim, so no
3312 // sibling id can be invented regardless of request wording.
3313 let _env_lock = crate::test_support::lock_test_env();
3314 let config = Config {
3315 provider: Some("ollama".to_string()),
3316 ..Default::default()
3317 };
3318 for prompt in ["hi", "please refactor the auth module for security"] {
3319 let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto")
3320 .await
3321 .expect("ollama Auto route should resolve");
3322 assert_eq!(route.provider.provider, ProviderKind::Ollama);
3323 assert_eq!(route.model, config.default_model());
3324 assert!(
3325 !route.model.to_ascii_lowercase().contains("deepseek"),
3326 "no DeepSeek id may be fabricated: {}",
3327 route.model
3328 );
3329 }
3330 }
3331
3332 #[test]
3333 fn config_auto_cost_saving_defaults_to_false() {
3334 let cfg = Config::default();
3335 assert!(!cfg.auto_cost_saving());
3336 }
3337
3338 #[test]
3339 fn config_auto_cost_saving_reads_table() {
3340 let cfg = Config {
3341 auto: Some(crate::config::AutoConfig {
3342 cost_saving: Some(true),
3343 cross_provider: None,
3344 router: None,
3345 }),
3346 ..Default::default()
3347 };
3348 assert!(cfg.auto_cost_saving());
3349 }
3350 }
3351
3352 #[cfg(test)]
3353 mod decision_router_tests {
3354 //! `[auto.router] kind = "decision"` (#6525) against wiremock. The
3355 //! resolver's `cfg!(test)` short-circuit stays; these call the router path
3356 //! (`auto_route_via_router`) and its pure policy directly.
3357
3358 use super::*;
3359 use wiremock::matchers::{header, method, path};
3360 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
3361
3362 const MARKER: &str = "PROVIDER-BODY-MARKER-must-not-leak";
3363
3364 /// Fields drop in declaration order: restore the environment before the
3365 /// lock is released, or another test observes our overrides.
3366 struct Env {
3367 _guards: Vec<crate::test_support::EnvVarGuard>,
3368 _home: tempfile::TempDir,
3369 _lock: crate::test_support::TestEnvLock,
3370 }
3371
3372 fn hermetic_env() -> Env {
3373 let lock = crate::test_support::lock_test_env();
3374 let home = tempfile::tempdir().expect("test home");
3375 let guards = vec![
3376 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()),
3377 crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY"),
3378 crate::test_support::EnvVarGuard::remove("TYPESAFE_API_KEY"),
3379 ];
3380 Env {
3381 _guards: guards,
3382 _home: home,
3383 _lock: lock,
3384 }
3385 }
3386
3387 /// Active DeepSeek (pro/flash pair runnable) with an OpenRouter decision
3388 /// router pointed at `openrouter_base`.
3389 fn decision_config(openrouter_base: &str, cost_saving: bool, timeout_secs: u64) -> Config {
3390 Config {
3391 provider: Some("deepseek".to_string()),
3392 default_text_model: Some("deepseek-v4-pro".to_string()),
3393 providers: Some(crate::config::ProvidersConfig {
3394 deepseek: crate::config::ProviderConfig {
3395 api_key: Some("ds-test-key".to_string()),
3396 ..Default::default()
3397 },
3398 openrouter: crate::config::ProviderConfig {
3399 api_key: Some("or-test-key".to_string()),
3400 base_url: Some(openrouter_base.to_string()),
3401 ..Default::default()
3402 },
3403 ..Default::default()
3404 }),
3405 auto: Some(crate::config::AutoConfig {
3406 cost_saving: Some(cost_saving),
3407 cross_provider: None,
3408 router: Some(crate::config::AutoRouterConfig {
3409 kind: Some("decision".to_string()),
3410 provider: Some("openrouter".to_string()),
3411 model: Some("typesafe/jev-1.13".to_string()),
3412 timeout_secs: Some(timeout_secs),
3413 ..Default::default()
3414 }),
3415 }),
3416 ..Default::default()
3417 }
3418 }
3419
3420 const TYPESAFE_TEST_KEY: &str = "tsbarekey0123456789";
3421
3422 fn answer_body(strong: f64, confidence: f64) -> serde_json::Value {
3423 serde_json::json!({
3424 "id": "gen-dec-1",
3425 "model": "typesafe/jev-1.13-20260917",
3426 "provider": "TypeSafe",
3427 "answers": {
3428 "tier": {
3429 "type": "choice",
3430 "choice": if strong >= 0.5 { "strong" } else { "fast" },
3431 "probabilities": { "fast": 1.0 - strong, "strong": strong },
3432 "confidence": confidence,
3433 },
3434 "thinking": {
3435 "type": "choice",
3436 "choice": "max",
3437 "probabilities": { "off": 0.02, "high": 0.21, "max": 0.77 },
3438 "confidence": 0.66,
3439 },
3440 },
3441 "usage": { "cost": 0.000019992, "input_tokens": 476, "output_tokens": 70 },
3442 })
3443 }
3444
3445 async fn route(config: &Config, latest: &str, context: &str) -> AutoRouteSelection {
3446 let inventory = ModelInventory::from_config(config).unwrap();
3447 assert!(
3448 inventory.router_available,
3449 "decision router must be available"
3450 );
3451 auto_route_via_router(
3452 config, &inventory, latest, context, "agent", "auto", "auto", false,
3453 )
3454 .await
3455 }
3456
3457 fn receipt(selection: &AutoRouteSelection) -> &AutoRouteReceipt {
3458 selection.receipt.as_ref().expect("auto receipt")
3459 }
3460
3461 #[tokio::test]
3462 async fn decision_request_has_pinned_shape_and_redacted_state() {
3463 let _env = hermetic_env();
3464 let server = MockServer::start().await;
3465 Mock::given(method("POST"))
3466 .and(path("/api/alpha/decisions"))
3467 .and(header("authorization", "Bearer or-test-key"))
3468 .respond_with(ResponseTemplate::new(200).set_body_json(answer_body(0.82, 0.64)))
3469 .expect(1)
3470 .mount(&server)
3471 .await;
3472 let mut config = decision_config(&format!("{}/api/v1", server.uri()), false, 2);
3473 // A configured credential that a tool result echoed back.
3474 let secret = "cw-router-secret-should-never-leave-process";
3475 config
3476 .providers
3477 .as_mut()
3478 .expect("providers")
3479 .deepseek
3480 .api_key = Some(secret.to_string());
3481 let selection = route(
3482 &config,
3483 "Refactor the parser across files",
3484 &format!("assistant: [tool result] token={secret}"),
3485 )
3486 .await;
3487 assert_eq!(selection.model, "deepseek-v4-pro");
3488
3489 let requests: Vec<Request> = server.received_requests().await.expect("recorded");
3490 assert_eq!(requests.len(), 1);
3491 let raw = String::from_utf8(requests[0].body.clone()).expect("utf8 body");
3492 assert!(
3493 !raw.contains(secret),
3494 "secret leaked into the decision state"
3495 );
3496 let body: serde_json::Value = serde_json::from_str(&raw).expect("json body");
3497 assert_eq!(body["model"], "typesafe/jev-1.13");
3498 let state_keys: Vec<&str> = body["state"]
3499 .as_object()
3500 .expect("state object")
3501 .keys()
3502 .map(String::as_str)
3503 .collect();
3504 assert_eq!(
3505 state_keys,
3506 [
3507 "session_mode",
3508 "selected_thinking_mode",
3509 "recent_context",
3510 "latest_request"
3511 ]
3512 );
3513 // The criteria are a product surface: pin the exact wording.
3514 assert_eq!(
3515 body["questions"],
3516 serde_json::json!({
3517 "tier": {
3518 "type": "choice",
3519 "instructions": "Which model tier should handle the latest request in this coding-agent session?",
3520 "criteria": {
3521 "fast": {
3522 "what": "A fast, cheaper model. Right for questions, explanations, lookups, small single-file edits, formatting, and routine follow-ups.",
3523 "not_for": "Multi-step agentic work, debugging across files, architecture or design, security review, release work."
3524 },
3525 "strong": {
3526 "what": "The strongest model. Right for multi-step agentic coding, multi-file changes, debugging, architecture or design, security review, release work, or anything the fast tier would likely get wrong.",
3527 "not_for": "Trivial questions or one-line edits."
3528 }
3529 }
3530 },
3531 "thinking": {
3532 "type": "choice",
3533 "instructions": "How much reasoning should the chosen model spend on the latest request?",
3534 "criteria": {
3535 "off": "A trivial answer with no tools and no reasoning needed.",
3536 "high": "Ordinary reasoning: a normal coding or explanation task.",
3537 "max": "Agentic, multi-file, debugging, architecture, security, release, or uncertain work."
3538 }
3539 }
3540 })
3541 );
3542 }
3543
3544 #[tokio::test]
3545 async fn confident_strong_decision_routes_strong_with_evidence_and_usage() {
3546 let _env = hermetic_env();
3547 let server = MockServer::start().await;
3548 Mock::given(method("POST"))
3549 .and(path("/api/alpha/decisions"))
3550 .respond_with(ResponseTemplate::new(200).set_body_json(answer_body(0.82, 0.64)))
3551 .mount(&server)
3552 .await;
3553 let config = decision_config(&format!("{}/api/v1", server.uri()), false, 2);
3554 let selection = route(&config, "Debug the release pipeline", "").await;
3555
3556 assert_eq!(selection.provider.provider, ProviderKind::Deepseek);
3557 assert_eq!(selection.model, "deepseek-v4-pro");
3558 assert_eq!(selection.source, AutoRouteSource::FlashRouter);
3559 assert_eq!(selection.reasoning_effort, Some(ReasoningEffort::Max));
3560 let receipt = receipt(&selection);
3561 assert_eq!(receipt.reason, AutoRouteReason::ClassifierRecommendation);
3562 assert_eq!(receipt.tier, AutoRouteTier::Strong);
3563 assert_eq!(receipt.scope, AutoRouteScope::ActiveProvider);
3564 assert_eq!(
3565 receipt.data_path,
3566 AutoRouteDataPath::Decision {
3567 route: DecisionRouterRoute::Openrouter,
3568 model: "typesafe/jev-1.13".to_string(),
3569 }
3570 );
3571 assert_eq!(receipt.router_failure, None);
3572 let decision = receipt.decision.as_ref().expect("decision evidence");
3573 assert_eq!(decision.choice, "strong");
3574 assert_eq!(
3575 decision.probabilities_bp,
3576 BTreeMap::from([("fast".to_string(), 1800), ("strong".to_string(), 8200)])
3577 );
3578 assert_eq!(decision.confidence_bp, 6400);
3579 assert_eq!(decision.min_confidence_bp, 5000);
3580 assert_eq!(decision.thinking.as_deref(), Some("max"));
3581 assert_eq!(
3582 decision.provider_reported_cost_usd.as_deref(),
3583 Some("0.000019992")
3584 );
3585 assert_eq!(
3586 decision.response_model.as_deref(),
3587 Some("typesafe/jev-1.13-20260917")
3588 );
3589 assert_eq!(selection.routed_usage.len(), 1);
3590 assert_eq!(selection.routed_usage[0].usage.usage.input_tokens, 476);
3591 assert_eq!(selection.routed_usage[0].usage.usage.output_tokens, 70);
3592 assert!(
3593 selection.routed_usage[0]
3594 .source_id
3595 .starts_with("auto-router:")
3596 );
3597 }
3598
3599 #[tokio::test]
3600 async fn low_confidence_takes_the_declared_fallback_and_keeps_usage() {
3601 let _env = hermetic_env();
3602 let server = MockServer::start().await;
3603 Mock::given(method("POST"))
3604 .and(path("/api/alpha/decisions"))
3605 .respond_with(ResponseTemplate::new(200).set_body_json(answer_body(0.35, 0.3)))
3606 .mount(&server)
3607 .await;
3608 let config = decision_config(&format!("{}/api/v1", server.uri()), false, 2);
3609 let selection = route(&config, "What does this function do?", "").await;
3610
3611 assert_eq!(selection.model, "deepseek-v4-pro", "declared default");
3612 assert_eq!(selection.source, AutoRouteSource::Heuristic);
3613 let receipt = receipt(&selection);
3614 assert_eq!(
3615 receipt.reason,
3616 AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::LowConfidence)
3617 );
3618 assert_eq!(receipt.router_failure, None);
3619 let decision = receipt.decision.as_ref().expect("evidence is kept");
3620 assert_eq!(decision.choice, "fast");
3621 assert_eq!(decision.confidence_bp, 3000);
3622 assert_eq!(
3623 decision.thinking, None,
3624 "an unacted decision applies no effort"
3625 );
3626 assert_eq!(selection.routed_usage.len(), 1, "usage is still recorded");
3627 }
3628
3629 #[tokio::test]
3630 async fn http_errors_fail_the_router_loudly_without_leaking_bodies() {
3631 for status in [401_u16, 402, 429, 500] {
3632 let _env = hermetic_env();
3633 let server = MockServer::start().await;
3634 Mock::given(method("POST"))
3635 .and(path("/api/alpha/decisions"))
3636 .respond_with(
3637 ResponseTemplate::new(status)
3638 .set_body_string(format!(r#"{{"error":{{"message":"{MARKER}"}}}}"#)),
3639 )
3640 .expect(1)
3641 .mount(&server)
3642 .await;
3643 let config = decision_config(&format!("{}/api/v1", server.uri()), false, 2);
3644 let selection = route(&config, "Explain the diff", "").await;
3645
3646 assert_eq!(selection.model, "deepseek-v4-pro", "status {status}");
3647 let receipt = receipt(&selection);
3648 assert!(
3649 matches!(receipt.reason, AutoRouteReason::ClassifierFallback(_)),
3650 "status {status}"
3651 );
3652 assert_eq!(
3653 receipt.router_failure,
3654 Some(AutoRouterFailure::Http { status }),
3655 "status {status}"
3656 );
3657 let json = serde_json::to_string(receipt).expect("receipt json");
3658 assert!(!json.contains(MARKER), "status {status}: body leaked");
3659 assert!(!receipt.router_failure.unwrap().label().contains(MARKER));
3660 assert!(selection.routed_usage.is_empty());
3661 assert_eq!(selection.routed_usage_dropped_records, 1, "status {status}");
3662 assert_eq!(selection.routed_usage_drop_records.len(), 1);
3663 }
3664 }
3665
3666 #[tokio::test]
3667 async fn dispatched_timeout_falls_back_and_marks_usage_missing() {
3668 let _env = hermetic_env();
3669 let server = MockServer::start().await;
3670 Mock::given(method("POST"))
3671 .and(path("/api/alpha/decisions"))
3672 .respond_with(
3673 ResponseTemplate::new(200)
3674 .set_body_json(answer_body(0.9, 0.8))
3675 .set_delay(Duration::from_millis(1_600)),
3676 )
3677 .mount(&server)
3678 .await;
3679 let config = decision_config(&format!("{}/api/v1", server.uri()), false, 1);
3680 let selection = route(&config, "Explain the diff", "").await;
3681
3682 let receipt = receipt(&selection);
3683 assert_eq!(receipt.router_failure, Some(AutoRouterFailure::Timeout));
3684 assert!(matches!(
3685 receipt.reason,
3686 AutoRouteReason::ClassifierFallback(_)
3687 ));
3688 assert!(selection.routed_usage.is_empty());
3689 // The POST was sent before the deadline: coverage must fail closed.
3690 assert_eq!(selection.routed_usage_drop_records.len(), 1);
3691 assert_eq!(selection.routed_usage_dropped_records, 1);
3692 }
3693
3694 #[tokio::test]
3695 async fn no_fast_strong_pair_means_no_request() {
3696 let _env = hermetic_env();
3697 let server = MockServer::start().await;
3698 Mock::given(method("POST"))
3699 .respond_with(ResponseTemplate::new(200).set_body_json(answer_body(0.9, 0.8)))
3700 .expect(0)
3701 .mount(&server)
3702 .await;
3703 let mut config = decision_config(&format!("{}/api/v1", server.uri()), false, 2);
3704 // A single-tier active provider: nothing for the decision to choose.
3705 config.provider = Some("openrouter".to_string());
3706 config.default_text_model = Some("synthetic/single-tier-model".to_string());
3707 let selection = route(&config, "Refactor everything", "").await;
3708
3709 let receipt = receipt(&selection);
3710 assert_eq!(
3711 receipt.reason,
3712 AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoFastSibling)
3713 );
3714 assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic);
3715 assert!(selection.routed_usage.is_empty());
3716
3717 // The setup test must say no call was made, not report a result.
3718 let reason = test_auto_router(&config)
3719 .await
3720 .expect_err("no test call without a strong/fast pair");
3721 assert_eq!(reason, AutoRouteHeuristicReason::NoFastSibling.label());
3722 }
3723
3724 #[tokio::test]
3725 async fn typesafe_direct_uses_its_own_endpoint_and_key() {
3726 let _env = hermetic_env();
3727 let server = MockServer::start().await;
3728 Mock::given(method("POST"))
3729 .and(path("/v1/systemone"))
3730 .and(header(
3731 "authorization",
3732 format!("Bearer {TYPESAFE_TEST_KEY}").as_str(),
3733 ))
3734 .respond_with(ResponseTemplate::new(200).set_body_json(answer_body(0.2, 0.6)))
3735 .expect(1)
3736 .mount(&server)
3737 .await;
3738 let mut config = decision_config("https://openrouter.invalid/api/v1", false, 2);
3739 let providers = config.providers.as_mut().expect("providers");
3740 providers.openrouter.api_key = None;
3741 // From the environment: not part of any `[providers.*]` table, so
3742 // only the TypeSafe-specific redaction covers it.
3743 let _key = crate::test_support::EnvVarGuard::set("TYPESAFE_API_KEY", TYPESAFE_TEST_KEY);
3744 let router = config
3745 .auto
3746 .as_mut()
3747 .and_then(|auto| auto.router.as_mut())
3748 .expect("router");
3749 router.provider = Some("typesafe".to_string());
3750 router.model = Some("jev-latest".to_string());
3751 router.base_url = Some(format!("{}/v1", server.uri()));
3752
3753 // The TypeSafe key is no chat provider's key; a bare echo of it in
3754 // context must still be redacted from the decision body.
3755 let selection = route(
3756 &config,
3757 "Rename a variable",
3758 &format!("assistant: [tool result] {TYPESAFE_TEST_KEY}"),
3759 )
3760 .await;
3761 let requests: Vec<Request> = server.received_requests().await.expect("recorded");
3762 let raw = String::from_utf8(requests[0].body.clone()).expect("utf8 body");
3763 assert!(!raw.contains(TYPESAFE_TEST_KEY), "TypeSafe key leaked");
3764
3765 assert_eq!(selection.model, "deepseek-v4-flash");
3766 let receipt = receipt(&selection);
3767 assert_eq!(receipt.reason, AutoRouteReason::ClassifierRecommendation);
3768 assert_eq!(
3769 receipt.data_path,
3770 AutoRouteDataPath::Decision {
3771 route: DecisionRouterRoute::Typesafe,
3772 model: "jev-latest".to_string(),
3773 }
3774 );
3775 assert!(receipt.decision.is_some());
3776 // A decision endpoint never inherits the active DeepSeek billing identity.
3777 assert_eq!(selection.routed_usage.len(), 1);
3778 let usage = &selection.routed_usage[0];
3779 assert_eq!(usage.usage.route.provider, ProviderKind::Custom);
3780 assert_eq!(usage.usage.route.provider_identity, "typesafe");
3781 assert_eq!(
3782 usage.usage.route.billing_mode,
3783 crate::cost_status::RouteBillingMode::Unknown
3784 );
3785 assert_eq!(usage.usage.usage.input_tokens, 476);
3786 assert_eq!(usage.usage.usage.output_tokens, 70);
3787 assert!(selection.routed_usage_drop_records.is_empty());
3788 }
3789
3790 #[test]
3791 fn declared_router_without_a_key_is_shown_as_failing() {
3792 let _env = hermetic_env();
3793 let mut config = decision_config("https://openrouter.invalid/api/v1", false, 2);
3794 config
3795 .providers
3796 .as_mut()
3797 .expect("providers")
3798 .openrouter
3799 .api_key = None;
3800 let inventory = ModelInventory::from_config(&config).unwrap();
3801 assert!(!inventory.router_available);
3802 let selection = auto_route_without_router(&config, &inventory);
3803 assert_eq!(
3804 receipt(&selection).router_failure,
3805 Some(AutoRouterFailure::NotRunnable)
3806 );
3807 }
3808
3809 fn parsed(json: serde_json::Value) -> SystemOneResponse {
3810 serde_json::from_str(&json.to_string()).expect("system one response")
3811 }
3812
3813 fn pair() -> ActiveTierPair {
3814 ActiveTierPair {
3815 provider: Config::default().test_identity_for_kind(ProviderKind::Deepseek),
3816 strong: "deepseek-v4-pro".to_string(),
3817 fast: "deepseek-v4-flash".to_string(),
3818 }
3819 }
3820
3821 fn policy(cost_saving: bool, response: &SystemOneResponse) -> InventoryAutoRouteAttempt {
3822 let _env = hermetic_env();
3823 let config = decision_config("https://openrouter.invalid/api/v1", cost_saving, 2);
3824 let inventory = ModelInventory::from_config(&config).unwrap();
3825 decision_attempt_from_response(cost_saving, &inventory, &pair(), None, response, 120)
3826 }
3827
3828 #[test]
3829 fn cost_saving_needs_a_clear_strong_probability() {
3830 let unsure = policy(true, &parsed(answer_body(0.70, 0.9)));
3831 let recommendation = unsure.recommendation.expect("acted");
3832 assert_eq!(recommendation.model, "deepseek-v4-flash");
3833 assert!(unsure.decision.expect("evidence").cost_saving_kept_fast);
3834
3835 let clear = policy(true, &parsed(answer_body(0.80, 0.9)));
3836 assert_eq!(
3837 clear.recommendation.expect("acted").model,
3838 "deepseek-v4-pro"
3839 );
3840 assert!(!clear.decision.expect("evidence").cost_saving_kept_fast);
3841
3842 let balanced = policy(false, &parsed(answer_body(0.70, 0.9)));
3843 assert_eq!(
3844 balanced.recommendation.expect("acted").model,
3845 "deepseek-v4-pro"
3846 );
3847 }
3848
3849 #[test]
3850 fn invalid_answers_are_rejected_not_repaired() {
3851 let mut outside = answer_body(0.8, 0.6);
3852 outside["answers"]["tier"]["choice"] = "medium".into();
3853 let mut bad_sum = answer_body(0.8, 0.6);
3854 bad_sum["answers"]["tier"]["probabilities"]["fast"] = 0.5.into();
3855 let mut missing = answer_body(0.8, 0.6);
3856 missing["answers"]
3857 .as_object_mut()
3858 .expect("answers")
3859 .remove("tier");
3860 let mut null_probability = answer_body(0.8, 0.6);
3861 null_probability["answers"]["tier"]["probabilities"]["fast"] = serde_json::Value::Null;
3862 let mut bad_confidence = answer_body(0.8, 0.6);
3863 bad_confidence["answers"]["tier"]["confidence"] = 1.5.into();
3864 for body in [outside, bad_sum, missing, null_probability, bad_confidence] {
3865 let attempt = policy(false, &parsed(body.clone()));
3866 assert_eq!(attempt.recommendation, None, "{body}");
3867 assert_eq!(
3868 attempt
3869 .decision
3870 .as_ref()
3871 .and_then(|r| r.provider_reported_cost_usd.as_deref()),
3872 parsed(body.clone())
3873 .usage
3874 .as_ref()
3875 .and_then(|u| u.reported_cost())
3876 .as_deref(),
3877 "rejected policy retains cost"
3878 );
3879 assert_eq!(
3880 attempt.failure,
3881 Some(AutoRouterFailure::InvalidAnswer),
3882 "{body}"
3883 );
3884 }
3885
3886 // An invalid `thinking` answer drops only the effort.
3887 let mut bad_thinking = answer_body(0.8, 0.6);
3888 bad_thinking["answers"]["thinking"]["choice"] = "ultra".into();
3889 let attempt = policy(false, &parsed(bad_thinking));
3890 let recommendation = attempt.recommendation.expect("tier still acted on");
3891 assert_eq!(recommendation.model, "deepseek-v4-pro");
3892 assert_eq!(recommendation.reasoning_effort, None);
3893 }
3894
3895 #[tokio::test]
3896 async fn malformed_decision_envelopes_preserve_cost_without_a_route_hop() {
3897 let _env = hermetic_env();
3898 for (pointer, value, incomplete_usage) in [
3899 ("/answers/tier/choice", serde_json::json!(17), false),
3900 ("/model", serde_json::json!({"invalid":"model"}), false),
3901 ("/usage/input_tokens", serde_json::json!(u64::MAX), true),
3902 ] {
3903 let server = MockServer::start().await;
3904 let mut body = answer_body(0.9, 0.9);
3905 *body.pointer_mut(pointer).expect("fixture field") = value;
3906 Mock::given(method("POST"))
3907 .and(path("/api/alpha/decisions"))
3908 .respond_with(ResponseTemplate::new(200).set_body_json(body))
3909 .expect(1)
3910 .mount(&server)
3911 .await;
3912 let config = decision_config(&format!("{}/api/v1", server.uri()), false, 2);
3913 let fallback =
3914 auto_route_without_router(&config, &ModelInventory::from_config(&config).unwrap());
3915 let selection = route(&config, "Explain a variable name", "").await;
3916 assert_eq!(selection.source, AutoRouteSource::Heuristic);
3917 assert_eq!(selection.provider, fallback.provider);
3918 assert_eq!(
3919 selection.model, fallback.model,
3920 "malformed policy cannot change the route"
3921 );
3922 let receipt = receipt(&selection);
3923 assert_eq!(
3924 receipt.router_failure,
3925 Some(AutoRouterFailure::InvalidAnswer)
3926 );
3927 assert_eq!(
3928 receipt
3929 .decision
3930 .as_ref()
3931 .expect("billing evidence")
3932 .provider_reported_cost_usd
3933 .as_deref(),
3934 Some("0.000019992")
3935 );
3936 if incomplete_usage {
3937 assert!(selection.routed_usage.is_empty());
3938 assert_eq!(selection.routed_usage_dropped_records, 1);
3939 assert_eq!(selection.routed_usage_drop_records.len(), 1);
3940 } else {
3941 assert_eq!(selection.routed_usage.len(), 1);
3942 assert!(selection.routed_usage_drop_records.is_empty());
3943 }
3944 }
3945 }
3946
3947 #[test]
3948 fn camel_case_usage_and_verbatim_cost_parse() {
3949 let response = parsed(serde_json::json!({
3950 "answers": {},
3951 "usage": { "inputTokens": 381, "outputTokens": 62, "cost": 0.000016002 },
3952 }));
3953 let usage = response.usage.expect("usage");
3954 assert_eq!(usage.input_tokens, 381);
3955 assert_eq!(usage.output_tokens, 62);
3956 assert_eq!(usage.reported_cost().as_deref(), Some("0.000016002"));
3957 }
3958
3959 #[test]
3960 fn receipts_saved_before_decision_routing_still_load() {
3961 let json = r#"{"tier":"fast","pair":{"strong":"deepseek-v4-pro","fast":"deepseek-v4-flash"},"scope":"active_provider","data_path":{"classifier":{"provider":"deepseek","model":"deepseek-v4-flash"}},"reason":"classifier_recommendation"}"#;
3962 let receipt: AutoRouteReceipt = serde_json::from_str(json).expect("legacy receipt");
3963 assert_eq!(receipt.decision, None);
3964 assert_eq!(receipt.router_failure, None);
3965 // And the new fields stay off the wire when empty.
3966 assert_eq!(serde_json::to_string(&receipt).expect("json"), json);
3967 }
3968 }
3969
3969 lines RUST