From 22815209a0631b50027ee84cb1d2633424a83d8d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 23 May 2026 23:46:49 -0700 Subject: [PATCH] Restore Carbon ads fallback --- cli/src/chat.tsx | 1 - cli/src/components/waiting-room-screen.tsx | 3 +- cli/src/hooks/use-gravity-ad.ts | 100 ++++---- web/src/app/api/v1/ads/__tests__/ads.test.ts | 214 ++++++++++++++++ web/src/app/api/v1/ads/_post.ts | 243 +++++++++++-------- 5 files changed, 397 insertions(+), 164 deletions(-) create mode 100644 web/src/app/api/v1/ads/__tests__/ads.test.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index b24f19c981..7f4d6031ee 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -177,7 +177,6 @@ export const Chat = ({ const { ads, recordClick, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription, provider: 'gravity', - fallbackProvider: 'zeroclick', }) // Set initial mode from CLI flag on mount diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 122e637be9..d7fab07cd1 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -298,12 +298,11 @@ export const WaitingRoomScreen: React.FC = ({ // Always enable ads in the waiting room — this is where monetization lives. // forceStart bypasses the "wait for first user message" gate inside the hook, // which would otherwise block ads here since no conversation exists yet. - // Try Gravity first, then fall back to ZeroClick when Gravity doesn't fill. + // The server tries Gravity first, then falls back to ZeroClick and Carbon. const { ads, recordClick, recordImpression } = useGravityAd({ enabled: true, forceStart: true, provider: 'gravity', - fallbackProvider: 'zeroclick', surface: 'waiting_room', }) diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 11491414c4..5a9dc3df45 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -92,17 +92,14 @@ export const useGravityAd = (options?: { /** Skip the "wait for first user message" gate. Used by the freebuff * waiting room, which has no conversation but still needs ads. */ forceStart?: boolean - /** Primary ad network to query. Defaults to Gravity. */ + /** Ad network to request first. The server owns fallback ordering. */ provider?: AdProvider - /** Backup ad network to try when the primary returns no fill or errors. */ - fallbackProvider?: AdProvider /** Product surface requesting the ad. The server maps this to placements. */ surface?: AdSurface }): GravityAdState => { const enabled = options?.enabled ?? true const forceStart = options?.forceStart ?? false const provider: AdProvider = options?.provider ?? 'gravity' - const fallbackProvider = options?.fallbackProvider const surface = options?.surface const [ads, setAds] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -305,61 +302,56 @@ export const useGravityAd = (options?: { } } - const providersToTry = - fallbackProvider && fallbackProvider !== provider - ? [provider, fallbackProvider] - : [provider] - - for (const providerToTry of providersToTry) { - try { - const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authToken}`, - 'User-Agent': getCliAdRequestUserAgent(), - }, - body: JSON.stringify({ - provider: providerToTry, - messages: adMessages, - sessionId: useChatStore.getState().chatSessionId, - device: getDeviceInfo(), - ...(surface ? { surface } : {}), - // Carbon requires a real browser-ish useragent for targeting/fraud - // detection. Gravity ignores it. We source one centrally so every - // provider that needs it sees the same value. - userAgent: getAdUserAgent(), - }), - }) + try { + const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + 'User-Agent': getCliAdRequestUserAgent(), + }, + body: JSON.stringify({ + provider, + messages: adMessages, + sessionId: useChatStore.getState().chatSessionId, + device: getDeviceInfo(), + ...(surface ? { surface } : {}), + // Carbon requires a real browser-ish useragent for targeting/fraud + // detection. Gravity ignores it. We source one centrally so every + // provider that needs it sees the same value. + userAgent: getAdUserAgent(), + }), + }) - if (!response.ok) { - logger.warn( - { - provider: providerToTry, - status: response.status, - response: await response.json(), - }, - '[ads] Web API returned error', - ) - continue + if (!response.ok) { + let responseBody: unknown + try { + const contentType = response.headers.get('content-type') ?? '' + responseBody = contentType.includes('application/json') + ? await response.json() + : await response.text() + } catch { + responseBody = 'Unable to parse error response' } + logger.warn( + { provider, status: response.status, response: responseBody }, + '[ads] Web API returned error', + ) + return null + } - const data = await response.json() + const data = await response.json() - if (Array.isArray(data.ads) && data.ads.length > 0) { - return { - ads: (data.ads as AdResponse[]).map((ad) => ({ - ...ad, - provider: data.provider ?? providerToTry, - })), - } + if (Array.isArray(data.ads) && data.ads.length > 0) { + return { + ads: (data.ads as AdResponse[]).map((ad) => ({ + ...ad, + provider: data.provider ?? provider, + })), } - } catch (err) { - logger.error( - { err, provider: providerToTry }, - '[ads] Failed to fetch ad', - ) } + } catch (err) { + logger.error({ err, provider }, '[ads] Failed to fetch ad') } return null @@ -434,7 +426,7 @@ export const useGravityAd = (options?: { return () => { clearInterval(id) } - }, [shouldStart, shouldHideAds, provider, fallbackProvider, surface]) + }, [shouldStart, shouldHideAds, provider, surface]) // Don't return ads when ads should be hidden const visible = shouldStart && !shouldHideAds diff --git a/web/src/app/api/v1/ads/__tests__/ads.test.ts b/web/src/app/api/v1/ads/__tests__/ads.test.ts new file mode 100644 index 0000000000..369f90108a --- /dev/null +++ b/web/src/app/api/v1/ads/__tests__/ads.test.ts @@ -0,0 +1,214 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' +import { NextRequest } from 'next/server' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' + +const insertedRows: unknown[] = [] + +const onConflictDoNothingMock = mock(() => Promise.resolve()) +const valuesMock = mock((row: unknown) => { + insertedRows.push(row) + return { onConflictDoNothing: onConflictDoNothingMock } +}) +const insertMock = mock(() => ({ values: valuesMock })) + +mock.module('@codebuff/internal/db', () => ({ + default: { + insert: insertMock, + }, +})) + +mock.module('@codebuff/internal/db/schema', () => ({ + adImpression: {}, +})) + +const { postAds } = await import('../_post') + +describe('/api/v1/ads POST endpoint', () => { + let logger: Logger + let loggerWithContext: LoggerWithContextFn + let trackEvent: TrackEventFn + + const getUserInfoFromApiKey: GetUserInfoFromApiKeyFn = async ({ + apiKey, + }) => { + if (apiKey !== 'test-key') return null + return { + id: 'user-123', + email: 'test@example.com', + discord_id: null, + } as Awaited> + } + + beforeEach(() => { + insertedRows.length = 0 + insertMock.mockClear() + valuesMock.mockClear() + onConflictDoNothingMock.mockClear() + + logger = { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + } + loggerWithContext = mock(() => logger) + trackEvent = mock(() => {}) + }) + + afterAll(() => { + mock.restore() + }) + + function createRequest(body: Record) { + return new NextRequest('http://localhost:3000/api/v1/ads', { + method: 'POST', + headers: { + Authorization: 'Bearer test-key', + 'Content-Type': 'application/json', + 'User-Agent': 'CodebuffCLI/1.0', + 'X-Forwarded-For': '203.0.113.10', + }, + body: JSON.stringify(body), + }) + } + + test('falls back from Gravity to ZeroClick to Carbon', async () => { + const upstreamUrls: string[] = [] + const fetchMock = mock( + async (url: string | URL | Request): Promise => { + const urlString = String(url) + upstreamUrls.push(urlString) + + if (urlString.includes('server.trygravity.ai')) { + return new Response(null, { status: 204 }) + } + + if (urlString.includes('zeroclick.dev')) { + return Response.json([]) + } + + if (urlString.includes('srv.buysellads.com')) { + return Response.json({ + ads: [ + { + statlink: '//srv.buysellads.com/click', + statimp: '//srv.buysellads.com/imp', + description: 'Carbon fallback ad', + company: 'Carbon Co', + callToAction: 'Try it', + image: 'https://example.com/carbon.png', + }, + ], + }) + } + + return new Response('unexpected upstream', { status: 500 }) + }, + ) + + const response = await postAds({ + req: createRequest({ + provider: 'gravity', + messages: [], + sessionId: 'session-123', + userAgent: 'Mozilla/5.0', + }), + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + fetch: fetchMock as unknown as typeof globalThis.fetch, + serverEnv: { + GRAVITY_API_KEY: 'gravity-key', + ZEROCLICK_API_KEY: 'zeroclick-key', + CARBON_ZONE_KEY: 'carbon-zone', + CB_ENVIRONMENT: 'prod', + }, + }) + + expect(response.status).toBe(200) + expect(upstreamUrls[0]).toContain('server.trygravity.ai') + expect(upstreamUrls[1]).toContain('zeroclick.dev') + expect(upstreamUrls[2]).toContain('srv.buysellads.com') + + const body = await response.json() + expect(body.provider).toBe('carbon') + expect(body.ads).toHaveLength(1) + expect(body.ads[0]).toMatchObject({ + adText: 'Carbon fallback ad', + title: 'Carbon Co', + clickUrl: 'https://srv.buysellads.com/click', + impUrl: 'https://srv.buysellads.com/imp', + }) + + expect(insertedRows).toHaveLength(1) + expect(insertedRows[0]).toMatchObject({ + user_id: 'user-123', + provider: 'carbon', + ad_text: 'Carbon fallback ad', + imp_url: 'https://srv.buysellads.com/imp', + }) + }) + + test('skips unconfigured providers and still reaches Carbon', async () => { + const upstreamUrls: string[] = [] + const fetchMock = mock( + async (url: string | URL | Request): Promise => { + const urlString = String(url) + upstreamUrls.push(urlString) + + if (urlString.includes('server.trygravity.ai')) { + return new Response(null, { status: 204 }) + } + + if (urlString.includes('srv.buysellads.com')) { + return Response.json({ + ads: [ + { + statlink: '//srv.buysellads.com/click', + statimp: '//srv.buysellads.com/imp', + description: 'Carbon fallback ad', + company: 'Carbon Co', + }, + ], + }) + } + + return new Response('unexpected upstream', { status: 500 }) + }, + ) + + const response = await postAds({ + req: createRequest({ + provider: 'gravity', + messages: [], + userAgent: 'Mozilla/5.0', + }), + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + fetch: fetchMock as unknown as typeof globalThis.fetch, + serverEnv: { + GRAVITY_API_KEY: 'gravity-key', + CARBON_ZONE_KEY: 'carbon-zone', + CB_ENVIRONMENT: 'prod', + }, + }) + + expect(response.status).toBe(200) + expect(upstreamUrls.some((url) => url.includes('zeroclick.dev'))).toBe( + false, + ) + + const body = await response.json() + expect(body.provider).toBe('carbon') + expect(body.ads).toHaveLength(1) + }) +}) diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 7762d151c1..c5a6908897 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -1,5 +1,4 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { getErrorObject } from '@codebuff/common/util/error' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { NextResponse } from 'next/server' @@ -61,6 +60,90 @@ function noAdsResponse(provider: AdProviderId) { return NextResponse.json({ ads: [], provider }, { status: 200 }) } +const providerFallbacks: Record = { + gravity: ['gravity', 'zeroclick', 'carbon'], + zeroclick: ['zeroclick', 'carbon'], + carbon: ['carbon'], +} + +function createConfiguredProvider( + providerId: AdProviderId, + serverEnv: AdsEnv, + logger: Logger, +): AdProvider | null { + switch (providerId) { + case 'carbon': + if (!serverEnv.CARBON_ZONE_KEY) { + logger.warn('[ads] CARBON_ZONE_KEY not configured') + return null + } + return createCarbonProvider({ zoneKey: serverEnv.CARBON_ZONE_KEY }) + case 'zeroclick': + if (!serverEnv.ZEROCLICK_API_KEY) { + logger.warn('[ads] ZEROCLICK_API_KEY not configured') + return null + } + return createZeroClickProvider({ apiKey: serverEnv.ZEROCLICK_API_KEY }) + case 'gravity': + if (!serverEnv.GRAVITY_API_KEY) { + logger.warn('[ads] GRAVITY_API_KEY not configured') + return null + } + return createGravityProvider({ apiKey: serverEnv.GRAVITY_API_KEY }) + } +} + +async function persistAdImpressions(params: { + ads: NormalizedAd[] + providerId: AdProviderId + userId: string + logger: Logger +}) { + const { ads, providerId, userId, logger } = params + + try { + await Promise.all( + ads.map((ad) => + db + .insert(schema.adImpression) + .values({ + user_id: userId, + provider: providerId, + ad_text: ad.adText, + title: ad.title, + cta: ad.cta, + url: ad.url, + favicon: ad.favicon, + click_url: ad.clickUrl, + imp_url: ad.impUrl, + extra_pixels: ad.extraPixels ?? null, + payout: ad.payout != null ? String(ad.payout) : null, + credits_granted: 0, + }) + .onConflictDoNothing(), + ), + ) + } catch (dbError) { + logger.warn( + { + userId, + provider: providerId, + adCount: ads.length, + error: + dbError instanceof Error + ? { name: dbError.name, message: dbError.message } + : dbError, + }, + '[ads] Failed to persist ad_impression rows, serving anyway', + ) + } +} + +function toClientAd(ad: NormalizedAd) { + const { payout: _p, extraPixels: _e, ...rest } = ad + return rest +} + export async function postAds(params: { req: NextRequest getUserInfoFromApiKey: GetUserInfoFromApiKeyFn @@ -122,121 +205,67 @@ export async function postAds(params: { parsedBody.userAgent ?? req.headers.get('user-agent') ?? undefined const requestUserAgent = req.headers.get('user-agent') ?? undefined - // Pick a provider. If the requested one isn't configured, return no ad - // rather than failing — the client falls back to its cache / fallback UI. - let provider: AdProvider | null = null - if (providerId === 'carbon') { - if (!serverEnv.CARBON_ZONE_KEY) { - logger.warn('[ads] CARBON_ZONE_KEY not configured') - return noAdsResponse(providerId) - } - provider = createCarbonProvider({ zoneKey: serverEnv.CARBON_ZONE_KEY }) - } else if (providerId === 'zeroclick') { - if (!serverEnv.ZEROCLICK_API_KEY) { - logger.warn('[ads] ZEROCLICK_API_KEY not configured') - return noAdsResponse(providerId) - } - provider = createZeroClickProvider({ apiKey: serverEnv.ZEROCLICK_API_KEY }) - } else { - if (!serverEnv.GRAVITY_API_KEY) { - logger.warn('[ads] GRAVITY_API_KEY not configured') - return noAdsResponse(providerId) - } - provider = createGravityProvider({ apiKey: serverEnv.GRAVITY_API_KEY }) - } + for (const providerToTry of providerFallbacks[providerId]) { + const provider = createConfiguredProvider(providerToTry, serverEnv, logger) + if (!provider) continue - try { - const result = await provider.fetchAd({ - userId, - userEmail: userInfo.email ?? null, - sessionId: parsedBody.sessionId, - clientIp, - userAgent, - requestUserAgent, - device: parsedBody.device, - surface: parsedBody.surface, - messages: parsedBody.messages, - testMode: serverEnv.CB_ENVIRONMENT !== 'prod', - logger, - fetch, - }) - - if (!result) { - return noAdsResponse(provider.id) - } - - // Persist served ads so the impression endpoint can validate + fire the - // correct pixels. Any DB failure is logged but doesn't block serving. try { - await Promise.all( - result.ads.map((ad) => - db - .insert(schema.adImpression) - .values({ - user_id: userId, - provider: provider.id, - ad_text: ad.adText, - title: ad.title, - cta: ad.cta, - url: ad.url, - favicon: ad.favicon, - click_url: ad.clickUrl, - imp_url: ad.impUrl, - extra_pixels: ad.extraPixels ?? null, - payout: ad.payout != null ? String(ad.payout) : null, - credits_granted: 0, - }) - .onConflictDoNothing(), - ), + const result = await provider.fetchAd({ + userId, + userEmail: userInfo.email ?? null, + sessionId: parsedBody.sessionId, + clientIp, + userAgent, + requestUserAgent, + device: parsedBody.device, + surface: parsedBody.surface, + messages: parsedBody.messages, + testMode: serverEnv.CB_ENVIRONMENT !== 'prod', + logger, + fetch, + }) + + if (!result) { + logger.debug( + { provider: provider.id }, + '[ads] Provider returned no fill', + ) + continue + } + + await persistAdImpressions({ + ads: result.ads, + providerId: provider.id, + userId, + logger, + }) + + logger.info( + { provider: provider.id, adCount: result.ads.length }, + '[ads] Fetched ads', ) - } catch (dbError) { - logger.warn( + return NextResponse.json({ + ads: result.ads.map(toClientAd), + provider: provider.id, + }) + } catch (error) { + logger.error( { userId, provider: provider.id, - adCount: result.ads.length, error: - dbError instanceof Error - ? { name: dbError.name, message: dbError.message } - : dbError, + error instanceof Error + ? { name: error.name, message: error.message } + : error, }, - '[ads] Failed to persist ad_impression rows, serving anyway', + '[ads] Failed to fetch ad', ) } - - // Strip server-only fields before sending to the CLI. - const toClient = (ad: NormalizedAd) => { - const { payout: _p, extraPixels: _e, ...rest } = ad - return rest - } - - logger.info( - { provider: provider.id, adCount: result.ads.length }, - '[ads] Fetched ads', - ) - return NextResponse.json({ - ads: result.ads.map(toClient), - provider: provider.id, - }) - } catch (error) { - logger.error( - { - userId, - provider: providerId, - error: - error instanceof Error - ? { name: error.name, message: error.message } - : error, - }, - '[ads] Failed to fetch ad', - ) - return NextResponse.json( - { - ads: [], - provider: providerId, - error: getErrorObject(error), - }, - { status: 500 }, - ) } + + logger.debug( + { requestedProvider: providerId }, + '[ads] No configured provider returned an ad', + ) + return noAdsResponse(providerId) }