1use std::sync::Arc;
11
12use bitrouter_core::routers::admin::{AdminRoutingTable, DynamicRoute};
13use serde::Serialize;
14use warp::Filter;
15
16pub fn admin_routes_filter<T>(
26 table: Arc<T>,
27) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
28where
29 T: AdminRoutingTable + Send + Sync + 'static,
30{
31 list_routes(table.clone())
32 .or(create_route(table.clone()))
33 .or(delete_route(table))
34}
35
36fn list_routes<T>(
39 table: Arc<T>,
40) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
41where
42 T: AdminRoutingTable + Send + Sync + 'static,
43{
44 warp::path!("admin" / "routes")
45 .and(warp::get())
46 .and(warp::any().map(move || table.clone()))
47 .map(handle_list_routes)
48}
49
50#[derive(Serialize)]
51struct AdminRouteListEntry {
52 model: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 strategy: Option<String>,
55 endpoints: Vec<AdminRouteEndpoint>,
56 source: &'static str,
57}
58
59#[derive(Serialize)]
60struct AdminRouteEndpoint {
61 provider: String,
62 model_id: String,
63}
64
65fn handle_list_routes<T: AdminRoutingTable>(table: Arc<T>) -> impl warp::Reply {
66 let mut entries: Vec<AdminRouteListEntry> = Vec::new();
67
68 for entry in table.list_routes() {
70 entries.push(AdminRouteListEntry {
71 model: entry.model,
72 strategy: None,
73 endpoints: vec![AdminRouteEndpoint {
74 provider: entry.provider,
75 model_id: String::new(),
76 }],
77 source: "config",
78 });
79 }
80
81 for route in table.list_dynamic_routes() {
83 entries.retain(|e| e.model != route.model);
85
86 let strategy = match route.strategy {
87 bitrouter_core::routers::admin::RouteStrategy::Priority => "priority",
88 bitrouter_core::routers::admin::RouteStrategy::LoadBalance => "load_balance",
89 };
90 entries.push(AdminRouteListEntry {
91 model: route.model,
92 strategy: Some(strategy.to_owned()),
93 endpoints: route
94 .endpoints
95 .into_iter()
96 .map(|ep| AdminRouteEndpoint {
97 provider: ep.provider,
98 model_id: ep.model_id,
99 })
100 .collect(),
101 source: "dynamic",
102 });
103 }
104
105 entries.sort_by(|a, b| a.model.cmp(&b.model));
106 warp::reply::json(&serde_json::json!({ "routes": entries }))
107}
108
109fn create_route<T>(
112 table: Arc<T>,
113) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
114where
115 T: AdminRoutingTable + Send + Sync + 'static,
116{
117 warp::path!("admin" / "routes")
118 .and(warp::post())
119 .and(warp::body::json::<DynamicRoute>())
120 .and(warp::any().map(move || table.clone()))
121 .map(handle_create_route)
122}
123
124fn handle_create_route<T: AdminRoutingTable>(
125 route: DynamicRoute,
126 table: Arc<T>,
127) -> impl warp::Reply {
128 let model = route.model.clone();
129 match table.add_route(route) {
130 Ok(()) => warp::reply::with_status(
131 warp::reply::json(&serde_json::json!({
132 "status": "ok",
133 "model": model,
134 })),
135 warp::http::StatusCode::OK,
136 ),
137 Err(e) => warp::reply::with_status(
138 warp::reply::json(&serde_json::json!({
139 "error": { "message": e.to_string() }
140 })),
141 warp::http::StatusCode::BAD_REQUEST,
142 ),
143 }
144}
145
146fn delete_route<T>(
149 table: Arc<T>,
150) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
151where
152 T: AdminRoutingTable + Send + Sync + 'static,
153{
154 warp::path!("admin" / "routes" / String)
155 .and(warp::delete())
156 .and(warp::any().map(move || table.clone()))
157 .map(handle_delete_route)
158}
159
160fn handle_delete_route<T: AdminRoutingTable>(name: String, table: Arc<T>) -> impl warp::Reply {
161 match table.remove_route(&name) {
162 Ok(true) => warp::reply::with_status(
163 warp::reply::json(&serde_json::json!({
164 "status": "ok",
165 "model": name,
166 "removed": true,
167 })),
168 warp::http::StatusCode::OK,
169 ),
170 Ok(false) => warp::reply::with_status(
171 warp::reply::json(&serde_json::json!({
172 "error": { "message": format!("no dynamic route found for model: {name}") }
173 })),
174 warp::http::StatusCode::NOT_FOUND,
175 ),
176 Err(e) => warp::reply::with_status(
177 warp::reply::json(&serde_json::json!({
178 "error": { "message": e.to_string() }
179 })),
180 warp::http::StatusCode::INTERNAL_SERVER_ERROR,
181 ),
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use std::sync::Arc;
188
189 use bitrouter_core::errors::{BitrouterError, Result};
190 use bitrouter_core::routers::admin::{
191 AdminRoutingTable, DynamicRoute, RouteEndpoint, RouteStrategy,
192 };
193 use bitrouter_core::routers::dynamic::DynamicRoutingTable;
194 use bitrouter_core::routers::routing_table::{RouteEntry, RoutingTable, RoutingTarget};
195
196 use super::admin_routes_filter;
197
198 struct MockTable;
199
200 impl RoutingTable for MockTable {
201 async fn route(&self, incoming: &str) -> Result<RoutingTarget> {
202 if incoming == "default" {
203 Ok(RoutingTarget {
204 provider_name: "openai".to_owned(),
205 model_id: "gpt-4o".to_owned(),
206 })
207 } else {
208 Err(BitrouterError::invalid_request(
209 None,
210 format!("no route: {incoming}"),
211 None,
212 ))
213 }
214 }
215
216 fn list_routes(&self) -> Vec<RouteEntry> {
217 vec![RouteEntry {
218 model: "default".to_owned(),
219 provider: "openai".to_owned(),
220 protocol: "openai".to_owned(),
221 }]
222 }
223 }
224
225 fn test_table() -> Arc<DynamicRoutingTable<MockTable>> {
226 Arc::new(DynamicRoutingTable::new(MockTable))
227 }
228
229 #[tokio::test]
230 async fn list_routes_returns_config_routes() {
231 let table = test_table();
232 let filter = admin_routes_filter(table);
233
234 let res = warp::test::request()
235 .method("GET")
236 .path("/admin/routes")
237 .reply(&filter)
238 .await;
239
240 assert_eq!(res.status(), 200);
241 let body: serde_json::Value = serde_json::from_slice(res.body()).unwrap();
242 let routes = body["routes"].as_array().unwrap();
243 assert_eq!(routes.len(), 1);
244 assert_eq!(routes[0]["model"], "default");
245 assert_eq!(routes[0]["source"], "config");
246 }
247
248 #[tokio::test]
249 async fn create_route_success() {
250 let table = test_table();
251 let filter = admin_routes_filter(table.clone());
252
253 let body = serde_json::json!({
254 "model": "research",
255 "strategy": "load_balance",
256 "endpoints": [
257 { "provider": "openai", "model_id": "gpt-4o" },
258 { "provider": "anthropic", "model_id": "claude-sonnet-4-20250514" }
259 ]
260 });
261
262 let res = warp::test::request()
263 .method("POST")
264 .path("/admin/routes")
265 .json(&body)
266 .reply(&filter)
267 .await;
268
269 assert_eq!(res.status(), 200);
270 let resp: serde_json::Value = serde_json::from_slice(res.body()).unwrap();
271 assert_eq!(resp["status"], "ok");
272 assert_eq!(resp["model"], "research");
273
274 assert_eq!(table.list_dynamic_routes().len(), 1);
276 }
277
278 #[tokio::test]
279 async fn create_route_empty_endpoints_fails() {
280 let table = test_table();
281 let filter = admin_routes_filter(table);
282
283 let body = serde_json::json!({
284 "model": "empty",
285 "endpoints": []
286 });
287
288 let res = warp::test::request()
289 .method("POST")
290 .path("/admin/routes")
291 .json(&body)
292 .reply(&filter)
293 .await;
294
295 assert_eq!(res.status(), 400);
296 }
297
298 #[tokio::test]
299 async fn delete_route_success() {
300 let table = test_table();
301
302 table
304 .add_route(DynamicRoute {
305 model: "temp".to_owned(),
306 strategy: RouteStrategy::Priority,
307 endpoints: vec![RouteEndpoint {
308 provider: "openai".to_owned(),
309 model_id: "gpt-4o".to_owned(),
310 }],
311 })
312 .ok();
313
314 let filter = admin_routes_filter(table.clone());
315
316 let res = warp::test::request()
317 .method("DELETE")
318 .path("/admin/routes/temp")
319 .reply(&filter)
320 .await;
321
322 assert_eq!(res.status(), 200);
323 let resp: serde_json::Value = serde_json::from_slice(res.body()).unwrap();
324 assert_eq!(resp["removed"], true);
325 assert!(table.list_dynamic_routes().is_empty());
326 }
327
328 #[tokio::test]
329 async fn delete_nonexistent_route_returns_404() {
330 let table = test_table();
331 let filter = admin_routes_filter(table);
332
333 let res = warp::test::request()
334 .method("DELETE")
335 .path("/admin/routes/nope")
336 .reply(&filter)
337 .await;
338
339 assert_eq!(res.status(), 404);
340 }
341
342 #[tokio::test]
343 async fn dynamic_route_shadows_config_in_listing() {
344 let table = test_table();
345
346 table
348 .add_route(DynamicRoute {
349 model: "default".to_owned(),
350 strategy: RouteStrategy::Priority,
351 endpoints: vec![RouteEndpoint {
352 provider: "anthropic".to_owned(),
353 model_id: "claude-sonnet-4-20250514".to_owned(),
354 }],
355 })
356 .ok();
357
358 let filter = admin_routes_filter(table);
359
360 let res = warp::test::request()
361 .method("GET")
362 .path("/admin/routes")
363 .reply(&filter)
364 .await;
365
366 assert_eq!(res.status(), 200);
367 let body: serde_json::Value = serde_json::from_slice(res.body()).unwrap();
368 let routes = body["routes"].as_array().unwrap();
369
370 let default_routes: Vec<_> = routes.iter().filter(|r| r["model"] == "default").collect();
372 assert_eq!(default_routes.len(), 1);
373 assert_eq!(default_routes[0]["source"], "dynamic");
374 }
375}