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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/assets/logo.svg +8 -0
- package/assets/screenshot.png +0 -0
- package/assets/wordmark.svg +9 -0
- package/config.example.yaml +56 -0
- package/dashboard/.env.example +12 -0
- package/dashboard/next-env.d.ts +6 -0
- package/dashboard/next.config.ts +12 -0
- package/dashboard/package-lock.json +1771 -0
- package/dashboard/package.json +29 -0
- package/dashboard/postcss.config.mjs +5 -0
- package/dashboard/src/app/(console)/combos/page.tsx +10 -0
- package/dashboard/src/app/(console)/config/page.tsx +5 -0
- package/dashboard/src/app/(console)/console/page.tsx +92 -0
- package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
- package/dashboard/src/app/(console)/layout.tsx +17 -0
- package/dashboard/src/app/(console)/page.tsx +8 -0
- package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/providers/page.tsx +5 -0
- package/dashboard/src/app/(console)/quota/page.tsx +5 -0
- package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/tools/page.tsx +5 -0
- package/dashboard/src/app/(console)/usage/page.tsx +24 -0
- package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
- package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
- package/dashboard/src/app/api/login/route.ts +30 -0
- package/dashboard/src/app/api/logout/route.ts +9 -0
- package/dashboard/src/app/api/password/route.ts +34 -0
- package/dashboard/src/app/globals.css +340 -0
- package/dashboard/src/app/icon.svg +8 -0
- package/dashboard/src/app/layout.tsx +28 -0
- package/dashboard/src/app/login/page.tsx +60 -0
- package/dashboard/src/components/AreaChart.tsx +115 -0
- package/dashboard/src/components/Badge.tsx +32 -0
- package/dashboard/src/components/Button.tsx +60 -0
- package/dashboard/src/components/CapacityBadges.tsx +40 -0
- package/dashboard/src/components/Checkbox.tsx +40 -0
- package/dashboard/src/components/CliToolConfig.tsx +63 -0
- package/dashboard/src/components/ConfigEditor.tsx +199 -0
- package/dashboard/src/components/ConfirmModal.tsx +36 -0
- package/dashboard/src/components/CooldownTimer.tsx +42 -0
- package/dashboard/src/components/EndpointView.tsx +439 -0
- package/dashboard/src/components/Icon.tsx +25 -0
- package/dashboard/src/components/KeyReveal.tsx +78 -0
- package/dashboard/src/components/Lamp.tsx +8 -0
- package/dashboard/src/components/LogTable.tsx +223 -0
- package/dashboard/src/components/LogoutButton.tsx +20 -0
- package/dashboard/src/components/ModelPicker.tsx +121 -0
- package/dashboard/src/components/ModelSelectModal.tsx +126 -0
- package/dashboard/src/components/PasswordEditor.tsx +86 -0
- package/dashboard/src/components/PricingEditor.tsx +171 -0
- package/dashboard/src/components/ProviderDetail.tsx +566 -0
- package/dashboard/src/components/ProviderManager.tsx +311 -0
- package/dashboard/src/components/QuotaView.tsx +78 -0
- package/dashboard/src/components/Rail.tsx +82 -0
- package/dashboard/src/components/RichCard.tsx +46 -0
- package/dashboard/src/components/RoutingView.tsx +329 -0
- package/dashboard/src/components/ThemeProvider.tsx +36 -0
- package/dashboard/src/components/ToastProvider.tsx +58 -0
- package/dashboard/src/components/ToolDetail.tsx +475 -0
- package/dashboard/src/components/TopBar.tsx +128 -0
- package/dashboard/src/components/UsageView.tsx +151 -0
- package/dashboard/src/components/ui.tsx +54 -0
- package/dashboard/src/lib/capabilities.ts +318 -0
- package/dashboard/src/lib/cliTools.ts +120 -0
- package/dashboard/src/lib/client.ts +190 -0
- package/dashboard/src/lib/gateway.ts +269 -0
- package/dashboard/src/lib/session.ts +71 -0
- package/dashboard/src/middleware.ts +37 -0
- package/dashboard/tsconfig.json +21 -0
- package/dist/adapters/anthropic.js +289 -0
- package/dist/adapters/anthropic.js.map +1 -0
- package/dist/adapters/gemini.js +268 -0
- package/dist/adapters/gemini.js.map +1 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openai.js +13 -0
- package/dist/adapters/openai.js.map +1 -0
- package/dist/cli/tray/autostart.js +152 -0
- package/dist/cli/tray/autostart.js.map +1 -0
- package/dist/cli/tray/icon.js +4 -0
- package/dist/cli/tray/icon.js.map +1 -0
- package/dist/cli/tray/tray.js +141 -0
- package/dist/cli/tray/tray.js.map +1 -0
- package/dist/cli/tray/trayRuntime.js +91 -0
- package/dist/cli/tray/trayRuntime.js.map +1 -0
- package/dist/cli.js +361 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +728 -0
- package/dist/config.js.map +1 -0
- package/dist/core/authStore.js +78 -0
- package/dist/core/authStore.js.map +1 -0
- package/dist/core/canonical.js +9 -0
- package/dist/core/canonical.js.map +1 -0
- package/dist/core/console-buffer.js +25 -0
- package/dist/core/console-buffer.js.map +1 -0
- package/dist/core/fallback.js +62 -0
- package/dist/core/fallback.js.map +1 -0
- package/dist/core/handler.js +174 -0
- package/dist/core/handler.js.map +1 -0
- package/dist/core/keypool.js +105 -0
- package/dist/core/keypool.js.map +1 -0
- package/dist/core/quota.js +165 -0
- package/dist/core/quota.js.map +1 -0
- package/dist/core/state.js +52 -0
- package/dist/core/state.js.map +1 -0
- package/dist/db.js +193 -0
- package/dist/db.js.map +1 -0
- package/dist/headroom/compress.js +44 -0
- package/dist/headroom/compress.js.map +1 -0
- package/dist/headroom/detect.js +108 -0
- package/dist/headroom/detect.js.map +1 -0
- package/dist/headroom/process.js +158 -0
- package/dist/headroom/process.js.map +1 -0
- package/dist/inject/caveman.js +30 -0
- package/dist/inject/caveman.js.map +1 -0
- package/dist/inject/index.js +24 -0
- package/dist/inject/index.js.map +1 -0
- package/dist/inject/ponytail.js +19 -0
- package/dist/inject/ponytail.js.map +1 -0
- package/dist/middleware/auth.js +66 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/providers/capabilities.js +246 -0
- package/dist/providers/capabilities.js.map +1 -0
- package/dist/providers/free.js +43 -0
- package/dist/providers/free.js.map +1 -0
- package/dist/providers/pricing.js +224 -0
- package/dist/providers/pricing.js.map +1 -0
- package/dist/providers/vertex.js +97 -0
- package/dist/providers/vertex.js.map +1 -0
- package/dist/routes/admin.js +622 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/health.js +4 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.js +12 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/v1.js +75 -0
- package/dist/routes/v1.js.map +1 -0
- package/dist/rtk/detect.js +50 -0
- package/dist/rtk/detect.js.map +1 -0
- package/dist/rtk/filters.js +85 -0
- package/dist/rtk/filters.js.map +1 -0
- package/dist/rtk/index.js +39 -0
- package/dist/rtk/index.js.map +1 -0
- package/dist/server.js +100 -0
- package/dist/server.js.map +1 -0
- package/dist/stream/anthropic-stream.js +239 -0
- package/dist/stream/anthropic-stream.js.map +1 -0
- package/dist/stream/chunk.js +7 -0
- package/dist/stream/chunk.js.map +1 -0
- package/dist/stream/gemini-stream.js +135 -0
- package/dist/stream/gemini-stream.js.map +1 -0
- package/dist/stream/index.js +12 -0
- package/dist/stream/index.js.map +1 -0
- package/dist/stream/openai-stream.js +34 -0
- package/dist/stream/openai-stream.js.map +1 -0
- package/dist/stream/sse.js +64 -0
- package/dist/stream/sse.js.map +1 -0
- package/dist/translator/thinking.js +70 -0
- package/dist/translator/thinking.js.map +1 -0
- package/dist/translator/thinkingUnified.js +322 -0
- package/dist/translator/thinkingUnified.js.map +1 -0
- package/dist/upstream/client.js +120 -0
- package/dist/upstream/client.js.map +1 -0
- package/package.json +76 -0
- package/run.sh +27 -0
- package/src/adapters/anthropic.ts +377 -0
- package/src/adapters/gemini.ts +341 -0
- package/src/adapters/index.ts +17 -0
- package/src/adapters/openai.ts +22 -0
- package/src/cli/tray/autostart.ts +133 -0
- package/src/cli/tray/icon.ts +4 -0
- package/src/cli/tray/tray.ts +156 -0
- package/src/cli/tray/trayRuntime.ts +90 -0
- package/src/cli.ts +379 -0
- package/src/config.ts +777 -0
- package/src/core/authStore.ts +86 -0
- package/src/core/canonical.ts +93 -0
- package/src/core/console-buffer.ts +39 -0
- package/src/core/fallback.ts +116 -0
- package/src/core/handler.ts +236 -0
- package/src/core/keypool.ts +152 -0
- package/src/core/quota.ts +214 -0
- package/src/core/state.ts +65 -0
- package/src/db.ts +280 -0
- package/src/headroom/compress.ts +78 -0
- package/src/headroom/detect.ts +119 -0
- package/src/headroom/process.ts +166 -0
- package/src/inject/caveman.ts +35 -0
- package/src/inject/index.ts +46 -0
- package/src/inject/ponytail.ts +31 -0
- package/src/middleware/auth.ts +76 -0
- package/src/providers/capabilities.ts +297 -0
- package/src/providers/free.ts +53 -0
- package/src/providers/pricing.ts +261 -0
- package/src/providers/vertex.ts +117 -0
- package/src/routes/admin.ts +716 -0
- package/src/routes/health.ts +5 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/v1.ts +87 -0
- package/src/rtk/detect.ts +55 -0
- package/src/rtk/filters.ts +94 -0
- package/src/rtk/index.ts +58 -0
- package/src/server.ts +108 -0
- package/src/stream/anthropic-stream.ts +310 -0
- package/src/stream/chunk.ts +46 -0
- package/src/stream/gemini-stream.ts +158 -0
- package/src/stream/index.ts +23 -0
- package/src/stream/openai-stream.ts +41 -0
- package/src/stream/sse.ts +72 -0
- package/src/translator/thinking.ts +64 -0
- package/src/translator/thinkingUnified.ts +319 -0
- package/src/upstream/client.ts +155 -0
- 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 & 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
|
+
}
|