pi-freeflow 1.26.1 → 1.28.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/src/deploy.ts CHANGED
@@ -16,17 +16,20 @@ import { randomBytes } from "node:crypto";
16
16
  * whitelist, SSRF guard, path resolver, header denylist, auth gate and
17
17
  * streaming forward live exactly once.
18
18
  *
19
+ * The core ships inside String.raw, so backslashes below reach the deployed
20
+ * file verbatim — write them singly, never double them for the outer literal.
21
+ *
19
22
  * @param relayAuth Per-deployment shared secret. Empty string disables the
20
23
  * auth gate (legacy/manual deploys); built-in deploys always embed one.
21
24
  */
22
25
  function buildRelayWorkerCore(relayAuth: string): string {
23
- return `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
26
+ return String.raw`// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
24
27
  const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
25
28
  const RELAY_AUTH = ${JSON.stringify(relayAuth)};
26
29
  const resolveRelayTarget = function(target, relayPath) {
27
30
  let targetUrl;
28
31
  try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
29
- if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\\\") !== -1 || relayPath.charAt(0) !== "/") {
32
+ if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/") {
30
33
  return { ok: false, status: 403, reason: "forbidden x-relay-path" };
31
34
  }
32
35
  let finalUrl;
@@ -38,7 +41,7 @@ const resolveRelayTarget = function(target, relayPath) {
38
41
  };
39
42
  const isPrivateHostname = function(h) {
40
43
  if (!h) return true
41
- let host = String(h).trim().toLowerCase().replace(/^\\[|\\]$/g, "")
44
+ let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
42
45
  if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
43
46
  if (!host) return true
44
47
  // Numeric IP literals outside dotted-decimal (decimal 2130706433, short 127.1,
@@ -65,7 +68,7 @@ const isPrivateHostname = function(h) {
65
68
  }
66
69
  if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
67
70
  if (host.startsWith("::")) return true
68
- const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/)
71
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
69
72
  if (v4) {
70
73
  const a = Number(v4[1])
71
74
  const b = Number(v4[2])
@@ -109,7 +112,7 @@ async function relayHandler(req) {
109
112
  if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
110
113
  if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
111
114
  if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
112
- const cleanTarget = target.replace(/\\/$/, "");
115
+ const cleanTarget = target.replace(/\/$/, "");
113
116
  if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
114
117
  const relayPath = req.headers.get("x-relay-path") || "/";
115
118
  const resolved = resolveRelayTarget(target, relayPath);
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
3
  *
4
- * Provides access to 33 free models (9 OpenCode Zen + 19 KiloCode Gateway + 5 Cline) with:
4
+ * Provides access to 33 free models (10 OpenCode Zen + 18 KiloCode Gateway + 5 Cline) with:
5
5
  * - Single-port daemon reuse on 28180 across concurrent subagents
6
6
  * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
7
  * - 0ms instant startup with verified static catalog and background live health checks
package/src/models.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 33 verified free models:
5
- * - 9 OpenCode Zen models (2 Responses API + 7 Chat Completions)
6
- * - 19 KiloCode Keyless Gateway models (18 OpenRouter format + 1 Standard format)
4
+ * Defines the 33 verified free models (live-verified 2026-09-28):
5
+ * - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
6
+ * - 18 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 5 Cline direct-only models (per-user pool)
8
8
  */
9
9
 
@@ -169,6 +169,23 @@ export const OPENCODE_MODELS: ModelDef[] = [
169
169
  max: "max",
170
170
  },
171
171
  },
172
+ {
173
+ id: "longcat-2.5-preview-free",
174
+ name: "LongCat 2.5 Preview (1M) [OpenCode]",
175
+ reasoning: true,
176
+ contextWindow: 1_000_000,
177
+ maxTokens: 131_072,
178
+ input: ["text", "image"],
179
+ thinkingLevelMap: {
180
+ off: null,
181
+ minimal: "minimal",
182
+ low: "low",
183
+ medium: "medium",
184
+ high: "high",
185
+ xhigh: "xhigh",
186
+ max: "max",
187
+ },
188
+ },
172
189
  ];
173
190
 
174
191
  /**
@@ -321,21 +338,26 @@ export const KILO_MODELS: ModelDef[] = [
321
338
  thinkingLevelMap: KILO_REASONING_MAP,
322
339
  },
323
340
  {
324
- id: "nex-agi/nex-n2.5-pro:free",
325
- name: "Nex N2.5 Pro [Kilo]",
341
+ // Resurrected 2026-09-28: back on the live Kilo free list
342
+ // (isFree:true, 0/0 pricing, no expiry) + keyless chat 200.
343
+ id: "stepfun/step-3.7-flash:free",
344
+ name: "Step 3.7 Flash [Kilo]",
326
345
  reasoning: true,
327
346
  contextWindow: 262_144,
328
- maxTokens: 235_929,
347
+ maxTokens: 262_144,
329
348
  input: ["text", "image"],
330
349
  thinkingFormat: "openrouter",
331
350
  thinkingLevelMap: KILO_REASONING_MAP,
332
351
  },
333
352
  {
334
- id: "nex-agi/nex-n2.5-mini:free",
335
- name: "Nex N2.5 Mini [Kilo]",
353
+ // Added 2026-09-28: live on the Kilo free list (isFree:true, 0/0
354
+ // pricing, 1M context, text+image in) + keyless chat 200. Passes
355
+ // isFreeCatalogId via MODEL_MAP (no :free suffix by design).
356
+ id: "stealth/space-bunny-alpha",
357
+ name: "Space Bunny Alpha (1M) [Kilo]",
336
358
  reasoning: true,
337
- contextWindow: 262_144,
338
- maxTokens: 235_929,
359
+ contextWindow: 1_000_000,
360
+ maxTokens: 524_288,
339
361
  input: ["text", "image"],
340
362
  thinkingFormat: "openrouter",
341
363
  thinkingLevelMap: KILO_REASONING_MAP,
@@ -370,16 +392,6 @@ export const KILO_MODELS: ModelDef[] = [
370
392
  thinkingFormat: "openrouter",
371
393
  thinkingLevelMap: KILO_REASONING_MAP,
372
394
  },
373
- {
374
- id: "z-ai/glm-5.2:free",
375
- name: "GLM 5.2 [Kilo]",
376
- reasoning: true,
377
- contextWindow: 32_768,
378
- maxTokens: 29_491,
379
- input: ["text"],
380
- thinkingFormat: "openrouter",
381
- thinkingLevelMap: KILO_REASONING_MAP,
382
- },
383
395
  ];
384
396
 
385
397
  /**
@@ -498,13 +510,14 @@ export const MODEL_ALIASES: Record<string, string> = {
498
510
  "content-safety": "nvidia/nemotron-3.5-content-safety:free",
499
511
  "ling-3.0-flash-fin": "inclusionai/ling-3.0-flash-fin:free",
500
512
  "ling-3.0-flash-sante": "inclusionai/ling-3.0-flash-sante:free",
501
- "nex-n2.5-pro": "nex-agi/nex-n2.5-pro:free",
502
- "nex-n2.5-mini": "nex-agi/nex-n2.5-mini:free",
513
+ "step-3.7-flash": "stepfun/step-3.7-flash:free",
514
+ "space-bunny-alpha": "stealth/space-bunny-alpha",
503
515
  "inkling-small": "thinkingmachines/inkling-small:free",
504
516
  "mimo-v2.6-flash": "mimo-v2.6-flash-free",
505
517
  "space-bunny": "space-bunny-free",
518
+ "longcat-2.5-preview": "longcat-2.5-preview-free",
519
+ "longcat": "longcat-2.5-preview-free",
506
520
  "qwen3.8-27b": "qwen/qwen3.8-27b:free",
507
- "glm-5.2": "z-ai/glm-5.2:free",
508
521
  // provider-prefixed short aliases (slash-normalized)
509
522
  "laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
510
523
  "laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
package/src/probe.ts CHANGED
@@ -24,6 +24,15 @@ export interface RelayProbeOptions {
24
24
  model?: string;
25
25
  /** Timeout in milliseconds (defaults to 5000) */
26
26
  timeoutMs?: number;
27
+ /**
28
+ * Number of retry attempts when the probe returns !ok or throws.
29
+ * Defaults to 0 (single attempt). Useful post-deploy for edge DNS propagation.
30
+ */
31
+ retries?: number;
32
+ /** Delay between retries in milliseconds (defaults to 2000). */
33
+ retryDelayMs?: number;
34
+ /** Optional progress callback before each retry */
35
+ onRetry?: (attempt: number, maxRetries: number, lastResult: RelayProbeResult) => void;
27
36
  }
