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,566 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback, useRef } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { adminApi } from "@/lib/client";
6
+ import { Lamp } from "@/components/Lamp";
7
+ import { Badge, FormatBadge } from "@/components/Badge";
8
+ import { CooldownTimer } from "@/components/CooldownTimer";
9
+ import { RichCard, CardTitle } from "@/components/RichCard";
10
+ import { Button, Input, Field } from "@/components/Button";
11
+ import { Icon } from "@/components/Icon";
12
+ import { fmt, Empty } from "@/components/ui";
13
+ import { ModelSelectModal, type DiscoveredModel } from "@/components/ModelSelectModal";
14
+ import { CapacityBadges } from "@/components/CapacityBadges";
15
+ import { ConfirmModal } from "@/components/ConfirmModal";
16
+ import type { MaskedConfig, MaskedProvider, ProviderSnapshot, PingResult } from "@/lib/gateway";
17
+
18
+ export function ProviderDetail({ id }: { id: string }) {
19
+ const router = useRouter();
20
+ const [provider, setProvider] = useState<MaskedProvider | null>(null);
21
+ const [health, setHealth] = useState<ProviderSnapshot | null>(null);
22
+ const [error, setError] = useState("");
23
+ const [ping, setPing] = useState<PingResult | null>(null);
24
+ const [busy, setBusy] = useState("");
25
+ const [newKey, setNewKey] = useState("");
26
+ const [newKeyName, setNewKeyName] = useState("");
27
+ const [editIdx, setEditIdx] = useState<number | null>(null);
28
+ const [editName, setEditName] = useState("");
29
+ const [editVal, setEditVal] = useState("");
30
+ const [newModel, setNewModel] = useState("");
31
+ const [modelFilter, setModelFilter] = useState("");
32
+ const [discovered, setDiscovered] = useState<DiscoveredModel[] | null>(null);
33
+ const [modelTest, setModelTest] = useState<Record<string, "testing" | "ok" | "fail">>({});
34
+ const [keyTest, setKeyTest] = useState<Record<number, "testing" | PingResult>>({});
35
+ const [testingAll, setTestingAll] = useState(false);
36
+ const [testAllSummary, setTestAllSummary] = useState<{ total: number; passed: number; failed: number } | null>(null);
37
+ const stopTestAll = useRef(false);
38
+ const [editingConn, setEditingConn] = useState(false);
39
+ const [connUrl, setConnUrl] = useState("");
40
+ const [connPrefix, setConnPrefix] = useState("");
41
+ const [revealedKeys, setRevealedKeys] = useState<Record<number, string>>({});
42
+ const [confirmDelete, setConfirmDelete] = useState(false);
43
+
44
+ async function testModel(mid: string) {
45
+ setModelTest((t) => ({ ...t, [mid]: "testing" }));
46
+ const r = await adminApi.testModel(id, mid);
47
+ setModelTest((t) => ({ ...t, [mid]: r.ok && r.data?.ok ? "ok" : "fail" }));
48
+ }
49
+
50
+ async function testKey(i: number) {
51
+ setKeyTest((t) => ({ ...t, [i]: "testing" }));
52
+ const r = await adminApi.testKey(id, i);
53
+ setKeyTest((t) => ({ ...t, [i]: r.data ?? { ok: false, reachable: false, status: 0, error: r.error } }));
54
+ }
55
+
56
+ async function testAllKeys(count: number) {
57
+ stopTestAll.current = false;
58
+ setTestingAll(true);
59
+ setTestAllSummary(null);
60
+ setKeyTest({});
61
+ let passed = 0;
62
+ let failed = 0;
63
+ for (let i = 0; i < count; i++) {
64
+ if (stopTestAll.current) break;
65
+ setKeyTest((t) => ({ ...t, [i]: "testing" }));
66
+ const r = await adminApi.testKey(id, i);
67
+ const result = r.data ?? { ok: false, reachable: false, status: 0, error: r.error };
68
+ setKeyTest((t) => ({ ...t, [i]: result }));
69
+ if (result.ok) passed++;
70
+ else failed++;
71
+ if (i < count - 1 && !stopTestAll.current) await new Promise((resolve) => setTimeout(resolve, 500));
72
+ }
73
+ setTestingAll(false);
74
+ setTestAllSummary({ total: count, passed, failed });
75
+ }
76
+
77
+ const reload = useCallback(async () => {
78
+ const [cfgRes, provRes] = await Promise.all([fetch("/api/gw/admin/config"), adminApi.providers()]);
79
+ if (!cfgRes.ok) {
80
+ setError("could not reach the gateway");
81
+ return;
82
+ }
83
+ const cfg = (await cfgRes.json()) as MaskedConfig;
84
+ const p = cfg.providers.find((x) => x.id === id) ?? null;
85
+ if (!p) {
86
+ setError(`provider "${id}" not found`);
87
+ return;
88
+ }
89
+ setError("");
90
+ setProvider(p);
91
+ setHealth(provRes.data?.providers.find((x) => x.id === id) ?? null);
92
+ }, [id]);
93
+
94
+ useEffect(() => {
95
+ void reload();
96
+ }, [reload]);
97
+
98
+ if (error) return <Empty>{error}</Empty>;
99
+ if (!provider) return <Empty>Loading…</Empty>;
100
+
101
+ const keys = provider.api_keys ?? (provider.api_key ? [provider.api_key] : []);
102
+ const q = modelFilter.trim().toLowerCase();
103
+ const shownModels = q ? provider.models.filter((m) => m.id.toLowerCase().includes(q)) : provider.models;
104
+
105
+ async function run(label: string, fn: () => Promise<{ ok: boolean; error?: string }>) {
106
+ setBusy(label);
107
+ const res = await fn();
108
+ setBusy("");
109
+ if (!res.ok) setError(res.error ?? "action failed");
110
+ else {
111
+ setError("");
112
+ await reload();
113
+ }
114
+ }
115
+
116
+ return (
117
+ <div>
118
+ <button onClick={() => router.push("/providers")} className="mb-4 inline-flex items-center gap-1 text-[12px] text-text-muted hover:text-text">
119
+ <Icon name="arrow_back" size={15} /> Providers
120
+ </button>
121
+
122
+ <div className="mb-6 flex items-center gap-3">
123
+ <Lamp state={health?.keys.some((k) => k.healthy) ?? true ? "live" : "down"} />
124
+ <div>
125
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">{provider.name || provider.id}</h1>
126
+ {provider.name && <span className="text-[12px] text-text-subtle">{provider.id}/</span>}
127
+ </div>
128
+ <FormatBadge format={provider.format} />
129
+ {provider.free && <Badge tone="info">free</Badge>}
130
+ {provider.service_account && <Badge tone="info">service-account</Badge>}
131
+ {provider.disabled && <Badge tone="warn">disabled</Badge>}
132
+
133
+ {/* enable/disable the whole provider — skipped in routing when off */}
134
+ <label className="ml-auto flex items-center gap-2 text-[12px] text-text-muted">
135
+ {provider.disabled ? "Disabled" : "Enabled"}
136
+ <button
137
+ type="button"
138
+ onClick={() => void adminApi.setProviderDisabled(id, !provider.disabled).then(() => reload())}
139
+ className={`relative h-5 w-9 rounded-full transition-colors ${provider.disabled ? "bg-border-subtle" : "bg-accent"}`}
140
+ aria-label="Toggle provider enabled"
141
+ title={provider.disabled ? "Provider is disabled — enable it" : "Provider is enabled — disable it"}
142
+ >
143
+ <span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${provider.disabled ? "left-0.5" : "left-[18px]"}`} />
144
+ </button>
145
+ </label>
146
+ </div>
147
+
148
+ <div className="grid gap-4 lg:grid-cols-2">
149
+ <RichCard header={<CardTitle title="Connection" />}>
150
+ {editingConn ? (
151
+ <div className="space-y-3">
152
+ <Field label="Name" hint="the id — also the call prefix (name/model) & combos">
153
+ <Input value={connPrefix} onChange={(e) => setConnPrefix(e.target.value)} placeholder="e.g. huki" className="font-mono text-[12.5px]" />
154
+ </Field>
155
+ {connPrefix.trim() && connPrefix.trim() !== id && (
156
+ <p className="flex items-start gap-1.5 rounded-brand border border-warning/40 bg-warning/8 px-2.5 py-2 text-[11.5px] text-warning">
157
+ <Icon name="warning" size={14} className="mt-0.5 flex-none" />
158
+ <span>
159
+ Renaming rewrites the call string. CLI tools pointing at{" "}
160
+ <code className="tnum">{id}/…</code> will break until repointed; combos that target it are
161
+ updated automatically.
162
+ </span>
163
+ </p>
164
+ )}
165
+ <Field label="Base URL">
166
+ <Input value={connUrl} onChange={(e) => setConnUrl(e.target.value)} placeholder="https://..." className="font-mono text-[12.5px]" />
167
+ </Field>
168
+ <div className="flex justify-end gap-2">
169
+ <Button variant="ghost" onClick={() => setEditingConn(false)}>Cancel</Button>
170
+ <Button disabled={busy === "editconn"} onClick={() => run("editconn", async () => {
171
+ // One identifier: Name == id == call prefix. Rename it (cascades to
172
+ // combos, moves this page to the new id) then apply base_url.
173
+ const newId = connPrefix.trim();
174
+ let activeId = id;
175
+ if (newId && newId !== id) {
176
+ const rr = await adminApi.renameProvider(id, newId);
177
+ if (!rr.ok) return rr;
178
+ activeId = newId;
179
+ }
180
+ const r = await adminApi.editProvider(activeId, { base_url: connUrl.trim() || undefined });
181
+ if (r.ok) {
182
+ setEditingConn(false);
183
+ if (activeId !== id) { router.push(`/providers/${encodeURIComponent(activeId)}`); return r; }
184
+ }
185
+ return r;
186
+ })}>Save</Button>
187
+ </div>
188
+ </div>
189
+ ) : (
190
+ <>
191
+ <div className="space-y-2 text-[13px]">
192
+ <Row k="Base URL" v={provider.base_url} />
193
+ <Row k="Format" v={provider.format} />
194
+ <Row k="Cooldown base" v={`${provider.cooldown_base_ms}ms`} />
195
+ <Row k="Max retries" v={String(provider.max_retries)} />
196
+ </div>
197
+ <div className="mt-4 flex items-center gap-2">
198
+ <Button variant="ghost" onClick={() => { setEditingConn(true); setConnUrl(provider.base_url); setConnPrefix(provider.id); }}>
199
+ <Icon name="edit" size={15} /> Edit
200
+ </Button>
201
+ <Button variant="ghost" disabled={busy === "test"} onClick={() => run("test", async () => {
202
+ const r = await adminApi.testProvider(id);
203
+ if (r.ok) setPing(r.data);
204
+ return r;
205
+ })}>
206
+ <Icon name="wifi_tethering" size={16} /> {busy === "test" ? "Testing…" : "Test connection"}
207
+ </Button>
208
+ <Button variant="ghost" disabled={busy === "discover"} onClick={() => run("discover", async () => {
209
+ const r = await adminApi.discoverModels(id);
210
+ if (r.ok) setDiscovered(r.data?.models ?? []);
211
+ return r;
212
+ })}>
213
+ <Icon name="sync" size={16} /> {busy === "discover" ? "Fetching…" : "Fetch models"}
214
+ </Button>
215
+ </div>
216
+ {ping && (
217
+ <div className="mt-3 text-[12px]">
218
+ <Badge tone={ping.ok ? "live" : ping.reachable ? "warn" : "down"}>
219
+ {ping.ok ? `ok (${ping.status})` : ping.reachable ? `reachable (${ping.status})` : "unreachable"}
220
+ </Badge>
221
+ {ping.error && <span className="ml-2 text-text-subtle">{ping.error}</span>}
222
+ </div>
223
+ )}
224
+ </>
225
+ )}
226
+ </RichCard>
227
+
228
+ <RichCard
229
+ header={
230
+ <>
231
+ <CardTitle title="Keys" sub={`${keys.length} configured`} />
232
+ <div className="flex flex-wrap items-center gap-3">
233
+ {keys.length > 1 && (
234
+ <Button variant="ghost" disabled={testingAll} onClick={() => testAllKeys(keys.length)}>
235
+ <Icon name={testingAll ? "progress_activity" : "sync"} size={15} />
236
+ {testingAll ? "Testing…" : "Test All"}
237
+ </Button>
238
+ )}
239
+ {testingAll && (
240
+ <Button variant="ghost" onClick={() => { stopTestAll.current = true; }}>
241
+ <Icon name="stop" size={15} /> Stop
242
+ </Button>
243
+ )}
244
+ <span className="text-[11px] text-text-subtle">Round Robin</span>
245
+ <button
246
+ onClick={() => {
247
+ const next = provider.strategy === "round-robin" ? null : "round-robin";
248
+ void adminApi.setProviderStrategy(id, next as "round-robin" | null, provider.sticky ?? 1).then(() => reload());
249
+ }}
250
+ className={`relative h-5 w-9 rounded-full transition-colors ${provider.strategy === "round-robin" ? "bg-accent" : "bg-border-subtle"}`}
251
+ aria-label="Toggle round-robin"
252
+ >
253
+ <span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${provider.strategy === "round-robin" ? "left-[18px]" : "left-0.5"}`} />
254
+ </button>
255
+ {provider.strategy === "round-robin" && (
256
+ <div className="flex items-center gap-1">
257
+ <span className="text-[11px] text-text-subtle">Sticky:</span>
258
+ <input
259
+ type="number"
260
+ min={1}
261
+ value={provider.sticky ?? 1}
262
+ onChange={(e) => {
263
+ const v = Number(e.target.value) || 1;
264
+ void adminApi.setProviderStrategy(id, "round-robin", v).then(() => reload());
265
+ }}
266
+ className="w-12 rounded border border-border-subtle bg-transparent px-1.5 py-0.5 text-[11px] text-text focus:border-accent focus:outline-none"
267
+ />
268
+ </div>
269
+ )}
270
+ </div>
271
+ </>
272
+ }
273
+ >
274
+ {testAllSummary && (
275
+ <div className="mb-3 flex items-center gap-2 text-[11.5px]">
276
+ <Badge tone={testAllSummary.failed === 0 ? "live" : "warn"}>
277
+ {testAllSummary.total} tested: {testAllSummary.passed} valid, {testAllSummary.failed} failed
278
+ </Badge>
279
+ </div>
280
+ )}
281
+ {keys.length === 0 ? (
282
+ <Empty>No keys (free / service-account provider).</Empty>
283
+ ) : (
284
+ <div className="space-y-1.5">
285
+ {keys.map((k, i) => {
286
+ const ks = health?.keys[i];
287
+ const test = keyTest[i];
288
+ const tested = test && test !== "testing" ? test : null;
289
+ const lamp = tested ? (tested.ok ? "live" : tested.reachable ? "idle" : "down") : ks ? (ks.healthy ? "live" : "down") : "idle";
290
+ const name = provider.key_names?.[k];
291
+ const disabled = provider.disabled_keys?.includes(i) ?? false;
292
+ if (editIdx === i) {
293
+ return (
294
+ <div key={i} className="space-y-2 rounded-brand border border-accent bg-accent-soft/40 px-3 py-2.5">
295
+ <Input value={editName} onChange={(e) => setEditName(e.target.value)} placeholder="key name (optional)" />
296
+ <Input value={editVal} onChange={(e) => setEditVal(e.target.value)} placeholder="new key value (leave blank to keep)" className="font-mono text-[12.5px]" />
297
+ <div className="flex justify-end gap-2">
298
+ <Button variant="ghost" onClick={() => setEditIdx(null)}>Cancel</Button>
299
+ <Button disabled={busy === `editkey${i}`} onClick={() => run(`editkey${i}`, async () => {
300
+ const r = await adminApi.editKey(id, i, { name: editName, key: editVal.trim() || undefined });
301
+ if (r.ok) setEditIdx(null);
302
+ return r;
303
+ })}>Save</Button>
304
+ </div>
305
+ </div>
306
+ );
307
+ }
308
+ return (
309
+ <div key={i} className={`rounded-brand border border-border-subtle px-3 py-2${disabled ? " opacity-60" : ""}`}>
310
+ <div className="flex items-center gap-2">
311
+ {/* reorder */}
312
+ <div className="flex flex-col">
313
+ <button
314
+ onClick={() => run(`reorder${i}up`, () => adminApi.reorderKey(id, i, i - 1))}
315
+ disabled={i === 0}
316
+ className="p-0.5 text-text-subtle hover:text-text disabled:opacity-30"
317
+ aria-label="Move up"
318
+ >
319
+ <Icon name="keyboard_arrow_up" size={14} />
320
+ </button>
321
+ <button
322
+ onClick={() => run(`reorder${i}dn`, () => adminApi.reorderKey(id, i, i + 1))}
323
+ disabled={i === keys.length - 1}
324
+ className="p-0.5 text-text-subtle hover:text-text disabled:opacity-30"
325
+ aria-label="Move down"
326
+ >
327
+ <Icon name="keyboard_arrow_down" size={14} />
328
+ </button>
329
+ </div>
330
+ <Lamp state={lamp} />
331
+ <div className="min-w-0 flex-1">
332
+ {name && <div className="text-[12px] font-semibold text-text-muted">{name}</div>}
333
+ <span className="block truncate font-mono text-[12.5px] text-text">{revealedKeys[i] ?? k}</span>
334
+ </div>
335
+ {revealedKeys[i] && (
336
+ <button
337
+ onClick={() => { void navigator.clipboard.writeText(revealedKeys[i]!); }}
338
+ className="flex-none rounded p-1 text-text-subtle transition-colors hover:text-text"
339
+ aria-label="Copy key"
340
+ title="Copy to clipboard"
341
+ >
342
+ <Icon name="content_copy" size={14} />
343
+ </button>
344
+ )}
345
+ {ks && ks.cooldown_ms > 0 && <CooldownTimer ms={ks.cooldown_ms} />}
346
+ {/* actions: reveal > toggle > test > edit > delete */}
347
+ <button
348
+ onClick={async () => {
349
+ if (revealedKeys[i]) { setRevealedKeys((r) => { const n = { ...r }; delete n[i]; return n; }); return; }
350
+ const r = await adminApi.revealKey(id, i);
351
+ if (r.ok && r.data?.key) setRevealedKeys((prev) => ({ ...prev, [i]: r.data!.key }));
352
+ }}
353
+ className="flex-none rounded p-1 text-text-subtle transition-colors hover:text-text"
354
+ aria-label={revealedKeys[i] ? "Hide key" : "Show key"}
355
+ title={revealedKeys[i] ? "Hide key" : "Show key"}
356
+ >
357
+ <Icon name={revealedKeys[i] ? "visibility_off" : "visibility"} size={15} />
358
+ </button>
359
+ <button
360
+ onClick={() => run(`toggle${i}`, () => adminApi.toggleKey(id, i, disabled))}
361
+ className="flex-none rounded p-1 text-text-subtle transition-colors hover:text-text"
362
+ aria-label={disabled ? "Enable key" : "Disable key"}
363
+ title={disabled ? "Enable this key" : "Disable this key"}
364
+ >
365
+ <Icon name={disabled ? "toggle_off" : "toggle_on"} size={20} className={disabled ? "text-text-subtle" : "text-success"} />
366
+ </button>
367
+ <button
368
+ onClick={() => testKey(i)}
369
+ disabled={test === "testing"}
370
+ className="flex-none rounded p-1 text-text-subtle transition-colors hover:text-text disabled:opacity-60"
371
+ aria-label={`Check key ${i + 1}`}
372
+ title="Check this key against the base URL"
373
+ >
374
+ <Icon name={test === "testing" ? "progress_activity" : "wifi_tethering"} size={15} />
375
+ </button>
376
+ <button
377
+ onClick={() => { setEditIdx(i); setEditName(name ?? ""); setEditVal(""); }}
378
+ className="flex-none rounded p-1 text-text-subtle transition-colors hover:text-text"
379
+ aria-label={`Edit key ${i + 1}`}
380
+ title="Rename or replace this key"
381
+ >
382
+ <Icon name="edit" size={15} />
383
+ </button>
384
+ <button
385
+ onClick={() => run(`rmkey${i}`, () => adminApi.removeKey(id, i))}
386
+ className="flex-none rounded p-1 text-text-subtle transition-colors hover:text-danger"
387
+ aria-label="Remove key"
388
+ >
389
+ <Icon name="delete" size={15} />
390
+ </button>
391
+ </div>
392
+ {tested && (
393
+ <div className="mt-1.5 flex items-center gap-2 pl-8 text-[11.5px]">
394
+ <Badge tone={tested.ok ? "live" : tested.reachable ? "warn" : "down"}>
395
+ {tested.ok ? "valid" : tested.reachable ? `reachable (${tested.status})` : "invalid"}
396
+ </Badge>
397
+ {tested.error && <span className="truncate text-danger">{tested.error}</span>}
398
+ </div>
399
+ )}
400
+ {!tested && ks?.last_error && (
401
+ <div className="mt-1.5 flex items-center gap-2 pl-8 text-[11.5px]">
402
+ <span className="text-danger">{ks.last_error.status ? `${ks.last_error.status}: ` : ""}{ks.last_error.message}</span>
403
+ <span className="text-text-subtle">{new Date(ks.last_error.at).toLocaleTimeString()}</span>
404
+ </div>
405
+ )}
406
+ </div>
407
+ );
408
+ })}
409
+ </div>
410
+ )}
411
+ <div className="mt-3 space-y-2">
412
+ <Input value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} placeholder="key name (optional, e.g. primary)" />
413
+ <div className="flex gap-2">
414
+ <Input value={newKey} onChange={(e) => setNewKey(e.target.value)} placeholder="add a key…" className="font-mono text-[12.5px]" />
415
+ <Button disabled={!newKey || busy === "addkey"} onClick={() => run("addkey", async () => {
416
+ const r = await adminApi.addKey(id, newKey, newKeyName.trim() || undefined);
417
+ if (r.ok) { setNewKey(""); setNewKeyName(""); }
418
+ return r;
419
+ })}>Add</Button>
420
+ </div>
421
+ </div>
422
+ </RichCard>
423
+
424
+ <RichCard
425
+ className="lg:col-span-2"
426
+ header={
427
+ <>
428
+ <CardTitle title="Models served" sub={`${provider.models.length} in catalog`} />
429
+ {provider.models.length > 0 && (
430
+ <button
431
+ onClick={() => run("clear", () => adminApi.clearModels(id))}
432
+ disabled={busy === "clear"}
433
+ className="text-[12px] text-text-subtle hover:text-danger"
434
+ >
435
+ Clear all
436
+ </button>
437
+ )}
438
+ </>
439
+ }
440
+ >
441
+ {provider.models.length === 0 ? (
442
+ <Empty>No models. Add one below, or fetch them for a free/auto provider.</Empty>
443
+ ) : (
444
+ <>
445
+ <p className="mb-2.5 text-[12px] text-text-subtle">
446
+ Call any of these as <span className="tnum text-text-muted">{provider.id}/&lt;model&gt;</span>, as a combo alias, or by the bare id.
447
+ </p>
448
+ {/* filter only earns its space once the catalog is long enough to scroll */}
449
+ {provider.models.length > 8 && (
450
+ <div className="mb-2.5 flex items-center gap-2">
451
+ <div className="relative flex-1">
452
+ <Icon name="search" size={15} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-text-subtle" />
453
+ <Input value={modelFilter} onChange={(e) => setModelFilter(e.target.value)} placeholder="filter models…" className="pl-8" />
454
+ </div>
455
+ <span className="tnum whitespace-nowrap text-[12px] text-text-subtle">
456
+ {q ? `${shownModels.length} of ${provider.models.length}` : `${provider.models.length}`}
457
+ </span>
458
+ </div>
459
+ )}
460
+ {shownModels.length === 0 ? (
461
+ <Empty>No model matches “{modelFilter}”.</Empty>
462
+ ) : (
463
+ <div className="max-h-[360px] divide-y divide-border-subtle overflow-y-auto rounded-brand border border-border-subtle">
464
+ {shownModels.map((m) => {
465
+ const st = modelTest[m.id];
466
+ const statusIcon = st === "ok" ? "check_circle" : st === "fail" ? "cancel" : "smart_toy";
467
+ const statusColor = st === "ok" ? "text-success" : st === "fail" ? "text-danger" : "text-text-subtle";
468
+ return (
469
+ <div key={m.id} className="group flex items-center justify-between gap-3 px-3 py-2 hover:bg-bg">
470
+ <div className="flex min-w-0 items-center gap-2">
471
+ <Icon name={statusIcon} size={15} className={`flex-none ${statusColor}`} />
472
+ {/* the prefix (= provider id) is what makes the call string; show it aigetwey-style */}
473
+ <span className="tnum truncate text-[12.5px]">
474
+ <span className="text-text-subtle">{provider.id}/</span>
475
+ <span className="text-text">{m.id}</span>
476
+ </span>
477
+ <CapacityBadges model={m.id} provider={provider.id} />
478
+ {(m.price_in !== undefined || m.price_out !== undefined) && (
479
+ <span className="tnum whitespace-nowrap text-[11px] text-text-subtle">
480
+ {fmt.cost(m.price_in ?? 0)}/{fmt.cost(m.price_out ?? 0)} per 1M
481
+ </span>
482
+ )}
483
+ </div>
484
+ <div className="flex flex-none items-center gap-0.5">
485
+ <button
486
+ onClick={() => testModel(m.id)}
487
+ disabled={st === "testing"}
488
+ className="rounded p-1 text-text-subtle transition-colors hover:bg-surface hover:text-accent disabled:opacity-60"
489
+ aria-label={`Test ${m.id}`}
490
+ title={st === "fail" ? "Test failed — click to retry" : "Test this model"}
491
+ >
492
+ <Icon name={st === "testing" ? "progress_activity" : "science"} size={15} />
493
+ </button>
494
+ <button
495
+ onClick={() => run(`rmmodel${m.id}`, () => adminApi.removeModel(id, m.id))}
496
+ disabled={busy === `rmmodel${m.id}`}
497
+ className="rounded p-1 text-text-subtle transition-colors hover:bg-surface hover:text-danger disabled:opacity-40"
498
+ aria-label={`Remove ${m.id}`}
499
+ >
500
+ <Icon name="delete" size={16} />
501
+ </button>
502
+ </div>
503
+ </div>
504
+ );
505
+ })}
506
+ </div>
507
+ )}
508
+ </>
509
+ )}
510
+ <div className="mt-3 flex gap-2">
511
+ <Input value={newModel} onChange={(e) => setNewModel(e.target.value)} placeholder="add a model id…" />
512
+ <Button disabled={!newModel || busy === "addmodel"} onClick={() => run("addmodel", async () => {
513
+ const r = await adminApi.addModel(id, newModel);
514
+ if (r.ok) setNewModel("");
515
+ return r;
516
+ })}>Add</Button>
517
+ </div>
518
+ </RichCard>
519
+ </div>
520
+
521
+ {discovered && (
522
+ <ModelSelectModal
523
+ models={discovered}
524
+ busy={busy === "addmodels"}
525
+ onClose={() => setDiscovered(null)}
526
+ onAdd={(ids) => run("addmodels", async () => {
527
+ const r = await adminApi.addModels(id, ids);
528
+ if (r.ok) setDiscovered(null);
529
+ return r;
530
+ })}
531
+ />
532
+ )}
533
+
534
+ <div className="mt-6">
535
+ <Button variant="danger" onClick={() => setConfirmDelete(true)}>
536
+ <Icon name="delete" size={16} /> Remove provider
537
+ </Button>
538
+ </div>
539
+
540
+ {confirmDelete && (
541
+ <ConfirmModal
542
+ title="Remove provider"
543
+ message={`Delete "${provider.name ?? id}"? All keys and model associations will be lost.`}
544
+ confirmLabel="Remove"
545
+ busy={busy === "rmprov"}
546
+ onCancel={() => setConfirmDelete(false)}
547
+ onConfirm={() => run("rmprov", async () => {
548
+ const r = await adminApi.removeProvider(id);
549
+ if (r.ok) router.push("/providers");
550
+ else setConfirmDelete(false);
551
+ return r;
552
+ })}
553
+ />
554
+ )}
555
+ </div>
556
+ );
557
+ }
558
+
559
+ function Row({ k, v }: { k: string; v: string }) {
560
+ return (
561
+ <div className="flex items-center justify-between gap-3">
562
+ <span className="text-text-subtle">{k}</span>
563
+ <span className="truncate text-text">{v}</span>
564
+ </div>
565
+ );
566
+ }