fix(server): throttle the cost-bearing proxies by default

The OpenAI endpoints (/api/realtime/token, /api/openai/hud-summary) now
allow 30 requests per minute per client IP and the Google Places endpoints
120, the caps the Pinokio build already ships. GEV_RATELIMIT_OPENAI_PER_MIN
and GEV_RATELIMIT_GOOGLE_PER_MIN still override them and exactly 0 disables
them; an unreadable value falls back to the default rather than to
unlimited.

Salvaged from #683 onto current main.

Co-authored-by: Sameh Khamis <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
authored and Sameh Khamis committed 2026-10-02 19:57:29 -07:00
1 parent 7aa9bbd3be
commit bb74071dc2
13 files changed
+409 -61

No files matched your search

+25 -18
View File
@@ -34,16 +34,19 @@ GOOGLE_MAPS_API_KEY=
# single shared key keeps working — splitting is opt-in.
# SERVER-SIDE ONLY.
GOOGLE_MAPS_SERVER_API_KEY=
# Optional: opt-in per-IP rate limit for the Google Places cost endpoint
# (/api/google/nearby-places), in requests per minute.
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
# Set a positive integer to cap per-IP Places quota spend when self-hosting.
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
# process-local guard (in-memory, resets on restart; over-limit requests get a
# sanitized 429) — it is NOT a billing cap. For hard spend protection set
# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts
# (plus per-API quotas under APIs & Services -> Quotas).
# GEV_RATELIMIT_GOOGLE_PER_MIN=60
# Per-IP rate limit for the Google Places cost endpoints
# (/api/google/nearby-places + /api/google/text-search), in requests per minute.
# DEFAULTS TO 120, the value the Pinokio build already ships. Raise or lower it
# with a positive integer; 60 is a sensible tightening whenever HOST is not
# localhost. Set it to exactly 0 to run unthrottled. An unreadable value (a
# typo, a stray unit) falls back to the default rather than to unlimited, so a
# slip cannot quietly disarm the guard.
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
# spend protection set provider-side budget alerts: Google Cloud Console ->
# Billing -> Budgets & alerts (plus per-API quotas under APIs & Services ->
# Quotas).
# GEV_RATELIMIT_GOOGLE_PER_MIN=60 # uncomment to tighten from the default 120
# Optional: Google Photorealistic 3D Tiles through Cesium ion, Bing world
# imagery, and Cesium World Terrain. The free Community plan is for eligible
@@ -65,15 +68,19 @@ OPENAI_REALTIME_REASONING_EFFORT=low
OPENAI_REALTIME_CONTEXT_TOKENS=3000
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Optional: opt-in per-IP rate limit for the OpenAI cost endpoints
# Per-IP rate limit for the OpenAI cost endpoints
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
# Set a positive integer to cap per-IP spend when self-hosting on a network.
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
# process-local guard (in-memory, resets on restart; over-limit requests get a
# sanitized 429) — it is NOT a billing cap. For hard spend protection set a
# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits).
# GEV_RATELIMIT_OPENAI_PER_MIN=30
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
# use several times over: an open tab asks for a HUD summary every 15s (4/min)
# and a voice session mints one token. Raise or lower it with a positive
# integer; set it to exactly 0 to run unthrottled. An unreadable value (a typo,
# a stray unit) falls back to the default rather than to unlimited, so a slip
# cannot quietly disarm the guard.
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
# Limits (usage limits).
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
# Default: oauth — requires client credentials below
+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
- Throttle the cost-bearing proxies by default instead of on request. The
OpenAI endpoints (`/api/realtime/token`, `/api/openai/hud-summary`) now allow
30 requests per minute per client IP and the Google Places endpoints
(`/api/google/nearby-places`, `/api/google/text-search`) 120 — the caps the
Pinokio build already ships, so the packaged app is unaffected and only an
unconfigured server changes, from unlimited to what the product already runs
with. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` still
override the caps, and exactly `0` disables them; a value that cannot be read
as a number now falls back to the default rather than to unlimited, so a typo
cannot silently disarm the guard (daikaginza, #683).
- Tighten the local MCP server and embed mode. The panel's `panel_request`
needs the key in its MCP server's panel page, and refuses Provider
Settings, credential and model endpoints, `/mcp` and development server
+1 -1
View File
@@ -512,7 +512,7 @@ Everything above is the deliberately cheap baseline — enough to get a real tas
### 🔒 Sharing an instance
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Per-IP throttles on the cost-bearing endpoints are on by default (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example` to tune them), but before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
Provider Settings is disabled when the server is shared, so remote users cannot
access the key-entry panel.
+1 -1
View File
@@ -80,7 +80,7 @@ The dev server is a **key broker**: every server-side key above is spendable by
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks).
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
- **App-level throttles (opt-in):** `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). They are **per-IP, process-local, in-memory guards** — they reset on restart and are **not billing caps**.
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
Pinokio release re-reads sharing state when an app registers its Open URL and
+12 -1
View File
@@ -915,10 +915,21 @@ errors.
## Places and CCTV request bounds
With a Google key configured, nearby and text search reject missing, blank,
non-numeric and out-of-range coordinates before the opt-in limiter and upstream
non-numeric and out-of-range coordinates before the per-IP limiter and upstream
request. Text search also requires a nonblank query. Keyless requests retain
their `configured: false` response.
The cost-bearing proxies are throttled per client IP without configuration:
`/api/realtime/token` and `/api/openai/hud-summary` share 30 requests per
minute per IP, `/api/google/nearby-places` and `/api/google/text-search` share
120 — the caps the Pinokio build already sets, so the packaged app is
unaffected. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` override
those; exactly `0` disables the limiter, while a value that cannot be read as a
number falls back to the default rather than to unlimited. Over-limit requests
receive a sanitized `429` with `Retry-After: 5` and never reach the provider.
The client key is the socket peer address only — a forwarded-for header is not
trusted, so a proxied deployment shares one bucket per upstream hop.
CCTV media waits at most 15 seconds for upstream response headers and returns
504 on timeout. Its timer stops when headers arrive, so live bodies can continue
streaming; a separate 30-second idle deadline then bounds the gap between
+2 -1
View File
@@ -893,5 +893,6 @@
"src/view/index.test.mjs",
"src/tools/views.test.mjs",
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs"
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs"
]
+38 -13
View File
@@ -2,24 +2,49 @@ import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
/**
* Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
* DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this
* returns `null` and the caller skips the check entirely — a runtime no-op that
* preserves the original behavior. Only a positive integer N enables a fixed
* 60s window of N requests/IP (built lazily once, then reused so its per-IP
* window state persists across requests). The global backstop is set to a
* generous multiple of the per-IP cap so a single host can't starve the rest.
* Resolve the per-IP, per-minute cap for a cost-bearing proxy from its env var.
*
* @param {string|undefined} envValue - Raw env value (requests/min/IP).
* The default is ON. An unset or blank value takes `defaultPerMin`; a positive
* integer overrides it; an explicit `0` disables the limiter for deployments
* that genuinely want no throttle.
*
* A value that cannot be read as a number — a typo, a stray unit, a negative —
* also takes the default rather than disabling the guard. That asymmetry is the
* point: `GEV_RATELIMIT_OPENAI_PER_MIN=3O` should not quietly hand an exposed
* server's API key to whoever asks. Removing the throttle stays possible, but
* only by saying `0`, which cannot be typed by accident.
*
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
* @returns {number} Requests/min/IP; `0` means unlimited.
*/
export function resolvePerMinuteCap(envValue, defaultPerMin) {
const raw = typeof envValue === 'string' ? envValue.trim() : envValue;
if (raw === undefined || raw === null || raw === '') return defaultPerMin;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return defaultPerMin;
return Math.floor(parsed); // includes the explicit 0 = unlimited opt-out
}
/**
* Per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
* Returns `null` only when the cap resolves to `0` (the explicit opt-out), in
* which case the caller skips the check entirely. Otherwise a fixed 60s window
* of N requests/IP, built lazily once and reused so its per-IP window state
* persists across requests. The global backstop is a generous multiple of the
* per-IP cap so a single host can't starve the rest.
*
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
* @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited.
*/
export function makeOptInRateLimiter(envValue) {
const max = Number(envValue);
if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited
export function makeCostRateLimiter(envValue, defaultPerMin) {
const max = resolvePerMinuteCap(envValue, defaultPerMin);
if (max <= 0) return null; // explicit opt-out
return makeRateLimiter({
windowMs: 60_000,
max: Math.floor(max),
globalMax: Math.floor(max) * 20,
max,
globalMax: max * 20,
});
}
+5 -5
View File
@@ -2,7 +2,7 @@ import {
HUD_SUMMARY_INSTRUCTIONS,
keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js';
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
import { readRequestBody } from '../common/request.js';
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
@@ -46,10 +46,10 @@ async function handleHudSummary(req, res) {
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
// fallback has no provider cost and resolves above without consuming a
// paid-endpoint quota slot.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
// Keyless HUD fallback has no provider cost and resolves above without
// consuming a paid-endpoint quota slot.
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
try {
const body = await readRequestBody(req, 64 * 1024);
+24 -11
View File
@@ -1,33 +1,46 @@
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
/**
* Requests/min/IP applied when GEV_RATELIMIT_OPENAI_PER_MIN is unset. Both
* places that already name a value agree on it: `.env.example` recommends 30
* and the Pinokio build ships 30 (pinokio/_ENVIRONMENT), so the packaged app
* is unaffected and only an unconfigured server changes. The HUD asks for a
* summary every 15s (HUD_SUMMARY_INTERVAL_MS), so an open tab spends 4/min and
* a voice session adds one token mint; the cap clears ordinary use several
* times over and bites only a caller working the endpoint far harder than the
* app does.
*/
const OPENAI_DEFAULT_PER_MIN = 30;
// Built LAZILY on first request, NOT at module load: `.env` values are applied to process.env later
// (the plugin config hook calls loadEnv → process.env, AFTER this module is imported), so reading
// process.env here at import time would always see them unset and silently stay unlimited even when
// process.env here at import time would always see them unset and fall back to the default even when
// configured via .env. Building on first request (like the OPENAI_API_KEY reads) sees the loaded env;
// the result is cached so the limiter's per-IP window state persists. `null` = unlimited (default).
// the result is cached so the limiter's per-IP window state persists. `null` = the explicit 0 opt-out.
let _openAiRateLimiter;
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
/** OpenAI cost endpoints (realtime/token + hud-summary). Null only when set to 0. */
function openAiRateLimiter() {
if (_openAiRateLimiter === undefined)
_openAiRateLimiter = makeOptInRateLimiter(
_openAiRateLimiter = makeCostRateLimiter(
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
OPENAI_DEFAULT_PER_MIN,
);
return _openAiRateLimiter;
}
/**
* Apply an opt-in limiter to a request, writing a 429 when over the cap.
* When `limiter` is null (unlimited, the default) this is a no-op returning
* `true`, so the handler proceeds exactly as before.
* Apply the limiter to a request, writing a 429 when over the cap.
* When `limiter` is null — the explicit `0` opt-out — this is a no-op
* returning `true`, so the handler proceeds unthrottled.
*
* @param {((key:string)=>boolean)|null} limiter
* @param {import('http').IncomingMessage} req
* @param {import('http').ServerResponse} res
* @returns {boolean} True if the request may proceed; false if a 429 was sent.
*/
function enforceOptInRateLimit(limiter, req, res) {
if (!limiter) return true; // unlimited (default) — no behavior change
function enforceRateLimit(limiter, req, res) {
if (!limiter) return true; // explicit 0 opt-out — unthrottled
if (limiter(clientKey(req))) return true;
res.statusCode = 429;
res.setHeader('Content-Type', 'application/json');
@@ -36,4 +49,4 @@ function enforceOptInRateLimit(limiter, req, res) {
return false;
}
export { enforceOptInRateLimit, openAiRateLimiter };
export { enforceRateLimit, openAiRateLimiter, OPENAI_DEFAULT_PER_MIN };
+3 -3
View File
@@ -1,4 +1,4 @@
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
import {
resolveVoiceModel,
isKnownVoiceTier,
@@ -31,8 +31,8 @@ function createRealtimeTokenHandler({
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
const apiKey = resolveApiKey();
if (!apiKey) {
+1 -1
View File
@@ -7,7 +7,7 @@ export {
export { installRouteMiddleware } from './places/routes.js';
export {
makeRateLimiter,
makeOptInRateLimiter,
makeCostRateLimiter,
clientKey,
} from './common/rate-limit.js';
export { haversineKm } from './common/geo.js';
+19 -6
View File
@@ -2,7 +2,7 @@ import {
googleServerApiKey,
keylessGooglePlacesResponse,
} from './google-key.js';
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
import { admitSameSite } from '../common/same-site.js';
import {
projectNearbyPlaces,
@@ -10,14 +10,27 @@ import {
} from '../../../src/data/placeProviderPayloads.js';
// Construct lazily after the standalone environment has loaded.
// undefined = not built yet; null = unlimited; fn = active limiter
// undefined = not built yet; null = the explicit 0 opt-out; fn = active limiter
let _googleRateLimiter;
/** Google cost endpoint (nearby-places). Null = unlimited (default). */
/**
* Requests/min/IP applied when GEV_RATELIMIT_GOOGLE_PER_MIN is unset. This is
* the value the Pinokio build already ships (pinokio/_ENVIRONMENT), so the
* packaged app keeps behaving exactly as it does today and only an
* unconfigured server changes — from unlimited to what the product already
* runs with. Places calls are user-driven (a search, a nearby lookup, an
* installation probe) rather than polled, so this sits far above what the app
* generates and bites only a caller enumerating the endpoint. `.env.example`
* suggests a tighter 60 when the host is not localhost; that still applies.
*/
export const GOOGLE_DEFAULT_PER_MIN = 120;
/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */
function googleRateLimiter() {
if (_googleRateLimiter === undefined)
_googleRateLimiter = makeOptInRateLimiter(
_googleRateLimiter = makeCostRateLimiter(
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
GOOGLE_DEFAULT_PER_MIN,
);
return _googleRateLimiter;
}
@@ -84,7 +97,7 @@ export function googlePlacesContextProxy({
}
const { latitude, longitude } = coordinates;
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
// Inlined (not the shared helper) so the 429 body keeps this endpoint's
// `places: []` contract that the client expects on every error response.
const _grl = googleRateLimiter();
@@ -204,7 +217,7 @@ export function googlePlacesContextProxy({
}
const { latitude, longitude } = coordinates;
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
// Inlined (like nearby-places) so the 429 body keeps the `places: []`
// contract the client expects on every error response.
const _grl = googleRateLimiter();
+267
View File
@@ -0,0 +1,267 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Readable } from 'node:stream';
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import {
makeCostRateLimiter,
resolvePerMinuteCap,
} from '../server/providers/common/rate-limit.js';
import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js';
import { handleHudSummary } from '../server/providers/openai/hud-summary.js';
import {
googlePlacesContextProxy,
GOOGLE_DEFAULT_PER_MIN,
} from '../server/providers/places/google.js';
// Both limiters are cached at module scope on first use, so the env value that
// matters is the one in place when a route is first exercised — restoring the
// variable afterwards does not rebuild the limiter. Each route is therefore
// driven by exactly one test here; a second test wanting a different cap would
// need its own process, not just a different env value.
/** Set an env var for one test and put the old value back afterwards. */
function env(t, name, value) {
const old = process.env[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
t.after(() => {
if (old === undefined) delete process.env[name];
else process.env[name] = old;
});
}
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
restart: async () => {},
});
return routes;
}
function request(
handler,
{ url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {},
) {
return new Promise((resolve, reject) => {
const req = Readable.from([]);
Object.assign(req, {
method,
url,
headers: { host: 'localhost:4173', 'content-type': 'application/json' },
socket: { remoteAddress },
});
const headers = {};
const res = {
statusCode: 200,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
end(body = '') {
resolve({ status: this.statusCode, headers, body: String(body) });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
test('an unset throttle resolves to the documented default, not to unlimited', () => {
// The defect #16 reports: the cap was opt-in, so an unconfigured server let
// anyone who could reach it mint tokens on the operator's key without bound.
assert.equal(resolvePerMinuteCap(undefined, 30), 30);
assert.equal(resolvePerMinuteCap('', 30), 30);
assert.equal(resolvePerMinuteCap(' ', 30), 30);
assert.ok(
typeof makeCostRateLimiter(undefined, 30) === 'function',
'an unconfigured cost endpoint must still be throttled',
);
});
test('an explicit 0 is the documented way to run unthrottled', () => {
assert.equal(resolvePerMinuteCap('0', 30), 0);
assert.equal(
makeCostRateLimiter('0', 30),
null,
'operators who mean "no throttle" must still be able to say so',
);
});
test('a configured value wins, and a fractional one floors', () => {
assert.equal(resolvePerMinuteCap('45', 30), 45);
assert.equal(resolvePerMinuteCap(45, 30), 45);
assert.equal(resolvePerMinuteCap('30.7', 30), 30);
});
test('an unreadable value keeps the guard instead of removing it', () => {
// `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces.
// Reading either as "unlimited" would disarm the spend guard on a typo —
// the opposite of what the operator was reaching for when they typed it.
for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) {
assert.equal(
resolvePerMinuteCap(typo, 30),
30,
`${typo} must fall back to the default, not to unlimited`,
);
}
});
test('the default cap clears the app’s own demand with room to spare', () => {
// src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open
// tab, plus one token mint per voice session. The cap has to sit well above
// that or it would throttle the app rather than an abuser.
const hudRequestsPerMinutePerTab = 60_000 / 15_000;
assert.ok(
OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4,
`default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`,
);
});
test('the realtime token route throttles by default, and says so in a 429', async (t) => {
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstreamCalls += 1;
return Response.json({ value: 'fixture-ephemeral' });
});
const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token');
const statuses = [];
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) {
statuses.push((await request(handler)).status);
}
assert.deepEqual(
statuses.slice(0, OPENAI_DEFAULT_PER_MIN),
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
'the default must not throttle ordinary use',
);
const overLimit = await request(handler);
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
assert.equal(
overLimit.body.includes('fixture-upstream-secret'),
false,
'a throttled reply must not leak the server-held key',
);
assert.ok(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
);
});
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstreamCalls += 1;
return Response.json({ output_text: 'five word hud summary here' });
});
// Its own client address: the limiter is cached at module scope and shared
// across the OpenAI routes, so the realtime case above has already spent
// 127.0.0.1's budget inside this process.
const from = '10.9.9.9';
const statuses = [];
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1)
statuses.push(
(await request(handleHudSummary, { remoteAddress: from })).status,
);
assert.deepEqual(
statuses,
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
'the default must not throttle ordinary HUD use',
);
const overLimit = await request(handleHudSummary, { remoteAddress: from });
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
assert.equal(
overLimit.body.includes('fixture-upstream-secret'),
false,
'a throttled reply must not leak the server-held key',
);
assert.ok(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
);
// The keyless HUD answer is deliberately NOT throttled: it is composed
// locally, spends nothing upstream, and capping it would only take the free
// HUD away from an unconfigured install. Same exhausted client, no key.
delete process.env.OPENAI_API_KEY;
const free = await request(handleHudSummary, { remoteAddress: from });
assert.equal(free.status, 200, 'the free path answers past a spent budget');
assert.equal(JSON.parse(free.body).configured, false);
assert.equal(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
true,
'the free path reaches no provider',
);
});
test('the Google places route throttles by default and keeps its places[] contract', async (t) => {
env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined);
let upstreamCalls = 0;
const handler = install(
googlePlacesContextProxy({
resolveApiKey: () => 'fixture-google-server-key',
fetchImpl: async () => {
upstreamCalls += 1;
return Response.json({ places: [] });
},
}),
).get('/api/google/nearby-places');
const url = '/?lat=37.77493&lon=-122.41942';
const statuses = [];
for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) {
statuses.push((await request(handler, { url, method: 'GET' })).status);
}
assert.ok(
statuses.every((s) => s === 200),
'the default must not throttle ordinary use',
);
const overLimit = await request(handler, { url, method: 'GET' });
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
// Every error response on this route carries places[]; the client reads it
// unconditionally, so a throttled reply must not be the one shape that omits it.
assert.deepEqual(JSON.parse(overLimit.body).places, []);
assert.equal(
upstreamCalls,
GOOGLE_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches Google, or it still costs money',
);
});
test('the code defaults match the caps the Pinokio build ships', () => {
// These two numbers used to live only in pinokio/_ENVIRONMENT, and the value
// this repo suggested elsewhere had already drifted away from them. Now that
// they are the server's defaults, the packaged app and an unconfigured
// checkout must agree, or a Pinokio user's throttle silently stops matching
// the one everybody else gets. If you deliberately want them to differ, this
// assertion is the place to say so.
const shipped = readPinokioEnvironment(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'pinokio',
'_ENVIRONMENT',
),
);
assert.equal(
Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN),
OPENAI_DEFAULT_PER_MIN,
);
assert.equal(
Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN),
GOOGLE_DEFAULT_PER_MIN,
);
});