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
@@ -15,11 +15,30 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
15
15
  { key: "error", label: "Errors" },
16
16
  ];
17
17
 
18
+ // shared control style for the filter row — bordered surface chip, accent on focus.
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
+
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
+
18
35
  export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
19
36
  const [logs, setLogs] = useState(initial);
20
37
  const [filter, setFilter] = useState<StatusFilter>("all");
21
38
  const [provFilter, setProvFilter] = useState<string>("all");
39
+ const [sinceMs, setSinceMs] = useState<number | null>(null);
22
40
  const [live, setLive] = useState(true);
41
+ const [collapsed, setCollapsed] = useState(false);
23
42
  const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
24
43
  const timer = useRef<ReturnType<typeof setInterval> | null>(null);
25
44
 
@@ -46,45 +65,49 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
46
65
  const okCount = logs.filter((l) => l.status >= 200 && l.status < 300).length;
47
66
  const errCount = logs.length - okCount;
48
67
 
68
+ const sinceFloor = sinceMs !== null ? Date.now() - sinceMs : null;
69
+
49
70
  const shown = logs.filter((l) => {
50
71
  if (filter === "ok" && !(l.status >= 200 && l.status < 300)) return false;
51
72
  if (filter === "error" && l.status >= 200 && l.status < 300) return false;
52
73
  if (provFilter !== "all" && l.provider !== provFilter) return false;
74
+ if (sinceFloor !== null && l.ts < sinceFloor) return false;
53
75
  return true;
54
76
  });
55
77
 
78
+ const hasFilters = filter !== "all" || provFilter !== "all" || sinceMs !== null;
79
+ const clearFilters = () => { setFilter("all"); setProvFilter("all"); setSinceMs(null); };
80
+
56
81
  const count = (k: StatusFilter) => (k === "all" ? logs.length : k === "ok" ? okCount : errCount);
57
82
 
58
83
  return (
59
84
  <div className="overflow-hidden rounded-brand-lg border border-border bg-surface shadow-soft">
60
- <header className="flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle px-4 py-3">
61
- <div className="flex items-center gap-1">
62
- {FILTERS.map((f) => (
85
+ <header className="flex flex-col gap-3 border-b border-border-subtle px-4 py-3">
86
+ <div className="flex flex-wrap items-center justify-between gap-3">
87
+ <div className="flex items-center gap-1.5">
63
88
  <button
64
- key={f.key}
65
- onClick={() => setFilter(f.key)}
66
- className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12px] font-medium transition-colors ${
67
- filter === f.key ? "bg-surface-2 text-text" : "text-text-muted hover:text-text"
68
- }`}
89
+ onClick={() => setCollapsed((v) => !v)}
90
+ className="flex-none rounded p-0.5 text-text-subtle transition-colors hover:text-text"
91
+ aria-label={collapsed ? "Expand requests" : "Collapse requests"}
92
+ title={collapsed ? "Expand" : "Collapse"}
69
93
  >
70
- {f.label}
71
- <span className="tnum text-text-subtle">{count(f.key)}</span>
94
+ <Icon name={collapsed ? "chevron_right" : "expand_more"} size={18} />
72
95
  </button>
73
- ))}
74
- </div>
75
- <div className="flex items-center gap-2">
76
- {providers.length > 1 && (
77
- <select
78
- value={provFilter}
79
- onChange={(e) => setProvFilter(e.target.value)}
80
- className="rounded border border-border-subtle bg-transparent px-2 py-1 text-[11px] text-text-muted focus:border-accent focus:outline-none"
81
- >
82
- <option value="all">All providers</option>
83
- {providers.map((p) => (
84
- <option key={p} value={p}>{p}</option>
96
+ <div className="flex items-center gap-1">
97
+ {FILTERS.map((f) => (
98
+ <button
99
+ key={f.key}
100
+ onClick={() => setFilter(f.key)}
101
+ className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12px] font-medium transition-colors ${
102
+ filter === f.key ? "bg-surface-2 text-text" : "text-text-muted hover:text-text"
103
+ }`}
104
+ >
105
+ {f.label}
106
+ <span className="tnum text-text-subtle">{count(f.key)}</span>
107
+ </button>
85
108
  ))}
86
- </select>
87
- )}
109
+ </div>
110
+ </div>
88
111
  <button
89
112
  onClick={() => setLive((v) => !v)}
