pi-freeflow 1.38.2 → 1.38.4

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.38.4
4
+
5
+ ### Patch Changes
6
+
7
+ - A relay that the upstream free-tier gate rejects can no longer wedge the pool: when a relay forwards back a 403 the relay itself is marked failed and the request rolls to the next candidate (or the direct route), instead of that 403 being recorded as a healthy verdict and pinning the relay as the pool's primary. The machine-wide upstream gate now only trips when the direct route is refused too, so one incompatible relay cannot fail everyone over.
8
+
9
+ Deploying a relay now proves inference through it, not just that its models list answers: the completion message verifies a real free-tier request and, when that fails, says so plainly — including the known Deno Deploy case (its platform rewrites the outbound User-Agent, so OpenCode Zen rejects traffic from it while Kilo still works). Deno Deploy deployments also resolve their public URL from the current API response again, so the deploy flow ends with a usable relay address instead of a "could not determine the public URL" error.
10
+
11
+ ## 1.38.3
12
+
13
+ ### Patch Changes
14
+
15
+ - Absorb tool-call argument fragments arriving on terminal finish_reason chunks into buffered prefixes to prevent argument rotation and corrupted JSON loops. Add Reflection AI's Beam Free (1M context) to the OpenCode Zen lineup, and retire deprecated LL Alpha and Muse Spark 1.3 contributor free models.
16
+
3
17
  ## 1.38.2
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 37 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 36 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
4
4
 
5
5
  Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
6
6
 
@@ -16,8 +16,8 @@ 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
- | **37 free models** | 14 from OpenCode Zen, 13 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
20
- | **Relay pool** | Route requests through your own Cloudflare Workers, Deno Deploy, and Vercel Edge relays. Traffic spreads across the pool, or stays on one sticky relay. 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 |
19
+ | **36 free models** | 13 from OpenCode Zen, 13 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
20
+ | **Relay pool** | Route requests through your own relays: a self-hosted egress pool (many egress IPs on one small host) or Cloudflare Workers / Deno Deploy / Vercel Edge workers. Traffic spreads across the pool, or stays on one sticky relay. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your host or 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** | Most models have a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`; the two Cline models have no alias and must be typed in full. | **$0** |
23
23
  | **Shared local proxy** | One daemon on `127.0.0.1:28180` serves every session on the machine, so parallel subagents reuse it instead of opening their own connections. | **$0** |
@@ -75,27 +75,50 @@ supported). The host resolves the rest.
75
75
  ### Add relays
76
76
 
77
77
  The default setup talks to upstream directly. Add relays when shared IPs start
78
- hitting rate limits. The fastest path is guided deploy: run
79
- `/freeflow deploy cloudflare` (or `deno`, `vercel`), paste your platform token
80
- once when asked, and the relay is created and added to your pool. The token
81
- stays in memory and is never written to disk.
78
+ hitting rate limits — the OpenCode Zen free tier counts them per egress IP, so
79
+ how many dedicated egress addresses you control decides how much you can run at
80
+ once.
82
81
 
83
- Manual fallback per platform:
82
+ **Option A: Self-hosted WARP relay pool (recommended for OpenCode Zen)**
83
+
84
+ One Linux VPS runs a pool of isolated Cloudflare WARP egress addresses with
85
+ automatic rotation of rate-limited IPs — [trefeon/warp-relay-pool](https://github.com/trefeon/warp-relay-pool):
84
86
 
85
- **Option A: Cloudflare Workers, auto deploy (recommended)**
87
+ ```bash
88
+ git clone https://github.com/trefeon/warp-relay-pool.git
89
+ cd warp-relay-pool && ./setup.sh
90
+ ```
91
+
92
+ - **Many egress IPs, tiny footprint**: each relay node is ~1.8 MiB of RAM; the
93
+ default five-relay pool fits a 512 MB VPS (~50 MB of stack over a private
94
+ mesh-VPN URL, ~160 MB with public tunnel URLs).
95
+ - **Auto-rotation**: a background service probes each egress for HTTP 429 and
96
+ rotates only the throttled node — the rest of the pool keeps serving and your
97
+ client URLs stay put.
98
+ - **No platform quotas, no request rewriting**: your host's bandwidth, and
99
+ requests arrive upstream with the client's own fingerprint.
100
+ - **Same contract as edge relays**: implements `x-relay-target`, so it can sit
101
+ in a pool alongside worker deployments.
102
+ - The setup wizard prints ready-to-paste `/freeflow add <url> <label>` commands;
103
+ pair the pool with `/freeflow spread` so parallel sessions and subagents land
104
+ on different egress IPs.
105
+
106
+ **Option B: Cloudflare Workers, auto deploy (zero-infra)**
86
107
  ```bash
