aigetwey 1.1.0 → 1.2.0
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 +46 -3
- package/README.md +4 -4
- package/config.example.yaml +6 -5
- package/dashboard/next.config.ts +6 -0
- package/dashboard/src/app/globals.css +47 -0
- package/dashboard/src/components/BudgetForm.tsx +258 -0
- package/dashboard/src/components/EndpointView.tsx +30 -0
- package/dashboard/src/components/LogTable.tsx +90 -25
- package/dashboard/src/components/ModelPicker.tsx +15 -7
- package/dashboard/src/components/ProviderDetail.tsx +27 -29
- package/dashboard/src/components/ProviderManager.tsx +36 -3
- package/dashboard/src/components/QuotaView.tsx +95 -81
- package/dashboard/src/components/Rail.tsx +1 -1
- package/dashboard/src/components/RoutingView.tsx +2 -2
- package/dashboard/src/components/ToolDetail.tsx +5 -3
- package/dashboard/src/components/TopBar.tsx +1 -1
- package/dashboard/src/components/UsageView.tsx +25 -6
- package/dashboard/src/lib/cliTools.ts +0 -43
- package/dashboard/src/lib/client.ts +9 -3
- package/dashboard/src/lib/gateway.ts +12 -1
- package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
- package/dist/cli.js +43 -8
- package/dist/cli.js.map +1 -1
- package/dist/config.js +56 -10
- package/dist/config.js.map +1 -1
- package/dist/core/budget.js +61 -16
- package/dist/core/budget.js.map +1 -1
- package/dist/core/handler.js +20 -6
- package/dist/core/handler.js.map +1 -1
- package/dist/core/state.js +10 -2
- package/dist/core/state.js.map +1 -1
- package/dist/db.js +39 -5
- package/dist/db.js.map +1 -1
- package/dist/middleware/auth.js +15 -8
- package/dist/middleware/auth.js.map +1 -1
- package/dist/routes/admin.js +26 -8
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/v1.js +15 -11
- package/dist/routes/v1.js.map +1 -1
- package/dist/server.js +4 -0
- package/dist/server.js.map +1 -1
- package/dist/upstream/client.js +9 -0
- package/dist/upstream/client.js.map +1 -1
- package/package.json +3 -4
- package/src/cli.ts +44 -8
- package/src/config.ts +57 -10
- package/src/core/budget.ts +77 -24
- package/src/core/handler.ts +24 -7
- package/src/core/state.ts +17 -2
- package/src/db.ts +50 -5
- package/src/middleware/auth.ts +18 -8
- package/src/routes/admin.ts +33 -12
- package/src/routes/v1.ts +15 -11
- package/src/server.ts +4 -0
- package/src/upstream/client.ts +9 -0
- package/dashboard/src/components/BudgetEditor.tsx +0 -97
package/src/middleware/auth.ts
CHANGED
|
@@ -15,15 +15,23 @@ function digest(s: string): Buffer {
|
|
|
15
15
|
return createHash("sha256").update(s).digest();
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
export function
|
|
18
|
+
/** Non-secret stable id for a client key: sha256 truncated to 8 hex chars. */
|
|
19
|
+
export function clientKeyFingerprint(key: string): string {
|
|
20
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 8);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Constant-time: returns the matching key (digest every candidate) or null. */
|
|
24
|
+
export function matchKey(presented: string, validKeys: string[]): string | null {
|
|
20
25
|
const p = digest(presented);
|
|
21
|
-
|
|
22
|
-
let ok = false;
|
|
26
|
+
let found: string | null = null;
|
|
23
27
|
for (const k of validKeys) {
|
|
24
|
-
if (timingSafeEqual(p, digest(k)))
|
|
28
|
+
if (timingSafeEqual(p, digest(k))) found = k;
|
|
25
29
|
}
|
|
26
|
-
return
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isValidKey(presented: string, validKeys: string[]): boolean {
|
|
34
|
+
return matchKey(presented, validKeys) !== null;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
export function extractKey(req: FastifyRequest): string | null {
|
|
@@ -40,14 +48,16 @@ export interface AuthResult {
|
|
|
40
48
|
ok: boolean;
|
|
41
49
|
status?: number;
|
|
42
50
|
error?: string;
|
|
51
|
+
keyFp?: string;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export function checkAuth(req: FastifyRequest, validKeys: string[]): AuthResult {
|
|
46
55
|
if (validKeys.length === 0) return { ok: true }; // auth disabled
|
|
47
56
|
const key = extractKey(req);
|
|
48
57
|
if (!key) return { ok: false, status: 401, error: "missing API key" };
|
|
49
|
-
|
|
50
|
-
return { ok:
|
|
58
|
+
const matched = matchKey(key, validKeys);
|
|
59
|
+
if (!matched) return { ok: false, status: 401, error: "invalid API key" };
|
|
60
|
+
return { ok: true, keyFp: clientKeyFingerprint(matched) };
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
/** Verifies a presented admin password (against the persisted hash store). */
|
package/src/routes/admin.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { resolve } from "node:path";
|
|
|
15
15
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
|
16
16
|
import type { GatewayState } from "../core/state.js";
|
|
17
17
|
import type { UsageDB } from "../db.js";
|
|
18
|
-
import { checkAdminAuth, type AdminVerifier } from "../middleware/auth.js";
|
|
18
|
+
import { checkAdminAuth, clientKeyFingerprint, type AdminVerifier } from "../middleware/auth.js";
|
|
19
19
|
import {
|
|
20
20
|
maskKey,
|
|
21
21
|
serializeConfig,
|
|
@@ -147,19 +147,21 @@ export function registerAdminRoutes(app: FastifyInstance, deps: AdminDeps): void
|
|
|
147
147
|
app.get("/admin/quota", requireAdmin, (_req, reply) => {
|
|
148
148
|
reply.send({
|
|
149
149
|
quota: deps.state.quota.snapshot(deps.state.config.listProviders()),
|
|
150
|
-
|
|
150
|
+
budgets: deps.state.budget.statuses(),
|
|
151
151
|
});
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
//
|
|
155
|
-
|
|
154
|
+
// add or replace a budget (keyed by scope). Body = Budget; invalid shape or an
|
|
155
|
+
// unknown provider scope -> 400 via zod / setBudget through state.reload().
|
|
156
|
+
app.put("/admin/budgets", requireAdmin, (req, reply) => {
|
|
156
157
|
const b = (req.body ?? {}) as Budget;
|
|
157
158
|
applyMutation(reply, (c) => setBudget(c, b));
|
|
158
159
|
});
|
|
159
160
|
|
|
160
|
-
// remove
|
|
161
|
-
app.delete("/admin/
|
|
162
|
-
|
|
161
|
+
// remove a budget by scope key: global | provider:<id> | model:<id>.
|
|
162
|
+
app.delete("/admin/budgets/:key", requireAdmin, (req, reply) => {
|
|
163
|
+
const key = decodeURIComponent((req.params as { key: string }).key);
|
|
164
|
+
applyMutation(reply, (c) => clearBudget(c, key));
|
|
163
165
|
});
|
|
164
166
|
|
|
165
167
|
// current config, secrets masked
|
|
@@ -443,11 +445,17 @@ export function registerAdminRoutes(app: FastifyInstance, deps: AdminDeps): void
|
|
|
443
445
|
|
|
444
446
|
// every callable model: provider/model catalog entries + routing aliases.
|
|
445
447
|
app.get("/admin/models", requireAdmin, (_req, reply) => {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
448
|
+
// disabled providers are skipped in routing, so their models must not be
|
|
449
|
+
// selectable anywhere this catalog feeds (combos, CLI-tool setup, budget
|
|
450
|
+
// scopes) — drop them here at the single source.
|
|
451
|
+
const providers = deps.state.config
|
|
452
|
+
.listProviders()
|
|
453
|
+
.filter((p) => !p.disabled)
|
|
454
|
+
.map((p) => ({
|
|
455
|
+
id: p.id,
|
|
456
|
+
format: p.format,
|
|
457
|
+
models: p.models.map((m) => ({ id: m.id, ref: `${p.id}/${m.id}`, price_in: m.price_in, price_out: m.price_out })),
|
|
458
|
+
}));
|
|
451
459
|
const routes = deps.state.config.listRoutes();
|
|
452
460
|
reply.send({ providers, routes });
|
|
453
461
|
});
|
|
@@ -563,6 +571,19 @@ export function registerAdminRoutes(app: FastifyInstance, deps: AdminDeps): void
|
|
|
563
571
|
applyMutation(reply, (c) => removeServerKey(c, i));
|
|
564
572
|
});
|
|
565
573
|
|
|
574
|
+
// server keys with a non-secret fingerprint + display name, for the budget
|
|
575
|
+
// key-scope picker. Never returns the raw key.
|
|
576
|
+
app.get("/admin/keys", requireAdmin, (_req, reply) => {
|
|
577
|
+
const s = deps.state.config.raw.server;
|
|
578
|
+
reply.send(
|
|
579
|
+
s.api_keys.map((k) => ({
|
|
580
|
+
fingerprint: clientKeyFingerprint(k),
|
|
581
|
+
name: s.key_names?.[k] ?? maskKey(k),
|
|
582
|
+
masked: maskKey(k),
|
|
583
|
+
})),
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
|
|
566
587
|
// reveal ONE raw gateway key (the "show key" button on the Endpoint page).
|
|
567
588
|
app.get("/admin/endpoint/keys/:index/reveal", requireAdmin, (req, reply) => {
|
|
568
589
|
const { index } = req.params as { index: string };
|
package/src/routes/v1.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
-
import { checkAuth } from "../middleware/auth.js";
|
|
2
|
+
import { checkAuth, extractKey, clientKeyFingerprint } from "../middleware/auth.js";
|
|
3
3
|
import type { GatewayState } from "../core/state.js";
|
|
4
4
|
import { handle, GatewayError, type HandleDeps } from "../core/handler.js";
|
|
5
5
|
import type { WireFormat } from "../core/canonical.js";
|
|
@@ -23,17 +23,21 @@ export function registerV1Routes(app: FastifyInstance, state: GatewayState, db?:
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
// build deps from the live holder per request (never close over config/pool).
|
|
26
|
-
const depsNow = (): HandleDeps =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
const depsNow = (req: FastifyRequest): HandleDeps => {
|
|
27
|
+
const presented = extractKey(req);
|
|
28
|
+
return {
|
|
29
|
+
config: state.config,
|
|
30
|
+
pool: state.pool,
|
|
31
|
+
quota: state.quota,
|
|
32
|
+
budget: state.budget,
|
|
33
|
+
db,
|
|
34
|
+
clientKeyFp: presented ? clientKeyFingerprint(presented) : undefined,
|
|
35
|
+
log: (msg) => app.log.info(msg),
|
|
36
|
+
};
|
|
37
|
+
};
|
|
34
38
|
|
|
35
|
-
app.post("/v1/chat/completions", requireAuth, (req, reply) => dispatch(depsNow(), "openai", req, reply));
|
|
36
|
-
app.post("/v1/messages", requireAuth, (req, reply) => dispatch(depsNow(), "anthropic", req, reply));
|
|
39
|
+
app.post("/v1/chat/completions", requireAuth, (req, reply) => dispatch(depsNow(req), "openai", req, reply));
|
|
40
|
+
app.post("/v1/messages", requireAuth, (req, reply) => dispatch(depsNow(req), "anthropic", req, reply));
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
const SSE_HEADERS = {
|
package/src/server.ts
CHANGED
|
@@ -78,6 +78,10 @@ async function main(): Promise<void> {
|
|
|
78
78
|
prefix: "/",
|
|
79
79
|
// forward the whole HTTP surface the dashboard needs (pages + its API).
|
|
80
80
|
httpMethods: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
|
81
|
+
// forward WebSocket upgrades too, so `next dev`'s HMR socket works when the
|
|
82
|
+
// dashboard is proxied — this is what lets dev run single-URL on the gateway
|
|
83
|
+
// port like production. Harmless for the prebuilt prod dashboard (no socket).
|
|
84
|
+
websocket: true,
|
|
81
85
|
// keep the ORIGINAL Host so Next builds redirects (e.g. → /login) against
|
|
82
86
|
// the gateway's address, not the internal dashboard port.
|
|
83
87
|
replyOptions: {
|
package/src/upstream/client.ts
CHANGED
|
@@ -68,6 +68,15 @@ function buildBody(
|
|
|
68
68
|
const adapter = adapterFor(provider.format);
|
|
69
69
|
const upstreamReq: CanonicalRequest = { ...req, model, stream };
|
|
70
70
|
const out = adapter.requestFromCanonical(upstreamReq) as Record<string, unknown>;
|
|
71
|
+
// OpenAI-compatible streams omit usage entirely unless you opt in — without this
|
|
72
|
+
// every streamed call through an openai-format provider logs 0 tokens in/out
|
|
73
|
+
// (anthropic/gemini report usage inline, so they're unaffected). Ask for the
|
|
74
|
+
// final usage chunk; the handler taps it for accounting. Preserve a usage opt-in
|
|
75
|
+
// the client already set.
|
|
76
|
+
if (stream && provider.format === "openai") {
|
|
77
|
+
const existing = (out.stream_options ?? {}) as Record<string, unknown>;
|
|
78
|
+
out.stream_options = { ...existing, include_usage: true };
|
|
79
|
+
}
|
|
71
80
|
// Normalize thinking into THIS provider's native format, keyed by the upstream
|
|
72
81
|
// model's capabilities. No-op for non-reasoning models. Runs per-attempt so each
|
|
73
82
|
// provider in a fallback chain gets the right shape.
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { adminApi } from "@/lib/client";
|
|
5
|
-
import { Button, Input, Select, Field } from "@/components/Button";
|
|
6
|
-
import type { BudgetStatus } from "@/lib/gateway";
|
|
7
|
-
|
|
8
|
-
const WINDOWS = ["5h", "daily", "weekly", "monthly"] as const;
|
|
9
|
-
|
|
10
|
-
export function BudgetEditor({
|
|
11
|
-
initial,
|
|
12
|
-
onSaved,
|
|
13
|
-
onCancel,
|
|
14
|
-
}: {
|
|
15
|
-
initial: BudgetStatus | null;
|
|
16
|
-
onSaved: () => void;
|
|
17
|
-
onCancel: () => void;
|
|
18
|
-
}) {
|
|
19
|
-
const [unit, setUnit] = useState<"usd" | "tokens">(initial?.unit ?? "usd");
|
|
20
|
-
const [limit, setLimit] = useState(String(initial?.limit ?? ""));
|
|
21
|
-
const [window, setWindow] = useState<(typeof WINDOWS)[number]>(initial?.window ?? "monthly");
|
|
22
|
-
const [alertAt, setAlertAt] = useState("80");
|
|
23
|
-
const [error, setError] = useState("");
|
|
24
|
-
const [saving, setSaving] = useState(false);
|
|
25
|
-
|
|
26
|
-
async function save() {
|
|
27
|
-
const limitNum = Number(limit);
|
|
28
|
-
if (!Number.isFinite(limitNum) || limitNum <= 0) {
|
|
29
|
-
setError("limit must be a positive number");
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
const alertPct = Number(alertAt);
|
|
33
|
-
if (!Number.isFinite(alertPct) || alertPct <= 0 || alertPct > 100) {
|
|
34
|
-
setError("alert % must be 1–100");
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
setSaving(true);
|
|
38
|
-
try {
|
|
39
|
-
const r = await adminApi.setBudget({ unit, limit: limitNum, window, alert_at: alertPct / 100 });
|
|
40
|
-
if (!r.ok) {
|
|
41
|
-
setError(r.error ?? "could not save budget");
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
onSaved();
|
|
45
|
-
} finally {
|
|
46
|
-
setSaving(false);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<div className="space-y-3">
|
|
52
|
-
<div className="flex gap-2">
|
|
53
|
-
<button
|
|
54
|
-
type="button"
|
|
55
|
-
onClick={() => setUnit("usd")}
|
|
56
|
-
className={`rounded-brand px-2.5 py-1 text-[13px] font-medium transition-colors ${unit === "usd" ? "bg-accent/12 text-accent" : "text-text-muted hover:text-text"}`}
|
|
57
|
-
>
|
|
58
|
-
USD
|
|
59
|
-
</button>
|
|
60
|
-
<button
|
|
61
|
-
type="button"
|
|
62
|
-
onClick={() => setUnit("tokens")}
|
|
63
|
-
className={`rounded-brand px-2.5 py-1 text-[13px] font-medium transition-colors ${unit === "tokens" ? "bg-accent/12 text-accent" : "text-text-muted hover:text-text"}`}
|
|
64
|
-
>
|
|
65
|
-
Tokens
|
|
66
|
-
</button>
|
|
67
|
-
</div>
|
|
68
|
-
|
|
69
|
-
<Field label="Limit" hint={unit === "usd" ? "$" : "tokens"}>
|
|
70
|
-
<Input value={limit} onChange={(e) => setLimit(e.target.value)} inputMode="decimal" placeholder={unit === "usd" ? "50.00" : "1000000"} />
|
|
71
|
-
</Field>
|
|
72
|
-
|
|
73
|
-
<Field label="Window">
|
|
74
|
-
<Select value={window} onChange={(e) => setWindow(e.target.value as (typeof WINDOWS)[number])}>
|
|
75
|
-
{WINDOWS.map((w) => (
|
|
76
|
-
<option key={w} value={w}>{w}</option>
|
|
77
|
-
))}
|
|
78
|
-
</Select>
|
|
79
|
-
</Field>
|
|
80
|
-
|
|
81
|
-
<Field label="Alert at" hint="%">
|
|
82
|
-
<Input value={alertAt} onChange={(e) => setAlertAt(e.target.value)} inputMode="numeric" placeholder="80" />
|
|
83
|
-
</Field>
|
|
84
|
-
|
|
85
|
-
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
|
|
86
|
-
|
|
87
|
-
<div className="flex items-center gap-2 pt-1">
|
|
88
|
-
<Button disabled={saving} onClick={save}>
|
|
89
|
-
{saving ? "Saving…" : "Save"}
|
|
90
|
-
</Button>
|
|
91
|
-
<Button variant="ghost" onClick={onCancel}>
|
|
92
|
-
Cancel
|
|
93
|
-
</Button>
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
);
|
|
97
|
-
}
|