Skip to main content

alex_proxy/
lib.rs

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