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