pi-freeflow 1.4.12 → 1.5.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/index.ts CHANGED
@@ -22,16 +22,20 @@ import { log, logInfo, logWarn } from "./logger.ts";
22
22
  import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, getAllRegisteredModels, resolveCanonicalModelId } from "./models.ts";
23
23
  import { isProxyAlive, startProxy } from "./proxy.ts";
24
24
  import { resetRateLimits } from "./rate-limiter.ts";
25
+ import { checkForUpdateInBackground } from "./update-checker.ts";
25
26
  import {
27
+ ensureRelay,
26
28
  getActiveRelayState,
27
29
  resolveRelayState,
28
30
  setActiveRelayState,
29
31
  setStatusUi,
30
32
  setFreeFlowModelActive,
33
+ shortRelayLabel,
31
34
  } from "./relay-state.ts";
32
35
  import type {
33
36
  ExtensionAPI,
34
37
  ExtensionContext,
38
+ ExtensionUIContext,
35
39
  ProviderConfig,
36
40
  RegisteredModel,
37
41
  } from "./types.ts";
@@ -49,6 +53,56 @@ export * from "./deploy.ts";
49
53
  export * from "./stream-pipe.ts";
50
54
  export * from "./proxy.ts";
51
55
  export * from "./commands.ts";
56
+ /**
57
+ * Bind widget click to relay picker when host supports it.
58
+ * Feature-detects `onStatusClick` (or similar) on ExtensionUIContext.
59
+ * Falls back to no-op; status hint already tells user to use `/freeflow use`.
60
+ */
61
+ export function bindWidgetClick(ui: ExtensionUIContext): void {
62
+ const anyUi = ui as unknown as Record<string, unknown>;
63
+ const candidates = ["onStatusClick", "onStatusBarClick", "onWidgetClick"] as const;
64
+ let clickFn: ((h: () => void | Promise<void>) => unknown) | undefined;
65
+ for (const name of candidates) {
66
+ const v = anyUi[name];
67
+ if (typeof v === "function") {
68
+ clickFn = v as (h: () => void | Promise<void>) => unknown;
69
+ break;
70
+ }
71
+ }
72
+ if (!clickFn) return;
73
+ try {
74
+ clickFn.call(anyUi, async () => {
75
+ try {
76
+ const state = getActiveRelayState();
77
+ if (!state.relays.length) {
78
+ ui.notify("Use /freeflow use — no relays saved. Add one with /freeflow add <URL>", "info");
79
+ return;
80
+ }
81
+ const fmt = (r: { url: string; label?: string }, idx: number) => {
82
+ const isAct = r.url === state.url ? "★ " : " ";
83
+ const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, state.relays)}] `;
84
+ return `${isAct}[${idx + 1}] ${lbl}→ ${r.url}`;
85
+ };
86
+ const opts = state.relays.map(fmt);
87
+ const choice = await ui.select("Switch active relay", opts);
88
+ if (!choice) return;
89
+ const idx = opts.indexOf(choice);
90
+ const match = idx >= 0 ? state.relays[idx] : undefined;
91
+ // Fallback find by formatting match
92
+ const resolved = match ?? state.relays.find((r, i) => fmt(r, i) === choice);
93
+ if (!resolved) return;
94
+ state.enabled = true;
95
+ state.url = resolved.url;
96
+ ensureRelay(state, resolved.url, resolved.label);
97
+ setActiveRelayState(state);
98
+ setStatusUi(ui);
99
+ updateStatusBar(ui);
100
+ ui.notify(`Relay active: ${resolved.label || shortRelayLabel(resolved.url, state.relays)}`, "info");
101
+ } catch {}
102
+ });
103
+ } catch {}
104
+ }
105
+
52
106
 
53
107
  /**
54
108
  * Construct standard ProviderConfig for pi-ai / OMP registration.
@@ -242,6 +296,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
242
296
  await ensureDaemon();
243
297
  const freshRelayState = resolveRelayState();
244
298
  setStatusUi(ctx.ui);
299
+ try { bindWidgetClick(ctx.ui); } catch {}
245
300
 
246
301
  let provider: string | undefined;
247
302
  let modelId: string | undefined;
@@ -267,12 +322,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
267
322
  } else {
268
323
  ctx.ui?.setStatus?.("freeflow", undefined);
269
324
  }
325
+ try { checkForUpdateInBackground(ctx.ui as unknown as ExtensionUIContext); } catch {}
270
326
  });
271
-
272
327
  pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
273
328
  await ensureDaemon();
274
329
  const freshRelayState = resolveRelayState();
275
330
  setStatusUi(ctx.ui);
331
+ try { bindWidgetClick(ctx.ui); } catch {}
276
332
  let provider: string | undefined;
277
333
  let modelId: string | undefined;
278
334
  if (event && typeof event === "object" && "model" in event && event.model && typeof event.model === "object") {
package/src/models.ts CHANGED
@@ -21,15 +21,14 @@ export const OPENCODE_MODELS: ModelDef[] = [
21
21
  maxTokens: 131_072,
22
22
  api: "openai-responses",
23
23
  input: ["text", "image"],
24
- thinkingLevelMap: {
25
- off: null,
26
- minimal: "minimal",
27
- low: "low",
28
- medium: "medium",
29
- high: "high",
30
- xhigh: "xhigh",
31
- max: "max",
32
- },
24
+ thinkingLevelMap: {
25
+ off: null,
26
+ minimal: "minimal",
27
+ low: "low",
28
+ medium: "medium",
29
+ high: "high",
30
+ xhigh: "xhigh",
31
+ max: null,},
33
32
  },
34
33
  {
35
34
  id: "mimo-v2.5-free",
@@ -38,15 +37,14 @@ export const OPENCODE_MODELS: ModelDef[] = [
38
37
  contextWindow: 1_048_576,
39
38
  maxTokens: 131_072,
40
39
  input: ["text", "image"],
41
- thinkingLevelMap: {
42
- off: null,
43
- minimal: "low",
44
- low: "low",
45
- medium: "medium",
46
- high: "high",
47
- xhigh: "high",
48
- max: "high",
49
- },
40
+ thinkingLevelMap: {
41
+ off: null,
42
+ minimal: "low",
43
+ low: "low",
44
+ medium: "medium",
45
+ high: "high",
46
+ xhigh: "high",
47
+ max: null,},
50
48
  },
51
49
  {
52
50
  id: "hy3-free",
@@ -55,15 +53,14 @@ export const OPENCODE_MODELS: ModelDef[] = [
55
53
  contextWindow: 262_144,
56
54
  maxTokens: 262_144,
57
55
  input: ["text"],
58
- thinkingLevelMap: {
59
- off: null,
60
- minimal: "low",
61
- low: "low",
62
- medium: "high",
63
- high: "high",
64
- xhigh: "max",
65
- max: "max",
66
- },
56
+ thinkingLevelMap: {
57
+ off: null,
58
+ minimal: "minimal",
59
+ low: "low",
60
+ medium: "medium",
61
+ high: "high",
62
+ xhigh: "xhigh",
63
+ max: null,},
67
64
  },
68
65
  {
69
66
  id: "nemotron-3-ultra-free",
@@ -72,15 +69,14 @@ export const OPENCODE_MODELS: ModelDef[] = [
72
69
  contextWindow: 1_000_000,
73
70
  maxTokens: 128_000,
74
71
  input: ["text"],
75
- thinkingLevelMap: {
76
- off: null,
77
- minimal: "low",
78
- low: "low",
79
- medium: "high",
80
- high: "high",
81
- xhigh: "max",
82
- max: "max",
83
- },
72
+ thinkingLevelMap: {
73
+ off: null,
74
+ minimal: "minimal",
75
+ low: "low",
76
+ medium: "medium",
77
+ high: "high",
78
+ xhigh: "xhigh",
79
+ max: null,},
84
80
  },
85
81
  {
86
82
  id: "nemotron-3.5-lightning-free",
@@ -89,15 +85,14 @@ export const OPENCODE_MODELS: ModelDef[] = [
89
85
  contextWindow: 1_000_000,
90
86
  maxTokens: 262_144,
91
87
  input: ["text"],
92
- thinkingLevelMap: {
93
- off: null,
94
- minimal: "low",
95
- low: "low",
96
- medium: "high",
97
- high: "high",
98
- xhigh: "max",
99
- max: "max",
100
- },
88
+ thinkingLevelMap: {
89
+ off: null,
90
+ minimal: "minimal",
91
+ low: "low",
92
+ medium: "medium",
93
+ high: "high",
94
+ xhigh: "xhigh",
95
+ max: null,},
101
96
  },
102
97
  {
103
98
  id: "big-pickle",
@@ -106,15 +101,14 @@ export const OPENCODE_MODELS: ModelDef[] = [
106
101
  contextWindow: 200_000,
107
102
  maxTokens: 32_000,
108
103
  input: ["text"],
109
- thinkingLevelMap: {
110
- off: null,
111
- minimal: "high",
112
- low: "high",
113
- medium: "high",
114
- high: "high",
115
- xhigh: "max",
116
- max: "max",
117
- },
104
+ thinkingLevelMap: {
105
+ off: null,
106
+ minimal: null,
107
+ low: null,
108
+ medium: null,
109
+ high: "high",
110
+ xhigh: null,
111
+ max: "max",},
118
112
  },
119
113
  {
120
114
  id: "laguna-s-2.1-free",
@@ -123,15 +117,14 @@ export const OPENCODE_MODELS: ModelDef[] = [
123
117
  contextWindow: 1_048_576,
124
118
  maxTokens: 131_072,
125
119
  input: ["text"],
126
- thinkingLevelMap: {
127
- off: null,
128
- minimal: "low",
129
- low: "low",
130
- medium: "high",
131
- high: "high",
132
- xhigh: "max",
133
- max: "max",
134
- },
120
+ thinkingLevelMap: {
121
+ off: null,
122
+ minimal: "minimal",
123
+ low: "low",
124
+ medium: "medium",
125
+ high: "high",
126
+ xhigh: "xhigh",
127
+ max: null,},
135
128
  },
136
129
  ];
137
130
 
@@ -186,7 +179,7 @@ export const KILO_MODELS: ModelDef[] = [
186
179
  name: "Nemotron 3.5 Lightning (Kilo)",
187
180
  reasoning: true,
188
181
  contextWindow: 1_000_000,
189
- maxTokens: 131_072,
182
+ maxTokens: 262_144,
190
183
  input: ["text"],
191
184
  thinkingFormat: "openrouter",
192
185
  },
@@ -206,6 +199,7 @@ export const KILO_MODELS: ModelDef[] = [
206
199
  contextWindow: 262_144,
207
200
  maxTokens: 262_144,
208
201
  input: ["text"],
202
+ thinkingFormat: "openrouter",
209
203
  },
210
204
  {
211
205
  id: "cohere/north-mini-code:free",
package/src/proxy.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { randomUUID } from "node:crypto";
9
9
  import * as http from "node:http";
10
10
  import * as https from "node:https";
11
+ import { handleHealthRequest } from "./health.ts";
11
12
  import { Readable } from "node:stream";
12
13
  import type { ReadableStream as WebReadableStream } from "node:stream/web";
13
14
  import { getAliveCatalog } from "./catalog.ts";
@@ -26,7 +27,7 @@ import { isDebugEnabled, log } from "./logger.ts";
26
27
  import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
27
28
  // normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
28
29
  import { checkRateLimit } from "./rate-limiter.ts";
29
- import { relayFetch } from "./relay.ts";
30
+ import { agent, relayFetch } from "./relay.ts";
30
31
  import { getActiveRelayState } from "./relay-state.ts";
31
32
  import { pipeUpstreamStream } from "./stream-pipe.ts";
32
33
  import type { Upstream } from "./types.ts";
@@ -147,13 +148,16 @@ export function startProxy(
147
148
  return;
148
149
  }
149
150
 
150
- // Serve ONLY our registered free models. Never forward /v1/models to upstream
151
- // to prevent paid/proprietary upstream models from leaking into the model picker.
152
- // Use pathname check so /v1/models?query variants are also guarded (no leak).
153
151
  let reqPathname: string | null = null;
154
152
  try {
155
153
  reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
156
154
  } catch {}
155
+ // Loopback-only health endpoint — always accessible even when widget hidden
156
+ if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health" || reqPathname.endsWith("/health"))) {
157
+ const addr = server.address();
158
+ const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
159
+ if (handleHealthRequest(req, res, realPort)) return;
160
+ }
157
161
  if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
158
162
  const alive = getAliveCatalog();
159
163
  const body = JSON.stringify({
@@ -365,75 +369,70 @@ export function startProxy(
365
369
  if (directBody.length > 0) {
366
370
  fwd["content-length"] = String(directBody.byteLength);
367
371
  }
372
+ fwd["connection"] = "keep-alive";
368
373
 
369
- const proxy = https.request(
370
- {
374
+ const controller = new AbortController();
375
+ const timeoutId = setTimeout(() => controller.abort(), 300_000);
376
+ const onClientClose = () => {
377
+ if (!res.writableEnded) controller.abort();
378
+ };
379
+ const onReqError = () => controller.abort();
380
+ res.on("close", onClientClose);
381
+ req.on("error", onReqError);
382
+
383
+ try {
384
+ const upstreamRes = await fetch(target.href, {
371
385
  method: req.method,
372
- hostname: target.hostname,
373
- port: 443,
374
- path: target.pathname + target.search,
375
386
  headers: fwd,
376
- },
377
- (upstream) => {
378
- const outHeaders: Record<string, string> = {};
379
- for (const h of [
380
- "content-type",
381
- "cache-control",
382
- "x-request-id",
383
- ]) {
384
- const val = upstream.headers[h];
385
- if (typeof val === "string") outHeaders[h] = val;
386
- }
387
- outHeaders["x-content-type-options"] = "nosniff";
388
- res.writeHead(upstream.statusCode ?? 502, outHeaders);
389
- if (isStream) {
390
- pipeUpstreamStream(upstream, res, req, reqId, "direct");
391
- } else {
392
- upstream.on("error", (streamErr) => {
393
- log(
394
- "error",
395
- "upstream stream error in direct proxy",
396
- { error: String(streamErr) },
397
- reqId,
398
- );
399
- if (!res.writableEnded) res.end();
400
- });
401
- upstream.pipe(res);
402
- }
403
- },
404
- );
405
-
406
- proxy.on("error", (proxyErr) => {
407
- log(
408
- "error",
409
- "proxy socket error",
410
- { error: String(proxyErr) },
411
- reqId,
412
- );
387
+ body: directBody.length > 0 ? directBody : undefined,
388
+ signal: controller.signal,
389
+ dispatcher: agent,
390
+ } as unknown as RequestInit);
391
+ clearTimeout(timeoutId);
392
+ res.off("close", onClientClose);
393
+ req.off("error", onReqError);
394
+
395
+ const outHeaders: Record<string, string> = {};
396
+ for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
397
+ const v = upstreamRes.headers.get(h);
398
+ if (v) outHeaders[h] = v;
399
+ }
400
+ outHeaders["x-content-type-options"] = "nosniff";
401
+ outHeaders["connection"] = "keep-alive";
402
+ const ka = upstreamRes.headers.get("keep-alive");
403
+ if (ka) outHeaders["keep-alive"] = ka;
404
+
405
+ res.writeHead(upstreamRes.status, outHeaders);
406
+ if (isStream && upstreamRes.body) {
407
+ pipeUpstreamStream(
408
+ Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
409
+ res,
410
+ req,
411
+ reqId,
412
+ "direct",
413
+ );
414
+ } else if (upstreamRes.body) {
415
+ const nodeStream = Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream);
416
+ nodeStream.on("error", (streamErr) => {
417
+ log("error", "upstream stream error in direct proxy", { error: String(streamErr) }, reqId);
418
+ if (!res.writableEnded) res.end();
419
+ });
420
+ nodeStream.pipe(res);
421
+ } else {
422
+ res.end();
423
+ }
424
+ } catch (proxyErr) {
425
+ clearTimeout(timeoutId);
426
+ res.off("close", onClientClose);
427
+ req.off("error", onReqError);
428
+ log("error", "proxy socket error", { error: String(proxyErr) }, reqId);
413
429
  if (!res.headersSent) {
414
430
  res.writeHead(502, { "content-type": "application/json" });
415
431
  res.end(JSON.stringify({ error: "upstream error" }));
416
432
  } else if (!res.writableEnded) {
417
433
  res.end();
418
434
  }
419
- });
420
-
421
- proxy.setTimeout(300_000, () => {
422
- proxy.destroy(new Error("timeout"));
423
- });
424
-
425
- // Premature client disconnect guard.
426
- // Node >= 19 emits req 'close' right after the request body 'end',
427
- // so destroying on req close/aborted kills every healthy upstream
428
- // socket milliseconds after creation. Only tear down when the
429
- // client response connection actually drops mid-flight.
430
- const destroyIfClientGone = () => {
431
- if (!res.writableEnded && !proxy.destroyed) proxy.destroy();
432
- };
433
- req.on("error", destroyIfClientGone);
434
- res.on("close", destroyIfClientGone);
435
-
436
- proxy.end(directBody);
435
+ }
437
436
  }
438
437
  } catch (err) {
439
438
  log("error", "proxy error", { error: String(err) }, reqId);
@@ -192,8 +192,6 @@ export function resolveRelayState(): RelayState {
192
192
  }
193
193
 
194
194
  let activeRelayState: RelayState = resolveRelayState();
195
- // Monotonic counter to distribute primary relay across concurrent subagents
196
- let roundRobinCounter = 0;
197
195
  let activeStatusUi: ExtensionUIContext | null = null;
198
196
  let isFreeFlowModelActive = true;
199
197
 
@@ -206,6 +204,7 @@ export interface RelayHealth {
206
204
  }
207
205
 
208
206
  const relayHealthMap = new Map<string, RelayHealth>();
207
+ let last429Warn = 0;
209
208
 
210
209
  /**
211
210
  * Mark a relay as healthy and active on successful response.
@@ -231,11 +230,15 @@ export function markRelayFailure(url: string, status?: number, error?: string):
231
230
  let cooldownMs = 30_000; // 30s default for socket/network/502/503
232
231
 
233
232
  if (status === 429) {
234
- cooldownMs = 90_000; // 90s cooldown for upstream rate limits
233
+ cooldownMs = 90_000;
234
+ if (now - last429Warn > 10 * 60 * 1000) {
235
+ logWarn("relay 429 burst >5/min, consider adding egress", { relay: clean });
236
+ last429Warn = now;
237
+ }
235
238
  } else if (status === 504) {
236
- cooldownMs = 60_000; // 60s cooldown for gateway timeout
239
+ cooldownMs = 60_000;
237
240
  } else if (status && status >= 500) {
238
- cooldownMs = 45_000; // 45s for 5xx errors
241
+ cooldownMs = 45_000;
239
242
  }
240
243
 
241
244
  // Escalate with consecutive failures so chronic offenders back off up to
@@ -273,7 +276,10 @@ export function getRelayHealth(url: string): RelayHealth | undefined {
273
276
  */
274
277
  export function resetAllRelayHealth(): void {
275
278
  relayHealthMap.clear();
279
+ last429Warn = 0;
276
280
  }
281
+ /** Test-only: reset 429 warn throttle */
282
+ export function _reset429WarnForTest(): void { last429Warn = 0; }
277
283
  /**
278
284
  * Mtime of the on-disk state file at the moment we last read or wrote it.
279
285
  * session's master daemon, while never clobbering this process's own
@@ -381,11 +387,11 @@ export function getOrderedRelayUrls(): string[] {
381
387
  if (activeIdx < 0) {
382
388
  activeIdx = 0;
383
389
  }
384
- // Rotate starting point per-request to avoid thundering herd when many
385
- // subagents hit the shared 127.0.0.1 daemon at once — each request
386
- // tries a different primary relay, but still rolls seamlessly on 429.
390
+ // Sticky primary until error — keep active relay as primary for all requests,
391
+ // only rolling to next healthy on 429/5xx or cooldown. Avoids per-request
392
+ // rotation that sprays load; successive requests reuse same egress IP.
387
393
  const totalRelays = activeRelayState.relays.length;
388
- const startIdx = (activeIdx + (roundRobinCounter++ % totalRelays)) % totalRelays;
394
+ const startIdx = activeIdx;
389
395
  const rawOrdered: string[] = [];
390
396
  for (let i = 0; i < totalRelays; i++) {
391
397
  const r = activeRelayState.relays[(startIdx + i) % totalRelays];
@@ -416,6 +422,10 @@ export function updateRelayStatusUi(targetUrl?: string): void {
416
422
  activeStatusUi.setStatus("freeflow", undefined);
417
423
  return;
418
424
  }
425
+ if (getActiveRelayState().hideWidget) {
426
+ activeStatusUi.setStatus("freeflow", undefined);
427
+ return;
428
+ }
419
429
  const state = getActiveRelayState();
420
430
  if (!state.enabled || !state.relays || state.relays.length === 0) {
421
431
  activeStatusUi.setStatus("freeflow", undefined);
package/src/relay.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
- * High-resiliency multi-cloud relay client and failover dispatcher for pi-freeflow
3
- *
4
- * Implements sticky preference, rolling failover across distributed egress relays,
5
- * 25-second fast fallback for Vercel 504 timeouts, and automatic direct upstream execution.
2
+ * High-resiliency multi-cloud relay client and failover dispatcher
6
3
  */
7
4
 
8
5
  import { randomUUID } from "node:crypto";
9
6
  import { Buffer } from "node:buffer";
7
+ import { Agent } from "undici";
10
8
  import { isDebugEnabled, log } from "./logger.ts";
9
+ import { getModelDef } from "./models.ts";
10
+
11
+ export const agent = new Agent({ keepAliveTimeout: 30_000 });
11
12
  import {
12
13
  getActiveRelayState,
13
14
  getOrderedRelayUrls,
@@ -33,6 +34,19 @@ export function isRetriableStatus(status: number): boolean {
33
34
  (status >= 520 && status <= 530)
34
35
  );
35
36
  }
37
+ export function clampMaxTokens(modelId: string, requested: number): number {
38
+ const def = getModelDef(modelId);
39
+ const max = def ? def.maxTokens - 1024 : 32_000 - 1024;
40
+ // special case for big-pickle acceptance
41
+ const effectiveMax = modelId.includes("big-pickle") ? 32_000 : max;
42
+ const clamped = Math.min(requested, effectiveMax);
43
+ if (clamped !== requested) {
44
+ // logDebug
45
+ try { log("debug", `clamp maxTokens ${requested}->${clamped} for ${modelId}`); } catch {}
46
+ }
47
+ return clamped;
48
+ }
49
+
36
50
 
37
51
  /**
38
52
  * Fetch a target URL through the active relay pool with rolling failover and direct fallback.
@@ -51,16 +65,16 @@ export async function relayFetch(
51
65
 
52
66
  if (!relayState.enabled) {
53
67
  log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
54
- return fetch(url, opts);
55
- }
68
+ return fetch(url, { ...opts, dispatcher: agent } as unknown as RequestInit);
56
69
 
70
+ }
57
71
  const candidates = getOrderedRelayUrls();
58
72
 
59
73
  if (candidates.length === 0) {
60
74
  // Empty pool: skip straight to upstream instead of logging a misleading
61
75
  // "relays bypassed/exhausted" WARN on every request.
62
76
  log("debug", `relayFetch: direct (empty relay pool) -> ${url}`, undefined, rid);
63
- return fetch(url, opts);
77
+ return fetch(url, { ...opts, dispatcher: agent } as unknown as RequestInit);
64
78
  }
65
79
 
66
80
  let lastResponse: Response | null = null;
@@ -109,9 +123,8 @@ export async function relayFetch(
109
123
  headers.set("x-request-id", rid);
110
124
 
111
125
  const signal = opts.signal || AbortSignal.timeout(300_000);
112
- const res = await fetch(targetUrl, { ...opts, headers, signal });
126
+ const res = await fetch(targetUrl, { ...opts, headers, signal, dispatcher: agent } as unknown as RequestInit);
113
127
  const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
114
-
115
128
  // Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
116
129
  // Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
117
130
  if (res.status === 504) {
@@ -192,9 +205,7 @@ export async function relayFetch(
192
205
  directHeaders.set("host", u.host);
193
206
  directHeaders.set("x-request-id", rid);
194
207
 
195
- const directRes = await fetch(url, { ...opts, headers: directHeaders });
196
- const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
197
- log("info", `direct fetch returned HTTP ${directRes.status} in ${directElapsed}s`, undefined, rid);
208
+ const directRes = await fetch(url, { ...opts, headers: directHeaders, dispatcher: agent } as unknown as RequestInit);
198
209
  // lastResponse holds an unread body that would otherwise leak its socket
199
210
  // until GC; the salvage path below still needs it, so only cancel here.
200
211
  lastResponse?.body?.cancel().catch(() => {});