Skip to main content

oxicode_ai/router/
mod.rs

1//! Model auto-routing for oxicode.
2//!
3//! Provides intelligent model selection based on conversation structure
4//! and behavioral signals. This is an **opt-in** feature — the user must
5//! explicitly select `router/auto` or another profile in model selection.
6
7#![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
24/// Global router state snapshot — updated after each routing decision.
25static ROUTER_SNAPSHOT: parking_lot::RwLock<Option<RouterSnapshot>> =
26    parking_lot::RwLock::new(None);
27
28/// Global pin tier override — set by /router pin command.
29static ROUTER_PIN_TIER: parking_lot::RwLock<Option<RouterTier>> = parking_lot::RwLock::new(None);
30
31/// Snapshot of the current router state for UI display.
32#[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
58// ── Helpers ──────────────────────────────────────────────────────────────────
59
60fn 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// ── Pipeline ─────────────────────────────────────────────────────────────────
89
90/// Routing pipeline state for a session.
91#[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    /// Route a context to (score, tier, phase, method).
134    ///
135    /// Decision cascade:
136    ///   1. Override? (pin / rule / scenario / context) → return immediately
137    ///   2. Signal fusion scoring (5 signals) → heuristic score → tier
138    ///   3. The caller (stream) handles LLM classification for ambiguous scores.
139    pub fn route(&mut self, context: &Context) -> (f64, RouterTier, RouterPhase, DecisionMethod) {
140        // ── Layer 0: Override check ────────────────────────────────────────
141        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        // ── Layer 1: Signal fusion scoring ─────────────────────────────────
147        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        // Apply phase bias — blend toward previous score
167        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) // dampened bias
172        };
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        // Context upgrade
181        if budget.should_upgrade_context() && tier != RouterTier::High {
182            tier = RouterTier::High;
183            method = DecisionMethod::ContextUpgrade;
184        }
185        // Budget downgrade
186        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    /// Layer 0: Check for definitive overrides.
195    fn check_override(&self, _context: &Context) -> Option<(RouterTier, DecisionMethod)> {
196        // Global pin (set by /router pin)
197        if let Some(tier) = *ROUTER_PIN_TIER.read() {
198            return Some((tier, DecisionMethod::PinOverride));
199        }
200        // Config pin
201        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    /// Get the classifier model identifier, if configured.
229    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
240// ── Provider ───────────────────────────────────────────────────────────────────
241
242/// Router provider implementing the [`Provider`] trait.
243///
244/// Registers as provider `"router"` and accepts profile names as model IDs
245/// (e.g. `"auto"`, `"budget"`). Delegation uses global provider resolution
246/// (`get_provider_arc`) when `use_global_resolution` is true.
247pub 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    /// Create with an instance-based ProviderRegistry.
256    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    /// Create with global provider resolution (for CLI use).
266    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    /// Hot-reload configuration at runtime.
276    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    /// Resolve a target provider (instance or global fallback).
286    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    /// Resolve model from registry (instance or global fallback).
295    #[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    /// Update the global snapshot after a routing decision.
301    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    /// Get the current router state snapshot (global).
317    pub fn get_snapshot() -> Option<RouterSnapshot> {
318        ROUTER_SNAPSHOT.read().clone()
319    }
320
321    /// Route with vision awareness — extracts VisionSignal and adjusts tier/model.
322    ///
323    /// Returns `(vision_signal, adjusted_tier_config)`.
324    pub fn route_with_vision(
325        &self,
326        context: &Context,
327        profile_name: &str,
328        tier: RouterTier,
329    ) -> (VisionSignal, RoutedTierConfig) {
330        // Extract vision signal from recent messages
331        let vision = VisionSignal::extract(&context.messages, 10);
332
333        // Get base tier config
334        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 is required, ensure we have a vision-capable model
346        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    /// Ensure the selected model supports vision.
355    ///
356    /// Fallback priority:
357    /// 1. Current model already supports vision → keep it
358    /// 2. Fallback list contains a vision model → swap
359    /// 3. Higher tier has a vision model → upgrade
360    /// 4. No vision model found → warn and keep original
361    fn ensure_vision_model(
362        &self,
363        tier_config: RoutedTierConfig,
364        tier: RouterTier,
365        profile_name: &str,
366    ) -> RoutedTierConfig {
367        // 1. Current model already supports vision?
368        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        // 2. Fallback list contains a vision model?
380        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        // 3. Higher tier has a vision model?
399        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        // 4. No vision model found — warn and keep original
422        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            // 1. Route through pipeline.
443            let (score, tier, phase, method) = self.pipeline.write().route(context);
444
445            // 2. Resolve tier config.
446            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            // 3. LLM classifier for ambiguous scores (optional).
463            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, // filled below
479                        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            // 4. Resolve tier config.
502            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            // 4. Record decision.
508            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            // 4. Resolve target provider.
533            let target_provider = self
534                .resolve_provider(&pm.provider)
535                .ok_or_else(|| ProviderError::UnknownProvider(pm.provider.clone()))?;
536
537            // 5. Build target model.
538            let target_model = build_target_model(&pm, tier_config.thinking.is_some());
539
540            // 6. Inject thinking.
541            let mut opts = options.unwrap_or_default();
542            if let Some(thinking) = tier_config.thinking {
543                opts.thinking_level = Some(thinking);
544            }
545
546            // 7. Truncate context if needed (preserve first message — system prompt).
547            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            // 8. Try primary model.
563            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
586// ── Registration ───────────────────────────────────────────────────────────────
587
588/// Register the router provider and its profile models.
589///
590/// This is **opt-in** — the router is only active when the user selects
591/// `router/auto` or another profile in model selection.
592pub 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
609/// Set the global router pin tier. Used by `/router pin <tier>`.
610pub fn set_router_pin(tier: Option<RouterTier>) {
611    *ROUTER_PIN_TIER.write() = tier;
612}
613
614/// Get the current global router pin tier.
615pub fn get_router_pin() -> Option<RouterTier> {
616    *ROUTER_PIN_TIER.read()
617}