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/CHANGELOG.md +65 -0
- package/README.md +36 -19
- package/package.json +1 -1
- package/src/catalog.ts +33 -21
- package/src/cline-accounts.ts +4 -3
- package/src/config.ts +13 -0
- package/src/models.ts +240 -64
- package/src/opencode-fingerprint.ts +78 -17
- package/src/proxy.ts +339 -12
- package/src/relay.ts +92 -11
- package/src/responses.ts +6 -0
- package/src/stream-pipe.ts +448 -67
- package/src/tool-translation.ts +15 -12
- package/src/toolcall-audit.ts +580 -0
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
|
-
* (
|
|
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
|
-
|
|
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,
|
|
270
|
-
: AbortSignal.any([
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
/**
|