Skip to content

Commit b296386

Browse files
steebchenclaude
andcommitted
feat(gateway): spend/age trust tiers + USD spend caps
Regular (kind=default, non-enterprise) orgs are now governed by a single trust tier ladder (T0-T4) qualified by account age OR lifetime usage spend. The tier drives both the per-endpoint RPM multiplier (replacing the old $1k/$10k/$50k usage-spend multiplier) and new daily/monthly USD spend caps. Caps are Redis UTC-bucket counters checked pre-request (429 at/over cap) and incremented post-request in the insertLog chokepoint. Tier uses the already-cached lifetime usage spend + org.createdAt, so no schema change. Dev/chat plans keep flat limits; enterprise and free models are exempt. Fail-open on Redis errors; caps default-off under test runners. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bfa036e commit b296386

12 files changed

Lines changed: 651 additions & 89 deletions

File tree

.env.example

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,27 @@ TIMEOUT_MS=5000
111111
# GATEWAY_RATE_LIMIT_CHATPLAN_AUDIO_SPEECH_RPM=30
112112
# GATEWAY_RATE_LIMIT_CHATPLAN_VIDEOS_RPM=12
113113

114-
# Spend-tier thresholds (USD lifetime spend) and the multiplier each grants on
115-
# top of the per-path base limit for regular orgs (defaults shown).
116-
# GATEWAY_RATE_LIMIT_TIER_1_THRESHOLD=1000
117-
# GATEWAY_RATE_LIMIT_TIER_1_MULTIPLIER=2
118-
# GATEWAY_RATE_LIMIT_TIER_2_THRESHOLD=10000
119-
# GATEWAY_RATE_LIMIT_TIER_2_MULTIPLIER=4
120-
# GATEWAY_RATE_LIMIT_TIER_3_THRESHOLD=50000
121-
# GATEWAY_RATE_LIMIT_TIER_3_MULTIPLIER=10
114+
# Unified trust tiers for regular orgs. An org gets the highest tier whose
115+
# age OR lifetime-usage-spend threshold is met; the tier sets the per-path RPM
116+
# multiplier AND the daily/monthly USD spend caps below. Defaults shown; every
117+
# value is overridable as GATEWAY_SPEND_TIER_<N>_<FIELD> for N in 0..4 and FIELD
118+
# in AGE_DAYS, SPEND_USD, RPM_MULTIPLIER, DAILY_CAP_USD, MONTHLY_CAP_USD.
119+
# GATEWAY_SPEND_TIER_1_AGE_DAYS=7
120+
# GATEWAY_SPEND_TIER_1_SPEND_USD=10
121+
# GATEWAY_SPEND_TIER_1_RPM_MULTIPLIER=2
122+
# GATEWAY_SPEND_TIER_1_DAILY_CAP_USD=100
123+
# GATEWAY_SPEND_TIER_1_MONTHLY_CAP_USD=1000
124+
# GATEWAY_SPEND_TIER_2_AGE_DAYS=30
125+
# GATEWAY_SPEND_TIER_2_SPEND_USD=100
126+
# GATEWAY_SPEND_TIER_3_AGE_DAYS=60
127+
# GATEWAY_SPEND_TIER_3_SPEND_USD=1000
128+
# GATEWAY_SPEND_TIER_4_AGE_DAYS=90
129+
# GATEWAY_SPEND_TIER_4_SPEND_USD=5000
130+
131+
# Global switch for the daily/monthly USD spend caps (default enabled, but off
132+
# under test runners). Set to "false" to disable the caps only; the RPM tier
133+
# multiplier above is unaffected.
134+
# GATEWAY_SPEND_CAPS_ENABLED=true
122135

123136
# How long (seconds) to cache an organization's lifetime spend used for tier
124137
# selection. Defaults to 900 (15 minutes).

apps/docs/content/resources/rate-limits.mdx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,23 @@ The default limits (requests per minute, per organization) are:
3838
limits. [Contact us](mailto:contact@llmgateway.io) about enterprise plans.
3939
</Callout>
4040

41-
### Higher Limits as You Scale
41+
### Trust Tiers (account age or spend)
4242

