1use 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
18struct DynamicRouteData {
20 strategy: RouteStrategy,
21 endpoints: Vec<RouteEndpoint>,
22 counter: AtomicUsize,
23}
24
25pub struct DynamicRoutingTable<T> {
30 inner: T,
31 routes: RwLock<HashMap<String, DynamicRouteData>>,
32}
33
34impl<T> DynamicRoutingTable<T> {
35 pub fn new(inner: T) -> Self {
37 Self {
38 inner,
39 routes: RwLock::new(HashMap::new()),
40 }
41 }
42
43 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 if let Some(target) = self.resolve_dynamic(incoming_model_name) {
73 return Ok(target);
74 }
75 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 entries.retain(|e| !routes.contains_key(&e.model));
85
86 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 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 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"); }
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}