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