diff --git a/.env.example b/.env.example index 77eb1bd8a..68fb3481a 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,7 @@ GOOGLE_MAPS_SERVER_API_KEY= # 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. +# slip cannot quietly disarm the guard. A positive value below 1 counts as 1. # 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 -> @@ -75,7 +75,7 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano # 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. +# cannot quietly disarm the guard. A positive value below 1 counts as 1. # 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 -> diff --git a/server/providers/common/rate-limit.js b/server/providers/common/rate-limit.js index 8b8bfe83a..feb602f1c 100644 --- a/server/providers/common/rate-limit.js +++ b/server/providers/common/rate-limit.js @@ -23,6 +23,9 @@ export function resolvePerMinuteCap(envValue, defaultPerMin) { if (raw === undefined || raw === null || raw === '') return defaultPerMin; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed < 0) return defaultPerMin; + // Only an explicit 0 disables the guard: a positive fraction such as 0.5 + // would floor to 0, so it takes the strictest real cap instead. + if (parsed > 0 && parsed < 1) return 1; return Math.floor(parsed); // includes the explicit 0 = unlimited opt-out } diff --git a/src/costEndpointThrottle.test.mjs b/src/costEndpointThrottle.test.mjs index f4df8a864..053b70017 100644 --- a/src/costEndpointThrottle.test.mjs +++ b/src/costEndpointThrottle.test.mjs @@ -95,6 +95,20 @@ test('a configured value wins, and a fractional one floors', () => { assert.equal(resolvePerMinuteCap('30.7', 30), 30); }); +test('a positive fraction keeps the guard rather than flooring to unlimited', () => { + for (const fraction of ['0.5', '0.99', 0.01, '1e-9']) { + assert.equal(resolvePerMinuteCap(fraction, 30), 1, String(fraction)); + assert.equal( + typeof makeCostRateLimiter(fraction, 30), + 'function', + `${fraction} must still throttle`, + ); + } + // Only an explicit zero turns the limiter off. + assert.equal(makeCostRateLimiter('0', 30), null); + assert.equal(makeCostRateLimiter('0.0', 30), null); +}); + 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 —