Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/code-map/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ describe('parse module', () => {
() => multilineCode,
)

expect(result.numLines).toBe(2) // Due to operator precedence: .match(/\n/g)?.length ?? 0 + 1 becomes (2 ?? 1) = 2
expect(result.numLines).toBe(3)
})

it('should deduplicate identifiers and calls', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/code-map/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function parseTokens(
calls: [] as string[],
}
}
const numLines = sourceCode.match(/\n/g)?.length ?? 0 + 1
const numLines = (sourceCode.match(/\n/g)?.length ?? 0) + 1
if (!parser || !query) {
throw new Error('Parser or query not found')
}
Expand Down
68 changes: 4 additions & 64 deletions web/src/app/api/v1/chat/completions/__tests__/completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,32 +18,25 @@ import type { BlockGrantResult } from '@codebuff/billing/subscription'
import type { GetUserPreferencesFn } from '../_post'

describe('/api/v1/chat/completions POST endpoint', () => {
// Old enough to clear the account-age gate in _post.ts
const AGED_ACCOUNT_CREATED_AT = new Date('2024-01-01T00:00:00Z')

const mockUserData: Record<
string,
{ id: string; banned: boolean; created_at: Date }
{ id: string; banned: boolean }
> = {
'test-api-key-123': {
id: 'user-123',
banned: false,
created_at: AGED_ACCOUNT_CREATED_AT,
},
'test-api-key-no-credits': {
id: 'user-no-credits',
banned: false,
created_at: AGED_ACCOUNT_CREATED_AT,
},
'test-api-key-blocked': {
id: 'banned-user-id',
banned: true,
created_at: AGED_ACCOUNT_CREATED_AT,
},
'test-api-key-new-free': {
id: 'user-new-free',
banned: false,
created_at: new Date(),
},
}

Expand All @@ -57,7 +50,6 @@ describe('/api/v1/chat/completions POST endpoint', () => {
return {
id: userData.id,
banned: userData.banned,
created_at: userData.created_at,
} as Awaited<ReturnType<GetUserInfoFromApiKeyFn>>
}

Expand Down Expand Up @@ -95,22 +87,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
totalDebt: 0,
netBalance: 0,
breakdown: {},
// Has purchased credits historically (principals > 0) but 0 remaining
// so the paid-plan gate passes and the credit check is what enforces 402.
principals: { purchase: 100 },
},
nextQuotaReset,
}
}
if (userId === 'user-new-free') {
return {
usageThisCycle: 0,
balance: {
totalRemaining: 100,
totalDebt: 0,
netBalance: 100,
breakdown: {} as Record<string, number>,
principals: {} as Record<string, number>,
principals: {},
},
nextQuotaReset,
}
Expand All @@ -122,7 +99,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
totalDebt: 0,
netBalance: 100,
breakdown: {},
principals: { purchase: 100 },
principals: {},
},
nextQuotaReset,
}
Expand Down Expand Up @@ -460,7 +437,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
expect(body.message).not.toContain(nextQuotaReset)
})

