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,78 @@
1
+ /**
2
+ * Request-time context compression via the external Headroom proxy.
3
+ *
4
+ * aigetwey's canonical request is OpenAI-shaped, and Headroom's /v1/compress only
5
+ * understands OpenAI shape — so we compress canonical.messages directly, no
6
+ * per-format translation dance (aigetwey has to translate Claude bodies first).
7
+ * Fail-open: any error returns null and the request proceeds uncompressed.
8
+ *
9
+ * aigetwey's own implementation.
10
+ */
11
+ import type { CanonicalMessage } from "../core/canonical.js";
12
+
13
+ const DEFAULT_TIMEOUT_MS = 3000;
14
+
15
+ export interface HeadroomStats {
16
+ tokens_before?: number;
17
+ tokens_after?: number;
18
+ tokens_saved?: number;
19
+ }
20
+
21
+ export interface HeadroomCompressOpts {
22
+ url: string;
23
+ model: string;
24
+ compressUserMessages?: boolean;
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ interface CompressReply extends HeadroomStats {
29
+ messages: CanonicalMessage[];
30
+ }
31
+
32
+ // POST messages to Headroom /v1/compress; returns compressed messages + stats or null.
33
+ async function callCompress(
34
+ url: string,
35
+ messages: CanonicalMessage[],
36
+ model: string,
37
+ timeoutMs: number,
38
+ compressUserMessages?: boolean,
39
+ ): Promise<CompressReply | null> {
40
+ const endpoint = `${String(url).replace(/\/$/, "")}/v1/compress`;
41
+ const payload: Record<string, unknown> = { messages, model };
42
+ if (compressUserMessages) payload.config = { compress_user_messages: true };
43
+ const res = await fetch(endpoint, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify(payload),
47
+ signal: AbortSignal.timeout(timeoutMs),
48
+ });
49
+ if (!res.ok) return null;
50
+ const data = (await res.json()) as CompressReply;
51
+ if (!Array.isArray(data?.messages)) return null;
52
+ return data;
53
+ }
54
+
55
+ /**
56
+ * Compress messages via the Headroom proxy. Returns the compressed messages +
57
+ * stats, or null on any failure (caller keeps the original messages).
58
+ */
59
+ export async function compressWithHeadroom(
60
+ messages: CanonicalMessage[],
61
+ { url, model, compressUserMessages, timeoutMs = DEFAULT_TIMEOUT_MS }: HeadroomCompressOpts,
62
+ ): Promise<CompressReply | null> {
63
+ if (!url || !Array.isArray(messages) || messages.length === 0) return null;
64
+ try {
65
+ return await callCompress(url, messages, model, timeoutMs, compressUserMessages);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function formatHeadroomLog(stats: HeadroomStats | null): string | null {
72
+ if (!stats) return null;
73
+ const before = stats.tokens_before || 0;
74
+ const after = stats.tokens_after || 0;
75
+ const saved = stats.tokens_saved || 0;
76
+ const pct = before > 0 ? ((saved / before) * 100).toFixed(1) : "0";
77
+ return `saved ${saved} tokens / ${before} (${pct}%) ${after ? `after=${after}` : ""}`.trim();
78
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Headroom CLI detection. "Headroom" is an external context-compression proxy
3
+ * (a Python tool, `headroom proxy`, default http://localhost:8787) that aigetwey
4
+ * pipes request messages through. This module only DETECTS it — install, python
5
+ * interpreter, and whether a proxy is already reachable.
6
+ *
7
+ * aigetwey's own implementation.
8
+ */
9
+ import { execSync } from "node:child_process";
10
+ import { delimiter } from "node:path";
11
+
12
+ const IS_WIN = process.platform === "win32";
13
+ const WHICH_CMD = IS_WIN ? "where" : "which";
14
+
15
+ // Extra bin dirs often missing from a packaged PATH (Python installs headroom here).
16
+ const EXTRA_BINS = IS_WIN
17
+ ? [
18
+ `${process.env.LOCALAPPDATA || ""}\\Programs\\Python\\Python313\\Scripts`,
19
+ `${process.env.LOCALAPPDATA || ""}\\Programs\\Python\\Python312\\Scripts`,
20
+ `${process.env.LOCALAPPDATA || ""}\\Programs\\Python\\Python311\\Scripts`,
21
+ `${process.env.APPDATA || ""}\\Python\\Python313\\Scripts`,
22
+ ]
23
+ : [
24
+ "/usr/local/bin",
25
+ "/opt/homebrew/bin",
26
+ `${process.env.HOME || ""}/.local/bin`,
27
+ "/usr/bin",
28
+ "/bin",
29
+ ];
30
+
31
+ const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(delimiter);
32
+ const PYTHON_CANDIDATES = ["python3.13", "python3.12", "python3.11", "python3.10", "python3", "python"];
33
+ const MIN_VERSION: [number, number] = [3, 10];
34
+ const HEADROOM_HEALTH_TIMEOUT_MS = 1500;
35
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
36
+
37
+ export const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
38
+
39
+ export interface HeadroomStatus {
40
+ installed: boolean;
41
+ path: string | null;
42
+ running: boolean;
43
+ python: string | null;
44
+ localUrl: boolean;
45
+ canStart: boolean;
46
+ }
47
+
48
+ /** Locate the `headroom` binary, or null if not installed. */
49
+ export function findHeadroomBinary(): string | null {
50
+ try {
51
+ const out = execSync(`${WHICH_CMD} headroom`, {
52
+ stdio: ["ignore", "pipe", "ignore"],
53
+ windowsHide: true,
54
+ env: { ...process.env, PATH: EXTENDED_PATH },
55
+ })
56
+ .toString()
57
+ .trim();
58
+ // Windows `where` may return multiple lines — take the first.
59
+ return out ? (out.split(/\r?\n/)[0] ?? "").trim() || null : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** Find a Python interpreter >= 3.10 (headroom-ai requires it), or null. */
66
+ export function findPython310(): string | null {
67
+ for (const candidate of PYTHON_CANDIDATES) {
68
+ try {
69
+ const ver = execSync(`${candidate} --version`, {
70
+ stdio: ["ignore", "pipe", "ignore"],
71
+ windowsHide: true,
72
+ env: { ...process.env, PATH: EXTENDED_PATH },
73
+ })
74
+ .toString()
75
+ .trim();
76
+ const match = ver.match(/(\d+)\.(\d+)/);
77
+ if (!match) continue;
78
+ const major = parseInt(match[1]!, 10);
79
+ const minor = parseInt(match[2]!, 10);
80
+ if (major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1])) {
81
+ return candidate;
82
+ }
83
+ } catch {
84
+ // candidate not present, try next
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /** Probe a Headroom proxy's /health. */
91
+ export async function probeProxyRunning(url: string): Promise<boolean> {
92
+ if (!url) return false;
93
+ const base = String(url).replace(/\/$/, "");
94
+ try {
95
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(HEADROOM_HEALTH_TIMEOUT_MS) });
96
+ return res.ok;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ export function isLoopbackHeadroomUrl(url: string): boolean {
103
+ try {
104
+ const parsed = new URL(url);
105
+ return LOOPBACK_HOSTS.has(parsed.hostname);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /** Aggregate status for the dashboard: installed, running, python interpreter. */
112
+ export async function getHeadroomStatus(url: string): Promise<HeadroomStatus> {
113
+ const path = findHeadroomBinary();
114
+ const python = findPython310();
115
+ const installed = Boolean(path);
116
+ const running = await probeProxyRunning(url);
117
+ const localUrl = isLoopbackHeadroomUrl(url);
118
+ return { installed, path, running, python, localUrl, canStart: installed && localUrl };
119
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Headroom proxy lifecycle — start/stop the external `headroom proxy` as a
3
+ * detached child of the gateway, tracked by a PID file under the data dir.
4
+ *
5
+ * aigetwey's own implementation.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { spawn } from "node:child_process";
10
+ import { findHeadroomBinary } from "./detect.js";
11
+
12
+ const DATA_DIR = path.resolve(process.env.AIGETWEY_DATA_DIR ?? "data");
13
+ const HEADROOM_DIR = path.join(DATA_DIR, "headroom");
14
+ const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid");
15
+ const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log");
16
+ const DEFAULT_PORT = 8787;
17
+ const STARTUP_TIMEOUT_MS = 8000;
18
+
19
+ interface CodedError extends Error {
20
+ code?: string;
21
+ }
22
+
23
+ function ensureDir(): void {
24
+ if (!fs.existsSync(HEADROOM_DIR)) fs.mkdirSync(HEADROOM_DIR, { recursive: true });
25
+ }
26
+
27
+ function readPid(): number | null {
28
+ try {
29
+ if (fs.existsSync(PID_FILE)) return parseInt(fs.readFileSync(PID_FILE, "utf8"), 10);
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function writePid(pid: number): void {
37
+ ensureDir();
38
+ fs.writeFileSync(PID_FILE, String(pid));
39
+ }
40
+
41
+ function clearPid(): void {
42
+ try {
43
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
44
+ } catch {
45
+ /* ignore */
46
+ }
47
+ }
48
+
49
+ /** process.kill(pid, 0) throws if the pid is dead — use it to probe liveness. */
50
+ export function isPidAlive(pid: number | null): boolean {
51
+ if (!pid || typeof pid !== "number") return false;
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export function getManagedPid(): number | null {
61
+ const pid = readPid();
62
+ return pid && isPidAlive(pid) ? pid : null;
63
+ }
64
+
65
+ export async function startHeadroomProxy({ port = DEFAULT_PORT }: { port?: number } = {}): Promise<{
66
+ pid: number;
67
+ alreadyRunning: boolean;
68
+ }> {
69
+ const safePort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : DEFAULT_PORT;
70
+ const binary = findHeadroomBinary();
71
+ if (!binary) {
72
+ const err: CodedError = new Error("Headroom CLI not installed");
73
+ err.code = "NOT_INSTALLED";
74
+ throw err;
75
+ }
76
+
77
+ const existing = getManagedPid();
78
+ if (existing) return { pid: existing, alreadyRunning: true };
79
+
80
+ ensureDir();
81
+ // spawn stdio requires fd numbers, not WriteStream objects.
82
+ const outFd = fs.openSync(LOG_FILE, "a");
83
+
84
+ const child = spawn(binary, ["proxy", "--port", String(safePort)], {
85
+ stdio: ["ignore", outFd, outFd],
86
+ detached: true,
87
+ windowsHide: true,
88
+ env: { ...process.env },
89
+ });
90
+
91
+ if (!child.pid) {
92
+ fs.closeSync(outFd);
93
+ const err: CodedError = new Error("Failed to spawn headroom proxy");
94
+ err.code = "SPAWN_FAILED";
95
+ throw err;
96
+ }
97
+
98
+ child.unref();
99
+ writePid(child.pid);
100
+
101
+ // Wait until the process either stays alive briefly (success) or exits fast (failure).
102
+ await new Promise<void>((resolve, reject) => {
103
+ const startupTimer = setTimeout(() => {
104
+ if (isPidAlive(child.pid ?? null)) resolve();
105
+ else reject(new Error("headroom proxy exited during startup — see proxy.log"));
106
+ }, STARTUP_TIMEOUT_MS);
107
+
108
+ child.once("exit", (code) => {
109
+ clearTimeout(startupTimer);
110
+ clearPid();
111
+ try {
112
+ fs.closeSync(outFd);
113
+ } catch {
114
+ /* already closed */
115
+ }
116
+ const e: CodedError = new Error(`headroom proxy exited early (code=${code}) — see proxy.log`);
117
+ e.code = "EARLY_EXIT";
118
+ reject(e);
119
+ });
120
+ });
121
+
122
+ // Close parent's copy of the fd; child retains its own after unref.
123
+ try {
124
+ fs.closeSync(outFd);
125
+ } catch {
126
+ /* already closed */
127
+ }
128
+
129
+ return { pid: child.pid, alreadyRunning: false };
130
+ }
131
+
132
+ export function stopHeadroomProxy(): { stopped: boolean; reason?: string; pid?: number } {
133
+ const pid = getManagedPid();
134
+ if (!pid) return { stopped: false, reason: "not_running" };
135
+ try {
136
+ process.kill(pid, "SIGTERM");
137
+ // Give it a moment, then force if still alive.
138
+ setTimeout(() => {
139
+ if (isPidAlive(pid)) {
140
+ try {
141
+ process.kill(pid, "SIGKILL");
142
+ } catch {
143
+ /* already gone */
144
+ }
145
+ }
146
+ }, 2000);
147
+ clearPid();
148
+ return { stopped: true, pid };
149
+ } catch (e) {
150
+ clearPid();
151
+ const err: CodedError = new Error(`Failed to stop headroom proxy: ${(e as Error).message}`);
152
+ err.code = "STOP_FAILED";
153
+ throw err;
154
+ }
155
+ }
156
+
157
+ export function getHeadroomLogTail(maxLines = 200): string {
158
+ try {
159
+ if (!fs.existsSync(LOG_FILE)) return "";
160
+ const content = fs.readFileSync(LOG_FILE, "utf8");
161
+ const lines = content.split(/\r?\n/).filter(Boolean);
162
+ return lines.slice(-maxLines).join("\n");
163
+ } catch {
164
+ return "";
165
+ }
166
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Caveman injection. Prepends a system instruction telling the model to answer
3
+ * in terse "caveman speak" — dropping articles, filler and pleasantries while
4
+ * keeping full technical substance — to cut OUTPUT tokens.
5
+ *
6
+ * Four intensities trade brevity against readability. Returns null for "off" so
7
+ * the handler can skip injection entirely.
8
+ */
9
+
10
+ export type InjectLevel = "off" | "lite" | "full" | "ultra";
11
+
12
+ const PROMPTS: Record<Exclude<InjectLevel, "off">, string> = {
13
+ lite:
14
+ "Trim filler. Drop pleasantries (sure/certainly/happy to), hedging, and " +
15
+ "restating the question. Keep all technical substance, code, and exact error " +
16
+ "text verbatim. Prefer short words.",
17
+ full:
18
+ "Answer like a terse expert. Drop articles (a/an/the), filler " +
19
+ "(just/really/basically/actually), pleasantries, and hedging. Fragments OK. " +
20
+ "Short synonyms (big not extensive, fix not implement-a-solution-for). " +
21
+ "Keep ALL technical substance, exact identifiers, and code blocks unchanged. " +
22
+ "Quote error messages exactly. Pattern: [thing] [action] [reason].",
23
+ ultra:
24
+ "Maximum compression. Telegraphic fragments only — no articles, no filler, no " +
25
+ "pleasantries, no transitions. One idea per line where possible. Keep every " +
26
+ "technical fact, identifier, number, and code block exact and complete; never " +
27
+ "drop substance to save words. Quote errors verbatim. Prose is the enemy; " +
28
+ "information is not.",
29
+ };
30
+
31
+ /** System-prompt text for a caveman level, or null when off. */
32
+ export function cavemanPrompt(level: InjectLevel): string | null {
33
+ if (level === "off") return null;
34
+ return PROMPTS[level];
35
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Inject orchestrator. Builds the combined caveman + ponytail system prompt and
3
+ * prepends it to a canonical request's messages, in place.
4
+ *
5
+ * Pipeline order (handler): RTK compresses tool_result in the input first, THEN
6
+ * inject prepends the output-style prompt — they touch different parts of the
7
+ * request and stack cleanly (RTK shrinks input, caveman shrinks output prose,
8
+ * ponytail shrinks output code).
9
+ *
10
+ * Fail-open: an injection error must never break a request — the caller wraps
11
+ * this in try/catch and proceeds without injection.
12
+ */
13
+ import type { CanonicalMessage, CanonicalRequest } from "../core/canonical.js";
14
+ import { cavemanPrompt, type InjectLevel } from "./caveman.js";
15
+ import { ponytailPrompt } from "./ponytail.js";
16
+
17
+ export type { InjectLevel } from "./caveman.js";
18
+
19
+ export interface InjectSettings {
20
+ caveman: InjectLevel;
21
+ ponytail: InjectLevel;
22
+ }
23
+
24
+ /** Combined system-prompt text for the active toggles, or null if both off. */
25
+ export function buildInjection(settings: InjectSettings): string | null {
26
+ const parts = [cavemanPrompt(settings.caveman), ponytailPrompt(settings.ponytail)].filter(
27
+ (p): p is string => p !== null,
28
+ );
29
+ return parts.length > 0 ? parts.join("\n\n") : null;
30
+ }
31
+
32
+ /**
33
+ * Prepend the injection as the FIRST system message. A dedicated leading system
34
+ * message (rather than merging into an existing one) keeps the gateway's
35
+ * instruction separate from the client's own system prompt, and works for every
36
+ * provider format since the adapter collapses system messages on egress.
37
+ *
38
+ * Returns true if anything was injected.
39
+ */
40
+ export function injectInto(req: CanonicalRequest, settings: InjectSettings): boolean {
41
+ const text = buildInjection(settings);
42
+ if (!text) return false;
43
+ const sys: CanonicalMessage = { role: "system", content: text };
44
+ req.messages.unshift(sys);
45
+ return true;
46
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Ponytail injection. Prepends a system instruction nudging the model toward a
3
+ * "lazy senior dev" coding style — minimal, YAGNI, deletion over addition — to
4
+ * cut OUTPUT tokens AND reduce over-engineered code.
5
+ *
6
+ * Shares the InjectLevel scale with caveman; the two stack (caveman shapes prose
7
+ * style, ponytail shapes code style). Returns null for "off".
8
+ */
9
+ import type { InjectLevel } from "./caveman.js";
10
+
11
+ const PROMPTS: Record<Exclude<InjectLevel, "off">, string> = {
12
+ lite:
13
+ "Prefer the smallest change that solves the problem. Don't add features, " +
14
+ "abstractions, or error handling beyond what was asked.",
15
+ full:
16
+ "Code like a lazy senior dev: do the minimum that fully solves the task. " +
17
+ "YAGNI — no speculative abstractions, config, or future-proofing. Prefer " +
18
+ "deleting code over adding it. No defensive checks for cases that can't " +
19
+ "happen. Don't explain code that's self-evident. Don't refactor unrelated code.",
20
+ ultra:
21
+ "Ruthless minimalism. Smallest possible diff. No new abstractions, no " +
22
+ "helpers for single callers, no comments unless a non-obvious WHY. Delete " +
23
+ "before you add. Skip boilerplate, validation, and error handling unless " +
24
+ "explicitly required. Output only the code that changed plus a one-line note.",
25
+ };
26
+
27
+ /** System-prompt text for a ponytail level, or null when off. */
28
+ export function ponytailPrompt(level: InjectLevel): string | null {
29
+ if (level === "off") return null;
30
+ return PROMPTS[level];
31
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Gateway-level auth. Clients present one of `server.api_keys` — OUR keys (handed
3
+ * to your devices), distinct from the upstream provider keys in each provider.
4
+ *
5
+ * Accepted in either header so both client families work unchanged:
6
+ * - Authorization: Bearer <key> (OpenAI-style clients)
7
+ * - x-api-key: <key> (Anthropic-style clients)
8
+ *
9
+ * Empty `server.api_keys` disables auth (localhost dev mode).
10
+ */
11
+ import { createHash, timingSafeEqual } from "node:crypto";
12
+ import type { FastifyRequest } from "fastify";
13
+
14
+ function digest(s: string): Buffer {
15
+ return createHash("sha256").update(s).digest();
16
+ }
17
+
18
+ /** Constant-time membership test over fixed-length digests. */
19
+ export function isValidKey(presented: string, validKeys: string[]): boolean {
20
+ const p = digest(presented);
21
+ // compare against every key so timing can't reveal which one matched.
22
+ let ok = false;
23
+ for (const k of validKeys) {
24
+ if (timingSafeEqual(p, digest(k))) ok = true;
25
+ }
26
+ return ok;
27
+ }
28
+
29
+ export function extractKey(req: FastifyRequest): string | null {
30
+ const auth = req.headers["authorization"];
31
+ if (typeof auth === "string" && auth.startsWith("Bearer ")) {
32
+ return auth.slice("Bearer ".length).trim();
33
+ }
34
+ const xkey = req.headers["x-api-key"];
35
+ if (typeof xkey === "string" && xkey.length > 0) return xkey;
36
+ return null;
37
+ }
38
+
39
+ export interface AuthResult {
40
+ ok: boolean;
41
+ status?: number;
42
+ error?: string;
43
+ }
44
+
45
+ export function checkAuth(req: FastifyRequest, validKeys: string[]): AuthResult {
46
+ if (validKeys.length === 0) return { ok: true }; // auth disabled
47
+ const key = extractKey(req);
48
+ if (!key) return { ok: false, status: 401, error: "missing API key" };
49
+ if (!isValidKey(key, validKeys)) return { ok: false, status: 401, error: "invalid API key" };
50
+ return { ok: true };
51
+ }
52
+
53
+ /** Verifies a presented admin password (against the persisted hash store). */
54
+ export interface AdminVerifier {
55
+ enabled: boolean;
56
+ verify(password: string): boolean;
57
+ }
58
+
59
+ /**
60
+ * Admin auth for /admin/* — the password is presented as a Bearer token (the
61
+ * dashboard proxies it server-side; never reaches the browser) and checked
62
+ * against the hash store (seeded from AIGETWEY_ADMIN_PASSWORD, changeable at
63
+ * runtime).
64
+ *
65
+ * If no password is set, admin routes LOCK (503) rather than open — admin
66
+ * surfaces provider keys, so failing open would leak secrets.
67
+ */
68
+ export function checkAdminAuth(req: FastifyRequest, auth: AdminVerifier | undefined): AuthResult {
69
+ if (!auth || !auth.enabled) {
70
+ return { ok: false, status: 503, error: "admin disabled (set AIGETWEY_ADMIN_PASSWORD)" };
71
+ }
72
+ const key = extractKey(req);
73
+ if (!key) return { ok: false, status: 401, error: "missing admin password" };
74
+ if (!auth.verify(key)) return { ok: false, status: 401, error: "invalid admin password" };
75
+ return { ok: true };
76
+ }