1#![allow(missing_docs)]
8
9pub mod classifier;
10pub mod fallback;
11pub mod profiles;
12pub mod scoring;
13pub mod signals;
14pub mod types;
15
16use crate::context::Context;
17use crate::error::ProviderError;
18use crate::messages::Message;
19use crate::providers::ProviderRegistry;
20use crate::providers::StreamOptions;
21use crate::types::Model;
22use crate::{Api, Provider, StreamResult, ThinkingLevel, register_model, register_provider};
23
24static ROUTER_SNAPSHOT: parking_lot::RwLock<Option<RouterSnapshot>> =
26 parking_lot::RwLock::new(None);
27
28static ROUTER_PIN_TIER: parking_lot::RwLock<Option<RouterTier>> = parking_lot::RwLock::new(None);
30
31#[derive(Debug, Clone, Default)]
33pub struct RouterSnapshot {
34 pub last_tier: Option<RouterTier>,
35 pub last_score: f64,
36 pub last_model: Option<String>,
37 pub last_provider: Option<String>,
38 pub accumulated_cost: f64,
39 pub turn_count: usize,
40 pub profile: Option<String>,
41}
42use parking_lot::RwLock;
43use std::future::Future;
44use std::pin::Pin;
45use std::sync::Arc;
46
47pub use fallback::FallbackChain;
48pub use profiles::{ProviderModel, RouterProfiles, parse_tier_model};
49pub use scoring::{compute_score, lerp};
50pub use signals::{
51 BehavioralSignal, ContextBudgetSignal, MessageContentSignal, StructuralSignal, VisionSignal,
52};
53pub use types::{
54 DecisionMethod, RoutedTierConfig, RouterConfig, RouterPhase, RouterProfile, RouterState,
55 RouterTier, RoutingDecision, RoutingScore, ScoringWeights,
56};
57
58fn message_chars(msg: &Message) -> usize {
61 match msg {
62 Message::User(u) => u.content.as_str().map(|s| s.len()).unwrap_or(0),
63 Message::Assistant(a) => a
64 .content
65 .iter()
66 .map(|b| b.as_text().map(|t| t.len()).unwrap_or(0))
67 .sum(),
68 Message::ToolResult(t) => t
69 .content
70 .iter()
71 .map(|b| b.as_text().map(|t| t.len()).unwrap_or(0))
72 .sum(),
73 }
74}
75
76fn build_target_model(pm: &ProviderModel, reasoning: bool) -> Model {
77 let mut m = Model::new(
78 &pm.model_id,
79 &pm.model_id,
80 Api::AnthropicMessages,
81 &pm.provider,
82 "",
83 );
84 m.reasoning = reasoning;
85 m
86}
87
88#[derive(Debug)]
92pub struct RouterPipeline {
93 pub weights: ScoringWeights,
94 decision_history: Vec<RoutingDecision>,
95 accumulated_cost: f64,
96 budget_limit: Option<f64>,
97 context_upgrade_threshold: Option<usize>,
98 last_score: f64,
99 pin_tier: Option<RouterTier>,
100 phase_bias: f64,
101 classifier_model: Option<String>,
102}
103
104impl RouterPipeline {
105 pub fn new() -> Self {
106 Self {
107 weights: ScoringWeights::default(),
108 decision_history: Vec::new(),
109 accumulated_cost: 0.0,
110 budget_limit: None,
111 context_upgrade_threshold: None,
112 last_score: 0.5,
113 pin_tier: None,
114 phase_bias: 0.5,
115 classifier_model: None,
116 }
117 }
118
119 pub fn from_config(config: &RouterConfig) -> Self {
120 Self {
121 weights: config.weights.clone(),
122 decision_history: Vec::new(),
123 accumulated_cost: 0.0,
124 budget_limit: config.max_session_budget,
125 context_upgrade_threshold: config.context_upgrade_threshold,
126 last_score: 0.5,
127 pin_tier: config.pin_tier,
128 phase_bias: config.phase_bias.unwrap_or(0.5).clamp(0.0, 1.0),
129 classifier_model: config.classifier_model.clone(),
130 }
131 }
132
133 pub fn route(&mut self, context: &Context) -> (f64, RouterTier, RouterPhase, DecisionMethod) {
140 if let Some((tier, method)) = self.check_override(context) {
142 let phase = BehavioralSignal::extract(&context.messages, &self.decision_history).phase;
143 return (self.last_score, tier, phase, method);
144 }
145
146 let structural = StructuralSignal::extract(&context.messages);
148 let behavioral = BehavioralSignal::extract(&context.messages, &self.decision_history);
149 let budget = ContextBudgetSignal::extract(
150 structural.estimated_tokens,
151 self.accumulated_cost,
152 self.budget_limit,
153 self.context_upgrade_threshold,
154 );
155 let message = MessageContentSignal::extract(&context.messages);
156
157 let raw_score = compute_score(
158 &structural,
159 &behavioral,
160 &budget,
161 None,
162 Some(&message),
163 &self.weights,
164 );
165
166 let blended = if self.decision_history.is_empty() {
168 raw_score
169 } else {
170 let prev = self.last_score;
171 lerp(raw_score, prev, self.phase_bias * 0.3) };
173
174 self.last_score = blended;
175
176 let score = RoutingScore(blended);
177 let mut tier = score.to_tier(0.65, 0.35);
178 let mut method = DecisionMethod::Heuristic;
179
180 if budget.should_upgrade_context() && tier != RouterTier::High {
182 tier = RouterTier::High;
183 method = DecisionMethod::ContextUpgrade;
184 }
185 if budget.is_over_budget() && tier == RouterTier::High {
187 tier = RouterTier::Medium;
188 method = DecisionMethod::BudgetDowngrade;
189 }
190
191 (blended, tier, behavioral.phase, method)
192 }
193
194 fn check_override(&self, _context: &Context) -> Option<(RouterTier, DecisionMethod)> {
196 if let Some(tier) = *ROUTER_PIN_TIER.read() {
198 return Some((tier, DecisionMethod::PinOverride));
199 }
200 if let Some(tier) = self.pin_tier {
202 return Some((tier, DecisionMethod::PinOverride));
203 }
204 None
205 }
206
207 pub fn record_decision(&mut self, decision: RoutingDecision) {
208 self.decision_history.push(decision);
209 if self.decision_history.len() > 20 {
210 self.decision_history.remove(0);
211 }
212 }
213
214 pub fn record_turn_cost(&mut self, cost: f64) {
215 self.accumulated_cost += cost;
216 }
217
218 pub fn accumulated_cost(&self) -> f64 {
219 self.accumulated_cost
220 }
221 pub fn last_score(&self) -> f64 {
222 self.last_score
223 }
224 pub fn history(&self) -> &[RoutingDecision] {
225 &self.decision_history
226 }
227
228 pub fn classifier_model(&self) -> Option<String> {
230 self.classifier_model.clone()
231 }
232}
233
234impl Default for RouterPipeline {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240pub struct RouterProvider {
248 pipeline: RwLock<RouterPipeline>,
249 profiles: RwLock<RouterProfiles>,
250 provider_registry: Arc<ProviderRegistry>,
251 use_global_resolution: bool,
252}
253
254impl RouterProvider {
255 pub fn new(config: &RouterConfig, registry: Arc<ProviderRegistry>) -> Self {
257 Self {
258 pipeline: RwLock::new(RouterPipeline::from_config(config)),
259 profiles: RwLock::new(RouterProfiles::from_config(config)),
260 provider_registry: registry,
261 use_global_resolution: false,
262 }
263 }
264
265 pub fn new_global(config: &RouterConfig) -> Self {
267 Self {
268 pipeline: RwLock::new(RouterPipeline::from_config(config)),
269 profiles: RwLock::new(RouterProfiles::from_config(config)),
270 provider_registry: Arc::new(ProviderRegistry::new()),
271 use_global_resolution: true,
272 }
273 }
274
275 pub fn reload_config(&self, config: &RouterConfig) {
277 let mut p = self.pipeline.write();
278 p.weights = config.weights.clone();
279 p.budget_limit = config.max_session_budget;
280 p.context_upgrade_threshold = config.context_upgrade_threshold;
281 drop(p);
282 *self.profiles.write() = RouterProfiles::from_config(config);
283 }
284
285 fn resolve_provider(&self, name: &str) -> Option<Arc<dyn Provider>> {
287 if self.use_global_resolution {
288 crate::get_provider_arc(name)
289 } else {
290 self.provider_registry.get(name)
291 }
292 }
293
294 #[allow(dead_code)]
296 fn resolve_model(&self, provider: &str, model_id: &str) -> Option<Model> {
297 crate::lookup_model(provider, model_id)
298 }
299
300 fn update_snapshot(&self) {
302 let p = self.pipeline.read();
303 let last = p.history().last();
304 let mut snap = ROUTER_SNAPSHOT.write();
305 *snap = Some(RouterSnapshot {
306 last_tier: last.map(|d| d.tier),
307 last_score: p.last_score(),
308 last_model: last.map(|d| d.target_label.clone()),
309 last_provider: last.map(|d| d.target_provider.clone()),
310 accumulated_cost: p.accumulated_cost(),
311 turn_count: p.history().len(),
312 profile: last.map(|d| d.profile.clone()),
313 });
314 }
315
316 pub fn get_snapshot() -> Option<RouterSnapshot> {
318 ROUTER_SNAPSHOT.read().clone()
319 }
320
321 pub fn route_with_vision(
325 &self,
326 context: &Context,
327 profile_name: &str,
328 tier: RouterTier,
329 ) -> (VisionSignal, RoutedTierConfig) {
330 let vision = VisionSignal::extract(&context.messages, 10);
332
333 let tier_config = self
335 .profiles
336 .read()
337 .tier_config(profile_name, tier)
338 .cloned()
339 .unwrap_or_else(|| RoutedTierConfig {
340 model: String::new(),
341 thinking: None,
342 fallbacks: vec![],
343 });
344
345 if vision.requires_vision() {
347 let adjusted = self.ensure_vision_model(tier_config, tier, profile_name);
348 (vision, adjusted)
349 } else {
350 (vision, tier_config)
351 }
352 }
353
354 fn ensure_vision_model(
362 &self,
363 tier_config: RoutedTierConfig,
364 tier: RouterTier,
365 profile_name: &str,
366 ) -> RoutedTierConfig {
367 if let Some(pm) = parse_tier_model(&tier_config)
369 && let Some(model) = crate::lookup_model(&pm.provider, &pm.model_id)
370 && model.supports_vision()
371 {
372 return tier_config;
373 }
374
375 let original_model = tier_config.model.clone();
376 let thinking = tier_config.thinking;
377 let fallbacks = tier_config.fallbacks.clone();
378
379 for fb in &fallbacks {
381 if let Some(pm) = ProviderModel::parse(fb)
382 && let Some(model) = crate::lookup_model(&pm.provider, &pm.model_id)
383 && model.supports_vision()
384 {
385 tracing::info!(
386 "Vision override: {} → {} (vision-capable fallback)",
387 original_model,
388 fb
389 );
390 return RoutedTierConfig {
391 model: fb.clone(),
392 thinking,
393 fallbacks,
394 };
395 }
396 }
397
398 let profiles = self.profiles.read();
400 if let Some(profile) = profiles.get_with_fallback(profile_name) {
401 for higher_tier in [RouterTier::High, RouterTier::Medium] {
402 if higher_tier.rank() > tier.rank() {
403 let tc = profile.tier_config(higher_tier);
404 if let Some(pm) = parse_tier_model(tc)
405 && let Some(model) = crate::lookup_model(&pm.provider, &pm.model_id)
406 && model.supports_vision()
407 {
408 tracing::info!(
409 "Vision upgrade: tier {:?} → {:?}, model {} → {}",
410 tier,
411 higher_tier,
412 original_model,
413 tc.model
414 );
415 return tc.clone();
416 }
417 }
418 }
419 }
420
421 tracing::warn!(
423 "Vision required but no vision-capable model found for tier {:?}. \
424 Model {} may fail with image content.",
425 tier,
426 original_model
427 );
428 tier_config
429 }
430}
431
432impl Provider for RouterProvider {
433 fn stream<'a>(
434 &'a self,
435 model: &'a Model,
436 context: &'a Context,
437 options: Option<StreamOptions>,
438 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
439 Box::pin(async move {
440 let profile_name = &model.id;
441
442 let (score, tier, phase, method) = self.pipeline.write().route(context);
444
445 let tier_config = match self
447 .profiles
448 .read()
449 .tier_config(profile_name, tier)
450 .cloned()
451 {
452 Some(tc) => tc,
453 None => {
454 return Err(ProviderError::StreamError(format!(
455 "Router profile '{}' is not configured. \
456 Run /router setup or edit ~/.oxicode/settings.toml",
457 profile_name
458 )));
459 }
460 };
461
462 let (score, tier, method) = {
464 let classifier_model = self.pipeline.read().classifier_model();
465 if (0.25..0.75).contains(&score) && classifier_model.is_some() {
466 let input = classifier::ClassifierInput {
467 message: context
468 .messages
469 .iter()
470 .rev()
471 .find_map(|m| match m {
472 Message::User(u) => {
473 Some(u.content.as_str().unwrap_or("").to_string())
474 }
475 _ => None,
476 })
477 .unwrap_or_default(),
478 context_tokens: 0, turn_count: self.pipeline.read().history().len(),
480 available_tools: vec![],
481 };
482 let llm = classifier::LlmClassifier::new(classifier_model);
483 match llm.classify(&input, score).await {
484 Ok(llm_score) => {
485 let llm_tier = RoutingScore(llm_score).to_tier(0.65, 0.35);
486 tracing::info!(
487 "LLM classifier: score {score:.2} → {llm_score:.2}, tier {tier:?} → {llm_tier:?}"
488 );
489 (llm_score, llm_tier, DecisionMethod::LlmClassifier)
490 }
491 Err(e) => {
492 tracing::warn!("LLM classifier failed: {e}, using heuristic score");
493 (score, tier, method)
494 }
495 }
496 } else {
497 (score, tier, method)
498 }
499 };
500
501 let pm = parse_tier_model(&tier_config).unwrap_or_else(|| ProviderModel {
503 provider: model.provider.clone(),
504 model_id: model.id.clone(),
505 });
506
507 let decision = RoutingDecision {
509 profile: profile_name.clone(),
510 tier,
511 phase,
512 target_provider: pm.provider.clone(),
513 target_model_id: pm.model_id.clone(),
514 target_label: tier_config.model.clone(),
515 reasoning: format!(
516 "tier={tier:?}, score={score:.2}, method={method:?}, provider={}, model={}",
517 pm.provider, pm.model_id
518 ),
519 thinking: tier_config.thinking.unwrap_or(ThinkingLevel::Off),
520 timestamp: chrono::Utc::now().timestamp_millis(),
521 score,
522 is_fallback: false,
523 is_context_triggered: method == DecisionMethod::ContextUpgrade,
524 is_budget_forced: method == DecisionMethod::BudgetDowngrade,
525 is_vision_triggered: false,
526 vision_images: 0,
527 decision_method: method,
528 };
529 self.pipeline.write().record_decision(decision);
530 self.update_snapshot();
531
532 let target_provider = self
534 .resolve_provider(&pm.provider)
535 .ok_or_else(|| ProviderError::UnknownProvider(pm.provider.clone()))?;
536
537 let target_model = build_target_model(&pm, tier_config.thinking.is_some());
539
540 let mut opts = options.unwrap_or_default();
542 if let Some(thinking) = tier_config.thinking {
543 opts.thinking_level = Some(thinking);
544 }
545
546 let estimated_chars: usize = context.messages.iter().map(message_chars).sum();
548 let estimated_tokens = estimated_chars / 4;
549 let adjusted_context = if estimated_tokens > target_model.context_window {
550 let mut ctx = context.clone();
551 let max_chars = target_model.context_window * 3;
552 let mut total: usize = ctx.messages.iter().map(message_chars).sum();
553 while total > max_chars && ctx.messages.len() > 2 {
554 let removed = ctx.messages.remove(1);
555 total -= message_chars(&removed);
556 }
557 ctx
558 } else {
559 context.clone()
560 };
561
562 match target_provider
564 .stream(&target_model, &adjusted_context, Some(opts.clone()))
565 .await
566 {
567 Ok(stream) => Ok(stream),
568 Err(primary_err) => {
569 if tier_config.fallbacks.is_empty() {
570 return Err(primary_err);
571 }
572 let chain = FallbackChain::new(tier_config.fallbacks.clone());
573 chain
574 .try_models_with_resolver(
575 |name| self.resolve_provider(name),
576 &adjusted_context,
577 Some(opts),
578 )
579 .await
580 }
581 }
582 })
583 }
584}
585
586pub fn register_router(config: &RouterConfig) {
593 let provider = RouterProvider::new_global(config);
594 register_provider("router", provider);
595 tracing::info!("Model router registered (opt-in: select router/auto)");
596 for name in config.profiles.keys() {
597 let model = Model::new(
598 name,
599 format!("Router ({name})"),
600 Api::AnthropicMessages,
601 "router",
602 "router://local",
603 );
604 register_model(model);
605 tracing::debug!("Registered router model: router/{}", name);
606 }
607}
608
609pub fn set_router_pin(tier: Option<RouterTier>) {
611 *ROUTER_PIN_TIER.write() = tier;
612}
613
614pub fn get_router_pin() -> Option<RouterTier> {
616 *ROUTER_PIN_TIER.read()
617}