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