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