pi-freeflow 1.26.0 → 1.27.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/src/relay.ts CHANGED
@@ -1,399 +1,422 @@
1
- /**
2
- * High-resiliency multi-cloud relay client and failover dispatcher
3
- */
4
-
5
- import { randomUUID } from "node:crypto";
6
- import { Buffer } from "node:buffer";
7
- import { isDebugEnabled, log } from "./logger.ts";
8
- import {
9
- getActiveRelayState,
10
- orderedRelayCandidates,
11
- getStatusUi,
12
- markRelayFailure,
13
- markRelaySuccess,
14
- shortRelayLabel,
15
- updateRelayStatusUi,
16
- withRelayState,
17
- validateRelayUrl,
18
- } from "./relay-state.ts";
19
-
20
- // Throttle user-facing roll notifications so a burst of failures surfaces
21
- // one warning instead of a wall of identical toasts.
22
- let lastRollNotify = 0;
23
- const ROLL_NOTIFY_MS = 5 * 60 * 1_000;
24
- /** Test-only: reset roll-notify throttle */
25
- export function _resetRollNotifyForTest(): void { lastRollNotify = 0; }
26
-
27
- /**
28
- * Determine if an HTTP status code indicates a temporary relay or upstream error
29
- * that warrants rolling to the next relay candidate.
30
- */
31
- export function isRetriableStatus(status: number): boolean {
32
- return (
33
- status === 429 ||
34
- status === 408 ||
35
- status === 502 ||
36
- status === 503 ||
37
- status === 504 ||
38
- (status >= 520 && status <= 530)
39
- );
40
- }
41
-
42
- /**
43
- * Gated 402 roll predicate: a 402 is a relay-host failure only when it
44
- * carries the Vercel edge-error marker or a DEPLOYMENT_DISABLED body match.
45
- * Generic 402s (payment/quota) carry neither and must surface immediately.
46
- * Only x-vercel-error qualifies as an edge marker: Vercel stamps x-vercel-id
47
- * on EVERY function response, so keying on it would misroll a genuine
48
- * upstream quota 402 forwarded by a healthy Vercel-hosted relay — and
49
- * wrongly cool that relay down.
50
- */
51
- function isRelayDeploymentDisabled(res: Response, bodyText: string | null): boolean {
52
- if (res.status !== 402) return false;
53
- if (Boolean(res.headers.get("x-vercel-error"))) return true;
54
- return bodyText !== null && bodyText.includes("DEPLOYMENT_DISABLED");
55
- }
56
- /**
57
- * Per-conversation reasoning affinity. Callers sending caller-bound reasoning
58
- * pass the relay that issued it; `onServed` reports the relay that actually
59
- * produced the response (`null` = direct fallback).
60
- */
61
- export interface RelayAffinity {
62
- preferred?: string;
63
- onServed?: (relay: string | null) => void;
64
- }
65
-
66
- /**
67
- * Fetch a target URL through the active relay pool with rolling failover and direct fallback.
68
- *
69
- * @param url Full upstream destination URL (e.g. https://opencode.ai/zen/v1/chat/completions)
70
- * @param opts Standard fetch RequestInit options
71
- * @param reqId Optional correlation request ID for end-to-end tracing
72
- * @param affinity Per-conversation affinity: `preferred` is tried first when it
73
- * is healthy (the caller is expected to send a body that relay can read),
74
- * and `onServed` reports which relay actually produced the response —
75
- * `null` for the direct fallback — so the caller does not have to infer
76
- * the issuer from mutable global state.
77
- */
78
- export async function relayFetch(
79
- url: string,
80
- opts: RequestInit = {},
81
- reqId?: string,
82
- affinity: RelayAffinity = {},
83
- ): Promise<Response> {
84
- const rid = reqId || randomUUID().slice(0, 8);
85
- const relayState = getActiveRelayState();
86
-
87
- if (!relayState.enabled) {
88
- log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
89
- affinity.onServed?.(null);
90
- return fetch(url, opts as unknown as RequestInit);
91
- }
92
- const candidates = orderedRelayCandidates(affinity.preferred);
93
-
94
- if (candidates.length === 0) {
95
- // Empty pool: skip straight to upstream instead of logging a misleading
96
- // "relays bypassed/exhausted" WARN on every request.
97
- log("debug", `relayFetch: direct (empty relay pool) -> ${url}`, undefined, rid);
98
- affinity.onServed?.(null);
99
- return fetch(url, opts as unknown as RequestInit);
100
- }
101
-
102
- let lastResponse: Response | null = null;
103
- let lastError: unknown = null;
104
- /** Relay that produced `lastResponse`, for accurate issuer reporting. */
105
- let lastResponseRelay: string | null = null;
106
- const u = new URL(url);
107
- const relayTarget = `${u.protocol}//${u.host}`;
108
- const relayPath = `${u.pathname}${u.search}`;
109
-
110
- const bodySizeKB =
111
- typeof opts.body === "string"
112
- ? (opts.body.length / 1024).toFixed(1)
113
- : Buffer.isBuffer(opts.body)
114
- ? (opts.body.length / 1024).toFixed(1)
115
- : "0";
116
-
117
- log("info", `request starting (${bodySizeKB}KB payload) -> ${url}`, undefined, rid);
118
- if (isDebugEnabled()) {
119
- try {
120
- const bodyPreview = typeof opts.body === "string" ? opts.body.slice(0, 1200) : "";
121
- const modelMatch = bodyPreview.match(/"model"\s*:\s*"([^"]+)"/);
122
- const streamMatch = bodyPreview.match(/"stream"\s*:\s*(true|false)/);
123
- log("debug", "request detail", {
124
- model: modelMatch?.[1],
125
- stream: streamMatch?.[1],
126
- sizeKB: bodySizeKB,
127
- relayTarget,
128
- relayPath,
129
- candidates: candidates.length,
130
- }, rid);
131
- } catch {}
132
- }
133
-
134
- for (let i = 0; i < candidates.length; i++) {
135
- const targetUrl = candidates[i];
136
- const attemptStart = Date.now();
137
- try {
138
- // SSRF guard: reject private/loopback/non-https candidates the same
139
- // way a deployed relay worker rejects an inbound x-relay-target.
140
- const candidateCheck = validateRelayUrl(targetUrl);
141
- if (!candidateCheck.ok) {
142
- log(
143
- "warn",
144
- `relay ${targetUrl} skipped — ${candidateCheck.reason}`,
145
- { upstream: url },
146
- rid,
147
- );
148
- continue;
149
- }
150
- let targetHost = "opencode.ai";
151
- try {
152
- if (targetUrl) targetHost = new URL(targetUrl).host;
153
- } catch {}
154
-
155
- const headers = new Headers(opts.headers);
156
- headers.set("x-relay-target", relayTarget);
157
- headers.set("x-relay-path", relayPath);
158
- headers.set("host", targetHost);
159
- headers.set("x-request-id", rid);
160
- // Per-relay shared secret set by /freeflow deploy. Legacy entries
161
- // without auth keep working: no header at all.
162
- const entry = getActiveRelayState().relays.find(
163
- (r) => r.url === targetUrl.trim(),
164
- );
165
- if (entry?.auth) {
166
- headers.set("x-relay-auth", entry.auth);
167
- }
168
-
169
- const signal = opts.signal || AbortSignal.timeout(300_000);
170
- const res = await fetch(targetUrl, { ...opts, headers, signal } as unknown as RequestInit);
171
- const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
172
- // Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
173
- // Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
174
- if (res.status === 504) {
175
- markRelayFailure(targetUrl, 504, "Gateway Timeout (25s exceeded)");
176
- res.body?.cancel().catch(() => {});
177
- log(
178
- "warn",
179
- `relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
180
- { upstream: url, sizeKB: bodySizeKB },
181
- rid,
182
- );
183
- const now = Date.now();
184
- if (now - lastRollNotify > ROLL_NOTIFY_MS) {
185
- lastRollNotify = now;
186
- const ui = getStatusUi();
187
- if (ui?.notify) {
188
- ui.notify(`relay ${shortRelayLabel(targetUrl)} hit HTTP 504 — falling back to direct`, "warning");
189
- }
190
- }
191
- break;
192
- }
193
- // Relay payload cap hit (413: request exceeds host payload limit):
194
- // Not a relay health signal, so no failure marking — try the next
195
- // relay (a different host may accept it), else the direct fallback.
196
- if (res.status === 413) {
197
- lastResponse?.body?.cancel().catch(() => {});
198
- lastResponse = res;
199
- lastResponseRelay = targetUrl;
200
- log(
201
- "warn",
202
- `relay ${targetUrl} hit HTTP 413 payload limit in ${elapsed}s — trying next path`,
203
- { upstream: url, sizeKB: bodySizeKB },
204
- rid,
205
- );
206
- const now = Date.now();
207
- if (now - lastRollNotify > ROLL_NOTIFY_MS) {
208
- lastRollNotify = now;
209
- const ui = getStatusUi();
210
- if (ui?.notify) {
211
- ui.notify(`relay ${shortRelayLabel(targetUrl)} hit payload limit — trying next path`, "warning");
212
- }
213
- }
214
- continue;
215
- }
216
-
217
- // Relay host infrastructure 404 (e.g. Vercel DEPLOYMENT_NOT_FOUND or non-JSON 404):
218
- // When a relay URL is deleted, misconfigured, or has no deployment, Vercel/Cloudflare
219
- // returns edge 404. This is a relay failure, not an upstream API response.
220
- const isRelayEdge404 =
221
- res.status === 404 &&
222
- (Boolean(res.headers.get("x-vercel-error")) ||
223
- Boolean(res.headers.get("x-vercel-id")) ||
224
- res.headers.get("server")?.toLowerCase().includes("vercel") ||
225
- !res.headers.get("content-type")?.includes("json"));
226
-
227
- if (isRelayEdge404) {
228
- markRelayFailure(targetUrl, 404, "Deployment or route not found on relay host");
229
- lastResponse?.body?.cancel().catch(() => {});
230
- lastResponse = res;
231
- lastResponseRelay = targetUrl;
232
- log(
233
- "warn",
234
- `relay ${targetUrl} returned edge 404 (deployment missing or route not found) — rolling to next relay`,
235
- { upstream: url },
236
- rid,
237
- );
238
- const now = Date.now();
239
- if (now - lastRollNotify > ROLL_NOTIFY_MS) {
240
- lastRollNotify = now;
241
- const ui = getStatusUi();
242
- if (ui?.notify) {
243
- ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 404) — rolled to next relay`, "warning");
244
- }
245
- }
246
- continue;
247
- }
248
- // Relay deployment disabled (402 DEPLOYMENT_DISABLED from Vercel):
249
- // A disabled deployment is a relay-host failure, so roll to the next
250
- // relay. A blanket 402 must NOT roll: generic 402s are payment/quota
251
- // verdicts that must surface immediately, so gate strictly on Vercel
252
- // edge markers or a DEPLOYMENT_DISABLED body match and otherwise fall
253
- // through to the terminal path below. The body is peeked via a clone so
254
- // the downstream stream stays intact; on any clone/read failure decide
255
- // on headers alone (headerless failure reads as a generic 402).
256
- if (res.status === 402) {
257
- let disabledBody: string | null = null;
258
- try {
259
- disabledBody = (await res.clone().text()).slice(0, 8192);
260
- } catch {
261
- disabledBody = null;
262
- }
263
- if (isRelayDeploymentDisabled(res, disabledBody)) {
264
- markRelayFailure(targetUrl, 402, "Deployment disabled (DEPLOYMENT_DISABLED) on relay host");
265
- lastResponse?.body?.cancel().catch(() => {});
266
- lastResponse = res;
267
- lastResponseRelay = targetUrl;
268
- log(
269
- "warn",
270
- `relay ${targetUrl} returned 402 DEPLOYMENT_DISABLED in ${elapsed}s — rolling to next relay`,
271
- { upstream: url },
272
- rid,
273
- );
274
- const now = Date.now();
275
- if (now - lastRollNotify > ROLL_NOTIFY_MS) {
276
- lastRollNotify = now;
277
- const ui = getStatusUi();
278
- if (ui?.notify) {
279
- ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 402) — rolled to next relay`, "warning");
280
- }
281
- }
282
- continue;
283
- }
284
- }
285
- if (isRetriableStatus(res.status)) {
286
- markRelayFailure(targetUrl, res.status);
287
- lastResponse?.body?.cancel().catch(() => {});
288
- lastResponse = res;
289
- lastResponseRelay = targetUrl;
290
- log(
291
- "warn",
292
- `relay ${targetUrl} returned HTTP ${res.status} in ${elapsed}s — rolling to next relay`,
293
- { upstream: url, status: res.status },
294
- rid,
295
- );
296
- const now = Date.now();
297
- if (now - lastRollNotify > ROLL_NOTIFY_MS) {
298
- lastRollNotify = now;
299
- const ui = getStatusUi();
300
- if (ui?.notify) {
301
- ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP ${res.status}) — rolled to next relay`, "warning");
302
- }
303
- }
304
- continue;
305
- }
306
-
307
- markRelaySuccess(targetUrl, Date.now() - attemptStart);
308
-
309
- // SUCCESS or non-retriable client error (e.g. 200, 404):
310
- // If we switched to a different relay because previous failed, update sticky active relay!
311
- //
312
- // Exception: a winner that is the caller's preferred (affinity) relay
313
- // was reached on the first attempt, so the sticky primary never failed
314
- // a roll and must not be rewritten. Without this, two conversations
315
- // pinned to different issuers would flip the machine-wide primary on
316
- // every turn, churning the state file and re-shuffling every other
317
- // session's candidate order.
318
- const affinityServed =
319
- Boolean(affinity.preferred) &&
320
- targetUrl.trim() === (affinity.preferred ?? "").trim();
321
- if (relayState.url !== targetUrl && !affinityServed) {
322
- log("info", `active relay auto-switched to ${targetUrl}`, {
323
- previous: relayState.url,
324
- }, rid);
325
- // CAS: re-apply the sticky-active switch to the freshest disk state at
326
- // write time so a concurrent session's pool edit is never clobbered.
327
- withRelayState((s) => {
328
- s.url = targetUrl;
329
- return s;
330
- });
331
- }
332
-
333
- log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)`, undefined, rid);
334
- if (isDebugEnabled()) {
335
- log("debug", "relay headers", {
336
- status: res.status,
337
- contentType: res.headers.get("content-type"),
338
- via: res.headers.get("via") || res.headers.get("x-vercel-id") || "direct",
339
- }, rid);
340
- }
341
-
342
- updateRelayStatusUi(targetUrl);
343
- affinity.onServed?.(targetUrl);
344
- return res;
345
- } catch (err) {
346
- // Client abort: do not mark the relay failed — the client cancelled the
347
- // request, the relay itself is not at fault. Propagate immediately.
348
- if ((err as Error)?.name === "AbortError") {
349
- throw err;
350
- }
351
- const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
352
- lastError = err;
353
- const errMsg = (err as Error)?.message || String(err);
354
- markRelayFailure(targetUrl, 0, errMsg);
355
- log(
356
- "warn",
357
- `relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
358
- { upstream: url, error: errMsg },
359
- rid,
360
- );
361
- continue;
362
- }
363
- }
364
-
365
- // Full fallback: attempt direct fetch to upstream
366
- const directStart = Date.now();
367
- try {
368
- log("warn", "relays bypassed/exhausted — attempting direct fetch to upstream", {
369
- upstream: url,
370
- sizeKB: bodySizeKB,
371
- }, rid);
372
-
373
- const directHeaders = new Headers(opts.headers);
374
- directHeaders.delete("x-relay-target");
375
- directHeaders.delete("x-relay-path");
376
- directHeaders.set("host", u.host);
377
- directHeaders.set("x-request-id", rid);
378
-
379
- const directRes = await fetch(url, { ...opts, headers: directHeaders } as unknown as RequestInit);
380
- // lastResponse holds an unread body that would otherwise leak its socket
381
- // until GC; the salvage path below still needs it, so only cancel here.
382
- lastResponse?.body?.cancel().catch(() => {});
383
- affinity.onServed?.(null);
384
- return directRes;
385
- } catch (directErr) {
386
- const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
387
- log("error", `direct fallback also failed in ${directElapsed}s`, {
388
- upstream: url,
389
- error: String(directErr),
390
- }, rid);
391
- if (lastResponse) {
392
- // Salvaged relay response: report the relay that produced it so the
393
- // caller keeps accurate affinity.
394
- affinity.onServed?.(lastResponseRelay);
395
- return lastResponse;
396
- }
397
- throw directErr || lastError;
398
- }
399
- }
1
+ /**
2
+ * High-resiliency multi-cloud relay client and failover dispatcher
3
+ */
4
+
5
+ import { randomUUID } from "node:crypto";
6
+ import { Buffer } from "node:buffer";
7
+ import { isDebugEnabled, log } from "./logger.ts";
8
+ import {
9
+ getActiveRelayState,
10
+ orderedRelayCandidates,
11
+ getStatusUi,
12
+ markRelayFailure,
13
+ markRelaySuccess,
14
+ shortRelayLabel,
15
+ updateRelayStatusUi,
16
+ withRelayState,
17
+ validateRelayUrl,
18
+ } from "./relay-state.ts";
19
+
20
+ // Throttle user-facing roll notifications so a burst of failures surfaces
21
+ // one warning instead of a wall of identical toasts.
22
+ let lastRollNotify = 0;
23
+ const ROLL_NOTIFY_MS = 5 * 60 * 1_000;
24
+ /** Test-only: reset roll-notify throttle */
25
+ export function _resetRollNotifyForTest(): void { lastRollNotify = 0; }
26
+
27
+ /**
28
+ * Determine if an HTTP status code indicates a temporary relay or upstream error
29
+ * that warrants rolling to the next relay candidate.
30
+ */
31
+ export function isRetriableStatus(status: number): boolean {
32
+ return (
33
+ status === 429 ||
34
+ status === 408 ||
35
+ status === 502 ||
36
+ status === 503 ||
37
+ status === 504 ||
38
+ (status >= 520 && status <= 530)
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Gated 402 roll predicate: a 402 is a relay-host failure only when it
44
+ * carries the Vercel edge-error marker or a DEPLOYMENT_DISABLED body match.
45
+ * Generic 402s (payment/quota) carry neither and must surface immediately.
46
+ * Only x-vercel-error qualifies as an edge marker: Vercel stamps x-vercel-id
47
+ * on EVERY function response, so keying on it would misroll a genuine
48
+ * upstream quota 402 forwarded by a healthy Vercel-hosted relay — and
49
+ * wrongly cool that relay down.
50
+ */
51
+ function isRelayDeploymentDisabled(res: Response, bodyText: string | null): boolean {
52
+ if (res.status !== 402) return false;
53
+ if (Boolean(res.headers.get("x-vercel-error"))) return true;
54
+ return bodyText !== null && bodyText.toLowerCase().includes("deployment_disabled");
55
+ }
56
+ /**
57
+ * Per-conversation reasoning affinity. Callers sending caller-bound reasoning
58
+ * pass the relay that issued it; `onServed` reports the relay that actually
59
+ * produced the response (`null` = direct fallback).
60
+ */
61
+ export interface RelayAffinity {
62
+ preferred?: string;
63
+ onServed?: (relay: string | null) => void;
64
+ }
65
+
66
+ /**
67
+ * Fetch a target URL through the active relay pool with rolling failover and direct fallback.
68
+ *
69
+ * @param url Full upstream destination URL (e.g. https://opencode.ai/zen/v1/chat/completions)
70
+ * @param opts Standard fetch RequestInit options
71
+ * @param reqId Optional correlation request ID for end-to-end tracing
72
+ * @param affinity Per-conversation affinity: `preferred` is tried first when it
73
+ * is healthy (the caller is expected to send a body that relay can read),
74
+ * and `onServed` reports which relay actually produced the response —
75
+ * `null` for the direct fallback — so the caller does not have to infer
76
+ * the issuer from mutable global state.
77
+ */
78
+ export async function relayFetch(
79
+ url: string,
80
+ opts: RequestInit = {},
81
+ reqId?: string,
82
+ affinity: RelayAffinity = {},
83
+ ): Promise<Response> {
84
+ const rid = reqId || randomUUID().slice(0, 8);
85
+ const relayState = getActiveRelayState();
86
+
87
+ if (!relayState.enabled) {
88
+ log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
89
+ affinity.onServed?.(null);
90
+ return fetch(url, opts as unknown as RequestInit);
91
+ }
92
+ const candidates = orderedRelayCandidates(affinity.preferred);
93
+
94
+ if (candidates.length === 0) {
95
+ // Empty pool: skip straight to upstream instead of logging a misleading
96
+ // "relays bypassed/exhausted" WARN on every request.
97
+ log("debug", `relayFetch: direct (empty relay pool) -> ${url}`, undefined, rid);
98
+ affinity.onServed?.(null);
99
+ return fetch(url, opts as unknown as RequestInit);
100
+ }
101
+
102
+ let lastResponse: Response | null = null;
103
+ let lastError: unknown = null;
104
+ /** Relay that produced `lastResponse`, for accurate issuer reporting. */
105
+ let lastResponseRelay: string | null = null;
106
+ /** Relay URLs that returned a gated 402 DEPLOYMENT_DISABLED this request. */
107
+ const disabledRelays: string[] = [];
108
+ const u = new URL(url);
109
+ const relayTarget = `${u.protocol}//${u.host}`;
110
+ const relayPath = `${u.pathname}${u.search}`;
111
+
112
+ const bodySizeKB =
113
+ typeof opts.body === "string"
114
+ ? (opts.body.length / 1024).toFixed(1)
115
+ : Buffer.isBuffer(opts.body)
116
+ ? (opts.body.length / 1024).toFixed(1)
117
+ : "0";
118
+
119
+ log("info", `request starting (${bodySizeKB}KB payload) -> ${url}`, undefined, rid);
120
+ if (isDebugEnabled()) {
121
+ try {
122
+ const bodyPreview = typeof opts.body === "string" ? opts.body.slice(0, 1200) : "";
123
+ const modelMatch = bodyPreview.match(/"model"\s*:\s*"([^"]+)"/);
124
+ const streamMatch = bodyPreview.match(/"stream"\s*:\s*(true|false)/);
125
+ log("debug", "request detail", {
126
+ model: modelMatch?.[1],
127
+ stream: streamMatch?.[1],
128
+ sizeKB: bodySizeKB,
129
+ relayTarget,
130
+ relayPath,
131
+ candidates: candidates.length,
132
+ }, rid);
133
+ } catch {}
134
+ }
135
+
136
+ for (let i = 0; i < candidates.length; i++) {
137
+ const targetUrl = candidates[i];
138
+ const attemptStart = Date.now();
139
+ try {
140
+ // SSRF guard: reject private/loopback/non-https candidates the same
141
+ // way a deployed relay worker rejects an inbound x-relay-target.
142
+ const candidateCheck = validateRelayUrl(targetUrl);
143
+ if (!candidateCheck.ok) {
144
+ log(
145
+ "warn",
146
+ `relay ${targetUrl} skipped — ${candidateCheck.reason}`,
147
+ { upstream: url },
148
+ rid,
149
+ );
150
+ continue;
151
+ }
152
+ let targetHost = "opencode.ai";
153
+ try {
154
+ if (targetUrl) targetHost = new URL(targetUrl).host;
155
+ } catch {}
156
+
157
+ const headers = new Headers(opts.headers);
158
+ headers.set("x-relay-target", relayTarget);
159
+ headers.set("x-relay-path", relayPath);
160
+ headers.set("host", targetHost);
161
+ headers.set("x-request-id", rid);
162
+ // Per-relay shared secret set by /freeflow deploy. Legacy entries
163
+ // without auth keep working: no header at all.
164
+ const entry = getActiveRelayState().relays.find(
165
+ (r) => r.url === targetUrl.trim(),
166
+ );
167
+ if (entry?.auth) {
168
+ headers.set("x-relay-auth", entry.auth);
169
+ }
170
+
171
+ const signal = opts.signal || AbortSignal.timeout(300_000);
172
+ const res = await fetch(targetUrl, { ...opts, headers, signal } as unknown as RequestInit);
173
+ const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
174
+ // Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
175
+ // Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
176
+ if (res.status === 504) {
177
+ markRelayFailure(targetUrl, 504, "Gateway Timeout (25s exceeded)");
178
+ res.body?.cancel().catch(() => {});
179
+ log(
180
+ "warn",
181
+ `relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
182
+ { upstream: url, sizeKB: bodySizeKB },
183
+ rid,
184
+ );
185
+ const now = Date.now();
186
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
187
+ lastRollNotify = now;
188
+ const ui = getStatusUi();
189
+ if (ui?.notify) {
190
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} hit HTTP 504 — falling back to direct`, "warning");
191
+ }
192
+ }
193
+ break;
194
+ }
195
+ // Relay payload cap hit (413: request exceeds host payload limit):
196
+ // Not a relay health signal, so no failure marking — try the next
197
+ // relay (a different host may accept it), else the direct fallback.
198
+ if (res.status === 413) {
199
+ lastResponse?.body?.cancel().catch(() => {});
200
+ lastResponse = res;
201
+ lastResponseRelay = targetUrl;
202
+ log(
203
+ "warn",
204
+ `relay ${targetUrl} hit HTTP 413 payload limit in ${elapsed}s — trying next path`,
205
+ { upstream: url, sizeKB: bodySizeKB },
206
+ rid,
207
+ );
208
+ const now = Date.now();
209
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
210
+ lastRollNotify = now;
211
+ const ui = getStatusUi();
212
+ if (ui?.notify) {
213
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} hit payload limit — trying next path`, "warning");
214
+ }
215
+ }
216
+ continue;
217
+ }
218
+
219
+ // Relay host infrastructure 404 (e.g. Vercel DEPLOYMENT_NOT_FOUND or non-JSON 404):
220
+ // When a relay URL is deleted, misconfigured, or has no deployment, Vercel/Cloudflare
221
+ // returns edge 404. This is a relay failure, not an upstream API response.
222
+ const isRelayEdge404 =
223
+ res.status === 404 &&
224
+ (Boolean(res.headers.get("x-vercel-error")) ||
225
+ Boolean(res.headers.get("x-vercel-id")) ||
226
+ res.headers.get("server")?.toLowerCase().includes("vercel") ||
227
+ !res.headers.get("content-type")?.includes("json"));
228
+
229
+ if (isRelayEdge404) {
230
+ markRelayFailure(targetUrl, 404, "Deployment or route not found on relay host");
231
+ lastResponse?.body?.cancel().catch(() => {});
232
+ lastResponse = res;
233
+ lastResponseRelay = targetUrl;
234
+ log(
235
+ "warn",
236
+ `relay ${targetUrl} returned edge 404 (deployment missing or route not found) — rolling to next relay`,
237
+ { upstream: url },
238
+ rid,
239
+ );
240
+ const now = Date.now();
241
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
242
+ lastRollNotify = now;
243
+ const ui = getStatusUi();
244
+ if (ui?.notify) {
245
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 404) — rolled to next relay`, "warning");
246
+ }
247
+ }
248
+ continue;
249
+ }
250
+ // Relay deployment disabled (402 DEPLOYMENT_DISABLED from Vercel):
251
+ // A disabled deployment is a relay-host failure, so roll to the next
252
+ // relay. A blanket 402 must NOT roll: generic 402s are payment/quota
253
+ // verdicts that must surface immediately, so gate strictly on Vercel
254
+ // edge markers or a DEPLOYMENT_DISABLED body match and otherwise fall
255
+ // through to the terminal path below. The body is peeked via a clone so
256
+ // the downstream stream stays intact; on any clone/read failure decide
257
+ // on headers alone (headerless failure reads as a generic 402).
258
+ if (res.status === 402) {
259
+ let disabledBody: string | null = null;
260
+ try {
261
+ disabledBody = (await res.clone().text()).slice(0, 8192);
262
+ } catch {
263
+ disabledBody = null;
264
+ }
265
+ if (isRelayDeploymentDisabled(res, disabledBody)) {
266
+ markRelayFailure(targetUrl, 402, "Deployment disabled (DEPLOYMENT_DISABLED) on relay host");
267
+ disabledRelays.push(targetUrl);
268
+ lastResponse?.body?.cancel().catch(() => {});
269
+ lastResponse = res;
270
+ lastResponseRelay = targetUrl;
271
+ log(
272
+ "warn",
273
+ `relay ${targetUrl} returned 402 DEPLOYMENT_DISABLED in ${elapsed}s — rolling to next relay`,
274
+ { upstream: url },
275
+ rid,
276
+ );
277
+ const now = Date.now();
278
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
279
+ lastRollNotify = now;
280
+ const ui = getStatusUi();
281
+ if (ui?.notify) {
282
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 402) — rolled to next relay`, "warning");
283
+ }
284
+ }
285
+ continue;
286
+ }
287
+ }
288
+ if (isRetriableStatus(res.status)) {
289
+ markRelayFailure(targetUrl, res.status);
290
+ lastResponse?.body?.cancel().catch(() => {});
291
+ lastResponse = res;
292
+ lastResponseRelay = targetUrl;
293
+ log(
294
+ "warn",
295
+ `relay ${targetUrl} returned HTTP ${res.status} in ${elapsed}s — rolling to next relay`,
296
+ { upstream: url, status: res.status },
297
+ rid,
298
+ );
299
+ const now = Date.now();
300
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
301
+ lastRollNotify = now;
302
+ const ui = getStatusUi();
303
+ if (ui?.notify) {
304
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP ${res.status}) — rolled to next relay`, "warning");
305
+ }
306
+ }
307
+ continue;
308
+ }
309
+
310
+ markRelaySuccess(targetUrl, Date.now() - attemptStart);
311
+
312
+ // SUCCESS or non-retriable client error (e.g. 200, 404):
313
+ // If we switched to a different relay because previous failed, update sticky active relay!
314
+ //
315
+ // Exception: a winner that is the caller's preferred (affinity) relay
316
+ // was reached on the first attempt, so the sticky primary never failed
317
+ // a roll and must not be rewritten. Without this, two conversations
318
+ // pinned to different issuers would flip the machine-wide primary on
319
+ // every turn, churning the state file and re-shuffling every other
320
+ // session's candidate order.
321
+ const affinityServed =
322
+ Boolean(affinity.preferred) &&
323
+ targetUrl.trim() === (affinity.preferred ?? "").trim();
324
+ if (relayState.url !== targetUrl && !affinityServed) {
325
+ log("info", `active relay auto-switched to ${targetUrl}`, {
326
+ previous: relayState.url,
327
+ }, rid);
328
+ // CAS: re-apply the sticky-active switch to the freshest disk state at
329
+ // write time so a concurrent session's pool edit is never clobbered.
330
+ withRelayState((s) => {
331
+ s.url = targetUrl;
332
+ return s;
333
+ });
334
+ }
335
+
336
+ log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)`, undefined, rid);
337
+ if (isDebugEnabled()) {
338
+ log("debug", "relay headers", {
339
+ status: res.status,
340
+ contentType: res.headers.get("content-type"),
341
+ via: res.headers.get("via") || res.headers.get("x-vercel-id") || "direct",
342
+ }, rid);
343
+ }
344
+
345
+ updateRelayStatusUi(targetUrl);
346
+ affinity.onServed?.(targetUrl);
347
+ return res;
348
+ } catch (err) {
349
+ // Client abort: do not mark the relay failed — the client cancelled the
350
+ // request, the relay itself is not at fault. Propagate immediately.
351
+ if ((err as Error)?.name === "AbortError") {
352
+ throw err;
353
+ }
354
+ const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
355
+ lastError = err;
356
+ const errMsg = (err as Error)?.message || String(err);
357
+ markRelayFailure(targetUrl, 0, errMsg);
358
+ log(
359
+ "warn",
360
+ `relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
361
+ { upstream: url, error: errMsg },
362
+ rid,
363
+ );
364
+ continue;
365
+ }
366
+ }
367
+
368
+ // Full fallback: attempt direct fetch to upstream
369
+ const directStart = Date.now();
370
+ try {
371
+ log("warn", "relays bypassed/exhausted — attempting direct fetch to upstream", {
372
+ upstream: url,
373
+ sizeKB: bodySizeKB,
374
+ }, rid);
375
+
376
+ const directHeaders = new Headers(opts.headers);
377
+ directHeaders.delete("x-relay-target");
378
+ directHeaders.delete("x-relay-path");
379
+ directHeaders.set("host", u.host);
380
+ directHeaders.set("x-request-id", rid);
381
+
382
+ const directRes = await fetch(url, { ...opts, headers: directHeaders } as unknown as RequestInit);
383
+ // lastResponse holds an unread body that would otherwise leak its socket
384
+ // until GC; the salvage path below still needs it, so only cancel here.
385
+ lastResponse?.body?.cancel().catch(() => {});
386
+ affinity.onServed?.(null);
387
+ return directRes;
388
+ } catch (directErr) {
389
+ const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
390
+ log("error", `direct fallback also failed in ${directElapsed}s`, {
391
+ upstream: url,
392
+ error: String(directErr),
393
+ }, rid);
394
+ if (disabledRelays.length > 0) {
395
+ // Disabled deployment(s) seen and the direct fallback failed: never
396
+ // surface the raw Vercel 402 to the host (its retry layer would
397
+ // misfire on a 30min provider-wait). Fail fast with 503
398
+ // relay_disabled instead so the host retries/fails over immediately.
399
+ lastResponse?.body?.cancel().catch(() => {});
400
+ const count = disabledRelays.length;
401
+ const list = disabledRelays.join(", ");
402
+ const hint = disabledRelays.map((u) => `/freeflow remove ${u}`).join("; ");
403
+ affinity.onServed?.(null);
404
+ return new Response(
405
+ JSON.stringify({
406
+ error: {
407
+ code: "relay_disabled",
408
+ message: `relay ${list} deployment disabled (DEPLOYMENT_DISABLED) \u2014 redeploy or ${hint}; rolled ${count} relay(s)`,
409
+ },
410
+ }),
411
+ { status: 503, headers: { "content-type": "application/json" } },
412
+ );
413
+ }
414
+ if (lastResponse) {
415
+ // Salvaged relay response: report the relay that produced it so the
416
+ // caller keeps accurate affinity.
417
+ affinity.onServed?.(lastResponseRelay);
418
+ return lastResponse;
419
+ }
420
+ throw directErr || lastError;
421
+ }
422
+ }