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/src/models.ts CHANGED
@@ -305,9 +305,57 @@ export const KILO_MODELS: ModelDef[] = [
305
305
  ];
306
306
 
307
307
  /**
308
- * Set of all KiloCode model IDs for fast lookup
308
+ * Model ID Aliases maps user-friendly / slash-free CLI IDs to canonical upstream model IDs.
309
309
  */
310
- export const KILO_MODEL_IDS = new Set<string>(KILO_MODELS.map((m) => m.id));
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
- return KILO_MODEL_IDS.has(id);
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
- typeof parsedBody?.model === "string" &&
203
- KILO_MODEL_IDS.has(parsedBody.model)
204
- ) {
205
- isKilo = true;
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
- if (
270
- relayState.enabled &&
271
- (relayState.url || relayState.relays.length > 0)
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
 
@@ -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
- // Auto-on by default if saved relays exist, unless explicitly set to false
25
- const enabled = s?.enabled !== undefined ? Boolean(s.enabled) : relays.length > 0;
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 to the known relay list.
62
+ * Deduplicate and add or update a relay URL in the known relay list.
55
63
  */
56
- export function ensureRelay(s: RelayState, url: string, label?: string): void {
57
- if (!url || s.relays.some((r) => r.url === url)) {
58
- return;
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
- s.relays.push({ url, label, addedAt: new Date().toISOString() });
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) return hit.label;
158
- return new URL(url).host.split(".")[0];
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 startIdx = (activeIdx + (roundRobinCounter++ % activeRelayState.relays.length)) % activeRelayState.relays.length;
187
- const ordered: string[] = [];
188
- for (let i = 0; i < activeRelayState.relays.length; i++) {
189
- const r = activeRelayState.relays[(startIdx + i) % activeRelayState.relays.length];
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
- ordered.push(r.url.trim());
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
- const currentUrl = targetUrl || activeRelayState.url;
210
- if (!activeRelayState.enabled || !currentUrl) {
211
- activeStatusUi.setStatus("freeflow", activeRelayState.enabled ? "relay: direct" : "relay: OFF");
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 = activeRelayState.relays.length || 1;
216
- const pos = Math.max(1, activeRelayState.relays.findIndex((r) => r.url === currentUrl) + 1);
217
- activeStatusUi.setStatus("freeflow", `relay: ON | ${label} ${pos}/${total}`);
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: String(err) },
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 {