Skip to content
73 changes: 73 additions & 0 deletions apps/api/src/routes/keys-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,79 @@ describe("keys route", () => {
expect(apiKey?.periodUsageDurationUnit).toBe("day");
});

test("POST /keys/api rejects a limit above the member budget", async () => {
await db
.update(tables.userOrganization)
.set({ usageLimit: "10" })
.where(eq(tables.userOrganization.id, "test-user-org-id"));

const res = await app.request("/keys/api", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: token,
},
body: JSON.stringify({
description: "Over-budget key",
projectId: "test-project-id",
usageLimit: "50",
}),
});

expect(res.status).toBe(400);
const json = await res.json();
expect(json.message).toMatch(/organization limit of \$10\.00/);

const apiKey = await db.query.apiKey.findFirst({
where: { description: { eq: "Over-budget key" } },
});
expect(apiKey).toBeUndefined();
});

test("POST /keys/api allows a limit at or below the member budget", async () => {
await db
.update(tables.userOrganization)
.set({ usageLimit: "10" })
.where(eq(tables.userOrganization.id, "test-user-org-id"));

const res = await app.request("/keys/api", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: token,
},
body: JSON.stringify({
description: "Within-budget key",
projectId: "test-project-id",
usageLimit: "10",
}),
});

expect(res.status).toBe(200);
const json = await res.json();
expect(json.apiKey.usageLimit).toBe("10");
});

test("PATCH /keys/api/limit/{id} rejects a limit above the member budget", async () => {
await db
.update(tables.userOrganization)
.set({ usageLimit: "10" })
.where(eq(tables.userOrganization.id, "test-user-org-id"));

const res = await app.request("/keys/api/limit/test-api-key-id", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Cookie: token,
},
body: JSON.stringify({ usageLimit: "50" }),
});

expect(res.status).toBe(400);
const json = await res.json();
expect(json.message).toMatch(/organization limit of \$10\.00/);
});

test("PATCH /keys/api/limit/{id} updates and resets period usage", async () => {
await db
.update(tables.apiKey)
Expand Down
131 changes: 130 additions & 1 deletion apps/api/src/routes/keys-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
eq,
getApiKeyCurrentPeriodState,
isValidApiKeyPeriodDuration,
resolveEffectiveMemberBudget,
shortid,
tables,
validateApiKeyLimitsWithinMemberBudget,
type ApiKeyPeriodDurationUnit,
type InferSelectModel,
} from "@llmgateway/db";
Expand Down Expand Up @@ -831,6 +833,76 @@ export interface CreateApiKeyInput {
expiresAt?: Date | string | null;
}

interface MemberBudgetColumns {
role: "owner" | "admin" | "developer";
maxApiKeys: number | null;
usageLimit: string | null;
periodUsageLimit: string | null;
periodUsageDurationValue: number | null;
periodUsageDurationUnit: ApiKeyPeriodDurationUnit | null;
}

interface OrgDeveloperDefaultColumns {
defaultDeveloperMaxApiKeys: number | null;
defaultDeveloperUsageLimit: string | null;
defaultDeveloperPeriodUsageLimit: string | null;
defaultDeveloperPeriodUsageDurationValue: number | null;
defaultDeveloperPeriodUsageDurationUnit: ApiKeyPeriodDurationUnit | null;
}

/**
* Reject a proposed API-key limit that would exceed the key owner's effective
* member budget (their own caps, or the org-wide default developer caps that
* SSO-provisioned members inherit). The gateway enforces the member budget
* first at request time regardless, but keeping a key's own limit at or below
* the member limit keeps the configured numbers honest. No-op when the member
* has no budget.
*/
function assertApiKeyLimitsWithinMemberBudget(
membership: MemberBudgetColumns | null | undefined,
organization: OrgDeveloperDefaultColumns,
keyLimits: ApiKeyLimitConfig,
): void {
if (!membership) {
return;
}

const budget = resolveEffectiveMemberBudget(
membership.role,
{
maxApiKeys: membership.maxApiKeys,
usageLimit: membership.usageLimit,
periodUsageLimit: membership.periodUsageLimit,
periodUsageDurationValue: membership.periodUsageDurationValue,
periodUsageDurationUnit: membership.periodUsageDurationUnit,
},
{
defaultDeveloperMaxApiKeys: organization.defaultDeveloperMaxApiKeys,
defaultDeveloperUsageLimit: organization.defaultDeveloperUsageLimit,
defaultDeveloperPeriodUsageLimit:
organization.defaultDeveloperPeriodUsageLimit,
defaultDeveloperPeriodUsageDurationValue:
organization.defaultDeveloperPeriodUsageDurationValue,
defaultDeveloperPeriodUsageDurationUnit:
organization.defaultDeveloperPeriodUsageDurationUnit,
},
);

const error = validateApiKeyLimitsWithinMemberBudget(
{
usageLimit: keyLimits.usageLimit,
periodUsageLimit: keyLimits.periodUsageLimit,
periodUsageDurationValue: keyLimits.periodUsageDurationValue,
periodUsageDurationUnit: keyLimits.periodUsageDurationUnit,
},
budget,
);

if (error) {
throw new HTTPException(400, { message: error });
}
}

export async function createApiKeyForProject(
projectId: string,
userId: string,
Expand Down Expand Up @@ -908,9 +980,32 @@ export async function createApiKeyForProject(
userId: { eq: userId },
organizationId: { eq: project.organization.id },
},
columns: { role: true, maxApiKeys: true },
columns: {
role: true,
maxApiKeys: true,
usageLimit: true,
periodUsageLimit: true,
periodUsageDurationValue: true,
periodUsageDurationUnit: true,
},
});

