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,29 @@
1
+ {
2
+ "name": "aigetwey-dashboard",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "next": "^16.2.9",
13
+ "react": "^19.0.0",
14
+ "react-dom": "^19.0.0",
15
+ "server-only": "^0.0.1",
16
+ "yaml": "^2.9.0"
17
+ },
18
+ "devDependencies": {
19
+ "@tailwindcss/postcss": "^4.0.0",
20
+ "@types/node": "^22.10.0",
21
+ "@types/react": "^19.0.0",
22
+ "@types/react-dom": "^19.0.0",
23
+ "tailwindcss": "^4.0.0",
24
+ "typescript": "^5.7.2"
25
+ },
26
+ "allowScripts": {
27
+ "sharp@0.34.5": true
28
+ }
29
+ }
@@ -0,0 +1,5 @@
1
+ const config = {
2
+ plugins: ["@tailwindcss/postcss"],
3
+ };
4
+
5
+ export default config;
@@ -0,0 +1,10 @@
1
+ import { RoutingView } from "@/components/RoutingView";
2
+
3
+ /**
4
+ * Combos & Routing — one concept (aigetwey-style): each combo is an alias + an
5
+ * ordered provider chain + a strategy. Call the alias as the model name from a
6
+ * CLI tool. No separate snapshot layer.
7
+ */
8
+ export default function CombosPage() {
9
+ return <RoutingView />;
10
+ }
@@ -0,0 +1,5 @@
1
+ import { ConfigEditor } from "@/components/ConfigEditor";
2
+
3
+ export default function ConfigPage() {
4
+ return <ConfigEditor />;
5
+ }
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import { Icon } from "@/components/Icon";
5
+ import { Button } from "@/components/Button";
6
+
7
+ const LOG_COLORS: Record<string, string> = {
8
+ LOG: "text-success",
9
+ INFO: "text-info",
10
+ WARN: "text-warning",
11
+ ERROR: "text-danger",
12
+ DEBUG: "text-text-subtle",
13
+ };
14
+
15
+ interface LogEntry {
16
+ ts: number;
17
+ level: string;
18
+ message: string;
19
+ }
20
+
21
+ export default function ConsolePage() {
22
+ const [logs, setLogs] = useState<LogEntry[]>([]);
23
+ const [connected, setConnected] = useState(false);
24
+ const logRef = useRef<HTMLDivElement>(null);
25
+
26
+ useEffect(() => {
27
+ const es = new EventSource("/api/gw/admin/console/stream");
28
+
29
+ es.onopen = () => setConnected(true);
30
+
31
+ es.onmessage = (e) => {
32
+ const msg = JSON.parse(e.data);
33
+ if (msg.type === "init") {
34
+ setLogs(msg.logs.slice(-300));
35
+ } else if (msg.type === "line") {
36
+ setLogs((prev) => {
37
+ const next = [...prev, msg as LogEntry];
38
+ return next.length > 300 ? next.slice(-300) : next;
39
+ });
40
+ }
41
+ };
42
+
43
+ es.onerror = () => setConnected(false);
44
+
45
+ return () => es.close();
46
+ }, []);
47
+
48
+ useEffect(() => {
49
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
50
+ }, [logs]);
51
+
52
+ const handleClear = async () => {
53
+ await fetch("/api/gw/admin/console", { method: "DELETE" });
54
+ setLogs([]);
55
+ };
56
+
57
+ return (
58
+ <div>
59
+ <div className="mb-4 flex items-center justify-between">
60
+ <div>
61
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">Server Console</h1>
62
+ <p className="mt-1 text-[13px] text-text-muted">Live gateway process output.</p>
63
+ </div>
64
+ <div className="flex items-center gap-3">
65
+ <span className={`flex items-center gap-1.5 text-[11px] ${connected ? "text-success" : "text-danger"}`}>
66
+ <Icon name={connected ? "radio_button_checked" : "radio_button_unchecked"} size={12} />
67
+ {connected ? "Connected" : "Disconnected"}
68
+ </span>
69
+ <Button variant="ghost" onClick={handleClear}>
70
+ <Icon name="delete" size={15} /> Clear
71
+ </Button>
72
+ </div>
73
+ </div>
74
+
75
+ <div
76
+ ref={logRef}
77
+ className="h-[calc(100vh-200px)] overflow-y-auto rounded-brand-lg border border-border bg-[#0a0a09] p-4 font-mono text-[12px]"
78
+ >
79
+ {logs.length === 0 ? (
80
+ <span className="text-text-subtle">No logs yet…</span>
81
+ ) : (
82
+ logs.map((entry, i) => (
83
+ <div key={i} className={`whitespace-pre-wrap break-all ${LOG_COLORS[entry.level] ?? "text-text"}`}>
84
+ <span className="text-text-subtle">{new Date(entry.ts).toLocaleTimeString()} </span>
85
+ <span className="font-semibold">[{entry.level}]</span> {entry.message}
86
+ </div>
87
+ ))
88
+ )}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,5 @@
1
+ import { EndpointView } from "@/components/EndpointView";
2
+
3
+ export default function EndpointPage() {
4
+ return <EndpointView />;
5
+ }
@@ -0,0 +1,17 @@
1
+ import { Rail } from "@/components/Rail";
2
+ import { TopBar } from "@/components/TopBar";
3
+
4
+ export default function ConsoleLayout({ children }: { children: React.ReactNode }) {
5
+ return (
6
+ <div className="console-grid">
7
+ <aside className="console-rail">
8
+ <Rail />
9
+ </aside>
10
+
11
+ <div className="console-col">
12
+ <TopBar />
13
+ <main className="console-main">{children}</main>
14
+ </div>
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,8 @@
1
+ import { EndpointView } from "@/components/EndpointView";
2
+
3
+ // Landing page IS Endpoint & Key — where "/dashboard" renders
4
+ // the endpoint view (gateway URL + keys + token savers), the first thing you
5
+ // need to wire up a CLI tool.
6
+ export default function LandingPage() {
7
+ return <EndpointView />;
8
+ }
@@ -0,0 +1,6 @@
1
+ import { ProviderDetail } from "@/components/ProviderDetail";
2
+
3
+ export default async function ProviderDetailPage({ params }: { params: Promise<{ id: string }> }) {
4
+ const { id } = await params;
5
+ return <ProviderDetail id={decodeURIComponent(id)} />;
6
+ }
@@ -0,0 +1,5 @@
1
+ import { ProviderManager } from "@/components/ProviderManager";
2
+
3
+ export default function ProvidersPage() {
4
+ return <ProviderManager />;
5
+ }
@@ -0,0 +1,5 @@
1
+ import { QuotaView } from "@/components/QuotaView";
2
+
3
+ export default function QuotaPage() {
4
+ return <QuotaView />;
5
+ }
@@ -0,0 +1,6 @@
1
+ import { ToolDetail } from "@/components/ToolDetail";
2
+
3
+ export default async function ToolDetailPage({ params }: { params: Promise<{ id: string }> }) {
4
+ const { id } = await params;
5
+ return <ToolDetail id={decodeURIComponent(id)} />;
6
+ }
@@ -0,0 +1,5 @@
1
+ import { CliToolConfig } from "@/components/CliToolConfig";
2
+
3
+ export default function ToolsPage() {
4
+ return <CliToolConfig />;
5
+ }
@@ -0,0 +1,24 @@
1
+ import { gateway } from "@/lib/gateway";
2
+ import { UsageView } from "@/components/UsageView";
3
+ import { LogTable } from "@/components/LogTable";
4
+ import { Empty } from "@/components/ui";
5
+
6
+ // always fresh — logs change every request.
7
+ export const dynamic = "force-dynamic";
8
+
9
+ // Usage = stats/charts + the full request log (with the detail drawer). aigetwey
10
+ // keeps request logs inside Usage rather than a separate menu, so we do too.
11
+ export default async function UsagePage() {
12
+ const res = await gateway.logs(200);
13
+ const logs = res.data?.logs ?? [];
14
+
15
+ return (
16
+ <div className="space-y-7">
17
+ <UsageView />
18
+ <div>
19
+ <h2 className="mb-3 text-[15px] font-semibold text-text">Requests</h2>
20
+ {res.ok ? <LogTable logs={logs} /> : <Empty>Could not reach the gateway: {res.error}</Empty>}
21
+ </div>
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,253 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { NextRequest } from "next/server";
3
+ import { exec } from "child_process";
4
+ import { promisify } from "util";
5
+ import fs from "fs/promises";
6
+ import path from "path";
7
+ import os from "os";
8
+ import { modalitiesForModel } from "@/lib/capabilities";
9
+
10
+ /**
11
+ * Local CLI-tool detection + auto-config. These run in the Next.js server (which,
12
+ * like the gateway, lives on the operator's machine), so they can read/write the
13
+ * tool's own config files — the trick behind aigetwey's "it just detects and
14
+ * configures itself". Session-gated by middleware like every other /api route.
15
+ *
16
+ * Only claude-code + opencode auto-configure (the two with a stable local config
17
+ * file we can safely merge into). Others report installed:false → the UI falls
18
+ * back to the manual env block.
19
+ */
20
+ const execAsync = promisify(exec);
21
+
22
+ type Json = Record<string, unknown>;
23
+
24
+ async function onPath(bin: string): Promise<boolean> {
25
+ try {
26
+ const cmd = os.platform() === "win32" ? `where ${bin}` : `which ${bin}`;
27
+ await execAsync(cmd, { windowsHide: true });
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ async function fileExists(p: string): Promise<boolean> {
35
+ try {
36
+ await fs.access(p);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ // tolerate JSONC (trailing commas) and unparseable files (treat as "no config").
44
+ function readJson(content: string): Json | null {
45
+ try {
46
+ return JSON.parse(content.replace(/,(\s*[}\]])/g, "$1")) as Json;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // ─── Claude Code: ~/.claude/settings.json env block ─────────────────────────
53
+ const claudePath = () => path.join(os.homedir(), ".claude", "settings.json");
54
+ const CLAUDE_KEYS = [
55
+ "ANTHROPIC_BASE_URL",
56
+ "ANTHROPIC_AUTH_TOKEN",
57
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
58
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
59
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
60
+ "API_TIMEOUT_MS",
61
+ ];
62
+
63
+ async function claudeStatus() {
64
+ const installed = (await onPath("claude")) || (await fileExists(claudePath()));
65
+ if (!installed) return { installed: false as const };
66
+ let settings: Json | null = null;
67
+ try {
68
+ settings = readJson(await fs.readFile(claudePath(), "utf-8"));
69
+ } catch {
70
+ settings = null;
71
+ }
72
+ const env = (settings?.env as Json | undefined) ?? {};
73
+ return {
74
+ installed: true as const,
75
+ configured: typeof env.ANTHROPIC_BASE_URL === "string",
76
+ path: claudePath(),
77
+ baseUrl: (env.ANTHROPIC_BASE_URL as string) ?? null,
78
+ modelSlots: {
79
+ opus: (env.ANTHROPIC_DEFAULT_OPUS_MODEL as string) ?? null,
80
+ sonnet: (env.ANTHROPIC_DEFAULT_SONNET_MODEL as string) ?? null,
81
+ haiku: (env.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) ?? null,
82
+ },
83
+ };
84
+ }
85
+
86
+ async function claudeApply(body: { base?: string; key?: string; models?: Record<string, string> }) {
87
+ if (!body.base) return { error: "base is required" };
88
+ const p = claudePath();
89
+ await fs.mkdir(path.dirname(p), { recursive: true });
90
+ let cur: Json = {};
91
+ try {
92
+ cur = readJson(await fs.readFile(p, "utf-8")) ?? {};
93
+ } catch {
94
+ cur = {};
95
+ }
96
+ const env: Record<string, string> = {
97
+ ANTHROPIC_BASE_URL: body.base,
98
+ API_TIMEOUT_MS: "600000",
99
+ };
100
+ if (body.key) env.ANTHROPIC_AUTH_TOKEN = body.key;
101
+ if (body.models?.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = body.models.opus;
102
+ if (body.models?.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = body.models.sonnet;
103
+ if (body.models?.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = body.models.haiku;
104
+ const next = { ...cur, hasCompletedOnboarding: true, env: { ...((cur.env as Json) ?? {}), ...env } };
105
+ await fs.writeFile(p, JSON.stringify(next, null, 2));
106
+ return { success: true, path: p };
107
+ }
108
+
109
+ async function claudeReset() {
110
+ const p = claudePath();
111
+ let cur: Json;
112
+ try {
113
+ cur = readJson(await fs.readFile(p, "utf-8")) ?? {};
114
+ } catch {
115
+ return { success: true };
116
+ }
117
+ const env = cur.env as Json | undefined;
118
+ if (env) {
119
+ for (const k of CLAUDE_KEYS) delete env[k];
120
+ if (Object.keys(env).length === 0) delete cur.env;
121
+ }
122
+ await fs.writeFile(p, JSON.stringify(cur, null, 2));
123
+ return { success: true };
124
+ }
125
+
126
+ // ─── opencode: ~/.config/opencode/opencode.json provider entry ──────────────
127
+ const OC_PROVIDER = "aigetwey";
128
+ const ocDir = () => path.join(os.homedir(), ".config", "opencode");
129
+ const ocPath = () => path.join(ocDir(), "opencode.json");
130
+
131
+ async function opencodeStatus() {
132
+ const installed = (await onPath("opencode")) || (await fileExists(ocPath()));
133
+ if (!installed) return { installed: false as const };
134
+ let cfg: Json | null = null;
135
+ try {
136
+ cfg = readJson(await fs.readFile(ocPath(), "utf-8"));
137
+ } catch {
138
+ cfg = null;
139
+ }
140
+ const prov = (cfg?.provider as Json | undefined)?.[OC_PROVIDER] as Json | undefined;
141
+ const models = prov?.models ? Object.keys(prov.models as Json) : [];
142
+ const active = typeof cfg?.model === "string" && cfg.model.startsWith(`${OC_PROVIDER}/`)
143
+ ? cfg.model.slice(OC_PROVIDER.length + 1)
144
+ : null;
145
+ return {
146
+ installed: true as const,
147
+ configured: !!prov,
148
+ path: ocPath(),
149
+ models,
150
+ activeModel: active,
151
+ baseUrl: ((prov?.options as Json | undefined)?.baseURL as string) ?? null,
152
+ };
153
+ }
154
+
155
+ async function opencodeApply(body: { base?: string; key?: string; models?: string[]; active?: string }) {
156
+ const models = (body.models ?? []).filter(Boolean);
157
+ if (!body.base || models.length === 0) return { error: "base and at least one model are required" };
158
+ const p = ocPath();
159
+ await fs.mkdir(ocDir(), { recursive: true });
160
+ let cfg: Json = {};
161
+ try {
162
+ cfg = readJson(await fs.readFile(p, "utf-8")) ?? {};
163
+ } catch {
164
+ cfg = {};
165
+ }
166
+ const baseURL = body.base.endsWith("/v1") ? body.base : `${body.base}/v1`;
167
+ const provider = (cfg.provider as Json | undefined) ?? {};
168
+ const existing = (provider[OC_PROVIDER] as Json | undefined) ?? {
169
+ npm: "@ai-sdk/openai-compatible",
170
+ options: {},
171
+ models: {},
172
+ };
173
+ existing.options = { ...((existing.options as Json) ?? {}), baseURL, apiKey: body.key || "aigetwey" };
174
+ const modelMap = (existing.models as Json) ?? {};
175
+ for (const m of models) modelMap[m] = { name: m, modalities: modalitiesForModel(m) };
176
+ existing.models = modelMap;
177
+ provider[OC_PROVIDER] = existing;
178
+ cfg.provider = provider;
179
+ const active = body.active && models.includes(body.active) ? body.active : models[0];
180
+ cfg.model = `${OC_PROVIDER}/${active}`;
181
+ await fs.writeFile(p, JSON.stringify(cfg, null, 2));
182
+ return { success: true, path: p };
183
+ }
184
+
185
+ async function opencodeReset() {
186
+ const p = ocPath();
187
+ let cfg: Json;
188
+ try {
189
+ cfg = readJson(await fs.readFile(p, "utf-8")) ?? {};
190
+ } catch {
191
+ return { success: true };
192
+ }
193
+ const provider = cfg.provider as Json | undefined;
194
+ if (provider) delete provider[OC_PROVIDER];
195
+ if (typeof cfg.model === "string" && cfg.model.startsWith(`${OC_PROVIDER}/`)) delete cfg.model;
196
+ await fs.writeFile(p, JSON.stringify(cfg, null, 2));
197
+ return { success: true };
198
+ }
199
+
200
+ type ApplyBody = { base?: string; key?: string; models?: string[] | Record<string, string>; active?: string };
201
+ const HANDLERS: Record<
202
+ string,
203
+ { status: () => Promise<unknown>; apply: (b: ApplyBody) => Promise<unknown>; reset: () => Promise<unknown> }
204
+ > = {
205
+ "claude-code": {
206
+ status: claudeStatus,
207
+ apply: (b) => claudeApply(b as { base?: string; key?: string; models?: Record<string, string> }),
208
+ reset: claudeReset,
209
+ },
210
+ opencode: {
211
+ status: opencodeStatus,
212
+ apply: (b) => opencodeApply(b as { base?: string; key?: string; models?: string[]; active?: string }),
213
+ reset: opencodeReset,
214
+ },
215
+ };
216
+
217
+ type Ctx = { params: Promise<{ tool: string }> };
218
+
219
+ export async function GET(_req: NextRequest, ctx: Ctx) {
220
+ const { tool } = await ctx.params;
221
+ const h = HANDLERS[tool];
222
+ if (!h) return NextResponse.json({ installed: false, auto: false });
223
+ try {
224
+ return NextResponse.json({ auto: true, ...(await h.status() as object) });
225
+ } catch (e) {
226
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
227
+ }
228
+ }
229
+
230
+ export async function POST(req: NextRequest, ctx: Ctx) {
231
+ const { tool } = await ctx.params;
232
+ const h = HANDLERS[tool];
233
+ if (!h) return NextResponse.json({ error: "tool does not support auto-config" }, { status: 400 });
234
+ try {
235
+ const body = (await req.json()) as ApplyBody;
236
+ const res = (await h.apply(body)) as { error?: string };
237
+ if (res.error) return NextResponse.json(res, { status: 400 });
238
+ return NextResponse.json(res);
239
+ } catch (e) {
240
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
241
+ }
242
+ }
243
+
244
+ export async function DELETE(_req: NextRequest, ctx: Ctx) {
245
+ const { tool } = await ctx.params;
246
+ const h = HANDLERS[tool];
247
+ if (!h) return NextResponse.json({ error: "tool does not support auto-config" }, { status: 400 });
248
+ try {
249
+ return NextResponse.json(await h.reset() as object);
250
+ } catch (e) {
251
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
252
+ }
253
+ }
@@ -0,0 +1,89 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { NextRequest } from "next/server";
3
+ import { currentPassword } from "@/lib/session";
4
+
5
+ /**
6
+ * Catch-all proxy to the gateway admin API. Client components call
7
+ * `/api/gw/admin/...`; this forwards the method, body, and query to the gateway
8
+ * with the admin password injected as a Bearer (never reaches the browser).
9
+ *
10
+ * Session-gated by middleware (every /api/* but login/logout needs a valid
11
+ * session), so only a logged-in browser can drive it. One thin file replaces a
12
+ * per-endpoint proxy for each admin route.
13
+ */
14
+ function gatewayUrl(): string {
15
+ return (process.env.GATEWAY_URL ?? "http://127.0.0.1:18080").replace(/\/$/, "");
16
+ }
17
+
18
+ async function proxy(req: NextRequest, path: string[]): Promise<NextResponse | Response> {
19
+ const sub = path.join("/");
20
+ if (!sub.startsWith("admin/")) {
21
+ return NextResponse.json({ error: "not found" }, { status: 404 });
22
+ }
23
+ const search = req.nextUrl.search;
24
+ const target = `${gatewayUrl()}/${sub}${search}`;
25
+
26
+ // SSE passthrough for console stream
27
+ if (sub === "admin/console/stream") {
28
+ try {
29
+ const res = await fetch(target, {
30
+ headers: { authorization: `Bearer ${await currentPassword()}` },
31
+ cache: "no-store",
32
+ });
33
+ if (!res.body) return NextResponse.json({ error: "no stream" }, { status: 502 });
34
+ return new Response(res.body, {
35
+ headers: {
36
+ "Content-Type": "text/event-stream",
37
+ "Cache-Control": "no-cache, no-transform",
38
+ Connection: "keep-alive",
39
+ // don't let Next's prod server buffer the proxied stream.
40
+ "X-Accel-Buffering": "no",
41
+ },
42
+ });
43
+ } catch (e) {
44
+ return NextResponse.json({ error: `gateway unreachable: ${(e as Error).message}` }, { status: 502 });
45
+ }
46
+ }
47
+
48
+ const hasBody = req.method !== "GET" && req.method !== "DELETE";
49
+ let body: string | undefined;
50
+ if (hasBody) {
51
+ body = await req.text();
52
+ }
53
+
54
+ let res: Response;
55
+ try {
56
+ res = await fetch(target, {
57
+ method: req.method,
58
+ headers: {
59
+ authorization: `Bearer ${await currentPassword()}`,
60
+ ...(body ? { "content-type": "application/json" } : {}),
61
+ },
62
+ body: body || undefined,
63
+ cache: "no-store",
64
+ });
65
+ } catch (e) {
66
+ return NextResponse.json({ error: `gateway unreachable: ${(e as Error).message}` }, { status: 502 });
67
+ }
68
+
69
+ const text = await res.text();
70
+ return new NextResponse(text || "{}", {
71
+ status: res.status,
72
+ headers: { "content-type": "application/json" },
73
+ });
74
+ }
75
+
76
+ type Ctx = { params: Promise<{ path: string[] }> };
77
+
78
+ export async function GET(req: NextRequest, ctx: Ctx) {
79
+ return proxy(req, (await ctx.params).path);
80
+ }
81
+ export async function POST(req: NextRequest, ctx: Ctx) {
82
+ return proxy(req, (await ctx.params).path);
83
+ }
84
+ export async function PUT(req: NextRequest, ctx: Ctx) {
85
+ return proxy(req, (await ctx.params).path);
86
+ }
87
+ export async function DELETE(req: NextRequest, ctx: Ctx) {
88
+ return proxy(req, (await ctx.params).path);
89
+ }
@@ -0,0 +1,30 @@
1
+ import { NextResponse } from "next/server";
2
+ import { cookies } from "next/headers";
3
+ import { sealSession, SESSION_COOKIE } from "@/lib/session";
4
+ import { checkGatewayAuth } from "@/lib/gateway";
5
+
6
+ /**
7
+ * Login: the gateway is the source of truth for the admin password (a hash
8
+ * store, changeable at runtime). We verify the submitted password directly
9
+ * against the gateway, then store it encrypted in the session cookie so later
10
+ * proxied calls can present it as the Bearer.
11
+ */
12
+ export async function POST(req: Request): Promise<NextResponse> {
13
+ const { password } = (await req.json().catch(() => ({}))) as { password?: string };
14
+ if (!password) {
15
+ return NextResponse.json({ error: "password required" }, { status: 400 });
16
+ }
17
+ if (!(await checkGatewayAuth(password))) {
18
+ return NextResponse.json({ error: "wrong password (or gateway unreachable)" }, { status: 401 });
19
+ }
20
+
21
+ const jar = await cookies();
22
+ jar.set(SESSION_COOKIE, sealSession(password), {
23
+ httpOnly: true,
24
+ sameSite: "lax",
25
+ secure: process.env.NODE_ENV === "production",
26
+ path: "/",
27
+ maxAge: 60 * 60 * 12, // 12h
28
+ });
29
+ return NextResponse.json({ ok: true });
30
+ }
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from "next/server";
2
+ import { cookies } from "next/headers";
3
+ import { SESSION_COOKIE } from "@/lib/session";
4
+
5
+ export async function POST(): Promise<NextResponse> {
6
+ const jar = await cookies();
7
+ jar.delete(SESSION_COOKIE);
8
+ return NextResponse.json({ ok: true });
9
+ }
@@ -0,0 +1,34 @@
1
+ import { NextResponse } from "next/server";
2
+ import { cookies } from "next/headers";
3
+ import { sealSession, SESSION_COOKIE } from "@/lib/session";
4
+ import { gateway } from "@/lib/gateway";
5
+
6
+ /**
7
+ * Change the admin password. The gateway verifies the current password and
8
+ * persists the new hash; on success we re-issue the session cookie carrying the
9
+ * new password so this browser stays logged in (other sessions must re-login).
10
+ */
11
+ export async function POST(req: Request): Promise<NextResponse> {
12
+ const { current, next } = (await req.json().catch(() => ({}))) as { current?: string; next?: string };
13
+ if (!current || !next) {
14
+ return NextResponse.json({ error: "current and next are required" }, { status: 400 });
15
+ }
16
+ if (next.length < 4) {
17
+ return NextResponse.json({ error: "new password must be at least 4 characters" }, { status: 400 });
18
+ }
19
+
20
+ const r = await gateway.changePassword(current, next);
21
+ if (!r.ok) {
22
+ return NextResponse.json({ error: r.error ?? "could not change password" }, { status: r.status || 400 });
23
+ }
24
+
25
+ const jar = await cookies();
26
+ jar.set(SESSION_COOKIE, sealSession(next), {
27
+ httpOnly: true,
28
+ sameSite: "lax",
29
+ secure: process.env.NODE_ENV === "production",
30
+ path: "/",
31
+ maxAge: 60 * 60 * 12,
32
+ });
33
+ return NextResponse.json({ ok: true });
34
+ }