1use 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#[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 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#[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 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}