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,311 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
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 { Button, Input, Field } from "@/components/Button";
|
|
10
|
+
import { Icon } from "@/components/Icon";
|
|
11
|
+
import { fmt, Empty } from "@/components/ui";
|
|
12
|
+
import type { MaskedConfig, PingResult, ProviderSnapshot, QuotaSnapshot, WireFormat } from "@/lib/gateway";
|
|
13
|
+
|
|
14
|
+
interface Loaded {
|
|
15
|
+
config: MaskedConfig;
|
|
16
|
+
health: ProviderSnapshot[];
|
|
17
|
+
quota: QuotaSnapshot[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ProviderManager() {
|
|
21
|
+
const [data, setData] = useState<Loaded | null>(null);
|
|
22
|
+
const [error, setError] = useState("");
|
|
23
|
+
const [adding, setAdding] = useState(false);
|
|
24
|
+
|
|
25
|
+
const reload = useCallback(async () => {
|
|
26
|
+
const [cfg, prov, q] = await Promise.all([
|
|
27
|
+
fetch("/api/gw/admin/config"),
|
|
28
|
+
adminApi.providers(),
|
|
29
|
+
adminApi.quota(),
|
|
30
|
+
]);
|
|
31
|
+
if (!cfg.ok || !prov.ok) {
|
|
32
|
+
setError("could not reach the gateway");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setError("");
|
|
36
|
+
setData({
|
|
37
|
+
config: (await cfg.json()) as MaskedConfig,
|
|
38
|
+
health: prov.data?.providers ?? [],
|
|
39
|
+
quota: q.data?.quota ?? [],
|
|
40
|
+
});
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
void reload();
|
|
45
|
+
}, [reload]);
|
|
46
|
+
|
|
47
|
+
if (error) return <Empty>{error}</Empty>;
|
|
48
|
+
if (!data) return <Empty>Loading…</Empty>;
|
|
49
|
+
|
|
50
|
+
const healthById = new Map(data.health.map((h) => [h.id, h]));
|
|
51
|
+
const quotaById = new Map(data.quota.map((q) => [q.provider, q]));
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
<div className="mb-6 flex items-center justify-between gap-3">
|
|
56
|
+
<div>
|
|
57
|
+
<h1 className="text-[22px] font-semibold tracking-tight text-text">Providers & Keys</h1>
|
|
58
|
+
<p className="mt-1 text-[13px] text-text-muted">Upstream providers the gateway routes to.</p>
|
|
59
|
+
</div>
|
|
60
|
+
<Button onClick={() => setAdding(true)}>
|
|
61
|
+
<Icon name="add" size={17} />
|
|
62
|
+
Add provider
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{adding && (
|
|
67
|
+
<AddProviderForm
|
|
68
|
+
onClose={() => setAdding(false)}
|
|
69
|
+
onDone={() => { setAdding(false); void reload(); }}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{data.config.providers.length === 0 ? (
|
|
74
|
+
<Empty>No providers yet. Add one to start routing.</Empty>
|
|
75
|
+
) : (
|
|
76
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
77
|
+
{data.config.providers.map((p) => {
|
|
78
|
+
const health = healthById.get(p.id);
|
|
79
|
+
const healthy = health ? health.keys.some((k) => k.healthy) : true;
|
|
80
|
+
const cooling = health?.keys.find((k) => !k.healthy && k.cooldown_ms > 0);
|
|
81
|
+
const q = quotaById.get(p.id);
|
|
82
|
+
return (
|
|
83
|
+
<Link
|
|
84
|
+
key={p.id}
|
|
85
|
+
href={`/providers/${encodeURIComponent(p.id)}`}
|
|
86
|
+
className="group rounded-brand-lg border border-border bg-surface p-4 shadow-soft transition-colors hover:border-text-subtle"
|
|
87
|
+
>
|
|
88
|
+
<div className="flex items-start justify-between gap-2">
|
|
89
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
90
|
+
<Lamp state={healthy ? "live" : "down"} />
|
|
91
|
+
<div className="min-w-0">
|
|
92
|
+
<span className="block truncate text-[14px] font-semibold text-text">{p.name || p.id}</span>
|
|
93
|
+
{p.name && <span className="block truncate text-[11px] text-text-subtle">{p.id}/</span>}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<FormatBadge format={p.format} />
|
|
97
|
+
</div>
|
|
98
|
+
<div className="mt-2 truncate text-[12px] text-text-subtle">{p.base_url}</div>
|
|
99
|
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
100
|
+
{p.disabled && <Badge tone="warn">disabled</Badge>}
|
|
101
|
+
{p.free && <Badge tone="info">free</Badge>}
|
|
102
|
+
{p.service_account && <Badge tone="info">service-account</Badge>}
|
|
103
|
+
<Badge tone="neutral">
|
|
104
|
+
{p.free || p.service_account ? `${(p.api_keys?.length ?? 0)} keys` : `${p.api_keys?.length ?? (p.api_key ? 1 : 0)} keys`}
|
|
105
|
+
</Badge>
|
|
106
|
+
<Badge tone="neutral">{p.models.length} models</Badge>
|
|
107
|
+
{cooling && <CooldownTimer ms={cooling.cooldown_ms} />}
|
|
108
|
+
</div>
|
|
109
|
+
{q && (
|
|
110
|
+
<div className="mt-3 border-t border-border-subtle pt-2.5">
|
|
111
|
+
<div className="flex items-center justify-between text-[11px] text-text-subtle">
|
|
112
|
+
<span>quota · {q.window}</span>
|
|
113
|
+
<CooldownTimer ms={q.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
|
|
114
|
+
</div>
|
|
115
|
+
{q.limit_tokens && (
|
|
116
|
+
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-surface-2">
|
|
117
|
+
<div
|
|
118
|
+
className={`h-full ${q.exhausted ? "bg-danger" : "bg-accent"}`}
|
|
119
|
+
style={{ width: `${Math.round((q.pct ?? 0) * 100)}%` }}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
<div className="mt-1 tnum text-[11px] text-text-muted">
|
|
124
|
+
{fmt.compact(q.consumed)}
|
|
125
|
+
{q.limit_tokens ? ` / ${fmt.compact(q.limit_tokens)}` : ""} tokens
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</Link>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Provider presets — pick a type first, which prefills Base URL + API Type, then
|
|
139
|
+
// you only fill Name + Key. Matches aigetwey's per-type forms but friendlier; the
|
|
140
|
+
// fields below are still aigetwey's (Name, API Type, Base URL, Key + Check, Model
|
|
141
|
+
// ID), minus the separate Prefix — our Name is the id and the model prefix.
|
|
142
|
+
type Preset = { id: string; label: string; sub: string; icon: string; format: WireFormat; base_url: string; hint: string; modelHint: string };
|
|
143
|
+
const PRESETS: Preset[] = [
|
|
144
|
+
{
|
|
145
|
+
id: "openai", label: "OpenAI compatible", sub: "/v1/chat/completions", icon: "bolt",
|
|
146
|
+
format: "openai", base_url: "https://api.openai.com/v1",
|
|
147
|
+
hint: "Base URL ending in /v1 for any OpenAI-compatible API.", modelHint: "e.g. gpt-4o, glm-5.2",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "anthropic", label: "Anthropic compatible", sub: "/v1/messages", icon: "smart_toy",
|
|
151
|
+
format: "anthropic", base_url: "https://api.anthropic.com",
|
|
152
|
+
hint: "Base URL of an Anthropic-compatible API; /messages is appended.", modelHint: "e.g. claude-sonnet-4-6",
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
function AddProviderForm({ onDone, onClose }: { onDone: () => void; onClose: () => void }) {
|
|
157
|
+
const [preset, setPreset] = useState<Preset | null>(null);
|
|
158
|
+
const [id, setId] = useState("");
|
|
159
|
+
const [baseUrl, setBaseUrl] = useState("");
|
|
160
|
+
const [apiKey, setApiKey] = useState("");
|
|
161
|
+
const [showKey, setShowKey] = useState(false);
|
|
162
|
+
const [modelId, setModelId] = useState("");
|
|
163
|
+
const [busy, setBusy] = useState(false);
|
|
164
|
+
const [checking, setChecking] = useState(false);
|
|
165
|
+
const [checkRes, setCheckRes] = useState<PingResult | null>(null);
|
|
166
|
+
const [err, setErr] = useState("");
|
|
167
|
+
|
|
168
|
+
function choosePreset(p: Preset) {
|
|
169
|
+
setPreset(p);
|
|
170
|
+
setBaseUrl(p.base_url);
|
|
171
|
+
setCheckRes(null);
|
|
172
|
+
setErr("");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// step 1: pick a type (OpenAI- or Anthropic-compatible) — this sets the wire
|
|
176
|
+
// format + base URL, exactly aigetwey's "Add OpenAI/Anthropic Compatible".
|
|
177
|
+
if (!preset) {
|
|
178
|
+
return (
|
|
179
|
+
<div className="mb-5 rounded-brand-lg border border-border bg-surface p-5 shadow-soft">
|
|
180
|
+
<div className="flex items-start justify-between gap-3">
|
|
181
|
+
<div>
|
|
182
|
+
<h2 className="text-[14px] font-semibold text-text">Add a provider</h2>
|
|
183
|
+
<p className="mt-0.5 text-[12.5px] text-text-muted">Pick the API your endpoint speaks — the rest is prefilled.</p>
|
|
184
|
+
</div>
|
|
185
|
+
<button type="button" onClick={onClose} className="flex-none text-text-subtle hover:text-text" aria-label="Cancel">
|
|
186
|
+
<Icon name="close" size={18} />
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
190
|
+
{PRESETS.map((p) => (
|
|
191
|
+
<button
|
|
192
|
+
key={p.id}
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={() => choosePreset(p)}
|
|
195
|
+
className="group flex items-start gap-3 rounded-brand-lg border border-border bg-bg p-4 text-left transition-colors hover:border-accent hover:bg-accent-soft"
|
|
196
|
+
>
|
|
197
|
+
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-brand bg-surface-2 text-text-muted group-hover:text-accent">
|
|
198
|
+
<Icon name={p.icon} size={20} />
|
|
199
|
+
</span>
|
|
200
|
+
<span className="min-w-0">
|
|
201
|
+
<span className="block text-[13.5px] font-semibold text-text">{p.label}</span>
|
|
202
|
+
<span className="block tnum text-[11.5px] text-text-subtle">{p.sub}</span>
|
|
203
|
+
<span className="mt-1 block text-[11.5px] text-text-muted">{p.hint}</span>
|
|
204
|
+
</span>
|
|
205
|
+
</button>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function check() {
|
|
213
|
+
if (!baseUrl || !preset) return;
|
|
214
|
+
setChecking(true);
|
|
215
|
+
setCheckRes(null);
|
|
216
|
+
const r = await adminApi.validateProvider({ format: preset.format, base_url: baseUrl, api_key: apiKey || undefined });
|
|
217
|
+
setChecking(false);
|
|
218
|
+
setCheckRes(r.data ?? { ok: false, reachable: false, status: 0, error: r.error });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function submit(e: React.FormEvent) {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
if (!preset || !id || !baseUrl) {
|
|
224
|
+
setErr("name and base URL are required");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
setBusy(true);
|
|
228
|
+
setErr("");
|
|
229
|
+
const res = await adminApi.addProvider({ id, format: preset.format, base_url: baseUrl, api_key: apiKey || undefined, free: !apiKey.trim() });
|
|
230
|
+
if (!res.ok) {
|
|
231
|
+
setBusy(false);
|
|
232
|
+
setErr(res.error ?? "failed");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (modelId.trim()) await adminApi.addModel(id, modelId.trim());
|
|
236
|
+
setBusy(false);
|
|
237
|
+
onDone();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// step 2: the aigetwey field set — Name, Base URL, API Key (for Check), Model ID.
|
|
241
|
+
return (
|
|
242
|
+
<div className="mb-5 rounded-brand-lg border border-border bg-surface p-5 shadow-soft">
|
|
243
|
+
<form onSubmit={submit}>
|
|
244
|
+
<div className="mb-4 flex items-center gap-2.5 border-b border-border-subtle pb-4">
|
|
245
|
+
<span className="flex h-8 w-8 items-center justify-center rounded-brand bg-surface-2 text-text-muted">
|
|
246
|
+
<Icon name={preset.icon} size={17} />
|
|
247
|
+
</span>
|
|
248
|
+
<div>
|
|
249
|
+
<div className="text-[13.5px] font-semibold text-text">{preset.label}</div>
|
|
250
|
+
<div className="tnum text-[11px] text-text-subtle">{preset.sub}</div>
|
|
251
|
+
</div>
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
onClick={() => { setPreset(null); setCheckRes(null); }}
|
|
255
|
+
className="ml-auto inline-flex items-center gap-1 text-[12px] text-text-subtle hover:text-text"
|
|
256
|
+
>
|
|
257
|
+
<Icon name="arrow_back" size={14} /> change type
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div className="space-y-3">
|
|
262
|
+
<Field label="Name" hint="a friendly id — also the model prefix (name/model)">
|
|
263
|
+
<Input value={id} onChange={(e) => setId(e.target.value)} placeholder="e.g. openai, huki" />
|
|
264
|
+
</Field>
|
|
265
|
+
<Field label="Base URL" hint={preset.hint}>
|
|
266
|
+
<Input value={baseUrl} onChange={(e) => { setBaseUrl(e.target.value); setCheckRes(null); }} placeholder={preset.base_url} className="font-mono text-[12.5px]" />
|
|
267
|
+
</Field>
|
|
268
|
+
<Field label="API Key" hint="used for Check and live requests — leave blank for a free / no-auth endpoint">
|
|
269
|
+
<div className="flex gap-2">
|
|
270
|
+
<div className="relative flex-1">
|
|
271
|
+
<Input
|
|
272
|
+
type={showKey ? "text" : "password"}
|
|
273
|
+
value={apiKey}
|
|
274
|
+
onChange={(e) => { setApiKey(e.target.value); setCheckRes(null); }}
|
|
275
|
+
placeholder="sk-…"
|
|
276
|
+
className="pr-9 font-mono text-[12.5px]"
|
|
277
|
+
/>
|
|
278
|
+
{apiKey && (
|
|
279
|
+
<button type="button" onClick={() => setShowKey((v) => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-subtle hover:text-text" aria-label={showKey ? "Hide key" : "Show key"}>
|
|
280
|
+
<Icon name={showKey ? "visibility_off" : "visibility"} size={15} />
|
|
281
|
+
</button>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
<Button type="button" variant="ghost" disabled={checking || !baseUrl} onClick={check}>
|
|
285
|
+
<Icon name={checking ? "progress_activity" : "wifi_tethering"} size={15} />
|
|
286
|
+
{checking ? "Checking…" : "Check"}
|
|
287
|
+
</Button>
|
|
288
|
+
</div>
|
|
289
|
+
</Field>
|
|
290
|
+
{checkRes && (
|
|
291
|
+
<div className="flex items-center gap-2 text-[12px]">
|
|
292
|
+
<Badge tone={checkRes.ok ? "live" : checkRes.reachable ? "warn" : "down"}>
|
|
293
|
+
{checkRes.ok ? "valid" : checkRes.reachable ? `reachable (${checkRes.status})` : "invalid"}
|
|
294
|
+
</Badge>
|
|
295
|
+
{checkRes.error && <span className="text-text-subtle">{checkRes.error}</span>}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
<Field label="Model ID" hint="optional — seed one if the provider has no /models endpoint">
|
|
299
|
+
<Input value={modelId} onChange={(e) => setModelId(e.target.value)} placeholder={preset.modelHint} className="font-mono text-[12.5px]" />
|
|
300
|
+
</Field>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
{err && <div className="mt-2 text-[12px] text-danger">{err}</div>}
|
|
304
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
305
|
+
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
|
|
306
|
+
<Button type="submit" disabled={busy}>{busy ? "Adding…" : "Add provider"}</Button>
|
|
307
|
+
</div>
|
|
308
|
+
</form>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { adminApi } from "@/lib/client";
|
|
5
|
+
import { Badge } from "@/components/Badge";
|
|
6
|
+
import { RichCard, CardTitle } from "@/components/RichCard";
|
|
7
|
+
import { CooldownTimer } from "@/components/CooldownTimer";
|
|
8
|
+
import { fmt, Empty } from "@/components/ui";
|
|
9
|
+
import type { QuotaSnapshot } from "@/lib/gateway";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Quota Tracker — the per-provider token budgets that were only
|
|
13
|
+
* visible as a strip on each provider card, now their own page: consumption vs
|
|
14
|
+
* limit, a fill bar, and a live countdown to the next scheduled window reset.
|
|
15
|
+
*/
|
|
16
|
+
export function QuotaView() {
|
|
17
|
+
const [quota, setQuota] = useState<QuotaSnapshot[] | null>(null);
|
|
18
|
+
const [error, setError] = useState("");
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
void adminApi.quota().then((r) => {
|
|
22
|
+
if (!r.ok) setError(r.error ?? "could not reach the gateway");
|
|
23
|
+
else setQuota(r.data?.quota ?? []);
|
|
24
|
+
});
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
if (error) return <Empty>{error}</Empty>;
|
|
28
|
+
if (!quota) return <Empty>Loading…</Empty>;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div>
|
|
32
|
+
<div className="mb-6">
|
|
33
|
+
<h1 className="text-[22px] font-semibold tracking-tight text-text">Quota Tracker</h1>
|
|
34
|
+
<p className="mt-1 text-[13px] text-text-muted">
|
|
35
|
+
Per-provider token budgets and when each window resets.
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
{quota.length === 0 ? (
|
|
40
|
+
<Empty>
|
|
41
|
+
No quotas configured. Add a <span className="tnum">quota</span> block to a provider in Settings.
|
|
42
|
+
</Empty>
|
|
43
|
+
) : (
|
|
44
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
45
|
+
{quota.map((q) => (
|
|
46
|
+
<RichCard
|
|
47
|
+
key={q.provider}
|
|
48
|
+
header={
|
|
49
|
+
<>
|
|
50
|
+
<CardTitle title={q.provider} sub={`window · ${q.window}`} />
|
|
51
|
+
<Badge tone={q.exhausted ? "down" : "live"}>{q.exhausted ? "exhausted" : "active"}</Badge>
|
|
52
|
+
</>
|
|
53
|
+
}
|
|
54
|
+
>
|
|
55
|
+
<div className="space-y-2.5">
|
|
56
|
+
{q.limit_tokens ? (
|
|
57
|
+
<div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
|
|
58
|
+
<div
|
|
59
|
+
className={`h-full ${q.exhausted ? "bg-danger" : "bg-accent"}`}
|
|
60
|
+
style={{ width: `${Math.round((q.pct ?? 0) * 100)}%` }}
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
) : null}
|
|
64
|
+
<div className="flex items-center justify-between text-[12px]">
|
|
65
|
+
<span className="tnum text-text-muted">
|
|
66
|
+
{fmt.compact(q.consumed)}
|
|
67
|
+
{q.limit_tokens ? ` / ${fmt.compact(q.limit_tokens)}` : ""} tokens
|
|
68
|
+
</span>
|
|
69
|
+
<CooldownTimer ms={q.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</RichCard>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
|
|
7
|
+
// Floating icon-rail (user's preferred chrome), carrying aigetwey's IA: Endpoint
|
|
8
|
+
// is the landing, routing lives in Combos, and operational pages sit below a
|
|
9
|
+
// divider. Labels surface as hover tooltips (data-label).
|
|
10
|
+
type NavItem = { href: string; label: string; icon: string };
|
|
11
|
+
|
|
12
|
+
const MAIN: NavItem[] = [
|
|
13
|
+
{ href: "/", label: "Endpoint & Key", icon: "api" },
|
|
14
|
+
{ href: "/providers", label: "Providers", icon: "dns" },
|
|
15
|
+
{ href: "/combos", label: "Combos", icon: "layers" },
|
|
16
|
+
{ href: "/usage", label: "Usage", icon: "bar_chart" },
|
|
17
|
+
{ href: "/quota", label: "Quota Tracker", icon: "data_usage" },
|
|
18
|
+
{ href: "/tools", label: "CLI Tools", icon: "terminal" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const SYSTEM: NavItem[] = [
|
|
22
|
+
{ href: "/console", label: "Server Console", icon: "terminal" },
|
|
23
|
+
{ href: "/config", label: "Settings", icon: "settings" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function Rail() {
|
|
27
|
+
const path = usePathname();
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
|
|
30
|
+
async function logout() {
|
|
31
|
+
await fetch("/api/logout", { method: "POST" });
|
|
32
|
+
router.replace("/login");
|
|
33
|
+
router.refresh();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Endpoint owns both "/" (landing) and "/endpoint"; others match the segment
|
|
37
|
+
// and its sub-routes (e.g. /providers/[id]).
|
|
38
|
+
const isActive = (href: string) =>
|
|
39
|
+
href === "/"
|
|
40
|
+
? path === "/" || path.startsWith("/endpoint")
|
|
41
|
+
: path === href || path.startsWith(`${href}/`);
|
|
42
|
+
|
|
43
|
+
const link = (item: NavItem) => {
|
|
44
|
+
const active = isActive(item.href);
|
|
45
|
+
return (
|
|
46
|
+
<Link
|
|
47
|
+
key={item.href}
|
|
48
|
+
href={item.href}
|
|
49
|
+
data-label={item.label}
|
|
50
|
+
className={`rail-icon${active ? " rail-icon-active" : ""}`}
|
|
51
|
+
aria-label={item.label}
|
|
52
|
+
>
|
|
53
|
+
<Icon name={item.icon} size={20} fill={active} />
|
|
54
|
+
</Link>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<Link href="/" className="rail-brand" aria-label="aigetwey">
|
|
61
|
+
{/* "a»" mark — ink on the lime tile (CSS provides the tile) */}
|
|
62
|
+
<svg viewBox="0 0 512 512" width="26" height="26" aria-hidden>
|
|
63
|
+
<text x="120" y="338" fontFamily="ui-sans-serif, Arial, sans-serif" fontSize="260" fontWeight="800" textAnchor="middle" fill="#14140f">a</text>
|
|
64
|
+
<g fill="none" stroke="#14140f" strokeWidth="34" strokeLinecap="round" strokeLinejoin="round">
|
|
65
|
+
<polyline points="276,182 352,256 276,330" />
|
|
66
|
+
<polyline points="346,182 422,256 346,330" />
|
|
67
|
+
</g>
|
|
68
|
+
</svg>
|
|
69
|
+
</Link>
|
|
70
|
+
|
|
71
|
+
<nav className="flex flex-col items-center gap-3">
|
|
72
|
+
{MAIN.map(link)}
|
|
73
|
+
<div className="rail-divider" />
|
|
74
|
+
{SYSTEM.map(link)}
|
|
75
|
+
</nav>
|
|
76
|
+
|
|
77
|
+
<button onClick={logout} data-label="Disconnect" className="rail-icon" aria-label="Disconnect">
|
|
78
|
+
<Icon name="logout" size={19} />
|
|
79
|
+
</button>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich content card — header (title slot + trailing slot), body, optional footer.
|
|
3
|
+
* Softly rounded, subtle border, warm surface. The Haulix-style workhorse.
|
|
4
|
+
*/
|
|
5
|
+
export function RichCard({
|
|
6
|
+
header,
|
|
7
|
+
footer,
|
|
8
|
+
children,
|
|
9
|
+
className,
|
|
10
|
+
}: {
|
|
11
|
+
header?: React.ReactNode;
|
|
12
|
+
footer?: React.ReactNode;
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<section
|
|
18
|
+
className={`overflow-hidden rounded-brand-lg border border-border bg-surface shadow-soft${className ? ` ${className}` : ""}`}
|
|
19
|
+
>
|
|
20
|
+
{header && (
|
|
21
|
+
<header className="flex items-center justify-between gap-3 border-b border-border-subtle px-4 py-3">
|
|
22
|
+
{header}
|
|
23
|
+
</header>
|
|
24
|
+
)}
|
|
25
|
+
<div className="p-4">{children}</div>
|
|
26
|
+
{footer && (
|
|
27
|
+
<footer className="flex items-center justify-between gap-3 border-t border-border-subtle bg-bg-alt px-4 py-2.5">
|
|
28
|
+
{footer}
|
|
29
|
+
</footer>
|
|
30
|
+
)}
|
|
31
|
+
</section>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Title + optional subtitle, for the header slot. */
|
|
36
|
+
export function CardTitle({ title, sub, icon }: { title: string; sub?: string; icon?: React.ReactNode }) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
39
|
+
{icon}
|
|
40
|
+
<div className="min-w-0">
|
|
41
|
+
<div className="truncate text-[14px] font-semibold tracking-tight text-text">{title}</div>
|
|
42
|
+
{sub && <div className="truncate text-[12px] text-text-muted">{sub}</div>}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|