@prakashpro1/auto-modal 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/bin/cli.mjs +138 -0
- package/claude-router.sh +28 -0
- package/config.default.yaml +23 -0
- package/package.json +63 -0
- package/scripts/free-port.mjs +26 -0
- package/src/anthropic.js +186 -0
- package/src/config.js +101 -0
- package/src/dashboard.js +560 -0
- package/src/envfile.js +60 -0
- package/src/loadenv.js +5 -0
- package/src/server.js +543 -0
- package/src/usage.js +131 -0
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// Self-contained dashboard page. No build step, no deps — vanilla JS that polls
|
|
2
|
+
// /status every 2s and renders one card per model with a row per (model, key) slot.
|
|
3
|
+
export const DASHBOARD_HTML = `<!doctype html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<title>Auto Modal — Status</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root { color-scheme: dark; }
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
13
|
+
background: #0d1117; color: #e6edf3; padding: 24px; }
|
|
14
|
+
h1 { font-size: 20px; margin: 0 0 2px; }
|
|
15
|
+
.sub { color: #7d8590; font-size: 13px; margin-bottom: 20px; }
|
|
16
|
+
#freepool { margin-top:-12px; }
|
|
17
|
+
#freepool b { color:#3fb950; font-size:15px; }
|
|
18
|
+
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
|
19
|
+
.ok { background:#3fb950; } .warn { background:#d29922; } .bad { background:#f85149; }
|
|
20
|
+
.grid { display:grid; gap:16px; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); }
|
|
21
|
+
.card { background:#161b22; border:1px solid #30363d; border-radius:10px; padding:16px; cursor:grab; }
|
|
22
|
+
.card.dragging { opacity:.4; cursor:grabbing; }
|
|
23
|
+
.prio { display:inline-block; min-width:22px; text-align:center; background:#21262d; color:#7d8590;
|
|
24
|
+
border-radius:6px; padding:0 6px; font-size:12px; margin-right:6px; }
|
|
25
|
+
.cardbtns { display:inline-flex; align-items:center; gap:7px; }
|
|
26
|
+
.tbtn, .ebtn { background:transparent; border:1px solid #30363d; color:#7d8590; font-size:11px; padding:2px 8px; border-radius:6px; }
|
|
27
|
+
.tbtn:hover { color:#58a6ff; border-color:#58a6ff; } .ebtn:hover { color:#d29922; border-color:#d29922; }
|
|
28
|
+
.ghostbtn { background:transparent; border:1px solid #30363d; color:#58a6ff; font-size:12px; padding:2px 10px; border-radius:6px; }
|
|
29
|
+
.ghostbtn:hover { border-color:#58a6ff; }
|
|
30
|
+
.spark { vertical-align:middle; }
|
|
31
|
+
.histn { color:#7d8590; font-size:11px; margin-left:6px; }
|
|
32
|
+
.testres { font-size:12px; margin:8px 0 0; min-height:0; }
|
|
33
|
+
.testres.ok { color:#3fb950; } .testres.bad { color:#f85149; } .testres.run { color:#7d8590; }
|
|
34
|
+
.editor { display:flex; gap:10px; align-items:end; margin-top:10px; flex-wrap:wrap; }
|
|
35
|
+
.editor[hidden] { display:none; }
|
|
36
|
+
.editor input { width:80px; }
|
|
37
|
+
.save-edit { background:#238636; color:#fff; } .cancel-edit { background:#30363d; color:#e6edf3; }
|
|
38
|
+
.card h2 { font-size:15px; margin:0 0 2px; display:flex; align-items:center; justify-content:space-between; }
|
|
39
|
+
.muted { color:#7d8590; font-size:12px; }
|
|
40
|
+
.model-id { color:#58a6ff; font-family: ui-monospace, monospace; font-size:12px; word-break:break-all; }
|
|
41
|
+
.remain { font-size:13px; margin-top:8px; }
|
|
42
|
+
.remain b { color:#e6edf3; font-size:15px; }
|
|
43
|
+
table { width:100%; border-collapse:collapse; margin-top:12px; }
|
|
44
|
+
th { text-align:left; color:#7d8590; font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:.04em; padding:4px 6px; }
|
|
45
|
+
td { padding:6px; border-top:1px solid #21262d; font-variant-numeric: tabular-nums; }
|
|
46
|
+
.bar { position:relative; height:6px; border-radius:3px; background:#21262d; overflow:hidden; margin-top:3px; }
|
|
47
|
+
.bar > span { position:absolute; inset:0 auto 0 0; border-radius:3px; }
|
|
48
|
+
.fill-ok > span { background:#3fb950; } .fill-warn > span { background:#d29922; } .fill-bad > span { background:#f85149; }
|
|
49
|
+
.badge { font-size:11px; padding:1px 7px; border-radius:20px; }
|
|
50
|
+
.b-ok { background:#1a3326; color:#3fb950; } .b-warn { background:#332a14; color:#d29922; } .b-bad { background:#3d1a1a; color:#f85149; }
|
|
51
|
+
.err { background:#3d1a1a; color:#f85149; border:1px solid #f85149; padding:12px; border-radius:8px; }
|
|
52
|
+
.foot { color:#7d8590; font-size:12px; margin-top:18px; }
|
|
53
|
+
code { background:#21262d; padding:1px 5px; border-radius:4px; font-size:12px; }
|
|
54
|
+
details { background:#161b22; border:1px solid #30363d; border-radius:10px; padding:0 16px; margin-bottom:20px; }
|
|
55
|
+
summary { cursor:pointer; padding:14px 0; font-weight:600; }
|
|
56
|
+
form { display:grid; grid-template-columns: repeat(auto-fit, minmax(160px,1fr)); gap:12px; padding-bottom:16px; }
|
|
57
|
+
label { display:flex; flex-direction:column; gap:4px; font-size:12px; color:#7d8590; }
|
|
58
|
+
input, select { background:#0d1117; border:1px solid #30363d; color:#e6edf3; border-radius:6px; padding:7px 9px; font:inherit; }
|
|
59
|
+
button { cursor:pointer; border:0; border-radius:6px; padding:8px 14px; font:inherit; font-weight:600; }
|
|
60
|
+
.add-btn { background:#238636; color:#fff; align-self:end; }
|
|
61
|
+
.add-btn:hover { background:#2ea043; }
|
|
62
|
+
.del { background:transparent; color:#7d8590; border:1px solid #30363d; font-size:11px; padding:2px 8px; border-radius:6px; }
|
|
63
|
+
.del:hover { color:#f85149; border-color:#f85149; }
|
|
64
|
+
.formmsg { grid-column:1/-1; font-size:13px; }
|
|
65
|
+
.formmsg.ok { color:#3fb950; } .formmsg.bad { color:#f85149; } .formmsg.warn { color:#d29922; }
|
|
66
|
+
.pickrow { display:flex; gap:10px; align-items:center; margin-bottom:6px; }
|
|
67
|
+
.pickrow > input { flex:1; }
|
|
68
|
+
.inline { flex-direction:row; align-items:center; gap:5px; color:#e6edf3; white-space:nowrap; }
|
|
69
|
+
.inline input { width:auto; }
|
|
70
|
+
select option { background:#0d1117; }
|
|
71
|
+
.keyvar { padding:10px 0; border-top:1px solid #21262d; }
|
|
72
|
+
.keyvar:first-child { border-top:0; }
|
|
73
|
+
.keylist { display:flex; flex-wrap:wrap; gap:8px; margin-top:6px; }
|
|
74
|
+
.keychip { display:inline-flex; align-items:center; gap:6px; background:#0d1117; border:1px solid #30363d;
|
|
75
|
+
border-radius:20px; padding:3px 6px 3px 12px; font-family:ui-monospace,monospace; font-size:12px; }
|
|
76
|
+
.keydel { background:transparent; color:#7d8590; border:0; cursor:pointer; font-size:13px; padding:0 4px; }
|
|
77
|
+
.keydel:hover { color:#f85149; }
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<h1><span id="health" class="dot bad"></span>Auto Modal</h1>
|
|
82
|
+
<div class="sub"><span id="meta">connecting…</span>
|
|
83
|
+
· <button id="testall" class="ghostbtn">Test all</button>
|
|
84
|
+
<span id="testallmsg" class="muted"></span>
|
|
85
|
+
<span id="credits" class="muted"></span></div>
|
|
86
|
+
<div class="sub" id="freepool"></div>
|
|
87
|
+
|
|
88
|
+
<details>
|
|
89
|
+
<summary>+ Add a model</summary>
|
|
90
|
+
<form id="addform">
|
|
91
|
+
<label>Provider<select name="provider" id="provider"></select></label>
|
|
92
|
+
<label style="grid-column:1/-1">Model
|
|
93
|
+
<div class="pickrow">
|
|
94
|
+
<input id="modelfilter" placeholder="search models…">
|
|
95
|
+
<label class="inline"><input type="checkbox" id="freeonly" checked> free only</label>
|
|
96
|
+
</div>
|
|
97
|
+
<select name="model" id="modelsel" required></select>
|
|
98
|
+
</label>
|
|
99
|
+
<label>ID (unique label)<input name="id" placeholder="auto from model" required></label>
|
|
100
|
+
<label>API keys (env ref)<input name="apiKeys" value="\${OPENROUTER_API_KEYS}" required></label>
|
|
101
|
+
<label>Daily limit (optional)<input name="dailyLimit" type="number" placeholder="auto for free"></label>
|
|
102
|
+
<label>RPM (optional)<input name="rpm" type="number" placeholder="20"></label>
|
|
103
|
+
<button type="submit" class="add-btn">Add model</button>
|
|
104
|
+
<div class="formmsg" id="formmsg"></div>
|
|
105
|
+
</form>
|
|
106
|
+
</details>
|
|
107
|
+
|
|
108
|
+
<details>
|
|
109
|
+
<summary>🔑 API keys</summary>
|
|
110
|
+
<div id="keyspanel"></div>
|
|
111
|
+
<form id="keyform">
|
|
112
|
+
<label>Key pool<select name="envVar" id="keyenv"></select></label>
|
|
113
|
+
<label style="flex:2; min-width:240px">New API key<input name="key" placeholder="sk-or-… or hf_…" required></label>
|
|
114
|
+
<button type="submit" class="add-btn">Add key</button>
|
|
115
|
+
<div class="formmsg" id="keymsg"></div>
|
|
116
|
+
</form>
|
|
117
|
+
</details>
|
|
118
|
+
|
|
119
|
+
<div id="root" class="grid"></div>
|
|
120
|
+
<div id="error"></div>
|
|
121
|
+
<div class="foot">Auto-refreshes every 2s · raw JSON at <code>/status</code> · <code>/usage</code></div>
|
|
122
|
+
|
|
123
|
+
<script>
|
|
124
|
+
const fmtMs = (ms) => {
|
|
125
|
+
if (!ms) return "";
|
|
126
|
+
const s = Math.ceil(ms / 1000);
|
|
127
|
+
if (s < 60) return s + "s";
|
|
128
|
+
const m = Math.floor(s / 60);
|
|
129
|
+
return m + "m " + (s % 60) + "s";
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function slotState(s) {
|
|
133
|
+
if (s.cooldownRemainingMs > 0) return "bad";
|
|
134
|
+
if (s.dailyLimit && s.count >= s.dailyLimit) return "bad";
|
|
135
|
+
if (s.dailyLimit && s.count >= s.dailyLimit * 0.8) return "warn";
|
|
136
|
+
return "ok";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const KEY_ENV = { openrouter: "\${OPENROUTER_API_KEYS}", huggingface: "\${HF_API_KEYS}" };
|
|
140
|
+
const catalog = {}; // provider -> [{id,name,contextLength,free}]
|
|
141
|
+
let dragging = false; // pause auto-refresh re-render while reordering cards
|
|
142
|
+
let editing = false; // pause auto-refresh while an inline editor is open
|
|
143
|
+
let creditsInfo = null; // OpenRouter account info incl. derived free daily cap
|
|
144
|
+
const lastTest = {}; // id -> last test result, re-applied after each re-render
|
|
145
|
+
|
|
146
|
+
// Inline SVG sparkline from an oldest..newest array of counts.
|
|
147
|
+
function spark(values) {
|
|
148
|
+
if (!values || !values.length) return "";
|
|
149
|
+
const w = 88, h = 22, n = values.length;
|
|
150
|
+
const max = Math.max(1, ...values);
|
|
151
|
+
const step = n > 1 ? w / (n - 1) : w;
|
|
152
|
+
const pts = values.map((v, i) =>
|
|
153
|
+
(i * step).toFixed(1) + "," + (h - 3 - (v / max) * (h - 6)).toFixed(1)).join(" ");
|
|
154
|
+
const stroke = values[n - 1] > 0 ? "#3fb950" : "#58a6ff"; // green if active right now
|
|
155
|
+
return '<svg class="spark" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h +
|
|
156
|
+
'" preserveAspectRatio="none"><polyline points="' + pts +
|
|
157
|
+
'" fill="none" stroke="' + stroke + '" stroke-width="1.5" stroke-linejoin="round"/></svg>';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function paintTest(out, res) {
|
|
161
|
+
if (!out || !res) return;
|
|
162
|
+
if (res.running) { out.className = "testres run"; out.textContent = "Testing…"; return; }
|
|
163
|
+
if (res.ok) {
|
|
164
|
+
out.className = "testres ok";
|
|
165
|
+
out.textContent = "✓ OK · " + res.latencyMs + "ms" + (res.sample ? ' · "' + res.sample + '"' : "");
|
|
166
|
+
} else {
|
|
167
|
+
out.className = "testres bad";
|
|
168
|
+
out.textContent = "✗ " + (res.status ? "HTTP " + res.status + " · " : "") + (res.error || "failed");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let providersLoaded = false;
|
|
173
|
+
function loadProviders(providers) {
|
|
174
|
+
if (providersLoaded || !providers) return;
|
|
175
|
+
const sel = document.getElementById("provider");
|
|
176
|
+
sel.innerHTML = providers.map(p => '<option value="' + p + '">' + p + '</option>').join("");
|
|
177
|
+
sel.addEventListener("change", onProviderChange);
|
|
178
|
+
document.getElementById("modelfilter").addEventListener("input", refreshModelOptions);
|
|
179
|
+
document.getElementById("freeonly").addEventListener("change", refreshModelOptions);
|
|
180
|
+
document.getElementById("modelsel").addEventListener("change", autofillId);
|
|
181
|
+
const idField = document.querySelector('[name=id]');
|
|
182
|
+
idField.addEventListener("input", () => { idField.dataset.touched = idField.value ? "1" : ""; });
|
|
183
|
+
providersLoaded = true;
|
|
184
|
+
onProviderChange(); // initial catalog load for the first provider
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function onProviderChange() {
|
|
188
|
+
const provider = document.getElementById("provider").value;
|
|
189
|
+
document.querySelector('[name=apiKeys]').value = KEY_ENV[provider] || "";
|
|
190
|
+
await refreshModelOptions();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function refreshModelOptions() {
|
|
194
|
+
const provider = document.getElementById("provider").value;
|
|
195
|
+
const sel = document.getElementById("modelsel");
|
|
196
|
+
if (!catalog[provider]) {
|
|
197
|
+
sel.innerHTML = '<option value="">loading catalog…</option>';
|
|
198
|
+
try {
|
|
199
|
+
const r = await fetch("/admin/catalog?provider=" + provider);
|
|
200
|
+
const j = await r.json();
|
|
201
|
+
if (!r.ok) throw new Error(j.error || r.status);
|
|
202
|
+
catalog[provider] = j.models || [];
|
|
203
|
+
} catch (e) {
|
|
204
|
+
sel.innerHTML = '<option value="">⚠ ' + e.message + '</option>';
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const q = document.getElementById("modelfilter").value.toLowerCase();
|
|
209
|
+
const freeOnly = document.getElementById("freeonly").checked;
|
|
210
|
+
const list = catalog[provider]
|
|
211
|
+
.filter(m => (!freeOnly || m.free) &&
|
|
212
|
+
(!q || m.id.toLowerCase().includes(q) || (m.name || "").toLowerCase().includes(q)))
|
|
213
|
+
.sort((a, b) => (b.contextLength || 0) - (a.contextLength || 0)) // largest context first
|
|
214
|
+
.slice(0, 400);
|
|
215
|
+
sel.innerHTML = list.length
|
|
216
|
+
? list.map(m => {
|
|
217
|
+
const ctx = m.contextLength ? " · " + Math.round(m.contextLength / 1000) + "k ctx" : "";
|
|
218
|
+
return '<option value="' + m.id + '">' + m.id + (m.free ? " (free)" : "") + ctx + "</option>";
|
|
219
|
+
}).join("")
|
|
220
|
+
: '<option value="">' +
|
|
221
|
+
(freeOnly ? '(none flagged free — uncheck “free only”)' : '(no matches)') +
|
|
222
|
+
'</option>';
|
|
223
|
+
autofillId();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Suggest an ID from the chosen model slug, e.g. "vendor/llama-3.3:free" -> "llama-3.3-free".
|
|
227
|
+
function autofillId() {
|
|
228
|
+
const idField = document.querySelector('[name=id]');
|
|
229
|
+
if (idField.dataset.touched) return;
|
|
230
|
+
const slug = document.getElementById("modelsel").value;
|
|
231
|
+
if (!slug) return;
|
|
232
|
+
idField.value = slug.split("/").pop().replace(/:/g, "-").replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function render(data) {
|
|
236
|
+
if (dragging || editing) return; // don't clobber the cards mid-drag/edit
|
|
237
|
+
document.getElementById("health").className = "dot ok";
|
|
238
|
+
loadProviders(data.providers);
|
|
239
|
+
document.getElementById("meta").textContent =
|
|
240
|
+
data.chain.length + " model(s) · " +
|
|
241
|
+
data.chain.reduce((n, m) => n + m.slots.length, 0) + " key-slot(s) · port " + data.port;
|
|
242
|
+
|
|
243
|
+
const root = document.getElementById("root");
|
|
244
|
+
root.innerHTML = data.chain.map((m, idx) => {
|
|
245
|
+
const rows = m.slots.map(s => {
|
|
246
|
+
const st = slotState(s);
|
|
247
|
+
const badge = st === "ok" ? "Ready" : st === "warn" ? "Near cap" : (s.cooldownRemainingMs > 0 ? "Cooldown" : "Capped");
|
|
248
|
+
const dailyPct = s.dailyLimit ? Math.min(100, (s.count / s.dailyLimit) * 100) : 0;
|
|
249
|
+
const tok = s.tokensThisMinute == null ? (m.rpm ?? "∞") : s.tokensThisMinute;
|
|
250
|
+
const tokPct = m.rpm ? Math.min(100, ((s.tokensThisMinute == null ? m.rpm : s.tokensThisMinute) / m.rpm) * 100) : 100;
|
|
251
|
+
const hist = s.history || [];
|
|
252
|
+
const histTotal = hist.reduce((a, b) => a + b, 0);
|
|
253
|
+
return \`<tr>
|
|
254
|
+
<td>key\${s.keyIdx}</td>
|
|
255
|
+
<td><span class="badge b-\${st}">\${badge}</span>\${s.cooldownRemainingMs>0?' '+fmtMs(s.cooldownRemainingMs):''}</td>
|
|
256
|
+
<td>\${s.count}\${s.dailyLimit?' / '+s.dailyLimit:''}
|
|
257
|
+
<div class="bar fill-\${dailyPct>=100?'bad':dailyPct>=80?'warn':'ok'}"><span style="width:\${dailyPct}%"></span></div></td>
|
|
258
|
+
<td>\${tok}\${m.rpm?' / '+m.rpm:''}
|
|
259
|
+
<div class="bar fill-\${tokPct<=10?'bad':tokPct<=33?'warn':'ok'}"><span style="width:\${tokPct}%"></span></div></td>
|
|
260
|
+
<td title="\${histTotal} request(s) in the last 30 min">\${spark(hist)}<span class="histn">\${histTotal}</span></td>
|
|
261
|
+
</tr>\`;
|
|
262
|
+
}).join("");
|
|
263
|
+
return \`<div class="card" draggable="true" data-id="\${m.id}">
|
|
264
|
+
<h2><span><span class="prio" title="Priority \${idx + 1} — drag to reorder">#\${idx + 1}</span> \${m.id}</span>
|
|
265
|
+
<span class="cardbtns"><span class="muted">\${m.provider}</span>
|
|
266
|
+
<button class="tbtn" title="Test this model">Test</button>
|
|
267
|
+
<button class="ebtn" title="Edit limits">✎</button>
|
|
268
|
+
<button class="del" data-id="\${m.id}" title="Remove model">✕</button></span></h2>
|
|
269
|
+
<div class="model-id">\${m.model}</div>
|
|
270
|
+
\${(() => {
|
|
271
|
+
const keys = m.slots.length;
|
|
272
|
+
const used = m.slots.reduce((a, s) => a + s.count, 0);
|
|
273
|
+
if (!m.dailyLimit) return '<div class="remain muted">Today: ' + used + ' used · no daily cap</div>';
|
|
274
|
+
const total = m.dailyLimit * keys;
|
|
275
|
+
const left = Math.max(0, total - used);
|
|
276
|
+
const pct = total ? (used / total) * 100 : 0;
|
|
277
|
+
const cls = left === 0 ? "bad" : pct >= 80 ? "warn" : "ok";
|
|
278
|
+
return '<div class="remain" title="Resets at UTC midnight · ' + m.dailyLimit + '/day × ' + keys + ' key(s)">' +
|
|
279
|
+
'Today: <b>' + left + '</b> of ' + total + ' left <span class="muted">(' + used + ' used)</span>' +
|
|
280
|
+
'<div class="bar fill-' + cls + '"><span style="width:' + pct + '%"></span></div></div>';
|
|
281
|
+
})()}
|
|
282
|
+
<div class="testres"></div>
|
|
283
|
+
<div class="editor" hidden>
|
|
284
|
+
<label class="inline">RPM <input type="number" class="ed-rpm" placeholder="∞" value="\${m.rpm ?? ""}"></label>
|
|
285
|
+
<label class="inline">Daily <input type="number" class="ed-daily" placeholder="∞" value="\${m.dailyLimit ?? ""}"></label>
|
|
286
|
+
<button class="save-edit">Save</button>
|
|
287
|
+
<button class="cancel-edit">Cancel</button>
|
|
288
|
+
</div>
|
|
289
|
+
<table>
|
|
290
|
+
<thead><tr><th>Key</th><th>Status</th><th>Today</th><th>Tokens/min</th><th>Traffic · 30m</th></tr></thead>
|
|
291
|
+
<tbody>\${rows}</tbody>
|
|
292
|
+
</table>
|
|
293
|
+
</div>\`;
|
|
294
|
+
}).join("");
|
|
295
|
+
// Re-apply persisted test results so the 2s refresh doesn't wipe them.
|
|
296
|
+
data.chain.forEach(m => {
|
|
297
|
+
if (lastTest[m.id]) paintTest(document.querySelector('.card[data-id="' + CSS.escape(m.id) + '"] .testres'), lastTest[m.id]);
|
|
298
|
+
});
|
|
299
|
+
renderFreePool(data);
|
|
300
|
+
document.getElementById("error").innerHTML = "";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Accurate OpenRouter free-tier remaining: the cap is per key and SHARED across
|
|
304
|
+
// all :free models, so we sum today's requests over every free OpenRouter slot.
|
|
305
|
+
function renderFreePool(data) {
|
|
306
|
+
const el = document.getElementById("freepool");
|
|
307
|
+
if (!creditsInfo || !creditsInfo.available || !creditsInfo.freeDailyLimit) { el.textContent = ""; return; }
|
|
308
|
+
const free = data.chain.filter(m => m.provider === "openrouter" && /:free$/.test(m.model));
|
|
309
|
+
if (!free.length) { el.textContent = ""; return; }
|
|
310
|
+
const keys = Math.max(...free.map(m => m.slots.length)); // key pool size
|
|
311
|
+
const used = free.reduce((a, m) => a + m.slots.reduce((b, s) => b + s.count, 0), 0);
|
|
312
|
+
const cap = creditsInfo.freeDailyLimit * keys;
|
|
313
|
+
const left = Math.max(0, cap - used);
|
|
314
|
+
el.innerHTML = "OpenRouter free pool: <b>" + left + "</b> of " + cap + " left today " +
|
|
315
|
+
'<span class="muted">(' + used + " used · " + creditsInfo.freeDailyLimit + "/day × " + keys +
|
|
316
|
+
" key" + (keys === 1 ? "" : "s") + ", shared across all free models, resets 00:00 UTC)</span>";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function renderKeys() {
|
|
320
|
+
let rows;
|
|
321
|
+
try { rows = await (await fetch("/admin/keys")).json(); } catch { return; }
|
|
322
|
+
document.getElementById("keyspanel").innerHTML = rows.map(r => \`
|
|
323
|
+
<div class="keyvar"><b>\${r.envVar}</b> <span class="muted">(\${r.keys.length} key\${r.keys.length === 1 ? "" : "s"})</span>
|
|
324
|
+
<div class="keylist">\${
|
|
325
|
+
r.keys.map((k, i) => '<span class="keychip">' + k +
|
|
326
|
+
'<button class="keydel" data-env="' + r.envVar + '" data-i="' + i + '" title="Remove key">✕</button></span>').join("")
|
|
327
|
+
|| '<span class="muted">none yet — add one below</span>'
|
|
328
|
+
}</div>
|
|
329
|
+
</div>\`).join("");
|
|
330
|
+
const sel = document.getElementById("keyenv");
|
|
331
|
+
const cur = sel.value;
|
|
332
|
+
sel.innerHTML = rows.map(r => '<option>' + r.envVar + '</option>').join("");
|
|
333
|
+
if (cur) sel.value = cur;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function tick() {
|
|
337
|
+
try {
|
|
338
|
+
const r = await fetch("/status");
|
|
339
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
340
|
+
render(await r.json());
|
|
341
|
+
renderKeys();
|
|
342
|
+
} catch (e) {
|
|
343
|
+
document.getElementById("health").className = "dot bad";
|
|
344
|
+
document.getElementById("error").innerHTML =
|
|
345
|
+
'<div class="err">Cannot reach router: ' + e.message + '. Is <code>npm start</code> running?</div>';
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Add a model.
|
|
349
|
+
document.getElementById("addform").addEventListener("submit", async (e) => {
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
const msg = document.getElementById("formmsg");
|
|
352
|
+
const f = e.target;
|
|
353
|
+
const body = {
|
|
354
|
+
id: f.id.value.trim(),
|
|
355
|
+
provider: f.provider.value,
|
|
356
|
+
model: f.model.value.trim(),
|
|
357
|
+
apiKeys: f.apiKeys.value.trim(),
|
|
358
|
+
dailyLimit: f.dailyLimit.value,
|
|
359
|
+
rpm: f.rpm.value,
|
|
360
|
+
};
|
|
361
|
+
msg.className = "formmsg"; msg.textContent = "Adding…";
|
|
362
|
+
try {
|
|
363
|
+
const r = await fetch("/admin/models", {
|
|
364
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
365
|
+
});
|
|
366
|
+
const j = await r.json();
|
|
367
|
+
if (!r.ok) { msg.className = "formmsg bad"; msg.textContent = j.error || ("HTTP " + r.status); return; }
|
|
368
|
+
if (j.warning) { msg.className = "formmsg warn"; msg.textContent = j.warning; }
|
|
369
|
+
else {
|
|
370
|
+
msg.className = "formmsg ok";
|
|
371
|
+
msg.textContent = "Added \\"" + body.id + "\\" — live now." +
|
|
372
|
+
(j.autoDailyLimit ? " Daily limit auto-set to " + j.autoDailyLimit + "/day (OpenRouter free tier)." : "");
|
|
373
|
+
}
|
|
374
|
+
f.id.dataset.touched = "";
|
|
375
|
+
f.reset();
|
|
376
|
+
onProviderChange();
|
|
377
|
+
tick();
|
|
378
|
+
} catch (err) { msg.className = "formmsg bad"; msg.textContent = err.message; }
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Drag-to-reorder model priority (HTML5 DnD on the cards container).
|
|
382
|
+
const rootEl = document.getElementById("root");
|
|
383
|
+
|
|
384
|
+
function cardAfterPointer(x, y) {
|
|
385
|
+
const cards = [...rootEl.querySelectorAll(".card:not(.dragging)")];
|
|
386
|
+
return cards.find((c) => {
|
|
387
|
+
const b = c.getBoundingClientRect();
|
|
388
|
+
const cy = b.top + b.height / 2, cx = b.left + b.width / 2;
|
|
389
|
+
return y < cy - 1 || (Math.abs(cy - y) <= b.height / 2 && x < cx); // reading order
|
|
390
|
+
}) || null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
rootEl.addEventListener("dragstart", (e) => {
|
|
394
|
+
const card = e.target.closest(".card");
|
|
395
|
+
if (!card) return;
|
|
396
|
+
dragging = true;
|
|
397
|
+
card.classList.add("dragging");
|
|
398
|
+
e.dataTransfer.effectAllowed = "move";
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
rootEl.addEventListener("dragover", (e) => {
|
|
402
|
+
if (!dragging) return;
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
const dragged = rootEl.querySelector(".card.dragging");
|
|
405
|
+
if (!dragged) return;
|
|
406
|
+
const after = cardAfterPointer(e.clientX, e.clientY);
|
|
407
|
+
if (after == null) rootEl.appendChild(dragged);
|
|
408
|
+
else if (after !== dragged) rootEl.insertBefore(dragged, after);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
rootEl.addEventListener("dragend", async (e) => {
|
|
412
|
+
e.target.closest(".card")?.classList.remove("dragging");
|
|
413
|
+
if (!dragging) return;
|
|
414
|
+
dragging = false;
|
|
415
|
+
const order = [...rootEl.querySelectorAll(".card")].map((c) => c.dataset.id);
|
|
416
|
+
try {
|
|
417
|
+
await fetch("/admin/models/order", {
|
|
418
|
+
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order }),
|
|
419
|
+
});
|
|
420
|
+
} catch (err) { /* next tick re-syncs from server */ }
|
|
421
|
+
tick();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Card buttons: delete / test / edit (event delegation on the cards container).
|
|
425
|
+
rootEl.addEventListener("click", async (e) => {
|
|
426
|
+
const card = e.target.closest(".card");
|
|
427
|
+
if (!card) return;
|
|
428
|
+
const id = card.dataset.id;
|
|
429
|
+
|
|
430
|
+
// Delete
|
|
431
|
+
if (e.target.closest(".del")) {
|
|
432
|
+
if (!confirm('Remove model "' + id + '" from the chain?')) return;
|
|
433
|
+
try {
|
|
434
|
+
const r = await fetch("/admin/models/" + encodeURIComponent(id), { method: "DELETE" });
|
|
435
|
+
if (!r.ok) { const j = await r.json(); alert(j.error || ("HTTP " + r.status)); return; }
|
|
436
|
+
tick();
|
|
437
|
+
} catch (err) { alert(err.message); }
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Test — fire one real request through this model only.
|
|
442
|
+
if (e.target.classList.contains("tbtn")) {
|
|
443
|
+
const out = card.querySelector(".testres");
|
|
444
|
+
lastTest[id] = { running: true }; paintTest(out, lastTest[id]);
|
|
445
|
+
try {
|
|
446
|
+
lastTest[id] = await (await fetch("/admin/models/" + encodeURIComponent(id) + "/test", { method: "POST" })).json();
|
|
447
|
+
} catch (err) { lastTest[id] = { ok: false, error: err.message }; }
|
|
448
|
+
paintTest(out, lastTest[id]);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Open inline editor
|
|
453
|
+
if (e.target.classList.contains("ebtn")) {
|
|
454
|
+
editing = true;
|
|
455
|
+
card.draggable = false;
|
|
456
|
+
card.querySelector(".editor").hidden = false;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Cancel edit
|
|
461
|
+
if (e.target.classList.contains("cancel-edit")) {
|
|
462
|
+
editing = false; tick();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Save edit (rpm / dailyLimit)
|
|
467
|
+
if (e.target.classList.contains("save-edit")) {
|
|
468
|
+
const body = {
|
|
469
|
+
rpm: card.querySelector(".ed-rpm").value,
|
|
470
|
+
dailyLimit: card.querySelector(".ed-daily").value,
|
|
471
|
+
};
|
|
472
|
+
try {
|
|
473
|
+
const r = await fetch("/admin/models/" + encodeURIComponent(id), {
|
|
474
|
+
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
475
|
+
});
|
|
476
|
+
if (!r.ok) { const j = await r.json(); alert(j.error || ("HTTP " + r.status)); return; }
|
|
477
|
+
} catch (err) { alert(err.message); return; }
|
|
478
|
+
editing = false; tick();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Add an API key to a pool.
|
|
484
|
+
document.getElementById("keyform").addEventListener("submit", async (e) => {
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
const msg = document.getElementById("keymsg");
|
|
487
|
+
const f = e.target;
|
|
488
|
+
const body = { envVar: f.envVar.value, key: f.key.value.trim() };
|
|
489
|
+
if (!body.key) return;
|
|
490
|
+
msg.className = "formmsg"; msg.textContent = "Adding…";
|
|
491
|
+
try {
|
|
492
|
+
const r = await fetch("/admin/keys", {
|
|
493
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
494
|
+
});
|
|
495
|
+
const j = await r.json();
|
|
496
|
+
if (!r.ok) { msg.className = "formmsg bad"; msg.textContent = j.error || ("HTTP " + r.status); return; }
|
|
497
|
+
msg.className = "formmsg ok";
|
|
498
|
+
msg.textContent = j.added ? (body.envVar + " now has " + j.count + " key(s).") : "Key already present.";
|
|
499
|
+
f.key.value = "";
|
|
500
|
+
renderKeys(); tick();
|
|
501
|
+
} catch (err) { msg.className = "formmsg bad"; msg.textContent = err.message; }
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Delete a key (event delegation on the panel).
|
|
505
|
+
document.getElementById("keyspanel").addEventListener("click", async (e) => {
|
|
506
|
+
const btn = e.target.closest(".keydel");
|
|
507
|
+
if (!btn) return;
|
|
508
|
+
if (!confirm("Remove this key from " + btn.dataset.env + "?")) return;
|
|
509
|
+
try {
|
|
510
|
+
const r = await fetch("/admin/keys", {
|
|
511
|
+
method: "DELETE", headers: { "Content-Type": "application/json" },
|
|
512
|
+
body: JSON.stringify({ envVar: btn.dataset.env, index: Number(btn.dataset.i) }),
|
|
513
|
+
});
|
|
514
|
+
if (!r.ok) { const j = await r.json(); alert(j.error || ("HTTP " + r.status)); return; }
|
|
515
|
+
renderKeys(); tick();
|
|
516
|
+
} catch (err) { alert(err.message); }
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Test all models at once.
|
|
520
|
+
document.getElementById("testall").addEventListener("click", async () => {
|
|
521
|
+
const msg = document.getElementById("testallmsg");
|
|
522
|
+
document.querySelectorAll(".card").forEach(c => {
|
|
523
|
+
const id = c.dataset.id;
|
|
524
|
+
lastTest[id] = { running: true };
|
|
525
|
+
paintTest(c.querySelector(".testres"), lastTest[id]);
|
|
526
|
+
});
|
|
527
|
+
msg.textContent = " testing…";
|
|
528
|
+
try {
|
|
529
|
+
const j = await (await fetch("/admin/test-all", { method: "POST" })).json();
|
|
530
|
+
for (const res of j.results) {
|
|
531
|
+
lastTest[res.id] = res;
|
|
532
|
+
paintTest(document.querySelector('.card[data-id="' + CSS.escape(res.id) + '"] .testres'), res);
|
|
533
|
+
}
|
|
534
|
+
const dead = j.results.filter(r => !r.ok).map(r => r.id);
|
|
535
|
+
msg.textContent = " " + j.healthy + "/" + j.total + " healthy" + (dead.length ? " · dead: " + dead.join(", ") : "");
|
|
536
|
+
} catch (e) { msg.textContent = " failed: " + e.message; }
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// OpenRouter account credits (fetched once on load + every 60s; the upstream
|
|
540
|
+
// call is rate-limited, so we don't poll it on the 2s status tick).
|
|
541
|
+
async function loadCredits() {
|
|
542
|
+
try {
|
|
543
|
+
const c = await (await fetch("/admin/credits")).json();
|
|
544
|
+
creditsInfo = c;
|
|
545
|
+
const el = document.getElementById("credits");
|
|
546
|
+
if (!c.available) { el.textContent = ""; return; }
|
|
547
|
+
const tier = c.isFreeTier ? "free tier" : "paid";
|
|
548
|
+
const used = "$" + Number(c.usageDaily || 0).toFixed(3) + " today";
|
|
549
|
+
const rem = c.limitRemaining != null ? " · $" + Number(c.limitRemaining).toFixed(2) + " credit left" : "";
|
|
550
|
+
el.textContent = " · OpenRouter: " + tier + " · " + used + rem;
|
|
551
|
+
} catch { /* ignore */ }
|
|
552
|
+
}
|
|
553
|
+
loadCredits();
|
|
554
|
+
setInterval(loadCredits, 60000);
|
|
555
|
+
|
|
556
|
+
tick();
|
|
557
|
+
setInterval(tick, 2000);
|
|
558
|
+
</script>
|
|
559
|
+
</body>
|
|
560
|
+
</html>`;
|
package/src/envfile.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Minimal .env reader/writer for managing API-key pools from the dashboard.
|
|
2
|
+
// Keys are stored comma-separated in env vars like OPENROUTER_API_KEYS.
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const ENV_PATH = process.env.ROUTER_ENV || join(__dirname, "..", ".env");
|
|
9
|
+
|
|
10
|
+
const readLines = () =>
|
|
11
|
+
existsSync(ENV_PATH) ? readFileSync(ENV_PATH, "utf8").split(/\r?\n/) : [];
|
|
12
|
+
const writeLines = (lines) => writeFileSync(ENV_PATH, lines.join("\n"));
|
|
13
|
+
|
|
14
|
+
// Current keys for a var, from the live process env (kept in sync with the file).
|
|
15
|
+
export function getKeysFor(varName) {
|
|
16
|
+
return (process.env[varName] || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Upsert VAR=value in the .env file, preserving other lines and comments.
|
|
20
|
+
function setVarInFile(varName, value) {
|
|
21
|
+
const lines = readLines();
|
|
22
|
+
let found = false;
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const m = lines[i].match(/^\s*([A-Z0-9_]+)\s*=/);
|
|
25
|
+
if (m && m[1] === varName) {
|
|
26
|
+
lines[i] = `${varName}=${value}`;
|
|
27
|
+
found = true;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!found) lines.push(`${varName}=${value}`);
|
|
32
|
+
writeLines(lines);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function addKey(varName, key) {
|
|
36
|
+
key = key.trim();
|
|
37
|
+
const keys = getKeysFor(varName);
|
|
38
|
+
if (keys.includes(key)) return { added: false, count: keys.length };
|
|
39
|
+
keys.push(key);
|
|
40
|
+
const value = keys.join(",");
|
|
41
|
+
process.env[varName] = value; // live, so the next reload() resolves it
|
|
42
|
+
setVarInFile(varName, value);
|
|
43
|
+
return { added: true, count: keys.length };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function removeKey(varName, index) {
|
|
47
|
+
const keys = getKeysFor(varName);
|
|
48
|
+
if (index < 0 || index >= keys.length) return { removed: false };
|
|
49
|
+
keys.splice(index, 1);
|
|
50
|
+
const value = keys.join(",");
|
|
51
|
+
process.env[varName] = value;
|
|
52
|
+
setVarInFile(varName, value);
|
|
53
|
+
return { removed: true, count: keys.length };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Show enough to recognize a key without exposing it.
|
|
57
|
+
export function maskKey(k) {
|
|
58
|
+
if (k.length <= 10) return "••••";
|
|
59
|
+
return k.slice(0, 6) + "…" + k.slice(-4);
|
|
60
|
+
}
|
package/src/loadenv.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Load the .env into process.env. Honors ROUTER_ENV (set by the global CLI to
|
|
2
|
+
// point at ~/.automodel/.env); falls back to the default ./.env for `npm start`.
|
|
3
|
+
// Imported first in server.js as a side effect so vars exist before config loads.
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
dotenv.config(process.env.ROUTER_ENV ? { path: process.env.ROUTER_ENV } : {});
|