// A key's limits must stay at or below the creator's effective member budget.
// Skipped for programmatic (master-key) creation, which has no interactive
// member context; the gateway still enforces the member budget at request time.
if (!options.skipAccessCheck) {
assertApiKeyLimitsWithinMemberBudget(
creatorMembership,
project.organization,
{
usageLimit: usageLimit ?? null,
periodUsageLimit: periodUsageLimit ?? null,
periodUsageDurationValue: periodUsageDurationValue ?? null,
periodUsageDurationUnit: periodUsageDurationUnit ?? null,
},
);
}

const effectiveMaxApiKeys =
creatorMembership?.maxApiKeys ??
(creatorMembership?.role === "developer"
Expand Down Expand Up @@ -1834,6 +1929,40 @@ keysApi.openapi(updateUsageLimit, async (c) => {
const nextLimitConfig = mergeApiKeyLimitConfig(apiKey, limitUpdate);
parseApiKeyPeriodConfig(nextLimitConfig);

// The key's limits must stay at or below the key owner's effective member
// budget (their own caps, or the org-wide default developer caps).
const ownerMembership = await db.query.userOrganization.findFirst({
where: {
userId: { eq: apiKey.createdBy },
organizationId: { eq: projectOrgId },
},
columns: {
role: true,
maxApiKeys: true,
usageLimit: true,
periodUsageLimit: true,
periodUsageDurationValue: true,
periodUsageDurationUnit: true,
},
});
const ownerOrg = await db.query.organization.findFirst({
where: { id: { eq: projectOrgId } },
columns: {
defaultDeveloperMaxApiKeys: true,
defaultDeveloperUsageLimit: true,
defaultDeveloperPeriodUsageLimit: true,
defaultDeveloperPeriodUsageDurationValue: true,
defaultDeveloperPeriodUsageDurationUnit: true,
},
});
if (ownerOrg) {
assertApiKeyLimitsWithinMemberBudget(
ownerMembership,
ownerOrg,
nextLimitConfig,
);
}

const periodConfigChanged = hasPeriodConfigChanged(apiKey, nextLimitConfig);

// Update the API key usage limit
Expand Down
8 changes: 4 additions & 4 deletions apps/gateway/src/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1856,8 +1856,6 @@ chat.openapi(completions, async (c) => {
});
}

assertApiKeyWithinUsageLimits(apiKey);

// LLM SDK: ephemeral end-user session tokens are bound to one wallet.
// Validate expiry + load the wallet now; below we present an "effective"
// project (forced credits mode) and organization (credits mirror the wallet
Expand Down Expand Up @@ -1888,9 +1886,11 @@ chat.openapi(completions, async (c) => {
});
}

// Enforce the per-member budget set on the Teams page (fails open on read
// errors). Uses the key creator + resolved org.
// User-level limits take priority: enforce the per-member budget (set on the
// Teams page; fails open on read errors) before the per-key usage limits, so a
// member who is over budget is denied even if the key itself is within limits.
await assertMemberWithinBudget(apiKey.createdBy, project.organizationId);
assertApiKeyWithinUsageLimits(apiKey);

// End-user sessions always bill via wallet credits through llmgateway's own
// provider keys — never the developer's BYO keys.
Expand Down
8 changes: 4 additions & 4 deletions apps/gateway/src/embeddings/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,6 @@ embeddings.openapi(createEmbeddings, async (c): Promise<any> => {
});
}

assertApiKeyWithinUsageLimits(apiKey);

