aigetwey 1.1.0 → 1.3.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 (76) hide show
  1. package/CHANGELOG.md +65 -4
  2. package/README.md +32 -11
  3. package/config.example.yaml +6 -6
  4. package/dashboard/next.config.ts +6 -0
  5. package/dashboard/src/app/(console)/quota/page.tsx +2 -2
  6. package/dashboard/src/app/globals.css +47 -0
  7. package/dashboard/src/components/BudgetForm.tsx +256 -0
  8. package/dashboard/src/components/BudgetTracker.tsx +181 -0
  9. package/dashboard/src/components/CooldownTimer.tsx +1 -1
  10. package/dashboard/src/components/EndpointView.tsx +285 -47
  11. package/dashboard/src/components/LogTable.tsx +97 -25
  12. package/dashboard/src/components/ModelPicker.tsx +15 -7
  13. package/dashboard/src/components/ProviderDetail.tsx +27 -29
  14. package/dashboard/src/components/ProviderManager.tsx +39 -31
  15. package/dashboard/src/components/Rail.tsx +1 -1
  16. package/dashboard/src/components/RoutingView.tsx +8 -4
  17. package/dashboard/src/components/ToolDetail.tsx +5 -3
  18. package/dashboard/src/components/TopBar.tsx +1 -1
  19. package/dashboard/src/components/UsageView.tsx +25 -6
  20. package/dashboard/src/components/ui.tsx +6 -1
  21. package/dashboard/src/lib/cliTools.ts +0 -43
  22. package/dashboard/src/lib/client.ts +14 -7
  23. package/dashboard/src/lib/gateway.ts +33 -15
  24. package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
  25. package/dist/cli.js +43 -8
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config.js +136 -27
  28. package/dist/config.js.map +1 -1
  29. package/dist/core/budget.js +62 -17
  30. package/dist/core/budget.js.map +1 -1
  31. package/dist/core/fallback.js +0 -6
  32. package/dist/core/fallback.js.map +1 -1
  33. package/dist/core/handler.js +24 -9
  34. package/dist/core/handler.js.map +1 -1
  35. package/dist/core/keysUsage.js +15 -0
  36. package/dist/core/keysUsage.js.map +1 -0
  37. package/dist/core/ratelimit.js +15 -0
  38. package/dist/core/ratelimit.js.map +1 -0
  39. package/dist/core/state.js +15 -15
  40. package/dist/core/state.js.map +1 -1
  41. package/dist/core/window.js +35 -0
  42. package/dist/core/window.js.map +1 -0
  43. package/dist/db.js +39 -25
  44. package/dist/db.js.map +1 -1
  45. package/dist/middleware/auth.js +15 -8
  46. package/dist/middleware/auth.js.map +1 -1
  47. package/dist/routes/admin.js +80 -17
  48. package/dist/routes/admin.js.map +1 -1
  49. package/dist/routes/v1.js +28 -11
  50. package/dist/routes/v1.js.map +1 -1
  51. package/dist/server.js +5 -7
  52. package/dist/server.js.map +1 -1
  53. package/dist/stream/openai-stream.js +3 -0
  54. package/dist/stream/openai-stream.js.map +1 -1
  55. package/dist/upstream/client.js +9 -0
  56. package/dist/upstream/client.js.map +1 -1
  57. package/package.json +3 -4
  58. package/src/cli.ts +44 -8
  59. package/src/config.ts +142 -29
  60. package/src/core/budget.ts +78 -25
  61. package/src/core/fallback.ts +0 -9
  62. package/src/core/handler.ts +31 -12
  63. package/src/core/keysUsage.ts +49 -0
  64. package/src/core/ratelimit.ts +25 -0
  65. package/src/core/state.ts +21 -16
  66. package/src/core/window.ts +45 -0
  67. package/src/db.ts +50 -28
  68. package/src/middleware/auth.ts +18 -8
  69. package/src/routes/admin.ts +93 -20
  70. package/src/routes/v1.ts +32 -11
  71. package/src/server.ts +5 -8
  72. package/src/stream/openai-stream.ts +3 -1
  73. package/src/upstream/client.ts +9 -0
  74. package/dashboard/src/components/BudgetEditor.tsx +0 -97
  75. package/dashboard/src/components/QuotaView.tsx +0 -152
  76. package/src/core/quota.ts +0 -253
package/dist/server.js CHANGED
@@ -4,7 +4,6 @@ import { loadConfig } from "./config.js";
4
4
  import { registerRoutes } from "./routes/index.js";
5
5
  import { GatewayState } from "./core/state.js";
6
6
  import { UsageDB } from "./db.js";
7
- import { QuotaTracker } from "./core/quota.js";
8
7
  import { AuthStore } from "./core/authStore.js";
9
8
  import { consoleBuffer } from "./core/console-buffer.js";
