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,329 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import { adminApi } from "@/lib/client";
5
+ import { Lamp } from "@/components/Lamp";
6
+ import { Badge } from "@/components/Badge";
7
+ import { RichCard, CardTitle } from "@/components/RichCard";
8
+ import { Button, Input, Select, Field } from "@/components/Button";
9
+ import { ModelPicker, type ModelGroup } from "@/components/ModelPicker";
10
+ import { Icon } from "@/components/Icon";
11
+ import { ConfirmModal } from "@/components/ConfirmModal";
12
+ import { fmt, Empty } from "@/components/ui";
13
+ import type { MaskedConfig, MaskedRoute, ProviderSnapshot } from "@/lib/gateway";
14
+
15
+ /** Upstream model id for the i-th target of a route (mirrors GatewayConfig). */
16
+ function modelFor(route: MaskedRoute, i: number): string {
17
+ if (Array.isArray(route.model)) return route.model[i] ?? route.model[0] ?? route.alias;
18
+ if (typeof route.model === "string") return route.model;
19
+ return route.alias;
20
+ }
21
+
22
+ export function RoutingView() {
23
+ const [config, setConfig] = useState<MaskedConfig | null>(null);
24
+ const [health, setHealth] = useState<ProviderSnapshot[]>([]);
25
+ const [error, setError] = useState("");
26
+ const [adding, setAdding] = useState(false);
27
+ const [busy, setBusy] = useState("");
28
+ const [chainTest, setChainTest] = useState<Record<string, Record<number, "testing" | "ok" | "fail">>>({});
29
+ const [chainBusy, setChainBusy] = useState<string | null>(null);
30
+ const [pendingDelete, setPendingDelete] = useState<string | null>(null);
31
+ const [editing, setEditing] = useState<MaskedRoute | null>(null);
32
+
33
+ const reload = useCallback(async () => {
34
+ const [cfgRes, prov] = await Promise.all([fetch("/api/gw/admin/config"), adminApi.providers()]);
35
+ if (!cfgRes.ok) {
36
+ setError("could not reach the gateway");
37
+ return;
38
+ }
39
+ setError("");
40
+ setConfig((await cfgRes.json()) as MaskedConfig);
41
+ setHealth(prov.data?.providers ?? []);
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ void reload();
46
+ }, [reload]);
47
+
48
+ if (error) return <Empty>{error}</Empty>;
49
+ if (!config) return <Empty>Loading…</Empty>;
50
+
51
+ const healthy = (pid: string) => {
52
+ const h = health.find((x) => x.id === pid);
53
+ return h ? h.keys.some((k) => k.healthy) : true;
54
+ };
55
+
56
+ async function del(alias: string) {
57
+ setBusy(alias);
58
+ const r = await adminApi.removeRoute(alias);
59
+ setBusy("");
60
+ if (!r.ok) setError(r.error ?? "failed");
61
+ else await reload();
62
+ }
63
+
64
+ async function testChain(route: MaskedRoute) {
65
+ setChainBusy(route.alias);
66
+ setChainTest((prev) => ({ ...prev, [route.alias]: {} }));
67
+ for (let i = 0; i < route.target.length; i++) {
68
+ setChainTest((prev) => ({ ...prev, [route.alias]: { ...prev[route.alias], [i]: "testing" } }));
69
+ const r = await adminApi.testProvider(route.target[i]!);
70
+ const ok = r.ok && r.data?.ok;
71
+ setChainTest((prev) => ({ ...prev, [route.alias]: { ...prev[route.alias], [i]: ok ? "ok" : "fail" } }));
72
+ if (i < route.target.length - 1) await new Promise((resolve) => setTimeout(resolve, 400));
73
+ }
74
+ setChainBusy(null);
75
+ }
76
+
77
+ return (
78
+ <div>
79
+ <div className="mb-5 flex items-center justify-between gap-3">
80
+ <div>
81
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">Combos &amp; Routing</h1>
82
+ <p className="mt-1 text-[13px] text-text-muted">
83
+ A combo is an alias your CLI tool calls, routed to a chain of providers. Fallback tries them in order; round-robin spreads load.
84
+ </p>
85
+ </div>
86
+ <Button onClick={() => setAdding((v) => !v)}>
87
+ <Icon name={adding ? "close" : "add"} size={17} />
88
+ {adding ? "Cancel" : "Add combo"}
89
+ </Button>
90
+ </div>
91
+
92
+ {adding && (
93
+ <RouteForm
94
+ providers={config.providers}
95
+ onDone={() => { setAdding(false); void reload(); }}
96
+ />
97
+ )}
98
+
99
+ {editing && !adding && (
100
+ <RouteForm
101
+ providers={config.providers}
102
+ initial={editing}
103
+ onDone={() => { setEditing(null); void reload(); }}
104
+ onCancel={() => setEditing(null)}
105
+ />
106
+ )}
107
+
108
+ {config.models.length === 0 ? (
109
+ <Empty>No combos yet. Add one to expose a model alias to your CLI tools.</Empty>
110
+ ) : (
111
+ <div className="grid gap-3 lg:grid-cols-2">
112
+ {config.models.map((route) => (
113
+ <RichCard
114
+ key={route.alias}
115
+ header={
116
+ <>
117
+ <CardTitle title={route.alias} sub={`${route.target.length} in chain`} />
118
+ <div className="flex items-center gap-2">
119
+ <button
120
+ onClick={() => testChain(route)}
121
+ disabled={chainBusy === route.alias}
122
+ className="flex items-center gap-1 rounded px-2 py-1 text-[11px] text-text-subtle transition-colors hover:bg-surface-2 hover:text-text disabled:opacity-60"
123
+ title="Test each provider in this chain"
124
+ >
125
+ <Icon name={chainBusy === route.alias ? "progress_activity" : "sync"} size={14} />
126
+ Test
127
+ </button>
128
+ <Badge tone={route.strategy === "round-robin" ? "info" : "neutral"}>{route.strategy}</Badge>
129
+ {(route.price_in !== undefined || route.price_out !== undefined) && (
130
+ <Badge tone="neutral">
131
+ {fmt.cost(route.price_in ?? 0)}/{fmt.cost(route.price_out ?? 0)} per 1M
132
+ </Badge>
133
+ )}
134
+ <button onClick={() => setEditing(route)} className="text-text-subtle hover:text-text" aria-label="Edit combo">
135
+ <Icon name="edit" size={16} />
136
+ </button>
137
+ <button onClick={() => setPendingDelete(route.alias)} disabled={busy === route.alias} className="text-text-subtle hover:text-danger" aria-label="Remove alias">
138
+ <Icon name="delete" size={16} />
139
+ </button>
140
+ </div>
141
+ </>
142
+ }
143
+ >
144
+ <ol className="space-y-1.5">
145
+ {route.target.map((pid, i) => {
146
+ const ct = chainTest[route.alias]?.[i];
147
+ const testLamp = ct === "ok" ? "live" : ct === "fail" ? "down" : ct === "testing" ? "idle" : null;
148
+ return (
149
+ <li key={pid + i} className="flex items-center gap-2.5 rounded-brand border border-border-subtle px-3 py-2">
150
+ <span className="tnum text-[11px] text-text-subtle">{i === 0 ? "primary" : `#${i + 1}`}</span>
151
+ <Lamp state={testLamp ?? (healthy(pid) ? "live" : "down")} />
152
+ <span className="text-[13px] text-text">{pid}</span>
153
+ <span className="ml-auto tnum text-[12px] text-text-muted">{modelFor(route, i)}</span>
154
+ {ct === "ok" && <Icon name="check_circle" size={14} className="text-success" />}
155
+ {ct === "fail" && <Icon name="cancel" size={14} className="text-danger" />}
156
+ {ct === "testing" && <Icon name="progress_activity" size={14} className="text-text-subtle" />}
157
+ </li>
158
+ );
159
+ })}
160
+ </ol>
161
+ </RichCard>
162
+ ))}
163
+ </div>
164
+ )}
165
+
166
+ {pendingDelete && (
167
+ <ConfirmModal
168
+ title="Remove combo"
169
+ message={`Delete "${pendingDelete}"? CLI tools using this alias will stop working.`}
170
+ confirmLabel="Remove"
171
+ busy={busy === pendingDelete}
172
+ onCancel={() => setPendingDelete(null)}
173
+ onConfirm={() => {
174
+ void del(pendingDelete).then(() => setPendingDelete(null));
175
+ }}
176
+ />
177
+ )}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ // Combo create form, for aigetwey's ComboFormModal: a name + ONE ordered
183
+ // list of concrete `provider/model` entries (fallback priority), picked from the
184
+ // providers' catalogs. On save each entry splits into target[i]/model[i].
185
+ type ProviderOption = { id: string; models: { id: string }[] };
186
+
187
+ function RouteForm({ providers, onDone, initial, onCancel }: { providers: ProviderOption[]; onDone: () => void; initial?: MaskedRoute; onCancel?: () => void }) {
188
+ const isEdit = !!initial;
189
+ const [alias, setAlias] = useState(initial?.alias ?? "");
190
+ const [entries, setEntries] = useState<string[]>(() => {
191
+ if (!initial) return [];
192
+ return initial.target.map((pid, i) => {
193
+ const m = Array.isArray(initial.model) ? initial.model[i] ?? initial.model[0] : initial.model ?? initial.alias;
194
+ return `${pid}/${m}`;
195
+ });
196
+ });
197
+ const [pickerOpen, setPickerOpen] = useState(false);
198
+ const [strategy, setStrategy] = useState<"fallback" | "round-robin">(initial?.strategy ?? "fallback");
199
+ const [priceIn, setPriceIn] = useState(initial?.price_in?.toString() ?? "");
200
+ const [priceOut, setPriceOut] = useState(initial?.price_out?.toString() ?? "");
201
+ const [busy, setBusy] = useState(false);
202
+ const [err, setErr] = useState("");
203
+ const [dragIdx, setDragIdx] = useState<number | null>(null);
204
+
205
+ // aigetwey-style picker: provider-grouped, click a model to add/remove it.
206
+ const groups: ModelGroup[] = providers
207
+ .filter((p) => p.models.length > 0)
208
+ .map((p) => ({ label: p.id, items: p.models.map((m) => ({ value: `${p.id}/${m.id}`, label: `${p.id}/${m.id}` })) }));
209
+
210
+ function toggle(v: string) {
211
+ setErr("");
212
+ setEntries((e) => (e.includes(v) ? e.filter((x) => x !== v) : [...e, v]));
213
+ }
214
+
215
+ // reorder fallback priority: dropping entry #from onto slot #to
216
+ function move(from: number, to: number) {
217
+ setEntries((e) => {
218
+ const next = [...e];
219
+ const [moved] = next.splice(from, 1);
220
+ next.splice(to, 0, moved);
221
+ return next;
222
+ });
223
+ }
224
+
225
+ async function submit(e: React.FormEvent) {
226
+ e.preventDefault();
227
+ if (!alias || entries.length === 0) {
228
+ setErr("a name and at least one model are required");
229
+ return;
230
+ }
231
+ setBusy(true);
232
+ setErr("");
233
+ const target = entries.map((x) => x.slice(0, x.indexOf("/")));
234
+ const model = entries.map((x) => x.slice(x.indexOf("/") + 1));
235
+ const r = await adminApi.setRoute(alias, {
236
+ target,
237
+ model,
238
+ strategy,
239
+ price_in: priceIn ? Number(priceIn) : undefined,
240
+ price_out: priceOut ? Number(priceOut) : undefined,
241
+ });
242
+ setBusy(false);
243
+ if (r.ok) onDone();
244
+ else setErr(r.error ?? "failed");
245
+ }
246
+
247
+ return (
248
+ <form onSubmit={submit} className="mb-5 rounded-brand-lg border border-border bg-surface p-4 shadow-soft">
249
+ <div className="grid gap-3 sm:grid-cols-2">
250
+ <Field label="Name" hint="the model name your CLI calls">
251
+ <Input value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="claude-sonnet-4-6" readOnly={isEdit} className={isEdit ? "opacity-60" : ""} />
252
+ </Field>
253
+ <Field label="Strategy" hint="how the chain is tried">
254
+ <Select value={strategy} onChange={(e) => setStrategy(e.target.value as "fallback" | "round-robin")}>
255
+ <option value="fallback">Fallback — try in order, next on failure</option>
256
+ <option value="round-robin">Round Robin — rotate to spread load</option>
257
+ </Select>
258
+ </Field>
259
+ <Field label="Price in" hint="per 1M, optional"><Input value={priceIn} onChange={(e) => setPriceIn(e.target.value)} placeholder="3" /></Field>
260
+ <Field label="Price out" hint="per 1M, optional"><Input value={priceOut} onChange={(e) => setPriceOut(e.target.value)} placeholder="15" /></Field>
261
+ </div>
262
+
263
+ <div className="mt-3">
264
+ <div className="flex items-center justify-between">
265
+ <span className="text-[11px] font-medium uppercase tracking-wider text-text-subtle">
266
+ Models — fallback order (drag to reorder)
267
+ </span>
268
+ <Button type="button" variant="ghost" onClick={() => setPickerOpen(true)}>
269
+ <Icon name="add" size={16} /> Add models
270
+ </Button>
271
+ </div>
272
+
273
+ {entries.length === 0 ? (
274
+ <div className="mt-2 rounded-brand border border-dashed border-border-subtle px-3 py-4 text-center text-[12px] text-text-subtle">
275
+ No models yet. Click <span className="text-text-muted">Add models</span> and pick from your providers.
276
+ </div>
277
+ ) : (
278
+ <ul className="mt-2 space-y-1.5">
279
+ {entries.map((entry, i) => (
280
+ <li
281
+ key={entry}
282
+ draggable
283
+ onDragStart={() => setDragIdx(i)}
284
+ onDragOver={(e) => e.preventDefault()}
285
+ onDrop={() => {
286
+ if (dragIdx !== null && dragIdx !== i) move(dragIdx, i);
287
+ setDragIdx(null);
288
+ }}
289
+ onDragEnd={() => setDragIdx(null)}
290
+ className={`flex cursor-grab items-center gap-2.5 rounded-brand border px-3 py-2 active:cursor-grabbing ${
291
+ dragIdx === i ? "border-accent bg-accent-soft" : "border-border-subtle"
292
+ }`}
293
+ >
294
+ <Icon name="drag_indicator" size={16} className="text-text-subtle" />
295
+ <span className="tnum text-[11px] text-text-subtle">{i === 0 ? "primary" : `#${i + 1}`}</span>
296
+ <span className="tnum truncate text-[13px] text-text">{entry}</span>
297
+ <button
298
+ type="button"
299
+ onClick={() => setEntries((e) => e.filter((_, idx) => idx !== i))}
300
+ className="ml-auto flex-none text-text-subtle hover:text-danger"
301
+ aria-label={`Remove ${entry}`}
302
+ >
303
+ <Icon name="close" size={14} />
304
+ </button>
305
+ </li>
306
+ ))}
307
+ </ul>
308
+ )}
309
+ </div>
310
+
311
+ {err && <div className="mt-2 text-[12px] text-danger">{err}</div>}
312
+ <div className="mt-3 flex justify-end gap-2">
313
+ {onCancel && <Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button>}
314
+ <Button type="submit" disabled={busy}>{busy ? "Saving…" : isEdit ? "Update combo" : "Save combo"}</Button>
315
+ </div>
316
+
317
+ {pickerOpen && (
318
+ <ModelPicker
319
+ title="Add models to the chain"
320
+ note="Click a model to add it, click again to remove. Drag to reorder after."
321
+ groups={groups}
322
+ selected={entries}
323
+ onToggle={toggle}
324
+ onClose={() => setPickerOpen(false)}
325
+ />
326
+ )}
327
+ </form>
328
+ );
329
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useState } from "react";
4
+
5
+ type Theme = "dark" | "light";
6
+
7
+ const ThemeCtx = createContext<{ theme: Theme; toggle: () => void }>({
8
+ theme: "dark",
9
+ toggle: () => {},
10
+ });
11
+
12
+ export function useTheme() {
13
+ return useContext(ThemeCtx);
14
+ }
15
+
16
+ export function ThemeProvider({ children }: { children: React.ReactNode }) {
17
+ const [theme, setTheme] = useState<Theme>("dark");
18
+
19
+ useEffect(() => {
20
+ const stored = localStorage.getItem("theme") as Theme | null;
21
+ const t = stored === "light" ? "light" : "dark";
22
+ setTheme(t);
23
+ document.documentElement.className = document.documentElement.className.replace(/\b(dark|light)\b/g, "").trim() + " " + t;
24
+ }, []);
25
+
26
+ const toggle = useCallback(() => {
27
+ setTheme((prev) => {
28
+ const next = prev === "dark" ? "light" : "dark";
29
+ localStorage.setItem("theme", next);
30
+ document.documentElement.className = document.documentElement.className.replace(/\b(dark|light)\b/g, "").trim() + " " + next;
31
+ return next;
32
+ });
33
+ }, []);
34
+
35
+ return <ThemeCtx value={{ theme, toggle }}>{children}</ThemeCtx>;
36
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useState } from "react";
4
+ import { Icon } from "./Icon";
5
+
6
+ type ToastType = "success" | "error" | "info";
7
+
8
+ interface Toast {
9
+ id: number;
10
+ message: string;
11
+ type: ToastType;
12
+ }
13
+
14
+ const ToastCtx = createContext<{ toast: (message: string, type?: ToastType) => void }>({
15
+ toast: () => {},
16
+ });
17
+
18
+ export function useToast() {
19
+ return useContext(ToastCtx);
20
+ }
21
+
22
+ let nextId = 0;
23
+
24
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
25
+ const [toasts, setToasts] = useState<Toast[]>([]);
26
+
27
+ const toast = useCallback((message: string, type: ToastType = "success") => {
28
+ const id = nextId++;
29
+ setToasts((prev) => [...prev, { id, message, type }]);
30
+ setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3500);
31
+ }, []);
32
+
33
+ return (
34
+ <ToastCtx value={{ toast }}>
35
+ {children}
36
+ <div className="fixed bottom-5 right-5 z-[100] flex flex-col gap-2">
37
+ {toasts.map((t) => (
38
+ <div
39
+ key={t.id}
40
+ className={`flex items-center gap-2 rounded-brand border px-4 py-2.5 text-[12.5px] shadow-elevated animate-[slideIn_0.2s_ease] ${
41
+ t.type === "success"
42
+ ? "border-success/30 bg-success/10 text-success"
43
+ : t.type === "error"
44
+ ? "border-danger/30 bg-danger/10 text-danger"
45
+ : "border-info/30 bg-info/10 text-info"
46
+ }`}
47
+ >
48
+ <Icon
49
+ name={t.type === "success" ? "check_circle" : t.type === "error" ? "error" : "info"}
50
+ size={16}
51
+ />
52
+ {t.message}
53
+ </div>
54
+ ))}
55
+ </div>
56
+ </ToastCtx>
57
+ );
58
+ }