it('returns 403 for a free-tier user with no paid relationship', async () => {
it('lets a new account with no paid relationship through for non-free mode', async () => {
const req = new NextRequest(
'http://localhost:3000/api/v1/chat/completions',
{
Expand Down Expand Up @@ -489,43 +466,6 @@ describe('/api/v1/chat/completions POST endpoint', () => {
loggerWithContext: mockLoggerWithContext,
})

expect(response.status).toBe(403)
const body = await response.json()
expect(body.error).toBe('requires_paid_plan')
})

it('lets a BYOK free-tier new account through the paid-plan gate', async () => {
const req = new NextRequest(
'http://localhost:3000/api/v1/chat/completions',
{
method: 'POST',
headers: {
Authorization: 'Bearer test-api-key-new-free',
'x-openrouter-api-key': 'sk-or-byok-test',
},
body: JSON.stringify({
model: 'test/test-model',
stream: false,
codebuff_metadata: {
run_id: 'run-123',
client_id: 'test-client-id-123',
},
}),
},
)

const response = await postChatCompletions({
req,
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
logger: mockLogger,
trackEvent: mockTrackEvent,
getUserUsageData: mockGetUserUsageData,
getAgentRunFromId: mockGetAgentRunFromId,
fetch: mockFetch,
insertMessageBigquery: mockInsertMessageBigquery,
loggerWithContext: mockLoggerWithContext,
})

expect(response.status).toBe(200)
})

Expand Down
54 changes: 3 additions & 51 deletions web/src/app/api/v1/chat/completions/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,6 @@ const FREE_MODE_ALLOWED_COUNTRIES = new Set([
'NO', 'SE', 'NL', 'DK', 'DE', 'FI', 'BE', 'LU', 'CH', 'IE', 'IS',
])

const MIN_ACCOUNT_AGE_DAYS = 3
const MIN_ACCOUNT_AGE_FOR_PAID_MS = MIN_ACCOUNT_AGE_DAYS * 24 * 60 * 60 * 1000

// Emails allowed to bypass the paid+aged-account gate so integration tests
// (e.g. the SDK prompt-caching test) can run against a real server without
// needing to seed a purchase on every fresh test account.
const PAID_GATE_BYPASS_EMAILS = new Set(['team@codebuff.com'])

function extractClientIp(req: NextRequest): string | undefined {
const forwardedFor = req.headers.get('x-forwarded-for')
if (forwardedFor) {
Expand Down Expand Up @@ -217,7 +209,7 @@ export async function postChatCompletions(params: {
// Get user info
const userInfo = await getUserInfoFromApiKey({
apiKey,
fields: ['id', 'email', 'discord_id', 'stripe_customer_id', 'banned', 'created_at'],
fields: ['id', 'email', 'discord_id', 'stripe_customer_id', 'banned'],
logger,
})
if (!userInfo) {
Expand Down Expand Up @@ -483,50 +475,10 @@ export async function postChatCompletions(params: {

// Fetch user credit data (includes subscription credits when block grant was ensured)
const {
balance: { totalRemaining, principals },
balance: { totalRemaining },
nextQuotaReset,
} = await getUserUsageData({ userId, logger, includeSubscriptionCredits })

// Gate non-free-mode requests behind (a) an established paid relationship
// AND (b) a non-new account. An ongoing abuse campaign uses freshly-signed-up
// self-referral accounts to burn credits via the stream-error billing gap in
// openrouter.ts; restricting to aged + paid accounts cuts off that vector.
// BYOK users bypass — they pay OpenRouter directly, so there's nothing to burn.
const openrouterApiKeyHeader = req.headers.get(BYOK_OPENROUTER_HEADER)
const hasPaidRelationship =
(principals.purchase ?? 0) > 0 || (principals.subscription ?? 0) > 0
const accountAgeMs = userInfo.created_at
? Date.now() - new Date(userInfo.created_at).getTime()
: 0
const accountIsTooNew = accountAgeMs < MIN_ACCOUNT_AGE_FOR_PAID_MS
const isBypassedEmail =
!!userInfo.email && PAID_GATE_BYPASS_EMAILS.has(userInfo.email.toLowerCase())
if (
!isFreeModeRequest &&
!openrouterApiKeyHeader &&
!isBypassedEmail &&
(!hasPaidRelationship || accountIsTooNew)
) {
trackEvent({
event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR,
userId,
properties: {
error: 'blocked_for_free_tier',
model: typedBody.model,
hasPaidRelationship,
accountAgeMs,
},
logger,
})
return NextResponse.json(
{
error: 'requires_paid_plan',
message: `Non-free mode requires a paid subscription or purchased credits on an account at least ${MIN_ACCOUNT_AGE_DAYS} days old. Visit ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage to upgrade, or pass an OpenRouter API key to bring your own credits.`,
},
{ status: 403 },
)
}

// Credit check
if (totalRemaining <= 0 && !isFreeModeRequest) {
trackEvent({
Expand All @@ -547,7 +499,7 @@ export async function postChatCompletions(params: {
)
}

const openrouterApiKey = openrouterApiKeyHeader
const openrouterApiKey = req.headers.get(BYOK_OPENROUTER_HEADER)

// Handle streaming vs non-streaming
try {
Expand Down
Loading