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