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