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.
- package/CHANGELOG.md +65 -4
- package/README.md +32 -11
- package/config.example.yaml +6 -6
- package/dashboard/next.config.ts +6 -0
- package/dashboard/src/app/(console)/quota/page.tsx +2 -2
- package/dashboard/src/app/globals.css +47 -0
- package/dashboard/src/components/BudgetForm.tsx +256 -0
- package/dashboard/src/components/BudgetTracker.tsx +181 -0
- package/dashboard/src/components/CooldownTimer.tsx +1 -1
- package/dashboard/src/components/EndpointView.tsx +285 -47
- package/dashboard/src/components/LogTable.tsx +97 -25
- package/dashboard/src/components/ModelPicker.tsx +15 -7
- package/dashboard/src/components/ProviderDetail.tsx +27 -29
- package/dashboard/src/components/ProviderManager.tsx +39 -31
- package/dashboard/src/components/Rail.tsx +1 -1
- package/dashboard/src/components/RoutingView.tsx +8 -4
- 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/components/ui.tsx +6 -1
- package/dashboard/src/lib/cliTools.ts +0 -43
- package/dashboard/src/lib/client.ts +14 -7
- package/dashboard/src/lib/gateway.ts +33 -15
- 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 +136 -27
- package/dist/config.js.map +1 -1
- package/dist/core/budget.js +62 -17
- package/dist/core/budget.js.map +1 -1
- package/dist/core/fallback.js +0 -6
- package/dist/core/fallback.js.map +1 -1
- package/dist/core/handler.js +24 -9
- package/dist/core/handler.js.map +1 -1
- package/dist/core/keysUsage.js +15 -0
- package/dist/core/keysUsage.js.map +1 -0
- package/dist/core/ratelimit.js +15 -0
- package/dist/core/ratelimit.js.map +1 -0
- package/dist/core/state.js +15 -15
- package/dist/core/state.js.map +1 -1
- package/dist/core/window.js +35 -0
- package/dist/core/window.js.map +1 -0
- package/dist/db.js +39 -25
- 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 +80 -17
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/v1.js +28 -11
- package/dist/routes/v1.js.map +1 -1
- package/dist/server.js +5 -7
- package/dist/server.js.map +1 -1
- package/dist/stream/openai-stream.js +3 -0
- package/dist/stream/openai-stream.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 +142 -29
- package/src/core/budget.ts +78 -25
- package/src/core/fallback.ts +0 -9
- package/src/core/handler.ts +31 -12
- package/src/core/keysUsage.ts +49 -0
- package/src/core/ratelimit.ts +25 -0
- package/src/core/state.ts +21 -16
- package/src/core/window.ts +45 -0
- package/src/db.ts +50 -28
- package/src/middleware/auth.ts +18 -8
- package/src/routes/admin.ts +93 -20
- package/src/routes/v1.ts +32 -11
- package/src/server.ts +5 -8
- package/src/stream/openai-stream.ts +3 -1
- package/src/upstream/client.ts +9 -0
- package/dashboard/src/components/BudgetEditor.tsx +0 -97
- package/dashboard/src/components/QuotaView.tsx +0 -152
- 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
|
|
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
|
|
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="
|
|
136
|
-
<div className="flex
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
</
|
|
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
|
-
<
|
|
168
|
-
|
|
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
|
|
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
|
);
|