mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
1 parent
7aa9bbd3be
commit
bb74071dc2
13 files changed
+409
-61
No files matched your search
+25
-18
@@ -34,16 +34,19 @@ GOOGLE_MAPS_API_KEY=
|
|||||||
# single shared key keeps working — splitting is opt-in.
|
# single shared key keeps working — splitting is opt-in.
|
||||||
# SERVER-SIDE ONLY.
|
# SERVER-SIDE ONLY.
|
||||||
GOOGLE_MAPS_SERVER_API_KEY=
|
GOOGLE_MAPS_SERVER_API_KEY=
|
||||||
# Optional: opt-in per-IP rate limit for the Google Places cost endpoint
|
# Per-IP rate limit for the Google Places cost endpoints
|
||||||
# (/api/google/nearby-places), in requests per minute.
|
# (/api/google/nearby-places + /api/google/text-search), in requests per minute.
|
||||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
# DEFAULTS TO 120, the value the Pinokio build already ships. Raise or lower it
|
||||||
# Set a positive integer to cap per-IP Places quota spend when self-hosting.
|
# with a positive integer; 60 is a sensible tightening whenever HOST is not
|
||||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
# localhost. Set it to exactly 0 to run unthrottled. An unreadable value (a
|
||||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
# typo, a stray unit) falls back to the default rather than to unlimited, so a
|
||||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set
|
# slip cannot quietly disarm the guard.
|
||||||
# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts
|
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
|
||||||
# (plus per-API quotas under APIs & Services -> Quotas).
|
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
|
||||||
# GEV_RATELIMIT_GOOGLE_PER_MIN=60
|
# 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
|
# Optional: Google Photorealistic 3D Tiles through Cesium ion, Bing world
|
||||||
# imagery, and Cesium World Terrain. The free Community plan is for eligible
|
# 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_TOKENS=3000
|
||||||
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
|
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
|
||||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
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.
|
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
|
||||||
# Set a positive integer to cap per-IP spend when self-hosting on a network.
|
# use several times over: an open tab asks for a HUD summary every 15s (4/min)
|
||||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
# and a voice session mints one token. Raise or lower it with a positive
|
||||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
# integer; set it to exactly 0 to run unthrottled. An unreadable value (a typo,
|
||||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set a
|
# a stray unit) falls back to the default rather than to unlimited, so a slip
|
||||||
# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits).
|
# cannot quietly disarm the guard.
|
||||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30
|
# 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)
|
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
||||||
# Default: oauth — requires client credentials below
|
# Default: oauth — requires client credentials below
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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`
|
- 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
|
needs the key in its MCP server's panel page, and refuses Provider
|
||||||
Settings, credential and model endpoints, `/mcp` and development server
|
Settings, credential and model endpoints, `/mcp` and development server
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ Everything above is the deliberately cheap baseline — enough to get a real tas
|
|||||||
|
|
||||||
### 🔒 Sharing an instance
|
### 🔒 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
|
Provider Settings is disabled when the server is shared, so remote users cannot
|
||||||
access the key-entry panel.
|
access the key-entry panel.
|
||||||
|
|||||||
+1
-1
@@ -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).
|
- **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.
|
- **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.
|
- **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 LAN and Cloudflare sharing are refused.** The current supported
|
||||||
Pinokio release re-reads sharing state when an app registers its Open URL and
|
Pinokio release re-reads sharing state when an app registers its Open URL and
|
||||||
|
|||||||
+12
-1
@@ -915,10 +915,21 @@ errors.
|
|||||||
## Places and CCTV request bounds
|
## Places and CCTV request bounds
|
||||||
|
|
||||||
With a Google key configured, nearby and text search reject missing, blank,
|
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
|
request. Text search also requires a nonblank query. Keyless requests retain
|
||||||
their `configured: false` response.
|
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
|
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
|
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
|
streaming; a separate 30-second idle deadline then bounds the gap between
|
||||||
|
|||||||
@@ -893,5 +893,6 @@
|
|||||||
"src/view/index.test.mjs",
|
"src/view/index.test.mjs",
|
||||||
"src/tools/views.test.mjs",
|
"src/tools/views.test.mjs",
|
||||||
"src/app/embed.test.mjs",
|
"src/app/embed.test.mjs",
|
||||||
"src/tools/globePanel.test.mjs"
|
"src/tools/globePanel.test.mjs",
|
||||||
|
"src/costEndpointThrottle.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -2,24 +2,49 @@ import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
|||||||
export { 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).
|
* Resolve the per-IP, per-minute cap for a cost-bearing proxy from its env var.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* @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.
|
* @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited.
|
||||||
*/
|
*/
|
||||||
export function makeOptInRateLimiter(envValue) {
|
export function makeCostRateLimiter(envValue, defaultPerMin) {
|
||||||
const max = Number(envValue);
|
const max = resolvePerMinuteCap(envValue, defaultPerMin);
|
||||||
if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited
|
if (max <= 0) return null; // explicit opt-out
|
||||||
return makeRateLimiter({
|
return makeRateLimiter({
|
||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
max: Math.floor(max),
|
max,
|
||||||
globalMax: Math.floor(max) * 20,
|
globalMax: max * 20,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
keylessHudSummaryResponse,
|
keylessHudSummaryResponse,
|
||||||
} from '../../../src/hudSummaryResponse.js';
|
} 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 { readRequestBody } from '../common/request.js';
|
||||||
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
|
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
|
||||||
|
|
||||||
@@ -46,10 +46,10 @@ async function handleHudSummary(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
|
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||||
// fallback has no provider cost and resolves above without consuming a
|
// Keyless HUD fallback has no provider cost and resolves above without
|
||||||
// paid-endpoint quota slot.
|
// consuming a paid-endpoint quota slot.
|
||||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await readRequestBody(req, 64 * 1024);
|
const body = await readRequestBody(req, 64 * 1024);
|
||||||
|
|||||||
@@ -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
|
// 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
|
// (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;
|
// 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;
|
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() {
|
function openAiRateLimiter() {
|
||||||
if (_openAiRateLimiter === undefined)
|
if (_openAiRateLimiter === undefined)
|
||||||
_openAiRateLimiter = makeOptInRateLimiter(
|
_openAiRateLimiter = makeCostRateLimiter(
|
||||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
|
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
|
||||||
|
OPENAI_DEFAULT_PER_MIN,
|
||||||
);
|
);
|
||||||
return _openAiRateLimiter;
|
return _openAiRateLimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply an opt-in limiter to a request, writing a 429 when over the cap.
|
* Apply the limiter to a request, writing a 429 when over the cap.
|
||||||
* When `limiter` is null (unlimited, the default) this is a no-op returning
|
* When `limiter` is null — the explicit `0` opt-out — this is a no-op
|
||||||
* `true`, so the handler proceeds exactly as before.
|
* returning `true`, so the handler proceeds unthrottled.
|
||||||
*
|
*
|
||||||
* @param {((key:string)=>boolean)|null} limiter
|
* @param {((key:string)=>boolean)|null} limiter
|
||||||
* @param {import('http').IncomingMessage} req
|
* @param {import('http').IncomingMessage} req
|
||||||
* @param {import('http').ServerResponse} res
|
* @param {import('http').ServerResponse} res
|
||||||
* @returns {boolean} True if the request may proceed; false if a 429 was sent.
|
* @returns {boolean} True if the request may proceed; false if a 429 was sent.
|
||||||
*/
|
*/
|
||||||
function enforceOptInRateLimit(limiter, req, res) {
|
function enforceRateLimit(limiter, req, res) {
|
||||||
if (!limiter) return true; // unlimited (default) — no behavior change
|
if (!limiter) return true; // explicit 0 opt-out — unthrottled
|
||||||
if (limiter(clientKey(req))) return true;
|
if (limiter(clientKey(req))) return true;
|
||||||
res.statusCode = 429;
|
res.statusCode = 429;
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
@@ -36,4 +49,4 @@ function enforceOptInRateLimit(limiter, req, res) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { enforceOptInRateLimit, openAiRateLimiter };
|
export { enforceRateLimit, openAiRateLimiter, OPENAI_DEFAULT_PER_MIN };
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||||
import {
|
import {
|
||||||
resolveVoiceModel,
|
resolveVoiceModel,
|
||||||
isKnownVoiceTier,
|
isKnownVoiceTier,
|
||||||
@@ -31,8 +31,8 @@ function createRealtimeTokenHandler({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
|
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||||
|
|
||||||
const apiKey = resolveApiKey();
|
const apiKey = resolveApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export {
|
|||||||
export { installRouteMiddleware } from './places/routes.js';
|
export { installRouteMiddleware } from './places/routes.js';
|
||||||
export {
|
export {
|
||||||
makeRateLimiter,
|
makeRateLimiter,
|
||||||
makeOptInRateLimiter,
|
makeCostRateLimiter,
|
||||||
clientKey,
|
clientKey,
|
||||||
} from './common/rate-limit.js';
|
} from './common/rate-limit.js';
|
||||||
export { haversineKm } from './common/geo.js';
|
export { haversineKm } from './common/geo.js';
|
||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
googleServerApiKey,
|
googleServerApiKey,
|
||||||
keylessGooglePlacesResponse,
|
keylessGooglePlacesResponse,
|
||||||
} from './google-key.js';
|
} 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 { admitSameSite } from '../common/same-site.js';
|
||||||
import {
|
import {
|
||||||
projectNearbyPlaces,
|
projectNearbyPlaces,
|
||||||
@@ -10,14 +10,27 @@ import {
|
|||||||
} from '../../../src/data/placeProviderPayloads.js';
|
} from '../../../src/data/placeProviderPayloads.js';
|
||||||
|
|
||||||
// Construct lazily after the standalone environment has loaded.
|
// 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;
|
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() {
|
function googleRateLimiter() {
|
||||||
if (_googleRateLimiter === undefined)
|
if (_googleRateLimiter === undefined)
|
||||||
_googleRateLimiter = makeOptInRateLimiter(
|
_googleRateLimiter = makeCostRateLimiter(
|
||||||
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
|
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
|
||||||
|
GOOGLE_DEFAULT_PER_MIN,
|
||||||
);
|
);
|
||||||
return _googleRateLimiter;
|
return _googleRateLimiter;
|
||||||
}
|
}
|
||||||
@@ -84,7 +97,7 @@ export function googlePlacesContextProxy({
|
|||||||
}
|
}
|
||||||
const { latitude, longitude } = coordinates;
|
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
|
// Inlined (not the shared helper) so the 429 body keeps this endpoint's
|
||||||
// `places: []` contract that the client expects on every error response.
|
// `places: []` contract that the client expects on every error response.
|
||||||
const _grl = googleRateLimiter();
|
const _grl = googleRateLimiter();
|
||||||
@@ -204,7 +217,7 @@ export function googlePlacesContextProxy({
|
|||||||
}
|
}
|
||||||
const { latitude, longitude } = coordinates;
|
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: []`
|
// Inlined (like nearby-places) so the 429 body keeps the `places: []`
|
||||||
// contract the client expects on every error response.
|
// contract the client expects on every error response.
|
||||||
const _grl = googleRateLimiter();
|
const _grl = googleRateLimiter();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user