1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use alex_auth::{now_ms, Account, Vault};
7use alex_core::{
8 compute_cost, conversation_root, parse_since, parse_sse_usage, parse_trace_tags, route_model,
9 usage_from_json, ClientFormat, Provider, TraceRecord,
10};
11use alex_store::{Store, TraceFilter};
12use axum::body::{Body, Bytes};
13use axum::extract::{ConnectInfo, Path, Query, State};
14use axum::http::{HeaderMap, HeaderValue, StatusCode};
15use axum::response::{IntoResponse, Response};
16use axum::routing::{get, post};
17use axum::Router;
18use futures_util::StreamExt;
19use serde_json::{json, Value};
20use tokio_stream::wrappers::ReceiverStream;
21
22const ANTHROPIC_BASE: &str = "https://api.anthropic.com";
23const OPENAI_BASE: &str = "https://api.openai.com";
24const CODEX_BASE: &str = "https://chatgpt.com/backend-api/codex";
25const XAI_BASE: &str = "https://cli-chat-proxy.grok.com/v1";
26const GROK_CLIENT_VERSION: &str = "0.2.77";
27const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
28const GEMINI_CODE_ASSIST_BASE: &str = "https://cloudcode-pa.googleapis.com";
29const GEMINI_CODE_ASSIST_VERSION: &str = "v1internal";
30const GEMINI_API_BASE: &str = "https://generativelanguage.googleapis.com";
31
32#[derive(Debug, Clone)]
33pub struct DarioActive {
34 pub generation_id: String,
35 pub base_url: String,
36 pub api_key: String,
37}
38
39pub trait DarioRouter: Send + Sync {
40 fn active(&self) -> Option<DarioActive>;
41 fn begin(&self, generation_id: &str) -> Option<Box<dyn std::any::Any + Send>>;
42 fn status(&self) -> Value;
43 fn suspect(&self, generation_id: &str);
44}
45
46fn suspect_dario(state: &AppState, account: &Account) {
47 if account.kind != "dario" {
48 return;
49 }
50 if let (Some(dario), Some(gen)) = (&state.dario, account.id.strip_prefix("dario:")) {
51 dario.suspect(gen);
52 }
53}
54
55pub struct AppState {
56 pub local_key: String,
57 pub vault: Arc<Vault>,
58 pub store: Arc<Store>,
59 pub http: reqwest::Client,
60 pub dario: Option<Arc<dyn DarioRouter>>,
61 pub in_flight: std::sync::atomic::AtomicI64,
62 pub started_ms: i64,
63 pub base_url: String,
64 pub anthropic_usage: std::sync::Mutex<UsageCache>,
65 pub logins: alex_auth::sessions::LoginManager,
66 pub run_keys: std::sync::RwLock<HashMap<String, CachedRunKey>>,
67 pub update_status: Arc<tokio::sync::RwLock<Option<Value>>>,
68}
69
70#[derive(Debug, Clone)]
71pub struct CachedRunKey {
72 pub run_id: Option<String>,
73 pub tags_json: Option<String>,
74 pub expires_ms: Option<i64>,
75}
76
77struct InFlight(Arc<AppState>);
78
79impl InFlight {
80 fn new(state: &Arc<AppState>) -> Self {
81 state
82 .in_flight
83 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
84 Self(state.clone())
85 }
86}
87
88impl Drop for InFlight {
89 fn drop(&mut self) {
90 self.0
91 .in_flight
92 .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
93 }
94}
95
96pub fn build_state(
97 local_key: String,
98 vault: Arc<Vault>,
99 store: Arc<Store>,
100 dario: Option<Arc<dyn DarioRouter>>,
101 base_url: String,
102) -> Arc<AppState> {
103 let http = reqwest::Client::builder()
104 .connect_timeout(Duration::from_secs(15))
105 .build()
106 .expect("reqwest client");
107 Arc::new(AppState {
108 local_key,
109 vault,
110 store,
111 http,
112 dario,
113 in_flight: std::sync::atomic::AtomicI64::new(0),
114 started_ms: now_ms(),
115 base_url,
116 anthropic_usage: std::sync::Mutex::new(UsageCache::default()),
117 logins: alex_auth::sessions::LoginManager::default(),
118 run_keys: std::sync::RwLock::new(HashMap::new()),
119 update_status: Arc::new(tokio::sync::RwLock::new(None)),
120 })
121}
122
123async fn require_local_key(
124 State(state): State<Arc<AppState>>,
125 req: axum::extract::Request,
126 next: axum::middleware::Next,
127) -> Response {
128 let ok = client_key(req.headers())
129 .map(|k| k == state.local_key)
130 .unwrap_or(false);
131 if !ok {
132 return error_response(
133 StatusCode::UNAUTHORIZED,
134 "admin routes require x-api-key: <local_key>",
135 );
136 }
137 next.run(req).await
138}
139
140pub fn router(state: Arc<AppState>) -> Router {
141 let gated = Router::new()
146 .route(
147 "/admin/run-keys",
148 get(admin_run_keys_list).post(admin_run_keys_create),
149 )
150 .route(
151 "/admin/run-keys/{id}",
152 axum::routing::delete(admin_run_keys_revoke),
153 )
154 .route("/admin/storage", get(admin_storage))
155 .route("/admin/storage/prune", post(admin_storage_prune))
156 .route("/admin/traces", get(admin_traces))
157 .route("/admin/accounts", get(admin_accounts))
158 .route(
159 "/admin/accounts/{id}",
160 axum::routing::delete(admin_account_remove),
161 )
162 .route("/admin/auth/gemini-key", post(admin_auth_gemini_key))
163 .route("/admin/health", get(admin_health))
164 .route("/admin/analytics", get(admin_analytics))
165 .route("/admin/limits", get(admin_limits))
166 .route("/admin/update", get(admin_update))
167 .route("/admin/dario", get(admin_dario))
168 .route("/admin/dario/prompt-caches", get(admin_dario_prompt_caches))
169 .route(
170 "/admin/dario/prompt-caches/{key}",
171 axum::routing::delete(admin_dario_prompt_cache_delete),
172 )
173 .route("/admin/auth/import", post(admin_auth_import))
174 .route("/admin/auth/login/start", post(admin_auth_login_start))
175 .route("/admin/auth/login/complete", post(admin_auth_login_complete))
176 .route("/admin/auth/login/{id}", get(admin_auth_login_status))
177 .route("/traces/search", get(traces_search))
178 .route("/traces/export.ndjson", get(traces_export))
179 .route("/traces/sessions", get(traces_sessions))
180 .route(
181 "/traces/sessions/{session_id}/transcript",
182 get(traces_session_transcript),
183 )
184 .route("/traces/{id}", get(trace_get).delete(trace_delete))
185 .route("/traces/{id}/reply.md", get(trace_reply_md))
186 .route("/traces/{id}/body/{kind}", get(trace_body))
187 .route("/traces/runs/{run_id}", get(traces_run_summary))
188 .route("/traces/runs/{run_id}/events", get(traces_run_events))
189 .route("/traces/runs/{run_id}/export.ndjson", get(traces_run_export))
190 .route("/traces/runs/{run_id}/artifacts", get(traces_run_artifacts))
191 .route_layer(axum::middleware::from_fn_with_state(
192 state.clone(),
193 require_local_key,
194 ));
195
196 Router::new()
197 .route("/health", get(health))
198 .route("/connect", get(connect_info))
199 .route("/v1/models", get(models))
200 .route("/v1/messages", post(anthropic_messages))
201 .route("/v1/chat/completions", post(openai_chat))
202 .route("/v1/responses", post(openai_responses))
203 .route("/chat/completions", post(openai_chat))
204 .route("/responses", post(openai_responses))
205 .route("/v1beta/models/{model_action}", post(gemini_generate))
206 .merge(gated)
207 .layer(axum::extract::DefaultBodyLimit::max(64 * 1024 * 1024))
208 .with_state(state)
209}
210
211async fn admin_auth_import(
212 State(state): State<Arc<AppState>>,
213 body: Option<axum::Json<Value>>,
214) -> Response {
215 let source = body
216 .as_ref()
217 .and_then(|b| b.0["source"].as_str().or(b.0["provider"].as_str()))
218 .unwrap_or("all")
219 .to_string();
220 match alex_auth::import_all(&state.vault, &source).await {
221 Ok(outcomes) => {
222 let items: Vec<Value> = outcomes
223 .iter()
224 .map(|o| {
225 json!({
226 "source": o.source,
227 "imported": o.imported,
228 "note": o.note,
229 })
230 })
231 .collect();
232 axum::Json(json!({"outcomes": items})).into_response()
233 }
234 Err(e) => error_response(StatusCode::BAD_REQUEST, &e.to_string()),
235 }
236}
237
238async fn admin_account_remove(
239 State(state): State<Arc<AppState>>,
240 Path(id): Path<String>,
241) -> Response {
242 match state.vault.remove(&id).await {
243 Ok(true) => axum::Json(json!({"removed": id})).into_response(),
244 Ok(false) => error_response(StatusCode::NOT_FOUND, &format!("unknown account '{id}'")),
245 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
246 }
247}
248
249async fn admin_auth_gemini_key(
250 State(state): State<Arc<AppState>>,
251 body: axum::Json<Value>,
252) -> Response {
253 let Some(key) = body.0["key"].as_str().map(str::trim).filter(|k| !k.is_empty()) else {
254 return error_response(StatusCode::BAD_REQUEST, "missing 'key'");
255 };
256 let account = Account {
257 id: "gemini-api-key".into(),
258 provider: Provider::Gemini,
259 kind: "api_key".into(),
260 label: Some("gemini (AI Studio key)".into()),
261 access_token: None,
262 refresh_token: None,
263 id_token: None,
264 api_key: Some(key.to_string()),
265 expires_at_ms: None,
266 last_refresh_ms: None,
267 account_meta: Value::Null,
268 cooldown_until_ms: None,
269 status: "active".into(),
270 };
271 match state.vault.upsert(account).await {
272 Ok(()) => axum::Json(json!({"saved": "gemini-api-key"})).into_response(),
273 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
274 }
275}
276
277async fn admin_auth_login_start(
278 State(state): State<Arc<AppState>>,
279 body: axum::Json<Value>,
280) -> Response {
281 let Some(provider) = body.0["provider"].as_str() else {
282 return error_response(StatusCode::BAD_REQUEST, "missing 'provider'");
283 };
284 match state.logins.start(state.vault.clone(), provider).await {
285 Ok(snapshot) => axum::Json(snapshot).into_response(),
286 Err(e) => error_response(StatusCode::BAD_REQUEST, &e.to_string()),
287 }
288}
289
290async fn admin_auth_login_complete(
291 State(state): State<Arc<AppState>>,
292 body: axum::Json<Value>,
293) -> Response {
294 let (Some(id), Some(input)) = (body.0["login_id"].as_str(), body.0["input"].as_str()) else {
295 return error_response(StatusCode::BAD_REQUEST, "missing 'login_id' or 'input'");
296 };
297 match state.logins.complete(state.vault.clone(), id, input).await {
298 Ok(snapshot) => axum::Json(snapshot).into_response(),
299 Err(e) => error_response(StatusCode::BAD_REQUEST, &e.to_string()),
300 }
301}
302
303async fn admin_auth_login_status(
304 State(state): State<Arc<AppState>>,
305 axum::extract::Path(id): axum::extract::Path<String>,
306) -> Response {
307 match state.logins.status(&id).await {
308 Some(snapshot) => axum::Json(snapshot).into_response(),
309 None => error_response(StatusCode::NOT_FOUND, "unknown or expired login session"),
310 }
311}
312
313async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
314 axum::Json(json!({
315 "status": "ok",
316 "service": "alexandria",
317 "version": env!("CARGO_PKG_VERSION"),
318 "in_flight": state.in_flight.load(std::sync::atomic::Ordering::SeqCst),
319 "uptime_s": (now_ms() - state.started_ms) / 1000,
320 "dario": state.dario.is_some(),
321 }))
322}
323
324fn rewrite_host(base_url: &str, host: &str) -> String {
325 let port = base_url.rsplit(':').next().unwrap_or("4100");
326 format!("http://{host}:{port}")
327}
328
329pub fn connect_payload(base_url: &str, local_key: &str) -> (Value, String) {
330 let base = base_url.trim_end_matches('/').to_string();
331 let v1 = format!("{base}/v1");
332 let exports = format!(
333 "export ANTHROPIC_BASE_URL={base}\nexport ANTHROPIC_API_KEY={local_key}\n\
334 export OPENAI_BASE_URL={v1}\nexport OPENAI_API_KEY={local_key}\n\
335 export XAI_API_KEY={local_key}\nexport GROK_MODELS_BASE_URL={v1}\n\
336 export GOOGLE_GEMINI_BASE_URL={base}\nexport GOOGLE_GENAI_API_VERSION=v1beta\n\
337 export GEMINI_API_KEY={local_key}\nexport GEMINI_API_KEY_AUTH_MECHANISM=bearer\n\
338 export GOOGLE_GENAI_USE_GCA=false\n"
339 );
340 let payload = json!({
341 "service": "alexandria",
342 "base_url": base,
343 "api_key": local_key,
344 "anthropic": {"base_url": base, "env": {"ANTHROPIC_BASE_URL": base, "ANTHROPIC_API_KEY": local_key}},
345 "openai": {"base_url": v1, "env": {"OPENAI_BASE_URL": v1, "OPENAI_API_KEY": local_key}},
346 "xai": {"base_url": v1, "env": {"XAI_API_KEY": local_key, "GROK_MODELS_BASE_URL": v1}},
347 "gemini": {"base_url": base, "env": {
348 "GOOGLE_GEMINI_BASE_URL": base,
349 "GOOGLE_GENAI_API_VERSION": "v1beta",
350 "GEMINI_API_KEY": local_key,
351 "GEMINI_API_KEY_AUTH_MECHANISM": "bearer",
352 "GOOGLE_GENAI_USE_GCA": "false",
353 }},
354 "exports": exports,
355 });
356 (payload, exports)
357}
358
359async fn connect_info(
360 State(state): State<Arc<AppState>>,
361 axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
362 Query(q): Query<HashMap<String, String>>,
363) -> Response {
364 if !peer.ip().is_loopback() {
365 return error_response(
366 StatusCode::FORBIDDEN,
367 "connection info is only served to loopback clients",
368 );
369 }
370 let base = match q.get("host").map(String::as_str) {
371 Some("docker") => rewrite_host(&state.base_url, "host.docker.internal"),
372 Some(host) if !host.is_empty() => rewrite_host(&state.base_url, host),
373 _ => state.base_url.clone(),
374 };
375 let (payload, exports) = connect_payload(&base, &state.local_key);
376 if q.get("format").map(|f| f == "env").unwrap_or(false) {
377 return Response::builder()
378 .status(StatusCode::OK)
379 .header("content-type", "text/plain")
380 .body(Body::from(exports))
381 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()));
382 }
383 axum::Json(payload).into_response()
384}
385
386async fn models(State(state): State<Arc<AppState>>) -> impl IntoResponse {
387 let mut ids = state.store.pricing_models();
388 for (alias, _) in alex_core::model_aliases() {
389 ids.push((*alias).to_string());
390 }
391 for id in ids.clone() {
392 ids.push(format!("alexandria/{id}"));
393 }
394 let data: Vec<Value> = ids
395 .into_iter()
396 .map(|m| json!({"id": m, "object": "model", "owned_by": "alexandria"}))
397 .collect();
398 axum::Json(json!({"object": "list", "data": data}))
399}
400
401async fn admin_analytics(
402 State(state): State<Arc<AppState>>,
403 Query(q): Query<HashMap<String, String>>,
404) -> Response {
405 let minutes: i64 = q
406 .get("since_minutes")
407 .and_then(|s| s.parse().ok())
408 .unwrap_or(60);
409 match state.store.analytics(now_ms() - minutes * 60_000) {
410 Ok(v) => axum::Json(v).into_response(),
411 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
412 }
413}
414
415const USAGE_CACHE_TTL_MS: i64 = 300_000;
416const USAGE_BACKOFF_BASE_MS: i64 = 60_000;
417const USAGE_BACKOFF_MAX_MS: i64 = 3_600_000;
418
419#[derive(Default)]
420pub struct UsageCache {
421 fetched_at_ms: i64,
422 entry: Option<Value>,
423 cooldown_until_ms: i64,
424 failures: u32,
425}
426
427fn usage_backoff_ms(failures: u32, retry_after_ms: Option<i64>) -> i64 {
428 let exp = USAGE_BACKOFF_BASE_MS
429 .saturating_mul(1i64 << failures.saturating_sub(1).min(6))
430 .min(USAGE_BACKOFF_MAX_MS);
431 exp.max(retry_after_ms.unwrap_or(0))
432}
433
434async fn anthropic_usage_entry(state: &Arc<AppState>) -> Option<Value> {
435 let account = state.vault.account_for(Provider::Anthropic, true).await.ok()?;
436 if account.kind != "oauth" {
437 return None;
438 }
439 let token = account.access_token.as_deref()?.to_string();
440 {
441 let cache = state.anthropic_usage.lock().unwrap();
442 if cache.entry.is_some() && now_ms() < cache.fetched_at_ms + USAGE_CACHE_TTL_MS {
443 return cache.entry.clone();
444 }
445 if now_ms() < cache.cooldown_until_ms {
446 return cache.entry.clone();
447 }
448 }
449 let result = state
450 .http
451 .get(format!("{ANTHROPIC_BASE}/api/oauth/usage"))
452 .header("authorization", format!("Bearer {token}"))
453 .header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
454 .header("accept", "application/json")
455 .header("user-agent", "claude-cli/2.1.202 (external, cli)")
456 .send()
457 .await;
458 match result {
459 Ok(resp) if resp.status().is_success() => {
460 let raw: Value = resp.json().await.unwrap_or(Value::Null);
461 let mut windows = Vec::new();
462 for (name, key) in [
463 ("5h", "five_hour"),
464 ("7d", "seven_day"),
465 ("7d opus", "seven_day_opus"),
466 ("7d sonnet", "seven_day_sonnet"),
467 ] {
468 let w = &raw[key];
469 if w.is_object() {
470 windows.push(json!({
471 "window": name,
472 "used_pct": w["utilization"],
473 "resets_at": w["resets_at"],
474 }));
475 }
476 }
477 let entry = json!({
478 "provider": "anthropic",
479 "source": "oauth usage endpoint",
480 "plan": account.label,
481 "windows": windows,
482 "extra_usage": raw["extra_usage"],
483 });
484 let mut cache = state.anthropic_usage.lock().unwrap();
485 cache.fetched_at_ms = now_ms();
486 cache.cooldown_until_ms = 0;
487 cache.failures = 0;
488 cache.entry = Some(entry.clone());
489 Some(entry)
490 }
491 Ok(resp) => {
492 let retry_after = resp
493 .headers()
494 .get("retry-after")
495 .and_then(|v| v.to_str().ok())
496 .and_then(|v| v.parse::<i64>().ok())
497 .map(|s| s.clamp(30, 3600) * 1000);
498 let mut cache = state.anthropic_usage.lock().unwrap();
499 cache.failures += 1;
500 let cooldown = usage_backoff_ms(cache.failures, retry_after);
501 cache.cooldown_until_ms = now_ms() + cooldown;
502 tracing::debug!(
503 status = resp.status().as_u16(),
504 failures = cache.failures,
505 cooldown_ms = cooldown,
506 "anthropic usage endpoint unavailable; backing off"
507 );
508 cache.entry.clone()
509 }
510 Err(_) => {
511 let mut cache = state.anthropic_usage.lock().unwrap();
512 cache.failures += 1;
513 let cooldown = usage_backoff_ms(cache.failures, None);
514 cache.cooldown_until_ms = now_ms() + cooldown;
515 cache.entry.clone()
516 }
517 }
518}
519
520pub async fn limits_snapshot(state: &Arc<AppState>) -> Value {
521 let mut providers: Vec<Value> = Vec::new();
522 if let Some(entry) = anthropic_usage_entry(state).await {
523 providers.push(entry);
524 }
525 for (provider_str, ts_ms, headers_json) in
526 state.store.latest_provider_headers().unwrap_or_default()
527 {
528 if providers
529 .iter()
530 .any(|p| p["provider"].as_str() == Some(&provider_str))
531 {
532 continue;
533 }
534 let Some(provider) = Provider::from_str_loose(&provider_str) else {
535 continue;
536 };
537 let headers: Value = serde_json::from_str(&headers_json).unwrap_or(Value::Null);
538 let mut parsed = alex_core::parse_limit_headers(provider, &headers);
539 if let Some(o) = parsed.as_object_mut() {
540 o.insert("provider".into(), json!(provider_str));
541 o.insert("source".into(), json!("captured response headers"));
542 o.insert("observed_at_ms".into(), json!(ts_ms));
543 providers.push(parsed);
544 }
545 }
546 providers.sort_by_key(|p| p["provider"].as_str().unwrap_or("").to_string());
547 json!({"providers": providers})
548}
549
550async fn admin_limits(State(state): State<Arc<AppState>>) -> Response {
551 axum::Json(limits_snapshot(&state).await).into_response()
552}
553
554async fn admin_update(State(state): State<Arc<AppState>>) -> Response {
555 let stored = state.update_status.read().await.clone();
556 let mut body = json!({
557 "current": env!("CARGO_PKG_VERSION"),
558 "latest": null,
559 "update_available": false,
560 "checked_at_ms": null,
561 });
562 if let Some(Value::Object(fields)) = stored {
563 if let Some(obj) = body.as_object_mut() {
564 obj.extend(fields);
565 obj.insert("current".into(), json!(env!("CARGO_PKG_VERSION")));
566 }
567 }
568 axum::Json(body).into_response()
569}
570
571async fn admin_dario(State(state): State<Arc<AppState>>) -> Response {
572 match &state.dario {
573 Some(d) => {
574 let mut status = d.status();
575 if let Some(obj) = status.as_object_mut() {
576 obj.insert("prompt_caches".into(), json!(dario_prompt_caches(&state)));
577 }
578 axum::Json(status).into_response()
579 }
580 None => error_response(StatusCode::NOT_FOUND, "dario mode is not enabled"),
581 }
582}
583
584fn dario_prompt_cache_dir(state: &AppState) -> PathBuf {
585 state.store.data_dir.join("dario-prompt-cache")
586}
587
588fn dario_prompt_cache_key_valid(key: &str) -> bool {
589 !key.is_empty()
590 && key.len() <= 128
591 && key
592 .bytes()
593 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
594}
595
596fn dario_prompt_cache_summary(path: PathBuf) -> Option<Value> {
597 let raw = std::fs::read_to_string(&path).ok()?;
598 let value: Value = serde_json::from_str(&raw).ok()?;
599 let key = path.file_stem()?.to_string_lossy().to_string();
600 let runs: Vec<Value> = value["runs"]
601 .as_array()
602 .map(|runs| runs.iter().rev().take(12).cloned().collect())
603 .unwrap_or_default();
604 Some(json!({
605 "key": value["key"].as_str().unwrap_or(&key),
606 "model": value["model"],
607 "source": value["source"],
608 "captured_at": value["captured_at"],
609 "last_used_at": value["last_used_at"],
610 "trace_id": value["trace_id"],
611 "claude_bin": value["claude_bin"],
612 "claude_version": value["claude_version"],
613 "system_prompt_chars": value["system_prompt_chars"],
614 "agent_identity_chars": value["agent_identity_chars"],
615 "path": path.to_string_lossy(),
616 "runs": runs,
617 }))
618}
619
620fn dario_prompt_caches(state: &AppState) -> Vec<Value> {
621 let dir = dario_prompt_cache_dir(state);
622 let mut caches: Vec<Value> = std::fs::read_dir(dir)
623 .ok()
624 .into_iter()
625 .flatten()
626 .flatten()
627 .filter_map(|entry| {
628 let path = entry.path();
629 (path.extension().and_then(|s| s.to_str()) == Some("json"))
630 .then(|| dario_prompt_cache_summary(path))
631 .flatten()
632 })
633 .collect();
634 caches.sort_by(|a, b| {
635 b["last_used_at"]
636 .as_str()
637 .unwrap_or("")
638 .cmp(a["last_used_at"].as_str().unwrap_or(""))
639 });
640 caches
641}
642
643async fn admin_dario_prompt_caches(State(state): State<Arc<AppState>>) -> Response {
644 axum::Json(json!({"prompt_caches": dario_prompt_caches(&state)})).into_response()
645}
646
647async fn admin_dario_prompt_cache_delete(
648 State(state): State<Arc<AppState>>,
649 Path(key): Path<String>,
650) -> Response {
651 if !dario_prompt_cache_key_valid(&key) {
652 return error_response(StatusCode::BAD_REQUEST, "invalid prompt cache key");
653 }
654 let path = dario_prompt_cache_dir(&state).join(format!("{key}.json"));
655 match std::fs::remove_file(&path) {
656 Ok(()) => axum::Json(json!({"deleted": true, "key": key})).into_response(),
657 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
658 error_response(StatusCode::NOT_FOUND, "unknown prompt cache")
659 }
660 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
661 }
662}
663
664async fn admin_storage(State(state): State<Arc<AppState>>) -> Response {
665 match state.store.disk_usage() {
666 Ok(v) => axum::Json(v).into_response(),
667 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
668 }
669}
670
671async fn admin_storage_prune(
672 State(state): State<Arc<AppState>>,
673 body: axum::Json<Value>,
674) -> Response {
675 let now = now_ms();
676 let cutoff = match &body["older_than_ms"] {
677 Value::Null => match body["older_than"].as_str() {
678 Some(s) => match parse_since(s, now) {
679 Some(ms) => ms,
680 None => {
681 return error_response(
682 StatusCode::BAD_REQUEST,
683 &format!("invalid 'older_than' '{s}' (use 45s, 30m, 24h, 7d, or RFC3339)"),
684 )
685 }
686 },
687 None => {
688 return error_response(
689 StatusCode::BAD_REQUEST,
690 "missing 'older_than_ms' or 'older_than'",
691 )
692 }
693 },
694 v => match v.as_i64() {
695 Some(ms) => ms,
696 None => {
697 return error_response(StatusCode::BAD_REQUEST, "'older_than_ms' must be an integer")
698 }
699 },
700 };
701 if cutoff > now {
702 return error_response(StatusCode::BAD_REQUEST, "cutoff is in the future");
703 }
704 let bodies_only = body["bodies_only"].as_bool().unwrap_or(true);
705 let dry_run = body["dry_run"].as_bool().unwrap_or(false);
706 let store = state.store.clone();
707 let report =
708 tokio::task::spawn_blocking(move || store.prune(cutoff, bodies_only, dry_run)).await;
709 match report {
710 Ok(Ok(r)) => axum::Json(serde_json::to_value(r).unwrap_or_default()).into_response(),
711 Ok(Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
712 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
713 }
714}
715
716async fn admin_traces(
717 State(state): State<Arc<AppState>>,
718 Query(q): Query<HashMap<String, String>>,
719) -> Response {
720 let limit = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50);
721 match state.store.list_traces(
722 limit,
723 q.get("session").map(|s| s.as_str()),
724 q.get("model").map(|s| s.as_str()),
725 ) {
726 Ok(rows) => axum::Json(json!({"traces": rows})).into_response(),
727 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
728 }
729}
730
731fn filter_from_query(q: &HashMap<String, String>) -> TraceFilter {
732 let now = now_ms();
733 TraceFilter {
734 since_ms: q.get("since").and_then(|s| parse_since(s, now)),
735 until_ms: q.get("until").and_then(|s| parse_since(s, now)),
736 run_id: q.get("run_id").cloned(),
737 session: q.get("session").cloned(),
738 model: q.get("model").cloned(),
739 provider: q.get("provider").cloned(),
740 path: q.get("path").cloned(),
741 harness: q.get("harness").cloned(),
742 status: q.get("status").and_then(|s| s.parse().ok()),
743 errors_only: q
744 .get("errors")
745 .map(|v| v == "1" || v == "true")
746 .unwrap_or(false),
747 key_fingerprint: q.get("key_fingerprint").cloned(),
748 reasoning_effort: q.get("effort").cloned(),
749 limit: q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(200),
750 }
751}
752
753fn wants_bodies(q: &HashMap<String, String>) -> bool {
754 q.get("bodies").map(|v| v == "1" || v == "true").unwrap_or(false)
755}
756
757fn read_gz_file(path: &str) -> Option<Vec<u8>> {
758 let file = std::fs::File::open(path).ok()?;
759 let mut decoder = flate2::read::GzDecoder::new(file);
760 let mut buf = Vec::new();
761 std::io::Read::read_to_end(&mut decoder, &mut buf).ok()?;
762 Some(buf)
763}
764
765fn inline_row_bodies(row: &mut Value) {
766 use base64::Engine;
767 for (path_key, out_key) in [
768 ("req_body_path", "req_body_b64"),
769 ("upstream_req_body_path", "upstream_req_body_b64"),
770 ("resp_body_path", "resp_body_b64"),
771 ] {
772 let Some(buf) = row[path_key].as_str().and_then(read_gz_file) else {
773 continue;
774 };
775 row[out_key] = json!(base64::engine::general_purpose::STANDARD.encode(&buf));
776 }
777}
778
779fn ndjson_response(mut rows: Vec<Value>, inline_bodies: bool) -> Response {
780 rows.sort_by_key(|r| r["ts_request_ms"].as_i64().unwrap_or(0));
781 let mut out = String::new();
782 for mut row in rows {
783 if inline_bodies {
784 inline_row_bodies(&mut row);
785 }
786 out.push_str(&serde_json::to_string(&row).unwrap_or_default());
787 out.push('\n');
788 }
789 Response::builder()
790 .status(StatusCode::OK)
791 .header("content-type", "application/x-ndjson")
792 .body(Body::from(out))
793 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))
794}
795
796const TEXT_SCAN_CAP: usize = 300;
797
798fn trace_matches_text(row: &Value, needle: &str) -> bool {
799 for key in ["req_body_path", "resp_body_path"] {
800 if let Some(path) = row.get(key).and_then(|v| v.as_str()) {
801 if let Some(bytes) = read_gz_file(path) {
802 if String::from_utf8_lossy(&bytes)
803 .to_lowercase()
804 .contains(needle)
805 {
806 return true;
807 }
808 }
809 }
810 }
811 false
812}
813
814async fn traces_search(
815 State(state): State<Arc<AppState>>,
816 Query(q): Query<HashMap<String, String>>,
817) -> Response {
818 let mut filter = filter_from_query(&q);
819 let text = q
820 .get("text")
821 .or_else(|| q.get("q"))
822 .map(|t| t.trim().to_lowercase())
823 .filter(|t| !t.is_empty());
824 if text.is_some() {
825 filter.limit = TEXT_SCAN_CAP;
826 }
827 match state.store.search_traces(&filter) {
828 Ok(rows) => match text {
829 Some(needle) => {
830 let scanned = rows.len();
831 let rows = tokio::task::spawn_blocking(move || {
832 rows.into_iter()
833 .filter(|r| trace_matches_text(r, &needle))
834 .collect::<Vec<_>>()
835 })
836 .await
837 .unwrap_or_default();
838 axum::Json(json!({"traces": rows, "scanned": scanned, "scan_cap": TEXT_SCAN_CAP}))
839 .into_response()
840 }
841 None => axum::Json(json!({"traces": rows})).into_response(),
842 },
843 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
844 }
845}
846
847async fn traces_export(
848 State(state): State<Arc<AppState>>,
849 Query(q): Query<HashMap<String, String>>,
850) -> Response {
851 match state.store.search_traces(&filter_from_query(&q)) {
852 Ok(rows) => ndjson_response(rows, wants_bodies(&q)),
853 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
854 }
855}
856
857fn truncate_chars(s: String, max: usize) -> String {
858 if s.chars().count() <= max {
859 s
860 } else {
861 s.chars().take(max).collect()
862 }
863}
864
865fn read_gz_json(path: Option<&str>) -> Option<Value> {
866 let buf = path.and_then(read_gz_file)?;
867 serde_json::from_slice(&buf).ok()
868}
869
870fn read_gz_text(path: Option<&str>) -> Option<String> {
871 let buf = path.and_then(read_gz_file)?;
872 Some(String::from_utf8_lossy(&buf).to_string())
873}
874
875fn body_date_dir_name(name: &str) -> bool {
876 let bytes = name.as_bytes();
877 bytes.len() == 10
878 && bytes[4] == b'-'
879 && bytes[7] == b'-'
880 && bytes
881 .iter()
882 .enumerate()
883 .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
884}
885
886fn dario_capture_suffix(kind: &str) -> Option<&'static str> {
887 match kind {
888 "dario-upstream-request" => Some("dario-upstream-request.json.gz"),
889 "dario-upstream-response" => Some("dario-upstream-response.json.gz"),
890 _ => None,
891 }
892}
893
894fn is_dario_trace(row: &Value) -> bool {
895 row["account_id"]
896 .as_str()
897 .map(|id| id.starts_with("dario:"))
898 .unwrap_or(false)
899}
900
901fn find_dario_capture_path(state: &AppState, row: &Value, kind: &str) -> Option<String> {
902 if !is_dario_trace(row) {
903 return None;
904 }
905 let trace_id = row["id"].as_str()?;
906 let suffix = dario_capture_suffix(kind)?;
907 let filename = format!("{trace_id}.{suffix}");
908 let bodies = state.store.data_dir.join("bodies");
909 let mut days: Vec<PathBuf> = std::fs::read_dir(&bodies)
910 .ok()?
911 .flatten()
912 .filter_map(|entry| {
913 let name = entry.file_name().to_string_lossy().to_string();
914 (entry.path().is_dir() && body_date_dir_name(&name)).then(|| entry.path())
915 })
916 .collect();
917 days.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
918 for day in days {
919 let path = day.join(&filename);
920 if path.is_file() {
921 return Some(path.to_string_lossy().to_string());
922 }
923 }
924 None
925}
926
927fn dario_capture_summary(state: &AppState, row: &Value) -> Option<Value> {
928 let request_path = find_dario_capture_path(state, row, "dario-upstream-request");
929 let response_path = find_dario_capture_path(state, row, "dario-upstream-response");
930 if request_path.is_none() && response_path.is_none() {
931 return None;
932 }
933 let prompt_cache = request_path
934 .as_deref()
935 .and_then(|path| read_gz_json(Some(path)))
936 .and_then(|body| {
937 body["prompt_cache"]
938 .as_object()
939 .map(|_| body["prompt_cache"].clone())
940 });
941 Some(json!({
942 "request_available": request_path.is_some(),
943 "response_available": response_path.is_some(),
944 "request_path": request_path,
945 "response_path": response_path,
946 "prompt_cache": prompt_cache,
947 }))
948}
949
950async fn traces_sessions(
951 State(state): State<Arc<AppState>>,
952 Query(q): Query<HashMap<String, String>>,
953) -> Response {
954 let since = q.get("since").and_then(|s| parse_since(s, now_ms()));
955 let limit = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(0);
956 match state.store.sessions(since, limit) {
957 Ok(rows) => axum::Json(json!({"sessions": rows})).into_response(),
958 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
959 }
960}
961
962fn transcript_turn(row: &Value) -> Value {
963 use alex_core::translate;
964 let user = read_gz_json(row["req_body_path"].as_str())
965 .and_then(|req| {
966 translate::last_user_text(row["client_format"].as_str().unwrap_or(""), &req)
967 })
968 .map(|s| truncate_chars(s, 8000));
969 let resp_text = read_gz_text(row["resp_body_path"].as_str());
970 let fmt = row["upstream_format"]
971 .as_str()
972 .or(row["client_format"].as_str())
973 .unwrap_or("")
974 .to_string();
975 let assistant = resp_text
976 .as_deref()
977 .and_then(|text| translate::assistant_reply_text(&fmt, text))
978 .map(|s| truncate_chars(s, 8000));
979 let tool_calls: Vec<Value> = resp_text
980 .as_deref()
981 .map(|text| translate::assistant_tool_calls(&fmt, text))
982 .unwrap_or_default()
983 .into_iter()
984 .take(24)
985 .map(|mut c| {
986 if let Some(a) = c["arguments"].as_str() {
987 let t = truncate_chars(a.to_string(), 600);
988 c["arguments"] = json!(t);
989 }
990 c
991 })
992 .collect();
993 json!({
994 "trace_id": row["id"],
995 "ts_request_ms": row["ts_request_ms"],
996 "ts_response_ms": row["ts_response_ms"],
997 "model": row["routed_model"],
998 "status": row["status"],
999 "input_tokens": row["input_tokens"],
1000 "output_tokens": row["output_tokens"],
1001 "reasoning_effort": row["reasoning_effort"],
1002 "thinking_budget": row["thinking_budget"],
1003 "cost_usd": row["cost_usd"],
1004 "error": row["error"],
1005 "user": user,
1006 "assistant": assistant,
1007 "tool_calls": tool_calls,
1008 })
1009}
1010
1011async fn traces_session_transcript(
1012 State(state): State<Arc<AppState>>,
1013 Path(session_id): Path<String>,
1014 Query(q): Query<HashMap<String, String>>,
1015) -> Response {
1016 let since = q
1017 .get("since_ms")
1018 .and_then(|s| s.parse::<i64>().ok())
1019 .or_else(|| q.get("since").and_then(|s| parse_since(s, now_ms())));
1020 let limit: usize = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(500);
1021 let rows = match state.store.session_traces(&session_id, since) {
1022 Ok(rows) => rows,
1023 Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1024 };
1025 let turns: Vec<Value> = rows.iter().take(limit).map(transcript_turn).collect();
1026 axum::Json(json!({"session_id": session_id, "turns": turns})).into_response()
1027}
1028
1029fn trace_reasoning_fields(req: &Value) -> (Option<String>, Option<i64>) {
1030 (
1031 req["reasoning"]["effort"].as_str().map(String::from),
1032 req["thinking"]["budget_tokens"].as_i64(),
1033 )
1034}
1035
1036fn trace_extras(req: &Value) -> Value {
1037 let system_text: Option<String> = match &req["system"] {
1038 Value::String(s) => Some(s.clone()),
1039 Value::Array(parts) => {
1040 let joined: Vec<&str> = parts.iter().filter_map(|p| p["text"].as_str()).collect();
1041 (!joined.is_empty()).then(|| joined.join("\n\n"))
1042 }
1043 _ => req["instructions"].as_str().map(String::from).or_else(|| {
1044 let joined: Vec<String> = req["messages"]
1045 .as_array()
1046 .into_iter()
1047 .flatten()
1048 .filter(|m| matches!(m["role"].as_str(), Some("system") | Some("developer")))
1049 .map(|m| match &m["content"] {
1050 Value::String(s) => s.clone(),
1051 Value::Array(parts) => parts
1052 .iter()
1053 .filter_map(|p| p["text"].as_str())
1054 .collect::<Vec<_>>()
1055 .join("\n"),
1056 _ => String::new(),
1057 })
1058 .filter(|s| !s.is_empty())
1059 .collect();
1060 (!joined.is_empty()).then(|| joined.join("\n\n"))
1061 }),
1062 };
1063 let system_chars = system_text.as_ref().map(|s| s.chars().count());
1064 let system_prompt = system_text.map(|s| truncate_chars(s, 64_000));
1065 let (reasoning_effort, thinking_budget) = trace_reasoning_fields(req);
1066 json!({
1067 "reasoning_effort": reasoning_effort,
1068 "thinking_budget": thinking_budget,
1069 "max_tokens": req["max_tokens"].as_i64().or(req["max_output_tokens"].as_i64()),
1070 "temperature": req["temperature"],
1071 "message_count": req["messages"].as_array().or(req["input"].as_array()).map(|a| a.len()),
1072 "system_chars": system_chars,
1073 "system_prompt": system_prompt,
1074 })
1075}
1076
1077async fn trace_get(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
1078 match state.store.get_trace(&id) {
1079 Ok(Some(row)) => {
1080 let mut extras = read_gz_json(row["req_body_path"].as_str())
1081 .map(|req| trace_extras(&req))
1082 .unwrap_or_else(|| json!({}));
1083 if let Some(summary) = dario_capture_summary(&state, &row) {
1084 if !extras.is_object() {
1085 extras = json!({});
1086 }
1087 if let Some(obj) = extras.as_object_mut() {
1088 obj.insert("dario_capture".into(), summary);
1089 }
1090 }
1091 axum::Json(json!({"trace": row, "extras": extras})).into_response()
1092 }
1093 Ok(None) => error_response(StatusCode::NOT_FOUND, &format!("unknown trace '{id}'")),
1094 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1095 }
1096}
1097
1098async fn trace_body(
1099 State(state): State<Arc<AppState>>,
1100 Path((id, kind)): Path<(String, String)>,
1101) -> Response {
1102 let row = match state.store.get_trace(&id) {
1103 Ok(Some(row)) => row,
1104 Ok(None) => {
1105 return error_response(StatusCode::NOT_FOUND, &format!("unknown trace '{id}'"))
1106 }
1107 Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1108 };
1109 let path = match kind.as_str() {
1110 "request" => row["req_body_path"].as_str().map(String::from),
1111 "upstream-request" => row["upstream_req_body_path"].as_str().map(String::from),
1112 "response" => row["resp_body_path"].as_str().map(String::from),
1113 "dario-upstream-request" | "dario-upstream-response" => {
1114 find_dario_capture_path(&state, &row, &kind)
1115 }
1116 _ => {
1117 return error_response(
1118 StatusCode::BAD_REQUEST,
1119 "kind must be request|upstream-request|response|dario-upstream-request|dario-upstream-response",
1120 )
1121 }
1122 };
1123 match read_gz_text(path.as_deref()) {
1124 Some(text) => {
1125 let ct = if text.trim_start().starts_with('{') || text.trim_start().starts_with('[') {
1126 "application/json; charset=utf-8"
1127 } else {
1128 "text/plain; charset=utf-8"
1129 };
1130 Response::builder()
1131 .status(StatusCode::OK)
1132 .header("content-type", ct)
1133 .header("x-alexandria-body-path", path.as_deref().unwrap_or(""))
1134 .body(Body::from(text))
1135 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))
1136 }
1137 None => error_response(
1138 StatusCode::NOT_FOUND,
1139 &format!("no {kind} body stored for trace '{id}'"),
1140 ),
1141 }
1142}
1143
1144async fn trace_reply_md(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
1145 use alex_core::translate;
1146 let row = match state.store.get_trace(&id) {
1147 Ok(Some(row)) => row,
1148 Ok(None) => {
1149 return error_response(StatusCode::NOT_FOUND, &format!("unknown trace '{id}'"))
1150 }
1151 Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1152 };
1153 let fmt = row["upstream_format"]
1154 .as_str()
1155 .or(row["client_format"].as_str())
1156 .unwrap_or("");
1157 let reply = read_gz_text(row["resp_body_path"].as_str())
1158 .and_then(|text| translate::assistant_reply_text(fmt, &text));
1159 match reply {
1160 Some(md) => Response::builder()
1161 .status(StatusCode::OK)
1162 .header("content-type", "text/markdown; charset=utf-8")
1163 .body(Body::from(md))
1164 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())),
1165 None => error_response(
1166 StatusCode::NOT_FOUND,
1167 &format!("no assistant reply available for trace '{id}'"),
1168 ),
1169 }
1170}
1171
1172async fn trace_delete(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
1173 match state.store.get_trace(&id) {
1174 Ok(Some(_)) => {}
1175 Ok(None) => {
1176 return error_response(StatusCode::NOT_FOUND, &format!("unknown trace '{id}'"))
1177 }
1178 Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1179 }
1180 match state.store.delete_trace(&id) {
1181 Ok(paths) => {
1182 let removed = paths
1183 .iter()
1184 .filter(|p| std::fs::remove_file(p).is_ok())
1185 .count();
1186 axum::Json(json!({"deleted": true, "files_removed": removed})).into_response()
1187 }
1188 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1189 }
1190}
1191
1192async fn traces_run_summary(
1193 State(state): State<Arc<AppState>>,
1194 Path(run_id): Path<String>,
1195) -> Response {
1196 match state.store.run_summary(&run_id) {
1197 Ok(v) if v["trace_count"].as_i64().unwrap_or(0) == 0 => error_response(
1198 StatusCode::NOT_FOUND,
1199 &format!("no traces for run '{run_id}'"),
1200 ),
1201 Ok(v) => axum::Json(v).into_response(),
1202 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1203 }
1204}
1205
1206async fn traces_run_events(
1207 State(state): State<Arc<AppState>>,
1208 Path(run_id): Path<String>,
1209 Query(q): Query<HashMap<String, String>>,
1210) -> Response {
1211 let filter = TraceFilter {
1212 run_id: Some(run_id),
1213 limit: q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(1000),
1214 ..Default::default()
1215 };
1216 match state.store.search_traces(&filter) {
1217 Ok(rows) => axum::Json(json!({"traces": rows})).into_response(),
1218 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1219 }
1220}
1221
1222async fn traces_run_export(
1223 State(state): State<Arc<AppState>>,
1224 Path(run_id): Path<String>,
1225 Query(q): Query<HashMap<String, String>>,
1226) -> Response {
1227 let filter = TraceFilter {
1228 run_id: Some(run_id),
1229 limit: q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(5000),
1230 ..Default::default()
1231 };
1232 match state.store.search_traces(&filter) {
1233 Ok(rows) => ndjson_response(rows, wants_bodies(&q)),
1234 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1235 }
1236}
1237
1238async fn traces_run_artifacts(
1239 State(state): State<Arc<AppState>>,
1240 Path(run_id): Path<String>,
1241) -> Response {
1242 match state.store.run_artifacts(&run_id) {
1243 Ok(artifacts) => axum::Json(json!({
1244 "run_id": run_id,
1245 "data_dir": state.store.data_dir.to_string_lossy(),
1246 "artifacts": artifacts,
1247 }))
1248 .into_response(),
1249 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1250 }
1251}
1252
1253async fn admin_accounts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
1254 let accounts: Vec<Value> = state
1255 .vault
1256 .list()
1257 .await
1258 .into_iter()
1259 .map(|a| {
1260 json!({
1261 "id": a.id,
1262 "provider": a.provider.as_str(),
1263 "kind": a.kind,
1264 "label": a.label,
1265 "status": a.status,
1266 "expires_at_ms": a.expires_at_ms,
1267 "expires_in_s": a.expires_at_ms.map(|e| (e - now_ms()) / 1000),
1268 })
1269 })
1270 .collect();
1271 axum::Json(json!({"accounts": accounts}))
1272}
1273
1274async fn admin_health(State(state): State<Arc<AppState>>) -> Response {
1275 let heartbeats = state.store.last_heartbeats().unwrap_or_default();
1276 let accounts: Vec<Value> = state
1277 .vault
1278 .list()
1279 .await
1280 .into_iter()
1281 .map(|a| {
1282 let last = heartbeats
1283 .iter()
1284 .find(|h| h["provider"].as_str() == Some(a.provider.as_str()))
1285 .cloned();
1286 json!({
1287 "id": a.id,
1288 "provider": a.provider.as_str(),
1289 "kind": a.kind,
1290 "status": a.status,
1291 "token_expires_in_s": a.expires_at_ms.map(|e| (e - now_ms()) / 1000),
1292 "last_heartbeat": last,
1293 })
1294 })
1295 .collect();
1296 axum::Json(json!({"accounts": accounts})).into_response()
1297}
1298
1299#[derive(Debug, Clone, serde::Serialize)]
1300pub struct PingResult {
1301 pub provider: &'static str,
1302 pub account_id: Option<String>,
1303 pub ok: bool,
1304 pub status: Option<u16>,
1305 pub latency_ms: i64,
1306 pub message: String,
1307}
1308
1309#[derive(Debug, Clone)]
1310pub struct PingModels {
1311 pub anthropic: String,
1312 pub openai: String,
1313 pub xai: String,
1314 pub gemini: String,
1315}
1316
1317pub async fn ping_provider(
1318 state: &Arc<AppState>,
1319 provider: Provider,
1320 models: &PingModels,
1321) -> PingResult {
1322 let start = now_ms();
1323 let prompt = "Health check: what time is it? If you cannot know, just reply: creds ok";
1324 let (format, path, body) = match provider {
1325 Provider::Anthropic => (
1326 ClientFormat::AnthropicMessages,
1327 "/v1/messages",
1328 json!({
1329 "model": models.anthropic,
1330 "max_tokens": 64,
1331 "system": "You are Claude Code, Anthropic's official CLI for Claude.",
1332 "messages": [{"role": "user", "content": prompt}],
1333 }),
1334 ),
1335 Provider::Openai => (
1336 ClientFormat::OpenaiResponses,
1337 "/v1/responses",
1338 json!({
1339 "model": models.openai,
1340 "stream": true,
1341 "store": false,
1342 "instructions": "You are a helpful assistant.",
1343 "input": [{"type": "message", "role": "user",
1344 "content": [{"type": "input_text", "text": prompt}]}],
1345 }),
1346 ),
1347 Provider::Xai => (
1348 ClientFormat::OpenaiChat,
1349 "/v1/chat/completions",
1350 json!({
1351 "model": models.xai,
1352 "stream": false,
1353 "messages": [{"role": "user", "content": prompt}],
1354 }),
1355 ),
1356 Provider::Gemini => (
1357 ClientFormat::GeminiGenerate,
1358 "/v1beta/models/:generateContent",
1359 json!({
1360 "model": models.gemini,
1361 "contents": [{"role": "user", "parts": [{"text": prompt}]}],
1362 "generationConfig": {"maxOutputTokens": 64},
1363 }),
1364 ),
1365 };
1366 let account_id = state
1367 .vault
1368 .account_for(provider, true)
1369 .await
1370 .ok()
1371 .map(|a| a.id);
1372 let mut headers = HeaderMap::new();
1373 headers.insert(
1374 "x-api-key",
1375 HeaderValue::from_str(&state.local_key).expect("key header"),
1376 );
1377 headers.insert("user-agent", HeaderValue::from_static("alexandria-ping"));
1378 let resp = proxy(
1379 state.clone(),
1380 format,
1381 path,
1382 headers,
1383 Bytes::from(serde_json::to_vec(&body).expect("ping body")),
1384 None,
1385 )
1386 .await;
1387 let status = resp.status().as_u16();
1388 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
1389 .await
1390 .unwrap_or_default();
1391 let text = String::from_utf8_lossy(&bytes);
1392 let message = extract_reply(&text).unwrap_or_else(|| snippet(&text));
1393 PingResult {
1394 provider: provider.as_str(),
1395 account_id,
1396 ok: (200..300).contains(&status),
1397 status: Some(status),
1398 latency_ms: now_ms() - start,
1399 message,
1400 }
1401}
1402
1403fn extract_reply(text: &str) -> Option<String> {
1404 if let Ok(v) = serde_json::from_str::<Value>(text) {
1405 if let Some(t) = v["content"][0]["text"].as_str() {
1406 return Some(t.to_string());
1407 }
1408 if let Some(t) = v["choices"][0]["message"]["content"].as_str() {
1409 return Some(t.to_string());
1410 }
1411 if let Some(t) = v["error"]["message"].as_str() {
1412 return Some(t.to_string());
1413 }
1414 if let Some(t) = v["detail"].as_str() {
1415 return Some(t.to_string());
1416 }
1417 }
1418 for line in text.lines() {
1419 let Some(data) = line.strip_prefix("data:") else {
1420 continue;
1421 };
1422 let Ok(v) = serde_json::from_str::<Value>(data.trim()) else {
1423 continue;
1424 };
1425 if v["type"].as_str() == Some("response.output_text.done") {
1426 if let Some(t) = v["text"].as_str() {
1427 return Some(t.to_string());
1428 }
1429 }
1430 }
1431 None
1432}
1433
1434fn snippet(text: &str) -> String {
1435 let t: String = text.chars().take(200).collect();
1436 t.replace('\n', " ")
1437}
1438
1439pub async fn heartbeat_once(state: &Arc<AppState>, models: &PingModels) -> Vec<PingResult> {
1440 let providers: Vec<Provider> = {
1441 let mut seen = Vec::new();
1442 for a in state.vault.list().await {
1443 if a.status == "active"
1444 && matches!(
1445 a.provider,
1446 Provider::Anthropic | Provider::Openai | Provider::Xai | Provider::Gemini
1447 )
1448 && !seen.contains(&a.provider)
1449 {
1450 seen.push(a.provider);
1451 }
1452 }
1453 seen
1454 };
1455 let mut results = Vec::new();
1456 for provider in providers {
1457 let r = ping_provider(state, provider, models).await;
1458 if let Err(e) = state.store.insert_heartbeat(
1459 now_ms(),
1460 r.provider,
1461 r.account_id.as_deref(),
1462 r.ok,
1463 r.status.map(|s| s as i64),
1464 r.latency_ms,
1465 &r.message,
1466 ) {
1467 tracing::warn!("failed to record heartbeat: {e}");
1468 }
1469 tracing::info!(
1470 provider = r.provider,
1471 ok = r.ok,
1472 status = r.status,
1473 latency_ms = r.latency_ms,
1474 reply = %r.message,
1475 "heartbeat"
1476 );
1477 results.push(r);
1478 }
1479 results
1480}
1481
1482async fn anthropic_messages(
1483 State(state): State<Arc<AppState>>,
1484 ConnectInfo(peer): ConnectInfo<std::net::SocketAddr>,
1485 headers: HeaderMap,
1486 body: Bytes,
1487) -> Response {
1488 proxy(
1489 state,
1490 ClientFormat::AnthropicMessages,
1491 "/v1/messages",
1492 headers,
1493 body,
1494 Some(peer),
1495 )
1496 .await
1497}
1498
1499async fn openai_chat(
1500 State(state): State<Arc<AppState>>,
1501 ConnectInfo(peer): ConnectInfo<std::net::SocketAddr>,
1502 headers: HeaderMap,
1503 body: Bytes,
1504) -> Response {
1505 proxy(
1506 state,
1507 ClientFormat::OpenaiChat,
1508 "/v1/chat/completions",
1509 headers,
1510 body,
1511 Some(peer),
1512 )
1513 .await
1514}
1515
1516async fn openai_responses(
1517 State(state): State<Arc<AppState>>,
1518 ConnectInfo(peer): ConnectInfo<std::net::SocketAddr>,
1519 headers: HeaderMap,
1520 body: Bytes,
1521) -> Response {
1522 proxy(
1523 state,
1524 ClientFormat::OpenaiResponses,
1525 "/v1/responses",
1526 headers,
1527 body,
1528 Some(peer),
1529 )
1530 .await
1531}
1532
1533async fn gemini_generate(
1534 State(state): State<Arc<AppState>>,
1535 ConnectInfo(peer): ConnectInfo<std::net::SocketAddr>,
1536 axum::extract::Path(model_action): axum::extract::Path<String>,
1537 headers: HeaderMap,
1538 body: Bytes,
1539) -> Response {
1540 let (model, method) = model_action
1541 .split_once(':')
1542 .unwrap_or((model_action.as_str(), "generateContent"));
1543 let stream = method == "streamGenerateContent";
1544 if method != "generateContent" && !stream {
1545 return error_response(
1546 StatusCode::NOT_FOUND,
1547 &format!("unsupported gemini method '{method}' (expected generateContent or streamGenerateContent)"),
1548 );
1549 }
1550 if model.is_empty() {
1551 return error_response(StatusCode::BAD_REQUEST, "missing model in path");
1552 }
1553 let mut v: Value = match serde_json::from_slice(&body) {
1554 Ok(v) => v,
1555 Err(e) => {
1556 return error_response(StatusCode::BAD_REQUEST, &format!("invalid JSON body: {e}"))
1557 }
1558 };
1559 v["model"] = json!(model);
1560 if stream {
1561 v["stream"] = json!(true);
1562 }
1563 let body = Bytes::from(serde_json::to_vec(&v).unwrap_or_default());
1564 let path = if stream {
1565 "/v1beta/models/:streamGenerateContent"
1566 } else {
1567 "/v1beta/models/:generateContent"
1568 };
1569 proxy(
1570 state,
1571 ClientFormat::GeminiGenerate,
1572 path,
1573 headers,
1574 body,
1575 Some(peer),
1576 )
1577 .await
1578}
1579
1580fn error_response(status: StatusCode, message: &str) -> Response {
1581 (
1582 status,
1583 axum::Json(json!({"error": {"type": "alexandria", "message": message}})),
1584 )
1585 .into_response()
1586}
1587
1588fn client_key(headers: &HeaderMap) -> Option<String> {
1589 if let Some(v) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
1590 return Some(v.to_string());
1591 }
1592 if let Some(v) = headers.get("x-goog-api-key").and_then(|v| v.to_str().ok()) {
1593 return Some(v.to_string());
1594 }
1595 headers
1596 .get("authorization")
1597 .and_then(|v| v.to_str().ok())
1598 .and_then(|v| v.strip_prefix("Bearer "))
1599 .map(String::from)
1600}
1601
1602fn redacted_headers(headers: &HeaderMap) -> String {
1603 let map: HashMap<String, String> = headers
1604 .iter()
1605 .map(|(k, v)| {
1606 let key = k.as_str().to_lowercase();
1607 let val = if ["authorization", "x-api-key", "cookie", "chatgpt-account-id"]
1608 .contains(&key.as_str())
1609 {
1610 "<redacted>".to_string()
1611 } else {
1612 v.to_str().unwrap_or("<binary>").to_string()
1613 };
1614 (key, val)
1615 })
1616 .collect();
1617 serde_json::to_string(&map).unwrap_or_default()
1618}
1619
1620#[derive(Debug, Clone, Copy, PartialEq)]
1621enum RespondAs {
1622 Anthropic,
1623 OpenaiChat,
1624 OpenaiResponses,
1625 Gemini,
1626}
1627
1628struct UpstreamPlan {
1629 url: String,
1630 account: Account,
1631 body: Vec<u8>,
1632 upstream_format: &'static str,
1633 destream: bool,
1634 respond_as: Option<RespondAs>,
1635 client_stream: bool,
1636 extra_headers: Vec<(String, String)>,
1637 dario_guard: Option<Box<dyn std::any::Any + Send>>,
1638}
1639
1640fn dario_account(active: &DarioActive) -> Account {
1641 Account {
1642 id: format!("dario:{}", active.generation_id),
1643 provider: Provider::Anthropic,
1644 kind: "dario".into(),
1645 label: Some("dario generational proxy".into()),
1646 access_token: None,
1647 refresh_token: None,
1648 id_token: None,
1649 api_key: Some(active.api_key.clone()),
1650 expires_at_ms: None,
1651 last_refresh_ms: None,
1652 account_meta: Value::Null,
1653 cooldown_until_ms: None,
1654 status: "active".into(),
1655 }
1656}
1657
1658async fn ensure_gemini_project(
1659 state: &AppState,
1660 account: &Account,
1661) -> Result<String, (StatusCode, String)> {
1662 if let Some(p) = account.account_meta.get("project_id").and_then(|v| v.as_str()) {
1663 if !p.is_empty() {
1664 return Ok(p.to_string());
1665 }
1666 }
1667 if let Ok(env_p) = std::env::var("GOOGLE_CLOUD_PROJECT") {
1668 if !env_p.is_empty() {
1669 let _ = state
1670 .vault
1671 .set_account_meta(&account.id, "project_id", json!(env_p))
1672 .await;
1673 return Ok(env_p);
1674 }
1675 }
1676 let token = account
1677 .access_token
1678 .as_deref()
1679 .ok_or_else(|| (StatusCode::BAD_GATEWAY, "gemini account has no access token".into()))?;
1680 let load_url = format!("{GEMINI_CODE_ASSIST_BASE}/{GEMINI_CODE_ASSIST_VERSION}:loadCodeAssist");
1681 let load_body = json!({
1682 "cloudaicompanionProject": null,
1683 "metadata": {"ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI"},
1684 });
1685 let resp = state
1686 .http
1687 .post(&load_url)
1688 .header("authorization", format!("Bearer {token}"))
1689 .json(&load_body)
1690 .send()
1691 .await
1692 .map_err(|e| (StatusCode::BAD_GATEWAY, format!("loadCodeAssist failed: {e}")))?;
1693 let load: Value = resp.json().await.unwrap_or(Value::Null);
1694 let extract = |v: &Value| -> Option<String> {
1695 for key in ["cloudaicompanionProject", "projectId", "project"] {
1696 match &v[key] {
1697 Value::String(s) if !s.is_empty() => return Some(s.clone()),
1698 obj if obj["id"].is_string() => {
1699 return obj["id"].as_str().map(String::from)
1700 }
1701 _ => {}
1702 }
1703 }
1704 None
1705 };
1706 if let Some(p) = extract(&load) {
1707 let _ = state
1708 .vault
1709 .set_account_meta(&account.id, "project_id", json!(p))
1710 .await;
1711 return Ok(p);
1712 }
1713 if let Some(free) = load["ineligibleTiers"]
1714 .as_array()
1715 .and_then(|t| t.iter().find(|t| t["tierId"] == json!("free-tier")))
1716 {
1717 let reason = free["reasonCode"].as_str().unwrap_or("");
1718 let msg = free["reasonMessage"].as_str().unwrap_or("");
1719 let hint = if let Some(url) = free["validationUrl"].as_str() {
1720 format!(
1721 "gemini account needs verification before the free Code Assist tier works — \
1722 open {url} to verify, then retry (or sign in with another personal Google \
1723 account, or set gemini_project to a GCP project)"
1724 )
1725 } else if reason == "DASHER_USER" {
1726 "this is a Google Workspace account, which cannot use the free Code Assist tier — \
1727 set gemini_project to a GCP project (Code Assist API enabled), or sign in with a \
1728 personal Google account"
1729 .to_string()
1730 } else {
1731 format!("gemini account is not eligible for the free Code Assist tier: {msg}")
1732 };
1733 return Err((StatusCode::BAD_GATEWAY, hint));
1734 }
1735 let tier = load["allowedTiers"]
1736 .as_array()
1737 .and_then(|tiers| tiers.iter().find(|t| t["isDefault"] == json!(true)))
1738 .and_then(|t| t["id"].as_str())
1739 .unwrap_or("free-tier")
1740 .to_string();
1741 let onboard_url =
1742 format!("{GEMINI_CODE_ASSIST_BASE}/{GEMINI_CODE_ASSIST_VERSION}:onboardUser");
1743 let onboard_body = json!({
1744 "tierId": tier,
1745 "cloudaicompanionProject": load["cloudaicompanionProject"],
1746 "metadata": {"ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI"},
1747 });
1748 for _ in 0..5 {
1749 let resp = state
1750 .http
1751 .post(&onboard_url)
1752 .header("authorization", format!("Bearer {token}"))
1753 .json(&onboard_body)
1754 .send()
1755 .await
1756 .map_err(|e| (StatusCode::BAD_GATEWAY, format!("onboardUser failed: {e}")))?;
1757 let lro: Value = resp.json().await.unwrap_or(Value::Null);
1758 if lro["done"] == json!(true) {
1759 if let Some(p) = extract(&lro["response"]) {
1760 let _ = state
1761 .vault
1762 .set_account_meta(&account.id, "project_id", json!(p))
1763 .await;
1764 return Ok(p);
1765 }
1766 }
1767 tokio::time::sleep(Duration::from_secs(2)).await;
1768 }
1769 Err((
1770 StatusCode::BAD_GATEWAY,
1771 "gemini Code Assist onboarding did not return a project id (set GOOGLE_CLOUD_PROJECT to override)".into(),
1772 ))
1773}
1774
1775async fn plan_upstream(
1776 state: &AppState,
1777 format: ClientFormat,
1778 provider: Provider,
1779 routed_model: &str,
1780 body_json: &mut Value,
1781 original_body: &[u8],
1782 trace_id: &str,
1783) -> Result<UpstreamPlan, (StatusCode, String)> {
1784 use alex_core::translate;
1785 let client_stream = body_json["stream"].as_bool().unwrap_or(false);
1786 match provider {
1787 Provider::Anthropic => {
1788 let converted = match format {
1789 ClientFormat::AnthropicMessages => None,
1790 ClientFormat::OpenaiChat => Some((
1791 translate::openai_chat_to_anthropic(body_json),
1792 RespondAs::OpenaiChat,
1793 )),
1794 ClientFormat::OpenaiResponses => Some((
1795 translate::openai_responses_to_anthropic(body_json),
1796 RespondAs::OpenaiResponses,
1797 )),
1798 ClientFormat::GeminiGenerate => Some((
1799 translate::gemini_to_anthropic(body_json),
1800 RespondAs::Gemini,
1801 )),
1802 };
1803 let dario_active = state.dario.as_ref().and_then(|d| d.active());
1804 let (base, account, dario_guard, dario_capture) = match (&state.dario, dario_active) {
1805 (Some(dario), Some(active)) => (
1806 active.base_url.trim_end_matches('/').to_string(),
1807 dario_account(&active),
1808 dario.begin(&active.generation_id),
1809 true,
1810 ),
1811 _ => {
1812 let account = state
1813 .vault
1814 .account_for(provider, true)
1815 .await
1816 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
1817 (ANTHROPIC_BASE.to_string(), account, None, false)
1818 }
1819 };
1820 let (body, respond_as) = match converted {
1821 None => {
1822 body_json["model"] = json!(routed_model);
1823 (
1824 serde_json::to_vec(body_json).unwrap_or_else(|_| original_body.to_vec()),
1825 None,
1826 )
1827 }
1828 Some((mut converted, respond_as)) => {
1829 converted["model"] = json!(routed_model);
1830 converted["stream"] = json!(false);
1831 (
1832 serde_json::to_vec(&converted).unwrap_or_else(|_| original_body.to_vec()),
1833 Some(respond_as),
1834 )
1835 }
1836 };
1837 Ok(UpstreamPlan {
1838 url: format!("{base}/v1/messages"),
1839 account,
1840 body,
1841 upstream_format: "anthropic",
1842 destream: false,
1843 respond_as,
1844 client_stream,
1845 extra_headers: if dario_capture {
1846 vec![
1847 ("x-dario-capture-id".into(), trace_id.into()),
1848 ("x-dario-capture-model".into(), routed_model.into()),
1849 ]
1850 } else {
1851 vec![]
1852 },
1853 dario_guard,
1854 })
1855 }
1856 Provider::Openai => {
1857 let prefer_oauth = format != ClientFormat::OpenaiChat;
1858 let account = state
1859 .vault
1860 .account_for(provider, prefer_oauth)
1861 .await
1862 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
1863 let oauth = account.kind == "oauth";
1864 match format {
1865 ClientFormat::OpenaiChat if !oauth => {
1866 body_json["model"] = json!(routed_model);
1867 let body =
1868 serde_json::to_vec(body_json).unwrap_or_else(|_| original_body.to_vec());
1869 Ok(UpstreamPlan {
1870 url: format!("{OPENAI_BASE}/v1/chat/completions"),
1871 account,
1872 body,
1873 upstream_format: "openai-chat",
1874 destream: false,
1875 respond_as: None,
1876 client_stream,
1877 extra_headers: vec![],
1878 dario_guard: None,
1879 })
1880 }
1881 ClientFormat::OpenaiChat => {
1882 let pivot = translate::openai_chat_to_anthropic(body_json);
1883 let mut converted = translate::anthropic_to_openai_responses(&pivot);
1884 converted["model"] = json!(routed_model);
1885 translate::normalize_codex_request(&mut converted);
1886 let body = serde_json::to_vec(&converted)
1887 .unwrap_or_else(|_| original_body.to_vec());
1888 Ok(UpstreamPlan {
1889 url: format!("{CODEX_BASE}/responses"),
1890 account,
1891 body,
1892 upstream_format: "openai-responses",
1893 destream: false,
1894 respond_as: Some(RespondAs::OpenaiChat),
1895 client_stream,
1896 extra_headers: vec![],
1897 dario_guard: None,
1898 })
1899 }
1900 ClientFormat::OpenaiResponses => {
1901 body_json["model"] = json!(routed_model);
1902 let mut destream = false;
1903 let url = if oauth {
1904 if body_json["stream"].as_bool() != Some(true) {
1905 destream = true;
1906 }
1907 translate::normalize_codex_request(body_json);
1908 format!("{CODEX_BASE}/responses")
1909 } else {
1910 format!("{OPENAI_BASE}/v1/responses")
1911 };
1912 let body =
1913 serde_json::to_vec(body_json).unwrap_or_else(|_| original_body.to_vec());
1914 Ok(UpstreamPlan {
1915 url,
1916 account,
1917 body,
1918 upstream_format: "openai-responses",
1919 destream,
1920 respond_as: None,
1921 client_stream,
1922 extra_headers: vec![],
1923 dario_guard: None,
1924 })
1925 }
1926 ClientFormat::AnthropicMessages => {
1927 let mut converted = translate::anthropic_to_openai_responses(body_json);
1928 converted["model"] = json!(routed_model);
1929 let url = if oauth {
1930 translate::normalize_codex_request(&mut converted);
1931 format!("{CODEX_BASE}/responses")
1932 } else {
1933 converted["stream"] = json!(false);
1934 format!("{OPENAI_BASE}/v1/responses")
1935 };
1936 let body = serde_json::to_vec(&converted)
1937 .unwrap_or_else(|_| original_body.to_vec());
1938 Ok(UpstreamPlan {
1939 url,
1940 account,
1941 body,
1942 upstream_format: "openai-responses",
1943 destream: false,
1944 respond_as: Some(RespondAs::Anthropic),
1945 client_stream,
1946 extra_headers: vec![],
1947 dario_guard: None,
1948 })
1949 }
1950 ClientFormat::GeminiGenerate => {
1951 let pivot = translate::gemini_to_anthropic(body_json);
1952 let mut converted = translate::anthropic_to_openai_responses(&pivot);
1953 converted["model"] = json!(routed_model);
1954 let url = if oauth {
1955 translate::normalize_codex_request(&mut converted);
1956 format!("{CODEX_BASE}/responses")
1957 } else {
1958 converted["stream"] = json!(false);
1959 format!("{OPENAI_BASE}/v1/responses")
1960 };
1961 let body = serde_json::to_vec(&converted)
1962 .unwrap_or_else(|_| original_body.to_vec());
1963 Ok(UpstreamPlan {
1964 url,
1965 account,
1966 body,
1967 upstream_format: "openai-responses",
1968 destream: false,
1969 respond_as: Some(RespondAs::Gemini),
1970 client_stream,
1971 extra_headers: vec![],
1972 dario_guard: None,
1973 })
1974 }
1975 }
1976 }
1977 Provider::Xai => {
1978 if format != ClientFormat::OpenaiChat {
1979 return Err((
1980 StatusCode::NOT_IMPLEMENTED,
1981 "the xai/grok upstream speaks OpenAI chat completions; POST to /v1/chat/completions".to_string(),
1982 ));
1983 }
1984 let account = state
1985 .vault
1986 .account_for(provider, true)
1987 .await
1988 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
1989 body_json["model"] = json!(routed_model);
1990 let body = serde_json::to_vec(body_json).unwrap_or_else(|_| original_body.to_vec());
1991 Ok(UpstreamPlan {
1992 url: format!("{XAI_BASE}/chat/completions"),
1993 account,
1994 body,
1995 upstream_format: "openai-chat",
1996 destream: false,
1997 respond_as: None,
1998 client_stream,
1999 extra_headers: vec![
2000 ("x-grok-model-override".into(), routed_model.to_string()),
2001 ("x-grok-conv-id".into(), trace_id.to_string()),
2002 ],
2003 dario_guard: None,
2004 })
2005 }
2006 Provider::Gemini => {
2007 let account = state
2009 .vault
2010 .account_for(provider, false)
2011 .await
2012 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
2013 let (gemini_req, respond_as) = match format {
2014 ClientFormat::GeminiGenerate => {
2015 let mut g = body_json.clone();
2016 if let Some(o) = g.as_object_mut() {
2017 o.remove("model");
2018 o.remove("stream");
2019 }
2020 (g, None)
2021 }
2022 ClientFormat::AnthropicMessages => (
2023 translate::anthropic_to_gemini_request(body_json),
2024 Some(RespondAs::Anthropic),
2025 ),
2026 ClientFormat::OpenaiChat => (
2027 translate::anthropic_to_gemini_request(&translate::openai_chat_to_anthropic(
2028 body_json,
2029 )),
2030 Some(RespondAs::OpenaiChat),
2031 ),
2032 ClientFormat::OpenaiResponses => (
2033 translate::anthropic_to_gemini_request(
2034 &translate::openai_responses_to_anthropic(body_json),
2035 ),
2036 Some(RespondAs::OpenaiResponses),
2037 ),
2038 };
2039 let method = if client_stream {
2040 "streamGenerateContent"
2041 } else {
2042 "generateContent"
2043 };
2044 let (url, body) = if account.kind == "api_key" {
2045 let mut url = format!(
2047 "{GEMINI_API_BASE}/v1beta/models/{routed_model}:{method}"
2048 );
2049 if client_stream {
2050 url.push_str("?alt=sse");
2051 }
2052 let body =
2053 serde_json::to_vec(&gemini_req).unwrap_or_else(|_| original_body.to_vec());
2054 (url, body)
2055 } else {
2056 let project = ensure_gemini_project(state, &account).await?;
2058 let mut url = format!(
2059 "{GEMINI_CODE_ASSIST_BASE}/{GEMINI_CODE_ASSIST_VERSION}:{method}"
2060 );
2061 if client_stream {
2062 url.push_str("?alt=sse");
2063 }
2064 let envelope = json!({
2065 "model": routed_model,
2066 "project": project,
2067 "request": gemini_req,
2068 });
2069 let body =
2070 serde_json::to_vec(&envelope).unwrap_or_else(|_| original_body.to_vec());
2071 (url, body)
2072 };
2073 Ok(UpstreamPlan {
2074 url,
2075 account,
2076 body,
2077 upstream_format: "gemini",
2078 destream: false,
2079 respond_as: respond_as.or(Some(RespondAs::Gemini)),
2080 client_stream,
2081 extra_headers: vec![],
2082 dario_guard: None,
2083 })
2084 }
2085 }
2086}
2087
2088#[cfg(test)]
2089mod trace_api_tests {
2090 use super::{
2091 session_from_metadata, trace_extras, trace_reasoning_fields, transcript_turn,
2092 truncate_chars,
2093 };
2094 use serde_json::json;
2095
2096 #[test]
2097 fn metadata_session_variants() {
2098 let claude = json!({"metadata": {"user_id":
2099 "{\"device_id\":\"d1\",\"session_id\":\"ses_inner\"}"}});
2100 assert_eq!(session_from_metadata(&claude), Some("ses_inner".into()));
2101 let plain = json!({"metadata": {"user_id": "user-123"}});
2102 assert_eq!(session_from_metadata(&plain), Some("user-123".into()));
2103 let json_no_session = json!({"metadata": {"user_id": "{\"device_id\":\"d1\"}"}});
2104 assert_eq!(
2105 session_from_metadata(&json_no_session),
2106 Some("{\"device_id\":\"d1\"}".into())
2107 );
2108 assert_eq!(session_from_metadata(&json!({})), None);
2109 }
2110
2111 #[test]
2112 fn extras_per_format() {
2113 let anthropic = json!({
2114 "system": [{"type": "text", "text": "abcd"}],
2115 "messages": [{"role": "user", "content": "hi"}],
2116 "max_tokens": 100,
2117 "temperature": 0.5,
2118 "thinking": {"type": "enabled", "budget_tokens": 4096},
2119 });
2120 let e = trace_extras(&anthropic);
2121 assert_eq!(e["thinking_budget"], 4096);
2122 assert_eq!(trace_reasoning_fields(&anthropic), (None, Some(4096)));
2123 assert_eq!(e["max_tokens"], 100);
2124 assert_eq!(e["temperature"], 0.5);
2125 assert_eq!(e["message_count"], 1);
2126 assert_eq!(e["system_chars"], 4);
2127 assert_eq!(e["reasoning_effort"], serde_json::Value::Null);
2128 let responses = json!({
2129 "instructions": "abc",
2130 "input": [{"type": "message"}, {"type": "message"}],
2131 "max_output_tokens": 200,
2132 "reasoning": {"effort": "high"},
2133 });
2134 let e = trace_extras(&responses);
2135 assert_eq!(e["reasoning_effort"], "high");
2136 assert_eq!(
2137 trace_reasoning_fields(&responses),
2138 (Some("high".into()), None)
2139 );
2140 assert_eq!(e["max_tokens"], 200);
2141 assert_eq!(e["message_count"], 2);
2142 assert_eq!(e["system_chars"], 3);
2143 assert_eq!(e["thinking_budget"], serde_json::Value::Null);
2144 }
2145
2146 #[test]
2147 fn truncates_on_char_boundaries() {
2148 assert_eq!(truncate_chars("abc".into(), 8000), "abc");
2149 assert_eq!(truncate_chars("héllo".repeat(2000), 8000).chars().count(), 8000);
2150 }
2151
2152 #[test]
2153 fn transcript_turn_missing_bodies_are_null() {
2154 let row = json!({
2155 "id": "t1", "ts_request_ms": 1, "ts_response_ms": 2,
2156 "routed_model": "m", "status": 200,
2157 "input_tokens": 10, "output_tokens": 5, "cost_usd": 0.01, "error": null,
2158 "reasoning_effort": "minimal", "thinking_budget": null,
2159 "req_body_path": "/nonexistent/x.gz", "resp_body_path": null,
2160 "client_format": "anthropic", "upstream_format": "anthropic",
2161 });
2162 let turn = transcript_turn(&row);
2163 assert_eq!(turn["trace_id"], "t1");
2164 assert_eq!(turn["user"], serde_json::Value::Null);
2165 assert_eq!(turn["assistant"], serde_json::Value::Null);
2166 assert_eq!(turn["model"], "m");
2167 assert_eq!(turn["reasoning_effort"], "minimal");
2168 assert_eq!(turn["thinking_budget"], serde_json::Value::Null);
2169 }
2170}
2171
2172#[cfg(test)]
2173mod run_key_tests {
2174 use super::{generate_run_key, key_fingerprint, key_hash_hex, merge_run_key_tags};
2175
2176 #[test]
2177 fn merge_header_tags_win_per_key() {
2178 let merged = merge_run_key_tags(
2179 Some(r#"{"task":"demo","suite":"swebench"}"#),
2180 Some(r#"{"task":"override","case":"x1"}"#),
2181 )
2182 .unwrap();
2183 let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
2184 assert_eq!(v["task"], "override");
2185 assert_eq!(v["suite"], "swebench");
2186 assert_eq!(v["case"], "x1");
2187 }
2188
2189 #[test]
2190 fn merge_handles_missing_sides() {
2191 assert_eq!(merge_run_key_tags(None, None), None);
2192 assert_eq!(
2193 merge_run_key_tags(Some(r#"{"a":"1"}"#), None).unwrap(),
2194 r#"{"a":"1"}"#
2195 );
2196 assert_eq!(
2197 merge_run_key_tags(None, Some(r#"{"b":"2"}"#)).unwrap(),
2198 r#"{"b":"2"}"#
2199 );
2200 assert_eq!(merge_run_key_tags(Some("not json"), None), None);
2201 }
2202
2203 #[test]
2204 fn run_key_shape_and_fingerprint() {
2205 let key = generate_run_key();
2206 assert!(key.starts_with("alxk-"));
2207 assert_eq!(key.len(), 5 + 64);
2208 assert_ne!(key, generate_run_key());
2209 let hash = key_hash_hex(&key);
2210 assert_eq!(hash.len(), 64);
2211 assert_eq!(hash[..16], key_fingerprint(&key));
2212 }
2213}
2214
2215#[cfg(test)]
2216mod usage_tests {
2217 use super::{key_fingerprint, usage_backoff_ms};
2218
2219 #[test]
2220 fn fingerprint_is_first_8_sha256_bytes_hex() {
2221 assert_eq!(key_fingerprint("abc"), "ba7816bf8f01cfea");
2222 assert_eq!(key_fingerprint("abc").len(), 16);
2223 assert_ne!(key_fingerprint("abc"), key_fingerprint("abd"));
2224 }
2225
2226 #[test]
2227 fn backoff_grows_and_caps() {
2228 assert_eq!(usage_backoff_ms(1, None), 60_000);
2229 assert_eq!(usage_backoff_ms(2, None), 120_000);
2230 assert_eq!(usage_backoff_ms(3, None), 240_000);
2231 assert_eq!(usage_backoff_ms(10, None), 3_600_000);
2232 }
2233
2234 #[test]
2235 fn retry_after_wins_when_larger() {
2236 assert_eq!(usage_backoff_ms(1, Some(600_000)), 600_000);
2237 assert_eq!(usage_backoff_ms(5, Some(1_000)), 960_000);
2238 }
2239}
2240
2241fn upstream_headers(
2242 account: &Account,
2243 client_headers: &HeaderMap,
2244) -> Result<reqwest::header::HeaderMap, (StatusCode, String)> {
2245 let mut h = reqwest::header::HeaderMap::new();
2246 h.insert("content-type", HeaderValue::from_static("application/json"));
2247 h.insert("accept", HeaderValue::from_static("*/*"));
2248 h.insert("accept-encoding", HeaderValue::from_static("identity"));
2249 if let Some(ua) = client_headers.get("user-agent") {
2250 h.insert("user-agent", ua.clone());
2251 }
2252 match (account.provider, account.kind.as_str()) {
2253 (Provider::Anthropic, "oauth") => {
2254 let token = account.access_token.as_deref().ok_or((
2255 StatusCode::BAD_GATEWAY,
2256 "anthropic oauth account has no access token".to_string(),
2257 ))?;
2258 h.insert(
2259 "authorization",
2260 HeaderValue::from_str(&format!("Bearer {token}"))
2261 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2262 );
2263 let mut beta = ANTHROPIC_OAUTH_BETA.to_string();
2264 if let Some(client_beta) = client_headers
2265 .get("anthropic-beta")
2266 .and_then(|v| v.to_str().ok())
2267 {
2268 if !client_beta.contains(ANTHROPIC_OAUTH_BETA) {
2269 beta = format!("{ANTHROPIC_OAUTH_BETA},{client_beta}");
2270 } else {
2271 beta = client_beta.to_string();
2272 }
2273 }
2274 h.insert(
2275 "anthropic-beta",
2276 HeaderValue::from_str(&beta)
2277 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2278 );
2279 insert_anthropic_version(&mut h, client_headers);
2280 }
2281 (Provider::Anthropic, _) => {
2282 let key = account
2283 .api_key
2284 .as_deref()
2285 .or(account.access_token.as_deref())
2286 .ok_or((
2287 StatusCode::BAD_GATEWAY,
2288 "anthropic account has no api key".to_string(),
2289 ))?;
2290 h.insert(
2291 "x-api-key",
2292 HeaderValue::from_str(key).map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2293 );
2294 if let Some(client_beta) = client_headers.get("anthropic-beta") {
2295 h.insert("anthropic-beta", client_beta.clone());
2296 }
2297 insert_anthropic_version(&mut h, client_headers);
2298 }
2299 (Provider::Openai, "oauth") => {
2300 let token = account.access_token.as_deref().ok_or((
2301 StatusCode::BAD_GATEWAY,
2302 "openai oauth account has no access token".to_string(),
2303 ))?;
2304 h.insert(
2305 "authorization",
2306 HeaderValue::from_str(&format!("Bearer {token}"))
2307 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2308 );
2309 if let Some(acct) = account.chatgpt_account_id() {
2310 h.insert(
2311 "chatgpt-account-id",
2312 HeaderValue::from_str(&acct)
2313 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2314 );
2315 }
2316 h.insert(
2317 "openai-beta",
2318 HeaderValue::from_static("responses=experimental"),
2319 );
2320 h.insert("originator", HeaderValue::from_static("codex_cli_rs"));
2321 let session = client_headers
2322 .get("session_id")
2323 .and_then(|v| v.to_str().ok())
2324 .map(String::from)
2325 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2326 h.insert(
2327 "session_id",
2328 HeaderValue::from_str(&session)
2329 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2330 );
2331 }
2332 (Provider::Openai, _) => {
2333 let key = account.api_key.as_deref().ok_or((
2334 StatusCode::BAD_GATEWAY,
2335 "openai account has no api key".to_string(),
2336 ))?;
2337 h.insert(
2338 "authorization",
2339 HeaderValue::from_str(&format!("Bearer {key}"))
2340 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2341 );
2342 }
2343 (Provider::Xai, _) => {
2344 let token = account
2345 .access_token
2346 .as_deref()
2347 .or(account.api_key.as_deref())
2348 .ok_or((
2349 StatusCode::BAD_GATEWAY,
2350 "xai account has no access token".to_string(),
2351 ))?;
2352 h.insert(
2353 "authorization",
2354 HeaderValue::from_str(&format!("Bearer {token}"))
2355 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2356 );
2357 h.insert("x-xai-token-auth", HeaderValue::from_static("xai-grok-cli"));
2358 h.insert(
2359 "x-grok-client-version",
2360 HeaderValue::from_static(GROK_CLIENT_VERSION),
2361 );
2362 h.insert("user-agent", HeaderValue::from_static("xai-grok-workspace"));
2363 h.insert("accept", HeaderValue::from_static("text/event-stream"));
2364 }
2365 (Provider::Gemini, "api_key") => {
2366 let key = account.api_key.as_deref().ok_or((
2367 StatusCode::BAD_GATEWAY,
2368 "gemini api_key account has no key".to_string(),
2369 ))?;
2370 h.insert(
2371 "x-goog-api-key",
2372 HeaderValue::from_str(key)
2373 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2374 );
2375 }
2376 (Provider::Gemini, _) => {
2377 let token = account.access_token.as_deref().ok_or((
2378 StatusCode::BAD_GATEWAY,
2379 "gemini account has no access token".to_string(),
2380 ))?;
2381 h.insert(
2382 "authorization",
2383 HeaderValue::from_str(&format!("Bearer {token}"))
2384 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
2385 );
2386 }
2387 }
2388 Ok(h)
2389}
2390
2391fn insert_anthropic_version(h: &mut reqwest::header::HeaderMap, client_headers: &HeaderMap) {
2392 match client_headers.get("anthropic-version") {
2393 Some(v) => {
2394 h.insert("anthropic-version", v.clone());
2395 }
2396 None => {
2397 h.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
2398 }
2399 }
2400}
2401
2402fn key_fingerprint(key: &str) -> String {
2403 use sha2::{Digest, Sha256};
2404 let digest = Sha256::digest(key.as_bytes());
2405 digest[..8].iter().map(|b| format!("{b:02x}")).collect()
2406}
2407
2408const RUN_KEY_PREFIX: &str = "alxk-";
2409const RUN_KEY_DEFAULT_TTL_S: i64 = 86_400;
2410const RUN_KEY_MAX_TTL_S: i64 = 604_800;
2411
2412pub fn generate_run_key() -> String {
2413 use rand::RngCore;
2414 let mut bytes = [0u8; 32];
2415 rand::thread_rng().fill_bytes(&mut bytes);
2416 format!(
2417 "{RUN_KEY_PREFIX}{}",
2418 bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()
2419 )
2420}
2421
2422fn key_hash_hex(key: &str) -> String {
2423 use sha2::{Digest, Sha256};
2424 let digest = Sha256::digest(key.as_bytes());
2425 digest.iter().map(|b| format!("{b:02x}")).collect()
2426}
2427
2428fn merge_run_key_tags(key_tags: Option<&str>, header_tags: Option<&str>) -> Option<String> {
2429 let parse = |s: Option<&str>| {
2430 s.and_then(|s| serde_json::from_str::<Value>(s).ok())
2431 .and_then(|v| v.as_object().cloned())
2432 .unwrap_or_default()
2433 };
2434 let mut merged = parse(key_tags);
2435 merged.extend(parse(header_tags));
2436 if merged.is_empty() {
2437 None
2438 } else {
2439 serde_json::to_string(&Value::Object(merged)).ok()
2440 }
2441}
2442
2443fn run_key_entry(state: &AppState, key_hash: &str) -> Option<CachedRunKey> {
2444 let now = now_ms();
2445 let cached = state.run_keys.read().unwrap().get(key_hash).cloned();
2446 if let Some(entry) = cached {
2447 if entry.expires_ms.map(|e| e > now).unwrap_or(true) {
2448 return Some(entry);
2449 }
2450 state.run_keys.write().unwrap().remove(key_hash);
2451 return None;
2452 }
2453 let row = state.store.lookup_run_key(key_hash, now).ok().flatten()?;
2454 let entry = CachedRunKey {
2455 run_id: row["run_id"].as_str().map(String::from),
2456 tags_json: row["tags"]
2457 .as_object()
2458 .filter(|o| !o.is_empty())
2459 .and_then(|o| serde_json::to_string(o).ok()),
2460 expires_ms: row["expires_ms"].as_i64(),
2461 };
2462 state
2463 .run_keys
2464 .write()
2465 .unwrap()
2466 .insert(key_hash.to_string(), entry.clone());
2467 Some(entry)
2468}
2469
2470async fn admin_run_keys_create(
2471 State(state): State<Arc<AppState>>,
2472 body: Option<axum::Json<Value>>,
2473) -> Response {
2474 let body = body.map(|b| b.0).unwrap_or_else(|| json!({}));
2475 let tags = match &body["tags"] {
2476 Value::Null => None,
2477 Value::Object(o) => Some(o.clone()),
2478 _ => return error_response(StatusCode::BAD_REQUEST, "'tags' must be an object"),
2479 };
2480 let kind = body["kind"].as_str().unwrap_or("run");
2481 if kind != "run" && kind != "harness" {
2482 return error_response(StatusCode::BAD_REQUEST, "'kind' must be 'run' or 'harness'");
2483 }
2484 let run_id = body["run_id"].as_str().map(String::from);
2485 let label = body["label"].as_str().map(String::from);
2486 if kind == "harness" && label.as_deref().unwrap_or("").trim().is_empty() {
2487 return error_response(StatusCode::BAD_REQUEST, "'label' is required for harness keys");
2488 }
2489 let created_ms = now_ms();
2490 let expires_ms = if kind == "harness" {
2491 None
2492 } else {
2493 let ttl_seconds = body["ttl_seconds"].as_i64().unwrap_or(RUN_KEY_DEFAULT_TTL_S);
2494 if ttl_seconds <= 0 {
2495 return error_response(StatusCode::BAD_REQUEST, "'ttl_seconds' must be positive");
2496 }
2497 Some(created_ms + ttl_seconds.min(RUN_KEY_MAX_TTL_S) * 1000)
2498 };
2499 let key = generate_run_key();
2500 let key_hash = key_hash_hex(&key);
2501 let id = format!("rk-{}", &key_hash[..8]);
2502 let tags_json = tags
2503 .as_ref()
2504 .filter(|o| !o.is_empty())
2505 .and_then(|o| serde_json::to_string(o).ok());
2506 match state.store.insert_run_key(
2507 &id,
2508 &key_hash,
2509 kind,
2510 run_id.as_deref(),
2511 tags_json.as_deref(),
2512 label.as_deref(),
2513 created_ms,
2514 expires_ms,
2515 ) {
2516 Ok(()) => {
2517 let (_, exports) = connect_payload(&state.base_url, &key);
2518 (
2519 StatusCode::CREATED,
2520 axum::Json(json!({
2521 "id": id,
2522 "key": key,
2523 "kind": kind,
2524 "run_id": run_id,
2525 "label": label,
2526 "tags": tags.map(Value::Object).unwrap_or_else(|| json!({})),
2527 "expires_ms": expires_ms,
2528 "exports": exports,
2529 })),
2530 )
2531 .into_response()
2532 }
2533 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
2534 }
2535}
2536
2537async fn admin_run_keys_list(
2538 State(state): State<Arc<AppState>>,
2539 Query(q): Query<HashMap<String, String>>,
2540) -> Response {
2541 let all = q.get("all").map(|v| v == "1" || v == "true").unwrap_or(false);
2542 match state.store.list_run_keys(all) {
2543 Ok(rows) => axum::Json(json!({"run_keys": rows})).into_response(),
2544 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
2545 }
2546}
2547
2548async fn admin_run_keys_revoke(
2549 State(state): State<Arc<AppState>>,
2550 Path(id): Path<String>,
2551) -> Response {
2552 match state.store.revoke_run_key(&id) {
2553 Ok(true) => {
2554 state.run_keys.write().unwrap().clear();
2555 axum::Json(json!({"revoked": true})).into_response()
2556 }
2557 Ok(false) => error_response(StatusCode::NOT_FOUND, &format!("unknown run key '{id}'")),
2558 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
2559 }
2560}
2561
2562fn session_from_metadata(body_json: &Value) -> Option<String> {
2563 let raw = body_json["metadata"]["user_id"].as_str()?;
2564 if let Ok(v) = serde_json::from_str::<Value>(raw) {
2565 if let Some(inner) = v["session_id"].as_str() {
2566 return Some(inner.to_string());
2567 }
2568 }
2569 Some(raw.to_string())
2570}
2571
2572const METADATA_HEADERS: &[(&str, &str)] = &[
2573 ("x-alexandria-harness", "harness"),
2574 ("x-alexandria-harness-version", "harness_version"),
2575 ("x-alexandria-task", "task"),
2576 ("x-alexandria-model", "model"),
2577 ("x-alexandria-job", "job"),
2578 ("x-alexandria-phase", "phase"),
2579 ("x-alexandria-kind", "kind"),
2580];
2581
2582fn trace_tags_json(headers: &HeaderMap) -> Option<String> {
2583 let values: Vec<&str> = headers
2584 .get_all("x-alexandria-trace-tag")
2585 .iter()
2586 .filter_map(|v| v.to_str().ok())
2587 .collect();
2588 let mut tags = parse_trace_tags(&values);
2589 if let Some(o) = tags.as_object_mut() {
2590 for (header, key) in METADATA_HEADERS {
2591 if o.contains_key(*key) {
2592 continue;
2593 }
2594 if let Some(v) = headers.get(*header).and_then(|v| v.to_str().ok()) {
2595 let v = v.trim();
2596 if !v.is_empty() {
2597 o.insert((*key).to_string(), json!(v));
2598 }
2599 }
2600 }
2601 }
2602 if tags.as_object().map(|o| o.is_empty()).unwrap_or(true) {
2603 return None;
2604 }
2605 serde_json::to_string(&tags).ok()
2606}
2607
2608async fn proxy(
2609 state: Arc<AppState>,
2610 format: ClientFormat,
2611 path: &'static str,
2612 headers: HeaderMap,
2613 body: Bytes,
2614 peer: Option<std::net::SocketAddr>,
2615) -> Response {
2616 let mut run_key: Option<CachedRunKey> = None;
2617 let client_fingerprint = match client_key(&headers) {
2618 Some(k) if k == state.local_key => key_fingerprint(&k),
2619 Some(k) => {
2620 let key_hash = key_hash_hex(&k);
2621 match run_key_entry(&state, &key_hash) {
2622 Some(entry) => {
2623 if let Err(e) = state.store.touch_run_key(&key_hash, now_ms()) {
2624 tracing::warn!("failed to touch run key: {e}");
2625 }
2626 run_key = Some(entry);
2627 key_hash.chars().take(16).collect()
2628 }
2629 None if k.starts_with(RUN_KEY_PREFIX) => {
2630 return error_response(
2631 StatusCode::UNAUTHORIZED,
2632 "run key expired or revoked",
2633 )
2634 }
2635 None => {
2636 return error_response(
2637 StatusCode::UNAUTHORIZED,
2638 "bad or missing local key (x-api-key / Authorization: Bearer)",
2639 )
2640 }
2641 }
2642 }
2643 None => {
2644 return error_response(
2645 StatusCode::UNAUTHORIZED,
2646 "bad or missing local key (x-api-key / Authorization: Bearer)",
2647 )
2648 }
2649 };
2650
2651 let in_flight = InFlight::new(&state);
2652 let trace_id = uuid::Uuid::new_v4().to_string();
2653 let mut trace = TraceRecord {
2654 id: trace_id.clone(),
2655 ts_request_ms: now_ms(),
2656 method: Some("POST".into()),
2657 path: Some(path.into()),
2658 client_format: Some(format.as_str().into()),
2659 harness: headers
2660 .get("user-agent")
2661 .and_then(|v| v.to_str().ok())
2662 .map(String::from),
2663 req_headers_json: Some(redacted_headers(&headers)),
2664 run_id: headers
2665 .get("x-alexandria-run-id")
2666 .and_then(|v| v.to_str().ok())
2667 .map(String::from)
2668 .or_else(|| run_key.as_ref().and_then(|k| k.run_id.clone())),
2669 tags: merge_run_key_tags(
2670 run_key.as_ref().and_then(|k| k.tags_json.as_deref()),
2671 trace_tags_json(&headers).as_deref(),
2672 ),
2673 client_ip: peer.map(|p| p.ip().to_string()),
2674 key_fingerprint: Some(client_fingerprint),
2675 ..Default::default()
2676 };
2677
2678 let mut body_json: Value = match serde_json::from_slice(&body) {
2679 Ok(v) => v,
2680 Err(e) => {
2681 return error_response(StatusCode::BAD_REQUEST, &format!("body is not JSON: {e}"))
2682 }
2683 };
2684 let (reasoning_effort, thinking_budget) = trace_reasoning_fields(&body_json);
2685 trace.reasoning_effort = reasoning_effort;
2686 trace.thinking_budget = thinking_budget;
2687
2688 trace.session_id = headers
2689 .get("x-session-id")
2690 .and_then(|v| v.to_str().ok())
2691 .map(String::from)
2692 .or_else(|| {
2693 headers
2694 .get("session_id")
2695 .and_then(|v| v.to_str().ok())
2696 .map(String::from)
2697 })
2698 .or_else(|| session_from_metadata(&body_json))
2699 .or_else(|| {
2700 conversation_root(format, &body_json).map(|root| {
2701 let ua = headers
2702 .get("user-agent")
2703 .and_then(|v| v.to_str().ok())
2704 .unwrap_or("");
2705 let ip = peer.map(|p| p.ip().to_string()).unwrap_or_default();
2706 format!("auto-{}", key_fingerprint(&format!("{root}{ua}{ip}")))
2707 })
2708 });
2709
2710 let requested_model = body_json["model"].as_str().unwrap_or("").to_string();
2711 if requested_model.is_empty() {
2712 return error_response(StatusCode::BAD_REQUEST, "missing 'model' in request body");
2713 }
2714 let (routed_provider, routed_model) = route_model(&requested_model);
2715 let provider = routed_provider.unwrap_or_else(|| format.default_provider());
2716 trace.requested_model = Some(requested_model.clone());
2717 trace.routed_model = Some(routed_model.clone());
2718 trace.upstream_provider = Some(provider.as_str().into());
2719 trace.streamed = Some(body_json["stream"].as_bool().unwrap_or(false));
2720
2721 let mut plan = match plan_upstream(
2722 &state,
2723 format,
2724 provider,
2725 &routed_model,
2726 &mut body_json,
2727 &body,
2728 &trace_id,
2729 )
2730 .await
2731 {
2732 Ok(p) => p,
2733 Err((status, msg)) => {
2734 trace.status = Some(status.as_u16() as i64);
2735 trace.error = Some(msg.clone());
2736 finalize_trace(&state, trace, &body, None, None);
2737 return error_response(status, &msg);
2738 }
2739 };
2740 trace.upstream_format = Some(plan.upstream_format.into());
2741 trace.account_id = Some(plan.account.id.clone());
2742 trace.billing_bucket = Some(
2743 if plan.account.kind == "oauth" || plan.account.kind == "dario" {
2744 "subscription"
2745 } else {
2746 "api"
2747 }
2748 .into(),
2749 );
2750
2751 tracing::info!(
2752 trace_id,
2753 model = %routed_model,
2754 provider = provider.as_str(),
2755 account = %plan.account.id,
2756 url = %plan.url,
2757 "proxying request"
2758 );
2759
2760 let mut account = plan.account.clone();
2761 let mut upstream_resp = None;
2762 for attempt in 0..2 {
2763 let mut up_headers = match upstream_headers(&account, &headers) {
2764 Ok(h) => h,
2765 Err((status, msg)) => {
2766 trace.status = Some(status.as_u16() as i64);
2767 trace.error = Some(msg.clone());
2768 finalize_trace(&state, trace, &body, Some(&plan.body), None);
2769 return error_response(status, &msg);
2770 }
2771 };
2772 for (k, v) in &plan.extra_headers {
2773 if let (Ok(name), Ok(value)) = (
2774 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2775 HeaderValue::from_str(v),
2776 ) {
2777 up_headers.insert(name, value);
2778 }
2779 }
2780 let resp = match state
2781 .http
2782 .post(&plan.url)
2783 .headers(up_headers)
2784 .body(plan.body.clone())
2785 .send()
2786 .await
2787 {
2788 Ok(r) => r,
2789 Err(e) => {
2790 let msg = format!("upstream request failed: {e}");
2791 suspect_dario(&state, &account);
2792 trace.status = Some(502);
2793 trace.error = Some(msg.clone());
2794 finalize_trace(&state, trace, &body, Some(&plan.body), None);
2795 return error_response(StatusCode::BAD_GATEWAY, &msg);
2796 }
2797 };
2798 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS && account.kind != "dario" {
2799 let retry_after_s = resp
2800 .headers()
2801 .get("retry-after")
2802 .and_then(|v| v.to_str().ok())
2803 .and_then(|v| v.parse::<i64>().ok())
2804 .unwrap_or(60)
2805 .clamp(1, 3600);
2806 tracing::warn!(
2807 account = %account.id,
2808 retry_after_s,
2809 "upstream returned 429; cooling account down"
2810 );
2811 if let Err(e) = state
2812 .vault
2813 .mark_cooldown(&account.id, now_ms() + retry_after_s * 1000)
2814 .await
2815 {
2816 tracing::warn!("failed to mark cooldown: {e}");
2817 }
2818 }
2819 if resp.status() == reqwest::StatusCode::UNAUTHORIZED
2820 && account.kind == "oauth"
2821 && attempt == 0
2822 {
2823 tracing::warn!(
2824 account = %account.id,
2825 "upstream returned 401 for oauth account; forcing token refresh and retrying"
2826 );
2827 match state.vault.refresh(&account.id, true).await {
2828 Ok(fresh) => {
2829 account = fresh;
2830 continue;
2831 }
2832 Err(e) => {
2833 tracing::warn!("forced refresh failed: {e}");
2834 }
2835 }
2836 }
2837 upstream_resp = Some(resp);
2838 break;
2839 }
2840 let upstream_resp = upstream_resp.expect("upstream response after retry loop");
2841
2842 let status = upstream_resp.status();
2843 trace.status = Some(status.as_u16() as i64);
2844 let resp_headers = upstream_resp.headers().clone();
2845 let content_type = resp_headers
2846 .get("content-type")
2847 .and_then(|v| v.to_str().ok())
2848 .unwrap_or("application/octet-stream")
2849 .to_string();
2850 trace.resp_headers_json = Some(redacted_headers(&resp_headers));
2851 let is_sse = content_type.starts_with("text/event-stream");
2852
2853 if let Some(target) = plan.respond_as {
2854 use alex_core::translate;
2855 let buf = match upstream_resp.bytes().await {
2856 Ok(b) => b.to_vec(),
2857 Err(e) => {
2858 let msg = format!("upstream body read failed: {e}");
2859 suspect_dario(&state, &account);
2860 trace.status = Some(502);
2861 trace.error = Some(msg.clone());
2862 finalize_trace(&state, trace, &body, Some(&plan.body), None);
2863 return error_response(StatusCode::BAD_GATEWAY, &msg);
2864 }
2865 };
2866 drop(plan.dario_guard.take());
2867 trace.ts_response_ms = Some(now_ms());
2868 fill_usage_and_cost(&state, &mut trace, &buf, is_sse);
2869 let text = String::from_utf8_lossy(&buf).to_string();
2870 if !status.is_success() {
2871 finalize_trace(&state, trace, &body, Some(&plan.body), Some(&buf));
2872 return Response::builder()
2873 .status(status)
2874 .header("content-type", "application/json")
2875 .header("x-alexandria-trace-id", &trace_id)
2876 .body(Body::from(buf))
2877 .unwrap_or_else(|e| {
2878 error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
2879 });
2880 }
2881 let trimmed = text.trim_start();
2882 let looks_sse = trimmed.starts_with("event:") || trimmed.starts_with("data:");
2883 let upstream_final = if plan.upstream_format == "anthropic" {
2884 if is_sse || looks_sse {
2885 translate::parse_anthropic_sse_to_message(&text)
2886 } else {
2887 serde_json::from_str(&text).ok()
2888 }
2889 } else if plan.upstream_format == "gemini" {
2890 translate::parse_gemini_upstream_final(&text)
2891 } else if is_sse || looks_sse {
2892 translate::parse_responses_sse_final(&text)
2893 } else {
2894 serde_json::from_str(&text).ok()
2895 };
2896 let Some(upstream_final) = upstream_final else {
2897 let msg = "could not reassemble upstream response for translation";
2898 trace.error = Some(msg.to_string());
2899 finalize_trace(&state, trace, &body, Some(&plan.body), Some(&buf));
2900 return error_response(StatusCode::BAD_GATEWAY, msg);
2901 };
2902 let out = match (target, plan.upstream_format) {
2903 (RespondAs::Gemini, "gemini") => upstream_final.clone(),
2904 (RespondAs::Anthropic, "gemini") => {
2905 translate::gemini_response_to_anthropic(&upstream_final, &requested_model)
2906 }
2907 (RespondAs::OpenaiChat, "gemini") => translate::anthropic_response_to_openai_chat(
2908 &translate::gemini_response_to_anthropic(&upstream_final, &requested_model),
2909 &requested_model,
2910 ),
2911 (RespondAs::OpenaiResponses, "gemini") => {
2912 translate::anthropic_response_to_openai_responses(
2913 &translate::gemini_response_to_anthropic(&upstream_final, &requested_model),
2914 &requested_model,
2915 )
2916 }
2917 (RespondAs::OpenaiChat, "anthropic") => {
2918 translate::anthropic_response_to_openai_chat(&upstream_final, &requested_model)
2919 }
2920 (RespondAs::OpenaiResponses, "anthropic") => translate::anthropic_response_to_openai_responses(
2921 &upstream_final,
2922 &requested_model,
2923 ),
2924 (RespondAs::Gemini, "anthropic") => {
2925 translate::anthropic_response_to_gemini(&upstream_final, &requested_model)
2926 }
2927 (RespondAs::Gemini, _) => translate::anthropic_response_to_gemini(
2928 &translate::responses_final_to_anthropic(&upstream_final, &requested_model),
2929 &requested_model,
2930 ),
2931 (RespondAs::Anthropic, "anthropic") | (RespondAs::OpenaiResponses, _) => {
2932 upstream_final.clone()
2933 }
2934 (RespondAs::Anthropic, _) => {
2935 translate::responses_final_to_anthropic(&upstream_final, &requested_model)
2936 }
2937 (RespondAs::OpenaiChat, _) => {
2938 translate::responses_final_to_openai_chat(&upstream_final, &requested_model)
2939 }
2940 };
2941 let (out_ct, out_body) = if plan.client_stream {
2942 let sse = match target {
2943 RespondAs::Anthropic => translate::synth_anthropic_sse(&out),
2944 RespondAs::OpenaiChat => translate::synth_openai_chat_sse(&out),
2945 RespondAs::OpenaiResponses => translate::synth_openai_responses_sse(&out),
2946 RespondAs::Gemini => translate::synth_gemini_sse(&out),
2947 };
2948 ("text/event-stream", sse.into_bytes())
2949 } else {
2950 (
2951 "application/json",
2952 serde_json::to_vec(&out).unwrap_or_default(),
2953 )
2954 };
2955 finalize_trace(&state, trace, &body, Some(&plan.body), Some(&buf));
2956 return Response::builder()
2957 .status(StatusCode::OK)
2958 .header("content-type", out_ct)
2959 .header("x-alexandria-trace-id", &trace_id)
2960 .body(Body::from(out_body))
2961 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()));
2962 }
2963
2964 if plan.destream {
2965 let buf = match upstream_resp.bytes().await {
2966 Ok(b) => b.to_vec(),
2967 Err(e) => {
2968 let msg = format!("upstream body read failed: {e}");
2969 trace.status = Some(502);
2970 trace.error = Some(msg.clone());
2971 finalize_trace(&state, trace, &body, Some(&plan.body), None);
2972 return error_response(StatusCode::BAD_GATEWAY, &msg);
2973 }
2974 };
2975 trace.ts_response_ms = Some(now_ms());
2976 fill_usage_and_cost(&state, &mut trace, &buf, is_sse);
2977 let text = String::from_utf8_lossy(&buf).to_string();
2978 let (out_status, out_body) = if status.is_success() {
2979 match extract_final_response(&text) {
2980 Some(v) => (StatusCode::OK, serde_json::to_vec(&v).unwrap_or_default()),
2981 None => {
2982 let msg = "upstream stream ended without a response.completed event";
2983 trace.error = Some(msg.to_string());
2984 (
2985 StatusCode::BAD_GATEWAY,
2986 serde_json::to_vec(
2987 &json!({"error": {"type": "alexandria", "message": msg}}),
2988 )
2989 .unwrap_or_default(),
2990 )
2991 }
2992 }
2993 } else {
2994 (status, buf.clone())
2995 };
2996 finalize_trace(&state, trace, &body, Some(&plan.body), Some(&buf));
2997 return Response::builder()
2998 .status(out_status)
2999 .header("content-type", "application/json")
3000 .header("x-alexandria-trace-id", &trace_id)
3001 .body(Body::from(out_body))
3002 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()));
3003 }
3004
3005 let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(64);
3006 let mut upstream_stream = upstream_resp.bytes_stream();
3007 let state2 = state.clone();
3008 let client_body = body.clone();
3009 let upstream_body = plan.body.clone();
3010 let dario_guard = plan.dario_guard.take();
3011 tokio::spawn(async move {
3012 let _dario_guard = dario_guard;
3013 let _in_flight = in_flight;
3014 let mut buf: Vec<u8> = Vec::new();
3015 let mut stream_error: Option<String> = None;
3016 while let Some(chunk) = upstream_stream.next().await {
3017 match chunk {
3018 Ok(b) => {
3019 buf.extend_from_slice(&b);
3020 let _ = tx.send(Ok(b)).await;
3021 }
3022 Err(e) => {
3023 stream_error = Some(format!("upstream stream error: {e}"));
3024 let _ = tx
3025 .send(Err(std::io::Error::new(
3026 std::io::ErrorKind::Other,
3027 e.to_string(),
3028 )))
3029 .await;
3030 break;
3031 }
3032 }
3033 }
3034 drop(tx);
3035 trace.ts_response_ms = Some(now_ms());
3036 if trace.error.is_none() {
3037 trace.error = stream_error;
3038 }
3039 if trace.error.is_some() {
3040 if let (Some(dario), Some(gen)) = (
3041 &state2.dario,
3042 trace
3043 .account_id
3044 .as_deref()
3045 .and_then(|id| id.strip_prefix("dario:")),
3046 ) {
3047 dario.suspect(gen);
3048 }
3049 }
3050
3051 fill_usage_and_cost(&state2, &mut trace, &buf, is_sse);
3052 finalize_trace(
3053 &state2,
3054 trace,
3055 &client_body,
3056 Some(&upstream_body),
3057 Some(&buf),
3058 );
3059 });
3060
3061 let mut response = Response::builder().status(status);
3062 for (k, v) in resp_headers.iter() {
3063 let key = k.as_str().to_lowercase();
3064 if [
3065 "transfer-encoding",
3066 "connection",
3067 "content-encoding",
3068 "content-length",
3069 ]
3070 .contains(&key.as_str())
3071 {
3072 continue;
3073 }
3074 response = response.header(k, v);
3075 }
3076 response = response.header("x-alexandria-trace-id", &trace_id);
3077 response
3078 .body(Body::from_stream(ReceiverStream::new(rx)))
3079 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))
3080}
3081
3082fn fill_usage_and_cost(state: &AppState, trace: &mut TraceRecord, buf: &[u8], is_sse: bool) {
3083 let text = String::from_utf8_lossy(buf);
3084 let trimmed = text.trim_start();
3085 let looks_sse = trimmed.starts_with("event:") || trimmed.starts_with("data:");
3086 trace.usage = if is_sse || looks_sse {
3087 parse_sse_usage(&text)
3088 } else {
3089 serde_json::from_str::<Value>(&text)
3090 .map(|v| usage_from_json(&v))
3091 .unwrap_or_default()
3092 };
3093 if !trace.usage.is_empty() {
3094 if let Some(pricing) = trace
3095 .routed_model
3096 .as_deref()
3097 .and_then(|m| state.store.pricing_for(m))
3098 {
3099 let input_includes_cached = trace
3100 .upstream_format
3101 .as_deref()
3102 .map(|f| f.starts_with("openai"))
3103 .unwrap_or(false);
3104 trace.cost_usd = Some(compute_cost(&trace.usage, &pricing, input_includes_cached));
3105 }
3106 }
3107}
3108
3109fn extract_final_response(text: &str) -> Option<Value> {
3110 let mut last: Option<Value> = None;
3111 for line in text.lines() {
3112 let Some(data) = line.strip_prefix("data:") else {
3113 continue;
3114 };
3115 let Ok(v) = serde_json::from_str::<Value>(data.trim()) else {
3116 continue;
3117 };
3118 if matches!(
3119 v["type"].as_str(),
3120 Some("response.completed") | Some("response.incomplete") | Some("response.failed")
3121 ) && v["response"].is_object()
3122 {
3123 last = Some(v["response"].clone());
3124 }
3125 }
3126 last
3127}
3128
3129fn finalize_trace(
3130 state: &AppState,
3131 mut trace: TraceRecord,
3132 client_body: &[u8],
3133 upstream_body: Option<&[u8]>,
3134 resp_body: Option<&[u8]>,
3135) {
3136 let store = &state.store;
3137 match store.write_body(&trace.id, "request.json", client_body) {
3138 Ok(p) => trace.req_body_path = Some(p),
3139 Err(e) => tracing::warn!("failed to write request body: {e}"),
3140 }
3141 if let Some(up) = upstream_body {
3142 if up != client_body {
3143 match store.write_body(&trace.id, "upstream-request.json", up) {
3144 Ok(p) => trace.upstream_req_body_path = Some(p),
3145 Err(e) => tracing::warn!("failed to write upstream request body: {e}"),
3146 }
3147 }
3148 }
3149 if let Some(resp) = resp_body {
3150 match store.write_body(&trace.id, "response.body", resp) {
3151 Ok(p) => trace.resp_body_path = Some(p),
3152 Err(e) => tracing::warn!("failed to write response body: {e}"),
3153 }
3154 }
3155 if let Err(e) = store.insert_trace(&trace) {
3156 tracing::error!("failed to insert trace {}: {e}", trace.id);
3157 } else {
3158 tracing::info!(
3159 trace_id = %trace.id,
3160 status = trace.status,
3161 input = trace.usage.input_tokens,
3162 output = trace.usage.output_tokens,
3163 cost = trace.cost_usd,
3164 "trace recorded"
3165 );
3166 }
3167}
3168
3169#[cfg(test)]
3170mod tests {
3171 use super::*;
3172 use std::path::PathBuf;
3173
3174 fn tmpdir(name: &str) -> PathBuf {
3175 let dir = std::env::temp_dir().join(format!(
3176 "alex-proxy-{name}-{}-{}",
3177 std::process::id(),
3178 std::time::SystemTime::now()
3179 .duration_since(std::time::UNIX_EPOCH)
3180 .unwrap()
3181 .as_nanos()
3182 ));
3183 std::fs::create_dir_all(&dir).unwrap();
3184 dir
3185 }
3186
3187 fn test_state(name: &str) -> Arc<AppState> {
3188 let dir = tmpdir(name);
3189 let store = Arc::new(Store::open(dir.join("store")).unwrap());
3190 let vault = Arc::new(Vault::open(dir.join("vault")).unwrap());
3191 build_state(
3192 "alx-local".into(),
3193 vault,
3194 store,
3195 None,
3196 "http://127.0.0.1:4100".into(),
3197 )
3198 }
3199
3200 async fn response_json(resp: Response) -> (StatusCode, Value) {
3201 let status = resp.status();
3202 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
3203 .await
3204 .unwrap();
3205 let value = serde_json::from_slice(&body).unwrap_or(Value::Null);
3206 (status, value)
3207 }
3208
3209 #[tokio::test]
3210 async fn harness_run_key_mints_lists_validates_and_revokes() {
3211 let state = test_state("harness-run-key");
3212 let (_status, created) = response_json(
3213 admin_run_keys_create(
3214 State(state.clone()),
3215 Some(axum::Json(json!({
3216 "kind": "harness",
3217 "label": "pi",
3218 "ttl_seconds": 1,
3219 "tags": {"harness": "pi"},
3220 }))),
3221 )
3222 .await,
3223 )
3224 .await;
3225 assert_eq!(_status, StatusCode::CREATED);
3226 assert_eq!(created["kind"], "harness");
3227 assert_eq!(created["label"], "pi");
3228 assert_eq!(created["expires_ms"], Value::Null);
3229 let id = created["id"].as_str().unwrap().to_string();
3230 let key = created["key"].as_str().unwrap();
3231 let hash = key_hash_hex(key);
3232
3233 let entry = run_key_entry(&state, &hash).unwrap();
3234 assert_eq!(entry.expires_ms, None);
3235 assert_eq!(entry.tags_json.as_deref(), Some(r#"{"harness":"pi"}"#));
3236
3237 let (_status, listed) = response_json(
3238 admin_run_keys_list(State(state.clone()), Query(HashMap::new())).await,
3239 )
3240 .await;
3241 assert_eq!(_status, StatusCode::OK);
3242 let rows = listed["run_keys"].as_array().unwrap();
3243 assert_eq!(rows.len(), 1);
3244 assert_eq!(rows[0]["id"], id);
3245 assert_eq!(rows[0]["kind"], "harness");
3246 assert_eq!(rows[0]["label"], "pi");
3247 assert_eq!(rows[0]["expires_ms"], Value::Null);
3248
3249 let (_status, revoked) =
3250 response_json(admin_run_keys_revoke(State(state.clone()), Path(id)).await).await;
3251 assert_eq!(_status, StatusCode::OK);
3252 assert_eq!(revoked["revoked"], true);
3253 assert!(run_key_entry(&state, &hash).is_none());
3254 }
3255
3256 #[tokio::test]
3257 async fn harness_run_key_requires_label() {
3258 let state = test_state("harness-run-key-label");
3259 let (status, body) = response_json(
3260 admin_run_keys_create(
3261 State(state),
3262 Some(axum::Json(json!({
3263 "kind": "harness",
3264 "tags": {"harness": "pi"},
3265 }))),
3266 )
3267 .await,
3268 )
3269 .await;
3270 assert_eq!(status, StatusCode::BAD_REQUEST);
3271 assert!(body["error"]["message"]
3272 .as_str()
3273 .unwrap()
3274 .contains("label"));
3275 }
3276}