87
108
  /freeflow deploy cloudflare # prompts token in-memory, auto-adds to pool
88
109
  # or /freeflow deploy with no argument to pick the platform interactively
89
110
  ```
90
111
  Manual fallback: `dash.cloudflare.com` → Workers → Create → Deploy → Edit code → paste the canonical worker source (see below) → Deploy → `/freeflow add https://your.workers.dev cf-worker-1`
91
112
 
92
- **Option B: Deno Deploy, auto deploy**
113
+ **Option C: Deno Deploy, auto deploy (Kilo models only)**
93
114
  ```bash
94
115
  /freeflow deploy deno # prompts token in-memory, auto-adds to pool
95
116
  ```
96
117
  Manual fallback: `dash.deno.com` → New Project → Playground → paste the canonical worker source (see below) → Deploy → `/freeflow add https://your-project.deno.dev deno-relay-1`
97
118
 
98
- **Option C: Vercel Edge Relay (last resort, paid plan only)**
119
+ Deno Deploy rewrites the outbound `User-Agent` at the platform layer, so OpenCode Zen free-tier traffic through a Deno relay is refused with `403 FreeTierError`; Kilo models still work. The deploy flow warns up front and its post-deploy inference probe reports what the relay can actually serve.
120
+
121
+ **Option D: Vercel Edge Relay (last resort, paid plan only)**
99
122
  ```bash
100
123
  /freeflow deploy vercel # prompts token in-memory, auto-adds to pool
101
124
  ```
@@ -108,18 +131,6 @@ For `api/relay.js`, use the canonical worker source (see below); `vercel.json` s
108
131
  { "rewrites": [{ "source": "/(.*)", "destination": "/api/relay" }] }
109
132
  ```
110
133
 
111
- **Option D: Self-Hosted WARP Relay on VPS (1GB VPS friendly)**
112
-
113
- If you have a Linux VPS (even a low-spec 1GB RAM instance), you can self-host a dedicated egress relay pool using [trefeon/warp-relay-pool](https://github.com/trefeon/warp-relay-pool):
114
- - **1GB VPS friendly**: Lightweight footprint designed to run comfortably on low-resource VPS nodes.
115
- - **Rotating Cloudflare WARP egress**: Automatically rotates outbound IPs so upstream rate limits don't block your sessions.
116
- - **Full protocol compatibility**: Implements the `x-relay-target` contract used by pi-freeflow.
117
-
118
- Once deployed on your VPS, register it in your relay pool:
119
- ```bash
120
- /freeflow add https://your-vps:port vps-warp-relay
121
- ```
122
-
123
134
  **Canonical worker source (all platforms)**
124
135
 
125
136
  The relay worker template is generated per deployment by `/freeflow deploy`, with one shared core adapted for Vercel, Cloudflare, and Deno. Every deploy embeds a random 32-byte secret, and the generated worker answers `401` to any request without a matching `x-relay-auth` header; the client sends that secret to each relay in the pool. Only a hand-pasted legacy worker built without a secret is open to anyone. Either way, a relay forwards only to an allowlist (`https://opencode.ai`, `https://api.kilo.ai`) with private-host and path checks.
@@ -197,14 +208,14 @@ The same command set works identically in OMP and Pi:
197
208
 
198
209
  ---
