Skip to main content

bitrouter_core/routers/
dynamic.rs

1//! A routing table wrapper that adds dynamic route management.
2//!
3//! [`DynamicRoutingTable`] wraps any [`RoutingTable`] and layers an in-memory
4//! set of dynamic routes on top. Dynamic routes take precedence over the inner
5//! table during resolution. All mutations are protected by a [`RwLock`].
6//!
7//! Dynamic routes are ephemeral — they are lost when the process exits.
8
9use std::collections::HashMap;
10use std::sync::RwLock;
11use std::sync::atomic::{AtomicUsize, Ordering};
12
13use crate::errors::{BitrouterError, Result};
14
15use super::admin::{AdminRoutingTable, DynamicRoute, RouteEndpoint, RouteStrategy};
16use super::routing_table::{ModelEntry, RouteEntry, RoutingTable, RoutingTarget};
17
18/// Internal representation of a dynamic route with its round-robin counter.
19struct DynamicRouteData {
20    strategy: RouteStrategy,
21    endpoints: Vec<RouteEndpoint>,
22    counter: AtomicUsize,
23}
24
25/// A routing table wrapper that adds dynamic route management.
26///
27/// Wraps any `T: RoutingTable` and layers an in-memory set of dynamic routes
28/// on top. Dynamic routes take precedence during resolution.
29pub struct DynamicRoutingTable<T> {
30    inner: T,
31    routes: RwLock<HashMap<String, DynamicRouteData>>,
32}
33
34impl<T> DynamicRoutingTable<T> {
35    /// Create a new dynamic routing table wrapping the given inner table.
36    pub fn new(inner: T) -> Self {
37        Self {
38            inner,
39            routes: RwLock::new(HashMap::new()),
40        }
41    }
42
43    /// Resolve a model name against dynamic routes only.
44    ///
45    /// Returns `None` if no dynamic route matches.
46    fn resolve_dynamic(&self, model: &str) -> Option<RoutingTarget> {
47        let routes = self.routes.read().ok()?;
48        let data = routes.get(model)?;
49
50        if data.endpoints.is_empty() {
51            return None;
52        }
53
54        let endpoint = match data.strategy {
55            RouteStrategy::Priority => &data.endpoints[0],
56            RouteStrategy::LoadBalance => {
57                let idx = data.counter.fetch_add(1, Ordering::Relaxed) % data.endpoints.len();
58                &data.endpoints[idx]
59            }
60        };
61
62        Some(RoutingTarget {
63            provider_name: endpoint.provider.clone(),
64            model_id: endpoint.model_id.clone(),
65        })
66    }
67}
68
69impl<T: RoutingTable + Sync> RoutingTable for DynamicRoutingTable<T> {
70    async fn route(&self, incoming_model_name: &str) -> Result<RoutingTarget> {
71        // Dynamic routes take precedence.
72        if let Some(target) = self.resolve_dynamic(incoming_model_name) {
73            return Ok(target);
74        }
75        // Fall back to the inner table.
76        self.inner.route(incoming_model_name).await
77    }
78
79    fn list_routes(&self) -> Vec<RouteEntry> {
80        let mut entries = self.inner.list_routes();
81
82        if let Ok(routes) = self.routes.read() {
83            // Remove config entries that are shadowed by dynamic routes.
84            entries.retain(|e| !routes.contains_key(&e.model));
85
86            // Append dynamic route entries.
87            for (model, data) in routes.iter() {
88                if let Some(ep) = data.endpoints.first() {
89                    entries.push(RouteEntry {
90                        model: model.clone(),
91                        provider: ep.provider.clone(),
92                        // Dynamic routes don't track protocol; default to provider name.
93                        protocol: ep.provider.clone(),
94                    });
95                }
96            }
97        }
98
99        entries.sort_by(|a, b| a.model.cmp(&b.model));
100        entries
101    }
102
103    fn list_models(&self) -> Vec<ModelEntry> {
104        self.inner.list_models()
105    }
106}
107
108impl<T: RoutingTable + Sync> AdminRoutingTable for DynamicRoutingTable<T> {
109    fn add_route(&self, route: DynamicRoute) -> Result<()> {
110        if route.endpoints.is_empty() {
111            return Err(BitrouterError::invalid_request(
112                None,
113                "route must have at least one endpoint".to_owned(),
114                None,
115            ));
116        }
117
118        let data = DynamicRouteData {
119            strategy: route.strategy,
120            endpoints: route.endpoints,
121            counter: AtomicUsize::new(0),
122        };
123
124        let mut routes = self
125            .routes
126            .write()
127            .map_err(|_| BitrouterError::transport(None, "routing table lock poisoned"))?;
128        routes.insert(route.model, data);
129        Ok(())
130    }
131
132    fn remove_route(&self, model: &str) -> Result<bool> {
133        let mut routes = self
134            .routes
135            .write()
136            .map_err(|_| BitrouterError::transport(None, "routing table lock poisoned"))?;
137        Ok(routes.remove(model).is_some())
138    }
139
140    fn list_dynamic_routes(&self) -> Vec<DynamicRoute> {
141        let routes = match self.routes.read() {
142            Ok(r) => r,
143            Err(_) => return Vec::new(),
144        };
145        let mut result: Vec<DynamicRoute> = routes
146            .iter()
147            .map(|(model, data)| DynamicRoute {
148                model: model.clone(),
149                strategy: data.strategy.clone(),
150                endpoints: data.endpoints.clone(),
151            })
152            .collect();
153        result.sort_by(|a, b| a.model.cmp(&b.model));
154        result
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    struct StaticTable;
163
164    impl RoutingTable for StaticTable {
165        async fn route(&self, incoming: &str) -> Result<RoutingTarget> {
166            if incoming == "default" {
167                Ok(RoutingTarget {
168                    provider_name: "openai".to_owned(),
169                    model_id: "gpt-4o".to_owned(),
170                })
171            } else {
172                Err(BitrouterError::invalid_request(
173                    None,
174                    format!("no route: {incoming}"),
175                    None,
176                ))
177            }
178        }
179
180        fn list_routes(&self) -> Vec<RouteEntry> {
181            vec![RouteEntry {
182                model: "default".to_owned(),
183                provider: "openai".to_owned(),
184                protocol: "openai".to_owned(),
185            }]
186        }
187    }
188
189    /// Helper to call the trait method with explicit type annotation.
190    async fn route(table: &DynamicRoutingTable<StaticTable>, model: &str) -> Result<RoutingTarget> {
191        <DynamicRoutingTable<StaticTable> as RoutingTable>::route(table, model).await
192    }
193
194    #[tokio::test]
195    async fn dynamic_route_takes_precedence() {
196        let table = DynamicRoutingTable::new(StaticTable);
197        table
198            .add_route(DynamicRoute {
199                model: "default".to_owned(),
200                strategy: RouteStrategy::Priority,
201                endpoints: vec![RouteEndpoint {
202                    provider: "anthropic".to_owned(),
203                    model_id: "claude-sonnet-4-20250514".to_owned(),
204                }],
205            })
206            .ok();
207
208        let target = route(&table, "default").await.ok();
209        assert!(target.is_some());
210        let target = target.unwrap();
211        assert_eq!(target.provider_name, "anthropic");
212        assert_eq!(target.model_id, "claude-sonnet-4-20250514");
213    }
214
215    #[tokio::test]
216    async fn falls_back_to_inner_table() {
217        let table = DynamicRoutingTable::new(StaticTable);
218
219        let target = route(&table, "default").await.ok();
220        assert!(target.is_some());
221        let target = target.unwrap();
222        assert_eq!(target.provider_name, "openai");
223        assert_eq!(target.model_id, "gpt-4o");
224    }
225
226    #[tokio::test]
227    async fn add_and_remove_dynamic_route() {
228        let table = DynamicRoutingTable::new(StaticTable);
229
230        table
231            .add_route(DynamicRoute {
232                model: "research".to_owned(),
233                strategy: RouteStrategy::Priority,
234                endpoints: vec![RouteEndpoint {
235                    provider: "openai".to_owned(),
236                    model_id: "o1".to_owned(),
237                }],
238            })
239            .ok();
240
241        assert!(route(&table, "research").await.is_ok());
242        assert_eq!(table.list_dynamic_routes().len(), 1);
243
244        let removed = table.remove_route("research").ok();
245        assert_eq!(removed, Some(true));
246        assert!(route(&table, "research").await.is_err());
247        assert!(table.list_dynamic_routes().is_empty());
248    }
249
250    #[test]
251    fn remove_nonexistent_returns_false() {
252        let table = DynamicRoutingTable::new(StaticTable);
253        let removed = table.remove_route("nope").ok();
254        assert_eq!(removed, Some(false));
255    }
256
257    #[test]
258    fn add_route_with_no_endpoints_fails() {
259        let table = DynamicRoutingTable::new(StaticTable);
260        let result = table.add_route(DynamicRoute {
261            model: "empty".to_owned(),
262            strategy: RouteStrategy::Priority,
263            endpoints: vec![],
264        });
265        assert!(result.is_err());
266    }
267
268    #[tokio::test]
269    async fn load_balance_round_robin() {
270        let table = DynamicRoutingTable::new(StaticTable);
271        table
272            .add_route(DynamicRoute {
273                model: "balanced".to_owned(),
274                strategy: RouteStrategy::LoadBalance,
275                endpoints: vec![
276                    RouteEndpoint {
277                        provider: "openai".to_owned(),
278                        model_id: "gpt-4o".to_owned(),
279                    },
280                    RouteEndpoint {
281                        provider: "anthropic".to_owned(),
282                        model_id: "claude-sonnet-4-20250514".to_owned(),
283                    },
284                ],
285            })
286            .ok();
287
288        let t1 = route(&table, "balanced").await.ok().unwrap();
289        let t2 = route(&table, "balanced").await.ok().unwrap();
290        let t3 = route(&table, "balanced").await.ok().unwrap();
291
292        assert_eq!(t1.provider_name, "openai");
293        assert_eq!(t2.provider_name, "anthropic");
294        assert_eq!(t3.provider_name, "openai"); // wraps around
295    }
296
297    #[test]
298    fn list_routes_includes_dynamic() {
299        let table = DynamicRoutingTable::new(StaticTable);
300        table
301            .add_route(DynamicRoute {
302                model: "custom".to_owned(),
303                strategy: RouteStrategy::Priority,
304                endpoints: vec![RouteEndpoint {
305                    provider: "anthropic".to_owned(),
306                    model_id: "claude-sonnet-4-20250514".to_owned(),
307                }],
308            })
309            .ok();
310
311        let routes = table.list_routes();
312        assert!(routes.iter().any(|r| r.model == "custom"));
313        assert!(routes.iter().any(|r| r.model == "default"));
314    }
315
316    #[test]
317    fn dynamic_route_shadows_config_in_list() {
318        let table = DynamicRoutingTable::new(StaticTable);
319        table
320            .add_route(DynamicRoute {
321                model: "default".to_owned(),
322                strategy: RouteStrategy::Priority,
323                endpoints: vec![RouteEndpoint {
324                    provider: "anthropic".to_owned(),
325                    model_id: "claude-sonnet-4-20250514".to_owned(),
326                }],
327            })
328            .ok();
329
330        let routes = table.list_routes();
331        let defaults: Vec<_> = routes.iter().filter(|r| r.model == "default").collect();
332        assert_eq!(defaults.len(), 1);
333        assert_eq!(defaults[0].provider, "anthropic");
334    }
335}