Skip to content

Commit fff1734

Browse files
committed
Allow free requests to go through even when subscription depleted and credit use not enabled
1 parent 7175592 commit fff1734

File tree

2 files changed

+87
-3
lines changed

2 files changed

+87
-3
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
@@ -420,6 +420,39 @@ describe('/api/v1/chat/completions POST endpoint', () => {
420420
expect(body.message).toContain(expectedResetCountdown)
421421
expect(body.message).not.toContain(nextQuotaReset)
422422
})
423+
424+
it('skips credit check when in FREE mode even with 0 credits', async () => {
425+
const req = new NextRequest(
426+
'http://localhost:3000/api/v1/chat/completions',
427+
{
428+
method: 'POST',
429+
headers: { Authorization: 'Bearer test-api-key-no-credits' },
430+
body: JSON.stringify({
431+
model: 'test/test-model',
432+
stream: false,
433+
codebuff_metadata: {
434+
run_id: 'run-123',
435+
client_id: 'test-client-id-123',
436+
cost_mode: 'free',
437+
},
438+
}),
439+
},
440+
)
441+
442+
const response = await postChatCompletions({
443+
req,
444+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
445+
logger: mockLogger,
446+
trackEvent: mockTrackEvent,
447+
getUserUsageData: mockGetUserUsageData,
448+
getAgentRunFromId: mockGetAgentRunFromId,
449+
fetch: mockFetch,
450+
insertMessageBigquery: mockInsertMessageBigquery,
451+
loggerWithContext: mockLoggerWithContext,
452+
})
453+
454+
expect(response.status).toBe(200)
455+
})
423456
})
424457

425458
describe('Successful responses', () => {
@@ -549,6 +582,52 @@ describe('/api/v1/chat/completions POST endpoint', () => {
549582
expect(body.message).toContain('Enable "Continue with credits"')
550583
})
551584

585+
it('skips subscription limit check when in FREE mode even with fallback disabled', async () => {
586+
const weeklyLimitError: BlockGrantResult = {
587+
error: 'weekly_limit_reached',
588+
used: 3500,
589+
limit: 3500,
590+
resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
591+
}
592+
const mockEnsureSubscriberBlockGrant = mock(async () => weeklyLimitError)
593+
const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({
594+
fallbackToALaCarte: false,
595+
}))
596+
597+
const freeModeRequest = new NextRequest(
598+
'http://localhost:3000/api/v1/chat/completions',
599+
{
600+
method: 'POST',
601+
headers: { Authorization: 'Bearer test-api-key-123' },
602+
body: JSON.stringify({
603+
model: 'test/test-model',
604+
stream: false,
605+
codebuff_metadata: {
606+
run_id: 'run-123',
607+
client_id: 'test-client-id-123',
608+
cost_mode: 'free',
609+
},
610+
}),
611+
},
612+
)
613+
614+
const response = await postChatCompletions({
615+
req: freeModeRequest,
616+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
617+
logger: mockLogger,
618+
trackEvent: mockTrackEvent,
619+
getUserUsageData: mockGetUserUsageData,
620+
getAgentRunFromId: mockGetAgentRunFromId,
621+
fetch: mockFetch,
622+
insertMessageBigquery: mockInsertMessageBigquery,
623+
loggerWithContext: mockLoggerWithContext,
624+
ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant,
625+
getUserPreferences: mockGetUserPreferences,
626+
})
627+
628+
expect(response.status).toBe(200)
629+
})
630+
552631
it('returns 429 when block exhausted and fallback disabled', async () => {
553632
const blockExhaustedError: BlockGrantResult = {
554633
error: 'block_exhausted',

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
22
import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok'
3+
import { isFreeMode } from '@codebuff/common/constants/free-agents'
34
import { getErrorObject } from '@codebuff/common/util/error'
45
import { pluralize } from '@codebuff/common/util/string'
56
import { env } from '@codebuff/internal/env'
@@ -199,12 +200,16 @@ export async function postChatCompletions(params: {
199200
logger,
200201
})
201202

202-
// Check user credits
203+
// Check if the request is in FREE mode (costs 0 credits for allowed agent+model combos)
204+
const costMode = typedBody.codebuff_metadata?.cost_mode
205+
const isFreeModeRequest = isFreeMode(costMode)
206+
207+
// Check user credits (skip for FREE mode since those requests cost 0 credits)
203208
const {
204209
balance: { totalRemaining },
205210
nextQuotaReset,
206211
} = await getUserUsageData({ userId, logger })
207-
if (totalRemaining <= 0) {
212+
if (totalRemaining <= 0 && !isFreeModeRequest) {
208213
trackEvent({
209214
event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS,
210215
userId,
@@ -294,7 +299,7 @@ export async function postChatCompletions(params: {
294299
? await getUserPreferences({ userId, logger })
295300
: { fallbackToALaCarte: true } // Default to allowing a-la-carte if no preference function
296301

297-
if (!preferences.fallbackToALaCarte) {
302+
if (!preferences.fallbackToALaCarte && !isFreeModeRequest) {
298303
const resetTime = blockGrantResult.resetsAt
299304
const resetCountdown = formatQuotaResetCountdown(resetTime.toISOString())
300305
const limitType = isWeeklyLimitError(blockGrantResult) ? 'weekly' : '5-hour session'

0 commit comments

Comments
 (0)