pi-freeflow 1.9.8 → 1.9.9

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
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to pi-freeflow. Public, user-visible behavior only.
4
4
 
5
+ ## 1.9.9 - 2026-09-06
6
+
7
+ ### Fixes
8
+ - **Removed our own request cap — sorry, that one was on us.** The proxy used to enforce a built-in request limit and could answer 429 before upstream quota was actually exhausted. That was a bug, not your quota. From this version the proxy never rejects on quota itself: a 429 only surfaces when the upstream — and every relay in your pool — genuinely is rate-limited, and that response now points you at `/freeflow deploy` to add relay egress.
9
+
10
+ ### Validation
11
+ - TypeScript typecheck passed cleanly (`tsc --noEmit`) on Windows and Ubuntu Linux (`acerblue-local`).
12
+ - Full test suite: Windows 302 tests (301 pass + 1 Linux-only skip), `acerblue-local` 302/302 pass, including new regressions locking the guidance hint onto genuine upstream 429s (direct path and exhausted relay pool).
13
+ - Sandboxed stress harness 7/7 on `acerblue-local`; extension smoke load green on both machines.
14
+
5
15
  ## 1.9.8 - 2026-09-06
6
16
 
7
17
  ### Fixes
package/README.md CHANGED
@@ -347,7 +347,6 @@ src/
347
347
  ├── proxy.ts # local proxy server (127.0.0.1:28180)
348
348
  ├── relay.ts # relay selection & round-robin
349
349
  ├── relay-state.ts # relay pool state, health tracking
350
- ├── rate-limiter.ts # in-memory sliding rate limiter (200/day, 200/hour)
351
350
  ├── stream-pipe.ts # SSE stream piping & truncation resilience
352
351
  ├── commands.ts # /freeflow CLI subcommands
353
352
  ├── deploy.ts # guided relay deploy (vercel/cloudflare/deno)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.9.8",
4
+ "version": "1.9.9",
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/config.ts CHANGED
@@ -7,7 +7,6 @@ import { homedir } from "node:os";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { readFileSync } from "node:fs";
10
- import type { Upstream } from "./types.ts";
11
10
 
12
11
  // Package version — stale-daemon detection in the shared-port reuse path.
13
12
  let PKG_VERSION = "0.0.0";
@@ -68,12 +67,6 @@ export const CATALOG_CACHE_TTL_MS = 86_400_000; // 24 hours — delegate to host
68
67
  export const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10MB per file
69
68
  export const LOG_MAX_FILES = 10; // 10 archived + current ≈ 110MB max (≈100MB per your request, rotated, not single 100MB blob)
70
69
 
71
- // ── Rate Limit Maxima ───────────────────────────────────────────────
72
- export const RATE_LIMIT_MAX: Record<Upstream, number> = {
73
- opencode: 200, // public free quota: requests per UTC day per IP
74
- kilo: 200, // documented gateway quota: requests per 1-hour window per IP
75
- };
76
-
77
70
  // ── Whitelists & Security ───────────────────────────────────────────
78
71
  export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&= %]*$/;
79
72
  export const PATH_TRAVERSAL_PATTERN = /\.\./;
