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