aigetwey 1.1.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +65 -4
  2. package/README.md +32 -11
  3. package/config.example.yaml +6 -6
  4. package/dashboard/next.config.ts +6 -0
  5. package/dashboard/src/app/(console)/quota/page.tsx +2 -2
  6. package/dashboard/src/app/globals.css +47 -0
  7. package/dashboard/src/components/BudgetForm.tsx +256 -0
  8. package/dashboard/src/components/BudgetTracker.tsx +181 -0
  9. package/dashboard/src/components/CooldownTimer.tsx +1 -1
  10. package/dashboard/src/components/EndpointView.tsx +285 -47
  11. package/dashboard/src/components/LogTable.tsx +97 -25
  12. package/dashboard/src/components/ModelPicker.tsx +15 -7
  13. package/dashboard/src/components/ProviderDetail.tsx +27 -29
  14. package/dashboard/src/components/ProviderManager.tsx +39 -31
  15. package/dashboard/src/components/Rail.tsx +1 -1
  16. package/dashboard/src/components/RoutingView.tsx +8 -4
  17. package/dashboard/src/components/ToolDetail.tsx +5 -3
  18. package/dashboard/src/components/TopBar.tsx +1 -1
  19. package/dashboard/src/components/UsageView.tsx +25 -6
  20. package/dashboard/src/components/ui.tsx +6 -1
  21. package/dashboard/src/lib/cliTools.ts +0 -43
  22. package/dashboard/src/lib/client.ts +14 -7
  23. package/dashboard/src/lib/gateway.ts +33 -15
  24. package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
  25. package/dist/cli.js +43 -8
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config.js +136 -27
  28. package/dist/config.js.map +1 -1
  29. package/dist/core/budget.js +62 -17
  30. package/dist/core/budget.js.map +1 -1
  31. package/dist/core/fallback.js +0 -6
  32. package/dist/core/fallback.js.map +1 -1
  33. package/dist/core/handler.js +24 -9
  34. package/dist/core/handler.js.map +1 -1
  35. package/dist/core/keysUsage.js +15 -0
  36. package/dist/core/keysUsage.js.map +1 -0
  37. package/dist/core/ratelimit.js +15 -0
  38. package/dist/core/ratelimit.js.map +1 -0
  39. package/dist/core/state.js +15 -15
  40. package/dist/core/state.js.map +1 -1
  41. package/dist/core/window.js +35 -0
  42. package/dist/core/window.js.map +1 -0
  43. package/dist/db.js +39 -25
  44. package/dist/db.js.map +1 -1
  45. package/dist/middleware/auth.js +15 -8
  46. package/dist/middleware/auth.js.map +1 -1
  47. package/dist/routes/admin.js +80 -17
  48. package/dist/routes/admin.js.map +1 -1
  49. package/dist/routes/v1.js +28 -11
  50. package/dist/routes/v1.js.map +1 -1
  51. package/dist/server.js +5 -7
  52. package/dist/server.js.map +1 -1
  53. package/dist/stream/openai-stream.js +3 -0
  54. package/dist/stream/openai-stream.js.map +1 -1
  55. package/dist/upstream/client.js +9 -0
  56. package/dist/upstream/client.js.map +1 -1
  57. package/package.json +3 -4
  58. package/src/cli.ts +44 -8
  59. package/src/config.ts +142 -29
  60. package/src/core/budget.ts +78 -25
  61. package/src/core/fallback.ts +0 -9
  62. package/src/core/handler.ts +31 -12
  63. package/src/core/keysUsage.ts +49 -0
  64. package/src/core/ratelimit.ts +25 -0
  65. package/src/core/state.ts +21 -16
  66. package/src/core/window.ts +45 -0
  67. package/src/db.ts +50 -28
  68. package/src/middleware/auth.ts +18 -8
  69. package/src/routes/admin.ts +93 -20
  70. package/src/routes/v1.ts +32 -11
  71. package/src/server.ts +5 -8
  72. package/src/stream/openai-stream.ts +3 -1
  73. package/src/upstream/client.ts +9 -0
  74. package/dashboard/src/components/BudgetEditor.tsx +0 -97
  75. package/dashboard/src/components/QuotaView.tsx +0 -152
  76. package/src/core/quota.ts +0 -253
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { adminApi } from "@/lib/client";
5
+ import { Badge } from "@/components/Badge";
6
+ import { RichCard, CardTitle } from "@/components/RichCard";
7
+ import { CooldownTimer } from "@/components/CooldownTimer";
8
+ import { fmt, Empty } from "@/components/ui";
9
+ import { BudgetForm } from "@/components/BudgetForm";
10
+ import { Button } from "@/components/Button";
11
+ import { Icon } from "@/components/Icon";
12
+ import type { BudgetStatus, KeyUsageRow } from "@/lib/gateway";
13
+
14
+ /**
15
+ * Budget Tracker — scoped spend budgets (global / per-provider / per-model /
16
+ * per-key) with an Add / Edit / Remove flow: consumption vs limit, a fill bar,
17
+ * and a live reset countdown.
18
+ */
19
+ export function BudgetTracker() {
20
+ const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
21
+ const [keys, setKeys] = useState<KeyUsageRow[]>([]);
22
+ const [loaded, setLoaded] = useState(false);
23
+ const [form, setForm] = useState<{ open: boolean; initial: BudgetStatus | null }>({ open: false, initial: null });
24
+ const [error, setError] = useState("");
25
+
26
+ const refresh = () => {
27
+ void adminApi.budgets().then((r) => {
28
+ if (!r.ok) setError(r.error ?? "could not reach the gateway");
29
+ else { setBudgets(r.data?.budgets ?? []); }
30
+ setLoaded(true);
31
+ });
32
+ void adminApi.keysUsage().then((r) => { if (r.ok) setKeys(r.data?.keys ?? []); });
33
+ };
34
+
35
+ useEffect(() => { refresh(); }, []);
36
+
37
+ if (error) return <Empty>{error}</Empty>;
38
+ if (!loaded) return <Empty>Loading...</Empty>;
39
+
40
+ return (
41
+ <div>
42
+ <div className="mb-6">
43
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">Budgets</h1>
44
+ <p className="mt-1 text-[13px] text-text-muted">
45
+ Spend caps (USD or tokens) with live reset countdowns.
46
+ </p>
47
+ </div>
48
+
49
+ {/* -- Budgets -- */}
50
+ <div className="mb-6">
51
+ <div className="mb-3 flex items-center justify-between">
52
+ <h2 className="text-[15px] font-semibold text-text">Overall</h2>
53
+ {!form.open && (
54
+ <Button onClick={() => setForm({ open: true, initial: null })}>
55
+ <Icon name="add" size={16} /> Add budget
56
+ </Button>
57
+ )}
58
+ </div>
59
+
60
+ {form.open && (
61
+ <BudgetForm
62
+ key={form.initial?.key ?? "new"}
63
+ initial={form.initial}
64
+ onSaved={() => { setForm({ open: false, initial: null }); refresh(); }}
65
+ onCancel={() => setForm({ open: false, initial: null })}
66
+ />
67
+ )}
68
+
69
+ {budgets.filter((b) => b.scope.type !== "key").length === 0 ? (
70
+ !form.open && <Empty>No budgets yet. Add one to cap spend globally, per provider, or per model.</Empty>
71
+ ) : (
72
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
73
+ {budgets.filter((b) => b.scope.type !== "key").map((b) => (
74
+ <RichCard
75
+ key={b.key}
76
+ header={
77
+ <>
78
+ <CardTitle title={b.label} sub={`${b.scope.type} · ${b.window}`} />
79
+ <Badge tone={b.exhausted ? "down" : b.alert ? "warn" : "live"}>
80
+ {b.exhausted ? "exhausted" : b.alert ? "alert" : "active"}
81
+ </Badge>
82
+ </>
83
+ }
84
+ >
85
+ <div className="space-y-2.5">
86
+ {b.note && <p className="text-[12px] text-text-muted">{b.note}</p>}
87
+ <div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
88
+ <div
89
+ className={`h-full rounded-full transition-all ${b.exhausted ? "bg-danger" : b.alert ? "bg-warning" : "bg-accent"}`}
90
+ style={{ width: `${Math.min(100, Math.round(b.pct * 100))}%` }}
91
+ />
92
+ </div>
93
+ <div className="flex items-center justify-between text-[12px]">
94
+ <span className="tnum text-text-muted">
95
+ {b.unit === "usd"
96
+ ? `$${b.spent.toFixed(2)} / $${b.limit.toFixed(2)}`
97
+ : `${fmt.compact(b.spent)} / ${fmt.compact(b.limit)} tokens`}
98
+ {b.est_converse != null
99
+ ? b.unit === "usd" ? ` · ~${fmt.compact(b.est_converse)} tok` : ` · ~$${b.est_converse.toFixed(2)}`
100
+ : " · —"}
101
+ </span>
102
+ <CooldownTimer ms={b.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
103
+ </div>
104
+ <div className="flex items-center gap-2 border-t border-border-subtle pt-2.5">
105
+ <Button variant="ghost" className="px-2.5 py-1 text-[12px]" onClick={() => setForm({ open: true, initial: b })}>
106
+ <Icon name="edit" size={14} /> Edit
107
+ </Button>
108
+ <Button
109
+ variant="danger"
110
+ className="px-2.5 py-1 text-[12px]"
111
+ onClick={async () => { const r = await adminApi.clearBudget(b.key); if (!r.ok) setError(r.error ?? "could not remove budget"); refresh(); }}
112
+ >
113
+ <Icon name="delete" size={14} /> Remove
114
+ </Button>
115
+ </div>
116
+ </div>
117
+ </RichCard>
118
+ ))}
119
+ </div>
120
+ )}
121
+ </div>
122
+
123
+ {/* -- Keys -- */}
124
+ <div>
125
+ <h2 className="mb-3 text-[15px] font-semibold text-text">Keys</h2>
126
+ {keys.length === 0 ? (
127
+ <Empty>No gateway keys yet. Add one on the Endpoint page.</Empty>
128
+ ) : (
129
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
130
+ {keys.map((k) => (
131
+ <RichCard
132
+ key={k.fingerprint}
133
+ header={
134
+ <>
135
+ <CardTitle title={k.name} sub={k.budget ? `key · ${k.budget.window}` : "key · no limit"} />
136
+ {k.expires && Date.now() > k.expires ? (
137
+ <Badge tone="down">expired</Badge>
138
+ ) : k.budget?.exhausted ? (
139
+ <Badge tone="down">exhausted</Badge>
140
+ ) : (
141
+ <Badge tone="live">active</Badge>
142
+ )}
143
+ </>
144
+ }
145
+ >
146
+ <div className="space-y-2.5">
147
+ {k.budget ? (
148
+ <>
149
+ <div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
150
+ <div
151
+ className={`h-full rounded-full transition-all ${k.budget.exhausted ? "bg-danger" : k.budget.alert ? "bg-warning" : "bg-accent"}`}
152
+ style={{ width: `${Math.min(100, Math.round(k.budget.pct * 100))}%` }}
153
+ />
154
+ </div>
155
+ <div className="flex items-center justify-between text-[12px]">
156
+ <span className="tnum text-text-muted">
157
+ {k.budget.unit === "usd"
158
+ ? `$${k.budget.spent.toFixed(2)} / $${k.budget.limit.toFixed(2)}`
159
+ : `${fmt.compact(k.budget.spent)} / ${fmt.compact(k.budget.limit)} tokens`}
160
+ </span>
161
+ <CooldownTimer ms={k.budget.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
162
+ </div>
163
+ </>
164
+ ) : (
165
+ <div className="flex items-center justify-between text-[12px]">
166
+ <span className="tnum text-text-muted">${k.spent.toFixed(2)} spent</span>
167
+ <span className="text-text-subtle">no limit</span>
168
+ </div>
169
+ )}
170
+ <div className="text-[11px] text-text-subtle">
171
+ {k.expires ? `expires ${fmt.date(k.expires)}` : "no expiry"}
172
+ </div>
173
+ </div>
174
+ </RichCard>
175
+ ))}
176
+ </div>
177
+ )}
178
+ </div>
179
+ </div>
180
+ );
181
+ }
@@ -6,7 +6,7 @@ import { fmt } from "./ui";
6
6
 
7
7
  /**
8
8
  * Live countdown from a gateway-snapshot remaining-ms. Counts down locally each
9
- * second from render. Used for both key cooldowns (danger tone) and quota window
9
+ * second from render. Used for both key cooldowns (danger tone) and budget window
10
10
  * resets (muted tone). Renders nothing once it hits zero unless `keepZero`.
11
11
  */
12
12
  export function CooldownTimer({
@@ -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
  }
@@ -248,17 +456,39 @@ function HeadroomCard({
248
456
  const [url, setUrl] = useState(h.url);
249
457
  const [localBusy, setLocalBusy] = useState("");
250
458
  const [msg, setMsg] = useState("");
459
+ const [check, setCheck] = useState<{ ok: boolean; text: string } | null>(null);
251
460
  useEffect(() => setUrl(h.url), [h.url]);
252
461
 
253
462
  async function act(label: string, fn: () => Promise<{ ok: boolean; error?: string }>) {
254
463
  setLocalBusy(label);
255
464
  setMsg("");
465
+ setCheck(null);
256
466
  const r = await fn();
257
467
  setLocalBusy("");
258
468
  if (!r.ok) setMsg(r.error ?? "action failed");
259
469
  await refresh();
260
470
  }
261
471
 
472
+ // Live re-probe: ask the gateway whether the proxy at the configured URL
473
+ // actually answers right now, and surface the result inline.
474
+ async function checkProxy() {
475
+ setLocalBusy("check");
476
+ setMsg("");
477
+ setCheck(null);
478
+ const r = await adminApi.headroomStatus();
479
+ setLocalBusy("");
480
+ await refresh();
481
+ if (!r.ok || !r.data) {
482
+ setCheck({ ok: false, text: r.error ?? "could not reach the gateway" });
483
+ return;
484
+ }
485
+ setCheck(
486
+ r.data.running
487
+ ? { ok: true, text: `proxy is up at ${r.data.url}` }
488
+ : { ok: false, text: `no proxy responding at ${r.data.url}` },
489
+ );
490
+ }
491
+
262
492
  return (
263
493
  <RichCard className="lg:col-span-2" header={<CardTitle title="Headroom" sub="external context-compression proxy" />}>
264
494
  <div className="space-y-4">
@@ -312,6 +542,9 @@ function HeadroomCard({
312
542
  >
313
543
  <Icon name="stop" size={16} /> Stop
314
544
  </Button>
545
+ <Button variant="ghost" disabled={localBusy === "check"} onClick={checkProxy}>
546
+ <Icon name="sync" size={16} /> {localBusy === "check" ? "Checking…" : "Check"}
547
+ </Button>
315
548
  {hr && !hr.installed && (
316
549
  <span className="text-[11px] text-text-subtle">
317
550
  Headroom isn’t installed. Get it from{" "}
@@ -329,6 +562,11 @@ function HeadroomCard({
329
562
  </div>
330
563
 
331
564
  {msg && <p className="text-[12px] text-danger">{msg}</p>}
565
+ {check && (
566
+ <p className={`flex items-center gap-1 text-[12px] ${check.ok ? "text-success" : "text-danger"}`}>
567
+ <Icon name={check.ok ? "check_circle" : "error"} size={14} /> {check.text}
568
+ </p>
569
+ )}
332
570
  </div>
333
571
  </RichCard>
334
572
  );