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.
Files changed (56) hide show
  1. package/CHANGELOG.md +46 -3
  2. package/README.md +4 -4
  3. package/config.example.yaml +6 -5
  4. package/dashboard/next.config.ts +6 -0
  5. package/dashboard/src/app/globals.css +47 -0
  6. package/dashboard/src/components/BudgetForm.tsx +258 -0
  7. package/dashboard/src/components/EndpointView.tsx +30 -0
  8. package/dashboard/src/components/LogTable.tsx +90 -25
  9. package/dashboard/src/components/ModelPicker.tsx +15 -7
  10. package/dashboard/src/components/ProviderDetail.tsx +27 -29
  11. package/dashboard/src/components/ProviderManager.tsx +36 -3
  12. package/dashboard/src/components/QuotaView.tsx +95 -81
  13. package/dashboard/src/components/Rail.tsx +1 -1
  14. package/dashboard/src/components/RoutingView.tsx +2 -2
  15. package/dashboard/src/components/ToolDetail.tsx +5 -3
  16. package/dashboard/src/components/TopBar.tsx +1 -1
  17. package/dashboard/src/components/UsageView.tsx +25 -6
  18. package/dashboard/src/lib/cliTools.ts +0 -43
  19. package/dashboard/src/lib/client.ts +9 -3
  20. package/dashboard/src/lib/gateway.ts +12 -1
  21. package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
  22. package/dist/cli.js +43 -8
  23. package/dist/cli.js.map +1 -1
  24. package/dist/config.js +56 -10
  25. package/dist/config.js.map +1 -1
  26. package/dist/core/budget.js +61 -16
  27. package/dist/core/budget.js.map +1 -1
  28. package/dist/core/handler.js +20 -6
  29. package/dist/core/handler.js.map +1 -1
  30. package/dist/core/state.js +10 -2
  31. package/dist/core/state.js.map +1 -1
  32. package/dist/db.js +39 -5
  33. package/dist/db.js.map +1 -1
  34. package/dist/middleware/auth.js +15 -8
  35. package/dist/middleware/auth.js.map +1 -1
  36. package/dist/routes/admin.js +26 -8
  37. package/dist/routes/admin.js.map +1 -1
  38. package/dist/routes/v1.js +15 -11
  39. package/dist/routes/v1.js.map +1 -1
  40. package/dist/server.js +4 -0
  41. package/dist/server.js.map +1 -1
  42. package/dist/upstream/client.js +9 -0
  43. package/dist/upstream/client.js.map +1 -1
  44. package/package.json +3 -4
  45. package/src/cli.ts +44 -8
  46. package/src/config.ts +57 -10
  47. package/src/core/budget.ts +77 -24
  48. package/src/core/handler.ts +24 -7
  49. package/src/core/state.ts +17 -2
  50. package/src/db.ts +50 -5
  51. package/src/middleware/auth.ts +18 -8
  52. package/src/routes/admin.ts +33 -12
  53. package/src/routes/v1.ts +15 -11
  54. package/src/server.ts +4 -0
  55. package/src/upstream/client.ts +9 -0
  56. package/dashboard/src/components/BudgetEditor.tsx +0 -97
@@ -15,15 +15,23 @@ function digest(s: string): Buffer {
15
15
  return createHash("sha256").update(s).digest();
16
16
  }
17
17
 
18
- /** Constant-time membership test over fixed-length digests. */
19
- export function isValidKey(presented: string, validKeys: string[]): boolean {
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
- // compare against every key so timing can't reveal which one matched.
22
- let ok = false;
26
+ let found: string | null = null;
23
27
  for (const k of validKeys) {
24
- if (timingSafeEqual(p, digest(k))) ok = true;
28
+ if (timingSafeEqual(p, digest(k))) found = k;
25
29
  }
26
- return ok;
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
- if (!isValidKey(key, validKeys)) return { ok: false, status: 401, error: "invalid API key" };
50
- return { ok: true };
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). */
@@ -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
- budget: deps.state.budget.status(),
150
+ budgets: deps.state.budget.statuses(),
151
151
  });
152
152
  });
153
153
 
154
- // set/replace the gateway-wide budget. Body = Budget; invalid shape -> 400.
155
- app.put("/admin/budget", requireAdmin, (req, reply) => {
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 the gateway-wide budget (feature off).
161
- app.delete("/admin/budget", requireAdmin, (_req, reply) => {
162
- applyMutation(reply, clearBudget);
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
- const providers = deps.state.config.listProviders().map((p) => ({
447
- id: p.id,
448
- format: p.format,
449
- models: p.models.map((m) => ({ id: m.id, ref: `${p.id}/${m.id}`, price_in: m.price_in, price_out: m.price_out })),
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
- config: state.config,
28
- pool: state.pool,
29
- quota: state.quota,
30
- budget: state.budget,
31
- db,
32
- log: (msg) => app.log.info(msg),
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: {
@@ -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
- }