aigetwey 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/assets/logo.svg +8 -0
- package/assets/screenshot.png +0 -0
- package/assets/wordmark.svg +9 -0
- package/config.example.yaml +56 -0
- package/dashboard/.env.example +12 -0
- package/dashboard/next-env.d.ts +6 -0
- package/dashboard/next.config.ts +12 -0
- package/dashboard/package-lock.json +1771 -0
- package/dashboard/package.json +29 -0
- package/dashboard/postcss.config.mjs +5 -0
- package/dashboard/src/app/(console)/combos/page.tsx +10 -0
- package/dashboard/src/app/(console)/config/page.tsx +5 -0
- package/dashboard/src/app/(console)/console/page.tsx +92 -0
- package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
- package/dashboard/src/app/(console)/layout.tsx +17 -0
- package/dashboard/src/app/(console)/page.tsx +8 -0
- package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/providers/page.tsx +5 -0
- package/dashboard/src/app/(console)/quota/page.tsx +5 -0
- package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/tools/page.tsx +5 -0
- package/dashboard/src/app/(console)/usage/page.tsx +24 -0
- package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
- package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
- package/dashboard/src/app/api/login/route.ts +30 -0
- package/dashboard/src/app/api/logout/route.ts +9 -0
- package/dashboard/src/app/api/password/route.ts +34 -0
- package/dashboard/src/app/globals.css +340 -0
- package/dashboard/src/app/icon.svg +8 -0
- package/dashboard/src/app/layout.tsx +28 -0
- package/dashboard/src/app/login/page.tsx +60 -0
- package/dashboard/src/components/AreaChart.tsx +115 -0
- package/dashboard/src/components/Badge.tsx +32 -0
- package/dashboard/src/components/Button.tsx +60 -0
- package/dashboard/src/components/CapacityBadges.tsx +40 -0
- package/dashboard/src/components/Checkbox.tsx +40 -0
- package/dashboard/src/components/CliToolConfig.tsx +63 -0
- package/dashboard/src/components/ConfigEditor.tsx +199 -0
- package/dashboard/src/components/ConfirmModal.tsx +36 -0
- package/dashboard/src/components/CooldownTimer.tsx +42 -0
- package/dashboard/src/components/EndpointView.tsx +439 -0
- package/dashboard/src/components/Icon.tsx +25 -0
- package/dashboard/src/components/KeyReveal.tsx +78 -0
- package/dashboard/src/components/Lamp.tsx +8 -0
- package/dashboard/src/components/LogTable.tsx +223 -0
- package/dashboard/src/components/LogoutButton.tsx +20 -0
- package/dashboard/src/components/ModelPicker.tsx +121 -0
- package/dashboard/src/components/ModelSelectModal.tsx +126 -0
- package/dashboard/src/components/PasswordEditor.tsx +86 -0
- package/dashboard/src/components/PricingEditor.tsx +171 -0
- package/dashboard/src/components/ProviderDetail.tsx +566 -0
- package/dashboard/src/components/ProviderManager.tsx +311 -0
- package/dashboard/src/components/QuotaView.tsx +78 -0
- package/dashboard/src/components/Rail.tsx +82 -0
- package/dashboard/src/components/RichCard.tsx +46 -0
- package/dashboard/src/components/RoutingView.tsx +329 -0
- package/dashboard/src/components/ThemeProvider.tsx +36 -0
- package/dashboard/src/components/ToastProvider.tsx +58 -0
- package/dashboard/src/components/ToolDetail.tsx +475 -0
- package/dashboard/src/components/TopBar.tsx +128 -0
- package/dashboard/src/components/UsageView.tsx +151 -0
- package/dashboard/src/components/ui.tsx +54 -0
- package/dashboard/src/lib/capabilities.ts +318 -0
- package/dashboard/src/lib/cliTools.ts +120 -0
- package/dashboard/src/lib/client.ts +190 -0
- package/dashboard/src/lib/gateway.ts +269 -0
- package/dashboard/src/lib/session.ts +71 -0
- package/dashboard/src/middleware.ts +37 -0
- package/dashboard/tsconfig.json +21 -0
- package/dist/adapters/anthropic.js +289 -0
- package/dist/adapters/anthropic.js.map +1 -0
- package/dist/adapters/gemini.js +268 -0
- package/dist/adapters/gemini.js.map +1 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openai.js +13 -0
- package/dist/adapters/openai.js.map +1 -0
- package/dist/cli/tray/autostart.js +152 -0
- package/dist/cli/tray/autostart.js.map +1 -0
- package/dist/cli/tray/icon.js +4 -0
- package/dist/cli/tray/icon.js.map +1 -0
- package/dist/cli/tray/tray.js +141 -0
- package/dist/cli/tray/tray.js.map +1 -0
- package/dist/cli/tray/trayRuntime.js +91 -0
- package/dist/cli/tray/trayRuntime.js.map +1 -0
- package/dist/cli.js +361 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +728 -0
- package/dist/config.js.map +1 -0
- package/dist/core/authStore.js +78 -0
- package/dist/core/authStore.js.map +1 -0
- package/dist/core/canonical.js +9 -0
- package/dist/core/canonical.js.map +1 -0
- package/dist/core/console-buffer.js +25 -0
- package/dist/core/console-buffer.js.map +1 -0
- package/dist/core/fallback.js +62 -0
- package/dist/core/fallback.js.map +1 -0
- package/dist/core/handler.js +174 -0
- package/dist/core/handler.js.map +1 -0
- package/dist/core/keypool.js +105 -0
- package/dist/core/keypool.js.map +1 -0
- package/dist/core/quota.js +165 -0
- package/dist/core/quota.js.map +1 -0
- package/dist/core/state.js +52 -0
- package/dist/core/state.js.map +1 -0
- package/dist/db.js +193 -0
- package/dist/db.js.map +1 -0
- package/dist/headroom/compress.js +44 -0
- package/dist/headroom/compress.js.map +1 -0
- package/dist/headroom/detect.js +108 -0
- package/dist/headroom/detect.js.map +1 -0
- package/dist/headroom/process.js +158 -0
- package/dist/headroom/process.js.map +1 -0
- package/dist/inject/caveman.js +30 -0
- package/dist/inject/caveman.js.map +1 -0
- package/dist/inject/index.js +24 -0
- package/dist/inject/index.js.map +1 -0
- package/dist/inject/ponytail.js +19 -0
- package/dist/inject/ponytail.js.map +1 -0
- package/dist/middleware/auth.js +66 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/providers/capabilities.js +246 -0
- package/dist/providers/capabilities.js.map +1 -0
- package/dist/providers/free.js +43 -0
- package/dist/providers/free.js.map +1 -0
- package/dist/providers/pricing.js +224 -0
- package/dist/providers/pricing.js.map +1 -0
- package/dist/providers/vertex.js +97 -0
- package/dist/providers/vertex.js.map +1 -0
- package/dist/routes/admin.js +622 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/health.js +4 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.js +12 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/v1.js +75 -0
- package/dist/routes/v1.js.map +1 -0
- package/dist/rtk/detect.js +50 -0
- package/dist/rtk/detect.js.map +1 -0
- package/dist/rtk/filters.js +85 -0
- package/dist/rtk/filters.js.map +1 -0
- package/dist/rtk/index.js +39 -0
- package/dist/rtk/index.js.map +1 -0
- package/dist/server.js +100 -0
- package/dist/server.js.map +1 -0
- package/dist/stream/anthropic-stream.js +239 -0
- package/dist/stream/anthropic-stream.js.map +1 -0
- package/dist/stream/chunk.js +7 -0
- package/dist/stream/chunk.js.map +1 -0
- package/dist/stream/gemini-stream.js +135 -0
- package/dist/stream/gemini-stream.js.map +1 -0
- package/dist/stream/index.js +12 -0
- package/dist/stream/index.js.map +1 -0
- package/dist/stream/openai-stream.js +34 -0
- package/dist/stream/openai-stream.js.map +1 -0
- package/dist/stream/sse.js +64 -0
- package/dist/stream/sse.js.map +1 -0
- package/dist/translator/thinking.js +70 -0
- package/dist/translator/thinking.js.map +1 -0
- package/dist/translator/thinkingUnified.js +322 -0
- package/dist/translator/thinkingUnified.js.map +1 -0
- package/dist/upstream/client.js +120 -0
- package/dist/upstream/client.js.map +1 -0
- package/package.json +76 -0
- package/run.sh +27 -0
- package/src/adapters/anthropic.ts +377 -0
- package/src/adapters/gemini.ts +341 -0
- package/src/adapters/index.ts +17 -0
- package/src/adapters/openai.ts +22 -0
- package/src/cli/tray/autostart.ts +133 -0
- package/src/cli/tray/icon.ts +4 -0
- package/src/cli/tray/tray.ts +156 -0
- package/src/cli/tray/trayRuntime.ts +90 -0
- package/src/cli.ts +379 -0
- package/src/config.ts +777 -0
- package/src/core/authStore.ts +86 -0
- package/src/core/canonical.ts +93 -0
- package/src/core/console-buffer.ts +39 -0
- package/src/core/fallback.ts +116 -0
- package/src/core/handler.ts +236 -0
- package/src/core/keypool.ts +152 -0
- package/src/core/quota.ts +214 -0
- package/src/core/state.ts +65 -0
- package/src/db.ts +280 -0
- package/src/headroom/compress.ts +78 -0
- package/src/headroom/detect.ts +119 -0
- package/src/headroom/process.ts +166 -0
- package/src/inject/caveman.ts +35 -0
- package/src/inject/index.ts +46 -0
- package/src/inject/ponytail.ts +31 -0
- package/src/middleware/auth.ts +76 -0
- package/src/providers/capabilities.ts +297 -0
- package/src/providers/free.ts +53 -0
- package/src/providers/pricing.ts +261 -0
- package/src/providers/vertex.ts +117 -0
- package/src/routes/admin.ts +716 -0
- package/src/routes/health.ts +5 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/v1.ts +87 -0
- package/src/rtk/detect.ts +55 -0
- package/src/rtk/filters.ts +94 -0
- package/src/rtk/index.ts +58 -0
- package/src/server.ts +108 -0
- package/src/stream/anthropic-stream.ts +310 -0
- package/src/stream/chunk.ts +46 -0
- package/src/stream/gemini-stream.ts +158 -0
- package/src/stream/index.ts +23 -0
- package/src/stream/openai-stream.ts +41 -0
- package/src/stream/sse.ts +72 -0
- package/src/translator/thinking.ts +64 -0
- package/src/translator/thinkingUnified.ts +319 -0
- package/src/upstream/client.ts +155 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,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
|