pi-freeflow 1.40.0 → 1.41.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
@@ -31,8 +31,8 @@ const ROLL_NOTIFY_MS = 5 * 60 * 1_000;
31
31
  * per attempt. Relay leg only - the direct-to-upstream
32
32
  * fallback keeps the built-in global dispatcher. headersTimeout
33
33
  * reaps wedged tunnels, bodyTimeout 0 disables per-chunk kill
34
- * (the 300s total backstop still bounds a turn), connections 50
35
- * caps burst spray.
34
+ * (bodies stream unbounded — a wedged leg is cut by stream-pipe's
35
+ * body-stall watchdog instead), connections 50 caps burst spray.
36
36
  *
37
37
  * Compat: an npm undici Agent is rejected as a `dispatcher`
38
38
  * by an older-major global fetch bundled in Node
@@ -75,6 +75,18 @@ export function _setHedgeGraceForTest(ms: number): () => void {
75
75
  }
76
76
  /** Test-only: reset roll-notify throttle */
77
77
  export function _resetRollNotifyForTest(): void { lastRollNotify = 0; }
78
+ /********************************************************
79
+ * Relay attempt header-wait deadline (config default).
80
+ * Bounded only to the header wait; the streaming body must never be
81
+ * killed by a fixed wall-clock ceiling (see buildAttempt).
82
+ ********************************************************/
83
+ let attemptHeaderTimeoutMs = UPSTREAM_HEADER_TIMEOUT_MS;
84
+ /** Test-only: override the relay attempt header deadline; returns a restore function. */
85
+ export function _setAttemptHeaderTimeoutForTest(ms: number): () => void {
86
+ const prev = attemptHeaderTimeoutMs;
87
+ attemptHeaderTimeoutMs = ms;
88
+ return () => { attemptHeaderTimeoutMs = prev; };
89
+ }
78
90
 
79
91
  /**
80
92
  * Determine if an HTTP status code indicates a temporary relay or upstream error
@@ -153,12 +165,18 @@ export interface RelayAffinity {
153
165
  * and `onServed` reports which relay actually produced the response —
154
166
  * `null` for the direct fallback — so the caller does not have to infer
155
167
  * the issuer from mutable global state.
168
+ * @param onAttemptFailure Observation hook: called once for every attempt this
169
+ * turn abandons in favour of another (each roll branch, plus the header
170
+ * deadline and network faults). `attempt` is 1-based and monotonic across
171
+ * candidates and hedges; `status` is the HTTP status when headers arrived.
172
+ * A throwing hook is swallowed — observation must never break a roll.
156
173
  */
