From 1d0a37736a12b23c5894282ccf231a75baacd0d3 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Mon, 4 May 2026 14:10:07 +0700 Subject: [PATCH] fix(gateway): re-check credits on env-var retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial credit gate in chat.ts only fires when the first attempt is about to use LLMGateway env-var tokens. Retry/fallback paths went through resolveProviderContext, which switched to env-var tokens without re-validating credits — letting a $0-credits org be billed via used_mode="credits" once a primary attempt failed over. Co-Authored-By: Claude Opus 4.7 --- apps/gateway/src/chat/chat.ts | 3 ++ .../chat/tools/resolve-provider-context.ts | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/gateway/src/chat/chat.ts b/apps/gateway/src/chat/chat.ts index b13bbbafc5..c1586536c2 100644 --- a/apps/gateway/src/chat/chat.ts +++ b/apps/gateway/src/chat/chat.ts @@ -2852,6 +2852,9 @@ chat.openapi(completions, async (c) => { : 0; const totalAvailableCredits = regularCredits + devPlanCreditsRemaining; + // We trust the bare `modelInfo.free` flag here: free models are always + // marked explicitly in the catalog, so a `free: true` model is intended + // to be usable without credits. Do not switch this to isModelTrulyFree. if ( totalAvailableCredits <= 0 && !free_models_only && diff --git a/apps/gateway/src/chat/tools/resolve-provider-context.ts b/apps/gateway/src/chat/tools/resolve-provider-context.ts index 8d37809ed9..e41258fd6e 100644 --- a/apps/gateway/src/chat/tools/resolve-provider-context.ts +++ b/apps/gateway/src/chat/tools/resolve-provider-context.ts @@ -111,6 +111,40 @@ interface OrgInfo { devPlanExpiresAt: Date | null; } +// Mirrors the initial credit gate in chat.ts so retry/fallback paths that +// switch to LLMGateway env-var tokens cannot be used to bill an organization +// with non-positive credits. Free models (explicitly flagged in the catalog) +// are exempt. +function assertOrganizationHasCreditsForEnvFallback( + organization: OrgInfo, + modelInfo: ModelDefinition, +): void { + if (modelInfo.free) { + return; + } + const regularCredits = parseFloat(organization.credits ?? "0"); + const devPlanCreditsRemaining = + organization.devPlan !== "none" + ? parseFloat(organization.devPlanCreditsLimit ?? "0") - + parseFloat(organization.devPlanCreditsUsed ?? "0") + : 0; + const totalAvailableCredits = regularCredits + devPlanCreditsRemaining; + if (totalAvailableCredits > 0) { + return; + } + if (organization.devPlan !== "none" && devPlanCreditsRemaining <= 0) { + const renewalDate = organization.devPlanExpiresAt + ? new Date(organization.devPlanExpiresAt).toLocaleDateString() + : "your next billing date"; + throw new HTTPException(402, { + message: `Dev Plan credit limit reached. Upgrade your plan or wait for renewal on ${renewalDate}.`, + }); + } + throw new HTTPException(402, { + message: `Organization ${organization.id} has insufficient credits`, + }); +} + export function formatUsedModelForDisplay( usedProvider: string, baseModelName: string, @@ -185,6 +219,7 @@ export async function resolveProviderContext( usedToken = providerKey.token; } else if (project.mode === "credits") { + assertOrganizationHasCreditsForEnvFallback(organization, modelInfo); const envResult = getProviderEnv(usedProvider as Provider, { excludedIndices: options.excludedEnvKeyIndices, }); @@ -211,6 +246,7 @@ export async function resolveProviderContext( if (providerKey) { usedToken = providerKey.token; } else { + assertOrganizationHasCreditsForEnvFallback(organization, modelInfo); const envResult = getProviderEnv(usedProvider as Provider, { excludedIndices: options.excludedEnvKeyIndices, });