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};
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    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    // Config-defined routes (from the inner table).
69    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    // Dynamic routes.
82    for route in table.list_dynamic_routes() {
83        // Remove any config entry that is shadowed by a dynamic route.
84        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
109// ── POST /admin/routes ───────────────────────────────────────────────
110
111fn 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
146// ── DELETE /admin/routes/:name ───────────────────────────────────────
147
148fn 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        // Verify the route was added.
275        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        // First add a dynamic route.
303        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        // Add a dynamic route that shadows "default".
347        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        // Should have only 1 entry for "default" (the dynamic one).
371        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}