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