pi-freeflow 1.4.2 → 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 -279
- package/package.json +1 -1
- package/src/catalog.ts +222 -207
- package/src/commands.ts +670 -618
- 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.ts +213 -210
- package/src/stream-pipe.ts +265 -263
package/src/relay.ts
CHANGED
|
@@ -1,210 +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 === 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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
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
|
+
}
|