1use serde::Serialize;
2
3use crate::core::budget_tracker::{BudgetLevel, BudgetSnapshot};
4use crate::core::context_ledger::PressureAction;
5use crate::core::intent_engine::{classify, route_intent, IntentDimension, ModelTier, TaskType};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct IntentRouteV1 {
9 pub schema_version: u32,
10 pub created_at: String,
11 pub inputs: IntentRouteInputsV1,
12 pub decision: IntentRouteDecisionV1,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct IntentRouteInputsV1 {
17 pub query_md5: String,
18 pub query_redacted: String,
19 pub role: String,
20 pub profile: String,
21 pub task_type: TaskType,
22 pub confidence: f64,
23 pub dimension: IntentDimension,
24 pub budgets: BudgetSnapshot,
25 pub pressure: PressureSummaryV1,
26 pub policy_md5: String,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct PressureSummaryV1 {
31 pub utilization_pct: u8,
32 pub remaining_tokens: usize,
33 pub action: PressureActionV1,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "snake_case")]
38pub enum PressureActionV1 {
39 NoAction,
40 SuggestCompression,
41 ForceCompression,
42 EvictLeastRelevant,
43}
44
45impl PressureActionV1 {
46 fn from_action(a: PressureAction) -> Self {
47 match a {
48 PressureAction::NoAction => Self::NoAction,
49 PressureAction::SuggestCompression => Self::SuggestCompression,
50 PressureAction::ForceCompression => Self::ForceCompression,
51 PressureAction::EvictLeastRelevant => Self::EvictLeastRelevant,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct IntentRouteDecisionV1 {
58 pub recommended_model_tier: ModelTier,
59 pub effective_model_tier: ModelTier,
60 pub recommended_read_mode: String,
61 pub effective_read_mode: String,
62 pub degraded_by_budget: bool,
63 pub degraded_by_pressure: bool,
64 pub reason: String,
65}
66
67#[derive(Debug, Clone)]
68pub struct RouteInputs {
69 pub tokens_level: BudgetLevel,
70 pub cost_level: BudgetLevel,
71 pub pressure_action: PressureAction,
72 pub pressure_utilization: f64,
73 pub pressure_remaining_tokens: usize,
74}
75
76pub fn route_v1(query: &str) -> IntentRouteV1 {
77 let budgets = crate::core::budget_tracker::BudgetTracker::global().check();
78 let ledger = crate::core::context_ledger::ContextLedger::load();
79 let pressure = ledger.pressure();
80 let profile_name = crate::core::profiles::active_profile_name();
81 let profile = crate::core::profiles::active_profile();
82 let role_name = crate::core::roles::active_role_name();
83
84 let inputs = RouteInputs {
85 tokens_level: budgets.tokens.level.clone(),
86 cost_level: budgets.cost.level.clone(),
87 pressure_action: pressure.recommendation,
88 pressure_utilization: pressure.utilization,
89 pressure_remaining_tokens: pressure.remaining_tokens,
90 };
91
92 route_v1_with(
93 query,
94 &role_name,
95 &profile_name,
96 &profile.routing,
97 budgets,
98 &inputs,
99 None,
100 )
101}
102
103pub fn route_v1_with(
104 query: &str,
105 role_name: &str,
106 profile_name: &str,
107 routing: &crate::core::profiles::RoutingConfig,
108 budgets: BudgetSnapshot,
109 inputs: &RouteInputs,
110 created_at_override: Option<&str>,
111) -> IntentRouteV1 {
112 let created_at = created_at_override.map_or_else(
113 || chrono::Utc::now().to_rfc3339(),
114 std::string::ToString::to_string,
115 );
116
117 let classification = classify(query);
118 let base = route_intent(query, &classification);
119
120 let query_redacted = truncate(&crate::core::redaction::redact_text(query), 180);
121 let query_md5 = md5_hex(query);
122
123 let policy_md5 = md5_hex(&format!(
124 "max_model_tier={};degrade_under_pressure={}",
125 routing.max_model_tier_effective(),
126 routing.degrade_under_pressure_effective()
127 ));
128
129 let recommended_model_tier = base.model_tier;
130 let tokens_level = inputs.tokens_level.clone();
131 let cost_level = inputs.cost_level.clone();
132 let (effective_model_tier, degraded_by_budget) = apply_budget_caps(
133 recommended_model_tier,
134 tokens_level.clone(),
135 cost_level.clone(),
136 routing,
137 );
138
139 let recommended_read_mode =
140 read_mode_for_tier(recommended_model_tier, classification.task_type);
141 let (effective_read_mode, degraded_by_pressure) = if routing.degrade_under_pressure_effective()
142 {
143 apply_pressure_degrade(&recommended_read_mode, inputs.pressure_action)
144 } else {
145 (recommended_read_mode.clone(), false)
146 };
147
148 let reason = build_reason(&ReasonInputs {
149 task_type: classification.task_type,
150 dimension: base.dimension,
151 recommended_tier: recommended_model_tier,
152 effective_tier: effective_model_tier,
153 read_mode: effective_read_mode.clone(),
154 degraded_by_budget,
155 degraded_by_pressure,
156 tokens_level,
157 cost_level,
158 pressure: inputs.pressure_action,
159 });
160
161 IntentRouteV1 {
162 schema_version: crate::core::contracts::INTENT_ROUTE_V1_SCHEMA_VERSION,
163 created_at,
164 inputs: IntentRouteInputsV1 {
165 query_md5,
166 query_redacted,
167 role: role_name.to_string(),
168 profile: profile_name.to_string(),
169 task_type: classification.task_type,
170 confidence: classification.confidence,
171 dimension: base.dimension,
172 budgets,
173 pressure: PressureSummaryV1 {
174 utilization_pct: (inputs.pressure_utilization * 100.0).min(254.0) as u8,
175 remaining_tokens: inputs.pressure_remaining_tokens,
176 action: PressureActionV1::from_action(inputs.pressure_action),
177 },
178 policy_md5,
179 },
180 decision: IntentRouteDecisionV1 {
181 recommended_model_tier,
182 effective_model_tier,
183 recommended_read_mode,
184 effective_read_mode,
185 degraded_by_budget,
186 degraded_by_pressure,
187 reason,
188 },
189 }
190}
191
192fn apply_budget_caps(
193 tier: ModelTier,
194 tokens_level: BudgetLevel,
195 cost_level: BudgetLevel,
196 routing: &crate::core::profiles::RoutingConfig,
197) -> (ModelTier, bool) {
198 let mut out = tier;
199 let mut degraded = false;
200
201 out = cap_to(out, parse_tier_cap(routing.max_model_tier_effective()));
203 if out != tier {
204 degraded = true;
205 }
206
207 let max_budget = worst_budget_level(tokens_level, cost_level);
209 out = match max_budget {
210 BudgetLevel::Ok => out,
211 BudgetLevel::Warning => cap_to(out, ModelTier::Standard),
212 BudgetLevel::Exhausted => cap_to(out, ModelTier::Fast),
213 };
214 if out != tier {
215 degraded = true;
216 }
217
218 (out, degraded)
219}
220
221fn worst_budget_level(a: BudgetLevel, b: BudgetLevel) -> BudgetLevel {
222 match (a, b) {
223 (BudgetLevel::Exhausted, _) | (_, BudgetLevel::Exhausted) => BudgetLevel::Exhausted,
224 (BudgetLevel::Warning, _) | (_, BudgetLevel::Warning) => BudgetLevel::Warning,
225 _ => BudgetLevel::Ok,
226 }
227}
228
229fn parse_tier_cap(s: &str) -> ModelTier {
230 match s.trim().to_lowercase().as_str() {
231 "fast" => ModelTier::Fast,
232 "standard" => ModelTier::Standard,
233 _ => ModelTier::Premium,
234 }
235}
236
237fn cap_to(tier: ModelTier, cap: ModelTier) -> ModelTier {
238 match cap {
239 ModelTier::Fast => ModelTier::Fast,
240 ModelTier::Standard => match tier {
241 ModelTier::Premium => ModelTier::Standard,
242 _ => tier,
243 },
244 ModelTier::Premium => tier,
245 }
246}
247
248pub fn read_mode_for_tier(tier: ModelTier, task_type: TaskType) -> String {
249 match (tier, task_type) {
250 (ModelTier::Fast, _) => "signatures".to_string(),
251 (ModelTier::Standard, TaskType::Explore | TaskType::Review) => "map".to_string(),
252 (ModelTier::Standard, _) => "full".to_string(),
253 (ModelTier::Premium, _) => "auto".to_string(),
254 }
255}
256
257fn apply_pressure_degrade(mode: &str, pressure: PressureAction) -> (String, bool) {
258 let out = match pressure {
259 PressureAction::NoAction => mode.to_string(),
260 PressureAction::SuggestCompression => match mode {
261 "full" => "map".to_string(),
262 "auto" => "full".to_string(),
263 other => other.to_string(),
264 },
265 PressureAction::ForceCompression => "signatures".to_string(),
266 PressureAction::EvictLeastRelevant => "reference".to_string(),
267 };
268 (out.clone(), out != mode)
269}
270
271struct ReasonInputs {
272 task_type: TaskType,
273 dimension: IntentDimension,
274 recommended_tier: ModelTier,
275 effective_tier: ModelTier,
276 read_mode: String,
277 degraded_by_budget: bool,
278 degraded_by_pressure: bool,
279 tokens_level: BudgetLevel,
280 cost_level: BudgetLevel,
281 pressure: PressureAction,
282}
283
284fn build_reason(i: &ReasonInputs) -> String {
285 format!(
286 "task={} dim={} tier={}→{} read={} budget={} pressure={:?} degrade(budget={},pressure={})",
287 i.task_type.as_str(),
288 i.dimension.as_str(),
289 i.recommended_tier.as_str(),
290 i.effective_tier.as_str(),
291 i.read_mode,
292 worst_budget_level(i.tokens_level.clone(), i.cost_level.clone()),
293 i.pressure,
294 i.degraded_by_budget,
295 i.degraded_by_pressure
296 )
297}
298
299fn truncate(s: &str, max: usize) -> String {
300 if s.chars().count() <= max {
301 return s.to_string();
302 }
303 s.chars().take(max).collect()
304}
305
306fn md5_hex(s: &str) -> String {
307 use md5::{Digest, Md5};
308 let mut hasher = Md5::new();
309 hasher.update(s.as_bytes());
310 format!("{:x}", hasher.finalize())
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::core::budget_tracker::{BudgetLevel, CostStatus, DimensionStatus};
317
318 fn budget(level: BudgetLevel) -> BudgetSnapshot {
319 BudgetSnapshot {
320 role: "coder".to_string(),
321 tokens: DimensionStatus {
322 used: 1,
323 limit: 1,
324 percent: 100,
325 level: level.clone(),
326 },
327 shell: DimensionStatus {
328 used: 0,
329 limit: 0,
330 percent: 0,
331 level: BudgetLevel::Ok,
332 },
333 cost: CostStatus {
334 used_usd: 0.0,
335 limit_usd: 0.0,
336 percent: 0,
337 level,
338 },
339 }
340 }
341
342 #[test]
343 fn routing_is_deterministic_for_same_inputs() {
344 let r = crate::core::profiles::RoutingConfig::default();
345 let b = budget(BudgetLevel::Ok);
346 let inputs = RouteInputs {
347 tokens_level: BudgetLevel::Ok,
348 cost_level: BudgetLevel::Ok,
349 pressure_action: PressureAction::NoAction,
350 pressure_utilization: 0.1,
351 pressure_remaining_tokens: 1000,
352 };
353 let a = route_v1_with(
354 "fix bug in src/lib.rs",
355 "coder",
356 "bugfix",
357 &r,
358 b.clone(),
359 &inputs,
360 Some("2026-01-01T00:00:00Z"),
361 );
362 let b2 = route_v1_with(
363 "fix bug in src/lib.rs",
364 "coder",
365 "bugfix",
366 &r,
367 b,
368 &inputs,
369 Some("2026-01-01T00:00:00Z"),
370 );
371 assert_eq!(
372 serde_json::to_string(&a).unwrap(),
373 serde_json::to_string(&b2).unwrap()
374 );
375 }
376
377 #[test]
378 fn budget_caps_premium_to_fast_when_exhausted() {
379 let routing = crate::core::profiles::RoutingConfig {
380 max_model_tier: Some("premium".to_string()),
381 ..Default::default()
382 };
383 let b = budget(BudgetLevel::Exhausted);
384 let inputs = RouteInputs {
385 tokens_level: BudgetLevel::Exhausted,
386 cost_level: BudgetLevel::Ok,
387 pressure_action: PressureAction::NoAction,
388 pressure_utilization: 0.1,
389 pressure_remaining_tokens: 1000,
390 };
391 let r = route_v1_with(
392 "implement feature x",
393 "coder",
394 "exploration",
395 &routing,
396 b,
397 &inputs,
398 Some("2026-01-01T00:00:00Z"),
399 );
400 assert_eq!(r.decision.effective_model_tier, ModelTier::Fast);
401 assert!(r.decision.degraded_by_budget);
402 }
403
404 #[test]
405 fn pressure_forces_reference_mode() {
406 let routing = crate::core::profiles::RoutingConfig::default();
407 let b = budget(BudgetLevel::Ok);
408 let inputs = RouteInputs {
409 tokens_level: BudgetLevel::Ok,
410 cost_level: BudgetLevel::Ok,
411 pressure_action: PressureAction::EvictLeastRelevant,
412 pressure_utilization: 0.95,
413 pressure_remaining_tokens: 100,
414 };
415 let r = route_v1_with(
416 "review the auth module",
417 "coder",
418 "review",
419 &routing,
420 b,
421 &inputs,
422 Some("2026-01-01T00:00:00Z"),
423 );
424 assert_eq!(r.decision.effective_read_mode, "reference");
425 assert!(r.decision.degraded_by_pressure);
426 }
427}