pi-freeflow 1.27.0 → 1.28.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.28.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Retry relay reachability checks after deployment so edge routing propagation delays do not show a fresh relay as unreachable — the check retries with progress notices and only warns if the relay stays dark.
8
+
9
+ Fix upstream drift alerts to stop flagging commercial paid model families as free candidates, while known alpha and stealth free models keep their free status.
10
+
11
+ ## 1.28.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 526d43f: Refresh the free model catalog to the current live rotation (33 models).
16
+
17
+ - New: Space Bunny Alpha on the KiloCode Gateway free list (1M context window) — pick it with the short name `space-bunny-alpha`.
18
+ - Back: Step 3.7 Flash returns to the KiloCode Gateway free list after a live serving re-check.
19
+ - Retired from the picker: Nex N2.5 Pro, Nex N2.5 Mini, and GLM 5.2 free — all three are gone from the free list (only paid versions remain).
20
+
21
+ - 526d43f: Add LongCat 2.5 Preview Free (`longcat-2.5-preview-free`) from OpenCode Zen with a 1M context window, reasoning, and vision.
22
+
23
+ Fix upstream drift detection to accurately recognize free and alpha models while excluding commercial paid model families from candidate suggestions.
24
+
25
+ ### Patch Changes
26
+
27
+ - 526d43f: Retry relay reachability verification after deployment so edge DNS and routing propagation delays (such as newly created Cloudflare Workers routes) do not show a false unreachable warning.
28
+
3
29
  ## 1.27.0
4
30
 
5
31
  ### Minor Changes
package/README.md CHANGED
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
16
16
 
17
17
  | Feature | What it does | Cost |
18
18
  | :--- | :--- | :--- |
19
- | **33 free models** | 9 from OpenCode Zen, 19 from KiloCode Gateway, 5 from Cline, context windows up to 1M. Full list below. | **$0** |
19
+ | **33 free models** | 10 from OpenCode Zen, 18 from KiloCode Gateway, 5 from Cline, context windows up to 1M. Full list below. | **$0** |
20
20
  | **Relay pool** | Route requests through your own Cloudflare Workers and Vercel Edge relays. Requests rotate across the pool. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
21
21
  | **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
22
22
  | **Short model names** | Every model has a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`. | **$0** |
@@ -190,7 +190,7 @@ The same command set works identically in OMP and Pi:
190
190
  /model → freeflow → pick
191
191
  ```
192
192
 
193
- #### OpenCode Zen (9 models), Responses and Chat API
193
+ #### OpenCode Zen (10 models), Responses and Chat API
194
194
 
195
195
  Good defaults for long coding sessions and agentic work.
196
196
 
@@ -205,8 +205,9 @@ Good defaults for long coding sessions and agentic work.
205
205
  | `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
206
206
  | `ling-3.0-flash-fin-free` | Inclusion AI | **262K** (262.144) | **131K** (131.072) | `minimal … xhigh` | ❌ |
207
207
  | `space-bunny-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **512K** (524.288) | `low … max` | ✅ |
208
+ | `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.000.000) | **131K** (131.072) | `minimal … max` | ✅ |
208
209
 
209
- #### KiloCode Gateway (19 models), OpenRouter compatible
210
+ #### KiloCode Gateway (18 models), OpenRouter compatible
210
211
 
211
212
  Keyless access. Short aliases work for every row (the full ID is in parentheses).
212
213
 
@@ -226,11 +227,10 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
226
227
  | `content-safety` (`nvidia/...:free`) | NVIDIA | **128K** (128.000) | **8K** (8.192) | ❌ *(non-thinking)* | ✅ |
227
228
  | `ling-3.0-flash-fin` (`inclusionai/ling-3.0-flash-fin:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