157
174
  export async function relayFetch(
158
175
  url: string,
159
176
  opts: RequestInit = {},
160
177
  reqId?: string,
161
178
  affinity: RelayAffinity = {},
179
+ onAttemptFailure?: (info: { attempt: number; status?: number; reason: string }) => void,
162
180
  ): Promise<Response> {
163
181
  const rid = reqId || randomUUID().slice(0, 8);
164
182
  const relayState = getActiveRelayState();
@@ -188,6 +206,23 @@ export async function relayFetch(
188
206
  let sawBare500 = false;
189
207
  /** True only while every candidate that produced a response answered 429. */
190
208
  let rateLimitOnly = true;
209
+ /**
210
+ * Abandoned candidates in roll order, 1-based — the numbering the failure
211
+ * hook reports. A candidate rejected before any fetch consumes a number
212
+ * too: the roll abandoned it just the same.
213
+ */
214
+ let attemptNo = 0;
215
+ const reportAbandoned = (reason: string, status?: number): void => {
216
+ attemptNo += 1;
217
+ if (!onAttemptFailure) return;
218
+ try {
219
+ onAttemptFailure(status === undefined
220
+ ? { attempt: attemptNo, reason }
221
+ : { attempt: attemptNo, status, reason });
222
+ } catch {
223
+ // Observation is best-effort; never let it disturb the roll.
224
+ }
225
+ };
191
226
  const u = new URL(url);
192
227
  const relayTarget = `${u.protocol}//${u.host}`;
193
228
  const relayPath = `${u.pathname}${u.search}`;
@@ -236,6 +271,7 @@ export async function relayFetch(
236
271
  { upstream: url },
237
272
  rid,
238
273
  );
274
+ reportAbandoned("invalid-relay-url");
239
275
  continue;
240
276
  }
241
277
  // Per-candidate wiring: relay-target headers, per-relay auth, and a
@@ -263,12 +299,28 @@ export async function relayFetch(
263
299
  // window, combined with the caller's signal so either can fire.
264
300
  // A hung relay then trips only its own timeout (roll) instead of
265
301
  // vetoing the pool; a genuine client cancel (caller signal
266
- // aborted) still propagates to both hedged attempts.
267
- const attemptTimeout = AbortSignal.timeout(UPSTREAM_HEADER_TIMEOUT_MS);
302
+ // aborted) still propagates to both hedged attempts. The timer
303
+ // is disarmed the moment the fetch settles: the deadline bounds
304
+ // the header WAIT only, never the streaming body (an
305
+ // AbortSignal.timeout would keep hanging over the body and kill
306
+ // a healthy turn past five minutes mid-generation).
307
+ const headerDeadlineCtl = new AbortController();
308
+ let headerTimer: NodeJS.Timeout | undefined;
309
+ let headerDeadlineDisarmed = false;
310
+ const disarmHeaderTimeout = (): void => {
311
+ if (headerDeadlineDisarmed) return;
312
+ headerDeadlineDisarmed = true;
313
+ clearTimeout(headerTimer);
314
+ headerTimer = undefined;
315
+ };
316
+ headerTimer = setTimeout(() => {
317
+ disarmHeaderTimeout();
318
+ headerDeadlineCtl.abort();
319
+ }, attemptHeaderTimeoutMs);
268
320
  const attemptSignal = opts.signal
269
- ? AbortSignal.any([opts.signal, attemptTimeout, hedgeCtl.signal])
270
- : AbortSignal.any([attemptTimeout, hedgeCtl.signal]);
271
- return { headers: attemptHeaders, signal: attemptSignal };
321
+ ? AbortSignal.any([opts.signal, headerDeadlineCtl.signal, hedgeCtl.signal])
322
+ : AbortSignal.any([headerDeadlineCtl.signal, hedgeCtl.signal]);
323
+ return { headers: attemptHeaders, signal: attemptSignal, disarmHeaderTimeout };
272
324
  };
273
325
 
274
326
  // Hedged failover: an attempt with no response headers within
@@ -289,14 +341,26 @@ export async function relayFetch(
289
341
  if (!canHedge) {
290
342
  const soloCtl = new AbortController();
291
343
  const solo = buildAttempt(targetUrl, soloCtl);
292
- res = await fetch(targetUrl, { ...opts, headers: solo.headers, signal: solo.signal, ...relayLegInit() } as unknown as RequestInit);
344
+ try {
345
+ res = await fetch(targetUrl, { ...opts, headers: solo.headers, signal: solo.signal, ...relayLegInit() } as unknown as RequestInit);
346
+ } finally {
347
+ solo.disarmHeaderTimeout();
348
+ }
293
349
  } else {
294
350
  const primaryUrl = targetUrl;
295
351
  const nextUrl = candidates[i + 1];
296
352
  const nextCheck = validateRelayUrl(nextUrl);
297
353
  const hedgeCtlA = new AbortController();
298
354
  const first = buildAttempt(primaryUrl, hedgeCtlA);
299
- const fetchA = fetch(primaryUrl, { ...opts, headers: first.headers, signal: first.signal, ...relayLegInit() } as unknown as RequestInit);
355
+ // Disarm a settled leg's header deadline: once the fetch resolves
356
+ // with headers (or rejects) the attempt's abort window is closed,
357
+ // so a streaming body is never killed past the fixed ceiling.
358
+ // Wrapping here covers both the staged promise reader and the
359
+ // hedge race below.
360
+ const fetchA = fetch(primaryUrl, { ...opts, headers: first.headers, signal: first.signal, ...relayLegInit() } as unknown as RequestInit).then(
361
+ (r: Response) => { first.disarmHeaderTimeout(); return r; },
362
+ (e: unknown) => { first.disarmHeaderTimeout(); throw e; },
363
+ );
300
364
  const stalled = await new Promise<boolean>((resolve) => {
301
365
  const graceTimer = setTimeout(() => resolve(true), HEDGE_GRACE_MS);
302
366
  fetchA.then(
@@ -311,7 +375,10 @@ export async function relayFetch(
311
375
  const hedgeCtlB = new AbortController();
312
376
  const second = buildAttempt(nextUrl, hedgeCtlB);
313
377
  const hedgeStart = Date.now();
314
- const fetchB = fetch(nextUrl, { ...opts, headers: second.headers, signal: second.signal, ...relayLegInit() } as unknown as RequestInit);
378
+ const fetchB = fetch(nextUrl, { ...opts, headers: second.headers, signal: second.signal, ...relayLegInit() } as unknown as RequestInit).then(
379
+ (r: Response) => { second.disarmHeaderTimeout(); return r; },
380
+ (e: unknown) => { second.disarmHeaderTimeout(); throw e; },
381
+ );
315
382
  // Swallow the loser path so the deliberate abort never
316
383
  // surfaces as an unhandled rejection or a direct fallback.
317
384
  fetchA.catch(() => {});
@@ -355,6 +422,11 @@ export async function relayFetch(
355
422
  throw hedgeErr;
356
423
  }
357
424
  markRelayFailure(nextUrl, 0, (hedgeErr as Error)?.message || String(hedgeErr));
425
+ // Both legs of this race were abandoned; the outer catch
426
+ // reports the primary one, this reports the hedge leg.
427
+ reportAbandoned(
428
+ (hedgeErr as Error)?.name === "AbortError" ? "header-timeout" : "network-error",
429
+ );
358
430
  hedgeSkipNext = true;
359
431
  throw hedgeErr;
360
432
  }
@@ -382,6 +454,7 @@ export async function relayFetch(
382
454
  ui.notify(`relay ${shortRelayLabel(targetUrl)} hit HTTP 504 — rolled to next relay`, "warning");
383
455
  }
384
456
  }
457
+ reportAbandoned("gateway-timeout", 504);
385
458
  continue;
386
459
  }
387
460
  // Relay payload cap hit (413: request exceeds host payload limit):
@@ -406,6 +479,7 @@ export async function relayFetch(
406
479
  ui.notify(`relay ${shortRelayLabel(targetUrl)} hit payload limit — trying next path`, "warning");
407
480
  }
408
481
  }
482
+ reportAbandoned("payload-too-large", 413);
409
483
  continue;
410
484
  }
411
485
 
@@ -441,6 +515,7 @@ export async function relayFetch(
441
515
  ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 404) — rolled to next relay`, "warning");
442
516
  }
443
517
  }
518
+ reportAbandoned("relay-edge-404", 404);
444
519
  continue;
445
520
  }
446
521
  // Relay deployment disabled (402 DEPLOYMENT_DISABLED from Vercel):
@@ -479,6 +554,7 @@ export async function relayFetch(
479
554
  ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 402) — rolled to next relay`, "warning");
480
555
  }
