Skip to main content

lean_ctx/proxy/
model_router.rs

1//! Intent-based model routing (P8 / DIM 3 — Leistungsstufe).
2//!
3//! Classifies each request's last user message via `crate::core::intent_engine`
4//! and resolves the resulting a model tier to a concrete routing target using
5//! `[proxy.routing.tiers]`. The forward path then rewrites the request body's
6//! `model` field (and optionally re-targets the upstream provider).
7//!
8//! **Fail-open by construction:** any classification miss, absent tier key, or
9//! empty target string leaves the request untouched. Premium work is never
10//! silently downgraded unless the operator explicitly configures a tier target.
11//!
12//! **Opt-in only:** requires `[proxy.routing] enabled = true` AND at least one
13//! tier entry. Without both, this module is a no-op.
14//!
15//! ## Interaction with other routing mechanisms
16//!
17//! - **Aliases** (exact model-name swap) run first — if the requested model
18//!   matches an alias, the aliased target is used and tier routing is skipped.
19//! - **Policy gate** (model ceiling, budgets) runs after routing — it sees the
20//!   *post-routing* model and can veto it.
21//! - **Effort routing** (thinking budget) is orthogonal — it adjusts the
22//!   `reasoning_effort` / `thinking` parameter, not the model identity.
23//!
24//! ## Cost-quality awareness
25//!
26//! When live model prices are available (loaded by the proxy at startup from
27//! `~/.config/lean-ctx/model-prices.json`), the router annotates its decision
28//! with cost savings estimates. This is observability only — the tier lookup
29//! is the authoritative routing decision, not a dynamic cost optimizer.
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::core::config::{RoutingRules, parse_route_target};
35use crate::core::intent_engine::{self, TaskClassification};
36
37/// A routing decision record — emitted for observability and future OCLA bus
38/// integration (P2). Deterministic: same input → same decision (#498).
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct RoutingDecision {
41    /// The model the client originally requested.
42    pub requested_model: String,
43    /// The model after routing (may be identical if no tier matched).
44    pub routed_model: String,
45    /// The provider the request is re-targeted to (None = keep upstream).
46    pub routed_provider: Option<String>,
47    /// The classified intent tier that drove the decision.
48    pub tier: String,
49    /// Classification confidence (0.0–1.0).
50    pub confidence: f64,
51    /// Why this tier was chosen (human-readable, deterministic).
52    pub reasoning: String,
53    /// Whether the model was actually changed.
54    pub model_changed: bool,
55    /// Estimated cost ratio (routed / original) when prices are known.
56    pub estimated_cost_ratio: Option<f64>,
57}
58
59/// Applies intent-based tier routing to a request body.
60///
61/// Returns `Some(decision)` when routing is active and classification
62/// succeeded. The caller must apply the model swap from the decision.
63/// Returns `None` when routing is inactive or the request is exempt.
64pub fn route(body: &Value, rules: &RoutingRules) -> Option<RoutingDecision> {
65    if !rules.is_active() || rules.tiers.is_empty() {
66        return None;
67    }
68
69    let requested_model = extract_model(body)?;
70
71    // Aliases take priority — if the model matches an alias, tier routing
72    // is skipped (the alias already resolved a specific target).
73    if rules.aliases.contains_key(&requested_model) {
74        return None;
75    }
76
77    let messages = body.get("messages")?;
78    let last_user_content = extract_last_user_content(messages)?;
79
80    let classification = intent_engine::classify(&last_user_content);
81    let route = intent_engine::route_intent(&last_user_content, &classification);
82
83    let tier_key = route.model_tier.as_str();
84    let target = rules.tiers.get(tier_key)?;
85
86    // Empty target = "keep the requested model for this tier".
87    if target.is_empty() {
88        return Some(RoutingDecision {
89            requested_model: requested_model.clone(),
90            routed_model: requested_model,
91            routed_provider: None,
92            tier: tier_key.to_string(),
93            confidence: route.confidence,
94            reasoning: route.reasoning,
95            model_changed: false,
96            estimated_cost_ratio: None,
97        });
98    }
99
100    let (provider, model) = parse_route_target(target)?;
101
102    let cost_ratio = estimate_cost_ratio(&requested_model, model);
103
104    Some(RoutingDecision {
105        requested_model: requested_model.clone(),
106        routed_model: model.to_string(),
107        routed_provider: provider.map(str::to_string),
108        tier: tier_key.to_string(),
109        confidence: route.confidence,
110        reasoning: route.reasoning,
111        model_changed: requested_model != model,
112        estimated_cost_ratio: cost_ratio,
113    })
114}
115
116/// Applies a routing decision to a mutable request body (in-place model swap).
117pub fn apply_decision(body: &mut Value, decision: &RoutingDecision) {
118    if !decision.model_changed {
119        return;
120    }
121    if let Some(obj) = body.as_object_mut() {
122        obj.insert(
123            "model".to_string(),
124            Value::String(decision.routed_model.clone()),
125        );
126    }
127}
128
129/// Classifies a request without applying routing — for dry-run / observability.
130pub fn classify_only(body: &Value) -> Option<(TaskClassification, intent_engine::IntentRoute)> {
131    let messages = body.get("messages")?;
132    let content = extract_last_user_content(messages)?;
133    let classification = intent_engine::classify(&content);
134    let route = intent_engine::route_intent(&content, &classification);
135    Some((classification, route))
136}
137
138// ─── Helpers ─────────────────────────────────────────────────────────────────
139
140fn extract_model(body: &Value) -> Option<String> {
141    body.get("model")
142        .and_then(Value::as_str)
143        .map(str::to_string)
144}
145
146/// Extracts the text content of the last user message from a messages array.
147fn extract_last_user_content(messages: &Value) -> Option<String> {
148    let arr = messages.as_array()?;
149    for msg in arr.iter().rev() {
150        let role = msg.get("role").and_then(Value::as_str)?;
151        if role != "user" {
152            continue;
153        }
154        // Content can be a string or an array of content blocks.
155        match msg.get("content") {
156            Some(Value::String(s)) => return Some(s.clone()),
157            Some(Value::Array(blocks)) => {
158                let text: String = blocks
159                    .iter()
160                    .filter_map(|b| {
161                        if b.get("type").and_then(Value::as_str) == Some("text") {
162                            b.get("text").and_then(Value::as_str)
163                        } else {
164                            None
165                        }
166                    })
167                    .collect::<Vec<_>>()
168                    .join("\n");
169                if !text.is_empty() {
170                    return Some(text);
171                }
172            }
173            _ => {}
174        }
175    }
176    None
177}
178
179/// Rough cost ratio estimate based on known model price tiers.
180/// Returns None when either model is unknown.
181fn estimate_cost_ratio(original: &str, routed: &str) -> Option<f64> {
182    let orig_cost = model_cost_tier(original)?;
183    let routed_cost = model_cost_tier(routed)?;
184    if orig_cost == 0.0 {
185        return None;
186    }
187    Some(routed_cost / orig_cost)
188}
189
190/// Relative cost tier for well-known models (normalized to Sonnet = 1.0).
191/// These are static approximations for estimation only — live prices from
192/// the model-prices.json file are used for actual billing.
193fn model_cost_tier(model: &str) -> Option<f64> {
194    let m = model.to_lowercase();
195    if m.contains("nano") || m.contains("gpt-4.1-nano") {
196        Some(0.04)
197    } else if m.contains("haiku")
198        || m.contains("flash")
199        || m.contains("4o-mini")
200        || m.contains("4.1-mini")
201        || m.contains("deepseek")
202    {
203        Some(0.2)
204    } else if m.contains("opus") || m.contains("o3-pro") || m.contains("o1-pro") {
205        Some(5.0)
206    } else if m.contains("o3") || m.contains("o1") || m.contains("gpt-5") {
207        Some(2.5)
208    } else if m.contains("sonnet")
209        || m.contains("gpt-4o")
210        || m.contains("gemini-2.5-pro")
211        || m.contains("gemini-2.0-pro")
212    {
213        Some(1.0)
214    } else {
215        None
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use serde_json::json;
223    use std::collections::BTreeMap;
224
225    fn active_rules(tiers: &[(&str, &str)]) -> RoutingRules {
226        RoutingRules {
227            enabled: Some(true),
228            aliases: BTreeMap::new(),
229            tiers: tiers
230                .iter()
231                .map(|(k, v)| (k.to_string(), v.to_string()))
232                .collect(),
233        }
234    }
235
236    fn request_body(model: &str, user_message: &str) -> Value {
237        json!({
238            "model": model,
239            "messages": [
240                {"role": "user", "content": user_message}
241            ]
242        })
243    }
244
245    // ─── Routing inactive / passthrough ──────────────────────────────────
246
247    #[test]
248    fn inactive_routing_returns_none() {
249        let rules = RoutingRules::default();
250        let body = request_body("claude-sonnet-4", "fix the bug");
251        assert!(route(&body, &rules).is_none());
252    }
253
254    #[test]
255    fn empty_tiers_returns_none() {
256        let rules = RoutingRules {
257            enabled: Some(true),
258            aliases: BTreeMap::new(),
259            tiers: BTreeMap::new(),
260        };
261        let body = request_body("claude-sonnet-4", "fix the bug");
262        assert!(route(&body, &rules).is_none());
263    }
264
265    #[test]
266    fn no_model_field_returns_none() {
267        let body = json!({"messages": [{"role": "user", "content": "hi"}]});
268        let rules = active_rules(&[("fast", "x")]);
269        assert!(route(&body, &rules).is_none());
270    }
271
272    #[test]
273    fn no_user_messages_returns_none() {
274        let body = json!({
275            "model": "claude-sonnet-4",
276            "messages": [{"role": "assistant", "content": "hello"}]
277        });
278        let rules = active_rules(&[("fast", "x")]);
279        assert!(route(&body, &rules).is_none());
280    }
281
282    #[test]
283    fn alias_takes_priority_over_tier() {
284        let mut rules = active_rules(&[("fast", "anthropic:claude-haiku-4-5")]);
285        rules
286            .aliases
287            .insert("my-model".to_string(), "openai:gpt-4o".to_string());
288        let body = request_body("my-model", "explain the code");
289        assert!(route(&body, &rules).is_none(), "alias exempts from tiers");
290    }
291
292    // ─── Tier routing ────────────────────────────────────────────────────
293
294    #[test]
295    fn fast_tier_downgrades_explore_queries() {
296        // "explain" + "how" → 2 Explore matches → confidence 0.85 → Fast
297        let rules = active_rules(&[("fast", "anthropic:claude-haiku-4-5")]);
298        let body = request_body("claude-sonnet-4", "explain how the cache works");
299        let decision = route(&body, &rules).expect("should route");
300
301        assert_eq!(decision.requested_model, "claude-sonnet-4");
302        assert_eq!(decision.routed_model, "claude-haiku-4-5");
303        assert_eq!(decision.routed_provider.as_deref(), Some("anthropic"));
304        assert_eq!(decision.tier, "fast");
305        assert!(decision.model_changed);
306        assert!(decision.confidence > 0.5);
307    }
308
309    #[test]
310    fn premium_tier_upgrades_generation_tasks() {
311        let rules = active_rules(&[("premium", "anthropic:claude-opus-4")]);
312        let body = request_body("claude-sonnet-4", "implement a new auth module with JWT");
313        let decision = route(&body, &rules).expect("should route");
314
315        assert_eq!(decision.routed_model, "claude-opus-4");
316        assert_eq!(decision.tier, "premium");
317        assert!(decision.model_changed);
318    }
319
320    #[test]
321    fn standard_tier_for_fixbug() {
322        // "fix" + "bug" → 2 FixBug matches → confidence 0.95 → Standard
323        let rules = active_rules(&[
324            ("fast", "claude-haiku-4-5"),
325            ("standard", "claude-sonnet-4"),
326            ("premium", "claude-opus-4"),
327        ]);
328        let body = request_body("claude-opus-4", "fix the bug in auth.rs");
329        let decision = route(&body, &rules).expect("should route");
330
331        assert_eq!(decision.tier, "standard");
332        assert_eq!(decision.routed_model, "claude-sonnet-4");
333    }
334
335    #[test]
336    fn missing_tier_key_is_passthrough() {
337        // Only "fast" configured — standard/premium queries pass through.
338        let rules = active_rules(&[("fast", "anthropic:claude-haiku-4-5")]);
339        let body = request_body("claude-sonnet-4", "fix the null pointer bug in auth.rs");
340        assert!(route(&body, &rules).is_none());
341    }
342
343    #[test]
344    fn empty_tier_target_keeps_model() {
345        // "explain" + "describe" → 2 Explore → Fast with confidence > 0.5
346        let rules = active_rules(&[("fast", "")]);
347        let body = request_body("claude-sonnet-4", "explain and describe this function");
348        let decision = route(&body, &rules).expect("should route");
349
350        assert_eq!(decision.routed_model, "claude-sonnet-4");
351        assert!(!decision.model_changed);
352        assert_eq!(decision.tier, "fast");
353    }
354
355    #[test]
356    fn model_only_target_keeps_provider() {
357        let rules = active_rules(&[("fast", "claude-haiku-4-5")]);
358        let body = request_body("claude-sonnet-4", "explain what this function does");
359        let decision = route(&body, &rules).expect("should route");
360
361        assert_eq!(decision.routed_model, "claude-haiku-4-5");
362        assert_eq!(decision.routed_provider, None, "no provider override");
363        assert!(decision.model_changed);
364    }
365
366    // ─── Decision application ────────────────────────────────────────────
367
368    #[test]
369    fn apply_decision_rewrites_body() {
370        let mut body = request_body("claude-sonnet-4", "explain");
371        let decision = RoutingDecision {
372            requested_model: "claude-sonnet-4".into(),
373            routed_model: "claude-haiku-4-5".into(),
374            routed_provider: Some("anthropic".into()),
375            tier: "fast".into(),
376            confidence: 0.85,
377            reasoning: "explore(what) + low complexity -> fast".into(),
378            model_changed: true,
379            estimated_cost_ratio: Some(0.2),
380        };
381        apply_decision(&mut body, &decision);
382        assert_eq!(body["model"], "claude-haiku-4-5");
383    }
384
385    #[test]
386    fn apply_decision_noop_when_unchanged() {
387        let mut body = request_body("claude-sonnet-4", "explain");
388        let decision = RoutingDecision {
389            requested_model: "claude-sonnet-4".into(),
390            routed_model: "claude-sonnet-4".into(),
391            routed_provider: None,
392            tier: "standard".into(),
393            confidence: 0.8,
394            reasoning: "fix_bug(how) -> standard".into(),
395            model_changed: false,
396            estimated_cost_ratio: None,
397        };
398        apply_decision(&mut body, &decision);
399        assert_eq!(body["model"], "claude-sonnet-4");
400    }
401
402    // ─── Cost estimation ─────────────────────────────────────────────────
403
404    #[test]
405    fn cost_ratio_estimates_downgrade_savings() {
406        let rules = active_rules(&[("fast", "claude-haiku-4-5")]);
407        let body = request_body("claude-sonnet-4", "explain what this module does");
408        let decision = route(&body, &rules).expect("should route");
409        let ratio = decision.estimated_cost_ratio.expect("known models");
410        assert!(ratio < 1.0, "haiku cheaper than sonnet: {ratio}");
411        assert!(ratio > 0.0);
412    }
413
414    #[test]
415    fn cost_ratio_none_for_unknown_models() {
416        let rules = active_rules(&[("fast", "custom-local-model")]);
417        let body = request_body("claude-sonnet-4", "explain what this code does");
418        let decision = route(&body, &rules).expect("should route");
419        assert_eq!(decision.estimated_cost_ratio, None);
420    }
421
422    #[test]
423    fn cost_tiers_are_ordered() {
424        assert!(
425            model_cost_tier("claude-opus-4").unwrap() > model_cost_tier("claude-sonnet-4").unwrap()
426        );
427        assert!(
428            model_cost_tier("claude-sonnet-4").unwrap()
429                > model_cost_tier("claude-haiku-4-5").unwrap()
430        );
431        assert!(model_cost_tier("gpt-4o").unwrap() > model_cost_tier("gpt-4o-mini").unwrap());
432    }
433
434    // ─── Content extraction ──────────────────────────────────────────────
435
436    #[test]
437    fn multipart_content_blocks_extracted() {
438        let body = json!({
439            "model": "claude-sonnet-4",
440            "messages": [{
441                "role": "user",
442                "content": [
443                    {"type": "text", "text": "explain how the session cache works"},
444                    {"type": "image", "source": {"type": "base64"}}
445                ]
446            }]
447        });
448        let rules = active_rules(&[("fast", "claude-haiku-4-5")]);
449        let decision = route(&body, &rules).expect("should extract text blocks");
450        assert_eq!(decision.tier, "fast");
451    }
452
453    // ─── Determinism ─────────────────────────────────────────────────────
454
455    #[test]
456    fn decision_is_deterministic() {
457        let rules = active_rules(&[
458            ("fast", "claude-haiku-4-5"),
459            ("standard", "claude-sonnet-4"),
460            ("premium", "claude-opus-4"),
461        ]);
462        let body = request_body("claude-sonnet-4", "explain how the proxy routing works");
463        let d1 = route(&body, &rules);
464        let d2 = route(&body, &rules);
465        assert_eq!(d1, d2, "routing must be deterministic (#498)");
466    }
467}