28
37
 
29
38
  /**
@@ -41,7 +50,7 @@ export interface RelayProbeOptions {
41
50
  * @param auth Optional per-relay shared secret; sent as x-relay-auth so
42
51
  * deployed relays (which enforce it) answer the probe instead of 401.
43
52
  */
44
- export async function probeRelay(
53
+ async function probeRelayOnce(
45
54
  url: string,
46
55
  auth?: string,
47
56
  opts?: RelayProbeOptions,
@@ -119,3 +128,26 @@ export async function probeRelay(
119
128
  };
120
129
  }
121
130
  }
131
+
132
+ export async function probeRelay(
133
+ url: string,
134
+ auth?: string,
135
+ opts?: RelayProbeOptions,
136
+ ): Promise<RelayProbeResult> {
137
+ const retries = Math.max(0, opts?.retries ?? 0);
138
+ const retryDelay = Math.max(10, opts?.retryDelayMs ?? 2000);
139
+
140
+ for (let attempt = 0; attempt <= retries; attempt++) {
141
+ if (attempt > 0) {
142
+ const { promise, resolve } = Promise.withResolvers<void>();
143
+ setTimeout(resolve, retryDelay);
144
+ await promise;
145
+ }
146
+ const result = await probeRelayOnce(url, auth, opts);
147
+ if (result.ok || attempt === retries) {
148
+ return result;
149
+ }
150
+ opts?.onRetry?.(attempt + 1, retries, result);
151
+ }
152
+ return { ok: false, status: 0, latencyMs: 0, error: "Probe failed" };
153
+ }
package/src/proxy.ts CHANGED
@@ -126,6 +126,27 @@ function withRateLimitHint(status: number, data: string): string {
126
126
  } catch { }
