Skip to content

Commit 468c5cc

Browse files
committed
completions endpoint: Move out of credits check to after checking for subscription
1 parent 18a91b4 commit 468c5cc

File tree

2 files changed

+108
-21
lines changed

2 files changed

+108
-21
lines changed

web/src/app/api/v1/chat/completions/__tests__/completions.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,85 @@ describe('/api/v1/chat/completions POST endpoint', () => {
801801
expect(response.status).toBe(200)
802802
})
803803

804+
it('allows subscriber with 0 a-la-carte credits but active block grant', async () => {
805+
const blockGrant: BlockGrantResult = {
806+
grantId: 'block-123',
807+
credits: 350,
808+
expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000),
809+
isNew: true,
810+
}
811+
const mockEnsureSubscriberBlockGrant = mock(async () => blockGrant)
812+
813+
// Use the no-credits user (totalRemaining = 0)
814+
const req = new NextRequest(
815+
'http://localhost:3000/api/v1/chat/completions',
816+
{
817+
method: 'POST',
818+
headers: { Authorization: 'Bearer test-api-key-no-credits' },
819+
body: JSON.stringify({
820+
model: 'test/test-model',
821+
stream: false,
822+
codebuff_metadata: {
823+
run_id: 'run-123',
824+
client_id: 'test-client-id-123',
825+
},
826+
}),
827+
},
828+
)
829+
830+
const response = await postChatCompletions({
831+
req,
832+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
833+
logger: mockLogger,
834+
trackEvent: mockTrackEvent,
835+
getUserUsageData: mockGetUserUsageData,
836+
getAgentRunFromId: mockGetAgentRunFromId,
837+
fetch: mockFetch,
838+
insertMessageBigquery: mockInsertMessageBigquery,
839+
loggerWithContext: mockLoggerWithContext,
840+
ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant,
841+
})
842+
843+
// Should succeed - subscriber has block grant credits despite 0 a-la-carte credits
844+
expect(response.status).toBe(200)
845+
})
846+
847+
it('returns 402 for non-subscriber with 0 credits when ensureSubscriberBlockGrant returns null', async () => {
848+
const mockEnsureSubscriberBlockGrant = mock(async () => null)
849+
850+
const req = new NextRequest(
851+
'http://localhost:3000/api/v1/chat/completions',
852+
{
853+
method: 'POST',
854+
headers: { Authorization: 'Bearer test-api-key-no-credits' },
855+
body: JSON.stringify({
856+
model: 'test/test-model',
857+
stream: false,
858+
codebuff_metadata: {
859+
run_id: 'run-123',
860+
client_id: 'test-client-id-123',
861+
},
862+
}),
863+
},
864+
)
865+
866+
const response = await postChatCompletions({
867+
req,
868+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
869+
logger: mockLogger,
870+
trackEvent: mockTrackEvent,
871+
getUserUsageData: mockGetUserUsageData,
872+
getAgentRunFromId: mockGetAgentRunFromId,
873+
fetch: mockFetch,
874+
insertMessageBigquery: mockInsertMessageBigquery,
875+
loggerWithContext: mockLoggerWithContext,
876+
ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant,
877+
})
878+
879+
// Non-subscriber with 0 credits should get 402
880+
expect(response.status).toBe(402)
881+
})
882+
804883
it('does not call ensureSubscriberBlockGrant before validation passes', async () => {
805884
const mockEnsureSubscriberBlockGrant = mock(async () => null)
806885

web/src/app/api/v1/chat/completions/_post.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -204,29 +204,11 @@ export async function postChatCompletions(params: {
204204
const costMode = typedBody.codebuff_metadata?.cost_mode
205205
const isFreeModeRequest = isFreeMode(costMode)
206206

207-
// Check user credits (skip for FREE mode since those requests cost 0 credits)
207+
// Fetch user credit data early (actual credit check happens after subscription block grant logic)
208208
const {
209209
balance: { totalRemaining },
210210
nextQuotaReset,
211211
} = await getUserUsageData({ userId, logger })
212-
if (totalRemaining <= 0 && !isFreeModeRequest) {
213-
trackEvent({
214-
event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS,
215-
userId,
216-
properties: {
217-
totalRemaining,
218-
nextQuotaReset,
219-
},
220-
logger,
221-
})
222-
const resetCountdown = formatQuotaResetCountdown(nextQuotaReset)
223-
return NextResponse.json(
224-
{
225-
message: `Out of credits. Please add credits at ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage. Your free credits reset ${resetCountdown}.`,
226-
},
227-
{ status: 402 },
228-
)
229-
}
230212

231213
// Extract and validate agent run ID
232214
const runIdFromBody = typedBody.codebuff_metadata?.run_id
@@ -288,6 +270,7 @@ export async function postChatCompletions(params: {
288270

289271
// For subscribers, ensure a block grant exists before processing the request.
290272
// This is done AFTER validation so malformed requests don't start a new 5-hour block.
273+
let subscriberHasAvailableCredits = false
291274
if (ensureSubscriberBlockGrant) {
292275
try {
293276
const blockGrantResult = await ensureSubscriberBlockGrant({ userId, logger })
@@ -328,17 +311,42 @@ export async function postChatCompletions(params: {
328311
{ userId, limitType: isWeeklyLimitError(blockGrantResult) ? 'weekly' : 'session' },
329312
'Subscriber hit limit, falling back to a-la-carte credits',
330313
)
314+
} else if (blockGrantResult) {
315+
subscriberHasAvailableCredits = true
331316
}
332317
} catch (error) {
333318
logger.error(
334319
{ error: getErrorObject(error), userId },
335320
'Error ensuring subscription block grant',
336321
)
337-
// Fail open: if we can't check the subscription status, allow the request to proceed
338-
// This is intentional - we prefer to allow requests rather than block legitimate users
322+
// Fail open: if we can't check the subscription status, allow the request to proceed.
323+
// Assume the user may be a subscriber so the credit check below doesn't reject them.
324+
subscriberHasAvailableCredits = true
339325
}
340326
}
341327

328+
// Credit check: reject if user has no a-la-carte credits AND is not covered by subscription.
329+
// Subscribers with available block grant credits bypass this check since their
330+
// subscription credits are excluded from totalRemaining (isPersonalContext: true).
331+
if (totalRemaining <= 0 && !isFreeModeRequest && !subscriberHasAvailableCredits) {
332+
trackEvent({
333+
event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS,
334+
userId,
335+
properties: {
336+
totalRemaining,
337+
nextQuotaReset,
338+
},
339+
logger,
340+
})
341+
const resetCountdown = formatQuotaResetCountdown(nextQuotaReset)
342+
return NextResponse.json(
343+
{
344+
message: `Out of credits. Please add credits at ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage. Your free credits reset ${resetCountdown}.`,
345+
},
346+
{ status: 402 },
347+
)
348+
}
349+
342350
const openrouterApiKey = req.headers.get(BYOK_OPENROUTER_HEADER)
343351

344352
// Handle streaming vs non-streaming

0 commit comments

Comments
 (0)