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 +14 -0
- package/README.md +38 -28
- package/package.json +1 -1
- package/src/catalog.ts +7 -1
- package/src/commands.ts +34 -3
- package/src/deploy.ts +32 -5
- package/src/models.ts +17 -38
- package/src/proxy.ts +10 -4
- package/src/relay.ts +33 -15
- package/src/stream-pipe.ts +49 -14
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
|
-
>
|
|
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
|
-
| **
|
|
20
|
-
| **Relay pool** | Route requests through your own Cloudflare Workers
|
|
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
|
|
79
|
-
|
|
80
|
-
once
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
###
|
|
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 (
|
|
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
|
-
| `
|
|
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 #
|
|
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.
|
|
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
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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?:
|
|
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(
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
5
|
-
* -
|
|
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-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
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:
|
|
270
|
-
maxTokens:
|
|
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
|
-
"
|
|
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
|
|
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
|
-
//
|
|
1617
|
-
//
|
|
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 (
|
|
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
|
|
493
|
-
// matches this entry's x-relay-auth answers
|
|
494
|
-
// before forwarding
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
510
|
-
|
|
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(
|
|
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;
|
package/src/stream-pipe.ts
CHANGED
|
@@ -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
|
|
493
|
-
|
|
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;
|