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