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
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 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}