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
149#[async_trait::async_trait]
150impl ModelRouter for BuiltinModelRouter {
151    async fn route_model(&self, request: ModelRouteRequest) -> OclaResult<RoutingDecision> {
152        let result = self.route_model_with_intent(&request, None)?;
153        if let Err(reason) = self.pep.enforce(&result.decision, &request) {
154            ocla_bus::emit(OclaEvent::AgentChainEvent {
155                agent_id: request.context.agent_id.clone(),
156                action: format!("model_route_denied: {reason}"),
157                parent_agent: None,
158            });
159            return Err(OclaError::InvalidRequest(format!(
160                "routing policy denied: {reason}"
161            )));
162        }
163
164        Ok(result.decision)
165    }
166}
167
168impl BuiltinModelRouter {
169    /// Routes using a classifier decision when available, while preserving the
170    /// proxy router as the fallback for absent or low-confidence decisions.
171    pub fn route_model_with_intent(
172        &self,
173        request: &ModelRouteRequest,
174        intent: Option<&IntentDecision>,
175    ) -> OclaResult<RoutingDecisionWithRationale> {
176        let requested_model = request
177            .candidate_models
178            .first()
179            .cloned()
180            .unwrap_or_else(|| "default".to_string());
181        let body = json!({
182            "model": requested_model.clone(),
183            "messages": [{"role": "user", "content": request.context.content_ref}]
184        });
185        let routed = intent
186            .as_ref()
187            .and_then(|decision| route_for_intent(&requested_model, decision, &self.rules))
188            .or_else(|| crate::proxy::model_router::route(&body, &self.rules));
189        let ledger = routing_ledger_summary();
190        let (model, provider, tier, model_changed) = routed.map_or_else(
191            || {
192                (
193                    requested_model.clone(),
194                    infer_provider(&requested_model),
195                    "standard".to_string(),
196                    false,
197                )
198            },
199            |decision| {
200                let model = decision.routed_model;
201                let provider = decision
202                    .routed_provider
203                    .unwrap_or_else(|| infer_provider(&model));
204                let changed = decision.model_changed;
205                (model, provider, decision.tier, changed)
206            },
207        );
208
209        ocla_bus::emit(OclaEvent::ModelRouted {
210            requested_model: requested_model.clone(),
211            routed_model: model.clone(),
212            tier: tier.clone(),
213            model_changed,
214        });
215
216        let routing_rationale = routing_rationale(intent, tier.as_str(), ledger.as_ref());
217        Ok(RoutingDecisionWithRationale {
218            decision: RoutingDecision {
219                model,
220                provider,
221                reasoning_budget_tokens: configured_reasoning_budget(),
222                decision_ref: format!("route:{}", request.context.request_id),
223            },
224            routing_rationale,
225        })
226    }
227}
228
229fn route_for_intent(
230    requested_model: &str,
231    decision: &IntentDecision,
232    rules: &RoutingRules,
233) -> Option<crate::proxy::model_router::RoutingDecision> {
234    if decision.confidence_milli < 500
235        || !rules.is_active()
236        || rules.tiers.is_empty()
237        || rules.aliases.contains_key(requested_model)
238    {
239        return None;
240    }
241    let tier = intent_tier(&decision.intent);
242    let target = rules.tiers.get(tier)?;
243    if target.is_empty() {
244        return Some(crate::proxy::model_router::RoutingDecision {
245            requested_model: requested_model.to_string(),
246            routed_model: requested_model.to_string(),
247            routed_provider: None,
248            tier: tier.to_string(),
249            confidence: f64::from(decision.confidence_milli) / 1000.0,
250            reasoning: format!("classifier intent selected {tier} tier"),
251            model_changed: false,
252            estimated_cost_ratio: None,
253        });
254    }
255    let (provider, model) = parse_route_target(target)?;
256    Some(crate::proxy::model_router::RoutingDecision {
257        requested_model: requested_model.to_string(),
258        routed_model: model.to_string(),
259        routed_provider: provider.map(str::to_string),
260        tier: tier.to_string(),
261        confidence: f64::from(decision.confidence_milli) / 1000.0,
262        reasoning: format!("classifier intent selected {tier} tier"),
263        model_changed: requested_model != model,
264        estimated_cost_ratio: None,
265    })
266}
267
268fn intent_tier(intent: &str) -> &'static str {
269    let intent = intent.to_ascii_lowercase();
270    if [
271        "read",
272        "list",
273        "show",
274        "explain",
275        "summarize",
276        "status",
277        "search",
278        "lookup",
279    ]
280    .iter()
281    .any(|term| intent.contains(term))
282    {
283        "fast"
284    } else if [
285        "code",
286        "fix",
287        "implement",
288        "refactor",
289        "debug",
290        "build",
291        "patch",
292        "test",
293    ]
294    .iter()
295    .any(|term| intent.contains(term))
296    {
297        "premium"
298    } else {
299        "standard"
300    }
301}
302
303fn routing_ledger_summary() -> Option<MechanismSummary> {
304    let path = store::default_path()?;
305    store::summarize_by_mechanism(&path).remove("routing")
306}
307
308fn routing_rationale(
309    intent: Option<&IntentDecision>,
310    tier: &str,
311    ledger: Option<&MechanismSummary>,
312) -> String {
313    let intent_part = intent.map_or_else(
314        || "proxy classifier".to_string(),
315        |decision| {
316            format!(
317                "classifier intent '{}' (confidence {})",
318                decision.intent, decision.confidence_milli
319            )
320        },
321    );
322    let ledger_part = match ledger {
323        Some(summary) if summary.saved_usd > 0.0 => format!(
324            "ledger confirms {} routing events saving ${:.6}",
325            summary.count, summary.saved_usd
326        ),
327        Some(summary) => format!(
328            "ledger reports {} routing events without positive savings",
329            summary.count
330        ),
331        None => "routing savings history unavailable".to_string(),
332    };
333    format!("{intent_part} selected {tier} tier; {ledger_part}")
334}
335
336fn configured_reasoning_budget() -> u64 {
337    reasoning_budget(Config::load().proxy.resolved_effort())
338}
339
340fn reasoning_budget(effort: Option<Effort>) -> u64 {
341    match effort {
342        Some(Effort::Minimal) => 1_024,
343        Some(Effort::Low) => 2_048,
344        Some(Effort::Medium) => 4_096,
345        Some(Effort::High) => 8_192,
346        None => 0,
347    }
348}
349
350fn infer_provider(model: &str) -> String {
351    if model.contains("gpt") || model.contains("o1") || model.contains("o3") {
352        "openai".to_string()
353    } else if model.contains("claude") {
354        "anthropic".to_string()
355    } else if model.contains("gemini") {
356        "google".to_string()
357    } else {
358        "unknown".to_string()
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::{
365        BuiltinModelRouter, PolicyEnforcementPoint, configured_reasoning_budget, reasoning_budget,
366        routing_rationale,
367    };
368    use crate::core::config::{Effort, RoutingRules};
369    use crate::core::ocla::traits::ModelRouter;
370    use crate::core::ocla::types::{
371        IntentDecision, ModelRouteRequest, OclaRequestContext, RoutingDecision,
372    };
373    use crate::core::savings_ledger::store::MechanismSummary;
374    use std::collections::BTreeMap;
375
376    fn route_req(candidates: &[&str]) -> ModelRouteRequest {
377        ModelRouteRequest {
378            context: OclaRequestContext {
379                request_id: "r1".into(),
380                session_id: "s1".into(),
381                agent_id: "agent-test".into(),
382                content_ref: "ref:test".into(),
383                tenant_id: None,
384                trace_id: "tr-unit".into(),
385            },
386            candidate_models: candidates.iter().map(|s| (*s).to_string()).collect(),
387            maximum_cost_micros: None,
388            maximum_latency_ms: None,
389        }
390    }
391
392    fn decision(model: &str, reasoning_budget_tokens: u64) -> RoutingDecision {
393        RoutingDecision {
394            model: model.to_string(),
395            provider: "test".to_string(),
396            reasoning_budget_tokens,
397            decision_ref: "route:r1".to_string(),
398        }
399    }
400
401    #[test]
402    fn permissive_pep_allows_any_decision() {
403        let pep = PolicyEnforcementPoint::permissive();
404
405        assert!(
406            pep.enforce(&decision("any-model", 0), &route_req(&[]))
407                .is_ok()
408        );
409    }
410
411    #[test]
412    fn pep_denies_model_in_denylist() {
413        let pep = PolicyEnforcementPoint {
414            model_denylist: vec!["blocked-model".to_string()],
415            ..PolicyEnforcementPoint::permissive()
416        };
417
418        assert!(
419            pep.enforce(&decision("blocked-model", 0), &route_req(&[]))
420                .is_err()
421        );
422    }
423
424    #[test]
425    fn pep_denies_model_missing_from_allowlist() {
426        let pep = PolicyEnforcementPoint {
427            model_allowlist: Some(vec!["approved-model".to_string()]),
428            ..PolicyEnforcementPoint::permissive()
429        };
430
431        assert!(
432            pep.enforce(&decision("other-model", 0), &route_req(&[]))
433                .is_err()
434        );
435    }
436
437    #[test]
438    fn pep_denies_request_cost_above_policy_maximum() {
439        let pep = PolicyEnforcementPoint {
440            max_cost_micros: Some(100),
441            ..PolicyEnforcementPoint::permissive()
442        };
443        let mut request = route_req(&[]);
444        request.maximum_cost_micros = Some(101);
445
446        assert!(pep.enforce(&decision("any-model", 0), &request).is_err());
447    }
448
449    fn active_rules(tiers: &[(&str, &str)]) -> RoutingRules {
450        RoutingRules {
451            enabled: Some(true),
452            aliases: BTreeMap::new(),
453            tiers: tiers
454                .iter()
455                .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
456                .collect(),
457        }
458    }
459
460    #[tokio::test]
461    async fn routes_first_candidate() {
462        let router = BuiltinModelRouter::new();
463        let decision = router
464            .route_model(route_req(&["gpt-4o", "claude-3"]))
465            .await
466            .unwrap();
467        assert_eq!(decision.model, "gpt-4o");
468        assert_eq!(decision.provider, "openai");
469    }
470
471    #[tokio::test]
472    async fn infers_anthropic_provider() {
473        let router = BuiltinModelRouter::new();
474        let decision = router
475            .route_model(route_req(&["claude-sonnet-4"]))
476            .await
477            .unwrap();
478        assert_eq!(decision.provider, "anthropic");
479    }
480
481    #[tokio::test]
482    async fn delegates_tier_selection_to_proxy_router() {
483        let router =
484            BuiltinModelRouter::with_rules(active_rules(&[("fast", "anthropic:claude-haiku-4-5")]));
485        let mut request = route_req(&["claude-sonnet-4"]);
486        request.context.content_ref = "explain how the cache works".into();
487
488        let decision = router.route_model(request).await.unwrap();
489
490        assert_eq!(decision.model, "claude-haiku-4-5");
491        assert_eq!(decision.provider, "anthropic");
492    }
493
494    #[tokio::test]
495    async fn unknown_model_falls_back_to_default() {
496        let router = BuiltinModelRouter::new();
497        let decision = router.route_model(route_req(&[])).await.unwrap();
498        assert_eq!(decision.model, "default");
499        assert_eq!(decision.provider, "unknown");
500        assert_eq!(decision.decision_ref, "route:r1");
501    }
502
503    #[tokio::test]
504    async fn registry_path_routes_with_configured_budget() {
505        let registry = crate::core::ocla::registry::OclaRegistry::with_builtins();
506        let decision = registry
507            .model_router
508            .route_model(route_req(&["gpt-4o"]))
509            .await
510            .unwrap();
511
512        assert_eq!(decision.model, "gpt-4o");
513        assert_eq!(
514            decision.reasoning_budget_tokens,
515            configured_reasoning_budget()
516        );
517    }
518
519    #[test]
520    fn maps_configured_effort_to_budget() {
521        assert_eq!(reasoning_budget(Some(Effort::Minimal)), 1_024);
522        assert_eq!(reasoning_budget(Some(Effort::High)), 8_192);
523        assert_eq!(reasoning_budget(None), 0);
524    }
525
526    #[test]
527    fn classifier_routes_simple_intent_to_fast_tier() {
528        let router = BuiltinModelRouter::with_rules(active_rules(&[
529            ("fast", "anthropic:claude-haiku-4-5"),
530            ("premium", "openai:gpt-5"),
531        ]));
532        let result = router
533            .route_model_with_intent(
534                &route_req(&["gpt-4o"]),
535                Some(&IntentDecision {
536                    intent: "read the config".into(),
537                    confidence_milli: 900,
538                    rationale_ref: None,
539                }),
540            )
541            .unwrap();
542
543        assert_eq!(result.decision.model, "claude-haiku-4-5");
544        assert!(result.routing_rationale.contains("read the config"));
545        assert!(result.routing_rationale.contains("fast tier"));
546    }
547
548    #[test]
549    fn ledger_rationale_distinguishes_material_savings() {
550        let summary = MechanismSummary {
551            count: 2,
552            saved_tokens: 0,
553            saved_usd: 0.25,
554        };
555        let rationale = routing_rationale(None, "fast", Some(&summary));
556        assert!(rationale.contains("confirms 2 routing events"));
557        assert!(rationale.contains("$0.250000"));
558    }
559}