fest-gateway 0.1.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 (161) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/dist/cli/claude.js +326 -0
  4. package/dist/cli/claude.js.map +1 -0
  5. package/dist/cli/config.js +59 -0
  6. package/dist/cli/config.js.map +1 -0
  7. package/dist/cli/login.js +130 -0
  8. package/dist/cli/login.js.map +1 -0
  9. package/dist/cli/logout.js +20 -0
  10. package/dist/cli/logout.js.map +1 -0
  11. package/dist/cli/whoami.js +34 -0
  12. package/dist/cli/whoami.js.map +1 -0
  13. package/dist/server/adapters/anthropic.js +74 -0
  14. package/dist/server/adapters/anthropic.js.map +1 -0
  15. package/dist/server/adapters/fireworks.js +54 -0
  16. package/dist/server/adapters/fireworks.js.map +1 -0
  17. package/dist/server/adapters/index.js +16 -0
  18. package/dist/server/adapters/index.js.map +1 -0
  19. package/dist/server/adapters/registry.js +38 -0
  20. package/dist/server/adapters/registry.js.map +1 -0
  21. package/dist/server/adapters/rewrite.js +152 -0
  22. package/dist/server/adapters/rewrite.js.map +1 -0
  23. package/dist/server/adapters/url.js +24 -0
  24. package/dist/server/adapters/url.js.map +1 -0
  25. package/dist/server/api/auth.js +213 -0
  26. package/dist/server/api/auth.js.map +1 -0
  27. package/dist/server/api/live.js +108 -0
  28. package/dist/server/api/live.js.map +1 -0
  29. package/dist/server/api/models.js +129 -0
  30. package/dist/server/api/models.js.map +1 -0
  31. package/dist/server/api/oauth.js +300 -0
  32. package/dist/server/api/oauth.js.map +1 -0
  33. package/dist/server/api/routes.js +164 -0
  34. package/dist/server/api/routes.js.map +1 -0
  35. package/dist/server/api/routing-view.js +47 -0
  36. package/dist/server/api/routing-view.js.map +1 -0
  37. package/dist/server/auth/accounts.js +111 -0
  38. package/dist/server/auth/accounts.js.map +1 -0
  39. package/dist/server/auth/gateway-401.js +85 -0
  40. package/dist/server/auth/gateway-401.js.map +1 -0
  41. package/dist/server/auth/guard.js +74 -0
  42. package/dist/server/auth/guard.js.map +1 -0
  43. package/dist/server/auth/oauth.js +43 -0
  44. package/dist/server/auth/oauth.js.map +1 -0
  45. package/dist/server/auth/password.js +104 -0
  46. package/dist/server/auth/password.js.map +1 -0
  47. package/dist/server/auth/posture.js +145 -0
  48. package/dist/server/auth/posture.js.map +1 -0
  49. package/dist/server/auth/session.js +161 -0
  50. package/dist/server/auth/session.js.map +1 -0
  51. package/dist/server/bin/fest.js +589 -0
  52. package/dist/server/bin/fest.js.map +1 -0
  53. package/dist/server/config.js +109 -0
  54. package/dist/server/config.js.map +1 -0
  55. package/dist/server/credentials/provider.js +70 -0
  56. package/dist/server/credentials/provider.js.map +1 -0
  57. package/dist/server/credentials/resolve.js +89 -0
  58. package/dist/server/credentials/resolve.js.map +1 -0
  59. package/dist/server/http/body.js +66 -0
  60. package/dist/server/http/body.js.map +1 -0
  61. package/dist/server/http/errors.js +144 -0
  62. package/dist/server/http/errors.js.map +1 -0
  63. package/dist/server/http/headers.js +189 -0
  64. package/dist/server/http/headers.js.map +1 -0
  65. package/dist/server/http/pipe.js +97 -0
  66. package/dist/server/http/pipe.js.map +1 -0
  67. package/dist/server/http/server.js +226 -0
  68. package/dist/server/http/server.js.map +1 -0
  69. package/dist/server/http/sse.js +167 -0
  70. package/dist/server/http/sse.js.map +1 -0
  71. package/dist/server/http/static.js +138 -0
  72. package/dist/server/http/static.js.map +1 -0
  73. package/dist/server/ingest/live-bus.js +67 -0
  74. package/dist/server/ingest/live-bus.js.map +1 -0
  75. package/dist/server/ingest/sink.js +141 -0
  76. package/dist/server/ingest/sink.js.map +1 -0
  77. package/dist/server/log.js +31 -0
  78. package/dist/server/log.js.map +1 -0
  79. package/dist/server/pipeline/count-tokens.js +48 -0
  80. package/dist/server/pipeline/count-tokens.js.map +1 -0
  81. package/dist/server/pipeline/dispatch.js +138 -0
  82. package/dist/server/pipeline/dispatch.js.map +1 -0
  83. package/dist/server/pipeline/passthrough.js +392 -0
  84. package/dist/server/pipeline/passthrough.js.map +1 -0
  85. package/dist/server/pipeline/preflight.js +66 -0
  86. package/dist/server/pipeline/preflight.js.map +1 -0
  87. package/dist/server/pipeline/record.js +49 -0
  88. package/dist/server/pipeline/record.js.map +1 -0
  89. package/dist/server/pipeline/substitute.js +287 -0
  90. package/dist/server/pipeline/substitute.js.map +1 -0
  91. package/dist/server/routes/resolve.js +176 -0
  92. package/dist/server/routes/resolve.js.map +1 -0
  93. package/dist/server/routes/table.js +247 -0
  94. package/dist/server/routes/table.js.map +1 -0
  95. package/dist/server/routes/watch.js +101 -0
  96. package/dist/server/routes/watch.js.map +1 -0
  97. package/dist/server/secret/fingerprint.js +107 -0
  98. package/dist/server/secret/fingerprint.js.map +1 -0
  99. package/dist/server/secret/non-persistable.js +69 -0
  100. package/dist/server/secret/non-persistable.js.map +1 -0
  101. package/dist/server/store/audit.js +53 -0
  102. package/dist/server/store/audit.js.map +1 -0
  103. package/dist/server/store/bootstrap.js +53 -0
  104. package/dist/server/store/bootstrap.js.map +1 -0
  105. package/dist/server/store/db.js +157 -0
  106. package/dist/server/store/db.js.map +1 -0
  107. package/dist/server/store/ids.js +14 -0
  108. package/dist/server/store/ids.js.map +1 -0
  109. package/dist/server/store/migrations/001-init.sql +213 -0
  110. package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
  111. package/dist/server/store/migrations/003-routing.sql +42 -0
  112. package/dist/server/store/migrations/004-admin-auth.sql +72 -0
  113. package/dist/server/store/migrations/005-notional-cost.sql +28 -0
  114. package/dist/server/store/queries.js +904 -0
  115. package/dist/server/store/queries.js.map +1 -0
  116. package/dist/server/store/retention.js +79 -0
  117. package/dist/server/store/retention.js.map +1 -0
  118. package/dist/server/store/seed.js +203 -0
  119. package/dist/server/store/seed.js.map +1 -0
  120. package/dist/server/store/tokens.js +133 -0
  121. package/dist/server/store/tokens.js.map +1 -0
  122. package/dist/server/store/write.js +330 -0
  123. package/dist/server/store/write.js.map +1 -0
  124. package/dist/server/usage/accumulator.js +209 -0
  125. package/dist/server/usage/accumulator.js.map +1 -0
  126. package/dist/server/usage/cost.js +57 -0
  127. package/dist/server/usage/cost.js.map +1 -0
  128. package/dist/server/usage/prices/catalog.js +136 -0
  129. package/dist/server/usage/prices/catalog.js.map +1 -0
  130. package/dist/server/usage/prices/refresh.js +125 -0
  131. package/dist/server/usage/prices/refresh.js.map +1 -0
  132. package/dist/server/usage/prices/snapshot.json.gz +0 -0
  133. package/dist/server/usage/prices/table.js +102 -0
  134. package/dist/server/usage/prices/table.js.map +1 -0
  135. package/dist/server/usage/pricing.js +103 -0
  136. package/dist/server/usage/pricing.js.map +1 -0
  137. package/dist/shared/api.js +19 -0
  138. package/dist/shared/api.js.map +1 -0
  139. package/dist/shared/base-models.js +28 -0
  140. package/dist/shared/base-models.js.map +1 -0
  141. package/dist/shared/demotion-vars.js +17 -0
  142. package/dist/shared/demotion-vars.js.map +1 -0
  143. package/dist/shared/series.js +50 -0
  144. package/dist/shared/series.js.map +1 -0
  145. package/dist/shared/types.js +40 -0
  146. package/dist/shared/types.js.map +1 -0
  147. package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
  148. package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
  149. package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
  150. package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  151. package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  152. package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  153. package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  154. package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  155. package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  156. package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  157. package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  158. package/dist/web/dist/index.html +44 -0
  159. package/dist/web/dist/nearform-icon.svg +4 -0
  160. package/package.json +82 -0
  161. package/routes.example.json +22 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Join an upstream base URL to an inbound request path.
