pi-freeflow 1.38.3 → 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 +8 -0
- package/README.md +32 -21
- package/package.json +1 -1
- package/src/commands.ts +34 -3
- package/src/deploy.ts +32 -5
- package/src/proxy.ts +10 -4
- package/src/relay.ts +33 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 1.38.3
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
|
|
|
17
17
|
| Feature | What it does | Cost |
|
|
18
18
|
| :--- | :--- | :--- |
|
|
19
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 Cloudflare Workers
|
|
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.
|
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/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/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;
|