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,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}/<model></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
|
+
}
|