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,190 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Browser-side admin client. Talks to the gateway THROUGH the same-origin proxy
5
+ * (`/api/gw/admin/...`), which injects the admin Bearer server-side — so the
6
+ * password never reaches the browser. Server components read initial data via
7
+ * lib/gateway.ts directly; client components mutate through here.
8
+ */
9
+ import type {
10
+ ConfigReply,
11
+ EndpointPayload,
12
+ HeadroomStatusReply,
13
+ InjectLevel,
14
+ PingResult,
15
+ PricingPayload,
16
+ ProviderSnapshot,
17
+ QuotaSnapshot,
18
+ WireFormat,
19
+ } from "./gateway";
20
+
21
+ export interface ApiResult<T> {
22
+ ok: boolean;
23
+ status: number;
24
+ data: T | null;
25
+ error?: string;
26
+ }
27
+
28
+ async function api<T>(method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
29
+ let res: Response;
30
+ try {
31
+ res = await fetch(`/api/gw${path}`, {
32
+ method,
33
+ headers: body !== undefined ? { "content-type": "application/json" } : {},
34
+ body: body !== undefined ? JSON.stringify(body) : undefined,
35
+ });
36
+ } catch (e) {
37
+ return { ok: false, status: 0, data: null, error: (e as Error).message };
38
+ }
39
+ const text = await res.text();
40
+ let data: T | null = null;
41
+ try {
42
+ data = text ? (JSON.parse(text) as T) : null;
43
+ } catch {
44
+ data = null;
45
+ }
46
+ if (!res.ok) {
47
+ const err = (data as { error?: string } | null)?.error ?? `request failed (${res.status})`;
48
+ return { ok: false, status: res.status, data, error: err };
49
+ }
50
+ return { ok: true, status: res.status, data };
51
+ }
52
+
53
+ export const adminApi = {
54
+ providers: () => api<{ providers: ProviderSnapshot[] }>("GET", "/admin/providers"),
55
+ quota: () => api<{ quota: QuotaSnapshot[] }>("GET", "/admin/quota"),
56
+
57
+ addProvider: (p: {
58
+ id: string;
59
+ format: WireFormat;
60
+ base_url: string;
61
+ api_key?: string;
62
+ free?: boolean;
63
+ auto_models?: boolean;
64
+ service_account?: string;
65
+ }) => api<ConfigReply>("POST", "/admin/providers", p),
66
+ editProvider: (id: string, patch: { base_url?: string; format?: WireFormat; name?: string }) =>
67
+ api<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}`, patch),
68
+ renameProvider: (id: string, newId: string) =>
69
+ api<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}/rename`, { id: newId }),
70
+ removeProvider: (id: string) => api<ConfigReply>("DELETE", `/admin/providers/${encodeURIComponent(id)}`),
71
+ addKey: (id: string, key: string, name?: string) =>
72
+ api<ConfigReply>("POST", `/admin/providers/${encodeURIComponent(id)}/keys`, { key, name }),
73
+ editKey: (id: string, index: number, patch: { key?: string; name?: string }) =>
74
+ api<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}/keys/${index}`, patch),
75
+ removeKey: (id: string, index: number) =>
76
+ api<ConfigReply>("DELETE", `/admin/providers/${encodeURIComponent(id)}/keys/${index}`),
77
+ revealKey: (id: string, index: number) =>
78
+ api<{ key: string }>("GET", `/admin/providers/${encodeURIComponent(id)}/keys/${index}/reveal`),
79
+ addModel: (id: string, model: string, price?: { price_in?: number; price_out?: number }) =>
80
+ api<ConfigReply>("POST", `/admin/providers/${encodeURIComponent(id)}/models`, { model, ...price }),
81
+ addModels: (id: string, models: string[]) =>
82
+ api<ConfigReply>("POST", `/admin/providers/${encodeURIComponent(id)}/models`, { models }),
83
+ // model id can hold a slash (provider/model); send it as a query param so the
84
+ // proxy's path split doesn't mangle it.
85
+ removeModel: (id: string, model: string) =>
86
+ api<ConfigReply>("DELETE", `/admin/providers/${encodeURIComponent(id)}/models?model=${encodeURIComponent(model)}`),
87
+ clearModels: (id: string) => api<ConfigReply>("DELETE", `/admin/providers/${encodeURIComponent(id)}/models`),
88
+ pricing: () => api<PricingPayload>("GET", "/admin/pricing"),
89
+ setModelPrice: (id: string, model: string, price: { price_in?: number | null; price_out?: number | null }) =>
90
+ api<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}/models/price`, { model, ...price }),
91
+ testProvider: (id: string) => api<PingResult>("POST", `/admin/providers/${encodeURIComponent(id)}/test`),
92
+ testKey: (id: string, index: number) =>
93
+ api<PingResult>("POST", `/admin/providers/${encodeURIComponent(id)}/keys/${index}/test`),
94
+ testModel: (id: string, model: string) =>
95
+ api<{ ok: boolean; status?: number; error?: string }>(
96
+ "POST",
97
+ `/admin/providers/${encodeURIComponent(id)}/models/test?model=${encodeURIComponent(model)}`,
98
+ ),
99
+ validateProvider: (b: { format: WireFormat; base_url: string; api_key?: string }) =>
100
+ api<PingResult>("POST", "/admin/providers/validate", b),
101
+ // discover: returns the upstream catalog flagged with which ids are in config.
102
+ discoverModels: (id: string) =>
103
+ api<{ ok: boolean; models: Array<{ id: string; added: boolean }> }>(
104
+ "POST",
105
+ `/admin/providers/${encodeURIComponent(id)}/connect`,
106
+ ),
107
+
108
+ reorderKey: (id: string, from: number, to: number) =>
109
+ api<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}/keys/reorder`, { from, to }),
110
+ toggleKey: (id: string, index: number, enabled: boolean) =>
111
+ api<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}/keys/${index}/toggle`, { enabled }),
112
+ setProviderStrategy: (id: string, strategy: "fallback" | "round-robin" | null, sticky?: number) =>
113
+ api<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}/strategy`, { strategy, sticky }),
114
+ setProviderDisabled: (id: string, disabled: boolean) =>
115
+ api<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}/disabled`, { disabled }),
116
+
117
+ setRoute: (
118
+ alias: string,
119
+ body: { target: string[]; model?: string | string[]; strategy?: "fallback" | "round-robin"; price_in?: number; price_out?: number },
120
+ ) => api<ConfigReply>("PUT", `/admin/routes/${encodeURIComponent(alias)}`, body),
121
+ removeRoute: (alias: string) => api<ConfigReply>("DELETE", `/admin/routes/${encodeURIComponent(alias)}`),
122
+
123
+ endpoint: () => api<EndpointPayload>("GET", "/admin/endpoint"),
124
+ setRtk: (enabled: boolean) => api<ConfigReply>("PUT", "/admin/endpoint/rtk", { enabled }),
125
+ setCaveman: (level: InjectLevel) => api<ConfigReply>("PUT", "/admin/endpoint/caveman", { level }),
126
+ setPonytail: (level: InjectLevel) => api<ConfigReply>("PUT", "/admin/endpoint/ponytail", { level }),
127
+ addServerKey: (key: string, name?: string) => api<ConfigReply>("POST", "/admin/endpoint/keys", { key, name }),
128
+ editServerKey: (index: number, name: string) => api<ConfigReply>("PUT", `/admin/endpoint/keys/${index}`, { name }),
129
+ removeServerKey: (index: number) => api<ConfigReply>("DELETE", `/admin/endpoint/keys/${index}`),
130
+ revealServerKey: (index: number) => api<{ key: string }>("GET", `/admin/endpoint/keys/${index}/reveal`),
131
+
132
+ setHeadroom: (patch: { enabled?: boolean; url?: string; compress_user_messages?: boolean }) =>
133
+ api<ConfigReply>("PUT", "/admin/endpoint/headroom", patch),
134
+ headroomStatus: () => api<HeadroomStatusReply>("GET", "/admin/headroom/status"),
135
+ headroomStart: () => api<{ success?: boolean; pid?: number; alreadyRunning?: boolean }>("POST", "/admin/headroom/start"),
136
+ headroomStop: () => api<{ stopped: boolean; reason?: string; pid?: number }>("POST", "/admin/headroom/stop"),
137
+
138
+ putConfig: (text: string) => api<{ ok: boolean }>("PUT", "/admin/config", { text }),
139
+
140
+ version: () => api<{ current: string; latest: string | null; updateAvailable: boolean }>("GET", "/admin/version"),
141
+ shutdown: () => api<{ ok: boolean; message: string }>("POST", "/admin/shutdown"),
142
+ };
143
+
144
+ // Local CLI-tool detection/auto-config. These hit the dashboard's OWN server
145
+ // routes (/api/cli-detect/*), not the gateway proxy — they read/write the tool's
146
+ // config file on this machine. Session-gated by middleware like everything else.
147
+ export interface CliStatus {
148
+ auto: boolean;
149
+ installed: boolean;
150
+ configured?: boolean;
151
+ path?: string;
152
+ baseUrl?: string | null;
153
+ models?: string[];
154
+ activeModel?: string | null;
155
+ // claude returns its three slot defaults instead of a flat list
156
+ modelSlots?: { opus?: string | null; sonnet?: string | null; haiku?: string | null };
157
+ }
158
+
159
+ async function appApi<T>(method: string, url: string, body?: unknown): Promise<ApiResult<T>> {
160
+ let res: Response;
161
+ try {
162
+ res = await fetch(url, {
163
+ method,
164
+ headers: body !== undefined ? { "content-type": "application/json" } : {},
165
+ body: body !== undefined ? JSON.stringify(body) : undefined,
166
+ });
167
+ } catch (e) {
168
+ return { ok: false, status: 0, data: null, error: (e as Error).message };
169
+ }
170
+ const text = await res.text();
171
+ const data = text ? (JSON.parse(text) as T) : null;
172
+ if (!res.ok) {
173
+ const err = (data as { error?: string } | null)?.error ?? `request failed (${res.status})`;
174
+ return { ok: false, status: res.status, data, error: err };
175
+ }
176
+ return { ok: true, status: res.status, data };
177
+ }
178
+
179
+ /** Admin account actions that hit the dashboard's OWN routes (not the gw proxy). */
180
+ export const account = {
181
+ changePassword: (current: string, next: string) =>
182
+ appApi<{ ok: boolean }>("POST", "/api/password", { current, next }),
183
+ };
184
+
185
+ export const cliConfig = {
186
+ status: (tool: string) => appApi<CliStatus>("GET", `/api/cli-detect/${encodeURIComponent(tool)}`),
187
+ apply: (tool: string, body: { base: string; key?: string; models?: string[] | Record<string, string>; active?: string }) =>
188
+ appApi<{ success?: boolean; path?: string }>("POST", `/api/cli-detect/${encodeURIComponent(tool)}`, body),
189
+ reset: (tool: string) => appApi<{ success?: boolean }>("DELETE", `/api/cli-detect/${encodeURIComponent(tool)}`),
190
+ };
@@ -0,0 +1,269 @@
1
+ import "server-only";
2
+ import { currentPassword } from "./session";
3
+
4
+ /**
5
+ * Server-side proxy to the gateway admin API. Runs only in Next.js server
6
+ * context (route handlers / server components) — injects the admin password as
7
+ * a Bearer token so it never reaches the browser.
8
+ *
9
+ * Scoped to the admin surface the gateway exposes today (usage, logs, providers,
10
+ * quota, whole-config CRUD). Granular provider/combo mutation helpers are added
11
+ * alongside the pages that drive them in phase 11.
12
+ */
13
+ function gatewayUrl(): string {
14
+ return (process.env.GATEWAY_URL ?? "http://127.0.0.1:18080").replace(/\/$/, "");
15
+ }
16
+
17
+ export interface GatewayResult<T> {
18
+ ok: boolean;
19
+ status: number;
20
+ data: T | null;
21
+ error?: string;
22
+ }
23
+
24
+ async function call<T>(method: string, path: string, body?: unknown): Promise<GatewayResult<T>> {
25
+ let res: Response;
26
+ try {
27
+ res = await fetch(gatewayUrl() + path, {
28
+ method,
29
+ headers: {
30
+ authorization: `Bearer ${await currentPassword()}`,
31
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
32
+ },
33
+ body: body !== undefined ? JSON.stringify(body) : undefined,
34
+ cache: "no-store",
35
+ });
36
+ } catch (e) {
37
+ return { ok: false, status: 0, data: null, error: `gateway unreachable: ${(e as Error).message}` };
38
+ }
39
+
40
+ const text = await res.text();
41
+ let data: T | null = null;
42
+ try {
43
+ data = text ? (JSON.parse(text) as T) : null;
44
+ } catch {
45
+ data = null;
46
+ }
47
+ if (!res.ok) {
48
+ const err = (data as { error?: string } | null)?.error ?? `gateway returned ${res.status}`;
49
+ return { ok: false, status: res.status, data, error: err };
50
+ }
51
+ return { ok: true, status: res.status, data };
52
+ }
53
+
54
+ /** Verify a specific admin password against the gateway (used at login, before a
55
+ * session cookie exists). */
56
+ export async function checkGatewayAuth(password: string): Promise<boolean> {
57
+ try {
58
+ const res = await fetch(gatewayUrl() + "/admin/providers", {
59
+ headers: { authorization: `Bearer ${password}` },
60
+ cache: "no-store",
61
+ });
62
+ return res.ok;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export const gateway = {
69
+ providers: () => call<{ providers: ProviderSnapshot[] }>("GET", "/admin/providers"),
70
+ quota: () => call<{ quota: QuotaSnapshot[] }>("GET", "/admin/quota"),
71
+ models: () => call<ModelsPayload>("GET", "/admin/models"),
72
+ logs: (limit = 100) => call<{ logs: UsageLog[] }>("GET", `/admin/logs?limit=${limit}`),
73
+ usage: (since = 0) => call<UsageSummary>("GET", `/admin/usage?since=${since}`),
74
+ usageSeries: (since: number, bucket: number) =>
75
+ call<{ series: UsageSeriesPoint[] }>("GET", `/admin/usage/series?since=${since}&bucket=${bucket}`),
76
+ config: () => call<MaskedConfig>("GET", "/admin/config"),
77
+ putConfig: (text: string) => call<{ ok: boolean; config: MaskedConfig }>("PUT", "/admin/config", { text }),
78
+ changePassword: (current: string, next: string) =>
79
+ call<{ ok: boolean }>("PUT", "/admin/password", { current, next }),
80
+
81
+ // ---- provider mutations (reply carries the fresh masked config) ----
82
+ addProvider: (p: {
83
+ id: string;
84
+ format: WireFormat;
85
+ base_url: string;
86
+ api_key?: string;
87
+ free?: boolean;
88
+ auto_models?: boolean;
89
+ service_account?: string;
90
+ }) => call<ConfigReply>("POST", "/admin/providers", p),
91
+ editProvider: (id: string, patch: { base_url?: string; format?: WireFormat }) =>
92
+ call<ConfigReply>("PUT", `/admin/providers/${encodeURIComponent(id)}`, patch),
93
+ removeProvider: (id: string) => call<ConfigReply>("DELETE", `/admin/providers/${encodeURIComponent(id)}`),
94
+ addKey: (id: string, key: string) =>
95
+ call<ConfigReply>("POST", `/admin/providers/${encodeURIComponent(id)}/keys`, { key }),
96
+ removeKey: (id: string, index: number) =>
97
+ call<ConfigReply>("DELETE", `/admin/providers/${encodeURIComponent(id)}/keys/${index}`),
98
+ addProviderModel: (id: string, model: string, price?: { price_in?: number; price_out?: number }) =>
99
+ call<ConfigReply>("POST", `/admin/providers/${encodeURIComponent(id)}/models`, { model, ...price }),
100
+ addProviderModels: (id: string, models: string[]) =>
101
+ call<ConfigReply>("POST", `/admin/providers/${encodeURIComponent(id)}/models`, { models }),
102
+ removeProviderModel: (id: string, model: string) =>
103
+ call<ConfigReply>("DELETE", `/admin/providers/${encodeURIComponent(id)}/models/${encodeURIComponent(model)}`),
104
+ clearProviderModels: (id: string) =>
105
+ call<ConfigReply>("DELETE", `/admin/providers/${encodeURIComponent(id)}/models`),
106
+ testProvider: (id: string) => call<PingResult>("POST", `/admin/providers/${encodeURIComponent(id)}/test`),
107
+ discoverModels: (id: string) =>
108
+ call<{ ok: boolean; models: Array<{ id: string; added: boolean }> }>(
109
+ "POST",
110
+ `/admin/providers/${encodeURIComponent(id)}/connect`,
111
+ ),
112
+
113
+ // ---- combos: alias + ordered provider chain + strategy ----
114
+ setRoute: (
115
+ alias: string,
116
+ body: { target: string[]; model?: string | string[]; strategy?: "fallback" | "round-robin"; price_in?: number; price_out?: number },
117
+ ) => call<ConfigReply>("PUT", `/admin/routes/${encodeURIComponent(alias)}`, body),
118
+ removeRoute: (alias: string) => call<ConfigReply>("DELETE", `/admin/routes/${encodeURIComponent(alias)}`),
119
+
120
+ // ---- endpoint: toggles + gateway keys ----
121
+ endpoint: () => call<EndpointPayload>("GET", "/admin/endpoint"),
122
+ setRtk: (enabled: boolean) => call<ConfigReply>("PUT", "/admin/endpoint/rtk", { enabled }),
123
+ setCaveman: (level: InjectLevel) => call<ConfigReply>("PUT", "/admin/endpoint/caveman", { level }),
124
+ setPonytail: (level: InjectLevel) => call<ConfigReply>("PUT", "/admin/endpoint/ponytail", { level }),
125
+ addServerKey: (key: string) => call<ConfigReply>("POST", "/admin/endpoint/keys", { key }),
126
+ removeServerKey: (index: number) => call<ConfigReply>("DELETE", `/admin/endpoint/keys/${index}`),
127
+ };
128
+
129
+ // ---- shapes mirrored from the gateway admin API ----
130
+
131
+ export type WireFormat = "openai" | "anthropic" | "gemini";
132
+ export type InjectLevel = "off" | "lite" | "full" | "ultra";
133
+
134
+ export interface ConfigReply {
135
+ ok: boolean;
136
+ config: MaskedConfig;
137
+ }
138
+
139
+ export interface MaskedRoute {
140
+ alias: string;
141
+ target: string[];
142
+ model?: string | string[];
143
+ strategy: "fallback" | "round-robin";
144
+ price_in?: number;
145
+ price_out?: number;
146
+ }
147
+ export interface MaskedProvider {
148
+ id: string;
149
+ name?: string;
150
+ format: WireFormat;
151
+ base_url: string;
152
+ api_key?: string;
153
+ api_keys?: string[];
154
+ /** optional friendly label per key, keyed by the MASKED key string. */
155
+ key_names?: Record<string, string>;
156
+ free: boolean;
157
+ auto_models: boolean;
158
+ service_account?: string;
159
+ models: Array<{ id: string; price_in?: number; price_out?: number }>;
160
+ quota?: { window: "5h" | "daily" | "weekly" | "monthly"; reset_at?: string; timezone: string; limit_tokens?: number };
161
+ cooldown_base_ms: number;
162
+ max_retries: number;
163
+ disabled_keys?: number[];
164
+ disabled?: boolean;
165
+ strategy?: "fallback" | "round-robin";
166
+ sticky?: number;
167
+ }
168
+ export interface MaskedConfig {
169
+ server: { host: string; port: number; api_keys: string[] };
170
+ endpoint: { rtk: boolean; caveman: InjectLevel; ponytail: InjectLevel };
171
+ providers: MaskedProvider[];
172
+ models: MaskedRoute[];
173
+ }
174
+
175
+ export interface EndpointPayload {
176
+ port: number;
177
+ rtk: boolean;
178
+ caveman: InjectLevel;
179
+ ponytail: InjectLevel;
180
+ headroom: { enabled: boolean; url: string; compress_user_messages: boolean };
181
+ keys: Array<{ key: string; name?: string }>;
182
+ }
183
+
184
+ export interface HeadroomStatusReply {
185
+ installed: boolean;
186
+ path: string | null;
187
+ running: boolean;
188
+ python: string | null;
189
+ localUrl: boolean;
190
+ canStart: boolean;
191
+ url: string;
192
+ managedPid: number | null;
193
+ enabled: boolean;
194
+ compress_user_messages: boolean;
195
+ }
196
+
197
+ export interface PingResult {
198
+ reachable: boolean;
199
+ status?: number;
200
+ ok: boolean;
201
+ error?: string;
202
+ }
203
+
204
+ export interface PricingModel {
205
+ id: string;
206
+ price_in: number | null;
207
+ price_out: number | null;
208
+ default_in: number | null;
209
+ default_out: number | null;
210
+ }
211
+ export interface PricingPayload {
212
+ providers: Array<{ id: string; models: PricingModel[] }>;
213
+ }
214
+
215
+ export interface ModelsPayload {
216
+ providers: Array<{
217
+ id: string;
218
+ format: WireFormat;
219
+ models: Array<{ id: string; ref: string; price_in?: number; price_out?: number }>;
220
+ }>;
221
+ routes: MaskedRoute[];
222
+ }
223
+
224
+ export interface KeySnapshot {
225
+ key: string;
226
+ healthy: boolean;
227
+ cooldown_ms: number;
228
+ fail_count: number;
229
+ last_error: { message: string; status?: number; at: number } | null;
230
+ }
231
+ export interface ProviderSnapshot {
232
+ id: string;
233
+ format: WireFormat;
234
+ keys: KeySnapshot[];
235
+ }
236
+ export interface QuotaSnapshot {
237
+ provider: string;
238
+ window: "5h" | "daily" | "weekly" | "monthly";
239
+ consumed: number;
240
+ limit_tokens?: number;
241
+ reset_in_ms: number;
242
+ pct?: number;
243
+ exhausted: boolean;
244
+ }
245
+ export interface UsageLog {
246
+ ts: number;
247
+ alias: string;
248
+ provider: string;
249
+ model: string;
250
+ tokens_in: number;
251
+ tokens_out: number;
252
+ cached_tokens: number;
253
+ cost: number;
254
+ status: number;
255
+ latency_ms: number;
256
+ stream: number;
257
+ }
258
+ export interface UsageSummary {
259
+ total: { requests: number; tokens_in: number; tokens_out: number; cost: number };
260
+ by_provider: Array<{ provider: string; requests: number; tokens_in: number; tokens_out: number; cost: number }>;
261
+ by_model: Array<{ alias: string; model: string; requests: number; tokens_in: number; tokens_out: number; cost: number }>;
262
+ }
263
+ export interface UsageSeriesPoint {
264
+ ts: number;
265
+ requests: number;
266
+ tokens_in: number;
267
+ tokens_out: number;
268
+ cost: number;
269
+ }
@@ -0,0 +1,71 @@
1
+ import { createHmac, timingSafeEqual, createCipheriv, createDecipheriv, scryptSync, randomBytes } from "node:crypto";
2
+ import { cookies } from "next/headers";
3
+
4
+ /**
5
+ * Dashboard session. The browser never holds the admin password in readable
6
+ * form: on login we verify it against the gateway, encrypt it (AES-256-GCM) and
7
+ * sign the ciphertext (HMAC), then store that in an httpOnly cookie. The proxy
8
+ * and server-side fetches decrypt it to use as the gateway Bearer; the password
9
+ * itself never reaches client JS.
10
+ *
11
+ * Cookie token = `<b64(iv|tag|ciphertext)>.<hmac(payload)>`. Middleware only
12
+ * needs the HMAC check (cheap, edge-safe); decryption happens in node handlers.
13
+ */
14
+ const COOKIE = "aigetwey_session";
15
+
16
+ function secret(): string {
17
+ return process.env.SESSION_SECRET ?? "";
18
+ }
19
+
20
+ function aesKey(): Buffer {
21
+ return scryptSync(secret(), "aigetwey-session-aes", 32);
22
+ }
23
+
24
+ export function sign(value: string): string {
25
+ return createHmac("sha256", secret()).update(value).digest("hex");
26
+ }
27
+
28
+ /** Encrypt + sign the password into a cookie token. */
29
+ export function sealSession(password: string): string {
30
+ const iv = randomBytes(12);
31
+ const cipher = createCipheriv("aes-256-gcm", aesKey(), iv);
32
+ const ct = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
33
+ const tag = cipher.getAuthTag();
34
+ const payload = Buffer.concat([iv, tag, ct]).toString("base64url");
35
+ return `${payload}.${sign(payload)}`;
36
+ }
37
+
38
+ /** Verify the token's signature only — no decryption (edge/middleware-safe). */
39
+ export function verifyToken(token: string | undefined): boolean {
40
+ if (!token || !secret()) return false;
41
+ const [payload, sig] = token.split(".");
42
+ if (!payload || !sig) return false;
43
+ const expected = sign(payload);
44
+ const a = Buffer.from(sig);
45
+ const b = Buffer.from(expected);
46
+ return a.length === b.length && timingSafeEqual(a, b);
47
+ }
48
+
49
+ /** Verify + decrypt the token back to the password, or null if tampered. */
50
+ export function openSession(token: string | undefined): string | null {
51
+ if (!verifyToken(token) || !token) return null;
52
+ try {
53
+ const payload = Buffer.from(token.split(".")[0], "base64url");
54
+ const iv = payload.subarray(0, 12);
55
+ const tag = payload.subarray(12, 28);
56
+ const ct = payload.subarray(28);
57
+ const decipher = createDecipheriv("aes-256-gcm", aesKey(), iv);
58
+ decipher.setAuthTag(tag);
59
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** The logged-in admin password, read from the session cookie (server-side). */
66
+ export async function currentPassword(): Promise<string> {
67
+ const token = (await cookies()).get(COOKIE)?.value;
68
+ return openSession(token) ?? "";
69
+ }
70
+
71
+ export const SESSION_COOKIE = COOKIE;
@@ -0,0 +1,37 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { NextRequest } from "next/server";
3
+ import { openSession, SESSION_COOKIE } from "@/lib/session";
4
+
5
+ /**
6
+ * Gate every page and admin-proxy route behind a valid session. The login page
7
+ * and the auth endpoints stay open; everything else redirects to /login (pages)
8
+ * or 401s (api).
9
+ */
10
+ const OPEN = ["/login", "/api/login", "/api/logout"];
11
+
12
+ export function middleware(req: NextRequest): NextResponse {
13
+ const { pathname } = req.nextUrl;
14
+ if (OPEN.some((p) => pathname === p || pathname.startsWith(p + "/"))) {
15
+ return NextResponse.next();
16
+ }
17
+
18
+ // a session is valid only if its cookie decrypts to a password — this also
19
+ // rejects stale cookies from an older format (which would yield an empty
20
+ // Bearer and a confusing "missing admin password" from the gateway).
21
+ const token = req.cookies.get(SESSION_COOKIE)?.value;
22
+ if (openSession(token)) return NextResponse.next();
23
+
24
+ if (pathname.startsWith("/api/")) {
25
+ return NextResponse.json({ error: "unauthorized" }, { status: 401 });
26
+ }
27
+ const url = req.nextUrl.clone();
28
+ url.pathname = "/login";
29
+ return NextResponse.redirect(url);
30
+ }
31
+
32
+ export const config = {
33
+ // run on everything except next internals and static assets
34
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
35
+ // session verification uses node:crypto, unsupported on the Edge runtime
36
+ runtime: "nodejs",
37
+ };
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "ES2022"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": { "@/*": ["./src/*"] }
18
+ },
19
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
20
+ "exclude": ["node_modules"]
21
+ }