diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 97eb0a0a3d..22422e1918 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -14,6 +14,7 @@ import { useShallow } from 'zustand/react/shallow' import { getAdsEnabled, handleAdsDisable } from './commands/ads' import { routeUserPrompt, addBashMessageToHistory } from './commands/router' import { AdBanner } from './components/ad-banner' +import { ChoiceAdBanner } from './components/choice-ad-banner' import { ChatInputBar } from './components/chat-input-bar' import { LoadPreviousButton } from './components/load-previous-button' import { ReviewScreen } from './components/review-screen' @@ -168,7 +169,7 @@ export const Chat = ({ }) const hasSubscription = subscriptionData?.hasSubscription ?? false - const { ad } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) + const { ad, adData, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) const [adsManuallyDisabled, setAdsManuallyDisabled] = useState(false) const handleDisableAds = useCallback(() => { @@ -1445,11 +1446,18 @@ export const Chat = ({ )} {ad && (IS_FREEBUFF || (!adsManuallyDisabled && getAdsEnabled())) && ( - + adData?.variant === 'choice' ? ( + + ) : ( + + ) )} {reviewMode ? ( diff --git a/cli/src/commands/ads.ts b/cli/src/commands/ads.ts index f111f3a66b..6170047b27 100644 --- a/cli/src/commands/ads.ts +++ b/cli/src/commands/ads.ts @@ -16,7 +16,7 @@ export const handleAdsEnable = (): { return { postUserMessage: (messages) => [ ...messages, - getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'), + getSystemMessage('Ads enabled. You will see contextual ads above the input.'), ], } } diff --git a/cli/src/components/ad-banner.tsx b/cli/src/components/ad-banner.tsx index 08ccf4ad40..4910952a73 100644 --- a/cli/src/components/ad-banner.tsx +++ b/cli/src/components/ad-banner.tsx @@ -150,10 +150,7 @@ export const AdBanner: React.FC = ({ ad, onDisableAds, isFreeMode {domain} )} - - {!IS_FREEBUFF && ad.credits != null && ad.credits > 0 && ( - +{ad.credits} credits - )} + {/* Info panel: shown when Ad label is clicked, below the ad */} @@ -179,7 +176,7 @@ export const AdBanner: React.FC = ({ ad, onDisableAds, isFreeMode {IS_FREEBUFF ? 'Ads help keep Freebuff free.' - : 'Ads are optional and earn you credits on each impression. Feel free to hide them anytime.'} + : 'Ads are optional. Feel free to hide them anytime.'} + ) + })} + + + + + ) +} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 88404af088..e8650d319d 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -110,7 +110,6 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { } const colorLevel = getBannerColorLevel(activeData.remainingBalance) - const adCredits = activeData.balanceBreakdown?.ad const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null @@ -152,9 +151,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { {activeData.remainingBalance?.toLocaleString() ?? '?'} credits )} - {adCredits != null && adCredits > 0 && ( - {`(${adCredits} from ads)`} - )} + {!activeSubscription && renewalDate && ( <> · Renews: diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 6893640516..4550895846 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -83,12 +83,12 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [ { id: 'ads:enable', label: 'ads:enable', - description: 'Enable contextual ads and earn credits', + description: 'Enable contextual ads', }, { id: 'ads:disable', label: 'ads:disable', - description: 'Disable contextual ads and stop earning credits', + description: 'Disable contextual ads', }, { id: 'refer-friends', diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index ee825baf56..4ed964c47a 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -27,15 +27,26 @@ export type AdResponse = { credits?: number // Set after impression is recorded (in cents) } +export type AdVariant = 'banner' | 'choice' + +export type AdData = + | { variant: 'banner'; ad: AdResponse } + | { variant: 'choice'; ads: AdResponse[] } + export type GravityAdState = { ad: AdResponse | null + adData: AdData | null isLoading: boolean + recordImpression: (impUrl: string) => void } // Consolidated controller state for the ad rotation logic type GravityController = { cache: AdResponse[] cacheIndex: number + choiceCache: AdResponse[][] // Cache of choice ad sets (each entry is 4 ads) + choiceCacheIndex: number + variant: AdVariant | null // Assigned variant from backend impressionsFired: Set adsShownSinceActivity: number tickInFlight: boolean @@ -57,6 +68,23 @@ function nextFromCache(ctrl: GravityController): AdResponse | null { return ad } +// Pure helper: add a choice ad set to the choice cache +function addToChoiceCache(ctrl: GravityController, ads: AdResponse[]): void { + // Deduplicate by checking if any set has the same first impUrl + const key = ads[0]?.impUrl + if (key && ctrl.choiceCache.some((set) => set[0]?.impUrl === key)) return + if (ctrl.choiceCache.length >= MAX_AD_CACHE_SIZE) ctrl.choiceCache.shift() + ctrl.choiceCache.push(ads) +} + +// Pure helper: get the next cached choice ad set +function nextFromChoiceCache(ctrl: GravityController): AdResponse[] | null { + if (ctrl.choiceCache.length === 0) return null + const set = ctrl.choiceCache[ctrl.choiceCacheIndex % ctrl.choiceCache.length]! + ctrl.choiceCacheIndex = (ctrl.choiceCacheIndex + 1) % ctrl.choiceCache.length + return set +} + /** * Hook for fetching and rotating Gravity ads. * @@ -71,6 +99,7 @@ function nextFromCache(ctrl: GravityController): AdResponse | null { export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => { const enabled = options?.enabled ?? true const [ad, setAd] = useState(null) + const [adData, setAdData] = useState(null) const [isLoading, setIsLoading] = useState(false) // Check if terminal height is too small to show ads @@ -94,6 +123,9 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => const ctrlRef = useRef({ cache: [], cacheIndex: 0, + choiceCache: [], + choiceCacheIndex: 0, + variant: null, impressionsFired: new Set(), adsShownSinceActivity: 0, tickInFlight: false, @@ -145,6 +177,22 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => ? { ...cur, credits: data.creditsGranted } : cur, ) + // Also update credits in adData for choice ads + setAdData((cur) => { + if (!cur) return cur + if (cur.variant === 'choice') { + return { + ...cur, + ads: cur.ads.map((a) => + a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a, + ), + } + } + if (cur.variant === 'banner' && cur.ad.impUrl === impUrl) { + return { ...cur, ad: { ...cur.ad, credits: data.creditsGranted } } + } + return cur + }) } }) .catch((err) => { @@ -152,14 +200,26 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => }) } - // Show an ad and fire impression + // Show a single banner ad and fire impression const showAd = (next: AdResponse): void => { setAd(next) + setAdData({ variant: 'banner', ad: next }) recordImpressionOnce(next.impUrl) } + // Show a choice ad set (impressions are fired by the component for visible ads only) + const showChoiceAds = (ads: AdResponse[]): void => { + setAd(ads[0] ?? null) // Keep backwards compat for ad field + setAdData({ variant: 'choice', ads }) + } + + type FetchAdResult = + | { variant: 'banner'; ad: AdResponse } + | { variant: 'choice'; ads: AdResponse[] } + | null + // Fetch an ad via web API - const fetchAd = async (): Promise => { + const fetchAd = async (): Promise => { // Don't fetch ads when they should be hidden if (shouldHideAdsRef.current) return null if (!getAdsEnabled()) return null @@ -223,7 +283,17 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => } const data = await response.json() - return data.ad as AdResponse | null + const variant = data.variant ?? 'banner' + + if (variant === 'choice' && Array.isArray(data.ads) && data.ads.length > 0) { + return { variant: 'choice', ads: data.ads as AdResponse[] } + } + + if (data.ad) { + return { variant: 'banner', ad: data.ad as AdResponse } + } + + return null } catch (err) { logger.error({ err }, '[gravity] Failed to fetch ad') return null @@ -245,21 +315,34 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => ctrl.adsShownSinceActivity < MAX_ADS_AFTER_ACTIVITY && isUserActive(ACTIVITY_THRESHOLD_MS) - let next: AdResponse | null = null - - if (canFetchNew) { - next = await fetchAd() - if (next) addToCache(ctrl, next) - } - - // Fall back to cached ads if no new ad - if (!next) { - next = nextFromCache(ctrl) - } - - if (next) { - ctrl.adsShownSinceActivity += 1 - showAd(next) + const result = canFetchNew ? await fetchAd() : null + + if (result) { + ctrl.variant = result.variant + if (result.variant === 'choice') { + addToChoiceCache(ctrl, result.ads) + ctrl.adsShownSinceActivity += 1 + showChoiceAds(result.ads) + } else { + addToCache(ctrl, result.ad) + ctrl.adsShownSinceActivity += 1 + showAd(result.ad) + } + } else { + // Fall back to cached ads + if (ctrl.variant === 'choice') { + const cachedSet = nextFromChoiceCache(ctrl) + if (cachedSet) { + ctrl.adsShownSinceActivity += 1 + showChoiceAds(cachedSet) + } + } else { + const next = nextFromCache(ctrl) + if (next) { + ctrl.adsShownSinceActivity += 1 + showAd(next) + } + } } } finally { ctrl.tickInFlight = false @@ -283,11 +366,18 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => // Fetch first ad immediately void (async () => { - const firstAd = await fetchAd() - if (firstAd) { - addToCache(ctrlRef.current, firstAd) - showAd(firstAd) - ctrlRef.current.adsShownSinceActivity = 1 + const result = await fetchAd() + if (result) { + const ctrl = ctrlRef.current + ctrl.variant = result.variant + if (result.variant === 'choice') { + addToChoiceCache(ctrl, result.ads) + showChoiceAds(result.ads) + } else { + addToCache(ctrl, result.ad) + showAd(result.ad) + } + ctrl.adsShownSinceActivity = 1 } setIsLoading(false) })() @@ -303,7 +393,13 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => }, [hasUserMessaged, shouldHideAds]) // Don't return ad when ads should be hidden - return { ad: hasUserMessaged && !shouldHideAds ? ad : null, isLoading } + const visible = hasUserMessaged && !shouldHideAds + return { + ad: visible ? ad : null, + adData: visible ? adData : null, + isLoading, + recordImpression: recordImpressionOnce, + } } type AdMessage = { role: 'user' | 'assistant'; content: string } diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 1e8cc407e1..39daa5d31c 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -1,3 +1,5 @@ +import { createHash } from 'crypto' + import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { buildArray } from '@codebuff/common/util/array' import { getErrorObject } from '@codebuff/common/util/error' @@ -18,6 +20,26 @@ import type { NextRequest } from 'next/server' const DEFAULT_PAYOUT = 0.04 +// A/B test: 50% of users see the "choice" ad variant (4 ads as bullet points) +type AdVariant = 'banner' | 'choice' + +const CHOICE_AD_PLACEMENT_IDS = [ + 'choice-ad-1', + 'choice-ad-2', + 'choice-ad-3', + 'choice-ad-4', +] + +/** + * Deterministically assign a user to an ad variant based on their userId. + * Uses a hash so the assignment is stable across requests. + */ +function getAdVariant(userId: string): AdVariant { + const hash = createHash('sha256').update(`ad-variant:${userId}`).digest() + // Use first byte: even = banner, odd = choice (50/50 split) + return hash[0] % 2 === 0 ? 'banner' : 'choice' +} + const messageSchema = z.object({ role: z.string(), content: z.string(), @@ -143,15 +165,25 @@ export async function postAds(params: { } : undefined + // Determine A/B test variant for this user + const variant = getAdVariant(userId) + + // Build placements based on variant + const placements = + variant === 'choice' + ? CHOICE_AD_PLACEMENT_IDS.map((id) => ({ + placement: 'below_response', + placement_id: id, + })) + : [{ placement: 'below_response', placement_id: 'code-assist-ad' }] + try { const requestBody = { messages: filteredMessages, sessionId: sessionId ?? userId, - placements: [ - { placement: 'below_response', placement_id: 'code-assist-ad' }, - ], + placements, testAd: serverEnv.CB_ENVIRONMENT !== 'prod', - relevancy: 0.3, + relevancy: 0, ...(device ? { device } : {}), user: { id: userId, @@ -174,7 +206,7 @@ export async function postAds(params: { { request: requestBody, status: response.status }, '[ads] No ad available from Gravity API', ) - return NextResponse.json({ ad: null }, { status: 200 }) + return NextResponse.json({ ad: null, variant }, { status: 200 }) } // Check response.ok BEFORE parsing JSON to handle HTML error pages gracefully @@ -196,7 +228,7 @@ export async function postAds(params: { { request: requestBody, response: errorBody, status: response.status }, '[ads] Gravity API returned error', ) - return NextResponse.json({ ad: null }, { status: 200 }) + return NextResponse.json({ ad: null, variant }, { status: 200 }) } // Now safe to parse JSON body since response.ok is true @@ -207,16 +239,75 @@ export async function postAds(params: { { request: requestBody, response: ads, status: response.status }, '[ads] No ads returned from Gravity API', ) - return NextResponse.json({ ad: null }, { status: 200 }) + return NextResponse.json({ ad: null, variant }, { status: 200 }) } - const ad = ads[0] + // Store all returned ads in the database (skip duplicates via imp_url unique constraint) + // Wrapped in try/catch so DB failures don't prevent serving ads to the client + try { + for (const ad of ads) { + const payout = ad.payout || DEFAULT_PAYOUT + await db + .insert(schema.adImpression) + .values({ + user_id: userId, + ad_text: ad.adText, + title: ad.title, + cta: ad.cta, + url: ad.url, + favicon: ad.favicon, + click_url: ad.clickUrl, + imp_url: ad.impUrl, + payout: String(payout), + credits_granted: 0, + }) + .onConflictDoNothing() + } + } catch (dbError) { + logger.warn( + { + userId, + adCount: ads.length, + error: + dbError instanceof Error + ? { name: dbError.name, message: dbError.message } + : dbError, + }, + '[ads] Failed to persist ad_impression rows, serving ads anyway', + ) + } + + // Strip payout from all ads before returning to client + const sanitizeAd = (ad: Record) => { + const { payout: _payout, ...rest } = ad + return rest + } + if (variant === 'choice') { + // Return all ads for the choice variant (up to 4) + const sanitizedAds = ads.map(sanitizeAd) + + logger.info( + { + variant, + adCount: sanitizedAds.length, + request: requestBody, + status: response.status, + }, + '[ads] Fetched choice ads from Gravity API', + ) + + return NextResponse.json({ ads: sanitizedAds, variant }) + } + + // Banner variant: return single ad (existing behavior) + const ad = ads[0] const payout = ad.payout || DEFAULT_PAYOUT logger.info( { ad, + variant, request: requestBody, status: response.status, payout: { @@ -229,41 +320,7 @@ export async function postAds(params: { '[ads] Fetched ad from Gravity API', ) - // Insert ad_impression row to database (served_at = now) - // This stores the trusted ad data server-side so we don't have to trust the client later - try { - await db.insert(schema.adImpression).values({ - user_id: userId, - ad_text: ad.adText, - title: ad.title, - cta: ad.cta, - url: ad.url, - favicon: ad.favicon, - click_url: ad.clickUrl, - imp_url: ad.impUrl, - payout: String(payout), - credits_granted: 0, // Will be updated when impression is fired - }) - } catch (error) { - // If insert fails (e.g., duplicate impUrl), log but continue - // The ad can still be shown, it just won't be tracked - logger.warn( - { - userId, - impUrl: ad.impUrl, - status: response.status, - error: - error instanceof Error - ? { name: error.name, message: error.message } - : error, - }, - '[ads] Failed to create ad_impression record (likely duplicate)', - ) - } - - // Return ad to client without payout (credits will come from impression endpoint) - const { payout: _payout, ...adWithoutPayout } = ad - return NextResponse.json({ ad: adWithoutPayout }) + return NextResponse.json({ ad: sanitizeAd(ad), variant }) } catch (error) { logger.error( { @@ -278,7 +335,7 @@ export async function postAds(params: { '[ads] Failed to fetch ad from Gravity API', ) return NextResponse.json( - { ad: null, error: getErrorObject(error) }, + { ad: null, variant, error: getErrorObject(error) }, { status: 500 }, ) } diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index f8d7a4e808..51482b9f30 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -1,5 +1,3 @@ -import { createHash } from 'crypto' - import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -9,7 +7,6 @@ import { z } from 'zod' import { requireUserFromApiKey } from '../../_helpers' -import type { processAndGrantCredit as ProcessAndGrantCreditFn } from '@codebuff/billing/grant-credits' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { @@ -18,10 +15,6 @@ import type { } from '@codebuff/common/types/contracts/logger' import type { NextRequest } from 'next/server' -// Revenue share: users get 75% of payout as credits -const AD_REVENUE_SHARE = 0.75 -const MINIMUM_CREDITS_GRANTED = 2 - // Rate limiting: max impressions per user per hour const MAX_IMPRESSIONS_PER_HOUR = 60 @@ -78,22 +71,8 @@ function checkRateLimit(userId: string): boolean { return true } -/** - * Generate a deterministic operation ID for deduplication. - * Same user + same impUrl = same operationId, preventing duplicate credits. - */ -function generateImpressionOperationId(userId: string, impUrl: string): string { - const hash = createHash('sha256') - .update(`${userId}:${impUrl}`) - .digest('hex') - .slice(0, 16) - return `ad-imp-${hash}` -} - const bodySchema = z.object({ - // Only impUrl needed - we look up the ad data from our database impUrl: z.url(), - // Mode to determine if credits should be granted (FREE mode gets no credits) mode: z.string().optional(), }) @@ -103,7 +82,6 @@ export async function postAdImpression(params: { logger: Logger loggerWithContext: LoggerWithContextFn trackEvent: TrackEventFn - processAndGrantCredit: typeof ProcessAndGrantCreditFn fetch: typeof globalThis.fetch }) { const { @@ -111,14 +89,12 @@ export async function postAdImpression(params: { getUserInfoFromApiKey, loggerWithContext, trackEvent, - processAndGrantCredit, fetch, } = params const baseLogger = params.logger // Parse and validate request body let impUrl: string - let mode: string | undefined try { const json = await req.json() const parsed = bodySchema.safeParse(json) @@ -129,7 +105,6 @@ export async function postAdImpression(params: { ) } impUrl = parsed.data.impUrl - mode = parsed.data.mode } catch { return NextResponse.json( { error: 'Invalid JSON in request body' }, @@ -203,16 +178,10 @@ export async function postAdImpression(params: { ) } - // Get payout from the trusted database record - const payout = parseFloat(adRecord.payout) - - // Generate deterministic operation ID for deduplication - const operationId = generateImpressionOperationId(userId, impUrl) - // Fire the impression pixel to Gravity try { await fetch(impUrl) - logger.info({ userId, operationId, impUrl }, '[ads] Fired impression pixel') + logger.info({ userId, impUrl }, '[ads] Fired impression pixel') } catch (error) { logger.warn( { @@ -224,68 +193,11 @@ export async function postAdImpression(params: { }, '[ads] Failed to fire impression pixel', ) - // Continue anyway - we still want to grant credits + // Continue anyway - we still want to record the impression } - // Calculate credits to grant (75% of payout, converted to credits) - // Payout is in dollars, credits are 1:1 with cents, so multiply by 100 - const userShareDollars = payout * AD_REVENUE_SHARE - const creditsToGrant = Math.max( - MINIMUM_CREDITS_GRANTED + Math.floor(3 * Math.random()), - Math.floor(userShareDollars * 100), - ) - - let creditsGranted = 0 - // FREE mode should not grant any credits - if (mode !== 'FREE' && creditsToGrant > 0) { - try { - await processAndGrantCredit({ - userId, - amount: creditsToGrant, - type: 'ad', - description: `Ad impression credit (${(userShareDollars * 100).toFixed(1)}¢ from $${payout.toFixed(4)} payout)`, - expiresAt: null, // Ad credits don't expire - operationId, - logger, - }) - - creditsGranted = creditsToGrant - - logger.info( - { - userId, - payout, - creditsGranted, - operationId, - }, - '[ads] Granted ad impression credits', - ) - - trackEvent({ - event: AnalyticsEvent.CREDIT_GRANT, - userId, - properties: { - type: 'ad', - amount: creditsGranted, - payout, - }, - logger, - }) - } catch (error) { - logger.error( - { - userId, - payout, - error: - error instanceof Error - ? { name: error.name, message: error.message } - : error, - }, - '[ads] Failed to grant ad impression credits', - ) - // Don't fail the request - we still want to update the impression record - } - } + // No credits granted for ad impressions + const creditsGranted = 0 // Update the ad_impression record with impression details (for ALL modes) try { @@ -293,13 +205,13 @@ export async function postAdImpression(params: { .update(schema.adImpression) .set({ impression_fired_at: new Date(), - credits_granted: creditsGranted, - grant_operation_id: creditsGranted > 0 ? operationId : null, + credits_granted: 0, + grant_operation_id: null, }) .where(eq(schema.adImpression.id, adRecord.id)) logger.info( - { userId, impUrl, creditsGranted, creditsToGrant }, + { userId, impUrl }, '[ads] Updated ad impression record', ) } catch (error) { diff --git a/web/src/app/api/v1/ads/impression/route.ts b/web/src/app/api/v1/ads/impression/route.ts index dd36bfc7ec..1212ace244 100644 --- a/web/src/app/api/v1/ads/impression/route.ts +++ b/web/src/app/api/v1/ads/impression/route.ts @@ -1,4 +1,3 @@ -import { processAndGrantCredit } from '@codebuff/billing/grant-credits' import { trackEvent } from '@codebuff/common/analytics' import { postAdImpression } from './_post' @@ -15,7 +14,6 @@ export async function POST(req: NextRequest) { logger, loggerWithContext, trackEvent, - processAndGrantCredit, fetch, }) }