const baseProject = await findProjectById(apiKey.projectId);
if (!baseProject) {
throw new HTTPException(500, {
Expand All @@ -565,9 +563,11 @@ embeddings.openapi(createEmbeddings, async (c): Promise<any> => {
});
}

// Enforce the per-member budget set on the Teams page (fails open on read
// errors). Uses the key creator + resolved org.
// User-level limits take priority: enforce the per-member budget (set on the
// Teams page; fails open on read errors) before the per-key usage limits, so a
// member who is over budget is denied even if the key itself is within limits.
await assertMemberWithinBudget(apiKey.createdBy, baseProject.organizationId);
assertApiKeyWithinUsageLimits(apiKey);

const baseOrganization = await findOrganizationById(
baseProject.organizationId,
Expand Down
8 changes: 5 additions & 3 deletions apps/gateway/src/mcp/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1161,16 +1161,18 @@ export async function mcpHandler(c: Context): Promise<Response> {
}

try {
assertApiKeyWithinUsageLimits(apiKeyRecord);
// Enforce the per-member budget set on the Teams page (fails open on read
// errors). Resolve the project so the org is known for the creator lookup.
// User-level limits take priority: enforce the per-member budget (set on
// the Teams page; fails open on read errors) before the per-key usage
// limits, so a member who is over budget is denied even if the key itself
// is within limits. Resolve the project so the org is known for the lookup.
const mcpProject = await findProjectById(apiKeyRecord.projectId);
if (mcpProject) {
await assertMemberWithinBudget(
apiKeyRecord.createdBy,
mcpProject.organizationId,
);
}
assertApiKeyWithinUsageLimits(apiKeyRecord);
} catch (error) {
// Preserve the thrown status: assertMemberWithinBudget uses 403 for a
// budget breach, which must not be flattened into a 401 (invalid key).
Expand Down
8 changes: 4 additions & 4 deletions apps/gateway/src/moderations/moderations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,6 @@ moderations.openapi(createModeration, async (c): Promise<any> => {
});
}

assertApiKeyWithinUsageLimits(apiKey);

const baseProject = await findProjectById(apiKey.projectId);
if (!baseProject) {
throw new HTTPException(500, {
Expand All @@ -376,9 +374,11 @@ moderations.openapi(createModeration, async (c): Promise<any> => {
});
}

// Enforce the per-member budget set on the Teams page (fails open on read
// errors). Uses the key creator + resolved org.
// User-level limits take priority: enforce the per-member budget (set on the
// Teams page; fails open on read errors) before the per-key usage limits, so a
// member who is over budget is denied even if the key itself is within limits.
await assertMemberWithinBudget(apiKey.createdBy, baseProject.organizationId);
assertApiKeyWithinUsageLimits(apiKey);

const baseOrganization = await findOrganizationById(
baseProject.organizationId,
Expand Down
8 changes: 4 additions & 4 deletions apps/gateway/src/ocr/ocr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,6 @@ ocr.openapi(createOcr, async (c): Promise<any> => {
});
}

assertApiKeyWithinUsageLimits(apiKey);

const baseProject = await findProjectById(apiKey.projectId);
if (!baseProject) {
throw new HTTPException(500, {
Expand All @@ -467,9 +465,11 @@ ocr.openapi(createOcr, async (c): Promise<any> => {
});
}

// Enforce the per-member budget set on the Teams page (fails open on read
// errors). Uses the key creator + resolved org.
// User-level limits take priority: enforce the per-member budget (set on the
// Teams page; fails open on read errors) before the per-key usage limits, so a
// member who is over budget is denied even if the key itself is within limits.
await assertMemberWithinBudget(apiKey.createdBy, baseProject.organizationId);
assertApiKeyWithinUsageLimits(apiKey);

const baseOrganization = await findOrganizationById(
baseProject.organizationId,
Expand Down
8 changes: 4 additions & 4 deletions apps/gateway/src/speech/speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,6 @@ speech.openapi(createSpeech, async (c): Promise<Response> => {
});
}

assertApiKeyWithinUsageLimits(apiKey);

const project = await findProjectById(apiKey.projectId);
if (!project) {
throw new HTTPException(500, { message: "Could not find project" });
Expand All @@ -585,9 +583,11 @@ speech.openapi(createSpeech, async (c): Promise<Response> => {
});
}

// Enforce the per-member budget set on the Teams page (fails open on read
// errors). Uses the key creator + resolved org.
// User-level limits take priority: enforce the per-member budget (set on the
// Teams page; fails open on read errors) before the per-key usage limits, so a
// member who is over budget is denied even if the key itself is within limits.
await assertMemberWithinBudget(apiKey.createdBy, project.organizationId);
assertApiKeyWithinUsageLimits(apiKey);

const organization = await findOrganizationById(project.organizationId);
if (!organization) {
Expand Down
Loading
Loading