481
556
  }
557
+ reportAbandoned("deployment-disabled", 402);
482
558
  continue;
483
559
  }
484
560
  }
@@ -501,6 +577,7 @@ export async function relayFetch(
501
577
  { upstream: url, status: 429 },
502
578
  rid,
503
579
  );
580
+ reportAbandoned("rate-limited", 429);
504
581
  continue;
505
582
  }
506
583
  log(
@@ -518,6 +595,7 @@ export async function relayFetch(
518
595
  ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP ${res.status}) — rolled to next relay`, "warning");
519
596
  }
520
597
  }
598
+ reportAbandoned("retriable-status", res.status);
521
599
  continue;
522
600
  }
523
601
 
@@ -537,6 +615,7 @@ export async function relayFetch(
537
615
  { upstream: url, status: 500 },
538
616
  rid,
539
617
  );
618
+ reportAbandoned("server-error", 500);
540
619
  continue;
541
620
  }
542
621
  // Second 500 of the turn: the cap is spent and this is the
@@ -609,6 +688,7 @@ export async function relayFetch(
609
688
  );
610
689
  }
611
690
  }
691
+ reportAbandoned(authRejected ? "relay-auth-rejected" : "relay-forbidden", res.status);
612
692
  continue;
613
693
  }
614
694
  // SUCCESS or non-retriable client error (e.g. 200, 404):
@@ -671,7 +751,7 @@ export async function relayFetch(
671
751
  rateLimitOnly = false;
672
752
  const errMsg =
673
753
  (err as Error)?.name === "AbortError"
674
- ? `relay header timeout (${Math.round(UPSTREAM_HEADER_TIMEOUT_MS / 1000)}s exceeded)`
754
+ ? `relay header timeout (${Math.round(attemptHeaderTimeoutMs / 1000)}s exceeded)`
675
755
  : (err as Error)?.message || String(err);
676
756
  markRelayFailure(targetUrl, 0, errMsg);
677
757
  log(
@@ -680,6 +760,7 @@ export async function relayFetch(
680
760
  { upstream: url, error: errMsg },
681
761
  rid,
682
762
  );
763
+ reportAbandoned((err as Error)?.name === "AbortError" ? "header-timeout" : "network-error");
683
764
  continue;
684
765
  }
685
766
  }
package/src/responses.ts CHANGED
@@ -297,6 +297,12 @@ export function prepareChatFailoverBody(parsedBody: Record<string, unknown>): vo
297
297
  if (typeof parsedBody.max_tokens === "number" && parsedBody.max_tokens > KILO_FAILOVER_MAX_OUTPUT_TOKENS) {
298
298
  parsedBody.max_tokens = KILO_FAILOVER_MAX_OUTPUT_TOKENS;
299
299
  }
300
+ if (
301
+ typeof parsedBody.max_completion_tokens === "number" &&
302
+ parsedBody.max_completion_tokens > KILO_FAILOVER_MAX_OUTPUT_TOKENS
303
+ ) {
304
+ parsedBody.max_completion_tokens = KILO_FAILOVER_MAX_OUTPUT_TOKENS;
305
+ }
300
306
  }
301
307
 
302
308
  /**