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            // 3b. Vision-aware model override. When recent messages carry image
502            // content (e.g. a `browse` screenshot), ensure the resolved model is
503            // vision-capable — swapping to a vision fallback or upgrading the tier
504            // via `ensure_vision_model`. No-op when no images are present. This
505            // activates the VisionSignal infrastructure (signals.rs) that was
506            // previously built but never wired into the routing hot path.
507            let vision = VisionSignal::extract(&context.messages, 10);
508            let is_vision_triggered = vision.requires_vision();
509            let vision_images = vision.recent_image_count;
510            let tier_config = if is_vision_triggered {
511                self.ensure_vision_model(tier_config, tier, profile_name)
512            } else {
513                tier_config
514            };
515
516            // 4. Resolve tier config.
517            let pm = parse_tier_model(&tier_config).unwrap_or_else(|| ProviderModel {
518                provider: model.provider.clone(),
519                model_id: model.id.clone(),
520            });
521
522            // 4. Record decision.
523            let decision = RoutingDecision {
524                profile: profile_name.clone(),
525                tier,
526                phase,
527                target_provider: pm.provider.clone(),
528                target_model_id: pm.model_id.clone(),
529                target_label: tier_config.model.clone(),
530                reasoning: format!(
531                    "tier={tier:?}, score={score:.2}, method={method:?}, provider={}, model={}",
532                    pm.provider, pm.model_id
533                ),
534                thinking: tier_config.thinking.unwrap_or(ThinkingLevel::Off),
535                timestamp: chrono::Utc::now().timestamp_millis(),
536                score,
537                is_fallback: false,
538                is_context_triggered: method == DecisionMethod::ContextUpgrade,
539                is_budget_forced: method == DecisionMethod::BudgetDowngrade,
540                is_vision_triggered,
541                vision_images,
542                decision_method: method,
543            };
544            self.pipeline.write().record_decision(decision);
545            self.update_snapshot();
546
547            // 4. Resolve target provider.
548            let target_provider = self
549                .resolve_provider(&pm.provider)
550                .ok_or_else(|| ProviderError::UnknownProvider(pm.provider.clone()))?;
551
552            // 5. Build target model.
553            let target_model = build_target_model(&pm, tier_config.thinking.is_some());
554
555            // 6. Inject thinking.
556            let mut opts = options.unwrap_or_default();
557            if let Some(thinking) = tier_config.thinking {
558                opts.thinking_level = Some(thinking);
559            }
560
561            // 7. Truncate context if needed (preserve first message — system prompt).
562            let estimated_chars: usize = context.messages.iter().map(message_chars).sum();
563            let estimated_tokens = estimated_chars / 4;
564            let adjusted_context = if estimated_tokens > target_model.context_window {
565                let mut ctx = context.clone();
566                let max_chars = target_model.context_window * 3;
567                let mut total: usize = ctx.messages.iter().map(message_chars).sum();
568                while total > max_chars && ctx.messages.len() > 2 {
569                    let removed = ctx.messages.remove(1);
570                    total -= message_chars(&removed);
571                }
572                ctx
573            } else {
574                context.clone()
575            };
576
577            // 8. Try primary model.
578            match target_provider
579                .stream(&target_model, &adjusted_context, Some(opts.clone()))
580                .await
581            {
582                Ok(stream) => Ok(stream),
583                Err(primary_err) => {
584                    if tier_config.fallbacks.is_empty() {
585                        return Err(primary_err);
586                    }
587                    let chain = FallbackChain::new(tier_config.fallbacks.clone());
588                    chain
589                        .try_models_with_resolver(
590                            |name| self.resolve_provider(name),
591                            &adjusted_context,
592                            Some(opts),
593                        )
594                        .await
595                }
596            }
597        })
598    }
599}
600
601// ── Registration ───────────────────────────────────────────────────────────────
602
603/// Register the router provider and its profile models.
604///
605/// This is **opt-in** — the router is only active when the user selects
606/// `router/auto` or another profile in model selection.
607pub fn register_router(config: &RouterConfig) {
608    let provider = RouterProvider::new_global(config);
609    register_provider("router", provider);
610    tracing::info!("Model router registered (opt-in: select router/auto)");
611    for name in config.profiles.keys() {
612        let model = Model::new(
613            name,
614            format!("Router ({name})"),
615            Api::AnthropicMessages,
616            "router",
617            "router://local",
618        );
619        register_model(model);
620        tracing::debug!("Registered router model: router/{}", name);
621    }
622}
623
624/// Set the global router pin tier. Used by `/router pin <tier>`.
625pub fn set_router_pin(tier: Option<RouterTier>) {
626    *ROUTER_PIN_TIER.write() = tier;
627}
628
629/// Get the current global router pin tier.
630pub fn get_router_pin() -> Option<RouterTier> {
631    *ROUTER_PIN_TIER.read()
632}
633
634#[cfg(test)]
635mod vision_routing_tests {
636    //! These tests cover the vision model-swap that `stream()` now activates.
637    //! `VisionSignal` extraction itself is covered in `signals::vision_tests`;
638    //! here we prove `route_with_vision` (the pub wrapper around the exact
639    //! `ensure_vision_model` logic the hot path calls) performs a real swap.
640    use super::types::RouterTier;
641    use super::{RoutedTierConfig, RouterConfig, RouterProfile, RouterProvider};
642    use crate::context::Context;
643    use crate::messages::{ContentBlock, ImageContent, Message, MessageContent, UserMessage};
644    use crate::providers::ProviderRegistry;
645    use crate::register_model;
646    use crate::types::{Api, InputModality, Model};
647    use std::sync::Arc;
648
649    /// Register a text-only model and a vision-capable model under a test
650    /// provider so `ensure_vision_model` can observe a real swap via the
651    /// global model registry (nextest isolates each test in its own process,
652    /// so the global mutation is safe).
653    fn register_test_models() {
654        let novision = Model::new(
655            "novision",
656            "NoVision",
657            Api::OpenAiCompletions,
658            "testvision",
659            "http://localhost",
660        ); // input defaults to [Text] — no vision
661        let mut sees = Model::new(
662            "sees",
663            "Sees",
664            Api::OpenAiCompletions,
665            "testvision",
666            "http://localhost",
667        );
668        sees.input.push(InputModality::Image);
669        register_model(novision);
670        register_model(sees);
671    }
672
673    /// Profile whose low tier is text-only with a vision-capable fallback.
674    fn swap_config() -> RouterConfig {
675        let mut config = RouterConfig::default();
676        config.profiles.insert(
677            "auto".to_string(),
678            RouterProfile {
679                high: RoutedTierConfig {
680                    model: "testvision/novision".to_string(),
681                    thinking: None,
682                    fallbacks: vec![],
683                },
684                medium: RoutedTierConfig {
685                    model: "testvision/novision".to_string(),
686                    thinking: None,
687                    fallbacks: vec![],
688                },
689                low: RoutedTierConfig {
690                    model: "testvision/novision".to_string(),
691                    thinking: None,
692                    fallbacks: vec!["testvision/sees".to_string()],
693                },
694            },
695        );
696        config
697    }
698
699    fn image_context() -> Context {
700        let mut ctx = Context::new();
701        ctx.messages
702            .push(Message::User(UserMessage::new(MessageContent::Blocks(
703                vec![ContentBlock::Image(ImageContent::new("fake", "image/png"))],
704            ))));
705        ctx
706    }
707
708    /// Image-bearing context routed to a text-only tier model must swap to its
709    /// vision-capable fallback — the core contract the hot-path wiring delivers.
710    #[test]
711    fn route_with_vision_swaps_to_vision_fallback() {
712        register_test_models();
713        let provider = RouterProvider::new(&swap_config(), Arc::new(ProviderRegistry::new()));
714
715        let (vision, tier_config) =
716            provider.route_with_vision(&image_context(), "auto", RouterTier::Low);
717
718        assert!(
719            vision.requires_vision(),
720            "image context must require vision"
721        );
722        assert_eq!(
723            tier_config.model, "testvision/sees",
724            "non-vision low-tier model must swap to the vision fallback"
725        );
726        assert_eq!(vision.recent_image_count, 1);
727    }
728
729    /// No images → no vision requirement → model unchanged.
730    #[test]
731    fn route_with_vision_keeps_model_without_images() {
732        register_test_models();
733        let provider = RouterProvider::new(&swap_config(), Arc::new(ProviderRegistry::new()));
734
735        let mut ctx = Context::new();
736        ctx.messages
737            .push(Message::User(UserMessage::new("just text")));
738
739        let (vision, tier_config) = provider.route_with_vision(&ctx, "auto", RouterTier::Low);
740
741        assert!(!vision.requires_vision());
742        assert_eq!(tier_config.model, "testvision/novision");
743    }
744}