3
+ *
4
+ * Deliberately NOT `new URL(path, base)`. URL resolution treats a path starting
5
+ * with `/` as absolute and throws away the base's own path, so
6
+ *
7
+ * new URL("/v1/messages", "https://api.fireworks.ai/inference/")
8
+ * -> https://api.fireworks.ai/v1/messages ✗ /inference is gone
9
+ *
10
+ * which is a 404 from the provider, at runtime, only for base URLs that carry a
11
+ * path prefix. A mock upstream at `http://127.0.0.1:PORT` has no prefix, so this
12
+ * is invisible to tests that do not deliberately use one. It was found by
13
+ * pointing it at the real Fireworks endpoint.
14
+ *
15
+ * So: concatenate, and normalise the seam. `baseUrl` is already stripped of
16
+ * trailing slashes when the routing table is parsed; this tolerates them anyway
17
+ * rather than depending on that at a distance.
18
+ */
19
+ export function joinUpstreamUrl(baseUrl, path) {
20
+ const base = baseUrl.replace(/\/+$/, "");
21
+ const suffix = path.startsWith("/") ? path : `/${path}`;
22
+ return new URL(`${base}${suffix}`);
23
+ }
24
+ //# sourceMappingURL=url.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url.js","sourceRoot":"","sources":["../../../server/adapters/url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,UAAU,eAAe,CAAC,OAAe,EAAE,IAAY;IAC3D,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACzC,MAAM,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;IACxD,OAAO,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,MAAM,EAAE,CAAC,CAAC;AACrC,CAAC"}
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Sign in, sign out, and "who am I".
3
+ *
4
+ * These are the only mutating endpoints Fest exposes over HTTP, so this file
5
+ * also owns the rules that apply to mutation: the origin check, the login
6
+ * throttle, and the audit entries.
7
+ */
8
+ import { authenticate, hasAnyOwner, normaliseEmail } from "../auth/accounts.js";
9
+ import { createSession, revokeSession, serializeCookie, clearedCookie, ABSOLUTE_MS, } from "../auth/session.js";
10
+ import { originAllowed, clientIp, sessionFromRequest } from "../auth/guard.js";
11
+ import { recordAudit } from "../store/audit.js";
12
+ import { log } from "../log.js";
13
+ import { handleOauth } from "./oauth.js";
14
+ import { configuredProviders } from "../auth/oauth.js";
15
+ const MAX_BODY_BYTES = 4 * 1024;
16
+ function json(res, status, body, extra = {}) {
17
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...extra });
18
+ res.end(JSON.stringify(body));
19
+ }
20
+ /**
21
+ * Login throttle, in memory and per (email, ip).
22
+ *
23
+ * In memory because Fest is one process per deployment, and putting this in
24
+ * SQLite would let an unauthenticated caller drive writes into the same
25
+ * single-writer database the metering flush needs. The trade is that a restart
26
+ * clears the counters — acceptable against online guessing, which is what this
27
+ * defends; offline guessing is scrypt's job.
28
+ */
29
+ const ATTEMPT_WINDOW_MS = 15 * 60_000;
30
+ const MAX_ATTEMPTS = 10;
31
+ const attempts = new Map();
32
+ function throttled(key, now) {
33
+ const entry = attempts.get(key);
34
+ if (entry === undefined)
35
+ return false;
36
+ if (now - entry.first > ATTEMPT_WINDOW_MS) {
37
+ attempts.delete(key);
38
+ return false;
39
+ }
40
+ return entry.count >= MAX_ATTEMPTS;
41
+ }
42
+ function noteFailure(key, now) {
43
+ const entry = attempts.get(key);
44
+ if (entry === undefined || now - entry.first > ATTEMPT_WINDOW_MS) {
45
+ attempts.set(key, { count: 1, first: now });
46
+ return;
47
+ }
48
+ entry.count += 1;
49
+ // Unbounded growth is a memory DoS from unauthenticated input.
50
+ if (attempts.size > 10_000)
51
+ attempts.clear();
52
+ }
53
+ export function clearLoginThrottle() {
54
+ attempts.clear();
55
+ }
56
+ async function readJsonBody(req) {
57
+ const chunks = [];
58
+ let size = 0;
59
+ for await (const chunk of req) {
60
+ size += chunk.length;
61
+ if (size > MAX_BODY_BYTES)
62
+ return null;
63
+ chunks.push(chunk);
64
+ }
65
+ try {
66
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
67
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ const str = (v) => (typeof v === "string" ? v : "");
74
+ /**
75
+ * Handle `/api/auth/*`. Returns false when the path is not ours.
76
+ *
77
+ * `session` is passed in rather than re-resolved so there is exactly one place
78
+ * a cookie is turned into an identity per request.
79
+ */
80
+ export async function handleAuth(req, res, path, deps, session) {
81
+ if (!path.startsWith("/api/auth/"))
82
+ return false;
83
+ const method = req.method ?? "GET";
84
+ // OAuth is a redirect-driven, GET-only surface, and Bearer/X-Fest-Token
85
+ // authenticated on /api/auth/identity — neither fits the POST + Origin +
86
+ // cookie-session shape the rest of this file enforces, so it's handled
87
+ // entirely separately.
88
+ if (await handleOauth(req, res, path, { store: deps.store, orgId: deps.orgId, config: deps.config })) {
89
+ return true;
90
+ }
91
+ if (path === "/api/auth/me" && method === "GET") {
92
+ json(res, 200, {
93
+ // Not 401: "am I signed in" is a question an anonymous caller is allowed
94
+ // to ask, and answering it with an error makes the login page's first
95
+ // render look like a failure.
96
+ authenticated: session !== null,
97
+ ...(session === null
98
+ ? {}
99
+ : { user: { id: session.userId, email: session.email, role: session.role } }),
100
+ // The dashboard shows a setup banner rather than a login form when
101
+ // nobody has claimed this deployment yet.
102
+ setupRequired: !hasAnyOwner(deps.store, deps.orgId),
103
+ oauthProviders: configuredProviders(deps.config),
104
+ });
105
+ return true;
106
+ }
107
+ if (method !== "POST") {
108
+ json(res, 405, { error: "method not allowed" });
109
+ return true;
110
+ }
111
+ if (!originAllowed(req)) {
112
+ json(res, 403, { error: "cross-origin request refused" });
113
+ return true;
114
+ }
115
+ if (path === "/api/auth/login") {
116
+ await handleLogin(req, res, deps);
117
+ return true;
118
+ }
119
+ if (path === "/api/auth/logout") {
120
+ if (session !== null) {
121
+ revokeSession(deps.store, session.sessionId);
122
+ recordAudit(deps.store, {
123
+ orgId: deps.orgId,
124
+ actorUserId: session.userId,
125
+ actorLabel: session.email,
126
+ action: "auth.logout",
127
+ outcome: "ok",
128
+ ip: clientIp(req),
129
+ });
130
+ }
131
+ // Cleared unconditionally: a request with a stale cookie should come back
132
+ // without one, whether or not the session was still live.
133
+ json(res, 200, { ok: true }, { "set-cookie": clearedCookie(deps.secureCookies) });
134
+ return true;
135
+ }
136
+ json(res, 404, { error: `no such endpoint: ${path}` });
137
+ return true;
138
+ }
139
+ async function handleLogin(req, res, deps) {
140
+ const body = await readJsonBody(req);
141
+ if (body === null) {
142
+ json(res, 400, { error: "expected a small JSON body" });
143
+ return;
144
+ }
145
+ const email = normaliseEmail(str(body["email"]));
146
+ const password = str(body["password"]);
147
+ const ip = clientIp(req);
148
+ const now = Date.now();
149
+ const key = `${email}|${ip}`;
150
+ if (email === "" || password === "") {
151
+ json(res, 400, { error: "email and password are required" });
152
+ return;
153
+ }
154
+ if (throttled(key, now)) {
155
+ recordAudit(deps.store, {
156
+ orgId: deps.orgId,
157
+ actorLabel: email,
158
+ action: "auth.login",
159
+ outcome: "denied",
160
+ detail: { reason: "throttled" },
161
+ ip,
162
+ });
163
+ // 429 and not 401: a caller being rate-limited needs to know that retrying
164
+ // with better credentials will not help until the window passes.
165
+ json(res, 429, { error: "too many attempts; try again later" });
166
+ return;
167
+ }
168
+ const outcome = await authenticate(deps.store, deps.orgId, email, password);
169
+ if (!outcome.ok || outcome.user === null) {
170
+ noteFailure(key, now);
171
+ // The reason is recorded for the operator and withheld from the client:
172
+ // "no such account" vs "wrong password" is an account enumeration oracle.
173
+ recordAudit(deps.store, {
174
+ orgId: deps.orgId,
175
+ actorLabel: email,
176
+ action: "auth.login",
177
+ outcome: "denied",
178
+ detail: { reason: outcome.reason },
179
+ ip,
180
+ });
181
+ log.warn("login failed", { email, reason: outcome.reason, ip });
182
+ json(res, 401, { error: "invalid email or password" });
183
+ return;
184
+ }
185
+ attempts.delete(key);
186
+ const created = createSession(deps.store, {
187
+ orgId: deps.orgId,
188
+ userId: outcome.user.id,
189
+ userAgent: String(req.headers["user-agent"] ?? ""),
190
+ ip,
191
+ });
192
+ recordAudit(deps.store, {
193
+ orgId: deps.orgId,
194
+ actorUserId: outcome.user.id,
195
+ actorLabel: outcome.user.email,
196
+ action: "auth.login",
197
+ target: created.sessionId,
198
+ outcome: "ok",
199
+ ip,
200
+ });
201
+ json(res, 200, {
202
+ authenticated: true,
203
+ user: { id: outcome.user.id, email: outcome.user.email, role: outcome.user.role },
204
+ setupRequired: false,
205
+ oauthProviders: configuredProviders(deps.config),
206
+ }, {
207
+ "set-cookie": serializeCookie(created.raw, {
208
+ secure: deps.secureCookies,
209
+ maxAgeSeconds: Math.floor(ABSOLUTE_MS / 1000),
210
+ }),
211
+ });
212
+ }
213
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../../../server/api/auth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `GET /api/live` — server-sent events for the dashboard's live feed.
3
+ *
4
+ * Subscribes to the flush-time bus, so nothing here can ever run on a
5
+ * developer's request path. See `ingest/live-bus.ts` for why that seam exists.
6
+ *
7
+ * Backpressure: a browser tab that stops reading makes the kernel socket buffer
8
+ * fill, and `res.write` starts queueing in userspace. Rather than let that grow,
9
+ * the subscriber refuses a frame once `writableLength` exceeds a modest high
10
+ * water mark and returns false, which the bus counts as a drop. A dashboard
11
+ * that falls behind loses the tail of its live view and keeps its authoritative
12
+ * history in `/api/requests` — that trade is the right way round.
13
+ *
14
+ * The anti-buffering headers are the same set the proxy path uses; without them
15
+ * a reverse proxy will happily hold these frames for a minute and the feed will
16
+ * look broken in exactly the deployment where you cannot debug it.
17
+ */
18
+ /** Beyond this many bytes pending, the client is not keeping up. */
19
+ const HIGH_WATER_MARK = 1 << 20;
20
+ const HEARTBEAT_MS = 20_000;
21
+ /**
22
+ * Project a usage record onto the feed row the dashboard renders.
23
+ *
24
+ * Explicit field-by-field, not a spread: `UsageRecord` carries fields the
25
+ * dashboard has no business seeing (`callerFingerprint`, `tokenId`,
26
+ * `errorMessage`, upstream ids), and a spread would ship every future field
27
+ * added to the record straight to the browser. Enumerating them means a new
28
+ * record field is invisible until someone decides it should be visible.
29
+ */
30
+ export function toLiveRow(rec) {
31
+ return {
32
+ seq: null,
33
+ id: rec.id,
34
+ startedAt: rec.startedAt,
35
+ userId: rec.userId,
36
+ sessionId: rec.sessionId,
37
+ requestedModel: rec.requestedModel,
38
+ servedModel: rec.servedModel,
39
+ posture: rec.posture,
40
+ credentialOrigin: rec.credentialOrigin,
41
+ credentialFingerprint: rec.credentialFingerprint,
42
+ status: rec.status,
43
+ httpStatus: rec.httpStatus,
44
+ errorType: rec.errorType ?? null,
45
+ stream: rec.stream,
46
+ partial: rec.partial,
47
+ usage: rec.usage,
48
+ costUsd: rec.costUsd,
49
+ costBasis: rec.costBasis,
50
+ notionalCostUsd: rec.notionalCostUsd,
51
+ ttfbMs: rec.ttfbMs,
52
+ durationMs: rec.durationMs,
53
+ rl5hUtilization: rec.rateLimit?.fiveHourUtilization ?? null,
54
+ rlClaim: rec.rateLimit?.representativeClaim ?? null,
55
+ clientVersion: rec.clientVersion,
56
+ pipeline: rec.pipeline,
57
+ routeId: rec.routeId,
58
+ credentialsConsidered: rec.credentialsConsidered,
59
+ };
60
+ }
61
+ export function handleLive(req, res, bus) {
62
+ res.writeHead(200, {
63
+ "content-type": "text/event-stream",
64
+ "cache-control": "no-cache, no-transform",
65
+ connection: "keep-alive",
66
+ "x-accel-buffering": "no",
67
+ });
68
+ res.socket?.setNoDelay(true);
69
+ res.flushHeaders();
70
+ // An immediate comment gives EventSource something to see, so the browser
71
+ // reports "open" rather than sitting in "connecting" until the first request
72
+ // happens to arrive — which on a quiet gateway could be hours.
73
+ res.write(": connected\n\n");
74
+ const unsubscribe = bus.subscribe((records) => {
75
+ if (res.writableEnded)
76
+ return false;
77
+ if (res.writableLength > HIGH_WATER_MARK)
78
+ return false;
79
+ const frame = { rows: records.map(toLiveRow) };
80
+ res.write(`event: usage\ndata: ${JSON.stringify(frame)}\n\n`);
81
+ return true;
82
+ },
83
+ // Shutdown hook: an event stream never ends on its own, so the server must
84
+ // be able to hang up on us or `server.close()` waits forever.
85
+ () => {
86
+ if (!res.writableEnded) {
87
+ // A comment, not an error: the client's EventSource will reconnect on
88
+ // its own once the new process is listening, which under --watch is
89
+ // about a second.
90
+ res.write(": server shutting down\n\n");
91
+ res.end();
92
+ }
93
+ });
94
+ // Proxies and load balancers close idle connections; a comment costs three
95
+ // bytes and keeps the stream alive through them.
96
+ const heartbeat = setInterval(() => {
97
+ if (!res.writableEnded)
98
+ res.write(": ping\n\n");
99
+ }, HEARTBEAT_MS);
100
+ heartbeat.unref();
101
+ const close = () => {
102
+ clearInterval(heartbeat);
103
+ unsubscribe();
104
+ };
105
+ res.on("close", close);
106
+ req.on("aborted", close);
107
+ }
108
+ //# sourceMappingURL=live.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live.js","sourceRoot":"","sources":["../../../server/api/live.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `GET /v1/models` — the model menu Claude Code shows under `/model`.
3
+ *
4
+ * Claude Code fetches `{base}/v1/models?limit=1000`, expects
5
+ * `{"data":[{id, display_name?, description?}]}`, and renders the result
6
+ * labelled "From gateway". Three details, all verified against the client and
7
+ * all easy to get wrong:
8
+ *
9
+ * 1. **Ids are filtered client-side by `/(claude|anthropic)/i`.** A menu entry
10
+ * whose id does not match that pattern is silently dropped. So a route to
11
+ * `accounts/fireworks/models/kimi-k2p7-code` must be published under the id
12
+ * the developer actually types — the route's `match` — not the provider's
13
+ * id. That is also the correct thing semantically: `match` is what the
14
+ * client sends, and the rewrite is Fest's business, not theirs.
15
+ * 2. **The default timeout is 3 seconds.** This handler therefore touches no
16
+ * network and no database — it is a projection of already-parsed config.
17
+ * 3. **The response is cached by the client** at `<cache>/gateway-models.json`
18
+ * (mode 0600), so a stale menu outlives a restart. `ETag` is served from the
19
+ * routing table's content hash to make that cache correct rather than
20
+ * merely fast.
21
+ * 4. **Entries are DEDUPED against the client's built-in list.** Publishing
22
+ * `claude-sonnet-5` collides with the built-in Sonnet and is dropped
23
+ * silently — so a substituted model never shows its destination in the
24
+ * picker, which is the one place a developer would notice. Verified by
25
+ * reading the merge in 2.1.278: gateway options are added only
26
+ * `if(!s.some((he)=>uT(he,U)))`. A route's `expose` alias exists to survive
27
+ * that: a distinct id like `claude-sonnet-5-fireworks` appears, labelled,
28
+ * and routes to the same place.
29
+ *
30
+ * ⚠ Discovery is also gated on **`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`**
31
+ * being set on the CLIENT. Without it the client never calls this endpoint at
32
+ * all — confirmed empirically (zero requests) and in the binary
33
+ * (`[Bootstrap] Skipped gateway /v1/models (… not set)`).
34
+ *
35
+ * ⚠ Discovery only happens in the KEY posture. It requires a credential in
36
+ * `ANTHROPIC_AUTH_TOKEN` / `apiKeyHelper` / an API key — which is precisely what
37
+ * disables subscription auth. In 2.1.278 a server-published menu and
38
+ * subscription pass-through are mutually exclusive. Do not design around having
39
+ * both.
40
+ */
41
+ // The menu is reached ONLY in the key posture — discovery requires a credential
42
+ // that disables subscription auth — so the resolver that assumes no caller
43
+ // credential is the one the request path will actually use. Using the plain
44
+ // `resolveRoute` here would omit every model served by the `anthropic` default,
45
+ // i.e. advertise less than the gateway can serve.
46
+ import { resolveWithoutCallerCredential } from "../routes/resolve.js";
47
+ // Anthropic's own current models, offered as menu candidates when a route does
48
+ // not already cover them. Shared with the `fest claude` preflight, which warns
49
+ // about any of them this gateway cannot serve — one list, so the menu and the
50
+ // warning cannot drift apart.
51
+ import { BASE_MODELS } from "../../shared/base-models.js";
52
+ /** Claude Code drops any id that does not look Anthropic-ish. */
53
+ const CLIENT_VISIBLE = /(claude|anthropic)/i;
54
+ /**
55
+ * The menu must advertise only what this gateway can actually serve.
56
+ *
57
+ * `/v1/models` is reached ONLY in the key posture — discovery requires a
58
+ * credential that disables subscription auth — and in that posture there is no
59
+ * caller credential to fall back on. So any published id without a route to a
60
+ * server-held credential is a guaranteed failure the moment someone selects it.
61
+ *
62
+ * The first version published Anthropic's built-in models unconditionally
63
+ * "so routing never loses Opus". That was exactly backwards: it produced a menu
64
+ * entry that 401s on selection. A menu is a promise, and this one must only
65
+ * promise what it can keep.
66
+ */
67
+ export function buildModelMenu(table) {
68
+ const candidates = new Map();
69
+ for (const model of BASE_MODELS)
70
+ candidates.set(model.id, model.name);
71
+ for (const route of table.routes) {
72
+ if (!route.match.includes("*"))
73
+ candidates.set(route.match, candidates.get(route.match) ?? route.match);
74
+ // An alias exists precisely to survive the client's dedupe, so it is always
75
+ // a candidate.
76
+ if (route.expose !== null)
77
+ candidates.set(route.expose, route.expose);
78
+ }
79
+ const entries = [];
80
+ for (const [id, name] of candidates) {
81
+ // Client-side, Claude Code drops any id not matching /(claude|anthropic)/i,
82
+ // so publishing one would make the entry vanish rather than appear.
83
+ if (!CLIENT_VISIBLE.test(id))
84
+ continue;
85
+ // Resolve through the REAL router rather than re-implementing matching, so
86
+ // the menu and the request path cannot disagree.
87
+ const decision = resolveWithoutCallerCredential(table, id);
88
+ // Unservable in this posture — do not offer it.
89
+ if (decision.pipeline !== "substitute" || decision.upstream === null)
90
+ continue;
91
+ entries.push({
92
+ type: "model",
93
+ id,
94
+ // Naming the destination is the point. A developer choosing "Sonnet 5"
95
+ // and silently getting Kimi is the substitution this project exists to
96
+ // prevent, and the model picker is the first place they would not notice.
97
+ display_name: `${name} → ${decision.upstream.id}`,
98
+ created_at: "1970-01-01T00:00:00Z",
99
+ });
100
+ }
101
+ return entries;
102
+ }
103
+ export function handleModels(res, table, ifNoneMatch) {
104
+ const models = buildModelMenu(table);
105
+ // Nothing to offer: 404 rather than an empty list. The client treats 404 as
106
+ // "no gateway menu" and falls back to its own built-in models — which is both
107
+ // documented behaviour and the right outcome, since an empty `data` array
108
+ // would leave a developer with a picker that offers nothing.
109
+ if (models.length === 0) {
110
+ res.writeHead(404, { "content-type": "application/json", "x-should-retry": "false" });
111
+ res.end(JSON.stringify({ type: "error", error: { type: "not_found_error", message: "no gateway models configured" } }));
112
+ return;
113
+ }
114
+ const etag = `"models-${table.version}"`;
115
+ if (ifNoneMatch === etag) {
116
+ res.writeHead(304, { etag });
117
+ res.end();
118
+ return;
119
+ }
120
+ res.writeHead(200, {
121
+ "content-type": "application/json",
122
+ etag,
123
+ // The menu changes only when routing config does, and the client caches it
124
+ // to disk anyway.
125
+ "cache-control": "no-cache",
126
+ });
127
+ res.end(JSON.stringify({ data: models, has_more: false, first_id: null, last_id: null }));
128
+ }
129
+ //# sourceMappingURL=models.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"models.js","sourceRoot":"","sources":["../../../server/api/models.ts"],"names":[],"mappings":"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"}