返回 CodeWhale
turn_route_plan.rs
根目录 / crates / tui / src / turn_route_plan.rs
1 //! The single shared turn-route planner (#1004).
2 //!
3 //! One function decides which provider, model, route identity, client, limits,
4 //! compaction policy, and reasoning tier a turn will use.
5 //! `spawned_dispatch_inner` calls it to *send* a turn; `/preview-request`
6 //! calls it with a hypothetical prompt to *describe* one. Because there is a
7 //! single implementation, a preview cannot report a route different from the
8 //! one dispatch would pick for the same prompt — which is the whole point of
9 //! previewing a route before spending anything on it.
10 //!
11 //! It lives outside the TUI module so the engine-side preview tests can drive
12 //! the same planner the UI drives, provider-free.
13 //!
14 //! The planner mutates no engine or session state. Its one outbound call is
15 //! the auto-router classifier, which only runs when auto model routing is on.
16 //! Production may use the deterministic response cache for that call;
17 //! `/preview-request` explicitly bypasses it so inspection does not perturb
18 //! later routing.
19
20 use crate::compaction::CompactionConfig;
21 use crate::config::{Config, ProviderIdentity, ProviderKind};
22 use crate::reasoning_preference::ReasoningEffort;
23 use crate::route_runtime::{ResolvedRuntimeRoute, resolve_runtime_route_for_identity};
24 use codewhale_config::AppMode;
25
26 /// Everything the shared turn-route planner needs.
27 ///
28 /// Borrowed rather than owned so the dispatch path can pass its already
29 /// captured `UserDispatchPrepare` fields and `/preview-request` can pass a
30 /// hypothetical prompt, without either one duplicating the other's logic.
31 pub(crate) struct TurnRoutePlanRequest<'a> {
32 pub(crate) route_config: &'a Config,
33 pub(crate) app_route_identity: &'a ProviderIdentity,
34 pub(crate) api_provider: ProviderKind,
35 pub(crate) app_model: &'a str,
36 pub(crate) auto_model: bool,
37 pub(crate) reasoning_effort: ReasoningEffort,
38 pub(crate) mode: AppMode,
39 /// Model-facing content of the next user message (file mentions and skill
40 /// wrapping already resolved). This is what the auto router classifies.
41 pub(crate) content: &'a str,
42 pub(crate) auto_router_context: &'a str,
43 pub(crate) should_auto_resolve: bool,
44 /// Production dispatch may use the deterministic response cache for the
45 /// auxiliary Auto classifier. Read-only previews must set this to false.
46 pub(crate) allow_auto_router_response_cache: bool,
47 pub(crate) preflight_required: bool,
48 pub(crate) auto_compact_user_configured: bool,
49 pub(crate) auto_compact: bool,
50 pub(crate) auto_compact_threshold_percent: f64,
51 }
52
53 /// The exact route, limits, compaction policy, and reasoning normalization one
54 /// turn would use.
55 pub(crate) struct PlannedTurnRoute {
56 pub(crate) route: ResolvedRuntimeRoute,
57 pub(crate) compaction: CompactionConfig,
58 pub(crate) effective_provider: ProviderKind,
59 pub(crate) effective_model: String,
60 pub(crate) effective_provider_identity: String,
61 pub(crate) effective_provider_label: String,
62 pub(crate) selected_reasoning_effort: Option<ReasoningEffort>,
63 /// Normalized api value for the resolved route — the string that reaches
64 /// the wire.
65 pub(crate) effective_reasoning_effort: Option<String>,
66 pub(crate) auto_controls_reasoning: bool,
67 pub(crate) auto_selection: Option<crate::model_routing::AutoRouteSelection>,
68 /// Bounded auxiliary classifier usage that must enter the accepted turn
69 /// under its own frozen routes. It is moved out of `auto_selection` so a
70 /// UI-only receipt consumer cannot accidentally become the accounting
71 /// owner or price it under the parent route.
72 pub(crate) initial_routed_usage: crate::cost_status::RuntimeUsageBatch,
73 /// Why this concrete route was selected. This is captured by the planner,
74 /// not inferred later from the resulting provider/model pair.
75 pub(crate) routing_source: TurnRoutingSource,
76 }
77
78 /// Durable provenance for the route selected for one turn.
79 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
80 pub(crate) enum TurnRoutingSource {
81 /// The active fixed route was used unchanged. This intentionally does not
82 /// guess whether an earlier UI action or persisted config installed it.
83 ActiveFixedRoute,
84 /// Auto model routing used its provider-backed classifier.
85 AutoProviderClassifier,
86 /// Auto model routing fell back to the local declared default (no
87 /// classifier signal; request wording never inspected).
88 AutoLocalFallback,
89 }
90
91 impl TurnRoutingSource {
92 pub(crate) const fn label(self) -> &'static str {
93 match self {
94 Self::ActiveFixedRoute => "active-fixed-route",
95 Self::AutoProviderClassifier => "auto-provider-classifier",
96 Self::AutoLocalFallback => "auto-local-fallback",
97 }
98 }
99 }
100
101 fn reasoning_effort_for_route_selection(
102 auto_model: bool,
103 provider: ProviderKind,
104 effort: ReasoningEffort,
105 ) -> &'static str {
106 if auto_model {
107 effort.as_setting()
108 } else {
109 effort.as_setting_for_provider(provider)
110 }
111 }
112
113 /// Settle the classifier usage of a turn whose route then failed, into the
114 /// cost scope that was current when the classifier call was made. A fresh
115 /// token taken here would bill it to whatever session is current by now:
116 /// after a `/new` or session load during the call, a different one.
117 fn settle_failed_parent_route(
118 cost_scope: crate::cost_status::CostScopeToken,
119 error: String,
120 initial_routed_usage: &crate::cost_status::RuntimeUsageBatch,
121 ) -> String {
122 crate::cost_status::report_runtime_usage_batch(cost_scope, None, initial_routed_usage);
123 error
124 }
125
126 /// Resolve the route for one turn.
127 ///
128 /// This is *the* route planner (#1004). `spawned_dispatch_inner` calls it to
129 /// send a turn; `/preview-request` calls it with a hypothetical prompt to
130 /// describe one. Because there is a single implementation, a preview cannot
131 /// report a provider, model, route identity, client, reasoning tier, limit,
132 /// tool budget, billing basis, or endpoint different from the one dispatch
133 /// would pick for the same prompt.
134 ///
135 /// It mutates no engine or session state: it reads config, resolves a route,
136 /// and returns a value. Its one outbound call is the auto-router classifier,
137 /// which is the same auxiliary call a real turn makes and only runs when auto
138 /// model routing is on. The caller chooses whether that auxiliary call may
139 /// touch the process-global deterministic response cache.
140 pub(crate) async fn plan_turn_route(
141 request: TurnRoutePlanRequest<'_>,
142 ) -> Result<PlannedTurnRoute, String> {
143 // Taken before the classifier call so its usage settles into the scope
144 // it was spent in, not the one current when a failure is noticed.
145 let cost_scope = crate::cost_status::scope_token();
146 let mut auto_selection = if request.should_auto_resolve {
147 Some(
148 crate::model_routing::resolve_auto_route_with_inventory_for_session_and_cache_policy(
149 request.route_config,
150 request.content,
151 request.auto_router_context,
152 request.mode.as_setting(),
153 if request.auto_model { "auto" } else { "fixed" },
154 reasoning_effort_for_route_selection(
155 request.auto_model,
156 request.api_provider,
157 request.reasoning_effort,
158 ),
159 request.allow_auto_router_response_cache,
160 )
161 .await
162 .map_err(|err| err.to_string())?,
163 )
164 } else {
165 None
166 };
167
168 // Move classifier accounting out immediately. Every later parent-route
169 // failure must settle this already-incurred auxiliary call instead of
170 // returning an error that silently drops its exact quote/usage.
171 let initial_routed_usage = auto_selection
172 .as_mut()
173 .map(|selection| crate::cost_status::RuntimeUsageBatch {
174 decisions: Vec::new(),
175 records: std::mem::take(&mut selection.routed_usage),
176 drop_records: std::mem::take(&mut selection.routed_usage_drop_records),
177 dropped_records: std::mem::take(&mut selection.routed_usage_dropped_records),
178 })
179 .unwrap_or_default();
180
181 let selected_identity = auto_selection
182 .as_ref()
183 .map_or(request.app_route_identity, |selection| &selection.provider);
184 let effective_provider = selected_identity.provider;
185
186 // Without an Auto selection there is no per-request signal, so the
187 // route is the configured model — the same declared default the local
188 // fallback uses. Request wording is never inspected (#6290 rework).
189 let effective_model = if request.auto_model {
190 auto_selection
191 .as_ref()
192 .map(|selection| selection.model.clone())
193 .unwrap_or_else(|| request.app_model.to_string())
194 } else {
195 request.app_model.to_string()
196 };
197
198 let turn_route = resolve_runtime_route_for_identity(
199 request.route_config,
200 selected_identity,
201 Some(&effective_model),
202 );
203
204 let turn_route = match turn_route {
205 Ok(route) => route,
206 Err(err) => {
207 return Err(settle_failed_parent_route(
208 cost_scope,
209 err.to_string(),
210 &initial_routed_usage,
211 ));
212 }
213 };
214 let turn_route = if request.preflight_required {
215 match turn_route.preflight() {
216 Ok(route) => route,
217 Err(err) => {
218 return Err(settle_failed_parent_route(
219 cost_scope,
220 err,
221 &initial_routed_usage,
222 ));
223 }
224 }
225 } else {
226 turn_route
227 };
228
229 let turn_route_limits = crate::route_budget::known_route_limits(turn_route.candidate.limits());
230 let effective_provider_identity = turn_route.identity.key.to_string();
231 let effective_provider_label = if effective_provider == ProviderKind::Custom {
232 effective_provider_identity.clone()
233 } else {
234 turn_route
235 .identity
236 .compatibility()
237 .map_or(effective_provider.as_str(), |row| row.label)
238 .to_string()
239 };
240
241 let turn_compaction = CompactionConfig {
242 enabled: if request.auto_compact_user_configured {
243 request.auto_compact
244 } else {
245 crate::route_budget::auto_compact_default_for_route(
246 turn_route.identity.provider,
247 &turn_route.model,
248 turn_route_limits,
249 )
250 },
251 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
252 turn_route.identity.provider,
253 &turn_route.model,
254 turn_route_limits,
255 request.auto_compact_threshold_percent,
256 ),
257 model: turn_route.model.clone(),
258 image_input: turn_route.candidate.capabilities().image_input,
259 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
260 turn_route.identity.provider,
261 &turn_route.model,
262 turn_route_limits,
263 )),
264 summary_instructions: request.route_config.compaction_summary_instructions(),
265 retained_user_message_tokens: request
266 .route_config
267 .compaction_retained_user_message_tokens(),
268 ..Default::default()
269 };
270
271 // Model selection and reasoning selection are independent. A fixed
272 // reasoning preference survives auto model routing and is normalized
273 // against the concrete route below; only an explicit `auto` delegates the
274 // tier to the classifier/declared fallback.
275 let auto_controls_reasoning = request.reasoning_effort == ReasoningEffort::Auto;
276 let selected_reasoning_effort = if auto_controls_reasoning {
277 Some(
278 auto_selection
279 .as_ref()
280 .and_then(|selection| selection.reasoning_effort)
281 .unwrap_or_else(crate::auto_reasoning::select),
282 )
283 } else {
284 None
285 };
286
287 let effective_reasoning_effort = selected_reasoning_effort
288 .unwrap_or(request.reasoning_effort)
289 .api_value_for_route(
290 effective_provider,
291 &turn_route.candidate.endpoint().base_url,
292 &turn_route.model,
293 )
294 .map(str::to_string);
295
296 let routing_source = if !request.auto_model {
297 TurnRoutingSource::ActiveFixedRoute
298 } else if auto_selection.is_some() {
299 TurnRoutingSource::AutoProviderClassifier
300 } else {
301 TurnRoutingSource::AutoLocalFallback
302 };
303
304 Ok(PlannedTurnRoute {
305 route: turn_route,
306 compaction: turn_compaction,
307 effective_provider,
308 effective_model,
309 effective_provider_identity,
310 effective_provider_label,
311 selected_reasoning_effort,
312 effective_reasoning_effort,
313 auto_controls_reasoning,
314 auto_selection,
315 initial_routed_usage,
316 routing_source,
317 })
318 }
319
320 #[cfg(test)]
321 mod tests {
322 use super::*;
323 use crate::config::DEFAULT_TEXT_MODEL;
324
325 fn deepseek_identity() -> ProviderIdentity {
326 crate::config::Config::default()
327 .builtin_provider_identity(ProviderKind::Deepseek)
328 .unwrap()
329 }
330
331 #[test]
332 fn failed_parent_route_settles_classifier_batch_once() {
333 let _cost_scope = crate::cost_status::test_scope();
334 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
335 None,
336 ProviderKind::Deepseek,
337 "deepseek",
338 "classifier-model",
339 Some(ProviderKind::Deepseek.provider().default_base_url()),
340 chrono::Utc::now(),
341 );
342 let batch = crate::cost_status::RuntimeUsageBatch {
343 decisions: Vec::new(),
344 records: vec![crate::cost_status::RuntimeUsageRecord {
345 source_id: "auto-router:plan-usage".to_string(),
346 usage: crate::cost_status::EffectiveRouteUsage {
347 route: route.clone(),
348 usage: codewhale_models::Usage {
349 input_tokens: 4,
350 output_tokens: 2,
351 ..Default::default()
352 },
353 },
354 }],
355 drop_records: vec![crate::cost_status::RuntimeUsageDropRecord {
356 reason: crate::cost_status::RuntimeUsageMissingReason::default(),
357 source_id: "auto-router:plan-drop".to_string(),
358 route,
359 }],
360 dropped_records: 1,
361 };
362
363 let scope = crate::cost_status::scope_token();
364 assert_eq!(
365 settle_failed_parent_route(scope, "route failed".to_string(), &batch),
366 "route failed"
367 );
368 settle_failed_parent_route(scope, "route failed".to_string(), &batch);
369 let pending = crate::cost_status::drain();
370 assert_eq!(
371 pending.usage_source_fingerprints.len(),
372 2,
373 "both exact classifier outcomes persist, and replay is idempotent"
374 );
375
376 // The classifier ran in one session; `/new` closed it before the
377 // route failed. Its cost belongs to the closed session, never the new.
378 let spent_in = crate::cost_status::scope_token();
379 let _closed = crate::cost_status::close_current_scope();
380 let batch = crate::cost_status::RuntimeUsageBatch {
381 decisions: Vec::new(),
382 records: batch
383 .records
384 .iter()
385 .cloned()
386 .map(|mut record| {
387 record.source_id = "auto-router:plan-usage-after-new".to_string();
388 record
389 })
390 .collect(),
391 drop_records: Vec::new(),
392 dropped_records: 0,
393 };
394 settle_failed_parent_route(spent_in, "route failed".to_string(), &batch);
395 assert!(
396 crate::cost_status::drain()
397 .usage_source_fingerprints
398 .is_empty(),
399 "classifier cost leaked into the session opened after it was spent"
400 );
401 }
402
403 #[test]
404 fn auto_model_route_selection_keeps_raw_reasoning_preference() {
405 assert_eq!(
406 reasoning_effort_for_route_selection(
407 true,
408 ProviderKind::OpenaiCodex,
409 ReasoningEffort::Off,
410 ),
411 "off"
412 );
413 assert_eq!(
414 reasoning_effort_for_route_selection(
415 false,
416 ProviderKind::OpenaiCodex,
417 ReasoningEffort::Off,
418 ),
419 "low"
420 );
421 }
422
423 #[tokio::test]
424 async fn auto_model_route_respects_fixed_reasoning_preference() {
425 let config = Config::default();
426 let identity = deepseek_identity();
427
428 let planned = plan_turn_route(TurnRoutePlanRequest {
429 route_config: &config,
430 app_route_identity: &identity,
431 api_provider: ProviderKind::Deepseek,
432 app_model: DEFAULT_TEXT_MODEL,
433 auto_model: true,
434 reasoning_effort: ReasoningEffort::Low,
435 mode: AppMode::Agent,
436 content: "explain this function",
437 auto_router_context: "",
438 should_auto_resolve: false,
439 allow_auto_router_response_cache: false,
440 preflight_required: false,
441 auto_compact_user_configured: false,
442 auto_compact: true,
443 auto_compact_threshold_percent: 80.0,
444 })
445 .await
446 .expect("plan auto-model turn");
447
448 assert_eq!(planned.routing_source, TurnRoutingSource::AutoLocalFallback);
449 assert!(!planned.auto_controls_reasoning);
450 assert_eq!(planned.selected_reasoning_effort, None);
451 // First-party DeepSeek routes carry low as the real wire tier
452 // (`reasoning_effort` low/high/max are documented); the App keeps the
453 // unresolved preference as Low either way.
454 assert_eq!(planned.effective_reasoning_effort.as_deref(), Some("low"));
455 }
456 }
457
457 lines RUST