Skip to main content

sim_lib_openai_server/routes/
admin.rs

1use std::collections::BTreeMap;
2
3use serde_json::{Map, Number, Value};
4use sim_kernel::{CapabilityName, ContentId, Expr, NumberLiteral, Symbol};
5use sim_lib_net_core::hex_encode;
6use sim_value::access::field_any;
7
8use crate::{
9    capabilities::OPENAI_GATEWAY_ADMIN_CAPABILITY,
10    objects::{GatewayEvent, GatewayRequest, GatewayResponse, GatewayRun, content_id_expr},
11    server::GatewayRouteState,
12    storage::GatewayStoreCounts,
13};
14
15use super::errors::OpenAiRouteError;
16
17/// Route path listing all recorded gateway runs.
18pub const ADMIN_RUNS_PATH: &str = "/v1/sim/admin/runs";
19/// Route template for retrieving a single run by id.
20pub const ADMIN_RUN_RETRIEVAL_ROUTE: &str = "/v1/sim/admin/runs/{id}";
21/// Path prefix stripped to extract a run id from a run-retrieval request.
22pub const ADMIN_RUN_RETRIEVAL_PREFIX: &str = "/v1/sim/admin/runs/";
23/// Route path listing all recorded gateway events.
24pub const ADMIN_EVENTS_PATH: &str = "/v1/sim/admin/events";
25/// Route path reporting storage object counts and latency stats.
26pub const ADMIN_STORAGE_STATS_PATH: &str = "/v1/sim/admin/storage-stats";
27/// Route path reporting backend and registered-model health.
28pub const ADMIN_MODEL_HEALTH_PATH: &str = "/v1/sim/admin/model-health";
29/// Route path reporting plan-cache statistics.
30pub const ADMIN_CACHE_STATS_PATH: &str = "/v1/sim/admin/cache-stats";
31/// Route path reporting key and capability configuration.
32pub const ADMIN_CAPABILITY_REPORT_PATH: &str = "/v1/sim/admin/capability-report";
33
34type RouteResult<T> = std::result::Result<T, OpenAiRouteError>;
35
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
37struct AdminCounters {
38    request_count: usize,
39    run_count: usize,
40    event_count: usize,
41    error_count: usize,
42    stream_count: usize,
43    active_streams: usize,
44}
45
46impl AdminCounters {
47    fn from_ledger(
48        counts: GatewayStoreCounts,
49        requests: &[(ContentId, GatewayRequest)],
50        events: &[(ContentId, GatewayEvent)],
51    ) -> Self {
52        Self {
53            request_count: counts.request_count,
54            run_count: counts.run_count,
55            event_count: counts.event_count,
56            error_count: events
57                .iter()
58                .filter(|(_, event)| event.kind().name.as_ref() == "error")
59                .count(),
60            stream_count: requests
61                .iter()
62                .filter(|(_, request)| request_streams(request))
63                .count(),
64            active_streams: 0,
65        }
66    }
67
68    fn to_expr(&self) -> Expr {
69        Expr::Map(vec![
70            field("request-count", usize_expr(self.request_count)),
71            field("run-count", usize_expr(self.run_count)),
72            field("event-count", usize_expr(self.event_count)),
73            field("error-count", usize_expr(self.error_count)),
74            field("stream-count", usize_expr(self.stream_count)),
75            field("active-streams", usize_expr(self.active_streams)),
76        ])
77    }
78}
79
80/// Handles the admin runs listing, returning every recorded run as JSON.
81pub fn handle_admin_runs(request: &GatewayRequest, state: &GatewayRouteState) -> GatewayResponse {
82    admin_json_response(request, state, || runs_expr(state))
83}
84
85/// Handles admin retrieval of a single run by the id embedded in the path.
86pub fn handle_admin_run_get(
87    request: &GatewayRequest,
88    state: &GatewayRouteState,
89) -> GatewayResponse {
90    admin_json_response(request, state, || {
91        let Some(run_id) = run_id_from_path(request.path()) else {
92            return Err(OpenAiRouteError::not_found_kind("run", request.path()));
93        };
94        run_get_expr(state, run_id)
95    })
96}
97
98/// Handles the admin events listing, returning every recorded event as JSON.
99pub fn handle_admin_events(request: &GatewayRequest, state: &GatewayRouteState) -> GatewayResponse {
100    admin_json_response(request, state, || events_expr(state))
101}
102
103/// Handles the admin storage-stats report (object counts and latencies).
104pub fn handle_admin_storage_stats(
105    request: &GatewayRequest,
106    state: &GatewayRouteState,
107) -> GatewayResponse {
108    admin_json_response(request, state, || storage_stats_expr(state))
109}
110
111/// Handles the admin model-health report over the registered runners.
112pub fn handle_admin_model_health(
113    request: &GatewayRequest,
114    state: &GatewayRouteState,
115) -> GatewayResponse {
116    admin_json_response(request, state, || model_health_expr(state))
117}
118
119/// Handles the admin plan-cache statistics report.
120pub fn handle_admin_cache_stats(
121    request: &GatewayRequest,
122    state: &GatewayRouteState,
123) -> GatewayResponse {
124    admin_json_response(request, state, || cache_stats_expr(state))
125}
126
127/// Handles the admin capability report (admin capability id, key count, and
128/// anonymous capability grants).
129pub fn handle_admin_capability_report(
130    request: &GatewayRequest,
131    state: &GatewayRouteState,
132) -> GatewayResponse {
133    admin_json_response(request, state, || capability_report_expr(state))
134}
135
136pub(crate) fn runs_expr(state: &GatewayRouteState) -> RouteResult<Expr> {
137    let store = state.store().lock().map_err(|err| {
138        OpenAiRouteError::internal_message(format!("gateway store lock failed: {err}"))
139    })?;
140    Ok(list_expr(
141        "openai-gateway/runs",
142        store
143            .runs()
144            .into_iter()
145            .map(|(id, run)| run_expr(&id, &run))
146            .collect(),
147    ))
148}
149
150pub(crate) fn run_get_expr(state: &GatewayRouteState, run_id: &str) -> RouteResult<Expr> {
151    let store = state.store().lock().map_err(|err| {
152        OpenAiRouteError::internal_message(format!("gateway store lock failed: {err}"))
153    })?;
154    store
155        .run_by_id(run_id)
156        .map(|(id, run)| run_expr(&id, &run))
157        .ok_or_else(|| OpenAiRouteError::not_found_kind("run", run_id))
158}
159
160pub(crate) fn events_expr(state: &GatewayRouteState) -> RouteResult<Expr> {
161    let store = state.store().lock().map_err(|err| {
162        OpenAiRouteError::internal_message(format!("gateway store lock failed: {err}"))
163    })?;
164    Ok(list_expr(
165        "openai-gateway/events",
166        store
167            .events()
168            .into_iter()
169            .map(|(id, event)| event_expr(&id, &event))
170            .collect(),
171    ))
172}
173
174pub(crate) fn storage_stats_expr(state: &GatewayRouteState) -> RouteResult<Expr> {
175    let store = state.store().lock().map_err(|err| {
176        OpenAiRouteError::internal_message(format!("gateway store lock failed: {err}"))
177    })?;
178    let counts = store.counts();
179    let requests = store.requests();
180    let events = store.events();
181    let counters = AdminCounters::from_ledger(counts, &requests, &events);
182    Ok(Expr::Map(vec![
183        field(
184            "object",
185            Expr::String("openai-gateway/storage-stats".to_owned()),
186        ),
187        field("counters", counters.to_expr()),
188        field("storage-object-counts", storage_counts_expr(counts)),
189        field(
190            "average-latency-ms-by-model-id",
191            average_latency_expr(&events),
192        ),
193        field(
194            "rejected-request-counts-by-error-code",
195            Expr::Map(Vec::new()),
196        ),
197        field("capability-denial-counts", Expr::Map(Vec::new())),
198    ]))
199}
200
201pub(crate) fn model_health_expr(state: &GatewayRouteState) -> RouteResult<Expr> {
202    let cards = state.runners().cards();
203    Ok(Expr::Map(vec![
204        field(
205            "object",
206            Expr::String("openai-gateway/model-health".to_owned()),
207        ),
208        field("backend", Expr::Symbol(Symbol::new("sim"))),
209        field("fixture-health", Expr::Symbol(Symbol::new("ok"))),
210        field("registered-runner-count", usize_expr(cards.len())),
211        field(
212            "registered-models",
213            Expr::List(
214                cards
215                    .into_iter()
216                    .map(|card| {
217                        Expr::Map(vec![
218                            field("model", Expr::String(card.model)),
219                            field("provider", Expr::Symbol(card.provider)),
220                            field("health", Expr::Symbol(Symbol::new("unknown"))),
221                        ])
222                    })
223                    .collect(),
224            ),
225        ),
226    ]))
227}
228
229pub(crate) fn cache_stats_expr(state: &GatewayRouteState) -> RouteResult<Expr> {
230    let cache = state.cache().lock().map_err(|err| {
231        OpenAiRouteError::internal_message(format!("gateway cache lock failed: {err}"))
232    })?;
233    Ok(Expr::Map(vec![
234        field(
235            "object",
236            Expr::String("openai-gateway/cache-stats".to_owned()),
237        ),
238        field("entry-count", usize_expr(cache.len())),
239        field("hit-count", usize_expr(0)),
240        field("miss-count", usize_expr(0)),
241        field("hit-rate", float_expr(0.0)),
242    ]))
243}
244
245pub(crate) fn capability_report_expr(state: &GatewayRouteState) -> RouteResult<Expr> {
246    let keys = state
247        .keys()
248        .list_keys()
249        .map_err(OpenAiRouteError::internal)?;
250    let anonymous = state
251        .keys()
252        .effective_capabilities(&GatewayRequest::get("/"))
253        .map_err(OpenAiRouteError::internal)?;
254    Ok(Expr::Map(vec![
255        field(
256            "object",
257            Expr::String("openai-gateway/capability-report".to_owned()),
258        ),
259        field(
260            "admin-capability",
261            Expr::String(OPENAI_GATEWAY_ADMIN_CAPABILITY.to_owned()),
262        ),
263        field("key-count", usize_expr(keys.len())),
264        field(
265            "anonymous-capability-count",
266            usize_expr(anonymous.iter().count()),
267        ),
268        field("capability-denial-count", usize_expr(0)),
269        field(
270            "keys",
271            Expr::List(keys.into_iter().map(|key| key.to_expr()).collect()),
272        ),
273    ]))
274}
275
276pub(crate) fn admin_expr_json(expr: &Expr) -> Value {
277    match expr {
278        Expr::Nil => Value::Null,
279        Expr::Bool(value) => Value::Bool(*value),
280        Expr::Number(value) => number_json(value),
281        Expr::String(value) => Value::String(value.clone()),
282        Expr::Symbol(symbol) | Expr::Local(symbol) => Value::String(symbol.to_string()),
283        Expr::Bytes(bytes) => Value::String(hex_encode(bytes)),
284        Expr::List(values) | Expr::Vector(values) | Expr::Set(values) | Expr::Block(values) => {
285            Value::Array(values.iter().map(admin_expr_json).collect())
286        }
287        Expr::Map(entries) => {
288            let mut object = Map::new();
289            for (key, value) in entries {
290                object.insert(expr_key(key), admin_expr_json(value));
291            }
292            Value::Object(object)
293        }
294        other => Value::String(format!("{other:?}")),
295    }
296}
297
298fn admin_json_response(
299    request: &GatewayRequest,
300    state: &GatewayRouteState,
301    expr: impl FnOnce() -> RouteResult<Expr>,
302) -> GatewayResponse {
303    match has_admin_access(request, state) {
304        Ok(true) => {}
305        Ok(false) => {
306            return OpenAiRouteError::forbidden(
307                "admin route requires openai-gateway.admin",
308                "capability_denied",
309            )
310            .into_response();
311        }
312        Err(error) => return error.into_response(),
313    }
314    expr()
315        .map(|expr| GatewayResponse::json_value(200, admin_expr_json(&expr)))
316        .unwrap_or_else(OpenAiRouteError::into_response)
317}
318
319fn has_admin_access(request: &GatewayRequest, state: &GatewayRouteState) -> RouteResult<bool> {
320    let capabilities = state
321        .keys()
322        .effective_capabilities(request)
323        .map_err(OpenAiRouteError::internal)?;
324    Ok(capabilities.contains(&CapabilityName::new(OPENAI_GATEWAY_ADMIN_CAPABILITY)))
325}
326
327fn list_expr(object: &str, data: Vec<Expr>) -> Expr {
328    Expr::Map(vec![
329        field("object", Expr::String(object.to_owned())),
330        field("data", Expr::List(data)),
331    ])
332}
333
334fn run_expr(content_id: &ContentId, run: &GatewayRun) -> Expr {
335    Expr::Map(vec![
336        field("object", Expr::String("openai-gateway/run".to_owned())),
337        field("content-id", content_id_expr(content_id)),
338        field("id", Expr::String(run.id().to_owned())),
339        field(
340            "request-content-id",
341            content_id_expr(run.request_content_id()),
342        ),
343        field("status", Expr::Symbol(run.status().clone())),
344        field("created-at-ms", u64_expr(run.created_at_ms())),
345    ])
346}
347
348fn event_expr(content_id: &ContentId, event: &GatewayEvent) -> Expr {
349    Expr::Map(vec![
350        field("object", Expr::String("openai-gateway/event".to_owned())),
351        field("content-id", content_id_expr(content_id)),
352        field("id", Expr::String(event.id().to_owned())),
353        field("run-id", Expr::String(event.run_id().to_owned())),
354        field("sequence", u64_expr(event.sequence())),
355        field("event-kind", Expr::Symbol(event.kind().clone())),
356        field("created-at-ms", u64_expr(event.created_at_ms())),
357        field("payload", event.payload().clone()),
358    ])
359}
360
361fn storage_counts_expr(counts: GatewayStoreCounts) -> Expr {
362    Expr::Map(vec![
363        field("requests", usize_expr(counts.request_count)),
364        field("runs", usize_expr(counts.run_count)),
365        field("events", usize_expr(counts.event_count)),
366        field("responses", usize_expr(counts.response_count)),
367        field("response-objects", usize_expr(counts.response_object_count)),
368        field("files", usize_expr(counts.file_count)),
369        field("file-bytes", usize_expr(counts.file_bytes_count)),
370        field("batches", usize_expr(counts.batch_count)),
371        field("threads", usize_expr(counts.thread_count)),
372        field("thread-messages", usize_expr(counts.thread_message_count)),
373        field("vector-stores", usize_expr(counts.vector_store_count)),
374    ])
375}
376
377fn average_latency_expr(events: &[(ContentId, GatewayEvent)]) -> Expr {
378    let mut totals = BTreeMap::<String, (u64, u64)>::new();
379    for (_, event) in events {
380        if event.kind().name.as_ref() != "final" {
381            continue;
382        }
383        let Some(model) = string_value(event.payload(), "model") else {
384            continue;
385        };
386        let Some(usage) = map_value(event.payload(), "usage") else {
387            continue;
388        };
389        let Some(latency) = u64_field(usage, "latency-ms") else {
390            continue;
391        };
392        let entry = totals.entry(model.to_owned()).or_default();
393        entry.0 = entry.0.saturating_add(latency);
394        entry.1 = entry.1.saturating_add(1);
395    }
396    Expr::Map(
397        totals
398            .into_iter()
399            .filter_map(|(model, (total, count))| {
400                (count != 0).then(|| (Expr::String(model), u64_expr(total / count)))
401            })
402            .collect(),
403    )
404}
405
406fn request_streams(request: &GatewayRequest) -> bool {
407    serde_json::from_slice::<Value>(request.body())
408        .ok()
409        .and_then(|value| value.get("stream").and_then(Value::as_bool))
410        .unwrap_or(false)
411}
412
413fn run_id_from_path(path: &str) -> Option<&str> {
414    super::path::id_from_path(path, ADMIN_RUN_RETRIEVAL_PREFIX)
415}
416
417fn string_value<'a>(expr: &'a Expr, name: &str) -> Option<&'a str> {
418    field_any(expr, name).and_then(|value| match value {
419        Expr::String(value) => Some(value.as_str()),
420        _ => None,
421    })
422}
423
424fn map_value<'a>(expr: &'a Expr, name: &str) -> Option<&'a Expr> {
425    field_any(expr, name).filter(|value| matches!(value, Expr::Map(_)))
426}
427
428fn u64_field(expr: &Expr, name: &str) -> Option<u64> {
429    match field_any(expr, name)? {
430        Expr::Number(number) => number.canonical.parse().ok(),
431        Expr::String(value) => value.parse().ok(),
432        _ => None,
433    }
434}
435
436use sim_value::build::entry as field;
437
438fn usize_expr(value: usize) -> Expr {
439    number_expr("usize", value.to_string())
440}
441
442fn u64_expr(value: u64) -> Expr {
443    number_expr("u64", value.to_string())
444}
445
446fn float_expr(value: f64) -> Expr {
447    number_expr("f64", value.to_string())
448}
449
450fn number_expr(domain: &str, canonical: String) -> Expr {
451    Expr::Number(NumberLiteral {
452        domain: Symbol::new(domain),
453        canonical,
454    })
455}
456
457fn number_json(value: &NumberLiteral) -> Value {
458    value
459        .canonical
460        .parse::<i64>()
461        .ok()
462        .map(Number::from)
463        .map(Value::Number)
464        .or_else(|| {
465            value
466                .canonical
467                .parse::<f64>()
468                .ok()
469                .and_then(Number::from_f64)
470                .map(Value::Number)
471        })
472        .unwrap_or_else(|| Value::String(value.canonical.clone()))
473}
474
475fn expr_key(expr: &Expr) -> String {
476    match expr {
477        Expr::String(value) => value.clone(),
478        Expr::Symbol(symbol) => symbol.to_string(),
479        _ => format!("{expr:?}"),
480    }
481}
482
483#[cfg(test)]
484#[path = "admin_tests.rs"]
485mod admin_tests;