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
16pub const ADMIN_RUNS_PATH: &str = "/v1/sim/admin/runs";
18pub const ADMIN_RUN_RETRIEVAL_ROUTE: &str = "/v1/sim/admin/runs/{id}";
20pub const ADMIN_RUN_RETRIEVAL_PREFIX: &str = "/v1/sim/admin/runs/";
22pub const ADMIN_EVENTS_PATH: &str = "/v1/sim/admin/events";
24pub const ADMIN_STORAGE_STATS_PATH: &str = "/v1/sim/admin/storage-stats";
26pub const ADMIN_MODEL_HEALTH_PATH: &str = "/v1/sim/admin/model-health";
28pub const ADMIN_CACHE_STATS_PATH: &str = "/v1/sim/admin/cache-stats";
30pub 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
79pub fn handle_admin_runs(request: &GatewayRequest, state: &GatewayRouteState) -> GatewayResponse {
81 admin_json_response(request, || runs_expr(state))
82}
83
84pub 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
97pub fn handle_admin_events(request: &GatewayRequest, state: &GatewayRouteState) -> GatewayResponse {
99 admin_json_response(request, || events_expr(state))
100}
101
102pub fn handle_admin_storage_stats(
104 request: &GatewayRequest,
105 state: &GatewayRouteState,
106) -> GatewayResponse {
107 admin_json_response(request, || storage_stats_expr(state))
108}
109
110pub fn handle_admin_model_health(
112 request: &GatewayRequest,
113 state: &GatewayRouteState,
114) -> GatewayResponse {
115 admin_json_response(request, || model_health_expr(state))
116}
117
118pub fn handle_admin_cache_stats(
120 request: &GatewayRequest,
121 state: &GatewayRouteState,
122) -> GatewayResponse {
123 admin_json_response(request, || cache_stats_expr(state))
124}
125
126pub 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}