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/README.md +62 -86
- package/package.json +2 -3
- package/src/catalog.ts +21 -12
- package/src/commands.ts +308 -146
- package/src/config.ts +24 -2
- package/src/deploy.ts +128 -185
- package/src/health.ts +2 -7
- package/src/index.ts +22 -4
- package/src/logger.ts +52 -24
- package/src/models.ts +139 -67
- package/src/probe.ts +49 -0
- package/src/proxy.ts +150 -57
- package/src/relay-state.ts +380 -69
- package/src/relay.ts +72 -26
- package/src/stream-pipe.ts +307 -271
- package/src/types.ts +4 -2
- package/src/update-checker.ts +21 -5
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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(() => {});
|