@@ -538,10 +580,10 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
el('div', {textContent: formatTime(r.timestamp)}),
el('div', {className: statusClass(r.status_code), textContent: String(r.status_code)}),
el('div', {textContent: r.backend || '--'}),
- el('div', {style:{color:'#8b949e'}}, modelChildren),
+ el('div', {style:{color:'var(--text-2)'}}, modelChildren),
el('div', {textContent: formatLatency(r.latency_ms)}),
- el('div', {textContent: r.input_tokens ? String(r.input_tokens) : '--', style:{color:'#8b949e'}}),
- el('div', {textContent: r.output_tokens ? String(r.output_tokens) : '--', style:{color:'#8b949e'}})
+ el('div', {textContent: r.input_tokens ? String(r.input_tokens) : '--', style:{color:'var(--text-2)'}}),
+ el('div', {textContent: r.output_tokens ? String(r.output_tokens) : '--', style:{color:'var(--text-2)'}})
]);
if (clickable && r.request_id) {
row.addEventListener('click', function() { toggleRequestDetail(row, r.request_id); });
@@ -672,7 +714,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
var list = el('div', {className: 'timeline-list'}, null);
items.forEach(function(item) {
var widthPct = Math.max(4, Math.round(((item.latency_ms || 0) / maxLatency) * 100));
- var barColor = item.status_code >= 500 ? '#f85149' : (item.status_code >= 400 ? '#d29922' : '#3fb950');
+ var barColor = item.status_code >= 500 ? '#e05252' : (item.status_code >= 400 ? '#d4922b' : '#4caf6e');
var timelineItem = el('div', {className: 'timeline-item'}, [
el('div', {className: 'timeline-meta'}, [
el('div', {textContent: formatTime(item.finished_at)}),
@@ -814,7 +856,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
document.getElementById('btn-pause-feed').addEventListener('click', function() {
feedPaused = !feedPaused;
this.textContent = feedPaused ? 'Resume' : 'Pause';
- this.style.borderColor = feedPaused ? '#d29922' : '#484f58';
+ this.style.borderColor = feedPaused ? 'var(--warn)' : 'var(--border)';
});
document.getElementById('obs-window').addEventListener('change', loadObservability);
document.getElementById('obs-backend').addEventListener('change', loadObservability);
@@ -853,7 +895,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
var pct = (data.error_rate * 100).toFixed(1) + '%';
var errEl = document.getElementById('stat-error-rate');
errEl.textContent = pct;
- errEl.style.color = data.error_rate > 0.05 ? '#f85149' : '#3fb950';
+ errEl.style.color = data.error_rate > 0.05 ? 'var(--err)' : 'var(--ok)';
}
if (data.requests_per_second != null) {
document.getElementById('stat-rpm').textContent = (data.requests_per_second * 60).toFixed(0);
@@ -929,7 +971,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
var pct = (metrics.error_rate * 100).toFixed(1) + '%';
var errEl = document.getElementById('stat-error-rate');
errEl.textContent = pct;
- errEl.style.color = metrics.error_rate > 0.05 ? '#f85149' : '#3fb950';
+ errEl.style.color = metrics.error_rate > 0.05 ? 'var(--err)' : 'var(--ok)';
}
// Streaming metrics from the total aggregate
updateDashboardMetrics(metrics);
@@ -995,8 +1037,8 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
'obs-chart-requests',
series,
[
- {color: '#4a9eff', getValue: function(point) { return point.requests_total || 0; }},
- {color: '#f85149', getValue: function(point) { return point.requests_error || 0; }}
+ {color: '#e8a030', getValue: function(point) { return point.requests_total || 0; }},
+ {color: '#e05252', getValue: function(point) { return point.requests_error || 0; }}
],
axisStart, axisEnd
);
@@ -1005,8 +1047,8 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
'obs-chart-tokens',
series,
[
- {color: '#3fb950', getValue: function(point) { return point.input_tokens || 0; }},
- {color: '#d29922', getValue: function(point) { return point.output_tokens || 0; }}
+ {color: '#4caf6e', getValue: function(point) { return point.input_tokens || 0; }},
+ {color: '#6eb5c0', getValue: function(point) { return point.output_tokens || 0; }}
],
axisStart, axisEnd
);
@@ -1014,7 +1056,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
renderLineChart(
'obs-chart-cost',
series,
- [{color: '#56d4dd', getValue: function(point) { return point.cost_usd || 0; }}],
+ [{color: '#c87dd4', getValue: function(point) { return point.cost_usd || 0; }}],
axisStart, axisEnd
);
@@ -1094,10 +1136,10 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
el('div', {className: statusClass(r.status_code), textContent: String(r.status_code)}),
el('div', {textContent: r.backend || '--'}),
el('div', {textContent: keyLabel, style:{color:'#8b949e',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}),
- el('div', {style:{color:'#8b949e'}}, modelChildren),
+ el('div', {style:{color:'var(--text-2)'}}, modelChildren),
el('div', {textContent: formatLatency(r.latency_ms)}),
- el('div', {textContent: r.input_tokens ? String(r.input_tokens) : '--', style:{color:'#8b949e'}}),
- el('div', {textContent: r.output_tokens ? String(r.output_tokens) : '--', style:{color:'#8b949e'}})
+ el('div', {textContent: r.input_tokens ? String(r.input_tokens) : '--', style:{color:'var(--text-2)'}}),
+ el('div', {textContent: r.output_tokens ? String(r.output_tokens) : '--', style:{color:'var(--text-2)'}})
]);
if (r.request_id) {
row.addEventListener('click', function() { toggleRequestDetail(row, r.request_id); });
@@ -1174,7 +1216,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
var grid = el('div', {className: 'model-grid'}, [bigLabel, bigInput, smallLabel, smallInput]);
var nameBadge = (overrideKeys.some(function(k) { return k.startsWith(name + '.'); }))
? el('span', {className: 'badge badge-override', textContent: 'overridden', style:{marginLeft:'6px'}}) : null;
- var nameEl = el('div', {className: 'form-label'}, [el('span', {style:{color:'#4a9eff'}, textContent: name})].concat(nameBadge ? [nameBadge] : []));
+ var nameEl = el('div', {className: 'form-label'}, [el('span', {className:'accent mono', textContent: name})].concat(nameBadge ? [nameBadge] : []));
var group = el('div', {className: 'form-group'}, [nameEl, grid]);
form.appendChild(group);
});
@@ -1237,7 +1279,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
if (val == null) return;
rows.push(el('div', {className: 'model-grid', style:{marginTop:'4px'}}, [
el('div', {className: 'label', textContent: k + ':'}),
- el('div', {textContent: String(val), style:{fontFamily:'monospace',fontSize:'12px',color:'#c9d1d9'}})
+ el('div', {textContent: String(val), style:{fontFamily:'monospace',fontSize:'12px',color:'var(--text-1)'}})
]));
});
if (rows.length === 0) return;
@@ -1263,7 +1305,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
var failPolicy = data['RATE_LIMIT_FAIL_POLICY'] || 'open (default)';
secCard.appendChild(el('div', {className: 'model-grid', style:{marginTop:'0'}}, [
el('div', {className: 'label', textContent: 'IP Allowlist:'}),
- el('div', {textContent: ipVal || 'not set (all IPs allowed)', style:{fontFamily:'monospace',fontSize:'12px',color: ipVal ? '#c9d1d9' : '#8b949e'}}),
+ el('div', {textContent: ipVal || 'not set (all IPs allowed)', style:{fontFamily:'monospace',fontSize:'12px',color: ipVal ? 'var(--text-1)' : 'var(--text-2)'}}),
el('div', {className: 'label', textContent: 'Rate Limit Fail Policy:'}),
el('div', {textContent: failPolicy, style:{fontFamily:'monospace',fontSize:'12px'}})
]));
@@ -1393,7 +1435,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
budgetCell = el('td', null, budgetChildren);
} else {
var spendAny = k.total_spend != null ? ('$' + Number(k.total_spend).toFixed(4)) : '--';
- budgetCell = el('td', {textContent: spendAny, style:{color:'#8b949e'}});
+ budgetCell = el('td', {textContent: spendAny, style:{color:'var(--text-2)'}});
}
// Allowed models chips
@@ -1408,7 +1450,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
}
// Prefix as link → filter requests
- var prefixLink = el('a', {textContent: k.key_prefix || '--', style:{fontFamily:'monospace',cursor:'pointer',color:'#4a9eff'}});
+ var prefixLink = el('a', {textContent: k.key_prefix || '--', className:'mono accent', style:{cursor:'pointer'}});
prefixLink.addEventListener('click', (function(kid) {
return function() {
// Switch to Request Log tab with this key pre-filtered
@@ -1446,9 +1488,9 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
actionTd.appendChild(spendBtn);
var row = el('tr', null, [
el('td', null, [prefixLink]),
- el('td', {textContent: k.description || '--', style:{color:'#8b949e'}}),
+ el('td', {textContent: k.description || '--', style:{color:'var(--text-2)'}}),
el('td', {textContent: k.role || 'developer'}),
- el('td', {textContent: rpmTpm, style:{color:'#8b949e'}}),
+ el('td', {textContent: rpmTpm, style:{color:'var(--text-2)'}}),
budgetCell,
el('td', {textContent: String(k.total_requests || 0)}),
modelsCell,
@@ -1702,11 +1744,11 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
entries.forEach(function(e) {
var targetText = e.target_type + (e.target_id ? ' #' + e.target_id : '');
tbody.appendChild(el('tr', null, [
- el('td', {textContent: e.timestamp || '--', style:{fontFamily:'monospace',fontSize:'11px',color:'#8b949e'}}),
- el('td', {textContent: e.action || '--', style:{fontFamily:'monospace',color:'#4a9eff'}}),
- el('td', {textContent: targetText, style:{color:'#8b949e'}}),
- el('td', {textContent: e.detail || '--', style:{color:'#c9d1d9',maxWidth:'300px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}),
- el('td', {textContent: e.source_ip || '--', style:{color:'#8b949e',fontFamily:'monospace',fontSize:'11px'}})
+ el('td', {textContent: e.timestamp || '--', className:'mono dim', style:{fontSize:'11px'}}),
+ el('td', {textContent: e.action || '--', className:'mono accent'}),
+ el('td', {textContent: targetText, className:'dim'}),
+ el('td', {textContent: e.detail || '--', style:{maxWidth:'300px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}),
+ el('td', {textContent: e.source_ip || '--', className:'mono dim', style:{fontSize:'11px'}})
]));
});
container.appendChild(el('table', {className: 'keys-grid'}, [thead, tbody]));
@@ -1791,7 +1833,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
var actionTd = el('td', null, [removeBtn]);
var row = el('tr', null, [
el('td', {textContent: m.model_name, style:{fontFamily:'monospace'}}),
- el('td', {textContent: String(m.deployments || 0), style:{color:'#8b949e'}}),
+ el('td', {textContent: String(m.deployments || 0), style:{color:'var(--text-2)'}}),
actionTd
]);
tbody.appendChild(row);
diff --git a/crates/proxy/src/admin/routes/mod.rs b/crates/proxy/src/admin/routes/mod.rs
index fa4d699..31d5bb9 100644
--- a/crates/proxy/src/admin/routes/mod.rs
+++ b/crates/proxy/src/admin/routes/mod.rs
@@ -234,6 +234,12 @@ async fn reject_cross_origin(
/// on first use (one-time token), preventing replay across multiple mutating requests.
/// Returns 403 with a descriptive error if the token is missing, mismatched, or unknown.
/// Applied inside validate_admin_token so unauthenticated requests are rejected first.
+///
+/// Long-lived sessions: the admin SPA fetches a fresh token before every mutating
+/// request (not once at login), so sessions open for more than 24 h still work as
+/// long as the browser can reach GET /admin/csrf-token. Both the cookie and the
+/// server-side entry expire after 24 h; if the cookie is gone the next mutation
+/// returns 403 until the page is refreshed.
pub async fn validate_csrf(
axum::extract::State(shared): axum::extract::State
,
req: axum::extract::Request,
@@ -453,7 +459,8 @@ async fn serve_spa() -> axum::response::Response {
let csp = format!(
"default-src 'self'; script-src 'self' 'nonce-{nonce}'; \
- style-src 'self' 'nonce-{nonce}'; \
+ style-src 'self' 'nonce-{nonce}' https://fonts.bunny.net; \
+ font-src https://fonts.bunny.net; \
connect-src 'self' ws: wss:; img-src 'self' data:; \
frame-ancestors 'none'"
);
diff --git a/crates/proxy/src/batch/anthropic_batch.rs b/crates/proxy/src/batch/anthropic_batch.rs
index 2df36e8..b22c705 100644
--- a/crates/proxy/src/batch/anthropic_batch.rs
+++ b/crates/proxy/src/batch/anthropic_batch.rs
@@ -67,8 +67,26 @@ pub(crate) async fn create_anthropic_batch(
}
};
- // Derive model name from first request (all should use the same model after mapping).
+ // Derive model name from first request. Anthropic's batch API allows mixed
+ // models in theory, but OpenAI's batch API requires all requests to use the
+ // same model. Reject mixed-model batches here rather than letting OpenAI
+ // reject them with an opaque error.
let model = req.requests[0].params.model.clone();
+ if let Some(mismatched) = req
+ .requests
+ .iter()
+ .find(|r| r.params.model != model)
+ {
+ return error_response(
+ StatusCode::UNPROCESSABLE_ENTITY,
+ ErrorType::InvalidRequestError,
+ &format!(
+ "All requests in a batch must use the same model. \
+ First request uses '{}', but another uses '{}'.",
+ model, mismatched.params.model
+ ),
+ );
+ }
// Translate Anthropic JSONL to OpenAI JSONL.
let openai_jsonl = translate_batch_to_openai_jsonl(&req.requests);
diff --git a/crates/proxy/src/main.rs b/crates/proxy/src/main.rs
index e7c3c31..6c6c38d 100644
--- a/crates/proxy/src/main.rs
+++ b/crates/proxy/src/main.rs
@@ -469,11 +469,15 @@ async fn async_main(args: Vec) {
loop {
interval.tick().await;
let now = anyllm_proxy::admin::keys::now_ms();
- // Check and prune old rate limit states
- for entry in virtual_keys_pruner.iter() {
- let _ = entry.rate_state.check_rpm(0, now);
- let _ = entry.rate_state.check_tpm(0, now);
- }
+ // Single pass: slide rate-limit windows forward (frees old
+ // buckets) and drop expired keys. Without active eviction,
+ // expired keys accumulate in the DashMap until next auth use.
+ let now_secs = (now / 1000) as i64;
+ virtual_keys_pruner.retain(|_, v| {
+ let _ = v.rate_state.check_rpm(0, now);
+ let _ = v.rate_state.check_tpm(0, now);
+ v.expires_at.is_none_or(|exp| now_secs < exp)
+ });
}
});
@@ -500,27 +504,46 @@ async fn async_main(args: Vec) {
};
// Admin token: use env var or generate 256-bit random hex written to a file.
- let admin_token = std::env::var("ADMIN_TOKEN").unwrap_or_else(|_| {
- let mut buf = [0u8; 32];
- getrandom::fill(&mut buf).expect("getrandom failed");
- let token = hex::encode(buf);
- let token_path = resolve_admin_token_path();
- let token_path = token_path.to_string_lossy().to_string();
- // Write token to file with restrictive permissions instead of stderr,
- // because stderr is captured by container log drivers in production.
- if let Err(e) = write_token_file(&token_path, &token) {
- // Do not print the token to stderr: container log drivers capture
- // stderr and persist it in centralized logging systems.
- panic!(
- "Cannot write admin token to {token_path}: {e}. \
- Set ADMIN_TOKEN env var explicitly or ensure the path is writable."
- );
- } else {
- // Log the path, not the token itself.
- tracing::info!(path = %token_path, "generated admin token written to file (set ADMIN_TOKEN env var to avoid this)");
+ let admin_token = match std::env::var("ADMIN_TOKEN") {
+ Ok(t) => {
+ if t.len() < 32 {
+ tracing::warn!(
+ len = t.len(),
+ "ADMIN_TOKEN is shorter than 32 characters; \
+ use a longer random value to reduce brute-force risk \
+ (generate one with: openssl rand -hex 32)"
+ );
+ }
+ t
}
- token
- });
+ Err(_) => {
+ let mut buf = [0u8; 32];
+ getrandom::fill(&mut buf).expect("getrandom failed");
+ let token = hex::encode(buf);
+ let token_path = resolve_admin_token_path();
+ let token_path = token_path.to_string_lossy().to_string();
+ // Write token to file with restrictive permissions instead of stderr,
+ // because stderr is captured by container log drivers in production.
+ if let Err(e) = write_token_file(&token_path, &token) {
+ // Do not print the token to stderr: container log drivers capture
+ // stderr and persist it in centralized logging systems.
+ panic!(
+ "Cannot write admin token to {token_path}: {e}. \
+ Set ADMIN_TOKEN env var explicitly or ensure the path is writable."
+ );
+ } else {
+ // Log the path and retrieval command, not the token itself.
+ // A new token is generated on every restart; set ADMIN_TOKEN for a fixed value.
+ tracing::info!(
+ path = %token_path,
+ "admin token written to file — \
+ retrieve with: cat {token_path} \
+ | set ADMIN_TOKEN env var to use a fixed token across restarts"
+ );
+ }
+ token
+ }
+ };
let admin_token = Arc::new(zeroize::Zeroizing::new(admin_token));
// Spawn periodic tasks: log retention and metrics snapshot broadcast.
diff --git a/crates/proxy/src/server/bedrock_passthrough.rs b/crates/proxy/src/server/bedrock_passthrough.rs
index 067db23..2189ec2 100644
--- a/crates/proxy/src/server/bedrock_passthrough.rs
+++ b/crates/proxy/src/server/bedrock_passthrough.rs
@@ -198,8 +198,15 @@ async fn bedrock_stream(
loop {
match eventstream::decode_frame(&mut event_buf) {
Err(e) => {
- tracing::warn!(error = %e, "Bedrock event stream CRC mismatch, dropping frame");
- // Buffer was already advanced past the bad frame; continue.
+ // On prelude CRC failure the decoder does NOT advance the buffer
+ // (total_len is untrustworthy), so continuing the inner loop
+ // would call decode_frame on the same bytes indefinitely.
+ // On message CRC failure the buffer is advanced, but the frame
+ // is corrupt regardless. Either way, close the connection per
+ // the decoder's contract ("caller closes the connection").
+ tracing::error!(error = %e, "Bedrock event stream CRC error; closing connection");
+ metrics.record_error();
+ return;
}
Ok(None) => break, // no complete frame yet
Ok(Some(payload)) => {
diff --git a/docker-compose.yml b/docker-compose.yml
index ce90059..c9ff81b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -38,6 +38,16 @@ services:
ADMIN_BIND: "0.0.0.0"
ADMIN_DB_PATH: /data/admin.db
ADMIN_TOKEN_PATH: /data/.admin_token
+ # Admin token — choose one approach:
+ #
+ # Option 1 (recommended): set a strong fixed token in .env or here.
+ # Generate: openssl rand -hex 32
+ # Token persists across restarts; no need to retrieve after each start.
+ # ADMIN_TOKEN: ""
+ #
+ # Option 2 (auto-generate): leave ADMIN_TOKEN unset.
+ # A new random token is written to /data/.admin_token on every restart.
+ # Retrieve it: docker compose exec proxy cat /data/.admin_token
# WEBUI: "1" # uncomment to always enable admin UI
# PROXY_CONFIG: /config/config.yaml
ports: