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