1use std::sync::Arc;
11
12use bitrouter_core::routers::admin::{AdminRoutingTable, DynamicRoute, RouteKind};
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 name: String,
53 kind: &'static str,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 strategy: Option<String>,
56 endpoints: Vec<AdminRouteEndpoint>,
57 source: &'static str,
58}
59
60#[derive(Serialize)]
61struct AdminRouteEndpoint {
62 provider: String,
63 service_id: String,
64}
65
66fn handle_list_routes<T: AdminRoutingTable>(table: Arc<T>) -> impl warp::Reply {
67 let mut entries: Vec<AdminRouteListEntry> = Vec::new();
68
69 for entry in table.list_routes() {
71 entries.push(AdminRouteListEntry {
72 name: entry.name,
73 kind: "model",
74 strategy: None,
75 endpoints: vec![AdminRouteEndpoint {
76 provider: entry.provider,
77 service_id: String::new(),
78 }],
79 source: "config",
80 });
81 }
82
83 for route in table.list_dynamic_routes() {
85 entries.retain(|e| e.name != route.name);
87
88 let kind = match route.kind {
89 RouteKind::Model => "model",
90 RouteKind::Tool => "tool",
91 RouteKind::Agent => "agent",
92 };
93 let strategy = match route.strategy {
94 bitrouter_core::routers::admin::RouteStrategy::Priority => "priority",
95 bitrouter_core::routers::admin::RouteStrategy::LoadBalance => "load_balance",
96 };
97 entries.push(AdminRouteListEntry {
98 name: route.name,
99 kind,
100 strategy: Some(strategy.to_owned()),
101 endpoints: route
102 .endpoints
103 .into_iter()
104 .map(|ep| AdminRouteEndpoint {
105 provider: ep.provider,
106 service_id: ep.service_id,
107 })
108 .collect(),
109 source: "dynamic",
110 });
111 }
112
113 entries.sort_by(|a, b| a.name.cmp(&b.name));
114 warp::reply::json(&serde_json::json!({ "routes": entries }))
115}
116
117fn create_route<T>(
120 table: Arc<T>,
121) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
122where
123 T: AdminRoutingTable + Send + Sync + 'static,
124{
125 warp::path!("admin" / "routes")
126 .and(warp::post())
127 .and(warp::body::json::<DynamicRoute>())
128 .and(warp::any().map(move || table.clone()))
129 .map(handle_create_route)
130}
131
132fn handle_create_route<T: AdminRoutingTable>(
133 route: DynamicRoute,
134 table: Arc<T>,
135) -> impl warp::Reply {
136 let name = route.name.clone();
137 match table.add_route(route) {
138 Ok(()) => warp::reply::with_status(
139 warp::reply::json(&serde_json::json!({
140 "status": "ok",
141 "name": name,
142 })),
143 warp::http::StatusCode::OK,
144 ),
145 Err(e) => warp::reply::with_status(
146 warp::reply::json(&serde_json::json!({
147 "error": { "message": e.to_string() }
148 })),
149 warp::http::StatusCode::BAD_REQUEST,
150 ),
151 }
152}
153
154fn delete_route<T>(
157 table: Arc<T>,
158) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
159where
160 T: AdminRoutingTable + Send + Sync + 'static,
161{
162 warp::path!("admin" / "routes" / String)
163 .and(warp::delete())
164 .and(warp::any().map(move || table.clone()))
165 .map(handle_delete_route)
166}
167
168fn handle_delete_route<T: AdminRoutingTable>(name: String, table: Arc<T>) -> impl warp::Reply {
169 match table.remove_route(&name) {
170 Ok(true) => warp::reply::with_status(
171 warp::reply::json(&serde_json::json!({
172 "status": "ok",
173 "name": name,
174 "removed": true,
175 })),
176 warp::http::StatusCode::OK,
177 ),
178 Ok(false) => warp::reply::with_status(
179 warp::reply::json(&serde_json::json!({
180 "error": { "message": format!("no dynamic route found for model: {name}") }
181 })),
182 warp::http::StatusCode::NOT_FOUND,
183 ),
184 Err(e) => warp::reply::with_status(
185 warp::reply::json(&serde_json::json!({
186 "error": { "message": e.to_string() }
187 })),
188 warp::http::StatusCode::INTERNAL_SERVER_ERROR,
189 ),
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use std::sync::Arc;
196
197 use bitrouter_core::errors::{BitrouterError, Result};
198 use bitrouter_core::routers::admin::{
199 AdminRoutingTable, DynamicRoute, RouteEndpoint, RouteKind, RouteStrategy,
200 };
201 use bitrouter_core::routers::content::RouteContext;
202 use bitrouter_core::routers::dynamic::DynamicRoutingTable;
203 use bitrouter_core::routers::routing_table::{
204 ApiProtocol, RouteEntry, RoutingTable, RoutingTarget,
205 };
206
207 use super::admin_routes_filter;
208
209 struct MockTable;
210
211 impl RoutingTable for MockTable {
212 async fn route(&self, incoming: &str, _context: &RouteContext) -> Result<RoutingTarget> {
213 if incoming == "default" {
214 Ok(RoutingTarget {
215 provider_name: "openai".to_owned(),
216 service_id: "gpt-4o".to_owned(),
217 api_protocol: ApiProtocol::Openai,
218 })
219 } else {
220 Err(BitrouterError::invalid_request(
221 None,
222 format!("no route: {incoming}"),
223 None,
224 ))
225 }
226 }
227
228 fn list_routes(&self) -> Vec<RouteEntry> {
229 vec![RouteEntry {
230 name: "default".to_owned(),
231 provider: "openai".to_owned(),
232 protocol: ApiProtocol::Openai,
233 }]
234 }
235 }
236
237 fn test_table() -> Arc<DynamicRoutingTable<MockTable>> {
238 Arc::new(DynamicRoutingTable::new(MockTable))
239 }
240
241 #[tokio::test]
242 async fn list_routes_returns_config_routes() {
243 let table = test_table();
244 let filter = admin_routes_filter(table);
245
246 let res = warp::test::request()
247 .method("GET")
248 .path("/admin/routes")
249 .reply(&filter)
250 .await;
251
252 assert_eq!(res.status(), 200);
253 let body: serde_json::Value = serde_json::from_slice(res.body()).unwrap();
254 let routes = body["routes"].as_array().unwrap();
255 assert_eq!(routes.len(), 1);
256 assert_eq!(routes[0]["name"], "default");
257 assert_eq!(routes[0]["kind"], "model");
258 assert_eq!(routes[0]["source"], "config");
259 }
260
261 #[tokio::test]
262 async fn create_route_success() {
263 let table = test_table();
264 let filter = admin_routes_filter(table.clone());
265
266 let body = serde_json::json!({
267 "name": "research",
268 "strategy": "load_balance",
269 "endpoints": [
270 { "provider": "openai", "service_id": "gpt-4o" },
271 { "provider": "anthropic", "service_id": "claude-sonnet-4-20250514" }
272 ]
273 });
274
275 let res = warp::test::request()
276 .method("POST")
277 .path("/admin/routes")
278 .json(&body)
279 .reply(&filter)
280 .await;
281
282 assert_eq!(res.status(), 200);
283 let resp: serde_json::Value = serde_json::from_slice(res.body()).unwrap();
284 assert_eq!(resp["status"], "ok");
285 assert_eq!(resp["name"], "research");
286
287 assert_eq!(table.list_dynamic_routes().len(), 1);
289 }
290
291 #[tokio::test]
292 async fn create_route_empty_endpoints_fails() {
293 let table = test_table();
294 let filter = admin_routes_filter(table);
295
296 let body = serde_json::json!({
297 "name": "empty",
298 "endpoints": []
299 });
300
301 let res = warp::test::request()
302 .method("POST")
303 .path("/admin/routes")
304 .json(&body)
305 .reply(&filter)
306 .await;
307
308 assert_eq!(res.status(), 400);
309 }
310
311 #[tokio::test]
312 async fn delete_route_success() {
313 let table = test_table();
314
315 table
317 .add_route(DynamicRoute {
318 name: "temp".to_owned(),
319 kind: RouteKind::Model,
320 strategy: RouteStrategy::Priority,
321 endpoints: vec![RouteEndpoint {
322 provider: "openai".to_owned(),
323 service_id: "gpt-4o".to_owned(),
324 api_protocol: None,
325 }],
326 })
327 .ok();
328
329 let filter = admin_routes_filter(table.clone());
330
331 let res = warp::test::request()
332 .method("DELETE")
333 .path("/admin/routes/temp")
334 .reply(&filter)
335 .await;
336
337 assert_eq!(res.status(), 200);
338 let resp: serde_json::Value = serde_json::from_slice(res.body()).unwrap();
339 assert_eq!(resp["removed"], true);
340 assert!(table.list_dynamic_routes().is_empty());
341 }
342
343 #[tokio::test]
344 async fn delete_nonexistent_route_returns_404() {
345 let table = test_table();
346 let filter = admin_routes_filter(table);
347
348 let res = warp::test::request()
349 .method("DELETE")
350 .path("/admin/routes/nope")
351 .reply(&filter)
352 .await;
353
354 assert_eq!(res.status(), 404);
355 }
356
357 #[tokio::test]
358 async fn dynamic_route_shadows_config_in_listing() {
359 let table = test_table();
360
361 table
363 .add_route(DynamicRoute {
364 name: "default".to_owned(),
365 kind: RouteKind::Model,
366 strategy: RouteStrategy::Priority,
367 endpoints: vec![RouteEndpoint {
368 provider: "anthropic".to_owned(),
369 service_id: "claude-sonnet-4-20250514".to_owned(),
370 api_protocol: None,
371 }],
372 })
373 .ok();
374
375 let filter = admin_routes_filter(table);
376
377 let res = warp::test::request()
378 .method("GET")
379 .path("/admin/routes")
380 .reply(&filter)
381 .await;
382
383 assert_eq!(res.status(), 200);
384 let body: serde_json::Value = serde_json::from_slice(res.body()).unwrap();
385 let routes = body["routes"].as_array().unwrap();
386
387 let default_routes: Vec<_> = routes.iter().filter(|r| r["name"] == "default").collect();
389 assert_eq!(default_routes.len(), 1);
390 assert_eq!(default_routes[0]["source"], "dynamic");
391 }
392
393 #[tokio::test]
394 async fn create_tool_route_success() {
395 let table = test_table();
396 let filter = admin_routes_filter(table.clone());
397
398 let body = serde_json::json!({
399 "name": "web_search",
400 "kind": "tool",
401 "strategy": "priority",
402 "endpoints": [
403 { "provider": "exa", "service_id": "search" }
404 ]
405 });
406
407 let res = warp::test::request()
408 .method("POST")
409 .path("/admin/routes")
410 .json(&body)
411 .reply(&filter)
412 .await;
413
414 assert_eq!(res.status(), 200);
415
416 let list_res = warp::test::request()
418 .method("GET")
419 .path("/admin/routes")
420 .reply(&filter)
421 .await;
422
423 let list_body: serde_json::Value = serde_json::from_slice(list_res.body()).unwrap();
424 let routes = list_body["routes"].as_array().unwrap();
425 let tool_route: Vec<_> = routes
426 .iter()
427 .filter(|r| r["name"] == "web_search")
428 .collect();
429 assert_eq!(tool_route.len(), 1);
430 assert_eq!(tool_route[0]["kind"], "tool");
431 assert_eq!(tool_route[0]["source"], "dynamic");
432 }
433}