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/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/src/cline-accounts.ts +962 -891
- package/src/commands.ts +1888 -1738
- package/src/deploy.ts +658 -576
- package/src/proxy.ts +138 -49
- package/src/relay-state.ts +1140 -1133
- package/src/relay.ts +422 -399
- package/src/responses.ts +425 -264
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("
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
headers.
|
|
158
|
-
headers.set("
|
|
159
|
-
headers.set("x-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
disabledBody =
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
directHeaders
|
|
377
|
-
directHeaders.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
+
}
|