Skip to main content

alex_proxy/
lib.rs

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