From 5b925160243d6aefea53bbe57cb9e6feb40c6a34 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 17 Apr 2026 00:37:18 -0700 Subject: [PATCH 1/8] Remove free credits for new accounts --- .../api/auth/[...nextauth]/auth-options.ts | 66 +----------------- .../api/auth/[...nextauth]/auth-options.ts | 67 +------------------ 2 files changed, 6 insertions(+), 127 deletions(-) diff --git a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts index 48fff09d9..ae0c4f04d 100644 --- a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -1,14 +1,8 @@ // TODO: Extract shared auth config to packages/auth to avoid duplication with web/src/app/api/auth/[...nextauth]/auth-options.ts import { DrizzleAdapter } from '@auth/drizzle-adapter' -import { processAndGrantCredit } from '@codebuff/billing' import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { - DEFAULT_FREE_CREDITS_GRANT, - SESSION_MAX_AGE_SECONDS, -} from '@codebuff/common/old-constants' -import { getNextQuotaReset } from '@codebuff/common/util/dates' -import { generateCompactId } from '@codebuff/common/util/string' +import { SESSION_MAX_AGE_SECONDS } from '@codebuff/common/old-constants' import { loops } from '@codebuff/internal' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -18,7 +12,6 @@ import { logSyncFailure } from '@codebuff/internal/util/sync-failure' import { eq } from 'drizzle-orm' import GitHubProvider from 'next-auth/providers/github' -import type { Logger } from '@codebuff/common/types/contracts/logger' import type { NextAuthOptions } from 'next-auth' import type { Adapter } from 'next-auth/adapters' @@ -78,53 +71,6 @@ async function createAndLinkStripeCustomer(params: { } } -async function createInitialCreditGrant(params: { - userId: string - expiresAt: Date | null - logger: Logger -}): Promise { - const { userId, expiresAt, logger } = params - - try { - const operationId = `free-${userId}-${generateCompactId()}` - const nextQuotaReset = getNextQuotaReset(expiresAt) - - await processAndGrantCredit({ - ...params, - amount: DEFAULT_FREE_CREDITS_GRANT, - type: 'free', - description: 'Initial free credits', - expiresAt: nextQuotaReset, - operationId, - }) - - logger.info( - { - userId, - operationId, - creditsGranted: DEFAULT_FREE_CREDITS_GRANT, - expiresAt: nextQuotaReset, - }, - 'Initial free credit grant created.', - ) - } catch (grantError) { - const errorMessage = - grantError instanceof Error - ? grantError.message - : 'Unknown error creating initial credit grant' - logger.error( - { userId, error: grantError }, - 'Failed to create initial credit grant.', - ) - await logSyncFailure({ - id: userId, - errorMessage, - provider: 'stripe', - logger, - }) - } -} - export const authOptions: NextAuthOptions = { adapter: DrizzleAdapter(db, { usersTable: schema.user, @@ -194,18 +140,12 @@ export const authOptions: NextAuthOptions = { return } - const customerId = await createAndLinkStripeCustomer({ + await createAndLinkStripeCustomer({ ...userData, userId: userData.id, }) - if (customerId) { - await createInitialCreditGrant({ - userId: userData.id, - expiresAt: userData.next_quota_reset, - logger, - }) - } + // Freebuff is free - new accounts do not receive any credit grant. await loops.sendSignupEventToLoops({ ...userData, diff --git a/web/src/app/api/auth/[...nextauth]/auth-options.ts b/web/src/app/api/auth/[...nextauth]/auth-options.ts index 8ab8fe444..9a7e8958b 100644 --- a/web/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -1,13 +1,7 @@ import { DrizzleAdapter } from '@auth/drizzle-adapter' -import { processAndGrantCredit } from '@codebuff/billing' import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { - DEFAULT_FREE_CREDITS_GRANT, - SESSION_MAX_AGE_SECONDS, -} from '@codebuff/common/old-constants' -import { getNextQuotaReset } from '@codebuff/common/util/dates' -import { generateCompactId } from '@codebuff/common/util/string' +import { SESSION_MAX_AGE_SECONDS } from '@codebuff/common/old-constants' import { loops } from '@codebuff/internal' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -17,7 +11,6 @@ import { logSyncFailure } from '@codebuff/internal/util/sync-failure' import { eq } from 'drizzle-orm' import GitHubProvider from 'next-auth/providers/github' -import type { Logger } from '@codebuff/common/types/contracts/logger' import type { NextAuthOptions } from 'next-auth' import type { Adapter } from 'next-auth/adapters' @@ -77,53 +70,6 @@ async function createAndLinkStripeCustomer(params: { } } -async function createInitialCreditGrant(params: { - userId: string - expiresAt: Date | null - logger: Logger -}): Promise { - const { userId, expiresAt, logger } = params - - try { - const operationId = `free-${userId}-${generateCompactId()}` - const nextQuotaReset = getNextQuotaReset(expiresAt) - - await processAndGrantCredit({ - ...params, - amount: DEFAULT_FREE_CREDITS_GRANT, - type: 'free', - description: 'Initial free credits', - expiresAt: nextQuotaReset, - operationId, - }) - - logger.info( - { - userId, - operationId, - creditsGranted: DEFAULT_FREE_CREDITS_GRANT, - expiresAt: nextQuotaReset, - }, - 'Initial free credit grant created.', - ) - } catch (grantError) { - const errorMessage = - grantError instanceof Error - ? grantError.message - : 'Unknown error creating initial credit grant' - logger.error( - { userId, error: grantError }, - 'Failed to create initial credit grant.', - ) - await logSyncFailure({ - id: userId, - errorMessage, - provider: 'stripe', - logger, - }) - } -} - export const authOptions: NextAuthOptions = { adapter: DrizzleAdapter(db, { usersTable: schema.user, @@ -206,20 +152,13 @@ export const authOptions: NextAuthOptions = { return } - const customerId = await createAndLinkStripeCustomer({ + await createAndLinkStripeCustomer({ ...userData, userId: userData.id, }) - if (customerId) { - await createInitialCreditGrant({ - userId: userData.id, - expiresAt: userData.next_quota_reset, - logger, - }) - } + // New codebuff accounts do not receive a signup bonus. - // Call the imported function await loops.sendSignupEventToLoops({ ...userData, userId: userData.id, From 23c304fd5e97202ba9c0a55790af2a59ca06f863 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 17 Apr 2026 01:33:23 -0700 Subject: [PATCH 2/8] No referral credits --- common/src/constants/limits.ts | 7 ++++++- web/src/app/api/referrals/helpers.ts | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/common/src/constants/limits.ts b/common/src/constants/limits.ts index 35dba95df..e887c16aa 100644 --- a/common/src/constants/limits.ts +++ b/common/src/constants/limits.ts @@ -5,7 +5,12 @@ export const MAX_DATE = new Date(86399999999999) export const BILLING_PERIOD_DAYS = 30 export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 // 30 days export const SESSION_TIME_WINDOW_MS = 30 * 60 * 1000 // 30 minutes - used for matching sessions created around fingerprint creation -export const CREDITS_REFERRAL_BONUS = 500 +// Referral credits disabled 2026-04-17: setting bonus to 0 stops new referral credit grants +// without removing the referral-tracking records. See scripts/opus-or-bleed.ts for the +// abuse pattern that motivated this (self-referral rings farming 1000 free credits per +// signup and burning them on Opus). Development focus is shifting to freebuff which has +// no credit system, so we don't need this growth lever going forward. +export const CREDITS_REFERRAL_BONUS = 0 export const AFFILIATE_USER_REFFERAL_LIMIT = 500 // Default number of free credits granted per cycle diff --git a/web/src/app/api/referrals/helpers.ts b/web/src/app/api/referrals/helpers.ts index f775bc364..90fa0dde2 100644 --- a/web/src/app/api/referrals/helpers.ts +++ b/web/src/app/api/referrals/helpers.ts @@ -138,7 +138,17 @@ export async function redeemReferralCode(referralCode: string, userId: string) { const operationId = referralRecord[0].operation_id - // 2. Process and grant credits for both users (one-time, never expires) + // 2. Grant credits for both users (skipped entirely when bonus is 0 — we still + // record the referral above for tracking, but don't write 0-principal rows + // into the credit ledger). + if (CREDITS_REFERRAL_BONUS <= 0) { + logger.info( + { operationId, referrerId: referrer.id, referredId: userId }, + 'Referral recorded; credit grants skipped (CREDITS_REFERRAL_BONUS=0).', + ) + return + } + const grantPromises = [] const grantForUser = (user: { id: string; role: 'referrer' | 'referred' }) => From f25213a494245a2def9881a26891cc8d61f4b2ca Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 17 Apr 2026 02:32:05 -0700 Subject: [PATCH 3/8] Block codebuff usage --- common/src/types/contracts/billing.ts | 1 + common/src/types/contracts/database.ts | 2 + .../completions/__tests__/completions.test.ts | 106 +++++++++++++++++- web/src/app/api/v1/chat/completions/_post.ts | 42 ++++++- .../docs-search/__tests__/docs-search.test.ts | 4 + web/src/app/api/v1/me/__tests__/me.test.ts | 4 +- web/src/app/api/v1/me/_get.ts | 2 +- .../web-search/__tests__/web-search.test.ts | 4 + web/src/db/user.ts | 1 + 9 files changed, 159 insertions(+), 7 deletions(-) diff --git a/common/src/types/contracts/billing.ts b/common/src/types/contracts/billing.ts index 36e088b4c..af0cc028e 100644 --- a/common/src/types/contracts/billing.ts +++ b/common/src/types/contracts/billing.ts @@ -12,6 +12,7 @@ export type GetUserUsageDataFn = (params: { totalDebt: number netBalance: number breakdown: Record + principals: Record } nextQuotaReset: string autoTopupTriggered?: boolean diff --git a/common/src/types/contracts/database.ts b/common/src/types/contracts/database.ts index c7250c347..d95ba17d8 100644 --- a/common/src/types/contracts/database.ts +++ b/common/src/types/contracts/database.ts @@ -8,6 +8,7 @@ type User = { referral_code: string | null stripe_customer_id: string | null banned: boolean + created_at: Date } export const userColumns = [ 'id', @@ -16,6 +17,7 @@ export const userColumns = [ 'referral_code', 'stripe_customer_id', 'banned', + 'created_at', ] as const export type UserColumn = keyof User export type GetUserInfoFromApiKeyInput = { diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 67d8fb9de..fe101e02c 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -18,21 +18,32 @@ 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 } + { id: string; banned: boolean; created_at: Date } > = { '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(), }, } @@ -43,7 +54,11 @@ describe('/api/v1/chat/completions POST endpoint', () => { if (!userData) { return null } - return { id: userData.id, banned: userData.banned } as Awaited> + return { + id: userData.id, + banned: userData.banned, + created_at: userData.created_at, + } as Awaited> } let mockLogger: Logger @@ -80,6 +95,22 @@ 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: {}, + principals: {}, }, nextQuotaReset, } @@ -91,6 +122,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { totalDebt: 0, netBalance: 100, breakdown: {}, + principals: { purchase: 100 }, }, nextQuotaReset, } @@ -421,6 +453,75 @@ 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 () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-new-free' }, + 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(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) + }) + it('skips credit check when in FREE mode even with 0 credits', async () => { const req = new NextRequest( 'http://localhost:3000/api/v1/chat/completions', @@ -818,6 +919,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { totalDebt: 0, netBalance: includeSubscriptionCredits ? 350 : 0, breakdown: {}, + principals: { subscription: 350 }, }, nextQuotaReset, })) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 8553aa69e..6547316c3 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -74,6 +74,9 @@ 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 + function extractClientIp(req: NextRequest): string | undefined { const forwardedFor = req.headers.get('x-forwarded-for') if (forwardedFor) { @@ -206,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'], + fields: ['id', 'email', 'discord_id', 'stripe_customer_id', 'banned', 'created_at'], logger, }) if (!userInfo) { @@ -440,10 +443,43 @@ export async function postChatCompletions(params: { // Fetch user credit data (includes subscription credits when block grant was ensured) const { - balance: { totalRemaining }, + balance: { totalRemaining, principals }, 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 + if (!openrouterApiKeyHeader && (!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({ @@ -464,7 +500,7 @@ export async function postChatCompletions(params: { ) } - const openrouterApiKey = req.headers.get(BYOK_OPENROUTER_HEADER) + const openrouterApiKey = openrouterApiKeyHeader // Handle streaming vs non-streaming try { diff --git a/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts b/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts index 2f053149a..6f3162365 100644 --- a/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts +++ b/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts @@ -41,6 +41,7 @@ describe('/api/v1/docs-search POST endpoint', () => { totalDebt: 0, netBalance: 10, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) @@ -113,6 +114,7 @@ describe('/api/v1/docs-search POST endpoint', () => { totalDebt: 0, netBalance: 0, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) @@ -163,6 +165,7 @@ describe('/api/v1/docs-search POST endpoint', () => { totalDebt: 0, netBalance: includeSubscriptionCredits ? 350 : 0, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) @@ -200,6 +203,7 @@ describe('/api/v1/docs-search POST endpoint', () => { totalDebt: 0, netBalance: 0, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) diff --git a/web/src/app/api/v1/me/__tests__/me.test.ts b/web/src/app/api/v1/me/__tests__/me.test.ts index 7b807f08c..8d23aff5f 100644 --- a/web/src/app/api/v1/me/__tests__/me.test.ts +++ b/web/src/app/api/v1/me/__tests__/me.test.ts @@ -25,6 +25,7 @@ describe('/api/v1/me route', () => { referral_code: 'ref-user-123', stripe_customer_id: 'cus_test_123', banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }, 'test-api-key-456': { id: 'user-456', @@ -33,6 +34,7 @@ describe('/api/v1/me route', () => { referral_code: 'ref-user-456', stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }, } @@ -214,7 +216,7 @@ describe('/api/v1/me route', () => { const body = await response.json() expect(body.error).toContain('Invalid fields: invalid_field') expect(body.error).toContain( - 'Valid fields are: id, email, discord_id, referral_code, stripe_customer_id, banned, referral_link', + 'Valid fields are: id, email, discord_id, referral_code, stripe_customer_id, banned, created_at, referral_link', ) }) diff --git a/web/src/app/api/v1/me/_get.ts b/web/src/app/api/v1/me/_get.ts index e5b52246f..1854a60e6 100644 --- a/web/src/app/api/v1/me/_get.ts +++ b/web/src/app/api/v1/me/_get.ts @@ -129,7 +129,7 @@ export async function getMe(params: { // Build response including derived fields const userInfoRecord = userInfo as Partial< - Record + Record > const responseBody: Record = {} diff --git a/web/src/app/api/v1/web-search/__tests__/web-search.test.ts b/web/src/app/api/v1/web-search/__tests__/web-search.test.ts index 18973f947..6a30fe9d6 100644 --- a/web/src/app/api/v1/web-search/__tests__/web-search.test.ts +++ b/web/src/app/api/v1/web-search/__tests__/web-search.test.ts @@ -43,6 +43,7 @@ describe('/api/v1/web-search POST endpoint', () => { totalDebt: 0, netBalance: 10, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) @@ -96,6 +97,7 @@ describe('/api/v1/web-search POST endpoint', () => { totalDebt: 0, netBalance: 0, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) @@ -148,6 +150,7 @@ describe('/api/v1/web-search POST endpoint', () => { totalDebt: 0, netBalance: includeSubscriptionCredits ? 350 : 0, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) @@ -186,6 +189,7 @@ describe('/api/v1/web-search POST endpoint', () => { totalDebt: 0, netBalance: 0, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) diff --git a/web/src/db/user.ts b/web/src/db/user.ts index 8fe37b83a..7fc2e3943 100644 --- a/web/src/db/user.ts +++ b/web/src/db/user.ts @@ -15,6 +15,7 @@ export const VALID_USER_INFO_FIELDS = [ 'referral_code', 'stripe_customer_id', 'banned', + 'created_at', ] as const export async function getUserInfoFromApiKey({ From 0ddfe32dd09f88e2d700d9660e399ddb63577569 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 17 Apr 2026 02:46:25 -0700 Subject: [PATCH 4/8] fix test --- .../app/api/v1/chat/completions/__tests__/completions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index fe101e02c..bcd6107cf 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -109,8 +109,8 @@ describe('/api/v1/chat/completions POST endpoint', () => { totalRemaining: 100, totalDebt: 0, netBalance: 100, - breakdown: {}, - principals: {}, + breakdown: {} as Record, + principals: {} as Record, }, nextQuotaReset, } From 56e32ac264f4b7bbde7dedf04724e6a24c9f33c3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 17 Apr 2026 02:53:45 -0700 Subject: [PATCH 5/8] Fix types --- sdk/e2e/utils/e2e-mocks.ts | 1 + sdk/src/__tests__/run-cancellation.test.ts | 15 +++++++++++++++ sdk/src/__tests__/run-file-filter.test.ts | 5 +++++ sdk/src/__tests__/run-handle-event.test.ts | 1 + sdk/src/__tests__/run-mcp-tool-filter.test.ts | 1 + 5 files changed, 23 insertions(+) diff --git a/sdk/e2e/utils/e2e-mocks.ts b/sdk/e2e/utils/e2e-mocks.ts index f57954075..7c1073cf7 100644 --- a/sdk/e2e/utils/e2e-mocks.ts +++ b/sdk/e2e/utils/e2e-mocks.ts @@ -25,6 +25,7 @@ const MOCK_USER = { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), } as const function buildMockAgentTemplate(params: { diff --git a/sdk/src/__tests__/run-cancellation.test.ts b/sdk/src/__tests__/run-cancellation.test.ts index e5ce5d539..119b75388 100644 --- a/sdk/src/__tests__/run-cancellation.test.ts +++ b/sdk/src/__tests__/run-cancellation.test.ts @@ -30,6 +30,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -98,6 +99,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -192,6 +194,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -234,6 +237,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -272,6 +276,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -307,6 +312,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -358,6 +364,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -439,6 +446,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -509,6 +517,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -637,6 +646,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -720,6 +730,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) const abortController = new AbortController() @@ -748,6 +759,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -814,6 +826,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -904,6 +917,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-2') @@ -987,6 +1001,7 @@ describe('Run Cancellation Handling', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') diff --git a/sdk/src/__tests__/run-file-filter.test.ts b/sdk/src/__tests__/run-file-filter.test.ts index 78ccdbf37..c3e82098c 100644 --- a/sdk/src/__tests__/run-file-filter.test.ts +++ b/sdk/src/__tests__/run-file-filter.test.ts @@ -74,6 +74,7 @@ describe('CodebuffClientOptions fileFilter', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -160,6 +161,7 @@ describe('CodebuffClientOptions fileFilter', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -243,6 +245,7 @@ describe('CodebuffClientOptions fileFilter', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -327,6 +330,7 @@ describe('CodebuffClientOptions fileFilter', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') @@ -399,6 +403,7 @@ describe('CodebuffClientOptions fileFilter', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') diff --git a/sdk/src/__tests__/run-handle-event.test.ts b/sdk/src/__tests__/run-handle-event.test.ts index d8f4df340..a5bd4d7e7 100644 --- a/sdk/src/__tests__/run-handle-event.test.ts +++ b/sdk/src/__tests__/run-handle-event.test.ts @@ -23,6 +23,7 @@ describe('CodebuffClient handleEvent / handleStreamChunk', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') diff --git a/sdk/src/__tests__/run-mcp-tool-filter.test.ts b/sdk/src/__tests__/run-mcp-tool-filter.test.ts index 0b0b0a8b7..5237da188 100644 --- a/sdk/src/__tests__/run-mcp-tool-filter.test.ts +++ b/sdk/src/__tests__/run-mcp-tool-filter.test.ts @@ -42,6 +42,7 @@ describe('MCP tool filtering', () => { referral_code: null, stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }) spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') From 84aca638193ea1f14f3a1674542ce551bfa73ed3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 17 Apr 2026 02:54:05 -0700 Subject: [PATCH 6/8] fix types2 --- common/src/testing/fixtures/agent-runtime.ts | 1 + evals/impl/agent-runtime.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/common/src/testing/fixtures/agent-runtime.ts b/common/src/testing/fixtures/agent-runtime.ts index fca059ffb..75c555de8 100644 --- a/common/src/testing/fixtures/agent-runtime.ts +++ b/common/src/testing/fixtures/agent-runtime.ts @@ -114,6 +114,7 @@ export const TEST_AGENT_RUNTIME_IMPL = Object.freeze({ referral_code: 'ref-test-code', stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), } as const return Object.fromEntries( fields.map((field) => [field, user[field as keyof typeof user]]), diff --git a/evals/impl/agent-runtime.ts b/evals/impl/agent-runtime.ts index a9801f59b..d20cb54ca 100644 --- a/evals/impl/agent-runtime.ts +++ b/evals/impl/agent-runtime.ts @@ -39,6 +39,7 @@ export const EVALS_AGENT_RUNTIME_IMPL = Object.freeze({ referral_code: 'ref-test-code', stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }), fetchAgentFromDatabase: async () => null, startAgentRun: async () => 'test-agent-run-id', From fce31b8d61d79892b35d482647c6b6bc09762d61 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 17 Apr 2026 03:00:17 -0700 Subject: [PATCH 7/8] ensure billed --- web/src/llm-api/openrouter.ts | 223 +++++++++++++++++++++++++++++++++- 1 file changed, 221 insertions(+), 2 deletions(-) diff --git a/web/src/llm-api/openrouter.ts b/web/src/llm-api/openrouter.ts index c08463172..a8528764f 100644 --- a/web/src/llm-api/openrouter.ts +++ b/web/src/llm-api/openrouter.ts @@ -23,7 +23,22 @@ import type { OpenRouterErrorMetadata, } from './types' -type StreamState = { responseText: string; reasoningText: string; ttftMs: number | null } +type StreamState = { + responseText: string + reasoningText: string + ttftMs: number | null + // Captured from the first regular chunk we see. Needed to bill via the + // generation-lookup fallback when a stream ends without a usage-bearing chunk + // (e.g., upstream error chunk, truncated response, network drop). + generationId: string | null + model: string | null + billed: boolean +} + +// How long to wait after stream close before querying OpenRouter's generation +// endpoint. OR finalizes generation records asynchronously; 500ms is enough +// in practice and keeps the delay off the client response path. +const GENERATION_LOOKUP_DELAY_MS = 500 // Extended timeout for deep-thinking models (e.g., gpt-5) that can take // a long time to start streaming. @@ -334,9 +349,45 @@ export async function handleOpenRouterStream({ } let heartbeatInterval: NodeJS.Timeout - let state: StreamState = { responseText: '', reasoningText: '', ttftMs: null } + let state: StreamState = { + responseText: '', + reasoningText: '', + ttftMs: null, + generationId: null, + model: null, + billed: false, + } let clientDisconnected = false + // Runs once on any stream-exit path. If we didn't bill through the normal + // path (stream ended without a usage chunk, got a provider error chunk, + // network drop), ask OpenRouter for the generation's final cost so we still + // capture what we were charged. Without this, a well-timed mid-stream failure + // lets the caller walk away with free completion tokens. + const ensureBilled = async () => { + if (state.billed || !state.generationId) return + await new Promise((resolve) => + setTimeout(resolve, GENERATION_LOOKUP_DELAY_MS), + ) + await fallbackBillFromGeneration({ + generationId: state.generationId, + openrouterApiKey, + userId, + stripeCustomerId, + agentId, + clientId, + clientRequestId, + costMode, + byok, + startTime, + state, + request: body, + fetch, + logger, + insertMessage: insertMessageBigquery, + }) + } + // Create a ReadableStream that Next.js can handle const stream = new ReadableStream({ async start(controller) { @@ -420,6 +471,7 @@ export async function handleOpenRouterStream({ if (!clientDisconnected) { controller.close() } + await ensureBilled() } catch (error) { if (!clientDisconnected) { controller.error(error) @@ -429,6 +481,7 @@ export async function handleOpenRouterStream({ 'Error after client disconnect in OpenRouter stream', ) } + await ensureBilled() } finally { clearInterval(heartbeatInterval) } @@ -609,6 +662,7 @@ async function handleResponse({ ttftMs: state.ttftMs, }) + state.billed = true return { state, billedCredits } } @@ -633,6 +687,17 @@ async function handleStreamChunk({ // still storing enough data for logging and billing. 1MB is a generous limit. const MAX_BUFFER_SIZE = 1 * 1024 * 1024 // 1MB + // Capture generation id and model from any regular chunk so we can still + // bill via the generation-lookup fallback if the stream never emits usage. + if (!('error' in data)) { + if (data.id && !state.generationId) { + state.generationId = data.id + } + if (data.model && !state.model) { + state.model = data.model + } + } + if ('error' in data) { // Log detailed error information for stream errors (e.g., Forbidden from Anthropic) const errorData = data.error as { @@ -819,6 +884,160 @@ function creditsToFakeCost(credits: number): number { return credits / ((1 + PROFIT_MARGIN) * 100) } +/** + * Bill a stream that exited before a usage-bearing chunk arrived by looking up + * the generation cost from OpenRouter's /generation endpoint. Mutates + * `state.billed` on success so callers can tell the gap was filled. + * + * Never throws — failures are logged and swallowed. The worst case is that we + * miss this one request, which is still strictly better than the old behavior. + */ +async function fallbackBillFromGeneration(params: { + generationId: string + openrouterApiKey: string | null + userId: string + stripeCustomerId?: string | null + agentId: string + clientId: string | null + clientRequestId: string | null + costMode: string | undefined + byok: boolean + startTime: Date + state: StreamState + request: unknown + fetch: typeof globalThis.fetch + logger: Logger + insertMessage: InsertMessageBigqueryFn +}): Promise { + const { + generationId, + openrouterApiKey, + userId, + stripeCustomerId, + agentId, + clientId, + clientRequestId, + costMode, + byok, + startTime, + state, + request, + fetch, + logger, + insertMessage, + } = params + + try { + const response = await fetch( + `https://openrouter.ai/api/v1/generation?id=${encodeURIComponent(generationId)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${openrouterApiKey ?? env.OPEN_ROUTER_API_KEY}`, + }, + }, + ) + + if (!response.ok) { + logger.error( + { + generationId, + status: response.status, + statusText: response.statusText, + userId, + agentId, + model: state.model, + responseTextLength: state.responseText.length, + }, + 'fallbackBillFromGeneration: generation lookup failed', + ) + return + } + + const body = (await response.json()) as { data?: Record } + const data = body?.data + if (!data) { + logger.warn( + { generationId, userId, agentId }, + 'fallbackBillFromGeneration: generation lookup returned no data', + ) + return + } + + const num = (v: unknown) => (typeof v === 'number' ? v : 0) + const usageData: UsageData = { + inputTokens: num(data.tokens_prompt) || num(data.native_tokens_prompt), + outputTokens: + num(data.tokens_completion) || num(data.native_tokens_completion), + cacheReadInputTokens: num(data.native_tokens_cached), + reasoningTokens: num(data.native_tokens_reasoning), + cost: num(data.total_cost), + } + const resolvedModel = + state.model ?? (typeof data.model === 'string' ? data.model : '') + + logger.warn( + { + generationId, + userId, + agentId, + model: resolvedModel, + cost: usageData.cost, + inputTokens: usageData.inputTokens, + outputTokens: usageData.outputTokens, + responseTextLength: state.responseText.length, + }, + 'fallbackBillFromGeneration: billing from generation lookup (stream exited without usage chunk)', + ) + + insertMessageToBigQuery({ + messageId: generationId, + userId, + startTime, + request, + reasoningText: state.reasoningText, + responseText: state.responseText, + usageData, + logger, + insertMessageBigquery: insertMessage, + }).catch((error) => { + logger.error( + { error: getErrorObject(error), generationId }, + 'fallbackBillFromGeneration: BigQuery insert failed', + ) + }) + + await consumeCreditsForMessage({ + messageId: generationId, + userId, + stripeCustomerId, + agentId, + clientId, + clientRequestId, + startTime, + model: resolvedModel, + reasoningText: state.reasoningText, + responseText: state.responseText, + usageData, + byok, + logger, + costMode, + ttftMs: state.ttftMs, + }) + state.billed = true + } catch (error) { + logger.error( + { + error: getErrorObject(error), + generationId, + userId, + agentId, + }, + 'fallbackBillFromGeneration threw', + ) + } +} + /** * Overwrite the cost field in the final SSE chunk to reflect actual billed credits. * This ensures the SDK calculates the exact credits value we stored in the database, From 6139a9de2ac7ca47be70327e2681b5ef51add247 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 17 Apr 2026 03:31:17 -0700 Subject: [PATCH 8/8] Allow freemode requests --- .../completions/__tests__/completions.test.ts | 33 +++++++++++++++++++ web/src/app/api/v1/chat/completions/_post.ts | 6 +++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index bcd6107cf..803b730ba 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -522,6 +522,39 @@ describe('/api/v1/chat/completions POST endpoint', () => { expect(response.status).toBe(200) }) + it('lets a freebuff/free-mode request through even for a brand-new unpaid account', async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-new-free' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + 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) + }) + it('skips credit check when in FREE mode even with 0 credits', async () => { const req = new NextRequest( 'http://localhost:3000/api/v1/chat/completions', diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 6547316c3..1d24d35ae 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -459,7 +459,11 @@ export async function postChatCompletions(params: { ? Date.now() - new Date(userInfo.created_at).getTime() : 0 const accountIsTooNew = accountAgeMs < MIN_ACCOUNT_AGE_FOR_PAID_MS - if (!openrouterApiKeyHeader && (!hasPaidRelationship || accountIsTooNew)) { + if ( + !isFreeModeRequest && + !openrouterApiKeyHeader && + (!hasPaidRelationship || accountIsTooNew) + ) { trackEvent({ event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, userId,