Skip to content

Commit b15bfdc

Browse files
[codex] Restore Carbon ads fallback (#741)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent 1c601b3 commit b15bfdc

5 files changed

Lines changed: 397 additions & 164 deletions

File tree

cli/src/chat.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ export const Chat = ({
177177
const { ads, recordClick, recordImpression } = useGravityAd({
178178
enabled: IS_FREEBUFF || !hasSubscription,
179179
provider: 'gravity',
180-
fallbackProvider: 'zeroclick',
181180
})
182181

183182
// Set initial mode from CLI flag on mount

cli/src/components/waiting-room-screen.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,12 +298,11 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
298298
// Always enable ads in the waiting room — this is where monetization lives.
299299
// forceStart bypasses the "wait for first user message" gate inside the hook,
300300
// which would otherwise block ads here since no conversation exists yet.
301-
// Try Gravity first, then fall back to ZeroClick when Gravity doesn't fill.
301+
// The server tries Gravity first, then falls back to ZeroClick and Carbon.
302302
const { ads, recordClick, recordImpression } = useGravityAd({
303303
enabled: true,
304304
forceStart: true,
305305
provider: 'gravity',
306-
fallbackProvider: 'zeroclick',
307306
surface: 'waiting_room',
308307
})
309308

cli/src/hooks/use-gravity-ad.ts

Lines changed: 46 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,14 @@ export const useGravityAd = (options?: {
9292
/** Skip the "wait for first user message" gate. Used by the freebuff
9393
* waiting room, which has no conversation but still needs ads. */
9494
forceStart?: boolean
95-
/** Primary ad network to query. Defaults to Gravity. */
95+
/** Ad network to request first. The server owns fallback ordering. */
9696
provider?: AdProvider
97-
/** Backup ad network to try when the primary returns no fill or errors. */
98-
fallbackProvider?: AdProvider
9997
/** Product surface requesting the ad. The server maps this to placements. */
10098
surface?: AdSurface
10199
}): GravityAdState => {
102100
const enabled = options?.enabled ?? true
103101
const forceStart = options?.forceStart ?? false
104102
const provider: AdProvider = options?.provider ?? 'gravity'
105-
const fallbackProvider = options?.fallbackProvider
106103
const surface = options?.surface
107104
const [ads, setAds] = useState<AdResponse[] | null>(null)
108105
const [isLoading, setIsLoading] = useState(false)
@@ -305,61 +302,56 @@ export const useGravityAd = (options?: {
305302
}
306303
}
307304

308-
const providersToTry =
309-
fallbackProvider && fallbackProvider !== provider
310-
? [provider, fallbackProvider]
311-
: [provider]
312-
313-
for (const providerToTry of providersToTry) {
314-
try {
315-
const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, {
316-
method: 'POST',
317-
headers: {
318-
'Content-Type': 'application/json',
319-
Authorization: `Bearer ${authToken}`,
320-
'User-Agent': getCliAdRequestUserAgent(),
321-
},
322-
body: JSON.stringify({
323-
provider: providerToTry,
324-
messages: adMessages,
325-
sessionId: useChatStore.getState().chatSessionId,
326-
device: getDeviceInfo(),
327-
...(surface ? { surface } : {}),
328-
// Carbon requires a real browser-ish useragent for targeting/fraud
329-
// detection. Gravity ignores it. We source one centrally so every
330-
// provider that needs it sees the same value.
331-
userAgent: getAdUserAgent(),
332-
}),
333-
})
305+
try {
306+
const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, {
307+
method: 'POST',
308+
headers: {
309+
'Content-Type': 'application/json',
310+
Authorization: `Bearer ${authToken}`,
311+
'User-Agent': getCliAdRequestUserAgent(),
312+
},
313+
body: JSON.stringify({
314+
provider,
315+
messages: adMessages,
316+
sessionId: useChatStore.getState().chatSessionId,
317+
device: getDeviceInfo(),
318+
...(surface ? { surface } : {}),
319+
// Carbon requires a real browser-ish useragent for targeting/fraud
320+
// detection. Gravity ignores it. We source one centrally so every
321+
// provider that needs it sees the same value.
322+
userAgent: getAdUserAgent(),
323+
}),
324+
})
334325

335-
if (!response.ok) {
336-
logger.warn(
337-
{
338-
provider: providerToTry,
339-
status: response.status,
340-
response: await response.json(),
341-
},
342-
'[ads] Web API returned error',
343-
)
344-
continue
326+
if (!response.ok) {
327+
let responseBody: unknown
328+
try {
329+
const contentType = response.headers.get('content-type') ?? ''
330+
responseBody = contentType.includes('application/json')
331+
? await response.json()
332+
: await response.text()
333+
} catch {
334+
responseBody = 'Unable to parse error response'
345335
}
336+
logger.warn(
337+
{ provider, status: response.status, response: responseBody },
338+
'[ads] Web API returned error',
339+
)
340+
return null
341+
}
346342

347-
const data = await response.json()
343+
const data = await response.json()
348344

349-
if (Array.isArray(data.ads) && data.ads.length > 0) {
350-
return {
351-
ads: (data.ads as AdResponse[]).map((ad) => ({
352-
...ad,
353-
provider: data.provider ?? providerToTry,
354-
})),
355-
}
345+
if (Array.isArray(data.ads) && data.ads.length > 0) {
346+
return {
347+
ads: (data.ads as AdResponse[]).map((ad) => ({
348+
...ad,
349+
provider: data.provider ?? provider,
350+
})),
356351
}
357-
} catch (err) {
358-
logger.error(
359-
{ err, provider: providerToTry },
360-
'[ads] Failed to fetch ad',
361-
)
362352
}
353+
} catch (err) {
354+
logger.error({ err, provider }, '[ads] Failed to fetch ad')
363355
}
364356

365357
return null
@@ -434,7 +426,7 @@ export const useGravityAd = (options?: {
434426
return () => {
435427
clearInterval(id)
436428
}
437-
}, [shouldStart, shouldHideAds, provider, fallbackProvider, surface])
429+
}, [shouldStart, shouldHideAds, provider, surface])
438430

439431
// Don't return ads when ads should be hidden
440432
const visible = shouldStart && !shouldHideAds
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
2+
import { NextRequest } from 'next/server'
3+
4+
import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics'
5+
import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database'
6+
import type {
7+
Logger,
8+
LoggerWithContextFn,
9+
} from '@codebuff/common/types/contracts/logger'
10+
11+
const insertedRows: unknown[] = []
12+
13+
const onConflictDoNothingMock = mock(() => Promise.resolve())
14+
const valuesMock = mock((row: unknown) => {
15+
insertedRows.push(row)
16+
return { onConflictDoNothing: onConflictDoNothingMock }
17+
})
18+
const insertMock = mock(() => ({ values: valuesMock }))
19+
20+
mock.module('@codebuff/internal/db', () => ({
21+
default: {
22+
insert: insertMock,
23+
},
24+
}))
25+
26+
mock.module('@codebuff/internal/db/schema', () => ({
27+
adImpression: {},
28+
}))
29+
30+
const { postAds } = await import('../_post')
31+
32+
describe('/api/v1/ads POST endpoint', () => {
33+
let logger: Logger
34+
let loggerWithContext: LoggerWithContextFn
35+
let trackEvent: TrackEventFn
36+
37+
const getUserInfoFromApiKey: GetUserInfoFromApiKeyFn = async ({
38+
apiKey,
39+
}) => {
40+
if (apiKey !== 'test-key') return null
41+
return {
42+
id: 'user-123',
43+
email: 'test@example.com',
44+
discord_id: null,
45+
} as Awaited<ReturnType<GetUserInfoFromApiKeyFn>>
46+
}
47+
48+
beforeEach(() => {
49+
insertedRows.length = 0
50+
insertMock.mockClear()
51+
valuesMock.mockClear()
52+
onConflictDoNothingMock.mockClear()
53+
54+
logger = {
55+
error: mock(() => {}),
56+
warn: mock(() => {}),
57+
info: mock(() => {}),
58+
debug: mock(() => {}),
59+
}
60+
loggerWithContext = mock(() => logger)
61+
trackEvent = mock(() => {})
62+
})
63+
64+
afterAll(() => {
65+
mock.restore()
66+
})
67+
68+
function createRequest(body: Record<string, unknown>) {
69+
return new NextRequest('http://localhost:3000/api/v1/ads', {
70+
method: 'POST',
71+
headers: {
72+
Authorization: 'Bearer test-key',
73+
'Content-Type': 'application/json',
74+
'User-Agent': 'CodebuffCLI/1.0',
75+
'X-Forwarded-For': '203.0.113.10',
76+
},
77+
body: JSON.stringify(body),
78+
})
79+
}
80+
81+
test('falls back from Gravity to ZeroClick to Carbon', async () => {
82+
const upstreamUrls: string[] = []
83+
const fetchMock = mock(
84+
async (url: string | URL | Request): Promise<Response> => {
85+
const urlString = String(url)
86+
upstreamUrls.push(urlString)
87+
88+
if (urlString.includes('server.trygravity.ai')) {
89+
return new Response(null, { status: 204 })
90+
}
91+
92+
if (urlString.includes('zeroclick.dev')) {
93+
return Response.json([])
94+
}
95+
96+
if (urlString.includes('srv.buysellads.com')) {
97+
return Response.json({
98+
ads: [
99+
{
100+
statlink: '//srv.buysellads.com/click',
101+
statimp: '//srv.buysellads.com/imp',
102+
description: 'Carbon fallback ad',
103+
company: 'Carbon Co',
104+
callToAction: 'Try it',
105+
image: 'https://example.com/carbon.png',
106+
},
107+
],
108+
})
109+
}
110+
111+
return new Response('unexpected upstream', { status: 500 })
112+
},
113+
)
114+
115+
const response = await postAds({
116+
req: createRequest({
117+
provider: 'gravity',
118+
messages: [],
119+
sessionId: 'session-123',
120+
userAgent: 'Mozilla/5.0',
121+
}),
122+
getUserInfoFromApiKey,
123+
logger,
124+
loggerWithContext,
125+
trackEvent,
126+
fetch: fetchMock as unknown as typeof globalThis.fetch,
127+
serverEnv: {
128+
GRAVITY_API_KEY: 'gravity-key',
129+
ZEROCLICK_API_KEY: 'zeroclick-key',
130+
CARBON_ZONE_KEY: 'carbon-zone',
131+
CB_ENVIRONMENT: 'prod',
132+
},
133+
})
134+
135+
expect(response.status).toBe(200)
136+
expect(upstreamUrls[0]).toContain('server.trygravity.ai')
137+
expect(upstreamUrls[1]).toContain('zeroclick.dev')
138+
expect(upstreamUrls[2]).toContain('srv.buysellads.com')
139+
140+
const body = await response.json()
141+
expect(body.provider).toBe('carbon')
142+
expect(body.ads).toHaveLength(1)
143+
expect(body.ads[0]).toMatchObject({
144+
adText: 'Carbon fallback ad',
145+
title: 'Carbon Co',
146+
clickUrl: 'https://srv.buysellads.com/click',
147+
impUrl: 'https://srv.buysellads.com/imp',
148+
})
149+
150+
expect(insertedRows).toHaveLength(1)
151+
expect(insertedRows[0]).toMatchObject({
152+
user_id: 'user-123',
153+
provider: 'carbon',
154+
ad_text: 'Carbon fallback ad',
155+
imp_url: 'https://srv.buysellads.com/imp',
156+
})
157+
})
158+
159+
test('skips unconfigured providers and still reaches Carbon', async () => {
160+
const upstreamUrls: string[] = []
161+
const fetchMock = mock(
162+
async (url: string | URL | Request): Promise<Response> => {
163+
const urlString = String(url)
164+
upstreamUrls.push(urlString)
165+
166+
if (urlString.includes('server.trygravity.ai')) {
167+
return new Response(null, { status: 204 })
168+
}
169+
170+
if (urlString.includes('srv.buysellads.com')) {
171+
return Response.json({
172+
ads: [
173+
{
174+
statlink: '//srv.buysellads.com/click',
175+
statimp: '//srv.buysellads.com/imp',
176+
description: 'Carbon fallback ad',
177+
company: 'Carbon Co',
178+
},
179+
],
180+
})
181+
}
182+
183+
return new Response('unexpected upstream', { status: 500 })
184+
},
185+
)
186+
187+
const response = await postAds({
188+
req: createRequest({
189+
provider: 'gravity',
190+
messages: [],
191+
userAgent: 'Mozilla/5.0',
192+
}),
193+
getUserInfoFromApiKey,
194+
logger,
195+
loggerWithContext,
196+
trackEvent,
197+
fetch: fetchMock as unknown as typeof globalThis.fetch,
198+
serverEnv: {
199+
GRAVITY_API_KEY: 'gravity-key',
200+
CARBON_ZONE_KEY: 'carbon-zone',
201+
CB_ENVIRONMENT: 'prod',
202+
},
203+
})
204+
205+
expect(response.status).toBe(200)
206+
expect(upstreamUrls.some((url) => url.includes('zeroclick.dev'))).toBe(
207+
false,
208+
)
209+
210+
const body = await response.json()
211+
expect(body.provider).toBe('carbon')
212+
expect(body.ads).toHaveLength(1)
213+
})
214+
})

0 commit comments

Comments
 (0)