pi-freeflow 1.38.3 → 1.38.5
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 +32 -21
- package/package.json +1 -1
- package/src/commands.ts +43 -11
- package/src/deploy.ts +32 -5
- package/src/proxy.ts +10 -4
- package/src/relay.ts +52 -15
- package/src/upstream-health.ts +10 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.38.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Salvage relay rate-limit responses when direct fallback encounters network-level or abusive-IP 403 errors, preventing transient rate limits from locking up upstream models. Add the `/freeflow sticky` command to pin requests to one healthy relay until a rate limit or edge error occurs, preserving fresh pool relays. Recognize network-restricted and abusive-traffic 403 variants as free-tier gates so fresh sessions automatically fail over to healthy fallback models without interruption.
|
|
8
|
+
|
|
9
|
+
## 1.38.4
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
3
17
|
## 1.38.3
|
|
4
18
|
|
|
5
19
|
### 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.5",
|
|
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
|
@@ -540,10 +540,11 @@ export function createCommandSpec(
|
|
|
540
540
|
): Omit<RegisteredCommand, "name"> {
|
|
541
541
|
return {
|
|
542
542
|
description:
|
|
543
|
-
"Relay egress: auto | spread | on | off | hide | show | widget hide/show | status | kill|stop|shutdown | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | url [value] | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | trace [reqId] | debug on|off|level <level> | refresh | models|reload | update [auto on|off] | autoupdate [on|off] | deploy cloudflare | deploy deno | deploy vercel | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot] | cline signout|switch",
|
|
543
|
+
"Relay egress: auto | sticky | spread | on | off | hide | show | widget hide/show | status | kill|stop|shutdown | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | url [value] | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | trace [reqId] | debug on|off|level <level> | refresh | models|reload | update [auto on|off] | autoupdate [on|off] | deploy cloudflare | deploy deno | deploy vercel | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot] | cline signout|switch",
|
|
544
544
|
getArgumentCompletions: (prefix: string) =>
|
|
545
545
|
[
|
|
546
546
|
"auto",
|
|
547
|
+
"sticky",
|
|
547
548
|
"spread",
|
|
548
549
|
"on",
|
|
549
550
|
"off",
|
|
@@ -728,6 +729,15 @@ export function createCommandSpec(
|
|
|
728
729
|
? "Deno Deploy relay — token: dash.deno.com → Access Tokens"
|
|
729
730
|
: "Vercel relay — token: vercel.com/account/tokens";
|
|
730
731
|
ctx.ui.notify(tokenHint, "info");
|
|
732
|
+
if (platform === "deno") {
|
|
733
|
+
// Verified 2026-10-17: Deno Deploy rewrites the outbound User-Agent at the
|
|
734
|
+
// platform layer, so OpenCode Zen's free tier rejects every request that
|
|
735
|
+
// rides this relay. Warn before spending a token on a Zen-only deployment.
|
|
736
|
+
ctx.ui.notify(
|
|
737
|
+
"⚠ 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.",
|
|
738
|
+
"warning",
|
|
739
|
+
);
|
|
740
|
+
}
|
|
731
741
|
const token = (
|
|
732
742
|
await ctx.ui.input(`${label} API token:`, "")
|
|
733
743
|
)?.trim();
|
|
@@ -812,9 +822,31 @@ export function createCommandSpec(
|
|
|
812
822
|
);
|
|
813
823
|
},
|
|
814
824
|
});
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
825
|
+
if (!probe.ok) {
|
|
826
|
+
probeNote = ` ⚠ deployed but unreachable (${probe.error || `HTTP ${probe.status}`}) — run /freeflow test ${finalUrl} to retry, or /freeflow remove ${finalUrl} to drop it`;
|
|
827
|
+
} else {
|
|
828
|
+
// Reachability is not enough: a relay can answer /v1/models while its
|
|
829
|
+
// egress is transformed in flight (Deno Deploy rewrites the outbound
|
|
830
|
+
// User-Agent), and OpenCode Zen then rejects the real traffic. The chat
|
|
831
|
+
// probe carries the full free-tier fingerprint + tools + stream, so it
|
|
832
|
+
// fails exactly when Zen inference through this relay would.
|
|
833
|
+
const chat = await probeRelay(finalUrl, auth, {
|
|
834
|
+
endpoint: "chat",
|
|
835
|
+
retries: 1,
|
|
836
|
+
retryDelayMs: retryDelay,
|
|
837
|
+
});
|
|
838
|
+
if (chat.ok) {
|
|
839
|
+
probeNote = ` ✓ reachable + free-tier inference verified (HTTP ${chat.status}, ${chat.latencyMs}ms)`;
|
|
840
|
+
} else {
|
|
841
|
+
// The Deno User-Agent rewrite only explains the free-tier gate; a
|
|
842
|
+
// timeout or a 5xx must report what was actually observed instead.
|
|
843
|
+
const denoGate = platform === "deno" && chat.status === 403;
|
|
844
|
+
const reason = denoGate
|
|
845
|
+
? "— Deno Deploy rewrites the outbound User-Agent, which OpenCode Zen rejects; Kilo models still work through this relay"
|
|
846
|
+
: `(${chat.error || `HTTP ${chat.status}`}) — this relay cannot serve OpenCode Zen traffic (its egress is transformed in flight)`;
|
|
847
|
+
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`;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
818
850
|
} catch {
|
|
819
851
|
// probeRelay never throws, but keep the notify safe regardless
|
|
820
852
|
}
|
|
@@ -1103,7 +1135,7 @@ export function createCommandSpec(
|
|
|
1103
1135
|
}
|
|
1104
1136
|
};
|
|
1105
1137
|
|
|
1106
|
-
if (sub === "auto") {
|
|
1138
|
+
if (sub === "auto" || sub === "sticky") {
|
|
1107
1139
|
applyRelayState((s) => {
|
|
1108
1140
|
s.mode = "auto";
|
|
1109
1141
|
s.enabled = true;
|
|
@@ -1180,10 +1212,10 @@ export function createCommandSpec(
|
|
|
1180
1212
|
relayState.mode === "off"
|
|
1181
1213
|
? "Mode: off (always direct)"
|
|
1182
1214
|
: relayState.mode === "on"
|
|
1183
|
-
? "Mode: on (always relay)"
|
|
1215
|
+
? "Mode: on (always relay, sticky primary)"
|
|
1184
1216
|
: relayState.mode === "spread"
|
|
1185
1217
|
? "Mode: spread (rotates across healthy relays, auto-rolls on 429/5xx)"
|
|
1186
|
-
: "Mode: auto (
|
|
1218
|
+
: "Mode: auto (sticky primary, auto-rolls on 429/5xx)";
|
|
1187
1219
|
const poolLine = `${relayState.relays.length} relay(s)${relayState.relays.length > 0
|
|
1188
1220
|
? ` | active: ${shortRelayLabel(relayState.url, relayState.relays)}`
|
|
1189
1221
|
: ""
|
|
@@ -1926,8 +1958,8 @@ export function createCommandSpec(
|
|
|
1926
1958
|
"Deploy Cloudflare relay…",
|
|
1927
1959
|
"Deploy Deno relay…",
|
|
1928
1960
|
"Deploy Vercel relay (last resort)…",
|
|
1929
|
-
"Mode: AUTO (auto-detect on model select)",
|
|
1930
|
-
"Mode: ON (always relay)",
|
|
1961
|
+
"Mode: AUTO (sticky primary, auto-detect on model select)",
|
|
1962
|
+
"Mode: ON (always relay, sticky primary)",
|
|
1931
1963
|
"Mode: OFF (always direct)",
|
|
1932
1964
|
"Mode: SPREAD (rotate across healthy relays)",
|
|
1933
1965
|
]);
|
|
@@ -1955,7 +1987,7 @@ export function createCommandSpec(
|
|
|
1955
1987
|
await doDeploy("cloudflare");
|
|
1956
1988
|
} else if (choice === "Deploy Deno relay…") {
|
|
1957
1989
|
await doDeploy("deno");
|
|
1958
|
-
} else if (choice === "Mode: AUTO (auto-detect on model select)") {
|
|
1990
|
+
} else if (choice === "Mode: AUTO (sticky primary, auto-detect on model select)") {
|
|
1959
1991
|
applyRelayState((s) => {
|
|
1960
1992
|
s.mode = "auto";
|
|
1961
1993
|
s.enabled = true;
|
|
@@ -1967,7 +1999,7 @@ export function createCommandSpec(
|
|
|
1967
1999
|
});
|
|
1968
2000
|
persist();
|
|
1969
2001
|
flash();
|
|
1970
|
-
} else if (choice === "Mode: ON (always relay)") {
|
|
2002
|
+
} else if (choice === "Mode: ON (always relay, sticky primary)") {
|
|
1971
2003
|
applyRelayState((s) => {
|
|
1972
2004
|
s.mode = "on";
|
|
1973
2005
|
s.enabled = true;
|
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;
|
|
@@ -603,6 +621,25 @@ export async function relayFetch(
|
|
|
603
621
|
directHeaders.set("x-request-id", rid);
|
|
604
622
|
|
|
605
623
|
const directRes = await fetch(url, { ...opts, headers: directHeaders } as unknown as RequestInit);
|
|
624
|
+
if (
|
|
625
|
+
directRes.status === 403 &&
|
|
626
|
+
lastResponse &&
|
|
627
|
+
(lastResponse.status === 429 || lastResponse.status === 502 || lastResponse.status === 503)
|
|
628
|
+
) {
|
|
629
|
+
log(
|
|
630
|
+
"warn",
|
|
631
|
+
`direct fallback returned HTTP 403 FreeTierError (direct IP untrusted) — salvaging last relay HTTP ${lastResponse.status}`,
|
|
632
|
+
{
|
|
633
|
+
upstream: url,
|
|
634
|
+
directStatus: 403,
|
|
635
|
+
relayStatus: lastResponse.status,
|
|
636
|
+
},
|
|
637
|
+
rid,
|
|
638
|
+
);
|
|
639
|
+
directRes.body?.cancel().catch(() => {});
|
|
640
|
+
affinity.onServed?.(lastResponseRelay);
|
|
641
|
+
return lastResponse;
|
|
642
|
+
}
|
|
606
643
|
// lastResponse holds an unread body that would otherwise leak its socket
|
|
607
644
|
// until GC; the salvage path below still needs it, so only cancel here.
|
|
608
645
|
lastResponse?.body?.cancel().catch(() => {});
|
package/src/upstream-health.ts
CHANGED
|
@@ -31,10 +31,11 @@ export const FALLBACK_KILO_MODEL = "nvidia/nemotron-3.5-lightning:free";
|
|
|
31
31
|
|
|
32
32
|
export type ZenRoute = "passthrough" | "failover" | "canary";
|
|
33
33
|
|
|
34
|
-
/** Free-tier gate marker: the free tier only works from inside OpenCode. */
|
|
34
|
+
/** Free-tier gate marker: the free tier only works from inside OpenCode or is blocked by network/VPN abuse detection. */
|
|
35
35
|
const GATE_CODE_MARKER = "FreeTierError";
|
|
36
36
|
const GATE_MESSAGE_MARKER = "only be used from within OpenCode";
|
|
37
|
-
|
|
37
|
+
const GATE_ABUSIVE_MARKER = "abusive traffic";
|
|
38
|
+
const GATE_NETWORK_MARKER = "aren't available from your current network";
|
|
38
39
|
/** User-visible hint attached to fast-failed 403 gate bodies. */
|
|
39
40
|
const FREE_TIER_HINT =
|
|
40
41
|
"OpenCode's free tier is only available inside OpenCode right now, " +
|
|
@@ -266,12 +267,14 @@ export function notifyGateTransition(upstream: UpstreamName, gated?: boolean): v
|
|
|
266
267
|
|
|
267
268
|
/** True when a status/body pair is the free-tier gate (fresh sessions refused). */
|
|
268
269
|
export function isFreeTierGate(status: number, bodyText: unknown): boolean {
|
|
269
|
-
|
|
270
|
-
return
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
if (status !== 403 || typeof bodyText !== "string") return false;
|
|
271
|
+
if (!bodyText.includes(GATE_CODE_MARKER)) return false;
|
|
272
|
+
return (
|
|
273
|
+
bodyText.includes(GATE_MESSAGE_MARKER) ||
|
|
274
|
+
bodyText.includes(GATE_ABUSIVE_MARKER) ||
|
|
275
|
+
bodyText.includes(GATE_NETWORK_MARKER)
|
|
276
|
+
);
|
|
273
277
|
}
|
|
274
|
-
|
|
275
278
|
/** Stable conversation key carried by request bodies, if the caller sent one. */
|
|
276
279
|
export function sessionKeyOf(parsedBody: unknown): string | null {
|
|
277
280
|
if (typeof parsedBody !== "object" || parsedBody === null) return null;
|