pi-freeflow 1.4.1 → 1.4.3
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 +325 -236
- package/package.json +4 -1
- package/src/catalog.ts +222 -207
- package/src/commands.ts +670 -606
- package/src/config.ts +144 -145
- package/src/deploy.ts +496 -126
- package/src/index.ts +301 -277
- package/src/models.ts +399 -399
- package/src/proxy.ts +503 -500
- package/src/relay-state.ts +33 -4
- package/src/relay.ts +213 -201
- package/src/stream-pipe.ts +265 -215
package/src/relay-state.ts
CHANGED
|
@@ -41,6 +41,32 @@ export function loadRelayState(): RelayState {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Block synchronously for ms without burning CPU. */
|
|
45
|
+
function sleepSync(ms: number): void {
|
|
46
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, Date.now() + ms);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Rename with bounded retry: Windows briefly locks the target when another
|
|
51
|
+
* process holds the state file open, surfacing as EPERM/EACCES/EBUSY.
|
|
52
|
+
* Three attempts with a fixed 50ms backoff; any other error fails immediately.
|
|
53
|
+
*/
|
|
54
|
+
function renameWithRetry(from: string, to: string): void {
|
|
55
|
+
const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
|
|
56
|
+
for (let attempt = 1; ; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
fs.renameSync(from, to);
|
|
59
|
+
return;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
62
|
+
if (!code || !retryable[code] || attempt >= 3) {
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
sleepSync(50);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
/**
|
|
45
71
|
* Atomically save relay state to disk using a temporary file and rename.
|
|
46
72
|
*/
|
|
@@ -52,7 +78,7 @@ export function saveRelayState(s: RelayState): void {
|
|
|
52
78
|
}
|
|
53
79
|
const tmpPath = `${RELAY_STATE_FILE}.${randomUUID()}.tmp`;
|
|
54
80
|
fs.writeFileSync(tmpPath, JSON.stringify(s, null, 2), "utf8");
|
|
55
|
-
|
|
81
|
+
renameWithRetry(tmpPath, RELAY_STATE_FILE);
|
|
56
82
|
} catch (e) {
|
|
57
83
|
logWarn("Could not persist relay state", { error: String(e) });
|
|
58
84
|
}
|
|
@@ -212,10 +238,13 @@ export function markRelayFailure(url: string, status?: number, error?: string):
|
|
|
212
238
|
cooldownMs = 45_000; // 45s for 5xx errors
|
|
213
239
|
}
|
|
214
240
|
|
|
241
|
+
// Escalate with consecutive failures so chronic offenders back off up to
|
|
242
|
+
// 4x their base cooldown instead of re-hammering at a fixed interval.
|
|
243
|
+
const multiplier = Math.min(4, consecutive);
|
|
215
244
|
relayHealthMap.set(clean, {
|
|
216
245
|
consecutiveFailures: consecutive,
|
|
217
246
|
lastFailureTime: now,
|
|
218
|
-
cooldownUntil: now + cooldownMs,
|
|
247
|
+
cooldownUntil: now + cooldownMs * multiplier,
|
|
219
248
|
lastStatus: status,
|
|
220
249
|
lastError: error,
|
|
221
250
|
});
|
|
@@ -370,9 +399,9 @@ export function getOrderedRelayUrls(): string[] {
|
|
|
370
399
|
const cooling = rawOrdered.filter((u) => !isRelayHealthy(u));
|
|
371
400
|
const ordered = [...healthy, ...cooling];
|
|
372
401
|
|
|
373
|
-
return ordered
|
|
402
|
+
return ordered;
|
|
374
403
|
}
|
|
375
|
-
return [
|
|
404
|
+
return [];
|
|
376
405
|
}
|
|
377
406
|
|
|
378
407
|
/**
|
package/src/relay.ts
CHANGED
|
@@ -1,201 +1,213 @@
|
|
|
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.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
9
|
-
import { Buffer } from "node:buffer";
|
|
10
|
-
import { isDebugEnabled, log } from "./logger.ts";
|
|
11
|
-
import {
|
|
12
|
-
getActiveRelayState,
|
|
13
|
-
getOrderedRelayUrls,
|
|
14
|
-
markRelayFailure,
|
|
15
|
-
markRelaySuccess,
|
|
16
|
-
saveRelayState,
|
|
17
|
-
setActiveRelayState,
|
|
18
|
-
shortRelayLabel,
|
|
19
|
-
updateRelayStatusUi,
|
|
20
|
-
} from "./relay-state.ts";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Determine if an HTTP status code indicates a temporary relay or upstream error
|
|
24
|
-
* that warrants rolling to the next relay candidate.
|
|
25
|
-
*/
|
|
26
|
-
export function isRetriableStatus(status: number): boolean {
|
|
27
|
-
return (
|
|
28
|
-
status === 429 ||
|
|
29
|
-
status === 408 ||
|
|
30
|
-
status ===
|
|
31
|
-
status ===
|
|
32
|
-
status ===
|
|
33
|
-
status
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* @param
|
|
42
|
-
* @param
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { Buffer } from "node:buffer";
|
|
10
|
+
import { isDebugEnabled, log } from "./logger.ts";
|
|
11
|
+
import {
|
|
12
|
+
getActiveRelayState,
|
|
13
|
+
getOrderedRelayUrls,
|
|
14
|
+
markRelayFailure,
|
|
15
|
+
markRelaySuccess,
|
|
16
|
+
saveRelayState,
|
|
17
|
+
setActiveRelayState,
|
|
18
|
+
shortRelayLabel,
|
|
19
|
+
updateRelayStatusUi,
|
|
20
|
+
} from "./relay-state.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Determine if an HTTP status code indicates a temporary relay or upstream error
|
|
24
|
+
* that warrants rolling to the next relay candidate.
|
|
25
|
+
*/
|
|
26
|
+
export function isRetriableStatus(status: number): boolean {
|
|
27
|
+
return (
|
|
28
|
+
status === 429 ||
|
|
29
|
+
status === 408 ||
|
|
30
|
+
status === 502 ||
|
|
31
|
+
status === 503 ||
|
|
32
|
+
status === 504 ||
|
|
33
|
+
(status >= 520 && status <= 530)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fetch a target URL through the active relay pool with rolling failover and direct fallback.
|
|
39
|
+
*
|
|
40
|
+
* @param url Full upstream destination URL (e.g. https://opencode.ai/zen/v1/chat/completions)
|
|
41
|
+
* @param opts Standard fetch RequestInit options
|
|
42
|
+
* @param reqId Optional correlation request ID for end-to-end tracing
|
|
43
|
+
*/
|
|
44
|
+
export async function relayFetch(
|
|
45
|
+
url: string,
|
|
46
|
+
opts: RequestInit = {},
|
|
47
|
+
reqId?: string,
|
|
48
|
+
): Promise<Response> {
|
|
49
|
+
const rid = reqId || randomUUID().slice(0, 8);
|
|
50
|
+
const relayState = getActiveRelayState();
|
|
51
|
+
|
|
52
|
+
if (!relayState.enabled) {
|
|
53
|
+
log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
|
|
54
|
+
return fetch(url, opts);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const candidates = getOrderedRelayUrls();
|
|
58
|
+
|
|
59
|
+
if (candidates.length === 0) {
|
|
60
|
+
// Empty pool: skip straight to upstream instead of logging a misleading
|
|
61
|
+
// "relays bypassed/exhausted" WARN on every request.
|
|
62
|
+
log("debug", `relayFetch: direct (empty relay pool) -> ${url}`, undefined, rid);
|
|
63
|
+
return fetch(url, opts);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let lastResponse: Response | null = null;
|
|
67
|
+
let lastError: unknown = null;
|
|
68
|
+
const u = new URL(url);
|
|
69
|
+
const relayTarget = `${u.protocol}//${u.host}`;
|
|
70
|
+
const relayPath = `${u.pathname}${u.search}`;
|
|
71
|
+
|
|
72
|
+
const bodySizeKB =
|
|
73
|
+
typeof opts.body === "string"
|
|
74
|
+
? (opts.body.length / 1024).toFixed(1)
|
|
75
|
+
: Buffer.isBuffer(opts.body)
|
|
76
|
+
? (opts.body.length / 1024).toFixed(1)
|
|
77
|
+
: "0";
|
|
78
|
+
|
|
79
|
+
log("info", `request starting (${bodySizeKB}KB payload) -> ${url}`, undefined, rid);
|
|
80
|
+
if (isDebugEnabled()) {
|
|
81
|
+
try {
|
|
82
|
+
const bodyPreview = typeof opts.body === "string" ? opts.body.slice(0, 1200) : "";
|
|
83
|
+
const modelMatch = bodyPreview.match(/"model"\s*:\s*"([^"]+)"/);
|
|
84
|
+
const streamMatch = bodyPreview.match(/"stream"\s*:\s*(true|false)/);
|
|
85
|
+
log("debug", "request detail", {
|
|
86
|
+
model: modelMatch?.[1],
|
|
87
|
+
stream: streamMatch?.[1],
|
|
88
|
+
sizeKB: bodySizeKB,
|
|
89
|
+
relayTarget,
|
|
90
|
+
relayPath,
|
|
91
|
+
candidates: candidates.length,
|
|
92
|
+
}, rid);
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
97
|
+
const targetUrl = candidates[i];
|
|
98
|
+
const attemptStart = Date.now();
|
|
99
|
+
try {
|
|
100
|
+
let targetHost = "opencode.ai";
|
|
101
|
+
try {
|
|
102
|
+
if (targetUrl) targetHost = new URL(targetUrl).host;
|
|
103
|
+
} catch {}
|
|
104
|
+
|
|
105
|
+
const headers = new Headers(opts.headers);
|
|
106
|
+
headers.set("x-relay-target", relayTarget);
|
|
107
|
+
headers.set("x-relay-path", relayPath);
|
|
108
|
+
headers.set("host", targetHost);
|
|
109
|
+
headers.set("x-request-id", rid);
|
|
110
|
+
|
|
111
|
+
const signal = opts.signal || AbortSignal.timeout(300_000);
|
|
112
|
+
const res = await fetch(targetUrl, { ...opts, headers, signal });
|
|
113
|
+
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
114
|
+
|
|
115
|
+
// Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
|
|
116
|
+
// Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
|
|
117
|
+
if (res.status === 504) {
|
|
118
|
+
markRelayFailure(targetUrl, 504, "Gateway Timeout (25s exceeded)");
|
|
119
|
+
res.body?.cancel().catch(() => {});
|
|
120
|
+
log(
|
|
121
|
+
"warn",
|
|
122
|
+
`relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
|
|
123
|
+
{ upstream: url, sizeKB: bodySizeKB },
|
|
124
|
+
rid,
|
|
125
|
+
);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isRetriableStatus(res.status)) {
|
|
130
|
+
markRelayFailure(targetUrl, res.status);
|
|
131
|
+
lastResponse?.body?.cancel().catch(() => {});
|
|
132
|
+
lastResponse = res;
|
|
133
|
+
log(
|
|
134
|
+
"warn",
|
|
135
|
+
`relay ${targetUrl} returned HTTP ${res.status} in ${elapsed}s — rolling to next relay`,
|
|
136
|
+
{ upstream: url, status: res.status },
|
|
137
|
+
rid,
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
markRelaySuccess(targetUrl);
|
|
143
|
+
|
|
144
|
+
// SUCCESS or non-retriable client error (e.g. 200, 404):
|
|
145
|
+
// If we switched to a different relay because previous failed, update sticky active relay!
|
|
146
|
+
if (relayState.url !== targetUrl) {
|
|
147
|
+
log("info", `active relay auto-switched to ${targetUrl}`, {
|
|
148
|
+
previous: relayState.url,
|
|
149
|
+
}, rid);
|
|
150
|
+
relayState.url = targetUrl;
|
|
151
|
+
saveRelayState(relayState);
|
|
152
|
+
setActiveRelayState(relayState, false);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)`, undefined, rid);
|
|
156
|
+
if (isDebugEnabled()) {
|
|
157
|
+
log("debug", "relay headers", {
|
|
158
|
+
status: res.status,
|
|
159
|
+
contentType: res.headers.get("content-type"),
|
|
160
|
+
via: res.headers.get("via") || res.headers.get("x-vercel-id") || "direct",
|
|
161
|
+
}, rid);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
updateRelayStatusUi(targetUrl);
|
|
165
|
+
return res;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
168
|
+
lastError = err;
|
|
169
|
+
const errMsg = (err as Error)?.message || String(err);
|
|
170
|
+
markRelayFailure(targetUrl, 0, errMsg);
|
|
171
|
+
log(
|
|
172
|
+
"warn",
|
|
173
|
+
`relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
|
|
174
|
+
{ upstream: url, error: errMsg },
|
|
175
|
+
rid,
|
|
176
|
+
);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Full fallback: attempt direct fetch to upstream
|
|
182
|
+
const directStart = Date.now();
|
|
183
|
+
try {
|
|
184
|
+
log("warn", "relays bypassed/exhausted — attempting direct fetch to upstream", {
|
|
185
|
+
upstream: url,
|
|
186
|
+
sizeKB: bodySizeKB,
|
|
187
|
+
}, rid);
|
|
188
|
+
|
|
189
|
+
const directHeaders = new Headers(opts.headers);
|
|
190
|
+
directHeaders.delete("x-relay-target");
|
|
191
|
+
directHeaders.delete("x-relay-path");
|
|
192
|
+
directHeaders.set("host", u.host);
|
|
193
|
+
directHeaders.set("x-request-id", rid);
|
|
194
|
+
|
|
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);
|
|
198
|
+
// lastResponse holds an unread body that would otherwise leak its socket
|
|
199
|
+
// until GC; the salvage path below still needs it, so only cancel here.
|
|
200
|
+
lastResponse?.body?.cancel().catch(() => {});
|
|
201
|
+
return directRes;
|
|
202
|
+
} catch (directErr) {
|
|
203
|
+
const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
|
|
204
|
+
log("error", `direct fallback also failed in ${directElapsed}s`, {
|
|
205
|
+
upstream: url,
|
|
206
|
+
error: String(directErr),
|
|
207
|
+
}, rid);
|
|
208
|
+
if (lastResponse) {
|
|
209
|
+
return lastResponse;
|
|
210
|
+
}
|
|
211
|
+
throw directErr || lastError;
|
|
212
|
+
}
|
|
213
|
+
}
|