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,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
|
+
}
|