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
1 change: 0 additions & 1 deletion cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,12 +298,11 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
// 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',
})

Expand Down
100 changes: 46 additions & 54 deletions cli/src/hooks/use-gravity-ad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdResponse[] | null>(null)
const [isLoading, setIsLoading] = useState(false)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
214 changes: 214 additions & 0 deletions web/src/app/api/v1/ads/__tests__/ads.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<GetUserInfoFromApiKeyFn>>
}

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<string, unknown>) {
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<Response> => {
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<Response> => {
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)
})
})
Loading
Loading