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.
Files changed (216) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +302 -0
  4. package/assets/logo.svg +8 -0
  5. package/assets/screenshot.png +0 -0
  6. package/assets/wordmark.svg +9 -0
  7. package/config.example.yaml +56 -0
  8. package/dashboard/.env.example +12 -0
  9. package/dashboard/next-env.d.ts +6 -0
  10. package/dashboard/next.config.ts +12 -0
  11. package/dashboard/package-lock.json +1771 -0
  12. package/dashboard/package.json +29 -0
  13. package/dashboard/postcss.config.mjs +5 -0
  14. package/dashboard/src/app/(console)/combos/page.tsx +10 -0
  15. package/dashboard/src/app/(console)/config/page.tsx +5 -0
  16. package/dashboard/src/app/(console)/console/page.tsx +92 -0
  17. package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
  18. package/dashboard/src/app/(console)/layout.tsx +17 -0
  19. package/dashboard/src/app/(console)/page.tsx +8 -0
  20. package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
  21. package/dashboard/src/app/(console)/providers/page.tsx +5 -0
  22. package/dashboard/src/app/(console)/quota/page.tsx +5 -0
  23. package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
  24. package/dashboard/src/app/(console)/tools/page.tsx +5 -0
  25. package/dashboard/src/app/(console)/usage/page.tsx +24 -0
  26. package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
  27. package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
  28. package/dashboard/src/app/api/login/route.ts +30 -0
  29. package/dashboard/src/app/api/logout/route.ts +9 -0
  30. package/dashboard/src/app/api/password/route.ts +34 -0
  31. package/dashboard/src/app/globals.css +340 -0
  32. package/dashboard/src/app/icon.svg +8 -0
  33. package/dashboard/src/app/layout.tsx +28 -0
  34. package/dashboard/src/app/login/page.tsx +60 -0
  35. package/dashboard/src/components/AreaChart.tsx +115 -0
  36. package/dashboard/src/components/Badge.tsx +32 -0
  37. package/dashboard/src/components/Button.tsx +60 -0
  38. package/dashboard/src/components/CapacityBadges.tsx +40 -0
  39. package/dashboard/src/components/Checkbox.tsx +40 -0
  40. package/dashboard/src/components/CliToolConfig.tsx +63 -0
  41. package/dashboard/src/components/ConfigEditor.tsx +199 -0
  42. package/dashboard/src/components/ConfirmModal.tsx +36 -0
  43. package/dashboard/src/components/CooldownTimer.tsx +42 -0
  44. package/dashboard/src/components/EndpointView.tsx +439 -0
  45. package/dashboard/src/components/Icon.tsx +25 -0
  46. package/dashboard/src/components/KeyReveal.tsx +78 -0
  47. package/dashboard/src/components/Lamp.tsx +8 -0
  48. package/dashboard/src/components/LogTable.tsx +223 -0
  49. package/dashboard/src/components/LogoutButton.tsx +20 -0
  50. package/dashboard/src/components/ModelPicker.tsx +121 -0
  51. package/dashboard/src/components/ModelSelectModal.tsx +126 -0
  52. package/dashboard/src/components/PasswordEditor.tsx +86 -0
  53. package/dashboard/src/components/PricingEditor.tsx +171 -0
  54. package/dashboard/src/components/ProviderDetail.tsx +566 -0
  55. package/dashboard/src/components/ProviderManager.tsx +311 -0
  56. package/dashboard/src/components/QuotaView.tsx +78 -0
  57. package/dashboard/src/components/Rail.tsx +82 -0
  58. package/dashboard/src/components/RichCard.tsx +46 -0
  59. package/dashboard/src/components/RoutingView.tsx +329 -0
  60. package/dashboard/src/components/ThemeProvider.tsx +36 -0
  61. package/dashboard/src/components/ToastProvider.tsx +58 -0
  62. package/dashboard/src/components/ToolDetail.tsx +475 -0
  63. package/dashboard/src/components/TopBar.tsx +128 -0
  64. package/dashboard/src/components/UsageView.tsx +151 -0
  65. package/dashboard/src/components/ui.tsx +54 -0
  66. package/dashboard/src/lib/capabilities.ts +318 -0
  67. package/dashboard/src/lib/cliTools.ts +120 -0
  68. package/dashboard/src/lib/client.ts +190 -0
  69. package/dashboard/src/lib/gateway.ts +269 -0
  70. package/dashboard/src/lib/session.ts +71 -0
  71. package/dashboard/src/middleware.ts +37 -0
  72. package/dashboard/tsconfig.json +21 -0
  73. package/dist/adapters/anthropic.js +289 -0
  74. package/dist/adapters/anthropic.js.map +1 -0
  75. package/dist/adapters/gemini.js +268 -0
  76. package/dist/adapters/gemini.js.map +1 -0
  77. package/dist/adapters/index.js +8 -0
  78. package/dist/adapters/index.js.map +1 -0
  79. package/dist/adapters/openai.js +13 -0
  80. package/dist/adapters/openai.js.map +1 -0
  81. package/dist/cli/tray/autostart.js +152 -0
  82. package/dist/cli/tray/autostart.js.map +1 -0
  83. package/dist/cli/tray/icon.js +4 -0
  84. package/dist/cli/tray/icon.js.map +1 -0
  85. package/dist/cli/tray/tray.js +141 -0
  86. package/dist/cli/tray/tray.js.map +1 -0
  87. package/dist/cli/tray/trayRuntime.js +91 -0
  88. package/dist/cli/tray/trayRuntime.js.map +1 -0
  89. package/dist/cli.js +361 -0
  90. package/dist/cli.js.map +1 -0
  91. package/dist/config.js +728 -0
  92. package/dist/config.js.map +1 -0
  93. package/dist/core/authStore.js +78 -0
  94. package/dist/core/authStore.js.map +1 -0
  95. package/dist/core/canonical.js +9 -0
  96. package/dist/core/canonical.js.map +1 -0
  97. package/dist/core/console-buffer.js +25 -0
  98. package/dist/core/console-buffer.js.map +1 -0
  99. package/dist/core/fallback.js +62 -0
  100. package/dist/core/fallback.js.map +1 -0
  101. package/dist/core/handler.js +174 -0
  102. package/dist/core/handler.js.map +1 -0
  103. package/dist/core/keypool.js +105 -0
  104. package/dist/core/keypool.js.map +1 -0
  105. package/dist/core/quota.js +165 -0
  106. package/dist/core/quota.js.map +1 -0
  107. package/dist/core/state.js +52 -0
  108. package/dist/core/state.js.map +1 -0
  109. package/dist/db.js +193 -0
  110. package/dist/db.js.map +1 -0
  111. package/dist/headroom/compress.js +44 -0
  112. package/dist/headroom/compress.js.map +1 -0
  113. package/dist/headroom/detect.js +108 -0
  114. package/dist/headroom/detect.js.map +1 -0
  115. package/dist/headroom/process.js +158 -0
  116. package/dist/headroom/process.js.map +1 -0
  117. package/dist/inject/caveman.js +30 -0
  118. package/dist/inject/caveman.js.map +1 -0
  119. package/dist/inject/index.js +24 -0
  120. package/dist/inject/index.js.map +1 -0
  121. package/dist/inject/ponytail.js +19 -0
  122. package/dist/inject/ponytail.js.map +1 -0
  123. package/dist/middleware/auth.js +66 -0
  124. package/dist/middleware/auth.js.map +1 -0
  125. package/dist/providers/capabilities.js +246 -0
  126. package/dist/providers/capabilities.js.map +1 -0
  127. package/dist/providers/free.js +43 -0
  128. package/dist/providers/free.js.map +1 -0
  129. package/dist/providers/pricing.js +224 -0
  130. package/dist/providers/pricing.js.map +1 -0
  131. package/dist/providers/vertex.js +97 -0
  132. package/dist/providers/vertex.js.map +1 -0
  133. package/dist/routes/admin.js +622 -0
  134. package/dist/routes/admin.js.map +1 -0
  135. package/dist/routes/health.js +4 -0
  136. package/dist/routes/health.js.map +1 -0
  137. package/dist/routes/index.js +12 -0
  138. package/dist/routes/index.js.map +1 -0
  139. package/dist/routes/v1.js +75 -0
  140. package/dist/routes/v1.js.map +1 -0
  141. package/dist/rtk/detect.js +50 -0
  142. package/dist/rtk/detect.js.map +1 -0
  143. package/dist/rtk/filters.js +85 -0
  144. package/dist/rtk/filters.js.map +1 -0
  145. package/dist/rtk/index.js +39 -0
  146. package/dist/rtk/index.js.map +1 -0
  147. package/dist/server.js +100 -0
  148. package/dist/server.js.map +1 -0
  149. package/dist/stream/anthropic-stream.js +239 -0
  150. package/dist/stream/anthropic-stream.js.map +1 -0
  151. package/dist/stream/chunk.js +7 -0
  152. package/dist/stream/chunk.js.map +1 -0
  153. package/dist/stream/gemini-stream.js +135 -0
  154. package/dist/stream/gemini-stream.js.map +1 -0
  155. package/dist/stream/index.js +12 -0
  156. package/dist/stream/index.js.map +1 -0
  157. package/dist/stream/openai-stream.js +34 -0
  158. package/dist/stream/openai-stream.js.map +1 -0
  159. package/dist/stream/sse.js +64 -0
  160. package/dist/stream/sse.js.map +1 -0
  161. package/dist/translator/thinking.js +70 -0
  162. package/dist/translator/thinking.js.map +1 -0
  163. package/dist/translator/thinkingUnified.js +322 -0
  164. package/dist/translator/thinkingUnified.js.map +1 -0
  165. package/dist/upstream/client.js +120 -0
  166. package/dist/upstream/client.js.map +1 -0
  167. package/package.json +76 -0
  168. package/run.sh +27 -0
  169. package/src/adapters/anthropic.ts +377 -0
  170. package/src/adapters/gemini.ts +341 -0
  171. package/src/adapters/index.ts +17 -0
  172. package/src/adapters/openai.ts +22 -0
  173. package/src/cli/tray/autostart.ts +133 -0
  174. package/src/cli/tray/icon.ts +4 -0
  175. package/src/cli/tray/tray.ts +156 -0
  176. package/src/cli/tray/trayRuntime.ts +90 -0
  177. package/src/cli.ts +379 -0
  178. package/src/config.ts +777 -0
  179. package/src/core/authStore.ts +86 -0
  180. package/src/core/canonical.ts +93 -0
  181. package/src/core/console-buffer.ts +39 -0
  182. package/src/core/fallback.ts +116 -0
  183. package/src/core/handler.ts +236 -0
  184. package/src/core/keypool.ts +152 -0
  185. package/src/core/quota.ts +214 -0
  186. package/src/core/state.ts +65 -0
  187. package/src/db.ts +280 -0
  188. package/src/headroom/compress.ts +78 -0
  189. package/src/headroom/detect.ts +119 -0
  190. package/src/headroom/process.ts +166 -0
  191. package/src/inject/caveman.ts +35 -0
  192. package/src/inject/index.ts +46 -0
  193. package/src/inject/ponytail.ts +31 -0
  194. package/src/middleware/auth.ts +76 -0
  195. package/src/providers/capabilities.ts +297 -0
  196. package/src/providers/free.ts +53 -0
  197. package/src/providers/pricing.ts +261 -0
  198. package/src/providers/vertex.ts +117 -0
  199. package/src/routes/admin.ts +716 -0
  200. package/src/routes/health.ts +5 -0
  201. package/src/routes/index.ts +24 -0
  202. package/src/routes/v1.ts +87 -0
  203. package/src/rtk/detect.ts +55 -0
  204. package/src/rtk/filters.ts +94 -0
  205. package/src/rtk/index.ts +58 -0
  206. package/src/server.ts +108 -0
  207. package/src/stream/anthropic-stream.ts +310 -0
  208. package/src/stream/chunk.ts +46 -0
  209. package/src/stream/gemini-stream.ts +158 -0
  210. package/src/stream/index.ts +23 -0
  211. package/src/stream/openai-stream.ts +41 -0
  212. package/src/stream/sse.ts +72 -0
  213. package/src/translator/thinking.ts +64 -0
  214. package/src/translator/thinkingUnified.ts +319 -0
  215. package/src/upstream/client.ts +155 -0
  216. 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 &amp; 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
+ }