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,716 @@
1
+ /**
2
+ * Admin API (/admin/*), behind a single admin password (AIGETWEY_ADMIN_PASSWORD),
3
+ * consumed by the Next.js dashboard via a server-side proxy. Read endpoints
4
+ * expose health/usage/logs; the config endpoints allow live editing with
5
+ * hot-reload.
6
+ *
7
+ * Provider keys are MASKED in every response. The only exception is the explicit
8
+ * `.../reveal` endpoints, which return one raw key on demand (the dashboard's
9
+ * "show key" button) — admin-gated like everything else, for the local operator
10
+ * who forgot what they pasted. Granular provider/combo mutation endpoints land in
11
+ * Phase 11 alongside the dashboard; Phase 5 ships read surfaces + config CRUD.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
16
+ import type { GatewayState } from "../core/state.js";
17
+ import type { UsageDB } from "../db.js";
18
+ import { checkAdminAuth, type AdminVerifier } from "../middleware/auth.js";
19
+ import {
20
+ maskKey,
21
+ serializeConfig,
22
+ addProvider,
23
+ editProvider,
24
+ renameProvider,
25
+ removeProvider,
26
+ addProviderKey,
27
+ removeProviderKey,
28
+ editProviderKey,
29
+ reorderProviderKey,
30
+ toggleProviderKey,
31
+ setProviderStrategy,
32
+ setProviderDisabled,
33
+ addProviderModel,
34
+ removeProviderModel,
35
+ addProviderModels,
36
+ clearProviderModels,
37
+ setProviderModelPrice,
38
+ setRoute,
39
+ removeRoute,
40
+ setRtk,
41
+ setCaveman,
42
+ setPonytail,
43
+ setHeadroom,
44
+ addServerKey,
45
+ editServerKey,
46
+ removeServerKey,
47
+ type Config,
48
+ type Provider,
49
+ type EndpointSettings,
50
+ } from "../config.js";
51
+ import { pingProvider } from "../upstream/client.js";
52
+ import { handle, GatewayError } from "../core/handler.js";
53
+ import { fetchModels } from "../providers/free.js";
54
+ import { consoleBuffer } from "../core/console-buffer.js";
55
+ import { getPricingForModel } from "../providers/pricing.js";
56
+ import { getHeadroomStatus, isLoopbackHeadroomUrl, DEFAULT_HEADROOM_URL } from "../headroom/detect.js";
57
+ import { startHeadroomProxy, stopHeadroomProxy, getManagedPid, getHeadroomLogTail } from "../headroom/process.js";
58
+
59
+ export interface AdminDeps {
60
+ state: GatewayState;
61
+ db?: UsageDB;
62
+ auth: AdminVerifier & { change(current: string, next: string): { ok: boolean; error?: string } };
63
+ }
64
+
65
+ /** Deep-clone the raw config and mask every secret for display. */
66
+ function maskedConfig(config: Config): Config {
67
+ const clone: Config = JSON.parse(JSON.stringify(config));
68
+ for (const p of clone.providers) {
69
+ if (p.api_key) p.api_key = maskKey(p.api_key);
70
+ if (p.api_keys) p.api_keys = p.api_keys.map(maskKey);
71
+ // key_names is keyed by the RAW key — re-key to the masked form so real keys
72
+ // never leak through /admin/config.
73
+ if (p.key_names) {
74
+ p.key_names = Object.fromEntries(
75
+ Object.entries(p.key_names).map(([k, name]) => [maskKey(k), name]),
76
+ );
77
+ }
78
+ }
79
+ clone.server.api_keys = clone.server.api_keys.map(maskKey);
80
+ // key_names is keyed by the RAW key — re-key it to the masked form so real
81
+ // keys never leak through /admin/config.
82
+ if (clone.server.key_names) {
83
+ clone.server.key_names = Object.fromEntries(
84
+ Object.entries(clone.server.key_names).map(([k, name]) => [maskKey(k), name]),
85
+ );
86
+ }
87
+ return clone;
88
+ }
89
+
90
+ export function registerAdminRoutes(app: FastifyInstance, deps: AdminDeps): void {
91
+ const requireAdmin = {
92
+ preHandler: (req: FastifyRequest, reply: FastifyReply, done: (e?: Error) => void) => {
93
+ const res = checkAdminAuth(req, deps.auth);
94
+ if (!res.ok) {
95
+ reply.code(res.status ?? 401).send({ error: res.error });
96
+ return;
97
+ }
98
+ done();
99
+ },
100
+ };
101
+
102
+ // change the admin password: verify the current one, then persist the new hash.
103
+ // The dashboard re-issues its session cookie with the new password on success.
104
+ app.put("/admin/password", requireAdmin, (req, reply) => {
105
+ const body = (req.body ?? {}) as { current?: unknown; next?: unknown };
106
+ if (typeof body.current !== "string" || typeof body.next !== "string") {
107
+ return reply.code(400).send({ error: "current and next are required" });
108
+ }
109
+ const r = deps.auth.change(body.current, body.next);
110
+ if (!r.ok) return reply.code(400).send({ error: r.error });
111
+ return reply.send({ ok: true });
112
+ });
113
+
114
+ app.get("/admin/usage", requireAdmin, (req, reply) => {
115
+ if (!deps.db) return reply.code(503).send({ error: "usage tracking disabled" });
116
+ const q = req.query as { since?: string };
117
+ const since = q.since ? Number(q.since) : 0;
118
+ reply.send(deps.db.summary(Number.isFinite(since) ? since : 0));
119
+ });
120
+
121
+ app.get("/admin/usage/series", requireAdmin, (req, reply) => {
122
+ if (!deps.db) return reply.code(503).send({ error: "usage tracking disabled" });
123
+ const q = req.query as { since?: string; bucket?: string };
124
+ const since = Number(q.since);
125
+ const bucket = Number(q.bucket);
126
+ const sinceMs = Number.isFinite(since) && since > 0 ? since : Date.now() - 24 * 3600 * 1000;
127
+ const bucketMs = Number.isFinite(bucket) && bucket > 0 ? bucket : 3600 * 1000;
128
+ reply.send({ series: deps.db.series(sinceMs, bucketMs) });
129
+ });
130
+
131
+ app.get("/admin/logs", requireAdmin, (req, reply) => {
132
+ if (!deps.db) return reply.code(503).send({ error: "usage tracking disabled" });
133
+ const q = req.query as { limit?: string };
134
+ const limit = q.limit ? Number(q.limit) : 100;
135
+ reply.send({ logs: deps.db.recent(Number.isFinite(limit) ? limit : 100) });
136
+ });
137
+
138
+ // live key health per provider, masked. Drives provider status lamps.
139
+ app.get("/admin/providers", requireAdmin, (_req, reply) => {
140
+ reply.send({ providers: deps.state.pool.snapshot(deps.state.config.listProviders()) });
141
+ });
142
+
143
+ // per-provider quota: consumed, limit, and ms until the next scheduled reset.
144
+ app.get("/admin/quota", requireAdmin, (_req, reply) => {
145
+ reply.send({ quota: deps.state.quota.snapshot(deps.state.config.listProviders()) });
146
+ });
147
+
148
+ // current config, secrets masked
149
+ app.get("/admin/config", requireAdmin, (_req, reply) => {
150
+ reply.send(maskedConfig(deps.state.config.raw));
151
+ });
152
+
153
+ // export the FULL config as YAML for backup — UNMASKED (real keys), so the
154
+ // backup can actually be restored. Admin-gated and same-origin only, like the
155
+ // /reveal endpoints that already hand back raw keys; intended for the local
156
+ // operator backing up their own gateway. Import is the existing PUT /admin/config.
157
+ app.get("/admin/config/export", requireAdmin, (_req, reply) => {
158
+ reply
159
+ .header("Content-Type", "text/yaml; charset=utf-8")
160
+ .header("Content-Disposition", 'attachment; filename="aigetwey-config.yaml"')
161
+ .send(serializeConfig(deps.state.config.raw));
162
+ });
163
+
164
+ // replace config (full YAML/JSON), validate + hot-reload. raw text in body.
165
+ app.put("/admin/config", requireAdmin, (req, reply) => {
166
+ const body = req.body as { text?: string } | string;
167
+ const text = typeof body === "string" ? body : body?.text;
168
+ if (typeof text !== "string" || !text.trim()) {
169
+ return reply.code(400).send({ error: "body must include config text" });
170
+ }
171
+ try {
172
+ deps.state.reload(text);
173
+ } catch (e) {
174
+ // validation failed — old config still serving
175
+ return reply.code(400).send({ error: (e as Error).message });
176
+ }
177
+ app.log.warn("[admin] config hot-reloaded");
178
+ reply.send({ ok: true, config: maskedConfig(deps.state.config.raw) });
179
+ });
180
+
181
+ // Apply a structural mutation: run the pure helper against the real (unmasked)
182
+ // config, then reload(serialized) so validation + atomic persist + pool swap
183
+ // all happen through the one trusted path. Serializing the real config means
184
+ // reload's unmask step is a no-op (no masked values present). Replies with the
185
+ // fresh masked config so the dashboard can re-render from one source of truth.
186
+ const applyMutation = (reply: FastifyReply, mutate: (config: Config) => Config): void => {
187
+ let next: Config;
188
+ try {
189
+ next = mutate(deps.state.config.raw);
190
+ } catch (e) {
191
+ reply.code(400).send({ error: (e as Error).message });
192
+ return;
193
+ }
194
+ try {
195
+ deps.state.reload(serializeConfig(next));
196
+ } catch (e) {
197
+ reply.code(400).send({ error: (e as Error).message });
198
+ return;
199
+ }
200
+ reply.send({ ok: true, config: maskedConfig(deps.state.config.raw) });
201
+ };
202
+
203
+ // ---- providers ----
204
+
205
+ app.post("/admin/providers", requireAdmin, (req, reply) => {
206
+ const b = req.body as Partial<{
207
+ id: string;
208
+ format: Provider["format"];
209
+ base_url: string;
210
+ api_key: string;
211
+ free: boolean;
212
+ auto_models: boolean;
213
+ service_account: string;
214
+ }>;
215
+ if (!b?.id || !b?.format || !b?.base_url) {
216
+ return reply.code(400).send({ error: "id, format, base_url required" });
217
+ }
218
+ applyMutation(reply, (c) =>
219
+ addProvider(c, {
220
+ id: b.id!,
221
+ format: b.format!,
222
+ base_url: b.base_url!,
223
+ api_key: b.api_key,
224
+ free: b.free,
225
+ auto_models: b.auto_models,
226
+ service_account: b.service_account,
227
+ }),
228
+ );
229
+ });
230
+
231
+ app.put("/admin/providers/:id", requireAdmin, (req, reply) => {
232
+ const { id } = req.params as { id: string };
233
+ const b = req.body as { base_url?: string; format?: Provider["format"]; name?: string };
234
+ applyMutation(reply, (c) => editProvider(c, id, { base_url: b?.base_url, format: b?.format, name: b?.name }));
235
+ });
236
+
237
+ // rename a provider's id (the call prefix); cascades to combos that target it.
238
+ app.put("/admin/providers/:id/rename", requireAdmin, (req, reply) => {
239
+ const { id } = req.params as { id: string };
240
+ const b = req.body as { id?: string };
241
+ if (!b?.id) return reply.code(400).send({ error: "new id required" });
242
+ applyMutation(reply, (c) => renameProvider(c, id, b.id!));
243
+ });
244
+
245
+ app.delete("/admin/providers/:id", requireAdmin, (req, reply) => {
246
+ const { id } = req.params as { id: string };
247
+ applyMutation(reply, (c) => removeProvider(c, id));
248
+ });
249
+
250
+ app.post("/admin/providers/:id/keys", requireAdmin, (req, reply) => {
251
+ const { id } = req.params as { id: string };
252
+ const b = req.body as { key?: string; name?: string };
253
+ if (!b?.key) return reply.code(400).send({ error: "key required" });
254
+ applyMutation(reply, (c) => addProviderKey(c, id, b.key!, b.name));
255
+ });
256
+
257
+ // edit ONE provider key: rename and/or swap its value (aigetwey-style).
258
+ app.put("/admin/providers/:id/keys/:index", requireAdmin, (req, reply) => {
259
+ const { id, index } = req.params as { id: string; index: string };
260
+ const i = Number(index);
261
+ if (!Number.isInteger(i)) return reply.code(400).send({ error: "index must be an integer" });
262
+ const b = req.body as { key?: string; name?: string };
263
+ applyMutation(reply, (c) => editProviderKey(c, id, i, { key: b?.key, name: b?.name }));
264
+ });
265
+
266
+ app.delete("/admin/providers/:id/keys/:index", requireAdmin, (req, reply) => {
267
+ const { id, index } = req.params as { id: string; index: string };
268
+ const i = Number(index);
269
+ if (!Number.isInteger(i)) return reply.code(400).send({ error: "index must be an integer" });
270
+ applyMutation(reply, (c) => removeProviderKey(c, id, i));
271
+ });
272
+
273
+ app.put("/admin/providers/:id/keys/reorder", requireAdmin, (req, reply) => {
274
+ const { id } = req.params as { id: string };
275
+ const b = req.body as { from?: number; to?: number };
276
+ if (!Number.isInteger(b?.from) || !Number.isInteger(b?.to)) {
277
+ return reply.code(400).send({ error: "from and to must be integers" });
278
+ }
279
+ applyMutation(reply, (c) => reorderProviderKey(c, id, b.from!, b.to!));
280
+ });
281
+
282
+ app.put("/admin/providers/:id/keys/:index/toggle", requireAdmin, (req, reply) => {
283
+ const { id, index } = req.params as { id: string; index: string };
284
+ const i = Number(index);
285
+ if (!Number.isInteger(i)) return reply.code(400).send({ error: "index must be an integer" });
286
+ const b = req.body as { enabled?: boolean };
287
+ if (typeof b?.enabled !== "boolean") return reply.code(400).send({ error: "enabled (boolean) required" });
288
+ applyMutation(reply, (c) => toggleProviderKey(c, id, i, b.enabled!));
289
+ });
290
+
291
+ app.put("/admin/providers/:id/strategy", requireAdmin, (req, reply) => {
292
+ const { id } = req.params as { id: string };
293
+ const b = req.body as { strategy?: "fallback" | "round-robin" | null; sticky?: number };
294
+ applyMutation(reply, (c) => setProviderStrategy(c, id, b?.strategy ?? null, b?.sticky));
295
+ });
296
+
297
+ app.put("/admin/providers/:id/disabled", requireAdmin, (req, reply) => {
298
+ const { id } = req.params as { id: string };
299
+ const b = req.body as { disabled?: boolean };
300
+ applyMutation(reply, (c) => setProviderDisabled(c, id, b?.disabled === true));
301
+ });
302
+
303
+ // reveal ONE raw provider key (the "show key" button). Index mirrors how the
304
+ // dashboard lists them: api_keys[], or the single api_key as index 0.
305
+ app.get("/admin/providers/:id/keys/:index/reveal", requireAdmin, (req, reply) => {
306
+ const { id, index } = req.params as { id: string; index: string };
307
+ const i = Number(index);
308
+ const provider = deps.state.config.raw.providers.find((p) => p.id === id);
309
+ if (!provider) return reply.code(404).send({ error: `provider "${id}" not found` });
310
+ const keys = provider.api_keys ?? (provider.api_key ? [provider.api_key] : []);
311
+ if (!Number.isInteger(i) || i < 0 || i >= keys.length) {
312
+ return reply.code(404).send({ error: "key index out of range" });
313
+ }
314
+ reply.send({ key: keys[i] });
315
+ });
316
+
317
+ app.post("/admin/providers/:id/models", requireAdmin, (req, reply) => {
318
+ const { id } = req.params as { id: string };
319
+ const b = req.body as { model?: string; models?: string[]; price_in?: number; price_out?: number };
320
+ // bulk add (from the discover modal) or single add (manual entry).
321
+ if (Array.isArray(b?.models)) {
322
+ if (b.models.length === 0) return reply.code(400).send({ error: "models[] empty" });
323
+ return applyMutation(reply, (c) => addProviderModels(c, id, b.models!));
324
+ }
325
+ if (!b?.model) return reply.code(400).send({ error: "model or models[] required" });
326
+ applyMutation(reply, (c) => addProviderModel(c, id, b.model!, { price_in: b.price_in, price_out: b.price_out }));
327
+ });
328
+
329
+ // remove one model (?model=<id>) or clear all (no query). Model ids can hold
330
+ // slashes (e.g. "anthropic/claude-opus-4-6"); a %2F path segment gets re-split
331
+ // by the dashboard proxy, so the id travels as a query param instead.
332
+ app.delete("/admin/providers/:id/models", requireAdmin, (req, reply) => {
333
+ const { id } = req.params as { id: string };
334
+ const model = (req.query as { model?: string }).model;
335
+ if (model) return applyMutation(reply, (c) => removeProviderModel(c, id, model));
336
+ applyMutation(reply, (c) => clearProviderModels(c, id));
337
+ });
338
+
339
+ // Pre-save connectivity check for the add-provider form's "Check" button:
340
+ // ping an ad-hoc provider config without persisting it. Matches aigetwey's
341
+ // validate-before-save. Never stores anything; the key stays in the request.
342
+ app.post("/admin/providers/validate", requireAdmin, async (req, reply) => {
343
+ const b = req.body as { format?: Provider["format"]; base_url?: string; api_key?: string };
344
+ if (!b?.format || !b?.base_url) {
345
+ return reply.code(400).send({ error: "format and base_url required" });
346
+ }
347
+ const probe = {
348
+ id: "_probe",
349
+ format: b.format,
350
+ base_url: b.base_url,
351
+ api_key: b.api_key,
352
+ free: !b.api_key,
353
+ auto_models: false,
354
+ models: [],
355
+ cooldown_base_ms: 1000,
356
+ max_retries: 0,
357
+ } as unknown as Provider;
358
+ reply.send(await pingProvider(probe, b.api_key));
359
+ });
360
+
361
+ // live connectivity check against the provider's /models. Uses a real
362
+ // (unmasked) key from the live config; never returns the key itself.
363
+ app.post("/admin/providers/:id/test", requireAdmin, async (req, reply) => {
364
+ const { id } = req.params as { id: string };
365
+ const provider = deps.state.config.raw.providers.find((p) => p.id === id);
366
+ if (!provider) return reply.code(404).send({ error: `provider "${id}" not found` });
367
+ const key = provider.api_keys?.[0] ?? provider.api_key;
368
+ reply.send(await pingProvider(provider, key));
369
+ });
370
+
371
+ // Test ONE key: ping the provider's /models with that specific key, so the
372
+ // operator can tell which of several keys is live. Index is numeric (no slash
373
+ // hazard), so it stays a path param.
374
+ app.post("/admin/providers/:id/keys/:index/test", requireAdmin, async (req, reply) => {
375
+ const { id, index } = req.params as { id: string; index: string };
376
+ const provider = deps.state.config.raw.providers.find((p) => p.id === id);
377
+ if (!provider) return reply.code(404).send({ error: `provider "${id}" not found` });
378
+ const keys = provider.api_keys ?? (provider.api_key ? [provider.api_key] : []);
379
+ const i = Number(index);
380
+ if (!Number.isInteger(i) || i < 0 || i >= keys.length) {
381
+ return reply.code(404).send({ error: "key index out of range" });
382
+ }
383
+ reply.send(await pingProvider(provider, keys[i]));
384
+ });
385
+
386
+ // Test ONE model end-to-end (aigetwey's per-model science button). Routes through
387
+ // the real pipeline via handle(), so the ping lands in usage/quota exactly like
388
+ // a normal call — and it catches "model not found / not entitled" a /models
389
+ // ping can't. Model id travels as ?model= to survive slashes through the proxy.
390
+ app.post("/admin/providers/:id/models/test", requireAdmin, async (req, reply) => {
391
+ const { id } = req.params as { id: string };
392
+ const modelId = (req.query as { model?: string }).model;
393
+ if (!modelId) return reply.code(400).send({ error: "model query param required" });
394
+ const provider = deps.state.config.raw.providers.find((p) => p.id === id);
395
+ if (!provider) return reply.code(404).send({ error: `provider "${id}" not found` });
396
+ try {
397
+ await handle(
398
+ { config: deps.state.config, pool: deps.state.pool, db: deps.db, quota: deps.state.quota },
399
+ "openai",
400
+ { model: `${id}/${modelId}`, messages: [{ role: "user", content: "ping" }], max_tokens: 1, stream: false },
401
+ );
402
+ reply.send({ ok: true });
403
+ } catch (e) {
404
+ if (e instanceof GatewayError) {
405
+ const msg = typeof e.payload === "string" ? e.payload
406
+ : (e.payload as { error?: string })?.error ?? JSON.stringify(e.payload);
407
+ return reply.send({ ok: false, status: e.status, error: msg });
408
+ }
409
+ reply.send({ ok: false, error: (e as Error).message });
410
+ }
411
+ });
412
+
413
+ // DISCOVER a provider's catalog without adding anything — returns the full
414
+ // upstream list flagged with which ids are already in config, so the UI can
415
+ // show a checklist instead of dumping every model into the catalog.
416
+ app.post("/admin/providers/:id/connect", requireAdmin, async (req, reply) => {
417
+ const { id } = req.params as { id: string };
418
+ const provider = deps.state.config.getProvider(id);
419
+ if (!provider) return reply.code(404).send({ error: `provider "${id}" not found` });
420
+ const result = await fetchModels(provider);
421
+ if (!result.ok) return reply.code(502).send({ error: result.error ?? "model fetch failed" });
422
+ const have = new Set(provider.models.map((m) => m.id));
423
+ const models = result.models.map((m) => ({ id: m.id, added: have.has(m.id) }));
424
+ reply.send({ ok: true, models });
425
+ });
426
+
427
+ // every callable model: provider/model catalog entries + routing aliases.
428
+ app.get("/admin/models", requireAdmin, (_req, reply) => {
429
+ const providers = deps.state.config.listProviders().map((p) => ({
430
+ id: p.id,
431
+ format: p.format,
432
+ models: p.models.map((m) => ({ id: m.id, ref: `${p.id}/${m.id}`, price_in: m.price_in, price_out: m.price_out })),
433
+ }));
434
+ const routes = deps.state.config.listRoutes();
435
+ reply.send({ providers, routes });
436
+ });
437
+
438
+ // pricing overview: every provider/model with its config override (if any) and
439
+ // the auto-resolved default from the pricing table. Drives the Settings editor.
440
+ app.get("/admin/pricing", requireAdmin, (_req, reply) => {
441
+ const providers = deps.state.config.listProviders().map((p) => ({
442
+ id: p.id,
443
+ models: p.models.map((m) => {
444
+ const def = getPricingForModel(p.id, m.id);
445
+ return {
446
+ id: m.id,
447
+ price_in: m.price_in ?? null,
448
+ price_out: m.price_out ?? null,
449
+ default_in: def?.input ?? null,
450
+ default_out: def?.output ?? null,
451
+ };
452
+ }),
453
+ }));
454
+ reply.send({ providers });
455
+ });
456
+
457
+ // set/clear a model's price override (per 1M tokens). model travels in the body
458
+ // (can hold slashes); null clears the override → cost falls back to the table.
459
+ app.put("/admin/providers/:id/models/price", requireAdmin, (req, reply) => {
460
+ const { id } = req.params as { id: string };
461
+ const b = req.body as { model?: string; price_in?: number | null; price_out?: number | null };
462
+ if (!b?.model) return reply.code(400).send({ error: "model required" });
463
+ applyMutation(reply, (c) => setProviderModelPrice(c, id, b.model!, { price_in: b.price_in, price_out: b.price_out }));
464
+ });
465
+
466
+ // ---- combos: client alias -> ordered provider chain + strategy ----
467
+
468
+ app.put("/admin/routes/:alias", requireAdmin, (req, reply) => {
469
+ const { alias } = req.params as { alias: string };
470
+ const b = req.body as {
471
+ target?: string[];
472
+ model?: string | string[];
473
+ strategy?: "fallback" | "round-robin";
474
+ price_in?: number;
475
+ price_out?: number;
476
+ };
477
+ if (!Array.isArray(b?.target) || b.target.length === 0) {
478
+ return reply.code(400).send({ error: "target[] required" });
479
+ }
480
+ applyMutation(reply, (c) =>
481
+ setRoute(c, {
482
+ alias: decodeURIComponent(alias),
483
+ target: b.target!,
484
+ model: b.model,
485
+ strategy: b.strategy,
486
+ price_in: b.price_in,
487
+ price_out: b.price_out,
488
+ }),
489
+ );
490
+ });
491
+
492
+ app.delete("/admin/routes/:alias", requireAdmin, (req, reply) => {
493
+ const { alias } = req.params as { alias: string };
494
+ applyMutation(reply, (c) => removeRoute(c, decodeURIComponent(alias)));
495
+ });
496
+
497
+ // ---- endpoint: token-saver toggles + gateway keys ----
498
+
499
+ app.get("/admin/endpoint", requireAdmin, (_req, reply) => {
500
+ reply.send(endpointPayload(deps.state.config.raw));
501
+ });
502
+
503
+ app.put("/admin/endpoint/rtk", requireAdmin, (req, reply) => {
504
+ const b = req.body as { enabled?: boolean };
505
+ applyMutation(reply, (c) => setRtk(c, !!b?.enabled));
506
+ });
507
+
508
+ app.put("/admin/endpoint/caveman", requireAdmin, (req, reply) => {
509
+ const b = req.body as { level?: EndpointSettings["caveman"] };
510
+ if (!isLevel(b?.level)) return reply.code(400).send({ error: "level must be off|lite|full|ultra" });
511
+ applyMutation(reply, (c) => setCaveman(c, b.level!));
512
+ });
513
+
514
+ app.put("/admin/endpoint/ponytail", requireAdmin, (req, reply) => {
515
+ const b = req.body as { level?: EndpointSettings["ponytail"] };
516
+ if (!isLevel(b?.level)) return reply.code(400).send({ error: "level must be off|lite|full|ultra" });
517
+ applyMutation(reply, (c) => setPonytail(c, b.level!));
518
+ });
519
+
520
+ app.put("/admin/endpoint/headroom", requireAdmin, (req, reply) => {
521
+ const b = req.body as { enabled?: boolean; url?: string; compress_user_messages?: boolean };
522
+ applyMutation(reply, (c) =>
523
+ setHeadroom(c, { enabled: b?.enabled, url: b?.url, compress_user_messages: b?.compress_user_messages }),
524
+ );
525
+ });
526
+
527
+ app.post("/admin/endpoint/keys", requireAdmin, (req, reply) => {
528
+ const b = req.body as { key?: string; name?: string };
529
+ if (!b?.key) return reply.code(400).send({ error: "key required" });
530
+ applyMutation(reply, (c) => addServerKey(c, b.key!, b.name));
531
+ });
532
+
533
+ // rename ONE gateway key's label (the Endpoint page's edit-name button).
534
+ app.put("/admin/endpoint/keys/:index", requireAdmin, (req, reply) => {
535
+ const { index } = req.params as { index: string };
536
+ const i = Number(index);
537
+ if (!Number.isInteger(i)) return reply.code(400).send({ error: "index must be an integer" });
538
+ const b = req.body as { name?: string };
539
+ applyMutation(reply, (c) => editServerKey(c, i, { name: b?.name }));
540
+ });
541
+
542
+ app.delete("/admin/endpoint/keys/:index", requireAdmin, (req, reply) => {
543
+ const { index } = req.params as { index: string };
544
+ const i = Number(index);
545
+ if (!Number.isInteger(i)) return reply.code(400).send({ error: "index must be an integer" });
546
+ applyMutation(reply, (c) => removeServerKey(c, i));
547
+ });
548
+
549
+ // reveal ONE raw gateway key (the "show key" button on the Endpoint page).
550
+ app.get("/admin/endpoint/keys/:index/reveal", requireAdmin, (req, reply) => {
551
+ const { index } = req.params as { index: string };
552
+ const i = Number(index);
553
+ const keys = deps.state.config.raw.server.api_keys;
554
+ if (!Number.isInteger(i) || i < 0 || i >= keys.length) {
555
+ return reply.code(404).send({ error: "key index out of range" });
556
+ }
557
+ reply.send({ key: keys[i] });
558
+ });
559
+
560
+ // ---- headroom: external context-compression proxy lifecycle ----
561
+
562
+ app.get("/admin/headroom/status", requireAdmin, async (_req, reply) => {
563
+ const hr = deps.state.config.raw.endpoint.headroom;
564
+ const url = hr.url || DEFAULT_HEADROOM_URL;
565
+ const status = await getHeadroomStatus(url);
566
+ reply.send({
567
+ ...status,
568
+ url,
569
+ managedPid: getManagedPid(),
570
+ enabled: hr.enabled,
571
+ compress_user_messages: hr.compress_user_messages,
572
+ });
573
+ });
574
+
575
+ app.post("/admin/headroom/start", requireAdmin, async (_req, reply) => {
576
+ const url = deps.state.config.raw.endpoint.headroom.url || DEFAULT_HEADROOM_URL;
577
+ if (!isLoopbackHeadroomUrl(url)) {
578
+ return reply
579
+ .code(400)
580
+ .send({ error: "external headroom proxies must be started outside aigetwey", code: "EXTERNAL_PROXY" });
581
+ }
582
+ let port = 8787;
583
+ try {
584
+ const p = parseInt(new URL(url).port, 10);
585
+ if (p > 0 && p < 65536) port = p;
586
+ } catch {
587
+ /* default */
588
+ }
589
+ try {
590
+ const result = await startHeadroomProxy({ port });
591
+ reply.send({ success: true, ...result });
592
+ } catch (e) {
593
+ const err = e as Error & { code?: string };
594
+ reply.code(err.code === "NOT_INSTALLED" ? 400 : 500).send({ error: err.message, code: err.code ?? null });
595
+ }
596
+ });
597
+
598
+ app.post("/admin/headroom/stop", requireAdmin, (_req, reply) => {
599
+ try {
600
+ const result = stopHeadroomProxy();
601
+ reply.code(result.stopped ? 200 : 409).send(result);
602
+ } catch (e) {
603
+ const err = e as Error & { code?: string };
604
+ reply.code(500).send({ error: err.message, code: err.code ?? null });
605
+ }
606
+ });
607
+
608
+ app.get("/admin/headroom/log", requireAdmin, (_req, reply) => {
609
+ reply.send({ log: getHeadroomLogTail() });
610
+ });
611
+
612
+ // ---- console log SSE stream ----
613
+
614
+ app.get("/admin/console/stream", requireAdmin, (req, reply) => {
615
+ reply.raw.writeHead(200, {
616
+ "Content-Type": "text/event-stream",
617
+ "Cache-Control": "no-cache, no-transform",
618
+ Connection: "keep-alive",
619
+ // stop reverse proxies / Next's prod server from buffering the stream.
620
+ "X-Accel-Buffering": "no",
621
+ });
622
+
623
+ const recent = consoleBuffer.recent();
624
+ reply.raw.write(`data: ${JSON.stringify({ type: "init", logs: recent })}\n\n`);
625
+
626
+ const unsub = consoleBuffer.subscribe((entry) => {
627
+ reply.raw.write(`data: ${JSON.stringify({ type: "line", ...entry })}\n\n`);
628
+ });
629
+
630
+ // heartbeat: keeps the connection (and any proxy in between) alive while idle,
631
+ // so the viewer stays "Connected" instead of silently dropping.
632
+ const keepalive = setInterval(() => reply.raw.write(": keepalive\n\n"), 15000);
633
+
634
+ req.raw.on("close", () => {
635
+ clearInterval(keepalive);
636
+ unsub();
637
+ });
638
+ });
639
+
640
+ app.delete("/admin/console", requireAdmin, (_req, reply) => {
641
+ consoleBuffer.clear();
642
+ reply.send({ ok: true });
643
+ });
644
+
645
+ // ---- version: current build + best-effort npm "update available" check ----
646
+ // Poll npm for the latest published version; a newer semver flips an
647
+ // "update available" flag in the dashboard. Best-effort — failures leave
648
+ // `latest` null and never show a false positive.
649
+ app.get("/admin/version", requireAdmin, async (_req, reply) => {
650
+ const current = readVersion();
651
+ let latest: string | null = null;
652
+ try {
653
+ const res = await fetch("https://registry.npmjs.org/aigetwey/latest", {
654
+ signal: AbortSignal.timeout(3000),
655
+ });
656
+ if (res.ok) {
657
+ const j = (await res.json()) as { version?: string };
658
+ latest = j.version ?? null;
659
+ }
660
+ } catch {
661
+ /* offline or unpublished — leave latest null (no update info) */
662
+ }
663
+ reply.send({ current, latest, updateAvailable: !!(latest && isNewerVersion(latest, current)) });
664
+ });
665
+
666
+ // ---- shutdown: stop the gateway process (dashboard power button) ----
667
+ // Matches aigetwey's POST /api/shutdown: reply first, then exit after a short
668
+ // delay so the response reaches the browser. Admin-gated like everything else;
669
+ // the DB is closed cleanly (same path as the SIGINT/SIGTERM handler).
670
+ app.post("/admin/shutdown", requireAdmin, (_req, reply) => {
671
+ app.log.warn("[admin] shutdown requested via dashboard");
672
+ reply.send({ ok: true, message: "shutting down" });
673
+ setTimeout(() => {
674
+ deps.db?.close();
675
+ process.exit(0);
676
+ }, 300);
677
+ });
678
+ }
679
+
680
+ function isLevel(v: unknown): v is EndpointSettings["caveman"] {
681
+ return v === "off" || v === "lite" || v === "full" || v === "ultra";
682
+ }
683
+
684
+ /** Current package version, read from the repo's package.json (cwd). */
685
+ function readVersion(): string {
686
+ try {
687
+ const pkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")) as { version?: string };
688
+ return pkg.version ?? "0.0.0";
689
+ } catch {
690
+ return "0.0.0";
691
+ }
692
+ }
693
+
694
+ /** True if semver `a` is strictly newer than `b` (numeric compare, ignores pre-release). */
695
+ function isNewerVersion(a: string, b: string): boolean {
696
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
697
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
698
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
699
+ const x = pa[i] ?? 0;
700
+ const y = pb[i] ?? 0;
701
+ if (x !== y) return x > y;
702
+ }
703
+ return false;
704
+ }
705
+
706
+ /** Endpoint settings: toggles + masked gateway keys + port. */
707
+ function endpointPayload(config: Config) {
708
+ return {
709
+ port: config.server.port,
710
+ rtk: config.endpoint.rtk,
711
+ caveman: config.endpoint.caveman,
712
+ ponytail: config.endpoint.ponytail,
713
+ headroom: config.endpoint.headroom,
714
+ keys: config.server.api_keys.map((k) => ({ key: maskKey(k), name: config.server.key_names?.[k] })),
715
+ };
716
+ }