228
229
  | `ling-3.0-flash-sante` (`inclusionai/ling-3.0-flash-sante:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
229
- | `nex-n2.5-pro` (`nex-agi/nex-n2.5-pro:free`) | Nex AGI | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ✅ |
230
- | `nex-n2.5-mini` (`nex-agi/nex-n2.5-mini:free`) | Nex AGI | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ✅ |
230
+ | `step-3.7-flash` (`stepfun/...:free`) | StepFun | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
231
+ | `space-bunny-alpha` (`stealth/...`) | Stealth preview (lab undisclosed) | **1M** (1.000.000) | **512K** (524.288) | `minimal…xhigh`\* | ✅ |
231
232
  | `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
232
233
  | `qwen3.8-27b` (`qwen/...:free`) | Alibaba Qwen | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ✅ |
233
- | `glm-5.2` (`z-ai/...:free`) | Zhipu AI | **32K** (32.768) | **29K** (29.491) | `minimal…xhigh`\* | ❌ |
234
234
 
235
235
  \* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
236
236
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.27.0",
4
+ "version": "1.28.1",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/catalog.ts CHANGED
@@ -30,9 +30,12 @@ import type {
30
30
 
31
31
  /**
32
32
  * Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
33
- * - jev-1.13-free: non-chat decision model, chat-incompatible — never registered.
34
- * - deepseek-v4-flash-free: listed but currently unserved upstream — stays out until it answers.
35
- * - stepfun/step-3.7-flash:free: gone from the live Kilo free list — only the paid counterpart remains.
33
+ * - jev-1.13-free: non-chat decision model, chat-incompatible (live chat 500) — never registered.
34
+ * - deepseek-v4-flash-free: listed but unserved upstream (live chat 400 "Model is unavailable").
35
+ * - nex-agi/nex-n2.5-pro:free + nex-agi/nex-n2.5-mini:free: gone 2026-09-28 — zero nex-agi
36
+ * IDs on the live Kilo list, keyless chat 404 "does not exist".
37
+ * - z-ai/glm-5.2:free: gone 2026-09-28 — :free ID absent (keyless 404); only the paid
38
+ * z-ai/glm-5.2 remains (isFree:false, keyless 401 PAID_MODEL_AUTH_REQUIRED).
36
39
  * - inclusionai/ling-3.0-flash-vl:free: gone from the live Kilo free list — only the paid counterpart remains.
37
40
  */
38
41
  export const DEAD_MODEL_IDS = new Set<string>([
@@ -47,7 +50,9 @@ export const DEAD_MODEL_IDS = new Set<string>([
47
50
  "minimax/minimax-m3:free",
48
51
  "thinkingmachines/inkling:free",
49
52
  "union-alpha",
50
- "stepfun/step-3.7-flash:free",
53
+ "nex-agi/nex-n2.5-pro:free",
54
+ "nex-agi/nex-n2.5-mini:free",
55
+ "z-ai/glm-5.2:free",
51
56
  "inclusionai/ling-3.0-flash-vl:free",
52
57
  ]);
53
58
  /**
@@ -55,7 +60,7 @@ export const DEAD_MODEL_IDS = new Set<string>([
55
60
  * Upstream lists paid models alongside free ones (e.g. claude-fable-5-1,
56
61
  * claude-opus-4-*, gemini-3-*) so a bare upstream merge leaks paid entries that
57
62
  * fail with 401 Missing API key. Known static IDs without a free suffix
58
- * (e.g. big-pickle) stay allowed via MODEL_MAP.
63
+ * (e.g. big-pickle, stealth/space-bunny-alpha) stay allowed via MODEL_MAP.
59
64
  */
60
65
  export function isFreeCatalogId(id: string): boolean {
61
66
  if (typeof id !== "string" || id.length === 0) return false;
package/src/commands.ts CHANGED
@@ -12,7 +12,7 @@ import path from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { getClientPort, isJsRuntimeExecutable, resolveDaemonRuntime } from "./client.ts";
14
14
  import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
15
- import { DAEMON_SPAWN_ENV, DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE } from "./config.ts";
15
+ import { DAEMON_SPAWN_ENV, DATA_DIR_ENV, DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE } from "./config.ts";
16
16
  import {
17
17
  compareVersions,
18
18
  fetchLatestVersion,
@@ -84,6 +84,17 @@ export function updateStatusBar(ui?: ExtensionUIContext): void {
84
84
  export const STARTUP_TASK_NAME = "pi-freeflow-daemon";
85
85
  export const STARTUP_SERVICE_NAME = "pi-freeflow.service";
86
86
 
87
+ let deployProbeRetries = 5;
88
+ let deployProbeRetryDelayMs: number | null = null;
89
+
90
+ /** Test-only: override post-deploy probe retry count and delay. */
91
+ export function _setDeployProbeOptionsForTest(
92
+ opts: { retries?: number; retryDelayMs?: number } | null,
93
+ ): void {
94
+ deployProbeRetries = opts?.retries ?? 5;
95
+ deployProbeRetryDelayMs = opts?.retryDelayMs ?? null;
96
+ }
97
+
87
98
  export interface StartupPlan {
88
99
  platform: string;
89
100
  taskName: string;
@@ -755,7 +766,18 @@ export function createCommandSpec(
755
766
  }
756
767
  let probeNote = "";
757
768
  try {
758
- const probe = await probeRelay(finalUrl, auth);
769
+ const retryDelay =
770
+ deployProbeRetryDelayMs ?? (process.env[DATA_DIR_ENV] ? 10 : 2000);
771
+ const probe = await probeRelay(finalUrl, auth, {
772
+ retries: deployProbeRetries,
773
+ retryDelayMs: retryDelay,
774
+ onRetry: (attempt, total) => {
775
+ ctx.ui.notify(
776
+ `Waiting for edge routing to propagate (${attempt}/${total})…`,
777
+ "info",
778
+ );
779
+ },
780
+ });
759
781
  probeNote = probe.ok
760
782
  ? ` ✓ reachable (HTTP ${probe.status}, ${probe.latencyMs}ms)`
761
783
  : ` ⚠ deployed but unreachable (${probe.error || `HTTP ${probe.status}`}) — run /freeflow test ${finalUrl} to retry, or /freeflow remove ${finalUrl} to drop it`;
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
@@ -1,121 +1,153 @@
1
- import { opencodeHeaders } from "./config.ts";
2
- import { ensureChatFingerprintTools } from "./opencode-fingerprint.ts";
3
-
4
- /**
5
- * Relay reachability probe: verifies a deployed relay answers requests
6
- * by issuing a minimal round trip through it and timing the response.
7
- */
8
-
9
- export interface RelayProbeResult {
10
- ok: boolean;
11
- status: number;
12
- latencyMs: number;
13
- error?: string;
14
- }
15
-
16
- export interface RelayProbeOptions {
17
- /**
18
- * Probe mode:
19
- * - "models" (default): GET /v1/models (reachability probe)
20
- * - "chat": POST /zen/v1/chat/completions (verifies free tier inference & spoofing)
21
- */
22
- endpoint?: "models" | "chat";
23
- /** Model to use for chat probe (defaults to "nemotron-3.5-lightning-free") */
24
- model?: string;
25
- /** Timeout in milliseconds (defaults to 5000) */
26
- timeoutMs?: number;
27
- }
28
-
29
- /**
30
- * Probe a relay endpoint to confirm it is reachable.
31
- *
32
- * Sends a minimal request to `<url>/v1/models` carrying the x-relay-target /
33
- * x-relay-path headers the relay expects, timing the round trip. Never
34
- * throws: network failures, timeouts, and non-2xx statuses are all reported
35
- * on the returned result instead.
36
- *
37
- * The path mirrors the proxy's real relay contract (`relayFetch` in relay.ts):
38
- * `x-relay-target` is the upstream origin and `x-relay-path` the full path
39
- * including the `/zen` prefix — the workers only allow the exact origins
40
- * `https://opencode.ai` / `https://api.kilo.ai` and forward `target + path`.
41
- * @param auth Optional per-relay shared secret; sent as x-relay-auth so
42
- * deployed relays (which enforce it) answer the probe instead of 401.
43
- */
44
- export async function probeRelay(
45
- url: string,
46
- auth?: string,
47
- opts?: RelayProbeOptions,
48
- ): Promise<RelayProbeResult> {
49
- const cleanUrl = url.trim().replace(/\/+$/, "");
50
- const isDirectOpenCode =
51
- cleanUrl === "https://opencode.ai" ||
52
- cleanUrl === "opencode" ||
53
- cleanUrl === "direct";
54
- const isChat = opts?.endpoint === "chat";
55
- const timeout = opts?.timeoutMs ?? (isChat ? 15_000 : 5_000);
56
- const start = Date.now();
57
-
58
- try {
59
- const headers: Record<string, string> = {
60
- ...opencodeHeaders(),
61
- ...(auth ? { "x-relay-auth": auth } : {}),
62
- };
63
-
64
- let fetchUrl: string;
65
- let method = "GET";
66
- let body: string | undefined;
67
-
68
- if (isDirectOpenCode) {
69
- if (isChat) {
70
- fetchUrl = "https://opencode.ai/zen/v1/chat/completions";
71
- method = "POST";
72
- headers["content-type"] = "application/json";
73
- const probePayload: Record<string, unknown> = {
74
- model: opts?.model || "nemotron-3.5-lightning-free",
75
- max_tokens: 1,
76
- stream: true,
77
- messages: [{ role: "user", content: "ping" }],
78
- };
79
- ensureChatFingerprintTools(probePayload);
80
- body = JSON.stringify(probePayload);
81
- } else {
82
- fetchUrl = "https://opencode.ai/zen/v1/models";
83
- }
84
- } else {
85
- if (isChat) {
86
- fetchUrl = `${cleanUrl}/zen/v1/chat/completions`;
87
- method = "POST";
88
- headers["content-type"] = "application/json";
89
- headers["x-relay-target"] = "https://opencode.ai";
90
- headers["x-relay-path"] = "/zen/v1/chat/completions";
91
- const probePayload: Record<string, unknown> = {
92
- model: opts?.model || "nemotron-3.5-lightning-free",
93
- max_tokens: 1,
94
- stream: true,
95
- messages: [{ role: "user", content: "ping" }],
96
- };
97
- ensureChatFingerprintTools(probePayload);
98
- body = JSON.stringify(probePayload);
99
- } else {
100
- fetchUrl = `${cleanUrl}/v1/models`;
101
- headers["x-relay-target"] = "https://opencode.ai";
102
- headers["x-relay-path"] = "/zen/v1/models";
103
- }
104
- }
105
-
106
- const res = await fetch(fetchUrl, {
107
- method,
108
- headers,
109
- body,
110
- signal: AbortSignal.timeout(timeout),
111
- });
112
- return { ok: res.ok, status: res.status, latencyMs: Date.now() - start };
113
- } catch (e) {
114
- return {
115
- ok: false,
116
- status: 0,
117
- latencyMs: Date.now() - start,
118
- error: (e as Error)?.message || String(e),
119
- };
120
- }
121
- }
1
+ import { opencodeHeaders } from "./config.ts";
2
+ import { ensureChatFingerprintTools } from "./opencode-fingerprint.ts";
3
+
4
+ /**
5
+ * Relay reachability probe: verifies a deployed relay answers requests
6
+ * by issuing a minimal round trip through it and timing the response.
7
+ */
8
+
9
+ export interface RelayProbeResult {
10
+ ok: boolean;
11
+ status: number;
12
+ latencyMs: number;
13
+ error?: string;
14
+ }
15
+
16
+ export interface RelayProbeOptions {
17
+ /**
18
+ * Probe mode:
19
+ * - "models" (default): GET /v1/models (reachability probe)
20
+ * - "chat": POST /zen/v1/chat/completions (verifies free tier inference & spoofing)
21
+ */
22
+ endpoint?: "models" | "chat";
23
+ /** Model to use for chat probe (defaults to "nemotron-3.5-lightning-free") */
24
+ model?: string;
25
+ /** Timeout in milliseconds (defaults to 5000) */
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;
36
+ }
37
+
38
+ /**
39
+ * Probe a relay endpoint to confirm it is reachable.
40
+ *
41
+ * Sends a minimal request to `<url>/v1/models` carrying the x-relay-target /
42
+ * x-relay-path headers the relay expects, timing the round trip. Never
43
+ * throws: network failures, timeouts, and non-2xx statuses are all reported
44
+ * on the returned result instead.
45
+ *
46
+ * The path mirrors the proxy's real relay contract (`relayFetch` in relay.ts):
47
+ * `x-relay-target` is the upstream origin and `x-relay-path` the full path
48
+ * including the `/zen` prefix — the workers only allow the exact origins
49
+ * `https://opencode.ai` / `https://api.kilo.ai` and forward `target + path`.
50
+ * @param auth Optional per-relay shared secret; sent as x-relay-auth so
51
+ * deployed relays (which enforce it) answer the probe instead of 401.
52
+ */
53
+ async function probeRelayOnce(
54
+ url: string,
55
+ auth?: string,
56
+ opts?: RelayProbeOptions,
57
+ ): Promise<RelayProbeResult> {
58
+ const cleanUrl = url.trim().replace(/\/+$/, "");
59
+ const isDirectOpenCode =
60
+ cleanUrl === "https://opencode.ai" ||
61
+ cleanUrl === "opencode" ||
62
+ cleanUrl === "direct";
63
+ const isChat = opts?.endpoint === "chat";
64
+ const timeout = opts?.timeoutMs ?? (isChat ? 15_000 : 5_000);
65
+ const start = Date.now();
66
+
67
+ try {
68
+ const headers: Record<string, string> = {
69
+ ...opencodeHeaders(),
70
+ ...(auth ? { "x-relay-auth": auth } : {}),
71
+ };
72
+
73
+ let fetchUrl: string;
74
+ let method = "GET";
75
+ let body: string | undefined;
76
+
77
+ if (isDirectOpenCode) {
78
+ if (isChat) {
79
+ fetchUrl = "https://opencode.ai/zen/v1/chat/completions";
80
+ method = "POST";
81
+ headers["content-type"] = "application/json";
82
+ const probePayload: Record<string, unknown> = {
83
+ model: opts?.model || "nemotron-3.5-lightning-free",
84
+ max_tokens: 1,
85
+ stream: true,
86
+ messages: [{ role: "user", content: "ping" }],
87
+ };
88
+ ensureChatFingerprintTools(probePayload);
89
+ body = JSON.stringify(probePayload);
90
+ } else {
91
+ fetchUrl = "https://opencode.ai/zen/v1/models";
92
+ }
93
+ } else {
94
+ if (isChat) {
95
+ fetchUrl = `${cleanUrl}/zen/v1/chat/completions`;
96
+ method = "POST";
97
+ headers["content-type"] = "application/json";
98
+ headers["x-relay-target"] = "https://opencode.ai";
99
+ headers["x-relay-path"] = "/zen/v1/chat/completions";
100
+ const probePayload: Record<string, unknown> = {
101
+ model: opts?.model || "nemotron-3.5-lightning-free",
102
+ max_tokens: 1,
103
+ stream: true,
104
+ messages: [{ role: "user", content: "ping" }],
105
+ };
106
+ ensureChatFingerprintTools(probePayload);
107
+ body = JSON.stringify(probePayload);
108
+ } else {
109
+ fetchUrl = `${cleanUrl}/v1/models`;
110
+ headers["x-relay-target"] = "https://opencode.ai";
111
+ headers["x-relay-path"] = "/zen/v1/models";
112
+ }
113
+ }
114
+
115
+ const res = await fetch(fetchUrl, {
116
+ method,
117
+ headers,
118
+ body,
119
+ signal: AbortSignal.timeout(timeout),
120
+ });
121
+ return { ok: res.ok, status: res.status, latencyMs: Date.now() - start };
122
+ } catch (e) {
123
+ return {
124
+ ok: false,
125
+ status: 0,
126
+ latencyMs: Date.now() - start,
127
+ error: (e as Error)?.message || String(e),
128
+ };
129
+ }
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
+ }