pi-freeflow 1.5.0 → 1.7.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
@@ -6,20 +6,37 @@ import { randomUUID } from "node:crypto";
6
6
  import { Buffer } from "node:buffer";
7
7
  import { Agent } from "undici";
8
8
  import { isDebugEnabled, log } from "./logger.ts";
9
- import { getModelDef } from "./models.ts";
10
9
 
11
10
  export const agent = new Agent({ keepAliveTimeout: 30_000 });
11
+ /**
12
+ * Node's global fetch runs on the node-process-bundled undici. Node 22/23
13
+ * bundle undici 6.x/7.x whose dispatcher interface rejects an npm-undici 8.x
14
+ * Agent passed as `dispatcher` (`invalid onRequestStart method`). Only attach
15
+ * the custom dispatcher when the bundled undici major matches (Node 25+);
16
+ * otherwise fall back to the built-in global dispatcher, which still pools
17
+ * keep-alive connections, so the relay proxy works on Node 22/23 too.
18
+ */
19
+ const bundledUndiciMajor = Number((process.versions.undici ?? "0").split(".")[0]);
20
+ export const canUseCustomDispatcher = bundledUndiciMajor >= 8;
12
21
  import {
13
22
  getActiveRelayState,
14
23
  getOrderedRelayUrls,
24
+ getStatusUi,
15
25
  markRelayFailure,
16
26
  markRelaySuccess,
17
- saveRelayState,
18
- setActiveRelayState,
19
27
  shortRelayLabel,
20
28
  updateRelayStatusUi,
29
+ withRelayState,
30
+ validateRelayUrl,
21
31
  } from "./relay-state.ts";
22
32
 