package/src/index.ts CHANGED
@@ -43,7 +43,6 @@ import type {
43
43
  export * from "./types.ts";
44
44
  export * from "./config.ts";
45
45
  export * from "./logger.ts";
46
- export * from "./rate-limiter.ts";
47
46
  export * from "./models.ts";
48
47
  export * from "./catalog.ts";
49
48
  export * from "./relay-state.ts";
package/src/proxy.ts CHANGED
@@ -32,11 +32,9 @@ import {
32
32
  import { isDebugEnabled, log } from "./logger.ts";
33
33
  import { KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
34
34
  // normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
35
- import { checkRateLimit } from "./rate-limiter.ts";
36
35
  import { relayFetch } from "./relay.ts";
37
36
  import { getActiveRelayState } from "./relay-state.ts";
38
37
  import { pipeUpstreamStream } from "./stream-pipe.ts";
39
- import type { Upstream } from "./types.ts";
40
38
 
41
39
 
42
40
  let shutdownShouldExit = false;
@@ -45,8 +43,9 @@ export function setShutdownShouldExit(v: boolean): void {
45
43
  }
46
44
 
47
45
  /**
48
- * Direct-mode 429 hint throttle: the guidance hint is emitted at most once per
49
- * 10 minutes per process so repeated rate-limit responses don't spam clients.
46
+ * Natural-429 hint throttle: the deploy guidance hint is attached to upstream
47
+ * 429 passthroughs at most once per 10 minutes per process so repeated
48
+ * rate-limit responses don't spam clients.
50
49
  */
51
50
  let last429HintAt = 0;
52
51
  function shouldShow429Hint(): boolean {
@@ -58,6 +57,26 @@ function shouldShow429Hint(): boolean {
58
57
  /** Test-only: reset 429 hint throttle */
59
58
  export function _reset429HintForTest(): void { last429HintAt = 0; }
60
59
 
60
+ /** Deploy guidance attached to a natural upstream 429 once the throttle allows. */
61
+ const RATE_LIMIT_HINT =
62
+ "Shared free-tier IP quota reached. Add your own relay egress: /freeflow deploy (Vercel 1M/mo recommended)";
63
+
64
+ /**
65
+ * Attach the deploy hint to a natural upstream 429 JSON body. Anything else —
66
+ * non-429 statuses, non-JSON bodies — passes through untouched without
67
+ * consuming the throttle slot.
68
+ */
69
+ function withRateLimitHint(status: number, data: string): string {
70
+ if (status !== 429) return data;
71
+ try {
72
+ const parsed: unknown = JSON.parse(data);
73
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && shouldShow429Hint()) {
74
+ return JSON.stringify({ ...(parsed as Record<string, unknown>), hint: RATE_LIMIT_HINT });
75
+ }
76
+ } catch {}
77
+ return data;
78
+ }
79
+
61
80
  /**
62
81
  * Extract client IP address from incoming HTTP request.
63
82
  */
@@ -504,26 +523,8 @@ export function startProxy(
504
523
  }
505
524
  } catch {}
506
525
 
507
- const upstream: Upstream = isKilo ? "kilo" : "opencode";
508
526
  const isStream = parsedBody?.stream === true;
509
527
 
510
- // Seamless sub-agent rate-limit: when relay pool is active, bypass
511
- // local per-IP quota (127.0.0.1 shared by all subagents) — upstream
512
- // quota is per-egress-IP and relayFetch already rolls on 429 across
513
- // 7 candidates until a response succeeds. Without this, parallel
514
- // subagents sharing the daemon would hit local 429 before relay failover.
515
- const relayPreview = getActiveRelayState();
516
- const willUseRelay = relayPreview.enabled && Boolean(relayPreview.url || relayPreview.relays.length > 0);
517
- if (!willUseRelay && !checkRateLimit(clientIP, upstream)) {
518
- const body: Record<string, unknown> = { error: "rate limit exceeded" };
519
- if (shouldShow429Hint()) {
520
- body.hint = "Shared free-tier IP quota reached. Add your own relay egress: /freeflow deploy (Vercel 1M/mo recommended)";
521
- }
522
- res.writeHead(429, { "content-type": "application/json" });
523
- res.end(JSON.stringify(body));
524
- return;
525
- }
526
-
527
528
  // Stale-registration guard: responses-only models (muse-spark-*) must
528
529
  // reach upstream via /v1/responses. A chat/completions request for one
529
530
  // means the host still holds a pre-fix provider registration (stale
@@ -593,7 +594,7 @@ export function startProxy(
593
594
  undefined,
594
595
  );
595
596
  } else {
596
- const data = await response.text();
597
+ const data = withRateLimitHint(response.status, await response.text());
597
598
  const ct =
598
599
  response.headers.get("content-type") || "application/json";
599
600
  res.writeHead(response.status, { "content-type": ct });
@@ -672,7 +673,7 @@ export function startProxy(
672
673
  if (!response.ok) {
673
674
  log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via relay`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url }, reqId);
674
675
  }
675
- const data = await response.text();
676
+ const data = withRateLimitHint(response.status, await response.text());
676
677
  const ct =
677
678
  response.headers.get("content-type") ||
678
679
  "application/json";
@@ -733,6 +734,17 @@ export function startProxy(
733
734
  log("warn", `direct upstream ${upstreamRes.status} for model ${String(parsedBody?.model ?? "?")} ${target.pathname}`, { status: upstreamRes.status, model: parsedBody?.model, path: target.pathname }, reqId);
734
735
  }
735
736
 
737
+ // Natural 429: every relay plus direct is rate-limited — buffer the
738
+ // JSON error and attach the deploy hint instead of piping it
739
+ // through as a stream body.
740
+ if (upstreamRes.status === 429) {
741
+ const data = withRateLimitHint(429, await upstreamRes.text());
742
+ const ct429 = upstreamRes.headers.get("content-type") || "application/json";
743
+ res.writeHead(429, { "content-type": ct429 });
744
+ res.end(data);
745
+ return;
746
+ }
747
+
736
748
  const outHeaders: Record<string, string> = {};
737
749
  for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
738
750
  const v = upstreamRes.headers.get(h);
package/src/types.ts CHANGED
@@ -76,19 +76,6 @@ export interface CatalogCacheData {
76
76
  etag?: string;
77
77
  }
78
78
 
79
- export interface RateLimitEntry {
80
- count: number;
81
- resetAt: number;
82
- }
83
-
84
- export interface RateLimitStatus {
85
- allowed: boolean;
86
- remaining: number;
87
- resetAt: number;
88
- limit: number;
89
- count: number;
90
- }
91
-
92
79
  // ── Extension API & UI Types (compatible with @earendil-works/pi-coding-agent) ──
93
80
 
94
81
  export interface ExtensionUIContext {
@@ -1,148 +0,0 @@
1
- /**
2
- * Memory-safe sliding rate limiter for pi-freeflow
3
- * Enforces:
4
- * - OpenCode Zen: 200 requests per UTC day per IP
5
- * - KiloCode Gateway: 200 requests per 1-hour window per IP
6
- */
7
-
8
- import { RATE_LIMIT_MAX } from "./config.ts";
9
- import type { RateLimitEntry, RateLimitStatus, Upstream } from "./types.ts";
10
-
11
- const rateLimitMap = new Map<string, RateLimitEntry>();
12
- let lastCleanupAt = 0;
13
- const CLEANUP_INTERVAL_MS = 60_000; // 1 minute
14
- const MAX_MAP_SIZE_BEFORE_CLEANUP = 500;
15
-
16
- /**
17
- * Calculate the next reset timestamp in epoch milliseconds.
18
- */
19
- export function rateLimitResetAt(upstream: Upstream, now: number): number {
20
- if (upstream === "kilo") {
21
- return now + 60 * 60_000; // 1 hour sliding window
22
- }
23
- // OpenCode resets at 00:00:00.000 UTC of next day
24
- const nextUtcDay = new Date(now);
25
- nextUtcDay.setUTCHours(24, 0, 0, 0);
26
- return nextUtcDay.getTime();
27
- }
28
-
29
- /**
30
- * Construct a cache key for an upstream + client IP.
31
- */
32
- export function rateLimitKey(
33
- upstream: Upstream,
34
- ip: string,
35
- now: number = Date.now(),
36
- ): string {
37
- const safeIp = ip.trim() || "127.0.0.1";
38
- if (upstream === "kilo") {
39
- return `kilo:${safeIp}`;
40
- }
41
- const utcDate = new Date(now).toISOString().slice(0, 10);
42
- return `opencode:${utcDate}:${safeIp}`;
43
- }
44
-
45
- /**
46
- * Purge expired rate limit buckets to guarantee bounded memory usage.
47
- * Returns the number of evicted entries.
48
- */
49
- export function cleanupRateLimits(now: number = Date.now()): number {
50
- let evicted = 0;
51
- for (const [key, entry] of rateLimitMap.entries()) {
52
- if (entry.resetAt <= now) {
53
- rateLimitMap.delete(key);
54
- evicted++;
55
- }
56
- }
57
- lastCleanupAt = now;
58
- return evicted;
59
- }
60
-
61
- /**
62
- * Trigger cleanup if interval elapsed or map has grown past the high watermark.
63
- */
64
- function maybeCleanup(now: number): void {
65
- if (
66
- now - lastCleanupAt > CLEANUP_INTERVAL_MS ||
67
- rateLimitMap.size > MAX_MAP_SIZE_BEFORE_CLEANUP
68
- ) {
69
- cleanupRateLimits(now);
70
- }
71
- }
72
-
73
- /**
74
- * Check and consume a quota token for the given IP and upstream.
75
- * Returns true if request is permitted, false if rate limit exceeded.
76
- */
77
- export function checkRateLimit(
78
- ip: string,
79
- upstream: Upstream,
80
- now: number = Date.now(),
81
- ): boolean {
82
- maybeCleanup(now);
83
-
84
- const key = rateLimitKey(upstream, ip, now);
85
- const entry = rateLimitMap.get(key);
86
- const maxLimit = RATE_LIMIT_MAX[upstream] ?? 200;
87
-
88
- if (!entry || entry.resetAt <= now) {
89
- rateLimitMap.set(key, {
90
- count: 1,
91
- resetAt: rateLimitResetAt(upstream, now),
92
- });
93
- return true;
94
- }
95
-
96
- if (entry.count >= maxLimit) {
97
- return false;
98
- }
99
-
100
- entry.count++;
101
- return true;
102
- }
103
-
104
- /**
105
- * Query current rate limit quota and remaining requests without mutating count.
106
- */
107
- export function getRateLimitStatus(
108
- ip: string,
109
- upstream: Upstream,
110
- now: number = Date.now(),
111
- ): RateLimitStatus {
112
- const key = rateLimitKey(upstream, ip, now);
113
- const entry = rateLimitMap.get(key);
114
- const limit = RATE_LIMIT_MAX[upstream] ?? 200;
115
-
116
- if (!entry || entry.resetAt <= now) {
117
- return {
118
- allowed: true,
119
- remaining: limit,
120
- resetAt: rateLimitResetAt(upstream, now),
121
- limit,
122
- count: 0,
123
- };
124
- }
125
-
126
- const remaining = Math.max(0, limit - entry.count);
127
- return {
128
- allowed: remaining > 0,
129
- remaining,
130
- resetAt: entry.resetAt,
131
- limit,
132
- count: entry.count,
133
- };
134
- }
135
- /**
136
- * Clear all rate limit records (primarily for testing and reset commands).
137
- */
138
- export function resetRateLimits(): void {
139
- rateLimitMap.clear();
140
- lastCleanupAt = Date.now();
141
- }
142
-
143
- /**
144
- * Get active count of entries in the rate limit table.
145
- */
146
- export function getRateLimitMapSize(): number {
147
- return rateLimitMap.size;
148
- }