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
17pub const ADMIN_RUNS_PATH: &str = "/v1/sim/admin/runs";
19pub const ADMIN_RUN_RETRIEVAL_ROUTE: &str = "/v1/sim/admin/runs/{id}";
21pub const ADMIN_RUN_RETRIEVAL_PREFIX: &str = "/v1/sim/admin/runs/";
23pub const ADMIN_EVENTS_PATH: &str = "/v1/sim/admin/events";
25pub const ADMIN_STORAGE_STATS_PATH: &str = "/v1/sim/admin/storage-stats";
27pub const ADMIN_MODEL_HEALTH_PATH: &str = "/v1/sim/admin/model-health";
29pub const ADMIN_CACHE_STATS_PATH: &str = "/v1/sim/admin/cache-stats";
31pub 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
80pub fn handle_admin_runs(request: &GatewayRequest, state: &GatewayRouteState) -> GatewayResponse {
82 admin_json_response(request, state, || runs_expr(state))
83}
84
85pub 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
98pub fn handle_admin_events(request: &GatewayRequest, state: &GatewayRouteState) -> GatewayResponse {
100 admin_json_response(request, state, || events_expr(state))
101}
102
103pub 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
111pub 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
119pub 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
127pub 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;