aigetwey 1.1.0 → 1.2.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 (56) hide show
  1. package/CHANGELOG.md +46 -3
  2. package/README.md +4 -4
  3. package/config.example.yaml +6 -5
  4. package/dashboard/next.config.ts +6 -0
  5. package/dashboard/src/app/globals.css +47 -0
  6. package/dashboard/src/components/BudgetForm.tsx +258 -0
  7. package/dashboard/src/components/EndpointView.tsx +30 -0
  8. package/dashboard/src/components/LogTable.tsx +90 -25
  9. package/dashboard/src/components/ModelPicker.tsx +15 -7
  10. package/dashboard/src/components/ProviderDetail.tsx +27 -29
  11. package/dashboard/src/components/ProviderManager.tsx +36 -3
  12. package/dashboard/src/components/QuotaView.tsx +95 -81
  13. package/dashboard/src/components/Rail.tsx +1 -1
  14. package/dashboard/src/components/RoutingView.tsx +2 -2
  15. package/dashboard/src/components/ToolDetail.tsx +5 -3
  16. package/dashboard/src/components/TopBar.tsx +1 -1
  17. package/dashboard/src/components/UsageView.tsx +25 -6
  18. package/dashboard/src/lib/cliTools.ts +0 -43
  19. package/dashboard/src/lib/client.ts +9 -3
  20. package/dashboard/src/lib/gateway.ts +12 -1
  21. package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
  22. package/dist/cli.js +43 -8
  23. package/dist/cli.js.map +1 -1
  24. package/dist/config.js +56 -10
  25. package/dist/config.js.map +1 -1
  26. package/dist/core/budget.js +61 -16
  27. package/dist/core/budget.js.map +1 -1
  28. package/dist/core/handler.js +20 -6
  29. package/dist/core/handler.js.map +1 -1
  30. package/dist/core/state.js +10 -2
  31. package/dist/core/state.js.map +1 -1
  32. package/dist/db.js +39 -5
  33. package/dist/db.js.map +1 -1
  34. package/dist/middleware/auth.js +15 -8
  35. package/dist/middleware/auth.js.map +1 -1
  36. package/dist/routes/admin.js +26 -8
  37. package/dist/routes/admin.js.map +1 -1
  38. package/dist/routes/v1.js +15 -11
  39. package/dist/routes/v1.js.map +1 -1
  40. package/dist/server.js +4 -0
  41. package/dist/server.js.map +1 -1
  42. package/dist/upstream/client.js +9 -0
  43. package/dist/upstream/client.js.map +1 -1
  44. package/package.json +3 -4
  45. package/src/cli.ts +44 -8
  46. package/src/config.ts +57 -10
  47. package/src/core/budget.ts +77 -24
  48. package/src/core/handler.ts +24 -7
  49. package/src/core/state.ts +17 -2
  50. package/src/db.ts +50 -5
  51. package/src/middleware/auth.ts +18 -8
  52. package/src/routes/admin.ts +33 -12
  53. package/src/routes/v1.ts +15 -11
  54. package/src/server.ts +4 -0
  55. package/src/upstream/client.ts +9 -0
  56. package/dashboard/src/components/BudgetEditor.tsx +0 -97
