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/package.json +11 -3
- package/src/catalog.ts +80 -5
- package/src/commands.ts +172 -128
- package/src/health.ts +106 -0
- package/src/index.ts +57 -1
- package/src/models.ts +58 -64
- package/src/proxy.ts +63 -64
- package/src/relay-state.ts +19 -9
- package/src/relay.ts +23 -12
- package/src/types.ts +172 -165
- package/src/update-checker.ts +12 -18
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:
|
|
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:
|
|
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: "
|
|
61
|
-
low: "low",
|
|
62
|
-
medium: "
|
|
63
|
-
high: "high",
|
|
64
|
-
xhigh: "
|
|
65
|
-
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: "
|
|
78
|
-
low: "low",
|
|
79
|
-
medium: "
|
|
80
|
-
high: "high",
|
|
81
|
-
xhigh: "
|
|
82
|
-
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: "
|
|
95
|
-
low: "low",
|
|
96
|
-
medium: "
|
|
97
|
-
high: "high",
|
|
98
|
-
xhigh: "
|
|
99
|
-
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:
|
|
112
|
-
low:
|
|
113
|
-
medium:
|
|
114
|
-
high: "high",
|
|
115
|
-
xhigh:
|
|
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: "
|
|
129
|
-
low: "low",
|
|
130
|
-
medium: "
|
|
131
|
-
high: "high",
|
|
132
|
-
xhigh: "
|
|
133
|
-
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:
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
outHeaders[
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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);
|
package/src/relay-state.ts
CHANGED
|
@@ -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;
|
|
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;
|
|
239
|
+
cooldownMs = 60_000;
|
|
237
240
|
} else if (status && status >= 500) {
|
|
238
|
-
cooldownMs = 45_000;
|
|
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
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
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 =
|
|
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
|
|
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(() => {});
|