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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/cli/claude.js +326 -0
- package/dist/cli/claude.js.map +1 -0
- package/dist/cli/config.js +59 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/login.js +130 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/logout.js +20 -0
- package/dist/cli/logout.js.map +1 -0
- package/dist/cli/whoami.js +34 -0
- package/dist/cli/whoami.js.map +1 -0
- package/dist/server/adapters/anthropic.js +74 -0
- package/dist/server/adapters/anthropic.js.map +1 -0
- package/dist/server/adapters/fireworks.js +54 -0
- package/dist/server/adapters/fireworks.js.map +1 -0
- package/dist/server/adapters/index.js +16 -0
- package/dist/server/adapters/index.js.map +1 -0
- package/dist/server/adapters/registry.js +38 -0
- package/dist/server/adapters/registry.js.map +1 -0
- package/dist/server/adapters/rewrite.js +152 -0
- package/dist/server/adapters/rewrite.js.map +1 -0
- package/dist/server/adapters/url.js +24 -0
- package/dist/server/adapters/url.js.map +1 -0
- package/dist/server/api/auth.js +213 -0
- package/dist/server/api/auth.js.map +1 -0
- package/dist/server/api/live.js +108 -0
- package/dist/server/api/live.js.map +1 -0
- package/dist/server/api/models.js +129 -0
- package/dist/server/api/models.js.map +1 -0
- package/dist/server/api/oauth.js +300 -0
- package/dist/server/api/oauth.js.map +1 -0
- package/dist/server/api/routes.js +164 -0
- package/dist/server/api/routes.js.map +1 -0
- package/dist/server/api/routing-view.js +47 -0
- package/dist/server/api/routing-view.js.map +1 -0
- package/dist/server/auth/accounts.js +111 -0
- package/dist/server/auth/accounts.js.map +1 -0
- package/dist/server/auth/gateway-401.js +85 -0
- package/dist/server/auth/gateway-401.js.map +1 -0
- package/dist/server/auth/guard.js +74 -0
- package/dist/server/auth/guard.js.map +1 -0
- package/dist/server/auth/oauth.js +43 -0
- package/dist/server/auth/oauth.js.map +1 -0
- package/dist/server/auth/password.js +104 -0
- package/dist/server/auth/password.js.map +1 -0
- package/dist/server/auth/posture.js +145 -0
- package/dist/server/auth/posture.js.map +1 -0
- package/dist/server/auth/session.js +161 -0
- package/dist/server/auth/session.js.map +1 -0
- package/dist/server/bin/fest.js +589 -0
- package/dist/server/bin/fest.js.map +1 -0
- package/dist/server/config.js +109 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/credentials/provider.js +70 -0
- package/dist/server/credentials/provider.js.map +1 -0
- package/dist/server/credentials/resolve.js +89 -0
- package/dist/server/credentials/resolve.js.map +1 -0
- package/dist/server/http/body.js +66 -0
- package/dist/server/http/body.js.map +1 -0
- package/dist/server/http/errors.js +144 -0
- package/dist/server/http/errors.js.map +1 -0
- package/dist/server/http/headers.js +189 -0
- package/dist/server/http/headers.js.map +1 -0
- package/dist/server/http/pipe.js +97 -0
- package/dist/server/http/pipe.js.map +1 -0
- package/dist/server/http/server.js +226 -0
- package/dist/server/http/server.js.map +1 -0
- package/dist/server/http/sse.js +167 -0
- package/dist/server/http/sse.js.map +1 -0
- package/dist/server/http/static.js +138 -0
- package/dist/server/http/static.js.map +1 -0
- package/dist/server/ingest/live-bus.js +67 -0
- package/dist/server/ingest/live-bus.js.map +1 -0
- package/dist/server/ingest/sink.js +141 -0
- package/dist/server/ingest/sink.js.map +1 -0
- package/dist/server/log.js +31 -0
- package/dist/server/log.js.map +1 -0
- package/dist/server/pipeline/count-tokens.js +48 -0
- package/dist/server/pipeline/count-tokens.js.map +1 -0
- package/dist/server/pipeline/dispatch.js +138 -0
- package/dist/server/pipeline/dispatch.js.map +1 -0
- package/dist/server/pipeline/passthrough.js +392 -0
- package/dist/server/pipeline/passthrough.js.map +1 -0
- package/dist/server/pipeline/preflight.js +66 -0
- package/dist/server/pipeline/preflight.js.map +1 -0
- package/dist/server/pipeline/record.js +49 -0
- package/dist/server/pipeline/record.js.map +1 -0
- package/dist/server/pipeline/substitute.js +287 -0
- package/dist/server/pipeline/substitute.js.map +1 -0
- package/dist/server/routes/resolve.js +176 -0
- package/dist/server/routes/resolve.js.map +1 -0
- package/dist/server/routes/table.js +247 -0
- package/dist/server/routes/table.js.map +1 -0
- package/dist/server/routes/watch.js +101 -0
- package/dist/server/routes/watch.js.map +1 -0
- package/dist/server/secret/fingerprint.js +107 -0
- package/dist/server/secret/fingerprint.js.map +1 -0
- package/dist/server/secret/non-persistable.js +69 -0
- package/dist/server/secret/non-persistable.js.map +1 -0
- package/dist/server/store/audit.js +53 -0
- package/dist/server/store/audit.js.map +1 -0
- package/dist/server/store/bootstrap.js +53 -0
- package/dist/server/store/bootstrap.js.map +1 -0
- package/dist/server/store/db.js +157 -0
- package/dist/server/store/db.js.map +1 -0
- package/dist/server/store/ids.js +14 -0
- package/dist/server/store/ids.js.map +1 -0
- package/dist/server/store/migrations/001-init.sql +213 -0
- package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
- package/dist/server/store/migrations/003-routing.sql +42 -0
- package/dist/server/store/migrations/004-admin-auth.sql +72 -0
- package/dist/server/store/migrations/005-notional-cost.sql +28 -0
- package/dist/server/store/queries.js +904 -0
- package/dist/server/store/queries.js.map +1 -0
- package/dist/server/store/retention.js +79 -0
- package/dist/server/store/retention.js.map +1 -0
- package/dist/server/store/seed.js +203 -0
- package/dist/server/store/seed.js.map +1 -0
- package/dist/server/store/tokens.js +133 -0
- package/dist/server/store/tokens.js.map +1 -0
- package/dist/server/store/write.js +330 -0
- package/dist/server/store/write.js.map +1 -0
- package/dist/server/usage/accumulator.js +209 -0
- package/dist/server/usage/accumulator.js.map +1 -0
- package/dist/server/usage/cost.js +57 -0
- package/dist/server/usage/cost.js.map +1 -0
- package/dist/server/usage/prices/catalog.js +136 -0
- package/dist/server/usage/prices/catalog.js.map +1 -0
- package/dist/server/usage/prices/refresh.js +125 -0
- package/dist/server/usage/prices/refresh.js.map +1 -0
- package/dist/server/usage/prices/snapshot.json.gz +0 -0
- package/dist/server/usage/prices/table.js +102 -0
- package/dist/server/usage/prices/table.js.map +1 -0
- package/dist/server/usage/pricing.js +103 -0
- package/dist/server/usage/pricing.js.map +1 -0
- package/dist/shared/api.js +19 -0
- package/dist/shared/api.js.map +1 -0
- package/dist/shared/base-models.js +28 -0
- package/dist/shared/base-models.js.map +1 -0
- package/dist/shared/demotion-vars.js +17 -0
- package/dist/shared/demotion-vars.js.map +1 -0
- package/dist/shared/series.js +50 -0
- package/dist/shared/series.js.map +1 -0
- package/dist/shared/types.js +40 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
- package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/web/dist/index.html +44 -0
- package/dist/web/dist/nearform-icon.svg +4 -0
- package/package.json +82 -0
- 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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"}
|