Skip to main content

oxicode_sdk/
routing.rs

1//! Runtime routing control — dynamic model routing and fallback management.
2
3use parking_lot::RwLock;
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7
8/// Runtime routing configuration.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct RoutingConfig {
11    /// Whether automatic routing is enabled.
12    pub auto_routing: bool,
13    /// Prefer cost-efficient models when routing.
14    pub prefer_cost_efficient: bool,
15    /// Fallback models to try when the primary model fails.
16    pub fallback_models: Vec<String>,
17    /// Models to exclude from routing (e.g., due to outages).
18    pub excluded_models: Vec<String>,
19}
20
21impl Default for RoutingConfig {
22    fn default() -> Self {
23        Self {
24            auto_routing: true,
25            prefer_cost_efficient: false,
26            fallback_models: Vec::new(),
27            excluded_models: Vec::new(),
28        }
29    }
30}
31
32/// Runtime routing control interface.
33///
34/// Allows dynamic toggling of routing, swapping fallback models,
35/// and excluding specific models at runtime.
36#[derive(Debug, Clone)]
37pub struct RoutingControl {
38    enabled: Arc<AtomicBool>,
39    config: Arc<RwLock<RoutingConfig>>,
40}
41
42impl RoutingControl {
43    /// Create a new routing control with the given config.
44    pub fn new(config: RoutingConfig) -> Self {
45        Self {
46            enabled: Arc::new(AtomicBool::new(config.auto_routing)),
47            config: Arc::new(RwLock::new(config)),
48        }
49    }
50
51    /// Create a disabled routing control.
52    pub fn disabled() -> Self {
53        Self {
54            enabled: Arc::new(AtomicBool::new(false)),
55            config: Arc::new(RwLock::new(RoutingConfig::default())),
56        }
57    }
58
59    /// Enable or disable routing.
60    pub fn set_enabled(&self, enabled: bool) {
61        self.enabled.store(enabled, Ordering::SeqCst);
62    }
63
64    /// Whether routing is currently enabled.
65    pub fn is_enabled(&self) -> bool {
66        self.enabled.load(Ordering::SeqCst)
67    }
68
69    /// Update the routing configuration.
70    pub fn update_config(&self, f: impl FnOnce(&mut RoutingConfig)) {
71        f(&mut self.config.write());
72    }
73
74    /// Replace the fallback model list.
75    pub fn set_fallback_models(&self, models: Vec<String>) {
76        self.config.write().fallback_models = models;
77    }
78
79    /// Exclude a specific model from routing.
80    pub fn exclude_model(&self, model_id: &str) {
81        let mut config = self.config.write();
82        if !config.excluded_models.contains(&model_id.to_string()) {
83            config.excluded_models.push(model_id.to_string());
84        }
85    }
86
87    /// Remove a model from the exclusion list.
88    pub fn unexclude_model(&self, model_id: &str) {
89        self.config
90            .write()
91            .excluded_models
92            .retain(|m| m != model_id);
93    }
94
95    /// Get the current routing config.
96    pub fn config(&self) -> RoutingConfig {
97        self.config.read().clone()
98    }
99
100    /// Get the fallback models.
101    pub fn fallback_models(&self) -> Vec<String> {
102        self.config.read().fallback_models.clone()
103    }
104
105    /// Get the excluded models.
106    pub fn excluded_models(&self) -> Vec<String> {
107        self.config.read().excluded_models.clone()
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn routing_control_default() {
117        let rc = RoutingControl::new(RoutingConfig::default());
118        assert!(rc.is_enabled());
119    }
120
121    #[test]
122    fn routing_control_toggle() {
123        let rc = RoutingControl::new(RoutingConfig::default());
124        rc.set_enabled(false);
125        assert!(!rc.is_enabled());
126        rc.set_enabled(true);
127        assert!(rc.is_enabled());
128    }
129
130    #[test]
131    fn routing_control_disabled() {
132        let rc = RoutingControl::disabled();
133        assert!(!rc.is_enabled());
134    }
135
136    #[test]
137    fn routing_control_fallback_models() {
138        let rc = RoutingControl::new(RoutingConfig::default());
139        rc.set_fallback_models(vec!["model-a".into(), "model-b".into()]);
140        assert_eq!(rc.fallback_models().len(), 2);
141    }
142
143    #[test]
144    fn routing_control_exclude_model() {
145        let rc = RoutingControl::new(RoutingConfig::default());
146        rc.exclude_model("bad-model");
147        assert!(rc.excluded_models().contains(&"bad-model".to_string()));
148        rc.unexclude_model("bad-model");
149        assert!(!rc.excluded_models().contains(&"bad-model".to_string()));
150    }
151
152    #[test]
153    fn routing_control_update_config() {
154        let rc = RoutingControl::new(RoutingConfig::default());
155        rc.update_config(|c| {
156            c.prefer_cost_efficient = true;
157        });
158        assert!(rc.config().prefer_cost_efficient);
159    }
160
161    #[test]
162    fn routing_control_no_duplicate_exclusion() {
163        let rc = RoutingControl::new(RoutingConfig::default());
164        rc.exclude_model("model-1");
165        rc.exclude_model("model-1");
166        assert_eq!(rc.excluded_models().len(), 1);
167    }
168
169    /// Mutations through one clone are visible through another —
170    /// this is what makes RoutingControl "live" across the
171    /// supervisor / handle / resolver boundary. The Arc-backed
172    /// inner state means callers can hold a clone and observe
173    /// runtime reconfiguration without re-fetching.
174    #[test]
175    fn routing_control_live_across_clones() {
176        let rc = RoutingControl::new(RoutingConfig::default());
177        let observer = rc.clone();
178
179        // Mutate via the original.
180        rc.set_enabled(false);
181        rc.exclude_model("primary-model");
182        rc.set_fallback_models(vec!["fallback-a".into(), "fallback-b".into()]);
183
184        // Observe via the clone — state is shared, not copied.
185        assert!(
186            !observer.is_enabled(),
187            "set_enabled must propagate to clones"
188        );
189        assert!(
190            observer
191                .excluded_models()
192                .contains(&"primary-model".to_string()),
193            "exclude_model must propagate to clones"
194        );
195        assert_eq!(
196            observer.fallback_models().len(),
197            2,
198            "set_fallback_models must propagate to clones"
199        );
200
201        // Reverse direction works too.
202        observer.unexclude_model("primary-model");
203        assert!(
204            !rc.excluded_models().contains(&"primary-model".to_string()),
205            "unexclude_model via clone must propagate back"
206        );
207    }
208
209    /// The host's resolver can call `config()` at provider-resolution
210    /// time to read the live config snapshot. This test verifies the
211    /// returned `RoutingConfig` is a consistent point-in-time copy
212    /// (snapshot semantics — not a live reference).
213    #[test]
214    fn routing_control_config_snapshot_is_point_in_time() {
215        let rc = RoutingControl::new(RoutingConfig::default());
216        let snap = rc.config();
217        rc.exclude_model("later-exclusion");
218        // snap was taken BEFORE the mutation — must not reflect it.
219        assert!(
220            !snap
221                .excluded_models
222                .contains(&"later-exclusion".to_string())
223        );
224        // A fresh read reflects it.
225        assert!(
226            rc.excluded_models()
227                .contains(&"later-exclusion".to_string())
228        );
229    }
230}