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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choose a pipeline for one request, and hand off.
|
|
3
|
+
*
|
|
4
|
+
* The fork lives here rather than inside either pipeline so that neither can
|
|
5
|
+
* reach into the other. `passthrough.ts` has no idea routing exists;
|
|
6
|
+
* `substitute.ts` has no idea a subscription exists. That separation is what
|
|
7
|
+
* keeps "bytes are forwarded verbatim on the subscription path" true by
|
|
8
|
+
* construction rather than by discipline.
|
|
9
|
+
*
|
|
10
|
+
* Order of operations matters and is deliberate:
|
|
11
|
+
*
|
|
12
|
+
* 1. identity — who is asking (both paths need this)
|
|
13
|
+
* 2. body — read as opaque bytes, inspected only via a copy
|
|
14
|
+
* 3. route — on the requested model
|
|
15
|
+
* 4. credential — resolved for the chosen route, refusing rather than
|
|
16
|
+
* falling back
|
|
17
|
+
* 5. dispatch
|
|
18
|
+
*
|
|
19
|
+
* Routing happens AFTER the body is read because the model id lives in the
|
|
20
|
+
* body, not the URL. That is also why a request whose model cannot be read
|
|
21
|
+
* stays on the pass-through path: routing a request we could not identify is
|
|
22
|
+
* how traffic ends up somewhere nobody intended.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { detectInbound } from "../auth/posture.js";
|
|
26
|
+
import { isSubscriptionCredential } from "../secret/fingerprint.js";
|
|
27
|
+
import { readBodyBytes, isTooLarge, peekRequest } from "../http/body.js";
|
|
28
|
+
import { anthropicError, statusForErrorType } from "../http/errors.js";
|
|
29
|
+
import { resolveRoute, resolveWithoutCallerCredential, defaultAnthropicUpstream, } from "../routes/resolve.js";
|
|
30
|
+
import { resolveCredential } from "../credentials/resolve.js";
|
|
31
|
+
import { handleMessages } from "./passthrough.js";
|
|
32
|
+
import { handleSubstitute, refuse } from "./substitute.js";
|
|
33
|
+
import { fireworksAdapter } from "../adapters/fireworks.js";
|
|
34
|
+
import { anthropicAdapter } from "../adapters/anthropic.js";
|
|
35
|
+
import { log } from "../log.js";
|
|
36
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
37
|
+
const ADAPTERS = {
|
|
38
|
+
fireworks: fireworksAdapter,
|
|
39
|
+
anthropic: anthropicAdapter,
|
|
40
|
+
};
|
|
41
|
+
export async function dispatchMessages(req, res, ctx) {
|
|
42
|
+
// The common case by far, and the one that must stay cheapest: no routing
|
|
43
|
+
// config at all means there is nothing to decide, so do not read the body
|
|
44
|
+
// twice or construct a decision just to discard it.
|
|
45
|
+
//
|
|
46
|
+
// An `anthropic` upstream counts as routing config even with no routes
|
|
47
|
+
// pointing at it — it is the default destination for Anthropic models in the
|
|
48
|
+
// key posture, so there IS a decision to make.
|
|
49
|
+
if (ctx.routes.routes.length === 0 && defaultAnthropicUpstream(ctx.routes) === null) {
|
|
50
|
+
await handleMessages(req, res, ctx);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const inbound = detectInbound(req.url ?? "/", req.headers);
|
|
54
|
+
const body = await readBodyBytes(req, MAX_BODY_BYTES);
|
|
55
|
+
if (isTooLarge(body)) {
|
|
56
|
+
const type = "invalid_request_error";
|
|
57
|
+
res.writeHead(statusForErrorType(type), { "content-type": "application/json" });
|
|
58
|
+
res.end(anthropicError(type, `Fest: request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const peek = peekRequest(body);
|
|
62
|
+
/**
|
|
63
|
+
* With no caller credential there is nothing to pass through, so an Anthropic
|
|
64
|
+
* model no route claims falls back to the `anthropic` upstream rather than to
|
|
65
|
+
* a first-message failure. With one, routing is posture-blind as before —
|
|
66
|
+
* the developer's own credential serves anything unrouted, and nothing
|
|
67
|
+
* diverts it onto org spend.
|
|
68
|
+
*/
|
|
69
|
+
const decision = inbound.upstreamCredential === null
|
|
70
|
+
? resolveWithoutCallerCredential(ctx.routes, peek.model)
|
|
71
|
+
: resolveRoute(ctx.routes, peek.model);
|
|
72
|
+
if (decision.pipeline === "passthrough") {
|
|
73
|
+
// Hand the already-read bytes on rather than re-reading a consumed stream.
|
|
74
|
+
await handleMessages(req, res, { ...ctx, routeId: decision.route?.id ?? null }, body);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// ── substitute ──────────────────────────────────────────────────────────────
|
|
78
|
+
const resolved = ctx.resolveIdentity(inbound.identityToken);
|
|
79
|
+
if (resolved !== null)
|
|
80
|
+
ctx.touchToken?.(resolved.tokenId);
|
|
81
|
+
const isSubscription = inbound.upstreamCredential !== null && isSubscriptionCredential(inbound.upstreamCredential);
|
|
82
|
+
const credential = resolveCredential(decision, inbound.upstreamCredential === null
|
|
83
|
+
? null
|
|
84
|
+
: { fingerprint: inbound.upstreamCredential.fingerprint, isSubscription }, ctx.secrets);
|
|
85
|
+
const base = {
|
|
86
|
+
id: randomUUID(),
|
|
87
|
+
startedAt: Date.now(),
|
|
88
|
+
posture: inbound.posture,
|
|
89
|
+
identityCarrier: inbound.identity.carrier,
|
|
90
|
+
callerFingerprint: inbound.identity.tokenFingerprint,
|
|
91
|
+
userId: resolved?.userId ?? null,
|
|
92
|
+
tokenId: resolved?.tokenId ?? null,
|
|
93
|
+
// The credential that SERVED the request is the server's, so the inbound
|
|
94
|
+
// fingerprint is not the right thing to record here. It is null rather
|
|
95
|
+
// than the caller's, because recording the caller's would read as though
|
|
96
|
+
// theirs was used.
|
|
97
|
+
credentialFingerprint: null,
|
|
98
|
+
// A server-held credential is org spend, always. This is the field the
|
|
99
|
+
// posture screen keys off.
|
|
100
|
+
credentialOrigin: "fallback_server",
|
|
101
|
+
sessionId: headerValue(req, "x-claude-code-session-id"),
|
|
102
|
+
clientVersion: headerValue(req, "user-agent"),
|
|
103
|
+
};
|
|
104
|
+
if (!credential.ok) {
|
|
105
|
+
log.warn("substitute refused: no server credential", {
|
|
106
|
+
route: decision.route?.id ?? null,
|
|
107
|
+
upstream: decision.upstream?.id ?? null,
|
|
108
|
+
model: decision.requestedModel,
|
|
109
|
+
});
|
|
110
|
+
refuse(res, ctx.sink, base, decision, credential.considered, credential.message);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const adapter = ADAPTERS[decision.upstream?.adapter ?? ""];
|
|
114
|
+
if (adapter === undefined || credential.secret === null) {
|
|
115
|
+
// Unreachable through validated config; refusing beats guessing.
|
|
116
|
+
refuse(res, ctx.sink, base, decision, credential.considered, `Fest: no adapter for upstream ${JSON.stringify(decision.upstream?.id ?? "?")}.`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await handleSubstitute(req, res, {
|
|
120
|
+
base,
|
|
121
|
+
decision,
|
|
122
|
+
adapter,
|
|
123
|
+
secret: credential.secret,
|
|
124
|
+
considered: credential.considered,
|
|
125
|
+
body,
|
|
126
|
+
path: inbound.effectivePath,
|
|
127
|
+
betas: headerValue(req, "anthropic-beta"),
|
|
128
|
+
stream: peek.stream,
|
|
129
|
+
sink: ctx.sink,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function headerValue(req, name) {
|
|
133
|
+
const v = req.headers[name];
|
|
134
|
+
if (v === undefined)
|
|
135
|
+
return null;
|
|
136
|
+
return Array.isArray(v) ? (v[0] ?? null) : v;
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=dispatch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dispatch.js","sourceRoot":"","sources":["../../../server/pipeline/dispatch.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pass-through pipeline: relay a request to Anthropic unchanged, and
|
|
3
|
+
* observe it on the way past.
|
|
4
|
+
*
|
|
5
|
+
* This pipeline deliberately does NOT use a provider-adapter interface.
|
|
6
|
+
* Adapters exist to transform requests; this path exists precisely not to. On
|
|
7
|
+
* the subscription posture the developer's own OAuth bearer is forwarded and
|
|
8
|
+
* Anthropic validates the request shape, so every byte of the body and every
|
|
9
|
+
* inbound header is sacred. Keeping this as its own pipeline — rather than a
|
|
10
|
+
* "no-op adapter" — is what makes that rule enforceable instead of aspirational,
|
|
11
|
+
* and leaves a future routing/rewriting path free to grow without endangering
|
|
12
|
+
* it.
|
|
13
|
+
*
|
|
14
|
+
* What Fest is allowed to do here: authenticate the caller, relay bytes, tee
|
|
15
|
+
* the stream for metering, and record what happened.
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { EMPTY_USAGE } from "../../shared/types.js";
|
|
19
|
+
import { detectInbound } from "../auth/posture.js";
|
|
20
|
+
import { buildUpstreamHeaders, buildDownstreamHeaders, parseRateLimit } from "../http/headers.js";
|
|
21
|
+
import { anthropicError, statusForErrorType, sseErrorEvent, describeUpstreamFailure, } from "../http/errors.js";
|
|
22
|
+
import { readBodyBytes, isTooLarge, peekRequest } from "../http/body.js";
|
|
23
|
+
import { createSseParser } from "../http/sse.js";
|
|
24
|
+
import { createUsageAccumulator, usageFromJson } from "../usage/accumulator.js";
|
|
25
|
+
import { priceUsage } from "../usage/pricing.js";
|
|
26
|
+
import { pipeWithTee, beginStream } from "../http/pipe.js";
|
|
27
|
+
import { isSubscriptionCredential } from "../secret/fingerprint.js";
|
|
28
|
+
import { badIdentity, noIdentity, noUpstreamCredential } from "../auth/gateway-401.js";
|
|
29
|
+
import { isWarmupPreflightRefusal } from "./preflight.js";
|
|
30
|
+
import { log } from "../log.js";
|
|
31
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
32
|
+
function credentialOrigin(hasSubscription, hasKey) {
|
|
33
|
+
if (hasSubscription)
|
|
34
|
+
return "inbound_subscription";
|
|
35
|
+
if (hasKey)
|
|
36
|
+
return "inbound_key";
|
|
37
|
+
return "none";
|
|
38
|
+
}
|
|
39
|
+
export async function handleMessages(req, res, ctx,
|
|
40
|
+
/**
|
|
41
|
+
* Bytes already read from the request, when the dispatcher had to read them
|
|
42
|
+
* to see the model id. The stream can only be consumed once, so re-reading
|
|
43
|
+
* here would hang. Still the SAME bytes — the dispatcher inspects a copy and
|
|
44
|
+
* never rewrites on this path.
|
|
45
|
+
*/
|
|
46
|
+
preRead) {
|
|
47
|
+
const startedAt = Date.now();
|
|
48
|
+
const id = randomUUID();
|
|
49
|
+
const inbound = detectInbound(req.url ?? "/", req.headers);
|
|
50
|
+
const sessionId = headerValue(req, "x-claude-code-session-id");
|
|
51
|
+
const clientVersion = headerValue(req, "user-agent");
|
|
52
|
+
const credentialsConsidered = inbound.upstreamCredential === null
|
|
53
|
+
? [{ source: "inbound", result: "missing", reason: "no credential on the request" }]
|
|
54
|
+
: [
|
|
55
|
+
{
|
|
56
|
+
source: isSubscriptionCredential(inbound.upstreamCredential)
|
|
57
|
+
? "inbound_subscription"
|
|
58
|
+
: "inbound_key",
|
|
59
|
+
result: "used",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
// Resolve identity before anything else, but note the two failure modes are
|
|
63
|
+
// NOT the same:
|
|
64
|
+
// * a token was presented and did not resolve -> always an error, because
|
|
65
|
+
// the developer believes they are attributed and they are not;
|
|
66
|
+
// * no token at all -> an error only when the deployment requires one.
|
|
67
|
+
// Conflating them would let a revoked token silently degrade to
|
|
68
|
+
// "unattributed" instead of telling the developer their token is dead.
|
|
69
|
+
const presentedToken = inbound.identityToken;
|
|
70
|
+
const resolved = ctx.resolveIdentity(presentedToken);
|
|
71
|
+
if (resolved !== null)
|
|
72
|
+
ctx.touchToken?.(resolved.tokenId);
|
|
73
|
+
const finish = (partial) => {
|
|
74
|
+
const rec = {
|
|
75
|
+
id,
|
|
76
|
+
startedAt,
|
|
77
|
+
endedAt: Date.now(),
|
|
78
|
+
posture: inbound.posture,
|
|
79
|
+
identityCarrier: inbound.identity.carrier,
|
|
80
|
+
callerFingerprint: inbound.identity.tokenFingerprint,
|
|
81
|
+
userId: resolved?.userId ?? null,
|
|
82
|
+
tokenId: resolved?.tokenId ?? null,
|
|
83
|
+
credentialFingerprint: inbound.upstreamCredential?.fingerprint ?? null,
|
|
84
|
+
credentialOrigin: credentialOrigin(inbound.upstreamCredential !== null && isSubscriptionCredential(inbound.upstreamCredential), inbound.upstreamCredential !== null && !isSubscriptionCredential(inbound.upstreamCredential)),
|
|
85
|
+
sessionId,
|
|
86
|
+
requestedModel: null,
|
|
87
|
+
servedModel: null,
|
|
88
|
+
upstream: ctx.upstreamBaseUrl,
|
|
89
|
+
stream: false,
|
|
90
|
+
httpStatus: null,
|
|
91
|
+
partial: false,
|
|
92
|
+
usage: EMPTY_USAGE,
|
|
93
|
+
costUsd: null,
|
|
94
|
+
costBasis: "none",
|
|
95
|
+
notionalCostUsd: null,
|
|
96
|
+
ttfbMs: null,
|
|
97
|
+
durationMs: Date.now() - startedAt,
|
|
98
|
+
bytesIn: 0,
|
|
99
|
+
bytesOut: 0,
|
|
100
|
+
upstreamRequestId: null,
|
|
101
|
+
rateLimit: null,
|
|
102
|
+
clientVersion,
|
|
103
|
+
pipeline: "passthrough",
|
|
104
|
+
routeId: ctx.routeId ?? null,
|
|
105
|
+
// Recorded even though this path never chooses: a record that says
|
|
106
|
+
// "the caller's own subscription served this, nothing else was tried"
|
|
107
|
+
// is what makes the absence of a substitution provable rather than
|
|
108
|
+
// assumed.
|
|
109
|
+
credentialsConsidered,
|
|
110
|
+
...partial,
|
|
111
|
+
};
|
|
112
|
+
// Fire and forget: metering must never delay or fail a request.
|
|
113
|
+
ctx.sink.record(rec);
|
|
114
|
+
};
|
|
115
|
+
const denyIdentity = (message) => {
|
|
116
|
+
const type = "authentication_error";
|
|
117
|
+
finish({ status: "identity_denied", httpStatus: statusForErrorType(type) });
|
|
118
|
+
res.writeHead(statusForErrorType(type), {
|
|
119
|
+
"content-type": "application/json",
|
|
120
|
+
// Nothing a retry can fix; the client honours this ahead of its own
|
|
121
|
+
// status-code rules, so say so rather than letting it back off ten times.
|
|
122
|
+
"x-should-retry": "false",
|
|
123
|
+
});
|
|
124
|
+
// Claude Code renders error.message to the developer, so this string is
|
|
125
|
+
// the user-facing UI. Write it for a human who is mid-task.
|
|
126
|
+
res.end(anthropicError(type, message));
|
|
127
|
+
};
|
|
128
|
+
if (presentedToken !== null && resolved === null) {
|
|
129
|
+
denyIdentity(badIdentity().message);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Identity is separate from the upstream credential and is never forwarded.
|
|
133
|
+
// Without it usage cannot be attributed, which for a team gateway defeats
|
|
134
|
+
// the point — so a team deployment sets FEST_REQUIRE_IDENTITY=1.
|
|
135
|
+
if (ctx.requireIdentity && resolved === null) {
|
|
136
|
+
denyIdentity(noIdentity(ctx.publicBaseUrl ?? "https://<fest>").message);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const bodyOrTooLarge = preRead ?? (await readBodyBytes(req, MAX_BODY_BYTES));
|
|
140
|
+
if (isTooLarge(bodyOrTooLarge)) {
|
|
141
|
+
const type = "invalid_request_error";
|
|
142
|
+
finish({ status: "bad_request", httpStatus: statusForErrorType(type) });
|
|
143
|
+
res.writeHead(statusForErrorType(type), { "content-type": "application/json" });
|
|
144
|
+
res.end(anthropicError(type, `Fest: request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const body = bodyOrTooLarge;
|
|
148
|
+
const peek = peekRequest(body);
|
|
149
|
+
const isSubscription = inbound.upstreamCredential !== null && isSubscriptionCredential(inbound.upstreamCredential);
|
|
150
|
+
// Key posture with a Fest token and nothing to pay with: the credential
|
|
151
|
+
// position holds our OWN token, which we will not forward, and no route
|
|
152
|
+
// claimed this model. Anthropic's 401 would be accurate but useless — it
|
|
153
|
+
// would describe a credential the developer never sent. Checked here, after
|
|
154
|
+
// the body, so the message can name the model that has no home.
|
|
155
|
+
if (inbound.upstreamCredential === null && inbound.identity.carrier === "auth_header") {
|
|
156
|
+
const failure = noUpstreamCredential(peek.model);
|
|
157
|
+
finish({
|
|
158
|
+
status: "bad_request",
|
|
159
|
+
httpStatus: failure.status,
|
|
160
|
+
requestedModel: peek.model,
|
|
161
|
+
bytesIn: body.byteLength,
|
|
162
|
+
errorType: failure.type,
|
|
163
|
+
});
|
|
164
|
+
res.writeHead(failure.status, {
|
|
165
|
+
"content-type": "application/json",
|
|
166
|
+
"x-should-retry": String(failure.retryable),
|
|
167
|
+
});
|
|
168
|
+
res.end(anthropicError(failure.type, failure.message));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const target = new URL(inbound.effectivePath, ctx.upstreamBaseUrl);
|
|
172
|
+
const headers = buildUpstreamHeaders(req.headers, {
|
|
173
|
+
posture: inbound.posture,
|
|
174
|
+
upstreamHost: target.host,
|
|
175
|
+
});
|
|
176
|
+
const abort = new AbortController();
|
|
177
|
+
let upstream;
|
|
178
|
+
try {
|
|
179
|
+
upstream = await fetch(target, {
|
|
180
|
+
method: req.method ?? "POST",
|
|
181
|
+
headers,
|
|
182
|
+
// The exact bytes we received. No parse, no re-serialise.
|
|
183
|
+
body,
|
|
184
|
+
signal: abort.signal,
|
|
185
|
+
redirect: "error",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
// Failed before any bytes were sent, so we can still choose a status.
|
|
190
|
+
const type = "api_error";
|
|
191
|
+
log.warn("upstream request failed", { id, error: String(err).slice(0, 200) });
|
|
192
|
+
finish({
|
|
193
|
+
status: "upstream_error",
|
|
194
|
+
httpStatus: statusForErrorType(type),
|
|
195
|
+
requestedModel: peek.model,
|
|
196
|
+
bytesIn: body.byteLength,
|
|
197
|
+
errorType: type,
|
|
198
|
+
errorMessage: String(err).slice(0, 200),
|
|
199
|
+
});
|
|
200
|
+
res.writeHead(statusForErrorType(type), { "content-type": "application/json" });
|
|
201
|
+
res.end(anthropicError(type, "Fest: upstream request failed."));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const rateLimit = parseRateLimit(upstream.headers);
|
|
205
|
+
const upstreamRequestId = upstream.headers.get("request-id") ?? upstream.headers.get("anthropic-request-id");
|
|
206
|
+
const downstream = buildDownstreamHeaders(upstream.headers, { stream: peek.stream });
|
|
207
|
+
const base = {
|
|
208
|
+
requestedModel: peek.model,
|
|
209
|
+
servedModel: peek.model,
|
|
210
|
+
stream: peek.stream,
|
|
211
|
+
httpStatus: upstream.status,
|
|
212
|
+
bytesIn: body.byteLength,
|
|
213
|
+
upstreamRequestId,
|
|
214
|
+
rateLimit,
|
|
215
|
+
};
|
|
216
|
+
// ── upstream refused ───────────────────────────────────────────────────────
|
|
217
|
+
/**
|
|
218
|
+
* Handled before the stream/non-stream split, because a refusal is the same
|
|
219
|
+
* shape either way: a short JSON body instead of an SSE stream, even when the
|
|
220
|
+
* client asked for one. Buffering it costs nothing and is the only chance to
|
|
221
|
+
* read it — a teed stream is relayed and discarded.
|
|
222
|
+
*
|
|
223
|
+
* This is the response whose CONTENT matters most and the one Fest used to
|
|
224
|
+
* record least. It recorded the status and dropped the reason, so a row said
|
|
225
|
+
* "400" and nothing about which field the provider objected to; the answer
|
|
226
|
+
* reached the developer's terminal and lived only until it scrolled.
|
|
227
|
+
*/
|
|
228
|
+
if (!upstream.ok) {
|
|
229
|
+
const failure = describeUpstreamFailure(upstream.status, await upstream.text());
|
|
230
|
+
/**
|
|
231
|
+
* Anthropic refusing the session-start warmup ping is expected and benign —
|
|
232
|
+
* see `preflight.ts`. It still gets a row, with the provider's own reason
|
|
233
|
+
* on it, because a thing that happens is worth recording; it just is not a
|
|
234
|
+
* failure and is not warned about, or every session would open with an
|
|
235
|
+
* error in the dashboard and a warning in the log.
|
|
236
|
+
*/
|
|
237
|
+
const preflight = isWarmupPreflightRefusal({
|
|
238
|
+
httpStatus: upstream.status,
|
|
239
|
+
stream: peek.stream,
|
|
240
|
+
maxTokens: peek.maxTokens,
|
|
241
|
+
rateLimit,
|
|
242
|
+
subscription: isSubscription,
|
|
243
|
+
});
|
|
244
|
+
/**
|
|
245
|
+
* Logged as well as recorded, because the database is the wrong place to
|
|
246
|
+
* find out something is broken NOW. Fest logs nothing per request by
|
|
247
|
+
* design — a gateway that narrates every relay is unreadable — but a
|
|
248
|
+
* refusal is not routine traffic, and a developer watching an error appear
|
|
249
|
+
* in their client had no way to see the reason without querying SQLite.
|
|
250
|
+
*
|
|
251
|
+
* `rateLimit` rides along: a 429 that carries no quota headers is a
|
|
252
|
+
* different fact from one that does, and that difference is invisible in
|
|
253
|
+
* the status alone.
|
|
254
|
+
*/
|
|
255
|
+
if (!preflight) {
|
|
256
|
+
log.warn("upstream refused", {
|
|
257
|
+
id,
|
|
258
|
+
status: upstream.status,
|
|
259
|
+
type: failure.type,
|
|
260
|
+
message: failure.message,
|
|
261
|
+
model: peek.model,
|
|
262
|
+
stream: peek.stream,
|
|
263
|
+
bytesIn: body.byteLength,
|
|
264
|
+
pipeline: "passthrough",
|
|
265
|
+
upstreamRequestId,
|
|
266
|
+
rateLimit,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// `stream: false`: whatever the client asked for, this is a JSON error, and
|
|
270
|
+
// framing it as a stream would tell the client to expect SSE it won't get.
|
|
271
|
+
res.writeHead(upstream.status, buildDownstreamHeaders(upstream.headers, { stream: false }));
|
|
272
|
+
res.end(failure.text);
|
|
273
|
+
finish({
|
|
274
|
+
...base,
|
|
275
|
+
status: preflight ? "preflight_refused" : "upstream_error",
|
|
276
|
+
bytesOut: Buffer.byteLength(failure.text),
|
|
277
|
+
errorType: failure.type,
|
|
278
|
+
errorMessage: failure.message,
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// ── non-streaming ──────────────────────────────────────────────────────────
|
|
283
|
+
if (!peek.stream || !upstream.body) {
|
|
284
|
+
const text = await upstream.text();
|
|
285
|
+
let usage = EMPTY_USAGE;
|
|
286
|
+
try {
|
|
287
|
+
usage = usageFromJson(JSON.parse(text)) ?? EMPTY_USAGE;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// A body we cannot read is still relayed; we just report less.
|
|
291
|
+
}
|
|
292
|
+
const priced = priceUsage(peek.model, usage, isSubscription);
|
|
293
|
+
res.writeHead(upstream.status, downstream);
|
|
294
|
+
res.end(text);
|
|
295
|
+
finish({
|
|
296
|
+
...base,
|
|
297
|
+
status: "ok",
|
|
298
|
+
usage,
|
|
299
|
+
costUsd: priced.cost,
|
|
300
|
+
costBasis: priced.basis,
|
|
301
|
+
notionalCostUsd: priced.notionalCost,
|
|
302
|
+
bytesOut: Buffer.byteLength(text),
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// ── streaming ──────────────────────────────────────────────────────────────
|
|
307
|
+
const parser = createSseParser();
|
|
308
|
+
const acc = createUsageAccumulator();
|
|
309
|
+
beginStream(res, upstream.status, downstream);
|
|
310
|
+
try {
|
|
311
|
+
const result = await pipeWithTee(upstream.body, res, {
|
|
312
|
+
abort,
|
|
313
|
+
startedAt,
|
|
314
|
+
observe: (chunk) => {
|
|
315
|
+
for (const event of parser.push(chunk))
|
|
316
|
+
acc.apply(event);
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
for (const event of parser.flush())
|
|
320
|
+
acc.apply(event);
|
|
321
|
+
const usage = acc.snapshot();
|
|
322
|
+
const priced = priceUsage(peek.model, usage, isSubscription);
|
|
323
|
+
const streamErr = acc.streamError();
|
|
324
|
+
/**
|
|
325
|
+
* Only a 2xx reaches here — a non-2xx was recorded and relayed above, which
|
|
326
|
+
* is what keeps an upstream that refuses everything from reading as a zero
|
|
327
|
+
* error rate. (It once did: status was derived purely from client aborts
|
|
328
|
+
* and in-band SSE error events, so a provider refusing before emitting any
|
|
329
|
+
* event produced a tidy, successful-looking record.)
|
|
330
|
+
*
|
|
331
|
+
* What remains are failures that happen once the stream is under way, and
|
|
332
|
+
* a client abort wins over them: the developer walking away is the more
|
|
333
|
+
* specific fact about what happened.
|
|
334
|
+
*/
|
|
335
|
+
let status = "ok";
|
|
336
|
+
if (result.clientAborted)
|
|
337
|
+
status = "client_abort";
|
|
338
|
+
else if (streamErr)
|
|
339
|
+
status = "stream_error";
|
|
340
|
+
if (!result.clientAborted)
|
|
341
|
+
res.end();
|
|
342
|
+
finish({
|
|
343
|
+
...base,
|
|
344
|
+
status,
|
|
345
|
+
// Input tokens were already consumed upstream even on an abort, so the
|
|
346
|
+
// partial usage is real and worth recording — just flagged as incomplete.
|
|
347
|
+
partial: result.clientAborted || !result.completed,
|
|
348
|
+
usage,
|
|
349
|
+
costUsd: priced.cost,
|
|
350
|
+
costBasis: priced.basis,
|
|
351
|
+
notionalCostUsd: priced.notionalCost,
|
|
352
|
+
ttfbMs: result.ttfbMs,
|
|
353
|
+
bytesOut: result.bytesOut,
|
|
354
|
+
...(streamErr ? { errorType: streamErr.type, errorMessage: streamErr.message } : {}),
|
|
355
|
+
});
|
|
356
|
+
if (result.observerErrors > 0) {
|
|
357
|
+
log.warn("metering observer errored mid-stream", { id, errors: result.observerErrors });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
// Headers are already sent, so the status cannot be changed. Synthesise a
|
|
362
|
+
// terminal SSE error frame so the client sees a clean end rather than a
|
|
363
|
+
// truncated stream. Never retry: partial text is already on screen.
|
|
364
|
+
for (const event of parser.flush())
|
|
365
|
+
acc.apply(event);
|
|
366
|
+
const usage = acc.snapshot();
|
|
367
|
+
const priced = priceUsage(peek.model, usage, isSubscription);
|
|
368
|
+
log.warn("upstream stream failed mid-flight", { id, error: String(err).slice(0, 200) });
|
|
369
|
+
if (!res.writableEnded) {
|
|
370
|
+
res.write(sseErrorEvent("api_error", "Fest: upstream connection closed mid-stream."));
|
|
371
|
+
res.end();
|
|
372
|
+
}
|
|
373
|
+
finish({
|
|
374
|
+
...base,
|
|
375
|
+
status: "stream_error",
|
|
376
|
+
partial: true,
|
|
377
|
+
usage,
|
|
378
|
+
costUsd: priced.cost,
|
|
379
|
+
costBasis: priced.basis,
|
|
380
|
+
notionalCostUsd: priced.notionalCost,
|
|
381
|
+
errorType: "api_error",
|
|
382
|
+
errorMessage: String(err).slice(0, 200),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function headerValue(req, name) {
|
|
387
|
+
const v = req.headers[name];
|
|
388
|
+
if (v === undefined)
|
|
389
|
+
return null;
|
|
390
|
+
return Array.isArray(v) ? (v[0] ?? null) : v;
|
|
391
|
+
}
|
|
392
|
+
//# sourceMappingURL=passthrough.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"passthrough.js","sourceRoot":"","sources":["../../../server/pipeline/passthrough.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's session-start warmup ping, and Anthropic's refusal of it.
|
|
3
|
+
*
|
|
4
|
+
* On every new session Claude Code sends exactly one non-streaming request
|
|
5
|
+
* before anything else: a ~311-byte, `max_tokens: 1`, no-system, no-tools ping.
|
|
6
|
+
* On a Claude subscription bearer Anthropic refuses it. The client shrugs this
|
|
7
|
+
* off and the session proceeds normally on the streaming requests that follow,
|
|
8
|
+
* so nothing is broken — but Fest recorded it as `upstream_error`, which put a
|
|
9
|
+
* failure in the dashboard at the start of every single session and warned
|
|
10
|
+
* about it in the gateway log.
|
|
11
|
+
*
|
|
12
|
+
* Captured directly against `api.anthropic.com` with Fest out of the path
|
|
13
|
+
* (`tools/capture-server.ts`, MODE=forward), so this is Anthropic's behaviour
|
|
14
|
+
* and not something the gateway causes:
|
|
15
|
+
*
|
|
16
|
+
* POST /v1/messages?beta=true 311 bytes
|
|
17
|
+
* model: claude-opus-5 stream: false max_tokens: 1
|
|
18
|
+
* system_present: false tool_count: 0 message_count: 1
|
|
19
|
+
* -> 429 x-should-retry: true NO anthropic-ratelimit-* headers
|
|
20
|
+
* {"type":"error","error":{"type":"rate_limit_error","message":"Error"}}
|
|
21
|
+
*
|
|
22
|
+
* The same ping on a server-held org API key returns 200, and the streaming
|
|
23
|
+
* turn 1.7s later on the same subscription bearer returns 200 with the full
|
|
24
|
+
* quota header set reading `allowed` at 0.37 utilization. So it is not a quota
|
|
25
|
+
* rejection; it is a shape rejection wearing a quota status code.
|
|
26
|
+
*
|
|
27
|
+
* WHY THIS IS NOT A BLANKET "IGNORE 429s"
|
|
28
|
+
*
|
|
29
|
+
* The discriminator is the headers. A genuine rate limit arrives with the
|
|
30
|
+
* `anthropic-ratelimit-unified-*` set, which is precisely what Claude Code's
|
|
31
|
+
* own `/status` reads; this refusal arrives with none of them. A developer who
|
|
32
|
+
* really is out of quota still gets a loud, correctly-counted error — which
|
|
33
|
+
* matters, because an error rate that reads as zero during an outage is the
|
|
34
|
+
* failure mode this codebase has already been bitten by once (see
|
|
35
|
+
* `test/stream-status.test.ts`).
|
|
36
|
+
*
|
|
37
|
+
* WHAT IS DELIBERATELY NOT DONE HERE
|
|
38
|
+
*
|
|
39
|
+
* The 429 is still relayed to Claude Code unchanged. Fest reclassifies its own
|
|
40
|
+
* record of what happened; it does not edit what Anthropic said. Rewriting a
|
|
41
|
+
* provider's response into a friendlier one would make the gateway a source of
|
|
42
|
+
* fiction about the provider, which is worth more than a tidy client UI.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Four conditions, all required. Each one narrows the predicate past something
|
|
46
|
+
* a real failure could look like:
|
|
47
|
+
*
|
|
48
|
+
* - `subscription` — the refusal is specific to the OAuth bearer; the same
|
|
49
|
+
* request on an org key succeeds, so an org key's 429 is always real.
|
|
50
|
+
* - `!stream` — Claude Code sends exactly one non-streaming request per
|
|
51
|
+
* session, this one. Every real turn streams.
|
|
52
|
+
* - `maxTokens === 1` — a ping, not work. No genuine request asks for a token.
|
|
53
|
+
* - `rateLimit === null` — a real quota 429 carries its headers.
|
|
54
|
+
*
|
|
55
|
+
* Narrow on purpose. If Anthropic changes the ping or starts answering it
|
|
56
|
+
* properly, this stops matching and the row goes back to being a plain
|
|
57
|
+
* `upstream_error` — which is the right direction to fail: visible, not silent.
|
|
58
|
+
*/
|
|
59
|
+
export function isWarmupPreflightRefusal(c) {
|
|
60
|
+
return (c.httpStatus === 429 &&
|
|
61
|
+
c.subscription &&
|
|
62
|
+
!c.stream &&
|
|
63
|
+
c.maxTokens === 1 &&
|
|
64
|
+
c.rateLimit === null);
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=preflight.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preflight.js","sourceRoot":"","sources":["../../../server/pipeline/preflight.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAgBH;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,wBAAwB,CAAC,CAAqB;IAC5D,OAAO,CACL,CAAC,CAAC,UAAU,KAAK,GAAG;QACpB,CAAC,CAAC,YAAY;QACd,CAAC,CAAC,CAAC,MAAM;QACT,CAAC,CAAC,SAAS,KAAK,CAAC;QACjB,CAAC,CAAC,SAAS,KAAK,IAAI,CACrB,CAAC;AACJ,CAAC"}
|