199
210
 
200
- ### 37 models, one command
211
+ ### 36 models, one command
201
212
 
202
213
  ```bash
203
214
  /model → freeflow → pick
204
215
  ```
205
216
 
206
217
  Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog, configured with the highest verified context, output ceiling, and reasoning capabilities across twin models.
207
- #### OpenCode Zen (14 models), Responses and Chat API
218
+ #### OpenCode Zen (13 models), Responses and Chat API
208
219
 
209
220
  Good defaults for long coding sessions and agentic work.
210
221
 
@@ -212,7 +223,6 @@ Good defaults for long coding sessions and agentic work.
212
223
  | :--- | :--- | :--- | :--- | :--- | :--- |
213
224
  | `cat-jam-free` | Cat Jam | **256K** (262.144) | **131K** (131.072) | `low … max` | ❌ |
214
225
  | `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
215
- | `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
216
226
  | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
217
227
  | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
218
228
  | `nemotron-3.5-super-vl-free` | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal … xhigh` | ✅ |
@@ -221,7 +231,7 @@ Good defaults for long coding sessions and agentic work.
221
231
  | `space-bunny-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **512K** (524.288) | `low … max` | ✅ |
222
232
  | `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.000.000) | **131K** (131.072) | `minimal … max` | ✅ |
223
233
  | `glm-5.3-flash-free` | Zhipu AI | **1M** (1.000.000) | **32K** (32.000) | `minimal … max` | ✅ |
224
- | `ll-alpha-free` | Undisclosed | **262K** (262.144) | **64K** (65.536) | `low … max` | ❌ |
234
+ | `beam-free` | Reflection AI | **1M** (1.048.576) | **131K** (131.072) | `low … max` | ❌ |
225
235
  | `exo-free` | Undisclosed | **1M** (1.048.576) | **131K** (131.072) | `high` | ✅ |
226
236
  | `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.000.000) | **384K** (384.000) | `minimal … max` | ✅ |
227
237
 
@@ -368,7 +378,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
368
378
  #### Project structure
369
379
 
