aigetwey 1.2.0 → 1.3.2

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +30 -7
  3. package/assets/screenshot.png +0 -0
  4. package/config.example.yaml +0 -1
  5. package/dashboard/src/app/(console)/quota/page.tsx +2 -2
  6. package/dashboard/src/app/layout.tsx +3 -2
  7. package/dashboard/src/components/BudgetForm.tsx +15 -17
  8. package/dashboard/src/components/{QuotaView.tsx → BudgetTracker.tsx} +71 -56
  9. package/dashboard/src/components/CooldownTimer.tsx +1 -1
  10. package/dashboard/src/components/EndpointView.tsx +255 -47
  11. package/dashboard/src/components/LogTable.tsx +36 -26
  12. package/dashboard/src/components/ProviderManager.tsx +3 -28
  13. package/dashboard/src/components/Rail.tsx +1 -1
  14. package/dashboard/src/components/RoutingView.tsx +6 -2
  15. package/dashboard/src/components/TopBar.tsx +1 -1
  16. package/dashboard/src/components/ui.tsx +6 -1
  17. package/dashboard/src/lib/client.ts +6 -5
  18. package/dashboard/src/lib/gateway.ts +24 -16
  19. package/dist/adapters/gemini.js +1 -0
  20. package/dist/adapters/gemini.js.map +1 -1
  21. package/dist/adapters/openai.js +13 -1
  22. package/dist/adapters/openai.js.map +1 -1
  23. package/dist/config.js +86 -23
  24. package/dist/config.js.map +1 -1
  25. package/dist/core/budget.js +1 -1
  26. package/dist/core/budget.js.map +1 -1
  27. package/dist/core/fallback.js +0 -6
  28. package/dist/core/fallback.js.map +1 -1
  29. package/dist/core/handler.js +13 -7
  30. package/dist/core/handler.js.map +1 -1
  31. package/dist/core/keysUsage.js +15 -0
  32. package/dist/core/keysUsage.js.map +1 -0
  33. package/dist/core/ratelimit.js +15 -0
  34. package/dist/core/ratelimit.js.map +1 -0
  35. package/dist/core/state.js +5 -13
  36. package/dist/core/state.js.map +1 -1
  37. package/dist/core/window.js +35 -0
  38. package/dist/core/window.js.map +1 -0
  39. package/dist/db.js +34 -29
  40. package/dist/db.js.map +1 -1
  41. package/dist/routes/admin.js +55 -10
  42. package/dist/routes/admin.js.map +1 -1
  43. package/dist/routes/v1.js +14 -1
  44. package/dist/routes/v1.js.map +1 -1
  45. package/dist/server.js +1 -7
  46. package/dist/server.js.map +1 -1
  47. package/dist/stream/anthropic-stream.js +7 -0
  48. package/dist/stream/anthropic-stream.js.map +1 -1
  49. package/dist/stream/gemini-stream.js +2 -1
  50. package/dist/stream/gemini-stream.js.map +1 -1
  51. package/dist/stream/openai-stream.js +10 -0
  52. package/dist/stream/openai-stream.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/adapters/gemini.ts +2 -0
  55. package/src/adapters/openai.ts +18 -1
  56. package/src/config.ts +89 -23
  57. package/src/core/budget.ts +1 -1
  58. package/src/core/fallback.ts +0 -9
  59. package/src/core/handler.ts +16 -9
  60. package/src/core/keysUsage.ts +49 -0
  61. package/src/core/ratelimit.ts +25 -0
  62. package/src/core/state.ts +4 -14
  63. package/src/core/window.ts +45 -0
  64. package/src/db.ts +35 -31
  65. package/src/routes/admin.ts +61 -9
  66. package/src/routes/v1.ts +18 -1
  67. package/src/server.ts +1 -8
  68. package/src/stream/anthropic-stream.ts +10 -1
  69. package/src/stream/chunk.ts +2 -0
  70. package/src/stream/gemini-stream.ts +3 -2
  71. package/src/stream/openai-stream.ts +12 -1
  72. 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 type { EndpointPayload, HeadroomStatusReply, InjectLevel } from "@/lib/gateway";
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="flex items-center justify-between gap-2 rounded-brand border border-border-subtle px-3 py-2">
136
- <div className="flex min-w-0 flex-col gap-0.5">
137
- {k.name && <span className="text-[12px] font-semibold text-text-muted">{k.name}</span>}
138
- <KeyReveal
139
- masked={k.key}
140
- reveal={async () => {
141
- const r = await adminApi.revealServerKey(i);
142
- return r.ok ? r.data?.key ?? null : null;
143
- }}
144
- />
145
- </div>
146
- <div className="flex flex-none items-center gap-1">
147
- <button
148
- onClick={() => { setEditKey(i); setEditKeyName(k.name ?? ""); }}
149
- className="text-text-subtle hover:text-text"
150
- aria-label="Rename key"
151
- title="Rename key"
152
- >
153
- <Icon name="edit" size={15} />
154
- </button>
155
- <button onClick={() => run(`rmkey${i}`, () => adminApi.removeServerKey(i))} className="text-text-subtle hover:text-danger" aria-label="Remove key">
156
- <Icon name="delete" size={16} />
157
- </button>
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
- <div className="relative flex-1">
168
- <Input
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 type your own key or click the dice for a random one.</p>
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 [startDate, setStartDate] = useState("");
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 startMs = startDate ? new Date(`${startDate}T00:00:00`).getTime() : null;
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 (startMs !== null && l.ts < startMs) return false;
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" || startDate !== "" || endDate !== "";
68
- const clearFilters = () => { setFilter("all"); setProvFilter("all"); setStartDate(""); setEndDate(""); };
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="Start date">
127
- <input
128
- type="date"
129
- value={startDate}
130
- max={endDate || undefined}
131
- onChange={(e) => setStartDate(e.target.value)}
132
- className={ctrl + " [color-scheme:dark]"}
133
- />
134
- </FilterField>
135
- <FilterField label="End date">
136
- <input
137
- type="date"
138
- value={endDate}
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}
@@ -188,7 +195,7 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
188
195
  {l.status}
