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.
Files changed (216) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +302 -0
  4. package/assets/logo.svg +8 -0
  5. package/assets/screenshot.png +0 -0
  6. package/assets/wordmark.svg +9 -0
  7. package/config.example.yaml +56 -0
  8. package/dashboard/.env.example +12 -0
  9. package/dashboard/next-env.d.ts +6 -0
  10. package/dashboard/next.config.ts +12 -0
  11. package/dashboard/package-lock.json +1771 -0
  12. package/dashboard/package.json +29 -0
  13. package/dashboard/postcss.config.mjs +5 -0
  14. package/dashboard/src/app/(console)/combos/page.tsx +10 -0
  15. package/dashboard/src/app/(console)/config/page.tsx +5 -0
  16. package/dashboard/src/app/(console)/console/page.tsx +92 -0
  17. package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
  18. package/dashboard/src/app/(console)/layout.tsx +17 -0
  19. package/dashboard/src/app/(console)/page.tsx +8 -0
  20. package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
  21. package/dashboard/src/app/(console)/providers/page.tsx +5 -0
  22. package/dashboard/src/app/(console)/quota/page.tsx +5 -0
  23. package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
  24. package/dashboard/src/app/(console)/tools/page.tsx +5 -0
  25. package/dashboard/src/app/(console)/usage/page.tsx +24 -0
  26. package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
  27. package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
  28. package/dashboard/src/app/api/login/route.ts +30 -0
  29. package/dashboard/src/app/api/logout/route.ts +9 -0
  30. package/dashboard/src/app/api/password/route.ts +34 -0
  31. package/dashboard/src/app/globals.css +340 -0
  32. package/dashboard/src/app/icon.svg +8 -0
  33. package/dashboard/src/app/layout.tsx +28 -0
  34. package/dashboard/src/app/login/page.tsx +60 -0
  35. package/dashboard/src/components/AreaChart.tsx +115 -0
  36. package/dashboard/src/components/Badge.tsx +32 -0
  37. package/dashboard/src/components/Button.tsx +60 -0
  38. package/dashboard/src/components/CapacityBadges.tsx +40 -0
  39. package/dashboard/src/components/Checkbox.tsx +40 -0
  40. package/dashboard/src/components/CliToolConfig.tsx +63 -0
  41. package/dashboard/src/components/ConfigEditor.tsx +199 -0
  42. package/dashboard/src/components/ConfirmModal.tsx +36 -0
  43. package/dashboard/src/components/CooldownTimer.tsx +42 -0
  44. package/dashboard/src/components/EndpointView.tsx +439 -0
  45. package/dashboard/src/components/Icon.tsx +25 -0
  46. package/dashboard/src/components/KeyReveal.tsx +78 -0
  47. package/dashboard/src/components/Lamp.tsx +8 -0
  48. package/dashboard/src/components/LogTable.tsx +223 -0
  49. package/dashboard/src/components/LogoutButton.tsx +20 -0
  50. package/dashboard/src/components/ModelPicker.tsx +121 -0
  51. package/dashboard/src/components/ModelSelectModal.tsx +126 -0
  52. package/dashboard/src/components/PasswordEditor.tsx +86 -0
  53. package/dashboard/src/components/PricingEditor.tsx +171 -0
  54. package/dashboard/src/components/ProviderDetail.tsx +566 -0
  55. package/dashboard/src/components/ProviderManager.tsx +311 -0
  56. package/dashboard/src/components/QuotaView.tsx +78 -0
  57. package/dashboard/src/components/Rail.tsx +82 -0
  58. package/dashboard/src/components/RichCard.tsx +46 -0
  59. package/dashboard/src/components/RoutingView.tsx +329 -0
  60. package/dashboard/src/components/ThemeProvider.tsx +36 -0
  61. package/dashboard/src/components/ToastProvider.tsx +58 -0
  62. package/dashboard/src/components/ToolDetail.tsx +475 -0
  63. package/dashboard/src/components/TopBar.tsx +128 -0
  64. package/dashboard/src/components/UsageView.tsx +151 -0
  65. package/dashboard/src/components/ui.tsx +54 -0
  66. package/dashboard/src/lib/capabilities.ts +318 -0
  67. package/dashboard/src/lib/cliTools.ts +120 -0
  68. package/dashboard/src/lib/client.ts +190 -0
  69. package/dashboard/src/lib/gateway.ts +269 -0
  70. package/dashboard/src/lib/session.ts +71 -0
  71. package/dashboard/src/middleware.ts +37 -0
  72. package/dashboard/tsconfig.json +21 -0
  73. package/dist/adapters/anthropic.js +289 -0
  74. package/dist/adapters/anthropic.js.map +1 -0
  75. package/dist/adapters/gemini.js +268 -0
  76. package/dist/adapters/gemini.js.map +1 -0
  77. package/dist/adapters/index.js +8 -0
  78. package/dist/adapters/index.js.map +1 -0
  79. package/dist/adapters/openai.js +13 -0
  80. package/dist/adapters/openai.js.map +1 -0
  81. package/dist/cli/tray/autostart.js +152 -0
  82. package/dist/cli/tray/autostart.js.map +1 -0
  83. package/dist/cli/tray/icon.js +4 -0
  84. package/dist/cli/tray/icon.js.map +1 -0
  85. package/dist/cli/tray/tray.js +141 -0
  86. package/dist/cli/tray/tray.js.map +1 -0
  87. package/dist/cli/tray/trayRuntime.js +91 -0
  88. package/dist/cli/tray/trayRuntime.js.map +1 -0
  89. package/dist/cli.js +361 -0
  90. package/dist/cli.js.map +1 -0
  91. package/dist/config.js +728 -0
  92. package/dist/config.js.map +1 -0
  93. package/dist/core/authStore.js +78 -0
  94. package/dist/core/authStore.js.map +1 -0
  95. package/dist/core/canonical.js +9 -0
  96. package/dist/core/canonical.js.map +1 -0
  97. package/dist/core/console-buffer.js +25 -0
  98. package/dist/core/console-buffer.js.map +1 -0
  99. package/dist/core/fallback.js +62 -0
  100. package/dist/core/fallback.js.map +1 -0
  101. package/dist/core/handler.js +174 -0
  102. package/dist/core/handler.js.map +1 -0
  103. package/dist/core/keypool.js +105 -0
  104. package/dist/core/keypool.js.map +1 -0
  105. package/dist/core/quota.js +165 -0
  106. package/dist/core/quota.js.map +1 -0
  107. package/dist/core/state.js +52 -0
  108. package/dist/core/state.js.map +1 -0
  109. package/dist/db.js +193 -0
  110. package/dist/db.js.map +1 -0
  111. package/dist/headroom/compress.js +44 -0
  112. package/dist/headroom/compress.js.map +1 -0
  113. package/dist/headroom/detect.js +108 -0
  114. package/dist/headroom/detect.js.map +1 -0
  115. package/dist/headroom/process.js +158 -0
  116. package/dist/headroom/process.js.map +1 -0
  117. package/dist/inject/caveman.js +30 -0
  118. package/dist/inject/caveman.js.map +1 -0
  119. package/dist/inject/index.js +24 -0
  120. package/dist/inject/index.js.map +1 -0
  121. package/dist/inject/ponytail.js +19 -0
  122. package/dist/inject/ponytail.js.map +1 -0
  123. package/dist/middleware/auth.js +66 -0
  124. package/dist/middleware/auth.js.map +1 -0
  125. package/dist/providers/capabilities.js +246 -0
  126. package/dist/providers/capabilities.js.map +1 -0
  127. package/dist/providers/free.js +43 -0
  128. package/dist/providers/free.js.map +1 -0
  129. package/dist/providers/pricing.js +224 -0
  130. package/dist/providers/pricing.js.map +1 -0
  131. package/dist/providers/vertex.js +97 -0
  132. package/dist/providers/vertex.js.map +1 -0
  133. package/dist/routes/admin.js +622 -0
  134. package/dist/routes/admin.js.map +1 -0
  135. package/dist/routes/health.js +4 -0
  136. package/dist/routes/health.js.map +1 -0
  137. package/dist/routes/index.js +12 -0
  138. package/dist/routes/index.js.map +1 -0
  139. package/dist/routes/v1.js +75 -0
  140. package/dist/routes/v1.js.map +1 -0
  141. package/dist/rtk/detect.js +50 -0
  142. package/dist/rtk/detect.js.map +1 -0
  143. package/dist/rtk/filters.js +85 -0
  144. package/dist/rtk/filters.js.map +1 -0
  145. package/dist/rtk/index.js +39 -0
  146. package/dist/rtk/index.js.map +1 -0
  147. package/dist/server.js +100 -0
  148. package/dist/server.js.map +1 -0
  149. package/dist/stream/anthropic-stream.js +239 -0
  150. package/dist/stream/anthropic-stream.js.map +1 -0
  151. package/dist/stream/chunk.js +7 -0
  152. package/dist/stream/chunk.js.map +1 -0
  153. package/dist/stream/gemini-stream.js +135 -0
  154. package/dist/stream/gemini-stream.js.map +1 -0
  155. package/dist/stream/index.js +12 -0
  156. package/dist/stream/index.js.map +1 -0
  157. package/dist/stream/openai-stream.js +34 -0
  158. package/dist/stream/openai-stream.js.map +1 -0
  159. package/dist/stream/sse.js +64 -0
  160. package/dist/stream/sse.js.map +1 -0
  161. package/dist/translator/thinking.js +70 -0
  162. package/dist/translator/thinking.js.map +1 -0
  163. package/dist/translator/thinkingUnified.js +322 -0
  164. package/dist/translator/thinkingUnified.js.map +1 -0
  165. package/dist/upstream/client.js +120 -0
  166. package/dist/upstream/client.js.map +1 -0
  167. package/package.json +76 -0
  168. package/run.sh +27 -0
  169. package/src/adapters/anthropic.ts +377 -0
  170. package/src/adapters/gemini.ts +341 -0
  171. package/src/adapters/index.ts +17 -0
  172. package/src/adapters/openai.ts +22 -0
  173. package/src/cli/tray/autostart.ts +133 -0
  174. package/src/cli/tray/icon.ts +4 -0
  175. package/src/cli/tray/tray.ts +156 -0
  176. package/src/cli/tray/trayRuntime.ts +90 -0
  177. package/src/cli.ts +379 -0
  178. package/src/config.ts +777 -0
  179. package/src/core/authStore.ts +86 -0
  180. package/src/core/canonical.ts +93 -0
  181. package/src/core/console-buffer.ts +39 -0
  182. package/src/core/fallback.ts +116 -0
  183. package/src/core/handler.ts +236 -0
  184. package/src/core/keypool.ts +152 -0
  185. package/src/core/quota.ts +214 -0
  186. package/src/core/state.ts +65 -0
  187. package/src/db.ts +280 -0
  188. package/src/headroom/compress.ts +78 -0
  189. package/src/headroom/detect.ts +119 -0
  190. package/src/headroom/process.ts +166 -0
  191. package/src/inject/caveman.ts +35 -0
  192. package/src/inject/index.ts +46 -0
  193. package/src/inject/ponytail.ts +31 -0
  194. package/src/middleware/auth.ts +76 -0
  195. package/src/providers/capabilities.ts +297 -0
  196. package/src/providers/free.ts +53 -0
  197. package/src/providers/pricing.ts +261 -0
  198. package/src/providers/vertex.ts +117 -0
  199. package/src/routes/admin.ts +716 -0
  200. package/src/routes/health.ts +5 -0
  201. package/src/routes/index.ts +24 -0
  202. package/src/routes/v1.ts +87 -0
  203. package/src/rtk/detect.ts +55 -0
  204. package/src/rtk/filters.ts +94 -0
  205. package/src/rtk/index.ts +58 -0
  206. package/src/server.ts +108 -0
  207. package/src/stream/anthropic-stream.ts +310 -0
  208. package/src/stream/chunk.ts +46 -0
  209. package/src/stream/gemini-stream.ts +158 -0
  210. package/src/stream/index.ts +23 -0
  211. package/src/stream/openai-stream.ts +41 -0
  212. package/src/stream/sse.ts +72 -0
  213. package/src/translator/thinking.ts +64 -0
  214. package/src/translator/thinkingUnified.ts +319 -0
  215. package/src/upstream/client.ts +155 -0
  216. package/tsconfig.json +20 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Per-provider key pool with round-robin selection and exponential-backoff
