Skip to main content

oxicode_ai/router/
types.rs

1//! Router type definitions — tiers, phases, decisions, and configuration.
2
3use crate::ThinkingLevel;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7// ── Tiers & Phases ─────────────────────────────────────────────────────────────
8
9/// Routing tier representing model capability level.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
11#[serde(rename_all = "lowercase")]
12pub enum RouterTier {
13    /// Highest capability — research, architecture, complex reasoning.
14    High,
15    /// Medium capability — standard coding, multi-step tasks.
16    Medium,
17    /// Lowest capability — simple Q&A, formatting, trivial edits.
18    Low,
19}
20
21impl RouterTier {
22    /// Returns a stable ordering value.
23    pub fn rank(&self) -> u8 {
24        match self {
25            Self::Low => 0,
26            Self::Medium => 1,
27            Self::High => 2,
28        }
29    }
30}
31
32/// Phase of the conversation influencing routing decisions.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
34#[serde(rename_all = "lowercase")]
35pub enum RouterPhase {
36    /// Default: active coding / tool-use phase.
37    #[default]
38    Implementation,
39    /// Planning / design / exploration phase.
40    Planning,
41    /// Lightweight / lookup phase.
42    Lightweight,
43}
44
45impl RouterPhase {
46    /// Returns a numeric weight for scoring.
47    pub fn weight(&self) -> f64 {
48        match self {
49            Self::Planning => 0.8,
50            Self::Implementation => 0.5,
51            Self::Lightweight => 0.2,
52        }
53    }
54}
55
56// ── Decision Method ──────────────────────────────────────────────────────────
57
58/// How the routing decision was made.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub enum DecisionMethod {
62    /// Heuristic scoring from structural / behavioral signals.
63    Heuristic,
64    /// LLM-based classifier for ambiguous cases.
65    LlmClassifier,
66    /// Explicit pin override from user config.
67    PinOverride,
68    /// Custom rule matched.
69    RuleMatch,
70    /// Tool-type scenario matched (web_search, thinking, etc.).
71    ScenarioMatch,
72    /// Automatic upgrade due to context length.
73    ContextUpgrade,
74    /// Automatic downgrade due to budget constraints.
75    BudgetDowngrade,
76}
77
78// ── Routing Score ─────────────────────────────────────────────────────────────
79
80/// Score produced by signal aggregation, mapped to a [`RouterTier`].
81#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
82pub struct RoutingScore(pub f64);
83
84impl RoutingScore {
85    /// Create a new score, clamped to `[0.0, 1.0]`.
86    pub fn new(raw: f64) -> Self {
87        Self(raw.clamp(0.0, 1.0))
88    }
89
90    /// Map the score to a routing tier using configurable thresholds.
91    pub fn to_tier(&self, high_threshold: f64, low_threshold: f64) -> RouterTier {
92        if self.0 >= high_threshold {
93            RouterTier::High
94        } else if self.0 <= low_threshold {
95            RouterTier::Low
96        } else {
97            RouterTier::Medium
98        }
99    }
100
101    /// Returns `true` if the score is close enough to a threshold boundary
102    /// that an LLM classifier could refine the decision.
103    pub fn needs_refinement(&self, margin: f64) -> bool {
104        let near_high = (self.0 - 0.65).abs() < margin;
105        let near_low = (self.0 - 0.35).abs() < margin;
106        near_high || near_low
107    }
108
109    /// Raw score value.
110    pub fn raw(&self) -> f64 {
111        self.0
112    }
113}
114
115// ── Routing Decision ──────────────────────────────────────────────────────────
116
117/// A single routing decision recording what was chosen and why.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct RoutingDecision {
120    /// Profile name used for this decision.
121    pub profile: String,
122    /// Selected tier.
123    pub tier: RouterTier,
124    /// Detected conversation phase.
125    pub phase: RouterPhase,
126    /// Target provider name (e.g. `"anthropic"`).
127    pub target_provider: String,
128    /// Target model identifier (e.g. `"claude-sonnet-4"`).
129    pub target_model_id: String,
130    /// Human-readable label for the target model.
131    pub target_label: String,
132    /// Short explanation of why this tier was chosen.
133    pub reasoning: String,
134    /// Thinking level to apply.
135    pub thinking: ThinkingLevel,
136    /// Unix-epoch milliseconds when the decision was made.
137    pub timestamp: i64,
138    /// Raw routing score `[0, 1]`.
139    pub score: f64,
140    /// Whether this is a fallback from a previous failure.
141    pub is_fallback: bool,
142    /// Whether the decision was influenced by context length.
143    pub is_context_triggered: bool,
144    /// Whether the decision was forced by budget constraints.
145    pub is_budget_forced: bool,
146    /// Whether vision capability influenced this decision.
147    #[serde(default)]
148    pub is_vision_triggered: bool,
149    /// Number of image blocks that triggered vision routing.
150    #[serde(default)]
151    pub vision_images: usize,
152    /// Method used to make the decision.
153    pub decision_method: DecisionMethod,
154}
155
156// ── Tier Config ──────────────────────────────────────────────────────────────
157
158/// Configuration for a single tier within a profile.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct RoutedTierConfig {
161    /// Model in `"provider/model-id"` format (e.g. `"anthropic/claude-sonnet-4"`).
162    pub model: String,
163    /// Optional thinking level override for this tier.
164    #[serde(default)]
165    pub thinking: Option<ThinkingLevel>,
166    /// Ordered list of fallback model strings (`"provider/model-id"`).
167    #[serde(default)]
168    pub fallbacks: Vec<String>,
169}
170
171/// A named routing profile mapping tiers to model configs.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct RouterProfile {
174    /// High-tier model configuration.
175    pub high: RoutedTierConfig,
176    /// Medium-tier model configuration.
177    pub medium: RoutedTierConfig,
178    /// Low-tier model configuration.
179    pub low: RoutedTierConfig,
180}
181
182impl RouterProfile {
183    /// Get the tier config for a given tier.
184    pub fn tier_config(&self, tier: RouterTier) -> &RoutedTierConfig {
185        match tier {
186            RouterTier::High => &self.high,
187            RouterTier::Medium => &self.medium,
188            RouterTier::Low => &self.low,
189        }
190    }
191}
192
193// ── Scoring Weights ──────────────────────────────────────────────────────────
194
195/// Weights for combining routing signals into a composite score.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ScoringWeights {
198    /// Weight for structural signal (message/tool count, context size).
199    #[serde(default = "default_structural")]
200    pub structural: f64,
201    /// Weight for behavioral signal (phase detection, tool density).
202    #[serde(default = "default_behavioral")]
203    pub behavioral: f64,
204    /// Weight for context/budget signal.
205    #[serde(default = "default_context")]
206    pub context_budget: f64,
207    /// Weight for vision signal (image content requiring vision-capable model).
208    #[serde(default = "default_vision")]
209    pub vision: f64,
210    /// Weight for message content signal (language-agnostic structural analysis).
211    #[serde(default = "default_message")]
212    pub message: f64,
213}
214
215fn default_structural() -> f64 {
216    0.25
217}
218fn default_behavioral() -> f64 {
219    0.20
220}
221fn default_context() -> f64 {
222    0.15
223}
224fn default_vision() -> f64 {
225    0.10
226}
227fn default_message() -> f64 {
228    0.30
229}
230
231impl Default for ScoringWeights {
232    fn default() -> Self {
233        Self {
234            structural: default_structural(),
235            behavioral: default_behavioral(),
236            context_budget: default_context(),
237            vision: default_vision(),
238            message: default_message(),
239        }
240    }
241}
242
243// ── Router Config ─────────────────────────────────────────────────────────────
244
245/// Top-level router configuration.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct RouterConfig {
248    /// Default profile name to use.
249    #[serde(default = "default_profile_name")]
250    pub default_profile: String,
251    /// Optional classifier model for LLM-based routing.
252    #[serde(default)]
253    pub classifier_model: Option<String>,
254    /// Context token threshold that triggers automatic upgrade.
255    #[serde(default)]
256    pub context_upgrade_threshold: Option<usize>,
257    /// Maximum session budget in dollars (optional).
258    #[serde(default)]
259    pub max_session_budget: Option<f64>,
260    /// Named routing profiles.
261    #[serde(default)]
262    pub profiles: HashMap<String, RouterProfile>,
263    /// Scoring weights.
264    #[serde(default)]
265    pub weights: ScoringWeights,
266    /// Pinned tier (manual override).
267    #[serde(default)]
268    pub pin_tier: Option<RouterTier>,
269    /// Phase bias: 0 = immediate tier switching, 1 = extreme stickiness.
270    #[serde(default)]
271    pub phase_bias: Option<f64>,
272}
273
274fn default_profile_name() -> String {
275    "auto".to_string()
276}
277
278impl Default for RouterConfig {
279    fn default() -> Self {
280        Self {
281            default_profile: default_profile_name(),
282            classifier_model: None,
283            context_upgrade_threshold: None,
284            max_session_budget: None,
285            profiles: HashMap::new(),
286            weights: ScoringWeights::default(),
287            pin_tier: None,
288            phase_bias: None,
289        }
290    }
291}
292
293impl RouterConfig {
294    /// Construct a basic router config (no pin/phase_bias).
295    pub fn new(
296        default_profile: String,
297        classifier_model: Option<String>,
298        context_upgrade_threshold: Option<usize>,
299        max_session_budget: Option<f64>,
300        profiles: HashMap<String, RouterProfile>,
301        weights: ScoringWeights,
302    ) -> Self {
303        Self {
304            default_profile,
305            classifier_model,
306            context_upgrade_threshold,
307            max_session_budget,
308            profiles,
309            weights,
310            pin_tier: None,
311            phase_bias: None,
312        }
313    }
314
315    /// Construct with full config including pin_tier and phase_bias.
316    #[allow(clippy::too_many_arguments)]
317    pub fn with_pinning(
318        default_profile: String,
319        classifier_model: Option<String>,
320        context_upgrade_threshold: Option<usize>,
321        max_session_budget: Option<f64>,
322        profiles: HashMap<String, RouterProfile>,
323        weights: ScoringWeights,
324        pin_tier: Option<RouterTier>,
325        phase_bias: Option<f64>,
326    ) -> Self {
327        Self {
328            default_profile,
329            classifier_model,
330            context_upgrade_threshold,
331            max_session_budget,
332            profiles,
333            weights,
334            pin_tier,
335            phase_bias,
336        }
337    }
338}
339
340/// Session-scoped router state (for persistence across restarts).
341#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct RouterState {
343    /// Accumulated session cost in dollars.
344    #[serde(default)]
345    pub accumulated_cost: f64,
346    /// Decision history for this session.
347    #[serde(default)]
348    pub decision_history: Vec<RoutingDecision>,
349}