33
+ // Throttle user-facing roll notifications so a burst of failures surfaces
34
+ // one warning instead of a wall of identical toasts.
35
+ let lastRollNotify = 0;
36
+ const ROLL_NOTIFY_MS = 5 * 60 * 1_000;
37
+ /** Test-only: reset roll-notify throttle */
38
+ export function _resetRollNotifyForTest(): void { lastRollNotify = 0; }
39
+
23
40
  /**
24
41
  * Determine if an HTTP status code indicates a temporary relay or upstream error
25
42
  * that warrants rolling to the next relay candidate.
@@ -34,20 +51,6 @@ export function isRetriableStatus(status: number): boolean {
34
51
  (status >= 520 && status <= 530)
35
52
  );
36
53
  }
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
-
50
-
51
54
  /**
52
55
  * Fetch a target URL through the active relay pool with rolling failover and direct fallback.
53
56
  *
@@ -65,8 +68,7 @@ export async function relayFetch(
65
68
 
66
69
  if (!relayState.enabled) {
67
70
  log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
68
- return fetch(url, { ...opts, dispatcher: agent } as unknown as RequestInit);
69
-
71
+ return fetch(url, { ...opts, ...(canUseCustomDispatcher ? { dispatcher: agent } : {}) } as unknown as RequestInit);
70
72
  }
71
73
  const candidates = getOrderedRelayUrls();
72
74
 
@@ -74,7 +76,7 @@ export async function relayFetch(
74
76
  // Empty pool: skip straight to upstream instead of logging a misleading
75
77
  // "relays bypassed/exhausted" WARN on every request.
76
78
  log("debug", `relayFetch: direct (empty relay pool) -> ${url}`, undefined, rid);
77
- return fetch(url, { ...opts, dispatcher: agent } as unknown as RequestInit);
79
+ return fetch(url, { ...opts, ...(canUseCustomDispatcher ? { dispatcher: agent } : {}) } as unknown as RequestInit);
78
80
  }
79
81
 
80
82
  let lastResponse: Response | null = null;
@@ -111,6 +113,18 @@ export async function relayFetch(
111
113
  const targetUrl = candidates[i];
112
114
  const attemptStart = Date.now();
113
115
  try {
116
+ // SSRF guard: reject private/loopback/non-https candidates the same
117
+ // way a deployed relay worker rejects an inbound x-relay-target.
118
+ const candidateCheck = validateRelayUrl(targetUrl);
119
+ if (!candidateCheck.ok) {
120
+ log(
121
+ "warn",
122
+ `relay ${targetUrl} skipped — ${candidateCheck.reason}`,
123
+ { upstream: url },
124
+ rid,
125
+ );
126
+ continue;
127
+ }
114
128
  let targetHost = "opencode.ai";
115
129
  try {
116
130
  if (targetUrl) targetHost = new URL(targetUrl).host;
@@ -121,9 +135,17 @@ export async function relayFetch(
121
135
  headers.set("x-relay-path", relayPath);
122
136
  headers.set("host", targetHost);
123
137
  headers.set("x-request-id", rid);
138
+ // Per-relay shared secret set by /freeflow deploy. Legacy entries
139
+ // without auth keep working: no header at all.
140
+ const entry = getActiveRelayState().relays.find(
141
+ (r) => r.url === targetUrl.trim(),
142
+ );
143
+ if (entry?.auth) {
144
+ headers.set("x-relay-auth", entry.auth);
145
+ }
124
146
 
125
147
  const signal = opts.signal || AbortSignal.timeout(300_000);
126
- const res = await fetch(targetUrl, { ...opts, headers, signal, dispatcher: agent } as unknown as RequestInit);
148
+ const res = await fetch(targetUrl, { ...opts, headers, signal, ...(canUseCustomDispatcher ? { dispatcher: agent } : {}) } as unknown as RequestInit);
127
149
  const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
128
150
  // Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
129
151
  // Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
@@ -136,6 +158,14 @@ export async function relayFetch(
136
158
  { upstream: url, sizeKB: bodySizeKB },
137
159
  rid,
138
160
  );
161
+ const now = Date.now();
162
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
163
+ lastRollNotify = now;
164
+ const ui = getStatusUi();
165
+ if (ui?.notify) {
166
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} hit HTTP 504 — falling back to direct`, "warning");
167
+ }
168
+ }
139
169
  break;
140
170
  }
141
171
 
@@ -149,10 +179,18 @@ export async function relayFetch(
149
179
  { upstream: url, status: res.status },
150
180
  rid,
151
181
  );
182
+ const now = Date.now();
183
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
184
+ lastRollNotify = now;
185
+ const ui = getStatusUi();
186
+ if (ui?.notify) {
187
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP ${res.status}) — rolled to next relay`, "warning");
188
+ }
189
+ }
152
190
  continue;
153
191
  }
154
192
 
155
- markRelaySuccess(targetUrl);
193
+ markRelaySuccess(targetUrl, Date.now() - attemptStart);
156
194
 
157
195
  // SUCCESS or non-retriable client error (e.g. 200, 404):
158
196
  // If we switched to a different relay because previous failed, update sticky active relay!
@@ -160,9 +198,12 @@ export async function relayFetch(
160
198
  log("info", `active relay auto-switched to ${targetUrl}`, {
161
199
  previous: relayState.url,
162
200
  }, rid);
163
- relayState.url = targetUrl;
164
- saveRelayState(relayState);
165
- setActiveRelayState(relayState, false);
201
+ // CAS: re-apply the sticky-active switch to the freshest disk state at
202
+ // write time so a concurrent session's pool edit is never clobbered.
203
+ withRelayState((s) => {
204
+ s.url = targetUrl;
205
+ return s;
206
+ });
166
207
  }
167
208
 
168
209
  log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)`, undefined, rid);
@@ -177,6 +218,11 @@ export async function relayFetch(
177
218
  updateRelayStatusUi(targetUrl);
178
219
  return res;
179
220
  } catch (err) {
221
+ // Client abort: do not mark the relay failed — the client cancelled the
222
+ // request, the relay itself is not at fault. Propagate immediately.
223
+ if ((err as Error)?.name === "AbortError") {
224
+ throw err;
225
+ }
180
226
  const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
181
227
  lastError = err;
182
228
  const errMsg = (err as Error)?.message || String(err);
@@ -205,7 +251,7 @@ export async function relayFetch(
205
251
  directHeaders.set("host", u.host);
206
252
  directHeaders.set("x-request-id", rid);
207
253
 
208
- const directRes = await fetch(url, { ...opts, headers: directHeaders, dispatcher: agent } as unknown as RequestInit);
254
+ const directRes = await fetch(url, { ...opts, headers: directHeaders, ...(canUseCustomDispatcher ? { dispatcher: agent } : {}) } as unknown as RequestInit);
209
255
  // lastResponse holds an unread body that would otherwise leak its socket
210
256
  // until GC; the salvage path below still needs it, so only cancel here.
211
257
  lastResponse?.body?.cancel().catch(() => {});