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