aigetwey 1.0.1 → 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.
- package/CHANGELOG.md +58 -3
- package/README.md +4 -4
- package/config.example.yaml +6 -5
- package/dashboard/next.config.ts +6 -0
- package/dashboard/src/app/globals.css +47 -0
- package/dashboard/src/components/BudgetForm.tsx +258 -0
- package/dashboard/src/components/EndpointView.tsx +30 -0
- package/dashboard/src/components/LogTable.tsx +90 -25
- package/dashboard/src/components/ModelPicker.tsx +15 -7
- package/dashboard/src/components/ProviderDetail.tsx +27 -29
- package/dashboard/src/components/ProviderManager.tsx +36 -3
- package/dashboard/src/components/QuotaView.tsx +106 -18
- package/dashboard/src/components/Rail.tsx +1 -1
- package/dashboard/src/components/RoutingView.tsx +2 -2
- package/dashboard/src/components/ToolDetail.tsx +5 -3
- package/dashboard/src/components/TopBar.tsx +1 -1
- package/dashboard/src/components/UsageView.tsx +25 -6
- package/dashboard/src/lib/cliTools.ts +0 -43
- package/dashboard/src/lib/client.ts +17 -1
- package/dashboard/src/lib/gateway.ts +25 -1
- package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
- package/dist/cli.js +43 -8
- package/dist/cli.js.map +1 -1
- package/dist/config.js +75 -0
- package/dist/config.js.map +1 -1
- package/dist/core/budget.js +97 -0
- package/dist/core/budget.js.map +1 -0
- package/dist/core/handler.js +21 -1
- package/dist/core/handler.js.map +1 -1
- package/dist/core/quota.js +33 -7
- package/dist/core/quota.js.map +1 -1
- package/dist/core/state.js +17 -2
- package/dist/core/state.js.map +1 -1
- package/dist/db.js +39 -5
- package/dist/db.js.map +1 -1
- package/dist/middleware/auth.js +15 -8
- package/dist/middleware/auth.js.map +1 -1
- package/dist/routes/admin.js +34 -4
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/v1.js +15 -10
- package/dist/routes/v1.js.map +1 -1
- package/dist/server.js +5 -1
- package/dist/server.js.map +1 -1
- package/dist/upstream/client.js +9 -0
- package/dist/upstream/client.js.map +1 -1
- package/package.json +3 -4
- package/src/cli.ts +44 -8
- package/src/config.ts +81 -0
- package/src/core/budget.ts +128 -0
- package/src/core/handler.ts +26 -1
- package/src/core/quota.ts +40 -1
- package/src/core/state.ts +24 -0
- package/src/db.ts +50 -5
- package/src/middleware/auth.ts +18 -8
- package/src/routes/admin.ts +45 -7
- package/src/routes/v1.ts +15 -10
- package/src/server.ts +5 -1
- package/src/upstream/client.ts +9 -0
|
@@ -15,11 +15,17 @@ 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
|
+
|
|
18
21
|
export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
|
|
19
22
|
const [logs, setLogs] = useState(initial);
|
|
20
23
|
const [filter, setFilter] = useState<StatusFilter>("all");
|
|
21
24
|
const [provFilter, setProvFilter] = useState<string>("all");
|
|
25
|
+
const [startDate, setStartDate] = useState("");
|
|
26
|
+
const [endDate, setEndDate] = useState("");
|
|
22
27
|
const [live, setLive] = useState(true);
|
|
28
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
23
29
|
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
|
24
30
|
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
25
31
|
|
|
@@ -46,45 +52,51 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
|
|
|
46
52
|
const okCount = logs.filter((l) => l.status >= 200 && l.status < 300).length;
|
|
47
53
|
const errCount = logs.length - okCount;
|
|
48
54
|
|
|
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;
|
|
57
|
+
|
|
49
58
|
const shown = logs.filter((l) => {
|
|
50
59
|
if (filter === "ok" && !(l.status >= 200 && l.status < 300)) return false;
|
|
51
60
|
if (filter === "error" && l.status >= 200 && l.status < 300) return false;
|
|
52
61
|
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;
|
|
53
64
|
return true;
|
|
54
65
|
});
|
|
55
66
|
|
|
67
|
+
const hasFilters = filter !== "all" || provFilter !== "all" || startDate !== "" || endDate !== "";
|
|
68
|
+
const clearFilters = () => { setFilter("all"); setProvFilter("all"); setStartDate(""); setEndDate(""); };
|
|
69
|
+
|
|
56
70
|
const count = (k: StatusFilter) => (k === "all" ? logs.length : k === "ok" ? okCount : errCount);
|
|
57
71
|
|
|
58
72
|
return (
|
|
59
73
|
<div className="overflow-hidden rounded-brand-lg border border-border bg-surface shadow-soft">
|
|
60
|
-
<header className="flex flex-
|
|
61
|
-
<div className="flex items-center gap-
|
|
62
|
-
|
|
74
|
+
<header className="flex flex-col gap-3 border-b border-border-subtle px-4 py-3">
|
|
75
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
76
|
+
<div className="flex items-center gap-1.5">
|
|
63
77
|
<button
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}`}
|
|
78
|
+
onClick={() => setCollapsed((v) => !v)}
|
|
79
|
+
className="flex-none rounded p-0.5 text-text-subtle transition-colors hover:text-text"
|
|
80
|
+
aria-label={collapsed ? "Expand requests" : "Collapse requests"}
|
|
81
|
+
title={collapsed ? "Expand" : "Collapse"}
|
|
69
82
|
>
|
|
70
|
-
{
|
|
71
|
-
<span className="tnum text-text-subtle">{count(f.key)}</span>
|
|
83
|
+
<Icon name={collapsed ? "chevron_right" : "expand_more"} size={18} />
|
|
72
84
|
</button>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
<div className="flex items-center gap-1">
|
|
86
|
+
{FILTERS.map((f) => (
|
|
87
|
+
<button
|
|
88
|
+
key={f.key}
|
|
89
|
+
onClick={() => setFilter(f.key)}
|
|
90
|
+
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
|
91
|
+
filter === f.key ? "bg-surface-2 text-text" : "text-text-muted hover:text-text"
|
|
92
|
+
}`}
|
|
93
|
+
>
|
|
94
|
+
{f.label}
|
|
95
|
+
<span className="tnum text-text-subtle">{count(f.key)}</span>
|
|
96
|
+
</button>
|
|
85
97
|
))}
|
|
86
|
-
</
|
|
87
|
-
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
88
100
|
<button
|
|
89
101
|
onClick={() => setLive((v) => !v)}
|
|
90
102
|
className={`flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
|
@@ -96,9 +108,53 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
|
|
|
96
108
|
Live
|
|
97
109
|
</button>
|
|
98
110
|
</div>
|
|
111
|
+
|
|
112
|
+
{!collapsed && (
|
|
113
|
+
<div className="flex flex-wrap items-end gap-2.5">
|
|
114
|
+
<FilterField label="Provider">
|
|
115
|
+
<select
|
|
116
|
+
value={provFilter}
|
|
117
|
+
onChange={(e) => setProvFilter(e.target.value)}
|
|
118
|
+
className={ctrl + " w-40"}
|
|
119
|
+
>
|
|
120
|
+
<option value="all">All providers</option>
|
|
121
|
+
{providers.map((p) => (
|
|
122
|
+
<option key={p} value={p}>{p}</option>
|
|
123
|
+
))}
|
|
124
|
+
</select>
|
|
125
|
+
</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
|
+
/>
|
|
143
|
+
</FilterField>
|
|
144
|
+
<button
|
|
145
|
+
onClick={clearFilters}
|
|
146
|
+
disabled={!hasFilters}
|
|
147
|
+
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"
|
|
148
|
+
title={hasFilters ? "Reset all filters" : "No filters applied"}
|
|
149
|
+
>
|
|
150
|
+
<Icon name="filter_alt_off" size={15} />
|
|
151
|
+
Clear
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
99
155
|
</header>
|
|
100
156
|
|
|
101
|
-
{shown.length === 0 ? (
|
|
157
|
+
{collapsed ? null : shown.length === 0 ? (
|
|
102
158
|
<div className="px-4 py-8 text-center text-[13px] text-text-muted">
|
|
103
159
|
{logs.length === 0 ? "No requests recorded yet." : "No requests match this filter."}
|
|
104
160
|
</div>
|
|
@@ -158,6 +214,15 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
|
|
|
158
214
|
);
|
|
159
215
|
}
|
|
160
216
|
|
|
217
|
+
function FilterField({ label, children }: { label: string; children: React.ReactNode }) {
|
|
218
|
+
return (
|
|
219
|
+
<label className="flex flex-col gap-1">
|
|
220
|
+
<span className="text-[10px] font-medium uppercase tracking-wider text-text-subtle">{label}</span>
|
|
221
|
+
{children}
|
|
222
|
+
</label>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
161
226
|
function Td({
|
|
162
227
|
children,
|
|
163
228
|
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=
|
|
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
|
-
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
{
|
|
132
|
-
|
|
133
|
-
{
|
|
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=
|
|
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" ? "
|
|
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
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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=
|
|
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
|
|
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,23 +6,30 @@ 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
|
|
9
|
+
import { BudgetForm } from "@/components/BudgetForm";
|
|
10
|
+
import { Button } from "@/components/Button";
|
|
11
|
+
import { Icon } from "@/components/Icon";
|
|
12
|
+
import type { QuotaSnapshot, BudgetStatus } from "@/lib/gateway";
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
15
19
|
*/
|
|
16
20
|
export function QuotaView() {
|
|
17
21
|
const [quota, setQuota] = useState<QuotaSnapshot[] | null>(null);
|
|
22
|
+
const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
|
|
23
|
+
const [form, setForm] = useState<{ open: boolean; initial: BudgetStatus | null }>({ open: false, initial: null });
|
|
18
24
|
const [error, setError] = useState("");
|
|
19
25
|
|
|
20
|
-
|
|
26
|
+
const refresh = () =>
|
|
21
27
|
void adminApi.quota().then((r) => {
|
|
22
28
|
if (!r.ok) setError(r.error ?? "could not reach the gateway");
|
|
23
|
-
else setQuota(r.data?.quota ?? []);
|
|
29
|
+
else { setQuota(r.data?.quota ?? []); setBudgets(r.data?.budgets ?? []); }
|
|
24
30
|
});
|
|
25
|
-
|
|
31
|
+
|
|
32
|
+
useEffect(() => { refresh(); }, []);
|
|
26
33
|
|
|
27
34
|
if (error) return <Empty>{error}</Empty>;
|
|
28
35
|
if (!quota) return <Empty>Loading…</Empty>;
|
|
@@ -30,25 +37,105 @@ export function QuotaView() {
|
|
|
30
37
|
return (
|
|
31
38
|
<div>
|
|
32
39
|
<div className="mb-6">
|
|
33
|
-
<h1 className="text-[22px] font-semibold tracking-tight text-text">
|
|
40
|
+
<h1 className="text-[22px] font-semibold tracking-tight text-text">Budget Tracker</h1>
|
|
34
41
|
<p className="mt-1 text-[13px] text-text-muted">
|
|
35
|
-
|
|
42
|
+
Spend caps (USD or tokens) and per-provider token quotas, with live reset countdowns.
|
|
36
43
|
</p>
|
|
37
44
|
</div>
|
|
38
45
|
|
|
39
|
-
{
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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>
|
|
54
|
+
)}
|
|
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
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
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">
|
|
45
130
|
{quota.map((q) => (
|
|
46
131
|
<RichCard
|
|
47
132
|
key={q.provider}
|
|
48
133
|
header={
|
|
49
134
|
<>
|
|
50
135
|
<CardTitle title={q.provider} sub={`window · ${q.window}`} />
|
|
51
|
-
<Badge tone={q.exhausted ? "down" :
|
|
136
|
+
<Badge tone={q.exhausted ? "down" : q.alert ? "warn" : "live"}>
|
|
137
|
+
{q.exhausted ? "exhausted" : q.alert ? "alert" : "active"}
|
|
138
|
+
</Badge>
|
|
52
139
|
</>
|
|
53
140
|
}
|
|
54
141
|
>
|
|
@@ -56,7 +143,7 @@ export function QuotaView() {
|
|
|
56
143
|
{q.limit_tokens ? (
|
|
57
144
|
<div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
|
|
58
145
|
<div
|
|
59
|
-
className={`h-full ${q.exhausted ? "bg-danger" : "bg-accent"}`}
|
|
146
|
+
className={`h-full rounded-full transition-all ${q.exhausted ? "bg-danger" : q.alert ? "bg-warning" : "bg-accent"}`}
|
|
60
147
|
style={{ width: `${Math.round((q.pct ?? 0) * 100)}%` }}
|
|
61
148
|
/>
|
|
62
149
|
</div>
|
|
@@ -71,7 +158,8 @@ export function QuotaView() {
|
|
|
71
158
|
</div>
|
|
72
159
|
</RichCard>
|
|
73
160
|
))}
|
|
74
|
-
|
|
161
|
+
</div>
|
|
162
|
+
</>
|
|
75
163
|
)}
|
|
76
164
|
</div>
|
|
77
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: "
|
|
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
|
-
//
|
|
115
|
-
const
|
|
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
|
|
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);
|