Phase 0: Dual-key router — no hardcoded keys, gemma sync, 262K context fix

- Removed all hardcoded API_KEYS from source (env var now REQUIRED)
- Added dual-key transition support (old keys deprecated, logged)
- Model sync: qwen3.5-9b-vlm → gemma-4-12b everywhere
- Dense context: 131K → 262K (all 3 models at 262K)
- Added /admin/keys endpoints for key lifecycle management
- Added ADMIN_KEY env var for admin auth
- Dashboard already synced to gemma-4-12b labels

Deployment: set API_KEYS from phase0-dual-keys.json in docker-compose
Transition: 7 days dual-key, then POST /admin/keys/revoke
This commit is contained in:
Abiba Bot
2026-06-06 00:51:29 +00:00
parent 1e24bc3b9b
commit 39cf297a8f
3 changed files with 634 additions and 72 deletions
+139 -10
View File
@@ -16,7 +16,7 @@ def fetch_state():
def broadcast_loop(): def broadcast_loop():
while True: while True:
time.sleep(3) time.sleep(5)
data = fetch_state(); payload = json.dumps(data) data = fetch_state(); payload = json.dumps(data)
with sse_lock: with sse_lock:
dead = [q for q in sse_subscribers if not q.put(payload)] dead = [q for q in sse_subscribers if not q.put(payload)]
@@ -101,7 +101,32 @@ body { background: #0b0f17; color: #bcc3cd; font-family: -apple-system, BlinkMac
<div class="col-md-4"><div class="chart-card"><div class="title">Model Distribution</div><div id="route-bars"></div></div></div> <div class="col-md-4"><div class="chart-card"><div class="title">Model Distribution</div><div id="route-bars"></div></div></div>
<div class="col-md-4"><div class="chart-card"><div class="title">Agent Activity</div><div id="agent-bars"></div></div></div> <div class="col-md-4"><div class="chart-card"><div class="title">Agent Activity</div><div id="agent-bars"></div></div></div>
<!-- ROW 4: Live Stream --> <!-- ROW 4: Performance Analytics -->
<div class="col-12 mb-2"><div class="d-flex align-items-center gap-2"><span class="fw-bold text-white" style="font-size:14px">&#x1F4CA; Performance Analytics</span>
<div class="d-flex gap-1 ms-auto">
<button class="btn-sm-period active" onclick="switchPerfWindow('1')">1h</button>
<button class="btn-sm-period" onclick="switchPerfWindow('24')">24h</button>
</div>
</div></div>
<div class="col-md-6"><div class="chart-card"><div class="title">Latency — P50 / P95 / P99 (ms)</div><div id="perf-latency"></div></div></div>
<div class="col-md-6"><div class="chart-card"><div class="title">Throughput — Tokens / sec</div><div id="perf-throughput"></div></div></div>
<div class="col-md-6"><div class="chart-card"><div class="title">Routing Effectiveness — by Reason</div><div id="perf-reasons"></div></div></div>
<div class="col-md-6"><div class="chart-card"><div class="title">Agent Performance</div><div id="perf-agents"></div></div></div>
<!-- ROW 5: Latency vs Context Scatter -->
<div class="col-12"><div class="chart-card"><div class="title d-flex justify-content-between align-items-center">
<span>Latency vs Prompt Size — by Model</span>
<div class="d-flex gap-2">
<select id="scatter-model" onchange="loadScatter()" style="font-size:10px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:4px;padding:2px 6px">
<option value="all">All Models</option>
<option value="gemma-4-12b">12B VLM</option>
<option value="qwen3.6-27B-code">27B Dense</option>
<option value="qwen3.6-35B-A3B">35B MoE</option>
</select>
</div>
</div><div id="scatter-plot" style="height:200px;position:relative"></div><div id="scatter-legend" class="d-flex justify-content-center gap-3 mt-2 flex-wrap small"></div></div></div>
<!-- ROW 6: Live Stream -->
<div class="col-12"><div class="chart-card"><div class="title">Live Stream</div> <div class="col-12"><div class="chart-card"><div class="title">Live Stream</div>
<div class="table-responsive"><table class="table table-custom mb-0"> <div class="table-responsive"><table class="table table-custom mb-0">
<thead><tr><th>Time</th><th>Agent</th><th>Model</th><th>Reason</th><th>Tier</th></tr></thead> <thead><tr><th>Time</th><th>Agent</th><th>Model</th><th>Reason</th><th>Tier</th></tr></thead>
@@ -113,13 +138,13 @@ body { background: #0b0f17; color: #bcc3cd; font-family: -apple-system, BlinkMac
<script> <script>
// ═══ MODEL CONFIG — Update ONLY this array for model migrations ═══ // ═══ MODEL CONFIG — Update ONLY this array for model migrations ═══
var MODELS=[ var MODELS=[
{id:'qwen3.6-35B-A3B',label:'Qwen MoE',gpu:'MoE - Strix Halo',color:'#a78bfa',short:'MoE',domId:'gpu-moe'}, {id:'qwen3.6-35B-A3B',label:'Qwen MoE',gpu:'MoE - Strix Halo',color:'#a78bfa',short:'MoE',domId:'gpu-moe',perf:'35B MoE'},
{id:'qwen3.6-27B-code',label:'Qwen Code',gpu:'Dense - RTX 3090',color:'#f59e0b',short:'Dense',domId:'gpu-dense'}, {id:'qwen3.6-27B-code',label:'Qwen Code',gpu:'Dense - RTX 3090',color:'#f59e0b',short:'Dense',domId:'gpu-dense',perf:'27B Dense'},
{id:'gemma-4-12b',label:'Gemma 4 12B',gpu:'VLM - RTX 5070',color:'#22c55e',short:'VLM',domId:'gpu-light'} {id:'gemma-4-12b',label:'Gemma 4 12B',gpu:'VLM - RTX 5070',color:'#22c55e',short:'VLM',domId:'gpu-light',perf:'12B VLM'}
]; ];
// Auto-derived lookups — DO NOT EDIT below // Auto-derived lookups — DO NOT EDIT below
var MC={},ML={},GL={},ids={},lb={}; var MC={},ML={},GL={},ids={},lb={},mlab={},mcol={};
MODELS.forEach(function(m){MC[m.id]=m.color;ML[m.id]=m.label;GL[m.id]=m.gpu;ids[m.id]=m.domId;lb[m.id]=m.short;}); MODELS.forEach(function(m){MC[m.id]=m.color;ML[m.id]=m.label;GL[m.id]=m.gpu;ids[m.id]=m.domId;lb[m.id]=m.short;mlab[m.id]=m.perf;mcol[m.id]=m.color;});
function $(id){return document.getElementById(id);} function $(id){return document.getElementById(id);}
function render(data){ function render(data){
@@ -166,7 +191,7 @@ el.innerHTML=h;
function renderGPUMetrics(data){ function renderGPUMetrics(data){
var el=$('gpu-metrics-card');if(!el)return; var el=$('gpu-metrics-card');if(!el)return;
var lb={'qwen3.6-35B-A3B':'MoE','qwen3.6-27B-code':'Dense','gemma-4-12b':'VLM'};
var h='';data.gpus.forEach(function(g){ var h='';data.gpus.forEach(function(g){
var nm=lb[g.id]||g.id,tp=g.temp_c||0,ut=g.gpu_util_pct||0,pw=g.power_w||0,pl=g.power_limit_w||0; var nm=lb[g.id]||g.id,tp=g.temp_c||0,ut=g.gpu_util_pct||0,pw=g.power_w||0,pl=g.power_limit_w||0;
var tc=tp>85?'#ef4444':tp>70?'#f59e0b':'#22c55e',uc=ut>90?'#ef4444':ut>70?'#f59e0b':'#22c55e'; var tc=tp>85?'#ef4444':tp>70?'#f59e0b':'#22c55e',uc=ut>90?'#ef4444':ut>70?'#f59e0b':'#22c55e';
@@ -193,8 +218,92 @@ var grid='';for(var g=0;g<=4;g++){var y=(g/4)*H;grid+='<line x1=\"0\" y1=\"'+y.t
cn.innerHTML='<svg viewBox=\"0 0 100 '+(H+16)+'\" style=\"width:100%;height:'+(H+20)+'px;display:block\" preserveAspectRatio=\"none\">'+grid+paths+'</svg>'; cn.innerHTML='<svg viewBox=\"0 0 100 '+(H+16)+'\" style=\"width:100%;height:'+(H+20)+'px;display:block\" preserveAspectRatio=\"none\">'+grid+paths+'</svg>';
lg.innerHTML=mn.map(function(m){return'<span class=\"d-flex align-items-center gap-1\"><svg width=\"14\" height=\"8\"><line x1=\"0\" y1=\"4\" x2=\"14\" y2=\"4\" stroke=\"'+(MC[m]||'#38bdf8')+'\" stroke-width=\"2\"/></svg>'+(ML[m]||m)+'</span>';}).join(''); lg.innerHTML=mn.map(function(m){return'<span class=\"d-flex align-items-center gap-1\"><svg width=\"14\" height=\"8\"><line x1=\"0\" y1=\"4\" x2=\"14\" y2=\"4\" stroke=\"'+(MC[m]||'#38bdf8')+'\" stroke-width=\"2\"/></svg>'+(ML[m]||m)+'</span>';}).join('');
} }
function poll(){fetch('/api/state').then(function(r){return r.json()}).then(function(data){render(data);$('connection-status').textContent='live';}).catch(function(){$('connection-status').textContent='reconnecting';});} var perfWindow='24';
poll();setInterval(poll,3000);loadTS(); function switchPerfWindow(w){perfWindow=w;document.querySelectorAll('.btn-sm-period').forEach(function(b,i){if(i>=4)b.classList.toggle('active',b.textContent.trim().replace('h','')===w)});loadPerf();}
function loadPerf(){fetch('/api/performance?window='+perfWindow).then(function(r){return r.json()}).then(renderPerf).catch(function(){})}
function renderPerf(d){
var models=d.models||[],reasons=d.reasons||[],agents=d.agents||[],sum=d.summary||{};
// Latency bars: p50/p95/p99 per model
// mlab/mcol auto-derived from MODELS above
if(!models.length){$('perf-latency').innerHTML='<div class="text-secondary small text-center py-4">Accumulating data...</div>';return;}
var maxLat=Math.max(...models.map(function(m){return m.latency.p99||0}),1);
var latHTML=models.map(function(m){
var l=m.latency||{},p50=l.p50||0,p95=l.p95||0,p99=l.p99||0,c=mcol[m.model]||'#38bdf8';
return'<div class="mb-2" style="font-size:11px"><div class="d-flex justify-content-between mb-1"><span style="color:#e2e8f0">'+mlab[m.model]+'</span><span class="text-secondary">'+m.count+' reqs</span></div>'+
'<div class="d-flex align-items-center gap-2 mb-1"><span class="text-secondary" style="min-width:28px">p50</span><div class="flex-grow-1" style="height:14px;background:#1e293b;border-radius:4px;overflow:hidden;position:relative"><div style="position:absolute;left:0;top:0;height:100%;width:'+(p50/maxLat*100)+'%;background:'+c+';opacity:0.3;border-radius:4px"></div><div style="position:absolute;left:0;top:0;height:100%;width:'+(p95/maxLat*100)+'%;background:'+c+';opacity:0.5;border-radius:4px"></div><div style="position:absolute;left:0;top:0;height:100%;width:'+(p99/maxLat*100)+'%;background:'+c+';border-radius:4px"></div></div><span style="color:'+c+';min-width:48px;text-align:right;font-variant-numeric:tabular-nums">'+p99+'ms</span></div>'+
'<div class="d-flex gap-3" style="font-size:10px;color:#64748b;padding-left:32px"><span>p50: '+p50+'ms</span><span>p95: '+p95+'ms</span><span>p99: '+p99+'ms</span></div></div>';
}).join('');
$('perf-latency').innerHTML=latHTML;
// Throughput comparison
var maxTps=Math.max(...models.map(function(m){return m.throughput.avg_tokens_per_sec||0}),1);
var tpsHTML=models.map(function(m){
var t=m.throughput||{},avg=t.avg_tokens_per_sec||0,p50=t.p50||0,c=mcol[m.model]||'#38bdf8';
var isAllStreaming = avg===0 && p50===0;
if(isAllStreaming){
return'<div class="mb-2" style="font-size:11px"><div class="d-flex justify-content-between mb-1"><span style="color:#e2e8f0">'+mlab[m.model]+'</span><span style="color:#64748b;font-style:italic">streaming only</span></div><div class="text-secondary" style="font-size:10px">t/s available for non-streaming requests only</div></div>';
}
return'<div class="mb-2" style="font-size:11px"><div class="d-flex justify-content-between mb-1"><span style="color:#e2e8f0">'+mlab[m.model]+'</span><span style="color:'+c+'" class="fw-bold">'+avg+' tok/s</span></div>'+
'<div class="d-flex align-items-center gap-2"><span class="text-secondary" style="min-width:28px">avg</span><div class="flex-grow-1" style="height:6px;background:#1e293b;border-radius:3px;overflow:hidden"><div style="height:100%;width:'+(Math.max(avg/maxTps*100,6))+'%;background:'+c+';border-radius:3px"></div></div><span class="small" style="color:'+c+';min-width:54px;text-align:right">'+avg+' tok/s</span></div>'+
'<div class="d-flex align-items-center gap-2 mt-1"><span class="text-secondary" style="min-width:28px;font-size:10px">p50</span><div class="flex-grow-1" style="height:4px;background:#1e293b;border-radius:2px;overflow:hidden"><div style="height:100%;width:'+(Math.max(p50/maxTps*100,4))+'%;background:'+c+';opacity:0.5;border-radius:2px"></div></div><span style="font-size:10px;color:#64748b">'+p50+' tok/s</span></div></div>';
}).join('');
$('perf-throughput').innerHTML=tpsHTML;
// Routing reasons table
if(reasons.length){
var rHTML='<table class="table table-custom mb-0"><thead><tr><th>Reason</th><th>Count</th><th>Avg Lat</th><th>P95 Lat</th></tr></thead><tbody>';
reasons.forEach(function(r){rHTML+='<tr><td>'+r.reason+'</td><td>'+r.count+'</td><td>'+r.avg_total_ms+'ms</td><td>'+r.p95_total_ms+'ms</td></tr>';});
rHTML+='</tbody></table>';$('perf-reasons').innerHTML=rHTML;
}else{$('perf-reasons').innerHTML='<div class="text-secondary small text-center py-3">-</div>';}
// Agent performance
if(agents.length){
var maxAc=Math.max(...agents.map(function(a){return a.count||0}),1);
var aHTML=agents.map(function(a){return'<div class="mb-2" style="font-size:11px"><div class="d-flex justify-content-between mb-1"><span style="color:#e2e8f0">'+a.agent+'</span><span class="text-secondary">'+a.count+' reqs</span></div><div class="d-flex align-items-center gap-2"><div class="flex-grow-1" style="height:4px;background:#1e293b;border-radius:2px;overflow:hidden"><div style="height:100%;width:'+(a.count/maxAc*100)+'%;background:#38bdf8;border-radius:2px"></div></div><span class="small" style="color:#38bdf8;min-width:60px;text-align:right">'+a.avg_total_ms+'ms avg</span></div></div>';}).join('');
$('perf-agents').innerHTML=aHTML;
}else{$('perf-agents').innerHTML='<div class="text-secondary small text-center py-3">-</div>';}
}
var sseConnected = false;
function poll(){if(sseConnected)return;fetch('/api/state').then(function(r){return r.json()}).then(function(data){render(data);$('connection-status').textContent='live';}).catch(function(){$('connection-status').textContent='reconnecting';});}
// SSE connection tracking
var es = new EventSource('/api/stream');
es.onopen = function(){sseConnected = true; $('connection-status').textContent = 'live (SSE)';};
es.onmessage = function(e){try{render(JSON.parse(e.data));}catch(ex){}};
es.onerror = function(){sseConnected = false; $('connection-status').textContent = 'polling';};
function loadScatter(){
var m=$('scatter-model').value;
fetch('/api/scatter?window=24&model='+m).then(function(r){return r.json()}).then(renderScatter).catch(function(){});
}
function renderScatter(d){
var pts=d.points||[],el=$('scatter-plot'),lg=$('scatter-legend');
if(!pts.length){el.innerHTML='<div class="text-secondary small text-center py-5">No data yet</div>';return;}
var mcol=Object.assign({unknown:'#38bdf8'},mcol);
// mlab auto-derived from MODELS above
var maxX=Math.max.apply(null,pts.map(function(p){return p.prompt_tokens||0}))||1000;
var maxY=Math.max.apply(null,pts.map(function(p){return p.inference_ms||0}))||5000;
// Log scale for X axis (prompt tokens vary widely)
var toX=function(t){return Math.log10(Math.max(t,1))/Math.log10(Math.max(maxX,10))*100;};
var toY=function(t){return (t/maxY)*100;};
var dots='';
pts.forEach(function(p){
var x=toX(p.prompt_tokens),y=toY(p.inference_ms),c=mcol[p.model]||'#38bdf8';
var r=p.stream?1.5:2.5,o=p.stream?0.4:0.8;
dots+='<circle cx="'+x+'" cy="'+(100-y)+'" r="'+r+'" fill="'+c+'" opacity="'+o+'"><title>'+mlab[p.model]+' | '+p.prompt_tokens+' tok | '+p.inference_ms+'ms | '+p.agent+'</title></circle>';
});
// Grid lines
var grid='';
for(var i=1;i<=4;i++){grid+='<line x1="0" y1="'+(i*20)+'" x2="100" y2="'+(i*20)+'" stroke="#1e293b" stroke-width="0.5"/>';}
for(var i=1;i<=4;i++){grid+='<line x1="'+(i*20)+'" y1="0" x2="'+(i*20)+'" y2="100" stroke="#1e293b" stroke-width="0.5"/>';}
// Axis labels
var xTicks='';
var xVals=[10,100,1000,10000,100000];
xVals.forEach(function(v){if(v<=maxX)xTicks+='<text x="'+toX(v)+'" y="103" text-anchor="middle" font-size="8" fill="#64748b">'+(v>=1000?(v/1000)+'k':v)+'</text>';});
var yTicks='';
var yVals=[500,1000,5000,10000,50000,100000];
yVals.forEach(function(v){if(v<=maxY)yTicks+='<text x="-2" y="'+(97-toY(v))+'" text-anchor="end" font-size="8" fill="#64748b">'+(v>=1000?(v/1000)+'s':v+'ms')+'</text>';});
el.innerHTML='<svg viewBox="-35 0 140 115" style="width:100%;height:200px">'+grid+dots+xTicks+yTicks+'<text x="50" y="112" text-anchor="middle" font-size="9" fill="#475569">Prompt Tokens (log scale)</text><text x="-38" y="50" text-anchor="middle" font-size="9" fill="#475569" transform="rotate(-90,-38,50)">Inference Time</text></svg>';
// Legend
var models=[];pts.forEach(function(p){if(models.indexOf(p.model)===-1)models.push(p.model);});
lg.innerHTML=models.map(function(m){return'<span class="d-flex align-items-center gap-1 small"><svg width="10" height="10"><circle cx="5" cy="5" r="3.5" fill="'+(mcol[m]||'#38bdf8')+'"/></svg>'+mlab[m]+'</span>';}).join('');
}
poll();setInterval(poll,10000);loadTS();loadPerf();setInterval(loadPerf,15000);loadScatter();setInterval(loadScatter,30000);
</script> </script>
</body> </body>
</html>""" </html>"""
@@ -205,6 +314,26 @@ def dashboard(): return render_template_string(DASHBOARD_HTML)
@app.route("/api/state") @app.route("/api/state")
def api_state(): return fetch_state() def api_state(): return fetch_state()
@app.route("/api/scatter")
def api_scatter():
window = request.args.get("window", "24")
model = request.args.get("model", "all")
try:
r = requests.get(f"http://router:9000/metrics/scatter?window={window}&model={model}", timeout=10)
if r.status_code == 200: return r.json()
except Exception: pass
return {"points": [], "count": 0}
@app.route("/api/performance")
def api_performance():
window = request.args.get("window", "24")
model = request.args.get("model", "all")
try:
r = requests.get(f"http://router:9000/metrics/performance?window={window}&model={model}", timeout=10)
if r.status_code == 200: return r.json()
except Exception: pass
return {"models": [], "reasons": [], "agents": [], "summary": {"total_requests": 0}}
@app.route("/api/timeseries") @app.route("/api/timeseries")
def api_timeseries(): def api_timeseries():
period = request.args.get("period", "day") period = request.args.get("period", "day")
+20
View File
@@ -0,0 +1,20 @@
{
"sk-syslog-local-master-key": {"tier": "enterprise", "agent": "admin", "deprecated": true},
"sk-33a0d6e6-a6da12fb483770d6f63f543b7e16a742": {"tier": "enterprise", "agent": "admin"},
"sk-syslog-abiba": {"tier": "enterprise", "agent": "Abiba", "deprecated": true},
"sk-6a6e49d0-e35e27c524ce0ba2de8f862a0e966b0c": {"tier": "enterprise", "agent": "Abiba"},
"sk-syslog-mumuni": {"tier": "enterprise", "agent": "Mumuni", "deprecated": true},
"sk-ba249c99-29e3b163a8d8ab7abb8663cae0dceb37": {"tier": "enterprise", "agent": "Mumuni"},
"sk-syslog-tanko": {"tier": "enterprise", "agent": "Tanko", "deprecated": true},
"sk-4a67112a-d9b62caf5a5881a15837df506f782708": {"tier": "enterprise", "agent": "Tanko"},
"sk-syslog-koby": {"tier": "enterprise", "agent": "Koby", "deprecated": true},
"sk-9dd66bba-316f616ec40cb51f3b489a0146bb986f": {"tier": "enterprise", "agent": "Koby"},
"sk-syslog-kagenz0": {"tier": "enterprise", "agent": "Kagenz0", "deprecated": true},
"sk-5a05020f-a984a8a20f731c5a65dfc07d51495bb3": {"tier": "enterprise", "agent": "Kagenz0"},
"sk-syslog-koonimo": {"tier": "enterprise", "agent": "Koonimo", "deprecated": true},
"sk-b2f0e1b2-70b7cf1c5a5e402a49b5469e88d9e335": {"tier": "enterprise", "agent": "Koonimo"},
"sk-starter-abc123": {"tier": "starter", "agent": "test-starter", "deprecated": true},
"sk-a8307a00-cd76daa07dd61c41aeb4545656f8b8c4": {"tier": "starter", "agent": "test-starter"},
"sk-professional-xyz789": {"tier": "professional", "agent": "test-pro", "deprecated": true},
"sk-b519a91e-4d2774c6c7da3fc31326e69ccc426781": {"tier": "professional", "agent": "test-pro"}
}
+475 -62
View File
@@ -1,4 +1,4 @@
import os, json, time, logging, traceback, threading, queue import os, json, time, logging, traceback, threading, queue, statistics, math
import requests, redis import requests, redis
from flask import Flask, request, jsonify, Response, stream_with_context from flask import Flask, request, jsonify, Response, stream_with_context
@@ -19,16 +19,16 @@ GPU_URLS = {
} }
# Max concurrent requests per GPU (based on llama.cpp --parallel) # Max concurrent requests per GPU (based on llama.cpp --parallel)
GPU_MAX_CONCURRENT = { GPU_MAX_CONCURRENT = {
"qwen3.6-35B-A3B": 2, # 2 slots "qwen3.6-35B-A3B": 2, # 2 slots (cross-agent spread prevents overheating)
"qwen3.6-27B-code": 2, # 2 slots "qwen3.6-27B-code": 2, # 2 slots
"gemma-4-12b": 2, # 2 slots (7.1GB VRAM) "gemma-4-12b": 2, # 2 slots (12GB VRAM, 4GB headroom)
} }
# Context window sizes (tokens) — used for compaction signals # Context window sizes (tokens) — used for compaction signals
GPU_CONTEXT = { GPU_CONTEXT = {
"qwen3.6-35B-A3B": 131072, "qwen3.6-35B-A3B": 262144,
"qwen3.6-27B-code": 262144, "qwen3.6-27B-code": 262144,
"gemma-4-12b": 262144, # 262K context (gemma-4-12b) "gemma-4-12b": 262144,
} }
TIER_MODELS = { TIER_MODELS = {
@@ -36,18 +36,45 @@ TIER_MODELS = {
"professional": ["qwen3.6-35B-A3B", "qwen3.6-27B-code", "gemma-4-12b"], "professional": ["qwen3.6-35B-A3B", "qwen3.6-27B-code", "gemma-4-12b"],
"enterprise": ["qwen3.6-35B-A3B", "qwen3.6-27B-code", "gemma-4-12b"], "enterprise": ["qwen3.6-35B-A3B", "qwen3.6-27B-code", "gemma-4-12b"],
} }
API_KEYS = { # ── PHASE 0: Dual-Key API Key System ──
"sk-syslog-local-master-key": {"tier": "enterprise", "agent": "admin"}, # API_KEYS env var is REQUIRED (JSON string). No hardcoded fallback.
"sk-syslog-abiba": {"tier": "enterprise", "agent": "Abiba"}, # Format: {"sk-xxx": {"tier": "enterprise", "agent": "Name"}}
"sk-syslog-mumuni": {"tier": "enterprise", "agent": "Mumuni"}, # Deprecated keys get {"deprecated": true} — still accepted, logged with warning.
"sk-syslog-tanko": {"tier": "enterprise", "agent": "Tanko"}, _raw_keys = os.environ.get("API_KEYS")
"sk-syslog-koby": {"tier": "enterprise", "agent": "Koby"}, if not _raw_keys:
"sk-syslog-kagenz0": {"tier": "enterprise", "agent": "Kagenz0"}, raise RuntimeError("FATAL: API_KEYS environment variable is required. "
"sk-syslog-koonimo": {"tier": "enterprise", "agent": "Koonimo"}, "Set it in docker-compose.yml or .env file. "
"sk-starter-abc123": {"tier": "starter", "agent": "test-starter"}, "No hardcoded keys fallback — this is a security feature.")
"sk-professional-xyz789": {"tier": "professional", "agent": "test-pro"}, API_KEYS = json.loads(_raw_keys)
log.info("Loaded %d API keys from env var (%d deprecated)",
len(API_KEYS), sum(1 for v in API_KEYS.values() if v.get("deprecated")))
# Rate limits: requests per minute per API key tier
RATE_LIMIT_RPM = {
"enterprise": 120,
"professional": 60,
"starter": 20,
} }
def check_rate_limit(api_key, tier):
"""Token bucket rate limiter using Redis. Returns (allowed, retry_after_or_remaining, reset_seconds)."""
if not r:
return True, 999, 60
limit = RATE_LIMIT_RPM.get(tier, 30)
key = f"ratelimit:{api_key}"
current = int(r.get(key) or 0)
if current >= limit:
ttl = r.ttl(key)
retry = max(ttl, 1) if ttl and ttl > 0 else 60
return False, retry, 0
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, 60) # 1-minute sliding window
pipe.execute()
remaining = limit - (current + 1)
reset_seconds = r.ttl(key) or 60
return True, remaining, reset_seconds
logging.basicConfig(level=logging.INFO, format="%(asctime)s [ROUTER] %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [ROUTER] %(levelname)s %(message)s")
log = logging.getLogger("router") log = logging.getLogger("router")
try: r = redis.from_url(REDIS_URL, decode_responses=True); r.ping() try: r = redis.from_url(REDIS_URL, decode_responses=True); r.ping()
@@ -94,24 +121,25 @@ def gpu_decr(model):
if v and int(v) < 0: if v and int(v) < 0:
r.set("active:" + model, 0) # never go negative r.set("active:" + model, 0) # never go negative
def check_gpu_health(model): def check_gpu_health(model, sidecar_timeout=5, gpu_timeout=3):
url = GPU_SIDECARS.get(model) url = GPU_SIDECARS.get(model)
if not url: return {"status": "unknown"} if not url: return {"status": "unknown"}
try: try:
resp = requests.get(url, timeout=5) resp = requests.get(url, timeout=sidecar_timeout)
if resp.status_code == 200: if resp.status_code == 200:
d = resp.json() d = resp.json()
pct = (d.get("vram_used_mb",0) / max(d.get("vram_total_mb",1), 1)) * 100 pct = (d.get("vram_used_mb",0) / max(d.get("vram_total_mb",1), 1)) * 100
status = "healthy" if pct < 90 else "saturated" status = "healthy" # VRAM usage != saturation; busy slots handled by is_gpu_busy()
vram_warning = pct >= 95
# Also check if llama.cpp endpoint is actually responding # Also check if llama.cpp endpoint is actually responding
gpu_url = GPU_URLS.get(model, "") gpu_url = GPU_URLS.get(model, "")
try: try:
hr = requests.get(gpu_url.replace("/v1","") + "/health", headers={"Authorization": "Bearer not-needed"}, timeout=3) hr = requests.get(gpu_url.replace("/v1","") + "/health", headers={"Authorization": "Bearer not-needed"}, timeout=gpu_timeout)
if hr.status_code != 200: if hr.status_code != 200:
status = "down" status = "down"
except Exception: except Exception:
status = "down" status = "down"
return {"status": status, "vram_used_mb": d.get("vram_used_mb"), "vram_total_mb": d.get("vram_total_mb"), "vram_pct": round(pct,1), "temp_c": d.get("temp_c"), "gpu_util_pct": d.get("gpu_util_pct"), "gpu_name": d.get("gpu_name"), "power_w": d.get("power_w"), "power_limit_w": d.get("power_limit_w")} return {"status": status, "vram_warning": vram_warning, "vram_used_mb": d.get("vram_used_mb"), "vram_total_mb": d.get("vram_total_mb"), "vram_pct": round(pct,1), "temp_c": d.get("temp_c"), "gpu_util_pct": d.get("gpu_util_pct"), "gpu_name": d.get("gpu_name"), "power_w": d.get("power_w"), "power_limit_w": d.get("power_limit_w")}
except Exception: pass except Exception: pass
return {"status": "down"} return {"status": "down"}
@@ -121,14 +149,65 @@ def estimate_tokens(msgs):
"""Estimate token count from messages. Uses JSON length / 3.5 (closer to real tokenizer ratios for dense text).""" """Estimate token count from messages. Uses JSON length / 3.5 (closer to real tokenizer ratios for dense text)."""
return len(json.dumps(msgs, default=str)) // 3.5 return len(json.dumps(msgs, default=str)) // 3.5
def store_perf_record(model, agent, tier, reason, queue_ms, inference_ms, prompt_tokens, completion_tokens, stream):
"""Store detailed performance record in Redis for analytics."""
if not r: return
try:
total_ms = queue_ms + inference_ms
tps = completion_tokens / (inference_ms / 1000) if inference_ms > 0 and completion_tokens > 0 else 0
rec = json.dumps({
"ts": time.time(),
"model": model, "agent": agent, "tier": tier, "reason": reason,
"queue_ms": round(queue_ms, 1),
"inference_ms": round(inference_ms, 1),
"total_ms": round(total_ms, 1),
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"tokens_per_sec": round(tps, 1),
"stream": stream
})
# Global recent list (last 500)
r.lpush("perf:recent", rec)
r.ltrim("perf:recent", 0, 499)
# Per-model list (last 200)
r.lpush("perf:model:" + model, rec)
r.ltrim("perf:model:" + model, 0, 199)
# Per-reason list (last 200)
r.lpush("perf:reason:" + reason, rec)
r.ltrim("perf:reason:" + reason, 0, 199)
# Per-agent list (last 200)
r.lpush("perf:agent:" + agent, rec)
r.ltrim("perf:agent:" + agent, 0, 199)
except Exception:
pass
def is_gpu_busy(model): def is_gpu_busy(model):
"""Check if GPU is at or near max concurrent capacity.""" """Check if GPU is at or near max concurrent capacity."""
active = gpu_active_count(model) active = gpu_active_count(model)
max_c = GPU_MAX_CONCURRENT.get(model, 1) max_c = GPU_MAX_CONCURRENT.get(model, 1)
return active >= max_c return active >= max_c
def select_best_gpu(candidates, reason): def select_best_gpu(candidates, reason, agent=""):
"""Pick the best GPU from candidates IN ORDER — first non-busy one wins.""" """Pick best GPU, spreading agents across GPUs to prevent hotspots."""
# Count how many distinct agents are on each GPU
gpu_agent_counts = {}
if r:
for m in GPU_URLS:
count = 0
for ak in API_KEYS.values():
if r.get("agent_gpu:" + ak["agent"] + ":" + m):
count += 1
gpu_agent_counts[m] = count
# First pass: prefer GPUs with 0 other agents (fresh GPU for this agent)
for m in candidates:
if not is_gpu_busy(m) and gpu_agent_counts.get(m, 0) == 0:
return {"model": m, "reason": reason}
# Second pass: prefer GPU this agent is NOT already on (skip own GPU)
if agent:
for m in candidates:
if not is_gpu_busy(m) and not r.get("agent_gpu:" + agent + ":" + m):
return {"model": m, "reason": reason}
# Third pass: any non-busy GPU
for m in candidates: for m in candidates:
if not is_gpu_busy(m): if not is_gpu_busy(m):
return {"model": m, "reason": reason} return {"model": m, "reason": reason}
@@ -144,7 +223,7 @@ def select_best_gpu(candidates, reason):
return {"model": best, "reason": "load_balanced_" + reason} return {"model": best, "reason": "load_balanced_" + reason}
return None return None
def route(rd, tier): def route(rd, tier, agent=""):
msgs = rd.get("messages",[]); t = estimate_tokens(msgs) msgs = rd.get("messages",[]); t = estimate_tokens(msgs)
sys = any(m.get("role")=="system" for m in msgs) sys = any(m.get("role")=="system" for m in msgs)
turns = len([m for m in msgs if m.get("role") in ("user","assistant")]) turns = len([m for m in msgs if m.get("role") in ("user","assistant")])
@@ -163,53 +242,52 @@ def route(rd, tier):
if is_gpu_busy(target) and req in allowed: if is_gpu_busy(target) and req in allowed:
alts = [m for m in avail if m != target and m in allowed] alts = [m for m in avail if m != target and m in allowed]
if alts: if alts:
alt = select_best_gpu(alts, "explicit") alt = select_best_gpu(alts, "explicit", agent)
if alt: return alt if alt: return alt
return {"model": target, "reason": "explicit"} return {"model": target, "reason": "explicit"}
if hints: if hints:
if hints.get("priority")=="speed" and "gemma-4-12b" in avail: if hints.get("priority")=="speed" and "gemma-4-12b" in avail:
return select_best_gpu(["gemma-4-12b"], "hint_speed") or {"model":"gemma-4-12b","reason":"hint_speed"} return select_best_gpu(["gemma-4-12b"], "hint_speed", agent) or {"model":"gemma-4-12b","reason":"hint_speed"}
if hints.get("priority")=="quality" and "qwen3.6-27B-code" in avail: if hints.get("priority")=="quality" and "qwen3.6-35B-A3B" in avail:
return select_best_gpu(["qwen3.6-27B-code"], "hint_quality") or {"model":"qwen3.6-27B-code","reason":"hint_quality"} return select_best_gpu(["qwen3.6-35B-A3B"], "hint_quality", agent) or {"model":"qwen3.6-35B-A3B","reason":"hint_quality"}
first_msg = msgs[0].get("content","") if msgs else "" first_msg = msgs[0].get("content","") if msgs else ""
words = len(first_msg.split()) if isinstance(first_msg, str) else 99 words = len(first_msg.split()) if isinstance(first_msg, str) else 99
# TIER 1: Lightweight — single-turn short queries → VLM first # TIER 1: Lightweight — single-turn short queries → VLM (fastest)
if not sys and turns <= 1 and words <= 100 and "gemma-4-12b" in avail: if not sys and turns <= 1 and t <= 500 and words <= 100 and "gemma-4-12b" in avail:
if not is_gpu_busy("gemma-4-12b"): if not is_gpu_busy("gemma-4-12b"):
return {"model":"gemma-4-12b","reason":"lightweight"} return {"model":"gemma-4-12b","reason":"lightweight"}
# VLM busy — fall back to Dense, then MoE # VLM busy — Dense is faster for short queries than MoE
fallback = [m for m in ["qwen3.6-35B-A3B","qwen3.6-27B-code"] if m in avail] fallback = [m for m in ["qwen3.6-27B-code","qwen3.6-35B-A3B"] if m in avail]
result = select_best_gpu(fallback, "lightweight_fallback") result = select_best_gpu(fallback, "lightweight_fallback", agent)
if result: return result if result: return result
# TIER 2: Simple conversations — short context, any prompt → VLM preferred # TIER 2: Simple conversations — VLM primary (up to 15K tok), fastest for moderate chat
if t <= 1000 and turns <= 4 and "gemma-4-12b" in avail: if t <= 15000 and turns <= 12 and "gemma-4-12b" in avail:
if not is_gpu_busy("gemma-4-12b"): if not is_gpu_busy("gemma-4-12b"):
return {"model":"gemma-4-12b","reason":"simple_conv"} return {"model":"gemma-4-12b","reason":"simple_conv"}
# VLM busy — try Dense # VLM busy — fall back to Dense, then MoE
if "qwen3.6-27B-code" in avail and not is_gpu_busy("qwen3.6-27B-code"): fallback = [m for m in ["qwen3.6-27B-code","qwen3.6-35B-A3B"] if m in avail]
return {"model":"qwen3.6-27B-code","reason":"simple_conv_fallback"} result = select_best_gpu(fallback, "simple_conv_fallback", agent)
if result: return result
# TIER 3: Heavy reasoning — extremely large context or very long conversations # TIER 3: Medium complexity — Dense primary, VLM fallback (quality + speed balance)
if t > 50000 or turns > 25: if t <= 25000:
# MoE first (131K context handles heavy sessions), then Dense (98K reasoning), then Light (131K fallback) candidates = [m for m in ["qwen3.6-27B-code","gemma-4-12b","qwen3.6-35B-A3B"] if m in avail]
result = select_best_gpu(candidates, "medium", agent)
if result: return result
# TIER 4: Heavy reasoning — MoE primary (workhorse), Dense fallback
if t > 25000:
candidates = [m for m in ["qwen3.6-35B-A3B","qwen3.6-27B-code","gemma-4-12b"] if m in avail] candidates = [m for m in ["qwen3.6-35B-A3B","qwen3.6-27B-code","gemma-4-12b"] if m in avail]
result = select_best_gpu(candidates, "heavy_reasoning") result = select_best_gpu(candidates, "heavy_reasoning", agent)
if result: return result if result: return result
# TIER 4: Default — MoE first, VLM helps, Dense last (slow) # TIER 5: Default — Dense primary, MoE fallback
if t <= 50000: candidates = [m for m in ["qwen3.6-27B-code","gemma-4-12b","qwen3.6-35B-A3B"] if m in avail]
candidates = [m for m in ["qwen3.6-35B-A3B","gemma-4-12b","qwen3.6-27B-code"] if m in avail] result = select_best_gpu(candidates, "default", agent)
result = select_best_gpu(candidates, "default")
if result: return result
# Fallback — best available
if "qwen3.6-35B-A3B" in avail and not is_gpu_busy("qwen3.6-35B-A3B"):
return {"model":"qwen3.6-35B-A3B","reason":"default_moe"}
result = select_best_gpu([m for m in avail], "fallback")
if result: return result if result: return result
return {"model":avail[0],"reason":"last_resort"} return {"model":avail[0],"reason":"last_resort"}
@@ -266,6 +344,27 @@ def chat():
return jsonify({"error": "Unauthorized — valid API key required"}), 401 return jsonify({"error": "Unauthorized — valid API key required"}), 401
ki = API_KEYS[ak] ki = API_KEYS[ak]
tier, agent = ki["tier"], ki["agent"] tier, agent = ki["tier"], ki["agent"]
# Phase 0: dual-key transition — log deprecated key usage
if ki.get("deprecated"):
new_key = next((k for k, v in API_KEYS.items()
if v.get("agent") == agent and not v.get("deprecated")), None)
log.warning("DEPRECATED_KEY: agent=%s using old key %s...%s — switch to %s...%s",
agent, ak[:12], ak[-8:],
new_key[:12] if new_key else "N/A",
new_key[-8:] if new_key else "N/A")
if r:
r.incr("deprecated_usage:" + agent)
# Rate limit check
allowed, rl_val, reset_sec = check_rate_limit(ak, tier)
if not allowed:
resp = jsonify({"error": "Rate limit exceeded", "retry_after_s": rl_val})
resp.headers["Retry-After"] = str(rl_val)
resp.headers["X-RateLimit-Limit"] = str(RATE_LIMIT_RPM.get(tier, 30))
resp.headers["X-RateLimit-Remaining"] = "0"
resp.headers["X-RateLimit-Reset"] = str(int(time.time() + rl_val))
log.warning("RATE_LIMIT: %s (%s) exceeded limit", agent, ak[-8:])
return resp, 429
# Allow agent to override queue timeout via header # Allow agent to override queue timeout via header
q_timeout = int(request.headers.get("X-Queue-Timeout", str(QUEUE_TIMEOUT))) q_timeout = int(request.headers.get("X-Queue-Timeout", str(QUEUE_TIMEOUT)))
@@ -281,7 +380,7 @@ def chat():
r.set("session:" + session_id, session_tokens, ex=86400) # TTL 24h r.set("session:" + session_id, session_tokens, ex=86400) # TTL 24h
except Exception: pass except Exception: pass
d = route(rd, tier) d = route(rd, tier, agent)
queue_start = time.time() queue_start = time.time()
# Queue loop: wait for a GPU slot instead of immediate 503 # Queue loop: wait for a GPU slot instead of immediate 503
@@ -293,22 +392,31 @@ def chat():
log.warning("QUEUE_TIMEOUT: %s waited %.1fs, all GPUs saturated", agent, elapsed) log.warning("QUEUE_TIMEOUT: %s waited %.1fs, all GPUs saturated", agent, elapsed)
return resp, 503 return resp, 503
time.sleep(0.5) # poll every 500ms time.sleep(0.5) # poll every 500ms
d = route(rd, tier) d = route(rd, tier, agent)
waited = time.time() - queue_start queue_ms = (time.time() - queue_start) * 1000
if waited > 0.5: if queue_ms > 500:
log.info("QUEUED: %s waited %.1fs before slot opened", agent, waited) log.info("QUEUED: %s waited %.0fms before slot opened", agent, queue_ms)
model, reason, url = d["model"], d["reason"], GPU_URLS[d["model"]] model, reason, url = d["model"], d["reason"], GPU_URLS[d["model"]]
# Stash rate limit values for response headers
_rl_remaining = rl_val
_rl_limit = RATE_LIMIT_RPM.get(tier, 30)
_rl_reset = reset_sec
is_stream = rd.get("stream", False) is_stream = rd.get("stream", False)
gpu_incr(model) gpu_incr(model)
log.info("ROUTE: %s -> %s (%s) stream=%s active=%d/%d", agent, model, reason, is_stream, gpu_active_count(model), GPU_MAX_CONCURRENT.get(model,1)) log.info("ROUTE: %s -> %s (%s) stream=%s active=%d/%d", agent, model, reason, is_stream, gpu_active_count(model), GPU_MAX_CONCURRENT.get(model,1))
# Track which GPU this agent is using (TTL 120s covers typical request)
if r and agent:
try: r.setex("agent_gpu:" + agent + ":" + model, 120, "1")
except: pass
if r: if r:
try: try:
r.incr("routes:"+model); r.incr("routes:tier:"+tier); r.incr("routes:agent:"+agent) r.incr("routes:"+model); r.incr("routes:tier:"+tier); r.incr("routes:agent:"+agent)
r.incr("ts:"+model+":"+time.strftime("%Y%m%d%H")) r.incr("ts:"+model+":"+time.strftime("%Y%m%d%H"))
r.lpush("routes:recent", json.dumps({"ts":time.time(),"model":model,"reason":reason,"tier":tier,"agent":agent})) r.lpush("routes:recent", json.dumps({"ts":time.time(),"model":model,"reason":reason,"tier":tier,"agent":agent,"queue_ms": round(queue_ms,1)}))
r.ltrim("routes:recent",0,999) r.ltrim("routes:recent",0,999)
except Exception: pass except Exception: pass
start = time.time() start = time.time()
@@ -319,14 +427,47 @@ def chat():
if resp.status_code != 200: return jsonify({"error":"GPU error "+str(resp.status_code)}), 502 if resp.status_code != 200: return jsonify({"error":"GPU error "+str(resp.status_code)}), 502
if is_stream: if is_stream:
# Buffer SSE chunks, handle split lines for large responses
chunks = []
stream_timings = {}
buf = "" # accumulate partial lines
for raw in resp.iter_content(chunk_size=None, decode_unicode=True):
if raw:
cleaned = clean_unicode(raw)
chunks.append(cleaned)
buf += cleaned
# Process complete lines from buffer
while "\n" in buf:
line, buf = buf.split("\n", 1)
line = line.strip()
if line.startswith("data: ") and not stream_timings:
js = line[6:].strip()
if js.startswith("{") and "timings" in js and "predicted_n" in js:
try:
tj = json.loads(js).get("timings", {})
if tj:
stream_timings = tj
except: pass
# Store perf record with real token counts from stream
if stream_timings:
pt = stream_timings.get("prompt_n", 0)
ct = stream_timings.get("predicted_n", 0)
tps = stream_timings.get("predicted_per_second", 0)
gen_ms = stream_timings.get("predicted_ms", lat)
store_perf_record(model, agent, tier, reason, queue_ms, gen_ms, pt, ct, True)
else:
store_perf_record(model, agent, tier, reason, queue_ms, lat, estimate_tokens(rd.get("messages",[])), 0, True)
# Yield all chunks to client
def gen(): def gen():
for raw in resp.iter_content(chunk_size=None, decode_unicode=True): for c in chunks: yield c
if raw: yield clean_unicode(raw)
bcast() bcast()
ctx_remaining = GPU_CONTEXT.get(model, 65536) - max(session_tokens, estimate_tokens(rd.get("messages",[]))) ctx_remaining = GPU_CONTEXT.get(model, 65536) - max(session_tokens, estimate_tokens(rd.get("messages",[])))
ctx_pct = ctx_remaining / GPU_CONTEXT.get(model, 65536) * 100 ctx_pct = ctx_remaining / GPU_CONTEXT.get(model, 65536) * 100
ctx_warning = "compact_urgent" if ctx_pct < 5 else ("compact_recommended" if ctx_pct < 15 else ("compact_soon" if ctx_pct < 30 else "ok")) ctx_warning = "compact_urgent" if ctx_pct < 5 else ("compact_recommended" if ctx_pct < 15 else ("compact_soon" if ctx_pct < 30 else "ok"))
sse_resp = Response(stream_with_context(gen()), mimetype="text/event-stream") sse_resp = Response(stream_with_context(gen()), mimetype="text/event-stream")
sse_resp.headers["X-RateLimit-Limit"] = str(_rl_limit)
sse_resp.headers["X-RateLimit-Remaining"] = str(max(0, _rl_remaining))
sse_resp.headers["X-RateLimit-Reset"] = str(int(time.time() + _rl_reset))
sse_resp.headers["X-Context-Remaining"] = str(max(0, ctx_remaining)) sse_resp.headers["X-Context-Remaining"] = str(max(0, ctx_remaining))
sse_resp.headers["X-Context-Warning"] = ctx_warning sse_resp.headers["X-Context-Warning"] = ctx_warning
sse_resp.headers["X-Context-Model"] = model sse_resp.headers["X-Context-Model"] = model
@@ -336,11 +477,21 @@ def chat():
msg = c.get("message",{}) msg = c.get("message",{})
if not msg.get("content") and msg.get("reasoning_content"): if not msg.get("content") and msg.get("reasoning_content"):
msg["content"] = msg["reasoning_content"] msg["content"] = msg["reasoning_content"]
# Extract performance data from llama.cpp response
usage = data.get("usage", {})
timings = data.get("timings", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
inference_ms = lat # total GPU round-trip
store_perf_record(model, agent, tier, reason, queue_ms, inference_ms, prompt_tokens, completion_tokens, False)
ctx_remaining = GPU_CONTEXT.get(model, 65536) - max(session_tokens, estimate_tokens(rd.get("messages",[]))) ctx_remaining = GPU_CONTEXT.get(model, 65536) - max(session_tokens, estimate_tokens(rd.get("messages",[])))
ctx_pct = ctx_remaining / GPU_CONTEXT.get(model, 65536) * 100 ctx_pct = ctx_remaining / GPU_CONTEXT.get(model, 65536) * 100
ctx_warning = "compact_urgent" if ctx_pct < 5 else ("compact_recommended" if ctx_pct < 15 else ("compact_soon" if ctx_pct < 30 else "ok")) ctx_warning = "compact_urgent" if ctx_pct < 5 else ("compact_recommended" if ctx_pct < 15 else ("compact_soon" if ctx_pct < 30 else "ok"))
data["routing"] = {"model":model,"reason":reason,"gpu":url,"tier":tier,"agent":agent,"latency_ms":lat,"active_gpu":gpu_active_count(model),"context_remaining": max(0, ctx_remaining),"context_pct": round(ctx_pct,1),"context_warning": ctx_warning} data["routing"] = {"model":model,"reason":reason,"gpu":url,"tier":tier,"agent":agent,"latency_ms":lat,"queue_ms": round(queue_ms,1),"active_gpu":gpu_active_count(model),"context_remaining": max(0, ctx_remaining),"context_pct": round(ctx_pct,1),"context_warning": ctx_warning}
resp = jsonify(data) resp = jsonify(data)
resp.headers["X-RateLimit-Limit"] = str(_rl_limit)
resp.headers["X-RateLimit-Remaining"] = str(max(0, _rl_remaining))
resp.headers["X-RateLimit-Reset"] = str(int(time.time() + _rl_reset))
resp.headers["X-Context-Remaining"] = str(max(0, ctx_remaining)) resp.headers["X-Context-Remaining"] = str(max(0, ctx_remaining))
resp.headers["X-Context-Warning"] = ctx_warning resp.headers["X-Context-Warning"] = ctx_warning
resp.headers["X-Context-Model"] = model resp.headers["X-Context-Model"] = model
@@ -355,14 +506,163 @@ def chat():
log.error("Error: %s\n%s", e, traceback.format_exc()) log.error("Error: %s\n%s", e, traceback.format_exc())
return jsonify({"error":str(e)}), 500 return jsonify({"error":str(e)}), 500
@app.route("/metrics/performance")
def performance():
"""Per-request performance analytics with percentiles per model/reason/agent."""
if not r: return jsonify({"error": "Redis unavailable"}), 503
try:
window_hours = int(request.args.get("window", "24"))
model_filter = request.args.get("model", "all")
# Load recent records
cutoff = time.time() - (window_hours * 3600)
raw = r.lrange("perf:recent", 0, -1)
records = []
for x in raw:
try:
rec = json.loads(x)
if rec["ts"] >= cutoff:
records.append(rec)
except: pass
# Filter by model if specified
if model_filter != "all":
records = [r for r in records if r["model"] == model_filter]
if not records:
return jsonify({"models": [], "reasons": [], "agents": [], "summary": {"total_requests": 0}})
def pct(values, p):
if len(values) < 2: return round(values[0], 1) if values else 0
return round(statistics.quantiles(sorted(values), n=100, method='inclusive')[min(p-1, 98)], 1)
# Per-model stats
model_groups = {}
for rec in records:
m = rec["model"]
if m not in model_groups: model_groups[m] = []
model_groups[m].append(rec)
models = []
for m, recs in sorted(model_groups.items()):
latencies = [r["total_ms"] for r in recs]
tps_vals = [r["tokens_per_sec"] for r in recs if r["tokens_per_sec"] > 0]
non_stream = [r for r in recs if not r["stream"]]
queue_times = [r["queue_ms"] for r in non_stream]
models.append({
"model": m,
"count": len(recs),
"stream_pct": round(len([r for r in recs if r["stream"]]) / len(recs) * 100, 1),
"latency": {
"avg": round(statistics.mean(latencies), 1),
"p50": pct(latencies, 50),
"p95": pct(latencies, 95),
"p99": pct(latencies, 99)
},
"throughput": {
"avg_tokens_per_sec": round(statistics.mean(tps_vals), 1) if tps_vals else 0,
"p50": pct(tps_vals, 50) if tps_vals else 0,
"p95": pct(tps_vals, 95) if tps_vals else 0,
},
"queue": {
"avg_ms": round(statistics.mean(queue_times), 1) if queue_times else 0,
"p95_ms": pct(queue_times, 95) if queue_times else 0,
} if queue_times else None
})
# Per-reason stats
reason_groups = {}
for rec in records:
rsn = rec["reason"]
if rsn not in reason_groups: reason_groups[rsn] = []
reason_groups[rsn].append(rec)
reasons = []
for rsn, recs in sorted(reason_groups.items(), key=lambda x: -len(x[1])):
latencies = [r["total_ms"] for r in recs]
reasons.append({
"reason": rsn,
"count": len(recs),
"avg_total_ms": round(statistics.mean(latencies), 1),
"p95_total_ms": pct(latencies, 95)
})
# Per-agent stats
agent_groups = {}
for rec in records:
ag = rec["agent"]
if ag not in agent_groups: agent_groups[ag] = []
agent_groups[ag].append(rec)
agents = []
for ag, recs in sorted(agent_groups.items(), key=lambda x: -len(x[1])):
latencies = [r["total_ms"] for r in recs]
tps_vals = [r["tokens_per_sec"] for r in recs if r["tokens_per_sec"] > 0]
agents.append({
"agent": ag,
"count": len(recs),
"avg_total_ms": round(statistics.mean(latencies), 1),
"avg_tokens_per_sec": round(statistics.mean(tps_vals), 1) if tps_vals else 0
})
all_lat = [r["total_ms"] for r in records]
all_tps = [r["tokens_per_sec"] for r in records if r["tokens_per_sec"] > 0]
summary = {
"total_requests": len(records),
"window_hours": window_hours,
"latency": {
"avg_ms": round(statistics.mean(all_lat), 1),
"p50_ms": pct(all_lat, 50),
"p95_ms": pct(all_lat, 95),
"p99_ms": pct(all_lat, 99)
},
"throughput_avg_tps": round(statistics.mean(all_tps), 1) if all_tps else 0
}
return jsonify({"models": models, "reasons": reasons, "agents": agents, "summary": summary})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/metrics/scatter")
def scatter():
"""Return individual data points for scatter plots (prompt_tokens vs latency)."""
if not r: return jsonify({"error": "Redis unavailable"}), 503
try:
window_hours = int(request.args.get("window", "24"))
model_filter = request.args.get("model", "all")
cutoff = time.time() - (window_hours * 3600)
raw = r.lrange("perf:recent", 0, -1)
points = []
for x in raw:
try:
rec = json.loads(x)
if rec["ts"] >= cutoff:
if model_filter == "all" or rec["model"] == model_filter:
points.append({
"model": rec["model"],
"agent": rec["agent"],
"reason": rec["reason"],
"prompt_tokens": int(rec.get("prompt_tokens", 0)),
"completion_tokens": rec.get("completion_tokens", 0),
"inference_ms": round(rec["inference_ms"], 1),
"tokens_per_sec": rec.get("tokens_per_sec", 0),
"stream": rec.get("stream", False)
})
except: pass
return jsonify({"points": points, "count": len(points)})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/v1/models") @app.route("/v1/models")
def models(): return jsonify({"object":"list","data":[{"id":m,"object":"model","owned_by":"syslog","status":check_gpu_health(m).get("status"),"gpu":check_gpu_health(m).get("gpu_name")} for m in GPU_URLS]}) def models():
def _h(m): return check_gpu_health(m, sidecar_timeout=1.5, gpu_timeout=1)
return jsonify({"object":"list","data":[{"id":m,"object":"model","owned_by":"syslog","status":_h(m).get("status"),"gpu":_h(m).get("gpu_name")} for m in GPU_URLS]})
@app.route("/health") @app.route("/health")
def health(): def health():
gpus = {} gpus = {}
for m in GPU_URLS: for m in GPU_URLS:
h = check_gpu_health(m) h = check_gpu_health(m, sidecar_timeout=1.5, gpu_timeout=1)
h["active_requests"] = gpu_active_count(m) h["active_requests"] = gpu_active_count(m)
h["max_concurrent"] = GPU_MAX_CONCURRENT.get(m, 1) h["max_concurrent"] = GPU_MAX_CONCURRENT.get(m, 1)
gpus[m] = h gpus[m] = h
@@ -413,6 +713,119 @@ def stream():
return Response(stream_with_context(ev()), mimetype="text/event-stream", return Response(stream_with_context(ev()), mimetype="text/event-stream",
headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no","Access-Control-Allow-Origin":"*"}) headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no","Access-Control-Allow-Origin":"*"})
# ── Phase 0: Admin Key Management ──
ADMIN_KEY = os.environ.get("ADMIN_KEY", "")
def _admin_auth():
"""Require admin key for management endpoints."""
if not ADMIN_KEY:
return False, "ADMIN_KEY not configured on server"
ak = request.headers.get("Authorization","").replace("Bearer ","")
if ak != ADMIN_KEY:
return False, "Admin key required"
return True, None
@app.route("/admin/keys")
def admin_keys():
"""List all API keys (masked) with agent, tier, and deprecation status."""
ok, err = _admin_auth()
if not ok: return jsonify({"error": err}), 401
keys = []
for key, info in API_KEYS.items():
masked = key[:8] + "..." + key[-8:]
keys.append({
"masked": masked,
"prefix": key[:8],
"agent": info["agent"],
"tier": info["tier"],
"deprecated": info.get("deprecated", False),
"length": len(key)
})
return jsonify({
"total": len(keys),
"active": sum(1 for k in keys if not k["deprecated"]),
"deprecated": sum(1 for k in keys if k["deprecated"]),
"keys": sorted(keys, key=lambda k: (k["deprecated"], k["agent"]))
})
@app.route("/admin/keys/deprecation-summary")
def admin_deprecation_summary():
"""Summary of deprecated key usage (from Redis logs, if available)."""
ok, err = _admin_auth()
if not ok: return jsonify({"error": err}), 401
deprecated_agents = []
for key, info in API_KEYS.items():
if info.get("deprecated"):
# Check Redis for usage count
count = 0
if r:
count = int(r.get("deprecated_usage:" + info["agent"]) or 0)
deprecated_agents.append({
"agent": info["agent"],
"deprecated_uses": count,
"needs_migration": count > 0
})
return jsonify({
"deprecated_agents": sorted(deprecated_agents, key=lambda d: -d["deprecated_uses"]),
"recommendation": "Run POST /admin/keys/revoke to remove keys with 0 usage"
})
@app.route("/admin/keys/generate", methods=["POST"])
def admin_generate_key():
"""Generate a new API key for an agent. Body: {"agent": "Name", "tier": "enterprise"}"""
ok, err = _admin_auth()
if not ok: return jsonify({"error": err}), 401
body = request.get_json(force=True)
agent = body.get("agent", "").strip()
tier = body.get("tier", "enterprise")
if not agent:
return jsonify({"error": "agent field required"}), 400
if tier not in ("starter", "professional", "enterprise"):
return jsonify({"error": "tier must be starter/professional/enterprise"}), 400
# Generate secure key
import secrets, hashlib
prefix = hashlib.sha256(secrets.token_bytes(12)).hexdigest()[:8]
suffix = secrets.token_hex(20)
new_key = f"sk-{prefix}-{suffix}"
# Update in-memory dict (note: not persisted across restarts without env var update)
API_KEYS[new_key] = {"tier": tier, "agent": agent}
log.info("KEY_GENERATED: agent=%s tier=%s key=%s...%s", agent, tier, new_key[:8], new_key[-8:])
return jsonify({
"agent": agent,
"tier": tier,
"key": new_key,
"masked": new_key[:8] + "..." + new_key[-8:],
"warning": "This key exists in memory only. Update API_KEYS env var and redeploy to persist."
}), 201
@app.route("/admin/keys/revoke", methods=["POST"])
def admin_revoke_key():
"""Revoke a deprecated key. Body: {"agent": "Name"} or {"key_prefix": "sk-xxxx"}"""
ok, err = _admin_auth()
if not ok: return jsonify({"error": err}), 401
body = request.get_json(force=True)
agent = body.get("agent", "")
key_prefix = body.get("key_prefix", "")
revoked = []
keys_to_remove = []
for key, info in API_KEYS.items():
if not info.get("deprecated"):
continue
if agent and info["agent"] == agent:
keys_to_remove.append(key)
elif key_prefix and key.startswith(key_prefix):
keys_to_remove.append(key)
for key in keys_to_remove:
info = API_KEYS.pop(key)
revoked.append({"agent": info["agent"], "masked": key[:8] + "..." + key[-8:]})
log.warning("KEY_REVOKED: agent=%s key=%s...%s", info["agent"], key[:8], key[-8:])
return jsonify({
"revoked": len(revoked),
"keys": revoked,
"remaining_total": len(API_KEYS),
"warning": "Memory-only revoke. Update API_KEYS env var and redeploy to persist."
})
if __name__ == "__main__": if __name__ == "__main__":
log.info("Router on :9000 (load-aware)") log.info("Router on :9000 (load-aware)")
app.run(host="0.0.0.0", port=9000, debug=False) app.run(host="0.0.0.0", port=9000, debug=False)