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 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.7",
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) => !DEAD_MODEL_IDS.has(m.id));
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
- // Ensure no dead IDs survive even if base was stale
80
- return [...byId.values()].filter((m) => !DEAD_MODEL_IDS.has(m.id));
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.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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 conditional fetch with If-None-Match when we have an etag
275
- if (cachedEtag || force) {
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.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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
@@ -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
@@ -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
- * 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,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
- // 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)";
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
- const data = await response.text();
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 {
@@ -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
- }