Skip to main content

bitrouter_api/router/
admin.rs

1//! Warp filters for the admin route management API.
2//!
3//! Provides HTTP endpoints for managing routes at runtime without requiring
4//! config file rewrites and daemon restarts:
5//!
6//! - `GET /admin/routes` — list all routes (config-defined + dynamic)
7//! - `POST /admin/routes` — create or update a dynamic route
8//! - `DELETE /admin/routes/:name` — remove a dynamically-added route
9
10use std::sync::Arc;
11
12use bitrouter_core::routers::admin::{AdminRoutingTable, DynamicRoute, RouteKind};
13use serde::Serialize;
14use warp::Filter;
15
16/// Mount all admin route management endpoints under `/admin/routes`.
17///
18/// The caller is responsible for auth gating — this function does not apply
19/// any authentication. Compose with an auth filter before mounting:
20///
21/// ```ignore
22/// let admin = auth_gate(management_auth(ctx.clone()))
23///     .and(admin::admin_routes_filter(table.clone()));
24/// ```
25pub 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
36// ── GET /admin/routes ────────────────────────────────────────────────
37
38fn 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    // Config-defined routes (from the inner table).
70    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    // Dynamic routes.
84    for route in table.list_dynamic_routes() {
85        // Remove any config entry that is shadowed by a dynamic route.
86        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
117// ── POST /admin/routes ───────────────────────────────────────────────
118
119fn 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
154// ── DELETE /admin/routes/:name ───────────────────────────────────────
155
156fn 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        // Verify the route was added.
288        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        // First add a dynamic route.
316        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        // Add a dynamic route that shadows "default".
362        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        // Should have only 1 entry for "default" (the dynamic one).
388        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        // Verify kind is preserved in listing.
417        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}