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