189
196
  </Badge>
190
197
  </Td>
191
- <Td muted title={fmt.time(l.ts)}>
198
+ <Td muted title={fmt.time(l.ts)} suppressHydrationWarning>
192
199
  {fmt.ago(l.ts)} ago
193
200
  </Td>
194
201
  <Td className="text-text">{l.alias}</Td>
@@ -229,16 +236,19 @@ function Td({
229
236
  right,
230
237
  title,
231
238
  className,
239
+ suppressHydrationWarning,
232
240
  }: {
233
241
  children: React.ReactNode;
234
242
  muted?: boolean;
235
243
  right?: boolean;
236
244
  title?: string;
237
245
  className?: string;
246
+ suppressHydrationWarning?: boolean;
238
247
  }) {
239
248
  return (
240
249
  <td
241
250
  title={title}
251
+ suppressHydrationWarning={suppressHydrationWarning}
242
252
  className={`whitespace-nowrap px-4 py-2.5 tnum text-[12.5px] ${right ? "text-right" : "text-left"} ${
243
253
  muted ? "text-text-muted" : "text-text"
244
254
  }${className ? ` ${className}` : ""}`}
@@ -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 { fmt, Empty } from "@/components/ui";
12
- import type { MaskedConfig, PingResult, ProviderSnapshot, QuotaSnapshot, WireFormat } from "@/lib/gateway";
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, q] = await Promise.all([
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: "Budget Tracker", icon: "data_usage" },
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="Name" hint="the model name your CLI calls">
251
- <Input value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="claude-sonnet-4-6" readOnly={isEdit} className={isEdit ? "opacity-60" : ""} />
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")}>
@@ -13,7 +13,7 @@ const LABELS: Record<string, string> = {
13
13
  providers: "Providers",
14
14
  combos: "Combos",
15
15
  usage: "Usage",
16
- quota: "Budget Tracker",
16
+ quota: "Budgets",
17
17
  tools: "CLI Tools",
18
18
  console: "Server Console",
19
19
  config: "Settings",
@@ -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 quota/cooldown countdowns */
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`;