370
380
  ```
371
- ├── models.ts # 37-model catalog definitions
381
+ ├── models.ts # 36-model catalog definitions
372
382
  ├── index.ts # extension entry, lifecycle hooks
373
383
  ├── catalog.ts # model catalog cache (24h disk)
374
384
  ├── proxy.ts # local proxy server (127.0.0.1:28180)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.38.2",
4
+ "version": "1.38.4",
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
@@ -124,6 +124,12 @@ export const DEAD_MODEL_IDS = new Set<string>([
124
124
  // answers 401 PAID_MODEL_AUTH_REQUIRED while the sibling z-ai/glm-5.3-flash
125
125
  // answers 200 keyless with the same request shape.
126
126
  "stepfun/step-5-preview-free",
127
+ // ll-alpha-free: decommissioned upstream (2026-10-17) — gone from live
128
+ // Zen /v1/models list, answers HTTP 410 "Model ll-alpha-free has been deprecated".
129
+ "ll-alpha-free",
130
+ // muse-spark-1.3-contributor-free: decommissioned upstream (2026-10-17) — gone from
131
+ // live Zen /v1/models list, answers HTTP 410 "Model muse-spark-1.3-contributor-free has been deprecated".
132
+ "muse-spark-1.3-contributor-free",
127
133
  ]);
128
134
  /**
129
135
  * Free-tier allowlist for anything entering the picker via network or stale disk.
@@ -163,7 +169,7 @@ export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredMode
163
169
  }
164
170
  /**
165
171
  * In-memory cache of currently active/available free models.
166
- * Initialized with all 37 verified models for 0ms instant availability.
172
+ * Initialized with all 36 verified models for 0ms instant availability.
167
173
  */
168
174
  let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
169
175
  ...m,
package/src/commands.ts CHANGED
@@ -728,6 +728,15 @@ export function createCommandSpec(
728
728
  ? "Deno Deploy relay — token: dash.deno.com → Access Tokens"
729
729
  : "Vercel relay — token: vercel.com/account/tokens";
730
730
  ctx.ui.notify(tokenHint, "info");
731
+ if (platform === "deno") {
732
+ // Verified 2026-10-17: Deno Deploy rewrites the outbound User-Agent at the
733
+ // platform layer, so OpenCode Zen's free tier rejects every request that
734
+ // rides this relay. Warn before spending a token on a Zen-only deployment.
735
+ ctx.ui.notify(
736
+ "⚠ Deno Deploy rewrites outbound User-Agent: OpenCode Zen free-tier requests through this relay are rejected (403 FreeTierError). It can still serve Kilo models — prefer Cloudflare for Zen.",
737
+ "warning",
738
+ );
739
+ }
731
740
  const token = (
732
741
  await ctx.ui.input(`${label} API token:`, "")
733
742
  )?.trim();
@@ -812,9 +821,31 @@ export function createCommandSpec(
812
821
  );
813
822
  },
814
823
  });
815
- probeNote = probe.ok
816
- ? ` ✓ reachable (HTTP ${probe.status}, ${probe.latencyMs}ms)`
817
- : ` ⚠ deployed but unreachable (${probe.error || `HTTP ${probe.status}`}) — run /freeflow test ${finalUrl} to retry, or /freeflow remove ${finalUrl} to drop it`;
824
+ if (!probe.ok) {
825
+ probeNote = ` ⚠ deployed but unreachable (${probe.error || `HTTP ${probe.status}`}) — run /freeflow test ${finalUrl} to retry, or /freeflow remove ${finalUrl} to drop it`;
826
+ } else {
827
+ // Reachability is not enough: a relay can answer /v1/models while its
828
+ // egress is transformed in flight (Deno Deploy rewrites the outbound
829
+ // User-Agent), and OpenCode Zen then rejects the real traffic. The chat
830
+ // probe carries the full free-tier fingerprint + tools + stream, so it
831
+ // fails exactly when Zen inference through this relay would.
832
+ const chat = await probeRelay(finalUrl, auth, {
833
+ endpoint: "chat",
834
+ retries: 1,
835
+ retryDelayMs: retryDelay,
836
+ });
837
+ if (chat.ok) {
838
+ probeNote = ` ✓ reachable + free-tier inference verified (HTTP ${chat.status}, ${chat.latencyMs}ms)`;
839
+ } else {
840
+ // The Deno User-Agent rewrite only explains the free-tier gate; a
841
+ // timeout or a 5xx must report what was actually observed instead.
842
+ const denoGate = platform === "deno" && chat.status === 403;
843
+ const reason = denoGate
844
+ ? "— Deno Deploy rewrites the outbound User-Agent, which OpenCode Zen rejects; Kilo models still work through this relay"
845
+ : `(${chat.error || `HTTP ${chat.status}`}) — this relay cannot serve OpenCode Zen traffic (its egress is transformed in flight)`;
846
+ probeNote = ` ⚠ reachable, but the free-tier inference probe failed ${reason}. Run /freeflow test ${finalUrl} --chat to re-check, or /freeflow remove ${finalUrl} to drop it`;
847
+ }
848
+ }
818
849
  } catch {
819
850
  // probeRelay never throws, but keep the notify safe regardless
820
851
  }
package/src/deploy.ts CHANGED
@@ -488,18 +488,45 @@ export async function deployCloudflareWorker(
488
488
  return { url, auth: relayAuth };
489
489
  }
490
490
 
491
+ type DenoTimeline = {
492
+ /** Live v2 shape: display name, e.g. "Production". */
493
+ name?: string;
494
+ /** Live v2 shape: routing context ("Production" / "Preview"). */
495
+ context?: string;
496
+ /** Live v2 shape: hostnames routed to this timeline. */
497
+ hostnames?: string[];
498
+ /** Legacy shape retained for older API responses. */
499
+ slug?: string;
500
+ domains?: Array<{ domain?: string }>;
501
+ };
502
+
491
503
  type DenoRevision = {
492
504
  status?: string;
493
505
  failure_reason?: string | null;
494
- timelines?: Array<{ slug?: string; domains?: Array<{ domain?: string }> }>;
506
+ timelines?: DenoTimeline[];
495
507
  };
496
508
 
509
+ /** The v2 API labels the production timeline via `name`/`context` (legacy: `slug`). */
510
+ function isProductionTimeline(t: DenoTimeline): boolean {
511
+ return [t.name, t.context, t.slug].some(
512
+ (v) => typeof v === "string" && v.trim().toLowerCase() === "production",
513
+ );
514
+ }
515
+
516
+ /**
517
+ * Resolve the hostname routed to the production timeline of a revision.
518
+ * The live v2 API answers `timelines[].hostnames` (e.g. `app-org.deno.net`);
519
+ * older responses carried `slug` + `domains: [{ domain }]`. Both are read,
520
+ * preferring whichever timeline is marked production, else the first one.
521
+ */
497
522
  function resolveRoutedDomain(revision: DenoRevision): string | null {
498
523
  const timelines = revision.timelines ?? [];
499
- const production = timelines.find((t) => t.slug === "production") ?? timelines[0];
500
- const host = (production?.domains ?? [])
501
- .map((d) => d.domain ?? "")
502
- .find((h) => h.length > 0);
524
+ const production = timelines.find(isProductionTimeline) ?? timelines[0];
525
+ if (!production) return null;
526
+ const host = [
527
+ ...(production.hostnames ?? []),
528
+ ...(production.domains ?? []).map((d) => d.domain ?? ""),
529
+ ].find((h) => typeof h === "string" && h.length > 0);
503
530
  return host ?? null;
504
531
  }
505
532
 
package/src/models.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 37 verified free models (live-verified 2026-10-16):
5
- * - 14 OpenCode Zen models (2 Responses API + 12 Chat Completions)
4
+ * Defines the 36 verified free models (live-verified 2026-10-17):
5
+ * - 13 OpenCode Zen models (1 Responses API + 12 Chat Completions)
6
6
  * - 13 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 2 Cline direct-only models (per-user pool)
8
8
  * - 8 OVHcloud AI Endpoints models (keyless, direct-only)
@@ -59,24 +59,6 @@ export const OPENCODE_MODELS: ModelDef[] = [
59
59
  max: null,
60
60
  },
61
61
  },
62
- {
63
- id: "muse-spark-1.3-contributor-free",
64
- name: "Muse Spark 1.3 [OpenCode]",
65
- reasoning: true,
66
- contextWindow: 1_048_576,
67
- maxTokens: 131_072,
68
- api: "openai-responses",
69
- input: ["text", "image", "video"],
70
- thinkingLevelMap: {
71
- off: null,
72
- minimal: "minimal",
73
- low: "low",
74
- medium: "medium",
75
- high: "high",
76
- xhigh: "xhigh",
77
- max: null,
78
- },
79
- },
80
62
  {
81
63
  id: "mimo-v2.6-flash-free",
82
64
  name: "MiMo V2.6 Flash [OpenCode]",
@@ -251,23 +233,20 @@ export const OPENCODE_MODELS: ModelDef[] = [
251
233
  },
252
234
  },
253
235
  {
254
- // Added 2026-10-16 (issue #71 drift): Zen v1.19.2 added this free id.
255
- // Specs agree across pi.dev and models.dev (262,144 ctx / 65,536 max,
256
- // text-only) and the boundary probe matches: a marker at 90% depth is
257
- // recalled at ~220K words and the gateway refuses ~250K words (400), with
258
- // a silent no-recall at ~240K — the window ends right about there.
259
- // maxTokens 65,536 published; the gateway accepted 131,072, which is
260
- // acceptance, not a cap. Effort ladder: low/medium/high/xhigh/max probed
261
- // 200 with visibly different reasoning lengths; `minimal` answers 200 too
262
- // but stays hidden because the published ladder starts at low (same class
263
- // as nemotron's tolerated-but-hidden `max`); `off` is null like everywhere.
264
- // Chat-only (/v1/responses 400 protocol). Text-only: an image probe
265
- // produced no answer.
266
- id: "ll-alpha-free",
267
- name: "LL Alpha [OpenCode]",
236
+ // Added 2026-10-17: Reflection AI's Beam 501B open-weight MoE model (23B active),
237
+ // added as a limited-time free model on OpenCode Zen (/zen/v1/models: beam-free).
238
+ // Live-probed 2026-10-17: 200 with stream:true and tool sextet.
239
+ // Context window: 1_048_576 tokens (1M), maxTokens: 131_072.
240
+ // Probed boundary: filler up to 900,000 words (~1.1M tokens) answers 200.
241
+ // Input: text-only (image probe answers 400 "Beam supports text input only").
242
+ // Wire format: Chat Completions (/v1/responses answers 400 ModelProtocolUnsupported).
243
+ // Reasoning effort ladder: minimal is rejected with 400 (unsupported_value),
244
+ // low/medium/high/xhigh/max all answer 200 with reasoning.
245
+ id: "beam-free",
246
+ name: "Beam Free [OpenCode]",
268
247
  reasoning: true,
269
- contextWindow: 262_144,
270
- maxTokens: 65_536,
248
+ contextWindow: 1_048_576,
249
+ maxTokens: 131_072,
271
250
  input: ["text"],
272
251
  thinkingLevelMap: {
273
252
  off: null,
@@ -722,7 +701,7 @@ export const MODEL_ALIASES: Record<string, string> = {
722
701
  "space-bunny": "space-bunny-free",
723
702
  "longcat-2.5-preview": "longcat-2.5-preview-free",
724
703
  "longcat": "longcat-2.5-preview-free",
725
- "ll-alpha": "ll-alpha-free",
704
+ "beam": "beam-free",
726
705
  "deepseek-v4.1-flash": "deepseek-v4.1-flash-free",
727
706
  // provider-prefixed short aliases (slash-normalized)
728
707
  "laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
@@ -777,7 +756,7 @@ export const OVH_MODEL_IDS = new Set<string>([
777
756
  .map(([alias]) => alias),
778
757
  ]);
779
758
  /**
780
- * Combined list of all 37 static free models (canonical)
759
+ * Combined list of all 36 static free models (canonical)
781
760
  */
782
761
  export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS, ...OVH_MODELS];
783
762
 
package/src/proxy.ts CHANGED
@@ -1612,12 +1612,18 @@ export function startProxy(
1612
1612
  ) {
1613
1613
  rememberIssuerRelay(conversationKey, servedIssuer);
1614
1614
  }
1615
- // Upstream degradation tracking: successes prove Zen reachable,
1616
- // 403 free-tier gates feed the health state machine (a 403 never
1617
- // rolls — it is a terminal upstream verdict, not a relay fault).
1615
+ // Upstream degradation tracking: successes prove Zen reachable; a 403
1616
+ // free-tier gate feeds the health state machine only when the DIRECT
1617
+ // path produced it (servedIssuer === null). A relay-served 403 is
1618
+ // relay-shaped — relay.ts rolls it and marks the relay — so a salvaged
1619
+ // relay 403 must never flip the machine-wide gate.
1618
1620
  if (response.ok) {
1619
1621
  recordUpstreamSuccess("zen", { sessionKey, canary: isCanary });
1620
- } else if (response.status === 403 && (response.headers.get("content-type") ?? "").includes("application/json")) {
1622
+ } else if (
1623
+ servedIssuer === null &&
1624
+ response.status === 403 &&
1625
+ (response.headers.get("content-type") ?? "").includes("application/json")
1626
+ ) {
1621
1627
  try {
1622
1628
  const gateText = await response.clone().text();
1623
1629
  recordUpstreamFailure("zen", 403, gateText);
package/src/relay.ts CHANGED
@@ -489,25 +489,38 @@ export async function relayFetch(
489
489
  continue;
490
490
  }
491
491
 
492
- // Relay auth rejection: a deployed worker whose RELAY_AUTH no longer
493
- // matches this entry's x-relay-auth answers 401 {"error":"unauthorized"}
494
- // before forwarding, so the relay never reached upstream (deploy.ts).
495
- // That is a relay-host fault, not an upstream verdict: mark it and roll
496
- // like every other relay failure. Left to fall through, the broken relay
497
- // reached markRelaySuccess below — recorded healthy/sticky, EWMA reset,
498
- // no roll, so it kept serving the pool until something else noticed.
499
- // Strictly 401: a 403 free-tier gate stays a terminal upstream verdict
500
- // (rolling one buys nothing and burns pool quota), and 404-style client
501
- // errors keep their non-penalizing terminal path below.
502
- if (res.status === 401) {
503
- markRelayFailure(targetUrl, 401, "Relay auth rejected (x-relay-auth mismatch)");
492
+ // Relay-host rejection: 401 = a deployed worker whose RELAY_AUTH no
493
+ // longer matches this entry's x-relay-auth answers
494
+ // 401 {"error":"unauthorized"} before forwarding (deploy.ts). 403 =
495
+ // the relay's egress transformed the request fingerprint (e.g. a
496
+ // stripped User-Agent), so the worker's target guard or the upstream
497
+ // free-tier check rejected it — the relay never produced a genuine
498
+ // upstream verdict either. Both are relay-host faults: mark and roll
499
+ // like every other relay failure. Left to fall through, the broken
500
+ // relay reached markRelaySuccess below — recorded healthy/sticky,
501
+ // EWMA reset, no roll, so it kept serving the pool until something
502
+ // else noticed. A 403 is no longer treated as a terminal upstream
503
+ // verdict: rolls are bounded by pool size, and the gate state machine
504
+ // only primes on a 403 the direct path produced (proxy.ts). Non-401/403
505
+ // client errors keep their non-penalizing terminal path below.
506
+ if (res.status === 401 || res.status === 403) {
507
+ const authRejected = res.status === 401;
508
+ markRelayFailure(
509
+ targetUrl,
510
+ res.status,
511
+ authRejected
512
+ ? "Relay auth rejected (x-relay-auth mismatch)"
513
+ : "Relay returned HTTP 403 (stripped or blocked egress fingerprint)",
514
+ );
504
515
  lastResponse?.body?.cancel().catch(() => {});
505
516
  lastResponse = res;
506
517
  lastResponseRelay = targetUrl;
507
518
  log(
508
519
  "warn",
509
- `relay ${targetUrl} rejected the relay auth in ${elapsed}s — rolling to next relay`,
510
- { upstream: url, status: 401 },
520
+ authRejected
521
+ ? `relay ${targetUrl} rejected the relay auth in ${elapsed}s — rolling to next relay`
522
+ : `relay ${targetUrl} returned HTTP 403 (blocked egress fingerprint) in ${elapsed}s — rolling to next relay`,
523
+ { upstream: url, status: res.status },
511
524
  rid,
512
525
  );
513
526
  const now = Date.now();
@@ -515,7 +528,12 @@ export async function relayFetch(
515
528
  lastRollNotify = now;
516
529
  const ui = getStatusUi();
517
530
  if (ui?.notify) {
518
- ui.notify(`relay ${shortRelayLabel(targetUrl)} rejected the relay auth (HTTP 401) — rolled to next relay`, "warning");
531
+ ui.notify(
532
+ authRejected
533
+ ? `relay ${shortRelayLabel(targetUrl)} rejected the relay auth (HTTP 401) — rolled to next relay`
534
+ : `relay ${shortRelayLabel(targetUrl)} failed (HTTP 403) — rolled to next relay`,
535
+ "warning",
536
+ );
519
537
  }
520
538
  }
521
539
  continue;
@@ -442,6 +442,53 @@ function cloakStreamResponsesObject(resp: unknown, cloak: StreamCloakOptions): v
442
442
  (resp as Record<string, unknown>).output = kept;
443
443
  }
444
444
 
445
+ /**
446
+ * Terminal chunk for chat choice: absorb any tool-call argument fragment
447
+ * riding the finish_reason chunk into the buffered prefix, then re-emit every
448
+ * buffered tool call's whole argument packet, repaired.
449
+ * Returns true if delta was mutated.
450
+ */
451
+ function flushChatTerminalToolCalls(
452
+ d: Record<string, unknown>,
453
+ state: SseStreamCloakState,
454
+ cloak: StreamCloakOptions,
455
+ flushedIndices: Set<number>,
456
+ ): boolean {
457
+ if (state.chatArgsByIndex.size === 0) return false;
458
+
459
+ for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) {
460
+ if (!tc || typeof tc !== "object" || Array.isArray(tc)) continue;
461
+ const rec = tc as Record<string, unknown>;
462
+ const idx = typeof rec.index === "number" ? rec.index : 0;
463
+ const fn = rec.function;
464
+ const fnRec = fn && typeof fn === "object" && !Array.isArray(fn) ? (fn as Record<string, unknown>) : null;
465
+ if (fnRec && state.chatArgsByIndex.has(idx) && typeof fnRec.arguments === "string") {
466
+ state.chatArgsByIndex.set(idx, (state.chatArgsByIndex.get(idx) ?? "") + fnRec.arguments);
467
+ }
468
+ }
469
+
470
+ const flushed: unknown[] = [];
471
+ for (const [idx, args] of state.chatArgsByIndex) {
472
+ const name = state.chatNameByIndex.get(idx);
473
+ if (name === undefined) continue;
474
+ flushedIndices.add(idx);
475
+ flushed.push({ index: idx, type: "function", function: { name, arguments: repairToolArguments(name, args, cloak.argSchemas) } });
476
+ }
477
+ state.chatArgsByIndex.clear();
478
+ state.chatNameByIndex.clear();
479
+
480
+ if (flushed.length === 0) return false;
481
+
482
+ const remaining = (Array.isArray(d.tool_calls) ? d.tool_calls : []).filter((tc) => {
483
+ if (!tc || typeof tc !== "object" || Array.isArray(tc)) return true;
484
+ const rec = tc as Record<string, unknown>;
485
+ const idx = typeof rec.index === "number" ? rec.index : 0;
486
+ return !flushedIndices.has(idx);
487
+ });
488
+ d.tool_calls = [...remaining, ...flushed];
489
+ return true;
490
+ }
491
+
445
492
  /**
446
493
  * Rewrite one Chat Completions SSE block: drop injected delta.tool_calls
447
494
  * (including nameless argument continuations via per-index tracking), restore
@@ -489,20 +536,8 @@ function rewriteChatBlock(
489
536
  // Indices flushed on this block: their arguments are final, so the
490
537
  // per-delta loop below must not buffer them a second time.
491
538
  const flushedIndices = new Set<number>();
492
- if (c.finish_reason && state.chatArgsByIndex.size > 0) {
493
- const flushed: unknown[] = [];
494
- for (const [idx, args] of state.chatArgsByIndex) {
495
- const name = state.chatNameByIndex.get(idx);
496
- if (name === undefined) continue;
497
- flushedIndices.add(idx);
498
- flushed.push({ index: idx, type: "function", function: { name, arguments: repairToolArguments(name, args, cloak.argSchemas) } });
499
- }
500
- state.chatArgsByIndex.clear();
501
- state.chatNameByIndex.clear();
502
- if (flushed.length > 0) {
503
- d.tool_calls = [...(Array.isArray(d.tool_calls) ? d.tool_calls : []), ...flushed];
504
- changed = true;
505
- }
539
+ if (c.finish_reason && flushChatTerminalToolCalls(d, state, cloak, flushedIndices)) {
540
+ changed = true;
506
541
  }
507
542
  if (!Array.isArray(d.tool_calls)) {
508
543
  if (Object.keys(d).length > 0) allDeltasEmpty = false;