Skip to main content

lean_ctx/core/ocla/builtin/
model_router.rs

1//! BuiltinModelRouter — intent-aware model routing via OCLA trait.
2//!
3//! Wraps `proxy/model_router.rs` and `proxy/effort_routing.rs` behind the
4//! canonical trait. Emits ModelRouted events. Routes to the best candidate
5//! model within the cost/latency constraints.
6
7use crate::core::config::{Config, Effort, RoutingRules, parse_route_target};
8use crate::core::ocla::traits::{ModelRouter, OclaService};
9use crate::core::ocla::types::{
10    IntentDecision, ModelRouteRequest, OclaCapability, OclaCapabilityKind, OclaError, OclaResult,
11    RoutingDecision,
12};
13use crate::core::ocla_bus::{self, OclaEvent};
14use crate::core::savings_ledger::store::{self, MechanismSummary};
15use serde::Deserialize;
16use serde_json::json;
17
18/// Policy Enforcement Point wrapping the model router.
19/// Validates routing decisions against policy constraints.
20#[derive(Debug)]
21pub struct PolicyEnforcementPoint {
22    pub max_cost_micros: Option<u64>,
23    pub model_allowlist: Option<Vec<String>>,
24    pub model_denylist: Vec<String>,
25    pub require_reasoning_budget: bool,
26}
27
28impl PolicyEnforcementPoint {
29    #[must_use]
30    pub fn permissive() -> Self {
31        Self {
32            max_cost_micros: None,
33            model_allowlist: None,
34            model_denylist: Vec::new(),
35            require_reasoning_budget: false,
36        }
37    }
38
39    #[must_use]
40    pub fn from_config() -> Self {
41        let Some(path) = dirs::config_dir().map(|path| path.join("lean-ctx/router-policy.toml"))
42        else {
43            return Self::permissive();
44        };
45        let Ok(contents) = std::fs::read_to_string(path) else {
46            return Self::permissive();
47        };
48        let Ok(config) = toml::from_str::<PolicyEnforcementConfig>(&contents) else {
49            return Self::permissive();
50        };
51
52        Self {
53            max_cost_micros: config.max_cost_micros,
54            model_allowlist: config.model_allowlist,
55            model_denylist: config.model_denylist,
56            require_reasoning_budget: config.require_reasoning_budget,
57        }
58    }
59
60    /// Validates a routing decision against configured router policy.
61    pub fn enforce(
62        &self,
63        decision: &RoutingDecision,
64        request: &ModelRouteRequest,
65    ) -> Result<(), String> {
66        if self
67            .model_denylist
68            .iter()
69            .any(|model| model == &decision.model)
70        {
71            return Err(format!(
72                "model '{}' is denied by router policy",
73                decision.model
74            ));
75        }
76        if let Some(allowlist) = &self.model_allowlist
77            && !allowlist.iter().any(|model| model == &decision.model)
78        {
79            return Err(format!(
80                "model '{}' is not allowed by router policy",
81                decision.model
82            ));
83        }
84        if let (Some(policy_maximum), Some(request_maximum)) =
85            (self.max_cost_micros, request.maximum_cost_micros)
86            && request_maximum > policy_maximum
87        {
88            return Err(format!(
89                "requested maximum cost {request_maximum} exceeds router policy maximum {policy_maximum}"
90            ));
91        }
92        if self.require_reasoning_budget && decision.reasoning_budget_tokens == 0 {
93            return Err("router policy requires a reasoning budget".to_string());
94        }
95
96        Ok(())
97    }
98}
99
100#[derive(Default, Deserialize)]
101struct PolicyEnforcementConfig {
102    max_cost_micros: Option<u64>,
103    model_allowlist: Option<Vec<String>>,
104    #[serde(default)]
105    model_denylist: Vec<String>,
106    #[serde(default)]
107    require_reasoning_budget: bool,
108}
109
110pub struct BuiltinModelRouter {
111    rules: RoutingRules,
112    pep: PolicyEnforcementPoint,
113}
114
115impl BuiltinModelRouter {
116    pub fn new() -> Self {
117        let mut router = Self::with_rules(Config::load().proxy.routing);
118        router.pep = PolicyEnforcementPoint::from_config();
119        router
120    }
121
122    pub(crate) fn with_rules(rules: RoutingRules) -> Self {
123        Self {
124            rules,
125            pep: PolicyEnforcementPoint::permissive(),
126        }
127    }
128}
129
130impl Default for BuiltinModelRouter {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl OclaService for BuiltinModelRouter {
137    fn capability(&self) -> OclaCapability {
138        OclaCapability::available(OclaCapabilityKind::ModelRouter)
139    }
140}
141
142/// Routing result with deterministic rationale for OCLA observability.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct RoutingDecisionWithRationale {
145    pub decision: RoutingDecision,
146    pub routing_rationale: String,
147}
148
149impl ModelRouter for BuiltinModelRouter {
150    fn route_model(&self, request: ModelRouteRequest) -> OclaResult<RoutingDecision> {
151        let result = self.route_model_with_intent(&request, None)?;
152        if let Err(reason) = self.pep.enforce(&result.decision, &request) {
153            ocla_bus::emit(OclaEvent::AgentChainEvent {
154                agent_id: request.context.agent_id.clone(),
155                action: format!("model_route_denied: {reason}"),
156                parent_agent: None,
157            });
158            return Err(OclaError::InvalidRequest(format!(
159                "routing policy denied: {reason}"
160            )));
161        }
162
163        Ok(result.decision)
164    }
165}
166
167impl BuiltinModelRouter {
168    /// Routes using a classifier decision when available, while preserving the
169    /// proxy router as the fallback for absent or low-confidence decisions.
170    pub fn route_model_with_intent(
171        &self,
172        request: &ModelRouteRequest,
173        intent: Option<&IntentDecision>,
174    ) -> OclaResult<RoutingDecisionWithRationale> {
175        let requested_model = request
176            .candidate_models
177            .first()
178            .cloned()
179            .unwrap_or_else(|| "default".to_string());
180        let body = json!({
181            "model": requested_model.clone(),
182            "messages": [{"role": "user", "content": request.context.content_ref}]
183        });
184        let routed = intent
185            .as_ref()
186            .and_then(|decision| route_for_intent(&requested_model, decision, &self.rules))
187            .or_else(|| crate::proxy::model_router::route(&body, &self.rules));
188        let ledger = routing_ledger_summary();
189        let (model, provider, tier, model_changed) = routed.map_or_else(
190            || {
191                (
192                    requested_model.clone(),
193                    infer_provider(&requested_model),
194                    "standard".to_string(),
195                    false,
196                )
197            },
198            |decision| {
199                let model = decision.routed_model;
200                let provider = decision
201                    .routed_provider
202                    .unwrap_or_else(|| infer_provider(&model));
203                let changed = decision.model_changed;
204                (model, provider, decision.tier, changed)
205            },
206        );
207
208        ocla_bus::emit(OclaEvent::ModelRouted {
209            requested_model: requested_model.clone(),
210            routed_model: model.clone(),
211            tier: tier.clone(),
212            model_changed,
213        });
214
215        let routing_rationale = routing_rationale(intent, tier.as_str(), ledger.as_ref());
216        Ok(RoutingDecisionWithRationale {
217            decision: RoutingDecision {
218                model,
219                provider,
220                reasoning_budget_tokens: configured_reasoning_budget(),
221                decision_ref: format!("route:{}", request.context.request_id),
222            },
223            routing_rationale,
224        })
225    }
226}
227
228fn route_for_intent(
229    requested_model: &str,
230    decision: &IntentDecision,
231    rules: &RoutingRules,
232) -> Option<crate::proxy::model_router::RoutingDecision> {
233    if decision.confidence_milli < 500
234        || !rules.is_active()
235        || rules.tiers.is_empty()
236        || rules.aliases.contains_key(requested_model)
237    {
238        return None;
239    }
240    let tier = intent_tier(&decision.intent);
241    let target = rules.tiers.get(tier)?;
242    if target.is_empty() {
243        return Some(crate::proxy::model_router::RoutingDecision {
244            requested_model: requested_model.to_string(),
245            routed_model: requested_model.to_string(),
246            routed_provider: None,
247            tier: tier.to_string(),
248            confidence: f64::from(decision.confidence_milli) / 1000.0,
249            reasoning: format!("classifier intent selected {tier} tier"),
250            model_changed: false,
251            estimated_cost_ratio: None,
252        });
253    }
254    let (provider, model) = parse_route_target(target)?;
255    Some(crate::proxy::model_router::RoutingDecision {
256        requested_model: requested_model.to_string(),
257        routed_model: model.to_string(),
258        routed_provider: provider.map(str::to_string),
259        tier: tier.to_string(),
260        confidence: f64::from(decision.confidence_milli) / 1000.0,
261        reasoning: format!("classifier intent selected {tier} tier"),
262        model_changed: requested_model != model,
263        estimated_cost_ratio: None,
264    })
265}
266
267fn intent_tier(intent: &str) -> &'static str {
268    let intent = intent.to_ascii_lowercase();
269    if [
270        "read",
271        "list",
272        "show",
273        "explain",
274        "summarize",
275        "status",
276        "search",
277        "lookup",
278    ]
279    .iter()
280    .any(|term| intent.contains(term))
281    {
282        "fast"
283    } else if [
284        "code",
285        "fix",
286        "implement",
287        "refactor",
288        "debug",
289        "build",
290        "patch",
291        "test",
292    ]
293    .iter()
294    .any(|term| intent.contains(term))
295    {
296        "premium"
297    } else {
298        "standard"
299    }
300}
301
302fn routing_ledger_summary() -> Option<MechanismSummary> {
303    let path = store::default_path()?;
304    store::summarize_by_mechanism(&path).remove("routing")
305}
306
307fn routing_rationale(
308    intent: Option<&IntentDecision>,
309    tier: &str,
310    ledger: Option<&MechanismSummary>,
311) -> String {
312    let intent_part = intent.map_or_else(
313        || "proxy classifier".to_string(),
314        |decision| {
315            format!(
316                "classifier intent '{}' (confidence {})",
317                decision.intent, decision.confidence_milli
318            )
319        },
320    );
321    let ledger_part = match ledger {
322        Some(summary) if summary.saved_usd > 0.0 => format!(
323            "ledger confirms {} routing events saving ${:.6}",
324            summary.count, summary.saved_usd
325        ),
326        Some(summary) => format!(
327            "ledger reports {} routing events without positive savings",
328            summary.count
329        ),
330        None => "routing savings history unavailable".to_string(),
331    };
332    format!("{intent_part} selected {tier} tier; {ledger_part}")
333}
334
335fn configured_reasoning_budget() -> u64 {
336    reasoning_budget(Config::load().proxy.resolved_effort())
337}
338
339fn reasoning_budget(effort: Option<Effort>) -> u64 {
340    match effort {
341        Some(Effort::Minimal) => 1_024,
342        Some(Effort::Low) => 2_048,
343        Some(Effort::Medium) => 4_096,
344        Some(Effort::High) => 8_192,
345        None => 0,
346    }
347}
348
349fn infer_provider(model: &str) -> String {
350    if model.contains("gpt") || model.contains("o1") || model.contains("o3") {
351        "openai".to_string()
352    } else if model.contains("claude") {
353        "anthropic".to_string()
354    } else if model.contains("gemini") {
355        "google".to_string()
356    } else {
357        "unknown".to_string()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::{
364        BuiltinModelRouter, PolicyEnforcementPoint, configured_reasoning_budget, reasoning_budget,
365        routing_rationale,
366    };
367    use crate::core::config::{Effort, RoutingRules};
368    use crate::core::ocla::traits::ModelRouter;
369    use crate::core::ocla::types::{
370        IntentDecision, ModelRouteRequest, OclaRequestContext, RoutingDecision,
371    };
372    use crate::core::savings_ledger::store::MechanismSummary;
373    use std::collections::BTreeMap;
374
375    fn route_req(candidates: &[&str]) -> ModelRouteRequest {
376        ModelRouteRequest {
377            context: OclaRequestContext {
378                request_id: "r1".into(),
379                session_id: "s1".into(),
380                agent_id: "agent-test".into(),
381                content_ref: "ref:test".into(),
382                tenant_id: None,
383                trace_id: "tr-unit".into(),
384            },
385            candidate_models: candidates.iter().map(|s| (*s).to_string()).collect(),
386            maximum_cost_micros: None,
387            maximum_latency_ms: None,
388        }
389    }
390
391    fn decision(model: &str, reasoning_budget_tokens: u64) -> RoutingDecision {
392        RoutingDecision {
393            model: model.to_string(),
394            provider: "test".to_string(),
395            reasoning_budget_tokens,
396            decision_ref: "route:r1".to_string(),
397        }
398    }
399
400    #[test]
401    fn permissive_pep_allows_any_decision() {
402        let pep = PolicyEnforcementPoint::permissive();
403
404        assert!(
405            pep.enforce(&decision("any-model", 0), &route_req(&[]))
406                .is_ok()
407        );
408    }
409
410    #[test]
411    fn pep_denies_model_in_denylist() {
412        let pep = PolicyEnforcementPoint {
413            model_denylist: vec!["blocked-model".to_string()],
414            ..PolicyEnforcementPoint::permissive()
415        };
416
417        assert!(
418            pep.enforce(&decision("blocked-model", 0), &route_req(&[]))
419                .is_err()
420        );
421    }
422
423    #[test]
424    fn pep_denies_model_missing_from_allowlist() {
425        let pep = PolicyEnforcementPoint {
426            model_allowlist: Some(vec!["approved-model".to_string()]),
427            ..PolicyEnforcementPoint::permissive()
428        };
429
430        assert!(
431            pep.enforce(&decision("other-model", 0), &route_req(&[]))
432                .is_err()
433        );
434    }
435
436    #[test]
437    fn pep_denies_request_cost_above_policy_maximum() {
438        let pep = PolicyEnforcementPoint {
439            max_cost_micros: Some(100),
440            ..PolicyEnforcementPoint::permissive()
441        };
442        let mut request = route_req(&[]);
443        request.maximum_cost_micros = Some(101);
444
445        assert!(pep.enforce(&decision("any-model", 0), &request).is_err());
446    }
447
448    fn active_rules(tiers: &[(&str, &str)]) -> RoutingRules {
449        RoutingRules {
450            enabled: Some(true),
451            aliases: BTreeMap::new(),
452            tiers: tiers
453                .iter()
454                .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
455                .collect(),
456        }
457    }
458
459    #[test]
460    fn routes_first_candidate() {
461        let router = BuiltinModelRouter::new();
462        let decision = router
463            .route_model(route_req(&["gpt-4o", "claude-3"]))
464            .unwrap();
465        assert_eq!(decision.model, "gpt-4o");
466        assert_eq!(decision.provider, "openai");
467    }
468
469    #[test]
470    fn infers_anthropic_provider() {
471        let router = BuiltinModelRouter::new();
472        let decision = router.route_model(route_req(&["claude-sonnet-4"])).unwrap();
473        assert_eq!(decision.provider, "anthropic");
474    }
475
476    #[test]
477    fn delegates_tier_selection_to_proxy_router() {
478        let router =
479            BuiltinModelRouter::with_rules(active_rules(&[("fast", "anthropic:claude-haiku-4-5")]));
480        let mut request = route_req(&["claude-sonnet-4"]);
481        request.context.content_ref = "explain how the cache works".into();
482
483        let decision = router.route_model(request).unwrap();
484
485        assert_eq!(decision.model, "claude-haiku-4-5");
486        assert_eq!(decision.provider, "anthropic");
487    }
488
489    #[test]
490    fn unknown_model_falls_back_to_default() {
491        let router = BuiltinModelRouter::new();
492        let decision = router.route_model(route_req(&[])).unwrap();
493        assert_eq!(decision.model, "default");
494        assert_eq!(decision.provider, "unknown");
495        assert_eq!(decision.decision_ref, "route:r1");
496    }
497
498    #[test]
499    fn registry_path_routes_with_configured_budget() {
500        let registry = crate::core::ocla::registry::OclaRegistry::with_builtins();
501        let decision = registry
502            .model_router
503            .route_model(route_req(&["gpt-4o"]))
504            .unwrap();
505
506        assert_eq!(decision.model, "gpt-4o");
507        assert_eq!(
508            decision.reasoning_budget_tokens,
509            configured_reasoning_budget()
510        );
511    }
512
513    #[test]
514    fn maps_configured_effort_to_budget() {
515        assert_eq!(reasoning_budget(Some(Effort::Minimal)), 1_024);
516        assert_eq!(reasoning_budget(Some(Effort::High)), 8_192);
517        assert_eq!(reasoning_budget(None), 0);
518    }
519
520    #[test]
521    fn classifier_routes_simple_intent_to_fast_tier() {
522        let router = BuiltinModelRouter::with_rules(active_rules(&[
523            ("fast", "anthropic:claude-haiku-4-5"),
524            ("premium", "openai:gpt-5"),
525        ]));
526        let result = router
527            .route_model_with_intent(
528                &route_req(&["gpt-4o"]),
529                Some(&IntentDecision {
530                    intent: "read the config".into(),
531                    confidence_milli: 900,
532                    rationale_ref: None,
533                }),
534            )
535            .unwrap();
536
537        assert_eq!(result.decision.model, "claude-haiku-4-5");
538        assert!(result.routing_rationale.contains("read the config"));
539        assert!(result.routing_rationale.contains("fast tier"));
540    }
541
542    #[test]
543    fn ledger_rationale_distinguishes_material_savings() {
544        let summary = MechanismSummary {
545            count: 2,
546            saved_tokens: 0,
547            saved_usd: 0.25,
548        };
549        let rationale = routing_rationale(None, "fast", Some(&summary));
550        assert!(rationale.contains("confirms 2 routing events"));
551        assert!(rationale.contains("$0.250000"));
552    }
553}