aigetwey 1.2.0 → 1.3.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/CHANGELOG.md +19 -1
- package/README.md +28 -7
- package/config.example.yaml +0 -1
- package/dashboard/src/app/(console)/quota/page.tsx +2 -2
- package/dashboard/src/components/BudgetForm.tsx +15 -17
- package/dashboard/src/components/{QuotaView.tsx → BudgetTracker.tsx} +71 -56
- package/dashboard/src/components/CooldownTimer.tsx +1 -1
- package/dashboard/src/components/EndpointView.tsx +255 -47
- package/dashboard/src/components/LogTable.tsx +32 -25
- package/dashboard/src/components/ProviderManager.tsx +3 -28
- package/dashboard/src/components/Rail.tsx +1 -1
- package/dashboard/src/components/RoutingView.tsx +6 -2
- package/dashboard/src/components/TopBar.tsx +1 -1
- package/dashboard/src/components/ui.tsx +6 -1
- package/dashboard/src/lib/client.ts +6 -5
- package/dashboard/src/lib/gateway.ts +23 -16
- package/dist/config.js +86 -23
- package/dist/config.js.map +1 -1
- package/dist/core/budget.js +1 -1
- package/dist/core/budget.js.map +1 -1
- package/dist/core/fallback.js +0 -6
- package/dist/core/fallback.js.map +1 -1
- package/dist/core/handler.js +6 -5
- package/dist/core/handler.js.map +1 -1
- package/dist/core/keysUsage.js +15 -0
- package/dist/core/keysUsage.js.map +1 -0
- package/dist/core/ratelimit.js +15 -0
- package/dist/core/ratelimit.js.map +1 -0
- package/dist/core/state.js +5 -13
- package/dist/core/state.js.map +1 -1
- package/dist/core/window.js +35 -0
- package/dist/core/window.js.map +1 -0
- package/dist/db.js +0 -20
- package/dist/db.js.map +1 -1
- package/dist/routes/admin.js +55 -10
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/v1.js +14 -1
- package/dist/routes/v1.js.map +1 -1
- package/dist/server.js +1 -7
- package/dist/server.js.map +1 -1
- package/dist/stream/openai-stream.js +3 -0
- package/dist/stream/openai-stream.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +89 -23
- package/src/core/budget.ts +1 -1
- package/src/core/fallback.ts +0 -9
- package/src/core/handler.ts +9 -7
- package/src/core/keysUsage.ts +49 -0
- package/src/core/ratelimit.ts +25 -0
- package/src/core/state.ts +4 -14
- package/src/core/window.ts +45 -0
- package/src/db.ts +0 -23
- package/src/routes/admin.ts +61 -9
- package/src/routes/v1.ts +18 -1
- package/src/server.ts +1 -8
- package/src/stream/openai-stream.ts +3 -1
- package/src/core/quota.ts +0 -253
|
@@ -7,11 +7,26 @@ import { RichCard, CardTitle } from "@/components/RichCard";
|
|
|
7
7
|
import { Button, Input } from "@/components/Button";
|
|
8
8
|
import { Icon } from "@/components/Icon";
|
|
9
9
|
import { KeyReveal } from "@/components/KeyReveal";
|
|
10
|
-
import { Empty } from "@/components/ui";
|
|
11
|
-
import
|
|
10
|
+
import { Empty, fmt } from "@/components/ui";
|
|
11
|
+
import { ModelPicker, type ModelGroup } from "@/components/ModelPicker";
|
|
12
|
+
import { ConfirmModal } from "@/components/ConfirmModal";
|
|
13
|
+
import type { EndpointPayload, HeadroomStatusReply, InjectLevel, MaskedConfig } from "@/lib/gateway";
|
|
12
14
|
|
|
13
15
|
const LEVELS: InjectLevel[] = ["off", "lite", "full", "ultra"];
|
|
14
16
|
|
|
17
|
+
/** Segment-pill style — matches the Budgets page pills. */
|
|
18
|
+
const pill = (active: boolean): string =>
|
|
19
|
+
`rounded-brand px-3 py-1.5 text-[13px] font-medium transition-colors ${
|
|
20
|
+
active ? "bg-accent/12 text-accent" : "bg-surface-2 text-text-muted hover:text-text"
|
|
21
|
+
}`;
|
|
22
|
+
|
|
23
|
+
const DAY_MS = 86_400_000;
|
|
24
|
+
const EXPIRY_MS: Record<"24h" | "7day" | "30day", number> = {
|
|
25
|
+
"24h": DAY_MS,
|
|
26
|
+
"7day": 7 * DAY_MS,
|
|
27
|
+
"30day": 30 * DAY_MS,
|
|
28
|
+
};
|
|
29
|
+
|
|
15
30
|
/** Generate a random gateway key client-side (aigetwey's one-click create). */
|
|
16
31
|
function generateKey(): string {
|
|
17
32
|
const bytes = new Uint8Array(24);
|
|
@@ -24,12 +39,22 @@ export function EndpointView() {
|
|
|
24
39
|
const [ep, setEp] = useState<EndpointPayload | null>(null);
|
|
25
40
|
const [error, setError] = useState("");
|
|
26
41
|
const [busy, setBusy] = useState("");
|
|
27
|
-
const [newKey, setNewKey] = useState("");
|
|
28
42
|
const [keyName, setKeyName] = useState("");
|
|
29
43
|
const [created, setCreated] = useState<{ key: string; name: string } | null>(null);
|
|
44
|
+
const [pendingDelKey, setPendingDelKey] = useState<{ i: number; label: string } | null>(null);
|
|
30
45
|
const [hr, setHr] = useState<HeadroomStatusReply | null>(null);
|
|
31
46
|
const [editKey, setEditKey] = useState<number | null>(null);
|
|
32
47
|
const [editKeyName, setEditKeyName] = useState("");
|
|
48
|
+
const [groups, setGroups] = useState<ModelGroup[]>([]);
|
|
49
|
+
const [scopeKey, setScopeKey] = useState<number | null>(null);
|
|
50
|
+
const [scopeModels, setScopeModels] = useState<string[]>([]);
|
|
51
|
+
const [scopeRpm, setScopeRpm] = useState("");
|
|
52
|
+
const [scopeExpiry, setScopeExpiry] = useState<"keep" | "never" | "24h" | "7day" | "30day" | "custom">("never");
|
|
53
|
+
const [scopeCustomDays, setScopeCustomDays] = useState("");
|
|
54
|
+
const [scopeLimit, setScopeLimit] = useState(""); // USD limit, "" = no cap
|
|
55
|
+
const [scopeWindow, setScopeWindow] = useState<"5h" | "24h" | "7day" | "30day">("30day");
|
|
56
|
+
const [keyBudgets, setKeyBudgets] = useState<Record<string, { limit: number; window: string }>>({});
|
|
57
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
33
58
|
|
|
34
59
|
const reload = useCallback(async () => {
|
|
35
60
|
const r = await adminApi.endpoint();
|
|
@@ -51,6 +76,29 @@ export function EndpointView() {
|
|
|
51
76
|
useEffect(() => {
|
|
52
77
|
void reload();
|
|
53
78
|
void reloadHr();
|
|
79
|
+
// load the model catalog for the per-key scope picker (combos + provider/model refs).
|
|
80
|
+
void (async () => {
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch("/api/gw/admin/config");
|
|
83
|
+
if (!res.ok) return;
|
|
84
|
+
const cfg = (await res.json()) as MaskedConfig;
|
|
85
|
+
const grps: ModelGroup[] = [];
|
|
86
|
+
if (cfg.models.length) grps.push({ label: "Combos", items: cfg.models.map((m) => ({ value: m.alias, label: m.alias })) });
|
|
87
|
+
for (const p of cfg.providers) {
|
|
88
|
+
if (p.models.length) grps.push({ label: p.id, items: p.models.map((m) => ({ value: `${p.id}/${m.id}`, label: `${p.id}/${m.id}` })) });
|
|
89
|
+
}
|
|
90
|
+
setGroups(grps);
|
|
91
|
+
} catch { /* non-critical — picker will just be empty */ }
|
|
92
|
+
})();
|
|
93
|
+
// index existing key-scoped budgets by fingerprint so the modal can prefill.
|
|
94
|
+
void adminApi.budgets().then((r) => {
|
|
95
|
+
if (!r.ok || !r.data) return;
|
|
96
|
+
const map: Record<string, { limit: number; window: string }> = {};
|
|
97
|
+
for (const b of r.data.budgets) {
|
|
98
|
+
if (b.scope.type === "key") map[b.scope.id] = { limit: b.limit, window: b.window };
|
|
99
|
+
}
|
|
100
|
+
setKeyBudgets(map);
|
|
101
|
+
});
|
|
54
102
|
}, [reload, reloadHr]);
|
|
55
103
|
|
|
56
104
|
if (error) return <Empty>{error}</Empty>;
|
|
@@ -82,7 +130,6 @@ export function EndpointView() {
|
|
|
82
130
|
}
|
|
83
131
|
setError("");
|
|
84
132
|
setKeyName("");
|
|
85
|
-
setNewKey("");
|
|
86
133
|
setCreated({ key: rawKey, name });
|
|
87
134
|
await reload();
|
|
88
135
|
}
|
|
@@ -132,60 +179,197 @@ export function EndpointView() {
|
|
|
132
179
|
</div>
|
|
133
180
|
</div>
|
|
134
181
|
) : (
|
|
135
|
-
<div key={i} className="
|
|
136
|
-
<div className="flex
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
</
|
|
182
|
+
<div key={i} className="space-y-0 rounded-brand border border-border-subtle">
|
|
183
|
+
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
|
184
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
185
|
+
{k.name && <span className="text-[12px] font-semibold text-text-muted">{k.name}</span>}
|
|
186
|
+
<KeyReveal
|
|
187
|
+
masked={k.key}
|
|
188
|
+
reveal={async () => {
|
|
189
|
+
const r = await adminApi.revealServerKey(i);
|
|
190
|
+
return r.ok ? r.data?.key ?? null : null;
|
|
191
|
+
}}
|
|
192
|
+
/>
|
|
193
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-text-subtle">
|
|
194
|
+
<span>{k.models?.length ? `${k.models.length} model${k.models.length > 1 ? "s" : ""}` : "all models"}</span>
|
|
195
|
+
<span>·</span>
|
|
196
|
+
<span>{k.rpm ? `${k.rpm}/min` : "no rate limit"}</span>
|
|
197
|
+
<span>·</span>
|
|
198
|
+
<span>
|
|
199
|
+
{k.expires
|
|
200
|
+
? (Date.now() > k.expires ? <span className="text-danger">expired</span> : `expires ${fmt.date(k.expires)}`)
|
|
201
|
+
: "no expiry"}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div className="flex flex-none items-center gap-1">
|
|
206
|
+
<button
|
|
207
|
+
onClick={() => {
|
|
208
|
+
setScopeKey(i);
|
|
209
|
+
setScopeModels(k.models ?? []);
|
|
210
|
+
setScopeRpm(k.rpm ? String(k.rpm) : "");
|
|
211
|
+
setScopeExpiry(k.expires ? "keep" : "never");
|
|
212
|
+
setScopeCustomDays("");
|
|
213
|
+
const kb = keyBudgets[k.fingerprint];
|
|
214
|
+
setScopeLimit(kb ? String(kb.limit) : "");
|
|
215
|
+
setScopeWindow((kb?.window as "5h" | "24h" | "7day" | "30day") ?? "30day");
|
|
216
|
+
}}
|
|
217
|
+
className="text-text-subtle hover:text-text"
|
|
218
|
+
aria-label="Edit key scope"
|
|
219
|
+
title="Model allowlist + rate limit + budget + expiry"
|
|
220
|
+
>
|
|
221
|
+
<Icon name="tune" size={15} />
|
|
222
|
+
</button>
|
|
223
|
+
<button
|
|
224
|
+
onClick={() => { setEditKey(i); setEditKeyName(k.name ?? ""); }}
|
|
225
|
+
className="text-text-subtle hover:text-text"
|
|
226
|
+
aria-label="Rename key"
|
|
227
|
+
title="Rename key"
|
|
228
|
+
>
|
|
229
|
+
<Icon name="edit" size={15} />
|
|
230
|
+
</button>
|
|
231
|
+
<button onClick={() => setPendingDelKey({ i, label: k.name || k.key })} className="text-text-subtle hover:text-danger" aria-label="Remove key">
|
|
232
|
+
<Icon name="delete" size={16} />
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
158
235
|
</div>
|
|
236
|
+
{scopeKey === i && (
|
|
237
|
+
<div className="space-y-2 border-t border-border-subtle bg-accent-soft/40 px-3 py-2.5">
|
|
238
|
+
<div>
|
|
239
|
+
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-text-subtle">Allowed models</div>
|
|
240
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
241
|
+
{scopeModels.length === 0 ? (
|
|
242
|
+
<span className="text-[12px] text-text-subtle">All models (unrestricted)</span>
|
|
243
|
+
) : (
|
|
244
|
+
scopeModels.map((m) => (
|
|
245
|
+
<span key={m} className="inline-flex items-center gap-1 rounded border border-accent bg-accent-soft px-2 py-0.5 text-[12px] text-accent">
|
|
246
|
+
<span className="tnum">{m}</span>
|
|
247
|
+
<button onClick={() => setScopeModels((s) => s.filter((x) => x !== m))} className="hover:text-danger" aria-label={`Remove ${m}`}>
|
|
248
|
+
<Icon name="close" size={12} />
|
|
249
|
+
</button>
|
|
250
|
+
</span>
|
|
251
|
+
))
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
<Button type="button" variant="ghost" className="mt-1.5" onClick={() => setPickerOpen(true)}>
|
|
255
|
+
<Icon name="add" size={15} /> Pick models
|
|
256
|
+
</Button>
|
|
257
|
+
</div>
|
|
258
|
+
<div>
|
|
259
|
+
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-text-subtle">Rate limit</div>
|
|
260
|
+
<Input
|
|
261
|
+
inputMode="numeric"
|
|
262
|
+
value={scopeRpm}
|
|
263
|
+
onChange={(e) => setScopeRpm(e.target.value.replace(/[^\d]/g, ""))}
|
|
264
|
+
placeholder="req/min (blank = unlimited)"
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
<div>
|
|
268
|
+
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-text-subtle">Access expiry</div>
|
|
269
|
+
{k.expires && (
|
|
270
|
+
<div className="mb-1.5 text-[11px] text-text-subtle">
|
|
271
|
+
currently {Date.now() > k.expires ? <span className="text-danger">expired</span> : `expires ${fmt.date(k.expires)}`}
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
<div className="flex flex-wrap gap-1">
|
|
275
|
+
{(k.expires
|
|
276
|
+
? (["keep", "never", "24h", "7day", "30day", "custom"] as const)
|
|
277
|
+
: (["never", "24h", "7day", "30day", "custom"] as const)
|
|
278
|
+
).map((opt) => (
|
|
279
|
+
<button
|
|
280
|
+
key={opt}
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={() => setScopeExpiry(opt)}
|
|
283
|
+
className={pill(scopeExpiry === opt)}
|
|
284
|
+
>
|
|
285
|
+
{opt === "never" ? "no expiry" : opt}
|
|
286
|
+
</button>
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
{scopeExpiry === "custom" && (
|
|
290
|
+
<div className="mt-1.5 flex items-center gap-1.5">
|
|
291
|
+
<Input
|
|
292
|
+
inputMode="numeric"
|
|
293
|
+
value={scopeCustomDays}
|
|
294
|
+
onChange={(e) => setScopeCustomDays(e.target.value.replace(/[^\d]/g, ""))}
|
|
295
|
+
placeholder="days"
|
|
296
|
+
className="w-24"
|
|
297
|
+
/>
|
|
298
|
+
<span className="text-[11px] text-text-subtle">days from now</span>
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
<div>
|
|
303
|
+
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-text-subtle">Spend cap (USD)</div>
|
|
304
|
+
<Input
|
|
305
|
+
inputMode="decimal"
|
|
306
|
+
value={scopeLimit}
|
|
307
|
+
onChange={(e) => setScopeLimit(e.target.value.replace(/[^\d.]/g, ""))}
|
|
308
|
+
placeholder="USD (blank = no cap)"
|
|
309
|
+
/>
|
|
310
|
+
{scopeLimit && (
|
|
311
|
+
<div className="mt-1.5 flex items-center gap-1.5">
|
|
312
|
+
<span className="text-[11px] text-text-subtle">resets every</span>
|
|
313
|
+
{(["5h", "24h", "7day", "30day"] as const).map((w) => (
|
|
314
|
+
<button
|
|
315
|
+
key={w}
|
|
316
|
+
type="button"
|
|
317
|
+
onClick={() => setScopeWindow(w)}
|
|
318
|
+
className={pill(scopeWindow === w)}
|
|
319
|
+
>
|
|
320
|
+
{w}
|
|
321
|
+
</button>
|
|
322
|
+
))}
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
<div className="flex justify-end gap-2">
|
|
327
|
+
<Button variant="ghost" onClick={() => setScopeKey(null)}>Cancel</Button>
|
|
328
|
+
<Button
|
|
329
|
+
disabled={busy === `scope${i}`}
|
|
330
|
+
onClick={() =>
|
|
331
|
+
run(`scope${i}`, async () => {
|
|
332
|
+
// "keep" leaves expiry untouched (omit); "never" clears; a duration sets now+N.
|
|
333
|
+
const expires =
|
|
334
|
+
scopeExpiry === "keep" ? undefined
|
|
335
|
+
: scopeExpiry === "never" ? null
|
|
336
|
+
: scopeExpiry === "custom" ? (scopeCustomDays ? Date.now() + Number(scopeCustomDays) * DAY_MS : null)
|
|
337
|
+
: Date.now() + EXPIRY_MS[scopeExpiry];
|
|
338
|
+
const r = await adminApi.setServerKeyScope(i, {
|
|
339
|
+
models: scopeModels,
|
|
340
|
+
rpm: scopeRpm ? Number(scopeRpm) : null,
|
|
341
|
+
expires,
|
|
342
|
+
});
|
|
343
|
+
if (!r.ok) return r;
|
|
344
|
+
const limit = scopeLimit ? Number(scopeLimit) : 0;
|
|
345
|
+
if (limit > 0) {
|
|
346
|
+
await adminApi.setBudget({ scope: { type: "key", id: k.fingerprint }, unit: "usd", limit, window: scopeWindow });
|
|
347
|
+
} else if (keyBudgets[k.fingerprint]) {
|
|
348
|
+
await adminApi.clearBudget(`key:${k.fingerprint}`);
|
|
349
|
+
}
|
|
350
|
+
setScopeKey(null);
|
|
351
|
+
return r;
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
>
|
|
355
|
+
Save
|
|
356
|
+
</Button>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
159
360
|
</div>
|
|
160
361
|
),
|
|
161
362
|
)}
|
|
162
363
|
</div>
|
|
163
364
|
)}
|
|
164
365
|
<div className="mt-3 space-y-2">
|
|
165
|
-
<Input value={keyName} onChange={(e) => setKeyName(e.target.value)} placeholder="key name (e.g. Claude Code)" />
|
|
166
366
|
<div className="flex gap-2">
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
value={newKey}
|
|
170
|
-
onChange={(e) => setNewKey(e.target.value)}
|
|
171
|
-
placeholder="type a custom key, or roll the dice →"
|
|
172
|
-
className="pr-9 font-mono text-[12.5px]"
|
|
173
|
-
/>
|
|
174
|
-
<button
|
|
175
|
-
type="button"
|
|
176
|
-
onClick={() => setNewKey(generateKey())}
|
|
177
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-subtle hover:text-accent"
|
|
178
|
-
aria-label="Generate a random key"
|
|
179
|
-
title="Generate a random key"
|
|
180
|
-
>
|
|
181
|
-
<Icon name="casino" size={16} />
|
|
182
|
-
</button>
|
|
183
|
-
</div>
|
|
184
|
-
<Button disabled={!newKey.trim() || busy === "genkey"} onClick={() => addKey(keyName, newKey.trim())}>
|
|
367
|
+
<Input value={keyName} onChange={(e) => setKeyName(e.target.value)} placeholder="key name (e.g. Claude Code)" className="flex-1" />
|
|
368
|
+
<Button disabled={busy === "genkey"} onClick={() => addKey(keyName, generateKey())}>
|
|
185
369
|
<Icon name="add" size={16} /> {busy === "genkey" ? "Adding…" : "Add key"}
|
|
186
370
|
</Button>
|
|
187
371
|
</div>
|
|
188
|
-
<p className="text-[11px] text-text-subtle">Name it, then
|
|
372
|
+
<p className="text-[11px] text-text-subtle">Name it, then click Add key — a key is generated and shown once. Configure its limits after.</p>
|
|
189
373
|
</div>
|
|
190
374
|
</RichCard>
|
|
191
375
|
|
|
@@ -226,6 +410,30 @@ export function EndpointView() {
|
|
|
226
410
|
</div>
|
|
227
411
|
|
|
228
412
|
{created && <KeyCreatedModal name={created.name} value={created.key} onClose={() => setCreated(null)} />}
|
|
413
|
+
{pendingDelKey && (
|
|
414
|
+
<ConfirmModal
|
|
415
|
+
title="Remove gateway key"
|
|
416
|
+
message={`Delete "${pendingDelKey.label}"? Any client using this key stops working immediately.`}
|
|
417
|
+
confirmLabel="Remove"
|
|
418
|
+
busy={busy === `rmkey${pendingDelKey.i}`}
|
|
419
|
+
onCancel={() => setPendingDelKey(null)}
|
|
420
|
+
onConfirm={() => {
|
|
421
|
+
const i = pendingDelKey.i;
|
|
422
|
+
void run(`rmkey${i}`, () => adminApi.removeServerKey(i)).then(() => setPendingDelKey(null));
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
)}
|
|
426
|
+
{pickerOpen && (
|
|
427
|
+
<ModelPicker
|
|
428
|
+
title="Allowed models"
|
|
429
|
+
note="Pick the models this key may call. None = all."
|
|
430
|
+
groups={groups}
|
|
431
|
+
selected={scopeModels}
|
|
432
|
+
onToggle={(v) => setScopeModels((s) => s.includes(v) ? s.filter((x) => x !== v) : [...s, v])}
|
|
433
|
+
onClose={() => setPickerOpen(false)}
|
|
434
|
+
showThinkingHint={false}
|
|
435
|
+
/>
|
|
436
|
+
)}
|
|
229
437
|
</div>
|
|
230
438
|
);
|
|
231
439
|
}
|
|
@@ -18,12 +18,25 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
|
|
|
18
18
|
// shared control style for the filter row — bordered surface chip, accent on focus.
|
|
19
19
|
const ctrl = "h-9 rounded-brand border border-border bg-surface-2 px-2.5 text-[12.5px] text-text focus:border-accent focus:outline-none";
|
|
20
20
|
|
|
21
|
+
// recency presets for the request log (a live 200-row buffer, so relative
|
|
22
|
+
// windows fit better than absolute dates). null = no time filter.
|
|
23
|
+
const SINCE_PRESETS: { label: string; ms: number | null }[] = [
|
|
24
|
+
{ label: "1h", ms: 3600_000 },
|
|
25
|
+
{ label: "6h", ms: 6 * 3600_000 },
|
|
26
|
+
{ label: "24h", ms: 24 * 3600_000 },
|
|
27
|
+
{ label: "7d", ms: 7 * 86400_000 },
|
|
28
|
+
{ label: "All", ms: null },
|
|
29
|
+
];
|
|
30
|
+
const sincePill = (active: boolean): string =>
|
|
31
|
+
`rounded-full px-2.5 py-1 text-[12px] font-medium transition-colors ${
|
|
32
|
+
active ? "bg-accent/15 text-accent" : "text-text-muted hover:text-text"
|
|
33
|
+
}`;
|
|
34
|
+
|
|
21
35
|
export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
|
|
22
36
|
const [logs, setLogs] = useState(initial);
|
|
23
37
|
const [filter, setFilter] = useState<StatusFilter>("all");
|
|
24
38
|
const [provFilter, setProvFilter] = useState<string>("all");
|
|
25
|
-
const [
|
|
26
|
-
const [endDate, setEndDate] = useState("");
|
|
39
|
+
const [sinceMs, setSinceMs] = useState<number | null>(null);
|
|
27
40
|
const [live, setLive] = useState(true);
|
|
28
41
|
const [collapsed, setCollapsed] = useState(false);
|
|
29
42
|
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
|
@@ -52,20 +65,18 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
|
|
|
52
65
|
const okCount = logs.filter((l) => l.status >= 200 && l.status < 300).length;
|
|
53
66
|
const errCount = logs.length - okCount;
|
|
54
67
|
|
|
55
|
-
const
|
|
56
|
-
const endMs = endDate ? new Date(`${endDate}T23:59:59.999`).getTime() : null;
|
|
68
|
+
const sinceFloor = sinceMs !== null ? Date.now() - sinceMs : null;
|
|
57
69
|
|
|
58
70
|
const shown = logs.filter((l) => {
|
|
59
71
|
if (filter === "ok" && !(l.status >= 200 && l.status < 300)) return false;
|
|
60
72
|
if (filter === "error" && l.status >= 200 && l.status < 300) return false;
|
|
61
73
|
if (provFilter !== "all" && l.provider !== provFilter) return false;
|
|
62
|
-
if (
|
|
63
|
-
if (endMs !== null && l.ts > endMs) return false;
|
|
74
|
+
if (sinceFloor !== null && l.ts < sinceFloor) return false;
|
|
64
75
|
return true;
|
|
65
76
|
});
|
|
66
77
|
|
|
67
|
-
const hasFilters = filter !== "all" || provFilter !== "all" ||
|
|
68
|
-
const clearFilters = () => { setFilter("all"); setProvFilter("all");
|
|
78
|
+
const hasFilters = filter !== "all" || provFilter !== "all" || sinceMs !== null;
|
|
79
|
+
const clearFilters = () => { setFilter("all"); setProvFilter("all"); setSinceMs(null); };
|
|
69
80
|
|
|
70
81
|
const count = (k: StatusFilter) => (k === "all" ? logs.length : k === "ok" ? okCount : errCount);
|
|
71
82
|
|
|
@@ -123,23 +134,19 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
|
|
|
123
134
|
))}
|
|
124
135
|
</select>
|
|
125
136
|
</FilterField>
|
|
126
|
-
<FilterField label="
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
min={startDate || undefined}
|
|
140
|
-
onChange={(e) => setEndDate(e.target.value)}
|
|
141
|
-
className={ctrl + " [color-scheme:dark]"}
|
|
142
|
-
/>
|
|
137
|
+
<FilterField label="Since">
|
|
138
|
+
<div className="flex h-9 items-center gap-0.5 rounded-brand border border-border bg-surface-2 px-1">
|
|
139
|
+
{SINCE_PRESETS.map((p) => (
|
|
140
|
+
<button
|
|
141
|
+
key={p.label}
|
|
142
|
+
type="button"
|
|
143
|
+
onClick={() => setSinceMs(p.ms)}
|
|
144
|
+
className={sincePill(sinceMs === p.ms)}
|
|
145
|
+
>
|
|
146
|
+
{p.label}
|
|
147
|
+
</button>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
143
150
|
</FilterField>
|
|
144
151
|
<button
|
|
145
152
|
onClick={clearFilters}
|
|
@@ -8,13 +8,12 @@ import { Badge, FormatBadge } from "@/components/Badge";
|
|
|
8
8
|
import { CooldownTimer } from "@/components/CooldownTimer";
|
|
9
9
|
import { Button, Input, Field } from "@/components/Button";
|
|
10
10
|
import { Icon } from "@/components/Icon";
|
|
11
|
-
import {
|
|
12
|
-
import type { MaskedConfig, PingResult, ProviderSnapshot,
|
|
11
|
+
import { Empty } from "@/components/ui";
|
|
12
|
+
import type { MaskedConfig, PingResult, ProviderSnapshot, WireFormat } from "@/lib/gateway";
|
|
13
13
|
|
|
14
14
|
interface Loaded {
|
|
15
15
|
config: MaskedConfig;
|
|
16
16
|
health: ProviderSnapshot[];
|
|
17
|
-
quota: QuotaSnapshot[];
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
export function ProviderManager() {
|
|
@@ -23,10 +22,9 @@ export function ProviderManager() {
|
|
|
23
22
|
const [adding, setAdding] = useState(false);
|
|
24
23
|
|
|
25
24
|
const reload = useCallback(async () => {
|
|
26
|
-
const [cfg, prov
|
|
25
|
+
const [cfg, prov] = await Promise.all([
|
|
27
26
|
fetch("/api/gw/admin/config"),
|
|
28
27
|
adminApi.providers(),
|
|
29
|
-
adminApi.quota(),
|
|
30
28
|
]);
|
|
31
29
|
if (!cfg.ok || !prov.ok) {
|
|
32
30
|
setError("could not reach the gateway");
|
|
@@ -36,7 +34,6 @@ export function ProviderManager() {
|
|
|
36
34
|
setData({
|
|
37
35
|
config: (await cfg.json()) as MaskedConfig,
|
|
38
36
|
health: prov.data?.providers ?? [],
|
|
39
|
-
quota: q.data?.quota ?? [],
|
|
40
37
|
});
|
|
41
38
|
}, []);
|
|
42
39
|
|
|
@@ -48,7 +45,6 @@ export function ProviderManager() {
|
|
|
48
45
|
if (!data) return <Empty>Loading…</Empty>;
|
|
49
46
|
|
|
50
47
|
const healthById = new Map(data.health.map((h) => [h.id, h]));
|
|
51
|
-
const quotaById = new Map(data.quota.map((q) => [q.provider, q]));
|
|
52
48
|
|
|
53
49
|
return (
|
|
54
50
|
<div>
|
|
@@ -78,7 +74,6 @@ export function ProviderManager() {
|
|
|
78
74
|
const health = healthById.get(p.id);
|
|
79
75
|
const healthy = health ? health.keys.some((k) => k.healthy) : true;
|
|
80
76
|
const cooling = health?.keys.find((k) => !k.healthy && k.cooldown_ms > 0);
|
|
81
|
-
const q = quotaById.get(p.id);
|
|
82
77
|
return (
|
|
83
78
|
<Link
|
|
84
79
|
key={p.id}
|
|
@@ -110,26 +105,6 @@ export function ProviderManager() {
|
|
|
110
105
|
<Badge tone="neutral">{p.models.length} models</Badge>
|
|
111
106
|
{cooling && <CooldownTimer ms={cooling.cooldown_ms} />}
|
|
112
107
|
</div>
|
|
113
|
-
{q && (
|
|
114
|
-
<div className="mt-3 border-t border-border-subtle pt-2.5">
|
|
115
|
-
<div className="flex items-center justify-between text-[11px] text-text-subtle">
|
|
116
|
-
<span>quota · {q.window}</span>
|
|
117
|
-
<CooldownTimer ms={q.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
|
|
118
|
-
</div>
|
|
119
|
-
{q.limit_tokens && (
|
|
120
|
-
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-surface-2">
|
|
121
|
-
<div
|
|
122
|
-
className={`h-full ${q.exhausted ? "bg-danger" : "bg-accent"}`}
|
|
123
|
-
style={{ width: `${Math.round((q.pct ?? 0) * 100)}%` }}
|
|
124
|
-
/>
|
|
125
|
-
</div>
|
|
126
|
-
)}
|
|
127
|
-
<div className="mt-1 tnum text-[11px] text-text-muted">
|
|
128
|
-
{fmt.compact(q.consumed)}
|
|
129
|
-
{q.limit_tokens ? ` / ${fmt.compact(q.limit_tokens)}` : ""} tokens
|
|
130
|
-
</div>
|
|
131
|
-
</div>
|
|
132
|
-
)}
|
|
133
108
|
</Link>
|
|
134
109
|
);
|
|
135
110
|
})}
|
|
@@ -14,7 +14,7 @@ const MAIN: NavItem[] = [
|
|
|
14
14
|
{ href: "/providers", label: "Providers", icon: "dns" },
|
|
15
15
|
{ href: "/combos", label: "Combos", icon: "layers" },
|
|
16
16
|
{ href: "/usage", label: "Usage", icon: "bar_chart" },
|
|
17
|
-
{ href: "/quota", label: "
|
|
17
|
+
{ href: "/quota", label: "Budgets", icon: "data_usage" },
|
|
18
18
|
{ href: "/tools", label: "CLI Tools", icon: "terminal" },
|
|
19
19
|
];
|
|
20
20
|
|
|
@@ -239,6 +239,10 @@ function RouteForm({ providers, onDone, initial, onCancel }: { providers: Provid
|
|
|
239
239
|
price_in: priceIn ? Number(priceIn) : undefined,
|
|
240
240
|
price_out: priceOut ? Number(priceOut) : undefined,
|
|
241
241
|
});
|
|
242
|
+
// rename: the new alias is saved above; drop the old one so it doesn't linger.
|
|
243
|
+
if (r.ok && isEdit && initial!.alias !== alias) {
|
|
244
|
+
await adminApi.removeRoute(initial!.alias);
|
|
245
|
+
}
|
|
242
246
|
setBusy(false);
|
|
243
247
|
if (r.ok) onDone();
|
|
244
248
|
else setErr(r.error ?? "failed");
|
|
@@ -247,8 +251,8 @@ function RouteForm({ providers, onDone, initial, onCancel }: { providers: Provid
|
|
|
247
251
|
return (
|
|
248
252
|
<form onSubmit={submit} className="mb-5 rounded-brand-lg border border-border bg-surface p-4 shadow-soft">
|
|
249
253
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
250
|
-
<Field label="
|
|
251
|
-
<Input value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="claude
|
|
254
|
+
<Field label="Alias" hint="the name your CLI requests as a model">
|
|
255
|
+
<Input value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="my-claude" />
|
|
252
256
|
</Field>
|
|
253
257
|
<Field label="Strategy" hint="how the chain is tried">
|
|
254
258
|
<Select value={strategy} onChange={(e) => setStrategy(e.target.value as "fallback" | "round-robin")}>
|
|
@@ -16,6 +16,11 @@ export const fmt = {
|
|
|
16
16
|
time(ts: number): string {
|
|
17
17
|
return new Date(ts).toLocaleString("en-US", { hour12: false });
|
|
18
18
|
},
|
|
19
|
+
/** DD/MM/YYYY (local) */
|
|
20
|
+
date(ts: number): string {
|
|
21
|
+
const d = new Date(ts);
|
|
22
|
+
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`;
|
|
23
|
+
},
|
|
19
24
|
/** "3m", "2h", "5d" — coarse relative age */
|
|
20
25
|
ago(ts: number): string {
|
|
21
26
|
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
@@ -24,7 +29,7 @@ export const fmt = {
|
|
|
24
29
|
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
25
30
|
return `${Math.floor(s / 86400)}d`;
|
|
26
31
|
},
|
|
27
|
-
/** ms duration -> "Xs", "Xm Ys", "Xh Ym" for
|
|
32
|
+
/** ms duration -> "Xs", "Xm Ys", "Xh Ym" for budget/cooldown countdowns */
|
|
28
33
|
duration(ms: number): string {
|
|
29
34
|
const s = Math.max(0, Math.round(ms / 1000));
|
|
30
35
|
if (s < 60) return `${s}s`;
|
|
@@ -12,11 +12,11 @@ import type {
|
|
|
12
12
|
EndpointPayload,
|
|
13
13
|
HeadroomStatusReply,
|
|
14
14
|
InjectLevel,
|
|
15
|
+
KeyUsageRow,
|
|
15
16
|
ModelsPayload,
|
|
16
17
|
PingResult,
|
|
17
18
|
PricingPayload,
|
|
18
19
|
ProviderSnapshot,
|
|
19
|
-
QuotaSnapshot,
|
|
20
20
|
WireFormat,
|
|
21
21
|
} from "./gateway";
|
|
22
22
|
|
|
@@ -54,17 +54,16 @@ async function api<T>(method: string, path: string, body?: unknown): Promise<Api
|
|
|
54
54
|
|
|
55
55
|
export const adminApi = {
|
|
56
56
|
providers: () => api<{ providers: ProviderSnapshot[] }>("GET", "/admin/providers"),
|
|
57
|
-
|
|
57
|
+
budgets: () => api<{ budgets: BudgetStatus[] }>("GET", "/admin/budgets"),
|
|
58
58
|
models: () => api<ModelsPayload>("GET", "/admin/models"),
|
|
59
59
|
keys: () => api<Array<{ fingerprint: string; name: string; masked: string }>>("GET", "/admin/keys"),
|
|
60
|
+
keysUsage: () => api<{ keys: KeyUsageRow[] }>("GET", "/admin/keys/usage"),
|
|
60
61
|
|
|
61
62
|
setBudget: (body: {
|
|
62
63
|
scope: { type: "global" } | { type: "provider"; id: string } | { type: "model"; id: string } | { type: "key"; id: string };
|
|
63
64
|
unit: "usd" | "tokens";
|
|
64
65
|
limit: number;
|
|
65
|
-
window: "5h" | "
|
|
66
|
-
reset_at?: string;
|
|
67
|
-
timezone?: string;
|
|
66
|
+
window: "5h" | "24h" | "7day" | "30day";
|
|
68
67
|
alert_at?: number;
|
|
69
68
|
note?: string;
|
|
70
69
|
}) => api<ConfigReply>("PUT", "/admin/budgets", body),
|
|
@@ -142,6 +141,8 @@ export const adminApi = {
|
|
|
142
141
|
setPonytail: (level: InjectLevel) => api<ConfigReply>("PUT", "/admin/endpoint/ponytail", { level }),
|
|
143
142
|
addServerKey: (key: string, name?: string) => api<ConfigReply>("POST", "/admin/endpoint/keys", { key, name }),
|
|
144
143
|
editServerKey: (index: number, name: string) => api<ConfigReply>("PUT", `/admin/endpoint/keys/${index}`, { name }),
|
|
144
|
+
setServerKeyScope: (index: number, body: { models?: string[]; rpm?: number | null; expires?: number | null }) =>
|
|
145
|
+
api<ConfigReply>("PUT", `/admin/endpoint/keys/${index}/scope`, body),
|
|
145
146
|
removeServerKey: (index: number) => api<ConfigReply>("DELETE", `/admin/endpoint/keys/${index}`),
|
|
146
147
|
revealServerKey: (index: number) => api<{ key: string }>("GET", `/admin/endpoint/keys/${index}/reveal`),
|
|
147
148
|
|