90
113
  className={`flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
@@ -96,9 +119,49 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
96
119
  Live
97
120
  </button>
98
121
  </div>
122
+
123
+ {!collapsed && (
124
+ <div className="flex flex-wrap items-end gap-2.5">
125
+ <FilterField label="Provider">
126
+ <select
127
+ value={provFilter}
128
+ onChange={(e) => setProvFilter(e.target.value)}
129
+ className={ctrl + " w-40"}
130
+ >
131
+ <option value="all">All providers</option>
132
+ {providers.map((p) => (
133
+ <option key={p} value={p}>{p}</option>
134
+ ))}
135
+ </select>
136
+ </FilterField>
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>
150
+ </FilterField>
151
+ <button
152
+ onClick={clearFilters}
153
+ disabled={!hasFilters}
154
+ className="flex h-9 items-center gap-1.5 rounded-brand border border-border bg-surface-2 px-3 text-[12.5px] font-medium text-text-muted transition-colors hover:border-text-subtle hover:text-text disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-muted"
155
+ title={hasFilters ? "Reset all filters" : "No filters applied"}
156
+ >
157
+ <Icon name="filter_alt_off" size={15} />
158
+ Clear
159
+ </button>
160
+ </div>
161
+ )}
99
162
  </header>
100
163
 
101
- {shown.length === 0 ? (
164
+ {collapsed ? null : shown.length === 0 ? (
102
165
  <div className="px-4 py-8 text-center text-[13px] text-text-muted">
103
166
  {logs.length === 0 ? "No requests recorded yet." : "No requests match this filter."}
104
167
  </div>
@@ -158,6 +221,15 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
158
221
  );
159
222
  }
160
223
 
224
+ function FilterField({ label, children }: { label: string; children: React.ReactNode }) {
225
+ return (
226
+ <label className="flex flex-col gap-1">
227
+ <span className="text-[10px] font-medium uppercase tracking-wider text-text-subtle">{label}</span>
228
+ {children}
229
+ </label>
230
+ );
231
+ }
232
+
161
233
  function Td({
162
234
  children,
163
235
  muted,
@@ -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>
@@ -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,16 +74,19 @@ 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}
85
80
  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"
81
+ className={`group rounded-brand-lg border bg-surface p-4 shadow-soft transition-colors ${
82
+ p.disabled
83
+ ? "border-danger/35 opacity-60 hover:opacity-100 hover:border-danger/60"
84
+ : "border-border hover:border-text-subtle"
85
+ }`}
87
86
  >
88
87
  <div className="flex items-start justify-between gap-2">
89
88
  <div className="flex items-center gap-2 min-w-0">
90
- <Lamp state={healthy ? "live" : "down"} />
89
+ <Lamp state={p.disabled ? "idle" : healthy ? "live" : "down"} />
91
90
  <div className="min-w-0">
92
91
  <span className="block truncate text-[14px] font-semibold text-text">{p.name || p.id}</span>
93
92
  {p.name && <span className="block truncate text-[11px] text-text-subtle">{p.id}/</span>}
@@ -97,7 +96,7 @@ export function ProviderManager() {
97
96
  </div>
98
97
  <div className="mt-2 truncate text-[12px] text-text-subtle">{p.base_url}</div>
99
98
  <div className="mt-3 flex flex-wrap items-center gap-2">
100
- {p.disabled && <Badge tone="warn">disabled</Badge>}
99
+ <ProviderToggle id={p.id} disabled={!!p.disabled} onDone={reload} />
101
100
  {p.free && <Badge tone="info">free</Badge>}
102
101
  {p.service_account && <Badge tone="info">service-account</Badge>}
103
102
  <Badge tone="neutral">
@@ -106,26 +105,6 @@ export function ProviderManager() {
106
105
  <Badge tone="neutral">{p.models.length} models</Badge>
107
106
  {cooling && <CooldownTimer ms={cooling.cooldown_ms} />}
108
107
  </div>
109
- {q && (
110
- <div className="mt-3 border-t border-border-subtle pt-2.5">
111
- <div className="flex items-center justify-between text-[11px] text-text-subtle">
112
- <span>quota · {q.window}</span>
113
- <CooldownTimer ms={q.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
114
- </div>
115
- {q.limit_tokens && (
116
- <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-surface-2">
117
- <div
118
- className={`h-full ${q.exhausted ? "bg-danger" : "bg-accent"}`}
119
- style={{ width: `${Math.round((q.pct ?? 0) * 100)}%` }}
120
- />
121
- </div>
122
- )}
123
- <div className="mt-1 tnum text-[11px] text-text-muted">
124
- {fmt.compact(q.consumed)}
125
- {q.limit_tokens ? ` / ${fmt.compact(q.limit_tokens)}` : ""} tokens
126
- </div>
127
- </div>
128
- )}
129
108
  </Link>
130
109
  );
131
110
  })}
@@ -135,6 +114,35 @@ export function ProviderManager() {
135
114
  );
136
115
  }
137
116
 
117
+ /**
118
+ * Inline enable/disable switch shown on each provider card. The card is a <Link>,
119
+ * so the button swallows the click (preventDefault + stopPropagation) to toggle in
120
+ * place instead of navigating into the provider. `busy` ignores double-clicks.
121
+ */
122
+ function ProviderToggle({ id, disabled, onDone }: { id: string; disabled: boolean; onDone: () => void }) {
123
+ const [busy, setBusy] = useState(false);
124
+ return (
125
+ <button
126
+ type="button"
127
+ onClick={(e) => {
128
+ e.preventDefault();
129
+ e.stopPropagation();
130
+ if (busy) return;
131
+ setBusy(true);
132
+ void adminApi.setProviderDisabled(id, !disabled).then(() => onDone()).finally(() => setBusy(false));
133
+ }}
134
+ className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${disabled ? "text-danger" : "text-text-muted"}`}
135
+ aria-label={disabled ? "Enable provider" : "Disable provider"}
136
+ title={disabled ? "Provider disabled — click to enable" : "Provider enabled — click to disable"}
137
+ >
138
+ <span className={`relative h-4 w-7 rounded-full transition-colors ${disabled ? "bg-danger" : "bg-accent"} ${busy ? "opacity-60" : ""}`}>
139
+ <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]"}`} />
140
+ </span>
141
+ {disabled ? "disabled" : "enabled"}
142
+ </button>
143
+ );
144
+ }
145
+
138
146
  // Provider presets — pick a type first, which prefills Base URL + API Type, then
139
147
  // you only fill Name + Key. Matches aigetwey's per-type forms but friendlier; the
140
148
  // fields below are still aigetwey's (Name, API Type, Base URL, Key + Check, Model
@@ -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: "Budgets", 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)}
@@ -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")}>
@@ -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: "Budgets",
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}`),
@@ -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`;