@@ -17,17 +17,23 @@ export interface ModelGroup {
17
17
  export function ModelPicker({
18
18
  title = "Add models",
19
19
  note = "Click to add, click again to remove.",
20
+ searchPlaceholder = "Search models…",
20
21
  groups,
21
22
  selected,
22
23
  onToggle,
23
24
  onClose,
25
+ showThinkingHint = true,
24
26
  }: {
25
27
  title?: string;
26
28
  note?: string;
29
+ searchPlaceholder?: string;
27
30
  groups: ModelGroup[];
28
31
  selected: string[];
29
32
  onToggle: (value: string) => void;
30
33
  onClose: () => void;
34
+ /** The "reasoning models accept a thinking suffix" footer only makes sense when
35
+ * picking MODELS. Provider/key pickers reuse this component, so they hide it. */
36
+ showThinkingHint?: boolean;
31
37
  }) {
32
38
  const [q, setQ] = useState("");
33
39
  const needle = q.trim().toLowerCase();
@@ -60,7 +66,7 @@ export function ModelPicker({
60
66
  autoFocus
61
67
  value={q}
62
68
  onChange={(e) => setQ(e.target.value)}
63
- placeholder="Search models…"
69
+ placeholder={searchPlaceholder}
64
70
  className="w-full rounded-brand border border-border bg-bg py-2 pl-8 pr-3 text-[13px] text-text placeholder:text-text-subtle focus:border-accent focus:outline-none"
65
71
  />
66
72
  </div>
@@ -102,12 +108,14 @@ export function ModelPicker({
102
108
  )}
103
109
  </div>
104
110
 
105
- <div className="border-t border-border-subtle px-4 py-2 text-[11px] text-text-subtle">
106
- <Icon name="neurology" size={12} className="mr-1 inline align-text-bottom text-warning" />
107
- Reasoning models accept a thinking suffix — call{" "}
108
- <code className="rounded bg-surface-2 px-1 text-text-muted">model(high)</code> or{" "}
109
- <code className="rounded bg-surface-2 px-1 text-text-muted">model(none)</code> (high·low·medium·minimal·auto·none·or a token budget).
110
- </div>
111
+ {showThinkingHint && (
112
+ <div className="border-t border-border-subtle px-4 py-2 text-[11px] text-text-subtle">
113
+ <Icon name="neurology" size={12} className="mr-1 inline align-text-bottom text-warning" />
114
+ Reasoning models accept a thinking suffix — call{" "}
115
+ <code className="rounded bg-surface-2 px-1 text-text-muted">model(high)</code> or{" "}
116
+ <code className="rounded bg-surface-2 px-1 text-text-muted">model(none)</code> (high·low·medium·minimal·auto·none·or a token budget).
117
+ </div>
118
+ )}
111
119
 
112
120
  <div className="flex items-center justify-between border-t border-border-subtle px-4 py-3">
113
121
  <span className="tnum text-[12px] text-text-subtle">{selected.length} selected</span>
@@ -120,7 +120,7 @@ export function ProviderDetail({ id }: { id: string }) {
120
120
  </button>
121
121
 
122
122
  <div className="mb-6 flex items-center gap-3">
123
- <Lamp state={health?.keys.some((k) => k.healthy) ?? true ? "live" : "down"} />
123
+ <Lamp state={provider.disabled ? "idle" : (health?.keys.some((k) => k.healthy) ?? true) ? "live" : "down"} />
124
124
  <div>
125
125
  <h1 className="text-[22px] font-semibold tracking-tight text-text">{provider.name || provider.id}</h1>
126
126
  {provider.name && <span className="text-[12px] text-text-subtle">{provider.id}/</span>}
@@ -128,24 +128,12 @@ export function ProviderDetail({ id }: { id: string }) {
128
128
  <FormatBadge format={provider.format} />
129
129
  {provider.free && <Badge tone="info">free</Badge>}
130
130
  {provider.service_account && <Badge tone="info">service-account</Badge>}
131
- {provider.disabled && <Badge tone="warn">disabled</Badge>}
132
-
133
- {/* enable/disable the whole provider skipped in routing when off */}
134
- <label className="ml-auto flex items-center gap-2 text-[12px] text-text-muted">
135
- {provider.disabled ? "Disabled" : "Enabled"}
136
- <button
137
- type="button"
138
- onClick={() => void adminApi.setProviderDisabled(id, !provider.disabled).then(() => reload())}
139
- className={`relative h-5 w-9 rounded-full transition-colors ${provider.disabled ? "bg-border-subtle" : "bg-accent"}`}
140
- aria-label="Toggle provider enabled"
141
- title={provider.disabled ? "Provider is disabled — enable it" : "Provider is enabled — disable it"}
142
- >
143
- <span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${provider.disabled ? "left-0.5" : "left-[18px]"}`} />
144
- </button>
145
- </label>
131
+ {/* enable/disable lives on the Providers list (one place); here we just
132
+ flag the state. Disabled = red badge + the content below fades. */}
133
+ {provider.disabled && <Badge tone="down">disabled</Badge>}
146
134
  </div>
147
135
 
148
- <div className="grid gap-4 lg:grid-cols-2">
136
+ <div className={`grid gap-4 lg:grid-cols-2 transition-opacity ${provider.disabled ? "opacity-50" : ""}`}>
149
137
  <RichCard header={<CardTitle title="Connection" />}>
150
138
  {editingConn ? (
151
139
  <div className="space-y-3">
@@ -250,21 +238,31 @@ export function ProviderDetail({ id }: { id: string }) {
250
238
  className={`relative h-5 w-9 rounded-full transition-colors ${provider.strategy === "round-robin" ? "bg-accent" : "bg-border-subtle"}`}
251
239
  aria-label="Toggle round-robin"
252
240
  >
253
- <span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${provider.strategy === "round-robin" ? "left-[18px]" : "left-0.5"}`} />
241
+ <span className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${provider.strategy === "round-robin" ? "translate-x-[18px]" : "translate-x-0"}`} />
254
242
  </button>
255
243
  {provider.strategy === "round-robin" && (
256
- <div className="flex items-center gap-1">
244
+ <div className="flex items-center gap-1.5">
257
245
  <span className="text-[11px] text-text-subtle">Sticky:</span>
258
- <input
259
- type="number"
260
- min={1}
261
- value={provider.sticky ?? 1}
262
- onChange={(e) => {
263
- const v = Number(e.target.value) || 1;
264
- void adminApi.setProviderStrategy(id, "round-robin", v).then(() => reload());
265
- }}
266
- className="w-12 rounded border border-border-subtle bg-transparent px-1.5 py-0.5 text-[11px] text-text focus:border-accent focus:outline-none"
267
- />
246
+ <div className="flex items-center rounded-brand border border-border-subtle">
247
+ <button
248
+ type="button"
249
+ disabled={(provider.sticky ?? 1) <= 1}
250
+ onClick={() => void adminApi.setProviderStrategy(id, "round-robin", Math.max(1, (provider.sticky ?? 1) - 1)).then(() => reload())}
251
+ className="px-1.5 py-0.5 text-text-subtle transition-colors hover:text-text disabled:opacity-30"
252
+ aria-label="Decrease sticky"
253
+ >
254
+ <Icon name="remove" size={13} />
255
+ </button>
256
+ <span className="tnum w-6 text-center text-[11px] text-text">{provider.sticky ?? 1}</span>
257
+ <button
258
+ type="button"
259
+ onClick={() => void adminApi.setProviderStrategy(id, "round-robin", (provider.sticky ?? 1) + 1).then(() => reload())}
260
+ className="px-1.5 py-0.5 text-text-subtle transition-colors hover:text-text"
261
+ aria-label="Increase sticky"
262
+ >
263
+ <Icon name="add" size={13} />
264
+ </button>
265
+ </div>
268
266
  </div>
269
267
  )}
270
268
  </div>
@@ -83,11 +83,15 @@ export function ProviderManager() {
83
83
  <Link
84
84
  key={p.id}
85
85
  href={`/providers/${encodeURIComponent(p.id)}`}
86
- className="group rounded-brand-lg border border-border bg-surface p-4 shadow-soft transition-colors hover:border-text-subtle"
86
+ className={`group rounded-brand-lg border bg-surface p-4 shadow-soft transition-colors ${
87
+ p.disabled
88
+ ? "border-danger/35 opacity-60 hover:opacity-100 hover:border-danger/60"
89
+ : "border-border hover:border-text-subtle"
90
+ }`}
87
91
  >
88
92
  <div className="flex items-start justify-between gap-2">
89
93
  <div className="flex items-center gap-2 min-w-0">
90
- <Lamp state={healthy ? "live" : "down"} />
94
+ <Lamp state={p.disabled ? "idle" : healthy ? "live" : "down"} />
91
95
  <div className="min-w-0">
92
96
  <span className="block truncate text-[14px] font-semibold text-text">{p.name || p.id}</span>
93
97
  {p.name && <span className="block truncate text-[11px] text-text-subtle">{p.id}/</span>}
@@ -97,7 +101,7 @@ export function ProviderManager() {
97
101
  </div>
98
102
  <div className="mt-2 truncate text-[12px] text-text-subtle">{p.base_url}</div>
99
103
  <div className="mt-3 flex flex-wrap items-center gap-2">
100
- {p.disabled && <Badge tone="warn">disabled</Badge>}
104
+ <ProviderToggle id={p.id} disabled={!!p.disabled} onDone={reload} />
101
105
  {p.free && <Badge tone="info">free</Badge>}
102
106
  {p.service_account && <Badge tone="info">service-account</Badge>}
103
107
  <Badge tone="neutral">
@@ -135,6 +139,35 @@ export function ProviderManager() {
135
139
  );
136
140
  }
137
141
 
142
+ /**
143
+ * Inline enable/disable switch shown on each provider card. The card is a <Link>,
144
+ * so the button swallows the click (preventDefault + stopPropagation) to toggle in
145
+ * place instead of navigating into the provider. `busy` ignores double-clicks.
146
+ */
147
+ function ProviderToggle({ id, disabled, onDone }: { id: string; disabled: boolean; onDone: () => void }) {
148
+ const [busy, setBusy] = useState(false);
149
+ return (
150
+ <button
151
+ type="button"
152
+ onClick={(e) => {
153
+ e.preventDefault();
154
+ e.stopPropagation();
155
+ if (busy) return;
156
+ setBusy(true);
157
+ void adminApi.setProviderDisabled(id, !disabled).then(() => onDone()).finally(() => setBusy(false));
158
+ }}
159
+ className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${disabled ? "text-danger" : "text-text-muted"}`}
160
+ aria-label={disabled ? "Enable provider" : "Disable provider"}
161
+ title={disabled ? "Provider disabled — click to enable" : "Provider enabled — click to disable"}
162
+ >
163
+ <span className={`relative h-4 w-7 rounded-full transition-colors ${disabled ? "bg-danger" : "bg-accent"} ${busy ? "opacity-60" : ""}`}>
164
+ <span className={`absolute left-0.5 top-0.5 h-3 w-3 rounded-full bg-white transition-transform ${disabled ? "translate-x-0" : "translate-x-[14px]"}`} />
165
+ </span>
166
+ {disabled ? "disabled" : "enabled"}
167
+ </button>
168
+ );
169
+ }
170
+
138
171
  // Provider presets — pick a type first, which prefills Base URL + API Type, then
139
172
  // you only fill Name + Key. Matches aigetwey's per-type forms but friendlier; the
140
173
  // fields below are still aigetwey's (Name, API Type, Base URL, Key + Check, Model
@@ -6,29 +6,27 @@ import { Badge } from "@/components/Badge";
6
6
  import { RichCard, CardTitle } from "@/components/RichCard";
7
7
  import { CooldownTimer } from "@/components/CooldownTimer";
8
8
  import { fmt, Empty } from "@/components/ui";
9
- import { BudgetEditor } from "@/components/BudgetEditor";
9
+ import { BudgetForm } from "@/components/BudgetForm";
10
+ import { Button } from "@/components/Button";
11
+ import { Icon } from "@/components/Icon";
10
12
  import type { QuotaSnapshot, BudgetStatus } from "@/lib/gateway";
11
13
 
12
14
  /**
13
- * Quota Tracker the per-provider token budgets that were only
14
- * visible as a strip on each provider card, now their own page: consumption vs
15
- * limit, a fill bar, and a live countdown to the next scheduled window reset.
16
- *
17
- * Also renders the global budget card (if configured) above the provider grid.
15
+ * Budget Tracker scoped spend budgets (global / per-provider / per-model /
16
+ * per-key) with an Add / Edit / Remove flow, shown above the per-provider token
17
+ * quota grid (the older hard token cap that drives each provider card's reset
18
+ * countdown): consumption vs limit, a fill bar, and a live reset countdown.
18
19
  */
19
20
  export function QuotaView() {
20
21
  const [quota, setQuota] = useState<QuotaSnapshot[] | null>(null);
21
- const [budget, setBudget] = useState<BudgetStatus | null>(null);
22
- const [editing, setEditing] = useState(false);
22
+ const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
23
+ const [form, setForm] = useState<{ open: boolean; initial: BudgetStatus | null }>({ open: false, initial: null });
23
24
  const [error, setError] = useState("");
24
25
 
25
26
  const refresh = () =>
26
27
  void adminApi.quota().then((r) => {
27
28
  if (!r.ok) setError(r.error ?? "could not reach the gateway");
28
- else {
29
- setQuota(r.data?.quota ?? []);
30
- setBudget(r.data?.budget ?? null);
31
- }
29
+ else { setQuota(r.data?.quota ?? []); setBudgets(r.data?.budgets ?? []); }
32
30
  });
33
31
 
34
32
  useEffect(() => { refresh(); }, []);
@@ -39,81 +37,96 @@ export function QuotaView() {
39
37
  return (
40
38
  <div>
41
39
  <div className="mb-6">
42
- <h1 className="text-[22px] font-semibold tracking-tight text-text">Quota Tracker</h1>
40
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">Budget Tracker</h1>
43
41
  <p className="mt-1 text-[13px] text-text-muted">
44
- Per-provider token budgets and when each window resets.
42
+ Spend caps (USD or tokens) and per-provider token quotas, with live reset countdowns.
45
43
  </p>
46
44
  </div>
47
45
 
48
- {/* ── Global budget card ── */}
49
- <div className="mb-4">
50
- <RichCard
51
- header={
52
- <>
53
- <CardTitle title="Global budget" sub={budget ? `window · ${budget.window}` : "not set"} />
54
- {budget ? (
55
- <Badge tone={budget.exhausted ? "down" : budget.alert ? "warn" : "live"}>
56
- {budget.exhausted ? "exhausted" : budget.alert ? "alert" : "active"}
57
- </Badge>
58
- ) : null}
59
- </>
60
- }
61
- >
62
- {editing ? (
63
- <BudgetEditor
64
- initial={budget}
65
- onSaved={() => { setEditing(false); refresh(); }}
66
- onCancel={() => setEditing(false)}
67
- />
68
- ) : budget ? (
69
- <div className="space-y-2.5">
70
- <div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
71
- <div
72
- className={`h-full rounded-full transition-all ${budget.exhausted ? "bg-danger" : budget.alert ? "bg-warning" : "bg-accent"}`}
73
- style={{ width: `${Math.min(100, Math.round(budget.pct * 100))}%` }}
74
- />
75
- </div>
76
- <div className="flex items-center justify-between text-[12px]">
77
- <span className="tnum text-text-muted">
78
- {budget.unit === "usd"
79
- ? `$${budget.spent.toFixed(2)} / $${budget.limit.toFixed(2)}`
80
- : `${fmt.compact(budget.spent)} / ${fmt.compact(budget.limit)} tokens`}
81
- {budget.est_converse != null
82
- ? budget.unit === "usd"
83
- ? ` · ~${fmt.compact(budget.est_converse)} tok`
84
- : ` · ~$${budget.est_converse.toFixed(2)}`
85
- : " · —"}
86
- </span>
87
- <CooldownTimer ms={budget.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
88
- </div>
89
- <div className="flex items-center gap-3">
90
- <button type="button" onClick={() => setEditing(true)} className="text-[12px] text-accent hover:underline">
91
- Edit
92
- </button>
93
- <button
94
- type="button"
95
- onClick={async () => { const r = await adminApi.clearBudget(); if (!r.ok) setError(r.error ?? "could not clear budget"); refresh(); }}
96
- className="text-[12px] text-text-muted hover:text-danger"
97
- >
98
- Clear
99
- </button>
100
- </div>
101
- </div>
102
- ) : (
103
- <button type="button" onClick={() => setEditing(true)} className="text-[13px] text-accent hover:underline">
104
- Set a budget
105
- </button>
46
+ {/* ── Budgets ── */}
47
+ <div className="mb-6">
48
+ <div className="mb-3 flex items-center justify-between">
49
+ <h2 className="text-[15px] font-semibold text-text">Budgets</h2>
50
+ {!form.open && (
51
+ <Button onClick={() => setForm({ open: true, initial: null })}>
52
+ <Icon name="add" size={16} /> Add budget
53
+ </Button>
106
54
  )}
107
- </RichCard>
55
+ </div>
56
+
57
+ {form.open && (
58
+ <BudgetForm
59
+ key={form.initial?.key ?? "new"}
60
+ initial={form.initial}
61
+ onSaved={() => { setForm({ open: false, initial: null }); refresh(); }}
62
+ onCancel={() => setForm({ open: false, initial: null })}
63
+ />
64
+ )}
65
+
66
+ {budgets.length === 0 ? (
67
+ !form.open && <Empty>No budgets yet. Add one to cap spend globally, per provider, or per model.</Empty>
68
+ ) : (
69
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
70
+ {budgets.map((b) => (
71
+ <RichCard
72
+ key={b.key}
73
+ header={
74
+ <>
75
+ <CardTitle title={b.label} sub={`${b.scope.type} · ${b.window}`} />
76
+ <Badge tone={b.exhausted ? "down" : b.alert ? "warn" : "live"}>
77
+ {b.exhausted ? "exhausted" : b.alert ? "alert" : "active"}
78
+ </Badge>
79
+ </>
80
+ }
81
+ >
82
+ <div className="space-y-2.5">
83
+ {b.note && <p className="text-[12px] text-text-muted">{b.note}</p>}
84
+ <div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
85
+ <div
86
+ className={`h-full rounded-full transition-all ${b.exhausted ? "bg-danger" : b.alert ? "bg-warning" : "bg-accent"}`}
87
+ style={{ width: `${Math.min(100, Math.round(b.pct * 100))}%` }}
88
+ />
89
+ </div>
90
+ <div className="flex items-center justify-between text-[12px]">
91
+ <span className="tnum text-text-muted">
92
+ {b.unit === "usd"
93
+ ? `$${b.spent.toFixed(2)} / $${b.limit.toFixed(2)}`
94
+ : `${fmt.compact(b.spent)} / ${fmt.compact(b.limit)} tokens`}
95
+ {b.est_converse != null
96
+ ? b.unit === "usd" ? ` · ~${fmt.compact(b.est_converse)} tok` : ` · ~$${b.est_converse.toFixed(2)}`
97
+ : " · —"}
98
+ </span>
99
+ <CooldownTimer ms={b.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
100
+ </div>
101
+ <div className="flex items-center gap-2 border-t border-border-subtle pt-2.5">
102
+ <Button variant="ghost" className="px-2.5 py-1 text-[12px]" onClick={() => setForm({ open: true, initial: b })}>
103
+ <Icon name="edit" size={14} /> Edit
104
+ </Button>
105
+ <Button
106
+ variant="danger"
107
+ className="px-2.5 py-1 text-[12px]"
108
+ onClick={async () => { const r = await adminApi.clearBudget(b.key); if (!r.ok) setError(r.error ?? "could not remove budget"); refresh(); }}
109
+ >
110
+ <Icon name="delete" size={14} /> Remove
111
+ </Button>
112
+ </div>
113
+ </div>
114
+ </RichCard>
115
+ ))}
116
+ </div>
117
+ )}
108
118
  </div>
109
119
 
110
- {/* ── Per-provider quota grid ── */}
111
- {quota.length === 0 ? (
112
- <Empty>
113
- No quotas configured. Add a <span className="tnum">quota</span> block to a provider in Settings.
114
- </Empty>
115
- ) : (
116
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
120
+ {/* ── Per-provider quota grid only shown once a provider actually has a
121
+ `quota:` cap configured; superseded by per-provider token budgets, so we
122
+ don't advertise it with an empty state. ── */}
123
+ {quota.length > 0 && (
124
+ <>
125
+ <div className="mb-3 flex items-baseline justify-between gap-3">
126
+ <h2 className="text-[15px] font-semibold text-text">Provider quotas</h2>
127
+ <span className="text-[12px] text-text-subtle">hard token cap per provider, per window</span>
128
+ </div>
129
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
117
130
  {quota.map((q) => (
118
131
  <RichCard
119
132
  key={q.provider}
@@ -145,7 +158,8 @@ export function QuotaView() {
145
158
  </div>
146
159
  </RichCard>
147
160
  ))}
148
- </div>
161
+ </div>
162
+ </>
149
163
  )}
150
164
  </div>
151
165
  );
@@ -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: "Quota Tracker", icon: "data_usage" },
17
+ { href: "/quota", label: "Budget Tracker", icon: "data_usage" },
18
18
  { href: "/tools", label: "CLI Tools", icon: "terminal" },
19
19
  ];
20
20
 
@@ -91,14 +91,14 @@ export function RoutingView() {
91
91
 
92
92
  {adding && (
93
93
  <RouteForm
94
- providers={config.providers}
94
+ providers={config.providers.filter((p) => !p.disabled)}
95
95
  onDone={() => { setAdding(false); void reload(); }}
96
96
  />
97
97
  )}
98
98
 
99
99
  {editing && !adding && (
100
100
  <RouteForm
101
- providers={config.providers}
101
+ providers={config.providers.filter((p) => !p.disabled)}
102
102
  initial={editing}
103
103
  onDone={() => { setEditing(null); void reload(); }}
104
104
  onCancel={() => setEditing(null)}
@@ -111,13 +111,15 @@ export function ToolDetail({ id }: { id: string }) {
111
111
  const cfg = (await cfgRes.json()) as MaskedConfig;
112
112
  const aliases = cfg.models.map((m) => m.alias);
113
113
  setCombos(aliases);
114
- // everything callable: combo aliases + every provider/model ref.
115
- const refs = cfg.providers.flatMap((p) => p.models.map((m) => `${p.id}/${m.id}`));
114
+ // disabled providers are skipped in routing, so hide their models here.
115
+ const liveProviders = cfg.providers.filter((p) => !p.disabled);
116
+ // everything callable: combo aliases + every (enabled) provider/model ref.
117
+ const refs = liveProviders.flatMap((p) => p.models.map((m) => `${p.id}/${m.id}`));
116
118
  setAllModels([...aliases, ...refs]);
117
119
  // grouped for the picker: Combos first, then one group per provider.
118
120
  const grps: ModelGroup[] = [];
119
121
  if (aliases.length) grps.push({ label: "Combos", items: aliases.map((a) => ({ value: a, label: a })) });
120
- for (const p of cfg.providers) {
122
+ for (const p of liveProviders) {
121
123
  if (p.models.length) grps.push({ label: p.id, items: p.models.map((m) => ({ value: `${p.id}/${m.id}`, label: `${p.id}/${m.id}` })) });
122
124
  }
123
125
  setGroups(grps);
@@ -13,7 +13,7 @@ const LABELS: Record<string, string> = {
13
13
  providers: "Providers",
14
14
  combos: "Combos",
15
15
  usage: "Usage",
16
- quota: "Quota Tracker",
16
+ quota: "Budget Tracker",
17
17
  tools: "CLI Tools",
18
18
  console: "Server Console",
19
19
  config: "Settings",
@@ -6,15 +6,34 @@ import { Stat, fmt, Empty } from "@/components/ui";
6
6
  import { RichCard } from "@/components/RichCard";
7
7
  import type { UsageSummary } from "@/lib/gateway";
8
8
 
9
- type Window = { label: string; ms: number; bucketMs: number };
9
+ type Window = { label: string; key: "today" | "24h" | "7d" | "30d" | "60d"; bucketMs: number };
10
10
 
11
- // window -> (lookback, chart bucket size). Buckets keep ~24-48 points per range.
11
+ // window -> chart bucket size. Buckets keep ~24-60 points per range.
12
12
  const WINDOWS: Window[] = [
13
- { label: "24h", ms: 24 * 3600_000, bucketMs: 3600_000 },
14
- { label: "7d", ms: 7 * 86400_000, bucketMs: 6 * 3600_000 },
15
- { label: "30d", ms: 30 * 86400_000, bucketMs: 86400_000 },
13
+ { label: "Today", key: "today", bucketMs: 3600_000 },
14
+ { label: "24h", key: "24h", bucketMs: 3600_000 },
15
+ { label: "7D", key: "7d", bucketMs: 6 * 3600_000 },
16
+ { label: "30D", key: "30d", bucketMs: 86400_000 },
17
+ { label: "60D", key: "60d", bucketMs: 2 * 86400_000 },
16
18
  ];
17
19
 
20
+ /** Lookback start (ms epoch) for a window. "Today" is since local midnight; the
21
+ * rest are rolling lookbacks from now. */
22
+ function sinceFor(key: Window["key"]): number {
23
+ const now = Date.now();
24
+ switch (key) {
25
+ case "today": {
26
+ const d = new Date();
27
+ d.setHours(0, 0, 0, 0);
28
+ return d.getTime();
29
+ }
30
+ case "24h": return now - 24 * 3600_000;
31
+ case "7d": return now - 7 * 86400_000;
32
+ case "30d": return now - 30 * 86400_000;
33
+ case "60d": return now - 60 * 86400_000;
34
+ }
35
+ }
36
+
18
37
  export function UsageView() {
19
38
  const [win, setWin] = useState<Window>(WINDOWS[0]!);
20
39
  const [summary, setSummary] = useState<UsageSummary | null>(null);
@@ -25,7 +44,7 @@ export function UsageView() {
25
44
  const load = useCallback(async (w: Window) => {
26
45
  setLoading(true);
27
46
  setError("");
28
- const since = Date.now() - w.ms;
47
+ const since = sinceFor(w.key);
29
48
  const [sumRes, serRes] = await Promise.all([
30
49
  fetch(`/api/gw/admin/usage?since=${since}`),
31
50
  fetch(`/api/gw/admin/usage/series?since=${since}&bucket=${w.bucketMs}`),
@@ -55,20 +55,6 @@ export const CLI_TOOLS: CliTool[] = [
55
55
  "The gateway translates Anthropic ↔ provider format, so any provider works behind it.",
56
56
  ],
57
57
  },
58
- {
59
- id: "codex",
60
- name: "Codex",
61
- icon: "code",
62
- format: "openai",
63
- blurb: "OpenAI-compatible. Use the /v1 base URL.",
64
- install: "npm i -g @openai/codex",
65
- slots: [{ label: "Model", alias: "gpt-5" }],
66
- env: (base, key) => [
67
- { name: "OPENAI_BASE_URL", value: `${base}/v1` },
68
- { name: "OPENAI_API_KEY", value: KEY(key) },
69
- ],
70
- steps: ["Set the base URL to the gateway's /v1 path.", "Create a combo named like the slot above, then use it as the model."],
71
- },
72
58
  {
73
59
  id: "opencode",
74
60
  name: "opencode",
@@ -84,35 +70,6 @@ export const CLI_TOOLS: CliTool[] = [
84
70
  ],
85
71
  steps: ["Add an OpenAI-compatible provider with the gateway /v1 base URL.", "Pick a combo alias as the model."],
86
72
  },
87
- {
88
- id: "cursor",
89
- name: "Cursor",
90
- icon: "edit_square",
91
- format: "openai",
92
- blurb: "OpenAI-compatible. Override the base URL in settings.",
93
- slots: [{ label: "Model", alias: "gpt-5" }],
94
- env: (base, key) => [
95
- { name: "Base URL", value: `${base}/v1` },
96
- { name: "API Key", value: KEY(key) },
97
- ],
98
- steps: [
99
- "Settings → Models → OpenAI API Key → override base URL with the gateway /v1.",
100
- "Add your combo aliases as custom model names.",
101
- ],
102
- },
103
- {
104
- id: "cline",
105
- name: "Cline",
106
- icon: "extension",
107
- format: "openai",
108
- blurb: "OpenAI-compatible VS Code agent.",
109
- slots: [{ label: "Model", alias: "gpt-5" }],
110
- env: (base, key) => [
111
- { name: "Base URL", value: `${base}/v1` },
112
- { name: "API Key", value: KEY(key) },
113
- ],
114
- steps: ["Choose the OpenAI-compatible provider.", "Set the base URL to the gateway /v1 and use a combo alias."],
115
- },
116
73
  ];
117
74
 
118
75
  export function toolById(id: string): CliTool | undefined {
@@ -12,6 +12,7 @@ import type {
12
12
  EndpointPayload,
13
13
  HeadroomStatusReply,
14
14
  InjectLevel,
15
+ ModelsPayload,
15
16
  PingResult,
16
17
  PricingPayload,
17
18
  ProviderSnapshot,
@@ -53,16 +54,21 @@ async function api<T>(method: string, path: string, body?: unknown): Promise<Api
53
54
 
54
55
  export const adminApi = {
55
56
  providers: () => api<{ providers: ProviderSnapshot[] }>("GET", "/admin/providers"),
56
- quota: () => api<{ quota: QuotaSnapshot[]; budget: BudgetStatus | null }>("GET", "/admin/quota"),
57
+ quota: () => api<{ quota: QuotaSnapshot[]; budgets: BudgetStatus[] }>("GET", "/admin/quota"),
58
+ models: () => api<ModelsPayload>("GET", "/admin/models"),
59
+ keys: () => api<Array<{ fingerprint: string; name: string; masked: string }>>("GET", "/admin/keys"),
60
+
57
61
  setBudget: (body: {
62
+ scope: { type: "global" } | { type: "provider"; id: string } | { type: "model"; id: string } | { type: "key"; id: string };
58
63
  unit: "usd" | "tokens";
59
64
  limit: number;
60
65
  window: "5h" | "daily" | "weekly" | "monthly";
61
66
  reset_at?: string;
62
67
  timezone?: string;
63
68
  alert_at?: number;
64
- }) => api<ConfigReply>("PUT", "/admin/budget", body),
65
- clearBudget: () => api<ConfigReply>("DELETE", "/admin/budget"),
69
+ note?: string;
70
+ }) => api<ConfigReply>("PUT", "/admin/budgets", body),
71
+ clearBudget: (key: string) => api<ConfigReply>("DELETE", `/admin/budgets/${encodeURIComponent(key)}`),
66
72
 
67
73
  addProvider: (p: {
68
74
  id: string;