返回 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::route_runtime::{
23 ResolvedRuntimeRoute, resolve_runtime_route, resolve_runtime_route_for_identity,
24 };
25 use crate::tui::app::{AppMode, ReasoningEffort};
26
27 /// Everything the shared turn-route planner needs.
28 ///
29 /// Borrowed rather than owned so the dispatch path can pass its already
30 /// captured `UserDispatchPrepare` fields and `/preview-request` can pass a
31 /// hypothetical prompt, without either one duplicating the other's logic.
32 pub(crate) struct TurnRoutePlanRequest<'a> {
33 pub(crate) route_config: &'a Config,
34 pub(crate) app_route_identity: &'a ProviderIdentity,
35 pub(crate) api_provider: ApiProvider,
36 pub(crate) app_model: &'a str,
37 pub(crate) auto_model: bool,
38 pub(crate) reasoning_effort: ReasoningEffort,
39 pub(crate) mode: AppMode,
40 /// Model-facing content of the next user message (file mentions and skill
41 /// wrapping already resolved). This is what the auto router classifies.
42 pub(crate) content: &'a str,
43 /// The user's display text, used by the heuristic and auto-reasoning
44 /// fallbacks exactly as production does.
45 pub(crate) display_text: &'a str,
46 pub(crate) auto_router_context: &'a str,
47 pub(crate) should_auto_resolve: bool,
48 /// Production dispatch may use the deterministic response cache for the
49 /// auxiliary Auto classifier. Read-only previews must set this to false.
50 pub(crate) allow_auto_router_response_cache: bool,
51 pub(crate) preflight_required: bool,
52 pub(crate) auto_compact_user_configured: bool,
53 pub(crate) auto_compact: bool,
54 pub(crate) auto_compact_threshold_percent: f64,
55 }
56
57 /// The exact route, limits, compaction policy, and reasoning normalization one
58 /// turn would use.
59 pub(crate) struct PlannedTurnRoute {
60 pub(crate) route: ResolvedRuntimeRoute,
61 pub(crate) compaction: CompactionConfig,
62 pub(crate) effective_provider: ApiProvider,
63 pub(crate) effective_model: String,
64 pub(crate) effective_provider_identity: String,
65 pub(crate) effective_provider_label: String,
66 pub(crate) selected_reasoning_effort: Option<ReasoningEffort>,
67 /// Normalized api value for the resolved route — the string that reaches
68 /// the wire.
69 pub(crate) effective_reasoning_effort: Option<String>,
70 pub(crate) auto_controls_reasoning: bool,
71 pub(crate) auto_selection: Option<crate::model_routing::AutoRouteSelection>,
72 /// Why this concrete route was selected. This is captured by the planner,
73 /// not inferred later from the resulting provider/model pair.
74 pub(crate) routing_source: TurnRoutingSource,
75 }
76
77 /// Durable provenance for the route selected for one turn.
78 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
79 pub(crate) enum TurnRoutingSource {
80 /// The active fixed route was used unchanged. This intentionally does not
81 /// guess whether an earlier UI action or persisted config installed it.
82 ActiveFixedRoute,
83 /// Auto model routing used its provider-backed classifier.
84 AutoProviderClassifier,
85 /// Auto model routing used the local deterministic fallback heuristic.
86 AutoLocalHeuristic,
87 }
88
89 impl TurnRoutingSource {
90 pub(crate) const fn label(self) -> &'static str {
91 match self {
92 Self::ActiveFixedRoute => "active-fixed-route",
93 Self::AutoProviderClassifier => "auto-provider-classifier",
94 Self::AutoLocalHeuristic => "auto-local-heuristic",
95 }
96 }
97 }
98
99 fn reasoning_effort_for_route_selection(
100 auto_model: bool,
101 provider: ApiProvider,
102 effort: ReasoningEffort,
103 ) -> &'static str {
104 if auto_model {
105 effort.as_setting()
106 } else {
107 effort.as_setting_for_provider(provider)
108 }
109 }
110
111 /// Resolve the route for one turn.
112 ///
113 /// This is *the* route planner (#1004). `spawned_dispatch_inner` calls it to
114 /// send a turn; `/preview-request` calls it with a hypothetical prompt to
115 /// describe one. Because there is a single implementation, a preview cannot
116 /// report a provider, model, route identity, client, reasoning tier, limit,
117 /// tool budget, billing basis, or endpoint different from the one dispatch
118 /// would pick for the same prompt.
119 ///
120 /// It mutates no engine or session state: it reads config, resolves a route,
121 /// and returns a value. Its one outbound call is the auto-router classifier,
122 /// which is the same auxiliary call a real turn makes and only runs when auto
123 /// model routing is on. The caller chooses whether that auxiliary call may
124 /// touch the process-global deterministic response cache.
125 pub(crate) async fn plan_turn_route(
126 request: TurnRoutePlanRequest<'_>,
127 ) -> Result<PlannedTurnRoute, String> {
128 let auto_selection = if request.should_auto_resolve {
129 Some(
130 crate::model_routing::resolve_auto_route_with_inventory_for_session_and_cache_policy(
131 request.route_config,
132 request.content,
133 request.auto_router_context,
134 request.mode.as_setting(),
135 if request.auto_model { "auto" } else { "fixed" },
136 reasoning_effort_for_route_selection(
137 request.auto_model,
138 request.api_provider,
139 request.reasoning_effort,
140 ),
141 request.allow_auto_router_response_cache,
142 )
143 .await
144 .map_err(|err| err.to_string())?,
145 )
146 } else {
147 None
148 };
149
150 let effective_provider = auto_selection
151 .as_ref()
152 .map(|selection| selection.provider)
153 .unwrap_or(request.api_provider);
154
155 let effective_model = if request.auto_model {
156 auto_selection
157 .as_ref()
158 .map(|selection| selection.model.clone())
159 .unwrap_or_else(|| {
160 crate::model_routing::auto_model_heuristic(request.display_text, request.app_model)
161 })
162 } else {
163 request.app_model.to_string()
164 };
165
166 let turn_route = if effective_provider == request.app_route_identity.provider {
167 resolve_runtime_route_for_identity(
168 request.route_config,
169 request.app_route_identity,
170 Some(&effective_model),
171 )
172 } else {
173 resolve_runtime_route(
174 request.route_config,
175 effective_provider,
176 Some(&effective_model),
177 )
178 };
179
180 let turn_route = turn_route.map_err(|err| err.to_string())?;
181 let turn_route = if request.preflight_required {
182 turn_route.preflight()?
183 } else {
184 turn_route
185 };
186
187 let turn_route_limits = crate::route_budget::known_route_limits(turn_route.candidate.limits());
188 let effective_provider_identity = turn_route.identity.key.clone();
189 let effective_provider_label = if effective_provider == ApiProvider::Custom {
190 effective_provider_identity.clone()
191 } else {
192 effective_provider.display_name().to_string()
193 };
194
195 let turn_compaction = CompactionConfig {
196 enabled: if request.auto_compact_user_configured {
197 request.auto_compact
198 } else {
199 crate::route_budget::auto_compact_default_for_route(
200 turn_route.identity.provider,
201 &turn_route.model,
202 turn_route_limits,
203 )
204 },
205 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
206 turn_route.identity.provider,
207 &turn_route.model,
208 turn_route_limits,
209 request.auto_compact_threshold_percent,
210 ),
211 model: turn_route.model.clone(),
212 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
213 turn_route.identity.provider,
214 &turn_route.model,
215 turn_route_limits,
216 )),
217 ..Default::default()
218 };
219
220 // Model selection and reasoning selection are independent. A fixed
221 // reasoning preference survives auto model routing and is normalized
222 // against the concrete route below; only an explicit `auto` delegates the
223 // tier to the classifier/heuristic.
224 let auto_controls_reasoning = request.reasoning_effort == ReasoningEffort::Auto;
225 let selected_reasoning_effort = if auto_controls_reasoning {
226 Some(
227 auto_selection
228 .as_ref()
229 .and_then(|selection| selection.reasoning_effort)
230 .unwrap_or_else(|| crate::auto_reasoning::select(false, request.display_text)),
231 )
232 } else {
233 None
234 };
235
236 let effective_reasoning_effort = selected_reasoning_effort
237 .unwrap_or(request.reasoning_effort)
238 .api_value_for_route(
239 effective_provider,
240 &turn_route.candidate.endpoint().base_url,
241 &turn_route.model,
242 )
243 .map(str::to_string);
244
245 let routing_source = if !request.auto_model {
246 TurnRoutingSource::ActiveFixedRoute
247 } else if auto_selection.is_some() {
248 TurnRoutingSource::AutoProviderClassifier
249 } else {
250 TurnRoutingSource::AutoLocalHeuristic
251 };
252
253 Ok(PlannedTurnRoute {
254 route: turn_route,
255 compaction: turn_compaction,
256 effective_provider,
257 effective_model,
258 effective_provider_identity,
259 effective_provider_label,
260 selected_reasoning_effort,
261 effective_reasoning_effort,
262 auto_controls_reasoning,
263 auto_selection,
264 routing_source,
265 })
266 }
267
268 #[cfg(test)]
269 mod tests {
270 use super::*;
271 use crate::config::DEFAULT_TEXT_MODEL;
272
273 fn deepseek_identity() -> ProviderIdentity {
274 ProviderIdentity {
275 provider: ApiProvider::Deepseek,
276 key: ApiProvider::Deepseek.as_str().to_string(),
277 exact_id: None,
278 }
279 }
280
281 #[test]
282 fn auto_model_route_selection_keeps_raw_reasoning_preference() {
283 assert_eq!(
284 reasoning_effort_for_route_selection(
285 true,
286 ApiProvider::OpenaiCodex,
287 ReasoningEffort::Off,
288 ),
289 "off"
290 );
291 assert_eq!(
292 reasoning_effort_for_route_selection(
293 false,
294 ApiProvider::OpenaiCodex,
295 ReasoningEffort::Off,
296 ),
297 "low"
298 );
299 }
300
301 #[tokio::test]
302 async fn auto_model_route_respects_fixed_reasoning_preference() {
303 let config = Config::default();
304 let identity = deepseek_identity();
305
306 let planned = plan_turn_route(TurnRoutePlanRequest {
307 route_config: &config,
308 app_route_identity: &identity,
309 api_provider: ApiProvider::Deepseek,
310 app_model: DEFAULT_TEXT_MODEL,
311 auto_model: true,
312 reasoning_effort: ReasoningEffort::Low,
313 mode: AppMode::Agent,
314 content: "explain this function",
315 display_text: "explain this function",
316 auto_router_context: "",
317 should_auto_resolve: false,
318 allow_auto_router_response_cache: false,
319 preflight_required: false,
320 auto_compact_user_configured: false,
321 auto_compact: true,
322 auto_compact_threshold_percent: 80.0,
323 })
324 .await
325 .expect("plan auto-model turn");
326
327 assert_eq!(
328 planned.routing_source,
329 TurnRoutingSource::AutoLocalHeuristic
330 );
331 assert!(!planned.auto_controls_reasoning);
332 assert_eq!(planned.selected_reasoning_effort, None);
333 // First-party DeepSeek routes carry low as the real wire tier
334 // (`reasoning_effort` low/high/max are documented); the App keeps the
335 // unresolved preference as Low either way.
336 assert_eq!(planned.effective_reasoning_effort.as_deref(), Some("low"));
337 }
338 }
339
339 lines RUST