aigetwey 1.0.1
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 +84 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/assets/logo.svg +8 -0
- package/assets/screenshot.png +0 -0
- package/assets/wordmark.svg +9 -0
- package/config.example.yaml +56 -0
- package/dashboard/.env.example +12 -0
- package/dashboard/next-env.d.ts +6 -0
- package/dashboard/next.config.ts +12 -0
- package/dashboard/package-lock.json +1771 -0
- package/dashboard/package.json +29 -0
- package/dashboard/postcss.config.mjs +5 -0
- package/dashboard/src/app/(console)/combos/page.tsx +10 -0
- package/dashboard/src/app/(console)/config/page.tsx +5 -0
- package/dashboard/src/app/(console)/console/page.tsx +92 -0
- package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
- package/dashboard/src/app/(console)/layout.tsx +17 -0
- package/dashboard/src/app/(console)/page.tsx +8 -0
- package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/providers/page.tsx +5 -0
- package/dashboard/src/app/(console)/quota/page.tsx +5 -0
- package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/tools/page.tsx +5 -0
- package/dashboard/src/app/(console)/usage/page.tsx +24 -0
- package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
- package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
- package/dashboard/src/app/api/login/route.ts +30 -0
- package/dashboard/src/app/api/logout/route.ts +9 -0
- package/dashboard/src/app/api/password/route.ts +34 -0
- package/dashboard/src/app/globals.css +340 -0
- package/dashboard/src/app/icon.svg +8 -0
- package/dashboard/src/app/layout.tsx +28 -0
- package/dashboard/src/app/login/page.tsx +60 -0
- package/dashboard/src/components/AreaChart.tsx +115 -0
- package/dashboard/src/components/Badge.tsx +32 -0
- package/dashboard/src/components/Button.tsx +60 -0
- package/dashboard/src/components/CapacityBadges.tsx +40 -0
- package/dashboard/src/components/Checkbox.tsx +40 -0
- package/dashboard/src/components/CliToolConfig.tsx +63 -0
- package/dashboard/src/components/ConfigEditor.tsx +199 -0
- package/dashboard/src/components/ConfirmModal.tsx +36 -0
- package/dashboard/src/components/CooldownTimer.tsx +42 -0
- package/dashboard/src/components/EndpointView.tsx +439 -0
- package/dashboard/src/components/Icon.tsx +25 -0
- package/dashboard/src/components/KeyReveal.tsx +78 -0
- package/dashboard/src/components/Lamp.tsx +8 -0
- package/dashboard/src/components/LogTable.tsx +223 -0
- package/dashboard/src/components/LogoutButton.tsx +20 -0
- package/dashboard/src/components/ModelPicker.tsx +121 -0
- package/dashboard/src/components/ModelSelectModal.tsx +126 -0
- package/dashboard/src/components/PasswordEditor.tsx +86 -0
- package/dashboard/src/components/PricingEditor.tsx +171 -0
- package/dashboard/src/components/ProviderDetail.tsx +566 -0
- package/dashboard/src/components/ProviderManager.tsx +311 -0
- package/dashboard/src/components/QuotaView.tsx +78 -0
- package/dashboard/src/components/Rail.tsx +82 -0
- package/dashboard/src/components/RichCard.tsx +46 -0
- package/dashboard/src/components/RoutingView.tsx +329 -0
- package/dashboard/src/components/ThemeProvider.tsx +36 -0
- package/dashboard/src/components/ToastProvider.tsx +58 -0
- package/dashboard/src/components/ToolDetail.tsx +475 -0
- package/dashboard/src/components/TopBar.tsx +128 -0
- package/dashboard/src/components/UsageView.tsx +151 -0
- package/dashboard/src/components/ui.tsx +54 -0
- package/dashboard/src/lib/capabilities.ts +318 -0
- package/dashboard/src/lib/cliTools.ts +120 -0
- package/dashboard/src/lib/client.ts +190 -0
- package/dashboard/src/lib/gateway.ts +269 -0
- package/dashboard/src/lib/session.ts +71 -0
- package/dashboard/src/middleware.ts +37 -0
- package/dashboard/tsconfig.json +21 -0
- package/dist/adapters/anthropic.js +289 -0
- package/dist/adapters/anthropic.js.map +1 -0
- package/dist/adapters/gemini.js +268 -0
- package/dist/adapters/gemini.js.map +1 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openai.js +13 -0
- package/dist/adapters/openai.js.map +1 -0
- package/dist/cli/tray/autostart.js +152 -0
- package/dist/cli/tray/autostart.js.map +1 -0
- package/dist/cli/tray/icon.js +4 -0
- package/dist/cli/tray/icon.js.map +1 -0
- package/dist/cli/tray/tray.js +141 -0
- package/dist/cli/tray/tray.js.map +1 -0
- package/dist/cli/tray/trayRuntime.js +91 -0
- package/dist/cli/tray/trayRuntime.js.map +1 -0
- package/dist/cli.js +361 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +728 -0
- package/dist/config.js.map +1 -0
- package/dist/core/authStore.js +78 -0
- package/dist/core/authStore.js.map +1 -0
- package/dist/core/canonical.js +9 -0
- package/dist/core/canonical.js.map +1 -0
- package/dist/core/console-buffer.js +25 -0
- package/dist/core/console-buffer.js.map +1 -0
- package/dist/core/fallback.js +62 -0
- package/dist/core/fallback.js.map +1 -0
- package/dist/core/handler.js +174 -0
- package/dist/core/handler.js.map +1 -0
- package/dist/core/keypool.js +105 -0
- package/dist/core/keypool.js.map +1 -0
- package/dist/core/quota.js +165 -0
- package/dist/core/quota.js.map +1 -0
- package/dist/core/state.js +52 -0
- package/dist/core/state.js.map +1 -0
- package/dist/db.js +193 -0
- package/dist/db.js.map +1 -0
- package/dist/headroom/compress.js +44 -0
- package/dist/headroom/compress.js.map +1 -0
- package/dist/headroom/detect.js +108 -0
- package/dist/headroom/detect.js.map +1 -0
- package/dist/headroom/process.js +158 -0
- package/dist/headroom/process.js.map +1 -0
- package/dist/inject/caveman.js +30 -0
- package/dist/inject/caveman.js.map +1 -0
- package/dist/inject/index.js +24 -0
- package/dist/inject/index.js.map +1 -0
- package/dist/inject/ponytail.js +19 -0
- package/dist/inject/ponytail.js.map +1 -0
- package/dist/middleware/auth.js +66 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/providers/capabilities.js +246 -0
- package/dist/providers/capabilities.js.map +1 -0
- package/dist/providers/free.js +43 -0
- package/dist/providers/free.js.map +1 -0
- package/dist/providers/pricing.js +224 -0
- package/dist/providers/pricing.js.map +1 -0
- package/dist/providers/vertex.js +97 -0
- package/dist/providers/vertex.js.map +1 -0
- package/dist/routes/admin.js +622 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/health.js +4 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.js +12 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/v1.js +75 -0
- package/dist/routes/v1.js.map +1 -0
- package/dist/rtk/detect.js +50 -0
- package/dist/rtk/detect.js.map +1 -0
- package/dist/rtk/filters.js +85 -0
- package/dist/rtk/filters.js.map +1 -0
- package/dist/rtk/index.js +39 -0
- package/dist/rtk/index.js.map +1 -0
- package/dist/server.js +100 -0
- package/dist/server.js.map +1 -0
- package/dist/stream/anthropic-stream.js +239 -0
- package/dist/stream/anthropic-stream.js.map +1 -0
- package/dist/stream/chunk.js +7 -0
- package/dist/stream/chunk.js.map +1 -0
- package/dist/stream/gemini-stream.js +135 -0
- package/dist/stream/gemini-stream.js.map +1 -0
- package/dist/stream/index.js +12 -0
- package/dist/stream/index.js.map +1 -0
- package/dist/stream/openai-stream.js +34 -0
- package/dist/stream/openai-stream.js.map +1 -0
- package/dist/stream/sse.js +64 -0
- package/dist/stream/sse.js.map +1 -0
- package/dist/translator/thinking.js +70 -0
- package/dist/translator/thinking.js.map +1 -0
- package/dist/translator/thinkingUnified.js +322 -0
- package/dist/translator/thinkingUnified.js.map +1 -0
- package/dist/upstream/client.js +120 -0
- package/dist/upstream/client.js.map +1 -0
- package/package.json +76 -0
- package/run.sh +27 -0
- package/src/adapters/anthropic.ts +377 -0
- package/src/adapters/gemini.ts +341 -0
- package/src/adapters/index.ts +17 -0
- package/src/adapters/openai.ts +22 -0
- package/src/cli/tray/autostart.ts +133 -0
- package/src/cli/tray/icon.ts +4 -0
- package/src/cli/tray/tray.ts +156 -0
- package/src/cli/tray/trayRuntime.ts +90 -0
- package/src/cli.ts +379 -0
- package/src/config.ts +777 -0
- package/src/core/authStore.ts +86 -0
- package/src/core/canonical.ts +93 -0
- package/src/core/console-buffer.ts +39 -0
- package/src/core/fallback.ts +116 -0
- package/src/core/handler.ts +236 -0
- package/src/core/keypool.ts +152 -0
- package/src/core/quota.ts +214 -0
- package/src/core/state.ts +65 -0
- package/src/db.ts +280 -0
- package/src/headroom/compress.ts +78 -0
- package/src/headroom/detect.ts +119 -0
- package/src/headroom/process.ts +166 -0
- package/src/inject/caveman.ts +35 -0
- package/src/inject/index.ts +46 -0
- package/src/inject/ponytail.ts +31 -0
- package/src/middleware/auth.ts +76 -0
- package/src/providers/capabilities.ts +297 -0
- package/src/providers/free.ts +53 -0
- package/src/providers/pricing.ts +261 -0
- package/src/providers/vertex.ts +117 -0
- package/src/routes/admin.ts +716 -0
- package/src/routes/health.ts +5 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/v1.ts +87 -0
- package/src/rtk/detect.ts +55 -0
- package/src/rtk/filters.ts +94 -0
- package/src/rtk/index.ts +58 -0
- package/src/server.ts +108 -0
- package/src/stream/anthropic-stream.ts +310 -0
- package/src/stream/chunk.ts +46 -0
- package/src/stream/gemini-stream.ts +158 -0
- package/src/stream/index.ts +23 -0
- package/src/stream/openai-stream.ts +41 -0
- package/src/stream/sse.ts +72 -0
- package/src/translator/thinking.ts +64 -0
- package/src/translator/thinkingUnified.ts +319 -0
- package/src/upstream/client.ts +155 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from "react";
|
|
4
|
+
import { adminApi } from "@/lib/client";
|
|
5
|
+
import { Badge } from "@/components/Badge";
|
|
6
|
+
import { RichCard, CardTitle } from "@/components/RichCard";
|
|
7
|
+
import { Button, Input } from "@/components/Button";
|
|
8
|
+
import { Icon } from "@/components/Icon";
|
|
9
|
+
import { KeyReveal } from "@/components/KeyReveal";
|
|
10
|
+
import { Empty } from "@/components/ui";
|
|
11
|
+
import type { EndpointPayload, HeadroomStatusReply, InjectLevel } from "@/lib/gateway";
|
|
12
|
+
|
|
13
|
+
const LEVELS: InjectLevel[] = ["off", "lite", "full", "ultra"];
|
|
14
|
+
|
|
15
|
+
/** Generate a random gateway key client-side (aigetwey's one-click create). */
|
|
16
|
+
function generateKey(): string {
|
|
17
|
+
const bytes = new Uint8Array(24);
|
|
18
|
+
crypto.getRandomValues(bytes);
|
|
19
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
20
|
+
return `aig-${hex}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function EndpointView() {
|
|
24
|
+
const [ep, setEp] = useState<EndpointPayload | null>(null);
|
|
25
|
+
const [error, setError] = useState("");
|
|
26
|
+
const [busy, setBusy] = useState("");
|
|
27
|
+
const [newKey, setNewKey] = useState("");
|
|
28
|
+
const [keyName, setKeyName] = useState("");
|
|
29
|
+
const [created, setCreated] = useState<{ key: string; name: string } | null>(null);
|
|
30
|
+
const [hr, setHr] = useState<HeadroomStatusReply | null>(null);
|
|
31
|
+
const [editKey, setEditKey] = useState<number | null>(null);
|
|
32
|
+
const [editKeyName, setEditKeyName] = useState("");
|
|
33
|
+
|
|
34
|
+
const reload = useCallback(async () => {
|
|
35
|
+
const r = await adminApi.endpoint();
|
|
36
|
+
if (!r.ok) {
|
|
37
|
+
setError(r.error ?? "could not reach the gateway");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setError("");
|
|
41
|
+
setEp(r.data);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
// Headroom status is a live probe (installed/running/python), separate from the
|
|
45
|
+
// endpoint config — reload it on mount and after any headroom action.
|
|
46
|
+
const reloadHr = useCallback(async () => {
|
|
47
|
+
const r = await adminApi.headroomStatus();
|
|
48
|
+
if (r.ok) setHr(r.data);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
void reload();
|
|
53
|
+
void reloadHr();
|
|
54
|
+
}, [reload, reloadHr]);
|
|
55
|
+
|
|
56
|
+
if (error) return <Empty>{error}</Empty>;
|
|
57
|
+
if (!ep) return <Empty>Loading…</Empty>;
|
|
58
|
+
|
|
59
|
+
const baseUrl = `http://127.0.0.1:${ep.port}`;
|
|
60
|
+
|
|
61
|
+
async function run(label: string, fn: () => Promise<{ ok: boolean; error?: string }>) {
|
|
62
|
+
setBusy(label);
|
|
63
|
+
const r = await fn();
|
|
64
|
+
setBusy("");
|
|
65
|
+
if (!r.ok) setError(r.error ?? "action failed");
|
|
66
|
+
else {
|
|
67
|
+
setError("");
|
|
68
|
+
await reload();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Create a key (generated or pasted) with its label, then surface it once in a
|
|
73
|
+
// modal — aigetwey, where the full key is shown at creation time.
|
|
74
|
+
async function addKey(label: string, rawKey: string) {
|
|
75
|
+
const name = (label || "Gateway key").trim();
|
|
76
|
+
setBusy("genkey");
|
|
77
|
+
const r = await adminApi.addServerKey(rawKey, name);
|
|
78
|
+
setBusy("");
|
|
79
|
+
if (!r.ok) {
|
|
80
|
+
setError(r.error ?? "could not add key");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setError("");
|
|
84
|
+
setKeyName("");
|
|
85
|
+
setNewKey("");
|
|
86
|
+
setCreated({ key: rawKey, name });
|
|
87
|
+
await reload();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div>
|
|
92
|
+
<div className="mb-6">
|
|
93
|
+
<h1 className="text-[22px] font-semibold tracking-tight text-text">Endpoint & Key</h1>
|
|
94
|
+
<p className="mt-1 text-[13px] text-text-muted">Gateway address, client keys, and the token-saver toggles.</p>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
98
|
+
<RichCard header={<CardTitle title="Gateway URL" sub="one endpoint for every client" />}>
|
|
99
|
+
<div className="text-[13px]">
|
|
100
|
+
<CopyRow label="Gateway URL" value={baseUrl} />
|
|
101
|
+
</div>
|
|
102
|
+
<p className="mt-3 text-[12px] text-text-subtle">
|
|
103
|
+
One gateway, both formats. Anthropic clients (Claude Code) use it as-is; OpenAI clients (opencode,
|
|
104
|
+
Cursor, Codex) append <span className="tnum">/v1</span>. The <span className="text-text-muted">CLI Tools</span>{" "}
|
|
105
|
+
page has copy-ready env per tool.
|
|
106
|
+
</p>
|
|
107
|
+
</RichCard>
|
|
108
|
+
|
|
109
|
+
<RichCard header={<CardTitle title="Gateway keys" sub={`${ep.keys.length} configured`} />}>
|
|
110
|
+
{ep.keys.length === 0 ? (
|
|
111
|
+
<Empty>No keys — auth is DISABLED (localhost only). Generate one below.</Empty>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="space-y-1.5">
|
|
114
|
+
{ep.keys.map((k, i) =>
|
|
115
|
+
editKey === i ? (
|
|
116
|
+
<div key={i} className="space-y-2 rounded-brand border border-accent bg-accent-soft/40 px-3 py-2.5">
|
|
117
|
+
<Input value={editKeyName} onChange={(e) => setEditKeyName(e.target.value)} placeholder="key name (e.g. Claude Code)" />
|
|
118
|
+
<div className="flex justify-end gap-2">
|
|
119
|
+
<Button variant="ghost" onClick={() => setEditKey(null)}>Cancel</Button>
|
|
120
|
+
<Button
|
|
121
|
+
disabled={busy === `editkey${i}`}
|
|
122
|
+
onClick={() =>
|
|
123
|
+
run(`editkey${i}`, async () => {
|
|
124
|
+
const r = await adminApi.editServerKey(i, editKeyName.trim());
|
|
125
|
+
if (r.ok) setEditKey(null);
|
|
126
|
+
return r;
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
Save
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
<div key={i} className="flex items-center justify-between gap-2 rounded-brand border border-border-subtle px-3 py-2">
|
|
136
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
137
|
+
{k.name && <span className="text-[12px] font-semibold text-text-muted">{k.name}</span>}
|
|
138
|
+
<KeyReveal
|
|
139
|
+
masked={k.key}
|
|
140
|
+
reveal={async () => {
|
|
141
|
+
const r = await adminApi.revealServerKey(i);
|
|
142
|
+
return r.ok ? r.data?.key ?? null : null;
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="flex flex-none items-center gap-1">
|
|
147
|
+
<button
|
|
148
|
+
onClick={() => { setEditKey(i); setEditKeyName(k.name ?? ""); }}
|
|
149
|
+
className="text-text-subtle hover:text-text"
|
|
150
|
+
aria-label="Rename key"
|
|
151
|
+
title="Rename key"
|
|
152
|
+
>
|
|
153
|
+
<Icon name="edit" size={15} />
|
|
154
|
+
</button>
|
|
155
|
+
<button onClick={() => run(`rmkey${i}`, () => adminApi.removeServerKey(i))} className="text-text-subtle hover:text-danger" aria-label="Remove key">
|
|
156
|
+
<Icon name="delete" size={16} />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
),
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
<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
|
+
<div className="flex gap-2">
|
|
167
|
+
<div className="relative flex-1">
|
|
168
|
+
<Input
|
|
169
|
+
value={newKey}
|
|
170
|
+
onChange={(e) => setNewKey(e.target.value)}
|
|
171
|
+
placeholder="type a custom key, or roll the dice →"
|
|
172
|
+
className="pr-9 font-mono text-[12.5px]"
|
|
173
|
+
/>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
onClick={() => setNewKey(generateKey())}
|
|
177
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-subtle hover:text-accent"
|
|
178
|
+
aria-label="Generate a random key"
|
|
179
|
+
title="Generate a random key"
|
|
180
|
+
>
|
|
181
|
+
<Icon name="casino" size={16} />
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
<Button disabled={!newKey.trim() || busy === "genkey"} onClick={() => addKey(keyName, newKey.trim())}>
|
|
185
|
+
<Icon name="add" size={16} /> {busy === "genkey" ? "Adding…" : "Add key"}
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
<p className="text-[11px] text-text-subtle">Name it, then type your own key or click the dice for a random one.</p>
|
|
189
|
+
</div>
|
|
190
|
+
</RichCard>
|
|
191
|
+
|
|
192
|
+
<RichCard className="lg:col-span-2" header={<CardTitle title="Token savers" sub="applied to every request before routing" />}>
|
|
193
|
+
<div className="space-y-4">
|
|
194
|
+
<Toggle
|
|
195
|
+
label="RTK"
|
|
196
|
+
desc="Compress bulky tool_result blocks (diffs, grep, listings) in the request."
|
|
197
|
+
on={ep.rtk}
|
|
198
|
+
busy={busy === "rtk"}
|
|
199
|
+
onChange={(v) => run("rtk", () => adminApi.setRtk(v))}
|
|
200
|
+
/>
|
|
201
|
+
<LevelRow
|
|
202
|
+
label="Caveman"
|
|
203
|
+
desc="Terser model output — drops filler, keeps substance."
|
|
204
|
+
value={ep.caveman}
|
|
205
|
+
busy={busy === "caveman"}
|
|
206
|
+
onChange={(lvl) => run("caveman", () => adminApi.setCaveman(lvl))}
|
|
207
|
+
/>
|
|
208
|
+
<LevelRow
|
|
209
|
+
label="Ponytail"
|
|
210
|
+
desc="Minimal, YAGNI code style — deletion over addition."
|
|
211
|
+
value={ep.ponytail}
|
|
212
|
+
busy={busy === "ponytail"}
|
|
213
|
+
onChange={(lvl) => run("ponytail", () => adminApi.setPonytail(lvl))}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
</RichCard>
|
|
217
|
+
|
|
218
|
+
<HeadroomCard
|
|
219
|
+
ep={ep}
|
|
220
|
+
hr={hr}
|
|
221
|
+
refresh={async () => {
|
|
222
|
+
await reload();
|
|
223
|
+
await reloadHr();
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{created && <KeyCreatedModal name={created.name} value={created.key} onClose={() => setCreated(null)} />}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Headroom = external context-compression proxy. Status is a live probe; the
|
|
235
|
+
* enable/url/compress fields persist to endpoint config; Start/Stop manage a
|
|
236
|
+
* gateway-spawned proxy when the CLI is installed and the URL is loopback.
|
|
237
|
+
*/
|
|
238
|
+
function HeadroomCard({
|
|
239
|
+
ep,
|
|
240
|
+
hr,
|
|
241
|
+
refresh,
|
|
242
|
+
}: {
|
|
243
|
+
ep: EndpointPayload;
|
|
244
|
+
hr: HeadroomStatusReply | null;
|
|
245
|
+
refresh: () => Promise<void>;
|
|
246
|
+
}) {
|
|
247
|
+
const h = ep.headroom;
|
|
248
|
+
const [url, setUrl] = useState(h.url);
|
|
249
|
+
const [localBusy, setLocalBusy] = useState("");
|
|
250
|
+
const [msg, setMsg] = useState("");
|
|
251
|
+
useEffect(() => setUrl(h.url), [h.url]);
|
|
252
|
+
|
|
253
|
+
async function act(label: string, fn: () => Promise<{ ok: boolean; error?: string }>) {
|
|
254
|
+
setLocalBusy(label);
|
|
255
|
+
setMsg("");
|
|
256
|
+
const r = await fn();
|
|
257
|
+
setLocalBusy("");
|
|
258
|
+
if (!r.ok) setMsg(r.error ?? "action failed");
|
|
259
|
+
await refresh();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<RichCard className="lg:col-span-2" header={<CardTitle title="Headroom" sub="external context-compression proxy" />}>
|
|
264
|
+
<div className="space-y-4">
|
|
265
|
+
<div className="flex flex-wrap items-center gap-2 text-[12px]">
|
|
266
|
+
<Badge tone={hr?.installed ? "live" : "neutral"}>{hr?.installed ? "installed" : "not installed"}</Badge>
|
|
267
|
+
<Badge tone={hr?.running ? "live" : "warn"}>{hr?.running ? "proxy running" : "proxy down"}</Badge>
|
|
268
|
+
<Badge tone={hr?.python ? "info" : "neutral"}>{hr?.python ? `python ${hr.python}` : "no python ≥3.10"}</Badge>
|
|
269
|
+
{hr?.managedPid ? <span className="tnum text-text-subtle">pid {hr.managedPid}</span> : null}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<Toggle
|
|
273
|
+
label="Enable headroom"
|
|
274
|
+
desc="Compress the full context through the proxy before each request (fail-open if it's down)."
|
|
275
|
+
on={h.enabled}
|
|
276
|
+
busy={localBusy === "enable"}
|
|
277
|
+
onChange={(v) => act("enable", () => adminApi.setHeadroom({ enabled: v }))}
|
|
278
|
+
/>
|
|
279
|
+
<Toggle
|
|
280
|
+
label="Compress user messages"
|
|
281
|
+
desc="Also squeeze user turns, not just tool/assistant context."
|
|
282
|
+
on={h.compress_user_messages}
|
|
283
|
+
busy={localBusy === "cum"}
|
|
284
|
+
onChange={(v) => act("cum", () => adminApi.setHeadroom({ compress_user_messages: v }))}
|
|
285
|
+
/>
|
|
286
|
+
|
|
287
|
+
<div className="flex items-end gap-2">
|
|
288
|
+
<div className="flex-1">
|
|
289
|
+
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-text-subtle">Proxy URL</div>
|
|
290
|
+
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://localhost:8787" className="font-mono text-[12.5px]" />
|
|
291
|
+
</div>
|
|
292
|
+
<Button
|
|
293
|
+
variant="ghost"
|
|
294
|
+
disabled={url.trim() === h.url || localBusy === "url"}
|
|
295
|
+
onClick={() => act("url", () => adminApi.setHeadroom({ url: url.trim() }))}
|
|
296
|
+
>
|
|
297
|
+
Save URL
|
|
298
|
+
</Button>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
302
|
+
<Button
|
|
303
|
+
disabled={!hr?.canStart || hr?.running || localBusy === "start"}
|
|
304
|
+
onClick={() => act("start", () => adminApi.headroomStart())}
|
|
305
|
+
>
|
|
306
|
+
<Icon name="play_arrow" size={16} /> {localBusy === "start" ? "Starting…" : "Start proxy"}
|
|
307
|
+
</Button>
|
|
308
|
+
<Button
|
|
309
|
+
variant="danger"
|
|
310
|
+
disabled={!hr?.managedPid || localBusy === "stop"}
|
|
311
|
+
onClick={() => act("stop", () => adminApi.headroomStop())}
|
|
312
|
+
>
|
|
313
|
+
<Icon name="stop" size={16} /> Stop
|
|
314
|
+
</Button>
|
|
315
|
+
{hr && !hr.installed && (
|
|
316
|
+
<span className="text-[11px] text-text-subtle">
|
|
317
|
+
Headroom isn’t installed. Get it from{" "}
|
|
318
|
+
<a href="https://github.com/chopratejas/headroom" target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
|
319
|
+
chopratejas/headroom
|
|
320
|
+
</a>{" "}
|
|
321
|
+
(needs Python ≥ 3.10):{" "}
|
|
322
|
+
<code className="rounded bg-surface-2 px-1">pipx install git+https://github.com/chopratejas/headroom</code>{" "}
|
|
323
|
+
— then re-open this page.
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
326
|
+
{hr?.installed && !hr.localUrl && (
|
|
327
|
+
<span className="text-[11px] text-text-subtle">URL isn’t loopback — start that proxy yourself.</span>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{msg && <p className="text-[12px] text-danger">{msg}</p>}
|
|
332
|
+
</div>
|
|
333
|
+
</RichCard>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Shows a freshly created key once (it's masked everywhere after), with copy. */
|
|
338
|
+
function KeyCreatedModal({ name, value, onClose }: { name: string; value: string; onClose: () => void }) {
|
|
339
|
+
const [copied, setCopied] = useState(false);
|
|
340
|
+
return (
|
|
341
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={onClose}>
|
|
342
|
+
<div
|
|
343
|
+
className="w-full max-w-md rounded-brand-lg border border-border bg-surface p-5 shadow-elevated"
|
|
344
|
+
onClick={(e) => e.stopPropagation()}
|
|
345
|
+
>
|
|
346
|
+
<div className="mb-1 flex items-center gap-2">
|
|
347
|
+
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-soft text-accent">
|
|
348
|
+
<Icon name="key" size={16} />
|
|
349
|
+
</span>
|
|
350
|
+
<h2 className="text-[15px] font-semibold text-text">Key created</h2>
|
|
351
|
+
</div>
|
|
352
|
+
<p className="mb-3 text-[12px] text-text-muted">
|
|
353
|
+
Copy <span className="text-text">{name}</span> now. You can reveal it again later from this page.
|
|
354
|
+
</p>
|
|
355
|
+
<button
|
|
356
|
+
onClick={() => {
|
|
357
|
+
void navigator.clipboard.writeText(value);
|
|
358
|
+
setCopied(true);
|
|
359
|
+
setTimeout(() => setCopied(false), 1500);
|
|
360
|
+
}}
|
|
361
|
+
className="flex w-full items-center justify-between gap-2 rounded-brand border border-border-subtle bg-bg px-3 py-2.5 text-left hover:border-text-subtle"
|
|
362
|
+
>
|
|
363
|
+
<span className="tnum truncate text-[12.5px] text-text">{value}</span>
|
|
364
|
+
<Icon name={copied ? "check" : "content_copy"} size={15} />
|
|
365
|
+
</button>
|
|
366
|
+
<div className="mt-4 flex justify-end">
|
|
367
|
+
<Button onClick={onClose}>Done</Button>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function CopyRow({ label, value }: { label: string; value: string }) {
|
|
375
|
+
const [copied, setCopied] = useState(false);
|
|
376
|
+
return (
|
|
377
|
+
<div className="flex items-center justify-between gap-3">
|
|
378
|
+
<span className="text-text-subtle">{label}</span>
|
|
379
|
+
<button
|
|
380
|
+
onClick={() => {
|
|
381
|
+
void navigator.clipboard.writeText(value);
|
|
382
|
+
setCopied(true);
|
|
383
|
+
setTimeout(() => setCopied(false), 1200);
|
|
384
|
+
}}
|
|
385
|
+
className="flex items-center gap-1.5 rounded-brand border border-border-subtle px-2.5 py-1 tnum text-[12.5px] text-text hover:border-text-subtle"
|
|
386
|
+
>
|
|
387
|
+
{value}
|
|
388
|
+
<Icon name={copied ? "check" : "content_copy"} size={13} />
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function Toggle({ label, desc, on, busy, onChange }: { label: string; desc: string; on: boolean; busy: boolean; onChange: (v: boolean) => void }) {
|
|
395
|
+
return (
|
|
396
|
+
<div className="flex items-center justify-between gap-4">
|
|
397
|
+
<div>
|
|
398
|
+
<div className="text-[13px] font-semibold text-text">{label}</div>
|
|
399
|
+
<div className="text-[12px] text-text-muted">{desc}</div>
|
|
400
|
+
</div>
|
|
401
|
+
<button
|
|
402
|
+
disabled={busy}
|
|
403
|
+
onClick={() => onChange(!on)}
|
|
404
|
+
className={`relative h-6 w-11 flex-none rounded-full transition-colors ${on ? "bg-accent" : "bg-surface-3"}`}
|
|
405
|
+
aria-pressed={on}
|
|
406
|
+
>
|
|
407
|
+
<span className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-bg transition-transform ${on ? "translate-x-5" : "translate-x-0"}`} />
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function LevelRow({ label, desc, value, busy, onChange }: { label: string; desc: string; value: InjectLevel; busy: boolean; onChange: (l: InjectLevel) => void }) {
|
|
414
|
+
return (
|
|
415
|
+
<div className="flex items-center justify-between gap-4">
|
|
416
|
+
<div>
|
|
417
|
+
<div className="flex items-center gap-2">
|
|
418
|
+
<span className="text-[13px] font-semibold text-text">{label}</span>
|
|
419
|
+
{value !== "off" && <Badge tone="info">{value}</Badge>}
|
|
420
|
+
</div>
|
|
421
|
+
<div className="text-[12px] text-text-muted">{desc}</div>
|
|
422
|
+
</div>
|
|
423
|
+
<div className="flex flex-none items-center gap-1 rounded-full border border-border bg-surface p-1">
|
|
424
|
+
{LEVELS.map((lvl) => (
|
|
425
|
+
<button
|
|
426
|
+
key={lvl}
|
|
427
|
+
disabled={busy}
|
|
428
|
+
onClick={() => onChange(lvl)}
|
|
429
|
+
className={`rounded-full px-2.5 py-1 text-[11.5px] font-medium transition-colors ${
|
|
430
|
+
value === lvl ? "bg-surface-2 text-text" : "text-text-muted hover:text-text"
|
|
431
|
+
}`}
|
|
432
|
+
>
|
|
433
|
+
{lvl}
|
|
434
|
+
</button>
|
|
435
|
+
))}
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Material Symbols icon. Name = ligature, e.g. "dashboard", "vpn_key", "add". */
|
|
2
|
+
export function Icon({
|
|
3
|
+
name,
|
|
4
|
+
size,
|
|
5
|
+
className,
|
|
6
|
+
fill,
|
|
7
|
+
}: {
|
|
8
|
+
name: string;
|
|
9
|
+
size?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
fill?: boolean;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
className={`material-symbols-outlined${className ? ` ${className}` : ""}`}
|
|
16
|
+
style={{
|
|
17
|
+
fontSize: size,
|
|
18
|
+
...(fill ? { fontVariationSettings: '"FILL" 1, "wght" 400, "GRAD" 0, "opsz" 24' } : {}),
|
|
19
|
+
}}
|
|
20
|
+
aria-hidden
|
|
21
|
+
>
|
|
22
|
+
{name}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Icon } from "./Icon";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A masked key with an eye toggle that fetches the raw value on demand — for the
|
|
8
|
+
* local operator who forgot what they pasted. `masked` is the already-masked
|
|
9
|
+
* string the dashboard renders everywhere; `reveal` lazily fetches the real key
|
|
10
|
+
* (admin-gated) the first time it's shown, then we cache it for copy/hide.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* `align`: "inline" keeps the eye + copy right next to the key text (use in a
|
|
14
|
+
* label/column context like Endpoint). "right" lets the text grow so the eye +
|
|
15
|
+
* copy push to the right edge (use in a single-line row like Provider keys).
|
|
16
|
+
*/
|
|
17
|
+
export function KeyReveal({
|
|
18
|
+
masked,
|
|
19
|
+
reveal,
|
|
20
|
+
className,
|
|
21
|
+
align = "inline",
|
|
22
|
+
}: {
|
|
23
|
+
masked: string;
|
|
24
|
+
reveal: () => Promise<string | null>;
|
|
25
|
+
className?: string;
|
|
26
|
+
align?: "inline" | "right";
|
|
27
|
+
}) {
|
|
28
|
+
const [real, setReal] = useState<string | null>(null);
|
|
29
|
+
const [shown, setShown] = useState(false);
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const [copied, setCopied] = useState(false);
|
|
32
|
+
|
|
33
|
+
async function toggle() {
|
|
34
|
+
if (shown) {
|
|
35
|
+
setShown(false);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (real === null) {
|
|
39
|
+
setLoading(true);
|
|
40
|
+
const k = await reveal();
|
|
41
|
+
setLoading(false);
|
|
42
|
+
if (k === null) return; // reveal failed — stay masked
|
|
43
|
+
setReal(k);
|
|
44
|
+
}
|
|
45
|
+
setShown(true);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const display = shown && real !== null ? real : masked;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<span className={`flex min-w-0 items-center gap-1.5${className ? ` ${className}` : ""}`}>
|
|
52
|
+
<span className={`tnum truncate text-[12.5px] text-text${align === "right" ? " flex-1" : ""}`}>{display}</span>
|
|
53
|
+
{shown && real !== null && (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => {
|
|
57
|
+
void navigator.clipboard.writeText(real);
|
|
58
|
+
setCopied(true);
|
|
59
|
+
setTimeout(() => setCopied(false), 1200);
|
|
60
|
+
}}
|
|
61
|
+
className="flex-none text-text-subtle transition-colors hover:text-text"
|
|
62
|
+
aria-label="Copy key"
|
|
63
|
+
>
|
|
64
|
+
<Icon name={copied ? "check" : "content_copy"} size={14} />
|
|
65
|
+
</button>
|
|
66
|
+
)}
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={toggle}
|
|
70
|
+
disabled={loading}
|
|
71
|
+
className="flex-none text-text-subtle transition-colors hover:text-text disabled:opacity-40"
|
|
72
|
+
aria-label={shown ? "Hide key" : "Show key"}
|
|
73
|
+
>
|
|
74
|
+
<Icon name={loading ? "hourglass_empty" : shown ? "visibility_off" : "visibility"} size={15} />
|
|
75
|
+
</button>
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Status lamp — green when serving, red pulse on cooldown, grey when idle. */
|
|
2
|
+
export function Lamp({ state, title }: { state: "live" | "idle" | "down"; title?: string }) {
|
|
3
|
+
return <span className={`lamp lamp-${state}`} title={title} aria-label={state} />;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function lampFor(healthy: boolean): "live" | "down" {
|
|
7
|
+
return healthy ? "live" : "down";
|
|
8
|
+
}
|