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 +10 -0
- package/README.md +0 -1
- package/package.json +1 -1
- package/src/config.ts +0 -7
- package/src/index.ts +0 -1
- package/src/proxy.ts +36 -24
- package/src/types.ts +0 -13
- package/src/rate-limiter.ts +0 -148
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.
|
|
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
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
|
-
*
|
|
49
|
-
* 10 minutes per process so repeated
|
|
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 {
|
package/src/rate-limiter.ts
DELETED
|
@@ -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
|
-
}
|