43-
For regular (pay-as-you-go) organizations, the endpoint limits above automatically increase based on your **lifetime spend**:
43+
For regular (pay-as-you-go) organizations, limits scale with a **trust tier**. An organization qualifies for a tier when **either** its account is old enough **or** its lifetime usage spend is high enough — whichever gets it to the higher tier. The tier raises the per-endpoint RPM limits **and** the daily/monthly USD spend caps below.
4444

45-
| Lifetime spend | Limit multiplier |
46-
| -------------- | ---------------- |
47-
| $1,000+ ||
48-
| $10,000+ ||
49-
| $50,000+ | 10× |
45+
| Tier | Qualifies (age **or** spend) | RPM multiplier | Daily cap | Monthly cap |
46+
| ---- | ---------------------------- | -------------- | --------- | ----------- |
47+
| 0 | new / $0 || $5 | $50 |
48+
| 1 | 7 days **or** $10 || $100 | $1,000 |
49+
| 2 | 30 days **or** $100 || $500 | $5,000 |
50+
| 3 | 60 days **or** $1,000 | 10× | $5,000 | $50,000 |
51+
| 4 | 90 days **or** $5,000 | 20× | $15,000 | $200,000 |
5052

51-
For example, an organization that has spent more than $10,000 gets a 4× multiplier, raising the chat completions limit from 600 to 2,400 requests per minute. The multiplier applies to every endpoint.
53+
For example, an org past 30 days old (or with $100+ of usage) is Tier 2: chat completions rises from 600 to 2,400 RPM, with a $500/day and $5,000/month spend ceiling.
54+
55+
### Daily & Monthly Spend Caps
56+
57+
Regular organizations also have hard **USD spend ceilings** — a daily and a monthly cap set by the trust tier above — so a brand-new account has a tight dollar velocity limit that rises as it ages or spends. Only real paid usage counts; **free models are exempt**, as are **enterprise** (no caps) and dev/chat plan orgs (which have their own plan limits). When a cap is reached, requests return `429` until the counter resets (UTC midnight for daily, first of the month for monthly).
5258

5359
### Dev and Chat Plans
5460

apps/gateway/src/api.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
22

3+
import { redisClient } from "@llmgateway/cache";
34
import { db, eq, tables } from "@llmgateway/db";
45
import { logger } from "@llmgateway/logger";
56

@@ -1434,6 +1435,55 @@ describe("api", () => {
14341435
);
14351436
});
14361437

