pi-freeflow 1.3.3 → 1.3.7
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 +110 -93
- package/package.json +2 -2
- package/src/catalog.ts +8 -5
- package/src/commands.ts +250 -92
- package/src/index.ts +47 -14
- package/src/models.ts +72 -8
- package/src/proxy.ts +31 -10
- package/src/relay-state.ts +212 -29
- package/src/relay.ts +9 -1
- package/src/types.ts +2 -1
package/src/models.ts
CHANGED
|
@@ -305,9 +305,57 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
305
305
|
];
|
|
306
306
|
|
|
307
307
|
/**
|
|
308
|
-
*
|
|
308
|
+
* Model ID Aliases — maps user-friendly / slash-free CLI IDs to canonical upstream model IDs.
|
|
309
309
|
*/
|
|
310
|
-
export const
|
|
310
|
+
export const MODEL_ALIASES: Record<string, string> = {
|
|
311
|
+
// Kilo Gateway slash-free & colon-free CLI aliases
|
|
312
|
+
"dots-3-note-preview": "dots-studio/dots-3-note-preview:free",
|
|
313
|
+
"dots-3-note-preview:free": "dots-studio/dots-3-note-preview:free",
|
|
314
|
+
"step-3.7-flash": "stepfun/step-3.7-flash:free",
|
|
315
|
+
"step-3.7-flash:free": "stepfun/step-3.7-flash:free",
|
|
316
|
+
"nemotron-3-nano-omni": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
|
317
|
+
"nemotron-3-nano-omni:free": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
|
318
|
+
"nemotron-3-ultra-550b": "nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
319
|
+
"nemotron-3-ultra-550b:free": "nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
320
|
+
"nemotron-3-super": "nvidia/nemotron-3-super-120b-a12b:free",
|
|
321
|
+
"nemotron-3-super:free": "nvidia/nemotron-3-super-120b-a12b:free",
|
|
322
|
+
"hy3:free": "tencent/hy3:free",
|
|
323
|
+
"north-mini-code": "cohere/north-mini-code:free",
|
|
324
|
+
"north-mini-code:free": "cohere/north-mini-code:free",
|
|
325
|
+
"laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
|
|
326
|
+
"laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
|
|
327
|
+
"lfm-2.5": "liquid/lfm-2.5-2.6b:free",
|
|
328
|
+
"lfm-2.5:free": "liquid/lfm-2.5-2.6b:free",
|
|
329
|
+
"content-safety": "nvidia/nemotron-3.5-content-safety:free",
|
|
330
|
+
"content-safety:free": "nvidia/nemotron-3.5-content-safety:free",
|
|
331
|
+
"kilo-auto": "kilo-auto/free",
|
|
332
|
+
"openrouter": "openrouter/free",
|
|
333
|
+
|
|
334
|
+
// OpenCode Zen aliases
|
|
335
|
+
"claude-sonnet-4.5-free": "muse-spark-1.2-contributor-free",
|
|
336
|
+
"claude-sonnet-4.5-contributor-free": "muse-spark-1.2-contributor-free",
|
|
337
|
+
"grok-code-fast-1-preview-f-free": "x-preview-f-free",
|
|
338
|
+
"minimax-m2.1-free": "laguna-s-2.1-free",
|
|
339
|
+
"qwen3-coder-480b-free": "hy3-free",
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Resolve any model alias to its canonical upstream model ID.
|
|
344
|
+
*/
|
|
345
|
+
export function resolveCanonicalModelId(id: string): string {
|
|
346
|
+
const clean = (id || "").trim();
|
|
347
|
+
return MODEL_ALIASES[clean] || clean;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Set of all KiloCode model IDs (including aliases) for fast lookup
|
|
352
|
+
*/
|
|
353
|
+
export const KILO_MODEL_IDS = new Set<string>([
|
|
354
|
+
...KILO_MODELS.map((m) => m.id),
|
|
355
|
+
...Object.entries(MODEL_ALIASES)
|
|
356
|
+
.filter(([_, target]) => KILO_MODELS.some((km) => km.id === target))
|
|
357
|
+
.map(([alias]) => alias),
|
|
358
|
+
]);
|
|
311
359
|
|
|
312
360
|
/**
|
|
313
361
|
* Combined list of all 23 static free models
|
|
@@ -317,22 +365,38 @@ export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS];
|
|
|
317
365
|
/**
|
|
318
366
|
* Map of model ID -> ModelDef
|
|
319
367
|
*/
|
|
320
|
-
export const MODEL_MAP = new Map<string, ModelDef>(
|
|
321
|
-
ALL_MODELS.map((m) => [m.id, m]),
|
|
322
|
-
)
|
|
323
|
-
|
|
368
|
+
export const MODEL_MAP = new Map<string, ModelDef>([
|
|
369
|
+
...ALL_MODELS.map((m): [string, ModelDef] => [m.id, m]),
|
|
370
|
+
...Object.entries(MODEL_ALIASES).map(([aliasId, canonicalId]): [string, ModelDef] => {
|
|
371
|
+
const base = ALL_MODELS.find((m) => m.id === canonicalId);
|
|
372
|
+
return [
|
|
373
|
+
aliasId,
|
|
374
|
+
base
|
|
375
|
+
? { ...base, id: aliasId }
|
|
376
|
+
: { id: aliasId, name: aliasId, reasoning: false, contextWindow: 200_000, maxTokens: 32_000, input: ["text"] },
|
|
377
|
+
];
|
|
378
|
+
}),
|
|
379
|
+
]);
|
|
324
380
|
/**
|
|
325
381
|
* Lookup a model definition by ID
|
|
326
382
|
*/
|
|
327
383
|
export function getModelDef(id: string): ModelDef | undefined {
|
|
328
|
-
return MODEL_MAP.get(id);
|
|
384
|
+
return MODEL_MAP.get(id) || MODEL_MAP.get(resolveCanonicalModelId(id));
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Get full list of registered models including CLI aliases
|
|
388
|
+
*/
|
|
389
|
+
export function getAllRegisteredModels(): ModelDef[] {
|
|
390
|
+
return Array.from(MODEL_MAP.values());
|
|
329
391
|
}
|
|
330
392
|
|
|
393
|
+
|
|
331
394
|
/**
|
|
332
395
|
* Check if a model ID belongs to KiloCode Gateway
|
|
333
396
|
*/
|
|
334
397
|
export function isKiloModel(id: string): boolean {
|
|
335
|
-
|
|
398
|
+
const canonical = resolveCanonicalModelId(id);
|
|
399
|
+
return KILO_MODEL_IDS.has(id) || KILO_MODEL_IDS.has(canonical);
|
|
336
400
|
}
|
|
337
401
|
|
|
338
402
|
/**
|
package/src/proxy.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
opencodeHeaders,
|
|
24
24
|
} from "./config.ts";
|
|
25
25
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
26
|
-
import { KILO_MODEL_IDS } from "./models.ts";
|
|
26
|
+
import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
|
|
27
27
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
28
28
|
import { checkRateLimit } from "./rate-limiter.ts";
|
|
29
29
|
import { relayFetch } from "./relay.ts";
|
|
@@ -87,6 +87,23 @@ export function sanitizeHeaders(
|
|
|
87
87
|
return sanitized;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Clamps reasoning_effort for upstream models with strict non-standard enums
|
|
92
|
+
* (e.g. OpenCode x-preview strictly requires 'low', 'high', or 'max' and rejects 'medium' with 400).
|
|
93
|
+
*/
|
|
94
|
+
function sanitizeReasoningForModel(bodyObj: Record<string, unknown>): void {
|
|
95
|
+
const model = String(bodyObj.model || "").toLowerCase();
|
|
96
|
+
if (model.includes("x-preview")) {
|
|
97
|
+
const effort = String(bodyObj.reasoning_effort || "").toLowerCase();
|
|
98
|
+
if (effort === "medium") {
|
|
99
|
+
bodyObj.reasoning_effort = "high";
|
|
100
|
+
} else if (effort === "minimal") {
|
|
101
|
+
bodyObj.reasoning_effort = "low";
|
|
102
|
+
} else if (!effort || effort === "off" || effort === "none") {
|
|
103
|
+
bodyObj.reasoning_effort = "low";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
90
107
|
/**
|
|
91
108
|
* Probe whether an existing pi-freeflow proxy daemon is running and responsive on a given port.
|
|
92
109
|
*/
|
|
@@ -198,11 +215,12 @@ export function startProxy(
|
|
|
198
215
|
|
|
199
216
|
try {
|
|
200
217
|
parsedBody = JSON.parse(bodyStr);
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
218
|
+
if (typeof parsedBody?.model === "string") {
|
|
219
|
+
const canonical = resolveCanonicalModelId(parsedBody.model);
|
|
220
|
+
parsedBody.model = canonical;
|
|
221
|
+
if (KILO_MODEL_IDS.has(canonical)) {
|
|
222
|
+
isKilo = true;
|
|
223
|
+
}
|
|
206
224
|
}
|
|
207
225
|
} catch {}
|
|
208
226
|
|
|
@@ -266,10 +284,11 @@ export function startProxy(
|
|
|
266
284
|
} else {
|
|
267
285
|
// OpenCode routing — relay when enabled, else direct upstream
|
|
268
286
|
const relayState = getActiveRelayState();
|
|
269
|
-
|
|
270
|
-
relayState.
|
|
271
|
-
|
|
272
|
-
|
|
287
|
+
const shouldUseRelay =
|
|
288
|
+
relayState.mode !== "off" &&
|
|
289
|
+
relayState.enabled !== false &&
|
|
290
|
+
Boolean(relayState.url || (relayState.relays && relayState.relays.length > 0));
|
|
291
|
+
if (shouldUseRelay) {
|
|
273
292
|
const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
|
|
274
293
|
const activeHost = relayState.url
|
|
275
294
|
? new URL(relayState.url).host
|
|
@@ -279,6 +298,7 @@ export function startProxy(
|
|
|
279
298
|
try {
|
|
280
299
|
if (parsedBody) {
|
|
281
300
|
const relayBodyObj = structuredClone(parsedBody);
|
|
301
|
+
sanitizeReasoningForModel(relayBodyObj as Record<string, unknown>);
|
|
282
302
|
const relayBody = Buffer.from(JSON.stringify(relayBodyObj));
|
|
283
303
|
const response = await relayFetch(
|
|
284
304
|
fullUrl,
|
|
@@ -334,6 +354,7 @@ export function startProxy(
|
|
|
334
354
|
let directBody = Buffer.concat(bodyChunks);
|
|
335
355
|
if (parsedBody) {
|
|
336
356
|
const directBodyObj = structuredClone(parsedBody);
|
|
357
|
+
sanitizeReasoningForModel(directBodyObj as Record<string, unknown>);
|
|
337
358
|
directBody = Buffer.from(JSON.stringify(directBodyObj));
|
|
338
359
|
}
|
|
339
360
|
|
package/src/relay-state.ts
CHANGED
|
@@ -9,27 +9,35 @@ import fs from "node:fs";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { DEFAULT_RELAY_URL, RELAY_STATE_FILE } from "./config.ts";
|
|
11
11
|
import { logWarn } from "./logger.ts";
|
|
12
|
-
import type { ExtensionUIContext, KnownRelay, RelayState } from "./types.ts";
|
|
13
|
-
|
|
12
|
+
import type { ExtensionUIContext, KnownRelay, RelayMode, RelayState } from "./types.ts";
|
|
14
13
|
/**
|
|
15
14
|
* Load persisted relay state from disk.
|
|
16
15
|
*/
|
|
17
16
|
export function loadRelayState(): RelayState {
|
|
18
17
|
try {
|
|
19
18
|
if (!fs.existsSync(RELAY_STATE_FILE)) {
|
|
20
|
-
return { enabled: true, url: DEFAULT_RELAY_URL, relays: [] };
|
|
19
|
+
return { mode: "auto", enabled: true, url: DEFAULT_RELAY_URL, relays: [] };
|
|
21
20
|
}
|
|
22
21
|
const s = JSON.parse(fs.readFileSync(RELAY_STATE_FILE, "utf8"));
|
|
23
22
|
const relays: KnownRelay[] = Array.isArray(s?.relays) ? s.relays : [];
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const mode: RelayMode =
|
|
24
|
+
s?.mode === "on" || s?.mode === "off" || s?.mode === "auto"
|
|
25
|
+
? s.mode
|
|
26
|
+
: "auto";
|
|
27
|
+
const enabled =
|
|
28
|
+
mode === "on"
|
|
29
|
+
? true
|
|
30
|
+
: mode === "off"
|
|
31
|
+
? false
|
|
32
|
+
: s?.enabled !== false && relays.length > 0;
|
|
26
33
|
return {
|
|
34
|
+
mode,
|
|
27
35
|
enabled,
|
|
28
36
|
url: typeof s?.url === "string" ? s.url.trim() : (relays[0]?.url || DEFAULT_RELAY_URL),
|
|
29
37
|
relays,
|
|
30
38
|
};
|
|
31
39
|
} catch {
|
|
32
|
-
return { enabled: true, url: DEFAULT_RELAY_URL, relays: [] };
|
|
40
|
+
return { mode: "auto", enabled: true, url: DEFAULT_RELAY_URL, relays: [] };
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -51,13 +59,83 @@ export function saveRelayState(s: RelayState): void {
|
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
54
|
-
* Deduplicate and add a relay URL
|
|
62
|
+
* Deduplicate and add or update a relay URL in the known relay list.
|
|
55
63
|
*/
|
|
56
|
-
export function ensureRelay(
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
export function ensureRelay(
|
|
65
|
+
s: RelayState,
|
|
66
|
+
url: string,
|
|
67
|
+
label?: string,
|
|
68
|
+
): KnownRelay {
|
|
69
|
+
const cleanUrl = (url || "").trim();
|
|
70
|
+
if (!cleanUrl) {
|
|
71
|
+
throw new Error("Relay URL cannot be empty");
|
|
72
|
+
}
|
|
73
|
+
const cleanLabel = (label || "").trim() || undefined;
|
|
74
|
+
const existing = s.relays.find((r) => r.url === cleanUrl);
|
|
75
|
+
if (existing) {
|
|
76
|
+
if (cleanLabel && cleanLabel !== "manual") {
|
|
77
|
+
existing.label = cleanLabel;
|
|
78
|
+
}
|
|
79
|
+
return existing;
|
|
80
|
+
}
|
|
81
|
+
const newRelay: KnownRelay = {
|
|
82
|
+
url: cleanUrl,
|
|
83
|
+
label: cleanLabel && cleanLabel !== "manual" ? cleanLabel : undefined,
|
|
84
|
+
addedAt: new Date().toISOString(),
|
|
85
|
+
};
|
|
86
|
+
s.relays.push(newRelay);
|
|
87
|
+
return newRelay;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Set or update short name / label for a relay by URL, index, or existing label.
|
|
92
|
+
*/
|
|
93
|
+
export function setRelayLabel(
|
|
94
|
+
s: RelayState,
|
|
95
|
+
identifier: string | number,
|
|
96
|
+
label: string,
|
|
97
|
+
): KnownRelay | null {
|
|
98
|
+
const relay = findRelay(s, identifier);
|
|
99
|
+
if (!relay) return null;
|
|
100
|
+
const cleanLabel = (label || "").trim();
|
|
101
|
+
relay.label = cleanLabel || undefined;
|
|
102
|
+
return relay;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find a relay in state by 1-based index, short name / label, or URL.
|
|
107
|
+
*/
|
|
108
|
+
export function findRelay(
|
|
109
|
+
s: RelayState,
|
|
110
|
+
identifier: string | number,
|
|
111
|
+
): KnownRelay | undefined {
|
|
112
|
+
if (typeof identifier === "number") {
|
|
113
|
+
const idx = identifier - 1;
|
|
114
|
+
return s.relays[idx];
|
|
115
|
+
}
|
|
116
|
+
const str = String(identifier || "").trim();
|
|
117
|
+
if (!str) return undefined;
|
|
118
|
+
|
|
119
|
+
// 1-based index (e.g. "1", "2")
|
|
120
|
+
if (/^\d+$/.test(str)) {
|
|
121
|
+
const num = Number.parseInt(str, 10);
|
|
122
|
+
if (num >= 1 && num <= s.relays.length) {
|
|
123
|
+
return s.relays[num - 1];
|
|
124
|
+
}
|
|
59
125
|
}
|
|
60
|
-
|
|
126
|
+
|
|
127
|
+
// Exact URL match
|
|
128
|
+
const exactUrl = s.relays.find((r) => r.url === str);
|
|
129
|
+
if (exactUrl) return exactUrl;
|
|
130
|
+
|
|
131
|
+
// Case-insensitive label match
|
|
132
|
+
const byLabel = s.relays.find(
|
|
133
|
+
(r) => r.label && r.label.toLowerCase() === str.toLowerCase(),
|
|
134
|
+
);
|
|
135
|
+
if (byLabel) return byLabel;
|
|
136
|
+
|
|
137
|
+
// Partial URL match
|
|
138
|
+
return s.relays.find((r) => r.url.toLowerCase().includes(str.toLowerCase()));
|
|
61
139
|
}
|
|
62
140
|
|
|
63
141
|
/**
|
|
@@ -87,14 +165,88 @@ export function resolveRelayState(): RelayState {
|
|
|
87
165
|
return s;
|
|
88
166
|
}
|
|
89
167
|
|
|
90
|
-
// In-memory global relay state
|
|
91
168
|
let activeRelayState: RelayState = resolveRelayState();
|
|
92
169
|
// Monotonic counter to distribute primary relay across concurrent subagents
|
|
93
170
|
let roundRobinCounter = 0;
|
|
94
171
|
let activeStatusUi: ExtensionUIContext | null = null;
|
|
172
|
+
let isFreeFlowModelActive = true;
|
|
173
|
+
|
|
174
|
+
export interface RelayHealth {
|
|
175
|
+
consecutiveFailures: number;
|
|
176
|
+
lastFailureTime: number;
|
|
177
|
+
cooldownUntil: number;
|
|
178
|
+
lastStatus?: number;
|
|
179
|
+
lastError?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const relayHealthMap = new Map<string, RelayHealth>();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Mark a relay as healthy and active on successful response.
|
|
186
|
+
*/
|
|
187
|
+
export function markRelaySuccess(url: string): void {
|
|
188
|
+
if (!url) return;
|
|
189
|
+
relayHealthMap.delete(url.trim());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mark a relay as degraded with temporary cooldown on failure/429/timeout/socket error.
|
|
194
|
+
*/
|
|
195
|
+
export function markRelayFailure(url: string, status?: number, error?: string): void {
|
|
196
|
+
if (!url) return;
|
|
197
|
+
const clean = url.trim();
|
|
198
|
+
const prev = relayHealthMap.get(clean) || {
|
|
199
|
+
consecutiveFailures: 0,
|
|
200
|
+
lastFailureTime: 0,
|
|
201
|
+
cooldownUntil: 0,
|
|
202
|
+
};
|
|
203
|
+
const consecutive = prev.consecutiveFailures + 1;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
let cooldownMs = 30_000; // 30s default for socket/network/502/503
|
|
206
|
+
|
|
207
|
+
if (status === 429) {
|
|
208
|
+
cooldownMs = 90_000; // 90s cooldown for upstream rate limits
|
|
209
|
+
} else if (status === 504) {
|
|
210
|
+
cooldownMs = 60_000; // 60s cooldown for gateway timeout
|
|
211
|
+
} else if (status && status >= 500) {
|
|
212
|
+
cooldownMs = 45_000; // 45s for 5xx errors
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
relayHealthMap.set(clean, {
|
|
216
|
+
consecutiveFailures: consecutive,
|
|
217
|
+
lastFailureTime: now,
|
|
218
|
+
cooldownUntil: now + cooldownMs,
|
|
219
|
+
lastStatus: status,
|
|
220
|
+
lastError: error,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check if a relay is currently healthy (not in active cooldown).
|
|
226
|
+
*/
|
|
227
|
+
export function isRelayHealthy(url: string): boolean {
|
|
228
|
+
if (!url) return true;
|
|
229
|
+
const clean = url.trim();
|
|
230
|
+
const health = relayHealthMap.get(clean);
|
|
231
|
+
if (!health) return true;
|
|
232
|
+
return Date.now() >= health.cooldownUntil;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get current health snapshot for a relay.
|
|
237
|
+
*/
|
|
238
|
+
export function getRelayHealth(url: string): RelayHealth | undefined {
|
|
239
|
+
return relayHealthMap.get(url.trim());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reset all in-memory relay health records.
|
|
244
|
+
*/
|
|
245
|
+
export function resetAllRelayHealth(): void {
|
|
246
|
+
relayHealthMap.clear();
|
|
247
|
+
}
|
|
95
248
|
/**
|
|
96
249
|
* Mtime of the on-disk state file at the moment we last read or wrote it.
|
|
97
|
-
* Lets worker processes pick up relay-pool changes persisted by another
|
|
98
250
|
* session's master daemon, while never clobbering this process's own
|
|
99
251
|
* unpersisted runtime overrides between external writes.
|
|
100
252
|
*/
|
|
@@ -140,6 +292,12 @@ export function setStatusUi(ui: ExtensionUIContext | null): void {
|
|
|
140
292
|
activeStatusUi = ui;
|
|
141
293
|
}
|
|
142
294
|
|
|
295
|
+
export function setFreeFlowModelActive(active: boolean): void {
|
|
296
|
+
isFreeFlowModelActive = active;
|
|
297
|
+
if (!active && activeStatusUi?.setStatus) {
|
|
298
|
+
activeStatusUi.setStatus("freeflow", undefined);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
143
301
|
/**
|
|
144
302
|
* Get the current Pi extension UI context
|
|
145
303
|
*/
|
|
@@ -149,13 +307,27 @@ export function getStatusUi(): ExtensionUIContext | null {
|
|
|
149
307
|
|
|
150
308
|
/**
|
|
151
309
|
* Generate a short, human-readable label for a relay URL.
|
|
310
|
+
* Prefers user-configured label/short name; falls back to clean domain/subdomain.
|
|
152
311
|
*/
|
|
153
312
|
export function shortRelayLabel(url: string, relays?: KnownRelay[]): string {
|
|
154
313
|
const pool = relays || activeRelayState.relays;
|
|
155
314
|
try {
|
|
156
315
|
const hit = pool.find((r) => r.url === url);
|
|
157
|
-
if (hit?.label)
|
|
158
|
-
|
|
316
|
+
if (hit?.label?.trim() && hit.label.trim() !== "manual") {
|
|
317
|
+
return hit.label.trim();
|
|
318
|
+
}
|
|
319
|
+
const u = new URL(url);
|
|
320
|
+
const host = u.host;
|
|
321
|
+
// IP address (e.g. 192.168.1.5:8080 or 10.0.0.1)
|
|
322
|
+
if (/^(\d{1,3}\.){3}\d{1,3}(:\d+)?$/.test(host)) {
|
|
323
|
+
return host;
|
|
324
|
+
}
|
|
325
|
+
const parts = host.split(".");
|
|
326
|
+
if (parts.length >= 3) {
|
|
327
|
+
// E.g. "my-relay" from "my-relay.workers.dev" or "my-app.vercel.app"
|
|
328
|
+
return parts[0];
|
|
329
|
+
}
|
|
330
|
+
return parts[0] || host;
|
|
159
331
|
} catch {
|
|
160
332
|
return url.slice(0, 18);
|
|
161
333
|
}
|
|
@@ -183,19 +355,23 @@ export function getOrderedRelayUrls(): string[] {
|
|
|
183
355
|
// Rotate starting point per-request to avoid thundering herd when many
|
|
184
356
|
// subagents hit the shared 127.0.0.1 daemon at once — each request
|
|
185
357
|
// tries a different primary relay, but still rolls seamlessly on 429.
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
358
|
+
const totalRelays = activeRelayState.relays.length;
|
|
359
|
+
const startIdx = (activeIdx + (roundRobinCounter++ % totalRelays)) % totalRelays;
|
|
360
|
+
const rawOrdered: string[] = [];
|
|
361
|
+
for (let i = 0; i < totalRelays; i++) {
|
|
362
|
+
const r = activeRelayState.relays[(startIdx + i) % totalRelays];
|
|
190
363
|
if (r?.url?.trim()) {
|
|
191
|
-
|
|
364
|
+
rawOrdered.push(r.url.trim());
|
|
192
365
|
}
|
|
193
366
|
}
|
|
367
|
+
|
|
368
|
+
// Partition into healthy candidates first, degraded/cooling candidates at the tail
|
|
369
|
+
const healthy = rawOrdered.filter((u) => isRelayHealthy(u));
|
|
370
|
+
const cooling = rawOrdered.filter((u) => !isRelayHealthy(u));
|
|
371
|
+
const ordered = [...healthy, ...cooling];
|
|
372
|
+
|
|
194
373
|
return ordered.length > 0 ? ordered : [DEFAULT_RELAY_URL];
|
|
195
374
|
}
|
|
196
|
-
if (activeRelayState.url?.trim()) {
|
|
197
|
-
return [activeRelayState.url.trim()];
|
|
198
|
-
}
|
|
199
375
|
return [DEFAULT_RELAY_URL];
|
|
200
376
|
}
|
|
201
377
|
|
|
@@ -206,13 +382,20 @@ export function updateRelayStatusUi(targetUrl?: string): void {
|
|
|
206
382
|
if (!activeStatusUi?.setStatus) {
|
|
207
383
|
return;
|
|
208
384
|
}
|
|
209
|
-
|
|
210
|
-
if (!
|
|
211
|
-
activeStatusUi.setStatus("freeflow",
|
|
385
|
+
// Do not update status bar if the user switched to another provider (e.g. Gemini/Claude)
|
|
386
|
+
if (!isFreeFlowModelActive) {
|
|
387
|
+
activeStatusUi.setStatus("freeflow", undefined);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const state = getActiveRelayState();
|
|
391
|
+
if (!state.enabled || !state.relays || state.relays.length === 0) {
|
|
392
|
+
activeStatusUi.setStatus("freeflow", undefined);
|
|
212
393
|
return;
|
|
213
394
|
}
|
|
395
|
+
const currentUrl = targetUrl || state.url || state.relays[0]?.url || "";
|
|
214
396
|
const label = shortRelayLabel(currentUrl);
|
|
215
|
-
const total =
|
|
216
|
-
const pos = Math.max(1,
|
|
217
|
-
|
|
397
|
+
const total = state.relays.length || 1;
|
|
398
|
+
const pos = Math.max(1, state.relays.findIndex((r) => r.url === currentUrl) + 1);
|
|
399
|
+
const modeLabel = state.mode === "on" ? "ON" : "AUTO (ON)";
|
|
400
|
+
activeStatusUi.setStatus("freeflow", `relay: ${modeLabel} | ${label} ${pos}/${total}`);
|
|
218
401
|
}
|
package/src/relay.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { isDebugEnabled, log } from "./logger.ts";
|
|
|
11
11
|
import {
|
|
12
12
|
getActiveRelayState,
|
|
13
13
|
getOrderedRelayUrls,
|
|
14
|
+
markRelayFailure,
|
|
15
|
+
markRelaySuccess,
|
|
14
16
|
saveRelayState,
|
|
15
17
|
setActiveRelayState,
|
|
16
18
|
shortRelayLabel,
|
|
@@ -106,6 +108,7 @@ export async function relayFetch(
|
|
|
106
108
|
// Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
|
|
107
109
|
// Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
|
|
108
110
|
if (res.status === 504) {
|
|
111
|
+
markRelayFailure(targetUrl, 504, "Gateway Timeout (25s exceeded)");
|
|
109
112
|
log(
|
|
110
113
|
"warn",
|
|
111
114
|
`relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
|
|
@@ -116,6 +119,7 @@ export async function relayFetch(
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
if (isRetriableStatus(res.status)) {
|
|
122
|
+
markRelayFailure(targetUrl, res.status);
|
|
119
123
|
lastResponse = res;
|
|
120
124
|
log(
|
|
121
125
|
"warn",
|
|
@@ -126,6 +130,8 @@ export async function relayFetch(
|
|
|
126
130
|
continue;
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
markRelaySuccess(targetUrl);
|
|
134
|
+
|
|
129
135
|
// SUCCESS or non-retriable client error (e.g. 200, 404):
|
|
130
136
|
// If we switched to a different relay because previous failed, update sticky active relay!
|
|
131
137
|
if (relayState.url !== targetUrl) {
|
|
@@ -151,10 +157,12 @@ export async function relayFetch(
|
|
|
151
157
|
} catch (err) {
|
|
152
158
|
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
153
159
|
lastError = err;
|
|
160
|
+
const errMsg = (err as Error)?.message || String(err);
|
|
161
|
+
markRelayFailure(targetUrl, 0, errMsg);
|
|
154
162
|
log(
|
|
155
163
|
"warn",
|
|
156
164
|
`relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
|
|
157
|
-
{ upstream: url, error:
|
|
165
|
+
{ upstream: url, error: errMsg },
|
|
158
166
|
rid,
|
|
159
167
|
);
|
|
160
168
|
continue;
|
package/src/types.ts
CHANGED
|
@@ -39,13 +39,14 @@ export interface KnownRelay {
|
|
|
39
39
|
label?: string;
|
|
40
40
|
addedAt?: string;
|
|
41
41
|
}
|
|
42
|
+
export type RelayMode = "auto" | "on" | "off";
|
|
42
43
|
|
|
43
44
|
export interface RelayState {
|
|
45
|
+
mode?: RelayMode;
|
|
44
46
|
enabled: boolean;
|
|
45
47
|
url: string;
|
|
46
48
|
relays: KnownRelay[];
|
|
47
49
|
}
|
|
48
|
-
|
|
49
50
|
export type LogLevel = "debug" | "info" | "warn" | "error" | "audit";
|
|
50
51
|
|
|
51
52
|
export interface DebugState {
|