1use std::collections::{HashMap, HashSet};
2use std::future::Future;
3use std::path::PathBuf;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::time::Duration;
7
8use alex_auth::{
9 codex_reserve_blocked, codex_reserve_pct, codex_reset_selection, now_ms, Account,
10 AccountPolicy, AccountPolicyMode, Vault,
11};
12use alex_core::{
13 compute_cost, conversation_root, parse_grpc_web_response, parse_since, parse_sse_usage,
14 parse_trace_tags, parse_usage_api_response, route_model, usage_from_json,
15 usage_to_limits_entry, validate_grpc_status_headers, window_label, ClientFormat, Provider,
16 TraceIngestPayload, TraceRecord, GROK_CREDITS_ENDPOINT, GROK_CREDITS_REQUEST_BODY,
17};
18use alex_store::{Store, TraceFilter};
19use axum::body::{Body, Bytes};
20use axum::extract::{ConnectInfo, Path, Query, State};
21use axum::http::{HeaderMap, HeaderValue, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use axum::Router;
25use futures_util::StreamExt;
26use serde_json::{json, Value};
27use tokio_stream::wrappers::ReceiverStream;
28
29const ANTHROPIC_BASE: &str = "https://api.anthropic.com";
30const OPENAI_BASE: &str = "https://api.openai.com";
31const CODEX_BASE: &str = "https://chatgpt.com/backend-api/codex";
32const XAI_BASE: &str = "https://cli-chat-proxy.grok.com/v1";
33const GROK_CLIENT_VERSION: &str = "0.2.77";
34const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
35const GEMINI_CODE_ASSIST_BASE: &str = "https://cloudcode-pa.googleapis.com";
36const GEMINI_CODE_ASSIST_VERSION: &str = "v1internal";
37const GEMINI_API_BASE: &str = "https://generativelanguage.googleapis.com";
38const CODEX_AFFINITY_TTL_MS: i64 = 30 * 24 * 60 * 60 * 1000;
39const CODEX_AFFINITY_MAX_ENTRIES: usize = 10_000;
40
41#[derive(Debug, Clone)]
42pub struct DarioActive {
43 pub generation_id: String,
44 pub base_url: String,
45 pub api_key: String,
46}
47
48pub trait DarioRouter: Send + Sync {
49 fn active(&self) -> Option<DarioActive>;
50 fn begin(&self, generation_id: &str) -> Option<Box<dyn std::any::Any + Send>>;
51 fn status(&self) -> Value;
52 fn suspect(&self, generation_id: &str);
53}
54
55pub type UpdateApplyFuture =
56 Pin<Box<dyn Future<Output = Result<Value, UpdateApplyError>> + Send + 'static>>;
57
58#[derive(Debug)]
59pub enum UpdateApplyError {
60 Conflict(Value),
61 Failed(String),
62}
63
64pub trait DaemonUpdater: Send + Sync {
65 fn apply(&self) -> UpdateApplyFuture;
66}
67
68fn suspect_dario(state: &AppState, account: &Account) {
69 if account.kind != "dario" {
70 return;
71 }
72 if let (Some(dario), Some(gen)) = (&state.dario, account.id.strip_prefix("dario:")) {
73 dario.suspect(gen);
74 }
75}
76
77fn codex_limits_from_headers(headers: &reqwest::header::HeaderMap) -> Option<Value> {
78 const LIMIT_HEADERS: &[&str] = &[
79 "x-codex-primary-used-percent",
80 "x-codex-primary-window-minutes",
81 "x-codex-primary-reset-at",
82 "x-codex-secondary-used-percent",
83 "x-codex-secondary-window-minutes",
84 "x-codex-secondary-reset-at",
85 "x-codex-plan-type",
86 "x-codex-active-limit",
87 "x-codex-credits-balance",
88 "x-codex-credits-has-credits",
89 "x-codex-credits-unlimited",
90 ];
91 let mut safe = serde_json::Map::new();
92 for name in LIMIT_HEADERS {
93 if let Some(value) = headers.get(*name).and_then(|value| value.to_str().ok()) {
94 safe.insert((*name).to_string(), json!(value));
95 }
96 }
97 if safe.is_empty() {
98 return None;
99 }
100 let parsed = alex_core::parse_limit_headers(Provider::Openai, &Value::Object(safe));
101 let has_windows = parsed
102 .get("windows")
103 .and_then(Value::as_array)
104 .map(|windows| !windows.is_empty())
105 .unwrap_or(false);
106 let has_plan = parsed.get("plan").is_some_and(|value| !value.is_null());
107 (has_windows || has_plan).then_some(parsed)
108}
109
110pub struct AppState {
111 pub local_key: String,
112 pub vault: Arc<Vault>,
113 pub store: Arc<Store>,
114 pub http: reqwest::Client,
115 pub dario: Option<Arc<dyn DarioRouter>>,
116 pub in_flight: std::sync::atomic::AtomicI64,
117 pub started_ms: i64,
118 pub base_url: String,
119 pub anthropic_usage: std::sync::Mutex<UsageCache>,
120 pub xai_usage: std::sync::Mutex<UsageCache>,
121 pub amp_usage: std::sync::Mutex<UsageCache>,
122 pub logins: alex_auth::sessions::LoginManager,
123 pub run_keys: std::sync::RwLock<HashMap<String, CachedRunKey>>,
124 trace_ingest_lock: tokio::sync::Mutex<()>,
125 pub update_status: Arc<tokio::sync::RwLock<Option<Value>>>,
126 pub daemon_updater: std::sync::RwLock<Option<Arc<dyn DaemonUpdater>>>,
127 codex_affinity: std::sync::Mutex<CodexAffinityCache>,
128 codex_affinity_locks:
129 std::sync::Mutex<HashMap<String, std::sync::Weak<tokio::sync::Mutex<()>>>>,
130}
131
132#[derive(Debug, Clone)]
133pub struct CachedRunKey {
134 pub kind: String,
135 pub run_id: Option<String>,
136 pub tags_json: Option<String>,
137 pub expires_ms: Option<i64>,
138}
139
140#[derive(Debug, Clone)]
141struct CodexAffinityEntry {
142 account_id: String,
143 expires_at_ms: i64,
144}
145
146#[derive(Debug)]
147struct CodexAffinityCache {
148 entries: HashMap<String, CodexAffinityEntry>,
149 ttl_ms: i64,
150 max_entries: usize,
151}
152
153impl Default for CodexAffinityCache {
154 fn default() -> Self {
155 Self {
156 entries: HashMap::new(),
157 ttl_ms: CODEX_AFFINITY_TTL_MS,
158 max_entries: CODEX_AFFINITY_MAX_ENTRIES,
159 }
160 }
161}
162
163impl CodexAffinityCache {
164 fn preferred(&mut self, session_id: &str, now: i64) -> Option<String> {
165 let entry = self.entries.get_mut(session_id)?;
166 if entry.expires_at_ms <= now {
167 self.entries.remove(session_id);
168 return None;
169 }
170 entry.expires_at_ms = now.saturating_add(self.ttl_ms);
173 Some(entry.account_id.clone())
174 }
175
176 fn bind(&mut self, session_id: &str, account_id: &str, now: i64) {
177 self.entries.retain(|_, entry| entry.expires_at_ms > now);
178 if !self.entries.contains_key(session_id) && self.entries.len() >= self.max_entries {
179 if let Some(oldest) = self
180 .entries
181 .iter()
182 .min_by_key(|(_, entry)| entry.expires_at_ms)
183 .map(|(session, _)| session.clone())
184 {
185 self.entries.remove(&oldest);
186 }
187 }
188 self.entries.insert(
189 session_id.to_string(),
190 CodexAffinityEntry {
191 account_id: account_id.to_string(),
192 expires_at_ms: now.saturating_add(self.ttl_ms),
193 },
194 );
195 }
196
197 fn unbind(&mut self, session_id: &str) {
198 self.entries.remove(session_id);
199 }
200}
201
202fn preferred_codex_account(state: &AppState, session_id: Option<&str>) -> Option<String> {
203 let session_id = session_id.filter(|value| !value.is_empty())?;
204 state
205 .codex_affinity
206 .lock()
207 .unwrap_or_else(|poisoned| poisoned.into_inner())
208 .preferred(session_id, now_ms())
209}
210
211fn codex_affinity_lock(state: &AppState, session_id: &str) -> Arc<tokio::sync::Mutex<()>> {
212 let mut locks = state
213 .codex_affinity_locks
214 .lock()
215 .unwrap_or_else(|poisoned| poisoned.into_inner());
216 locks.retain(|_, lock| lock.strong_count() > 0);
217 if let Some(lock) = locks.get(session_id).and_then(std::sync::Weak::upgrade) {
218 return lock;
219 }
220 let lock = Arc::new(tokio::sync::Mutex::new(()));
221 locks.insert(session_id.to_string(), Arc::downgrade(&lock));
222 lock
223}
224
225fn bind_codex_account(state: &AppState, session_id: Option<&str>, account: &Account) {
226 let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
227 return;
228 };
229 if account.provider != Provider::Openai {
230 return;
231 }
232 let mut affinity = state
233 .codex_affinity
234 .lock()
235 .unwrap_or_else(|poisoned| poisoned.into_inner());
236 if account.kind == "oauth" {
237 affinity.bind(session_id, &account.id, now_ms());
238 } else {
239 affinity.unbind(session_id);
243 }
244}
245
246struct InFlight(Arc<AppState>);
247
248impl InFlight {
249 fn new(state: &Arc<AppState>) -> Self {
250 state
251 .in_flight
252 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
253 Self(state.clone())
254 }
255}
256
257impl Drop for InFlight {
258 fn drop(&mut self) {
259 self.0
260 .in_flight
261 .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
262 }
263}
264
265pub fn build_state(
266 local_key: String,
267 vault: Arc<Vault>,
268 store: Arc<Store>,
269 dario: Option<Arc<dyn DarioRouter>>,
270 base_url: String,
271) -> Arc<AppState> {
272 let http = reqwest::Client::builder()
273 .connect_timeout(Duration::from_secs(15))
274 .build()
275 .expect("reqwest client");
276 Arc::new(AppState {
277 local_key,
278 vault,
279 store,
280 http,
281 dario,
282 in_flight: std::sync::atomic::AtomicI64::new(0),
283 started_ms: now_ms(),
284 base_url,
285 anthropic_usage: std::sync::Mutex::new(UsageCache::default()),
286 xai_usage: std::sync::Mutex::new(UsageCache::default()),
287 amp_usage: std::sync::Mutex::new(UsageCache::default()),
288 logins: alex_auth::sessions::LoginManager::default(),
289 run_keys: std::sync::RwLock::new(HashMap::new()),
290 trace_ingest_lock: tokio::sync::Mutex::new(()),
291 update_status: Arc::new(tokio::sync::RwLock::new(None)),
292 daemon_updater: std::sync::RwLock::new(None),
293 codex_affinity: std::sync::Mutex::new(CodexAffinityCache::default()),
294 codex_affinity_locks: std::sync::Mutex::new(HashMap::new()),
295 })
296}
297
298pub fn set_daemon_updater(state: &Arc<AppState>, updater: Arc<dyn DaemonUpdater>) {
299 if let Ok(mut slot) = state.daemon_updater.write() {
300 *slot = Some(updater);
301 }
302}
303
304async fn require_local_key(
305 State(state): State<Arc<AppState>>,
306 req: axum::extract::Request,
307 next: axum::middleware::Next,
308) -> Response {
309 let ok = client_key(req.headers())
310 .map(|k| k == state.local_key)
311 .unwrap_or(false);
312 if !ok {
313 return error_response(
314 StatusCode::UNAUTHORIZED,
315 "admin routes require x-api-key: <local_key>",
316 );
317 }
318 next.run(req).await
319}
320
321async fn require_trace_ingest_key(
322 State(state): State<Arc<AppState>>,
323 req: axum::extract::Request,
324 next: axum::middleware::Next,
325) -> Response {
326 match authenticate_trace_ingest(&state, req.headers()) {
327 Ok(_) => next.run(req).await,
328 Err(response) => response,
329 }
330}
331
332pub fn router(state: Arc<AppState>) -> Router {
333 let gated = Router::new()
338 .route(
339 "/admin/run-keys",
340 get(admin_run_keys_list).post(admin_run_keys_create),
341 )
342 .route(
343 "/admin/run-keys/{id}",
344 axum::routing::delete(admin_run_keys_revoke),
345 )
346 .route("/admin/storage", get(admin_storage))
347 .route("/admin/storage/prune", post(admin_storage_prune))
348 .route("/admin/traces", get(admin_traces))
349 .route("/admin/accounts", get(admin_accounts))
350 .route("/admin/accounts/analytics", get(admin_account_analytics))
351 .route(
352 "/admin/accounts/routing/openai",
353 get(admin_codex_routing).put(admin_codex_routing_update),
354 )
355 .route(
356 "/admin/accounts/{id}",
357 axum::routing::delete(admin_account_remove).put(admin_account_update),
358 )
359 .route("/admin/auth/gemini-key", post(admin_auth_gemini_key))
360 .route("/admin/health", get(admin_health))
361 .route("/admin/analytics", get(admin_analytics))
362 .route("/admin/limits", get(admin_limits))
363 .route("/admin/update", get(admin_update).post(admin_update_apply))
364 .route("/admin/dario", get(admin_dario))
365 .route("/admin/dario/prompt-caches", get(admin_dario_prompt_caches))
366 .route(
367 "/admin/dario/prompt-caches/{key}",
368 axum::routing::delete(admin_dario_prompt_cache_delete),
369 )
370 .route("/admin/auth/import", post(admin_auth_import))
371 .route("/admin/auth/login/start", post(admin_auth_login_start))
372 .route(
373 "/admin/auth/login/complete",
374 post(admin_auth_login_complete),
375 )
376 .route("/admin/auth/login/{id}", get(admin_auth_login_status))
377 .route("/traces/search", get(traces_search))
378 .route("/traces/export.ndjson", get(traces_export))
379 .route("/traces/sessions", get(traces_sessions))
380 .route(
381 "/traces/sessions/{session_id}/transcript",
382 get(traces_session_transcript),
383 )
384 .route("/traces/{id}", get(trace_get).delete(trace_delete))
385 .route("/traces/{id}/reply.md", get(trace_reply_md))
386 .route("/traces/{id}/body/{kind}", get(trace_body))
387 .route("/traces/runs/{run_id}", get(traces_run_summary))
388 .route("/traces/runs/{run_id}/events", get(traces_run_events))
389 .route(
390 "/traces/runs/{run_id}/export.ndjson",
391 get(traces_run_export),
392 )
393 .route("/traces/runs/{run_id}/artifacts", get(traces_run_artifacts))
394 .route_layer(axum::middleware::from_fn_with_state(
395 state.clone(),
396 require_local_key,
397 ));
398 let ingest = Router::new()
401 .route(
402 "/traces/ingest",
403 get(traces_ingest_status).post(traces_ingest),
404 )
405 .route_layer(axum::middleware::from_fn_with_state(
406 state.clone(),
407 require_trace_ingest_key,
408 ));
409
410 Router::new()
411 .route("/health", get(health))
412 .route("/connect", get(connect_info))
413 .route("/v1/models", get(models))
414 .route("/v1/messages", post(anthropic_messages))
415 .route("/v1/chat/completions", post(openai_chat))
416 .route("/v1/responses", post(openai_responses))
417 .route("/chat/completions", post(openai_chat))
418 .route("/responses", post(openai_responses))
419 .route("/v1beta/models/{model_action}", post(gemini_generate))
420 .merge(ingest)
421 .merge(gated)
422 .layer(axum::extract::DefaultBodyLimit::max(64 * 1024 * 1024))
423 .with_state(state)
424}
425
426async fn admin_auth_import(
427 State(state): State<Arc<AppState>>,
428 body: Option<axum::Json<Value>>,
429) -> Response {
430 let source = body
431 .as_ref()
432 .and_then(|b| b.0["source"].as_str().or(b.0["provider"].as_str()))
433 .unwrap_or("all")
434 .to_string();
435 match alex_auth::import_all(&state.vault, &source).await {
436 Ok(outcomes) => {
437 let items: Vec<Value> = outcomes
438 .iter()
439 .map(|o| {
440 json!({
441 "source": o.source,
442 "imported": o.imported,
443 "note": o.note,
444 })
445 })
446 .collect();
447 axum::Json(json!({"outcomes": items})).into_response()
448 }
449 Err(e) => error_response(StatusCode::BAD_REQUEST, &e.to_string()),
450 }
451}
452
453async fn admin_account_remove(
454 State(state): State<Arc<AppState>>,
455 Path(id): Path<String>,
456) -> Response {
457 match state.vault.remove(&id).await {
458 Ok(true) => axum::Json(json!({"removed": id})).into_response(),
459 Ok(false) => error_response(StatusCode::NOT_FOUND, &format!("unknown account '{id}'")),
460 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
461 }
462}
463
464async fn admin_account_update(
465 State(state): State<Arc<AppState>>,
466 Path(id): Path<String>,
467 body: axum::Json<Value>,
468) -> Response {
469 let Some(paused) = body.0["paused"].as_bool() else {
470 return error_response(StatusCode::BAD_REQUEST, "missing boolean 'paused'");
471 };
472 match state.vault.set_paused(&id, paused).await {
473 Ok(()) => axum::Json(json!({"id": id, "paused": paused})).into_response(),
474 Err(e) if e.to_string().starts_with("unknown account") => {
475 error_response(StatusCode::NOT_FOUND, &e.to_string())
476 }
477 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
478 }
479}
480
481async fn admin_auth_gemini_key(
482 State(state): State<Arc<AppState>>,
483 body: axum::Json<Value>,
484) -> Response {
485 let Some(key) = body.0["key"]
486 .as_str()
487 .map(str::trim)
488 .filter(|k| !k.is_empty())
489 else {
490 return error_response(StatusCode::BAD_REQUEST, "missing 'key'");
491 };
492 let account = Account {
493 id: alex_auth::named_account_id(Provider::Gemini, "api_key", "default"),
494 provider: Provider::Gemini,
495 kind: "api_key".into(),
496 name: "default".into(),
497 description: None,
498 paused: false,
499 label: Some("gemini (AI Studio key)".into()),
500 access_token: None,
501 refresh_token: None,
502 id_token: None,
503 api_key: Some(key.to_string()),
504 expires_at_ms: None,
505 last_refresh_ms: None,
506 account_meta: Value::Null,
507 cooldown_until_ms: None,
508 status: "active".into(),
509 path: None,
510 };
511 match state.vault.upsert(account).await {
512 Ok(()) => axum::Json(json!({"saved": "gemini-api-key"})).into_response(),
513 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
514 }
515}
516
517async fn admin_auth_login_start(
518 State(state): State<Arc<AppState>>,
519 body: axum::Json<Value>,
520) -> Response {
521 let Some(provider) = body.0["provider"].as_str() else {
522 return error_response(StatusCode::BAD_REQUEST, "missing 'provider'");
523 };
524 if body.0["auto_identity"].as_bool() == Some(true) {
525 return match state.logins.start_auto(state.vault.clone(), provider).await {
526 Ok(snapshot) => axum::Json(snapshot).into_response(),
527 Err(e) => error_response(StatusCode::BAD_REQUEST, &e.to_string()),
528 };
529 }
530 let name = body.0["name"].as_str().unwrap_or("default");
531 match state.logins.start(state.vault.clone(), provider, name).await {
532 Ok(snapshot) => axum::Json(snapshot).into_response(),
533 Err(e) => error_response(StatusCode::BAD_REQUEST, &e.to_string()),
534 }
535}
536
537async fn admin_auth_login_complete(
538 State(state): State<Arc<AppState>>,
539 body: axum::Json<Value>,
540) -> Response {
541 let (Some(id), Some(input)) = (body.0["login_id"].as_str(), body.0["input"].as_str()) else {
542 return error_response(StatusCode::BAD_REQUEST, "missing 'login_id' or 'input'");
543 };
544 match state.logins.complete(state.vault.clone(), id, input).await {
545 Ok(snapshot) => axum::Json(snapshot).into_response(),
546 Err(e) => error_response(StatusCode::BAD_REQUEST, &e.to_string()),
547 }
548}
549
550async fn admin_auth_login_status(
551 State(state): State<Arc<AppState>>,
552 axum::extract::Path(id): axum::extract::Path<String>,
553) -> Response {
554 match state.logins.status(&id).await {
555 Some(snapshot) => axum::Json(snapshot).into_response(),
556 None => error_response(StatusCode::NOT_FOUND, "unknown or expired login session"),
557 }
558}
559
560async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
561 axum::Json(json!({
562 "status": "ok",
563 "service": "alexandria",
564 "version": env!("CARGO_PKG_VERSION"),
565 "in_flight": state.in_flight.load(std::sync::atomic::Ordering::SeqCst),
566 "uptime_s": (now_ms() - state.started_ms) / 1000,
567 "dario": state.dario.is_some(),
568 }))
569}
570
571fn rewrite_host(base_url: &str, host: &str) -> String {
572 let port = base_url.rsplit(':').next().unwrap_or("4100");
573 format!("http://{host}:{port}")
574}
575
576pub fn connect_payload(base_url: &str, local_key: &str) -> (Value, String) {
577 let base = base_url.trim_end_matches('/').to_string();
578 let v1 = format!("{base}/v1");
579 let exports = format!(
580 "export ANTHROPIC_BASE_URL={base}\nexport ANTHROPIC_API_KEY={local_key}\n\
581 export OPENAI_BASE_URL={v1}\nexport OPENAI_API_KEY={local_key}\n\
582 export XAI_API_KEY={local_key}\nexport GROK_MODELS_BASE_URL={v1}\n\
583 export GOOGLE_GEMINI_BASE_URL={base}\nexport GOOGLE_GENAI_API_VERSION=v1beta\n\
584 export GEMINI_API_KEY={local_key}\nexport GEMINI_API_KEY_AUTH_MECHANISM=bearer\n\
585 export GOOGLE_GENAI_USE_GCA=false\n"
586 );
587 let payload = json!({
588 "service": "alexandria",
589 "base_url": base,
590 "api_key": local_key,
591 "anthropic": {"base_url": base, "env": {"ANTHROPIC_BASE_URL": base, "ANTHROPIC_API_KEY": local_key}},
592 "openai": {"base_url": v1, "env": {"OPENAI_BASE_URL": v1, "OPENAI_API_KEY": local_key}},
593 "xai": {"base_url": v1, "env": {"XAI_API_KEY": local_key, "GROK_MODELS_BASE_URL": v1}},
594 "gemini": {"base_url": base, "env": {
595 "GOOGLE_GEMINI_BASE_URL": base,
596 "GOOGLE_GENAI_API_VERSION": "v1beta",
597 "GEMINI_API_KEY": local_key,
598 "GEMINI_API_KEY_AUTH_MECHANISM": "bearer",
599 "GOOGLE_GENAI_USE_GCA": "false",
600 }},
601 "exports": exports,
602 });
603 (payload, exports)
604}
605
606async fn connect_info(
607 State(state): State<Arc<AppState>>,
608 axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
609 Query(q): Query<HashMap<String, String>>,
610) -> Response {
611 if !peer.ip().is_loopback() {
612 return error_response(
613 StatusCode::FORBIDDEN,
614 "connection info is only served to loopback clients",
615 );
616 }
617 let base = match q.get("host").map(String::as_str) {
618 Some("docker") => rewrite_host(&state.base_url, "host.docker.internal"),
619 Some(host) if !host.is_empty() => rewrite_host(&state.base_url, host),
620 _ => state.base_url.clone(),
621 };
622 let (payload, exports) = connect_payload(&base, &state.local_key);
623 if q.get("format").map(|f| f == "env").unwrap_or(false) {
624 return Response::builder()
625 .status(StatusCode::OK)
626 .header("content-type", "text/plain")
627 .body(Body::from(exports))
628 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()));
629 }
630 axum::Json(payload).into_response()
631}
632
633async fn models(State(state): State<Arc<AppState>>) -> impl IntoResponse {
634 let mut ids = state.store.pricing_models();
635 for (alias, _) in alex_core::model_aliases() {
636 ids.push((*alias).to_string());
637 }
638 for id in ids.clone() {
639 ids.push(format!("alexandria/{id}"));
640 }
641 let data: Vec<Value> = ids
642 .into_iter()
643 .map(|m| json!({"id": m, "object": "model", "owned_by": "alexandria"}))
644 .collect();
645 axum::Json(json!({"object": "list", "data": data}))
646}
647
648async fn admin_analytics(
649 State(state): State<Arc<AppState>>,
650 Query(q): Query<HashMap<String, String>>,
651) -> Response {
652 let minutes: i64 = q
653 .get("since_minutes")
654 .and_then(|s| s.parse().ok())
655 .unwrap_or(60);
656 match state.store.analytics(now_ms() - minutes * 60_000) {
657 Ok(v) => axum::Json(v).into_response(),
658 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
659 }
660}
661
662async fn admin_account_analytics(
663 State(state): State<Arc<AppState>>,
664 Query(q): Query<HashMap<String, String>>,
665) -> Response {
666 let minutes: i64 = q
667 .get("since_minutes")
668 .and_then(|s| s.parse().ok())
669 .unwrap_or(24 * 60)
670 .clamp(1, 30 * 24 * 60);
671 let bucket_minutes: i64 = q
672 .get("bucket_minutes")
673 .and_then(|s| s.parse().ok())
674 .unwrap_or(60)
675 .clamp(1, 24 * 60);
676 match state
677 .store
678 .account_analytics(now_ms() - minutes * 60_000, bucket_minutes * 60_000)
679 {
680 Ok(v) => axum::Json(v).into_response(),
681 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
682 }
683}
684
685const USAGE_CACHE_TTL_MS: i64 = 300_000;
686const USAGE_BACKOFF_BASE_MS: i64 = 60_000;
687const USAGE_BACKOFF_MAX_MS: i64 = 3_600_000;
688
689#[derive(Default)]
690pub struct UsageCache {
691 fetched_at_ms: i64,
692 entry: Option<Value>,
693 cooldown_until_ms: i64,
694 failures: u32,
695}
696
697fn usage_backoff_ms(failures: u32, retry_after_ms: Option<i64>) -> i64 {
698 let exp = USAGE_BACKOFF_BASE_MS
699 .saturating_mul(1i64 << failures.saturating_sub(1).min(6))
700 .min(USAGE_BACKOFF_MAX_MS);
701 exp.max(retry_after_ms.unwrap_or(0))
702}
703
704async fn anthropic_usage_entry(state: &Arc<AppState>) -> Option<Value> {
705 let account = state
706 .vault
707 .account_for(Provider::Anthropic, true)
708 .await
709 .ok()?;
710 if account.kind != "oauth" {
711 return None;
712 }
713 let token = account.access_token.as_deref()?.to_string();
714 {
715 let cache = state.anthropic_usage.lock().unwrap();
716 if cache.entry.is_some() && now_ms() < cache.fetched_at_ms + USAGE_CACHE_TTL_MS {
717 return cache.entry.clone();
718 }
719 if now_ms() < cache.cooldown_until_ms {
720 return cache.entry.clone();
721 }
722 }
723 let result = state
724 .http
725 .get(format!("{ANTHROPIC_BASE}/api/oauth/usage"))
726 .header("authorization", format!("Bearer {token}"))
727 .header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
728 .header("accept", "application/json")
729 .header("user-agent", "claude-cli/2.1.202 (external, cli)")
730 .send()
731 .await;
732 match result {
733 Ok(resp) if resp.status().is_success() => {
734 let raw: Value = resp.json().await.unwrap_or(Value::Null);
735 let mut windows = Vec::new();
736 for (name, key) in [
737 ("5h", "five_hour"),
738 ("7d", "seven_day"),
739 ("7d opus", "seven_day_opus"),
740 ("7d sonnet", "seven_day_sonnet"),
741 ] {
742 let w = &raw[key];
743 if w.is_object() {
744 windows.push(json!({
745 "window": name,
746 "used_pct": w["utilization"],
747 "resets_at": w["resets_at"],
748 }));
749 }
750 }
751 let entry = json!({
752 "provider": "anthropic",
753 "source": "oauth usage endpoint",
754 "plan": account.label,
755 "windows": windows,
756 "extra_usage": raw["extra_usage"],
757 });
758 let mut cache = state.anthropic_usage.lock().unwrap();
759 cache.fetched_at_ms = now_ms();
760 cache.cooldown_until_ms = 0;
761 cache.failures = 0;
762 cache.entry = Some(entry.clone());
763 Some(entry)
764 }
765 Ok(resp) => {
766 let retry_after = resp
767 .headers()
768 .get("retry-after")
769 .and_then(|v| v.to_str().ok())
770 .and_then(|v| v.parse::<i64>().ok())
771 .map(|s| s.clamp(30, 3600) * 1000);
772 let mut cache = state.anthropic_usage.lock().unwrap();
773 cache.failures += 1;
774 let cooldown = usage_backoff_ms(cache.failures, retry_after);
775 cache.cooldown_until_ms = now_ms() + cooldown;
776 tracing::debug!(
777 status = resp.status().as_u16(),
778 failures = cache.failures,
779 cooldown_ms = cooldown,
780 "anthropic usage endpoint unavailable; backing off"
781 );
782 cache.entry.clone()
783 }
784 Err(_) => {
785 let mut cache = state.anthropic_usage.lock().unwrap();
786 cache.failures += 1;
787 let cooldown = usage_backoff_ms(cache.failures, None);
788 cache.cooldown_until_ms = now_ms() + cooldown;
789 cache.entry.clone()
790 }
791 }
792}
793
794const AMP_USAGE_URL: &str = "https://ampcode.com/api/internal?userDisplayBalanceInfo";
795
796async fn amp_usage_entry(state: &Arc<AppState>) -> Option<Value> {
798 let account = state.vault.account_for(Provider::Amp, false).await.ok()?;
799 let token = account
800 .api_key
801 .as_deref()
802 .or(account.access_token.as_deref())?
803 .to_string();
804 {
805 let cache = state.amp_usage.lock().unwrap();
806 if cache.entry.is_some() && now_ms() < cache.fetched_at_ms + USAGE_CACHE_TTL_MS {
807 return cache.entry.clone();
808 }
809 if now_ms() < cache.cooldown_until_ms {
810 return cache.entry.clone();
811 }
812 }
813 let body = json!({ "method": "userDisplayBalanceInfo", "params": {} });
814 let result = state
815 .http
816 .post(AMP_USAGE_URL)
817 .header("authorization", format!("Bearer {token}"))
818 .header("accept", "application/json")
819 .header("content-type", "application/json")
820 .header("user-agent", "alexandria-amp-usage")
821 .json(&body)
822 .send()
823 .await;
824 match result {
825 Ok(resp) if resp.status().is_success() => {
826 let raw = resp.text().await.unwrap_or_default();
827 match parse_usage_api_response(&raw) {
828 Ok(snap) => {
829 let entry = usage_to_limits_entry(&snap, account.label.as_deref());
830 let mut cache = state.amp_usage.lock().unwrap();
831 cache.fetched_at_ms = now_ms();
832 cache.cooldown_until_ms = 0;
833 cache.failures = 0;
834 cache.entry = Some(entry.clone());
835 Some(entry)
836 }
837 Err(e) => {
838 tracing::debug!(error = %e, "amp usage parse failed");
839 let mut cache = state.amp_usage.lock().unwrap();
840 cache.failures += 1;
841 let cooldown = usage_backoff_ms(cache.failures, None);
842 cache.cooldown_until_ms = now_ms() + cooldown;
843 cache.entry.clone().or_else(|| {
844 Some(json!({
845 "provider": "amp",
846 "source": "amp usage API",
847 "error": e,
848 "plan": account.label,
849 }))
850 })
851 }
852 }
853 }
854 Ok(resp) => {
855 let status = resp.status().as_u16();
856 let mut cache = state.amp_usage.lock().unwrap();
857 cache.failures += 1;
858 let cooldown = usage_backoff_ms(cache.failures, None);
859 cache.cooldown_until_ms = now_ms() + cooldown;
860 tracing::debug!(status, "amp usage endpoint unavailable; backing off");
861 cache.entry.clone().or_else(|| {
862 Some(json!({
863 "provider": "amp",
864 "source": "amp usage API",
865 "error": format!("HTTP {status}"),
866 "plan": account.label,
867 }))
868 })
869 }
870 Err(e) => {
871 let mut cache = state.amp_usage.lock().unwrap();
872 cache.failures += 1;
873 let cooldown = usage_backoff_ms(cache.failures, None);
874 cache.cooldown_until_ms = now_ms() + cooldown;
875 tracing::debug!(error = %e, "amp usage request failed");
876 cache.entry.clone()
877 }
878 }
879}
880
881async fn xai_usage_entry(state: &Arc<AppState>) -> Option<Value> {
884 let account = state.vault.account_for(Provider::Xai, true).await.ok()?;
885 if account.kind != "oauth" {
886 return None;
887 }
888 let token = account.access_token.as_deref()?.to_string();
889 {
890 let cache = state.xai_usage.lock().unwrap();
891 if cache.entry.is_some() && now_ms() < cache.fetched_at_ms + USAGE_CACHE_TTL_MS {
892 return cache.entry.clone();
893 }
894 if now_ms() < cache.cooldown_until_ms {
895 return cache.entry.clone();
896 }
897 }
898
899 let result = state
900 .http
901 .post(GROK_CREDITS_ENDPOINT)
902 .header("authorization", format!("Bearer {token}"))
903 .header("origin", "https://grok.com")
904 .header("referer", "https://grok.com/?_s=usage")
905 .header("accept", "*/*")
906 .header("content-type", "application/grpc-web+proto")
907 .header("x-grpc-web", "1")
908 .header("x-user-agent", "connect-es/2.1.1")
909 .header("user-agent", "Alexandria")
910 .body(GROK_CREDITS_REQUEST_BODY.to_vec())
911 .timeout(Duration::from_secs(15))
912 .send()
913 .await;
914
915 match result {
916 Ok(resp) if resp.status().is_success() => {
917 let headers_for_grpc: Vec<(String, String)> = resp
918 .headers()
919 .iter()
920 .filter_map(|(k, v)| {
921 let key = k.as_str();
922 if key.starts_with("grpc-") {
923 v.to_str()
924 .ok()
925 .map(|val| (key.to_string(), val.to_string()))
926 } else {
927 None
928 }
929 })
930 .collect();
931 if let Err(e) = validate_grpc_status_headers(headers_for_grpc) {
932 tracing::debug!(error = %e, "xai grok credits grpc header status failed");
933 let mut cache = state.xai_usage.lock().unwrap();
934 cache.failures += 1;
935 let cooldown = usage_backoff_ms(cache.failures, None);
936 cache.cooldown_until_ms = now_ms() + cooldown;
937 return cache.entry.clone();
938 }
939 let body = match resp.bytes().await {
940 Ok(b) => b,
941 Err(e) => {
942 tracing::debug!(error = %e, "xai grok credits body read failed");
943 let mut cache = state.xai_usage.lock().unwrap();
944 cache.failures += 1;
945 let cooldown = usage_backoff_ms(cache.failures, None);
946 cache.cooldown_until_ms = now_ms() + cooldown;
947 return cache.entry.clone();
948 }
949 };
950 let now_s = now_ms() / 1000;
951 match parse_grpc_web_response(&body, now_s) {
952 Ok(snap) => {
953 let label = window_label(snap.resets_at_s, now_s);
954 let mut window = json!({
955 "window": label,
956 "used_pct": snap.used_percent,
957 });
958 if let Some(ts) = snap.resets_at_s {
959 window["resets_at_s"] = json!(ts);
960 }
961 let entry = json!({
962 "provider": "xai",
963 "source": "grok web billing",
964 "plan": account.label,
965 "windows": [window],
966 });
967 let mut cache = state.xai_usage.lock().unwrap();
968 cache.fetched_at_ms = now_ms();
969 cache.cooldown_until_ms = 0;
970 cache.failures = 0;
971 cache.entry = Some(entry.clone());
972 Some(entry)
973 }
974 Err(e) => {
975 tracing::debug!(error = %e, "xai grok credits parse failed");
976 let mut cache = state.xai_usage.lock().unwrap();
977 cache.failures += 1;
978 let cooldown = usage_backoff_ms(cache.failures, None);
979 cache.cooldown_until_ms = now_ms() + cooldown;
980 cache.entry.clone()
981 }
982 }
983 }
984 Ok(resp) => {
985 let status = resp.status().as_u16();
986 let retry_after = resp
987 .headers()
988 .get("retry-after")
989 .and_then(|v| v.to_str().ok())
990 .and_then(|v| v.parse::<i64>().ok())
991 .map(|s| s.clamp(30, 3600) * 1000);
992 let mut cache = state.xai_usage.lock().unwrap();
993 cache.failures += 1;
994 let cooldown = usage_backoff_ms(cache.failures, retry_after);
995 cache.cooldown_until_ms = now_ms() + cooldown;
996 tracing::debug!(
997 status,
998 failures = cache.failures,
999 cooldown_ms = cooldown,
1000 "xai grok web billing unavailable; backing off"
1001 );
1002 cache.entry.clone()
1003 }
1004 Err(e) => {
1005 tracing::debug!(error = %e, "xai grok web billing request failed");
1006 let mut cache = state.xai_usage.lock().unwrap();
1007 cache.failures += 1;
1008 let cooldown = usage_backoff_ms(cache.failures, None);
1009 cache.cooldown_until_ms = now_ms() + cooldown;
1010 cache.entry.clone()
1011 }
1012 }
1013}
1014
1015pub async fn limits_snapshot(state: &Arc<AppState>) -> Value {
1016 let mut providers: Vec<Value> = Vec::new();
1017 if let Some(entry) = anthropic_usage_entry(state).await {
1018 providers.push(entry);
1019 }
1020 if let Some(entry) = xai_usage_entry(state).await {
1021 providers.push(entry);
1022 }
1023 if let Some(entry) = amp_usage_entry(state).await {
1024 providers.push(entry);
1025 }
1026 for (provider_str, ts_ms, headers_json) in
1027 state.store.latest_provider_headers().unwrap_or_default()
1028 {
1029 if providers
1030 .iter()
1031 .any(|p| p["provider"].as_str() == Some(&provider_str))
1032 {
1033 continue;
1034 }
1035 let Some(provider) = Provider::from_str_loose(&provider_str) else {
1036 continue;
1037 };
1038 let headers: Value = serde_json::from_str(&headers_json).unwrap_or(Value::Null);
1039 let mut parsed = alex_core::parse_limit_headers(provider, &headers);
1040 if let Some(o) = parsed.as_object_mut() {
1041 o.insert("provider".into(), json!(provider_str));
1042 o.insert("source".into(), json!("captured response headers"));
1043 o.insert("observed_at_ms".into(), json!(ts_ms));
1044 providers.push(parsed);
1045 }
1046 }
1047 providers.sort_by_key(|p| p["provider"].as_str().unwrap_or("").to_string());
1048 json!({"providers": providers})
1049}
1050
1051async fn admin_limits(State(state): State<Arc<AppState>>) -> Response {
1052 axum::Json(limits_snapshot(&state).await).into_response()
1053}
1054
1055async fn admin_update(State(state): State<Arc<AppState>>) -> Response {
1056 let stored = state.update_status.read().await.clone();
1057 let mut body = json!({
1058 "current": env!("CARGO_PKG_VERSION"),
1059 "latest": null,
1060 "update_available": false,
1061 "checked_at_ms": null,
1062 });
1063 if let Some(Value::Object(fields)) = stored {
1064 if let Some(obj) = body.as_object_mut() {
1065 obj.extend(fields);
1066 obj.insert("current".into(), json!(env!("CARGO_PKG_VERSION")));
1067 }
1068 }
1069 axum::Json(body).into_response()
1070}
1071
1072async fn admin_update_apply(State(state): State<Arc<AppState>>) -> Response {
1073 let updater = state
1074 .daemon_updater
1075 .read()
1076 .ok()
1077 .and_then(|slot| slot.clone());
1078 let Some(updater) = updater else {
1079 return error_response(
1080 StatusCode::SERVICE_UNAVAILABLE,
1081 "daemon updater is not configured",
1082 );
1083 };
1084 match updater.apply().await {
1085 Ok(body) => {
1086 let status = if body["applying"].as_bool() == Some(true) {
1087 StatusCode::ACCEPTED
1088 } else {
1089 StatusCode::OK
1090 };
1091 (status, axum::Json(body)).into_response()
1092 }
1093 Err(UpdateApplyError::Conflict(body)) => {
1094 (StatusCode::CONFLICT, axum::Json(body)).into_response()
1095 }
1096 Err(UpdateApplyError::Failed(message)) => {
1097 error_response(StatusCode::INTERNAL_SERVER_ERROR, &message)
1098 }
1099 }
1100}
1101
1102async fn admin_dario(State(state): State<Arc<AppState>>) -> Response {
1103 match &state.dario {
1104 Some(d) => {
1105 let mut status = d.status();
1106 if let Some(obj) = status.as_object_mut() {
1107 obj.insert("prompt_caches".into(), json!(dario_prompt_caches(&state)));
1108 }
1109 axum::Json(status).into_response()
1110 }
1111 None => error_response(StatusCode::NOT_FOUND, "dario mode is not enabled"),
1112 }
1113}
1114
1115fn dario_prompt_cache_dir(state: &AppState) -> PathBuf {
1116 state.store.data_dir.join("dario-prompt-cache")
1117}
1118
1119fn dario_prompt_cache_key_valid(key: &str) -> bool {
1120 !key.is_empty()
1121 && key.len() <= 128
1122 && key
1123 .bytes()
1124 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
1125}
1126
1127fn dario_prompt_cache_summary(path: PathBuf) -> Option<Value> {
1128 let raw = std::fs::read_to_string(&path).ok()?;
1129 let value: Value = serde_json::from_str(&raw).ok()?;
1130 let key = path.file_stem()?.to_string_lossy().to_string();
1131 let runs: Vec<Value> = value["runs"]
1132 .as_array()
1133 .map(|runs| runs.iter().rev().take(12).cloned().collect())
1134 .unwrap_or_default();
1135 Some(json!({
1136 "key": value["key"].as_str().unwrap_or(&key),
1137 "model": value["model"],
1138 "source": value["source"],
1139 "captured_at": value["captured_at"],
1140 "last_used_at": value["last_used_at"],
1141 "trace_id": value["trace_id"],
1142 "claude_bin": value["claude_bin"],
1143 "claude_version": value["claude_version"],
1144 "system_prompt_chars": value["system_prompt_chars"],
1145 "agent_identity_chars": value["agent_identity_chars"],
1146 "path": path.to_string_lossy(),
1147 "runs": runs,
1148 }))
1149}
1150
1151fn dario_prompt_caches(state: &AppState) -> Vec<Value> {
1152 let dir = dario_prompt_cache_dir(state);
1153 let mut caches: Vec<Value> = std::fs::read_dir(dir)
1154 .ok()
1155 .into_iter()
1156 .flatten()
1157 .flatten()
1158 .filter_map(|entry| {
1159 let path = entry.path();
1160 (path.extension().and_then(|s| s.to_str()) == Some("json"))
1161 .then(|| dario_prompt_cache_summary(path))
1162 .flatten()
1163 })
1164 .collect();
1165 caches.sort_by(|a, b| {
1166 b["last_used_at"]
1167 .as_str()
1168 .unwrap_or("")
1169 .cmp(a["last_used_at"].as_str().unwrap_or(""))
1170 });
1171 caches
1172}
1173
1174async fn admin_dario_prompt_caches(State(state): State<Arc<AppState>>) -> Response {
1175 axum::Json(json!({"prompt_caches": dario_prompt_caches(&state)})).into_response()
1176}
1177
1178async fn admin_dario_prompt_cache_delete(
1179 State(state): State<Arc<AppState>>,
1180 Path(key): Path<String>,
1181) -> Response {
1182 if !dario_prompt_cache_key_valid(&key) {
1183 return error_response(StatusCode::BAD_REQUEST, "invalid prompt cache key");
1184 }
1185 let path = dario_prompt_cache_dir(&state).join(format!("{key}.json"));
1186 match std::fs::remove_file(&path) {
1187 Ok(()) => axum::Json(json!({"deleted": true, "key": key})).into_response(),
1188 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1189 error_response(StatusCode::NOT_FOUND, "unknown prompt cache")
1190 }
1191 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1192 }
1193}
1194
1195async fn admin_storage(State(state): State<Arc<AppState>>) -> Response {
1196 match state.store.disk_usage() {
1197 Ok(v) => axum::Json(v).into_response(),
1198 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1199 }
1200}
1201
1202async fn admin_storage_prune(
1203 State(state): State<Arc<AppState>>,
1204 body: axum::Json<Value>,
1205) -> Response {
1206 let now = now_ms();
1207 let cutoff = match &body["older_than_ms"] {
1208 Value::Null => match body["older_than"].as_str() {
1209 Some(s) => match parse_since(s, now) {
1210 Some(ms) => ms,
1211 None => {
1212 return error_response(
1213 StatusCode::BAD_REQUEST,
1214 &format!("invalid 'older_than' '{s}' (use 45s, 30m, 24h, 7d, or RFC3339)"),
1215 )
1216 }
1217 },
1218 None => {
1219 return error_response(
1220 StatusCode::BAD_REQUEST,
1221 "missing 'older_than_ms' or 'older_than'",
1222 )
1223 }
1224 },
1225 v => match v.as_i64() {
1226 Some(ms) => ms,
1227 None => {
1228 return error_response(
1229 StatusCode::BAD_REQUEST,
1230 "'older_than_ms' must be an integer",
1231 )
1232 }
1233 },
1234 };
1235 if cutoff > now {
1236 return error_response(StatusCode::BAD_REQUEST, "cutoff is in the future");
1237 }
1238 let bodies_only = body["bodies_only"].as_bool().unwrap_or(true);
1239 let dry_run = body["dry_run"].as_bool().unwrap_or(false);
1240 let store = state.store.clone();
1241 let report =
1242 tokio::task::spawn_blocking(move || store.prune(cutoff, bodies_only, dry_run)).await;
1243 match report {
1244 Ok(Ok(r)) => axum::Json(serde_json::to_value(r).unwrap_or_default()).into_response(),
1245 Ok(Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1246 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1247 }
1248}
1249
1250async fn admin_traces(
1251 State(state): State<Arc<AppState>>,
1252 Query(q): Query<HashMap<String, String>>,
1253) -> Response {
1254 let limit = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50);
1255 match state.store.list_traces(
1256 limit,
1257 q.get("session").map(|s| s.as_str()),
1258 q.get("model").map(|s| s.as_str()),
1259 ) {
1260 Ok(rows) => axum::Json(json!({"traces": rows})).into_response(),
1261 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1262 }
1263}
1264
1265fn filter_from_query(q: &HashMap<String, String>) -> TraceFilter {
1266 let now = now_ms();
1267 TraceFilter {
1268 since_ms: q.get("since").and_then(|s| parse_since(s, now)),
1269 until_ms: q.get("until").and_then(|s| parse_since(s, now)),
1270 run_id: q.get("run_id").cloned(),
1271 session: q.get("session").cloned(),
1272 model: q.get("model").cloned(),
1273 provider: q.get("provider").cloned(),
1274 path: q.get("path").cloned(),
1275 harness: q.get("harness").cloned(),
1276 status: q.get("status").and_then(|s| s.parse().ok()),
1277 errors_only: q
1278 .get("errors")
1279 .map(|v| v == "1" || v == "true")
1280 .unwrap_or(false),
1281 key_fingerprint: q.get("key_fingerprint").cloned(),
1282 reasoning_effort: q.get("effort").cloned(),
1283 limit: q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(200),
1284 }
1285}
1286
1287fn wants_bodies(q: &HashMap<String, String>) -> bool {
1288 q.get("bodies")
1289 .map(|v| v == "1" || v == "true")
1290 .unwrap_or(false)
1291}
1292
1293fn read_gz_file(path: &str) -> Option<Vec<u8>> {
1294 let file = std::fs::File::open(path).ok()?;
1295 let mut decoder = flate2::read::GzDecoder::new(file);
1296 let mut buf = Vec::new();
1297 std::io::Read::read_to_end(&mut decoder, &mut buf).ok()?;
1298 Some(buf)
1299}
1300
1301fn inline_row_bodies(row: &mut Value) {
1302 use base64::Engine;
1303 for (path_key, out_key) in [
1304 ("req_body_path", "req_body_b64"),
1305 ("upstream_req_body_path", "upstream_req_body_b64"),
1306 ("resp_body_path", "resp_body_b64"),
1307 ] {
1308 let Some(buf) = row[path_key].as_str().and_then(read_gz_file) else {
1309 continue;
1310 };
1311 row[out_key] = json!(base64::engine::general_purpose::STANDARD.encode(&buf));
1312 }
1313}
1314
1315fn ndjson_response(mut rows: Vec<Value>, inline_bodies: bool) -> Response {
1316 rows.sort_by_key(|r| r["ts_request_ms"].as_i64().unwrap_or(0));
1317 let mut out = String::new();
1318 for mut row in rows {
1319 if inline_bodies {
1320 inline_row_bodies(&mut row);
1321 }
1322 out.push_str(&serde_json::to_string(&row).unwrap_or_default());
1323 out.push('\n');
1324 }
1325 Response::builder()
1326 .status(StatusCode::OK)
1327 .header("content-type", "application/x-ndjson")
1328 .body(Body::from(out))
1329 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))
1330}
1331
1332const TEXT_SCAN_CAP: usize = 300;
1333
1334fn trace_matches_text(row: &Value, needle: &str) -> bool {
1335 for key in ["req_body_path", "resp_body_path"] {
1336 if let Some(path) = row.get(key).and_then(|v| v.as_str()) {
1337 if let Some(bytes) = read_gz_file(path) {
1338 if String::from_utf8_lossy(&bytes)
1339 .to_lowercase()
1340 .contains(needle)
1341 {
1342 return true;
1343 }
1344 }
1345 }
1346 }
1347 false
1348}
1349
1350async fn traces_search(
1351 State(state): State<Arc<AppState>>,
1352 Query(q): Query<HashMap<String, String>>,
1353) -> Response {
1354 let mut filter = filter_from_query(&q);
1355 let text = q
1356 .get("text")
1357 .or_else(|| q.get("q"))
1358 .map(|t| t.trim().to_lowercase())
1359 .filter(|t| !t.is_empty());
1360 if text.is_some() {
1361 filter.limit = TEXT_SCAN_CAP;
1362 }
1363 match state.store.search_traces(&filter) {
1364 Ok(rows) => match text {
1365 Some(needle) => {
1366 let scanned = rows.len();
1367 let rows = tokio::task::spawn_blocking(move || {
1368 rows.into_iter()
1369 .filter(|r| trace_matches_text(r, &needle))
1370 .collect::<Vec<_>>()
1371 })
1372 .await
1373 .unwrap_or_default();
1374 axum::Json(json!({"traces": rows, "scanned": scanned, "scan_cap": TEXT_SCAN_CAP}))
1375 .into_response()
1376 }
1377 None => axum::Json(json!({"traces": rows})).into_response(),
1378 },
1379 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1380 }
1381}
1382
1383async fn traces_export(
1384 State(state): State<Arc<AppState>>,
1385 Query(q): Query<HashMap<String, String>>,
1386) -> Response {
1387 match state.store.search_traces(&filter_from_query(&q)) {
1388 Ok(rows) => ndjson_response(rows, wants_bodies(&q)),
1389 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1390 }
1391}
1392
1393fn truncate_chars(s: String, max: usize) -> String {
1394 if s.chars().count() <= max {
1395 s
1396 } else {
1397 s.chars().take(max).collect()
1398 }
1399}
1400
1401fn read_gz_json(path: Option<&str>) -> Option<Value> {
1402 let buf = path.and_then(read_gz_file)?;
1403 serde_json::from_slice(&buf).ok()
1404}
1405
1406fn read_gz_text(path: Option<&str>) -> Option<String> {
1407 let buf = path.and_then(read_gz_file)?;
1408 Some(String::from_utf8_lossy(&buf).to_string())
1409}
1410
1411fn body_date_dir_name(name: &str) -> bool {
1412 let bytes = name.as_bytes();
1413 bytes.len() == 10
1414 && bytes[4] == b'-'
1415 && bytes[7] == b'-'
1416 && bytes
1417 .iter()
1418 .enumerate()
1419 .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
1420}
1421
1422fn dario_capture_suffix(kind: &str) -> Option<&'static str> {
1423 match kind {
1424 "dario-upstream-request" => Some("dario-upstream-request.json.gz"),
1425 "dario-upstream-response" => Some("dario-upstream-response.json.gz"),
1426 _ => None,
1427 }
1428}
1429
1430fn is_dario_trace(row: &Value) -> bool {
1431 row["account_id"]
1432 .as_str()
1433 .map(|id| id.starts_with("dario:"))
1434 .unwrap_or(false)
1435}
1436
1437fn find_dario_capture_path(state: &AppState, row: &Value, kind: &str) -> Option<String> {
1438 if !is_dario_trace(row) {
1439 return None;
1440 }
1441 let trace_id = row["id"].as_str()?;
1442 let suffix = dario_capture_suffix(kind)?;
1443 let filename = format!("{trace_id}.{suffix}");
1444 let bodies = state.store.data_dir.join("bodies");
1445 let mut days: Vec<PathBuf> = std::fs::read_dir(&bodies)
1446 .ok()?
1447 .flatten()
1448 .filter_map(|entry| {
1449 let name = entry.file_name().to_string_lossy().to_string();
1450 (entry.path().is_dir() && body_date_dir_name(&name)).then(|| entry.path())
1451 })
1452 .collect();
1453 days.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
1454 for day in days {
1455 let path = day.join(&filename);
1456 if path.is_file() {
1457 return Some(path.to_string_lossy().to_string());
1458 }
1459 }
1460 None
1461}
1462
1463fn dario_capture_summary(state: &AppState, row: &Value) -> Option<Value> {
1464 let request_path = find_dario_capture_path(state, row, "dario-upstream-request");
1465 let response_path = find_dario_capture_path(state, row, "dario-upstream-response");
1466 if request_path.is_none() && response_path.is_none() {
1467 return None;
1468 }
1469 let prompt_cache = request_path
1470 .as_deref()
1471 .and_then(|path| read_gz_json(Some(path)))
1472 .and_then(|body| {
1473 body["prompt_cache"]
1474 .as_object()
1475 .map(|_| body["prompt_cache"].clone())
1476 });
1477 Some(json!({
1478 "request_available": request_path.is_some(),
1479 "response_available": response_path.is_some(),
1480 "request_path": request_path,
1481 "response_path": response_path,
1482 "prompt_cache": prompt_cache,
1483 }))
1484}
1485
1486async fn traces_sessions(
1487 State(state): State<Arc<AppState>>,
1488 Query(q): Query<HashMap<String, String>>,
1489) -> Response {
1490 let since = q.get("since").and_then(|s| parse_since(s, now_ms()));
1491 let limit = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(0);
1492 match state.store.sessions(since, limit) {
1493 Ok(rows) => axum::Json(json!({"sessions": rows})).into_response(),
1494 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1495 }
1496}
1497
1498fn transcript_assistant_blocks(resp_text: &str) -> Vec<Value> {
1499 let Ok(response) = serde_json::from_str::<Value>(resp_text) else {
1500 return Vec::new();
1501 };
1502 let mut tool_calls = 0usize;
1503 response
1504 .pointer("/_alexandria/assistant_blocks")
1505 .and_then(Value::as_array)
1506 .into_iter()
1507 .flatten()
1508 .filter_map(|block| match block["type"].as_str() {
1509 Some("text") => block["text"].as_str().map(
1510 |text| json!({"type": "text", "text": truncate_chars(text.to_string(), 8000)}),
1511 ),
1512 Some("tool_call") if tool_calls < 24 => {
1513 let name = block["name"].as_str()?;
1514 tool_calls += 1;
1515 Some(json!({
1516 "type": "tool_call",
1517 "name": name,
1518 "arguments": truncate_chars(
1519 block["arguments"].as_str().unwrap_or_default().to_string(),
1520 600,
1521 ),
1522 }))
1523 }
1524 _ => None,
1525 })
1526 .take(64)
1527 .collect()
1528}
1529
1530fn transcript_turn(row: &Value) -> Value {
1531 use alex_core::translate;
1532 let user = read_gz_json(row["req_body_path"].as_str())
1533 .and_then(|req| {
1534 translate::last_user_text(row["client_format"].as_str().unwrap_or(""), &req)
1535 })
1536 .map(|s| truncate_chars(s, 8000));
1537 let resp_text = read_gz_text(row["resp_body_path"].as_str());
1538 let fmt = row["upstream_format"]
1539 .as_str()
1540 .or(row["client_format"].as_str())
1541 .unwrap_or("")
1542 .to_string();
1543 let assistant = resp_text
1544 .as_deref()
1545 .and_then(|text| translate::assistant_reply_text(&fmt, text))
1546 .map(|s| truncate_chars(s, 8000));
1547 let tool_calls: Vec<Value> = resp_text
1548 .as_deref()
1549 .map(|text| translate::assistant_tool_calls(&fmt, text))
1550 .unwrap_or_default()
1551 .into_iter()
1552 .take(24)
1553 .map(|mut c| {
1554 if let Some(a) = c["arguments"].as_str() {
1555 let t = truncate_chars(a.to_string(), 600);
1556 c["arguments"] = json!(t);
1557 }
1558 c
1559 })
1560 .collect();
1561 let assistant_blocks = resp_text
1562 .as_deref()
1563 .map(transcript_assistant_blocks)
1564 .unwrap_or_default();
1565 json!({
1566 "trace_id": row["id"],
1567 "ts_request_ms": row["ts_request_ms"],
1568 "ts_response_ms": row["ts_response_ms"],
1569 "model": row["routed_model"],
1570 "provider": row["upstream_provider"],
1571 "status": row["status"],
1572 "input_tokens": row["input_tokens"],
1573 "output_tokens": row["output_tokens"],
1574 "reasoning_effort": row["reasoning_effort"],
1575 "thinking_budget": row["thinking_budget"],
1576 "cost_usd": row["cost_usd"],
1577 "account_id": row["account_id"],
1578 "error": row["error"],
1579 "user": user,
1580 "assistant": assistant,
1581 "tool_calls": tool_calls,
1582 "assistant_blocks": assistant_blocks,
1583 })
1584}
1585
1586async fn traces_session_transcript(
1587 State(state): State<Arc<AppState>>,
1588 Path(session_id): Path<String>,
1589 Query(q): Query<HashMap<String, String>>,
1590) -> Response {
1591 let since = q
1592 .get("since_ms")
1593 .and_then(|s| s.parse::<i64>().ok())
1594 .or_else(|| q.get("since").and_then(|s| parse_since(s, now_ms())));
1595 let limit: usize = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(500);
1596 let rows = match state.store.session_traces(&session_id, since) {
1597 Ok(rows) => rows,
1598 Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1599 };
1600 let turns: Vec<Value> = rows.iter().take(limit).map(transcript_turn).collect();
1601 axum::Json(json!({"session_id": session_id, "turns": turns})).into_response()
1602}
1603
1604fn trace_reasoning_fields(req: &Value) -> (Option<String>, Option<i64>) {
1605 (
1606 req["reasoning"]["effort"]
1607 .as_str()
1608 .or_else(|| req["output_config"]["effort"].as_str())
1609 .map(String::from),
1610 req["thinking"]["budget_tokens"].as_i64(),
1611 )
1612}
1613
1614fn trace_extras(req: &Value) -> Value {
1615 let system_text: Option<String> = match &req["system"] {
1616 Value::String(s) => Some(s.clone()),
1617 Value::Array(parts) => {
1618 let joined: Vec<&str> = parts.iter().filter_map(|p| p["text"].as_str()).collect();
1619 (!joined.is_empty()).then(|| joined.join("\n\n"))
1620 }
1621 _ => req["instructions"].as_str().map(String::from).or_else(|| {
1622 let joined: Vec<String> = req["messages"]
1623 .as_array()
1624 .into_iter()
1625 .flatten()
1626 .filter(|m| matches!(m["role"].as_str(), Some("system") | Some("developer")))
1627 .map(|m| match &m["content"] {
1628 Value::String(s) => s.clone(),
1629 Value::Array(parts) => parts
1630 .iter()
1631 .filter_map(|p| p["text"].as_str())
1632 .collect::<Vec<_>>()
1633 .join("\n"),
1634 _ => String::new(),
1635 })
1636 .filter(|s| !s.is_empty())
1637 .collect();
1638 (!joined.is_empty()).then(|| joined.join("\n\n"))
1639 }),
1640 };
1641 let system_chars = system_text.as_ref().map(|s| s.chars().count());
1642 let system_prompt = system_text.map(|s| truncate_chars(s, 64_000));
1643 let (reasoning_effort, thinking_budget) = trace_reasoning_fields(req);
1644 json!({
1645 "reasoning_effort": reasoning_effort,
1646 "thinking_budget": thinking_budget,
1647 "max_tokens": req["max_tokens"].as_i64().or(req["max_output_tokens"].as_i64()),
1648 "temperature": req["temperature"],
1649 "message_count": req["messages"].as_array().or(req["input"].as_array()).map(|a| a.len()),
1650 "system_chars": system_chars,
1651 "system_prompt": system_prompt,
1652 })
1653}
1654
1655async fn trace_get(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
1656 match state.store.get_trace(&id) {
1657 Ok(Some(row)) => {
1658 let mut extras = read_gz_json(row["req_body_path"].as_str())
1659 .map(|req| trace_extras(&req))
1660 .unwrap_or_else(|| json!({}));
1661 if let Some(summary) = dario_capture_summary(&state, &row) {
1662 if !extras.is_object() {
1663 extras = json!({});
1664 }
1665 if let Some(obj) = extras.as_object_mut() {
1666 obj.insert("dario_capture".into(), summary);
1667 }
1668 }
1669 axum::Json(json!({"trace": row, "extras": extras})).into_response()
1670 }
1671 Ok(None) => error_response(StatusCode::NOT_FOUND, &format!("unknown trace '{id}'")),
1672 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1673 }
1674}
1675
1676async fn trace_body(
1677 State(state): State<Arc<AppState>>,
1678 Path((id, kind)): Path<(String, String)>,
1679) -> Response {
1680 let row = match state.store.get_trace(&id) {
1681 Ok(Some(row)) => row,
1682 Ok(None) => return error_response(StatusCode::NOT_FOUND, &format!("unknown trace '{id}'")),
1683 Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1684 };
1685 let path = match kind.as_str() {
1686 "request" => row["req_body_path"].as_str().map(String::from),
1687 "upstream-request" => row["upstream_req_body_path"].as_str().map(String::from),
1688 "response" => row["resp_body_path"].as_str().map(String::from),
1689 "dario-upstream-request" | "dario-upstream-response" => {
1690 find_dario_capture_path(&state, &row, &kind)
1691 }
1692 _ => {
1693 return error_response(
1694 StatusCode::BAD_REQUEST,
1695 "kind must be request|upstream-request|response|dario-upstream-request|dario-upstream-response",
1696 )
1697 }
1698 };
1699 match read_gz_text(path.as_deref()) {
1700 Some(text) => {
1701 let ct = if text.trim_start().starts_with('{') || text.trim_start().starts_with('[') {
1702 "application/json; charset=utf-8"
1703 } else {
1704 "text/plain; charset=utf-8"
1705 };
1706 Response::builder()
1707 .status(StatusCode::OK)
1708 .header("content-type", ct)
1709 .header("x-alexandria-body-path", path.as_deref().unwrap_or(""))
1710 .body(Body::from(text))
1711 .unwrap_or_else(|e| {
1712 error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
1713 })
1714 }
1715 None => error_response(
1716 StatusCode::NOT_FOUND,
1717 &format!("no {kind} body stored for trace '{id}'"),
1718 ),
1719 }
1720}
1721
1722async fn trace_reply_md(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
1723 use alex_core::translate;
1724 let row = match state.store.get_trace(&id) {
1725 Ok(Some(row)) => row,
1726 Ok(None) => return error_response(StatusCode::NOT_FOUND, &format!("unknown trace '{id}'")),
1727 Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1728 };
1729 let fmt = row["upstream_format"]
1730 .as_str()
1731 .or(row["client_format"].as_str())
1732 .unwrap_or("");
1733 let reply = read_gz_text(row["resp_body_path"].as_str())
1734 .and_then(|text| translate::assistant_reply_text(fmt, &text));
1735 match reply {
1736 Some(md) => Response::builder()
1737 .status(StatusCode::OK)
1738 .header("content-type", "text/markdown; charset=utf-8")
1739 .body(Body::from(md))
1740 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())),
1741 None => error_response(
1742 StatusCode::NOT_FOUND,
1743 &format!("no assistant reply available for trace '{id}'"),
1744 ),
1745 }
1746}
1747
1748async fn trace_delete(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
1749 match state.store.get_trace(&id) {
1750 Ok(Some(_)) => {}
1751 Ok(None) => return error_response(StatusCode::NOT_FOUND, &format!("unknown trace '{id}'")),
1752 Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1753 }
1754 match state.store.delete_trace(&id) {
1755 Ok(paths) => {
1756 let removed = paths
1757 .iter()
1758 .filter(|p| std::fs::remove_file(p).is_ok())
1759 .count();
1760 axum::Json(json!({"deleted": true, "files_removed": removed})).into_response()
1761 }
1762 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1763 }
1764}
1765
1766async fn traces_run_summary(
1767 State(state): State<Arc<AppState>>,
1768 Path(run_id): Path<String>,
1769) -> Response {
1770 match state.store.run_summary(&run_id) {
1771 Ok(v) if v["trace_count"].as_i64().unwrap_or(0) == 0 => error_response(
1772 StatusCode::NOT_FOUND,
1773 &format!("no traces for run '{run_id}'"),
1774 ),
1775 Ok(v) => axum::Json(v).into_response(),
1776 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1777 }
1778}
1779
1780async fn traces_run_events(
1781 State(state): State<Arc<AppState>>,
1782 Path(run_id): Path<String>,
1783 Query(q): Query<HashMap<String, String>>,
1784) -> Response {
1785 let filter = TraceFilter {
1786 run_id: Some(run_id),
1787 limit: q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(1000),
1788 ..Default::default()
1789 };
1790 match state.store.search_traces(&filter) {
1791 Ok(rows) => axum::Json(json!({"traces": rows})).into_response(),
1792 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1793 }
1794}
1795
1796async fn traces_run_export(
1797 State(state): State<Arc<AppState>>,
1798 Path(run_id): Path<String>,
1799 Query(q): Query<HashMap<String, String>>,
1800) -> Response {
1801 let filter = TraceFilter {
1802 run_id: Some(run_id),
1803 limit: q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(5000),
1804 ..Default::default()
1805 };
1806 match state.store.search_traces(&filter) {
1807 Ok(rows) => ndjson_response(rows, wants_bodies(&q)),
1808 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1809 }
1810}
1811
1812async fn traces_run_artifacts(
1813 State(state): State<Arc<AppState>>,
1814 Path(run_id): Path<String>,
1815) -> Response {
1816 match state.store.run_artifacts(&run_id) {
1817 Ok(artifacts) => axum::Json(json!({
1818 "run_id": run_id,
1819 "data_dir": state.store.data_dir.to_string_lossy(),
1820 "artifacts": artifacts,
1821 }))
1822 .into_response(),
1823 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
1824 }
1825}
1826
1827async fn admin_accounts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
1828 let openai_policy = state.vault.policy(Provider::Openai);
1829 let accounts: Vec<Value> = state
1830 .vault
1831 .list()
1832 .await
1833 .into_iter()
1834 .map(|a| {
1835 let email = a.email();
1836 let routing = if a.provider == Provider::Openai {
1837 let reserve_pct = codex_reserve_pct(&a, &openai_policy);
1838 json!({
1839 "eligible": !openai_policy.disabled.iter().any(|name| name == &a.name || name == &a.id),
1840 "priority": openai_policy.order.iter().position(|name| name == &a.name),
1841 "reserve_pct": reserve_pct,
1842 "reserve_blocked": codex_reserve_blocked(&a, reserve_pct, now_ms() / 1000),
1843 "reset_selection": codex_reset_selection(&a, now_ms() / 1000),
1844 })
1845 } else {
1846 Value::Null
1847 };
1848 let limits = if a.provider == Provider::Openai {
1849 a.account_meta
1850 .get("codex_limits")
1851 .cloned()
1852 .unwrap_or(Value::Null)
1853 } else {
1854 Value::Null
1855 };
1856 json!({
1857 "id": a.id,
1858 "provider": a.provider.as_str(),
1859 "name": a.name,
1860 "kind": a.kind,
1861 "label": a.label,
1862 "description": a.description,
1863 "email": email,
1864 "paused": a.paused,
1865 "path": a.path.map(|p| p.display().to_string()),
1866 "status": a.status,
1867 "expires_at_ms": a.expires_at_ms,
1868 "expires_in_s": a.expires_at_ms.map(|e| (e - now_ms()) / 1000),
1869 "routing": routing,
1870 "limits": limits,
1871 })
1872 })
1873 .collect();
1874 axum::Json(json!({"accounts": accounts}))
1875}
1876
1877fn codex_strategy_name(mode: &AccountPolicyMode) -> &'static str {
1878 match mode {
1879 AccountPolicyMode::ResetFirst => "reset_first",
1880 AccountPolicyMode::RoundRobin => "round_robin",
1881 AccountPolicyMode::Priority | AccountPolicyMode::Threshold => "priority",
1882 }
1883}
1884
1885async fn codex_routing_snapshot(state: &Arc<AppState>) -> Value {
1886 let policy = state.vault.policy(Provider::Openai);
1887 let mut accounts: Vec<Account> = state
1888 .vault
1889 .list()
1890 .await
1891 .into_iter()
1892 .filter(|account| account.provider == Provider::Openai)
1893 .collect();
1894 accounts.sort_by_key(|account| {
1895 (
1896 policy
1897 .order
1898 .iter()
1899 .position(|name| name == &account.name)
1900 .unwrap_or(usize::MAX / 2),
1901 account.name.clone(),
1902 account.id.clone(),
1903 )
1904 });
1905 let accounts: Vec<Value> = accounts
1906 .into_iter()
1907 .enumerate()
1908 .map(|(fallback_priority, account)| {
1909 let eligible = !policy
1910 .disabled
1911 .iter()
1912 .any(|name| name == &account.name || name == &account.id);
1913 let priority = policy
1914 .order
1915 .iter()
1916 .position(|name| name == &account.name)
1917 .unwrap_or(fallback_priority);
1918 let limits = account
1919 .account_meta
1920 .get("codex_limits")
1921 .cloned()
1922 .unwrap_or_else(|| json!({}));
1923 let reserve_pct = codex_reserve_pct(&account, &policy);
1924 let reset_selection = codex_reset_selection(&account, now_ms() / 1000);
1925 json!({
1926 "account_id": account.id,
1927 "eligible": eligible,
1928 "priority": priority,
1929 "reserve_pct": reserve_pct,
1930 "reserve_blocked": codex_reserve_blocked(&account, reserve_pct, now_ms() / 1000),
1931 "reset_selection": reset_selection,
1932 "observed_at_ms": limits.get("observed_at_ms"),
1933 "plan": limits.get("plan"),
1934 "active_limit": limits.get("active_limit"),
1935 "windows": limits.get("windows").cloned().unwrap_or_else(|| json!([])),
1936 "credits": limits.get("credits").cloned().unwrap_or(Value::Null),
1937 })
1938 })
1939 .collect();
1940 json!({
1941 "provider": "openai",
1942 "strategy": codex_strategy_name(&policy.mode),
1943 "reserve_pct": policy.reserve_pct.unwrap_or(10).min(100),
1944 "allow_mid_thread_failover": policy.allow_mid_thread_failover,
1945 "reset_selection_rule": "highest_used_pct_then_earliest_reset",
1946 "accounts": accounts,
1947 })
1948}
1949
1950async fn admin_codex_routing(State(state): State<Arc<AppState>>) -> Response {
1951 axum::Json(codex_routing_snapshot(&state).await).into_response()
1952}
1953
1954async fn admin_codex_routing_update(
1955 State(state): State<Arc<AppState>>,
1956 body: axum::Json<Value>,
1957) -> Response {
1958 let current_policy = state.vault.policy(Provider::Openai);
1959 let mode = match body.0.get("strategy").and_then(Value::as_str) {
1960 Some("reset_first") => AccountPolicyMode::ResetFirst,
1961 Some("priority") => AccountPolicyMode::Priority,
1962 Some("round_robin") => AccountPolicyMode::RoundRobin,
1963 _ => {
1964 return error_response(
1965 StatusCode::BAD_REQUEST,
1966 "strategy must be reset_first, priority, or round_robin",
1967 )
1968 }
1969 };
1970 let reserve_pct = match body.0.get("reserve_pct") {
1971 Some(value) => match value.as_u64() {
1972 Some(value) => value,
1973 None => return error_response(StatusCode::BAD_REQUEST, "reserve_pct must be an integer"),
1974 },
1975 None => current_policy.reserve_pct.unwrap_or(10) as u64,
1976 };
1977 if reserve_pct > 100 {
1978 return error_response(StatusCode::BAD_REQUEST, "reserve_pct must be between 0 and 100");
1979 }
1980 let allow_mid_thread_failover = match body.0.get("allow_mid_thread_failover") {
1981 Some(value) => match value.as_bool() {
1982 Some(value) => value,
1983 None => return error_response(
1984 StatusCode::BAD_REQUEST,
1985 "allow_mid_thread_failover must be boolean",
1986 ),
1987 },
1988 None => current_policy.allow_mid_thread_failover,
1989 };
1990 let Some(requested) = body.0.get("accounts").and_then(Value::as_array) else {
1991 return error_response(StatusCode::BAD_REQUEST, "accounts must be an array");
1992 };
1993 let openai_accounts: Vec<Account> = state
1994 .vault
1995 .list()
1996 .await
1997 .into_iter()
1998 .filter(|account| account.provider == Provider::Openai)
1999 .collect();
2000 let by_id: HashMap<String, String> = openai_accounts
2001 .iter()
2002 .map(|account| (account.id.clone(), account.name.clone()))
2003 .collect();
2004 let mut seen = std::collections::HashSet::new();
2005 let mut ordered = Vec::new();
2006 for item in requested {
2007 let Some(account_id) = item.get("account_id").and_then(Value::as_str) else {
2008 return error_response(StatusCode::BAD_REQUEST, "each account needs account_id");
2009 };
2010 let Some(eligible) = item.get("eligible").and_then(Value::as_bool) else {
2011 return error_response(StatusCode::BAD_REQUEST, "each account needs boolean eligible");
2012 };
2013 let Some(priority) = item.get("priority").and_then(Value::as_u64) else {
2014 return error_response(StatusCode::BAD_REQUEST, "each account needs integer priority");
2015 };
2016 let account_reserve_pct = match item.get("reserve_pct") {
2017 Some(value) => match value.as_u64() {
2018 Some(value) if value <= 100 => value as u8,
2019 Some(_) => return error_response(
2020 StatusCode::BAD_REQUEST,
2021 "account reserve_pct must be between 0 and 100",
2022 ),
2023 None => return error_response(
2024 StatusCode::BAD_REQUEST,
2025 "account reserve_pct must be an integer",
2026 ),
2027 },
2028 None => reserve_pct as u8,
2029 };
2030 let Some(name) = by_id.get(account_id) else {
2031 return error_response(
2032 StatusCode::BAD_REQUEST,
2033 &format!("unknown OpenAI account '{account_id}'"),
2034 );
2035 };
2036 if !seen.insert(account_id.to_string()) {
2037 return error_response(
2038 StatusCode::BAD_REQUEST,
2039 &format!("duplicate OpenAI account '{account_id}'"),
2040 );
2041 }
2042 ordered.push((priority, name.clone(), eligible, account_reserve_pct));
2043 }
2044 if seen.len() != by_id.len() {
2045 return error_response(
2046 StatusCode::CONFLICT,
2047 "account list changed; refresh Settings and save again",
2048 );
2049 }
2050 ordered.sort_by_key(|(priority, name, _, _)| (*priority, name.clone()));
2051 let policy = AccountPolicy {
2052 order: ordered.iter().map(|(_, name, _, _)| name.clone()).collect(),
2053 mode,
2054 threshold_pct: None,
2055 reserve_pct: Some(reserve_pct as u8),
2056 account_reserve_pct: ordered
2057 .iter()
2058 .map(|(_, name, _, reserve_pct)| (name.clone(), *reserve_pct))
2059 .collect(),
2060 allow_mid_thread_failover,
2061 disabled: ordered
2062 .iter()
2063 .filter(|(_, _, eligible, _)| !*eligible)
2064 .map(|(_, name, _, _)| name.clone())
2065 .collect(),
2066 };
2067 match state
2068 .vault
2069 .set_policy_persisted(Provider::Openai, policy)
2070 .await
2071 {
2072 Ok(()) => axum::Json(codex_routing_snapshot(&state).await).into_response(),
2073 Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
2074 }
2075}
2076
2077async fn admin_health(State(state): State<Arc<AppState>>) -> Response {
2078 let heartbeats = state.store.last_heartbeats().unwrap_or_default();
2079 let accounts: Vec<Value> = state
2080 .vault
2081 .list()
2082 .await
2083 .into_iter()
2084 .map(|a| {
2085 let last = heartbeats
2086 .iter()
2087 .find(|h| h["provider"].as_str() == Some(a.provider.as_str()))
2088 .cloned();
2089 json!({
2090 "id": a.id,
2091 "provider": a.provider.as_str(),
2092 "name": a.name,
2093 "kind": a.kind,
2094 "status": a.status,
2095 "paused": a.paused,
2096 "path": a.path.map(|p| p.display().to_string()),
2097 "token_expires_in_s": a.expires_at_ms.map(|e| (e - now_ms()) / 1000),
2098 "last_heartbeat": last,
2099 })
2100 })
2101 .collect();
2102 axum::Json(json!({"accounts": accounts})).into_response()
2103}
2104
2105#[derive(Debug, Clone, serde::Serialize)]
2106pub struct PingResult {
2107 pub provider: &'static str,
2108 pub account_id: Option<String>,
2109 pub ok: bool,
2110 pub status: Option<u16>,
2111 pub latency_ms: i64,
2112 pub message: String,
2113}
2114
2115#[derive(Debug, Clone)]
2116pub struct PingModels {
2117 pub anthropic: String,
2118 pub openai: String,
2119 pub xai: String,
2120 pub gemini: String,
2121}
2122
2123pub async fn ping_provider(
2124 state: &Arc<AppState>,
2125 provider: Provider,
2126 models: &PingModels,
2127) -> PingResult {
2128 let start = now_ms();
2129 let prompt = "Health check: what time is it? If you cannot know, just reply: creds ok";
2130 let (format, path, body) = match provider {
2131 Provider::Anthropic => (
2132 ClientFormat::AnthropicMessages,
2133 "/v1/messages",
2134 json!({
2135 "model": models.anthropic,
2136 "max_tokens": 64,
2137 "system": "You are Claude Code, Anthropic's official CLI for Claude.",
2138 "messages": [{"role": "user", "content": prompt}],
2139 }),
2140 ),
2141 Provider::Openai => (
2142 ClientFormat::OpenaiResponses,
2143 "/v1/responses",
2144 json!({
2145 "model": models.openai,
2146 "stream": true,
2147 "store": false,
2148 "instructions": "You are a helpful assistant.",
2149 "input": [{"type": "message", "role": "user",
2150 "content": [{"type": "input_text", "text": prompt}]}],
2151 }),
2152 ),
2153 Provider::Xai => (
2154 ClientFormat::OpenaiChat,
2155 "/v1/chat/completions",
2156 json!({
2157 "model": models.xai,
2158 "stream": false,
2159 "messages": [{"role": "user", "content": prompt}],
2160 }),
2161 ),
2162 Provider::Gemini => (
2163 ClientFormat::GeminiGenerate,
2164 "/v1beta/models/:generateContent",
2165 json!({
2166 "model": models.gemini,
2167 "contents": [{"role": "user", "parts": [{"text": prompt}]}],
2168 "generationConfig": {"maxOutputTokens": 64},
2169 }),
2170 ),
2171 Provider::Amp => {
2172 let account_id = state
2173 .vault
2174 .account_for(Provider::Amp, false)
2175 .await
2176 .ok()
2177 .map(|a| a.id);
2178 let entry = amp_usage_entry(state).await;
2179 let ok = entry
2180 .as_ref()
2181 .map(|e| e.get("error").is_none())
2182 .unwrap_or(false);
2183 let message = entry
2184 .as_ref()
2185 .and_then(|e| {
2186 e.get("display_text")
2187 .and_then(|v| v.as_str())
2188 .map(|s| s.lines().next().unwrap_or(s).to_string())
2189 .or_else(|| e.get("error").and_then(|v| v.as_str()).map(String::from))
2190 })
2191 .unwrap_or_else(|| "no amp credentials".into());
2192 return PingResult {
2193 provider: "amp",
2194 account_id,
2195 ok,
2196 status: if ok { Some(200) } else { None },
2197 latency_ms: now_ms() - start,
2198 message,
2199 };
2200 }
2201 };
2202 let account_id = state
2203 .vault
2204 .account_for(provider, true)
2205 .await
2206 .ok()
2207 .map(|a| a.id);
2208 let mut headers = HeaderMap::new();
2209 headers.insert(
2210 "x-api-key",
2211 HeaderValue::from_str(&state.local_key).expect("key header"),
2212 );
2213 headers.insert("user-agent", HeaderValue::from_static("alexandria-ping"));
2214 let resp = proxy(
2215 state.clone(),
2216 format,
2217 path,
2218 headers,
2219 Bytes::from(serde_json::to_vec(&body).expect("ping body")),
2220 None,
2221 )
2222 .await;
2223 let status = resp.status().as_u16();
2224 let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
2225 .await
2226 .unwrap_or_default();
2227 let text = String::from_utf8_lossy(&bytes);
2228 let message = extract_reply(&text).unwrap_or_else(|| snippet(&text));
2229 PingResult {
2230 provider: provider.as_str(),
2231 account_id,
2232 ok: (200..300).contains(&status),
2233 status: Some(status),
2234 latency_ms: now_ms() - start,
2235 message,
2236 }
2237}
2238
2239fn extract_reply(text: &str) -> Option<String> {
2240 if let Ok(v) = serde_json::from_str::<Value>(text) {
2241 if let Some(t) = v["content"][0]["text"].as_str() {
2242 return Some(t.to_string());
2243 }
2244 if let Some(t) = v["choices"][0]["message"]["content"].as_str() {
2245 return Some(t.to_string());
2246 }
2247 if let Some(t) = v["error"]["message"].as_str() {
2248 return Some(t.to_string());
2249 }
2250 if let Some(t) = v["detail"].as_str() {
2251 return Some(t.to_string());
2252 }
2253 }
2254 for line in text.lines() {
2255 let Some(data) = line.strip_prefix("data:") else {
2256 continue;
2257 };
2258 let Ok(v) = serde_json::from_str::<Value>(data.trim()) else {
2259 continue;
2260 };
2261 if v["type"].as_str() == Some("response.output_text.done") {
2262 if let Some(t) = v["text"].as_str() {
2263 return Some(t.to_string());
2264 }
2265 }
2266 }
2267 None
2268}
2269
2270fn snippet(text: &str) -> String {
2271 let t: String = text.chars().take(200).collect();
2272 t.replace('\n', " ")
2273}
2274
2275pub async fn heartbeat_once(state: &Arc<AppState>, models: &PingModels) -> Vec<PingResult> {
2276 let providers: Vec<Provider> = {
2277 let mut seen = Vec::new();
2278 for a in state.vault.list().await {
2279 if a.status == "active"
2280 && matches!(
2281 a.provider,
2282 Provider::Anthropic
2283 | Provider::Openai
2284 | Provider::Xai
2285 | Provider::Gemini
2286 | Provider::Amp
2287 )
2288 && !seen.contains(&a.provider)
2289 {
2290 seen.push(a.provider);
2291 }
2292 }
2293 seen
2294 };
2295 let mut results = Vec::new();
2296 for provider in providers {
2297 let r = ping_provider(state, provider, models).await;
2298 if let Err(e) = state.store.insert_heartbeat(
2299 now_ms(),
2300 r.provider,
2301 r.account_id.as_deref(),
2302 r.ok,
2303 r.status.map(|s| s as i64),
2304 r.latency_ms,
2305 &r.message,
2306 ) {
2307 tracing::warn!("failed to record heartbeat: {e}");
2308 }
2309 tracing::info!(
2310 provider = r.provider,
2311 ok = r.ok,
2312 status = r.status,
2313 latency_ms = r.latency_ms,
2314 reply = %r.message,
2315 "heartbeat"
2316 );
2317 results.push(r);
2318 }
2319 results
2320}
2321
2322async fn anthropic_messages(
2323 State(state): State<Arc<AppState>>,
2324 ConnectInfo(peer): ConnectInfo<std::net::SocketAddr>,
2325 headers: HeaderMap,
2326 body: Bytes,
2327) -> Response {
2328 proxy(
2329 state,
2330 ClientFormat::AnthropicMessages,
2331 "/v1/messages",
2332 headers,
2333 body,
2334 Some(peer),
2335 )
2336 .await
2337}
2338
2339async fn openai_chat(
2340 State(state): State<Arc<AppState>>,
2341 ConnectInfo(peer): ConnectInfo<std::net::SocketAddr>,
2342 headers: HeaderMap,
2343 body: Bytes,
2344) -> Response {
2345 proxy(
2346 state,
2347 ClientFormat::OpenaiChat,
2348 "/v1/chat/completions",
2349 headers,
2350 body,
2351 Some(peer),
2352 )
2353 .await
2354}
2355
2356async fn openai_responses(
2357 State(state): State<Arc<AppState>>,
2358 ConnectInfo(peer): ConnectInfo<std::net::SocketAddr>,
2359 headers: HeaderMap,
2360 body: Bytes,
2361) -> Response {
2362 proxy(
2363 state,
2364 ClientFormat::OpenaiResponses,
2365 "/v1/responses",
2366 headers,
2367 body,
2368 Some(peer),
2369 )
2370 .await
2371}
2372
2373async fn gemini_generate(
2374 State(state): State<Arc<AppState>>,
2375 ConnectInfo(peer): ConnectInfo<std::net::SocketAddr>,
2376 axum::extract::Path(model_action): axum::extract::Path<String>,
2377 headers: HeaderMap,
2378 body: Bytes,
2379) -> Response {
2380 let (model, method) = model_action
2381 .split_once(':')
2382 .unwrap_or((model_action.as_str(), "generateContent"));
2383 let stream = method == "streamGenerateContent";
2384 if method != "generateContent" && !stream {
2385 return error_response(
2386 StatusCode::NOT_FOUND,
2387 &format!("unsupported gemini method '{method}' (expected generateContent or streamGenerateContent)"),
2388 );
2389 }
2390 if model.is_empty() {
2391 return error_response(StatusCode::BAD_REQUEST, "missing model in path");
2392 }
2393 let mut v: Value = match serde_json::from_slice(&body) {
2394 Ok(v) => v,
2395 Err(e) => {
2396 return error_response(StatusCode::BAD_REQUEST, &format!("invalid JSON body: {e}"))
2397 }
2398 };
2399 v["model"] = json!(model);
2400 if stream {
2401 v["stream"] = json!(true);
2402 }
2403 let body = Bytes::from(serde_json::to_vec(&v).unwrap_or_default());
2404 let path = if stream {
2405 "/v1beta/models/:streamGenerateContent"
2406 } else {
2407 "/v1beta/models/:generateContent"
2408 };
2409 proxy(
2410 state,
2411 ClientFormat::GeminiGenerate,
2412 path,
2413 headers,
2414 body,
2415 Some(peer),
2416 )
2417 .await
2418}
2419
2420fn error_response(status: StatusCode, message: &str) -> Response {
2421 (
2422 status,
2423 axum::Json(json!({"error": {"type": "alexandria", "message": message}})),
2424 )
2425 .into_response()
2426}
2427
2428fn client_key(headers: &HeaderMap) -> Option<String> {
2429 if let Some(v) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
2430 return Some(v.to_string());
2431 }
2432 if let Some(v) = headers.get("x-goog-api-key").and_then(|v| v.to_str().ok()) {
2433 return Some(v.to_string());
2434 }
2435 headers
2436 .get("authorization")
2437 .and_then(|v| v.to_str().ok())
2438 .and_then(|v| v.strip_prefix("Bearer "))
2439 .map(String::from)
2440}
2441
2442const CLAUDE_CODE_PASSTHROUGH_HEADERS: &[&str] = &[
2443 "accept",
2444 "x-app",
2445 "x-claude-code-session-id",
2446 "x-stainless-arch",
2447 "x-stainless-lang",
2448 "x-stainless-os",
2449 "x-stainless-package-version",
2450 "x-stainless-retry-count",
2451 "x-stainless-runtime",
2452 "x-stainless-runtime-version",
2453 "x-stainless-timeout",
2454 "anthropic-dangerous-direct-browser-access",
2455];
2456
2457fn is_genuine_claude_code_request(
2458 format: ClientFormat,
2459 headers: &HeaderMap,
2460 body: &Value,
2461) -> bool {
2462 if format != ClientFormat::AnthropicMessages {
2463 return false;
2464 }
2465
2466 for harness in headers.get_all("x-alexandria-harness").iter() {
2467 let Ok(harness) = harness.to_str() else {
2468 return false;
2469 };
2470 let harness = harness.trim().to_ascii_lowercase();
2471 if !matches!(harness.as_str(), "claude" | "claude-code") {
2472 return false;
2473 }
2474 }
2475
2476 let claude_user_agent = headers
2477 .get("user-agent")
2478 .and_then(|value| value.to_str().ok())
2479 .is_some_and(|value| value.to_ascii_lowercase().starts_with("claude-cli/"));
2480 let cli_app = headers
2481 .get("x-app")
2482 .and_then(|value| value.to_str().ok())
2483 .is_some_and(|value| value.eq_ignore_ascii_case("cli"));
2484 let has_session = headers
2485 .get("x-claude-code-session-id")
2486 .and_then(|value| value.to_str().ok())
2487 .is_some_and(|value| !value.trim().is_empty());
2488 let has_billing_header = body["system"]
2489 .as_array()
2490 .and_then(|blocks| blocks.first())
2491 .and_then(|block| block["text"].as_str())
2492 .is_some_and(|text| text.starts_with("x-anthropic-billing-header:"));
2493
2494 claude_user_agent && cli_app && has_session && has_billing_header
2495}
2496
2497fn redacted_headers(headers: &HeaderMap) -> String {
2498 let map: HashMap<String, String> = headers
2499 .iter()
2500 .map(|(k, v)| {
2501 let key = k.as_str().to_lowercase();
2502 let val = if ["authorization", "x-api-key", "cookie", "chatgpt-account-id"]
2503 .contains(&key.as_str())
2504 {
2505 "<redacted>".to_string()
2506 } else {
2507 v.to_str().unwrap_or("<binary>").to_string()
2508 };
2509 (key, val)
2510 })
2511 .collect();
2512 serde_json::to_string(&map).unwrap_or_default()
2513}
2514
2515#[derive(Debug, Clone, Copy, PartialEq)]
2516enum RespondAs {
2517 Anthropic,
2518 OpenaiChat,
2519 OpenaiResponses,
2520 Gemini,
2521}
2522
2523struct UpstreamPlan {
2524 url: String,
2525 account: Account,
2526 body: Vec<u8>,
2527 upstream_format: &'static str,
2528 destream: bool,
2529 respond_as: Option<RespondAs>,
2530 client_stream: bool,
2531 extra_headers: Vec<(String, String)>,
2532 dario_guard: Option<Box<dyn std::any::Any + Send>>,
2533}
2534
2535fn dario_account(active: &DarioActive) -> Account {
2536 Account {
2537 id: format!("dario:{}", active.generation_id),
2538 provider: Provider::Anthropic,
2539 kind: "dario".into(),
2540 name: active.generation_id.clone(),
2541 description: None,
2542 paused: false,
2543 label: Some("dario generational proxy".into()),
2544 access_token: None,
2545 refresh_token: None,
2546 id_token: None,
2547 api_key: Some(active.api_key.clone()),
2548 expires_at_ms: None,
2549 last_refresh_ms: None,
2550 account_meta: Value::Null,
2551 cooldown_until_ms: None,
2552 status: "active".into(),
2553 path: None,
2554 }
2555}
2556
2557async fn ensure_gemini_project(
2558 state: &AppState,
2559 account: &Account,
2560) -> Result<String, (StatusCode, String)> {
2561 if let Some(p) = account
2562 .account_meta
2563 .get("project_id")
2564 .and_then(|v| v.as_str())
2565 {
2566 if !p.is_empty() {
2567 return Ok(p.to_string());
2568 }
2569 }
2570 if let Ok(env_p) = std::env::var("GOOGLE_CLOUD_PROJECT") {
2571 if !env_p.is_empty() {
2572 let _ = state
2573 .vault
2574 .set_account_meta(&account.id, "project_id", json!(env_p))
2575 .await;
2576 return Ok(env_p);
2577 }
2578 }
2579 let token = account.access_token.as_deref().ok_or_else(|| {
2580 (
2581 StatusCode::BAD_GATEWAY,
2582 "gemini account has no access token".into(),
2583 )
2584 })?;
2585 let load_url = format!("{GEMINI_CODE_ASSIST_BASE}/{GEMINI_CODE_ASSIST_VERSION}:loadCodeAssist");
2586 let load_body = json!({
2587 "cloudaicompanionProject": null,
2588 "metadata": {"ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI"},
2589 });
2590 let resp = state
2591 .http
2592 .post(&load_url)
2593 .header("authorization", format!("Bearer {token}"))
2594 .json(&load_body)
2595 .send()
2596 .await
2597 .map_err(|e| {
2598 (
2599 StatusCode::BAD_GATEWAY,
2600 format!("loadCodeAssist failed: {e}"),
2601 )
2602 })?;
2603 let load: Value = resp.json().await.unwrap_or(Value::Null);
2604 let extract = |v: &Value| -> Option<String> {
2605 for key in ["cloudaicompanionProject", "projectId", "project"] {
2606 match &v[key] {
2607 Value::String(s) if !s.is_empty() => return Some(s.clone()),
2608 obj if obj["id"].is_string() => return obj["id"].as_str().map(String::from),
2609 _ => {}
2610 }
2611 }
2612 None
2613 };
2614 if let Some(p) = extract(&load) {
2615 let _ = state
2616 .vault
2617 .set_account_meta(&account.id, "project_id", json!(p))
2618 .await;
2619 return Ok(p);
2620 }
2621 if let Some(free) = load["ineligibleTiers"]
2622 .as_array()
2623 .and_then(|t| t.iter().find(|t| t["tierId"] == json!("free-tier")))
2624 {
2625 let reason = free["reasonCode"].as_str().unwrap_or("");
2626 let msg = free["reasonMessage"].as_str().unwrap_or("");
2627 let hint = if let Some(url) = free["validationUrl"].as_str() {
2628 format!(
2629 "gemini account needs verification before the free Code Assist tier works — \
2630 open {url} to verify, then retry (or sign in with another personal Google \
2631 account, or set gemini_project to a GCP project)"
2632 )
2633 } else if reason == "DASHER_USER" {
2634 "this is a Google Workspace account, which cannot use the free Code Assist tier — \
2635 set gemini_project to a GCP project (Code Assist API enabled), or sign in with a \
2636 personal Google account"
2637 .to_string()
2638 } else {
2639 format!("gemini account is not eligible for the free Code Assist tier: {msg}")
2640 };
2641 return Err((StatusCode::BAD_GATEWAY, hint));
2642 }
2643 let tier = load["allowedTiers"]
2644 .as_array()
2645 .and_then(|tiers| tiers.iter().find(|t| t["isDefault"] == json!(true)))
2646 .and_then(|t| t["id"].as_str())
2647 .unwrap_or("free-tier")
2648 .to_string();
2649 let onboard_url = format!("{GEMINI_CODE_ASSIST_BASE}/{GEMINI_CODE_ASSIST_VERSION}:onboardUser");
2650 let onboard_body = json!({
2651 "tierId": tier,
2652 "cloudaicompanionProject": load["cloudaicompanionProject"],
2653 "metadata": {"ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI"},
2654 });
2655 for _ in 0..5 {
2656 let resp = state
2657 .http
2658 .post(&onboard_url)
2659 .header("authorization", format!("Bearer {token}"))
2660 .json(&onboard_body)
2661 .send()
2662 .await
2663 .map_err(|e| (StatusCode::BAD_GATEWAY, format!("onboardUser failed: {e}")))?;
2664 let lro: Value = resp.json().await.unwrap_or(Value::Null);
2665 if lro["done"] == json!(true) {
2666 if let Some(p) = extract(&lro["response"]) {
2667 let _ = state
2668 .vault
2669 .set_account_meta(&account.id, "project_id", json!(p))
2670 .await;
2671 return Ok(p);
2672 }
2673 }
2674 tokio::time::sleep(Duration::from_secs(2)).await;
2675 }
2676 Err((
2677 StatusCode::BAD_GATEWAY,
2678 "gemini Code Assist onboarding did not return a project id (set GOOGLE_CLOUD_PROJECT to override)".into(),
2679 ))
2680}
2681
2682async fn plan_upstream(
2683 state: &AppState,
2684 format: ClientFormat,
2685 provider: Provider,
2686 routed_model: &str,
2687 body_json: &mut Value,
2688 original_body: &[u8],
2689 trace_id: &str,
2690 excluded_accounts: &HashSet<String>,
2691 affinity_session: Option<&str>,
2692 client_headers: &HeaderMap,
2693) -> Result<UpstreamPlan, (StatusCode, String)> {
2694 use alex_core::translate;
2695 let client_stream = body_json["stream"].as_bool().unwrap_or(false);
2696 let genuine_claude_code =
2697 is_genuine_claude_code_request(format, client_headers, body_json);
2698 match provider {
2699 Provider::Anthropic => {
2700 let converted = match format {
2701 ClientFormat::AnthropicMessages => None,
2702 ClientFormat::OpenaiChat => Some((
2703 translate::openai_chat_to_anthropic(body_json),
2704 RespondAs::OpenaiChat,
2705 )),
2706 ClientFormat::OpenaiResponses => Some((
2707 translate::openai_responses_to_anthropic(body_json),
2708 RespondAs::OpenaiResponses,
2709 )),
2710 ClientFormat::GeminiGenerate => Some((
2711 translate::gemini_to_anthropic(body_json),
2712 RespondAs::Gemini,
2713 )),
2714 };
2715 let (base, account, dario_guard, dario_capture) = if genuine_claude_code {
2716 let account = state
2717 .vault
2718 .account_for_excluding(provider, true, excluded_accounts)
2719 .await
2720 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
2721 (ANTHROPIC_BASE.to_string(), account, None, false)
2722 } else {
2723 let dario_active = state.dario.as_ref().and_then(|dario| dario.active());
2724 match (&state.dario, dario_active) {
2725 (Some(dario), Some(active)) => {
2726 let guard = dario.begin(&active.generation_id).ok_or_else(|| {
2727 (
2728 StatusCode::SERVICE_UNAVAILABLE,
2729 "Dario became unavailable while routing the Anthropic request"
2730 .to_string(),
2731 )
2732 })?;
2733 (
2734 active.base_url.trim_end_matches('/').to_string(),
2735 dario_account(&active),
2736 Some(guard),
2737 true,
2738 )
2739 }
2740 (Some(_), None) => {
2741 return Err((
2742 StatusCode::SERVICE_UNAVAILABLE,
2743 "Dario is configured but no healthy generation is available"
2744 .to_string(),
2745 ));
2746 }
2747 (None, None) => {
2748 let account = state
2749 .vault
2750 .account_for_excluding(provider, true, excluded_accounts)
2751 .await
2752 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
2753 (ANTHROPIC_BASE.to_string(), account, None, false)
2754 }
2755 (None, Some(_)) => unreachable!("Dario cannot be active when it is disabled"),
2756 }
2757 };
2758 let (body, respond_as) = match converted {
2759 None => {
2760 body_json["model"] = json!(routed_model);
2761 (
2762 serde_json::to_vec(body_json).unwrap_or_else(|_| original_body.to_vec()),
2763 None,
2764 )
2765 }
2766 Some((mut converted, respond_as)) => {
2767 converted["model"] = json!(routed_model);
2768 converted["stream"] = json!(false);
2769 (
2770 serde_json::to_vec(&converted).unwrap_or_else(|_| original_body.to_vec()),
2771 Some(respond_as),
2772 )
2773 }
2774 };
2775 Ok(UpstreamPlan {
2776 url: format!("{base}/v1/messages"),
2777 account,
2778 body,
2779 upstream_format: "anthropic",
2780 destream: false,
2781 respond_as,
2782 client_stream,
2783 extra_headers: if dario_capture {
2784 vec![
2785 ("x-dario-capture-id".into(), trace_id.into()),
2786 ("x-dario-capture-model".into(), routed_model.into()),
2787 ]
2788 } else {
2789 vec![]
2790 },
2791 dario_guard,
2792 })
2793 }
2794 Provider::Openai => {
2795 let _affinity_guard = match affinity_session.filter(|value| !value.is_empty()) {
2799 Some(session_id) => Some(
2800 codex_affinity_lock(state, session_id)
2801 .lock_owned()
2802 .await,
2803 ),
2804 None => None,
2805 };
2806 let prefer_oauth = format != ClientFormat::OpenaiChat;
2807 let policy = state.vault.policy(Provider::Openai);
2808 let preferred = preferred_codex_account(state, affinity_session);
2809 let account = state
2810 .vault
2811 .account_for_excluding_preferred_mode(
2812 provider,
2813 prefer_oauth,
2814 excluded_accounts,
2815 preferred.as_deref(),
2816 preferred.is_some() && !policy.allow_mid_thread_failover,
2817 )
2818 .await
2819 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
2820 let oauth = account.kind == "oauth";
2821 bind_codex_account(state, affinity_session, &account);
2822 match format {
2823 ClientFormat::OpenaiChat if !oauth => {
2824 body_json["model"] = json!(routed_model);
2825 let body =
2826 serde_json::to_vec(body_json).unwrap_or_else(|_| original_body.to_vec());
2827 Ok(UpstreamPlan {
2828 url: format!("{OPENAI_BASE}/v1/chat/completions"),
2829 account,
2830 body,
2831 upstream_format: "openai-chat",
2832 destream: false,
2833 respond_as: None,
2834 client_stream,
2835 extra_headers: vec![],
2836 dario_guard: None,
2837 })
2838 }
2839 ClientFormat::OpenaiChat => {
2840 let pivot = translate::openai_chat_to_anthropic(body_json);
2841 let mut converted = translate::anthropic_to_openai_responses(&pivot);
2842 converted["model"] = json!(routed_model);
2843 translate::normalize_codex_request(&mut converted);
2844 let body = serde_json::to_vec(&converted)
2845 .unwrap_or_else(|_| original_body.to_vec());
2846 Ok(UpstreamPlan {
2847 url: format!("{CODEX_BASE}/responses"),
2848 account,
2849 body,
2850 upstream_format: "openai-responses",
2851 destream: false,
2852 respond_as: Some(RespondAs::OpenaiChat),
2853 client_stream,
2854 extra_headers: vec![],
2855 dario_guard: None,
2856 })
2857 }
2858 ClientFormat::OpenaiResponses => {
2859 body_json["model"] = json!(routed_model);
2860 let mut destream = false;
2861 let url = if oauth {
2862 if body_json["stream"].as_bool() != Some(true) {
2863 destream = true;
2864 }
2865 translate::normalize_codex_request(body_json);
2866 format!("{CODEX_BASE}/responses")
2867 } else {
2868 format!("{OPENAI_BASE}/v1/responses")
2869 };
2870 let body =
2871 serde_json::to_vec(body_json).unwrap_or_else(|_| original_body.to_vec());
2872 Ok(UpstreamPlan {
2873 url,
2874 account,
2875 body,
2876 upstream_format: "openai-responses",
2877 destream,
2878 respond_as: None,
2879 client_stream,
2880 extra_headers: vec![],
2881 dario_guard: None,
2882 })
2883 }
2884 ClientFormat::AnthropicMessages => {
2885 let mut converted = translate::anthropic_to_openai_responses(body_json);
2886 converted["model"] = json!(routed_model);
2887 let url = if oauth {
2888 translate::normalize_codex_request(&mut converted);
2889 format!("{CODEX_BASE}/responses")
2890 } else {
2891 converted["stream"] = json!(false);
2892 format!("{OPENAI_BASE}/v1/responses")
2893 };
2894 let body = serde_json::to_vec(&converted)
2895 .unwrap_or_else(|_| original_body.to_vec());
2896 Ok(UpstreamPlan {
2897 url,
2898 account,
2899 body,
2900 upstream_format: "openai-responses",
2901 destream: false,
2902 respond_as: Some(RespondAs::Anthropic),
2903 client_stream,
2904 extra_headers: vec![],
2905 dario_guard: None,
2906 })
2907 }
2908 ClientFormat::GeminiGenerate => {
2909 let pivot = translate::gemini_to_anthropic(body_json);
2910 let mut converted = translate::anthropic_to_openai_responses(&pivot);
2911 converted["model"] = json!(routed_model);
2912 let url = if oauth {
2913 translate::normalize_codex_request(&mut converted);
2914 format!("{CODEX_BASE}/responses")
2915 } else {
2916 converted["stream"] = json!(false);
2917 format!("{OPENAI_BASE}/v1/responses")
2918 };
2919 let body = serde_json::to_vec(&converted)
2920 .unwrap_or_else(|_| original_body.to_vec());
2921 Ok(UpstreamPlan {
2922 url,
2923 account,
2924 body,
2925 upstream_format: "openai-responses",
2926 destream: false,
2927 respond_as: Some(RespondAs::Gemini),
2928 client_stream,
2929 extra_headers: vec![],
2930 dario_guard: None,
2931 })
2932 }
2933 }
2934 }
2935 Provider::Xai => {
2936 let account = state
2937 .vault
2938 .account_for_excluding(provider, true, excluded_accounts)
2939 .await
2940 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
2941 let extra_headers = vec![
2942 ("x-grok-model-override".into(), routed_model.to_string()),
2943 ("x-grok-conv-id".into(), trace_id.to_string()),
2944 ];
2945 match format {
2946 ClientFormat::OpenaiChat => {
2947 body_json["model"] = json!(routed_model);
2948 let body =
2949 serde_json::to_vec(body_json).unwrap_or_else(|_| original_body.to_vec());
2950 Ok(UpstreamPlan {
2951 url: format!("{XAI_BASE}/chat/completions"),
2952 account,
2953 body,
2954 upstream_format: "openai-chat",
2955 destream: false,
2956 respond_as: None,
2957 client_stream,
2958 extra_headers,
2959 dario_guard: None,
2960 })
2961 }
2962 ClientFormat::AnthropicMessages => {
2963 let mut converted = translate::anthropic_to_openai_chat(body_json);
2965 converted["model"] = json!(routed_model);
2966 converted["stream"] = json!(false);
2968 let body = serde_json::to_vec(&converted)
2969 .unwrap_or_else(|_| original_body.to_vec());
2970 Ok(UpstreamPlan {
2971 url: format!("{XAI_BASE}/chat/completions"),
2972 account,
2973 body,
2974 upstream_format: "openai-chat",
2975 destream: false,
2976 respond_as: Some(RespondAs::Anthropic),
2977 client_stream,
2978 extra_headers,
2979 dario_guard: None,
2980 })
2981 }
2982 ClientFormat::OpenaiResponses | ClientFormat::GeminiGenerate => Err((
2983 StatusCode::NOT_IMPLEMENTED,
2984 "the xai/grok upstream speaks OpenAI chat completions; POST to /v1/chat/completions or /v1/messages"
2985 .to_string(),
2986 )),
2987 }
2988 }
2989 Provider::Amp => Err((
2990 StatusCode::NOT_IMPLEMENTED,
2991 "amp is wrap/billing-only (no /v1 upstream). Use `alex wrap amp` for harness capture and `alex limits` for credits.".into(),
2992 )),
2993 Provider::Gemini => {
2994 let account = state
2996 .vault
2997 .account_for_excluding(provider, false, excluded_accounts)
2998 .await
2999 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
3000 let (gemini_req, respond_as) = match format {
3001 ClientFormat::GeminiGenerate => {
3002 let mut g = body_json.clone();
3003 if let Some(o) = g.as_object_mut() {
3004 o.remove("model");
3005 o.remove("stream");
3006 }
3007 (g, None)
3008 }
3009 ClientFormat::AnthropicMessages => (
3010 translate::anthropic_to_gemini_request(body_json),
3011 Some(RespondAs::Anthropic),
3012 ),
3013 ClientFormat::OpenaiChat => (
3014 translate::anthropic_to_gemini_request(&translate::openai_chat_to_anthropic(
3015 body_json,
3016 )),
3017 Some(RespondAs::OpenaiChat),
3018 ),
3019 ClientFormat::OpenaiResponses => (
3020 translate::anthropic_to_gemini_request(
3021 &translate::openai_responses_to_anthropic(body_json),
3022 ),
3023 Some(RespondAs::OpenaiResponses),
3024 ),
3025 };
3026 let method = if client_stream {
3027 "streamGenerateContent"
3028 } else {
3029 "generateContent"
3030 };
3031 let (url, body) = if account.kind == "api_key" {
3032 let mut url = format!(
3034 "{GEMINI_API_BASE}/v1beta/models/{routed_model}:{method}"
3035 );
3036 if client_stream {
3037 url.push_str("?alt=sse");
3038 }
3039 let body =
3040 serde_json::to_vec(&gemini_req).unwrap_or_else(|_| original_body.to_vec());
3041 (url, body)
3042 } else {
3043 let project = ensure_gemini_project(state, &account).await?;
3045 let mut url = format!(
3046 "{GEMINI_CODE_ASSIST_BASE}/{GEMINI_CODE_ASSIST_VERSION}:{method}"
3047 );
3048 if client_stream {
3049 url.push_str("?alt=sse");
3050 }
3051 let envelope = json!({
3052 "model": routed_model,
3053 "project": project,
3054 "request": gemini_req,
3055 });
3056 let body =
3057 serde_json::to_vec(&envelope).unwrap_or_else(|_| original_body.to_vec());
3058 (url, body)
3059 };
3060 Ok(UpstreamPlan {
3061 url,
3062 account,
3063 body,
3064 upstream_format: "gemini",
3065 destream: false,
3066 respond_as: respond_as.or(Some(RespondAs::Gemini)),
3067 client_stream,
3068 extra_headers: vec![],
3069 dario_guard: None,
3070 })
3071 }
3072 }
3073}
3074
3075#[cfg(test)]
3076mod trace_api_tests {
3077 use super::{
3078 session_from_metadata, trace_extras, trace_harness, trace_reasoning_fields,
3079 transcript_assistant_blocks, transcript_turn, truncate_chars,
3080 };
3081 use axum::http::{HeaderMap, HeaderValue};
3082 use serde_json::json;
3083
3084 #[test]
3085 fn metadata_session_variants() {
3086 let claude = json!({"metadata": {"user_id":
3087 "{\"device_id\":\"d1\",\"session_id\":\"ses_inner\"}"}});
3088 assert_eq!(session_from_metadata(&claude), Some("ses_inner".into()));
3089 let plain = json!({"metadata": {"user_id": "user-123"}});
3090 assert_eq!(session_from_metadata(&plain), Some("user-123".into()));
3091 let json_no_session = json!({"metadata": {"user_id": "{\"device_id\":\"d1\"}"}});
3092 assert_eq!(
3093 session_from_metadata(&json_no_session),
3094 Some("{\"device_id\":\"d1\"}".into())
3095 );
3096 assert_eq!(session_from_metadata(&json!({})), None);
3097 }
3098
3099 #[test]
3100 fn explicit_harness_header_wins_over_sdk_user_agent() {
3101 let mut headers = HeaderMap::new();
3102 headers.insert(
3103 "user-agent",
3104 HeaderValue::from_static("Anthropic/JS 0.91.1"),
3105 );
3106 assert_eq!(trace_harness(&headers).as_deref(), Some("Anthropic/JS 0.91.1"));
3107 headers.insert(
3108 "x-alexandria-harness",
3109 HeaderValue::from_static("pi"),
3110 );
3111 assert_eq!(trace_harness(&headers).as_deref(), Some("pi"));
3112 }
3113
3114 #[test]
3115 fn extras_per_format() {
3116 let anthropic = json!({
3117 "system": [{"type": "text", "text": "abcd"}],
3118 "messages": [{"role": "user", "content": "hi"}],
3119 "max_tokens": 100,
3120 "temperature": 0.5,
3121 "thinking": {"type": "enabled", "budget_tokens": 4096},
3122 });
3123 let e = trace_extras(&anthropic);
3124 assert_eq!(e["thinking_budget"], 4096);
3125 assert_eq!(trace_reasoning_fields(&anthropic), (None, Some(4096)));
3126 assert_eq!(e["max_tokens"], 100);
3127 assert_eq!(e["temperature"], 0.5);
3128 assert_eq!(e["message_count"], 1);
3129 assert_eq!(e["system_chars"], 4);
3130 assert_eq!(e["reasoning_effort"], serde_json::Value::Null);
3131 let responses = json!({
3132 "instructions": "abc",
3133 "input": [{"type": "message"}, {"type": "message"}],
3134 "max_output_tokens": 200,
3135 "reasoning": {"effort": "high"},
3136 });
3137 let e = trace_extras(&responses);
3138 assert_eq!(e["reasoning_effort"], "high");
3139 assert_eq!(
3140 trace_reasoning_fields(&responses),
3141 (Some("high".into()), None)
3142 );
3143 assert_eq!(e["max_tokens"], 200);
3144 assert_eq!(e["message_count"], 2);
3145 assert_eq!(e["system_chars"], 3);
3146 assert_eq!(e["thinking_budget"], serde_json::Value::Null);
3147 }
3148
3149 #[test]
3150 fn truncates_on_char_boundaries() {
3151 assert_eq!(truncate_chars("abc".into(), 8000), "abc");
3152 assert_eq!(
3153 truncate_chars("héllo".repeat(2000), 8000).chars().count(),
3154 8000
3155 );
3156 }
3157
3158 #[test]
3159 fn transcript_turn_missing_bodies_are_null() {
3160 let row = json!({
3161 "id": "t1", "ts_request_ms": 1, "ts_response_ms": 2,
3162 "routed_model": "m", "status": 200,
3163 "input_tokens": 10, "output_tokens": 5, "cost_usd": 0.01, "error": null,
3164 "reasoning_effort": "minimal", "thinking_budget": null,
3165 "req_body_path": "/nonexistent/x.gz", "resp_body_path": null,
3166 "client_format": "anthropic", "upstream_format": "anthropic",
3167 });
3168 let turn = transcript_turn(&row);
3169 assert_eq!(turn["trace_id"], "t1");
3170 assert_eq!(turn["user"], serde_json::Value::Null);
3171 assert_eq!(turn["assistant"], serde_json::Value::Null);
3172 assert_eq!(turn["model"], "m");
3173 assert_eq!(turn["reasoning_effort"], "minimal");
3174 assert_eq!(turn["thinking_budget"], serde_json::Value::Null);
3175 }
3176
3177 #[test]
3178 fn transcript_assistant_blocks_preserve_text_tool_text_order() {
3179 let response = json!({
3180 "_alexandria": {"assistant_blocks": [
3181 {"type": "text", "text": "Listing the workspace."},
3182 {"type": "tool_call", "name": "Shell", "arguments": "{\"command\":\"ls\"}"},
3183 {"type": "text", "text": "Here are the files."},
3184 ]}
3185 });
3186 let blocks = transcript_assistant_blocks(&response.to_string());
3187 assert_eq!(blocks.len(), 3);
3188 assert_eq!(blocks[0]["type"], "text");
3189 assert_eq!(blocks[0]["text"], "Listing the workspace.");
3190 assert_eq!(blocks[1]["type"], "tool_call");
3191 assert_eq!(blocks[1]["name"], "Shell");
3192 assert_eq!(blocks[2]["type"], "text");
3193 assert_eq!(blocks[2]["text"], "Here are the files.");
3194 }
3195}
3196
3197#[cfg(test)]
3198mod run_key_tests {
3199 use super::{generate_run_key, key_fingerprint, key_hash_hex, merge_run_key_tags};
3200
3201 #[test]
3202 fn merge_header_tags_win_per_key() {
3203 let merged = merge_run_key_tags(
3204 Some(r#"{"task":"demo","suite":"swebench"}"#),
3205 Some(r#"{"task":"override","case":"x1"}"#),
3206 )
3207 .unwrap();
3208 let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
3209 assert_eq!(v["task"], "override");
3210 assert_eq!(v["suite"], "swebench");
3211 assert_eq!(v["case"], "x1");
3212 }
3213
3214 #[test]
3215 fn merge_handles_missing_sides() {
3216 assert_eq!(merge_run_key_tags(None, None), None);
3217 assert_eq!(
3218 merge_run_key_tags(Some(r#"{"a":"1"}"#), None).unwrap(),
3219 r#"{"a":"1"}"#
3220 );
3221 assert_eq!(
3222 merge_run_key_tags(None, Some(r#"{"b":"2"}"#)).unwrap(),
3223 r#"{"b":"2"}"#
3224 );
3225 assert_eq!(merge_run_key_tags(Some("not json"), None), None);
3226 }
3227
3228 #[test]
3229 fn run_key_shape_and_fingerprint() {
3230 let key = generate_run_key();
3231 assert!(key.starts_with("alxk-"));
3232 assert_eq!(key.len(), 5 + 64);
3233 assert_ne!(key, generate_run_key());
3234 let hash = key_hash_hex(&key);
3235 assert_eq!(hash.len(), 64);
3236 assert_eq!(hash[..16], key_fingerprint(&key));
3237 }
3238}
3239
3240#[cfg(test)]
3241mod usage_tests {
3242 use super::{key_fingerprint, usage_backoff_ms};
3243
3244 #[test]
3245 fn fingerprint_is_first_8_sha256_bytes_hex() {
3246 assert_eq!(key_fingerprint("abc"), "ba7816bf8f01cfea");
3247 assert_eq!(key_fingerprint("abc").len(), 16);
3248 assert_ne!(key_fingerprint("abc"), key_fingerprint("abd"));
3249 }
3250
3251 #[test]
3252 fn backoff_grows_and_caps() {
3253 assert_eq!(usage_backoff_ms(1, None), 60_000);
3254 assert_eq!(usage_backoff_ms(2, None), 120_000);
3255 assert_eq!(usage_backoff_ms(3, None), 240_000);
3256 assert_eq!(usage_backoff_ms(10, None), 3_600_000);
3257 }
3258
3259 #[test]
3260 fn retry_after_wins_when_larger() {
3261 assert_eq!(usage_backoff_ms(1, Some(600_000)), 600_000);
3262 assert_eq!(usage_backoff_ms(5, Some(1_000)), 960_000);
3263 }
3264}
3265
3266fn upstream_headers(
3267 account: &Account,
3268 client_headers: &HeaderMap,
3269 genuine_claude_code: bool,
3270) -> Result<reqwest::header::HeaderMap, (StatusCode, String)> {
3271 let mut h = reqwest::header::HeaderMap::new();
3272 h.insert("content-type", HeaderValue::from_static("application/json"));
3273 h.insert("accept", HeaderValue::from_static("*/*"));
3274 h.insert("accept-encoding", HeaderValue::from_static("identity"));
3275 if let Some(ua) = client_headers.get("user-agent") {
3276 h.insert("user-agent", ua.clone());
3277 }
3278 match (account.provider, account.kind.as_str()) {
3279 (Provider::Anthropic, "oauth") => {
3280 let token = account.access_token.as_deref().ok_or((
3281 StatusCode::BAD_GATEWAY,
3282 "anthropic oauth account has no access token".to_string(),
3283 ))?;
3284 h.insert(
3285 "authorization",
3286 HeaderValue::from_str(&format!("Bearer {token}"))
3287 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3288 );
3289 let mut beta = ANTHROPIC_OAUTH_BETA.to_string();
3290 if let Some(client_beta) = client_headers
3291 .get("anthropic-beta")
3292 .and_then(|v| v.to_str().ok())
3293 {
3294 if !client_beta.contains(ANTHROPIC_OAUTH_BETA) {
3295 beta = format!("{ANTHROPIC_OAUTH_BETA},{client_beta}");
3296 } else {
3297 beta = client_beta.to_string();
3298 }
3299 }
3300 h.insert(
3301 "anthropic-beta",
3302 HeaderValue::from_str(&beta)
3303 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3304 );
3305 insert_anthropic_version(&mut h, client_headers);
3306 }
3307 (Provider::Anthropic, _) => {
3308 let key = account
3309 .api_key
3310 .as_deref()
3311 .or(account.access_token.as_deref())
3312 .ok_or((
3313 StatusCode::BAD_GATEWAY,
3314 "anthropic account has no api key".to_string(),
3315 ))?;
3316 h.insert(
3317 "x-api-key",
3318 HeaderValue::from_str(key).map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3319 );
3320 if let Some(client_beta) = client_headers.get("anthropic-beta") {
3321 h.insert("anthropic-beta", client_beta.clone());
3322 }
3323 insert_anthropic_version(&mut h, client_headers);
3324 }
3325 (Provider::Openai, "oauth") => {
3326 let token = account.access_token.as_deref().ok_or((
3327 StatusCode::BAD_GATEWAY,
3328 "openai oauth account has no access token".to_string(),
3329 ))?;
3330 h.insert(
3331 "authorization",
3332 HeaderValue::from_str(&format!("Bearer {token}"))
3333 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3334 );
3335 if let Some(acct) = account.chatgpt_account_id() {
3336 h.insert(
3337 "chatgpt-account-id",
3338 HeaderValue::from_str(&acct)
3339 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3340 );
3341 }
3342 h.insert(
3343 "openai-beta",
3344 HeaderValue::from_static("responses=experimental"),
3345 );
3346 h.insert("originator", HeaderValue::from_static("codex_cli_rs"));
3347 let session = client_headers
3348 .get("session_id")
3349 .and_then(|v| v.to_str().ok())
3350 .map(String::from)
3351 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
3352 h.insert(
3353 "session_id",
3354 HeaderValue::from_str(&session)
3355 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3356 );
3357 }
3358 (Provider::Openai, _) => {
3359 let key = account.api_key.as_deref().ok_or((
3360 StatusCode::BAD_GATEWAY,
3361 "openai account has no api key".to_string(),
3362 ))?;
3363 h.insert(
3364 "authorization",
3365 HeaderValue::from_str(&format!("Bearer {key}"))
3366 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3367 );
3368 }
3369 (Provider::Xai, _) => {
3370 let token = account
3371 .access_token
3372 .as_deref()
3373 .or(account.api_key.as_deref())
3374 .ok_or((
3375 StatusCode::BAD_GATEWAY,
3376 "xai account has no access token".to_string(),
3377 ))?;
3378 h.insert(
3379 "authorization",
3380 HeaderValue::from_str(&format!("Bearer {token}"))
3381 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3382 );
3383 h.insert("x-xai-token-auth", HeaderValue::from_static("xai-grok-cli"));
3384 h.insert(
3385 "x-grok-client-version",
3386 HeaderValue::from_static(GROK_CLIENT_VERSION),
3387 );
3388 h.insert("user-agent", HeaderValue::from_static("xai-grok-workspace"));
3389 h.insert("accept", HeaderValue::from_static("text/event-stream"));
3390 }
3391 (Provider::Gemini, "api_key") => {
3392 let key = account.api_key.as_deref().ok_or((
3393 StatusCode::BAD_GATEWAY,
3394 "gemini api_key account has no key".to_string(),
3395 ))?;
3396 h.insert(
3397 "x-goog-api-key",
3398 HeaderValue::from_str(key).map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3399 );
3400 }
3401 (Provider::Gemini, _) => {
3402 let token = account.access_token.as_deref().ok_or((
3403 StatusCode::BAD_GATEWAY,
3404 "gemini account has no access token".to_string(),
3405 ))?;
3406 h.insert(
3407 "authorization",
3408 HeaderValue::from_str(&format!("Bearer {token}"))
3409 .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?,
3410 );
3411 }
3412 (Provider::Amp, _) => {
3413 return Err((
3414 StatusCode::NOT_IMPLEMENTED,
3415 "amp is wrap/billing-only".to_string(),
3416 ));
3417 }
3418 }
3419 if genuine_claude_code && account.provider == Provider::Anthropic {
3420 for name in CLAUDE_CODE_PASSTHROUGH_HEADERS {
3421 if let Some(value) = client_headers.get(*name) {
3422 h.insert(*name, value.clone());
3423 }
3424 }
3425 }
3426 Ok(h)
3427}
3428
3429fn insert_anthropic_version(h: &mut reqwest::header::HeaderMap, client_headers: &HeaderMap) {
3430 match client_headers.get("anthropic-version") {
3431 Some(v) => {
3432 h.insert("anthropic-version", v.clone());
3433 }
3434 None => {
3435 h.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
3436 }
3437 }
3438}
3439
3440fn key_fingerprint(key: &str) -> String {
3441 use sha2::{Digest, Sha256};
3442 let digest = Sha256::digest(key.as_bytes());
3443 digest[..8].iter().map(|b| format!("{b:02x}")).collect()
3444}
3445
3446const RUN_KEY_PREFIX: &str = "alxk-";
3447const RUN_KEY_DEFAULT_TTL_S: i64 = 86_400;
3448const RUN_KEY_MAX_TTL_S: i64 = 604_800;
3449
3450pub fn generate_run_key() -> String {
3451 use rand::RngCore;
3452 let mut bytes = [0u8; 32];
3453 rand::thread_rng().fill_bytes(&mut bytes);
3454 format!(
3455 "{RUN_KEY_PREFIX}{}",
3456 bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()
3457 )
3458}
3459
3460fn key_hash_hex(key: &str) -> String {
3461 use sha2::{Digest, Sha256};
3462 let digest = Sha256::digest(key.as_bytes());
3463 digest.iter().map(|b| format!("{b:02x}")).collect()
3464}
3465
3466fn merge_run_key_tags(key_tags: Option<&str>, header_tags: Option<&str>) -> Option<String> {
3467 let parse = |s: Option<&str>| {
3468 s.and_then(|s| serde_json::from_str::<Value>(s).ok())
3469 .and_then(|v| v.as_object().cloned())
3470 .unwrap_or_default()
3471 };
3472 let mut merged = parse(key_tags);
3473 merged.extend(parse(header_tags));
3474 if merged.is_empty() {
3475 None
3476 } else {
3477 serde_json::to_string(&Value::Object(merged)).ok()
3478 }
3479}
3480
3481fn run_key_entry(state: &AppState, key_hash: &str) -> Option<CachedRunKey> {
3482 let now = now_ms();
3483 let cached = state.run_keys.read().unwrap().get(key_hash).cloned();
3484 if let Some(entry) = cached {
3485 if entry.expires_ms.map(|e| e > now).unwrap_or(true) {
3486 return Some(entry);
3487 }
3488 state.run_keys.write().unwrap().remove(key_hash);
3489 return None;
3490 }
3491 let row = state.store.lookup_run_key(key_hash, now).ok().flatten()?;
3492 let entry = CachedRunKey {
3493 kind: row["kind"].as_str().unwrap_or("run").to_string(),
3494 run_id: row["run_id"].as_str().map(String::from),
3495 tags_json: row["tags"]
3496 .as_object()
3497 .filter(|o| !o.is_empty())
3498 .and_then(|o| serde_json::to_string(o).ok()),
3499 expires_ms: row["expires_ms"].as_i64(),
3500 };
3501 state
3502 .run_keys
3503 .write()
3504 .unwrap()
3505 .insert(key_hash.to_string(), entry.clone());
3506 Some(entry)
3507}
3508
3509async fn admin_run_keys_create(
3510 State(state): State<Arc<AppState>>,
3511 body: Option<axum::Json<Value>>,
3512) -> Response {
3513 let body = body.map(|b| b.0).unwrap_or_else(|| json!({}));
3514 let tags = match &body["tags"] {
3515 Value::Null => None,
3516 Value::Object(o) => Some(o.clone()),
3517 _ => return error_response(StatusCode::BAD_REQUEST, "'tags' must be an object"),
3518 };
3519 let kind = body["kind"].as_str().unwrap_or("run");
3520 if kind != "run" && kind != "harness" && kind != "wrap" {
3521 return error_response(
3522 StatusCode::BAD_REQUEST,
3523 "'kind' must be 'run', 'harness', or 'wrap'",
3524 );
3525 }
3526 let run_id = body["run_id"].as_str().map(String::from);
3527 let label = body["label"].as_str().map(String::from);
3528 if (kind == "harness" || kind == "wrap") && label.as_deref().unwrap_or("").trim().is_empty() {
3529 return error_response(
3530 StatusCode::BAD_REQUEST,
3531 "'label' is required for harness and wrap keys",
3532 );
3533 }
3534 let created_ms = now_ms();
3535 let expires_ms = if kind == "harness" || kind == "wrap" {
3536 None
3537 } else {
3538 let ttl_seconds = body["ttl_seconds"]
3539 .as_i64()
3540 .unwrap_or(RUN_KEY_DEFAULT_TTL_S);
3541 if ttl_seconds <= 0 {
3542 return error_response(StatusCode::BAD_REQUEST, "'ttl_seconds' must be positive");
3543 }
3544 Some(created_ms + ttl_seconds.min(RUN_KEY_MAX_TTL_S) * 1000)
3545 };
3546 let key = generate_run_key();
3547 let key_hash = key_hash_hex(&key);
3548 let id = format!("rk-{}", &key_hash[..8]);
3549 let tags_json = tags
3550 .as_ref()
3551 .filter(|o| !o.is_empty())
3552 .and_then(|o| serde_json::to_string(o).ok());
3553 match state.store.insert_run_key(
3554 &id,
3555 &key_hash,
3556 kind,
3557 run_id.as_deref(),
3558 tags_json.as_deref(),
3559 label.as_deref(),
3560 created_ms,
3561 expires_ms,
3562 ) {
3563 Ok(()) => {
3564 let exports = if kind == "wrap" {
3565 format!(
3566 "export ALEXANDRIA_TRACE_URL={}\nexport ALEXANDRIA_TRACE_KEY={}\n",
3567 state.base_url.trim_end_matches('/'),
3568 key
3569 )
3570 } else {
3571 connect_payload(&state.base_url, &key).1
3572 };
3573 (
3574 StatusCode::CREATED,
3575 axum::Json(json!({
3576 "id": id,
3577 "key": key,
3578 "kind": kind,
3579 "run_id": run_id,
3580 "label": label,
3581 "tags": tags.map(Value::Object).unwrap_or_else(|| json!({})),
3582 "expires_ms": expires_ms,
3583 "exports": exports,
3584 })),
3585 )
3586 .into_response()
3587 }
3588 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
3589 }
3590}
3591
3592async fn admin_run_keys_list(
3593 State(state): State<Arc<AppState>>,
3594 Query(q): Query<HashMap<String, String>>,
3595) -> Response {
3596 let all = q
3597 .get("all")
3598 .map(|v| v == "1" || v == "true")
3599 .unwrap_or(false);
3600 match state.store.list_run_keys(all) {
3601 Ok(rows) => axum::Json(json!({"run_keys": rows})).into_response(),
3602 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
3603 }
3604}
3605
3606async fn admin_run_keys_revoke(
3607 State(state): State<Arc<AppState>>,
3608 Path(id): Path<String>,
3609) -> Response {
3610 match state.store.revoke_run_key(&id) {
3611 Ok(true) => {
3612 state.run_keys.write().unwrap().clear();
3613 axum::Json(json!({"revoked": true})).into_response()
3614 }
3615 Ok(false) => error_response(StatusCode::NOT_FOUND, &format!("unknown run key '{id}'")),
3616 Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
3617 }
3618}
3619
3620const MAX_INGEST_BODY_BYTES: usize = 16 * 1024 * 1024;
3621
3622fn valid_ingest_trace_id(id: &str) -> bool {
3623 !id.is_empty()
3624 && id.len() <= 200
3625 && id
3626 .chars()
3627 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
3628}
3629
3630fn decode_ingest_body(encoded: Option<&str>, field: &str) -> Result<Option<Vec<u8>>, String> {
3631 use base64::Engine;
3632
3633 let Some(encoded) = encoded else {
3634 return Ok(None);
3635 };
3636 let bytes = base64::engine::general_purpose::STANDARD
3637 .decode(encoded)
3638 .map_err(|e| format!("'{field}' is not valid base64: {e}"))?;
3639 if bytes.len() > MAX_INGEST_BODY_BYTES {
3640 return Err(format!(
3641 "'{field}' exceeds the {} byte limit",
3642 MAX_INGEST_BODY_BYTES
3643 ));
3644 }
3645 Ok(Some(bytes))
3646}
3647
3648fn authenticate_trace_ingest(
3649 state: &AppState,
3650 headers: &HeaderMap,
3651) -> Result<(String, Option<CachedRunKey>, bool), Response> {
3652 let Some(key) = client_key(headers) else {
3653 return Err(error_response(
3654 StatusCode::UNAUTHORIZED,
3655 "trace ingest requires an Alexandria wrap key",
3656 ));
3657 };
3658 if key == state.local_key {
3659 return Ok((key_fingerprint(&key), None, true));
3660 }
3661 let key_hash = key_hash_hex(&key);
3662 let Some(entry) = run_key_entry(state, &key_hash) else {
3663 return Err(error_response(
3664 StatusCode::UNAUTHORIZED,
3665 "wrap key expired, revoked, or unknown",
3666 ));
3667 };
3668 if entry.kind != "wrap" {
3669 return Err(error_response(
3670 StatusCode::FORBIDDEN,
3671 "trace ingest requires a key minted with --kind wrap",
3672 ));
3673 }
3674 Ok((key_hash.chars().take(16).collect(), Some(entry), false))
3675}
3676
3677fn touch_trace_ingest_key(state: &AppState, key: Option<&CachedRunKey>, headers: &HeaderMap) {
3678 if key.is_none() {
3679 return;
3680 }
3681 let Some(raw_key) = client_key(headers) else {
3682 return;
3683 };
3684 let key_hash = key_hash_hex(&raw_key);
3685 if let Err(error) = state.store.touch_run_key(&key_hash, now_ms()) {
3686 tracing::warn!("failed to touch wrap key: {error}");
3687 }
3688}
3689
3690async fn traces_ingest_status(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
3691 match authenticate_trace_ingest(&state, &headers) {
3692 Ok((_, key, _)) => {
3693 touch_trace_ingest_key(&state, key.as_ref(), &headers);
3694 axum::Json(json!({
3695 "ok": true,
3696 "capability": "trace-ingest-v1",
3697 "max_body_bytes": MAX_INGEST_BODY_BYTES,
3698 }))
3699 .into_response()
3700 }
3701 Err(response) => response,
3702 }
3703}
3704
3705async fn traces_ingest(
3706 State(state): State<Arc<AppState>>,
3707 headers: HeaderMap,
3708 axum::Json(mut payload): axum::Json<TraceIngestPayload>,
3709) -> Response {
3710 let (fingerprint, key, local_admin) = match authenticate_trace_ingest(&state, &headers) {
3711 Ok(auth) => auth,
3712 Err(response) => return response,
3713 };
3714 touch_trace_ingest_key(&state, key.as_ref(), &headers);
3715 if !valid_ingest_trace_id(&payload.trace.id) {
3716 return error_response(
3717 StatusCode::BAD_REQUEST,
3718 "trace.id must be 1-200 characters using only letters, numbers, '.', '_', or '-'",
3719 );
3720 }
3721 if payload.trace.ts_request_ms <= 0 {
3722 return error_response(
3723 StatusCode::BAD_REQUEST,
3724 "trace.ts_request_ms must be positive",
3725 );
3726 }
3727 let request = match decode_ingest_body(payload.request_body_b64.as_deref(), "request_body_b64")
3728 {
3729 Ok(body) => body,
3730 Err(error) => return error_response(StatusCode::BAD_REQUEST, &error),
3731 };
3732 let upstream_request = match decode_ingest_body(
3733 payload.upstream_request_body_b64.as_deref(),
3734 "upstream_request_body_b64",
3735 ) {
3736 Ok(body) => body,
3737 Err(error) => return error_response(StatusCode::BAD_REQUEST, &error),
3738 };
3739 let response =
3740 match decode_ingest_body(payload.response_body_b64.as_deref(), "response_body_b64") {
3741 Ok(body) => body,
3742 Err(error) => return error_response(StatusCode::BAD_REQUEST, &error),
3743 };
3744
3745 let _ingest_guard = state.trace_ingest_lock.lock().await;
3749 let existing = match state.store.get_trace(&payload.trace.id) {
3750 Ok(row) => row,
3751 Err(error) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
3752 };
3753 if let Some(row) = &existing {
3754 let owner = row["key_fingerprint"].as_str();
3755 if !local_admin && owner != Some(fingerprint.as_str()) {
3756 return error_response(
3757 StatusCode::CONFLICT,
3758 "trace id already belongs to another credential",
3759 );
3760 }
3761 }
3762
3763 payload.trace.run_id = payload
3764 .trace
3765 .run_id
3766 .or_else(|| key.as_ref().and_then(|entry| entry.run_id.clone()));
3767 payload.trace.tags = merge_run_key_tags(
3768 key.as_ref().and_then(|entry| entry.tags_json.as_deref()),
3769 payload.trace.tags.as_deref(),
3770 );
3771 payload.trace.key_fingerprint = Some(fingerprint);
3772 payload.trace.client_ip = None;
3773 payload.trace.req_body_path = existing
3774 .as_ref()
3775 .and_then(|row| row["req_body_path"].as_str().map(String::from));
3776 payload.trace.upstream_req_body_path = existing
3777 .as_ref()
3778 .and_then(|row| row["upstream_req_body_path"].as_str().map(String::from));
3779 payload.trace.resp_body_path = existing
3780 .as_ref()
3781 .and_then(|row| row["resp_body_path"].as_str().map(String::from));
3782
3783 if let Some(body) = request {
3784 match state
3785 .store
3786 .write_body(&payload.trace.id, "request.json", &body)
3787 {
3788 Ok(path) => payload.trace.req_body_path = Some(path),
3789 Err(error) => {
3790 return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string())
3791 }
3792 }
3793 }
3794 if let Some(body) = upstream_request {
3795 match state
3796 .store
3797 .write_body(&payload.trace.id, "upstream-request.json", &body)
3798 {
3799 Ok(path) => payload.trace.upstream_req_body_path = Some(path),
3800 Err(error) => {
3801 return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string())
3802 }
3803 }
3804 }
3805 if let Some(body) = response {
3806 match state
3807 .store
3808 .write_body(&payload.trace.id, "response.body", &body)
3809 {
3810 Ok(path) => payload.trace.resp_body_path = Some(path),
3811 Err(error) => {
3812 return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string())
3813 }
3814 }
3815 }
3816 if let Err(error) = state.store.insert_trace(&payload.trace) {
3817 return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string());
3818 }
3819 let outcome = if existing.is_some() {
3820 "updated"
3821 } else {
3822 "inserted"
3823 };
3824 (
3825 if existing.is_some() {
3826 StatusCode::OK
3827 } else {
3828 StatusCode::CREATED
3829 },
3830 axum::Json(json!({"id": payload.trace.id, "outcome": outcome})),
3831 )
3832 .into_response()
3833}
3834
3835fn session_from_metadata(body_json: &Value) -> Option<String> {
3836 let raw = body_json["metadata"]["user_id"].as_str()?;
3837 if let Ok(v) = serde_json::from_str::<Value>(raw) {
3838 if let Some(inner) = v["session_id"].as_str() {
3839 return Some(inner.to_string());
3840 }
3841 }
3842 Some(raw.to_string())
3843}
3844
3845fn trace_harness(headers: &HeaderMap) -> Option<String> {
3846 headers
3847 .get("x-alexandria-harness")
3848 .and_then(|value| value.to_str().ok())
3849 .map(str::trim)
3850 .filter(|value| !value.is_empty())
3851 .map(String::from)
3852 .or_else(|| {
3853 headers
3854 .get("user-agent")
3855 .and_then(|value| value.to_str().ok())
3856 .map(String::from)
3857 })
3858}
3859
3860const METADATA_HEADERS: &[(&str, &str)] = &[
3861 ("x-alexandria-harness", "harness"),
3862 ("x-alexandria-harness-version", "harness_version"),
3863 ("x-alexandria-task", "task"),
3864 ("x-alexandria-model", "model"),
3865 ("x-alexandria-job", "job"),
3866 ("x-alexandria-phase", "phase"),
3867 ("x-alexandria-kind", "kind"),
3868];
3869
3870fn trace_tags_json(headers: &HeaderMap) -> Option<String> {
3871 let values: Vec<&str> = headers
3872 .get_all("x-alexandria-trace-tag")
3873 .iter()
3874 .filter_map(|v| v.to_str().ok())
3875 .collect();
3876 let mut tags = parse_trace_tags(&values);
3877 if let Some(o) = tags.as_object_mut() {
3878 for (header, key) in METADATA_HEADERS {
3879 if o.contains_key(*key) {
3880 continue;
3881 }
3882 if let Some(v) = headers.get(*header).and_then(|v| v.to_str().ok()) {
3883 let v = v.trim();
3884 if !v.is_empty() {
3885 o.insert((*key).to_string(), json!(v));
3886 }
3887 }
3888 }
3889 }
3890 if tags.as_object().map(|o| o.is_empty()).unwrap_or(true) {
3891 return None;
3892 }
3893 serde_json::to_string(&tags).ok()
3894}
3895
3896fn retryable_failover_status(status: reqwest::StatusCode) -> bool {
3897 status == reqwest::StatusCode::TOO_MANY_REQUESTS
3898 || status == reqwest::StatusCode::UNAUTHORIZED
3899 || status == reqwest::StatusCode::FORBIDDEN
3900 || status.is_server_error()
3901}
3902
3903fn retry_failover_allowed(
3904 provider: Provider,
3905 thread_was_affined: bool,
3906 allow_mid_thread_failover: bool,
3907) -> bool {
3908 provider != Provider::Openai || !thread_was_affined || allow_mid_thread_failover
3909}
3910
3911async fn proxy(
3912 state: Arc<AppState>,
3913 format: ClientFormat,
3914 path: &'static str,
3915 headers: HeaderMap,
3916 body: Bytes,
3917 peer: Option<std::net::SocketAddr>,
3918) -> Response {
3919 let mut run_key: Option<CachedRunKey> = None;
3920 let client_fingerprint = match client_key(&headers) {
3921 Some(k) if k == state.local_key => key_fingerprint(&k),
3922 Some(k) => {
3923 let key_hash = key_hash_hex(&k);
3924 match run_key_entry(&state, &key_hash) {
3925 Some(entry) if entry.kind == "wrap" => {
3926 return error_response(
3927 StatusCode::FORBIDDEN,
3928 "wrap keys may only post to /traces/ingest",
3929 )
3930 }
3931 Some(entry) => {
3932 if let Err(e) = state.store.touch_run_key(&key_hash, now_ms()) {
3933 tracing::warn!("failed to touch run key: {e}");
3934 }
3935 run_key = Some(entry);
3936 key_hash.chars().take(16).collect()
3937 }
3938 None if k.starts_with(RUN_KEY_PREFIX) => {
3939 return error_response(StatusCode::UNAUTHORIZED, "run key expired or revoked")
3940 }
3941 None => {
3942 return error_response(
3943 StatusCode::UNAUTHORIZED,
3944 "bad or missing local key (x-api-key / Authorization: Bearer)",
3945 )
3946 }
3947 }
3948 }
3949 None => {
3950 return error_response(
3951 StatusCode::UNAUTHORIZED,
3952 "bad or missing local key (x-api-key / Authorization: Bearer)",
3953 )
3954 }
3955 };
3956
3957 let in_flight = InFlight::new(&state);
3958 let trace_id = uuid::Uuid::new_v4().to_string();
3959 let mut trace = TraceRecord {
3960 id: trace_id.clone(),
3961 ts_request_ms: now_ms(),
3962 method: Some("POST".into()),
3963 path: Some(path.into()),
3964 client_format: Some(format.as_str().into()),
3965 harness: trace_harness(&headers),
3966 req_headers_json: Some(redacted_headers(&headers)),
3967 run_id: headers
3968 .get("x-alexandria-run-id")
3969 .and_then(|v| v.to_str().ok())
3970 .map(String::from)
3971 .or_else(|| run_key.as_ref().and_then(|k| k.run_id.clone())),
3972 tags: merge_run_key_tags(
3973 run_key.as_ref().and_then(|k| k.tags_json.as_deref()),
3974 trace_tags_json(&headers).as_deref(),
3975 ),
3976 client_ip: peer.map(|p| p.ip().to_string()),
3977 key_fingerprint: Some(client_fingerprint),
3978 ..Default::default()
3979 };
3980
3981 let mut body_json: Value = match serde_json::from_slice(&body) {
3982 Ok(v) => v,
3983 Err(e) => {
3984 return error_response(StatusCode::BAD_REQUEST, &format!("body is not JSON: {e}"))
3985 }
3986 };
3987 let (reasoning_effort, thinking_budget) = trace_reasoning_fields(&body_json);
3988 trace.reasoning_effort = reasoning_effort;
3989 trace.thinking_budget = thinking_budget;
3990 let genuine_claude_code = is_genuine_claude_code_request(format, &headers, &body_json);
3991
3992 trace.session_id = headers
3993 .get("x-session-id")
3994 .and_then(|v| v.to_str().ok())
3995 .map(String::from)
3996 .or_else(|| {
3997 headers
3998 .get("session_id")
3999 .and_then(|v| v.to_str().ok())
4000 .map(String::from)
4001 })
4002 .or_else(|| {
4003 headers
4004 .get("x-claude-code-session-id")
4005 .and_then(|v| v.to_str().ok())
4006 .map(String::from)
4007 })
4008 .or_else(|| session_from_metadata(&body_json))
4009 .or_else(|| {
4010 body_json
4011 .get("prompt_cache_key")
4012 .and_then(Value::as_str)
4013 .filter(|value| !value.is_empty())
4014 .map(String::from)
4015 })
4016 .or_else(|| {
4017 conversation_root(format, &body_json).map(|root| {
4018 let ua = headers
4019 .get("user-agent")
4020 .and_then(|v| v.to_str().ok())
4021 .unwrap_or("");
4022 let ip = peer.map(|p| p.ip().to_string()).unwrap_or_default();
4023 format!("auto-{}", key_fingerprint(&format!("{root}{ua}{ip}")))
4024 })
4025 });
4026
4027 let requested_model = body_json["model"].as_str().unwrap_or("").to_string();
4028 if requested_model.is_empty() {
4029 return error_response(StatusCode::BAD_REQUEST, "missing 'model' in request body");
4030 }
4031 let (routed_provider, routed_model) = route_model(&requested_model);
4032 let provider = routed_provider.unwrap_or_else(|| format.default_provider());
4033 trace.requested_model = Some(requested_model.clone());
4034 trace.routed_model = Some(routed_model.clone());
4035 trace.upstream_provider = Some(provider.as_str().into());
4036 trace.streamed = Some(body_json["stream"].as_bool().unwrap_or(false));
4037
4038 let codex_thread_was_affined = provider == Provider::Openai
4042 && preferred_codex_account(&state, trace.session_id.as_deref()).is_some();
4043 let allow_mid_thread_failover = state
4044 .vault
4045 .policy(Provider::Openai)
4046 .allow_mid_thread_failover;
4047
4048 let original_body_json = body_json.clone();
4049 let mut attempted_accounts = HashSet::new();
4050 let mut plan = match plan_upstream(
4051 &state,
4052 format,
4053 provider,
4054 &routed_model,
4055 &mut body_json,
4056 &body,
4057 &trace_id,
4058 &attempted_accounts,
4059 trace.session_id.as_deref(),
4060 &headers,
4061 )
4062 .await
4063 {
4064 Ok(p) => p,
4065 Err((status, msg)) => {
4066 trace.status = Some(status.as_u16() as i64);
4067 trace.error = Some(msg.clone());
4068 finalize_trace(&state, trace, &body, None, None);
4069 return error_response(status, &msg);
4070 }
4071 };
4072 trace.upstream_format = Some(plan.upstream_format.into());
4073 trace.account_id = Some(plan.account.id.clone());
4074 trace.billing_bucket = Some(
4075 if plan.account.kind == "oauth" || plan.account.kind == "dario" {
4076 "subscription"
4077 } else {
4078 "api"
4079 }
4080 .into(),
4081 );
4082
4083 tracing::info!(
4084 trace_id,
4085 model = %routed_model,
4086 provider = provider.as_str(),
4087 account = %plan.account.id,
4088 url = %plan.url,
4089 genuine_claude_code,
4090 "proxying request"
4091 );
4092
4093 let mut account = plan.account.clone();
4094 let mut upstream_resp = None;
4095 'accounts: loop {
4096 if !attempted_accounts.insert(account.id.clone()) {
4097 tracing::error!(account = %account.id, "refusing to retry an already-attempted account");
4098 break;
4099 }
4100
4101 let mut forced_oauth_refresh = false;
4102 loop {
4103 let mut up_headers =
4104 match upstream_headers(&account, &headers, genuine_claude_code) {
4105 Ok(h) => h,
4106 Err((status, msg)) => {
4107 trace.status = Some(status.as_u16() as i64);
4108 trace.error = Some(msg.clone());
4109 finalize_trace(&state, trace, &body, Some(&plan.body), None);
4110 return error_response(status, &msg);
4111 }
4112 };
4113 for (k, v) in &plan.extra_headers {
4114 if let (Ok(name), Ok(value)) = (
4115 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
4116 HeaderValue::from_str(v),
4117 ) {
4118 up_headers.insert(name, value);
4119 }
4120 }
4121 let resp = match state
4122 .http
4123 .post(&plan.url)
4124 .headers(up_headers)
4125 .body(plan.body.clone())
4126 .send()
4127 .await
4128 {
4129 Ok(r) => r,
4130 Err(e) => {
4131 let msg = format!("upstream request failed: {e}");
4132 suspect_dario(&state, &account);
4133 trace.status = Some(502);
4134 trace.error = Some(msg.clone());
4135 finalize_trace(&state, trace, &body, Some(&plan.body), None);
4136 return error_response(StatusCode::BAD_GATEWAY, &msg);
4137 }
4138 };
4139
4140 if account.provider == Provider::Openai && account.kind == "oauth" {
4141 if let Some(snapshot) = codex_limits_from_headers(resp.headers()) {
4142 if let Err(error) = state
4143 .vault
4144 .record_codex_limits(&account.id, snapshot)
4145 .await
4146 {
4147 tracing::warn!(account = %account.id, %error, "could not persist Codex limit snapshot");
4148 }
4149 }
4150 }
4151 if resp.status() == reqwest::StatusCode::UNAUTHORIZED
4152 && account.kind == "oauth"
4153 && !forced_oauth_refresh
4154 {
4155 tracing::warn!(
4156 account = %account.id,
4157 "upstream returned 401 for oauth account; forcing token refresh and retrying"
4158 );
4159 forced_oauth_refresh = true;
4160 match state.vault.refresh(&account.id, true).await {
4161 Ok(fresh) => {
4162 account = fresh;
4163 continue;
4164 }
4165 Err(e) => {
4166 tracing::warn!("forced refresh failed: {e}");
4167 }
4168 }
4169 }
4170
4171 if retryable_failover_status(resp.status()) && account.kind != "dario" {
4172 let retry_after_s = resp
4173 .headers()
4174 .get("retry-after")
4175 .and_then(|v| v.to_str().ok())
4176 .and_then(|v| v.parse::<i64>().ok())
4177 .unwrap_or(60)
4178 .clamp(1, 3600);
4179 tracing::warn!(account = %account.id, retry_after_s, status = %resp.status(), "upstream failed; cooling account down");
4180 if let Err(e) = state
4181 .vault
4182 .mark_cooldown(&account.id, now_ms() + retry_after_s * 1000)
4183 .await
4184 {
4185 tracing::warn!("failed to mark cooldown: {e}");
4186 }
4187
4188 if !retry_failover_allowed(
4189 provider,
4190 codex_thread_was_affined,
4191 allow_mid_thread_failover,
4192 ) {
4193 tracing::warn!(
4194 account = %account.id,
4195 status = %resp.status(),
4196 "returning retryable Codex error without switching an affined thread"
4197 );
4198 upstream_resp = Some(resp);
4199 break 'accounts;
4200 }
4201
4202 let mut retry_body_json = original_body_json.clone();
4203 match plan_upstream(
4204 &state,
4205 format,
4206 provider,
4207 &routed_model,
4208 &mut retry_body_json,
4209 &body,
4210 &trace_id,
4211 &attempted_accounts,
4212 trace.session_id.as_deref(),
4213 &headers,
4214 )
4215 .await
4216 {
4217 Ok(next_plan) if !attempted_accounts.contains(&next_plan.account.id) => {
4218 plan = next_plan;
4219 account = plan.account.clone();
4220 trace.account_id = Some(account.id.clone());
4221 trace.upstream_format = Some(plan.upstream_format.into());
4222 trace.billing_bucket = Some(
4223 if account.kind == "oauth" || account.kind == "dario" {
4224 "subscription"
4225 } else {
4226 "api"
4227 }
4228 .into(),
4229 );
4230 tracing::warn!(
4231 account = %account.id,
4232 attempted = attempted_accounts.len(),
4233 "retrying with failover account"
4234 );
4235 continue 'accounts;
4236 }
4237 Ok(next_plan) => {
4238 tracing::error!(
4239 account = %next_plan.account.id,
4240 "account selector returned an already-attempted failover account"
4241 );
4242 }
4243 Err((status, msg)) => tracing::warn!(
4244 status = %status,
4245 error = %msg,
4246 attempted = attempted_accounts.len(),
4247 "no untried failover account available"
4248 ),
4249 }
4250 }
4251
4252 upstream_resp = Some(resp);
4253 break 'accounts;
4254 }
4255 }
4256 let upstream_resp = upstream_resp.expect("upstream response after retry loop");
4257 trace.account_id = Some(account.id.clone());
4258
4259 let status = upstream_resp.status();
4260 trace.status = Some(status.as_u16() as i64);
4261 let resp_headers = upstream_resp.headers().clone();
4262 let content_type = resp_headers
4263 .get("content-type")
4264 .and_then(|v| v.to_str().ok())
4265 .unwrap_or("application/octet-stream")
4266 .to_string();
4267 trace.resp_headers_json = Some(redacted_headers(&resp_headers));
4268 let is_sse = content_type.starts_with("text/event-stream");
4269
4270 if let Some(target) = plan.respond_as {
4271 use alex_core::translate;
4272 let buf = match upstream_resp.bytes().await {
4273 Ok(b) => b.to_vec(),
4274 Err(e) => {
4275 let msg = format!("upstream body read failed: {e}");
4276 suspect_dario(&state, &account);
4277 trace.status = Some(502);
4278 trace.error = Some(msg.clone());
4279 finalize_trace(&state, trace, &body, Some(&plan.body), None);
4280 return error_response(StatusCode::BAD_GATEWAY, &msg);
4281 }
4282 };
4283 drop(plan.dario_guard.take());
4284 trace.ts_response_ms = Some(now_ms());
4285 fill_usage_and_cost(&state, &mut trace, &buf, is_sse);
4286 let text = String::from_utf8_lossy(&buf).to_string();
4287 if !status.is_success() {
4288 finalize_trace(&state, trace, &body, Some(&plan.body), Some(&buf));
4289 return Response::builder()
4290 .status(status)
4291 .header("content-type", "application/json")
4292 .header("x-alexandria-trace-id", &trace_id)
4293 .body(Body::from(buf))
4294 .unwrap_or_else(|e| {
4295 error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
4296 });
4297 }
4298 let trimmed = text.trim_start();
4299 let looks_sse = trimmed.starts_with("event:") || trimmed.starts_with("data:");
4300 let upstream_final = if plan.upstream_format == "anthropic" {
4301 if is_sse || looks_sse {
4302 translate::parse_anthropic_sse_to_message(&text)
4303 } else {
4304 serde_json::from_str(&text).ok()
4305 }
4306 } else if plan.upstream_format == "gemini" {
4307 translate::parse_gemini_upstream_final(&text)
4308 } else if plan.upstream_format == "openai-chat" {
4309 if is_sse || looks_sse {
4310 translate::parse_openai_chat_sse_final(&text)
4311 } else {
4312 serde_json::from_str(&text).ok()
4313 }
4314 } else if is_sse || looks_sse {
4315 translate::parse_responses_sse_final(&text)
4316 } else {
4317 serde_json::from_str(&text).ok()
4318 };
4319 let Some(upstream_final) = upstream_final else {
4320 let msg = "could not reassemble upstream response for translation";
4321 trace.error = Some(msg.to_string());
4322 finalize_trace(&state, trace, &body, Some(&plan.body), Some(&buf));
4323 return error_response(StatusCode::BAD_GATEWAY, msg);
4324 };
4325 let out = match (target, plan.upstream_format) {
4326 (RespondAs::Gemini, "gemini") => upstream_final.clone(),
4327 (RespondAs::Anthropic, "gemini") => {
4328 translate::gemini_response_to_anthropic(&upstream_final, &requested_model)
4329 }
4330 (RespondAs::OpenaiChat, "gemini") => translate::anthropic_response_to_openai_chat(
4331 &translate::gemini_response_to_anthropic(&upstream_final, &requested_model),
4332 &requested_model,
4333 ),
4334 (RespondAs::OpenaiResponses, "gemini") => {
4335 translate::anthropic_response_to_openai_responses(
4336 &translate::gemini_response_to_anthropic(&upstream_final, &requested_model),
4337 &requested_model,
4338 )
4339 }
4340 (RespondAs::OpenaiChat, "anthropic") => {
4341 translate::anthropic_response_to_openai_chat(&upstream_final, &requested_model)
4342 }
4343 (RespondAs::OpenaiResponses, "anthropic") => {
4344 translate::anthropic_response_to_openai_responses(&upstream_final, &requested_model)
4345 }
4346 (RespondAs::Gemini, "anthropic") => {
4347 translate::anthropic_response_to_gemini(&upstream_final, &requested_model)
4348 }
4349 (RespondAs::Anthropic, "openai-chat") => {
4350 translate::openai_chat_response_to_anthropic(&upstream_final, &requested_model)
4351 }
4352 (RespondAs::OpenaiChat, "openai-chat") => upstream_final.clone(),
4353 (RespondAs::OpenaiResponses, "openai-chat") => {
4354 translate::anthropic_response_to_openai_responses(
4355 &translate::openai_chat_response_to_anthropic(
4356 &upstream_final,
4357 &requested_model,
4358 ),
4359 &requested_model,
4360 )
4361 }
4362 (RespondAs::Gemini, "openai-chat") => translate::anthropic_response_to_gemini(
4363 &translate::openai_chat_response_to_anthropic(&upstream_final, &requested_model),
4364 &requested_model,
4365 ),
4366 (RespondAs::Gemini, _) => translate::anthropic_response_to_gemini(
4367 &translate::responses_final_to_anthropic(&upstream_final, &requested_model),
4368 &requested_model,
4369 ),
4370 (RespondAs::Anthropic, "anthropic") | (RespondAs::OpenaiResponses, _) => {
4371 upstream_final.clone()
4372 }
4373 (RespondAs::Anthropic, _) => {
4374 translate::responses_final_to_anthropic(&upstream_final, &requested_model)
4375 }
4376 (RespondAs::OpenaiChat, _) => {
4377 translate::responses_final_to_openai_chat(&upstream_final, &requested_model)
4378 }
4379 };
4380 let (out_ct, out_body) = if plan.client_stream {
4381 let sse = match target {
4382 RespondAs::Anthropic => translate::synth_anthropic_sse(&out),
4383 RespondAs::OpenaiChat => translate::synth_openai_chat_sse(&out),
4384 RespondAs::OpenaiResponses => translate::synth_openai_responses_sse(&out),
4385 RespondAs::Gemini => translate::synth_gemini_sse(&out),
4386 };
4387 ("text/event-stream", sse.into_bytes())
4388 } else {
4389 (
4390 "application/json",
4391 serde_json::to_vec(&out).unwrap_or_default(),
4392 )
4393 };
4394 finalize_trace(&state, trace, &body, Some(&plan.body), Some(&buf));
4395 return Response::builder()
4396 .status(StatusCode::OK)
4397 .header("content-type", out_ct)
4398 .header("x-alexandria-trace-id", &trace_id)
4399 .body(Body::from(out_body))
4400 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()));
4401 }
4402
4403 if plan.destream {
4404 let buf = match upstream_resp.bytes().await {
4405 Ok(b) => b.to_vec(),
4406 Err(e) => {
4407 let msg = format!("upstream body read failed: {e}");
4408 trace.status = Some(502);
4409 trace.error = Some(msg.clone());
4410 finalize_trace(&state, trace, &body, Some(&plan.body), None);
4411 return error_response(StatusCode::BAD_GATEWAY, &msg);
4412 }
4413 };
4414 trace.ts_response_ms = Some(now_ms());
4415 fill_usage_and_cost(&state, &mut trace, &buf, is_sse);
4416 let text = String::from_utf8_lossy(&buf).to_string();
4417 let (out_status, out_body) = if status.is_success() {
4418 match extract_final_response(&text) {
4419 Some(v) => (StatusCode::OK, serde_json::to_vec(&v).unwrap_or_default()),
4420 None => {
4421 let msg = "upstream stream ended without a response.completed event";
4422 trace.error = Some(msg.to_string());
4423 (
4424 StatusCode::BAD_GATEWAY,
4425 serde_json::to_vec(
4426 &json!({"error": {"type": "alexandria", "message": msg}}),
4427 )
4428 .unwrap_or_default(),
4429 )
4430 }
4431 }
4432 } else {
4433 (status, buf.clone())
4434 };
4435 finalize_trace(&state, trace, &body, Some(&plan.body), Some(&buf));
4436 return Response::builder()
4437 .status(out_status)
4438 .header("content-type", "application/json")
4439 .header("x-alexandria-trace-id", &trace_id)
4440 .body(Body::from(out_body))
4441 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()));
4442 }
4443
4444 let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(64);
4445 let mut upstream_stream = upstream_resp.bytes_stream();
4446 let state2 = state.clone();
4447 let client_body = body.clone();
4448 let upstream_body = plan.body.clone();
4449 let dario_guard = plan.dario_guard.take();
4450 tokio::spawn(async move {
4451 let _dario_guard = dario_guard;
4452 let _in_flight = in_flight;
4453 let mut buf: Vec<u8> = Vec::new();
4454 let mut stream_error: Option<String> = None;
4455 while let Some(chunk) = upstream_stream.next().await {
4456 match chunk {
4457 Ok(b) => {
4458 buf.extend_from_slice(&b);
4459 let _ = tx.send(Ok(b)).await;
4460 }
4461 Err(e) => {
4462 stream_error = Some(format!("upstream stream error: {e}"));
4463 let _ = tx
4464 .send(Err(std::io::Error::new(
4465 std::io::ErrorKind::Other,
4466 e.to_string(),
4467 )))
4468 .await;
4469 break;
4470 }
4471 }
4472 }
4473 drop(tx);
4474 trace.ts_response_ms = Some(now_ms());
4475 if trace.error.is_none() {
4476 trace.error = stream_error;
4477 }
4478 if trace.error.is_some() {
4479 if let (Some(dario), Some(gen)) = (
4480 &state2.dario,
4481 trace
4482 .account_id
4483 .as_deref()
4484 .and_then(|id| id.strip_prefix("dario:")),
4485 ) {
4486 dario.suspect(gen);
4487 }
4488 }
4489
4490 fill_usage_and_cost(&state2, &mut trace, &buf, is_sse);
4491 finalize_trace(
4492 &state2,
4493 trace,
4494 &client_body,
4495 Some(&upstream_body),
4496 Some(&buf),
4497 );
4498 });
4499
4500 let mut response = Response::builder().status(status);
4501 for (k, v) in resp_headers.iter() {
4502 let key = k.as_str().to_lowercase();
4503 if [
4504 "transfer-encoding",
4505 "connection",
4506 "content-encoding",
4507 "content-length",
4508 ]
4509 .contains(&key.as_str())
4510 {
4511 continue;
4512 }
4513 response = response.header(k, v);
4514 }
4515 response = response.header("x-alexandria-trace-id", &trace_id);
4516 response
4517 .body(Body::from_stream(ReceiverStream::new(rx)))
4518 .unwrap_or_else(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))
4519}
4520
4521fn fill_usage_and_cost(state: &AppState, trace: &mut TraceRecord, buf: &[u8], is_sse: bool) {
4522 let text = String::from_utf8_lossy(buf);
4523 let trimmed = text.trim_start();
4524 let looks_sse = trimmed.starts_with("event:") || trimmed.starts_with("data:");
4525 trace.usage = if is_sse || looks_sse {
4526 parse_sse_usage(&text)
4527 } else {
4528 serde_json::from_str::<Value>(&text)
4529 .map(|v| usage_from_json(&v))
4530 .unwrap_or_default()
4531 };
4532 if !trace.usage.is_empty() {
4533 if let Some(pricing) = trace
4534 .routed_model
4535 .as_deref()
4536 .and_then(|m| state.store.pricing_for(m))
4537 {
4538 let input_includes_cached = trace
4539 .upstream_format
4540 .as_deref()
4541 .map(|f| f.starts_with("openai"))
4542 .unwrap_or(false);
4543 trace.cost_usd = Some(compute_cost(&trace.usage, &pricing, input_includes_cached));
4544 }
4545 }
4546}
4547
4548fn extract_final_response(text: &str) -> Option<Value> {
4549 let mut last: Option<Value> = None;
4550 for line in text.lines() {
4551 let Some(data) = line.strip_prefix("data:") else {
4552 continue;
4553 };
4554 let Ok(v) = serde_json::from_str::<Value>(data.trim()) else {
4555 continue;
4556 };
4557 if matches!(
4558 v["type"].as_str(),
4559 Some("response.completed") | Some("response.incomplete") | Some("response.failed")
4560 ) && v["response"].is_object()
4561 {
4562 last = Some(v["response"].clone());
4563 }
4564 }
4565 last
4566}
4567
4568fn finalize_trace(
4569 state: &AppState,
4570 mut trace: TraceRecord,
4571 client_body: &[u8],
4572 upstream_body: Option<&[u8]>,
4573 resp_body: Option<&[u8]>,
4574) {
4575 let store = &state.store;
4576 match store.write_body(&trace.id, "request.json", client_body) {
4577 Ok(p) => trace.req_body_path = Some(p),
4578 Err(e) => tracing::warn!("failed to write request body: {e}"),
4579 }
4580 if let Some(up) = upstream_body {
4581 if up != client_body {
4582 match store.write_body(&trace.id, "upstream-request.json", up) {
4583 Ok(p) => trace.upstream_req_body_path = Some(p),
4584 Err(e) => tracing::warn!("failed to write upstream request body: {e}"),
4585 }
4586 }
4587 }
4588 if let Some(resp) = resp_body {
4589 match store.write_body(&trace.id, "response.body", resp) {
4590 Ok(p) => trace.resp_body_path = Some(p),
4591 Err(e) => tracing::warn!("failed to write response body: {e}"),
4592 }
4593 }
4594 if let Err(e) = store.insert_trace(&trace) {
4595 tracing::error!("failed to insert trace {}: {e}", trace.id);
4596 } else {
4597 tracing::info!(
4598 trace_id = %trace.id,
4599 status = trace.status,
4600 input = trace.usage.input_tokens,
4601 output = trace.usage.output_tokens,
4602 cost = trace.cost_usd,
4603 "trace recorded"
4604 );
4605 }
4606}
4607
4608#[cfg(test)]
4609mod tests {
4610 use super::*;
4611 use std::path::PathBuf;
4612
4613 fn tmpdir(name: &str) -> PathBuf {
4614 let dir = std::env::temp_dir().join(format!(
4615 "alex-proxy-{name}-{}-{}",
4616 std::process::id(),
4617 std::time::SystemTime::now()
4618 .duration_since(std::time::UNIX_EPOCH)
4619 .unwrap()
4620 .as_nanos()
4621 ));
4622 std::fs::create_dir_all(&dir).unwrap();
4623 dir
4624 }
4625
4626 fn test_state(name: &str) -> Arc<AppState> {
4627 test_state_with_dario(name, None)
4628 }
4629
4630 fn test_state_with_dario(
4631 name: &str,
4632 dario: Option<Arc<dyn DarioRouter>>,
4633 ) -> Arc<AppState> {
4634 let dir = tmpdir(name);
4635 let store = Arc::new(Store::open(dir.join("store")).unwrap());
4636 let vault = Arc::new(Vault::open(dir.join("vault")).unwrap());
4637 build_state(
4638 "alx-local".into(),
4639 vault,
4640 store,
4641 dario,
4642 "http://127.0.0.1:4100".into(),
4643 )
4644 }
4645
4646 fn anthropic_account() -> Account {
4647 Account {
4648 id: "anthropic:test".into(),
4649 provider: Provider::Anthropic,
4650 kind: "oauth".into(),
4651 name: "test".into(),
4652 description: None,
4653 paused: false,
4654 label: None,
4655 access_token: Some("direct-token".into()),
4656 refresh_token: None,
4657 id_token: None,
4658 api_key: None,
4659 expires_at_ms: Some(now_ms() + 3_600_000),
4660 last_refresh_ms: None,
4661 account_meta: Value::Null,
4662 cooldown_until_ms: None,
4663 status: "active".into(),
4664 path: None,
4665 }
4666 }
4667
4668 struct FakeDario {
4669 active: Option<DarioActive>,
4670 begin_succeeds: bool,
4671 }
4672
4673 impl DarioRouter for FakeDario {
4674 fn active(&self) -> Option<DarioActive> {
4675 self.active.clone()
4676 }
4677
4678 fn begin(&self, _generation_id: &str) -> Option<Box<dyn std::any::Any + Send>> {
4679 self.begin_succeeds
4680 .then(|| Box::new(()) as Box<dyn std::any::Any + Send>)
4681 }
4682
4683 fn status(&self) -> Value {
4684 json!({"active": self.active.is_some()})
4685 }
4686
4687 fn suspect(&self, _generation_id: &str) {}
4688 }
4689
4690 fn active_dario() -> Arc<dyn DarioRouter> {
4691 Arc::new(FakeDario {
4692 active: Some(DarioActive {
4693 generation_id: "test-generation".into(),
4694 base_url: "http://127.0.0.1:9191".into(),
4695 api_key: "dario-key".into(),
4696 }),
4697 begin_succeeds: true,
4698 })
4699 }
4700
4701 fn claude_code_request() -> (HeaderMap, Value) {
4702 let mut headers = HeaderMap::new();
4703 headers.insert("user-agent", HeaderValue::from_static("claude-cli/2.1.0"));
4704 headers.insert("x-app", HeaderValue::from_static("cli"));
4705 headers.insert(
4706 "x-claude-code-session-id",
4707 HeaderValue::from_static("session-123"),
4708 );
4709 let body = json!({
4710 "model": "claude-sonnet-4-5",
4711 "system": [{
4712 "type": "text",
4713 "text": "x-anthropic-billing-header: cc_version=2.1.0"
4714 }],
4715 "messages": []
4716 });
4717 (headers, body)
4718 }
4719
4720 #[test]
4721 fn claude_code_detection_requires_the_complete_signature() {
4722 let (headers, body) = claude_code_request();
4723 assert!(is_genuine_claude_code_request(
4724 ClientFormat::AnthropicMessages,
4725 &headers,
4726 &body
4727 ));
4728 assert!(!is_genuine_claude_code_request(
4729 ClientFormat::OpenaiChat,
4730 &headers,
4731 &body
4732 ));
4733
4734 for required in ["user-agent", "x-app", "x-claude-code-session-id"] {
4735 let mut missing = headers.clone();
4736 missing.remove(required);
4737 assert!(
4738 !is_genuine_claude_code_request(
4739 ClientFormat::AnthropicMessages,
4740 &missing,
4741 &body
4742 ),
4743 "request without {required} must not bypass Dario"
4744 );
4745 }
4746
4747 let mut missing_billing = body.clone();
4748 missing_billing["system"] = json!([]);
4749 assert!(!is_genuine_claude_code_request(
4750 ClientFormat::AnthropicMessages,
4751 &headers,
4752 &missing_billing
4753 ));
4754
4755 let mut explicit_other_harness = headers;
4756 explicit_other_harness.insert(
4757 "x-alexandria-harness",
4758 HeaderValue::from_static("pi"),
4759 );
4760 assert!(!is_genuine_claude_code_request(
4761 ClientFormat::AnthropicMessages,
4762 &explicit_other_harness,
4763 &body
4764 ));
4765
4766 let (mut conflicting_harness, body) = claude_code_request();
4767 conflicting_harness.append(
4768 "x-alexandria-harness",
4769 HeaderValue::from_static("claude-code"),
4770 );
4771 conflicting_harness.append(
4772 "x-alexandria-harness",
4773 HeaderValue::from_static("codex"),
4774 );
4775 assert!(!is_genuine_claude_code_request(
4776 ClientFormat::AnthropicMessages,
4777 &conflicting_harness,
4778 &body
4779 ));
4780
4781 let (mut malformed_harness, body) = claude_code_request();
4782 malformed_harness.insert(
4783 "x-alexandria-harness",
4784 HeaderValue::from_bytes(b"\x80").unwrap(),
4785 );
4786 assert!(!is_genuine_claude_code_request(
4787 ClientFormat::AnthropicMessages,
4788 &malformed_harness,
4789 &body
4790 ));
4791 }
4792
4793 #[tokio::test]
4794 async fn anthropic_routing_bypasses_dario_only_for_claude_code() {
4795 let state = test_state_with_dario("dario-routing", Some(active_dario()));
4796 state.vault.upsert(anthropic_account()).await.unwrap();
4797 let (claude_headers, request) = claude_code_request();
4798 let original = serde_json::to_vec(&request).unwrap();
4799 let excluded = HashSet::new();
4800
4801 let mut direct_body = request.clone();
4802 let direct = plan_upstream(
4803 &state,
4804 ClientFormat::AnthropicMessages,
4805 Provider::Anthropic,
4806 "claude-sonnet-4-5",
4807 &mut direct_body,
4808 &original,
4809 "trace-direct",
4810 &excluded,
4811 None,
4812 &claude_headers,
4813 )
4814 .await
4815 .unwrap();
4816 assert_eq!(direct.url, "https://api.anthropic.com/v1/messages");
4817 assert_eq!(direct.account.id, "anthropic:test");
4818 assert!(direct.extra_headers.is_empty());
4819
4820 let mut harness_headers = HeaderMap::new();
4821 harness_headers.insert("x-alexandria-harness", HeaderValue::from_static("pi"));
4822 let mut dario_body = request;
4823 let dario = plan_upstream(
4824 &state,
4825 ClientFormat::AnthropicMessages,
4826 Provider::Anthropic,
4827 "claude-sonnet-4-5",
4828 &mut dario_body,
4829 &original,
4830 "trace-dario",
4831 &excluded,
4832 None,
4833 &harness_headers,
4834 )
4835 .await
4836 .unwrap();
4837 assert_eq!(dario.url, "http://127.0.0.1:9191/v1/messages");
4838 assert_eq!(dario.account.kind, "dario");
4839 assert!(dario
4840 .extra_headers
4841 .contains(&("x-dario-capture-id".into(), "trace-dario".into())));
4842 }
4843
4844 #[tokio::test]
4845 async fn configured_dario_fails_closed_when_unhealthy() {
4846 let state = test_state_with_dario(
4847 "dario-unhealthy",
4848 Some(Arc::new(FakeDario {
4849 active: None,
4850 begin_succeeds: false,
4851 })),
4852 );
4853 state.vault.upsert(anthropic_account()).await.unwrap();
4854 let (_, mut request) = claude_code_request();
4855 let client_headers = HeaderMap::new();
4856 let excluded = HashSet::new();
4857 let original = serde_json::to_vec(&request).unwrap();
4858
4859 let result = plan_upstream(
4860 &state,
4861 ClientFormat::AnthropicMessages,
4862 Provider::Anthropic,
4863 "claude-sonnet-4-5",
4864 &mut request,
4865 &original,
4866 "trace-unhealthy",
4867 &excluded,
4868 None,
4869 &client_headers,
4870 )
4871 .await;
4872 match result {
4873 Err((status, message)) => {
4874 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
4875 assert!(message.contains("no healthy generation"));
4876 }
4877 Ok(_) => panic!("an unhealthy configured Dario must not fall back to direct traffic"),
4878 }
4879 }
4880
4881 #[tokio::test]
4882 async fn dario_generation_race_fails_closed() {
4883 let state = test_state_with_dario(
4884 "dario-generation-race",
4885 Some(Arc::new(FakeDario {
4886 active: Some(DarioActive {
4887 generation_id: "vanished-generation".into(),
4888 base_url: "http://127.0.0.1:9191".into(),
4889 api_key: "dario-key".into(),
4890 }),
4891 begin_succeeds: false,
4892 })),
4893 );
4894 let (_, mut request) = claude_code_request();
4895 let original = serde_json::to_vec(&request).unwrap();
4896 let client_headers = HeaderMap::new();
4897 let excluded = HashSet::new();
4898
4899 let result = plan_upstream(
4900 &state,
4901 ClientFormat::AnthropicMessages,
4902 Provider::Anthropic,
4903 "claude-sonnet-4-5",
4904 &mut request,
4905 &original,
4906 "trace-race",
4907 &excluded,
4908 None,
4909 &client_headers,
4910 )
4911 .await;
4912 match result {
4913 Err((status, message)) => {
4914 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
4915 assert!(message.contains("became unavailable"));
4916 }
4917 Ok(_) => panic!("a vanished Dario generation must not fall back to direct traffic"),
4918 }
4919 }
4920
4921 #[test]
4922 fn direct_claude_code_headers_are_allowlisted() {
4923 let (mut client_headers, _) = claude_code_request();
4924 for name in CLAUDE_CODE_PASSTHROUGH_HEADERS {
4925 client_headers.insert(*name, HeaderValue::from_static("safe-client-value"));
4926 }
4927 client_headers.insert(
4928 "authorization",
4929 HeaderValue::from_static("Bearer client-secret"),
4930 );
4931 client_headers.insert("x-api-key", HeaderValue::from_static("client-secret"));
4932 client_headers.insert("host", HeaderValue::from_static("attacker.invalid"));
4933 client_headers.insert("content-length", HeaderValue::from_static("999"));
4934 client_headers.insert("connection", HeaderValue::from_static("close"));
4935 client_headers.insert("accept-encoding", HeaderValue::from_static("br"));
4936 client_headers.insert(
4937 "x-alexandria-harness",
4938 HeaderValue::from_static("claude"),
4939 );
4940 client_headers.insert(
4941 "x-dario-capture-id",
4942 HeaderValue::from_static("spoofed"),
4943 );
4944
4945 let direct = upstream_headers(&anthropic_account(), &client_headers, true).unwrap();
4946 for name in CLAUDE_CODE_PASSTHROUGH_HEADERS {
4947 assert_eq!(direct[*name], "safe-client-value", "missing {name}");
4948 }
4949 assert_eq!(direct["authorization"], "Bearer direct-token");
4950 assert!(direct.get("x-api-key").is_none());
4951 assert!(direct.get("host").is_none());
4952 assert!(direct.get("content-length").is_none());
4953 assert!(direct.get("connection").is_none());
4954 assert_eq!(direct["accept-encoding"], "identity");
4955 assert!(direct.get("x-alexandria-harness").is_none());
4956 assert!(direct.get("x-dario-capture-id").is_none());
4957
4958 let non_claude = upstream_headers(&anthropic_account(), &client_headers, false).unwrap();
4959 assert!(non_claude.get("x-app").is_none());
4960 assert!(non_claude.get("x-claude-code-session-id").is_none());
4961 assert_eq!(non_claude["authorization"], "Bearer direct-token");
4962 }
4963
4964 async fn response_json(resp: Response) -> (StatusCode, Value) {
4965 let status = resp.status();
4966 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4967 .await
4968 .unwrap();
4969 let value = serde_json::from_slice(&body).unwrap_or(Value::Null);
4970 (status, value)
4971 }
4972
4973 struct FakeUpdater {
4974 result: Result<Value, UpdateApplyError>,
4975 }
4976
4977 impl DaemonUpdater for FakeUpdater {
4978 fn apply(&self) -> UpdateApplyFuture {
4979 let result = match &self.result {
4980 Ok(body) => Ok(body.clone()),
4981 Err(UpdateApplyError::Conflict(body)) => {
4982 Err(UpdateApplyError::Conflict(body.clone()))
4983 }
4984 Err(UpdateApplyError::Failed(message)) => {
4985 Err(UpdateApplyError::Failed(message.clone()))
4986 }
4987 };
4988 Box::pin(async move { result })
4989 }
4990 }
4991
4992 fn test_openai_account(name: &str) -> Account {
4993 Account {
4994 id: if name == "default" {
4995 "openai-oauth".into()
4996 } else {
4997 format!("openai-oauth-{name}")
4998 },
4999 provider: Provider::Openai,
5000 kind: "oauth".into(),
5001 name: name.into(),
5002 description: None,
5003 paused: false,
5004 label: Some("codex (test)".into()),
5005 access_token: Some(format!("token-{name}")),
5006 refresh_token: Some(format!("refresh-{name}")),
5007 id_token: None,
5008 api_key: None,
5009 expires_at_ms: Some(now_ms() + 3_600_000),
5010 last_refresh_ms: Some(now_ms()),
5011 account_meta: json!({"account_id": format!("chatgpt-{name}")}),
5012 cooldown_until_ms: None,
5013 status: "active".into(),
5014 path: None,
5015 }
5016 }
5017
5018 #[test]
5019 fn codex_affinity_cache_expires_and_evicts_oldest_session() {
5020 let mut cache = CodexAffinityCache {
5021 entries: HashMap::new(),
5022 ttl_ms: 100,
5023 max_entries: 2,
5024 };
5025 cache.bind("session-a", "account-a", 0);
5026 cache.bind("session-b", "account-b", 10);
5027 assert_eq!(cache.preferred("session-a", 20).as_deref(), Some("account-a"));
5028
5029 cache.bind("session-c", "account-c", 30);
5031 assert!(cache.preferred("session-b", 30).is_none());
5032 assert_eq!(cache.preferred("session-a", 30).as_deref(), Some("account-a"));
5033 assert_eq!(cache.preferred("session-c", 30).as_deref(), Some("account-c"));
5034 assert!(cache.preferred("session-a", 131).is_none());
5035 }
5036
5037 #[test]
5038 fn captures_only_safe_codex_limit_headers() {
5039 let mut headers = reqwest::header::HeaderMap::new();
5040 headers.insert("authorization", HeaderValue::from_static("Bearer secret"));
5041 headers.insert("x-codex-plan-type", HeaderValue::from_static("plus"));
5042 headers.insert(
5043 "x-codex-primary-used-percent",
5044 HeaderValue::from_static("42"),
5045 );
5046 headers.insert(
5047 "x-codex-primary-window-minutes",
5048 HeaderValue::from_static("300"),
5049 );
5050 headers.insert(
5051 "x-codex-primary-reset-at",
5052 HeaderValue::from_static("1800000000"),
5053 );
5054 let snapshot = codex_limits_from_headers(&headers).unwrap();
5055 assert_eq!(snapshot["plan"], "plus");
5056 assert_eq!(snapshot["windows"][0]["window"], "5h");
5057 assert_eq!(snapshot["windows"][0]["used_pct"], 42.0);
5058 assert!(!snapshot.to_string().contains("secret"));
5059 }
5060
5061 #[tokio::test]
5062 async fn codex_routing_api_updates_eligibility_order_and_reserve() {
5063 let state = test_state("codex-routing");
5064 state
5065 .vault
5066 .upsert(test_openai_account("default"))
5067 .await
5068 .unwrap();
5069 state
5070 .vault
5071 .upsert(test_openai_account("work"))
5072 .await
5073 .unwrap();
5074 let (status, body) = response_json(
5075 admin_codex_routing_update(
5076 State(state.clone()),
5077 axum::Json(json!({
5078 "strategy": "priority",
5079 "reserve_pct": 15,
5080 "allow_mid_thread_failover": false,
5081 "accounts": [
5082 {"account_id": "openai-oauth-work", "eligible": true, "priority": 0, "reserve_pct": 22},
5083 {"account_id": "openai-oauth", "eligible": false, "priority": 1}
5084 ]
5085 })),
5086 )
5087 .await,
5088 )
5089 .await;
5090 assert_eq!(status, StatusCode::OK);
5091 assert_eq!(body["strategy"], "priority");
5092 assert_eq!(body["reserve_pct"], 15);
5093 assert_eq!(body["allow_mid_thread_failover"], false);
5094 assert_eq!(body["accounts"][0]["account_id"], "openai-oauth-work");
5095 assert_eq!(body["accounts"][0]["eligible"], true);
5096 assert_eq!(body["accounts"][0]["reserve_pct"], 22);
5097 assert_eq!(body["accounts"][1]["eligible"], false);
5098 assert_eq!(body["accounts"][1]["reserve_pct"], 15);
5099 assert_eq!(
5100 state
5101 .vault
5102 .policy(Provider::Openai)
5103 .account_reserve_pct["work"],
5104 22
5105 );
5106 assert_eq!(
5107 state
5108 .vault
5109 .account_for(Provider::Openai, true)
5110 .await
5111 .unwrap()
5112 .name,
5113 "work"
5114 );
5115 }
5116
5117 #[tokio::test]
5118 async fn admin_update_apply_response_shapes() {
5119 let state = test_state("admin-update-apply");
5120 set_daemon_updater(
5121 &state,
5122 Arc::new(FakeUpdater {
5123 result: Ok(json!({
5124 "applying": true,
5125 "current": "0.1.0",
5126 "latest": "0.2.0",
5127 "update_available": true,
5128 })),
5129 }),
5130 );
5131 let (status, body) = response_json(admin_update_apply(State(state.clone())).await).await;
5132 assert_eq!(status, StatusCode::ACCEPTED);
5133 assert_eq!(body["applying"], true);
5134 assert_eq!(body["latest"], "0.2.0");
5135
5136 set_daemon_updater(
5137 &state,
5138 Arc::new(FakeUpdater {
5139 result: Ok(json!({
5140 "applying": false,
5141 "current": "0.2.0",
5142 "latest": "0.2.0",
5143 "update_available": false,
5144 })),
5145 }),
5146 );
5147 let (status, body) = response_json(admin_update_apply(State(state.clone())).await).await;
5148 assert_eq!(status, StatusCode::OK);
5149 assert_eq!(body["update_available"], false);
5150
5151 set_daemon_updater(
5152 &state,
5153 Arc::new(FakeUpdater {
5154 result: Err(UpdateApplyError::Conflict(json!({
5155 "applying": false,
5156 "current": "0.1.0",
5157 "latest": "0.2.0",
5158 "update_available": true,
5159 "reason": "alex is managed by Homebrew - run `brew upgrade alex`",
5160 }))),
5161 }),
5162 );
5163 let (status, body) = response_json(admin_update_apply(State(state)).await).await;
5164 assert_eq!(status, StatusCode::CONFLICT);
5165 assert_eq!(
5166 body["reason"],
5167 "alex is managed by Homebrew - run `brew upgrade alex`"
5168 );
5169 }
5170
5171 #[tokio::test]
5172 async fn harness_run_key_mints_lists_validates_and_revokes() {
5173 let state = test_state("harness-run-key");
5174 let (_status, created) = response_json(
5175 admin_run_keys_create(
5176 State(state.clone()),
5177 Some(axum::Json(json!({
5178 "kind": "harness",
5179 "label": "pi",
5180 "ttl_seconds": 1,
5181 "tags": {"harness": "pi"},
5182 }))),
5183 )
5184 .await,
5185 )
5186 .await;
5187 assert_eq!(_status, StatusCode::CREATED);
5188 assert_eq!(created["kind"], "harness");
5189 assert_eq!(created["label"], "pi");
5190 assert_eq!(created["expires_ms"], Value::Null);
5191 let id = created["id"].as_str().unwrap().to_string();
5192 let key = created["key"].as_str().unwrap();
5193 let hash = key_hash_hex(key);
5194
5195 let entry = run_key_entry(&state, &hash).unwrap();
5196 assert_eq!(entry.expires_ms, None);
5197 assert_eq!(entry.tags_json.as_deref(), Some(r#"{"harness":"pi"}"#));
5198
5199 let (_status, listed) =
5200 response_json(admin_run_keys_list(State(state.clone()), Query(HashMap::new())).await)
5201 .await;
5202 assert_eq!(_status, StatusCode::OK);
5203 let rows = listed["run_keys"].as_array().unwrap();
5204 assert_eq!(rows.len(), 1);
5205 assert_eq!(rows[0]["id"], id);
5206 assert_eq!(rows[0]["kind"], "harness");
5207 assert_eq!(rows[0]["label"], "pi");
5208 assert_eq!(rows[0]["expires_ms"], Value::Null);
5209
5210 let (_status, revoked) =
5211 response_json(admin_run_keys_revoke(State(state.clone()), Path(id)).await).await;
5212 assert_eq!(_status, StatusCode::OK);
5213 assert_eq!(revoked["revoked"], true);
5214 assert!(run_key_entry(&state, &hash).is_none());
5215 }
5216
5217 #[tokio::test]
5218 async fn harness_run_key_requires_label() {
5219 let state = test_state("harness-run-key-label");
5220 let (status, body) = response_json(
5221 admin_run_keys_create(
5222 State(state),
5223 Some(axum::Json(json!({
5224 "kind": "harness",
5225 "tags": {"harness": "pi"},
5226 }))),
5227 )
5228 .await,
5229 )
5230 .await;
5231 assert_eq!(status, StatusCode::BAD_REQUEST);
5232 assert!(body["error"]["message"].as_str().unwrap().contains("label"));
5233 }
5234
5235 #[tokio::test]
5236 async fn wrap_key_ingests_and_updates_trace_bodies() {
5237 use base64::Engine;
5238
5239 let state = test_state("wrap-trace-ingest");
5240 let (status, created) = response_json(
5241 admin_run_keys_create(
5242 State(state.clone()),
5243 Some(axum::Json(json!({
5244 "kind": "wrap",
5245 "label": "remote-mac",
5246 "tags": {"machine": "remote-mac"},
5247 }))),
5248 )
5249 .await,
5250 )
5251 .await;
5252 assert_eq!(status, StatusCode::CREATED);
5253 assert_eq!(created["kind"], "wrap");
5254 assert_eq!(created["expires_ms"], Value::Null);
5255 let key = created["key"].as_str().unwrap();
5256 assert!(created["exports"]
5257 .as_str()
5258 .unwrap()
5259 .contains("ALEXANDRIA_TRACE_KEY"));
5260 assert!(!created["exports"]
5261 .as_str()
5262 .unwrap()
5263 .contains("OPENAI_API_KEY"));
5264 let mut headers = HeaderMap::new();
5265 headers.insert("x-api-key", HeaderValue::from_str(key).unwrap());
5266 let response_body = |text: &str| {
5267 base64::engine::general_purpose::STANDARD.encode(
5268 json!({"choices":[{"message":{"role":"assistant","content":text}}]}).to_string(),
5269 )
5270 };
5271 let payload = |text: &str| TraceIngestPayload {
5272 trace: TraceRecord {
5273 id: "agent-remote-session-1".into(),
5274 ts_request_ms: 1000,
5275 ts_response_ms: Some(2000),
5276 session_id: Some("remote-session".into()),
5277 harness: Some("agent".into()),
5278 upstream_provider: Some("cursor".into()),
5279 routed_model: Some("cursor-agent".into()),
5280 tags: Some(r#"{"stream":"dialogue"}"#.into()),
5281 ..Default::default()
5282 },
5283 request_body_b64: Some(
5284 base64::engine::general_purpose::STANDARD.encode(br#"{"messages":[]}"#),
5285 ),
5286 upstream_request_body_b64: None,
5287 response_body_b64: Some(response_body(text)),
5288 };
5289
5290 let (status, body) = response_json(
5291 traces_ingest(
5292 State(state.clone()),
5293 headers.clone(),
5294 axum::Json(payload("progress")),
5295 )
5296 .await,
5297 )
5298 .await;
5299 assert_eq!(status, StatusCode::CREATED);
5300 assert_eq!(body["outcome"], "inserted");
5301 let row = state
5302 .store
5303 .get_trace("agent-remote-session-1")
5304 .unwrap()
5305 .unwrap();
5306 assert_eq!(row["key_fingerprint"], &key_hash_hex(key)[..16]);
5307 assert_eq!(
5308 row["tags_json"].as_str().unwrap().contains("remote-mac"),
5309 true
5310 );
5311 assert!(row["req_body_path"].as_str().is_some());
5312 assert!(read_gz_text(row["resp_body_path"].as_str())
5313 .unwrap()
5314 .contains("progress"));
5315
5316 let (status, body) = response_json(
5317 traces_ingest(
5318 State(state.clone()),
5319 headers,
5320 axum::Json(payload("final answer")),
5321 )
5322 .await,
5323 )
5324 .await;
5325 assert_eq!(status, StatusCode::OK);
5326 assert_eq!(body["outcome"], "updated");
5327 let row = state
5328 .store
5329 .get_trace("agent-remote-session-1")
5330 .unwrap()
5331 .unwrap();
5332 assert!(read_gz_text(row["resp_body_path"].as_str())
5333 .unwrap()
5334 .contains("final answer"));
5335 }
5336
5337 #[tokio::test]
5338 async fn trace_ingest_rejects_non_wrap_key() {
5339 let state = test_state("trace-ingest-kind");
5340 let (status, created) = response_json(
5341 admin_run_keys_create(
5342 State(state.clone()),
5343 Some(axum::Json(json!({"kind": "run", "label": "not-wrap"}))),
5344 )
5345 .await,
5346 )
5347 .await;
5348 assert_eq!(status, StatusCode::CREATED);
5349 let mut headers = HeaderMap::new();
5350 headers.insert(
5351 "x-api-key",
5352 HeaderValue::from_str(created["key"].as_str().unwrap()).unwrap(),
5353 );
5354 let (status, _) = response_json(
5355 traces_ingest(
5356 State(state),
5357 headers,
5358 axum::Json(TraceIngestPayload {
5359 trace: TraceRecord {
5360 id: "remote-1".into(),
5361 ts_request_ms: 1,
5362 ..Default::default()
5363 },
5364 request_body_b64: None,
5365 upstream_request_body_b64: None,
5366 response_body_b64: None,
5367 }),
5368 )
5369 .await,
5370 )
5371 .await;
5372 assert_eq!(status, StatusCode::FORBIDDEN);
5373 }
5374
5375 #[tokio::test]
5376 async fn trace_ingest_rejects_updates_from_a_different_wrap_key() {
5377 let state = test_state("trace-ingest-owner");
5378 let mint = |label: &str| {
5379 admin_run_keys_create(
5380 State(state.clone()),
5381 Some(axum::Json(json!({"kind": "wrap", "label": label}))),
5382 )
5383 };
5384 let (_, first) = response_json(mint("first").await).await;
5385 let (_, second) = response_json(mint("second").await).await;
5386 let headers = |key: &str| {
5387 let mut headers = HeaderMap::new();
5388 headers.insert("x-api-key", HeaderValue::from_str(key).unwrap());
5389 headers
5390 };
5391 let payload = || TraceIngestPayload {
5392 trace: TraceRecord {
5393 id: "owned-trace-1".into(),
5394 ts_request_ms: 1,
5395 ..Default::default()
5396 },
5397 request_body_b64: None,
5398 upstream_request_body_b64: None,
5399 response_body_b64: None,
5400 };
5401
5402 let (status, _) = response_json(
5403 traces_ingest(
5404 State(state.clone()),
5405 headers(first["key"].as_str().unwrap()),
5406 axum::Json(payload()),
5407 )
5408 .await,
5409 )
5410 .await;
5411 assert_eq!(status, StatusCode::CREATED);
5412 let (status, body) = response_json(
5413 traces_ingest(
5414 State(state),
5415 headers(second["key"].as_str().unwrap()),
5416 axum::Json(payload()),
5417 )
5418 .await,
5419 )
5420 .await;
5421 assert_eq!(status, StatusCode::CONFLICT);
5422 assert!(body["error"]["message"]
5423 .as_str()
5424 .unwrap()
5425 .contains("another credential"));
5426 }
5427
5428 #[tokio::test]
5429 async fn wrap_key_cannot_invoke_models() {
5430 let state = test_state("wrap-key-inference");
5431 let (_, created) = response_json(
5432 admin_run_keys_create(
5433 State(state.clone()),
5434 Some(axum::Json(json!({"kind": "wrap", "label": "remote"}))),
5435 )
5436 .await,
5437 )
5438 .await;
5439 let mut headers = HeaderMap::new();
5440 headers.insert(
5441 "x-api-key",
5442 HeaderValue::from_str(created["key"].as_str().unwrap()).unwrap(),
5443 );
5444 let response = proxy(
5445 state,
5446 ClientFormat::OpenaiChat,
5447 "/v1/chat/completions",
5448 headers,
5449 Bytes::from_static(br#"{"model":"gpt-5.5","messages":[]}"#),
5450 None,
5451 )
5452 .await;
5453 assert_eq!(response.status(), StatusCode::FORBIDDEN);
5454 let (_, body) = response_json(response).await;
5455 assert!(body["error"]["message"]
5456 .as_str()
5457 .unwrap()
5458 .contains("only post to /traces/ingest"));
5459 }
5460
5461 #[test]
5462 fn failover_statuses_are_limited_to_auth_rate_limit_and_server_errors() {
5463 for status in [
5464 reqwest::StatusCode::UNAUTHORIZED,
5465 reqwest::StatusCode::FORBIDDEN,
5466 reqwest::StatusCode::TOO_MANY_REQUESTS,
5467 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
5468 reqwest::StatusCode::BAD_GATEWAY,
5469 reqwest::StatusCode::SERVICE_UNAVAILABLE,
5470 ] {
5471 assert!(
5472 retryable_failover_status(status),
5473 "{status} should fail over"
5474 );
5475 }
5476 for status in [
5477 reqwest::StatusCode::OK,
5478 reqwest::StatusCode::BAD_REQUEST,
5479 reqwest::StatusCode::NOT_FOUND,
5480 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
5481 ] {
5482 assert!(
5483 !retryable_failover_status(status),
5484 "{status} should be returned without failover"
5485 );
5486 }
5487 }
5488
5489 #[test]
5490 fn disabled_mid_thread_failover_only_pins_existing_codex_threads() {
5491 assert!(!retry_failover_allowed(Provider::Openai, true, false));
5492 assert!(retry_failover_allowed(Provider::Openai, false, false));
5493 assert!(retry_failover_allowed(Provider::Openai, true, true));
5494 assert!(retry_failover_allowed(Provider::Anthropic, true, false));
5495 }
5496
5497 #[tokio::test]
5498 async fn codex_planning_excludes_every_account_already_attempted() {
5499 let state = test_state("codex-plan-exclusions");
5500 for (id, name) in [
5501 ("openai-oauth-a", "a"),
5502 ("openai-oauth-b", "b"),
5503 ("openai-oauth-c", "c"),
5504 ] {
5505 state
5506 .vault
5507 .upsert(Account {
5508 id: id.into(),
5509 provider: Provider::Openai,
5510 kind: "oauth".into(),
5511 name: name.into(),
5512 description: None,
5513 paused: false,
5514 label: None,
5515 access_token: Some(format!("access-{name}")),
5516 refresh_token: Some(format!("refresh-{name}")),
5517 id_token: None,
5518 api_key: None,
5519 expires_at_ms: Some(now_ms() + 3_600_000),
5520 last_refresh_ms: Some(now_ms()),
5521 account_meta: json!({"account_id": format!("chatgpt-{name}")}),
5522 cooldown_until_ms: None,
5523 status: "active".into(),
5524 path: None,
5525 })
5526 .await
5527 .unwrap();
5528 }
5529
5530 let original = json!({"model": "gpt-5.3-codex", "stream": true});
5531 let raw = serde_json::to_vec(&original).unwrap();
5532 let client_headers = HeaderMap::new();
5533 let mut excluded = HashSet::new();
5534 let mut selected = Vec::new();
5535 for _ in 0..3 {
5536 let mut body = original.clone();
5537 let plan = plan_upstream(
5538 &state,
5539 ClientFormat::OpenaiResponses,
5540 Provider::Openai,
5541 "gpt-5.3-codex",
5542 &mut body,
5543 &raw,
5544 "trace-test",
5545 &excluded,
5546 None,
5547 &client_headers,
5548 )
5549 .await
5550 .unwrap();
5551 assert!(excluded.insert(plan.account.id.clone()));
5552 selected.push(plan.account.id);
5553 }
5554
5555 assert_eq!(
5556 selected,
5557 vec!["openai-oauth-a", "openai-oauth-b", "openai-oauth-c"]
5558 );
5559 let mut body = original;
5560 assert!(plan_upstream(
5561 &state,
5562 ClientFormat::OpenaiResponses,
5563 Provider::Openai,
5564 "gpt-5.3-codex",
5565 &mut body,
5566 &raw,
5567 "trace-test",
5568 &excluded,
5569 None,
5570 &client_headers,
5571 )
5572 .await
5573 .is_err());
5574 }
5575
5576 #[tokio::test]
5577 async fn codex_sessions_stay_affined_and_failover_rebinds() {
5578 let state = test_state("codex-session-affinity");
5579 state
5580 .vault
5581 .upsert(test_openai_account("a"))
5582 .await
5583 .unwrap();
5584 state
5585 .vault
5586 .upsert(test_openai_account("b"))
5587 .await
5588 .unwrap();
5589 state
5590 .vault
5591 .set_policies(vec![(
5592 Provider::Openai,
5593 AccountPolicy {
5594 order: vec!["a".into(), "b".into()],
5595 mode: AccountPolicyMode::RoundRobin,
5596 reserve_pct: Some(0),
5597 ..AccountPolicy::default()
5598 },
5599 )])
5600 .await;
5601
5602 let request = json!({"model": "gpt-5.3-codex", "stream": true});
5603 let raw = serde_json::to_vec(&request).unwrap();
5604 let headers = HeaderMap::new();
5605 let none_excluded = HashSet::new();
5606
5607 let mut body = request.clone();
5608 let session_a_first = plan_upstream(
5609 &state,
5610 ClientFormat::OpenaiResponses,
5611 Provider::Openai,
5612 "gpt-5.3-codex",
5613 &mut body,
5614 &raw,
5615 "trace-a-1",
5616 &none_excluded,
5617 Some("session-a"),
5618 &headers,
5619 )
5620 .await
5621 .unwrap()
5622 .account
5623 .id;
5624
5625 let mut body = request.clone();
5626 let session_b_first = plan_upstream(
5627 &state,
5628 ClientFormat::OpenaiResponses,
5629 Provider::Openai,
5630 "gpt-5.3-codex",
5631 &mut body,
5632 &raw,
5633 "trace-b-1",
5634 &none_excluded,
5635 Some("session-b"),
5636 &headers,
5637 )
5638 .await
5639 .unwrap()
5640 .account
5641 .id;
5642 assert_ne!(session_a_first, session_b_first);
5643
5644 let mut body = request.clone();
5645 let session_a_again = plan_upstream(
5646 &state,
5647 ClientFormat::OpenaiResponses,
5648 Provider::Openai,
5649 "gpt-5.3-codex",
5650 &mut body,
5651 &raw,
5652 "trace-a-2",
5653 &none_excluded,
5654 Some("session-a"),
5655 &headers,
5656 )
5657 .await
5658 .unwrap()
5659 .account
5660 .id;
5661 assert_eq!(session_a_again, session_a_first);
5662
5663 let excluded = HashSet::from([session_a_first.clone()]);
5664 let mut body = request.clone();
5665 let failover = plan_upstream(
5666 &state,
5667 ClientFormat::OpenaiResponses,
5668 Provider::Openai,
5669 "gpt-5.3-codex",
5670 &mut body,
5671 &raw,
5672 "trace-a-failover",
5673 &excluded,
5674 Some("session-a"),
5675 &headers,
5676 )
5677 .await
5678 .unwrap()
5679 .account
5680 .id;
5681 assert_eq!(failover, session_b_first);
5682
5683 let mut body = request;
5684 let after_failover = plan_upstream(
5685 &state,
5686 ClientFormat::OpenaiResponses,
5687 Provider::Openai,
5688 "gpt-5.3-codex",
5689 &mut body,
5690 &raw,
5691 "trace-a-3",
5692 &none_excluded,
5693 Some("session-a"),
5694 &headers,
5695 )
5696 .await
5697 .unwrap()
5698 .account
5699 .id;
5700 assert_eq!(after_failover, failover);
5701 }
5702}