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