3
+ * cooldown. In-memory only (personal use — state need not survive restart).
4
+ *
5
+ * A key that hits a retryable failure is penalized: it goes on cooldown for
6
+ * `base * 2^(failCount-1)` ms (capped), so a flaky/rate-limited key is skipped
7
+ * for progressively longer while healthy keys keep serving.
8
+ */
9
+ import type { Provider } from "../config.js";
10
+ import { maskKey } from "../config.js";
11
+
12
+ const COOLDOWN_CAP_MS = 5 * 60_000; // 5 minutes
13
+
14
+ export interface KeyError {
15
+ message: string;
16
+ status?: number;
17
+ at: number;
18
+ }
19
+
20
+ interface KeyState {
21
+ key: string;
22
+ cooldownUntil: number;
23
+ failCount: number;
24
+ lastError?: KeyError;
25
+ }
26
+
27
+ interface ProviderPool {
28
+ baseMs: number;
29
+ cursor: number;
30
+ states: KeyState[];
31
+ }
32
+
33
+ /**
34
+ * Keys a provider routes through. A free/keyless provider (OpenCode Free, local
35
+ * Ollama) gets one empty slot so it still routes — the upstream client omits the
36
+ * auth header when the key is empty.
37
+ */
38
+ function keysOf(provider: Provider): string[] {
39
+ if (provider.api_keys && provider.api_keys.length > 0) return provider.api_keys;
40
+ if (provider.api_key) return [provider.api_key];
41
+ return [""];
42
+ }
43
+
44
+ export class KeyPool {
45
+ private readonly pools = new Map<string, ProviderPool>();
46
+ private readonly now: () => number;
47
+
48
+ constructor(now: () => number = Date.now) {
49
+ this.now = now;
50
+ }
51
+
52
+ private poolFor(provider: Provider): ProviderPool {
53
+ let pool = this.pools.get(provider.id);
54
+ if (!pool) {
55
+ pool = {
56
+ baseMs: provider.cooldown_base_ms,
57
+ cursor: 0,
58
+ states: keysOf(provider).map((key) => ({ key, cooldownUntil: 0, failCount: 0 })),
59
+ };
60
+ this.pools.set(provider.id, pool);
61
+ }
62
+ return pool;
63
+ }
64
+
65
+ /**
66
+ * Pick the next available (not-in-cooldown) key, round-robin.
67
+ * Returns null when every key for this provider is cooling down.
68
+ */
69
+ pick(provider: Provider): string | null {
70
+ const pool = this.poolFor(provider);
71
+ const n = pool.states.length;
72
+ const t = this.now();
73
+ const disabled = new Set(provider.disabled_keys ?? []);
74
+ for (let i = 0; i < n; i++) {
75
+ const idx = (pool.cursor + i) % n;
76
+ if (disabled.has(idx)) continue;
77
+ const state = pool.states[idx]!;
78
+ if (state.cooldownUntil <= t) {
79
+ pool.cursor = (idx + 1) % n;
80
+ return state.key;
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /** Mark a retryable failure: bump failCount, apply backoff cooldown, and persist the error. */
87
+ penalize(provider: Provider, key: string, error?: { message: string; status?: number }): void {
88
+ const pool = this.poolFor(provider);
89
+ const state = pool.states.find((s) => s.key === key);
90
+ if (!state) return;
91
+ state.failCount += 1;
92
+ const backoff = pool.baseMs * 2 ** (state.failCount - 1);
93
+ state.cooldownUntil = this.now() + Math.min(backoff, COOLDOWN_CAP_MS);
94
+ if (error) {
95
+ state.lastError = { message: error.message, status: error.status, at: this.now() };
96
+ }
97
+ }
98
+
99
+ /** Mark success: clear failure state so the key is healthy again. */
100
+ success(provider: Provider, key: string): void {
101
+ const state = this.poolFor(provider).states.find((s) => s.key === key);
102
+ if (!state) return;
103
+ state.failCount = 0;
104
+ state.cooldownUntil = 0;
105
+ }
106
+
107
+ /** True if at least one key is currently usable. */
108
+ hasAvailable(provider: Provider): boolean {
109
+ const pool = this.poolFor(provider);
110
+ const t = this.now();
111
+ const disabled = new Set(provider.disabled_keys ?? []);
112
+ return pool.states.some((s, i) => !disabled.has(i) && s.cooldownUntil <= t);
113
+ }
114
+
115
+ /**
116
+ * Read-only view of key health for the dashboard. Keys are MASKED — raw
117
+ * secrets never leave the gateway. `cooldown_ms` is the remaining cooldown
118
+ * (0 = healthy).
119
+ */
120
+ snapshot(providers: Provider[]): ProviderSnapshot[] {
121
+ const t = this.now();
122
+ return providers.map((provider) => {
123
+ const pool = this.poolFor(provider);
124
+ return {
125
+ id: provider.id,
126
+ format: provider.format,
127
+ keys: pool.states.map((s) => ({
128
+ key: maskKey(s.key),
129
+ healthy: s.cooldownUntil <= t,
130
+ cooldown_ms: Math.max(0, s.cooldownUntil - t),
131
+ fail_count: s.failCount,
132
+ last_error: s.lastError ?? null,
133
+ })),
134
+ };
135
+ });
136
+ }
137
+ }
138
+
139
+ export interface KeySnapshot {
140
+ /** masked key, e.g. "sk-…cd12" */
141
+ key: string;
142
+ healthy: boolean;
143
+ cooldown_ms: number;
144
+ fail_count: number;
145
+ last_error: KeyError | null;
146
+ }
147
+
148
+ export interface ProviderSnapshot {
149
+ id: string;
150
+ format: Provider["format"];
151
+ keys: KeySnapshot[];
152
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Per-provider token quota tracking with scheduled window resets.
3
+ *
4
+ * Distinct from the key-pool cooldown: a cooldown is a transient penalty after a
5
+ * 429; a quota is a budget that refills on a schedule (a 5-hour rolling window, a
6
+ * daily/weekly/monthly calendar boundary). When a provider's `limit_tokens` is
7
+ * reached before its window resets, routing skips it — like a key that's cooling
8
+ * down, but for the whole provider.
9
+ *
10
+ * State is in-memory, optionally persisted so counts survive a restart within
11
+ * the same window. Calendar boundaries are computed in the provider's timezone.
12
+ */
13
+ import type { Provider, Quota } from "../config.js";
14
+
15
+ const HOUR_MS = 3600_000;
16
+ const DAY_MS = 24 * HOUR_MS;
17
+
18
+ const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
19
+
20
+ /** Optional persistence hook so counts survive a restart within a window. */
21
+ export interface QuotaStore {
22
+ load(): Array<{ provider_id: string; window_start: number; consumed: number }>;
23
+ save(providerId: string, windowStart: number, consumed: number): void;
24
+ }
25
+
26
+ interface QuotaState {
27
+ windowStart: number;
28
+ consumed: number;
29
+ }
30
+
31
+ export interface QuotaSnapshot {
32
+ provider: string;
33
+ window: Quota["window"];
34
+ consumed: number;
35
+ limit_tokens?: number;
36
+ /** ms until the next scheduled reset */
37
+ reset_in_ms: number;
38
+ /** 0..1 fraction of the limit used, if a limit is set */
39
+ pct?: number;
40
+ exhausted: boolean;
41
+ }
42
+
43
+ // ---- timezone-aware calendar math -----------------------------------------
44
+
45
+ /** Wall-clock offset (ms) of `tz` at instant `date`: tzWallAsUTC - actualUTC. */
46
+ function tzOffsetMs(date: Date, tz: string): number {
47
+ const dtf = new Intl.DateTimeFormat("en-US", {
48
+ timeZone: tz,
49
+ hourCycle: "h23",
50
+ year: "numeric",
51
+ month: "2-digit",
52
+ day: "2-digit",
53
+ hour: "2-digit",
54
+ minute: "2-digit",
55
+ second: "2-digit",
56
+ });
57
+ const parts = Object.fromEntries(dtf.formatToParts(date).map((p) => [p.type, p.value]));
58
+ const asUTC = Date.UTC(
59
+ Number(parts.year),
60
+ Number(parts.month) - 1,
61
+ Number(parts.day),
62
+ Number(parts.hour),
63
+ Number(parts.minute),
64
+ Number(parts.second),
65
+ );
66
+ return asUTC - date.getTime();
67
+ }
68
+
69
+ /** Convert a desired wall-clock time in `tz` to an epoch ms. DST-corrected once. */
70
+ function zonedWallToEpoch(y: number, mo: number, d: number, h: number, mi: number, tz: string): number {
71
+ const guessUTC = Date.UTC(y, mo, d, h, mi);
72
+ const offset = tzOffsetMs(new Date(guessUTC), tz);
73
+ let epoch = guessUTC - offset;
74
+ // re-check once: the offset can differ across a DST boundary
75
+ const offset2 = tzOffsetMs(new Date(epoch), tz);
76
+ if (offset2 !== offset) epoch = guessUTC - offset2;
77
+ return epoch;
78
+ }
79
+
80
+ /** Wall-clock parts of `nowMs` in `tz`. */
81
+ function zonedParts(nowMs: number, tz: string) {
82
+ const dtf = new Intl.DateTimeFormat("en-US", {
83
+ timeZone: tz,
84
+ hourCycle: "h23",
85
+ weekday: "long",
86
+ year: "numeric",
87
+ month: "2-digit",
88
+ day: "2-digit",
89
+ hour: "2-digit",
90
+ minute: "2-digit",
91
+ });
92
+ const p = Object.fromEntries(dtf.formatToParts(nowMs).map((x) => [x.type, x.value]));
93
+ return {
94
+ year: Number(p.year),
95
+ month: Number(p.month) - 1,
96
+ day: Number(p.day),
97
+ hour: Number(p.hour),
98
+ minute: Number(p.minute),
99
+ weekday: String(p.weekday).toLowerCase(),
100
+ };
101
+ }
102
+
103
+ function parseHHMM(reset_at: string | undefined): { h: number; m: number } {
104
+ const m = /^(\d{1,2}):(\d{2})$/.exec(reset_at ?? "");
105
+ if (!m) return { h: 0, m: 0 };
106
+ return { h: Math.min(23, Number(m[1])), m: Math.min(59, Number(m[2])) };
107
+ }
108
+
109
+ /**
110
+ * Next reset instant (epoch ms) strictly after `now` for a quota schedule.
111
+ * - 5h: rolling — windowStart + 5h.
112
+ * - daily: next `reset_at` (HH:MM, default 00:00) wall-clock in tz.
113
+ * - weekly: next `reset_at` weekday (default monday) at 00:00 in tz.
114
+ * - monthly: next 1st of month at 00:00 in tz.
115
+ */
116
+ export function nextResetAt(quota: Quota, windowStart: number, now: number): number {
117
+ const tz = quota.timezone || "UTC";
118
+ if (quota.window === "5h") return windowStart + 5 * HOUR_MS;
119
+
120
+ const p = zonedParts(now, tz);
121
+
122
+ if (quota.window === "daily") {
123
+ const { h, m } = parseHHMM(quota.reset_at);
124
+ let candidate = zonedWallToEpoch(p.year, p.month, p.day, h, m, tz);
125
+ if (candidate <= now) candidate = zonedWallToEpoch(p.year, p.month, p.day + 1, h, m, tz);
126
+ return candidate;
127
+ }
128
+
129
+ if (quota.window === "weekly") {
130
+ const target = WEEKDAYS.indexOf((quota.reset_at ?? "monday").toLowerCase());
131
+ const targetIdx = target === -1 ? 1 : target;
132
+ const curIdx = WEEKDAYS.indexOf(p.weekday);
133
+ let daysAhead = (targetIdx - curIdx + 7) % 7;
134
+ let candidate = zonedWallToEpoch(p.year, p.month, p.day + daysAhead, 0, 0, tz);
135
+ if (candidate <= now) candidate = zonedWallToEpoch(p.year, p.month, p.day + daysAhead + 7, 0, 0, tz);
136
+ return candidate;
137
+ }
138
+
139
+ // monthly: first of next month at 00:00
140
+ return zonedWallToEpoch(p.year, p.month + 1, 1, 0, 0, tz);
141
+ }
142
+
143
+ export class QuotaTracker {
144
+ private readonly states = new Map<string, QuotaState>();
145
+
146
+ constructor(
147
+ private readonly now: () => number = Date.now,
148
+ private readonly store?: QuotaStore,
149
+ ) {
150
+ if (store) {
151
+ for (const row of store.load()) {
152
+ this.states.set(row.provider_id, { windowStart: row.window_start, consumed: row.consumed });
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Return the live state for a provider, rolling the window over (resetting
159
+ * consumed to 0) if `now` has crossed the scheduled reset boundary.
160
+ */
161
+ private current(provider: Provider): QuotaState | null {
162
+ if (!provider.quota) return null;
163
+ const t = this.now();
164
+ const state = this.states.get(provider.id) ?? { windowStart: t, consumed: 0 };
165
+ if (!this.states.has(provider.id)) this.states.set(provider.id, state);
166
+ // boundary is the first reset AFTER this window opened — computed from
167
+ // windowStart, not `now`. Computing it from `now` would always return the
168
+ // NEXT future boundary and so never detect that we've crossed one.
169
+ const reset = nextResetAt(provider.quota, state.windowStart, state.windowStart);
170
+ if (t >= reset) {
171
+ state.windowStart = t;
172
+ state.consumed = 0;
173
+ this.store?.save(provider.id, state.windowStart, state.consumed);
174
+ }
175
+ return state;
176
+ }
177
+
178
+ /** Add consumed tokens for a provider (no-op if it has no quota config). */
179
+ consume(provider: Provider, tokens: number): void {
180
+ const state = this.current(provider);
181
+ if (!state) return;
182
+ state.consumed += Math.max(0, tokens);
183
+ this.store?.save(provider.id, state.windowStart, state.consumed);
184
+ }
185
+
186
+ /** True when a token limit is set AND it's been reached in the current window. */
187
+ isExhausted(provider: Provider): boolean {
188
+ const state = this.current(provider);
189
+ if (!state || !provider.quota?.limit_tokens) return false;
190
+ return state.consumed >= provider.quota.limit_tokens;
191
+ }
192
+
193
+ /** Dashboard view: window, consumed, countdown, and progress for each provider. */
194
+ snapshot(providers: Provider[]): QuotaSnapshot[] {
195
+ const t = this.now();
196
+ return providers.flatMap((provider) => {
197
+ if (!provider.quota) return [];
198
+ const state = this.current(provider)!;
199
+ const reset = nextResetAt(provider.quota, state.windowStart, t);
200
+ const limit = provider.quota.limit_tokens;
201
+ return [
202
+ {
203
+ provider: provider.id,
204
+ window: provider.quota.window,
205
+ consumed: state.consumed,
206
+ limit_tokens: limit,
207
+ reset_in_ms: Math.max(0, reset - t),
208
+ pct: limit ? Math.min(1, state.consumed / limit) : undefined,
209
+ exhausted: limit ? state.consumed >= limit : false,
210
+ },
211
+ ];
212
+ });
213
+ }
214
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Mutable holder for the live gateway config, key pool, and quota tracker.
3
+ *
4
+ * Config loads once at boot, but the dashboard edits it at runtime. Routes read
5
+ * `state.config` / `state.pool` / `state.quota` fresh per request (never close
6
+ * over them), so a successful reload swaps in the new config + pool atomically —
7
+ * no restart.
8
+ *
9
+ * reload() validates and persists BEFORE swapping: an invalid edit throws and
10
+ * the old config keeps serving. The pool is rebuilt (cooldown is transient), but
11
+ * the quota tracker is KEPT across reloads — a budget consumed this window must
12
+ * survive a config edit, else editing config would silently reset every quota.
13
+ */
14
+ import {
15
+ GatewayConfig,
16
+ parseConfigText,
17
+ validateConfig,
18
+ unmaskSecrets,
19
+ writeConfigFile,
20
+ } from "../config.js";
21
+ import { KeyPool } from "./keypool.js";
22
+ import { QuotaTracker } from "./quota.js";
23
+
24
+ export class GatewayState {
25
+ private _config: GatewayConfig;
26
+ private _pool: KeyPool;
27
+ private readonly _quota: QuotaTracker;
28
+
29
+ constructor(
30
+ private readonly configPath: string,
31
+ initial: GatewayConfig,
32
+ quota?: QuotaTracker,
33
+ ) {
34
+ this._config = initial;
35
+ this._pool = new KeyPool();
36
+ this._quota = quota ?? new QuotaTracker();
37
+ }
38
+
39
+ get config(): GatewayConfig {
40
+ return this._config;
41
+ }
42
+
43
+ get pool(): KeyPool {
44
+ return this._pool;
45
+ }
46
+
47
+ get quota(): QuotaTracker {
48
+ return this._quota;
49
+ }
50
+
51
+ /**
52
+ * Validate edited config text, restore masked secrets from the live config,
53
+ * persist atomically, then swap in a fresh config + pool. Throws without
54
+ * changing anything if validation fails or a masked key can't be resolved —
55
+ * the old config keeps serving. The quota tracker is intentionally preserved.
56
+ */
57
+ reload(text: string): void {
58
+ const parsed = parseConfigText(text);
59
+ const merged = unmaskSecrets(parsed.raw, this._config.raw);
60
+ const next = validateConfig(merged);
61
+ writeConfigFile(this.configPath, next.raw);
62
+ this._config = next;
63
+ this._pool = new KeyPool();
64
+ }
65
+ }
package/src/db.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Usage tracking store, backed by the built-in node:sqlite (no native build).
3
+ * One `usage` row per upstream request that produced usage; an optional `logs`
4
+ * table holds request/response summaries for debugging. Unified under DATA_DIR
5
+ * (default ./data).
6
+ */
7
+ import { mkdirSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { createRequire } from "node:module";
10
+
11
+ // node:sqlite is a recent builtin; require it dynamically so bundlers/test
12
+ // transformers that don't yet know the `node:sqlite` specifier don't choke.
13
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as {
14
+ DatabaseSync: typeof import("node:sqlite").DatabaseSync;
15
+ };
16
+ type DatabaseSync = import("node:sqlite").DatabaseSync;
17
+
18
+ export interface UsageRow {
19
+ ts: number;
20
+ alias: string;
21
+ provider: string;
22
+ model: string;
23
+ tokens_in: number;
24
+ tokens_out: number;
25
+ cached_tokens: number;
26
+ cost: number;
27
+ status: number;
28
+ latency_ms: number;
29
+ stream: number; // 0/1
30
+ }
31
+
32
+ export interface LogRow {
33
+ ts: number;
34
+ direction: string; // "ingress" | "egress" | "error"
35
+ provider: string;
36
+ status: number;
37
+ request_summary: string;
38
+ response_summary: string;
39
+ }
40
+
41
+ export interface UsageSummary {
42
+ total: { requests: number; tokens_in: number; tokens_out: number; cost: number };
43
+ by_provider: Array<{ provider: string; requests: number; tokens_in: number; tokens_out: number; cost: number }>;
44
+ by_model: Array<{ alias: string; model: string; requests: number; tokens_in: number; tokens_out: number; cost: number }>;
45
+ }
46
+
47
+ export interface UsageSeriesPoint {
48
+ ts: number;
49
+ requests: number;
50
+ tokens_in: number;
51
+ tokens_out: number;
52
+ cost: number;
53
+ }
54
+
55
+ // node:sqlite returns loosely-typed rows; this alias documents the cast site.
56
+ type SqlRow = Record<string, unknown>;
57
+ const num = (v: unknown): number => Number(v ?? 0);
58
+
59
+ export class UsageDB {
60
+ private readonly db: DatabaseSync;
61
+ private readonly insertUsage;
62
+ private readonly insertLog;
63
+ private readonly upsertQuota;
64
+ private readonly now: () => number;
65
+
66
+ constructor(path: string, now: () => number = Date.now) {
67
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
68
+ this.db = new DatabaseSync(path);
69
+ this.db.exec(`
70
+ CREATE TABLE IF NOT EXISTS usage (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ ts INTEGER NOT NULL,
73
+ alias TEXT NOT NULL,
74
+ provider TEXT NOT NULL,
75
+ model TEXT NOT NULL,
76
+ tokens_in INTEGER NOT NULL DEFAULT 0,
77
+ tokens_out INTEGER NOT NULL DEFAULT 0,
78
+ cached_tokens INTEGER NOT NULL DEFAULT 0,
79
+ cost REAL NOT NULL DEFAULT 0,
80
+ status INTEGER NOT NULL,
81
+ latency_ms INTEGER NOT NULL DEFAULT 0,
82
+ stream INTEGER NOT NULL DEFAULT 0
83
+ );
84
+ CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage(ts);
85
+ CREATE TABLE IF NOT EXISTS logs (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ ts INTEGER NOT NULL,
88
+ direction TEXT NOT NULL,
89
+ provider TEXT NOT NULL DEFAULT '',
90
+ status INTEGER NOT NULL DEFAULT 0,
91
+ request_summary TEXT NOT NULL DEFAULT '',
92
+ response_summary TEXT NOT NULL DEFAULT ''
93
+ );
94
+ CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts);
95
+ CREATE TABLE IF NOT EXISTS quota_state (
96
+ provider_id TEXT PRIMARY KEY,
97
+ window_start INTEGER NOT NULL,
98
+ consumed INTEGER NOT NULL DEFAULT 0,
99
+ last_reset INTEGER NOT NULL DEFAULT 0
100
+ );
101
+ `);
102
+ this.now = now;
103
+ this.insertUsage = this.db.prepare(`
104
+ INSERT INTO usage (ts, alias, provider, model, tokens_in, tokens_out, cached_tokens, cost, status, latency_ms, stream)
105
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
106
+ `);
107
+ this.insertLog = this.db.prepare(`
108
+ INSERT INTO logs (ts, direction, provider, status, request_summary, response_summary)
109
+ VALUES (?, ?, ?, ?, ?, ?)
110
+ `);
111
+ // upsert keyed on provider_id so each provider keeps one live window row.
112
+ this.upsertQuota = this.db.prepare(`
113
+ INSERT INTO quota_state (provider_id, window_start, consumed, last_reset)
114
+ VALUES (?, ?, ?, ?)
115
+ ON CONFLICT(provider_id) DO UPDATE SET window_start = excluded.window_start,
116
+ consumed = excluded.consumed, last_reset = excluded.last_reset
117
+ `);
118
+ }
119
+
120
+ record(row: Omit<UsageRow, "ts"> & { ts?: number }): void {
121
+ this.insertUsage.run(
122
+ row.ts ?? this.now(),
123
+ row.alias,
124
+ row.provider,
125
+ row.model,
126
+ row.tokens_in,
127
+ row.tokens_out,
128
+ row.cached_tokens,
129
+ row.cost,
130
+ row.status,
131
+ row.latency_ms,
132
+ row.stream,
133
+ );
134
+ }
135
+
136
+ log(row: Omit<LogRow, "ts"> & { ts?: number }): void {
137
+ this.insertLog.run(
138
+ row.ts ?? this.now(),
139
+ row.direction,
140
+ row.provider,
141
+ row.status,
142
+ row.request_summary,
143
+ row.response_summary,
144
+ );
145
+ }
146
+
147
+ /** Summary over rows with ts >= sinceMs (default: all time). */
148
+ summary(sinceMs = 0): UsageSummary {
149
+ const total = this.db
150
+ .prepare(
151
+ `SELECT COUNT(*) requests, COALESCE(SUM(tokens_in),0) tokens_in,
152
+ COALESCE(SUM(tokens_out),0) tokens_out, COALESCE(SUM(cost),0) cost
153
+ FROM usage WHERE ts >= ?`,
154
+ )
155
+ .get(sinceMs) as SqlRow;
156
+
157
+ const by_provider = this.db
158
+ .prepare(
159
+ `SELECT provider, COUNT(*) requests, COALESCE(SUM(tokens_in),0) tokens_in,
160
+ COALESCE(SUM(tokens_out),0) tokens_out, COALESCE(SUM(cost),0) cost
161
+ FROM usage WHERE ts >= ? GROUP BY provider ORDER BY cost DESC`,
162
+ )
163
+ .all(sinceMs) as SqlRow[];
164
+
165
+ const by_model = this.db
166
+ .prepare(
167
+ `SELECT alias, model, COUNT(*) requests, COALESCE(SUM(tokens_in),0) tokens_in,
168
+ COALESCE(SUM(tokens_out),0) tokens_out, COALESCE(SUM(cost),0) cost
169
+ FROM usage WHERE ts >= ? GROUP BY alias, model ORDER BY cost DESC`,
170
+ )
171
+ .all(sinceMs) as SqlRow[];
172
+
173
+ return {
174
+ total: {
175
+ requests: num(total.requests),
176
+ tokens_in: num(total.tokens_in),
177
+ tokens_out: num(total.tokens_out),
178
+ cost: num(total.cost),
179
+ },
180
+ by_provider: by_provider.map((r) => ({
181
+ provider: String(r.provider),
182
+ requests: num(r.requests),
183
+ tokens_in: num(r.tokens_in),
184
+ tokens_out: num(r.tokens_out),
185
+ cost: num(r.cost),
186
+ })),
187
+ by_model: by_model.map((r) => ({
188
+ alias: String(r.alias),
189
+ model: String(r.model),
190
+ requests: num(r.requests),
191
+ tokens_in: num(r.tokens_in),
192
+ tokens_out: num(r.tokens_out),
193
+ cost: num(r.cost),
194
+ })),
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Bucketed time-series for charts: one point per `bucketMs` interval from
200
+ * `sinceMs` to now, aligned to the bucket boundary, with zero-filled gaps.
201
+ */
202
+ series(sinceMs: number, bucketMs: number): UsageSeriesPoint[] {
203
+ const now = this.now();
204
+ const start = Math.floor(sinceMs / bucketMs) * bucketMs;
205
+ const rows = this.db
206
+ .prepare(
207
+ `SELECT CAST(ts / ? AS INTEGER) * ? AS bucket, COUNT(*) requests,
208
+ COALESCE(SUM(tokens_in),0) tokens_in,
209
+ COALESCE(SUM(tokens_out),0) tokens_out, COALESCE(SUM(cost),0) cost
210
+ FROM usage WHERE ts >= ? GROUP BY bucket ORDER BY bucket`,
211
+ )
212
+ .all(bucketMs, bucketMs, sinceMs) as SqlRow[];
213
+
214
+ const byBucket = new Map<number, SqlRow>();
215
+ for (const r of rows) byBucket.set(num(r.bucket), r);
216
+
217
+ const out: UsageSeriesPoint[] = [];
218
+ for (let t = start; t <= now; t += bucketMs) {
219
+ const r = byBucket.get(t);
220
+ out.push({
221
+ ts: t,
222
+ requests: r ? num(r.requests) : 0,
223
+ tokens_in: r ? num(r.tokens_in) : 0,
224
+ tokens_out: r ? num(r.tokens_out) : 0,
225
+ cost: r ? num(r.cost) : 0,
226
+ });
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /** Most recent usage rows, newest first. For the dashboard logs page. */
232
+ recent(limit = 100): UsageRow[] {
233
+ const rows = this.db
234
+ .prepare(
235
+ `SELECT ts, alias, provider, model, tokens_in, tokens_out, cached_tokens,
236
+ cost, status, latency_ms, stream
237
+ FROM usage ORDER BY id DESC LIMIT ?`,
238
+ )
239
+ .all(Math.max(1, Math.min(limit, 1000))) as SqlRow[];
240
+ return rows.map((r) => ({
241
+ ts: num(r.ts),
242
+ alias: String(r.alias),
243
+ provider: String(r.provider),
244
+ model: String(r.model),
245
+ tokens_in: num(r.tokens_in),
246
+ tokens_out: num(r.tokens_out),
247
+ cached_tokens: num(r.cached_tokens),
248
+ cost: num(r.cost),
249
+ status: num(r.status),
250
+ latency_ms: num(r.latency_ms),
251
+ stream: num(r.stream),
252
+ }));
253
+ }
254
+
255
+ // ---- QuotaStore: one live window row per provider (survives restart) ----
256
+
257
+ loadQuota(): Array<{ provider_id: string; window_start: number; consumed: number }> {
258
+ const rows = this.db.prepare(`SELECT provider_id, window_start, consumed FROM quota_state`).all() as SqlRow[];
259
+ return rows.map((r) => ({
260
+ provider_id: String(r.provider_id),
261
+ window_start: num(r.window_start),
262
+ consumed: num(r.consumed),
263
+ }));
264
+ }
265
+
266
+ saveQuota(providerId: string, windowStart: number, consumed: number): void {
267
+ this.upsertQuota.run(providerId, windowStart, consumed, this.now());
268
+ }
269
+
270
+ close(): void {
271
+ this.db.close();
272
+ }
273
+ }
274
+
275
+ /** Compute USD cost from token counts and per-1M prices. */
276
+ export function computeCost(tokensIn: number, tokensOut: number, priceIn?: number, priceOut?: number): number {
277
+ const ci = priceIn ? (tokensIn / 1_000_000) * priceIn : 0;
278
+ const co = priceOut ? (tokensOut / 1_000_000) * priceOut : 0;
279
+ return ci + co;
280
+ }