@@ -18,21 +18,32 @@ import type { BlockGrantResult } from '@codebuff/billing/subscription'
1818import type { GetUserPreferencesFn } from '../_post'
1919
2020describe ( '/api/v1/chat/completions POST endpoint' , ( ) => {
21+ // Old enough to clear the account-age gate in _post.ts
22+ const AGED_ACCOUNT_CREATED_AT = new Date ( '2024-01-01T00:00:00Z' )
23+
2124 const mockUserData : Record <
2225 string ,
23- { id : string ; banned : boolean }
26+ { id : string ; banned : boolean ; created_at : Date }
2427 > = {
2528 'test-api-key-123' : {
2629 id : 'user-123' ,
2730 banned : false ,
31+ created_at : AGED_ACCOUNT_CREATED_AT ,
2832 } ,
2933 'test-api-key-no-credits' : {
3034 id : 'user-no-credits' ,
3135 banned : false ,
36+ created_at : AGED_ACCOUNT_CREATED_AT ,
3237 } ,
3338 'test-api-key-blocked' : {
3439 id : 'banned-user-id' ,
3540 banned : true ,
41+ created_at : AGED_ACCOUNT_CREATED_AT ,
42+ } ,
43+ 'test-api-key-new-free' : {
44+ id : 'user-new-free' ,
45+ banned : false ,
46+ created_at : new Date ( ) ,
3647 } ,
3748 }
3849
@@ -43,7 +54,11 @@ describe('/api/v1/chat/completions POST endpoint', () => {
4354 if ( ! userData ) {
4455 return null
4556 }
46- return { id : userData . id , banned : userData . banned } as Awaited < ReturnType < GetUserInfoFromApiKeyFn > >
57+ return {
58+ id : userData . id ,
59+ banned : userData . banned ,
60+ created_at : userData . created_at ,
61+ } as Awaited < ReturnType < GetUserInfoFromApiKeyFn > >
4762 }
4863
4964 let mockLogger : Logger
@@ -80,6 +95,22 @@ describe('/api/v1/chat/completions POST endpoint', () => {
8095 totalDebt : 0 ,
8196 netBalance : 0 ,
8297 breakdown : { } ,
98+ // Has purchased credits historically (principals > 0) but 0 remaining
99+ // so the paid-plan gate passes and the credit check is what enforces 402.
100+ principals : { purchase : 100 } ,
101+ } ,
102+ nextQuotaReset,
103+ }
104+ }
105+ if ( userId === 'user-new-free' ) {
106+ return {
107+ usageThisCycle : 0 ,
108+ balance : {
109+ totalRemaining : 100 ,
110+ totalDebt : 0 ,
111+ netBalance : 100 ,
112+ breakdown : { } ,
113+ principals : { } ,
83114 } ,
84115 nextQuotaReset,
85116 }
@@ -91,6 +122,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
91122 totalDebt : 0 ,
92123 netBalance : 100 ,
93124 breakdown : { } ,
125+ principals : { purchase : 100 } ,
94126 } ,
95127 nextQuotaReset,
96128 }
@@ -421,6 +453,75 @@ describe('/api/v1/chat/completions POST endpoint', () => {
421453 expect ( body . message ) . not . toContain ( nextQuotaReset )
422454 } )
423455
456+ it ( 'returns 403 for a free-tier user with no paid relationship' , async ( ) => {
457+ const req = new NextRequest (
458+ 'http://localhost:3000/api/v1/chat/completions' ,
459+ {
460+ method : 'POST' ,
461+ headers : { Authorization : 'Bearer test-api-key-new-free' } ,
462+ body : JSON . stringify ( {
463+ model : 'test/test-model' ,
464+ stream : false ,
465+ codebuff_metadata : {
466+ run_id : 'run-123' ,
467+ client_id : 'test-client-id-123' ,
468+ } ,
469+ } ) ,
470+ } ,
471+ )
472+
473+ const response = await postChatCompletions ( {
474+ req,
475+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
476+ logger : mockLogger ,
477+ trackEvent : mockTrackEvent ,
478+ getUserUsageData : mockGetUserUsageData ,
479+ getAgentRunFromId : mockGetAgentRunFromId ,
480+ fetch : mockFetch ,
481+ insertMessageBigquery : mockInsertMessageBigquery ,
482+ loggerWithContext : mockLoggerWithContext ,
483+ } )
484+
485+ expect ( response . status ) . toBe ( 403 )
486+ const body = await response . json ( )
487+ expect ( body . error ) . toBe ( 'requires_paid_plan' )
488+ } )
489+
490+ it ( 'lets a BYOK free-tier new account through the paid-plan gate' , async ( ) => {
491+ const req = new NextRequest (
492+ 'http://localhost:3000/api/v1/chat/completions' ,
493+ {
494+ method : 'POST' ,
495+ headers : {
496+ Authorization : 'Bearer test-api-key-new-free' ,
497+ 'x-openrouter-api-key' : 'sk-or-byok-test' ,
498+ } ,
499+ body : JSON . stringify ( {
500+ model : 'test/test-model' ,
501+ stream : false ,
502+ codebuff_metadata : {
503+ run_id : 'run-123' ,
504+ client_id : 'test-client-id-123' ,
505+ } ,
506+ } ) ,
507+ } ,
508+ )
509+
510+ const response = await postChatCompletions ( {
511+ req,
512+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
513+ logger : mockLogger ,
514+ trackEvent : mockTrackEvent ,
515+ getUserUsageData : mockGetUserUsageData ,
516+ getAgentRunFromId : mockGetAgentRunFromId ,
517+ fetch : mockFetch ,
518+ insertMessageBigquery : mockInsertMessageBigquery ,
519+ loggerWithContext : mockLoggerWithContext ,
520+ } )
521+
522+ expect ( response . status ) . toBe ( 200 )
523+ } )
524+
424525 it ( 'skips credit check when in FREE mode even with 0 credits' , async ( ) => {
425526 const req = new NextRequest (
426527 'http://localhost:3000/api/v1/chat/completions' ,
@@ -818,6 +919,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
818919 totalDebt : 0 ,
819920 netBalance : includeSubscriptionCredits ? 350 : 0 ,
820921 breakdown : { } ,
922+ principals : { subscription : 350 } ,
821923 } ,
822924 nextQuotaReset,
823925 } ) )
0 commit comments