Skip to main content

alex_proxy/
lib.rs

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