1438+
test("/v1/chat/completions returns 429 when the org is over its daily spend cap", async () => {
1439+
await harness.setProjectMode("credits");
1440+
await harness.setOrganizationCredits("100");
1441+
await db
1442+
.update(tables.organization)
1443+
.set({ retentionLevel: "none" })
1444+
.where(eq(tables.organization.id, "org-id"));
1445+
1446+
await db.insert(tables.apiKey).values({
1447+
id: "token-id-spend-cap",
1448+
token: "real-token-spend-cap",
1449+
projectId: "project-id",
1450+
description: "Test API Key",
1451+
createdBy: "user-id",
1452+
});
1453+
1454+
const now = new Date();
1455+
const dayKey = `${now.getUTCFullYear()}-${String(
1456+
now.getUTCMonth() + 1,
1457+
).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`;
1458+
const counterKey = `spend_cap:daily:org-id:${dayKey}`;
1459+
1460+
process.env.GATEWAY_SPEND_CAPS_ENABLED = "true";
1461+
// Well above any tier's daily cap so this holds regardless of the seeded
1462+
// org's age/spend tier.
1463+
await redisClient.set(counterKey, "1000000");
1464+
try {
1465+
const res = await app.request("/v1/chat/completions", {
1466+
method: "POST",
1467+
headers: {
1468+
"Content-Type": "application/json",
1469+
Authorization: "Bearer real-token-spend-cap",
1470+
},
1471+
body: JSON.stringify({
1472+
model: "gpt-4o-mini",
1473+
messages: [{ role: "user", content: "Hello!" }],
1474+
}),
1475+
});
1476+
1477+
expect(res.status).toBe(429);
1478+
const json = await res.json();
1479+
expect(json.error.type).toBe("rate_limit_error");
1480+
expect(json.error.message).toContain("spend limit");
1481+
} finally {
1482+
delete process.env.GATEWAY_SPEND_CAPS_ENABLED;
1483+
await redisClient.del(counterKey);
1484+
}
1485+
});
1486+
14371487
test("/v1/embeddings hybrid fallback requires credits", async () => {
14381488
await harness.setProjectMode("hybrid");
14391489
await harness.setOrganizationCredits("0");

apps/gateway/src/chat/chat.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
import { getResponsesContext } from "@/lib/responses-context.js";
8282
import { getResolvedRoutingConfig } from "@/lib/routing-config-loader.js";
8383
import { getNoFallbackRoutingMetadata } from "@/lib/routing-metadata.js";
84+
import { checkSpendLimit } from "@/lib/spend-limit.js";
8485
import {
8586
createCombinedSignal,
8687
createStreamingCombinedSignal,
@@ -4664,6 +4665,18 @@ chat.openapi(completions, async (c) => {
46644665
});
46654666
}
46664667

4668+
// Per-org daily/monthly USD spend caps. Applies to regular pay-as-you-go
4669+
// orgs only (kind/enterprise/enabled gates live inside checkSpendLimit);
4670+
// free models are exempt.
4671+
if (!((finalModelInfo ?? modelInfo) as ModelDefinition).free) {
4672+
const spendLimit = await checkSpendLimit(organization);
4673+
if (!spendLimit.allowed) {
4674+
throw new HTTPException(429, {
4675+
message: `Organization ${organization.id} has reached its ${spendLimit.period} spend limit of $${spendLimit.limit}. Try again later or contact support to raise your limit.`,
4676+
});
4677+
}
4678+
}
4679+
46674680
if (usedProvider === "llmgateway") {
46684681
throw new HTTPException(400, {
46694682
message:

apps/gateway/src/embeddings/embeddings.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { extractApiToken } from "@/lib/extract-api-token.js";
3636
import { createFailedKeyTracker } from "@/lib/failed-key-tracker.js";
3737
import { throwIamException, validateRequestModelAccess } from "@/lib/iam.js";
3838
import { calculateDataStorageCost, insertLog } from "@/lib/logs.js";
39+
import { checkSpendLimit } from "@/lib/spend-limit.js";
3940
import { createCombinedSignal, isTimeoutError } from "@/lib/timeout-config.js";
4041

4142
import { getProviderHeaders } from "@llmgateway/actions";
@@ -264,12 +265,24 @@ function getAvailableCredits(
264265
};
265266
}
266267

267-
function assertCreditsAvailableForEmbedding(
268+
async function assertCreditsAvailableForEmbedding(
268269
organization: InferSelectModel<typeof tables.organization>,
269270
modelDef: ModelDefinition,
270271
insufficientCreditsMessage: string,
271272
devPlanCreditLimitMessage: (renewalDate: string) => string,
272273
) {
274+
// Per-org daily/monthly USD spend caps, checked even when the org has
275+
// credits (a funded org can still hit its cap). Free models are exempt; the
276+
// kind/enterprise/enabled gates live inside checkSpendLimit.
277+
if (!modelDef.free) {
278+
const spendLimit = await checkSpendLimit(organization);
279+
if (!spendLimit.allowed) {
280+
throw new HTTPException(429, {
281+
message: `Organization ${organization.id} has reached its ${spendLimit.period} spend limit of $${spendLimit.limit}. Try again later or contact support to raise your limit.`,
282+
});
283+
}
284+
}
285+
273286
const {
274287
devPlanCreditsRemaining,
275288
chatPlanCreditsRemaining,
@@ -712,7 +725,7 @@ embeddings.openapi(createEmbeddings, async (c): Promise<any> => {
712725
}
713726
usedToken = providerKey.token;
714727
} else if (retryProject.mode === "credits") {
715-
assertCreditsAvailableForEmbedding(
728+
await assertCreditsAvailableForEmbedding(
716729
retryOrganization,
717730
modelDef,
718731
`Organization ${retryOrganization.id} has insufficient credits`,
@@ -737,7 +750,7 @@ embeddings.openapi(createEmbeddings, async (c): Promise<any> => {
737750
if (providerKey) {
738751
usedToken = providerKey.token;
739752
} else {
740-
assertCreditsAvailableForEmbedding(
753+
await assertCreditsAvailableForEmbedding(
741754
retryOrganization,
742755
modelDef,
743756
"No API key set for provider and organization has insufficient credits",

apps/gateway/src/lib/logs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
import { recordChatCompletionMetrics } from "@llmgateway/instrumentation";
99
import { logger } from "@llmgateway/logger";
1010

11+
import { recordSpend } from "./spend-limit.js";
12+
1113
import type { InferInsertModel } from "@llmgateway/db";
1214

1315
/**
@@ -328,6 +330,11 @@ export async function insertLog(
328330
errorType,
329331
});
330332

333+
// Maintain per-org daily/monthly spend-cap counters. Single DRY chokepoint
334+
// for every request path; only increments when cost > 0 and swallows its own
335+
// Redis errors so logging is never blocked.
336+
await recordSpend(logData.organizationId, logData.cost ?? 0);
337+
331338
if (options?.syncInsert) {
332339
await db.insert(log).values(logData as LogData);
333340
return 1;

apps/gateway/src/lib/org-rate-limit.spec.ts

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import {
44
baseLimitEnvVar,
55
checkOrgRateLimit,
66
getBaseLimit,
7+
getOrgSpendTier,
78
getPlanClass,
8-
getSpendTierMultiplier,
99
isOrgRateLimitEnabled,
1010
PATH_RATE_LIMITS,
1111
resolvePathRateLimit,
@@ -54,12 +54,8 @@ const ENV_KEYS = [
5454
"GATEWAY_RATE_LIMIT_CHAT_COMPLETIONS_RPM",
5555
"GATEWAY_RATE_LIMIT_DEV_CHAT_COMPLETIONS_RPM",
5656
"GATEWAY_RATE_LIMIT_CHATPLAN_CHAT_COMPLETIONS_RPM",
57-
"GATEWAY_RATE_LIMIT_TIER_1_THRESHOLD",
58-
"GATEWAY_RATE_LIMIT_TIER_2_THRESHOLD",
59-
"GATEWAY_RATE_LIMIT_TIER_3_THRESHOLD",
60-
"GATEWAY_RATE_LIMIT_TIER_1_MULTIPLIER",
61-
"GATEWAY_RATE_LIMIT_TIER_3_MULTIPLIER",
62-
"GATEWAY_RATE_LIMIT_TIER_0_MULTIPLIER",
57+
"GATEWAY_SPEND_TIER_1_SPEND_USD",
58+
"GATEWAY_SPEND_TIER_1_RPM_MULTIPLIER",
6359
"GATEWAY_RATE_LIMIT_WINDOW_SECONDS",
6460
];
6561

@@ -125,34 +121,68 @@ describe("isOrgRateLimitEnabled", () => {
125121
});
126122
});
127123

128-
describe("getSpendTierMultiplier", () => {
129-
it("returns tier 0 below the first threshold", () => {
130-
expect(getSpendTierMultiplier(0)).toEqual({ tier: 0, multiplier: 1 });
131-
expect(getSpendTierMultiplier(999)).toEqual({ tier: 0, multiplier: 1 });
124+
describe("getOrgSpendTier", () => {
125+
const NOW = Date.UTC(2026, 0, 15);
126+
const daysAgo = (n: number) => {
127+
const offsetMs = n * 86_400_000;
128+
return new Date(NOW - offsetMs);
129+
};
130+
131+
it("classifies a brand-new $0 org as T0", () => {
132+
expect(getOrgSpendTier({ createdAt: daysAgo(0) }, 0, NOW)).toMatchObject({
133+
tier: 0,
134+
rpmMultiplier: 1,
135+
dailyCapUsd: 5,
136+
monthlyCapUsd: 50,
137+
});
138+
});
139+
140+
it("qualifies by account age alone (spend $0)", () => {
141+
expect(getOrgSpendTier({ createdAt: daysAgo(8) }, 0, NOW).tier).toBe(1);
142+
expect(getOrgSpendTier({ createdAt: daysAgo(31) }, 0, NOW).tier).toBe(2);
143+
expect(getOrgSpendTier({ createdAt: daysAgo(61) }, 0, NOW).tier).toBe(3);
144+
expect(getOrgSpendTier({ createdAt: daysAgo(90) }, 0, NOW).tier).toBe(4);
132145
});
133146

134-
it("returns tier 1 at $1k", () => {
135-
expect(getSpendTierMultiplier(1_000)).toEqual({ tier: 1, multiplier: 2 });
136-
expect(getSpendTierMultiplier(9_999)).toEqual({ tier: 1, multiplier: 2 });
147+
it("qualifies by lifetime spend alone on a brand-new org", () => {
148+
const org = { createdAt: daysAgo(0) };
149+
expect(getOrgSpendTier(org, 10, NOW).tier).toBe(1);
150+
expect(getOrgSpendTier(org, 100, NOW).tier).toBe(2);
151+
expect(getOrgSpendTier(org, 1_000, NOW).tier).toBe(3);
152+
expect(getOrgSpendTier(org, 5_000, NOW).tier).toBe(4);
153+
expect(getOrgSpendTier(org, 1_000_000, NOW).tier).toBe(4);
137154
});
138155

139-
it("returns tier 2 at $10k", () => {
140-
expect(getSpendTierMultiplier(10_000)).toEqual({ tier: 2, multiplier: 4 });
156+
it("takes the highest of the age-or-spend qualifiers", () => {
157+
// 31 days old (age → T2) but $1,000 lifetime spend (spend → T3) => T3
158+
expect(getOrgSpendTier({ createdAt: daysAgo(31) }, 1_000, NOW).tier).toBe(
159+
3,
160+
);
141161
});
142162

143-
it("returns tier 3 at $50k", () => {
144-
expect(getSpendTierMultiplier(50_000)).toEqual({ tier: 3, multiplier: 10 });
145-
expect(getSpendTierMultiplier(1_000_000)).toEqual({
146-
tier: 3,
147-
multiplier: 10,
163+
it("treats thresholds as inclusive", () => {
164+
expect(getOrgSpendTier({ createdAt: daysAgo(7) }, 0, NOW).tier).toBe(1);
165+
expect(getOrgSpendTier({ createdAt: daysAgo(6) }, 9, NOW).tier).toBe(0);
166+
});
167+
168+
it("exposes the caps and multiplier for the resolved tier", () => {
169+
expect(
170+
getOrgSpendTier({ createdAt: daysAgo(0) }, 5_000, NOW),
171+
).toMatchObject({
172+
tier: 4,
173+
rpmMultiplier: 20,
174+
dailyCapUsd: 15_000,
175+
monthlyCapUsd: 200_000,
148176
});
149177
});
150178

151-
it("respects env overrides for thresholds and multipliers", () => {
152-
process.env.GATEWAY_RATE_LIMIT_TIER_1_THRESHOLD = "500";
153-
process.env.GATEWAY_RATE_LIMIT_TIER_1_MULTIPLIER = "3";
154-
expect(getSpendTierMultiplier(500)).toEqual({ tier: 1, multiplier: 3 });
155-
expect(getSpendTierMultiplier(499)).toEqual({ tier: 0, multiplier: 1 });
179+
it("respects env overrides for tier thresholds and values", () => {
180+
process.env.GATEWAY_SPEND_TIER_1_SPEND_USD = "5";
181+
process.env.GATEWAY_SPEND_TIER_1_RPM_MULTIPLIER = "3";
182+
expect(getOrgSpendTier({ createdAt: daysAgo(0) }, 5, NOW)).toMatchObject({
183+
tier: 1,
184+
rpmMultiplier: 3,
185+
});
156186
});
157187
});
158188

0 commit comments

Comments
 (0)