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