127
127
  return data;
128
128
  }
129
+ /**
130
+ * Exhausted-disabled relay verdict from the relay layer (HTTP 503 JSON with
131
+ * `error.code === "relay_disabled"`): every candidate rolled as disabled and
132
+ * the direct fallback is unavailable, so the host must retry/fail fast
133
+ * instead of parking on a provider wait. Passes through to the host
134
+ * unchanged — hint rewrites must never touch its code or guidance.
135
+ */
136
+ export function isRelayDisabledError(status: number, data: string): boolean {
137
+ if (status !== 503) return false;
138
+ try {
139
+ const parsed: unknown = JSON.parse(data);
140
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
141
+ if (!("error" in parsed)) return false;
142
+ const err: unknown = parsed.error;
143
+ if (typeof err !== "object" || err === null || Array.isArray(err)) return false;
144
+ if (!("code" in err)) return false;
145
+ return err.code === "relay_disabled";
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
129
150
  /** Cline marker errors: each status carries account-actionable guidance. */
130
151
  const CLINE_RATE_LIMIT_HINT =
131
152
  "Cline free-use limit reached for this login. Wait for the reset or switch models.";
@@ -1300,7 +1321,7 @@ export function startProxy(
1300
1321
  );
1301
1322
  } else {
1302
1323
  if (!response.ok) {
1303
- log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via relay`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url }, reqId);
1324
+ log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via relay ${relayState.url || "pool"}`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url, relay: relayState.url }, reqId);
1304
1325
  }
1305
1326
  let rawText = await response.text();
1306
1327
  let ct =
@@ -1310,7 +1331,9 @@ export function startProxy(
1310
1331
  rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected);
1311
1332
  ct = "application/json";
1312
1333
  }
1313
- const data = withFreeTierHint(response.status, withRateLimitHint(response.status, rawText));
1334
+ const data = isRelayDisabledError(response.status, rawText)
1335
+ ? rawText
1336
+ : withFreeTierHint(response.status, withRateLimitHint(response.status, rawText));
1314
1337
  res.writeHead(response.status, { "content-type": ct });
1315
1338
  res.end(data);
1316
1339
  }