10
9
  async function main() {
@@ -48,13 +47,8 @@ async function main() {
48
47
  // unified data dir (default ./data); usage tracking lives here.
49
48
  const dataDir = resolve(process.env.AIGETWEY_DATA_DIR ?? "data");
50
49
  const db = new UsageDB(join(dataDir, "usage.sqlite"));
51
- // quota counts persist via the DB so a restart within a window keeps the budget.
52
- const quota = new QuotaTracker(Date.now, {
53
- load: () => db.loadQuota(),
54
- save: (id, start, consumed) => db.saveQuota(id, start, consumed),
55
- });
56
50
  // holder enables runtime config edits (hot-reload) from the dashboard.
57
- const state = new GatewayState(configPath, config, quota, db);
51
+ const state = new GatewayState(configPath, config, db);
58
52
  // admin password lives in a hash store (seeded from the env on first run,
59
53
  // changeable at runtime from the dashboard).
60
54
  const auth = AuthStore.open(dataDir, process.env.AIGETWEY_ADMIN_PASSWORD);
@@ -71,6 +65,10 @@ async function main() {
71
65
  prefix: "/",
72
66
  // forward the whole HTTP surface the dashboard needs (pages + its API).
73
67
  httpMethods: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
68
+ // forward WebSocket upgrades too, so `next dev`'s HMR socket works when the
69
+ // dashboard is proxied — this is what lets dev run single-URL on the gateway
70
+ // port like production. Harmless for the prebuilt prod dashboard (no socket).
71
+ websocket: true,
74
72
  // keep the ORIGINAL Host so Next builds redirects (e.g. → /login) against
75
73
  // the gateway's address, not the internal dashboard port.
76
74
  replyOptions: {
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -16,7 +16,10 @@ export async function* streamToCanonical(events) {
16
16
  /** Lift vendor reasoning fields into the canonical `delta.reasoning`. */
17
17
  function normalize(chunk) {
18
18
  for (const choice of chunk.choices ?? []) {
19
+ // a finish_reason chunk carries no `delta`; skip it (and any delta-less choice).
19
20
  const d = choice.delta;
21
+ if (!d)
22
+ continue;
20
23
  if (d.reasoning === undefined) {
21
24
  const vendor = d["reasoning_content"] ?? d["reasoning"];
22
25
  if (vendor)
@@ -1 +1 @@
1
- {"version":3,"file":"openai-stream.js","sourceRoot":"","sources":["../../src/stream/openai-stream.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,KAAK,SAAS,CAAC,CAAC,iBAAiB,CAAC,MAA+B;IACtE,IAAI,KAAK,EAAE,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QAC9B,MAAM,IAAI,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,CAAC,IAAI,IAAI,IAAI,KAAK,QAAQ;YAAE,SAAS;QACzC,IAAI,MAAe,CAAC;QACpB,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC5B,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,MAAM,SAAS,CAAC,MAAwB,CAAC,CAAC;IAC5C,CAAC;AACH,CAAC;AAED,yEAAyE;AACzE,SAAS,SAAS,CAAC,KAAqB;IACtC,KAAK,MAAM,MAAM,IAAI,KAAK,CAAC,OAAO,IAAI,EAAE,EAAE,CAAC;QACzC,MAAM,CAAC,GAAG,MAAM,CAAC,KAAyD,CAAC;QAC3E,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;YAC9B,MAAM,MAAM,GAAI,CAAC,CAAC,mBAAmB,CAAwB,IAAK,CAAC,CAAC,WAAW,CAAwB,CAAC;YACxG,IAAI,MAAM;gBAAE,CAAC,CAAC,SAAS,GAAG,MAAM,CAAC;QACnC,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,CAAC,KAAK,SAAS,CAAC,CAAC,mBAAmB,CAAC,MAAqC;IAC9E,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QACjC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;IACxC,CAAC;IACD,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;AAC3B,CAAC"}
1
+ {"version":3,"file":"openai-stream.js","sourceRoot":"","sources":["../../src/stream/openai-stream.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,KAAK,SAAS,CAAC,CAAC,iBAAiB,CAAC,MAA+B;IACtE,IAAI,KAAK,EAAE,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QAC9B,MAAM,IAAI,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,CAAC,IAAI,IAAI,IAAI,KAAK,QAAQ;YAAE,SAAS;QACzC,IAAI,MAAe,CAAC;QACpB,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC5B,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,MAAM,SAAS,CAAC,MAAwB,CAAC,CAAC;IAC5C,CAAC;AACH,CAAC;AAED,yEAAyE;AACzE,SAAS,SAAS,CAAC,KAAqB;IACtC,KAAK,MAAM,MAAM,IAAI,KAAK,CAAC,OAAO,IAAI,EAAE,EAAE,CAAC;QACzC,iFAAiF;QACjF,MAAM,CAAC,GAAG,MAAM,CAAC,KAAuE,CAAC;QACzF,IAAI,CAAC,CAAC;YAAE,SAAS;QACjB,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;YAC9B,MAAM,MAAM,GAAI,CAAC,CAAC,mBAAmB,CAAwB,IAAK,CAAC,CAAC,WAAW,CAAwB,CAAC;YACxG,IAAI,MAAM;gBAAE,CAAC,CAAC,SAAS,GAAG,MAAM,CAAC;QACnC,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,CAAC,KAAK,SAAS,CAAC,CAAC,mBAAmB,CAAC,MAAqC;IAC9E,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QACjC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;IACxC,CAAC;IACD,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;AAC3B,CAAC"}
@@ -57,6 +57,15 @@ function buildBody(provider, req, model, stream, thinkingIntent) {
57
57
  const adapter = adapterFor(provider.format);
58
58
  const upstreamReq = { ...req, model, stream };
59
59
  const out = adapter.requestFromCanonical(upstreamReq);
60
+ // OpenAI-compatible streams omit usage entirely unless you opt in — without this
61
+ // every streamed call through an openai-format provider logs 0 tokens in/out
62
+ // (anthropic/gemini report usage inline, so they're unaffected). Ask for the
63
+ // final usage chunk; the handler taps it for accounting. Preserve a usage opt-in
64
+ // the client already set.
65
+ if (stream && provider.format === "openai") {
66
+ const existing = (out.stream_options ?? {});
67
+ out.stream_options = { ...existing, include_usage: true };
68
+ }
60
69
  // Normalize thinking into THIS provider's native format, keyed by the upstream
61
70
  // model's capabilities. No-op for non-reasoning models. Runs per-attempt so each
62
71
  // provider in a fallback chain gets the right shape.
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/upstream/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/upstream/client.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "aigetwey",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
- "description": "Personal AI gateway — route, translate and track requests across Anthropic / OpenAI / Gemini-compatible providers, with a built-in dashboard.",
5
+ "description": "Personal AI gateway — route, translate and track requests across Anthropic and OpenAI-compatible providers, with a built-in dashboard.",
6
6
  "keywords": [
7
7
  "ai",
8
8
  "gateway",
@@ -10,7 +10,6 @@
10
10
  "proxy",
11
11
  "anthropic",
12
12
  "openai",
13
- "gemini",
14
13
  "router",
15
14
  "claude",
16
15
  "dashboard"
@@ -29,7 +28,7 @@
29
28
  "node": ">=22"
30
29
  },
31
30
  "bin": {
32
- "aigetwey": "./dist/cli.js"
31
+ "aigetwey": "dist/cli.js"
33
32
  },
34
33
  "files": [
35
34
  "dist",
package/src/cli.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { spawn, execSync, type ChildProcess } from "node:child_process";
16
16
  import { randomBytes } from "node:crypto";
17
- import { existsSync, copyFileSync } from "node:fs";
17
+ import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
18
18
  import { resolve, dirname, join } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { createInterface } from "node:readline";
@@ -65,24 +65,57 @@ const HELP = `
65
65
  const GATEWAY_PORT = opts.port ?? Number(process.env.AIGETWEY_PORT ?? 18080);
66
66
  const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT ?? 3000);
67
67
 
68
- // reuse env secrets if present, otherwise generate (admin) / random (session).
68
+ // reuse env secrets if present, otherwise generate (admin) / persist (session).
69
69
  const adminPassword = process.env.AIGETWEY_ADMIN_PASSWORD ?? randomBytes(6).toString("hex");
70
- const sessionSecret = process.env.SESSION_SECRET ?? randomBytes(24).toString("hex");
71
70
  const generatedPw = !process.env.AIGETWEY_ADMIN_PASSWORD;
72
71
 
72
+ /**
73
+ * The dashboard session cookie is signed+encrypted with SESSION_SECRET. A fresh
74
+ * random secret each boot would invalidate every cookie on restart — the symptom
75
+ * being "re-enter the password after a relaunch" — so persist a generated one to
76
+ * the data dir (alongside auth.json) and reuse it. An explicit env var wins.
77
+ */
78
+ function loadOrCreateSessionSecret(): string {
79
+ if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
80
+ const dataDir = resolve(process.env.AIGETWEY_DATA_DIR ?? join(root, "data"));
81
+ const file = join(dataDir, "session-secret");
82
+ try {
83
+ const existing = readFileSync(file, "utf8").trim();
84
+ if (existing) return existing;
85
+ } catch {
86
+ // not created yet — fall through and generate.
87
+ }
88
+ const secret = randomBytes(24).toString("hex");
89
+ try {
90
+ mkdirSync(dataDir, { recursive: true });
91
+ writeFileSync(file, secret, { mode: 0o600 });
92
+ } catch {
93
+ // unwritable data dir — fall back to an ephemeral secret (cookies won't
94
+ // survive this boot, but the gateway still runs).
95
+ }
96
+ return secret;
97
+ }
98
+ const sessionSecret = loadOrCreateSessionSecret();
99
+
73
100
  function openBrowser(url: string): void {
74
101
  const cmd =
75
102
  process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
76
103
  spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
77
104
  }
78
105
 
79
- async function waitForGateway(url: string, timeoutMs = 20000): Promise<boolean> {
106
+ async function waitForGateway(
107
+ url: string,
108
+ timeoutMs = 20000,
109
+ ready: (status: number) => boolean = (s) => s > 0,
110
+ ): Promise<boolean> {
80
111
  const deadline = Date.now() + timeoutMs;
81
112
  while (Date.now() < deadline) {
82
113
  try {
83
114
  const res = await fetch(url, { method: "GET" });
84
- // any HTTP answer (even 401/503) means the port is up
85
- if (res.status > 0) return true;
115
+ // default: any HTTP answer (even 401/503) means the port is up. A caller
116
+ // can demand more — e.g. a non-5xx, to wait past a proxy's boot-time 502/500
117
+ // while the upstream it fronts is still coming up.
118
+ if (ready(res.status)) return true;
86
119
  } catch {
87
120
  // not up yet
88
121
  }
@@ -349,9 +382,12 @@ async function main(): Promise<void> {
349
382
  });
350
383
 
351
384
  // one URL for everything — the gateway reverse-proxies the dashboard. Wait for
352
- // the dashboard to answer THROUGH the proxy before opening the browser.
385
+ // the dashboard to be READY before opening the browser. Probe it directly on
386
+ // its own port (not through the proxy) and require a non-5xx answer: a proxy
387
+ // hit during boot returns 500 (ECONNREFUSED upstream), which a bare "port up"
388
+ // check would mistake for ready and open the browser into a wall of 500s.
353
389
  const appUrl = `http://127.0.0.1:${GATEWAY_PORT}`;
354
- await waitForGateway(`${appUrl}/login`, 30000);
390
+ await waitForGateway(`http://127.0.0.1:${DASHBOARD_PORT}/login`, 30000, (s) => s > 0 && s < 500);
355
391
  console.log(`\n aigetwey ${appUrl} (dashboard + API, one URL)`);
356
392
  if (generatedPw) {
357
393
  console.log(`\n admin password (generated): ${adminPassword}`);
package/src/config.ts CHANGED
@@ -9,27 +9,18 @@ import {
9
9
  import { dirname } from "node:path";
10
10
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
11
11
  import { z } from "zod";
12
+ import { clientKeyFingerprint } from "./middleware/auth.js";
13
+
14
+ export { clientKeyFingerprint } from "./middleware/auth.js";
12
15
 
13
16
  // ---- schema (PLAN §8) -------------------------------------------------------
14
17
  //
15
18
  // Shape differs from a flat OpenAI gateway: routing lives in a top-level
16
19
  // `models[]` layer (alias -> provider chain), the endpoint block carries the
17
20
  // token-saver toggles, and providers may be free passthroughs or service-account
18
- // backed. The handler/keypool/quota phases read these fields; defining the full
21
+ // backed. The handler/keypool phases read these fields; defining the full
19
22
  // shape up front avoids reshaping config across later phases.
20
23
 
21
- /** Token quota window for a provider — drives the dashboard reset countdown. */
22
- const QuotaSchema = z.object({
23
- window: z.enum(["5h", "daily", "weekly", "monthly"]),
24
- // daily: "HH:MM" local reset; weekly: weekday name ("monday"); others: ignored.
25
- reset_at: z.string().optional(),
26
- timezone: z.string().default("UTC"),
27
- // optional ceiling for a progress bar; quota tracking works without it.
28
- limit_tokens: z.number().int().positive().optional(),
29
- // soft-alert threshold (0..1); UI flags the quota when pct >= this. Default 0.8.
30
- alert_at: z.number().gt(0).lte(1).optional(),
31
- });
32
-
33
24
  const ProviderModelSchema = z.object({
34
25
  id: z.string().min(1),
35
26
  price_in: z.number().nonnegative().optional(),
@@ -56,7 +47,6 @@ const ProviderSchema = z
56
47
  service_account: z.string().optional(),
57
48
  models: z.array(ProviderModelSchema).default([]),
58
49
  headers: z.record(z.string()).optional(),
59
- quota: QuotaSchema.optional(),
60
50
  // when true the provider is skipped in routing (kept in config, like a key's
61
51
  // disabled state but for the whole provider).
62
52
  disabled: z.boolean().optional(),
@@ -117,21 +107,46 @@ const ServerSchema = z
117
107
  // optional friendly label per key, keyed by the key itself. Kept separate so
118
108
  // api_keys stays a plain string[] (auth/masking paths untouched).
119
109
  key_names: z.record(z.string()).optional(),
110
+ // per-key model allowlist (call-strings) + rate limit (req/min), keyed by the
111
+ // raw key like key_names. Absent → unrestricted / unlimited.
112
+ key_models: z.record(z.array(z.string().min(1))).optional(),
113
+ key_rpm: z.record(z.number().int().positive()).optional(),
114
+ // per-key access expiry, epoch ms, keyed by the RAW key. Absent → never expires.
115
+ key_expires: z.record(z.number().int().positive()).optional(),
120
116
  })
121
117
  .default({ host: "127.0.0.1", port: 18080, api_keys: [] });
122
118
 
123
119
  /**
124
- * Gateway-wide spend budget. unit picks what `limit` means USD cost or total
125
- * tokens across every provider. Soft-alert at alert_at (default 0.8), hard-stop
126
- * at 100%. Window math reuses the quota calendar engine. Opt-in: omit to disable.
120
+ * A spend budget scoped to the whole gateway, one provider, or one upstream
121
+ * model. unit picks what `limit` means USD cost or total tokens. Soft-alert at
122
+ * alert_at (default 0.8), hard-stop at 100%. Each window is a rolling tumbling
123
+ * bucket on the epoch grid (window.ts). Opt-in: omit / empty list to disable.
127
124
  */
125
+ const BudgetScopeSchema = z.discriminatedUnion("type", [
126
+ z.object({ type: z.literal("global") }),
127
+ z.object({ type: z.literal("provider"), id: z.string().min(1) }),
128
+ z.object({ type: z.literal("model"), id: z.string().min(1) }),
129
+ z.object({ type: z.literal("key"), id: z.string().min(1) }),
130
+ ]);
131
+
132
+ // rolling windows replaced the old calendar windows; coerce any legacy value so
133
+ // existing config.yaml budgets keep loading (daily→24h, weekly→7day, monthly→30day).
134
+ const LEGACY_WINDOW: Record<string, string> = { daily: "24h", weekly: "7day", monthly: "30day" };
135
+ const WindowSchema = z.preprocess(
136
+ (v) => (typeof v === "string" && v in LEGACY_WINDOW ? LEGACY_WINDOW[v] : v),
137
+ z.enum(["5h", "24h", "7day", "30day"]),
138
+ );
139
+
128
140
  const BudgetSchema = z.object({
141
+ scope: BudgetScopeSchema,
129
142
  unit: z.enum(["usd", "tokens"]),
130
143
  limit: z.number().positive(),
131
- window: z.enum(["5h", "daily", "weekly", "monthly"]),
132
- reset_at: z.string().optional(),
133
- timezone: z.string().default("UTC"),
144
+ window: WindowSchema,
145
+ // epoch ms the recurring cycle is anchored to; stamped by setBudget on create.
146
+ anchor: z.number().int().nonnegative().optional(),
134
147
  alert_at: z.number().gt(0).lte(1).optional(),
148
+ // optional free-text label so an operator remembers what a budget is for.
149
+ note: z.string().max(200).optional(),
135
150
  });
136
151
 
137
152
  const ConfigSchema = z.object({
@@ -140,14 +155,14 @@ const ConfigSchema = z.object({
140
155
  providers: z.array(ProviderSchema).default([]),
141
156
  // the routing layer. Each entry is a "combo": an alias + a provider chain.
142
157
  models: z.array(ModelRouteSchema).default([]),
143
- budget: BudgetSchema.optional(),
158
+ budgets: z.array(BudgetSchema).default([]),
144
159
  });
145
160
 
146
- export type Quota = z.infer<typeof QuotaSchema>;
147
161
  export type ProviderModel = z.infer<typeof ProviderModelSchema>;
148
162
  export type Provider = z.infer<typeof ProviderSchema>;
149
163
  export type ModelRoute = z.infer<typeof ModelRouteSchema>;
150
164
  export type EndpointSettings = z.infer<typeof EndpointSchema>;
165
+ export type BudgetScope = z.infer<typeof BudgetScopeSchema>;
151
166
  export type Budget = z.infer<typeof BudgetSchema>;
152
167
  export type Config = z.infer<typeof ConfigSchema>;
153
168
 
@@ -277,6 +292,16 @@ export class GatewayConfig {
277
292
 
278
293
  /** Validate an already-parsed config object. Throws with readable issues. */
279
294
  export function validateConfig(parsed: unknown): GatewayConfig {
295
+ // migrate the legacy single `budget` (pre-scoped) into a global-scoped entry
296
+ // before zod runs — zod would otherwise strip the unknown `budget` key.
297
+ if (parsed && typeof parsed === "object") {
298
+ const raw = parsed as Record<string, unknown>;
299
+ if (raw.budget && !raw.budgets) {
300
+ const legacy = raw.budget as Record<string, unknown>;
301
+ raw.budgets = [{ scope: { type: "global" }, ...legacy }];
302
+ }
303
+ delete raw.budget;
304
+ }
280
305
  const result = ConfigSchema.safeParse(parsed ?? {});
281
306
  if (!result.success) {
282
307
  const issues = result.error.issues
@@ -757,19 +782,49 @@ export function setHeadroom(
757
782
  return next;
758
783
  }
759
784
 
760
- // ---- global budget ---------------------------------------------------------
785
+ // ---- scoped budgets --------------------------------------------------------
761
786
 
762
- /** Set (or replace) the gateway-wide spend budget. */
763
- export function setBudget(config: Config, budget: Budget): Config {
787
+ /** Stable identity key for a budget's scope. */
788
+ export function budgetKey(scope: BudgetScope): string {
789
+ return scope.type === "global" ? "global" : `${scope.type}:${scope.id}`;
790
+ }
791
+
792
+ /** Add a budget, or replace the existing one with the same scope key. */
793
+ export function setBudget(config: Config, budget: Budget, now: number = Date.now()): Config {
794
+ if (budget.scope.type === "provider") {
795
+ const { id } = budget.scope;
796
+ if (!config.providers.some((p) => p.id === id)) {
797
+ throw new Error(`unknown provider "${id}" for budget scope`);
798
+ }
799
+ }
800
+ if (budget.scope.type === "key") {
801
+ const { id } = budget.scope;
802
+ if (!config.server.api_keys.some((k) => clientKeyFingerprint(k) === id)) {
803
+ throw new Error(`unknown API key fingerprint "${id}" for budget scope`);
804
+ }
805
+ }
764
806
  const next = cloneConfig(config);
765
- next.budget = budget;
807
+ const key = budgetKey(budget.scope);
808
+ const idx = next.budgets.findIndex((b) => budgetKey(b.scope) === key);
809
+ if (idx === -1) {
810
+ next.budgets.push({ ...budget, anchor: budget.anchor ?? now });
811
+ } else {
812
+ const prev = next.budgets[idx]!;
813
+ // keep the running cycle on edit (preserve prev anchor as-is, including a
814
+ // legacy undefined = epoch grid, so editing a limit never resets spend);
815
+ // start a fresh cycle only when the window length actually changed.
816
+ const anchor = budget.anchor ?? (prev.window === budget.window ? prev.anchor : now);
817
+ next.budgets[idx] = { ...budget, anchor };
818
+ }
766
819
  return next;
767
820
  }
768
821
 
769
- /** Remove the gateway-wide budget (turns the feature off). */
770
- export function clearBudget(config: Config): Config {
822
+ /** Remove a budget by its scope key (global | provider:<id> | model:<id> | key:<fp>). */
823
+ export function clearBudget(config: Config, key: string): Config {
771
824
  const next = cloneConfig(config);
772
- delete next.budget;
825
+ const idx = next.budgets.findIndex((b) => budgetKey(b.scope) === key);
826
+ if (idx === -1) throw new Error(`no budget with scope "${key}"`);
827
+ next.budgets.splice(idx, 1);
773
828
  return next;
774
829
  }
775
830
 
@@ -807,5 +862,63 @@ export function removeServerKey(config: Config, index: number): Config {
807
862
  if (removed && next.server.key_names && removed in next.server.key_names) {
808
863
  delete next.server.key_names[removed];
809
864
  }
865
+ if (removed && next.server.key_models && removed in next.server.key_models) {
866
+ delete next.server.key_models[removed];
867
+ if (Object.keys(next.server.key_models).length === 0) next.server.key_models = undefined;
868
+ }
869
+ if (removed && next.server.key_rpm && removed in next.server.key_rpm) {
870
+ delete next.server.key_rpm[removed];
871
+ if (Object.keys(next.server.key_rpm).length === 0) next.server.key_rpm = undefined;
872
+ }
873
+ if (removed && next.server.key_expires && removed in next.server.key_expires) {
874
+ delete next.server.key_expires[removed];
875
+ if (Object.keys(next.server.key_expires).length === 0) next.server.key_expires = undefined;
876
+ }
810
877
  return next;
811
878
  }
879
+
880
+ /**
881
+ * Set or clear a gateway key's scopes (by index, since keys are masked in the
882
+ * API). `models`/`rpm` are each applied only when present in the patch; an empty
883
+ * list or null/0 clears that scope. Empty maps are pruned to undefined.
884
+ */
885
+ export function setServerKeyScope(
886
+ config: Config,
887
+ index: number,
888
+ patch: { models?: string[] | null; rpm?: number | null; expires?: number | null },
889
+ ): Config {
890
+ const next = cloneConfig(config);
891
+ const keys = next.server.api_keys;
892
+ if (index < 0 || index >= keys.length) throw new Error(`no gateway key at index ${index}`);
893
+ const key = keys[index]!;
894
+
895
+ if (patch.models !== undefined) {
896
+ const models = { ...(next.server.key_models ?? {}) };
897
+ const list = (patch.models ?? []).map((m) => m.trim()).filter(Boolean);
898
+ if (list.length > 0) models[key] = list;
899
+ else delete models[key];
900
+ next.server.key_models = Object.keys(models).length > 0 ? models : undefined;
901
+ }
902
+
903
+ if (patch.rpm !== undefined) {
904
+ const rpm = { ...(next.server.key_rpm ?? {}) };
905
+ if (patch.rpm && patch.rpm > 0) rpm[key] = Math.floor(patch.rpm);
906
+ else delete rpm[key];
907
+ next.server.key_rpm = Object.keys(rpm).length > 0 ? rpm : undefined;
908
+ }
909
+
910
+ if (patch.expires !== undefined) {
911
+ const exp = { ...(next.server.key_expires ?? {}) };
912
+ if (patch.expires && patch.expires > 0) exp[key] = Math.floor(patch.expires);
913
+ else delete exp[key];
914
+ next.server.key_expires = Object.keys(exp).length > 0 ? exp : undefined;
915
+ }
916
+
917
+ return next;
918
+ }
919
+
920
+ /** True when `rawKey` has an expiry set and `now` is strictly past it. */
921
+ export function isKeyExpired(server: Config["server"], rawKey: string, now: number): boolean {
922
+ const at = server.key_expires?.[rawKey];
923
+ return at !== undefined && now > at;
924
+ }
@@ -1,71 +1,124 @@
1
1
  /**
2
- * Gateway-wide spend budget, derived from the usage table (the single source of
3
- * truth) rather than a parallel counter. status() sums cost/tokens over the
4
- * current window and reports spent / pct / alert / exhausted, plus an estimate
5
- * in the OTHER unit from the window's blended rate. The result is cached for a
6
- * few seconds so the per-request hard-stop check stays one DB query per window.
2
+ * Scoped spend budgets, derived from the usage table (the single source of
3
+ * truth) rather than a parallel counter. Each budget targets the whole gateway,
4
+ * one provider, or one upstream model. statuses() computes every budget's spend
5
+ * over its window; the result list is cached a few seconds so the per-request
6
+ * hard-stop check stays cheap. blocks() answers "is a route to this
7
+ * provider/model barred by an exhausted budget?".
7
8
  */
8
- import type { Budget } from "../config.js";
9
- import { currentWindowStart, nextResetAt } from "./quota.js";
9
+ import type { Budget, BudgetScope } from "../config.js";
10
+ import { budgetKey } from "../config.js";
11
+ import { currentWindowStart, nextResetAt } from "./window.js";
10
12
 
11
13
  export interface BudgetStatus {
14
+ scope: BudgetScope;
15
+ key: string;
16
+ label: string;
17
+ note?: string;
12
18
  unit: "usd" | "tokens";
13
19
  limit: number;
14
20
  spent: number;
15
21
  pct: number;
16
22
  alert: boolean;
23
+ alert_at: number;
17
24
  exhausted: boolean;
18
- /** estimate in the converse unit (tokens if unit=usd, usd if unit=tokens); null when no usage yet */
19
25
  est_converse: number | null;
20
26
  reset_in_ms: number;
21
27
  window: Budget["window"];
22
28
  }
23
29
 
24
- interface SummaryReader {
25
- summary(since: number): { total: { tokens_in: number; tokens_out: number; cost: number } };
30
+ interface TotalsReader {
31
+ totals(sinceMs: number, filter?: { provider?: string; model?: string; client_key?: string }): {
32
+ tokens_in: number;
33
+ tokens_out: number;
34
+ cost: number;
35
+ };
36
+ }
37
+
38
+ function scopeLabel(scope: BudgetScope, keyName: (fp: string) => string): string {
39
+ if (scope.type === "global") return "Global";
40
+ if (scope.type === "key") return keyName(scope.id);
41
+ return scope.id;
42
+ }
43
+
44
+ function scopeFilter(scope: BudgetScope): { provider?: string; model?: string; client_key?: string } | undefined {
45
+ if (scope.type === "provider") return { provider: scope.id };
46
+ if (scope.type === "model") return { model: scope.id };
47
+ if (scope.type === "key") return { client_key: scope.id };
48
+ return undefined;
26
49
  }
27
50
 
28
51
  export class BudgetTracker {
29
- private cached?: { at: number; status: BudgetStatus | null };
52
+ private cached?: { at: number; list: BudgetStatus[] };
30
53
 
31
54
  constructor(
32
- private readonly getSpec: () => Budget | undefined,
33
- private readonly db: SummaryReader,
55
+ private readonly getBudgets: () => Budget[],
56
+ private readonly db: TotalsReader,
34
57
  private readonly now: () => number = Date.now,
35
58
  private readonly cacheMs = 5000,
59
+ private readonly keyName: (fp: string) => string = (fp) => `key …${fp}`,
36
60
  ) {}
37
61
 
38
- /** Flush the cached status — call after a config reload that may have changed the budget spec. */
39
62
  clearCache(): void {
40
63
  this.cached = undefined;
41
64
  }
42
65
 
43
- status(): BudgetStatus | null {
66
+ statuses(): BudgetStatus[] {
44
67
  const t = this.now();
45
- if (this.cached && t - this.cached.at < this.cacheMs) return this.cached.status;
46
- const status = this.compute(t);
47
- this.cached = { at: t, status };
48
- return status;
68
+ if (this.cached && t - this.cached.at < this.cacheMs) return this.cached.list;
69
+ const list = this.getBudgets().map((b) => this.compute(b, t));
70
+ this.cached = { at: t, list };
71
+ return list;
72
+ }
73
+
74
+ globalStatus(): BudgetStatus | null {
75
+ return this.statuses().find((s) => s.scope.type === "global") ?? null;
76
+ }
77
+
78
+ /** First exhausted provider/model budget matching a route, or null. */
79
+ blocks(providerId: string, model: string): { exhausted: true; reset_in_ms: number } | null {
80
+ for (const s of this.statuses()) {
81
+ if (!s.exhausted) continue;
82
+ if (s.scope.type === "provider" && s.scope.id === providerId)
83
+ return { exhausted: true, reset_in_ms: s.reset_in_ms };
84
+ if (s.scope.type === "model" && s.scope.id === model)
85
+ return { exhausted: true, reset_in_ms: s.reset_in_ms };
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /** The exhausted key-scoped budget for this fingerprint, or null. */
91
+ blocksKey(fp: string): { exhausted: true; reset_in_ms: number } | null {
92
+ for (const s of this.statuses()) {
93
+ if (s.exhausted && s.scope.type === "key" && s.scope.id === fp) {
94
+ return { exhausted: true, reset_in_ms: s.reset_in_ms };
95
+ }
96
+ }
97
+ return null;
49
98
  }
50
99
 
51
- private compute(t: number): BudgetStatus | null {
52
- const spec = this.getSpec();
53
- if (!spec) return null;
100
+ private compute(spec: Budget, t: number): BudgetStatus {
54
101
  const windowStart = currentWindowStart(spec, t);
55
- const total = this.db.summary(windowStart).total;
102
+ const total = this.db.totals(windowStart, scopeFilter(spec.scope));
56
103
  const tokens = total.tokens_in + total.tokens_out;
57
104
  const cost = total.cost;
58
- const rate = tokens > 0 ? cost / tokens : undefined; // $/token, blended over the window
105
+ const rate = tokens > 0 ? cost / tokens : undefined;
59
106
  const spent = spec.unit === "usd" ? cost : tokens;
60
107
  const limit = spec.limit;
61
108
  const pct = limit > 0 ? Math.min(1, spent / limit) : 0;
109
+ const alertAt = spec.alert_at ?? 0.8;
62
110
  const est_converse = rate === undefined ? null : spec.unit === "usd" ? limit / rate : limit * rate;
63
111
  return {
112
+ scope: spec.scope,
113
+ key: budgetKey(spec.scope),
114
+ label: scopeLabel(spec.scope, this.keyName),
115
+ note: spec.note,
64
116
  unit: spec.unit,
65
117
  limit,
66
118
  spent,
67
119
  pct,
68
- alert: pct >= (spec.alert_at ?? 0.8),
120
+ alert: pct >= alertAt,
121
+ alert_at: alertAt,
69
122
  exhausted: spent >= limit,
70
123
  est_converse,
71
124
  reset_in_ms: Math.max(0, nextResetAt(spec, windowStart, t) - t),
@@ -32,8 +32,6 @@ export interface FallbackOpts {
32
32
  onAttempt?: (log: AttemptLog) => void;
33
33
  /** which key the pool handed out for the winning attempt (handler uses it for usage). */
34
34
  onServed?: (route: ResolvedRoute, key: string) => void;
35
- /** when set, a provider this returns true for is skipped (quota exhausted). */
36
- isExhausted?: (provider: ResolvedRoute["provider"]) => boolean;
37
35
  /** captured client thinking intent, applied per-attempt in the provider's format. */
38
36
  thinkingIntent?: ThinkingConfig | null;
39
37
  }
@@ -56,13 +54,6 @@ export async function executeWithFallback(
56
54
  for (const route of routes) {
57
55
  const { provider } = route;
58
56
 
59
- // skip a provider whose token budget is spent for this window — like a key
60
- // cooling down, but for the whole provider. Falls through to the next route.
61
- if (opts.isExhausted?.(provider)) {
62
- log({ provider: provider.id, model: route.model, outcome: "skip", detail: "quota exhausted" });
63
- continue;
64
- }
65
-
66
57
  const attempts = provider.max_retries + 1;
67
58
 
68
59
  for (let i = 0; i < attempts; i++) {