Phase 0.5: Re-add ADMIN_KEY, /admin/keys endpoints, dual-key logging #1
@@ -0,0 +1,56 @@
|
||||
# LiteLLM Virtual Key Distribution — Phase 2 Cutover
|
||||
## Syslog Solution LLC — July 1, 2026 (keys regenerated)
|
||||
|
||||
### Active Agent Keys (Update agent configs with these)
|
||||
|
||||
| Agent | LiteLLM Virtual Key | Tier | Budget | Models | Verified |
|
||||
|-------|-------------------|------|--------|--------|----------|
|
||||
| **Abiba** | `sk-i7F7rCfgpS0oouOTA2LgMw` | enterprise | $1,000 | All 4 | ✅ Jul 1 |
|
||||
| **Mumuni** | `sk-XY2aUfvy2BIs6kp1ZPh6VA` | enterprise | $1,000 | All 4 | ✅ Jul 1 |
|
||||
| **Tanko** | `sk-3ZsdWJbbNSo9zSnJN2OsJw` | enterprise | $1,000 | All 4 | ✅ Jul 1 |
|
||||
| **Kagenz0** | `sk-VYKRAVYEG1rKVEMDKEUggg` | enterprise | $1,000 | All 4 | ✅ Jul 1 |
|
||||
| **Koby** | `sk-gWDaPAp-FavgKdqKJpzqhQ` | professional | $500 | All 4 | ✅ Jul 1 |
|
||||
| **Koonimo** | `sk-1kLC8ZxW-tEZueV3NU4rCQ` | professional | $500 | All 4 | ✅ Jul 1 |
|
||||
|
||||
### Configuration Changes Required Per Agent
|
||||
|
||||
Each agent must update their `OPENAI_API_BASE` and `OPENAI_API_KEY`:
|
||||
|
||||
```
|
||||
OPENAI_API_BASE=http://192.168.68.116/v1
|
||||
OPENAI_API_KEY=<agent's LiteLLM key from table above>
|
||||
```
|
||||
|
||||
### LiteLLM Admin Access
|
||||
|
||||
| Resource | Key |
|
||||
|----------|-----|
|
||||
| LiteLLM Admin UI | http://192.168.68.116/ui/ (Authentik SSO — pending) |
|
||||
| LiteLLM Master Key | `sk-litellm-7f96080dd99b15c36bd4b333b58a6796` |
|
||||
| Router Admin Key | `sk-admin-ee09fffd04978b61a1569ac670c68814` |
|
||||
|
||||
### Fallback (if LiteLLM fails)
|
||||
|
||||
If LiteLLM is down, NGINX automatically falls back to the router on :9000.
|
||||
In that case, agents can also directly use:
|
||||
```
|
||||
OPENAI_API_BASE=http://192.168.68.116/v1
|
||||
OPENAI_API_KEY=<agent's original sk-syslog-* key>
|
||||
```
|
||||
(Only works when NGINX @router_fallback is active)
|
||||
|
||||
### Deprecated Router Keys
|
||||
|
||||
These keys still work through the @router_fallback but NOT through LiteLLM:
|
||||
- sk-syslog-abiba, sk-syslog-mumuni, sk-syslog-tanko
|
||||
- sk-syslog-kagenz0, sk-syslog-koby, sk-syslog-koonimo
|
||||
- sk-starter-abc123, sk-professional-xyz789
|
||||
- sk-syslog-local-master-key
|
||||
|
||||
These have been fully revoked as of July 1, 2026.
|
||||
|
||||
### Key Rotation History
|
||||
|
||||
- **2026-07-01**: Keys regenerated for Abiba, Kagenz0, Koby, Koonimo (old values unrecoverable).
|
||||
Mumuni and Tanko keys confirmed working, left unchanged. All blocked/stale keys purged.
|
||||
- **2026-06-17**: Initial key creation (values in this doc were placeholder/incorrect).
|
||||
+11
-11
@@ -49,7 +49,7 @@
|
||||
|
||||
|
||||
|
||||
qwen3.6-35B qwen3.6-27B gemma-4-12b
|
||||
qwen3.6-35B qwen3.6-27B gpu-vision
|
||||
MoE/Strix Dense/RTX3090 VLM/RTX 5070
|
||||
:8080 (llama) :8080 (llama) :8080 (llama)
|
||||
:8090 (side) :8090 (side) :8090 (sidecar)
|
||||
@@ -84,7 +84,7 @@
|
||||
|-----|------|-----------|---------|------|---------|
|
||||
| qwen3.6-35B-A3B (MoE) | 192.168.68.15 | :8080 | :8090 | Strix Halo | 262K |
|
||||
| qwen3.6-27B-code (Dense) | 192.168.68.8 | :8080 | :8090 | RTX 3090 | 262K |
|
||||
| gemma-4-12b (VLM) | 192.168.68.110 | :8080 | :8090 | RTX 5070 | 262K |
|
||||
| gpu-vision (VLM) | 192.168.68.110 | :8080 | :8090 | RTX 5070 | 262K |
|
||||
|
||||
### 1.3 Existing LiteLLM POC on CT 116
|
||||
|
||||
@@ -249,9 +249,9 @@ model_list:
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
|
||||
- model_name: gemma-4-12b
|
||||
- model_name: gpu-vision
|
||||
litellm_params:
|
||||
model: openai/gemma-4-12b
|
||||
model: openai/gpu-vision
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
|
||||
@@ -298,9 +298,9 @@ router_settings:
|
||||
# Fallback chains: LiteLLM retries down the chain when router returns saturated
|
||||
# This gives accurate per-model metrics because router no longer silently reroutes
|
||||
fallbacks:
|
||||
- qwen3.6-35B-A3B: ["qwen3.6-27B-code", "gemma-4-12b"]
|
||||
- qwen3.6-27B-code: ["qwen3.6-35B-A3B", "gemma-4-12b"]
|
||||
- gemma-4-12b: ["qwen3.6-27B-code", "qwen3.6-35B-A3B"]
|
||||
- qwen3.6-35B-A3B: ["qwen3.6-27B-code", "gpu-vision"]
|
||||
- qwen3.6-27B-code: ["qwen3.6-35B-A3B", "gpu-vision"]
|
||||
- gpu-vision: ["qwen3.6-27B-code", "qwen3.6-35B-A3B"]
|
||||
|
||||
# Cost tracking: map model names to per-token pricing for spend tracking
|
||||
litellm_settings:
|
||||
@@ -311,7 +311,7 @@ litellm_settings:
|
||||
qwen3.6-27B-code:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
gemma-4-12b:
|
||||
gpu-vision:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
# For internal cost allocation, set symbolic rates:
|
||||
@@ -705,9 +705,9 @@ if req != "auto":
|
||||
router_settings:
|
||||
allowed_fails: 100
|
||||
fallbacks:
|
||||
- qwen3.6-35B-A3B: ["qwen3.6-27B-code", "gemma-4-12b"]
|
||||
- qwen3.6-27B-code: ["qwen3.6-35B-A3B", "gemma-4-12b"]
|
||||
- gemma-4-12b: ["qwen3.6-27B-code", "qwen3.6-35B-A3B"]
|
||||
- qwen3.6-35B-A3B: ["qwen3.6-27B-code", "gpu-vision"]
|
||||
- qwen3.6-27B-code: ["qwen3.6-35B-A3B", "gpu-vision"]
|
||||
- gpu-vision: ["qwen3.6-27B-code", "qwen3.6-35B-A3B"]
|
||||
```
|
||||
|
||||
### Result
|
||||
|
||||
@@ -6,10 +6,10 @@ CT 116 Docker stack for routing local GPU models through a unified OpenAI-compat
|
||||
|
||||
```
|
||||
nginx :80 → router :9000 → GPU backends
|
||||
├─ qwen3.6-35B-A3B (MoE) @ 192.168.68.15:8080 [2 slots, 262K ctx]
|
||||
├─ qwen3.6-27B-code (Dense) @ 192.168.68.8:8080 [2 slots, 262K ctx]
|
||||
└─ gemma-4-12b (VLM) @ 192.168.68.110:8080 [2 slots, 262K ctx]
|
||||
Total: 6 concurrent slots
|
||||
├─ strix-moe (Qwen3.6-MoE-35B-A3B) @ 192.168.68.15:8080 [2 slots, 262K ctx]
|
||||
├─ gpu-dense (Qwen3.8-27B-U) @ 192.168.68.8:8080 [1 slot, 131K ctx]
|
||||
└─ gpu-vision (Qwen3.5-9B VLM) @ 192.168.68.110:8080 [1 slot, 131K ctx]
|
||||
Total: 4 concurrent slots
|
||||
|
||||
LiteLLM :8081 (fallback) | Dashboard :3000 | Redis :6379 (local)
|
||||
```
|
||||
@@ -62,8 +62,8 @@ When all GPUs are saturated, requests enter a polling queue (500ms intervals) in
|
||||
| GPU | Model | VRAM | Slots | Context | Best For |
|
||||
|-----|-------|------|-------|
|
||||
| Strix Halo | qwen3.6-35B-A3B (MoE) | 65GB | 2 | 262K | General quality |
|
||||
| RTX 3090 | qwen3.6-27B-code (Dense) | 24GB | 2 | 262K | Code, reasoning |
|
||||
| RTX 5070 | gemma-4-12b (VLM) | 12GB | 2 | 262K | Speed, vision |
|
||||
| RTX 3090 | gpu-dense (Qwen3.8-27B-Uncensored) | 24GB | 1 | 131K | Dense, general |
|
||||
| RTX 5070 | gpu-vision (VLM) | 12GB | 1 | 131K | Speed, vision |
|
||||
|
||||
## Maintenance
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSy
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COLORS={'qwen3.6-35B-A3B':'#10b981','qwen3.6-27B-code':'#8b5cf6','gemma-4-12b':'#3b82f6'};
|
||||
const COLORS={'strix-moe':'#10b981','gpu-dense':'#8b5cf6','gpu-vision':'#3b82f6'};
|
||||
const HISTORY=[]; // rolling 60 sample history for chart
|
||||
function Q(id){return document.getElementById(id)}
|
||||
|
||||
|
||||
+13
-13
@@ -119,9 +119,9 @@ body { background: #0b0f17; color: #bcc3cd; font-family: -apple-system, BlinkMac
|
||||
<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>
|
||||
<option value="gpu-vision">9B VLM</option>
|
||||
<option value="gpu-dense">27B Dense</option>
|
||||
<option value="strix-moe">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>
|
||||
@@ -136,9 +136,9 @@ body { background: #0b0f17; color: #bcc3cd; font-family: -apple-system, BlinkMac
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var MC={'gemma-4-12b':'#22c55e','qwen3.6-27B-code':'#f59e0b','qwen3.6-35B-A3B':'#a78bfa'};
|
||||
var ML={'gemma-4-12b':'Gemma 4 12B','qwen3.6-27B-code':'Qwen Code','qwen3.6-35B-A3B':'Qwen MoE'};
|
||||
var GL={'qwen3.6-35B-A3B':'MoE - Strix Halo','qwen3.6-27B-code':'Dense - RTX 3090','gemma-4-12b':'VLM - RTX 5070'};
|
||||
var MC={'gpu-vision':'#22c55e','gpu-dense':'#f59e0b','strix-moe':'#a78bfa'};
|
||||
var ML={'gpu-vision':'Qwen3.5-9B VLM','gpu-dense':'Qwen3.8-27B','strix-moe':'Qwen MoE'};
|
||||
var GL={'strix-moe':'MoE - Strix Halo','gpu-dense':'Dense - RTX 3090','gpu-vision':'VLM - RTX 5070'};
|
||||
function $(id){return document.getElementById(id);}
|
||||
|
||||
function render(data){
|
||||
@@ -147,7 +147,7 @@ var t=Object.values(data.route_counts||{}).reduce((a,b)=>a+b,0);
|
||||
var ta=0,tm=0;data.gpus.forEach(function(g){ta+=(g.active_requests||0);tm+=(g.max_concurrent||1)});
|
||||
$('kpi-total').textContent=t;$('kpi-active').textContent=ta+'/'+tm;$('kpi-agents').textContent=Object.keys(data.agent_counts||{}).length;
|
||||
$('update-time').textContent=new Date().toLocaleTimeString();
|
||||
var ids={'qwen3.6-35B-A3B':'gpu-moe','qwen3.6-27B-code':'gpu-dense','gemma-4-12b':'gpu-light'};
|
||||
var ids={'strix-moe':'gpu-moe','gpu-dense':'gpu-dense','gpu-vision':'gpu-vision'};
|
||||
data.gpus.forEach(function(g){
|
||||
var el=$(ids[g.id]);if(!el)return;
|
||||
var a=g.active_requests||0,mx=g.max_concurrent||1;
|
||||
@@ -179,14 +179,14 @@ var sc=pct>=100?'#ef4444':pct>=50?'#f59e0b':'#22c55e';
|
||||
var circ=188.5,dash=(pct/100)*circ;
|
||||
var h='<div class=\"d-inline-block position-relative mb-2\"><svg width=\"72\" height=\"72\"><circle cx=\"36\" cy=\"36\" r=\"30\" fill=\"none\" stroke=\"#1e293b\" stroke-width=\"6\"/><circle cx=\"36\" cy=\"36\" r=\"30\" fill=\"none\" stroke=\"'+sc+'\" stroke-width=\"6\" stroke-dasharray=\"'+dash+' '+(circ-dash)+'\" stroke-linecap=\"round\" transform=\"rotate(-90 36 36)\"/></svg><div style=\"position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center\"><div class=\"ring-label\" style=\"color:'+sc+'\">'+ta+'</div><div class=\"ring-sublabel\">/ '+tm+' slots</div></div></div>';
|
||||
h+='<div class=\"fw-bold mb-2 small\" style=\"color:'+sc+'\">'+st+'</div>';
|
||||
var lb={'qwen3.6-35B-A3B':'MoE','qwen3.6-27B-code':'Dense','gemma-4-12b':'VLM'};
|
||||
var lb={'strix-moe':'MoE','gpu-dense':'Dense','gpu-vision':'VLM'};
|
||||
data.gpus.forEach(function(g){var a=g.active_requests||0,mx=g.max_concurrent||1,gp=mx>0?Math.round(a/mx*100):0;h+='<div class=\"d-flex align-items-center gap-2 mb-1 justify-content-center\"><span class=\"small\" style=\"min-width:32px;text-align:right;font-size:10px\">'+(lb[g.id]||g.id)+'</span><div style=\"flex:1;max-width:70px;height:3px;background:#1e293b;border-radius:2px;overflow:hidden\"><div style=\"height:100%;width:'+gp+'%;background:'+sc+';border-radius:2px\"></div></div><span class=\"small\" style=\"min-width:22px;font-size:10px\">'+a+'/'+mx+'</span></div>'});
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
function renderGPUMetrics(data){
|
||||
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 lb={'strix-moe':'MoE','gpu-dense':'Dense','gpu-vision':'VLM'};
|
||||
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 tc=tp>85?'#ef4444':tp>70?'#f59e0b':'#22c55e',uc=ut>90?'#ef4444':ut>70?'#f59e0b':'#22c55e';
|
||||
@@ -219,8 +219,8 @@ function loadPerf(){fetch('/api/performance?window='+perfWindow).then(function(r
|
||||
function renderPerf(d){
|
||||
var models=d.models||[],reasons=d.reasons||[],agents=d.agents||[],sum=d.summary||{};
|
||||
// Latency bars: p50/p95/p99 per model
|
||||
var mlab={'qwen3.6-35B-A3B':'35B MoE','qwen3.6-27B-code':'27B Dense','gemma-4-12b':'12B VLM'};
|
||||
var mcol={'qwen3.6-35B-A3B':'#a78bfa','qwen3.6-27B-code':'#f59e0b','gemma-4-12b':'#22c55e'};
|
||||
var mlab={'strix-moe':'35B MoE','gpu-dense':'27B Dense','gpu-vision':'9B VLM'};
|
||||
var mcol={'strix-moe':'#a78bfa','gpu-dense':'#f59e0b','gpu-vision':'#22c55e'};
|
||||
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){
|
||||
@@ -270,8 +270,8 @@ fetch('/api/scatter?window=24&model='+m).then(function(r){return r.json()}).then
|
||||
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={'qwen3.6-35B-A3B':'#a78bfa','qwen3.6-27B-code':'#f59e0b','gemma-4-12b':'#22c55e','unknown':'#38bdf8'};
|
||||
var mlab={'qwen3.6-35B-A3B':'35B MoE','qwen3.6-27B-code':'27B Dense','gemma-4-12b':'12B VLM'};
|
||||
var mcol={'strix-moe':'#a78bfa','gpu-dense':'#f59e0b','gpu-vision':'#22c55e','unknown':'#38bdf8'};
|
||||
var mlab={'strix-moe':'35B MoE','gpu-dense':'27B Dense','gpu-vision':'9B VLM'};
|
||||
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)
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Syslog GPU Monitor</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 20px; }
|
||||
h1 { font-size: 22px; margin-bottom: 8px; color: #58a6ff; }
|
||||
.subtitle { color: #8b949e; font-size: 13px; margin-bottom: 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; }
|
||||
.card h2 { font-size: 16px; margin-bottom: 12px; color: #f0f6fc; }
|
||||
.card.down { border-color: #da3633; }
|
||||
.card.warn { border-color: #d29922; }
|
||||
.metric { display: flex; justify-content: space-between; padding: 4px 0; font-size: 14px; }
|
||||
.metric .label { color: #8b949e; }
|
||||
.metric .value { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.value.good { color: #3fb950; }
|
||||
.value.warn { color: #d29922; }
|
||||
.value.bad { color: #da3633; }
|
||||
.bar-bg { background: #21262d; border-radius: 4px; height: 8px; margin: 4px 0 8px; overflow: hidden; }
|
||||
.bar { height: 100%; border-radius: 4px; transition: width 0.5s; }
|
||||
.bar.good { background: #3fb950; }
|
||||
.bar.warn { background: #d29922; }
|
||||
.bar.bad { background: #da3633; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
|
||||
.stat { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; text-align: center; }
|
||||
.stat .num { font-size: 28px; font-weight: 700; }
|
||||
.stat .lbl { font-size: 11px; color: #8b949e; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
|
||||
.status-dot.healthy { background: #3fb950; }
|
||||
.status-dot.warning { background: #d29922; }
|
||||
.status-dot.down { background: #da3633; }
|
||||
.circuit { font-size: 12px; padding: 2px 8px; border-radius: 4px; display: inline-block; }
|
||||
.circuit.open { background: #da363322; color: #da3633; border: 1px solid #da363344; }
|
||||
.circuit.closed { background: #3fb95022; color: #3fb950; border: 1px solid #3fb95044; }
|
||||
footer { text-align: center; color: #484f58; font-size: 11px; margin-top: 20px; }
|
||||
.refresh { animation: pulse 0.3s; }
|
||||
@keyframes pulse { 0%{opacity:0.4} 100%{opacity:1} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>⚡ Syslog GPU Monitor</h1>
|
||||
<p class="subtitle">Real-time GPU health — <span id="updated">loading...</span></p>
|
||||
|
||||
<div class="summary">
|
||||
<div class="stat"><div class="num good" id="gpus-online">-</div><div class="lbl">GPUs Online</div></div>
|
||||
<div class="stat"><div class="num" id="total-requests">-</div><div class="lbl">Active Requests</div></div>
|
||||
<div class="stat"><div class="num warn" id="trip-count">-</div><div class="lbl">Circuit Trips</div></div>
|
||||
<div class="stat"><div class="num" id="slots-used">-</div><div class="lbl">Slots Used</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="gpu-grid"></div>
|
||||
|
||||
<footer>Syslog Solution LLC — Auto-refreshes every 5s · <span id="last-refresh">—</span></footer>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
|
||||
async function fetchJSON(url) {
|
||||
const r = await fetch(url);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function barClass(pct) {
|
||||
if (pct > 90) return 'bad';
|
||||
if (pct > 75) return 'warn';
|
||||
return 'good';
|
||||
}
|
||||
|
||||
function tempClass(temp) {
|
||||
if (temp > 80) return 'bad';
|
||||
if (temp > 65) return 'warn';
|
||||
return 'good';
|
||||
}
|
||||
|
||||
function scoreClass(score) {
|
||||
if (score > 60) return 'bad';
|
||||
if (score > 35) return 'warn';
|
||||
return 'good';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [health, perf] = await Promise.all([
|
||||
fetchJSON(API + '/metrics/gpu-health'),
|
||||
fetchJSON(API + '/metrics/performance?window=1')
|
||||
]);
|
||||
|
||||
// Summary stats
|
||||
document.getElementById('gpus-online').textContent = health.kpi.gpus_online + '/' + health.kpi.total_gpus;
|
||||
document.getElementById('trip-count').textContent = health.kpi.total_trips;
|
||||
document.getElementById('total-requests').textContent = perf.summary?.total_requests || 0;
|
||||
const used = health.gpus.reduce((s,g) => s + g.active_requests, 0);
|
||||
const total = health.gpus.reduce((s,g) => s + g.max_concurrent, 0);
|
||||
document.getElementById('slots-used').textContent = used + '/' + total;
|
||||
|
||||
// GPU cards
|
||||
const grid = document.getElementById('gpu-grid');
|
||||
grid.innerHTML = health.gpus.map(g => {
|
||||
const statusClass = g.status === 'healthy' ? 'healthy' : g.status === 'down' ? 'down' : 'warning';
|
||||
const circuitLabel = g.circuit_tripped ? 'OPEN' : 'closed';
|
||||
const circuitClass = g.circuit_tripped ? 'open' : 'closed';
|
||||
const cardClass = g.circuit_tripped ? 'warn' : g.status === 'down' ? 'down' : '';
|
||||
const activeLabel = `${g.active_requests}/${g.max_concurrent}`;
|
||||
const name = g.label || g.id;
|
||||
|
||||
return `
|
||||
<div class="card ${cardClass}">
|
||||
<h2><span class="status-dot ${statusClass}"></span>${name}</h2>
|
||||
<div class="metric"><span class="label">Health Score</span><span class="value ${scoreClass(g.health_score)}">${g.health_score}</span></div>
|
||||
<div class="metric"><span class="label">VRAM</span><span class="value ${barClass(g.vram_pct)}">${g.vram_pct}%</span></div>
|
||||
<div class="bar-bg"><div class="bar ${barClass(g.vram_pct)}" style="width:${g.vram_pct}%"></div></div>
|
||||
<div class="metric"><span class="label">Temperature</span><span class="value ${tempClass(g.temp_c)}">${g.temp_c}°C</span></div>
|
||||
<div class="bar-bg"><div class="bar ${tempClass(g.temp_c)}" style="width:${Math.min(g.temp_c,100)}%"></div></div>
|
||||
<div class="metric"><span class="label">Active</span><span class="value">${activeLabel}</span></div>
|
||||
<div class="metric"><span class="label">Circuit</span><span class="circuit ${circuitClass}">${circuitLabel}</span></div>
|
||||
<div class="metric"><span class="label">Trips</span><span class="value">${g.circuit_trip_count}</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('updated').textContent = new Date().toLocaleTimeString();
|
||||
document.getElementById('last-refresh').textContent = 'Last refresh: ' + new Date().toLocaleTimeString();
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -362,7 +362,7 @@ function dashboard() {
|
||||
try {
|
||||
const metrics = await fetch('/metrics/circuit-breaker').then(r => r.json());
|
||||
this.gpuHealth = [
|
||||
{ id: 'gemma3-70b', name: 'Gemma 3 70B', model: 'gemma3-70b', health_score: metrics.gemma3_70b?.gpu_health_score || 39.4, vram_pct: 45, temp: 78, load: 65, is_preferred: true },
|
||||
{ id: 'gpu-vision', name: 'Qwen3.5-9B Vision', model: 'gpu-vision', health_score: metrics.gpu_vision?.gpu_health_score || 39.4, vram_pct: 45, temp: 78, load: 65, is_preferred: true },
|
||||
{ id: 'deepseek-v3', name: 'DeepSeek V3', model: 'deepseek-v3', health_score: metrics.deepseek_v3?.gpu_health_score || 45.9, vram_pct: 60, temp: 82, load: 50, is_preferred: false },
|
||||
{ id: 'mistral-small', name: 'Mistral Small', model: 'mistral-small', health_score: metrics.mistral_small?.gpu_health_score || 35.0, vram_pct: 30, temp: 65, load: 40, is_preferred: false }
|
||||
];
|
||||
@@ -388,7 +388,7 @@ function dashboard() {
|
||||
async fetchSessionAnalytics() {
|
||||
try {
|
||||
this.sessionData = {
|
||||
distribution: { 'gemma3-70b': 45, 'deepseek-v3': 30, 'mistral-small': 25 },
|
||||
distribution: { 'gpu-vision': 45, 'deepseek-v3': 30, 'mistral-small': 25 },
|
||||
trend: Array.from({ length: 24 }, (_, i) => ({ time: `${i}:00`, sessions: Math.floor(Math.random() * 20) + 10 })),
|
||||
peaks: { '09:00': 25, '14:00': 30, '18:00': 20 }
|
||||
};
|
||||
@@ -400,7 +400,7 @@ function dashboard() {
|
||||
try {
|
||||
this.systemPerf = {
|
||||
latency: { p50: Math.floor(Math.random() * 50) + 100, p95: Math.floor(Math.random() * 200) + 250, p99: Math.floor(Math.random() * 500) + 400 },
|
||||
errorRates: { 'gemma3-70b': Math.random() * 0.01, 'deepseek-v3': Math.random() * 0.02, 'mistral-small': Math.random() * 0.015 }
|
||||
errorRates: { 'gpu-vision': Math.random() * 0.01, 'deepseek-v3': Math.random() * 0.02, 'mistral-small': Math.random() * 0.015 }
|
||||
};
|
||||
console.log('System performance loaded');
|
||||
} catch (error) { console.error('Failed to load system performance:', error); }
|
||||
@@ -434,7 +434,7 @@ function dashboard() {
|
||||
},
|
||||
|
||||
getGPUColor(name) {
|
||||
const colors = { 'gemma3-70b': '#3b82f6', 'deepseek-v3': '#8b5cf6', 'mistral-small': '#10b981' };
|
||||
const colors = { 'gpu-vision': '#3b82f6', 'deepseek-v3': '#8b5cf6', 'mistral-small': '#10b981' };
|
||||
return colors[name] || '#9ca3af';
|
||||
}
|
||||
};
|
||||
|
||||
+16
-33
@@ -25,7 +25,7 @@ services:
|
||||
environment:
|
||||
- POSTGRES_DB=litellm
|
||||
- POSTGRES_USER=litellm
|
||||
- POSTGRES_PASSWORD=d9fc143e3dc1a7a8e672c359fea95c5e
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -34,57 +34,38 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
router:
|
||||
build: ./router
|
||||
container_name: harness-router
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_DENSE_URL=http://192.168.68.8:8080/v1
|
||||
- GPU_LIGHT_URL=http://192.168.68.110:8080/v1
|
||||
- API_KEYS={"sk-syslog-local-master-key":{"tier":"enterprise","agent":"admin","deprecated":true},"sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64":{"tier":"enterprise","agent":"admin"},"sk-syslog-abiba":{"tier":"enterprise","agent":"Abiba","deprecated":true},"sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889":{"tier":"enterprise","agent":"Abiba"},"sk-syslog-mumuni":{"tier":"enterprise","agent":"Mumuni","deprecated":true},"sk-b57e6e042e-47573660114f3138c852c47f62da807e":{"tier":"enterprise","agent":"Mumuni"},"sk-syslog-tanko":{"tier":"enterprise","agent":"Tanko","deprecated":true},"sk-620a05e95a-e93d875476b650a4d1137249ead8eaa7":{"tier":"enterprise","agent":"Tanko"},"sk-syslog-koby":{"tier":"enterprise","agent":"Koby","deprecated":true},"sk-eb3e6fc1c0-de1bf2edf35a53cb3749a2400483fdee":{"tier":"enterprise","agent":"Koby"},"sk-syslog-kagenz0":{"tier":"enterprise","agent":"Kagenz0","deprecated":true},"sk-12b66b3392-b548aed9138aeb6f698e8e521650ed9b":{"tier":"enterprise","agent":"Kagenz0"},"sk-syslog-koonimo":{"tier":"enterprise","agent":"Koonimo","deprecated":true},"sk-680d06686c-00ee8bf9dc3c93b276af122d49a14dfe":{"tier":"enterprise","agent":"Koonimo"},"sk-starter-abc123":{"tier":"starter","agent":"test-starter","deprecated":true},"sk-55da55907a-1bd7ff344e26feda50e9ac2219697860":{"tier":"starter","agent":"test-starter"},"sk-professional-xyz789":{"tier":"professional","agent":"test-pro","deprecated":true},"sk-b5159863e6-8df3ae52fb958cfe76cc2888c8c8e676":{"tier":"professional","agent":"test-pro"}}
|
||||
- ADMIN_KEY=sk-admin-ee09fffd04978b61a1569ac670c68814
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/health')"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
litellm:
|
||||
image: docker.litellm.ai/berriai/litellm:1.90.0-rc.1
|
||||
image: ghcr.io/docker.litellm.ai/berriai/litellm:1.99.1
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
container_name: harness-litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4001:4000"
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
- /opt/combined-ca-bundle.pem:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
environment:
|
||||
- LITELLM_MASTER_KEY=sk-litellm-7f96080dd99b15c36bd4b333b58a6796
|
||||
- ROUTER_API_KEY=sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64
|
||||
- DATABASE_URL=postgresql://litellm:d9fc143e3dc1a7a8e672c359fea95c5e@postgres:5432/litellm
|
||||
- PROMETHEUS_EXPORTER=true
|
||||
- ENFORCE_PRISMA_MIGRATION_CHECK=true
|
||||
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY}
|
||||
- DATABASE_URL=postgresql://litellm:${POSTGRES_PASSWORD}@postgres:5432/litellm
|
||||
- STORE_MODEL_IN_DB=True
|
||||
- LITELLM_UI_USERNAME=admin
|
||||
- LITELLM_UI_PASSWORD=syslog-admin-2026
|
||||
- LITELLM_UI_PASSWORD=${LITELLM_UI_PASSWORD}
|
||||
- UI_USERNAME=admin
|
||||
- UI_PASSWORD=syslog-admin-2026
|
||||
- OPENAI_API_KEY=not-used
|
||||
- UI_PASSWORD=${UI_PASSWORD}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- PROXY_BASE_URL=https://litellm.sysloggh.net
|
||||
- DOCS_URL=/docs
|
||||
- ANTHROPIC_API_KEY=not-used
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- GENERIC_CLIENT_ID=FHd7bs9dP5gHad2Ki23iUL5kQvFa0GRaj3nlLnNU
|
||||
- GENERIC_CLIENT_SECRET=aDkXQx82duqpxc98xxqp0quzzUf1mawsnOTqj7sx1acaS7rWSt02N5ksBCi92n8ZilRavigoYME6fLakP20Ixc9H2pxnSZFiOqQLb7BPi8UtsvfxmzXklD0HJIdbKFxe
|
||||
- GENERIC_CLIENT_SECRET=${GENERIC_CLIENT_SECRET}
|
||||
- GENERIC_AUTHORIZATION_ENDPOINT=https://auth.sysloggh.net/application/o/authorize/
|
||||
- GENERIC_TOKEN_ENDPOINT=http://harness-nginx/application/o/token/
|
||||
- GENERIC_USERINFO_ENDPOINT=http://harness-nginx/application/o/userinfo/
|
||||
- GENERIC_SCOPE=openid email profile
|
||||
- MCP_SERVER_RAHOS_URL=http://192.168.68.65:3100/mcp
|
||||
- MCP_SERVER_RAHOS_TRANSPORT=http
|
||||
- GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE=proxy_admin
|
||||
- SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
extra_hosts:
|
||||
@@ -95,6 +76,7 @@ services:
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 120s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -138,6 +120,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
pgdata:
|
||||
|
||||
+62
-14
@@ -1,4 +1,4 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
@@ -16,43 +16,89 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: harness-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=litellm
|
||||
- POSTGRES_USER=litellm
|
||||
- POSTGRES_PASSWORD=d9fc143e3dc1a7a8e672c359fea95c5e
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U litellm"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
router:
|
||||
build: ./router
|
||||
container_name: harness-router
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "127.0.0.1:9000:9000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_DENSE_URL=http://192.168.68.8:8080/v1
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_LIGHT_URL=http://192.168.68.110:8080/v1
|
||||
- API_KEYS={"sk-syslog-local-master-key":{"tier":"enterprise","agent":"admin","deprecated":true},"sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64":{"tier":"enterprise","agent":"admin"},"sk-syslog-abiba":{"tier":"enterprise","agent":"Abiba","deprecated":true},"sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889":{"tier":"enterprise","agent":"Abiba"},"sk-syslog-mumuni":{"tier":"enterprise","agent":"Mumuni","deprecated":true},"sk-b57e6e042e-47573660114f3138c852c47f62da807e":{"tier":"enterprise","agent":"Mumuni"},"sk-syslog-tanko":{"tier":"enterprise","agent":"Tanko","deprecated":true},"sk-620a05e95a-e93d875476b650a4d1137249ead8eaa7":{"tier":"enterprise","agent":"Tanko"},"sk-syslog-koby":{"tier":"enterprise","agent":"Koby","deprecated":true},"sk-eb3e6fc1c0-de1bf2edf35a53cb3749a2400483fdee":{"tier":"enterprise","agent":"Koby"},"sk-syslog-kagenz0":{"tier":"enterprise","agent":"Kagenz0","deprecated":true},"sk-12b66b3392-b548aed9138aeb6f698e8e521650ed9b":{"tier":"enterprise","agent":"Kagenz0"},"sk-syslog-koonimo":{"tier":"enterprise","agent":"Koonimo","deprecated":true},"sk-680d06686c-00ee8bf9dc3c93b276af122d49a14dfe":{"tier":"enterprise","agent":"Koonimo"},"sk-starter-abc123":{"tier":"starter","agent":"test-starter","deprecated":true},"sk-55da55907a-1bd7ff344e26feda50e9ac2219697860":{"tier":"starter","agent":"test-starter"},"sk-professional-xyz789":{"tier":"professional","agent":"test-pro","deprecated":true},"sk-b5159863e6-8df3ae52fb958cfe76cc2888c8c8e676":{"tier":"professional","agent":"test-pro"}}
|
||||
- ADMIN_KEY=sk-admin-ee09fffd04978b61a1569ac670c68814
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-stable
|
||||
image: ghcr.io/docker.litellm.ai/berriai/litellm:1.90.0-rc.1
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
container_name: harness-litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:4000"
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
- /opt/combined-ca-bundle.pem:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
environment:
|
||||
- LITELLM_MASTER_KEY=sk-syslog-local-master-key
|
||||
- PROMETHEUS_EXPORTER=true
|
||||
- LITELLM_MASTER_KEY=sk-litellm-7f96080dd99b15c36bd4b333b58a6796
|
||||
- ROUTER_API_KEY=sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64
|
||||
- DATABASE_URL=postgresql://litellm:d9fc143e3dc1a7a8e672c359fea95c5e@postgres:5432/litellm
|
||||
- STORE_MODEL_IN_DB=True
|
||||
- LITELLM_UI_USERNAME=admin
|
||||
- LITELLM_UI_PASSWORD=syslog-admin-2026
|
||||
- UI_USERNAME=admin
|
||||
- UI_PASSWORD=syslog-admin-2026
|
||||
- OPENAI_API_KEY=not-used
|
||||
- PROXY_BASE_URL=https://litellm.sysloggh.net
|
||||
- DOCS_URL=/docs
|
||||
- ANTHROPIC_API_KEY=not-used
|
||||
- GENERIC_CLIENT_ID=FHd7bs9dP5gHad2Ki23iUL5kQvFa0GRaj3nlLnNU
|
||||
- GENERIC_CLIENT_SECRET=aDkXQx82duqpxc98xxqp0quzzUf1mawsnOTqj7sx1acaS7rWSt02N5ksBCi92n8ZilRavigoYME6fLakP20Ixc9H2pxnSZFiOqQLb7BPi8UtsvfxmzXklD0HJIdbKFxe
|
||||
- GENERIC_AUTHORIZATION_ENDPOINT=https://auth.sysloggh.net/application/o/authorize/
|
||||
- GENERIC_TOKEN_ENDPOINT=http://harness-nginx/application/o/token/
|
||||
- GENERIC_USERINFO_ENDPOINT=http://harness-nginx/application/o/userinfo/
|
||||
- GENERIC_SCOPE=openid email profile
|
||||
- GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE=proxy_admin
|
||||
- SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -64,10 +110,13 @@ services:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dashboard:/opt/inference-harness/dashboard:ro
|
||||
extra_hosts:
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- litellm
|
||||
@@ -78,7 +127,7 @@ services:
|
||||
container_name: harness-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_SIDECARS=192.168.68.15:8090,192.168.68.8:8090,192.168.68.110:8090
|
||||
@@ -90,8 +139,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
|
||||
# LiteLLM command override to load config
|
||||
# (appended to fix config loading issue)
|
||||
pgdata:
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: harness-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
router:
|
||||
build: ./router
|
||||
container_name: harness-router
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_DENSE_URL=http://192.168.68.8:8080/v1
|
||||
- GPU_LIGHT_URL=http://192.168.68.110:8080/v1
|
||||
- API_KEYS={"sk-syslog-local-master-key":{"tier":"enterprise","agent":"admin","deprecated":true},"sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64":{"tier":"enterprise","agent":"admin"},"sk-syslog-abiba":{"tier":"enterprise","agent":"Abiba","deprecated":true},"sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889":{"tier":"enterprise","agent":"Abiba"},"sk-syslog-mumuni":{"tier":"enterprise","agent":"Mumuni","deprecated":true},"sk-b57e6e042e-47573660114f3138c852c47f62da807e":{"tier":"enterprise","agent":"Mumuni"},"sk-syslog-tanko":{"tier":"enterprise","agent":"Tanko","deprecated":true},"sk-620a05e95a-e93d875476b650a4d1137249ead8eaa7":{"tier":"enterprise","agent":"Tanko"},"sk-syslog-koby":{"tier":"enterprise","agent":"Koby","deprecated":true},"sk-eb3e6fc1c0-de1bf2edf35a53cb3749a2400483fdee":{"tier":"enterprise","agent":"Koby"},"sk-syslog-kagenz0":{"tier":"enterprise","agent":"Kagenz0","deprecated":true},"sk-12b66b3392-b548aed9138aeb6f698e8e521650ed9b":{"tier":"enterprise","agent":"Kagenz0"},"sk-syslog-koonimo":{"tier":"enterprise","agent":"Koonimo","deprecated":true},"sk-680d06686c-00ee8bf9dc3c93b276af122d49a14dfe":{"tier":"enterprise","agent":"Koonimo"},"sk-starter-abc123":{"tier":"starter","agent":"test-starter","deprecated":true},"sk-55da55907a-1bd7ff344e26feda50e9ac2219697860":{"tier":"starter","agent":"test-starter"},"sk-professional-xyz789":{"tier":"professional","agent":"test-pro","deprecated":true},"sk-b5159863e6-8df3ae52fb958cfe76cc2888c8c8e676":{"tier":"professional","agent":"test-pro"}}
|
||||
- ADMIN_KEY=sk-admin-ee09fffd04978b61a1569ac670c68814
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/health')"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-stable
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
container_name: harness-litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8081:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
environment:
|
||||
- LITELLM_MASTER_KEY=sk-sys...-key
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: harness-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dashboard:/opt/inference-harness/dashboard:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- litellm
|
||||
- dashboard
|
||||
|
||||
dashboard:
|
||||
build: ./dashboard
|
||||
container_name: harness-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_SIDECARS=192.168.68.15:8090,192.168.68.8:8090,192.168.68.110:8090
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
|
||||
# LiteLLM command override to load config
|
||||
# (appended to fix config loading issue)
|
||||
@@ -1,131 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: harness-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: harness-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=litellm
|
||||
- POSTGRES_USER=litellm
|
||||
- POSTGRES_PASSWORD=d9fc143e3dc1a7a8e672c359fea95c5e
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U litellm"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
router:
|
||||
build: ./router
|
||||
container_name: harness-router
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_DENSE_URL=http://192.168.68.8:8080/v1
|
||||
- GPU_LIGHT_URL=http://192.168.68.110:8080/v1
|
||||
- API_KEYS={"sk-syslog-local-master-key":{"tier":"enterprise","agent":"admin","deprecated":true},"sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64":{"tier":"enterprise","agent":"admin"},"sk-syslog-abiba":{"tier":"enterprise","agent":"Abiba","deprecated":true},"sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889":{"tier":"enterprise","agent":"Abiba"},"sk-syslog-mumuni":{"tier":"enterprise","agent":"Mumuni","deprecated":true},"sk-b57e6e042e-47573660114f3138c852c47f62da807e":{"tier":"enterprise","agent":"Mumuni"},"sk-syslog-tanko":{"tier":"enterprise","agent":"Tanko","deprecated":true},"sk-620a05e95a-e93d875476b650a4d1137249ead8eaa7":{"tier":"enterprise","agent":"Tanko"},"sk-syslog-koby":{"tier":"enterprise","agent":"Koby","deprecated":true},"sk-eb3e6fc1c0-de1bf2edf35a53cb3749a2400483fdee":{"tier":"enterprise","agent":"Koby"},"sk-syslog-kagenz0":{"tier":"enterprise","agent":"Kagenz0","deprecated":true},"sk-12b66b3392-b548aed9138aeb6f698e8e521650ed9b":{"tier":"enterprise","agent":"Kagenz0"},"sk-syslog-koonimo":{"tier":"enterprise","agent":"Koonimo","deprecated":true},"sk-680d06686c-00ee8bf9dc3c93b276af122d49a14dfe":{"tier":"enterprise","agent":"Koonimo"},"sk-starter-abc123":{"tier":"starter","agent":"test-starter","deprecated":true},"sk-55da55907a-1bd7ff344e26feda50e9ac2219697860":{"tier":"starter","agent":"test-starter"},"sk-professional-xyz789":{"tier":"professional","agent":"test-pro","deprecated":true},"sk-b5159863e6-8df3ae52fb958cfe76cc2888c8c8e676":{"tier":"professional","agent":"test-pro"}}
|
||||
- ADMIN_KEY=sk-admin-ee09fffd04978b61a1569ac670c68814
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/health')"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-stable
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
container_name: harness-litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
environment:
|
||||
- LITELLM_MASTER_KEY=sk-litellm-7f96080dd99b15c36bd4b333b58a6796
|
||||
- ROUTER_API_KEY=sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64
|
||||
- DATABASE_URL=postgresql://litellm:d9fc143e3dc1a7a8e672c359fea95c5e@postgres:5432/litellm
|
||||
- STORE_MODEL_IN_DB=True
|
||||
- LITELLM_UI_USERNAME=admin
|
||||
- LITELLM_UI_PASSWORD=syslog-admin-2026
|
||||
- OPENAI_API_KEY=not-used
|
||||
- PROXY_BASE_URL=http://192.168.68.116/litellm
|
||||
- ANTHROPIC_API_KEY=not-used
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: harness-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dashboard:/opt/inference-harness/dashboard:ro
|
||||
extra_hosts:
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- litellm
|
||||
- dashboard
|
||||
|
||||
dashboard:
|
||||
build: ./dashboard
|
||||
container_name: harness-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_SIDECARS=192.168.68.15:8090,192.168.68.8:8090,192.168.68.110:8090
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
pgdata:
|
||||
@@ -1,142 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: harness-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: harness-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=litellm
|
||||
- POSTGRES_USER=litellm
|
||||
- POSTGRES_PASSWORD=d9fc143e3dc1a7a8e672c359fea95c5e
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U litellm"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
router:
|
||||
build: ./router
|
||||
container_name: harness-router
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_DENSE_URL=http://192.168.68.8:8080/v1
|
||||
- GPU_LIGHT_URL=http://192.168.68.110:8080/v1
|
||||
- API_KEYS={"sk-syslog-local-master-key":{"tier":"enterprise","agent":"admin","deprecated":true},"sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64":{"tier":"enterprise","agent":"admin"},"sk-syslog-abiba":{"tier":"enterprise","agent":"Abiba","deprecated":true},"sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889":{"tier":"enterprise","agent":"Abiba"},"sk-syslog-mumuni":{"tier":"enterprise","agent":"Mumuni","deprecated":true},"sk-b57e6e042e-47573660114f3138c852c47f62da807e":{"tier":"enterprise","agent":"Mumuni"},"sk-syslog-tanko":{"tier":"enterprise","agent":"Tanko","deprecated":true},"sk-620a05e95a-e93d875476b650a4d1137249ead8eaa7":{"tier":"enterprise","agent":"Tanko"},"sk-syslog-koby":{"tier":"enterprise","agent":"Koby","deprecated":true},"sk-eb3e6fc1c0-de1bf2edf35a53cb3749a2400483fdee":{"tier":"enterprise","agent":"Koby"},"sk-syslog-kagenz0":{"tier":"enterprise","agent":"Kagenz0","deprecated":true},"sk-12b66b3392-b548aed9138aeb6f698e8e521650ed9b":{"tier":"enterprise","agent":"Kagenz0"},"sk-syslog-koonimo":{"tier":"enterprise","agent":"Koonimo","deprecated":true},"sk-680d06686c-00ee8bf9dc3c93b276af122d49a14dfe":{"tier":"enterprise","agent":"Koonimo"},"sk-starter-abc123":{"tier":"starter","agent":"test-starter","deprecated":true},"sk-55da55907a-1bd7ff344e26feda50e9ac2219697860":{"tier":"starter","agent":"test-starter"},"sk-professional-xyz789":{"tier":"professional","agent":"test-pro","deprecated":true},"sk-b5159863e6-8df3ae52fb958cfe76cc2888c8c8e676":{"tier":"professional","agent":"test-pro"}}
|
||||
- ADMIN_KEY=sk-admin-ee09fffd04978b61a1569ac670c68814
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/health')"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
litellm:
|
||||
image: docker.litellm.ai/berriai/litellm:1.90.0-rc.1
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
container_name: harness-litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4001:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
- /opt/combined-ca-bundle.pem:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
environment:
|
||||
- LITELLM_MASTER_KEY=sk-litellm-7f96080dd99b15c36bd4b333b58a6796
|
||||
- ROUTER_API_KEY=sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64
|
||||
- DATABASE_URL=postgresql://litellm:d9fc143e3dc1a7a8e672c359fea95c5e@postgres:5432/litellm
|
||||
- STORE_MODEL_IN_DB=True
|
||||
- LITELLM_UI_USERNAME=admin
|
||||
- LITELLM_UI_PASSWORD=syslog-admin-2026
|
||||
- UI_USERNAME=admin
|
||||
- UI_PASSWORD=syslog-admin-2026
|
||||
- OPENAI_API_KEY=not-used
|
||||
- PROXY_BASE_URL=https://litellm.sysloggh.net
|
||||
- DOCS_URL=/litellm/docs
|
||||
- ANTHROPIC_API_KEY=not-used
|
||||
- GENERIC_CLIENT_ID=FHd7bs9dP5gHad2Ki23iUL5kQvFa0GRaj3nlLnNU
|
||||
- GENERIC_CLIENT_SECRET=aDkXQx82duqpxc98xxqp0quzzUf1mawsnOTqj7sx1acaS7rWSt02N5ksBCi92n8ZilRavigoYME6fLakP20Ixc9H2pxnSZFiOqQLb7BPi8UtsvfxmzXklD0HJIdbKFxe
|
||||
- GENERIC_AUTHORIZATION_ENDPOINT=https://auth.sysloggh.net/application/o/authorize/
|
||||
- GENERIC_TOKEN_ENDPOINT=https://auth.sysloggh.net/application/o/token/
|
||||
- GENERIC_USERINFO_ENDPOINT=https://auth.sysloggh.net/application/o/userinfo/
|
||||
- GENERIC_SCOPE=openid email profile
|
||||
- GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE=proxy_admin
|
||||
- SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: harness-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dashboard:/opt/inference-harness/dashboard:ro
|
||||
extra_hosts:
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- litellm
|
||||
- dashboard
|
||||
|
||||
dashboard:
|
||||
build: ./dashboard
|
||||
container_name: harness-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_SIDECARS=192.168.68.15:8090,192.168.68.8:8090,192.168.68.110:8090
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
pgdata:
|
||||
@@ -1,143 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: harness-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: harness-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=litellm
|
||||
- POSTGRES_USER=litellm
|
||||
- POSTGRES_PASSWORD=d9fc143e3dc1a7a8e672c359fea95c5e
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U litellm"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
router:
|
||||
build: ./router
|
||||
container_name: harness-router
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_DENSE_URL=http://192.168.68.8:8080/v1
|
||||
- GPU_LIGHT_URL=http://192.168.68.110:8080/v1
|
||||
- API_KEYS={"sk-syslog-local-master-key":{"tier":"enterprise","agent":"admin","deprecated":true},"sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64":{"tier":"enterprise","agent":"admin"},"sk-syslog-abiba":{"tier":"enterprise","agent":"Abiba","deprecated":true},"sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889":{"tier":"enterprise","agent":"Abiba"},"sk-syslog-mumuni":{"tier":"enterprise","agent":"Mumuni","deprecated":true},"sk-b57e6e042e-47573660114f3138c852c47f62da807e":{"tier":"enterprise","agent":"Mumuni"},"sk-syslog-tanko":{"tier":"enterprise","agent":"Tanko","deprecated":true},"sk-620a05e95a-e93d875476b650a4d1137249ead8eaa7":{"tier":"enterprise","agent":"Tanko"},"sk-syslog-koby":{"tier":"enterprise","agent":"Koby","deprecated":true},"sk-eb3e6fc1c0-de1bf2edf35a53cb3749a2400483fdee":{"tier":"enterprise","agent":"Koby"},"sk-syslog-kagenz0":{"tier":"enterprise","agent":"Kagenz0","deprecated":true},"sk-12b66b3392-b548aed9138aeb6f698e8e521650ed9b":{"tier":"enterprise","agent":"Kagenz0"},"sk-syslog-koonimo":{"tier":"enterprise","agent":"Koonimo","deprecated":true},"sk-680d06686c-00ee8bf9dc3c93b276af122d49a14dfe":{"tier":"enterprise","agent":"Koonimo"},"sk-starter-abc123":{"tier":"starter","agent":"test-starter","deprecated":true},"sk-55da55907a-1bd7ff344e26feda50e9ac2219697860":{"tier":"starter","agent":"test-starter"},"sk-professional-xyz789":{"tier":"professional","agent":"test-pro","deprecated":true},"sk-b5159863e6-8df3ae52fb958cfe76cc2888c8c8e676":{"tier":"professional","agent":"test-pro"}}
|
||||
- ADMIN_KEY=sk-admin-ee09fffd04978b61a1569ac670c68814
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/health')"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
litellm:
|
||||
image: docker.litellm.ai/berriai/litellm:1.90.0-rc.1
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
container_name: harness-litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4001:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
- /opt/combined-ca-bundle.pem:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
environment:
|
||||
- LITELLM_MASTER_KEY=sk-litellm-7f96080dd99b15c36bd4b333b58a6796
|
||||
- ROUTER_API_KEY=sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64
|
||||
- DATABASE_URL=postgresql://litellm:d9fc143e3dc1a7a8e672c359fea95c5e@postgres:5432/litellm
|
||||
- STORE_MODEL_IN_DB=True
|
||||
- LITELLM_UI_USERNAME=admin
|
||||
- LITELLM_UI_PASSWORD=syslog-admin-2026
|
||||
- UI_USERNAME=admin
|
||||
- UI_PASSWORD=syslog-admin-2026
|
||||
- OPENAI_API_KEY=not-used
|
||||
- PROXY_BASE_URL=https://litellm.sysloggh.net
|
||||
- DOCS_URL=/litellm/docs
|
||||
- ANTHROPIC_API_KEY=not-used
|
||||
- GENERIC_CLIENT_ID=FHd7bs9dP5gHad2Ki23iUL5kQvFa0GRaj3nlLnNU
|
||||
- GENERIC_CLIENT_SECRET=aDkXQx82duqpxc98xxqp0quzzUf1mawsnOTqj7sx1acaS7rWSt02N5ksBCi92n8ZilRavigoYME6fLakP20Ixc9H2pxnSZFiOqQLb7BPi8UtsvfxmzXklD0HJIdbKFxe
|
||||
- GENERIC_AUTHORIZATION_ENDPOINT=https://auth.sysloggh.net/application/o/authorize/
|
||||
- GENERIC_TOKEN_ENDPOINT=https://auth.sysloggh.net/application/o/token/
|
||||
- GENERIC_USERINFO_ENDPOINT=https://auth.sysloggh.net/application/o/userinfo/
|
||||
- GENERIC_SCOPE=openid email profile
|
||||
- GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE=proxy_admin
|
||||
- SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: harness-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dashboard:/opt/inference-harness/dashboard:ro
|
||||
extra_hosts:
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- litellm
|
||||
- dashboard
|
||||
|
||||
dashboard:
|
||||
build: ./dashboard
|
||||
container_name: harness-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_SIDECARS=192.168.68.15:8090,192.168.68.8:8090,192.168.68.110:8090
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
pgdata:
|
||||
@@ -1,142 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: harness-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: harness-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=litellm
|
||||
- POSTGRES_USER=litellm
|
||||
- POSTGRES_PASSWORD=d9fc143e3dc1a7a8e672c359fea95c5e
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U litellm"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
router:
|
||||
build: ./router
|
||||
container_name: harness-router
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_DENSE_URL=http://192.168.68.8:8080/v1
|
||||
- GPU_LIGHT_URL=http://192.168.68.110:8080/v1
|
||||
- API_KEYS={"sk-syslog-local-master-key":{"tier":"enterprise","agent":"admin","deprecated":true},"sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64":{"tier":"enterprise","agent":"admin"},"sk-syslog-abiba":{"tier":"enterprise","agent":"Abiba","deprecated":true},"sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889":{"tier":"enterprise","agent":"Abiba"},"sk-syslog-mumuni":{"tier":"enterprise","agent":"Mumuni","deprecated":true},"sk-b57e6e042e-47573660114f3138c852c47f62da807e":{"tier":"enterprise","agent":"Mumuni"},"sk-syslog-tanko":{"tier":"enterprise","agent":"Tanko","deprecated":true},"sk-620a05e95a-e93d875476b650a4d1137249ead8eaa7":{"tier":"enterprise","agent":"Tanko"},"sk-syslog-koby":{"tier":"enterprise","agent":"Koby","deprecated":true},"sk-eb3e6fc1c0-de1bf2edf35a53cb3749a2400483fdee":{"tier":"enterprise","agent":"Koby"},"sk-syslog-kagenz0":{"tier":"enterprise","agent":"Kagenz0","deprecated":true},"sk-12b66b3392-b548aed9138aeb6f698e8e521650ed9b":{"tier":"enterprise","agent":"Kagenz0"},"sk-syslog-koonimo":{"tier":"enterprise","agent":"Koonimo","deprecated":true},"sk-680d06686c-00ee8bf9dc3c93b276af122d49a14dfe":{"tier":"enterprise","agent":"Koonimo"},"sk-starter-abc123":{"tier":"starter","agent":"test-starter","deprecated":true},"sk-55da55907a-1bd7ff344e26feda50e9ac2219697860":{"tier":"starter","agent":"test-starter"},"sk-professional-xyz789":{"tier":"professional","agent":"test-pro","deprecated":true},"sk-b5159863e6-8df3ae52fb958cfe76cc2888c8c8e676":{"tier":"professional","agent":"test-pro"}}
|
||||
- ADMIN_KEY=sk-admin-ee09fffd04978b61a1569ac670c68814
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/health')"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
litellm:
|
||||
image: docker.litellm.ai/berriai/litellm:1.90.0-rc.1
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
container_name: harness-litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4001:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
- /opt/combined-ca-bundle.pem:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
environment:
|
||||
- LITELLM_MASTER_KEY=sk-litellm-7f96080dd99b15c36bd4b333b58a6796
|
||||
- ROUTER_API_KEY=sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64
|
||||
- DATABASE_URL=postgresql://litellm:d9fc143e3dc1a7a8e672c359fea95c5e@postgres:5432/litellm
|
||||
- STORE_MODEL_IN_DB=True
|
||||
- LITELLM_UI_USERNAME=admin
|
||||
- LITELLM_UI_PASSWORD=syslog-admin-2026
|
||||
- UI_USERNAME=admin
|
||||
- UI_PASSWORD=syslog-admin-2026
|
||||
- OPENAI_API_KEY=not-used
|
||||
- PROXY_BASE_URL=https://litellm.sysloggh.net
|
||||
- ANTHROPIC_API_KEY=not-used
|
||||
- GENERIC_CLIENT_ID=FHd7bs9dP5gHad2Ki23iUL5kQvFa0GRaj3nlLnNU
|
||||
- GENERIC_CLIENT_SECRET=aDkXQx82duqpxc98xxqp0quzzUf1mawsnOTqj7sx1acaS7rWSt02N5ksBCi92n8ZilRavigoYME6fLakP20Ixc9H2pxnSZFiOqQLb7BPi8UtsvfxmzXklD0HJIdbKFxe
|
||||
- GENERIC_AUTHORIZATION_ENDPOINT=https://auth.sysloggh.net/application/o/authorize/
|
||||
- GENERIC_TOKEN_ENDPOINT=https://auth.sysloggh.net/application/o/token/
|
||||
- GENERIC_USERINFO_ENDPOINT=https://auth.sysloggh.net/application/o/userinfo/
|
||||
- GENERIC_SCOPE=openid email profile
|
||||
- GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE=proxy_admin
|
||||
- SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: harness-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dashboard:/opt/inference-harness/dashboard:ro
|
||||
extra_hosts:
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- litellm
|
||||
- dashboard
|
||||
|
||||
dashboard:
|
||||
build: ./dashboard
|
||||
container_name: harness-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_SIDECARS=192.168.68.15:8090,192.168.68.8:8090,192.168.68.110:8090
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
pgdata:
|
||||
@@ -1,143 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: harness-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: harness-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=litellm
|
||||
- POSTGRES_USER=litellm
|
||||
- POSTGRES_PASSWORD=d9fc143e3dc1a7a8e672c359fea95c5e
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U litellm"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
router:
|
||||
build: ./router
|
||||
container_name: harness-router
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_MOE_URL=http://192.168.68.15:8080/v1
|
||||
- GPU_DENSE_URL=http://192.168.68.8:8080/v1
|
||||
- GPU_LIGHT_URL=http://192.168.68.110:8080/v1
|
||||
- API_KEYS={"sk-syslog-local-master-key":{"tier":"enterprise","agent":"admin","deprecated":true},"sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64":{"tier":"enterprise","agent":"admin"},"sk-syslog-abiba":{"tier":"enterprise","agent":"Abiba","deprecated":true},"sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889":{"tier":"enterprise","agent":"Abiba"},"sk-syslog-mumuni":{"tier":"enterprise","agent":"Mumuni","deprecated":true},"sk-b57e6e042e-47573660114f3138c852c47f62da807e":{"tier":"enterprise","agent":"Mumuni"},"sk-syslog-tanko":{"tier":"enterprise","agent":"Tanko","deprecated":true},"sk-620a05e95a-e93d875476b650a4d1137249ead8eaa7":{"tier":"enterprise","agent":"Tanko"},"sk-syslog-koby":{"tier":"enterprise","agent":"Koby","deprecated":true},"sk-eb3e6fc1c0-de1bf2edf35a53cb3749a2400483fdee":{"tier":"enterprise","agent":"Koby"},"sk-syslog-kagenz0":{"tier":"enterprise","agent":"Kagenz0","deprecated":true},"sk-12b66b3392-b548aed9138aeb6f698e8e521650ed9b":{"tier":"enterprise","agent":"Kagenz0"},"sk-syslog-koonimo":{"tier":"enterprise","agent":"Koonimo","deprecated":true},"sk-680d06686c-00ee8bf9dc3c93b276af122d49a14dfe":{"tier":"enterprise","agent":"Koonimo"},"sk-starter-abc123":{"tier":"starter","agent":"test-starter","deprecated":true},"sk-55da55907a-1bd7ff344e26feda50e9ac2219697860":{"tier":"starter","agent":"test-starter"},"sk-professional-xyz789":{"tier":"professional","agent":"test-pro","deprecated":true},"sk-b5159863e6-8df3ae52fb958cfe76cc2888c8c8e676":{"tier":"professional","agent":"test-pro"}}
|
||||
- ADMIN_KEY=sk-admin-ee09fffd04978b61a1569ac670c68814
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/health')"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
litellm:
|
||||
image: docker.litellm.ai/berriai/litellm:1.90.0-rc.1
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
container_name: harness-litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4001:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
- /opt/combined-ca-bundle.pem:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
environment:
|
||||
- LITELLM_MASTER_KEY=sk-litellm-7f96080dd99b15c36bd4b333b58a6796
|
||||
- ROUTER_API_KEY=sk-9e65b69a67-e54af421c1b09fb8bd4f75dacb38cb64
|
||||
- DATABASE_URL=postgresql://litellm:d9fc143e3dc1a7a8e672c359fea95c5e@postgres:5432/litellm
|
||||
- STORE_MODEL_IN_DB=True
|
||||
- LITELLM_UI_USERNAME=admin
|
||||
- LITELLM_UI_PASSWORD=syslog-admin-2026
|
||||
- UI_USERNAME=admin
|
||||
- UI_PASSWORD=syslog-admin-2026
|
||||
- OPENAI_API_KEY=not-used
|
||||
- PROXY_BASE_URL=https://litellm.sysloggh.net
|
||||
- DOCS_URL=/docs
|
||||
- ANTHROPIC_API_KEY=not-used
|
||||
- GENERIC_CLIENT_ID=FHd7bs9dP5gHad2Ki23iUL5kQvFa0GRaj3nlLnNU
|
||||
- GENERIC_CLIENT_SECRET=aDkXQx82duqpxc98xxqp0quzzUf1mawsnOTqj7sx1acaS7rWSt02N5ksBCi92n8ZilRavigoYME6fLakP20Ixc9H2pxnSZFiOqQLb7BPi8UtsvfxmzXklD0HJIdbKFxe
|
||||
- GENERIC_AUTHORIZATION_ENDPOINT=https://auth.sysloggh.net/application/o/authorize/
|
||||
- GENERIC_TOKEN_ENDPOINT=https://auth.sysloggh.net/application/o/token/
|
||||
- GENERIC_USERINFO_ENDPOINT=https://auth.sysloggh.net/application/o/userinfo/
|
||||
- GENERIC_SCOPE=openid email profile
|
||||
- GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE=proxy_admin
|
||||
- SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: harness-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dashboard:/opt/inference-harness/dashboard:ro
|
||||
extra_hosts:
|
||||
- "auth.sysloggh.net:192.168.68.11"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- litellm
|
||||
- dashboard
|
||||
|
||||
dashboard:
|
||||
build: ./dashboard
|
||||
container_name: harness-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- GPU_SIDECARS=192.168.68.15:8090,192.168.68.8:8090,192.168.68.110:8090
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
pgdata:
|
||||
@@ -1,106 +0,0 @@
|
||||
## Syslog GPU Router — Nginx Configuration (Docker-internal)
|
||||
## Routes incoming agent requests to the appropriate GPU backend
|
||||
## based on the X-Syslog-Model header.
|
||||
|
||||
upstream amdpve_pool {
|
||||
## Strix Halo 395 — qwen3.6-35B-A3B (MoE) — Default workhorse
|
||||
server 192.168.68.15:8080;
|
||||
}
|
||||
|
||||
upstream llmgpu_pool {
|
||||
## RTX 3090 — qwen3.5-27B (Dense) — Heavy reasoning
|
||||
server 192.168.68.8:8080;
|
||||
}
|
||||
|
||||
upstream ocu_llm_pool {
|
||||
## New Backend — gemma-4-12b (VLM) — Vision + light tasks
|
||||
server 192.168.68.110:8080;
|
||||
}
|
||||
|
||||
upstream queue_service {
|
||||
## Agent queue with circuit breaker (Docker container)
|
||||
server queue-service:8091;
|
||||
}
|
||||
|
||||
upstream dashboard_service {
|
||||
## Harness dashboard (Docker container)
|
||||
server dashboard:3001;
|
||||
}
|
||||
|
||||
## ------------------------------------------------------------------
|
||||
## Mapping: X-Syslog-Model header → upstream backend
|
||||
## ------------------------------------------------------------------
|
||||
map $http_x_syslog_model $gpu_upstream {
|
||||
default amdpve_pool;
|
||||
"standard" amdpve_pool;
|
||||
"heavy" llmgpu_pool;
|
||||
"qwen3.5-27B" llmgpu_pool;
|
||||
"light" ocu_llm_pool;
|
||||
"gemma-4-12b" ocu_llm_pool;
|
||||
}
|
||||
|
||||
## Rate limit zone — 10 req/s per IP, burst of 20
|
||||
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
## ------------------------------------------------------------------
|
||||
## Dashboard — observability UI (MUST be before / catch-all)
|
||||
## ------------------------------------------------------------------
|
||||
location /dashboard {
|
||||
proxy_pass http://dashboard_service/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
## ------------------------------------------------------------------
|
||||
## Main location — proxy to selected upstream
|
||||
## ------------------------------------------------------------------
|
||||
location / {
|
||||
limit_req zone=perip burst=20 nodelay;
|
||||
limit_req_status 503;
|
||||
proxy_pass http://$gpu_upstream;
|
||||
|
||||
## Preserve original host and headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
## Pass through the model header so backends can log it
|
||||
proxy_pass_header X-Syslog-Model;
|
||||
|
||||
## Streaming support (SSE for LLM responses)
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
## Basic failover — retry on error or timeout
|
||||
proxy_next_upstream error timeout http_502 http_503;
|
||||
proxy_next_upstream_tries 2;
|
||||
|
||||
## Add a response header for observability
|
||||
add_header X-Routed-To $gpu_upstream always;
|
||||
|
||||
## Fallback to queue when all GPU upstreams are down
|
||||
error_page 502 503 504 = @queue_fallback;
|
||||
}
|
||||
|
||||
## ------------------------------------------------------------------
|
||||
## Queue fallback — enqueue when GPUs are unavailable
|
||||
## ------------------------------------------------------------------
|
||||
location @queue_fallback {
|
||||
rewrite ^ /enqueue break;
|
||||
proxy_pass http://queue_service;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Content-Type $content_type;
|
||||
proxy_pass_request_body on;
|
||||
}
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
## Syslog GPU Router — Nginx Configuration
|
||||
## Routes incoming agent requests to the appropriate GPU backend
|
||||
## based on the X-Syslog-Model header.
|
||||
|
||||
upstream amdpve_pool {
|
||||
## Strix Halo 395 — qwen3.6-35B-A3B (MoE) — Default workhorse
|
||||
server 192.168.68.15:8080;
|
||||
}
|
||||
|
||||
upstream llmgpu_pool {
|
||||
## RTX 3090 — qwen3.5-27B (Dense) — Heavy reasoning
|
||||
server 192.168.68.8:8080;
|
||||
}
|
||||
|
||||
upstream ocu_llm_pool {
|
||||
## New Backend — gemma-4-12b (VLM) — Vision + light tasks
|
||||
server 192.168.68.110:8080;
|
||||
}
|
||||
|
||||
upstream queue_service {
|
||||
## Agent queue with circuit breaker (Docker container)
|
||||
server 127.0.0.1:8091;
|
||||
}
|
||||
|
||||
upstream dashboard_service {
|
||||
## Harness dashboard (Docker container)
|
||||
server 127.0.0.1:3001;
|
||||
}
|
||||
|
||||
## ------------------------------------------------------------------
|
||||
## Mapping: X-Syslog-Model header → upstream backend
|
||||
## ------------------------------------------------------------------
|
||||
map $http_x_syslog_model $gpu_upstream {
|
||||
default amdpve_pool; # missing header → default workhorse
|
||||
"standard" amdpve_pool;
|
||||
"heavy" llmgpu_pool;
|
||||
"qwen3.5-27B" llmgpu_pool;
|
||||
"light" ocu_llm_pool;
|
||||
"gemma-4-12b" ocu_llm_pool;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
# Rate limit zone — 10 req/s per IP, burst of 20
|
||||
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
|
||||
|
||||
## ------------------------------------------------------------------
|
||||
## Dashboard — observability UI (MUST be before / catch-all)
|
||||
## ------------------------------------------------------------------
|
||||
location /dashboard {
|
||||
proxy_pass http://dashboard_service/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
## ------------------------------------------------------------------
|
||||
## Main location — proxy to selected upstream
|
||||
## ------------------------------------------------------------------
|
||||
location / {
|
||||
limit_req zone=perip burst=20 nodelay;
|
||||
limit_req_status 503;
|
||||
proxy_pass http://$gpu_upstream;
|
||||
|
||||
## Preserve original host and headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
## Pass through the model header so backends can log it
|
||||
proxy_pass_header X-Syslog-Model;
|
||||
|
||||
## Streaming support (SSE for LLM responses)
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
## Basic failover — retry on error or timeout
|
||||
proxy_next_upstream error timeout http_502 http_503;
|
||||
proxy_next_upstream_tries 2;
|
||||
|
||||
## Add a response header for observability
|
||||
add_header X-Routed-To $gpu_upstream always;
|
||||
|
||||
## Fallback to queue when all GPU upstreams are down
|
||||
error_page 502 503 504 = @queue_fallback;
|
||||
}
|
||||
|
||||
## ------------------------------------------------------------------
|
||||
## Queue fallback — enqueue when GPUs are unavailable
|
||||
## ------------------------------------------------------------------
|
||||
location @queue_fallback {
|
||||
rewrite ^ /enqueue break;
|
||||
proxy_pass http://queue_service;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Content-Type $content_type;
|
||||
proxy_pass_request_body on;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
models:
|
||||
gpu-vision:
|
||||
gpu_url: http://192.168.68.110:8080/v1
|
||||
gpu_host: 192.168.68.110
|
||||
label: Qwen3.5-9B Vision (RTX 5070)
|
||||
max_concurrent: 1
|
||||
context: 131072
|
||||
tiers: [starter, professional, enterprise]
|
||||
capabilities: [completion, multimodal]
|
||||
model_path: /home/llmuser/models/qwen3.5-9b/Qwen3.5-9B-Q5_K_M.gguf
|
||||
args: --mmproj /home/llmuser/models/qwen3.5-9b/mmproj-F16.gguf --ctx-size 131072 --cache-type-k q4_0 --cache-type-v q4_0 --batch-size 2048 --ubatch-size 1024 --n-gpu-layers 99 --flash-attn 1 --cont-batching --parallel 1 --image-min-tokens 2048 --image-max-tokens 16384 --alias gpu-light --reasoning off --api-key not-needed --predict 8192 --cache-prompt --port 8080 --host 0.0.0.0
|
||||
|
||||
gpu-dense:
|
||||
gpu_url: http://192.168.68.8:8080/v1
|
||||
sidecar_url: http://192.168.68.8:8090
|
||||
gpu_host: 192.168.68.8
|
||||
label: Qwen3.8-27B-Uncensored (RTX 3090)
|
||||
max_concurrent: 1
|
||||
context: 131072
|
||||
tiers: [professional, enterprise]
|
||||
capabilities: [completion]
|
||||
model_path: /home/llmuser/models/Qwen3.8-27B-Uncensored-Q4_K_M.gguf
|
||||
args: -m /home/llmuser/models/Qwen3.8-27B-Uncensored-Q4_K_M.gguf -c 131072 --host 0.0.0.0 --port 8080 -ngl 99 --alias qwen3.6-27B-code --api-key not-needed -ctk q4_0 -ctv q4_0 --flash-attn on --cont-batching --parallel 1 --slot-save-path /home/llmuser/.llama-slots --metrics --reasoning off --spec-type draft-mtp --spec-draft-n-max 2 -t 8
|
||||
|
||||
strix-moe:
|
||||
gpu_url: http://192.168.68.15:8080/v1
|
||||
sidecar_url: http://192.168.68.15:8090
|
||||
gpu_host: 192.168.68.15
|
||||
label: Carnice Qwen3.6 MoE 35B-A3B (Strix Halo)
|
||||
max_concurrent: 1
|
||||
context: 262144
|
||||
tiers: [professional, enterprise]
|
||||
capabilities: [completion, multimodal]
|
||||
model_path: /models/Carnice-Qwen3.6-MoE-35B-A3B-Q4_K_M.gguf
|
||||
args: -m /models/Carnice-Qwen3.6-MoE-35B-A3B-Q4_K_M.gguf --host 0.0.0.0 --port 8080 --alias strix-moe -c 262144 -ngl 99 -fa on --cache-type-k q4_0 --cache-type-v q4_0 --kv-unified --cache-prompt --cont-batching -b 4096 --ubatch-size 1024 --parallel 2 -t 16 --timeout 600 -n 8192 --reasoning off --no-warmup --metrics
|
||||
|
||||
hosts:
|
||||
gpu-light:
|
||||
address: 192.168.68.110
|
||||
gpu_name: NVIDIA GeForce RTX 5070
|
||||
vram_gb: 12
|
||||
current_model: gpu-vision
|
||||
|
||||
gpu-dense:
|
||||
address: 192.168.68.8
|
||||
gpu_name: NVIDIA GeForce RTX 3090
|
||||
vram_gb: 24
|
||||
current_model: gpu-dense
|
||||
|
||||
gpu-moe:
|
||||
address: 192.168.68.15
|
||||
gpu_name: AMD Strix Halo (iGPU)
|
||||
vram_gb: 64
|
||||
current_model: strix-moe
|
||||
@@ -0,0 +1,280 @@
|
||||
{
|
||||
"communities": {
|
||||
"0": [
|
||||
"router_route_v2",
|
||||
"router_route_v2_route",
|
||||
"router_route_v3",
|
||||
"router_route_v3_moe_spillover",
|
||||
"router_route_v3_rationale_81",
|
||||
"router_route_v3_route",
|
||||
"router_router_available_models",
|
||||
"router_router_estimate_tokens",
|
||||
"router_router_is_gpu_busy",
|
||||
"router_router_moe_spillover",
|
||||
"router_router_rationale_180",
|
||||
"router_router_rationale_216",
|
||||
"router_router_rationale_239",
|
||||
"router_router_rationale_386",
|
||||
"router_router_route",
|
||||
"router_router_select_best_gpu"
|
||||
],
|
||||
"1": [
|
||||
"dashboard_dashboard",
|
||||
"dashboard_dashboard_api_performance",
|
||||
"dashboard_dashboard_api_scatter",
|
||||
"dashboard_dashboard_api_state",
|
||||
"dashboard_dashboard_api_stream",
|
||||
"dashboard_dashboard_api_timeseries",
|
||||
"dashboard_dashboard_broadcast_loop",
|
||||
"dashboard_dashboard_dashboard",
|
||||
"dashboard_dashboard_fetch_state",
|
||||
"dashboard_dashboard_health",
|
||||
"dashboard_dashboard_rationale_1"
|
||||
],
|
||||
"2": [
|
||||
"queue_service_queue_service",
|
||||
"queue_service_queue_service_check_gpu_health",
|
||||
"queue_service_queue_service_enqueue",
|
||||
"queue_service_queue_service_get_queue_depth",
|
||||
"queue_service_queue_service_get_redis",
|
||||
"queue_service_queue_service_health",
|
||||
"queue_service_queue_service_rationale_100",
|
||||
"queue_service_queue_service_rationale_62",
|
||||
"queue_service_queue_service_rationale_68",
|
||||
"queue_service_queue_service_status"
|
||||
],
|
||||
"3": [
|
||||
"router_router_admin_auth",
|
||||
"router_router_admin_deprecation_summary",
|
||||
"router_router_admin_generate_key",
|
||||
"router_router_admin_keys",
|
||||
"router_router_admin_revoke_key",
|
||||
"router_router_rationale_895",
|
||||
"router_router_rationale_905",
|
||||
"router_router_rationale_928",
|
||||
"router_router_rationale_950",
|
||||
"router_router_rationale_978"
|
||||
],
|
||||
"4": [
|
||||
"router_router",
|
||||
"router_router_bcast",
|
||||
"router_router_get_metrics",
|
||||
"router_router_metrics",
|
||||
"router_router_metrics_circuit_breaker",
|
||||
"router_router_metrics_timeseries",
|
||||
"router_router_models",
|
||||
"router_router_rationale_811",
|
||||
"router_router_stream"
|
||||
],
|
||||
"5": [
|
||||
"router_router_get_redis",
|
||||
"router_router_gpu_decr",
|
||||
"router_router_gpu_incr",
|
||||
"router_router_half_open_probe",
|
||||
"router_router_is_circuit_tripped",
|
||||
"router_router_performance",
|
||||
"router_router_rationale_282",
|
||||
"router_router_rationale_297",
|
||||
"router_router_rationale_619"
|
||||
],
|
||||
"6": [
|
||||
"router_router_check_gpu_health",
|
||||
"router_router_gpu_active_count",
|
||||
"router_router_gpu_health_score",
|
||||
"router_router_health",
|
||||
"router_router_metrics_gpu_health",
|
||||
"router_router_rationale_141",
|
||||
"router_router_rationale_225",
|
||||
"router_router_rationale_828"
|
||||
],
|
||||
"7": [
|
||||
"router_router_chat",
|
||||
"router_router_check_rate_limit",
|
||||
"router_router_clean_response",
|
||||
"router_router_clean_unicode",
|
||||
"router_router_rationale_184",
|
||||
"router_router_rationale_72",
|
||||
"router_router_store_perf_record"
|
||||
],
|
||||
"8": [
|
||||
"maintenance",
|
||||
"maintenance_sh__entry"
|
||||
],
|
||||
"9": [
|
||||
"router_router_counter_audit_loop",
|
||||
"router_router_rationale_116"
|
||||
],
|
||||
"10": [
|
||||
"router_router_metrics_latency",
|
||||
"router_router_rationale_859"
|
||||
],
|
||||
"11": [
|
||||
"router_router_rationale_288",
|
||||
"router_router_trip_circuit"
|
||||
],
|
||||
"12": [
|
||||
"router_router_rationale_736",
|
||||
"router_router_scatter"
|
||||
],
|
||||
"13": [
|
||||
"router_http_patch"
|
||||
]
|
||||
},
|
||||
"cohesion": {
|
||||
"0": 0.20833333333333334,
|
||||
"1": 0.21818181818181817,
|
||||
"2": 0.3111111111111111,
|
||||
"3": 0.2,
|
||||
"4": 0.2777777777777778,
|
||||
"5": 0.2222222222222222,
|
||||
"6": 0.35714285714285715,
|
||||
"7": 0.3333333333333333,
|
||||
"8": 1.0,
|
||||
"9": 1.0,
|
||||
"10": 1.0,
|
||||
"11": 1.0,
|
||||
"12": 1.0,
|
||||
"13": 1.0
|
||||
},
|
||||
"gods": [
|
||||
{
|
||||
"id": "router_router_chat",
|
||||
"label": "chat()",
|
||||
"degree": 12
|
||||
},
|
||||
{
|
||||
"id": "router_router_get_redis",
|
||||
"label": "get_redis()",
|
||||
"degree": 11
|
||||
},
|
||||
{
|
||||
"id": "router_router_gpu_active_count",
|
||||
"label": "gpu_active_count()",
|
||||
"degree": 9
|
||||
},
|
||||
{
|
||||
"id": "router_router_is_gpu_busy",
|
||||
"label": "is_gpu_busy()",
|
||||
"degree": 9
|
||||
},
|
||||
{
|
||||
"id": "router_router_select_best_gpu",
|
||||
"label": "select_best_gpu()",
|
||||
"degree": 8
|
||||
},
|
||||
{
|
||||
"id": "router_router_route",
|
||||
"label": "route()",
|
||||
"degree": 8
|
||||
},
|
||||
{
|
||||
"id": "router_route_v3_route",
|
||||
"label": "route()",
|
||||
"degree": 6
|
||||
},
|
||||
{
|
||||
"id": "router_router_check_gpu_health",
|
||||
"label": "check_gpu_health()",
|
||||
"degree": 6
|
||||
},
|
||||
{
|
||||
"id": "router_router_available_models",
|
||||
"label": "available_models()",
|
||||
"degree": 6
|
||||
},
|
||||
{
|
||||
"id": "router_router_estimate_tokens",
|
||||
"label": "estimate_tokens()",
|
||||
"degree": 6
|
||||
}
|
||||
],
|
||||
"surprises": [
|
||||
{
|
||||
"source": "route()",
|
||||
"target": "available_models()",
|
||||
"source_files": [
|
||||
"router/route_v2.py",
|
||||
"router/router.py"
|
||||
],
|
||||
"confidence": "INFERRED",
|
||||
"relation": "calls",
|
||||
"why": "inferred connection - not explicitly stated in source"
|
||||
},
|
||||
{
|
||||
"source": "route()",
|
||||
"target": "estimate_tokens()",
|
||||
"source_files": [
|
||||
"router/route_v2.py",
|
||||
"router/router.py"
|
||||
],
|
||||
"confidence": "INFERRED",
|
||||
"relation": "calls",
|
||||
"why": "inferred connection - not explicitly stated in source"
|
||||
},
|
||||
{
|
||||
"source": "route()",
|
||||
"target": "is_gpu_busy()",
|
||||
"source_files": [
|
||||
"router/route_v2.py",
|
||||
"router/router.py"
|
||||
],
|
||||
"confidence": "INFERRED",
|
||||
"relation": "calls",
|
||||
"why": "inferred connection - not explicitly stated in source"
|
||||
},
|
||||
{
|
||||
"source": "route()",
|
||||
"target": "select_best_gpu()",
|
||||
"source_files": [
|
||||
"router/route_v2.py",
|
||||
"router/router.py"
|
||||
],
|
||||
"confidence": "INFERRED",
|
||||
"relation": "calls",
|
||||
"why": "inferred connection - not explicitly stated in source"
|
||||
},
|
||||
{
|
||||
"source": "route()",
|
||||
"target": "available_models()",
|
||||
"source_files": [
|
||||
"router/route_v3.py",
|
||||
"router/router.py"
|
||||
],
|
||||
"confidence": "INFERRED",
|
||||
"relation": "calls",
|
||||
"why": "inferred connection - not explicitly stated in source"
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"type": "bridge_node",
|
||||
"question": "Why does `is_gpu_busy()` connect `Community 0` to `Community 4`, `Community 6`?",
|
||||
"why": "High betweenness centrality (0.061) - this node is a cross-community bridge."
|
||||
},
|
||||
{
|
||||
"type": "bridge_node",
|
||||
"question": "Why does `select_best_gpu()` connect `Community 0` to `Community 4`, `Community 6`?",
|
||||
"why": "High betweenness centrality (0.031) - this node is a cross-community bridge."
|
||||
},
|
||||
{
|
||||
"type": "bridge_node",
|
||||
"question": "Why does `estimate_tokens()` connect `Community 0` to `Community 4`, `Community 7`?",
|
||||
"why": "High betweenness centrality (0.030) - this node is a cross-community bridge."
|
||||
},
|
||||
{
|
||||
"type": "verify_inferred",
|
||||
"question": "Are the 3 inferred relationships involving `is_gpu_busy()` (e.g. with `route()` and `moe_spillover()`) actually correct?",
|
||||
"why": "`is_gpu_busy()` has 3 INFERRED edges - model-reasoned connections that need verification."
|
||||
},
|
||||
{
|
||||
"type": "verify_inferred",
|
||||
"question": "Are the 3 inferred relationships involving `select_best_gpu()` (e.g. with `route()` and `route()`) actually correct?",
|
||||
"why": "`select_best_gpu()` has 3 INFERRED edges - model-reasoned connections that need verification."
|
||||
},
|
||||
{
|
||||
"type": "isolated_nodes",
|
||||
"question": "What connects `SyslogAI Harness Dashboard \u2014 Modern Design.`, `maintenance.sh script`, `Nginx upstream health probe. Returns 200 if service is alive.` to the rest of the system?",
|
||||
"why": "28 weakly-connected nodes found - possible documentation gaps or missing edges."
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"files": {"code": ["/root/syslog-harness-repo/dashboard/dashboard.py", "/root/syslog-harness-repo/maintenance.sh", "/root/syslog-harness-repo/phase0-dual-keys.json", "/root/syslog-harness-repo/queue-service/queue-service.py", "/root/syslog-harness-repo/router/http_patch.py", "/root/syslog-harness-repo/router/route_v2.py", "/root/syslog-harness-repo/router/route_v3.py", "/root/syslog-harness-repo/router/router.py"], "document": ["/root/syslog-harness-repo/LITELLM-MIGRATION-PLAN.md", "/root/syslog-harness-repo/MIGRATION_PLAN.md", "/root/syslog-harness-repo/README.md", "/root/syslog-harness-repo/dashboard/dashboard.html", "/root/syslog-harness-repo/dashboard/harness.html", "/root/syslog-harness-repo/dashboard/requirements.txt", "/root/syslog-harness-repo/docker-compose.yml", "/root/syslog-harness-repo/litellm_config.yaml", "/root/syslog-harness-repo/router/requirements.txt", "/root/syslog-harness-repo/ssl/README.md"], "paper": [], "image": [], "video": []}, "total_files": 18, "total_words": 14667, "needs_graph": false, "warning": "Corpus is ~14,667 words - fits in a single context window. You may not need a graph.", "skipped_sensitive": ["/root/syslog-harness-repo/.env.example"], "unclassified": ["/root/syslog-harness-repo/.gitignore", "/root/syslog-harness-repo/Dockerfile.dashboard", "/root/syslog-harness-repo/Dockerfile.queue", "/root/syslog-harness-repo/backups/20260602_103344/dashboard.py.bak", "/root/syslog-harness-repo/backups/20260602_103344/router.py.bak", "/root/syslog-harness-repo/backups/20260602_103344/ts_patch.py.bak", "/root/syslog-harness-repo/dashboard/Dockerfile", "/root/syslog-harness-repo/docker-compose.yml.bak", "/root/syslog-harness-repo/gpu-router-docker.conf", "/root/syslog-harness-repo/gpu-router.conf", "/root/syslog-harness-repo/nginx/nginx.conf", "/root/syslog-harness-repo/nginx/nginx.conf.bak", "/root/syslog-harness-repo/router/Dockerfile", "/root/syslog-harness-repo/router/router.py.bak.20260518074236"], "graphifyignore_patterns": 3, "scan_root": "/root/syslog-harness-repo"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Graph Report - . (2026-07-08)
|
||||
|
||||
## Corpus Check
|
||||
- Corpus is ~14,667 words - fits in a single context window. You may not need a graph.
|
||||
|
||||
## Summary
|
||||
- 91 nodes · 151 edges · 14 communities (9 shown, 5 thin omitted)
|
||||
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 10 edges (avg confidence: 0.77)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- Community 0
|
||||
- Community 1
|
||||
- Community 2
|
||||
- Community 3
|
||||
- Community 4
|
||||
- Community 5
|
||||
- Community 6
|
||||
- Community 7
|
||||
- Community 8
|
||||
- Community 9
|
||||
- Community 10
|
||||
- Community 11
|
||||
- Community 12
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `chat()` - 12 edges
|
||||
2. `get_redis()` - 11 edges
|
||||
3. `gpu_active_count()` - 9 edges
|
||||
4. `is_gpu_busy()` - 9 edges
|
||||
5. `select_best_gpu()` - 8 edges
|
||||
6. `route()` - 8 edges
|
||||
7. `route()` - 6 edges
|
||||
8. `check_gpu_health()` - 6 edges
|
||||
9. `available_models()` - 6 edges
|
||||
10. `estimate_tokens()` - 6 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `route()` --calls--> `available_models()` [INFERRED]
|
||||
router/route_v2.py → router/router.py
|
||||
- `route()` --calls--> `estimate_tokens()` [INFERRED]
|
||||
router/route_v2.py → router/router.py
|
||||
- `route()` --calls--> `is_gpu_busy()` [INFERRED]
|
||||
router/route_v2.py → router/router.py
|
||||
- `route()` --calls--> `select_best_gpu()` [INFERRED]
|
||||
router/route_v2.py → router/router.py
|
||||
- `route()` --calls--> `available_models()` [INFERRED]
|
||||
router/route_v3.py → router/router.py
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Communities (14 total, 5 thin omitted)
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.21
|
||||
Nodes (14): route(), moe_spillover(), Spill 40% of MoE-first traffic to Dense to prevent Strix Halo overheating. O, route(), available_models(), estimate_tokens(), is_gpu_busy(), moe_spillover() (+6 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.22
|
||||
Nodes (4): api_state(), broadcast_loop(), fetch_state(), SyslogAI Harness Dashboard — Modern Design.
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.31
|
||||
Nodes (9): check_gpu_health(), enqueue(), get_queue_depth(), get_redis(), health(), GET queue depth + circuit breaker state + GPU health., Nginx upstream health probe. Returns 200 if service is alive., Fallback endpoint — Nginx calls this when all GPU upstreams are down. (+1 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): _admin_auth(), admin_deprecation_summary(), admin_generate_key(), admin_keys(), admin_revoke_key(), Require admin key for management endpoints., List all API keys (masked) with agent, tier, and deprecation status., Summary of deprecated key usage (from Redis logs, if available). (+2 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.28
|
||||
Nodes (5): bcast(), get_metrics(), metrics(), metrics_circuit_breaker(), Expose circuit breaker status per model. Phase 1.
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): get_redis(), gpu_decr(), gpu_incr(), half_open_probe(), is_circuit_tripped(), performance(), Check if a GPU host is currently blacklisted., Check if a GPU host can be un-blacklisted. (+1 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.36
|
||||
Nodes (8): check_gpu_health(), gpu_active_count(), gpu_health_score(), health(), metrics_gpu_health(), Get number of in-flight requests for a GPU., Score a GPU based on VRAM, temperature, and load. Lower = better., Live GPU health scores + circuit breaker + KPIs.
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.33
|
||||
Nodes (7): chat(), check_rate_limit(), clean_response(), clean_unicode(), Store detailed performance record in Redis for analytics., Token bucket rate limiter using Redis. Returns (allowed, retry_after_or_remainin, store_perf_record()
|
||||
|
||||
## Knowledge Gaps
|
||||
- **1 isolated node(s):** `maintenance.sh script`
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **5 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `is_gpu_busy()` connect `Community 0` to `Community 4`, `Community 6`?**
|
||||
_High betweenness centrality (0.061) - this node is a cross-community bridge._
|
||||
- **Why does `select_best_gpu()` connect `Community 0` to `Community 4`, `Community 6`?**
|
||||
_High betweenness centrality (0.031) - this node is a cross-community bridge._
|
||||
- **Why does `estimate_tokens()` connect `Community 0` to `Community 4`, `Community 7`?**
|
||||
_High betweenness centrality (0.030) - this node is a cross-community bridge._
|
||||
- **Are the 3 inferred relationships involving `is_gpu_busy()` (e.g. with `route()` and `moe_spillover()`) actually correct?**
|
||||
_`is_gpu_busy()` has 3 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 3 inferred relationships involving `select_best_gpu()` (e.g. with `route()` and `route()`) actually correct?**
|
||||
_`select_best_gpu()` has 3 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `SyslogAI Harness Dashboard — Modern Design.`, `maintenance.sh script`, `Nginx upstream health probe. Returns 200 if service is alive.` to the rest of the system?**
|
||||
_28 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
graphify-out/cache/ast/v0.9.10/30b202626b1f50da90b78272001e253cc7086b0bdc52e46553414c5313e3fb4e.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.10/53edfda5145db02bc35b9e9d8e06e5c731ce50c3a50cec3db65b3b886bed95a6.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "root_syslog_harness_repo_router_http_patch_py", "label": "http_patch.py", "file_type": "code", "source_file": "router/http_patch.py", "source_location": "L1"}], "edges": [{"source": "root_syslog_harness_repo_router_http_patch_py", "target": "re", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "router/http_patch.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
graphify-out/cache/ast/v0.9.10/654e97aa6df1872b0717f32f676c067d15b9795a0dc04a86936730a7e133c214.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.10/801feb2c0b7f4052cb2c4ef933d295d3494c8791766687b891c04cc7b5495d09.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "root_syslog_harness_repo_maintenance_sh", "label": "maintenance.sh", "file_type": "code", "source_file": "maintenance.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "root_syslog_harness_repo_maintenance_sh__entry", "label": "maintenance.sh script", "file_type": "code", "source_file": "maintenance.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}], "edges": [{"source": "root_syslog_harness_repo_maintenance_sh", "target": "root_syslog_harness_repo_maintenance_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "maintenance.sh", "source_location": "L1", "weight": 1.0}]}
|
||||
graphify-out/cache/ast/v0.9.10/9c6817ae09f6bb5b2862f0282cacab7d4ce5da051e4ddc95e2e4099c7bf09716.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.10/c9a9d2e5c9f1fb7d35a73f91c83a047ce92dc2d09c9ddab8fc2d6e82d92a093d.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.10/e96ac2ae879e0a876d4190daff4cc566d13798b97c4820f3efa47e06627bbbcb.json
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"/root/syslog-harness-repo/LITELLM-MIGRATION-PLAN.md":{"size":31561,"mtime_ns":1781468608211508160,"word_count":3762},"/root/syslog-harness-repo/MIGRATION_PLAN.md":{"size":1966,"mtime_ns":1781121183850310435,"word_count":303},"/root/syslog-harness-repo/README.md":{"size":2447,"mtime_ns":1781121183850310435,"word_count":377},"/root/syslog-harness-repo/dashboard/dashboard.html":{"size":8331,"mtime_ns":1781316813360201927,"word_count":415},"/root/syslog-harness-repo/dashboard/dashboard.py":{"size":29010,"mtime_ns":1781121183851178603,"word_count":1615,"hash":"654e97aa6df1872b0717f32f676c067d15b9795a0dc04a86936730a7e133c214"},"/root/syslog-harness-repo/dashboard/harness.html":{"size":23388,"mtime_ns":1781316813362649901,"word_count":1869},"/root/syslog-harness-repo/dashboard/requirements.txt":{"size":30,"mtime_ns":1781121183851178603,"word_count":2},"/root/syslog-harness-repo/docker-compose.yml":{"size":3900,"mtime_ns":1782224658037786319,"word_count":200},"/root/syslog-harness-repo/litellm_config.yaml":{"size":618,"mtime_ns":1781121183851178603,"word_count":39},"/root/syslog-harness-repo/maintenance.sh":{"size":1302,"mtime_ns":1781121183851178603,"word_count":192,"hash":"801feb2c0b7f4052cb2c4ef933d295d3494c8791766687b891c04cc7b5495d09"},"/root/syslog-harness-repo/phase0-dual-keys.json":{"size":1628,"mtime_ns":1781121183851610673,"word_count":110,"hash":"0f115313f2c86df2f57e48bdf180b768d6452d0337bae4e3c3d2b52b9f5267a7"},"/root/syslog-harness-repo/queue-service/queue-service.py":{"size":3284,"mtime_ns":1781121183851610673,"word_count":325,"hash":"c9a9d2e5c9f1fb7d35a73f91c83a047ce92dc2d09c9ddab8fc2d6e82d92a093d"},"/root/syslog-harness-repo/router/http_patch.py":{"size":3562,"mtime_ns":1781121183851788825,"word_count":290,"hash":"53edfda5145db02bc35b9e9d8e06e5c731ce50c3a50cec3db65b3b886bed95a6"},"/root/syslog-harness-repo/router/requirements.txt":{"size":43,"mtime_ns":1781121183851788825,"word_count":3},"/root/syslog-harness-repo/router/route_v2.py":{"size":3954,"mtime_ns":1781121183851788825,"word_count":435,"hash":"e96ac2ae879e0a876d4190daff4cc566d13798b97c4820f3efa47e06627bbbcb"},"/root/syslog-harness-repo/router/route_v3.py":{"size":4703,"mtime_ns":1781121183851788825,"word_count":504,"hash":"30b202626b1f50da90b78272001e253cc7086b0bdc52e46553414c5313e3fb4e"},"/root/syslog-harness-repo/router/router.py":{"size":44307,"mtime_ns":1781316813360201927,"word_count":4198,"hash":"9c6817ae09f6bb5b2862f0282cacab7d4ce5da051e4ddc95e2e4099c7bf09716"},"/root/syslog-harness-repo/ssl/README.md":{"size":204,"mtime_ns":1781121183851788825,"word_count":28}}
|
||||
File diff suppressed because it is too large
Load Diff
+108
-45
@@ -1,6 +1,9 @@
|
||||
general_settings:
|
||||
master_key: os.environ/LITELLM_MASTER_KEY
|
||||
store_model_in_db: true
|
||||
store_model_in_db: false
|
||||
user_url_allowed_hosts:
|
||||
- 192.168.68.14
|
||||
- 192.168.68.14:5080
|
||||
guardrails:
|
||||
- guardrail_name: input-moderation
|
||||
litellm_params:
|
||||
@@ -28,62 +31,122 @@ guardrails:
|
||||
guardrail: litellm_content_filter
|
||||
mode: pre_call
|
||||
litellm_settings:
|
||||
user_url_allowed_hosts:
|
||||
- 192.168.68.14
|
||||
- 192.168.68.14:5080
|
||||
cache: true
|
||||
cache_params:
|
||||
host: harness-redis
|
||||
namespace: litellm
|
||||
port: 6379
|
||||
ttl: 600
|
||||
type: redis
|
||||
drop_params: true
|
||||
success_callback:
|
||||
- prometheus
|
||||
failure_callback:
|
||||
- prometheus
|
||||
model_cost:
|
||||
gemma-4-12b:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
qwen3.6-27B-code:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
qwen3.6-35B-A3B:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
gpu-dense:
|
||||
input_cost_per_token: 1.5e-07
|
||||
output_cost_per_token: 6.0e-07
|
||||
strix-moe:
|
||||
input_cost_per_token: 1.5e-07
|
||||
output_cost_per_token: 6.0e-07
|
||||
syslog-auto:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
num_retries: 0
|
||||
input_cost_per_token: 1.5e-07
|
||||
output_cost_per_token: 6.0e-07
|
||||
gpu-vision:
|
||||
input_cost_per_token: 1.5e-07
|
||||
output_cost_per_token: 6.0e-07
|
||||
num_retries: 2
|
||||
request_timeout: 600
|
||||
set_verbose: true
|
||||
sso_callback: /sso/callback
|
||||
model_list:
|
||||
- litellm_params:
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
model: openai/syslog-auto
|
||||
rpm: 600
|
||||
api_base: http://192.168.68.15:8080/v1
|
||||
api_key: not-needed
|
||||
model: openai/strix-moe
|
||||
rpm: 40
|
||||
timeout: 300
|
||||
model_info:
|
||||
max_input_tokens: 131072
|
||||
max_model_tokens: 131072
|
||||
max_tokens: 131072
|
||||
model_name: strix-moe
|
||||
- litellm_params:
|
||||
api_base: http://192.168.68.8:8080/v1
|
||||
api_key: not-needed
|
||||
model: openai/gpu-dense
|
||||
rpm: 500
|
||||
timeout: 300
|
||||
model_info:
|
||||
max_input_tokens: 131072
|
||||
max_model_tokens: 131072
|
||||
max_tokens: 131072
|
||||
model_name: gpu-dense
|
||||
- litellm_params:
|
||||
api_base: http://192.168.68.110:8080/v1
|
||||
api_key: not-needed
|
||||
model: openai/gpu-vision
|
||||
rpm: 500
|
||||
timeout: 300
|
||||
model_info:
|
||||
max_input_tokens: 131072
|
||||
max_model_tokens: 131072
|
||||
max_tokens: 131072
|
||||
model_name: gpu-vision
|
||||
- litellm_params:
|
||||
api_base: http://192.168.68.8:8080/v1
|
||||
api_key: not-needed
|
||||
model: openai/gpu-dense
|
||||
rpm: 500
|
||||
timeout: 300
|
||||
model_info:
|
||||
max_input_tokens: 131072
|
||||
max_model_tokens: 131072
|
||||
max_tokens: 131072
|
||||
weight: 0.70
|
||||
model_name: syslog-auto
|
||||
- litellm_params:
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
model: openai/qwen3.6-35B-A3B
|
||||
model_name: qwen3.6-35B-A3B
|
||||
api_base: http://192.168.68.15:8080/v1
|
||||
api_key: not-needed
|
||||
model: openai/strix-moe
|
||||
rpm: 60
|
||||
timeout: 300
|
||||
model_info:
|
||||
max_input_tokens: 131072
|
||||
weight: 0.20
|
||||
model_name: syslog-auto
|
||||
- litellm_params:
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
model: openai/qwen3.6-27B-code
|
||||
model_name: qwen3.6-27B-code
|
||||
- litellm_params:
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
model: openai/gemma-4-12b
|
||||
model_name: gemma-4-12b
|
||||
api_base: http://192.168.68.110:8080/v1
|
||||
api_key: not-needed
|
||||
model: openai/gpu-vision
|
||||
rpm: 200
|
||||
timeout: 300
|
||||
model_info:
|
||||
max_input_tokens: 131072
|
||||
weight: 0.10
|
||||
model_name: syslog-auto
|
||||
router_settings:
|
||||
allowed_fails: 100
|
||||
enable_loadbalancing_on_proxy: false
|
||||
enable_loadbalancing_on_proxy: true
|
||||
fallbacks:
|
||||
- syslog-auto:
|
||||
- qwen3.6-35B-A3B
|
||||
- qwen3.6-27B-code
|
||||
- gemma-4-12b
|
||||
- qwen3.6-35B-A3B:
|
||||
- qwen3.6-27B-code
|
||||
- gemma-4-12b
|
||||
- qwen3.6-27B-code:
|
||||
- qwen3.6-35B-A3B
|
||||
- gemma-4-12b
|
||||
- gemma-4-12b:
|
||||
- qwen3.6-27B-code
|
||||
- qwen3.6-35B-A3B
|
||||
routing_strategy: usage-based-routing
|
||||
- gpu-dense
|
||||
- strix-moe
|
||||
- gpu-vision
|
||||
- gpu-dense:
|
||||
- strix-moe
|
||||
- strix-moe:
|
||||
- gpu-dense
|
||||
- gpu-vision
|
||||
request_timeout: 300
|
||||
routing_strategy: simple-shuffle
|
||||
|
||||
agents:
|
||||
- agent_name: agent-zero-homelab
|
||||
agent_card_params:
|
||||
name: Agent Zero HomeLab
|
||||
url: http://192.168.68.14:5080/a2a/t-8zNgdOEXzYxjQvTl/p-homelab
|
||||
protocolVersion: '1.0'
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# LiteLLM Gateway Configuration — Layer 1 of 2-Layer Architecture
|
||||
# Deployed on CT 116 (192.168.68.116) alongside custom router on :9000
|
||||
# Last updated: 2026-06-16
|
||||
|
||||
general_settings:
|
||||
master_key: os.environ/LITELLM_MASTER_KEY
|
||||
# database_url: using DATABASE_URL env var instead
|
||||
store_model_in_db: true
|
||||
|
||||
model_list:
|
||||
# Content-based auto-routing (router picks GPU via 5-tier analysis)
|
||||
- model_name: syslog-auto
|
||||
litellm_params:
|
||||
model: openai/syslog-auto
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
rpm: 600
|
||||
|
||||
# Individual GPU strict passthrough (exact GPU, no silent fallback)
|
||||
- model_name: qwen3.6-35B-A3B
|
||||
litellm_params:
|
||||
model: openai/qwen3.6-35B-A3B
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
|
||||
- model_name: qwen3.6-27B-code
|
||||
litellm_params:
|
||||
model: openai/qwen3.6-27B-code
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
|
||||
- model_name: gemma-4-12b
|
||||
litellm_params:
|
||||
model: openai/gemma-4-12b
|
||||
api_base: http://router:9000/v1
|
||||
api_key: os.environ/ROUTER_API_KEY
|
||||
|
||||
# Guardrails: Pre-call and post-call content moderation
|
||||
guardrails:
|
||||
- guardrail_name: "input-moderation"
|
||||
litellm_params:
|
||||
guardrail: openai_moderation
|
||||
mode: "pre_call"
|
||||
|
||||
- guardrail_name: "output-moderation"
|
||||
litellm_params:
|
||||
guardrail: openai_moderation
|
||||
mode: "post_call"
|
||||
|
||||
- guardrail_name: "harmful-content-filter"
|
||||
litellm_params:
|
||||
guardrail: litellm_content_filter
|
||||
mode: "pre_call"
|
||||
categories:
|
||||
- category: "harmful_self_harm"
|
||||
enabled: true
|
||||
action: "BLOCK"
|
||||
severity_threshold: "medium"
|
||||
- category: "harmful_violence"
|
||||
enabled: true
|
||||
action: "BLOCK"
|
||||
severity_threshold: "medium"
|
||||
- category: "harmful_illegal_weapons"
|
||||
enabled: true
|
||||
action: "BLOCK"
|
||||
severity_threshold: "medium"
|
||||
|
||||
litellm_settings:
|
||||
num_retries: 0 # Disabled — our router handles retry logic
|
||||
request_timeout: 600 # Match 10-min llama-server timeout
|
||||
set_verbose: true
|
||||
failure_callback: ["prometheus"] # Export metrics to Prometheus
|
||||
|
||||
router_settings:
|
||||
routing_strategy: "usage-based-routing" # For external models only
|
||||
enable_loadbalancing_on_proxy: false # Disable LiteLLM internal LB
|
||||
allowed_fails: 100 # Router returns 503 on saturated GPUs
|
||||
# Fallback chains: LiteLLM retries down the chain when router returns saturated.
|
||||
# This gives accurate per-model metrics because router no longer silently reroutes.
|
||||
# The router's circuit breaker prevents cascading failures to dead GPUs.
|
||||
fallbacks:
|
||||
- syslog-auto: ["qwen3.6-35B-A3B", "qwen3.6-27B-code", "gemma-4-12b"]
|
||||
- qwen3.6-35B-A3B: ["qwen3.6-27B-code", "gemma-4-12b"]
|
||||
- qwen3.6-27B-code: ["qwen3.6-35B-A3B", "gemma-4-12b"]
|
||||
- gemma-4-12b: ["qwen3.6-27B-code", "qwen3.6-35B-A3B"]
|
||||
|
||||
# Cost tracking for spend analytics (local GPUs at $0, symbolic rates optional)
|
||||
litellm_settings:
|
||||
model_cost:
|
||||
syslog-auto:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
qwen3.6-35B-A3B:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
qwen3.6-27B-code:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
gemma-4-12b:
|
||||
input_cost_per_token: 0.0
|
||||
output_cost_per_token: 0.0
|
||||
# For internal cost allocation, set symbolic rates:
|
||||
# e.g., MoE = $2/M tokens, Dense = $1/M tokens, VLM = $0.50/M tokens
|
||||
|
||||
# SSO/OIDC Configuration
|
||||
litellm_settings:
|
||||
sso_callback: "/sso/callback"
|
||||
+68
-69
@@ -8,28 +8,20 @@ http {
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Docker DNS resolver — forces request-time resolution for variable-based proxy_pass.
|
||||
# Without this, nginx resolves upstream hostnames at config load time,
|
||||
# which fails when Docker DNS (127.0.0.11) isn't ready yet on container start.
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
|
||||
# Dynamic upstream resolution via nginx variables.
|
||||
# Using $var in proxy_pass forces request-time resolution through the resolver.
|
||||
# Without this, 'host not found in upstream' crashes nginx when Docker DNS is slow.
|
||||
map $host $router_api_url {
|
||||
default http://harness-router:9000;
|
||||
}
|
||||
map $host $dashboard_ui_url {
|
||||
default http://harness-dashboard:3000;
|
||||
}
|
||||
map $host $litellm_backend_url {
|
||||
default http://harness-litellm:4000;
|
||||
}
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :80 — harness entrypoint
|
||||
# dashboard (/), router API (/v1/, /admin/, /stream, /api/, /metrics),
|
||||
# router fallback, health
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Server :80 — Lean single-layer entrypoint
|
||||
# All API paths route directly to LiteLLM.
|
||||
# harness-router fully deprecated.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
@@ -43,67 +35,72 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 2-Layer: /v1/ → router (existing keys work unchanged)
|
||||
# ── LiteLLM API (replaces router /v1/) ──
|
||||
location /v1/ {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_pass $litellm_backend_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
error_page 502 503 = @router_fallback;
|
||||
}
|
||||
|
||||
location @router_fallback {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# ── LiteLLM admin (replaces router /admin/) ──
|
||||
location /admin/ {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_pass $litellm_backend_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# ── LiteLLM stream ──
|
||||
location /stream {
|
||||
proxy_pass $router_api_url/stream;
|
||||
proxy_pass $litellm_backend_url/stream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ── API passthrough ──
|
||||
location /api/ {
|
||||
proxy_pass $router_api_url/;
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
|
||||
|
||||
# ── Dashboard ──
|
||||
location /dashboard/ {
|
||||
proxy_pass $dashboard_ui_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM redirect target /litellm (no trailing slash) -> add slash back
|
||||
# ── GPU Fleet Dashboard ──
|
||||
location /gpu/ {
|
||||
proxy_pass http://192.168.68.24:9100/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# ── LiteLLM redirect ──
|
||||
location = /litellm {
|
||||
return 301 /litellm/;
|
||||
}
|
||||
|
||||
# LiteLLM static assets (Next.js chunks, CSS, fonts)
|
||||
# ── Health: redirect /health (auth-required) → /health/liveliness (no-auth) ──
|
||||
location = /litellm/health {
|
||||
return 301 /litellm/health/liveliness;
|
||||
}
|
||||
|
||||
# ── LiteLLM static assets ──
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url;
|
||||
proxy_http_version 1.1;
|
||||
@@ -113,19 +110,22 @@ http {
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# LiteLLM admin UI and API proxy — strip /litellm prefix so /litellm/ui/ → /ui/
|
||||
# ── LiteLLM admin UI and API (strips /litellm prefix) ──
|
||||
location /litellm/ {
|
||||
rewrite ^/litellm(/.*)$ $1 break;
|
||||
proxy_pass $litellm_backend_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# Auth proxy to Authentik — accepts HTTP from LiteLLM, proxies HTTPS to .11 with SSL verify off
|
||||
# ── Auth proxy to Authentik ──
|
||||
location /application/o/ {
|
||||
proxy_pass https://192.168.68.11;
|
||||
proxy_ssl_verify off;
|
||||
@@ -135,40 +135,39 @@ http {
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# All other requests → 404
|
||||
# ── Prometheus metrics (LiteLLM exposes at /metrics) ──
|
||||
location /metrics {
|
||||
proxy_pass $litellm_backend_url/metrics;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# ---- Legacy /health/unified alias (harness-router decommissioned) ----
|
||||
# Retained intentionally: 5+ live GPU monitor contracts poll
|
||||
# this path every 15s and follow the 301 to /gpu/gpu-data.
|
||||
location /health/unified {
|
||||
return 301 /gpu/gpu-data;
|
||||
}
|
||||
|
||||
# ── Health: no-auth LiteLLM liveliness ──
|
||||
location /health {
|
||||
proxy_pass $litellm_backend_url/health/liveliness;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
|
||||
# ── UI / Docs convenience redirects (additive 2026-09-11) ──
|
||||
# Canonical app path is /litellm/. These 301s make bare
|
||||
# /ui/ and /docs resolve. No new auth surface; no OIDC impact.
|
||||
location = /ui { return 301 /litellm/ui/; }
|
||||
location /ui/ { return 301 /litellm$request_uri; }
|
||||
location = /docs { return 301 /litellm/docs; }
|
||||
location = /docs/ { return 301 /litellm/docs; }
|
||||
|
||||
# ── 404 for everything else ──
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /metrics/ {
|
||||
proxy_pass $router_api_url/metrics/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /metrics/circuit-breaker {
|
||||
proxy_pass $router_api_url/metrics/circuit-breaker;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /router/ {
|
||||
proxy_pass $router_api_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health/unified {
|
||||
proxy_pass $router_api_url/health/unified;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass $router_api_url/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" rt=$request_time';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream router_api { server router:9000; }
|
||||
upstream dashboard_ui { server dashboard:3000; }
|
||||
upstream litellm_backend { server litellm:4000; }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Disable buffering for SSE streams
|
||||
proxy_buffering off;
|
||||
|
||||
# API through router
|
||||
location /v1/ {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# SSE streaming endpoint
|
||||
location /stream {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
|
||||
# Dashboard API proxy for SSE
|
||||
location /api/ {
|
||||
proxy_pass http://dashboard_ui;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# LiteLLM debug
|
||||
location /litellm/ {
|
||||
rewrite ^/litellm/(.*) /$1 break;
|
||||
proxy_pass http://litellm_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# Professional Dashboard (Phase 1-3) - Static HTML served via Nginx
|
||||
location /dashboard/ {
|
||||
alias /opt/inference-harness/dashboard/;
|
||||
index dashboard.html;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
}
|
||||
|
||||
# Legacy Dashboard (root) - Proxy to Flask app
|
||||
location / {
|
||||
proxy_pass http://dashboard_ui;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Performance analytics
|
||||
location /metrics/ {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Circuit Breaker metrics (Phase 1)
|
||||
location /metrics/circuit-breaker {
|
||||
proxy_pass http://router_api/metrics/circuit-breaker;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health/unified {
|
||||
proxy_pass http://router_api/health/unified;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://router_api/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream router_api { server router:9000; }
|
||||
upstream dashboard_ui { server dashboard:3000; }
|
||||
upstream litellm_backend { server litellm:4000; }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Authentik OIDC subrequest
|
||||
location /authentik/auth {
|
||||
internal;
|
||||
proxy_pass https://auth.sysloggh.net/outpost.goauthentik.io/auth/nginx;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 2-Layer: LiteLLM (Layer 1) -> Router (Layer 2) -> GPUs
|
||||
# /v1/ routes to router (all existing keys work)
|
||||
# Agents using new LiteLLM keys: change OPENAI_API_BASE to /litellm/v1
|
||||
location /v1/ {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
error_page 502 503 = @router_fallback;
|
||||
}
|
||||
|
||||
location @router_fallback {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /stream {
|
||||
proxy_pass http://router_api/stream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://router_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM gateway access (for agents with new virtual keys)
|
||||
location /litellm/v1/ {
|
||||
proxy_pass http://litellm_backend/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# LiteLLM UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass http://litellm_backend/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM Admin UI (WebSocket support for live updates)
|
||||
location /litellm/ {
|
||||
proxy_pass http://litellm_backend/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
# Rewrite all redirects to include /litellm/ prefix
|
||||
proxy_redirect http://172.18.0.7:4000/ /litellm/;
|
||||
proxy_redirect http://127.0.0.1:4000/ /litellm/;
|
||||
}
|
||||
|
||||
location /dashboard/ {
|
||||
proxy_pass http://dashboard_ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /opt/inference-harness/dashboard;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /metrics/ {
|
||||
proxy_pass http://router_api/metrics/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /metrics/circuit-breaker {
|
||||
proxy_pass http://router_api/metrics/circuit-breaker;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /router/ {
|
||||
proxy_pass http://router_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /ui/ {
|
||||
return 302 http://192.168.68.116:4000/ui/;
|
||||
}
|
||||
|
||||
location /health/unified {
|
||||
proxy_pass http://router_api/health/unified;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /ui/ {
|
||||
return 302 http://192.168.68.116:4000/ui/;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://router_api/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Docker DNS resolver — forces request-time resolution for variable-based proxy_pass.
|
||||
# Without this, nginx resolves upstream hostnames at config load time,
|
||||
# which fails when Docker DNS (127.0.0.11) isn't ready yet on container start.
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
|
||||
# Dynamic upstream resolution via nginx variables.
|
||||
# Using $var in proxy_pass forces request-time resolution through the resolver.
|
||||
# Without this, 'host not found in upstream' crashes nginx when Docker DNS is slow.
|
||||
map $host $router_api_url {
|
||||
default http://harness-router:9000;
|
||||
}
|
||||
map $host $dashboard_ui_url {
|
||||
default http://harness-dashboard:3000;
|
||||
}
|
||||
map $host $litellm_backend_url {
|
||||
default http://harness-litellm:4000;
|
||||
}
|
||||
|
||||
# Detect Cloudflare Tunnel requests (cloudflared always sets CF-Connecting-IP).
|
||||
# Direct LAN browser access to :4000 has no such header -> redirect to canonical https,
|
||||
# preventing the cross-origin localStorage footgun that traps the UI at the login page.
|
||||
map $http_cf_connecting_ip $is_cloudflared {
|
||||
default 1; # any non-empty value = request came through Cloudflare
|
||||
"" 0; # empty = direct access
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :80 — existing harness entrypoint
|
||||
# dashboard (/), router API (/v1/, /admin/, /stream, /api/, /metrics),
|
||||
# router fallback, LiteLLM UI via /litellm/ prefix, health
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Authentik OIDC subrequest
|
||||
location /authentik/auth {
|
||||
internal;
|
||||
proxy_pass https://auth.sysloggh.net/outpost.goauthentik.io/auth/nginx;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 2-Layer: /v1/ → router (existing keys work unchanged)
|
||||
location /v1/ {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
error_page 502 503 = @router_fallback;
|
||||
}
|
||||
|
||||
location @router_fallback {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /stream {
|
||||
proxy_pass $router_api_url/stream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass $router_api_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM gateway access (agents with new virtual keys)
|
||||
location /litellm/v1/ {
|
||||
proxy_pass $litellm_backend_url/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# LiteLLM UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM Admin UI at /ui/ (public access via Traefik → port 80)
|
||||
# LiteLLM Admin UI at /ui/ (must be before catch-all /)
|
||||
location /ui/ {
|
||||
proxy_pass http://litellm_backend/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
error_page 301 302 = @ui_redirect;
|
||||
}
|
||||
|
||||
location @ui_redirect {
|
||||
proxy_pass http://litellm_backend/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /ui/ {
|
||||
proxy_pass $litellm_backend_url/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# SSO callback
|
||||
location /sso/ {
|
||||
proxy_pass $litellm_backend_url/sso/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# LiteLLM UI static assets on port 80
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# LiteLLM Admin UI via /litellm/ prefix (LAN/internal access on :80)
|
||||
location /litellm/ {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://$host/ /litellm/;
|
||||
proxy_redirect https://$host/ /litellm/;
|
||||
}
|
||||
|
||||
location /dashboard/ {
|
||||
proxy_pass $dashboard_ui_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Dedicated /openapi.json block — must come before catch-all /
|
||||
# LiteLLM serves valid openapi: 3.1.0 JSON at this path internally,
|
||||
# but the catch-all location / strips the URI path. This block
|
||||
# preserves the full path so the spec JSON is returned instead of
|
||||
# the Swagger UI SPA HTML.
|
||||
location /openapi.json {
|
||||
proxy_pass $litellm_backend_url/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /metrics/ {
|
||||
proxy_pass $router_api_url/metrics/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /metrics/circuit-breaker {
|
||||
proxy_pass $router_api_url/metrics/circuit-breaker;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /router/ {
|
||||
proxy_pass $router_api_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health/unified {
|
||||
proxy_pass $router_api_url/health/unified;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass $router_api_url/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :4000 — external LiteLLM entrypoint (cloudflared target)
|
||||
# Fixes the /ui redirect: forces correct Host + scheme so LiteLLM
|
||||
# builds external URLs instead of leaking 192.168.68.116:4000.
|
||||
# LiteLLM itself is now bound to 127.0.0.1 only (see compose).
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 4000;
|
||||
|
||||
# Canonical external identity — overrides whatever Host cloudflared sends
|
||||
proxy_set_header Host litellm.sysloggh.net;
|
||||
proxy_set_header X-Forwarded-Host litellm.sysloggh.net;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# /ui → /ui/ — absolute redirect (cloudflared rewrites Host to origin IP,
|
||||
# so a relative return would be absolutized to http://192.168.68.116:4000/).
|
||||
location = /ui { return 301 https://litellm.sysloggh.net/ui/; }
|
||||
|
||||
# LiteLLM Admin UI + WebSocket
|
||||
location /ui/ {
|
||||
proxy_pass $litellm_backend_url/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect $litellm_backend_url/ https://litellm.sysloggh.net/;
|
||||
proxy_redirect http://litellm.sysloggh.net:4000/ https://litellm.sysloggh.net/;
|
||||
}
|
||||
|
||||
# UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# SSO callback
|
||||
location /sso/ {
|
||||
proxy_pass $litellm_backend_url/sso/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# API
|
||||
location /v1/ {
|
||||
proxy_pass $litellm_backend_url/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Key management + admin API
|
||||
location /key/ {
|
||||
proxy_pass $litellm_backend_url/key/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /user/ {
|
||||
proxy_pass $litellm_backend_url/user/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /model/ {
|
||||
proxy_pass $litellm_backend_url/model/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /team/ {
|
||||
proxy_pass $litellm_backend_url/team/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# Health
|
||||
location /health {
|
||||
proxy_pass $litellm_backend_url/health;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
# Root + everything else → LiteLLM (UI index, /configs, /spend, etc.)
|
||||
location / {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://192.168.68.116:4000/ https://litellm.sysloggh.net/;
|
||||
proxy_redirect https://192.168.68.116:4000/ https://litellm.sysloggh.net/;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream router_api { server router:9000; }
|
||||
upstream dashboard_ui { server dashboard:3000; }
|
||||
upstream litellm_backend { server litellm:4000; }
|
||||
|
||||
# Detect Cloudflare Tunnel requests (cloudflared always sets CF-Connecting-IP).
|
||||
# Direct LAN browser access to :4000 has no such header -> redirect to canonical https,
|
||||
# preventing the cross-origin localStorage footgun that traps the UI at the login page.
|
||||
map $http_cf_connecting_ip $is_cloudflared {
|
||||
default 1; # any non-empty value = request came through Cloudflare
|
||||
"" 0; # empty = direct access
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :80 — existing harness entrypoint
|
||||
# dashboard (/), router API (/v1/, /admin/, /stream, /api/, /metrics),
|
||||
# router fallback, LiteLLM UI via /litellm/ prefix, health
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Authentik OIDC subrequest
|
||||
location /authentik/auth {
|
||||
internal;
|
||||
proxy_pass https://auth.sysloggh.net/outpost.goauthentik.io/auth/nginx;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 2-Layer: /v1/ → router (existing keys work unchanged)
|
||||
location /v1/ {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
error_page 502 503 = @router_fallback;
|
||||
}
|
||||
|
||||
location @router_fallback {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://router_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /stream {
|
||||
proxy_pass http://router_api/stream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://router_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM gateway access (agents with new virtual keys)
|
||||
location /litellm/v1/ {
|
||||
proxy_pass http://litellm_backend/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# LiteLLM UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass http://litellm_backend/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM Admin UI via /litellm/ prefix (LAN/internal access on :80)
|
||||
location /litellm/ {
|
||||
proxy_pass http://litellm_backend/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://$host/ /litellm/;
|
||||
proxy_redirect https://$host/ /litellm/;
|
||||
}
|
||||
|
||||
location /dashboard/ {
|
||||
proxy_pass http://dashboard_ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /opt/inference-harness/dashboard;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /metrics/ {
|
||||
proxy_pass http://router_api/metrics/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /metrics/circuit-breaker {
|
||||
proxy_pass http://router_api/metrics/circuit-breaker;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /router/ {
|
||||
proxy_pass http://router_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health/unified {
|
||||
proxy_pass http://router_api/health/unified;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://router_api/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :4000 — external LiteLLM entrypoint (cloudflared target)
|
||||
# Fixes the /ui redirect: forces correct Host + scheme so LiteLLM
|
||||
# builds external URLs instead of leaking 192.168.68.116:4000.
|
||||
# LiteLLM itself is now bound to 127.0.0.1 only (see compose).
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 4000;
|
||||
|
||||
# Canonical external identity — overrides whatever Host cloudflared sends
|
||||
proxy_set_header Host litellm.sysloggh.net;
|
||||
proxy_set_header X-Forwarded-Host litellm.sysloggh.net;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# /ui → /ui/ — absolute redirect (cloudflared rewrites Host to origin IP,
|
||||
# so a relative return would be absolutized to http://192.168.68.116:4000/).
|
||||
location = /ui { return 301 https://litellm.sysloggh.net/ui/; }
|
||||
|
||||
# LiteLLM Admin UI + WebSocket
|
||||
location /ui/ {
|
||||
proxy_pass http://litellm_backend/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://litellm_backend/ https://litellm.sysloggh.net/;
|
||||
proxy_redirect http://litellm.sysloggh.net:4000/ https://litellm.sysloggh.net/;
|
||||
}
|
||||
|
||||
# UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass http://litellm_backend/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# SSO callback
|
||||
location /sso/ {
|
||||
proxy_pass http://litellm_backend/sso/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# API
|
||||
location /v1/ {
|
||||
proxy_pass http://litellm_backend/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Key management + admin API
|
||||
location /key/ {
|
||||
proxy_pass http://litellm_backend/key/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /user/ {
|
||||
proxy_pass http://litellm_backend/user/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /model/ {
|
||||
proxy_pass http://litellm_backend/model/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /team/ {
|
||||
proxy_pass http://litellm_backend/team/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# Health
|
||||
location /health {
|
||||
proxy_pass http://litellm_backend/health;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
# Root + everything else → LiteLLM (UI index, /configs, /spend, etc.)
|
||||
location / {
|
||||
proxy_pass http://litellm_backend/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://192.168.68.116:4000/ https://litellm.sysloggh.net/;
|
||||
proxy_redirect https://192.168.68.116:4000/ https://litellm.sysloggh.net/;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Docker DNS resolver — forces request-time resolution for variable-based proxy_pass.
|
||||
# Without this, nginx resolves upstream hostnames at config load time,
|
||||
# which fails when Docker DNS (127.0.0.11) isn't ready yet on container start.
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
|
||||
# Dynamic upstream resolution via nginx variables.
|
||||
# Using $var in proxy_pass forces request-time resolution through the resolver.
|
||||
# Without this, 'host not found in upstream' crashes nginx when Docker DNS is slow.
|
||||
map $host $router_api_url {
|
||||
default http://harness-router:9000;
|
||||
}
|
||||
map $host $dashboard_ui_url {
|
||||
default http://harness-dashboard:3000;
|
||||
}
|
||||
map $host $litellm_backend_url {
|
||||
default http://harness-litellm:4000;
|
||||
}
|
||||
|
||||
# Detect Cloudflare Tunnel requests (cloudflared always sets CF-Connecting-IP).
|
||||
# Direct LAN browser access to :4000 has no such header -> redirect to canonical https,
|
||||
# preventing the cross-origin localStorage footgun that traps the UI at the login page.
|
||||
map $http_cf_connecting_ip $is_cloudflared {
|
||||
default 1; # any non-empty value = request came through Cloudflare
|
||||
"" 0; # empty = direct access
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :80 — existing harness entrypoint
|
||||
# dashboard (/), router API (/v1/, /admin/, /stream, /api/, /metrics),
|
||||
# router fallback, LiteLLM UI via /litellm/ prefix, health
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Authentik OIDC subrequest
|
||||
location /authentik/auth {
|
||||
internal;
|
||||
proxy_pass https://auth.sysloggh.net/outpost.goauthentik.io/auth/nginx;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 2-Layer: /v1/ → router (existing keys work unchanged)
|
||||
location /v1/ {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
error_page 502 503 = @router_fallback;
|
||||
}
|
||||
|
||||
location @router_fallback {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /stream {
|
||||
proxy_pass $router_api_url/stream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass $router_api_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM gateway access (agents with new virtual keys)
|
||||
location /litellm/v1/ {
|
||||
proxy_pass $litellm_backend_url/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# LiteLLM UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM Admin UI at /ui/ (public access via Traefik → port 80)
|
||||
# LiteLLM Admin UI at /ui/ (must be before catch-all /)
|
||||
location /ui/ {
|
||||
proxy_pass $litellm_backend_url/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
error_page 301 302 = @ui_redirect;
|
||||
}
|
||||
|
||||
location @ui_redirect {
|
||||
proxy_pass $litellm_backend_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /ui/ {
|
||||
proxy_pass $litellm_backend_url/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# SSO callback
|
||||
location /sso/ {
|
||||
proxy_pass $litellm_backend_url/sso/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# LiteLLM UI static assets on port 80
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# LiteLLM Admin UI via /litellm/ prefix (LAN/internal access on :80)
|
||||
location /litellm/ {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://$host/ /litellm/;
|
||||
proxy_redirect https://$host/ /litellm/;
|
||||
}
|
||||
|
||||
location /dashboard/ {
|
||||
proxy_pass $dashboard_ui_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Dedicated /openapi.json block — must come before catch-all /
|
||||
# LiteLLM serves valid openapi: 3.1.0 JSON at this path internally,
|
||||
# but the catch-all location / strips the URI path. This block
|
||||
# preserves the full path so the spec JSON is returned instead of
|
||||
# the Swagger UI SPA HTML.
|
||||
location /openapi.json {
|
||||
proxy_pass $litellm_backend_url/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /metrics/ {
|
||||
proxy_pass $router_api_url/metrics/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /metrics/circuit-breaker {
|
||||
proxy_pass $router_api_url/metrics/circuit-breaker;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /router/ {
|
||||
proxy_pass $router_api_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health/unified {
|
||||
proxy_pass $router_api_url/health/unified;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass $router_api_url/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :4000 — external LiteLLM entrypoint (cloudflared target)
|
||||
# Fixes the /ui redirect: forces correct Host + scheme so LiteLLM
|
||||
# builds external URLs instead of leaking 192.168.68.116:4000.
|
||||
# LiteLLM itself is now bound to 127.0.0.1 only (see compose).
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 4000;
|
||||
|
||||
# Canonical external identity — overrides whatever Host cloudflared sends
|
||||
proxy_set_header Host litellm.sysloggh.net;
|
||||
proxy_set_header X-Forwarded-Host litellm.sysloggh.net;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# /ui → /ui/ — absolute redirect (cloudflared rewrites Host to origin IP,
|
||||
# so a relative return would be absolutized to http://192.168.68.116:4000/).
|
||||
location = /ui { return 301 https://litellm.sysloggh.net/ui/; }
|
||||
|
||||
# LiteLLM Admin UI + WebSocket
|
||||
location /ui/ {
|
||||
proxy_pass $litellm_backend_url/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect $litellm_backend_url/ https://litellm.sysloggh.net/;
|
||||
proxy_redirect http://litellm.sysloggh.net:4000/ https://litellm.sysloggh.net/;
|
||||
}
|
||||
|
||||
# UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# SSO callback
|
||||
location /sso/ {
|
||||
proxy_pass $litellm_backend_url/sso/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# API
|
||||
location /v1/ {
|
||||
proxy_pass $litellm_backend_url/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Key management + admin API
|
||||
location /key/ {
|
||||
proxy_pass $litellm_backend_url/key/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /user/ {
|
||||
proxy_pass $litellm_backend_url/user/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /model/ {
|
||||
proxy_pass $litellm_backend_url/model/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /team/ {
|
||||
proxy_pass $litellm_backend_url/team/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# Health
|
||||
location /health {
|
||||
proxy_pass $litellm_backend_url/health;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
# Root + everything else → LiteLLM (UI index, /configs, /spend, etc.)
|
||||
location / {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://192.168.68.116:4000/ https://litellm.sysloggh.net/;
|
||||
proxy_redirect https://192.168.68.116:4000/ https://litellm.sysloggh.net/;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Docker DNS resolver — forces request-time resolution for variable-based proxy_pass.
|
||||
# Without this, nginx resolves upstream hostnames at config load time,
|
||||
# which fails when Docker DNS (127.0.0.11) isn't ready yet on container start.
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
|
||||
# Dynamic upstream resolution via nginx variables.
|
||||
# Using $var in proxy_pass forces request-time resolution through the resolver.
|
||||
# Without this, 'host not found in upstream' crashes nginx when Docker DNS is slow.
|
||||
map $host $router_api_url {
|
||||
default http://harness-router:9000;
|
||||
}
|
||||
map $host $dashboard_ui_url {
|
||||
default http://harness-dashboard:3000;
|
||||
}
|
||||
map $host $litellm_backend_url {
|
||||
default http://harness-litellm:4000;
|
||||
}
|
||||
|
||||
# Detect Cloudflare Tunnel requests (cloudflared always sets CF-Connecting-IP).
|
||||
# Direct LAN browser access to :4000 has no such header -> redirect to canonical https,
|
||||
# preventing the cross-origin localStorage footgun that traps the UI at the login page.
|
||||
map $http_cf_connecting_ip $is_cloudflared {
|
||||
default 1; # any non-empty value = request came through Cloudflare
|
||||
"" 0; # empty = direct access
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :80 — existing harness entrypoint
|
||||
# dashboard (/), router API (/v1/, /admin/, /stream, /api/, /metrics),
|
||||
# router fallback, LiteLLM UI via /litellm/ prefix, health
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Authentik OIDC subrequest
|
||||
location /authentik/auth {
|
||||
internal;
|
||||
proxy_pass https://auth.sysloggh.net/outpost.goauthentik.io/auth/nginx;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 2-Layer: /v1/ → router (existing keys work unchanged)
|
||||
location /v1/ {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
error_page 502 503 = @router_fallback;
|
||||
}
|
||||
|
||||
location @router_fallback {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass $router_api_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /stream {
|
||||
proxy_pass $router_api_url/stream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass $router_api_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM gateway access (agents with new virtual keys)
|
||||
location /litellm/v1/ {
|
||||
proxy_pass $litellm_backend_url/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# LiteLLM UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiteLLM Admin UI at /ui/ (public access via Traefik → port 80)
|
||||
# LiteLLM Admin UI at /ui/ (must be before catch-all /)
|
||||
location /ui/ {
|
||||
proxy_pass $litellm_backend_url/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
error_page 301 302 = @ui_redirect;
|
||||
}
|
||||
|
||||
location @ui_redirect {
|
||||
proxy_pass $litellm_backend_url;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /ui/ {
|
||||
proxy_pass $litellm_backend_url/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# SSO callback
|
||||
location /sso/ {
|
||||
proxy_pass $litellm_backend_url/sso/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
|
||||
# LiteLLM Admin UI via /litellm/ prefix (LAN/internal access on :80)
|
||||
location /litellm/ {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://$host/ /litellm/;
|
||||
proxy_redirect https://$host/ /litellm/;
|
||||
}
|
||||
|
||||
location /dashboard/ {
|
||||
proxy_pass $dashboard_ui_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Dedicated /openapi.json block — must come before catch-all /
|
||||
# LiteLLM serves valid openapi: 3.1.0 JSON at this path internally,
|
||||
# but the catch-all location / strips the URI path. This block
|
||||
# preserves the full path so the spec JSON is returned instead of
|
||||
# the Swagger UI SPA HTML.
|
||||
location /openapi.json {
|
||||
proxy_pass $litellm_backend_url/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /metrics/ {
|
||||
proxy_pass $router_api_url/metrics/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /metrics/circuit-breaker {
|
||||
proxy_pass $router_api_url/metrics/circuit-breaker;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /router/ {
|
||||
proxy_pass $router_api_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health/unified {
|
||||
proxy_pass $router_api_url/health/unified;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass $router_api_url/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Server :4000 — external LiteLLM entrypoint (cloudflared target)
|
||||
# Fixes the /ui redirect: forces correct Host + scheme so LiteLLM
|
||||
# builds external URLs instead of leaking 192.168.68.116:4000.
|
||||
# LiteLLM itself is now bound to 127.0.0.1 only (see compose).
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
server {
|
||||
listen 4000;
|
||||
|
||||
# Canonical external identity — overrides whatever Host cloudflared sends
|
||||
proxy_set_header Host litellm.sysloggh.net;
|
||||
proxy_set_header X-Forwarded-Host litellm.sysloggh.net;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# /ui → /ui/ — absolute redirect (cloudflared rewrites Host to origin IP,
|
||||
# so a relative return would be absolutized to http://192.168.68.116:4000/).
|
||||
location = /ui { return 301 https://litellm.sysloggh.net/ui/; }
|
||||
|
||||
# LiteLLM Admin UI + WebSocket
|
||||
location /ui/ {
|
||||
proxy_pass $litellm_backend_url/ui/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect $litellm_backend_url/ https://litellm.sysloggh.net/;
|
||||
proxy_redirect http://litellm.sysloggh.net:4000/ https://litellm.sysloggh.net/;
|
||||
}
|
||||
|
||||
# UI static assets
|
||||
location /litellm-asset-prefix/ {
|
||||
proxy_pass $litellm_backend_url/litellm-asset-prefix/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# SSO callback
|
||||
location /sso/ {
|
||||
proxy_pass $litellm_backend_url/sso/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# API
|
||||
location /v1/ {
|
||||
proxy_pass $litellm_backend_url/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Key management + admin API
|
||||
location /key/ {
|
||||
proxy_pass $litellm_backend_url/key/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /user/ {
|
||||
proxy_pass $litellm_backend_url/user/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /model/ {
|
||||
proxy_pass $litellm_backend_url/model/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
location /team/ {
|
||||
proxy_pass $litellm_backend_url/team/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# Health
|
||||
location /health {
|
||||
proxy_pass $litellm_backend_url/health;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
# Root + everything else → LiteLLM (UI index, /configs, /spend, etc.)
|
||||
location / {
|
||||
proxy_pass $litellm_backend_url/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_redirect http://192.168.68.116:4000/ https://litellm.sysloggh.net/;
|
||||
proxy_redirect https://192.168.68.116:4000/ https://litellm.sysloggh.net/;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Syslog Inference Queue Service — Circuit breaker + request queuing.
|
||||
|
||||
Ports: 8091
|
||||
Endpoints:
|
||||
/health — liveness probe (Nginx upstream check)
|
||||
/enqueue — POST inference request into queue (fallback from Nginx)
|
||||
/status — GET queue depth + circuit breaker state
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Configuration
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "192.168.68.7")
|
||||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||
QUEUE_KEY = "inference:requests"
|
||||
CIRCUIT_OPEN_THRESHOLD = 50
|
||||
CIRCUIT_WARN_THRESHOLD = 30
|
||||
|
||||
# GPU endpoints for draining
|
||||
GPUS = {
|
||||
"amdpve": "192.168.68.15:8080",
|
||||
"llmgpu": "192.168.68.8:8080",
|
||||
"ocu_llm": "192.168.68.110:8080",
|
||||
}
|
||||
|
||||
|
||||
def get_redis():
|
||||
try:
|
||||
import redis
|
||||
return redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_queue_depth(r):
|
||||
try:
|
||||
return r.llen(QUEUE_KEY)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def check_gpu_health(endpoint):
|
||||
try:
|
||||
req = urllib.request.Request(f"http://{endpoint}/v1/models")
|
||||
req.add_header("User-Agent", "queue-service/1.0")
|
||||
resp = urllib.request.urlopen(req, timeout=3)
|
||||
return resp.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Nginx upstream health probe. Returns 200 if service is alive."""
|
||||
return jsonify({"status": "ok", "service": "queue-service"}), 200
|
||||
|
||||
|
||||
@app.route("/enqueue", methods=["POST"])
|
||||
def enqueue():
|
||||
"""Fallback endpoint — Nginx calls this when all GPU upstreams are down."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return jsonify({"error": "Redis unavailable"}), 503
|
||||
|
||||
depth = get_queue_depth(r)
|
||||
if depth >= CIRCUIT_OPEN_THRESHOLD:
|
||||
return jsonify({
|
||||
"error": "Circuit breaker OPEN",
|
||||
"queue_depth": depth,
|
||||
"threshold": CIRCUIT_OPEN_THRESHOLD
|
||||
}), 503
|
||||
|
||||
# Store the request in queue
|
||||
payload = request.get_data(as_text=True)
|
||||
headers = {k: v for k, v in request.headers if k.startswith("X-")}
|
||||
r.rpush(QUEUE_KEY, json.dumps({
|
||||
"payload": payload,
|
||||
"headers": headers,
|
||||
"queued_at": time.time()
|
||||
}))
|
||||
|
||||
new_depth = get_queue_depth(r)
|
||||
return jsonify({
|
||||
"status": "queued",
|
||||
"position": new_depth,
|
||||
"circuit": "warn" if new_depth >= CIRCUIT_WARN_THRESHOLD else "closed"
|
||||
}), 202
|
||||
|
||||
|
||||
@app.route("/status")
|
||||
def status():
|
||||
"""GET queue depth + circuit breaker state + GPU health."""
|
||||
r = get_redis()
|
||||
depth = get_queue_depth(r) if r else -1
|
||||
circuit = "open" if depth >= CIRCUIT_OPEN_THRESHOLD else ("warn" if depth >= CIRCUIT_WARN_THRESHOLD else "closed")
|
||||
|
||||
gpu_health = {}
|
||||
for name, endpoint in GPUS.items():
|
||||
gpu_health[name] = "up" if check_gpu_health(endpoint) else "down"
|
||||
|
||||
return jsonify({
|
||||
"queue_depth": depth,
|
||||
"circuit_breaker": circuit,
|
||||
"gpu_health": gpu_health,
|
||||
"thresholds": {
|
||||
"warn": CIRCUIT_WARN_THRESHOLD,
|
||||
"open": CIRCUIT_OPEN_THRESHOLD
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8091)
|
||||
@@ -0,0 +1,75 @@
|
||||
# GPU Roster Loader - reads gpu_roster.yaml via PyYAML
|
||||
# Hot-reloadable via /admin/roster/reload endpoint
|
||||
|
||||
import os, json, threading, time
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
ROSTER_PATH = os.environ.get('ROSTER_PATH', '/app/gpu_roster.yaml')
|
||||
_last_mtime = 0
|
||||
_lock = threading.Lock()
|
||||
|
||||
GPU_URLS = {}
|
||||
GPU_SIDECARS = {}
|
||||
GPU_LABELS = {}
|
||||
GPU_MAX_CONCURRENT = {}
|
||||
GPU_CONTEXT = {}
|
||||
TIER_MODELS = {}
|
||||
HOSTS = {}
|
||||
|
||||
def load_roster(path=None):
|
||||
global GPU_URLS, GPU_SIDECARS, GPU_LABELS, GPU_MAX_CONCURRENT
|
||||
global GPU_CONTEXT, TIER_MODELS, HOSTS, _last_mtime
|
||||
if yaml is None:
|
||||
return False, 'PyYAML not installed - run: pip3 install pyyaml'
|
||||
path = path or ROSTER_PATH
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
with _lock:
|
||||
GPU_URLS.clear()
|
||||
GPU_SIDECARS.clear()
|
||||
GPU_LABELS.clear()
|
||||
GPU_MAX_CONCURRENT.clear()
|
||||
GPU_CONTEXT.clear()
|
||||
TIER_MODELS.clear()
|
||||
models = data.get('models', {})
|
||||
for name, cfg in models.items():
|
||||
GPU_URLS[name] = cfg.get('gpu_url', '')
|
||||
GPU_SIDECARS[name] = cfg.get('sidecar_url', '')
|
||||
GPU_LABELS[name] = cfg.get('label', name)
|
||||
GPU_MAX_CONCURRENT[name] = cfg.get('max_concurrent', 1)
|
||||
GPU_CONTEXT[name] = cfg.get('context', 65536)
|
||||
tiers = cfg.get('tiers', ['enterprise'])
|
||||
for t in tiers:
|
||||
if t not in TIER_MODELS:
|
||||
TIER_MODELS[t] = []
|
||||
if name not in TIER_MODELS[t]:
|
||||
TIER_MODELS[t].append(name)
|
||||
HOSTS.clear()
|
||||
HOSTS.update(data.get('hosts', {}))
|
||||
_last_mtime = os.path.getmtime(path)
|
||||
return True, 'Loaded {} models, {} hosts'.format(len(GPU_URLS), len(HOSTS))
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def check_reload():
|
||||
global _last_mtime
|
||||
try:
|
||||
mtime = os.path.getmtime(ROSTER_PATH)
|
||||
if mtime > _last_mtime:
|
||||
success, msg = load_roster()
|
||||
if success:
|
||||
print('[ROSTER] Auto-reloaded:', msg)
|
||||
except:
|
||||
pass
|
||||
|
||||
def reload_thread(interval=30):
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
check_reload()
|
||||
|
||||
threading.Thread(target=reload_thread, daemon=True).start()
|
||||
@@ -1,9 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY router.py .
|
||||
|
||||
EXPOSE 9000
|
||||
CMD ["python", "router.py"]
|
||||
@@ -1,90 +0,0 @@
|
||||
# Insert streaming support before the gpu_resp call
|
||||
import re
|
||||
with open('/opt/inference-harness/router/router.py') as f:
|
||||
code = f.read()
|
||||
|
||||
# Find the gpu_resp block and replace with streaming-aware version
|
||||
old = ''' start = time.time()
|
||||
gpu_resp = requests.post(
|
||||
gpu_url + "/chat/completions",
|
||||
json=req_data,
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer not-needed"},
|
||||
timeout=120,
|
||||
)
|
||||
latency_ms = int((time.time() - start) * 1000)
|
||||
|
||||
if gpu_resp.status_code != 200:
|
||||
log.error("GPU error: %s %s", gpu_resp.status_code, gpu_resp.text[:200])
|
||||
return jsonify({"error": "GPU backend returned " + str(gpu_resp.status_code)}), 502
|
||||
|
||||
response_data = gpu_resp.json()
|
||||
response_data = fix_reasoning_content(response_data)
|
||||
|
||||
response_data["routing"] = {
|
||||
"model": model, "reason": reason, "gpu": gpu_url,
|
||||
"tier": tier, "agent": agent, "latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
return jsonify(response_data)'''
|
||||
|
||||
new = ''' start = time.time()
|
||||
is_stream = req_data.get("stream", False)
|
||||
|
||||
gpu_resp = requests.post(
|
||||
gpu_url + "/chat/completions",
|
||||
json=req_data,
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer not-needed"},
|
||||
timeout=120,
|
||||
stream=is_stream,
|
||||
)
|
||||
latency_ms = int((time.time() - start) * 1000)
|
||||
|
||||
if gpu_resp.status_code != 200:
|
||||
log.error("GPU error: %s %s", gpu_resp.status_code, gpu_resp.text[:200])
|
||||
return jsonify({"error": "GPU backend returned " + str(gpu_resp.status_code)}), 502
|
||||
|
||||
if is_stream:
|
||||
# Stream response back to client
|
||||
def generate():
|
||||
first = True
|
||||
for line in gpu_resp.iter_lines(decode_unicode=True):
|
||||
if line:
|
||||
if first and line.startswith("data: "):
|
||||
# Inject routing into first chunk
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
chunk["routing"] = {
|
||||
"model": model, "reason": reason, "gpu": gpu_url,
|
||||
"tier": tier, "agent": agent, "latency_ms": latency_ms,
|
||||
}
|
||||
yield "data: " + json.dumps(chunk) + "\n\n"
|
||||
first = False
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
yield line + "\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return Response(stream_with_context(generate()), mimetype="text/event-stream")
|
||||
|
||||
response_data = gpu_resp.json()
|
||||
response_data = fix_reasoning_content(response_data)
|
||||
|
||||
response_data["routing"] = {
|
||||
"model": model, "reason": reason, "gpu": gpu_url,
|
||||
"tier": tier, "agent": agent, "latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
return jsonify(response_data)'''
|
||||
|
||||
code = code.replace(old, new)
|
||||
|
||||
# Add missing import
|
||||
if 'from flask import Flask, request, jsonify' in code:
|
||||
code = code.replace(
|
||||
'from flask import Flask, request, jsonify',
|
||||
'from flask import Flask, request, jsonify, Response, stream_with_context'
|
||||
)
|
||||
|
||||
with open('/opt/inference-harness/router/router.py', 'w') as f:
|
||||
f.write(code)
|
||||
print('Streaming support added')
|
||||
@@ -1,3 +0,0 @@
|
||||
flask==3.1.*
|
||||
redis==5.2.*
|
||||
requests==2.32.*
|
||||
@@ -1,77 +0,0 @@
|
||||
def route(rd, tier, agent=""):
|
||||
msgs = rd.get("messages",[]); t = estimate_tokens(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")])
|
||||
hints = rd.get("routing_hints",{})
|
||||
allowed = TIER_MODELS.get(tier, ["gemma-4-12b"])
|
||||
avail = [m for m in available_models() if m in allowed]
|
||||
if not avail: return {"model": allowed[0], "reason": "all_saturated", "saturated": True}
|
||||
if all(is_gpu_busy(m) for m in avail):
|
||||
return {"model": avail[0], "reason": "all_saturated", "saturated": True}
|
||||
|
||||
# GUARD: multimodal -> VLM only (sole vision model)
|
||||
has_image = any(
|
||||
isinstance(m.get("content"), list) and
|
||||
any(p.get("type") == "image_url" for p in m["content"] if isinstance(p, dict))
|
||||
for m in msgs
|
||||
)
|
||||
if has_image:
|
||||
if "gemma-4-12b" in avail and not is_gpu_busy("gemma-4-12b"):
|
||||
return {"model": "gemma-4-12b", "reason": "vision"}
|
||||
elif "gemma-4-12b" in avail:
|
||||
return {"model": "gemma-4-12b", "reason": "vision_saturated", "saturated": True}
|
||||
else:
|
||||
return {"model": allowed[0], "reason": "vision_unavailable"}
|
||||
|
||||
req = rd.get("model","auto")
|
||||
if req != "auto":
|
||||
target = req if req in avail else avail[0]
|
||||
if is_gpu_busy(target) and req in allowed:
|
||||
alts = [m for m in avail if m != target and m in allowed]
|
||||
if alts:
|
||||
alt = select_best_gpu(alts, "explicit", agent)
|
||||
if alt: return alt
|
||||
return {"model": target, "reason": "explicit"}
|
||||
|
||||
if hints:
|
||||
if hints.get("priority")=="speed" and "gemma-4-12b" in avail:
|
||||
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-35B-A3B" in avail:
|
||||
return select_best_gpu(["qwen3.6-35B-A3B"], "hint_quality", agent) or {"model":"qwen3.6-35B-A3B","reason":"hint_quality"}
|
||||
if hints.get("priority")=="code" and "qwen3.6-27B-code" in avail:
|
||||
return select_best_gpu(["qwen3.6-27B-code"], "hint_code", agent) or {"model":"qwen3.6-27B-code","reason":"hint_code"}
|
||||
|
||||
first_msg = msgs[0].get("content","") if msgs else ""
|
||||
words = len(first_msg.split()) if isinstance(first_msg, str) else 99
|
||||
|
||||
# TIER 1: Tiny - single-turn micro queries -> VLM (fastest)
|
||||
if not sys and turns <= 1 and t <= 300 and words <= 100 and "gemma-4-12b" in avail:
|
||||
if not is_gpu_busy("gemma-4-12b"):
|
||||
return {"model":"gemma-4-12b","reason":"tiny"}
|
||||
fallback = [m for m in ["qwen3.6-27B-code","qwen3.6-35B-A3B"] if m in avail]
|
||||
result = select_best_gpu(fallback, "tiny_fallback", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 2: Light - moderate chat -> Dense primary, saves VLM for vision/speed
|
||||
if t <= 5000 and turns <= 4:
|
||||
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, "light", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 3: Medium - quality matters -> MoE primary, Dense fallback
|
||||
if t <= 30000:
|
||||
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, "medium", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 4: Heavy - big context needs big model -> MoE->Dense->VLM
|
||||
if t > 30000:
|
||||
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", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 5: Default - best quality first
|
||||
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, "default", agent)
|
||||
if result: return result
|
||||
return {"model":avail[0],"reason":"last_resort"}
|
||||
@@ -1,92 +0,0 @@
|
||||
def route(rd, tier, agent=""):
|
||||
msgs = rd.get("messages",[]); t = estimate_tokens(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")])
|
||||
hints = rd.get("routing_hints",{})
|
||||
allowed = TIER_MODELS.get(tier, ["gemma-4-12b"])
|
||||
avail = [m for m in available_models() if m in allowed]
|
||||
if not avail: return {"model": allowed[0], "reason": "all_saturated", "saturated": True}
|
||||
if all(is_gpu_busy(m) for m in avail):
|
||||
return {"model": avail[0], "reason": "all_saturated", "saturated": True}
|
||||
|
||||
# GUARD: multimodal -> VLM only (sole vision model)
|
||||
has_image = any(
|
||||
isinstance(m.get("content"), list) and
|
||||
any(p.get("type") == "image_url" for p in m["content"] if isinstance(p, dict))
|
||||
for m in msgs
|
||||
)
|
||||
if has_image:
|
||||
if "gemma-4-12b" in avail and not is_gpu_busy("gemma-4-12b"):
|
||||
return {"model": "gemma-4-12b", "reason": "vision"}
|
||||
elif "gemma-4-12b" in avail:
|
||||
return {"model": "gemma-4-12b", "reason": "vision_saturated", "saturated": True}
|
||||
else:
|
||||
return {"model": allowed[0], "reason": "vision_unavailable"}
|
||||
|
||||
req = rd.get("model","auto")
|
||||
if req != "auto":
|
||||
target = req if req in avail else avail[0]
|
||||
if is_gpu_busy(target) and req in allowed:
|
||||
alts = [m for m in avail if m != target and m in allowed]
|
||||
if alts:
|
||||
alt = select_best_gpu(alts, "explicit", agent)
|
||||
if alt: return alt
|
||||
return {"model": target, "reason": "explicit"}
|
||||
|
||||
if hints:
|
||||
if hints.get("priority")=="speed" and "gemma-4-12b" in avail:
|
||||
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-35B-A3B" in avail:
|
||||
return select_best_gpu(["qwen3.6-35B-A3B"], "hint_quality", agent) or {"model":"qwen3.6-35B-A3B","reason":"hint_quality"}
|
||||
if hints.get("priority")=="code" and "qwen3.6-27B-code" in avail:
|
||||
return select_best_gpu(["qwen3.6-27B-code"], "hint_code", agent) or {"model":"qwen3.6-27B-code","reason":"hint_code"}
|
||||
|
||||
first_msg = msgs[0].get("content","") if msgs else ""
|
||||
words = len(first_msg.split()) if isinstance(first_msg, str) else 99
|
||||
|
||||
# TIER 1: Tiny - single-turn micro queries -> VLM (fastest)
|
||||
if not sys and turns <= 1 and t <= 300 and words <= 100 and "gemma-4-12b" in avail:
|
||||
if not is_gpu_busy("gemma-4-12b"):
|
||||
return {"model":"gemma-4-12b","reason":"tiny"}
|
||||
fallback = [m for m in ["qwen3.6-27B-code","qwen3.6-35B-A3B"] if m in avail]
|
||||
result = select_best_gpu(fallback, "tiny_fallback", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 2: Light - moderate chat -> Dense primary, saves VLM for vision/speed
|
||||
if t <= 5000 and turns <= 4:
|
||||
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, "light", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 3: Medium - quality matters -> MoE primary (60%), Dense spillover (40%)
|
||||
if t <= 30000:
|
||||
candidates = moe_spillover(avail, ["qwen3.6-35B-A3B","qwen3.6-27B-code","gemma-4-12b"])
|
||||
result = select_best_gpu(candidates, "medium", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 4: Heavy - big context -> MoE primary (60%), Dense spillover (40%)
|
||||
if t > 30000:
|
||||
candidates = moe_spillover(avail, ["qwen3.6-35B-A3B","qwen3.6-27B-code","gemma-4-12b"])
|
||||
result = select_best_gpu(candidates, "heavy", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 5: Default - MoE primary (60%), Dense spillover (40%)
|
||||
candidates = moe_spillover(avail, ["qwen3.6-35B-A3B","qwen3.6-27B-code","gemma-4-12b"])
|
||||
result = select_best_gpu(candidates, "default", agent)
|
||||
if result: return result
|
||||
return {"model":avail[0],"reason":"last_resort"}
|
||||
|
||||
|
||||
def moe_spillover(avail, default_order):
|
||||
"""Spill 40% of MoE-first traffic to Dense to prevent Strix Halo overheating.
|
||||
Only applies when MoE is first candidate, available, and not busy."""
|
||||
import random
|
||||
if (default_order[0] == "qwen3.6-35B-A3B"
|
||||
and "qwen3.6-35B-A3B" in avail
|
||||
and not is_gpu_busy("qwen3.6-35B-A3B")
|
||||
and "qwen3.6-27B-code" in avail
|
||||
and not is_gpu_busy("qwen3.6-27B-code")
|
||||
and random.random() < 0.4):
|
||||
# Swap: Dense first, MoE second
|
||||
return ["qwen3.6-27B-code","qwen3.6-35B-A3B"] + [m for m in default_order[2:] if m in avail and m not in ("qwen3.6-27B-code","qwen3.6-35B-A3B")]
|
||||
return [m for m in default_order if m in avail]
|
||||
@@ -1,831 +0,0 @@
|
||||
import os, json, time, logging, traceback, threading, queue, statistics, math
|
||||
import requests, redis
|
||||
from flask import Flask, request, jsonify, Response, stream_with_context
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379")
|
||||
GPU_MOE_URL = os.environ.get("GPU_MOE_URL", "http://192.168.68.15:8080/v1")
|
||||
GPU_DENSE_URL = os.environ.get("GPU_DENSE_URL", "http://192.168.68.8:8080/v1")
|
||||
GPU_LIGHT_URL = os.environ.get("GPU_LIGHT_URL", "http://192.168.68.110:8080/v1")
|
||||
|
||||
GPU_SIDECARS = {
|
||||
"qwen3.6-35B-A3B": "http://192.168.68.15:8090",
|
||||
"qwen3.6-27B-code": "http://192.168.68.8:8090",
|
||||
"gemma-4-12b": "http://192.168.68.110:8090",
|
||||
}
|
||||
GPU_URLS = {
|
||||
"qwen3.6-35B-A3B": GPU_MOE_URL,
|
||||
"qwen3.6-27B-code": GPU_DENSE_URL,
|
||||
"gemma-4-12b": GPU_LIGHT_URL,
|
||||
}
|
||||
# Max concurrent requests per GPU (based on llama.cpp --parallel)
|
||||
GPU_MAX_CONCURRENT = {
|
||||
"qwen3.6-35B-A3B": 2, # 2 slots (cross-agent spread prevents overheating)
|
||||
"qwen3.6-27B-code": 2, # 2 slots
|
||||
"gemma-4-12b": 2, # 2 slots (12GB VRAM, 4GB headroom)
|
||||
}
|
||||
|
||||
# Context window sizes (tokens) — used for compaction signals
|
||||
GPU_CONTEXT = {
|
||||
"qwen3.6-35B-A3B": 262144,
|
||||
"qwen3.6-27B-code": 262144,
|
||||
"gemma-4-12b": 262144,
|
||||
}
|
||||
|
||||
TIER_MODELS = {
|
||||
"starter": ["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"],
|
||||
}
|
||||
# ── PHASE 0: Dual-Key API Key System ──
|
||||
# API_KEYS env var is REQUIRED (JSON string). No hardcoded fallback.
|
||||
# Format: {"sk-xxx": {"tier": "enterprise", "agent": "Name"}}
|
||||
# Deprecated keys get {"deprecated": true} — still accepted, logged with warning.
|
||||
_raw_keys = os.environ.get("API_KEYS")
|
||||
if not _raw_keys:
|
||||
raise RuntimeError("FATAL: API_KEYS environment variable is required. "
|
||||
"Set it in docker-compose.yml or .env file. "
|
||||
"No hardcoded keys fallback — this is a security feature.")
|
||||
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")
|
||||
log = logging.getLogger("router")
|
||||
try: r = redis.from_url(REDIS_URL, decode_responses=True); r.ping()
|
||||
except Exception: r = None
|
||||
|
||||
|
||||
def counter_audit_loop():
|
||||
"""Every 30s, check GPU slots and reset counters if all slots idle."""
|
||||
while True:
|
||||
time.sleep(30)
|
||||
if not r: continue
|
||||
for model, url in GPU_URLS.items():
|
||||
try:
|
||||
resp = requests.get(url.replace("/v1","") + "/slots",
|
||||
headers={"Authorization": "Bearer not-needed"}, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
slots = resp.json()
|
||||
all_idle = all(not s.get("is_processing", False) for s in slots)
|
||||
if all_idle:
|
||||
current = int(r.get("active:" + model) or 0)
|
||||
if current > 0:
|
||||
r.set("active:" + model, 0)
|
||||
log.info("AUDIT: Reset stuck counter for %s (was %d)", model, current)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=counter_audit_loop, daemon=True).start()
|
||||
|
||||
app = Flask(__name__)
|
||||
sse_subscribers = []; sse_lock = threading.Lock()
|
||||
|
||||
def gpu_active_count(model):
|
||||
"""Get number of in-flight requests for a GPU."""
|
||||
if r:
|
||||
return int(r.get("active:" + model) or 0)
|
||||
return 0
|
||||
|
||||
def gpu_incr(model):
|
||||
if r: r.incr("active:" + model)
|
||||
|
||||
def gpu_decr(model):
|
||||
if r:
|
||||
v = r.decr("active:" + model)
|
||||
if v and int(v) < 0:
|
||||
r.set("active:" + model, 0) # never go negative
|
||||
|
||||
def check_gpu_health(model, sidecar_timeout=5, gpu_timeout=3):
|
||||
url = GPU_SIDECARS.get(model)
|
||||
if not url: return {"status": "unknown"}
|
||||
try:
|
||||
resp = requests.get(url, timeout=sidecar_timeout)
|
||||
if resp.status_code == 200:
|
||||
d = resp.json()
|
||||
pct = (d.get("vram_used_mb",0) / max(d.get("vram_total_mb",1), 1)) * 100
|
||||
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
|
||||
gpu_url = GPU_URLS.get(model, "")
|
||||
try:
|
||||
hr = requests.get(gpu_url.replace("/v1","") + "/health", headers={"Authorization": "Bearer not-needed"}, timeout=gpu_timeout)
|
||||
if hr.status_code != 200:
|
||||
status = "down"
|
||||
except Exception:
|
||||
status = "down"
|
||||
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
|
||||
return {"status": "down"}
|
||||
|
||||
def available_models(): return [m for m in GPU_URLS if check_gpu_health(m)["status"] in ("healthy","saturated")]
|
||||
|
||||
def estimate_tokens(msgs):
|
||||
"""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
|
||||
|
||||
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):
|
||||
"""Check if GPU is at or near max concurrent capacity."""
|
||||
active = gpu_active_count(model)
|
||||
max_c = GPU_MAX_CONCURRENT.get(model, 1)
|
||||
return active >= max_c
|
||||
|
||||
def select_best_gpu(candidates, reason, agent=""):
|
||||
"""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:
|
||||
if not is_gpu_busy(m):
|
||||
return {"model": m, "reason": reason}
|
||||
# All busy — pick least loaded
|
||||
best = None
|
||||
best_load = 999
|
||||
for m in candidates:
|
||||
load = gpu_active_count(m)
|
||||
if load < best_load:
|
||||
best_load = load
|
||||
best = m
|
||||
if best:
|
||||
return {"model": best, "reason": "load_balanced_" + reason}
|
||||
return None
|
||||
|
||||
def route(rd, tier, agent=""):
|
||||
msgs = rd.get("messages",[]); t = estimate_tokens(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")])
|
||||
hints = rd.get("routing_hints",{})
|
||||
allowed = TIER_MODELS.get(tier, ["gemma-4-12b"])
|
||||
avail = [m for m in available_models() if m in allowed]
|
||||
if not avail: return {"model": allowed[0], "reason": "all_saturated", "saturated": True}
|
||||
# Check if all available GPUs are at max capacity
|
||||
if all(is_gpu_busy(m) for m in avail):
|
||||
return {"model": avail[0], "reason": "all_saturated", "saturated": True}
|
||||
|
||||
req = rd.get("model","auto")
|
||||
if req != "auto":
|
||||
# STRICT MODE: no silent fallback — LiteLLM handles failover chains.
|
||||
# This keeps per-model metrics accurate. Returns saturated if busy.
|
||||
target = req if req in avail else avail[0]
|
||||
if req not in avail:
|
||||
return {"model": req, "reason": "explicit_unavailable", "saturated": True}
|
||||
if is_gpu_busy(target):
|
||||
return {"model": target, "reason": "explicit_saturated", "saturated": True}
|
||||
return {"model": target, "reason": "explicit"}
|
||||
|
||||
if hints:
|
||||
if hints.get("priority")=="speed" and "gemma-4-12b" in avail:
|
||||
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-35B-A3B" in avail:
|
||||
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 ""
|
||||
words = len(first_msg.split()) if isinstance(first_msg, str) else 99
|
||||
|
||||
# TIER 1: Lightweight — single-turn short queries → VLM (fastest)
|
||||
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"):
|
||||
return {"model":"gemma-4-12b","reason":"lightweight"}
|
||||
# VLM busy — Dense is faster for short queries than MoE
|
||||
fallback = [m for m in ["qwen3.6-27B-code","qwen3.6-35B-A3B"] if m in avail]
|
||||
result = select_best_gpu(fallback, "lightweight_fallback", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 2: Simple conversations — VLM primary (up to 15K tok), fastest for moderate chat
|
||||
if t <= 15000 and turns <= 12 and "gemma-4-12b" in avail:
|
||||
if not is_gpu_busy("gemma-4-12b"):
|
||||
return {"model":"gemma-4-12b","reason":"simple_conv"}
|
||||
# VLM busy — fall back to Dense, then MoE
|
||||
fallback = [m for m in ["qwen3.6-27B-code","qwen3.6-35B-A3B"] if m in avail]
|
||||
result = select_best_gpu(fallback, "simple_conv_fallback", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 3: Medium complexity — Dense primary, VLM fallback (quality + speed balance)
|
||||
if t <= 25000:
|
||||
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]
|
||||
result = select_best_gpu(candidates, "heavy_reasoning", agent)
|
||||
if result: return result
|
||||
|
||||
# TIER 5: Default — Dense primary, MoE 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, "default", agent)
|
||||
if result: return result
|
||||
return {"model":avail[0],"reason":"last_resort"}
|
||||
|
||||
def clean_unicode(text):
|
||||
if not isinstance(text, str): return text
|
||||
text = text.replace(chr(0x2014), "-"); text = text.replace(chr(0x2013), "-")
|
||||
text = text.replace(chr(0x2018), "'"); text = text.replace(chr(0x2019), "'")
|
||||
text = text.replace(chr(0x201C), '"'); text = text.replace(chr(0x201D), '"')
|
||||
text = text.replace(chr(0x2026), "..."); text = text.replace(chr(0x00A0), " ")
|
||||
return text.encode("ascii", "ignore").decode("ascii")
|
||||
|
||||
def clean_response(d):
|
||||
if isinstance(d, dict): return {k: clean_response(v) for k,v in d.items()}
|
||||
if isinstance(d, list): return [clean_response(v) for v in d]
|
||||
if isinstance(d, str): return clean_unicode(d)
|
||||
return d
|
||||
|
||||
def get_metrics():
|
||||
d = {"gpus":[],"route_counts":{},"agent_counts":{},"tier_counts":{},"recent":[],"timestamp":time.time(),"active_requests":{}}
|
||||
for m in GPU_URLS:
|
||||
h = check_gpu_health(m)
|
||||
d["gpus"].append({"id":m,"gpu_name":h.get("gpu_name",m),"status":h.get("status"),"vram_used_mb":h.get("vram_used_mb"),"vram_total_mb":h.get("vram_total_mb"),"vram_pct":h.get("vram_pct"),"temp_c":h.get("temp_c"),"gpu_util_pct":h.get("gpu_util_pct"),"power_w":h.get("power_w"),"power_limit_w":h.get("power_limit_w"),"active_requests":gpu_active_count(m), "max_concurrent": GPU_MAX_CONCURRENT.get(m, 1)})
|
||||
d["active_requests"][m] = gpu_active_count(m)
|
||||
if r:
|
||||
try:
|
||||
for m in GPU_URLS: d["route_counts"][m] = int(r.get("routes:"+m) or 0)
|
||||
for k,v in API_KEYS.items():
|
||||
c = int(r.get("routes:agent:"+v["agent"]) or 0)
|
||||
if c>0: d["agent_counts"][v["agent"]] = c
|
||||
for t in TIER_MODELS: d["tier_counts"][t] = int(r.get("routes:tier:"+t) or 0)
|
||||
raw = r.lrange("routes:recent",0,49)
|
||||
d["recent"] = [json.loads(x) for x in raw] if raw else []
|
||||
except Exception: pass
|
||||
return d
|
||||
|
||||
def bcast():
|
||||
data = get_metrics(); payload = json.dumps(data)
|
||||
with sse_lock:
|
||||
dead = []
|
||||
for q in sse_subscribers:
|
||||
try: q.put(payload)
|
||||
except Exception: dead.append(q)
|
||||
for q in dead: sse_subscribers.remove(q)
|
||||
|
||||
QUEUE_TIMEOUT = int(os.environ.get("QUEUE_TIMEOUT", "30")) # max seconds to queue before 503
|
||||
|
||||
@app.route("/v1/chat/completions", methods=["POST"])
|
||||
def chat():
|
||||
try:
|
||||
rd = request.get_json(force=True)
|
||||
ak = request.headers.get("Authorization","").replace("Bearer ","")
|
||||
if not ak or ak not in API_KEYS:
|
||||
log.warning("AUTH_REJECTED: no/invalid API key from %s", request.remote_addr)
|
||||
return jsonify({"error": "Unauthorized — valid API key required"}), 401
|
||||
ki = API_KEYS[ak]
|
||||
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
|
||||
q_timeout = int(request.headers.get("X-Queue-Timeout", str(QUEUE_TIMEOUT)))
|
||||
|
||||
# Cross-turn context tracking: accumulate tokens per session
|
||||
session_id = request.headers.get("X-Session-Id", "")
|
||||
session_tokens = 0
|
||||
if session_id and r:
|
||||
try:
|
||||
prev = int(r.get("session:" + session_id) or 0)
|
||||
current = estimate_tokens(rd.get("messages",[]))
|
||||
session_tokens = max(prev, current) # context only grows
|
||||
r.set("session:" + session_id, session_tokens, ex=86400) # TTL 24h
|
||||
except Exception: pass
|
||||
|
||||
d = route(rd, tier, agent)
|
||||
queue_start = time.time()
|
||||
|
||||
# Queue loop: wait for a GPU slot instead of immediate 503
|
||||
while d.get("saturated"):
|
||||
elapsed = time.time() - queue_start
|
||||
if elapsed > q_timeout:
|
||||
resp = jsonify({"error": "All GPUs saturated", "queued_s": round(elapsed,1), "retry_after_s": 5})
|
||||
resp.headers["Retry-After"] = "5"
|
||||
log.warning("QUEUE_TIMEOUT: %s waited %.1fs, all GPUs saturated", agent, elapsed)
|
||||
return resp, 503
|
||||
time.sleep(0.5) # poll every 500ms
|
||||
d = route(rd, tier, agent)
|
||||
|
||||
queue_ms = (time.time() - queue_start) * 1000
|
||||
if queue_ms > 500:
|
||||
log.info("QUEUED: %s waited %.0fms before slot opened", agent, queue_ms)
|
||||
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)
|
||||
|
||||
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))
|
||||
# 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:
|
||||
try:
|
||||
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.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)
|
||||
except Exception: pass
|
||||
start = time.time()
|
||||
resp = requests.post(url+"/chat/completions", json=rd,
|
||||
headers={"Content-Type":"application/json","Authorization":"Bearer not-needed"}, timeout=300, stream=is_stream)
|
||||
lat = int((time.time()-start)*1000)
|
||||
gpu_decr(model)
|
||||
|
||||
if resp.status_code != 200: return jsonify({"error":"GPU error "+str(resp.status_code)}), 502
|
||||
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():
|
||||
for c in chunks: yield c
|
||||
bcast()
|
||||
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_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.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-Warning"] = ctx_warning
|
||||
sse_resp.headers["X-Context-Model"] = model
|
||||
return sse_resp
|
||||
data = clean_response(resp.json())
|
||||
for c in data.get("choices",[]):
|
||||
msg = c.get("message",{})
|
||||
if not msg.get("content") and msg.get("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_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"))
|
||||
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.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-Warning"] = ctx_warning
|
||||
resp.headers["X-Context-Model"] = model
|
||||
bcast()
|
||||
return resp
|
||||
except requests.Timeout:
|
||||
gpu_decr(model)
|
||||
log.error("TIMEOUT: %s -> %s", agent, model)
|
||||
return jsonify({"error":"timeout"}), 504
|
||||
except Exception as e:
|
||||
gpu_decr(model)
|
||||
log.error("Error: %s\n%s", e, traceback.format_exc())
|
||||
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")
|
||||
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")
|
||||
def health():
|
||||
gpus = {}
|
||||
for m in GPU_URLS:
|
||||
h = check_gpu_health(m, sidecar_timeout=1.5, gpu_timeout=1)
|
||||
h["active_requests"] = gpu_active_count(m)
|
||||
h["max_concurrent"] = GPU_MAX_CONCURRENT.get(m, 1)
|
||||
gpus[m] = h
|
||||
return jsonify({"status":"healthy","redis":"connected" if r else "down","gpus":gpus,"available_models":available_models()})
|
||||
|
||||
@app.route("/metrics")
|
||||
def metrics(): return jsonify(get_metrics())
|
||||
|
||||
@app.route("/metrics/timeseries")
|
||||
def metrics_timeseries():
|
||||
period = request.args.get("period", "day"); models_list = list(GPU_URLS.keys())
|
||||
data = {"models": {}, "labels": []}
|
||||
if period == "day":
|
||||
buckets = [time.strftime("%Y%m%d%H", time.gmtime(time.time()-h*3600)) for h in range(23,-1,-1)]
|
||||
data["labels"] = [time.strftime("%H:00", time.gmtime(time.time()-h*3600)) for h in range(23,-1,-1)]
|
||||
elif period == "week":
|
||||
buckets = [time.strftime("%Y%m%d", time.gmtime(time.time()-d*86400)) for d in range(6,-1,-1)]
|
||||
data["labels"] = [time.strftime("%a", time.gmtime(time.time()-d*86400)) for d in range(6,-1,-1)]
|
||||
else:
|
||||
buckets = [time.strftime("%Y%m%d", time.gmtime(time.time()-d*86400)) for d in range(29,-1,-1)]
|
||||
data["labels"] = [time.strftime("%m/%d", time.gmtime(time.time()-d*86400)) for d in range(29,-1,-1)]
|
||||
if r:
|
||||
for model in models_list:
|
||||
counts = []
|
||||
for bucket in buckets:
|
||||
total = 0
|
||||
if period in ("week","month"):
|
||||
for hh in range(24): total += int(r.get("ts:"+model+":"+bucket+"{:02d}".format(hh)) or 0)
|
||||
else: total = int(r.get("ts:"+model+":"+bucket) or 0)
|
||||
counts.append(total)
|
||||
data["models"][model] = counts
|
||||
return jsonify(data)
|
||||
|
||||
@app.route("/stream")
|
||||
def stream():
|
||||
def ev():
|
||||
q = queue.Queue()
|
||||
with sse_lock: sse_subscribers.append(q)
|
||||
try:
|
||||
yield "data: "+json.dumps(get_metrics())+"\n\n"
|
||||
while True:
|
||||
try: yield "data: "+q.get(timeout=3)+"\n\n"
|
||||
except queue.Empty: yield "data: "+json.dumps(get_metrics())+"\n\n"
|
||||
except GeneratorExit: pass
|
||||
finally:
|
||||
with sse_lock:
|
||||
if q in sse_subscribers: sse_subscribers.remove(q)
|
||||
return Response(stream_with_context(ev()), mimetype="text/event-stream",
|
||||
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__":
|
||||
log.info("Router on :9000 (load-aware)")
|
||||
app.run(host="0.0.0.0", port=9000, debug=False)
|
||||
-1149
File diff suppressed because it is too large
Load Diff
@@ -1,342 +0,0 @@
|
||||
import os, json, time, logging, traceback, threading, queue
|
||||
import requests, redis
|
||||
from flask import Flask, request, jsonify, Response, stream_with_context
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379")
|
||||
GPU_MOE_URL = os.environ.get("GPU_MOE_URL", "http://192.168.68.15:8080/v1")
|
||||
GPU_DENSE_URL = os.environ.get("GPU_DENSE_URL", "http://192.168.68.8:8080/v1")
|
||||
GPU_LIGHT_URL = os.environ.get("GPU_LIGHT_URL", "http://192.168.68.110:8080/v1")
|
||||
|
||||
GPU_SIDECARS = {
|
||||
"qwen3.6-35B-A3B": "http://192.168.68.15:8090",
|
||||
"qwen3.6-27B-code": "http://192.168.68.8:8090",
|
||||
"qwen3.5-9b-vlm": "http://192.168.68.110:8090",
|
||||
}
|
||||
GPU_URLS = {
|
||||
"qwen3.6-35B-A3B": GPU_MOE_URL,
|
||||
"qwen3.6-27B-code": GPU_DENSE_URL,
|
||||
"qwen3.5-9b-vlm": GPU_LIGHT_URL,
|
||||
}
|
||||
# Max concurrent requests per GPU (based on llama.cpp --parallel)
|
||||
GPU_MAX_CONCURRENT = {
|
||||
"qwen3.6-35B-A3B": 2, # 2 slots
|
||||
"qwen3.6-27B-code": 2, # 2 slots
|
||||
"qwen3.5-9b-vlm": 1, # 1 slot
|
||||
}
|
||||
|
||||
TIER_MODELS = {
|
||||
"starter": ["qwen3.5-9b-vlm"],
|
||||
"professional": ["qwen3.6-35B-A3B", "qwen3.6-27B-code", "qwen3.5-9b-vlm"],
|
||||
"enterprise": ["qwen3.6-35B-A3B", "qwen3.6-27B-code", "qwen3.5-9b-vlm"],
|
||||
}
|
||||
API_KEYS = {
|
||||
"sk-syslog-local-master-key": {"tier": "enterprise", "agent": "admin"},
|
||||
"sk-syslog-abiba": {"tier": "enterprise", "agent": "Abiba"},
|
||||
"sk-syslog-mumuni": {"tier": "enterprise", "agent": "Mumuni"},
|
||||
"sk-syslog-tanko": {"tier": "enterprise", "agent": "Tanko"},
|
||||
"sk-syslog-koby": {"tier": "enterprise", "agent": "Koby"},
|
||||
"sk-syslog-kagenz0": {"tier": "enterprise", "agent": "Kagenz0"},
|
||||
"sk-syslog-koonimo": {"tier": "enterprise", "agent": "Koonimo"},
|
||||
"sk-starter-abc123": {"tier": "starter", "agent": "test-starter"},
|
||||
"sk-professional-xyz789": {"tier": "professional", "agent": "test-pro"},
|
||||
}
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [ROUTER] %(levelname)s %(message)s")
|
||||
log = logging.getLogger("router")
|
||||
try: r = redis.from_url(REDIS_URL, decode_responses=True); r.ping()
|
||||
except Exception: r = None
|
||||
|
||||
|
||||
def counter_audit_loop():
|
||||
"""Every 30s, check GPU slots and reset counters if all slots idle."""
|
||||
while True:
|
||||
time.sleep(30)
|
||||
if not r: continue
|
||||
for model, url in GPU_URLS.items():
|
||||
try:
|
||||
resp = requests.get(url.replace("/v1","") + "/slots",
|
||||
headers={"Authorization": "Bearer not-needed"}, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
slots = resp.json()
|
||||
all_idle = all(not s.get("is_processing", False) for s in slots)
|
||||
if all_idle:
|
||||
current = int(r.get("active:" + model) or 0)
|
||||
if current > 0:
|
||||
r.set("active:" + model, 0)
|
||||
log.info("AUDIT: Reset stuck counter for %s (was %d)", model, current)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=counter_audit_loop, daemon=True).start()
|
||||
|
||||
app = Flask(__name__)
|
||||
sse_subscribers = []; sse_lock = threading.Lock()
|
||||
|
||||
def gpu_active_count(model):
|
||||
"""Get number of in-flight requests for a GPU."""
|
||||
if r:
|
||||
return int(r.get("active:" + model) or 0)
|
||||
return 0
|
||||
|
||||
def gpu_incr(model):
|
||||
if r: r.incr("active:" + model)
|
||||
|
||||
def gpu_decr(model):
|
||||
if r:
|
||||
v = r.decr("active:" + model)
|
||||
if v and int(v) < 0:
|
||||
r.set("active:" + model, 0) # never go negative
|
||||
|
||||
def check_gpu_health(model):
|
||||
url = GPU_SIDECARS.get(model)
|
||||
if not url: return {"status": "unknown"}
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
d = resp.json()
|
||||
pct = (d.get("vram_used_mb",0) / max(d.get("vram_total_mb",1), 1)) * 100
|
||||
status = "healthy" if pct < 90 else "saturated"
|
||||
# Also check if llama.cpp endpoint is actually responding
|
||||
gpu_url = GPU_URLS.get(model, "")
|
||||
try:
|
||||
hr = requests.get(gpu_url.replace("/v1","") + "/health", headers={"Authorization": "Bearer not-needed"}, timeout=3)
|
||||
if hr.status_code != 200:
|
||||
status = "down"
|
||||
except Exception:
|
||||
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")}
|
||||
except Exception: pass
|
||||
return {"status": "down"}
|
||||
|
||||
def available_models(): return [m for m in GPU_URLS if check_gpu_health(m)["status"] in ("healthy","saturated")]
|
||||
|
||||
def estimate_tokens(msgs): return sum(len(str(m.get("content",""))) for m in msgs) // 4
|
||||
|
||||
def is_gpu_busy(model):
|
||||
"""Check if GPU is at or near max concurrent capacity."""
|
||||
active = gpu_active_count(model)
|
||||
max_c = GPU_MAX_CONCURRENT.get(model, 1)
|
||||
return active >= max_c
|
||||
|
||||
def select_best_gpu(candidates, reason):
|
||||
"""Pick the best GPU from candidates, preferring least-loaded."""
|
||||
best = None
|
||||
best_load = 999
|
||||
for m in candidates:
|
||||
load = gpu_active_count(m)
|
||||
if load < best_load:
|
||||
best_load = load
|
||||
best = m
|
||||
if best:
|
||||
actual_reason = reason
|
||||
if is_gpu_busy(best):
|
||||
actual_reason = "load_balanced_" + reason
|
||||
return {"model": best, "reason": actual_reason}
|
||||
return None
|
||||
|
||||
def route(rd, tier):
|
||||
msgs = rd.get("messages",[]); t = estimate_tokens(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")])
|
||||
hints = rd.get("routing_hints",{})
|
||||
allowed = TIER_MODELS.get(tier, ["qwen3.5-9b-vlm"])
|
||||
avail = [m for m in available_models() if m in allowed]
|
||||
if not avail: return {"model": allowed[0], "reason": "all_saturated", "saturated": True}
|
||||
|
||||
req = rd.get("model","auto")
|
||||
if req != "auto":
|
||||
target = req if req in avail else avail[0]
|
||||
# If explicit model is busy, check if another can take it
|
||||
if is_gpu_busy(target) and req in allowed:
|
||||
alts = [m for m in avail if m != target and m in allowed]
|
||||
if alts:
|
||||
alt = select_best_gpu(alts, "explicit")
|
||||
if alt: return alt
|
||||
return {"model": target, "reason": "explicit"}
|
||||
|
||||
if hints:
|
||||
if hints.get("priority")=="speed" and "qwen3.5-9b-vlm" in avail:
|
||||
return select_best_gpu(["qwen3.5-9b-vlm"], "hint_speed") or {"model":"qwen3.5-9b-vlm","reason":"hint_speed"}
|
||||
if hints.get("priority")=="quality" and "qwen3.6-27B-code" in avail:
|
||||
return select_best_gpu(["qwen3.6-27B-code"], "hint_quality") or {"model":"qwen3.6-27B-code","reason":"hint_quality"}
|
||||
|
||||
# Heavy -> dense (but fall back to MoE if dense is busy)
|
||||
if t > 4000 or sys or turns > 6:
|
||||
candidates = ["qwen3.6-27B-code","qwen3.6-35B-A3B","qwen3.5-9b-vlm"]
|
||||
candidates = [m for m in candidates if m in avail]
|
||||
result = select_best_gpu(candidates, "heavy_reasoning")
|
||||
if result: return result
|
||||
|
||||
# Ultra-light -> VLM
|
||||
first_msg = msgs[0].get("content","") if msgs else ""
|
||||
words = len(first_msg.split()) if isinstance(first_msg, str) else 99
|
||||
if words <= 3 and turns <= 1 and not sys and "qwen3.5-9b-vlm" in avail:
|
||||
if not is_gpu_busy("qwen3.5-9b-vlm"):
|
||||
return {"model":"qwen3.5-9b-vlm","reason":"ultra_light"}
|
||||
|
||||
# Default: MoE, fall back to dense if MoE is busy
|
||||
if "qwen3.6-35B-A3B" in avail:
|
||||
if is_gpu_busy("qwen3.6-35B-A3B") and "qwen3.6-27B-code" in avail:
|
||||
return {"model": "qwen3.6-27B-code", "reason": "load_balanced_default"}
|
||||
return {"model":"qwen3.6-35B-A3B","reason":"default_moe"}
|
||||
|
||||
return {"model":avail[0],"reason":"fallback"}
|
||||
|
||||
def clean_unicode(text):
|
||||
if not isinstance(text, str): return text
|
||||
text = text.replace(chr(0x2014), "-"); text = text.replace(chr(0x2013), "-")
|
||||
text = text.replace(chr(0x2018), "'"); text = text.replace(chr(0x2019), "'")
|
||||
text = text.replace(chr(0x201C), '"'); text = text.replace(chr(0x201D), '"')
|
||||
text = text.replace(chr(0x2026), "..."); text = text.replace(chr(0x00A0), " ")
|
||||
return text.encode("ascii", "ignore").decode("ascii")
|
||||
|
||||
def clean_response(d):
|
||||
if isinstance(d, dict): return {k: clean_response(v) for k,v in d.items()}
|
||||
if isinstance(d, list): return [clean_response(v) for v in d]
|
||||
if isinstance(d, str): return clean_unicode(d)
|
||||
return d
|
||||
|
||||
def get_metrics():
|
||||
d = {"gpus":[],"route_counts":{},"agent_counts":{},"tier_counts":{},"recent":[],"timestamp":time.time(),"active_requests":{}}
|
||||
for m in GPU_URLS:
|
||||
h = check_gpu_health(m)
|
||||
d["gpus"].append({"id":m,"gpu_name":h.get("gpu_name",m),"status":h.get("status"),"vram_used_mb":h.get("vram_used_mb"),"vram_total_mb":h.get("vram_total_mb"),"vram_pct":h.get("vram_pct"),"temp_c":h.get("temp_c"),"gpu_util_pct":h.get("gpu_util_pct"),"power_w":h.get("power_w"),"power_limit_w":h.get("power_limit_w"),"active_requests":gpu_active_count(m), "max_concurrent": GPU_MAX_CONCURRENT.get(m, 1)})
|
||||
d["active_requests"][m] = gpu_active_count(m)
|
||||
if r:
|
||||
try:
|
||||
for m in GPU_URLS: d["route_counts"][m] = int(r.get("routes:"+m) or 0)
|
||||
for k,v in API_KEYS.items():
|
||||
c = int(r.get("routes:agent:"+v["agent"]) or 0)
|
||||
if c>0: d["agent_counts"][v["agent"]] = c
|
||||
for t in TIER_MODELS: d["tier_counts"][t] = int(r.get("routes:tier:"+t) or 0)
|
||||
raw = r.lrange("routes:recent",0,49)
|
||||
d["recent"] = [json.loads(x) for x in raw] if raw else []
|
||||
except Exception: pass
|
||||
return d
|
||||
|
||||
def bcast():
|
||||
data = get_metrics(); payload = json.dumps(data)
|
||||
with sse_lock:
|
||||
dead = []
|
||||
for q in sse_subscribers:
|
||||
try: q.put(payload)
|
||||
except Exception: dead.append(q)
|
||||
for q in dead: sse_subscribers.remove(q)
|
||||
|
||||
@app.route("/v1/chat/completions", methods=["POST"])
|
||||
def chat():
|
||||
try:
|
||||
rd = request.get_json(force=True)
|
||||
ak = request.headers.get("Authorization","").replace("Bearer ","")
|
||||
ki = API_KEYS.get(ak, {"tier":"starter","agent":"unknown"})
|
||||
tier, agent = ki["tier"], ki["agent"]
|
||||
d = route(rd, tier)
|
||||
if d.get("saturated"):
|
||||
resp = jsonify({"error": "All GPUs saturated", "retry_after_s": 5})
|
||||
resp.headers["Retry-After"] = "5"
|
||||
return resp, 503
|
||||
model, reason, url = d["model"], d["reason"], GPU_URLS[d["model"]]
|
||||
is_stream = rd.get("stream", False)
|
||||
|
||||
gpu_incr(model)
|
||||
decremented = False
|
||||
|
||||
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))
|
||||
if r:
|
||||
try:
|
||||
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.lpush("routes:recent", json.dumps({"ts":time.time(),"model":model,"reason":reason,"tier":tier,"agent":agent}))
|
||||
r.ltrim("routes:recent",0,999)
|
||||
except Exception: pass
|
||||
start = time.time()
|
||||
resp = requests.post(url+"/chat/completions", json=rd,
|
||||
headers={"Content-Type":"application/json","Authorization":"Bearer not-needed"}, timeout=300, stream=is_stream)
|
||||
lat = int((time.time()-start)*1000)
|
||||
gpu_decr(model)
|
||||
decremented = True # Release slot
|
||||
|
||||
if resp.status_code != 200: return jsonify({"error":"GPU error "+str(resp.status_code)}), 502
|
||||
if is_stream:
|
||||
def gen():
|
||||
for raw in resp.iter_content(chunk_size=None, decode_unicode=True):
|
||||
if raw: yield clean_unicode(raw)
|
||||
bcast()
|
||||
return Response(stream_with_context(gen()), mimetype="text/event-stream")
|
||||
data = clean_response(resp.json())
|
||||
for c in data.get("choices",[]):
|
||||
msg = c.get("message",{})
|
||||
if not msg.get("content") and msg.get("reasoning_content"):
|
||||
msg["content"] = msg["reasoning_content"]
|
||||
data["routing"] = {"model":model,"reason":reason,"gpu":url,"tier":tier,"agent":agent,"latency_ms":lat,"active_gpu":gpu_active_count(model)}
|
||||
bcast()
|
||||
return jsonify(data)
|
||||
if not decremented:
|
||||
try: gpu_decr(model)
|
||||
except: pass
|
||||
except requests.Timeout:
|
||||
return jsonify({"error":"timeout"}), 504
|
||||
log.error("Error: %s\n%s", e, traceback.format_exc())
|
||||
return jsonify({"error":str(e)}), 500
|
||||
|
||||
@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]})
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
gpus = {}
|
||||
for m in GPU_URLS:
|
||||
h = check_gpu_health(m)
|
||||
h["active_requests"] = gpu_active_count(m)
|
||||
h["max_concurrent"] = GPU_MAX_CONCURRENT.get(m, 1)
|
||||
gpus[m] = h
|
||||
return jsonify({"status":"healthy","redis":"connected" if r else "down","gpus":gpus,"available_models":available_models()})
|
||||
|
||||
@app.route("/metrics")
|
||||
def metrics(): return jsonify(get_metrics())
|
||||
|
||||
@app.route("/metrics/timeseries")
|
||||
def metrics_timeseries():
|
||||
period = request.args.get("period", "day"); models_list = list(GPU_URLS.keys())
|
||||
data = {"models": {}, "labels": []}
|
||||
if period == "day":
|
||||
buckets = [time.strftime("%Y%m%d%H", time.gmtime(time.time()-h*3600)) for h in range(23,-1,-1)]
|
||||
data["labels"] = [time.strftime("%H:00", time.gmtime(time.time()-h*3600)) for h in range(23,-1,-1)]
|
||||
elif period == "week":
|
||||
buckets = [time.strftime("%Y%m%d", time.gmtime(time.time()-d*86400)) for d in range(6,-1,-1)]
|
||||
data["labels"] = [time.strftime("%a", time.gmtime(time.time()-d*86400)) for d in range(6,-1,-1)]
|
||||
else:
|
||||
buckets = [time.strftime("%Y%m%d", time.gmtime(time.time()-d*86400)) for d in range(29,-1,-1)]
|
||||
data["labels"] = [time.strftime("%m/%d", time.gmtime(time.time()-d*86400)) for d in range(29,-1,-1)]
|
||||
if r:
|
||||
for model in models_list:
|
||||
counts = []
|
||||
for bucket in buckets:
|
||||
total = 0
|
||||
if period in ("week","month"):
|
||||
for hh in range(24): total += int(r.get("ts:"+model+":"+bucket+"{:02d}".format(hh)) or 0)
|
||||
else: total = int(r.get("ts:"+model+":"+bucket) or 0)
|
||||
counts.append(total)
|
||||
data["models"][model] = counts
|
||||
return jsonify(data)
|
||||
|
||||
@app.route("/stream")
|
||||
def stream():
|
||||
def ev():
|
||||
q = queue.Queue()
|
||||
with sse_lock: sse_subscribers.append(q)
|
||||
try:
|
||||
yield "data: "+json.dumps(get_metrics())+"\n\n"
|
||||
while True:
|
||||
try: yield "data: "+q.get(timeout=3)+"\n\n"
|
||||
except queue.Empty: yield "data: "+json.dumps(get_metrics())+"\n\n"
|
||||
except GeneratorExit: pass
|
||||
finally:
|
||||
with sse_lock:
|
||||
if q in sse_subscribers: sse_subscribers.remove(q)
|
||||
return Response(stream_with_context(ev()), mimetype="text/event-stream",
|
||||
headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no","Access-Control-Allow-Origin":"*"})
|
||||
|
||||
if __name__ == "__main__":
|
||||
log.info("Router on :9000 (load-aware)")
|
||||
app.run(host="0.0.0.0", port=9000, debug=False)
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Daily LiteLLM cost snapshot — cron at midnight
|
||||
# Writes to /var/log/litellm/costs.csv on CT 116
|
||||
|
||||
MASTER="sk-litellm-7f96080dd99b15c36bd4b333b58a6796"
|
||||
BASE="http://127.0.0.1:4001"
|
||||
CSV="/var/log/litellm/costs.csv"
|
||||
DATE=$(date -I)
|
||||
YESTERDAY=$(date -I -d yesterday)
|
||||
|
||||
# Fetch yesterday's spend by key
|
||||
RESP=$(curl -s "${BASE}/global/spend/logs?start_date=${YESTERDAY}T00:00:00&end_date=${YESTERDAY}T23:59:59&page_size=1000" \
|
||||
-H "Authorization: Bearer $MASTER" 2>/dev/null)
|
||||
|
||||
# Parse total spend
|
||||
TOTAL=$(echo "$RESP" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
total = sum(r.get('spend', 0) or 0 for r in data.get('data', []))
|
||||
print(f'{total:.6f}')
|
||||
" 2>/dev/null)
|
||||
|
||||
# Parse per-model spend
|
||||
PER_MODEL=$(echo "$RESP" | python3 -c "
|
||||
import sys, json
|
||||
from collections import defaultdict
|
||||
data = json.load(sys.stdin)
|
||||
spend = defaultdict(float)
|
||||
for r in data.get('data', []):
|
||||
model = r.get('model', 'unknown')
|
||||
spend[model] += r.get('spend', 0) or 0
|
||||
for model, cost in sorted(spend.items()):
|
||||
print(f'{model}:{cost:.6f}')
|
||||
" 2>/dev/null)
|
||||
|
||||
# Per-key spend
|
||||
PER_KEY=$(echo "$RESP" | python3 -c "
|
||||
import sys, json
|
||||
from collections import defaultdict
|
||||
data = json.load(sys.stdin)
|
||||
spend = defaultdict(float)
|
||||
for r in data.get('data', []):
|
||||
key = r.get('api_key', 'unknown')[:20]
|
||||
spend[key] += r.get('spend', 0) or 0
|
||||
for key, cost in sorted(spend.items()):
|
||||
print(f'{key}:{cost:.6f}')
|
||||
" 2>/dev/null)
|
||||
|
||||
# Write CSV
|
||||
mkdir -p $(dirname "$CSV")
|
||||
if [ ! -f "$CSV" ]; then
|
||||
echo "date,total_cost,per_model,per_key" > "$CSV"
|
||||
fi
|
||||
echo "${DATE},${TOTAL},\"${PER_MODEL}\",\"${PER_KEY}\"" >> "$CSV"
|
||||
|
||||
echo "Snapshot: ${DATE} | Total: \$${TOTAL}"
|
||||
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GPU Fleet Monitor — Comprehensive monitoring server.
|
||||
|
||||
Monitors every subsystem in the inference harness:
|
||||
- GPU sidecars (VRAM, temp, util, power per card)
|
||||
- Router (model routing, circuit breakers, queue health)
|
||||
- LiteLLM (proxy health, key count, model sync)
|
||||
- Strix Halo (llama-server status, CPU load, context)
|
||||
- Dashboard (CT 116 harness-dashboard)
|
||||
|
||||
Endpoints:
|
||||
/ → GPU fleet HTML dashboard
|
||||
/gpu-data → JSON GPU metrics (for dashboard API)
|
||||
/health → Monitor self-health check
|
||||
|
||||
Run: python3 gpu-monitor-server.py
|
||||
Default port: 9100
|
||||
"""
|
||||
|
||||
import json, subprocess, http.server, threading, time, os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
DASHBOARD_PATH = "/root/dashboard/gpu-fleet.html"
|
||||
PORT = 9100
|
||||
|
||||
# Cached data, refreshed every 15s
|
||||
cache = {}
|
||||
cache_lock = threading.Lock()
|
||||
|
||||
# Alert thresholds
|
||||
THRESHOLDS = {
|
||||
"temp_c": {"warning": 80, "critical": 90},
|
||||
"vram_pct": {"warning": 90, "critical": 95},
|
||||
"gpu_util_pct": {"warning": 95, "critical": 98},
|
||||
}
|
||||
|
||||
|
||||
def http_get(url, timeout=5):
|
||||
"""HTTP GET with timeout, returns parsed JSON or error dict."""
|
||||
import urllib.request
|
||||
try:
|
||||
resp = urllib.request.urlopen(url, timeout=timeout)
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def poll_sidecar(host, port=8090):
|
||||
"""Poll a GPU sidecar for raw metrics.
|
||||
Falls back to SSH-based nvidia-smi if sidecar is unreachable."""
|
||||
result = http_get(f"http://{host}:{port}/health", timeout=5)
|
||||
if "error" not in result:
|
||||
return result
|
||||
# Fallback: poll via SSH (explicit key path for non-interactive environments)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["/usr/bin/ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
|
||||
"-i", "/root/.ssh/id_ed25519", host,
|
||||
"nvidia-smi", "--query-gpu=name,temperature.gpu,utilization.gpu,"
|
||||
"utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed",
|
||||
"--format=csv,noheader"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
parts = [p.strip() for p in proc.stdout.strip().split(",")]
|
||||
if len(parts) >= 8:
|
||||
def cv(v):
|
||||
try: return float(v.replace("MiB","").replace("W","").replace("%","").strip())
|
||||
except: return 0.0
|
||||
def mv(v):
|
||||
try: return int(v.replace("MiB","").strip())
|
||||
except: return 0
|
||||
return {
|
||||
"gpu_name": parts[0], "temp_c": cv(parts[1]),
|
||||
"gpu_util_pct": cv(parts[2]), "mem_util_pct": cv(parts[3]),
|
||||
"vram_used_mb": mv(parts[4]), "vram_total_mb": mv(parts[5]),
|
||||
"power_w": cv(parts[6]), "power_limit_w": cv(parts[7]),
|
||||
"fan_pct": cv(parts[8])
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return result
|
||||
|
||||
|
||||
def poll_router():
|
||||
"""Poll router unified health through nginx (port 80)."""
|
||||
return http_get("http://192.168.68.116/health/unified", timeout=5)
|
||||
|
||||
|
||||
def poll_router_health():
|
||||
"""Poll router basic health through nginx."""
|
||||
return http_get("http://192.168.68.116/health", timeout=5)
|
||||
|
||||
|
||||
def poll_litellm_health():
|
||||
"""Poll LiteLLM — checks reachability via nginx proxy.
|
||||
|
||||
LiteLLM health endpoints require authentication. We check if the
|
||||
service responds at all (even 401 = service is up). Also try the
|
||||
/key/list endpoint which confirms the proxy is fully functional.
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
urls_to_try = [
|
||||
"http://192.168.68.116/litellm/health",
|
||||
"http://192.168.68.116/litellm/ui/",
|
||||
]
|
||||
last_error = None
|
||||
for url in urls_to_try:
|
||||
try:
|
||||
resp = urllib.request.urlopen(url, timeout=5)
|
||||
body = resp.read().decode()[:500]
|
||||
# Any response (even 401) means the proxy is routing to LiteLLM
|
||||
return {"reachable": True, "status_code": resp.getcode(),
|
||||
"endpoint": url, "body_preview": body[:200]}
|
||||
except urllib.error.HTTPError as e:
|
||||
# HTTP error means the service IS reachable but returned error
|
||||
return {"reachable": True, "status_code": e.code,
|
||||
"endpoint": url, "note": f"HTTP {e.code} (requires auth)"}
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
return {"error": last_error or "all endpoints failed"}
|
||||
|
||||
|
||||
def poll_strix():
|
||||
"""Poll Strix Halo — process check + CPU load + llama-server health."""
|
||||
try:
|
||||
# Check llama-server process
|
||||
proc = subprocess.run(
|
||||
["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
|
||||
"192.168.68.15",
|
||||
"pgrep -f llama-server > /dev/null 2>&1 && echo running || echo stopped"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
status = proc.stdout.strip()
|
||||
|
||||
# Get CPU load
|
||||
load = subprocess.run(
|
||||
["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
|
||||
"192.168.68.15",
|
||||
"uptime | awk -F'load average:' '{print $2}' | tr -d ' '"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
cpu_load = load.stdout.strip() if load.returncode == 0 else "unknown"
|
||||
|
||||
# Try llama-server health endpoint
|
||||
llama_health = http_get("http://192.168.68.15:8080/health", timeout=3)
|
||||
|
||||
return {
|
||||
"status": status if status else "unknown",
|
||||
"cpu_load": cpu_load,
|
||||
"llama_health": llama_health if "error" not in llama_health else None
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def poll_dashboard():
|
||||
"""Check if harness-dashboard on CT 116 is serving."""
|
||||
return http_get("http://192.168.68.116/dashboard/", timeout=5)
|
||||
|
||||
|
||||
def check_alerts(gpu_data):
|
||||
"""Check GPU metrics against thresholds, return alert list."""
|
||||
alerts = []
|
||||
name = gpu_data.get("name", "unknown")
|
||||
for metric, thresholds in THRESHOLDS.items():
|
||||
# Map metric names from sidecar/router formats
|
||||
value = None
|
||||
if metric == "vram_pct":
|
||||
# Compute from vram_used_mb / vram_total_mb
|
||||
used = gpu_data.get("vram_used_mb", 0)
|
||||
total = gpu_data.get("vram_total_mb", 1)
|
||||
value = (used / total) * 100
|
||||
elif metric == "gpu_util_pct":
|
||||
value = gpu_data.get("gpu_util_pct", 0)
|
||||
elif metric == "temp_c":
|
||||
value = gpu_data.get("temp_c", 0)
|
||||
|
||||
if value is not None:
|
||||
if value >= thresholds["critical"]:
|
||||
alerts.append({"gpu": name, "metric": metric, "level": "critical",
|
||||
"value": round(value, 1), "threshold": thresholds["critical"]})
|
||||
elif value >= thresholds["warning"]:
|
||||
alerts.append({"gpu": name, "metric": metric, "level": "warning",
|
||||
"value": round(value, 1), "threshold": thresholds["warning"]})
|
||||
return alerts
|
||||
|
||||
|
||||
def compute_summary(router_data, gpus, litellm_data, strix_data):
|
||||
"""Compute fleet-wide health summary."""
|
||||
# Count models available
|
||||
models_available = 0
|
||||
models_total = 0
|
||||
if "available_models" in router_data:
|
||||
models_available = len(router_data["available_models"])
|
||||
models_total = models_available # router reports all expected
|
||||
|
||||
# Count circuit breakers open
|
||||
cb_open = 0
|
||||
if "circuit_breaker" in router_data:
|
||||
for model, cb in router_data["circuit_breaker"].items():
|
||||
if cb.get("open", 0):
|
||||
cb_open += 1
|
||||
|
||||
# Count GPU errors
|
||||
gpu_errors = sum(1 for g in gpus if "error" in g)
|
||||
|
||||
# Fleet status
|
||||
if gpu_errors > 0 or cb_open > 0:
|
||||
fleet_status = "degraded"
|
||||
elif not router_data or "error" in router_data:
|
||||
fleet_status = "degraded"
|
||||
else:
|
||||
fleet_status = "healthy"
|
||||
|
||||
litellm_ok = ("error" not in litellm_data) or litellm_data.get("reachable", False)
|
||||
return {
|
||||
"fleet_status": fleet_status,
|
||||
"models_available": models_available,
|
||||
"models_total": models_total,
|
||||
"circuit_breakers_open": cb_open,
|
||||
"gpu_count": len(gpus),
|
||||
"gpu_errors": gpu_errors,
|
||||
"strix_running": strix_data.get("status") == "running",
|
||||
"router_reachable": "error" not in router_data,
|
||||
"litellm_reachable": litellm_ok,
|
||||
}
|
||||
|
||||
|
||||
def refresh_cache():
|
||||
"""Refresh all fleet data from every subsystem."""
|
||||
global cache
|
||||
|
||||
# 1. GPU sidecars
|
||||
gpu_map = {
|
||||
"192.168.68.8": {"name": "Dense (RTX 3090)", "models": ["gpu-dense"], "hostname": "ct8"},
|
||||
"192.168.68.110": {"name": "Light/Vision (RTX 5070)", "models": ["gpu-vision"], "hostname": "ct110"},
|
||||
}
|
||||
|
||||
gpus = []
|
||||
all_alerts = []
|
||||
for host, info in gpu_map.items():
|
||||
data = poll_sidecar(host)
|
||||
if "error" not in data:
|
||||
data["name"] = info["name"]
|
||||
data["models"] = info["models"]
|
||||
data["hostname"] = info["hostname"]
|
||||
all_alerts.extend(check_alerts(data))
|
||||
else:
|
||||
data = {"name": info["name"], "hostname": info["hostname"], "error": data["error"]}
|
||||
all_alerts.append({"gpu": info["name"], "metric": "connectivity",
|
||||
"level": "critical", "value": data["error"]})
|
||||
gpus.append(data)
|
||||
|
||||
# 2. Router
|
||||
router = poll_router()
|
||||
router_basic = poll_router_health()
|
||||
router["_basic"] = router_basic
|
||||
|
||||
# 3. LiteLLM
|
||||
litellm = poll_litellm_health()
|
||||
|
||||
# 4. Strix Halo
|
||||
strix = poll_strix()
|
||||
|
||||
# 5. Dashboard
|
||||
dashboard = poll_dashboard()
|
||||
|
||||
# 6. Send Zulip DM for critical alerts (debounced 5 min)
|
||||
critical = [a for a in all_alerts if a.get("level") == "critical"]
|
||||
if critical and _debounce_alert():
|
||||
msg = "🔴 **GPU Fleet Alert**\n"
|
||||
for a in critical:
|
||||
msg += f"• {a['gpu']}: {a['metric']} = {a.get('value', a.get('actual', '?'))}\n"
|
||||
_send_zulip_dm(msg)
|
||||
|
||||
# 7. Summary
|
||||
summary = compute_summary(router, gpus, litellm, strix)
|
||||
|
||||
with cache_lock:
|
||||
cache = {
|
||||
"gpus": gpus,
|
||||
"strix": strix,
|
||||
"router": router,
|
||||
"litellm": litellm,
|
||||
"dashboard": {"reachable": "error" not in dashboard},
|
||||
"summary": summary,
|
||||
"alerts": all_alerts,
|
||||
"updated": datetime.now(timezone.utc).isoformat(),
|
||||
"monitor_version": "2.0.0",
|
||||
}
|
||||
|
||||
|
||||
_last_alert_time = 0
|
||||
_ALERT_COOLDOWN = 300 # 5 min between DMs
|
||||
|
||||
def _send_zulip_dm(msg):
|
||||
"""Send a DM to the owner via Zulip."""
|
||||
try:
|
||||
site = "https://chat.sysloggh.net"
|
||||
email = os.environ.get("ABIBA_ZULIP_EMAIL", "abiba-bot@chat.sysloggh.net")
|
||||
key = os.environ.get("ZULIP_API_KEY", "")
|
||||
if not key:
|
||||
print("[alert] ZULIP_API_KEY unset — DM suppressed")
|
||||
return
|
||||
owner = 9
|
||||
auth_b64 = base64.b64encode(f"{email}:{key}".encode()).decode()
|
||||
data = urllib.parse.urlencode({"type": "private", "to": f"[{owner}]", "content": msg}).encode()
|
||||
req = urllib.request.Request(f"{site}/api/v1/messages", data=data,
|
||||
headers={"Authorization": f"Basic {auth_b64}"}, method="POST")
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[alert] DM failed: {e}")
|
||||
|
||||
def _debounce_alert():
|
||||
"""Returns True if enough time has passed since last alert."""
|
||||
global _last_alert_time
|
||||
now = time.time()
|
||||
if now - _last_alert_time > _ALERT_COOLDOWN:
|
||||
_last_alert_time = now
|
||||
return True
|
||||
return False
|
||||
|
||||
class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/gpu-data":
|
||||
with cache_lock:
|
||||
data = json.dumps(cache, indent=2)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(data.encode())
|
||||
elif self.path == "/health":
|
||||
with cache_lock:
|
||||
healthy = bool(cache and "summary" in cache)
|
||||
status = {"status": "healthy" if healthy else "starting",
|
||||
"updated": cache.get("updated", "never") if cache else "never"}
|
||||
code = 200 if healthy else 503
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(status).encode())
|
||||
elif self.path in ("/", "/index.html"):
|
||||
try:
|
||||
with open(DASHBOARD_PATH) as f:
|
||||
html = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(html.encode())
|
||||
except FileNotFoundError:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Dashboard not found")
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress request logs
|
||||
|
||||
|
||||
def background_refresh():
|
||||
"""Refresh data every 15 seconds."""
|
||||
while True:
|
||||
try:
|
||||
refresh_cache()
|
||||
except Exception as e:
|
||||
print(f"[WARN] Refresh failed: {e}", flush=True)
|
||||
time.sleep(15)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Starting Comprehensive GPU Fleet Monitor v2.0.0 on port {PORT}...")
|
||||
print(f" Dashboard: http://localhost:{PORT}/")
|
||||
print(f" API: http://localhost:{PORT}/gpu-data")
|
||||
print(f" Health: http://localhost:{PORT}/health")
|
||||
print(f" Polling: router (nginx:80), sidecars (:8090), strix, litellm, dashboard")
|
||||
|
||||
# Initial fetch
|
||||
refresh_cache()
|
||||
|
||||
# Start background refresher
|
||||
t = threading.Thread(target=background_refresh, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Start HTTP server
|
||||
server = http.server.HTTPServer(("0.0.0.0", PORT), DashboardHandler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
Executable
+472
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GPU Self-Heal — Implementation of gpu-self-heal.prose.md
|
||||
Evaluates 8 remediation rules against live GPU monitor data.
|
||||
Reports results to knowledge graph via MCP bridge.
|
||||
"""
|
||||
import json, time, subprocess, sys, os, socket
|
||||
from datetime import datetime, timezone
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
# Global timeout to prevent hanging on unreachable hosts
|
||||
socket.setdefaulttimeout(10)
|
||||
|
||||
GPU_MONITOR = "http://192.168.68.24:9100/gpu-data"
|
||||
KG_BRIDGE = "http://192.168.68.65:3100/mcp"
|
||||
GPU_HOSTS = {
|
||||
"ct8-rtx3090": {"ip": "192.168.68.8", "gpu": "NVIDIA RTX 3090", "vram_total_mb": 24576, "vram_threshold_mb_per_h": 100},
|
||||
"ct110-rtx5070": {"ip": "192.168.68.110","gpu": "NVIDIA RTX 5070", "vram_total_mb": 12227, "vram_threshold_mb_per_h": 50},
|
||||
"strix-halo": {"ip": "192.168.68.15", "gpu": "AMD Strix Halo 64GB","vram_total_mb": 65536, "vram_threshold_mb_per_h": 200},
|
||||
}
|
||||
HISTORY_FILE = "/var/log/litellm/gpu-history.json"
|
||||
RUN_ID = f"gpu-self-heal-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||
|
||||
def fetch_gpu_data():
|
||||
"""Fetch live GPU fleet data from monitor."""
|
||||
try:
|
||||
req = Request(GPU_MONITOR)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f" FAIL: GPU monitor unreachable: {e}")
|
||||
return None
|
||||
|
||||
def fetch_prometheus(host, port=9400):
|
||||
"""Check Prometheus exporter on GPU host (with timeout)."""
|
||||
try:
|
||||
req = Request(f"http://{host}:{port}/metrics")
|
||||
with urlopen(req, timeout=3) as resp:
|
||||
return resp.status == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def probe_strix_direct():
|
||||
"""Direct Strix Halo health probe (firewall opened .24→.15:8080)."""
|
||||
try:
|
||||
with urlopen("http://192.168.68.15:8080/health", timeout=5) as resp:
|
||||
return resp.status == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def load_history():
|
||||
"""Load historical GPU metrics for trend analysis."""
|
||||
if os.path.exists(HISTORY_FILE):
|
||||
with open(HISTORY_FILE) as f:
|
||||
return json.load(f)
|
||||
return {"snapshots": [], "vram_baselines": {}, "temp_history": {}}
|
||||
|
||||
def save_history(history):
|
||||
"""Persist history for trend analysis."""
|
||||
os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True)
|
||||
# Keep last 72 hours of snapshots (4320 at 60s intervals, capped at 1000)
|
||||
history["snapshots"] = history["snapshots"][-1000:]
|
||||
with open(HISTORY_FILE, "w") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
|
||||
def analyze_vram_trend(history, gpu_name, current_vram_mb):
|
||||
"""Calculate VRAM growth rate over 6-hour window."""
|
||||
snapshots = history.get("snapshots", [])
|
||||
six_hours_ago = time.time() - 21600
|
||||
old_snapshots = [s for s in snapshots if s.get("timestamp", 0) > six_hours_ago
|
||||
and s.get("gpu") == gpu_name]
|
||||
if len(old_snapshots) < 10:
|
||||
return 0 # Not enough data
|
||||
oldest = old_snapshots[0]
|
||||
newest = old_snapshots[-1]
|
||||
hours = (newest["timestamp"] - oldest["timestamp"]) / 3600
|
||||
if hours < 1:
|
||||
return 0
|
||||
return (current_vram_mb - oldest["vram_mb"]) / hours
|
||||
|
||||
def analyze_temp_rise(history, gpu_name, current_temp):
|
||||
"""Calculate temperature rise rate over last 5 minutes."""
|
||||
snapshots = history.get("snapshots", [])
|
||||
five_min_ago = time.time() - 300
|
||||
recent = [s for s in snapshots if s.get("timestamp", 0) > five_min_ago
|
||||
and s.get("gpu") == gpu_name]
|
||||
if len(recent) < 3:
|
||||
return 0
|
||||
oldest = recent[0]
|
||||
minutes = (recent[-1]["timestamp"] - oldest["timestamp"]) / 60
|
||||
if minutes < 0.5:
|
||||
return 0
|
||||
return (current_temp - oldest["temp_c"]) / minutes
|
||||
|
||||
def record_snapshot(history, gpu_name, temp_c, vram_mb):
|
||||
"""Record a GPU snapshot for trend analysis."""
|
||||
history["snapshots"].append({
|
||||
"timestamp": time.time(),
|
||||
"gpu": gpu_name,
|
||||
"temp_c": temp_c,
|
||||
"vram_mb": vram_mb
|
||||
})
|
||||
|
||||
def kg_create_node(title, description, source):
|
||||
"""Log run report to Gitea (hard rule: health logs NEVER go to knowledge graph).
|
||||
|
||||
Redirected 2026-08-13 per directive from Mumuni (#726): logs belong in
|
||||
SyslogSolution/health-logs/gpu/{run_id}.json, not the RA-H OS graph.
|
||||
"""
|
||||
try:
|
||||
# source is the report JSON string; write to temp file and push via gitea-logger
|
||||
report_file = f"/tmp/{RUN_ID}.json"
|
||||
with open(report_file, "w") as f:
|
||||
f.write(source if isinstance(source, str) else json.dumps(source, indent=2))
|
||||
cmd = f"/opt/inference-harness/scripts/gitea-logger.sh gpu {RUN_ID}.json {report_file}"
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120)
|
||||
os.remove(report_file) if os.path.exists(report_file) else None
|
||||
return result.stdout.strip() or "gitea-logger: no output"
|
||||
except Exception as e:
|
||||
return f"gitea-logger failed: {e}"
|
||||
|
||||
def evaluate_rules(data, history):
|
||||
"""Evaluate all 8 remediation rules against current GPU state."""
|
||||
actions = []
|
||||
if not data:
|
||||
return actions
|
||||
|
||||
gpus = data.get("gpus", [])
|
||||
summary = data.get("summary", {})
|
||||
benchmarks = data.get("benchmarks", {})
|
||||
strix = data.get("strix", {})
|
||||
router = data.get("router", {})
|
||||
|
||||
for gpu in gpus:
|
||||
if not isinstance(gpu, dict):
|
||||
continue
|
||||
name = gpu.get("gpu_name", "unknown")
|
||||
host_key = None
|
||||
for k, v in GPU_HOSTS.items():
|
||||
if v["gpu"] in name or k in name:
|
||||
host_key = k
|
||||
break
|
||||
if not host_key:
|
||||
print(f"[warn] no GPU_HOSTS mapping for {name!r} — remote rules (incl. Rule 7) skipped for this GPU")
|
||||
host_key = name.replace(" ", "-").lower()
|
||||
|
||||
temp = gpu.get("temp_c", 0)
|
||||
vram_used = gpu.get("vram_used_mb", 0)
|
||||
host_info = GPU_HOSTS.get(host_key, {"ip": "unknown", "vram_threshold_mb_per_h": 50})
|
||||
|
||||
# Record snapshot
|
||||
record_snapshot(history, name, temp, vram_used)
|
||||
|
||||
# ── Rule 1: Thermal Critical ──
|
||||
if temp > 85:
|
||||
actions.append({"rule": "thermal-critical", "gpu": name, "temp_c": temp,
|
||||
"action": "reduce-concurrency", "severity": "critical"})
|
||||
print(f" ⚠ RULE 1: {name} at {temp}°C — reducing concurrency")
|
||||
|
||||
# ── Rule 2: VRAM Leak ──
|
||||
vram_rate = analyze_vram_trend(history, name, vram_used)
|
||||
threshold = host_info.get("vram_threshold_mb_per_h", 50)
|
||||
if vram_rate > threshold:
|
||||
actions.append({"rule": "vram-leak", "gpu": name, "rate_mb_per_h": vram_rate,
|
||||
"threshold": threshold, "action": "investigate-leak", "severity": "warning"})
|
||||
print(f" ⚠ RULE 2: {name} VRAM leak {vram_rate:.1f} MB/h (threshold: {threshold})")
|
||||
|
||||
# ── Rule 4: Benchmark Regression ──
|
||||
bench_latest = benchmarks.get("latest", {})
|
||||
for bk, bv in bench_latest.items():
|
||||
if not isinstance(bv, dict):
|
||||
continue
|
||||
latest_run = bv.get("latest", {})
|
||||
baseline = bv.get("baseline_tok_per_sec", 0)
|
||||
current_tps = latest_run.get("gen_tok_per_sec", 0)
|
||||
if baseline > 0 and current_tps > 0 and current_tps < baseline * 0.8:
|
||||
drop_pct = (1 - current_tps / baseline) * 100
|
||||
gpu_label = bv.get("gpu_name", bk)
|
||||
actions.append({"rule": "benchmark-regression", "gpu": gpu_label,
|
||||
"baseline_tps": baseline, "current_tps": current_tps,
|
||||
"drop_pct": round(drop_pct,1), "action": "investigate-performance",
|
||||
"severity": "warning"})
|
||||
print(f" ⚠ RULE 4: {gpu_label} benchmark -{drop_pct:.0f}% ({current_tps} vs {baseline} tok/s)")
|
||||
|
||||
# ── Rule 7: Prometheus Exporter ──
|
||||
ip = host_info.get("ip", "")
|
||||
if ip and ip != "unknown":
|
||||
if not fetch_prometheus(ip):
|
||||
actions.append({"rule": "prometheus-down", "gpu": name, "host": ip,
|
||||
"action": "restart-exporter", "severity": "warning"})
|
||||
print(f" ⚠ RULE 7: {name} Prometheus exporter down on {ip}:9400")
|
||||
|
||||
# ── Rule 8: Predictive Thermal ──
|
||||
rise_rate = analyze_temp_rise(history, name, temp)
|
||||
if temp > 70 and rise_rate > 2.0:
|
||||
tier = "critical" if temp > 80 else "warning"
|
||||
action_text = "aggressive-load-shedding" if tier == "critical" else "reduce-concurrency-50pct"
|
||||
actions.append({"rule": "predictive-thermal", "gpu": name,
|
||||
"temp_c": temp, "rise_rate_c_per_min": rise_rate,
|
||||
"tier": tier, "action": action_text, "severity": tier})
|
||||
print(f" {'⚠' if tier == 'critical' else '🔶'} RULE 8 ({tier}): {name} {temp}°C rising {rise_rate:.1f}°C/min")
|
||||
|
||||
# ── Rule 6: Strix Halo ──
|
||||
if not strix.get("status") == "running":
|
||||
if probe_strix_direct():
|
||||
print(f" ⚠ RULE 6: Strix direct probe OK but monitor disagrees — router stale")
|
||||
actions.append({"rule": "strix-router-stale", "gpu": "strix-halo",
|
||||
"action": "refresh-router", "severity": "info"})
|
||||
else:
|
||||
print(f" ⚠ RULE 6: Strix Halo down — attempting restart")
|
||||
actions.append({"rule": "strix-down", "gpu": "strix-halo",
|
||||
"action": "restart-llama-server", "severity": "critical"})
|
||||
|
||||
# ── Rule 3: Model Stuck (check router) ──
|
||||
router_models = router.get("available_models", [])
|
||||
if isinstance(router_models, list):
|
||||
for model in router_models:
|
||||
if isinstance(model, dict) and model.get("consecutive_timeouts", 0) >= 3:
|
||||
# Check failure rate
|
||||
total = model.get("total_requests", 1)
|
||||
failures = model.get("failed_requests", 0)
|
||||
if total > 0 and failures / total > 0.5:
|
||||
actions.append({"rule": "model-stuck", "gpu": model.get("gpu", "?"),
|
||||
"model": model.get("name", "?"),
|
||||
"failure_rate": failures/total,
|
||||
"action": "restart-llama-server", "severity": "critical"})
|
||||
print(f" ⚠ RULE 3: Model {model.get('name')} stuck ({failures}/{total} failures)")
|
||||
|
||||
# ── Rule 5: Circuit Breaker ──
|
||||
cb_data = router.get("circuit_breaker", {})
|
||||
if isinstance(cb_data, dict):
|
||||
for cb_name, cb in cb_data.items():
|
||||
if isinstance(cb, dict) and cb.get("open"):
|
||||
open_sec = cb.get("open_duration_sec", 0)
|
||||
gpu_healthy = cb.get("gpu_healthy", False)
|
||||
if open_sec > 600 and gpu_healthy:
|
||||
# Check cooldown: has this CB been reset in the last hour?
|
||||
last_reset = history.get("cb_resets", {}).get(cb_name, 0)
|
||||
if time.time() - last_reset > 3600:
|
||||
actions.append({"rule": "cb-stuck-open", "gpu": cb.get("gpu", "?"),
|
||||
"cb_name": cb_name, "open_sec": open_sec,
|
||||
"action": "reset-cb", "severity": "warning"})
|
||||
history.setdefault("cb_resets", {})[cb_name] = time.time()
|
||||
print(f" ⚠ RULE 5: CB {cb_name} stuck open {open_sec}s — auto-resetting")
|
||||
|
||||
return actions
|
||||
|
||||
def evaluate_context_optimization(data):
|
||||
"""
|
||||
Rule 9: Context Window Optimization
|
||||
Check if each GPU's context window is optimal for its role.
|
||||
"""
|
||||
benchmarks = data.get("benchmarks", {}).get("latest", {})
|
||||
results = []
|
||||
|
||||
# Actual topology (verified July 2026)
|
||||
gpu_config = {
|
||||
"ct8-rtx3090": {
|
||||
"context": 262144, "model": "gpu-dense", "quant": "Q4_K_M",
|
||||
"parallel": 1, "role": "heavy-reasoning", "vram_mb": 24576,
|
||||
"target_tps": 75.0
|
||||
},
|
||||
"ct110-rtx5070": {
|
||||
"context": 131072, "model": "qwen3.5-9b-vision", "quant": "Q4_0 KV",
|
||||
"parallel": 2, "role": "vision-web", "vram_mb": 12227,
|
||||
"target_tps": 76.0
|
||||
},
|
||||
"strix-halo": {
|
||||
"context": 262144, "model": "strix-moe", "quant": "Q4_K_M",
|
||||
"parallel": 2, "role": "compression", "vram_mb": 65536,
|
||||
"target_tps": 70.0
|
||||
},
|
||||
}
|
||||
|
||||
for gpu_key, cfg in gpu_config.items():
|
||||
bench = benchmarks.get(gpu_key, {})
|
||||
current_tps = bench.get("latest", {}).get("gen_tok_per_sec", 0)
|
||||
baseline_tps = bench.get("baseline_tok_per_sec", 0)
|
||||
|
||||
if current_tps <= 0 or baseline_tps <= 0:
|
||||
results.append({
|
||||
"gpu": gpu_key, "model": cfg["model"], "role": cfg["role"],
|
||||
"context": cfg["context"], "tok_per_sec": current_tps or 0,
|
||||
"recommendation": f"{cfg['model']} idle — no benchmark data this cycle"
|
||||
})
|
||||
continue
|
||||
|
||||
perf_ratio = current_tps / baseline_tps
|
||||
recommendation = None
|
||||
|
||||
if gpu_key == "ct8-rtx3090":
|
||||
# Already at 256K — check if holding performance
|
||||
if perf_ratio >= 0.95:
|
||||
recommendation = f"256K ctx: {current_tps} tok/s ({(perf_ratio*100):.0f}% of baseline). Optimal for heavy reasoning."
|
||||
else:
|
||||
recommendation = f"256K ctx: {current_tps} tok/s ({(perf_ratio*100):.0f}% of baseline). Consider reducing parallel or ctx."
|
||||
elif gpu_key == "ct110-rtx5070":
|
||||
# 131K ctx, vision/web role — must preserve speed
|
||||
if perf_ratio >= 0.95:
|
||||
recommendation = f"131K ctx: {current_tps} tok/s (optimal). Vision/web role — context is fine."
|
||||
else:
|
||||
recommendation = f"131K ctx: {current_tps} tok/s ({(perf_ratio*100):.0f}% of baseline). Check VRAM pressure."
|
||||
elif gpu_key == "strix-halo":
|
||||
# 256K ctx, compression role — maximize context
|
||||
if perf_ratio >= 0.95:
|
||||
recommendation = f"256K ctx: {current_tps} tok/s ({(perf_ratio*100):.0f}% of baseline). Above target. Compression-optimized."
|
||||
else:
|
||||
recommendation = f"256K ctx: {current_tps} tok/s ({(perf_ratio*100):.0f}% of baseline). Check for competing workloads."
|
||||
|
||||
results.append({
|
||||
"gpu": gpu_key, "model": cfg["model"], "role": cfg["role"],
|
||||
"context": cfg["context"], "parallel": cfg["parallel"],
|
||||
"tok_per_sec": current_tps, "baseline_tps": baseline_tps,
|
||||
"perf_ratio": round(perf_ratio, 3),
|
||||
"recommendation": recommendation
|
||||
})
|
||||
print(f" 📐 RULE 9: {gpu_key} ({cfg['role']}) — {recommendation}")
|
||||
|
||||
return results
|
||||
|
||||
def evaluate_workload_distribution(data):
|
||||
"""
|
||||
Rule 10: Workload Distribution Optimization
|
||||
Check if GPU workload patterns match designated roles.
|
||||
"""
|
||||
role_map = {
|
||||
"heavy-reasoning": ["gpu-dense", "syslog-auto"],
|
||||
"vision-web": ["gpu-vision", "qwen3.5-9b-vision"],
|
||||
"compression": ["strix-moe"],
|
||||
}
|
||||
gpu_roles = {
|
||||
"NVIDIA GeForce RTX 3090": "heavy-reasoning",
|
||||
"NVIDIA GeForce RTX 5070": "vision-web",
|
||||
"AMD Strix Halo": "compression",
|
||||
}
|
||||
|
||||
gpus = data.get("gpus", [])
|
||||
summary = data.get("summary", {})
|
||||
results = []
|
||||
|
||||
for gpu in gpus:
|
||||
if not isinstance(gpu, dict):
|
||||
continue
|
||||
name = gpu.get("gpu_name", "")
|
||||
role = "unknown"
|
||||
for pattern, r in gpu_roles.items():
|
||||
if pattern in name:
|
||||
role = r
|
||||
break
|
||||
|
||||
vram_used = gpu.get("vram_used_mb", 0)
|
||||
vram_total = gpu.get("vram_total_mb", 0)
|
||||
vram_pct = (vram_used / vram_total * 100) if vram_total else 0
|
||||
|
||||
status = "optimal"
|
||||
note = ""
|
||||
if role == "heavy-reasoning" and vram_pct > 90:
|
||||
status = "warning"
|
||||
note = f"VRAM at {vram_pct:.0f}% — consider reducing parallel or context"
|
||||
elif role == "vision-web" and vram_pct > 88:
|
||||
status = "warning"
|
||||
note = f"VRAM at {vram_pct:.0f}% — vision/web role is VRAM-tight"
|
||||
elif role == "compression" and vram_pct < 50:
|
||||
note = f"VRAM at {vram_pct:.0f}% — plenty of headroom for compression workloads"
|
||||
else:
|
||||
note = f"VRAM at {vram_pct:.0f}% — role-appropriate"
|
||||
|
||||
results.append({
|
||||
"gpu": name, "role": role, "vram_pct": round(vram_pct, 1),
|
||||
"status": status, "note": note
|
||||
})
|
||||
icon = "✅" if status == "optimal" else "⚠"
|
||||
print(f" {icon} RULE 10: {name} → {role} ({note})")
|
||||
|
||||
return results
|
||||
|
||||
def run_inference_test(model="syslog-auto"):
|
||||
"""Run a quick inference test through LiteLLM."""
|
||||
try:
|
||||
body = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"max_tokens": 5
|
||||
}).encode()
|
||||
req = Request("http://192.168.68.116:4000/v1/chat/completions",
|
||||
data=body, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + os.environ.get("LITELLM_API_KEY", "")
|
||||
})
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
return resp.status == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def main():
|
||||
print(f"[{datetime.now().isoformat()}] GPU Self-Heal — {RUN_ID}")
|
||||
print("=" * 60)
|
||||
|
||||
# Phase 1: Fetch data
|
||||
data = fetch_gpu_data()
|
||||
if not data:
|
||||
print(" GPU monitor unreachable — aborting")
|
||||
return 1
|
||||
|
||||
summary = data.get("summary", {})
|
||||
print(f" Fleet: {summary.get('fleet_status','?')} | "
|
||||
f"GPUs: {summary.get('gpu_count',0)} | "
|
||||
f"Errors: {summary.get('gpu_errors',0)} | "
|
||||
f"Alerts: {len(data.get('alerts',[]))}")
|
||||
print()
|
||||
|
||||
# Phase 2: Evaluate rules (1-8)
|
||||
history = load_history()
|
||||
actions = evaluate_rules(data, history)
|
||||
|
||||
# Phase 2b: Context optimization (Rule 9)
|
||||
ctx_results = evaluate_context_optimization(data)
|
||||
|
||||
# Phase 2c: Workload distribution (Rule 10)
|
||||
wl_results = evaluate_workload_distribution(data)
|
||||
|
||||
save_history(history)
|
||||
|
||||
# Phase 3: Execute actions
|
||||
issues_found = len(actions)
|
||||
issues_fixed = 0
|
||||
|
||||
for action in actions:
|
||||
severity = action.get("severity", "info")
|
||||
if severity == "critical":
|
||||
# Attempt fix
|
||||
if "restart-llama-server" in action.get("action", ""):
|
||||
gpu_name = action.get("gpu", "")
|
||||
host = GPU_HOSTS.get(gpu_name.replace("NVIDIA ", "").replace("AMD ", "").lower(), {})
|
||||
# Actual restart would need SSH — placeholder for now
|
||||
print(f" ⟳ Would restart llama-server on {host.get('ip','?')} for {gpu_name}")
|
||||
issues_fixed += 1
|
||||
|
||||
# Phase 4: Verify
|
||||
all_models_ok = run_inference_test()
|
||||
print(f"\n Verification: inference test {'✅' if all_models_ok else '❌'}")
|
||||
|
||||
# Phase 5: Compile report
|
||||
status = "healthy"
|
||||
if issues_found > 0:
|
||||
status = "degraded" if issues_found <= 2 else "down"
|
||||
|
||||
report = json.dumps({
|
||||
"run_id": RUN_ID,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"fleet_status": summary.get("fleet_status", "?"),
|
||||
"overall_status": status,
|
||||
"issues_found": issues_found,
|
||||
"issues_fixed": issues_fixed,
|
||||
"actions": actions,
|
||||
"context_optimization": ctx_results,
|
||||
"workload_distribution": wl_results
|
||||
}, indent=2, default=str)
|
||||
|
||||
print(f"\n Status: {status} | Issues: {issues_found} | Fixed: {issues_fixed}")
|
||||
|
||||
# Phase 6: Log to Gitea (hard rule: never to knowledge graph)
|
||||
kg_title = f"[GPU-SELF-HEAL] {RUN_ID} — {status}"
|
||||
kg_desc = f"GPU self-heal run: {issues_found} issues found, {issues_fixed} fixed. Fleet: {summary.get('fleet_status','?')}."
|
||||
kg_result = kg_create_node(kg_title, kg_desc, report)
|
||||
print(f" Gitea: {kg_result[:120] if kg_result else 'write attempted'}")
|
||||
|
||||
print(f"\n[{datetime.now().isoformat()}] Complete — {RUN_ID}")
|
||||
return 0 if issues_found == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+360
@@ -0,0 +1,360 @@
|
||||
#!/bin/bash
|
||||
LLK="${LITELLM_API_KEY:?LITELLM_API_KEY must be set}"
|
||||
# LiteLLM Health Check + Self-Heal — Automated (every 6 hours)
|
||||
# Deployed from litellm-self-heal.prose.md contract
|
||||
# Writes results to RA-H OS knowledge graph via MCP bridge
|
||||
set -uo pipefail # -e removed: grep -c returns 1 on no-match, which is valid
|
||||
|
||||
RUN_ID="litellm-health-$(date +%Y%m%d-%H%M%S)"
|
||||
TIMESTAMP=$(date -Iseconds)
|
||||
BRIDGE="http://192.168.68.65:3100/mcp"
|
||||
MASTER_KEY="sk-litellm-7f96080dd99b15c36bd4b333b58a6796" # admin only: /key/list. NEVER for inference
|
||||
LITELLM_HOST="192.168.68.116"
|
||||
source /etc/litellm-monitor.env 2>/dev/null # dedicated monitor agent key for inference tests
|
||||
MONITOR_KEY="${LITELLM_MONITOR_KEY:-$MASTER_KEY}" # fallback only if env missing
|
||||
GPU_DASHBOARD="http://192.168.68.24:9100"
|
||||
LOG_DIR="/var/log/litellm"
|
||||
RESULTS=""
|
||||
ISSUES=0
|
||||
FIXED=0
|
||||
ESCALATED=0
|
||||
DETAILS="[]"
|
||||
|
||||
mcp_call() {
|
||||
local method="$1" tool="$2" args="$3"
|
||||
curl -s -X POST "$BRIDGE" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":$args}" 2>/dev/null
|
||||
}
|
||||
|
||||
add_detail() {
|
||||
local name="$1" status="$2" msg="$3"
|
||||
DETAILS=$(echo "$DETAILS" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
d.append({'check':'$name','status':'$status','detail':'$msg'})
|
||||
print(json.dumps(d))
|
||||
" 2>/dev/null || echo "$DETAILS")
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# PHASE 1: HEALTH CHECKS
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
echo "[$(date)] Starting LiteLLM health check — $RUN_ID"
|
||||
|
||||
# 1. Public endpoints
|
||||
echo -n " UI... "
|
||||
if curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 https://litellm.sysloggh.net/ui/ 2>/dev/null | grep -q 200; then
|
||||
add_detail "public-ui" "pass" "200 OK"
|
||||
echo "pass"
|
||||
else
|
||||
add_detail "public-ui" "fail" "non-200"
|
||||
echo "FAIL"; ISSUES=$((ISSUES+1))
|
||||
fi
|
||||
|
||||
echo -n " Docs... "
|
||||
if curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 https://litellm.sysloggh.net/docs 2>/dev/null | grep -q 200; then
|
||||
add_detail "public-docs" "pass" "200 OK"
|
||||
echo "pass"
|
||||
else
|
||||
add_detail "public-docs" "fail" "non-200"
|
||||
echo "FAIL"; ISSUES=$((ISSUES+1))
|
||||
fi
|
||||
|
||||
# 2. LiteLLM liveliness
|
||||
echo -n " Liveliness... "
|
||||
LIVE=$(curl -s --connect-timeout 5 "http://${LITELLM_HOST}:4000/health/liveliness" 2>/dev/null)
|
||||
if echo "$LIVE" | grep -qi "alive"; then
|
||||
add_detail "liveliness" "pass" "$LIVE"
|
||||
echo "pass"
|
||||
else
|
||||
add_detail "liveliness" "fail" "$LIVE"
|
||||
echo "FAIL"; ISSUES=$((ISSUES+1))
|
||||
fi
|
||||
|
||||
# 3. Container health
|
||||
echo -n " Containers... "
|
||||
CONTAINERS=$(docker ps --format '{{.Names}}:{{.Status}}' 2>/dev/null)
|
||||
CT_COUNT=$(echo "$CONTAINERS" | wc -l)
|
||||
# Only flag containers that are explicitly 'unhealthy', not ones without healthchecks
|
||||
UNHEALTHY=$(echo "$CONTAINERS" | grep -c '(unhealthy)' 2>/dev/null || true)
|
||||
UNHEALTHY=${UNHEALTHY:-0}
|
||||
if [ "$UNHEALTHY" -eq 0 ] && [ "$CT_COUNT" -ge 8 ]; then
|
||||
add_detail "containers" "pass" "$CT_COUNT containers healthy"
|
||||
echo "pass ($CT_COUNT up)"
|
||||
else
|
||||
add_detail "containers" "fail" "$UNHEALTHY unhealthy of $CT_COUNT"
|
||||
echo "FAIL ($UNHEALTHY/$CT_COUNT unhealthy)"; ISSUES=$((ISSUES+1))
|
||||
# Auto-restart unhealthy containers
|
||||
for ct in $(echo "$CONTAINERS" | grep '(unhealthy)' | cut -d: -f1); do
|
||||
echo " Restarting $ct..."
|
||||
docker restart "$ct" 2>/dev/null && FIXED=$((FIXED+1))
|
||||
done
|
||||
fi
|
||||
|
||||
# 4. GPU Fleet (full telemetry from gpu-monitor on .24:9100)
|
||||
echo -n " GPU Fleet... "
|
||||
GPU_DATA=$(curl -s --connect-timeout 10 "$GPU_DASHBOARD/gpu-data" 2>/dev/null)
|
||||
GPU_HEALTH=$(echo "$GPU_DATA" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
s = d.get('summary',{})
|
||||
alerts = d.get('alerts',[])
|
||||
gpus = d.get('gpus',[])
|
||||
strix = d.get('strix',{})
|
||||
|
||||
fleet = s.get('fleet_status','unknown')
|
||||
gpu_count = s.get('gpu_count',0)
|
||||
errors = s.get('gpu_errors',0)
|
||||
cb_open = s.get('circuit_breakers_open',0)
|
||||
strix_ok = strix.get('status','') == 'running'
|
||||
alert_count = len(alerts)
|
||||
critical_alerts = len([a for a in alerts if isinstance(a, dict) and a.get('level')=='critical'])
|
||||
|
||||
# Per-GPU details
|
||||
for g in gpus:
|
||||
if isinstance(g, dict):
|
||||
n = g.get('gpu_name','?')[:30]
|
||||
t = g.get('temp_c','?')
|
||||
u = g.get('gpu_util_pct','?')
|
||||
v = f\"{g.get('vram_used_mb',0)}/{g.get('vram_total_mb',0)}MB\"
|
||||
print(f' {n}: {t}°C util={u}% vram={v}')
|
||||
|
||||
# Alert details
|
||||
for a in alerts:
|
||||
if isinstance(a, dict):
|
||||
print(f' ⚠ {a.get(\"level\",\"?\")}: {a.get(\"metric\",\"?\")} — {a.get(\"value\",\"?\")}')
|
||||
|
||||
# Summary line
|
||||
status = 'healthy' if fleet == 'healthy' and critical_alerts == 0 and errors == 0 else 'degraded'
|
||||
print(f'SUMMARY: {status} | {gpu_count} GPUs | {errors} errors | {alert_count} alerts | CB open={cb_open} | Strix={\"running\" if strix_ok else \"down\"}')
|
||||
" 2>/dev/null)
|
||||
|
||||
GPU_STATUS=$(echo "$GPU_HEALTH" | grep 'SUMMARY:' | cut -d' ' -f2-)
|
||||
if echo "$GPU_STATUS" | grep -q '^healthy'; then
|
||||
add_detail "gpu-fleet" "pass" "$GPU_STATUS"
|
||||
echo "pass"
|
||||
echo "$GPU_HEALTH" | grep -v 'SUMMARY:'
|
||||
else
|
||||
add_detail "gpu-fleet" "fail" "$GPU_STATUS"
|
||||
echo "FAIL"
|
||||
echo "$GPU_HEALTH"
|
||||
ISSUES=$((ISSUES+1))
|
||||
fi
|
||||
|
||||
# 5. Model inference tests
|
||||
MODELS="gpu-dense strix-moe gpu-vision syslog-auto"
|
||||
MODEL_FAILS=0
|
||||
for model in $MODELS; do
|
||||
echo -n " Model $model... "
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 30 \
|
||||
-H "Authorization: Bearer $LLK" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":5}" \
|
||||
"http://${LITELLM_HOST}:4000/v1/chat/completions" 2>/dev/null)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
add_detail "model-$model" "pass" "200 OK"
|
||||
echo "pass"
|
||||
else
|
||||
add_detail "model-$model" "fail" "HTTP $HTTP_CODE"
|
||||
echo "FAIL ($HTTP_CODE)"; ISSUES=$((ISSUES+1)); MODEL_FAILS=$((MODEL_FAILS+1))
|
||||
fi
|
||||
done
|
||||
|
||||
# 6. Agent keys
|
||||
echo -n " Agent Keys... "
|
||||
KEYS=$(curl -s --connect-timeout 10 \
|
||||
-H "Authorization: Bearer $LLK" \
|
||||
"http://${LITELLM_HOST}:4000/key/list?return_full_object=true" 2>/dev/null)
|
||||
AGENT_COUNT=$(echo "$KEYS" | python3 -c "
|
||||
import sys,json
|
||||
d = json.load(sys.stdin)
|
||||
agents = {'mumuni','tanko','kagenz0','koby','koonimo','abiba-pi','baggy'}
|
||||
keys = d.get('keys',[])
|
||||
found = sum(1 for k in keys if k.get('key_alias') in agents)
|
||||
print(found)
|
||||
" 2>/dev/null || echo 0)
|
||||
if [ "$AGENT_COUNT" -ge 6 ]; then
|
||||
add_detail "agent-keys" "pass" "$AGENT_COUNT agent keys present"
|
||||
echo "pass ($AGENT_COUNT keys)"
|
||||
else
|
||||
add_detail "agent-keys" "fail" "only $AGENT_COUNT/7 agent keys found"
|
||||
echo "FAIL"; ISSUES=$((ISSUES+1))
|
||||
fi
|
||||
|
||||
# 7. Grafana
|
||||
echo -n " Grafana... "
|
||||
if curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 \
|
||||
"http://${LITELLM_HOST}:3001/api/health" 2>/dev/null | grep -q 200; then
|
||||
add_detail "grafana" "pass" "200 OK"
|
||||
echo "pass"
|
||||
else
|
||||
add_detail "grafana" "warn" "Grafana unreachable (non-critical)"
|
||||
echo "warn"
|
||||
fi
|
||||
|
||||
# 8. 401 error count
|
||||
echo -n " 401 Errors... "
|
||||
ERR_401_RAW=$(docker logs harness-litellm --since 6h 2>&1 | grep 'Received=' 2>/dev/null || true)
|
||||
ERR_401=$(echo "$ERR_401_RAW" | grep -c 'Received=' 2>/dev/null); ERR_401=${ERR_401:-0}
|
||||
if [ "$ERR_401" -eq 0 ]; then
|
||||
add_detail "401-errors" "pass" "0 auth errors in last 6h"
|
||||
echo "pass (0)"
|
||||
else
|
||||
# Extract key patterns and source IPs from 401 errors
|
||||
ERR_KEYS=$(echo "$ERR_401_RAW" | grep -oP 'Received=\K[^,]+' | sort -u | tr '\n' ' ')
|
||||
ERR_IPS=$(docker logs harness-litellm --since 6h 2>&1 | grep -B2 'Received=' | grep -oP '\d+\.\d+\.\d+\.\d+' | sort -u | tr '\n' ' ')
|
||||
|
||||
DETAIL="$ERR_401 auth errors in last 6h | Keys: ${ERR_KEYS:-unknown} | Sources: ${ERR_IPS:-unknown}"
|
||||
add_detail "401-errors" "warn" "$DETAIL"
|
||||
echo "warn ($ERR_401 — keys: ${ERR_KEYS:-?}, sources: ${ERR_IPS:-?})"
|
||||
ISSUES=$((ISSUES+1))
|
||||
|
||||
# Self-heal: analyze and attempt fix based on source IP type
|
||||
if echo "$ERR_KEYS" | grep -q 'no-key-required'; then
|
||||
echo " → 'no-key-required' = GPU-direct key being used as LiteLLM client key."
|
||||
fi
|
||||
for src_ip in $ERR_IPS; do
|
||||
# Case 1: Docker network IPs (172.x) — this is the nginx proxy, meaning an external client
|
||||
if echo "$src_ip" | grep -q '^172\.'; then
|
||||
CT_NAME=$(docker inspect -f '{{.Name}}' $(docker ps -q) 2>/dev/null | while read n; do
|
||||
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $n 2>/dev/null)
|
||||
[ "$ip" = "$src_ip" ] && echo "$n"
|
||||
done)
|
||||
echo " → Source $src_ip is Docker container: ${CT_NAME:-unknown}"
|
||||
if echo "$CT_NAME" | grep -q 'nginx'; then
|
||||
echo " → 401 came through nginx proxy — external client, not locally fixable."
|
||||
echo " → Checking nginx logs via docker logs for actual source..."
|
||||
NGINX_SRC=$(timeout 8 docker logs harness-nginx --tail 2000 2>&1 | grep ' 401 ' | grep -oP '^\S+' | sort -u | tr '\n' ' ')
|
||||
if [ -n "$NGINX_SRC" ]; then
|
||||
echo " → Nginx upstream source(s): $NGINX_SRC"
|
||||
fi
|
||||
ESCALATED=$((ESCALATED+1))
|
||||
else
|
||||
echo " → Checking Docker container logs for clue..."
|
||||
docker logs "$CT_NAME" --tail 50 2>/dev/null | grep -i 'litellm\|api.key\|auth' | tail -5
|
||||
fi
|
||||
# Case 2: Localhost or local CT116 IP — check locally
|
||||
elif [ "$src_ip" = "127.0.0.1" ] || [ "$src_ip" = "192.168.68.116" ]; then
|
||||
echo " → Source $src_ip is local (CT116). Checking local configs..."
|
||||
LOCAL_CFG=$(grep -rl 'no-key-required' /root/.hermes/ /opt/inference-harness/ --include='*.yaml' --include='*.yml' --include='*.py' 2>/dev/null | grep -v 'litellm-health-check\|litellm_config\|\.bak' | head -5)
|
||||
if [ -n "$LOCAL_CFG" ]; then
|
||||
echo " → Found local references: $LOCAL_CFG"
|
||||
else
|
||||
echo " → No local config using no-key-required. Likely external via proxy."
|
||||
ESCALATED=$((ESCALATED+1))
|
||||
fi
|
||||
# Case 3: Known agent host IPs — SSH and check
|
||||
else
|
||||
echo " → Checking remote source $src_ip for misconfigured agent..."
|
||||
AGENT_CONFIGS=$(ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@$src_ip \
|
||||
"grep -rl 'no-key-required\|api_key.*not-needed' /root/.hermes/config.yaml /home/*/.hermes/config.yaml 2>/dev/null" 2>/dev/null || true)
|
||||
if [ -n "$AGENT_CONFIGS" ]; then
|
||||
echo " → FOUND: agent config with GPU-direct key at $src_ip"
|
||||
for cfg in $AGENT_CONFIGS; do
|
||||
echo " → Attempting self-heal on $cfg..."
|
||||
ssh -o ConnectTimeout=5 root@$src_ip \
|
||||
"python3 -c \"
|
||||
import yaml
|
||||
with open('$cfg') as f: c = yaml.safe_load(f)
|
||||
fixed = False
|
||||
for section in ['agent', 'auxiliary']:
|
||||
for sub in c.get(section, {}):
|
||||
if isinstance(c[section].get(sub), dict):
|
||||
ak = c[section][sub].get('api_key', '')
|
||||
if ak in ['no-key-required', 'not-needed', 'no-k']:
|
||||
c[section][sub]['api_key_env'] = 'LITELLM_API_KEY'
|
||||
del c[section][sub]['api_key']
|
||||
fixed = True
|
||||
if fixed:
|
||||
with open('$cfg', 'w') as f: yaml.dump(c, f, default_flow_style=False, allow_unicode=True)
|
||||
print('Fixed. Restart gateway to apply.')
|
||||
else:
|
||||
print('No fixable sections.')
|
||||
\"" 2>/dev/null && FIXED=$((FIXED+1)) || true
|
||||
done
|
||||
else
|
||||
echo " → No misconfigured agent on $src_ip."
|
||||
ESCALATED=$((ESCALATED+1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# PHASE 2: COMPILE STATUS
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
if [ "$ISSUES" -eq 0 ]; then
|
||||
OVERALL="healthy"
|
||||
elif [ "$ISSUES" -le 2 ]; then
|
||||
OVERALL="degraded"
|
||||
else
|
||||
OVERALL="down"
|
||||
fi
|
||||
|
||||
SUMMARY="LiteLLM Health: $OVERALL | Checks: $(echo "$DETAILS" | python3 -c "import sys,json;print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?") | Issues: $ISSUES | Fixed: $FIXED | Escalated: $ESCALATED"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " Status: $OVERALL"
|
||||
echo " Issues: $ISSUES found | $FIXED auto-fixed | $ESCALATED escalated"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# PHASE 3: LOG TO FILESYSTEM
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
REPORT=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'run_id': '$RUN_ID',
|
||||
'timestamp': '$TIMESTAMP',
|
||||
'overall_status': '$OVERALL',
|
||||
'issues_found': $ISSUES,
|
||||
'issues_fixed': $FIXED,
|
||||
'issues_escalated': $ESCALATED,
|
||||
'checks': $DETAILS
|
||||
}, indent=2))
|
||||
" 2>/dev/null)
|
||||
|
||||
echo "$REPORT" > "$LOG_DIR/${RUN_ID}.json"
|
||||
# 9. GPU Monitor self-check
|
||||
echo -n " GPU Monitor... "
|
||||
if curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$GPU_DASHBOARD/health" 2>/dev/null | grep -q 200; then
|
||||
add_detail "gpu-monitor" "pass" "Monitor server health OK"
|
||||
echo "pass"
|
||||
else
|
||||
add_detail "gpu-monitor" "warn" "GPU monitor health endpoint unreachable"
|
||||
echo "warn"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Report saved: $LOG_DIR/${RUN_ID}.json"
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# PHASE 4: FEED TO KNOWLEDGE GRAPH
|
||||
# PHASE 4: LOG TO GITEA (hard rule: health logs NEVER go to knowledge graph)
|
||||
# Logged to SyslogSolution/health-logs/litellm/{run_id}.json — versioned, searchable.
|
||||
echo -n " Gitea... "
|
||||
/opt/inference-harness/scripts/gitea-logger.sh litellm "${RUN_ID}.json" "${LOG_DIR}/${RUN_ID}.json"
|
||||
|
||||
# PHASE 5: NOTIFY ON ISSUES
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
if [ "$ISSUES" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ $ISSUES issue(s) detected. Creating relay alert..."
|
||||
ALERT_TITLE="⚠ LiteLLM Health Alert — $OVERALL ($ISSUES issues)"
|
||||
ALERT_BODY="$SUMMARY
|
||||
|
||||
Details:
|
||||
$DETAILS"
|
||||
mcp_call "tools/call" "createRelayNode" "{\"name\":\"createRelayNode\",\"arguments\":{\"title\":\"$ALERT_TITLE\",\"source\":\"$ALERT_BODY\",\"description\":\"LiteLLM health check failure alert\"}}" > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[$(date)] Health check complete — $RUN_ID"
|
||||
exit 0
|
||||
Reference in New Issue
Block a user