pi-freeflow 1.9.7 → 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 +23 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +48 -18
- package/src/config.ts +0 -7
- package/src/index.ts +0 -1
- package/src/proxy.ts +51 -23
- package/src/types.ts +0 -13
- package/src/rate-limiter.ts +0 -148
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
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
|
+
|
|
15
|
+
## 1.9.8 - 2026-09-06
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
- **Stale model list heals itself (follow-up to #6).** If your saved model list predates a newly added model, the background refresh now repairs the entry (correct endpoint and details) instead of sending requests to the wrong address — no manual `/freeflow refresh` or cache deletion needed.
|
|
19
|
+
- **Paid models stay out of the picker even from old saved lists.** Every read of the saved model list now drops non-free entries, so models requiring an API key cannot linger after an upgrade.
|
|
20
|
+
- **Old saved lists without a sync marker now re-sync once.** A saved list that could never trigger a network check now performs one plain revalidation (then syncs normally), so newly added free models appear without manual intervention. Missing or corrupt lists still fall back silently with no network call.
|
|
21
|
+
- **Upstream errors are now visible in the proxy log.** Failed upstream responses log their status code and model, and a model routed to the wrong endpoint logs the mismatch with the fix (restart Pi/OMP after upgrade).
|
|
22
|
+
|
|
23
|
+
### Validation
|
|
24
|
+
- TypeScript typecheck passed cleanly (`tsc --noEmit`).
|
|
25
|
+
- Full test suite passed on Windows (304 tests) and Ubuntu Linux (`acerblue`, 305/305 tests passed), including new regressions for the stale-cache shape from #6.
|
|
26
|
+
- Live sweep of all 26 models through a fresh install on `acerblue`: 23/26 answered on first try (both Muse Spark models via the Responses endpoint); the 3 misses are upstream per-model daily quotas (429), zero server errors.
|
|
27
|
+
|
|
5
28
|
## 1.9.7 - 2026-09-06
|
|
6
29
|
|
|
7
30
|
### Fixes
|
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ Optimized for deep reasoning, long-horizon coding & autonomous agentic workflows
|
|
|
50
50
|
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
51
51
|
| `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
|
|
52
52
|
| `ling-3.0-flash-fin-free` | Inclusion AI | **262K** (262.144) | **131K** (131.072) | `minimal … xhigh` | ❌ |
|
|
53
|
+
|
|
53
54
|
#### KiloCode Gateway (19 Models), OpenRouter Compatible
|
|
54
55
|
Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI aliases supported.
|
|
55
56
|
|
|
@@ -346,7 +347,6 @@ src/
|
|
|
346
347
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
347
348
|
├── relay.ts # relay selection & round-robin
|
|
348
349
|
├── relay-state.ts # relay pool state, health tracking
|
|
349
|
-
├── rate-limiter.ts # in-memory sliding rate limiter (200/day, 200/hour)
|
|
350
350
|
├── stream-pipe.ts # SSE stream piping & truncation resilience
|
|
351
351
|
├── commands.ts # /freeflow CLI subcommands
|
|
352
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/catalog.ts
CHANGED
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
ALL_MODELS,
|
|
21
21
|
KILO_MODEL_IDS,
|
|
22
22
|
MODEL_MAP,
|
|
23
|
-
OPENCODE_MODELS,
|
|
24
23
|
} from "./models.ts";
|
|
25
24
|
import type {
|
|
26
25
|
CatalogCacheData,
|
|
@@ -40,6 +39,39 @@ export const DEAD_MODEL_IDS = new Set<string>([
|
|
|
40
39
|
"meituan/longcat-2.0-free",
|
|
41
40
|
"laguna-s-2.1-free",
|
|
42
41
|
]);
|
|
42
|
+
/**
|
|
43
|
+
* Free-tier allowlist for anything entering the picker via network or stale disk.
|
|
44
|
+
* Upstream lists paid models alongside free ones (e.g. claude-fable-5-1,
|
|
45
|
+
* claude-opus-4-*, gemini-3-*) so a bare upstream merge leaks paid entries that
|
|
46
|
+
* fail with 401 Missing API key. Known static IDs without a free suffix
|
|
47
|
+
* (e.g. big-pickle) stay allowed via MODEL_MAP.
|
|
48
|
+
*/
|
|
49
|
+
export function isFreeCatalogId(id: string): boolean {
|
|
50
|
+
if (typeof id !== "string" || id.length === 0) return false;
|
|
51
|
+
if (DEAD_MODEL_IDS.has(id)) return false;
|
|
52
|
+
return id.includes("-free") || id.includes(":free") || id.includes("/free") || MODEL_MAP.has(id);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Purge paid/dead entries from a catalog list and repair known models against
|
|
56
|
+
* the static definitions. Stale disk caches predate the paid filter and the
|
|
57
|
+
* muse-spark-1.3 responses-api entry, so loading them verbatim replays a wrong
|
|
58
|
+
* api (chat/completions for a responses-only model -> upstream 500) until the
|
|
59
|
+
* 24h TTL expires. Repairing here self-heals on the next refresh without a
|
|
60
|
+
* reinstall.
|
|
61
|
+
*/
|
|
62
|
+
export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredModel[] {
|
|
63
|
+
const out: RegisteredModel[] = [];
|
|
64
|
+
for (const m of models) {
|
|
65
|
+
if (!m || typeof m.id !== "string" || !isFreeCatalogId(m.id)) continue;
|
|
66
|
+
const known = MODEL_MAP.get(m.id);
|
|
67
|
+
if (known) {
|
|
68
|
+
out.push({ ...known, source: m.source ?? (KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode") });
|
|
69
|
+
} else {
|
|
70
|
+
out.push(m);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
43
75
|
/**
|
|
44
76
|
* In-memory cache of currently active/available free models.
|
|
45
77
|
* Initialized with all 26 verified models for 0ms instant availability.
|
|
@@ -73,11 +105,12 @@ export function mergeCatalog(
|
|
|
73
105
|
base: RegisteredModel[],
|
|
74
106
|
fresh: RegisteredModel[],
|
|
75
107
|
): RegisteredModel[] {
|
|
76
|
-
const filteredFresh = fresh.filter((m) =>
|
|
108
|
+
const filteredFresh = fresh.filter((m) => m && typeof m.id === "string" && isFreeCatalogId(m.id));
|
|
77
109
|
const byId = new Map(base.map((m) => [m.id, m]));
|
|
78
110
|
for (const m of filteredFresh) byId.set(m.id, m);
|
|
79
|
-
//
|
|
80
|
-
|
|
111
|
+
// Sanitize the merged result so stale paid entries in a pre-fix base and
|
|
112
|
+
// stale api fields on known models never survive the merge.
|
|
113
|
+
return sanitizeCatalogModels([...byId.values()]);
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
/**
|
|
@@ -188,7 +221,7 @@ export function readCatalogCache(): CatalogCacheData | null {
|
|
|
188
221
|
if (!Array.isArray(data.models)) {
|
|
189
222
|
return null;
|
|
190
223
|
}
|
|
191
|
-
data.models = data.models
|
|
224
|
+
data.models = sanitizeCatalogModels(data.models);
|
|
192
225
|
if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
|
|
193
226
|
return data;
|
|
194
227
|
}
|
|
@@ -250,7 +283,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
250
283
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
251
284
|
const age = Date.now() - (disk.timestamp ?? 0);
|
|
252
285
|
if (!force && age < CATALOG_CACHE_TTL_MS) {
|
|
253
|
-
aliveCatalog = disk.models
|
|
286
|
+
aliveCatalog = sanitizeCatalogModels(disk.models);
|
|
254
287
|
return aliveCatalog;
|
|
255
288
|
}
|
|
256
289
|
}
|
|
@@ -271,8 +304,12 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
271
304
|
}
|
|
272
305
|
}
|
|
273
306
|
|
|
274
|
-
// Attempt
|
|
275
|
-
|
|
307
|
+
// Attempt a fetch whenever there is cache material to revalidate: conditional
|
|
308
|
+
// with If-None-Match when we have an etag, plain otherwise. A pre-fix cache
|
|
309
|
+
// file with no etag must still go live (acquiring an etag and discovering
|
|
310
|
+
// new free models) instead of serving stale indefinitely. Corrupt/missing
|
|
311
|
+
// caches (staleForEtag null) skip the network and fall through to static.
|
|
312
|
+
if (cachedEtag || force || staleForEtag) {
|
|
276
313
|
try {
|
|
277
314
|
const headers: Record<string, string> = { ...opencodeHeaders() };
|
|
278
315
|
if (cachedEtag) {
|
|
@@ -306,14 +343,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
306
343
|
}
|
|
307
344
|
}
|
|
308
345
|
if (rawList.length > 0) {
|
|
309
|
-
const freeRawList = rawList.filter((r) =>
|
|
310
|
-
if (!r || typeof r.id !== "string") return false;
|
|
311
|
-
if (DEAD_MODEL_IDS.has(r.id)) return false;
|
|
312
|
-
return (
|
|
313
|
-
r.id.includes("-free") ||
|
|
314
|
-
OPENCODE_MODELS.some((m) => m.id === r.id)
|
|
315
|
-
);
|
|
316
|
-
});
|
|
346
|
+
const freeRawList = rawList.filter((r) => r && typeof r.id === "string" && isFreeCatalogId(r.id));
|
|
317
347
|
const fresh = freeRawList.map((r) => enrichModelDef(r, "opencode"));
|
|
318
348
|
const merged = mergeCatalog(aliveCatalog, fresh);
|
|
319
349
|
aliveCatalog = merged;
|
|
@@ -345,7 +375,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
345
375
|
|
|
346
376
|
// Stale cache still better than empty — return it without network (filtered)
|
|
347
377
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
348
|
-
const filtered = disk.models
|
|
378
|
+
const filtered = sanitizeCatalogModels(disk.models);
|
|
349
379
|
if (filtered.length >= ALL_MODELS.length) {
|
|
350
380
|
aliveCatalog = filtered;
|
|
351
381
|
return aliveCatalog;
|
|
@@ -357,7 +387,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
357
387
|
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
358
388
|
const stale = JSON.parse(raw) as CatalogCacheData;
|
|
359
389
|
if (Array.isArray(stale.models) && stale.models.length > 0) {
|
|
360
|
-
const filtered = stale.models
|
|
390
|
+
const filtered = sanitizeCatalogModels(stale.models);
|
|
361
391
|
if (filtered.length >= ALL_MODELS.length) {
|
|
362
392
|
aliveCatalog = filtered;
|
|
363
393
|
return aliveCatalog;
|
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
|
@@ -30,13 +30,11 @@ import {
|
|
|
30
30
|
} from "./config.ts";
|
|
31
31
|
|
|
32
32
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
33
|
-
import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
|
|
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,18 @@ 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
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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)";
|
|
528
|
+
// Stale-registration guard: responses-only models (muse-spark-*) must
|
|
529
|
+
// reach upstream via /v1/responses. A chat/completions request for one
|
|
530
|
+
// means the host still holds a pre-fix provider registration (stale
|
|
531
|
+
// disk cache or no restart after upgrade) and upstream answers 500.
|
|
532
|
+
if (!isKilo && typeof parsedBody?.model === "string" && target.pathname.endsWith("/chat/completions")) {
|
|
533
|
+
const knownDef = MODEL_MAP.get(String(parsedBody.model));
|
|
534
|
+
if (knownDef?.api === "openai-responses") {
|
|
535
|
+
log("warn", `model ${String(parsedBody.model)} expects openai-responses but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
|
|
521
536
|
}
|
|
522
|
-
res.writeHead(429, { "content-type": "application/json" });
|
|
523
|
-
res.end(JSON.stringify(body));
|
|
524
|
-
return;
|
|
525
537
|
}
|
|
526
|
-
|
|
527
538
|
try {
|
|
528
539
|
if (isKilo && parsedBody) {
|
|
529
540
|
// Header-wait timeout + client-disconnect abort: once headers
|
|
@@ -583,7 +594,7 @@ export function startProxy(
|
|
|
583
594
|
undefined,
|
|
584
595
|
);
|
|
585
596
|
} else {
|
|
586
|
-
const data = await response.text();
|
|
597
|
+
const data = withRateLimitHint(response.status, await response.text());
|
|
587
598
|
const ct =
|
|
588
599
|
response.headers.get("content-type") || "application/json";
|
|
589
600
|
res.writeHead(response.status, { "content-type": ct });
|
|
@@ -659,7 +670,10 @@ export function startProxy(
|
|
|
659
670
|
relayState.url,
|
|
660
671
|
);
|
|
661
672
|
} else {
|
|
662
|
-
|
|
673
|
+
if (!response.ok) {
|
|
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);
|
|
675
|
+
}
|
|
676
|
+
const data = withRateLimitHint(response.status, await response.text());
|
|
663
677
|
const ct =
|
|
664
678
|
response.headers.get("content-type") ||
|
|
665
679
|
"application/json";
|
|
@@ -716,6 +730,20 @@ export function startProxy(
|
|
|
716
730
|
clearTimeout(timeoutId);
|
|
717
731
|
res.off("close", onClientClose);
|
|
718
732
|
req.off("error", onReqError);
|
|
733
|
+
if (upstreamRes.status >= 400) {
|
|
734
|
+
log("warn", `direct upstream ${upstreamRes.status} for model ${String(parsedBody?.model ?? "?")} ${target.pathname}`, { status: upstreamRes.status, model: parsedBody?.model, path: target.pathname }, reqId);
|
|
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
|
+
}
|
|
719
747
|
|
|
720
748
|
const outHeaders: Record<string, string> = {};
|
|
721
749
|
for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
|
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
|
-
}
|