pi-freeflow 1.38.4 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 1.38.4
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.38.4",
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",
@@ -1134,7 +1135,7 @@ export function createCommandSpec(
1134
1135
  }
1135
1136
  };
1136
1137
 
1137
- if (sub === "auto") {
1138
+ if (sub === "auto" || sub === "sticky") {
1138
1139
  applyRelayState((s) => {
1139
1140
  s.mode = "auto";
1140
1141
  s.enabled = true;
@@ -1211,10 +1212,10 @@ export function createCommandSpec(
1211
1212
  relayState.mode === "off"
1212
1213
  ? "Mode: off (always direct)"
1213
1214
  : relayState.mode === "on"
1214
- ? "Mode: on (always relay)"
1215
+ ? "Mode: on (always relay, sticky primary)"
1215
1216
  : relayState.mode === "spread"
1216
1217
  ? "Mode: spread (rotates across healthy relays, auto-rolls on 429/5xx)"
1217
- : "Mode: auto (enabled on session, auto-rolls on 429/5xx)";
1218
+ : "Mode: auto (sticky primary, auto-rolls on 429/5xx)";
1218
1219
  const poolLine = `${relayState.relays.length} relay(s)${relayState.relays.length > 0
1219
1220
  ? ` | active: ${shortRelayLabel(relayState.url, relayState.relays)}`
1220
1221
  : ""
@@ -1957,8 +1958,8 @@ export function createCommandSpec(
1957
1958
  "Deploy Cloudflare relay…",
1958
1959
  "Deploy Deno relay…",
1959
1960
  "Deploy Vercel relay (last resort)…",
1960
- "Mode: AUTO (auto-detect on model select)",
1961
- "Mode: ON (always relay)",
1961
+ "Mode: AUTO (sticky primary, auto-detect on model select)",
1962
+ "Mode: ON (always relay, sticky primary)",
1962
1963
  "Mode: OFF (always direct)",
1963
1964
  "Mode: SPREAD (rotate across healthy relays)",
1964
1965
  ]);
@@ -1986,7 +1987,7 @@ export function createCommandSpec(
1986
1987
  await doDeploy("cloudflare");
1987
1988
  } else if (choice === "Deploy Deno relay…") {
1988
1989
  await doDeploy("deno");
1989
- } else if (choice === "Mode: AUTO (auto-detect on model select)") {
1990
+ } else if (choice === "Mode: AUTO (sticky primary, auto-detect on model select)") {
1990
1991
  applyRelayState((s) => {
1991
1992
  s.mode = "auto";
1992
1993
  s.enabled = true;
@@ -1998,7 +1999,7 @@ export function createCommandSpec(
1998
1999
  });
1999
2000
  persist();
2000
2001
  flash();
2001
- } else if (choice === "Mode: ON (always relay)") {
2002
+ } else if (choice === "Mode: ON (always relay, sticky primary)") {
2002
2003
  applyRelayState((s) => {
2003
2004
  s.mode = "on";
2004
2005
  s.enabled = true;
package/src/relay.ts CHANGED
@@ -621,6 +621,25 @@ export async function relayFetch(
621
621
  directHeaders.set("x-request-id", rid);
622
622
 
623
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
+ }
624
643
  // lastResponse holds an unread body that would otherwise leak its socket
625
644
  // until GC; the salvage path below still needs it, so only cancel here.
626
645
  lastResponse?.body?.cancel().catch(() => {});
@@ -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
- if (status !== 403 || typeof bodyText !== "string") return false;
270
- return (
271
- bodyText.includes(GATE_CODE_MARKER) && bodyText.includes(GATE_MESSAGE_MARKER)
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;