pi-freeflow 1.40.1 → 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
@@ -165,12 +165,18 @@ export interface RelayAffinity {
165
165
  * and `onServed` reports which relay actually produced the response —
166
166
  * `null` for the direct fallback — so the caller does not have to infer
167
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.
168
173
  */
169
174
  export async function relayFetch(
170
175
  url: string,
171
176
  opts: RequestInit = {},
172
177
  reqId?: string,
173
178
  affinity: RelayAffinity = {},
179
+ onAttemptFailure?: (info: { attempt: number; status?: number; reason: string }) => void,
174
180
  ): Promise<Response> {
175
181
  const rid = reqId || randomUUID().slice(0, 8);
176
182
  const relayState = getActiveRelayState();
@@ -200,6 +206,23 @@ export async function relayFetch(
200
206
  let sawBare500 = false;
201
207
  /** True only while every candidate that produced a response answered 429. */
202
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
+ };
203
226
  const u = new URL(url);
204
227
  const relayTarget = `${u.protocol}//${u.host}`;
205
228
  const relayPath = `${u.pathname}${u.search}`;
@@ -248,6 +271,7 @@ export async function relayFetch(
248
271
  { upstream: url },
249
272
  rid,
250
273
  );
274
+ reportAbandoned("invalid-relay-url");
251
275
  continue;
252
276
  }
253
277
  // Per-candidate wiring: relay-target headers, per-relay auth, and a
@@ -398,6 +422,11 @@ export async function relayFetch(
398
422
  throw hedgeErr;
399
423
  }
400
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
+ );
401
430
  hedgeSkipNext = true;
402
431
  throw hedgeErr;
403
432
  }
@@ -425,6 +454,7 @@ export async function relayFetch(
425
454
  ui.notify(`relay ${shortRelayLabel(targetUrl)} hit HTTP 504 — rolled to next relay`, "warning");
426
455
  }
427
456
  }
457
+ reportAbandoned("gateway-timeout", 504);
428
458
  continue;
429
459
  }
430
460
  // Relay payload cap hit (413: request exceeds host payload limit):
@@ -449,6 +479,7 @@ export async function relayFetch(
449
479
  ui.notify(`relay ${shortRelayLabel(targetUrl)} hit payload limit — trying next path`, "warning");
450
480
  }
451
481
  }
482
+ reportAbandoned("payload-too-large", 413);
452
483
  continue;
453
484
  }
454
485
 
@@ -484,6 +515,7 @@ export async function relayFetch(
484
515
  ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 404) — rolled to next relay`, "warning");
485
516
  }
486
517
  }
518
+ reportAbandoned("relay-edge-404", 404);
487
519
  continue;
488
520
  }
489
521
  // Relay deployment disabled (402 DEPLOYMENT_DISABLED from Vercel):
@@ -522,6 +554,7 @@ export async function relayFetch(
522
554
  ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 402) — rolled to next relay`, "warning");
523
555
  }
524
556
  }
557
+ reportAbandoned("deployment-disabled", 402);
525
558
  continue;
526
559
  }
527
560
  }
@@ -544,6 +577,7 @@ export async function relayFetch(
544
577
  { upstream: url, status: 429 },
545
578
  rid,
546
579
  );
580
+ reportAbandoned("rate-limited", 429);
547
581
  continue;
548
582
  }
549
583
  log(
@@ -561,6 +595,7 @@ export async function relayFetch(
561
595
  ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP ${res.status}) — rolled to next relay`, "warning");
562
596
  }
563
597
  }
598
+ reportAbandoned("retriable-status", res.status);
564
599
  continue;
565
600
  }
566
601
 
@@ -580,6 +615,7 @@ export async function relayFetch(
580
615
  { upstream: url, status: 500 },
581
616
  rid,
582
617
  );
618
+ reportAbandoned("server-error", 500);
583
619
  continue;
584
620
  }
585
621
  // Second 500 of the turn: the cap is spent and this is the
@@ -652,6 +688,7 @@ export async function relayFetch(
652
688
  );
653
689
  }
654
690
  }
691
+ reportAbandoned(authRejected ? "relay-auth-rejected" : "relay-forbidden", res.status);
655
692
  continue;
656
693
  }
657
694
  // SUCCESS or non-retriable client error (e.g. 200, 404):
@@ -723,6 +760,7 @@ export async function relayFetch(
723
760
  { upstream: url, error: errMsg },
724
761
  rid,
725
762
  );
763
+ reportAbandoned((err as Error)?.name === "AbortError" ? "header-timeout" : "network-error");
726
764
  continue;
727
765
  }
728
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
  /**