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