diff --git a/.env.example b/.env.example index b62d5d11ea..17aba42c79 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,7 @@ STRIPE_SUBSCRIPTION_500_PRICE_ID=price_dummy_subscription_500_id # External Services LINKUP_API_KEY=dummy_linkup_key LOOPS_API_KEY=dummy_loops_key +ZEROCLICK_API_KEY=dummy_zeroclick_key # Discord Integration DISCORD_PUBLIC_KEY=dummy_discord_public_key diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts index fe102f0326..a6da96c58c 100644 --- a/agents/__tests__/base2.test.ts +++ b/agents/__tests__/base2.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, FREEBUFF_MINIMAX_MODEL_ID, @@ -13,6 +14,7 @@ describe('base2 reviewer selection', () => { [FREEBUFF_MINIMAX_MODEL_ID, 'code-reviewer-minimax'], [FREEBUFF_KIMI_MODEL_ID, 'code-reviewer-kimi'], [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, 'code-reviewer-deepseek'], + [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, 'code-reviewer-deepseek-flash'], ])('uses matching reviewer for model %p', (model, expectedReviewer) => { const base2 = createBase2('free', { model }) diff --git a/agents/base2/base2-free-deepseek-flash.ts b/agents/base2/base2-free-deepseek-flash.ts new file mode 100644 index 0000000000..77dd48543e --- /dev/null +++ b/agents/base2/base2-free-deepseek-flash.ts @@ -0,0 +1,13 @@ +import { FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID } from '@codebuff/common/constants/freebuff-models' + +import { createBase2 } from './base2' + +const definition = { + ...createBase2('free', { + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + }), + id: 'base2-free-deepseek-flash', + displayName: 'Buffy the DeepSeek Flash Free Orchestrator', +} + +export default definition diff --git a/agents/base2/base2-free-deepseek.ts b/agents/base2/base2-free-deepseek.ts index 6b40e34894..b73bb4730e 100644 --- a/agents/base2/base2-free-deepseek.ts +++ b/agents/base2/base2-free-deepseek.ts @@ -4,7 +4,6 @@ import { createBase2 } from './base2' const definition = { ...createBase2('free', { - noAskUser: true, model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, }), id: 'base2-free-deepseek', diff --git a/agents/reviewer/code-reviewer-deepseek-flash.ts b/agents/reviewer/code-reviewer-deepseek-flash.ts new file mode 100644 index 0000000000..23550079f2 --- /dev/null +++ b/agents/reviewer/code-reviewer-deepseek-flash.ts @@ -0,0 +1,13 @@ +import { FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID } from '@codebuff/common/constants/freebuff-models' + +import { publisher } from '../constants' +import type { SecretAgentDefinition } from '../types/secret-agent-definition' +import { createReviewer } from './code-reviewer' + +const definition: SecretAgentDefinition = { + id: 'code-reviewer-deepseek-flash', + publisher, + ...createReviewer(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID), +} + +export default definition diff --git a/agents/types/agent-definition.ts b/agents/types/agent-definition.ts index 2d05e4e0bf..030de3a14f 100644 --- a/agents/types/agent-definition.ts +++ b/agents/types/agent-definition.ts @@ -417,6 +417,8 @@ export type ModelName = // DeepSeek | 'deepseek/deepseek-v4-pro' | 'deepseek-v4-pro' + | 'deepseek/deepseek-v4-flash' + | 'deepseek-v4-flash' | 'deepseek/deepseek-chat-v3-0324' | 'deepseek/deepseek-chat-v3-0324:nitro' | 'deepseek/deepseek-r1-0528' diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index a8bae5b033..ba35cda9ee 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -177,7 +177,7 @@ export const Chat = ({ const { ads, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription, provider: 'gravity', - fallbackProvider: 'carbon', + fallbackProvider: 'zeroclick', }) // Set initial mode from CLI flag on mount diff --git a/cli/src/components/blocks/agent-branch-wrapper.tsx b/cli/src/components/blocks/agent-branch-wrapper.tsx index d07355735b..79c7b6ae00 100644 --- a/cli/src/components/blocks/agent-branch-wrapper.tsx +++ b/cli/src/components/blocks/agent-branch-wrapper.tsx @@ -17,6 +17,7 @@ import { ToolBlockGroup } from './tool-block-group' import { useTheme } from '../../hooks/use-theme' import { useChatStore } from '../../state/chat-store' import { isTextBlock } from '../../types/chat' +import { getAgentDisplayPrompt } from '../../utils/agent-display' import { getAgentStatusInfo } from '../../utils/agent-helpers' import { processBlocks, @@ -64,9 +65,10 @@ function getCollapsedPreview( } } - // Default preview: use initialPrompt or first line of text content - if (agentBlock.initialPrompt) { - return sanitizePreview(agentBlock.initialPrompt) + // Default preview: use the displayed prompt or first line of text content. + const displayPrompt = getAgentDisplayPrompt(agentBlock) + if (displayPrompt) { + return sanitizePreview(displayPrompt) } const textContent = @@ -413,6 +415,7 @@ export const AgentBranchWrapper = memo( // Compute collapsed preview text const preview = getCollapsedPreview(agentBlock, isStreaming, isCollapsed) + const displayPrompt = getAgentDisplayPrompt(agentBlock) const effectiveStatus = isStreaming ? 'running' : agentBlock.status const { @@ -429,7 +432,7 @@ export const AgentBranchWrapper = memo( void + onImpression?: (ad: AdResponse) => void } export const CHOICE_AD_BANNER_HEIGHT = 5 // border-top + 2 lines description + spacer + cta row + border-bottom @@ -82,7 +82,7 @@ export const ChoiceAdBanner: React.FC = ({ ads, onImpressio useEffect(() => { if (onImpression) { for (const ad of visibleAds) { - onImpression(ad.impUrl) + onImpression(ad) } } }, [visibleAds, onImpression]) diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index a07971cab8..87874a4cc2 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -234,12 +234,12 @@ 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 Carbon when Gravity doesn't fill. + // Try Gravity first, then fall back to ZeroClick when Gravity doesn't fill. const { ads, recordImpression } = useGravityAd({ enabled: true, forceStart: true, provider: 'gravity', - fallbackProvider: 'carbon', + fallbackProvider: 'zeroclick', surface: 'waiting_room', }) diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 0a7f2e9e6d..d012817860 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -15,6 +15,7 @@ const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then pause fetching new ads const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads const MAX_AD_CACHE_SIZE = 50 // Maximum number of ads to keep in cache +const ZEROCLICK_IMPRESSIONS_URL = 'https://zeroclick.dev/api/v2/impressions' // Ad response type (normalized shape across providers; credits added after impression) export type AdResponse = { @@ -25,6 +26,8 @@ export type AdResponse = { favicon: string clickUrl: string impUrl: string + provider?: AdProvider + impressionIds?: string[] credits?: number // Set after impression is recorded (in cents) } @@ -32,13 +35,13 @@ export type AdResponse = { * Which upstream ad network to query. The server maps each provider onto the * same normalized response shape, so the rest of the hook is provider-agnostic. */ -export type AdProvider = 'gravity' | 'carbon' +export type AdProvider = 'gravity' | 'carbon' | 'zeroclick' export type AdSurface = 'waiting_room' export type GravityAdState = { ads: AdResponse[] | null isLoading: boolean - recordImpression: (impUrl: string) => void + recordImpression: (ad: AdResponse) => void } // Consolidated controller state for the ad rotation logic @@ -52,6 +55,10 @@ type GravityController = { // Pure helper: add a choice ad set to the choice cache function addToChoiceCache(ctrl: GravityController, ads: AdResponse[]): void { + // ZeroClick offer responses must not be stored for later display. Keep them + // out of the rotation cache and only render them for the live request. + if (ads.some((ad) => ad.provider === 'zeroclick')) return + // 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 @@ -134,50 +141,89 @@ export const useGravityAd = (options?: { shouldHideAdsRef.current = shouldHideAds // Fire impression and update credits (called when showing an ad) - const recordImpressionOnce = (impUrl: string): void => { + const recordImpressionOnce = (ad: AdResponse): void => { // Don't record impressions when ads should be hidden if (shouldHideAdsRef.current) return const ctrl = ctrlRef.current + const { impUrl } = ad if (ctrl.impressionsFired.has(impUrl)) return ctrl.impressionsFired.add(impUrl) - const authToken = getAuthToken() - if (!authToken) { - logger.warn('[ads] No auth token, skipping impression recording') - return - } + const recordLocalImpression = async (): Promise => { + const authToken = getAuthToken() + if (!authToken) { + logger.warn('[ads] No auth token, skipping local impression recording') + return + } - // Include mode in request - Freebuff should not grant credits (no balance concept). - const agentMode = useChatStore.getState().agentMode + // Include mode in request - Freebuff should not grant credits (no balance concept). + const agentMode = useChatStore.getState().agentMode - fetch(`${WEBSITE_URL}/api/v1/ads/impression`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ impUrl, mode: agentMode }), - }) - .then((res) => res.json()) - .then((data) => { - if (data.creditsGranted > 0) { - logger.info( - { creditsGranted: data.creditsGranted }, - '[ads] Ad impression credits granted', + const res = await fetch(`${WEBSITE_URL}/api/v1/ads/impression`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ impUrl, mode: agentMode }), + }) + + if (!res.ok) { + logger.debug( + { status: res.status }, + '[ads] Failed to record local ad impression', + ) + return + } + + const data = await res.json() + if (data.creditsGranted > 0) { + logger.info( + { creditsGranted: data.creditsGranted }, + '[ads] Ad impression credits granted', + ) + // Also update credits in visible ads + setAds((cur) => { + if (!cur) return cur + return cur.map((a) => + a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a, ) - // Also update credits in visible ads - setAds((cur) => { - if (!cur) return cur - return cur.map((a) => - a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a, - ) + }) + } + } + + if (ad.provider === 'zeroclick' && ad.impressionIds?.length) { + void (async () => { + try { + const res = await fetch(ZEROCLICK_IMPRESSIONS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: ad.impressionIds }), }) + + if (!res.ok) { + logger.debug( + { status: res.status }, + '[ads] Failed to record ZeroClick impression', + ) + return + } + } catch (err) { + logger.debug({ err }, '[ads] Failed to record ZeroClick impression') + return } - }) - .catch((err) => { - logger.debug({ err }, '[ads] Failed to record ad impression') - }) + + recordLocalImpression().catch((err) => { + logger.debug({ err }, '[ads] Failed to record local ad impression') + }) + })() + return + } + + recordLocalImpression().catch((err) => { + logger.debug({ err }, '[ads] Failed to record ad impression') + }) } type FetchAdResult = { ads: AdResponse[] } | null @@ -265,7 +311,12 @@ export const useGravityAd = (options?: { const data = await response.json() if (Array.isArray(data.ads) && data.ads.length > 0) { - return { ads: data.ads as AdResponse[] } + return { + ads: (data.ads as AdResponse[]).map((ad) => ({ + ...ad, + provider: data.provider ?? providerToTry, + })), + } } } catch (err) { logger.error( @@ -305,6 +356,8 @@ export const useGravityAd = (options?: { if (cachedSet) { ctrl.adsShownSinceActivity += 1 setAds(cachedSet) + } else { + setAds((cur) => (cur?.[0]?.provider === 'zeroclick' ? null : cur)) } } } finally { diff --git a/cli/src/utils/__tests__/agent-display.test.ts b/cli/src/utils/__tests__/agent-display.test.ts new file mode 100644 index 0000000000..82e410dcfc --- /dev/null +++ b/cli/src/utils/__tests__/agent-display.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' + +import { getAgentDisplayPrompt } from '../agent-display' + +import type { AgentContentBlock } from '../../types/chat' + +const createAgentBlock = ( + overrides: Partial, +): AgentContentBlock => ({ + type: 'agent', + agentId: 'agent-1', + agentName: 'Basher', + agentType: 'basher', + content: '', + status: 'running', + blocks: [], + initialPrompt: '', + ...overrides, +}) + +describe('getAgentDisplayPrompt', () => { + test('uses initial prompt when present', () => { + const block = createAgentBlock({ + initialPrompt: 'Run tests', + params: { + what_to_summarize: 'Summarize failures', + }, + }) + + expect(getAgentDisplayPrompt(block)).toBe('Run tests') + }) + + test('uses basher what_to_summarize when prompt is omitted', () => { + const block = createAgentBlock({ + params: { + command: 'bun test', + what_to_summarize: 'Summarize failing tests only', + }, + }) + + expect(getAgentDisplayPrompt(block)).toBe('Summarize failing tests only') + }) + + test('normalizes scoped and versioned basher agent ids', () => { + const block = createAgentBlock({ + agentType: 'codebuff/basher@1.0.0', + params: { + what_to_summarize: 'Summarize command output', + }, + }) + + expect(getAgentDisplayPrompt(block)).toBe('Summarize command output') + }) + + test('ignores non-basher what_to_summarize params', () => { + const block = createAgentBlock({ + agentName: 'code-searcher', + agentType: 'code-searcher', + params: { + what_to_summarize: 'This is not a basher prompt', + }, + }) + + expect(getAgentDisplayPrompt(block)).toBeUndefined() + }) +}) diff --git a/cli/src/utils/__tests__/sdk-event-handlers.test.ts b/cli/src/utils/__tests__/sdk-event-handlers.test.ts index b86566b437..c1e2442656 100644 --- a/cli/src/utils/__tests__/sdk-event-handlers.test.ts +++ b/cli/src/utils/__tests__/sdk-event-handlers.test.ts @@ -295,6 +295,40 @@ describe('sdk-event-handlers', () => { expect(getStreamingAgents().size).toBe(0) }) + test('preserves spawn_agents params on placeholder agent blocks', () => { + const { ctx, getMessages, getStreamingAgents } = createTestContext() + const handleEvent = createEventHandler(ctx) + + handleEvent({ + type: 'tool_call', + toolCallId: 'tool-1', + toolName: 'spawn_agents', + input: { + agents: [ + { + agent_type: 'basher', + params: { + command: 'git status --short', + what_to_summarize: 'Report whether the worktree is clean', + }, + }, + ], + }, + agentId: 'main-agent', + parentAgentId: undefined, + } as any) + + const agentBlock = (getMessages()[0].blocks ?? [])[0] as AgentContentBlock + expect(agentBlock.agentId).toBe('tool-1-0') + expect(agentBlock.agentType).toBe('basher') + expect(agentBlock.initialPrompt).toBe('') + expect(agentBlock.params).toEqual({ + command: 'git status --short', + what_to_summarize: 'Report whether the worktree is clean', + }) + expect(getStreamingAgents().has('tool-1-0')).toBe(true) + }) + test('handles spawn_agents tool results and clears streaming agents', () => { const { ctx, getMessages, getStreamingAgents } = createTestContext() ctx.message.updater.addBlock( diff --git a/cli/src/utils/agent-display.ts b/cli/src/utils/agent-display.ts new file mode 100644 index 0000000000..18c3668fd4 --- /dev/null +++ b/cli/src/utils/agent-display.ts @@ -0,0 +1,21 @@ +import { getAgentBaseName } from './message-block-helpers' + +import type { AgentContentBlock } from '../types/chat' + +export function getAgentDisplayPrompt( + agentBlock: AgentContentBlock, +): string | undefined { + const initialPrompt = agentBlock.initialPrompt?.trim() + if (initialPrompt) { + return initialPrompt + } + + if (getAgentBaseName(agentBlock.agentType) !== 'basher') { + return undefined + } + + const whatToSummarize = agentBlock.params?.what_to_summarize + return typeof whatToSummarize === 'string' && whatToSummarize.trim() + ? whatToSummarize.trim() + : undefined +} diff --git a/cli/src/utils/sdk-event-handlers.ts b/cli/src/utils/sdk-event-handlers.ts index 42c273a82e..ca9ee14b6a 100644 --- a/cli/src/utils/sdk-event-handlers.ts +++ b/cli/src/utils/sdk-event-handlers.ts @@ -285,6 +285,7 @@ const handleSpawnAgentsToolCall = ( agentId: `${event.toolCallId}-${originalIndex}`, agentType: agent.agent_type || '', prompt: agent.prompt, + params: agent.params, spawnToolCallId: event.toolCallId, spawnIndex: originalIndex, parentAgentType, diff --git a/common/src/__tests__/free-agents.test.ts b/common/src/__tests__/free-agents.test.ts index 003e179b54..2a790b190a 100644 --- a/common/src/__tests__/free-agents.test.ts +++ b/common/src/__tests__/free-agents.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_GEMINI_PRO_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, @@ -24,6 +25,9 @@ describe('free mode agent model allowlist', () => { expect( getFreebuffRootAgentIdForModel(FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID), ).toBe('base2-free-deepseek') + expect( + getFreebuffRootAgentIdForModel(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID), + ).toBe('base2-free-deepseek-flash') }) test('allows each freebuff root agent only with its configured model', () => { @@ -48,6 +52,12 @@ describe('free mode agent model allowlist', () => { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, ), ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'base2-free-deepseek-flash', + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ), + ).toBe(true) }) test('allows each freebuff reviewer agent only with its configured model', () => { @@ -72,6 +82,12 @@ describe('free mode agent model allowlist', () => { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, ), ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-deepseek-flash', + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ), + ).toBe(true) }) test('allows legacy code-reviewer-lite with freebuff reviewer models', () => { @@ -90,6 +106,12 @@ describe('free mode agent model allowlist', () => { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, ), ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-lite', + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ), + ).toBe(true) }) test('allows the browser-use subagent with its bundled model', () => { diff --git a/common/src/__tests__/freebuff-models.test.ts b/common/src/__tests__/freebuff-models.test.ts index 87ba034773..efdbc8b435 100644 --- a/common/src/__tests__/freebuff-models.test.ts +++ b/common/src/__tests__/freebuff-models.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test' import { canFreebuffModelSpawnGeminiThinker, DEFAULT_FREEBUFF_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_GLM_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, @@ -12,6 +13,7 @@ import { getFreebuffDeploymentAvailabilityLabel, isFreebuffDeploymentHours, isFreebuffModelId, + isFreebuffPremiumModelId, isSupportedFreebuffModelId, } from '../constants/freebuff-models' @@ -27,6 +29,16 @@ describe('freebuff model availability', () => { expect(deepseek?.warning).toBe('Collects data for training') }) + test('DeepSeek V4 Flash is selectable and unlimited', () => { + expect(FREEBUFF_MODELS.map((model) => model.id)).toContain( + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ) + expect(isFreebuffModelId(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID)).toBe(true) + expect(isFreebuffPremiumModelId(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID)).toBe( + false, + ) + }) + test('only smart freebuff models can spawn the gemini-thinker subagent', () => { expect(canFreebuffModelSpawnGeminiThinker(FREEBUFF_KIMI_MODEL_ID)).toBe( true, @@ -37,6 +49,9 @@ describe('freebuff model availability', () => { expect(canFreebuffModelSpawnGeminiThinker(FREEBUFF_MINIMAX_MODEL_ID)).toBe( false, ) + expect( + canFreebuffModelSpawnGeminiThinker(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID), + ).toBe(false) }) test('supports GLM 5.1 as a legacy server-side model without selecting it for new clients', () => { diff --git a/common/src/constants/free-agents.ts b/common/src/constants/free-agents.ts index 0159132d9b..a14ca9f870 100644 --- a/common/src/constants/free-agents.ts +++ b/common/src/constants/free-agents.ts @@ -2,6 +2,7 @@ import { parseAgentId } from '../util/agent-id-parsing' import { FREEBUFF_GEMINI_THINKER_AGENT_ID } from './freebuff-gemini-thinker' import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_GEMINI_PRO_MODEL_ID, FREEBUFF_GLM_MODEL_ID, @@ -28,6 +29,7 @@ export const FREEBUFF_ROOT_AGENT_IDS = [ 'base2-free', 'base2-free-kimi', 'base2-free-deepseek', + 'base2-free-deepseek-flash', ] as const const FREEBUFF_ROOT_AGENT_ID_SET: ReadonlySet = new Set( FREEBUFF_ROOT_AGENT_IDS, @@ -40,12 +42,14 @@ export const FREEBUFF_ROOT_AGENT_ID_BY_MODEL: Record = { [FREEBUFF_MINIMAX_MODEL_ID]: 'base2-free', [FREEBUFF_KIMI_MODEL_ID]: 'base2-free-kimi', [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]: 'base2-free-deepseek', + [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID]: 'base2-free-deepseek-flash', } export const FREEBUFF_REVIEWER_AGENT_ID_BY_MODEL: Record = { [FREEBUFF_MINIMAX_MODEL_ID]: 'code-reviewer-minimax', [FREEBUFF_KIMI_MODEL_ID]: 'code-reviewer-kimi', [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]: 'code-reviewer-deepseek', + [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID]: 'code-reviewer-deepseek-flash', } export function getFreebuffRootAgentIdForModel(model: string): string { @@ -66,10 +70,12 @@ export const FREE_MODE_AGENT_MODELS: Record> = { FREEBUFF_MINIMAX_MODEL_ID, FREEBUFF_GLM_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, ]), 'base2-free-kimi': new Set([FREEBUFF_KIMI_MODEL_ID]), 'base2-free-deepseek': new Set([FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]), + 'base2-free-deepseek-flash': new Set([FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID]), // File exploration agents 'file-picker': new Set(['google/gemini-2.5-flash-lite']), @@ -93,12 +99,16 @@ export const FREE_MODE_AGENT_MODELS: Record> = { ]), 'code-reviewer-kimi': new Set([FREEBUFF_KIMI_MODEL_ID]), 'code-reviewer-deepseek': new Set([FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]), + 'code-reviewer-deepseek-flash': new Set([ + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ]), // Legacy freebuff clients spawned code-reviewer-lite under provider-specific // free roots before those reviewer IDs existed. 'code-reviewer-lite': new Set([ FREEBUFF_MINIMAX_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, ]), // Legacy: kept for the standalone gemini thinker agent if invoked directly. diff --git a/common/src/constants/freebuff-models.ts b/common/src/constants/freebuff-models.ts index 434ed35f45..173da1587b 100644 --- a/common/src/constants/freebuff-models.ts +++ b/common/src/constants/freebuff-models.ts @@ -34,6 +34,7 @@ export interface FreebuffModelOption { export const FREEBUFF_DEPLOYMENT_HOURS_LABEL = '9am ET-5pm PT every day' export const FREEBUFF_GEMINI_PRO_MODEL_ID = 'google/gemini-3.1-pro-preview' export const FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID = 'deepseek/deepseek-v4-pro' +export const FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID = 'deepseek/deepseek-v4-flash' export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1' export const FREEBUFF_KIMI_MODEL_ID = 'moonshotai/kimi-k2.6' export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7' @@ -86,6 +87,12 @@ export const FREEBUFF_MODELS = [ tagline: 'Fastest', availability: 'always', }, + { + id: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + displayName: 'DeepSeek V4 Flash', + tagline: 'Most efficient', + availability: 'always', + }, ] as const satisfies readonly FreebuffModelOption[] export const LEGACY_FREEBUFF_MODELS = [ diff --git a/common/src/constants/model-config.ts b/common/src/constants/model-config.ts index e86e2adfea..f45d0ed161 100644 --- a/common/src/constants/model-config.ts +++ b/common/src/constants/model-config.ts @@ -65,6 +65,8 @@ export const deepseekModels = { deepseekReasoner: 'deepseek-reasoner', deepseekV4ProDirect: 'deepseek-v4-pro', deepseekV4Pro: 'deepseek/deepseek-v4-pro', + deepseekV4FlashDirect: 'deepseek-v4-flash', + deepseekV4Flash: 'deepseek/deepseek-v4-flash', } as const export type DeepseekModel = (typeof deepseekModels)[keyof typeof deepseekModels] diff --git a/common/src/templates/initial-agents-dir/types/agent-definition.ts b/common/src/templates/initial-agents-dir/types/agent-definition.ts index 2d05e4e0bf..030de3a14f 100644 --- a/common/src/templates/initial-agents-dir/types/agent-definition.ts +++ b/common/src/templates/initial-agents-dir/types/agent-definition.ts @@ -417,6 +417,8 @@ export type ModelName = // DeepSeek | 'deepseek/deepseek-v4-pro' | 'deepseek-v4-pro' + | 'deepseek/deepseek-v4-flash' + | 'deepseek-v4-flash' | 'deepseek/deepseek-chat-v3-0324' | 'deepseek/deepseek-chat-v3-0324:nitro' | 'deepseek/deepseek-r1-0528' diff --git a/docs/freebuff-waiting-room.md b/docs/freebuff-waiting-room.md index 9713538810..25999fb339 100644 --- a/docs/freebuff-waiting-room.md +++ b/docs/freebuff-waiting-room.md @@ -153,18 +153,18 @@ The final tick result carries a `queueDepthByModel` map and a single `skipped` r ### Tunables -| Constant | Location | Default | Purpose | -|---|---|---|---| -| `ADMISSION_TICK_MS` | `config.ts` | 15000 | How often the ticker fires. Up to one user is admitted per model per tick. | -| `FREEBUFF_MODELS` | `common/src/constants/freebuff-models.ts` | `deepseek-v4-pro`, `kimi-k2.6`, `minimax-m2.7` | Selectable models; each gets its own queue and admission slot. | -| `FIREWORKS_DEPLOYMENT_MAP` | `web/src/llm-api/fireworks-config.ts` | `glm-5.1` | Models with dedicated Fireworks deployments. Models not listed are treated as `healthy` (serverless fallback) — drop this default when they migrate to their own deployments. | -| `HEALTH_CACHE_TTL_MS` | `fireworks-health.ts` | 25000 | Fleet probe cache TTL. Sits just under the Fireworks 30s exporter cadence and 6 req/min rate limit. | -| `FREEBUFF_SESSION_LENGTH_MS` | env | 3_600_000 | Session lifetime | -| `SESSION_GRACE_MS` | `web/src/server/free-session/config.ts` | 1_800_000 | Drain window after expiry — gate still admits requests so an in-flight agent can finish, but the CLI is expected to block new prompts. Hard cutoff at `expires_at + grace`. | +| Constant | Location | Default | Purpose | +| ---------------------------- | ----------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ADMISSION_TICK_MS` | `config.ts` | 15000 | How often the ticker fires. Up to one user is admitted per model per tick. | +| `FREEBUFF_MODELS` | `common/src/constants/freebuff-models.ts` | `deepseek-v4-pro`, `kimi-k2.6`, `minimax-m2.7`, `deepseek-v4-flash` | Selectable models; each gets its own queue and admission slot. | +| `FIREWORKS_DEPLOYMENT_MAP` | `web/src/llm-api/fireworks-config.ts` | `glm-5.1` | Models with dedicated Fireworks deployments. Models not listed are treated as `healthy` (serverless fallback) — drop this default when they migrate to their own deployments. | +| `HEALTH_CACHE_TTL_MS` | `fireworks-health.ts` | 25000 | Fleet probe cache TTL. Sits just under the Fireworks 30s exporter cadence and 6 req/min rate limit. | +| `FREEBUFF_SESSION_LENGTH_MS` | env | 3_600_000 | Session lifetime | +| `SESSION_GRACE_MS` | `web/src/server/free-session/config.ts` | 1_800_000 | Drain window after expiry — gate still admits requests so an in-flight agent can finish, but the CLI is expected to block new prompts. Hard cutoff at `expires_at + grace`. | ### Premium Session Quota -DeepSeek, Kimi, and legacy GLM share a per-user premium quota. The server counts `free_session_admit` rows from the last midnight in `America/Los_Angeles`; when the user reaches `FREEBUFF_PREMIUM_SESSION_LIMIT`, the next premium `POST /session` is rejected until the next Pacific midnight reset. MiniMax remains unlimited. +DeepSeek V4 Pro, Kimi, and legacy GLM share a per-user premium quota. The server counts `free_session_admit` rows from the last midnight in `America/Los_Angeles`; when the user reaches `FREEBUFF_PREMIUM_SESSION_LIMIT`, the next premium `POST /session` is rejected until the next Pacific midnight reset. MiniMax and DeepSeek V4 Flash remain unlimited. ## HTTP API @@ -264,13 +264,13 @@ For free-mode requests (`codebuff_metadata.cost_mode === 'free'`), `_post.ts` ca ### Response codes -| HTTP | `error` | When | -|---|---|---| -| 426 | `freebuff_update_required` | Request did not include a `freebuff_instance_id` — the client is a pre-waiting-room build. The CLI shows the server-supplied message verbatim. | -| 428 | `waiting_room_required` | No session row exists. Client should call POST /session. | -| 429 | `waiting_room_queued` | Row exists with `status='queued'`. Client should keep polling GET. | -| 409 | `session_superseded` | Claimed `instance_id` does not match stored one — another CLI took over. | -| 410 | `session_expired` | `expires_at + grace < now()` (past the hard cutoff). Client should POST /session to re-queue. | +| HTTP | `error` | When | +| ---- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| 426 | `freebuff_update_required` | Request did not include a `freebuff_instance_id` — the client is a pre-waiting-room build. The CLI shows the server-supplied message verbatim. | +| 428 | `waiting_room_required` | No session row exists. Client should call POST /session. | +| 429 | `waiting_room_queued` | Row exists with `status='queued'`. Client should keep polling GET. | +| 409 | `session_superseded` | Claimed `instance_id` does not match stored one — another CLI took over. | +| 410 | `session_expired` | `expires_at + grace < now()` (past the hard cutoff). Client should POST /session to re-queue. | Successful results carry one of three reasons: `disabled` (gate is off), `active` (`expires_at > now()`, `remainingMs` provided), or `draining` (`expires_at <= now() < expires_at + grace`, `gracePeriodRemainingMs` provided). The CLI should treat `draining` as "let any in-flight agent run finish, but block new user prompts" — see [Drain / Grace Window](#drain--grace-window) below. The corresponding wire status from `getSessionState` is `ended`. @@ -320,25 +320,25 @@ The `disabled` response means the server has the waiting room turned off. CLI tr - **`/api/v1/freebuff/session` routes** are stateless per pod; all state lives in Postgres. Any pod can serve any request. - **Chat completions gate** is a single `SELECT` per free-mode request. At high QPS this is the hottest path — the `user_id` PK lookup is O(1). If it ever becomes a problem, the obvious fix is to cache the session row for ~1s per pod. -- **Admission loop** runs on every pod. Per-model advisory locks serialize admission *within* each model while allowing different models to admit on different pods concurrently. At any given tick, exactly one pod actually admits for each model; the rest early-return on that model's lock. +- **Admission loop** runs on every pod. Per-model advisory locks serialize admission _within_ each model while allowing different models to admit on different pods concurrently. At any given tick, exactly one pod actually admits for each model; the rest early-return on that model's lock. - **Fleet health probe** is cached per-pod (`HEALTH_CACHE_TTL_MS`, 25s). Each pod hits the Fireworks metrics endpoint at most ~2.4/min, staying under the 6 req/min account rate limit with a comfortable margin. ## Abuse Resistance Summary -| Attack | Mitigation | -|---|---| -| CLI keeps submitting new prompts past `expires_at` | Trusted client; bounded by 30-min hard cutoff at `expires_at + grace`. After that the gate returns `session_expired` and the user must re-queue. | -| Multiple sessions per account | PK on `user_id` — structurally impossible | -| Multiple CLIs sharing one session | `active_instance_id` rotates on POST; stale id → 409 | -| Client-forged timestamps | All timestamps server-supplied (`DEFAULT now()` or explicit) | -| Queue jumping via timestamp manipulation | `queued_at` is server-supplied; FIFO order is server-determined | -| Repeatedly calling POST to reset queue position | POST preserves `queued_at` for already-queued users | -| Two pods admitting the same user | Per-model `SELECT ... FOR UPDATE SKIP LOCKED` + per-model advisory xact lock | -| Spamming POST/GET to starve admission tick | Admission uses per-model Postgres advisory locks; DDoS protection is upstream (Next's global rate limits). Consider adding a per-user limiter on `/session` if traffic warrants. | -| Repeatedly POSTing different models to get across every queue | Single row per user (PK on `user_id`); switching models moves the row, never clones it. A user holds exactly one queue slot at any time. | -| Fireworks metrics endpoint down / slow | `getFleetHealth()` fails closed (timeout, non-OK, or missing API key) → every dedicated-deployment model is flagged `unhealthy` and its queue pauses. | -| One deployment degraded while others are fine | Health is classified per-deployment; only the affected model's queue pauses, so a degraded GLM deployment doesn't block MiniMax admissions. | -| Zombie expired sessions holding capacity | Swept on every admission tick, even when upstream is unhealthy | +| Attack | Mitigation | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CLI keeps submitting new prompts past `expires_at` | Trusted client; bounded by 30-min hard cutoff at `expires_at + grace`. After that the gate returns `session_expired` and the user must re-queue. | +| Multiple sessions per account | PK on `user_id` — structurally impossible | +| Multiple CLIs sharing one session | `active_instance_id` rotates on POST; stale id → 409 | +| Client-forged timestamps | All timestamps server-supplied (`DEFAULT now()` or explicit) | +| Queue jumping via timestamp manipulation | `queued_at` is server-supplied; FIFO order is server-determined | +| Repeatedly calling POST to reset queue position | POST preserves `queued_at` for already-queued users | +| Two pods admitting the same user | Per-model `SELECT ... FOR UPDATE SKIP LOCKED` + per-model advisory xact lock | +| Spamming POST/GET to starve admission tick | Admission uses per-model Postgres advisory locks; DDoS protection is upstream (Next's global rate limits). Consider adding a per-user limiter on `/session` if traffic warrants. | +| Repeatedly POSTing different models to get across every queue | Single row per user (PK on `user_id`); switching models moves the row, never clones it. A user holds exactly one queue slot at any time. | +| Fireworks metrics endpoint down / slow | `getFleetHealth()` fails closed (timeout, non-OK, or missing API key) → every dedicated-deployment model is flagged `unhealthy` and its queue pauses. | +| One deployment degraded while others are fine | Health is classified per-deployment; only the affected model's queue pauses, so a degraded GLM deployment doesn't block MiniMax admissions. | +| Zombie expired sessions holding capacity | Swept on every admission tick, even when upstream is unhealthy | ## Testing diff --git a/freebuff/README.md b/freebuff/README.md index 27a199a446..7e757ce410 100644 --- a/freebuff/README.md +++ b/freebuff/README.md @@ -54,7 +54,7 @@ freebuff **How can it be free?** Freebuff is supported by ads shown in the CLI. -**What models do you use?** DeepSeek V4 Pro (default, but its API collects data for training) or Kimi K2.6 as the main coding agent. Gemini 3.1 Flash Lite for finding files and research, and GPT-5.4 for deep thinking if you connect your ChatGPT subscription. +**What models do you use?** DeepSeek V4 Pro (smartest, but its API collects data for training), Kimi K2.6, MiniMax M2.7, or DeepSeek V4 Flash as the main coding agent. Gemini 3.1 Flash Lite handles file finding and research, and GPT-5.4 handles deep thinking if you connect your ChatGPT subscription. **Are you training on my data?** No. We only use model providers that do not train on our requests. Your code stays yours. diff --git a/freebuff/SPEC.md b/freebuff/SPEC.md index ea973ba5a0..134cd471c7 100644 --- a/freebuff/SPEC.md +++ b/freebuff/SPEC.md @@ -72,19 +72,19 @@ Freebuff only supports **FREE mode**. All mode-related features are stripped. ### Commands to REMOVE in Freebuff -| Command | Reason | -| -------------------------------------------------- | --------------------------------------------------------- | -| `/subscribe` (+ `/strong`, `/sub`, `/buy-credits`) | No subscription model | -| `/usage` (+ `/credits`) | No credits display | -| `/ads:enable` | Ads always on, not toggleable | -| `/ads:disable` | Ads always on, not toggleable | -| `/connect:claude` (+ `/claude`) | Claude subscription not available | -| `/refer-friends` (+ `/referral`, `/redeem`) | Referrals earn credits, not applicable | -| `/mode:*` (all mode commands) | Only FREE mode | -| `/agent:gpt-5` | Premium agent, not available in free tier | -| `/review` | Uses thinker-gpt under the hood | -| `/publish` | Agent publishing not available in free tier | -| `/image` (+ `/img`, `/attach`) | Image attachments unavailable with free models (Kimi K2.6, DeepSeek V4 Pro) | +| Command | Reason | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `/subscribe` (+ `/strong`, `/sub`, `/buy-credits`) | No subscription model | +| `/usage` (+ `/credits`) | No credits display | +| `/ads:enable` | Ads always on, not toggleable | +| `/ads:disable` | Ads always on, not toggleable | +| `/connect:claude` (+ `/claude`) | Claude subscription not available | +| `/refer-friends` (+ `/referral`, `/redeem`) | Referrals earn credits, not applicable | +| `/mode:*` (all mode commands) | Only FREE mode | +| `/agent:gpt-5` | Premium agent, not available in free tier | +| `/review` | Uses thinker-gpt under the hood | +| `/publish` | Agent publishing not available in free tier | +| `/image` (+ `/img`, `/attach`) | Image attachments unavailable with free models (Kimi K2.6, DeepSeek V4 Pro, DeepSeek V4 Flash) | ### Commands to KEEP diff --git a/freebuff/web/src/app/home-client.tsx b/freebuff/web/src/app/home-client.tsx index 4721640f95..5e30128cc1 100644 --- a/freebuff/web/src/app/home-client.tsx +++ b/freebuff/web/src/app/home-client.tsx @@ -26,7 +26,7 @@ const faqs = [ { question: 'What models do you use?', answer: - 'You can choose from:\n\n- DeepSeek V4 Pro: smartest. Its API collects data for training.\n- Kimi K2.6: balanced.\n- MiniMax M2.7: fastest.\n\nAlso, Gemini 3.1 Flash Lite handles file finding and research. Connect your ChatGPT subscription to unlock GPT-5.4 for deep thinking.', + 'You can choose from:\n\n- DeepSeek V4 Pro: smartest. Its API collects data for training.\n- Kimi K2.6: balanced.\n- MiniMax M2.7: fastest.\n- DeepSeek V4 Flash: most efficient.\n\nAlso, Gemini 3.1 Flash Lite handles file finding and research. Connect your ChatGPT subscription to unlock GPT-5.4 for deep thinking.', }, { question: 'Which countries is Freebuff available in?', diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index f478663c39..8fe2e26787 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -16,6 +16,8 @@ export const serverEnvSchema = clientEnvSchema.extend({ CONTEXT7_API_KEY: z.string().optional(), GRAVITY_API_KEY: z.string().min(1), IPINFO_TOKEN: z.string().min(1), + // ZeroClick tenant API key used for server-side offer fallback requests. + ZEROCLICK_API_KEY: z.string().min(1).optional(), // BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad. // Optional: when unset the Carbon provider returns no ad and callers fall // back to their cached ads / fallback content. `CVADC53U` is the public @@ -98,6 +100,7 @@ export const serverProcessEnv: ServerInput = { CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY, GRAVITY_API_KEY: process.env.GRAVITY_API_KEY, IPINFO_TOKEN: process.env.IPINFO_TOKEN, + ZEROCLICK_API_KEY: process.env.ZEROCLICK_API_KEY, CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY, PORT: process.env.PORT, diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 370f11622b..51419d8fb5 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -9,6 +9,7 @@ import { requireUserFromApiKey } from '../_helpers' import { createCarbonProvider } from '@/lib/ad-providers/carbon' import { createGravityProvider } from '@/lib/ad-providers/gravity' +import { createZeroClickProvider } from '@/lib/ad-providers/zeroclick' import type { AdProvider, @@ -34,7 +35,9 @@ const deviceSchema = z.object({ locale: z.string().optional(), }) -const providerSchema = z.enum(['gravity', 'carbon']).default('gravity') +const providerSchema = z + .enum(['gravity', 'carbon', 'zeroclick']) + .default('gravity') const surfaceSchema = z.enum(['waiting_room']) const bodySchema = z.object({ @@ -50,6 +53,7 @@ const bodySchema = z.object({ export type AdsEnv = { GRAVITY_API_KEY: string CARBON_ZONE_KEY?: string + ZEROCLICK_API_KEY?: string CB_ENVIRONMENT: string } @@ -126,6 +130,12 @@ export async function postAds(params: { 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') diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index 3d6e53aeef..a1f3e04a3d 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -84,13 +84,8 @@ export async function postAdImpression(params: { trackEvent: TrackEventFn fetch: typeof globalThis.fetch }) { - const { - req, - getUserInfoFromApiKey, - loggerWithContext, - trackEvent, - fetch, - } = params + const { req, getUserInfoFromApiKey, loggerWithContext, trackEvent, fetch } = + params const baseLogger = params.logger // Parse and validate request body @@ -179,36 +174,39 @@ export async function postAdImpression(params: { } // Fire the primary impression pixel plus any provider-specific extra - // tracking pixels (Carbon returns these via the `pixel` field). Each extra - // pixel may contain `[timestamp]` which we substitute with unix seconds. - const now = Math.floor(Date.now() / 1000).toString() - const extraPixels = (adRecord.extra_pixels ?? []).map((p) => - p.replaceAll('[timestamp]', now), - ) - const pixelUrls = [impUrl, ...extraPixels] - - await Promise.all( - pixelUrls.map(async (pixelUrl) => { - try { - await fetch(pixelUrl) - } catch (error) { - logger.warn( - { - pixelUrl, - error: - error instanceof Error - ? { name: error.name, message: error.message } - : error, - }, - '[ads] Failed to fire impression pixel', - ) - } - }), - ) - logger.info( - { userId, provider: adRecord.provider, pixelCount: pixelUrls.length }, - '[ads] Fired impression pixels', - ) + // tracking pixels (Carbon returns these via the `pixel` field). ZeroClick + // impressions must be reported from the client device, so the CLI handles + // that directly and this endpoint only records our local state. + if (adRecord.provider !== 'zeroclick') { + const now = Math.floor(Date.now() / 1000).toString() + const extraPixels = (adRecord.extra_pixels ?? []).map((p) => + p.replaceAll('[timestamp]', now), + ) + const pixelUrls = [impUrl, ...extraPixels] + + await Promise.all( + pixelUrls.map(async (pixelUrl) => { + try { + await fetch(pixelUrl) + } catch (error) { + logger.warn( + { + pixelUrl, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to fire impression pixel', + ) + } + }), + ) + logger.info( + { userId, provider: adRecord.provider, pixelCount: pixelUrls.length }, + '[ads] Fired impression pixels', + ) + } // No credits granted for ad impressions const creditsGranted = 0 @@ -224,10 +222,7 @@ export async function postAdImpression(params: { }) .where(eq(schema.adImpression.id, adRecord.id)) - logger.info( - { userId, impUrl }, - '[ads] Updated ad impression record', - ) + logger.info({ userId, impUrl }, '[ads] Updated ad impression record') } catch (error) { logger.error( { diff --git a/web/src/app/api/v1/ads/route.ts b/web/src/app/api/v1/ads/route.ts index 0b90fd1eef..32c86d873f 100644 --- a/web/src/app/api/v1/ads/route.ts +++ b/web/src/app/api/v1/ads/route.ts @@ -19,6 +19,7 @@ export async function POST(req: NextRequest) { serverEnv: { GRAVITY_API_KEY: env.GRAVITY_API_KEY, CARBON_ZONE_KEY: env.CARBON_ZONE_KEY, + ZEROCLICK_API_KEY: env.ZEROCLICK_API_KEY, CB_ENVIRONMENT: env.NEXT_PUBLIC_CB_ENVIRONMENT, }, }) 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 84c49f4fe5..1ec5a37a51 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 @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, mock, it } from 'bun:test' import { NextRequest } from 'next/server' +import { TEST_USER_ID } from '@codebuff/common/constants/paths' import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_GEMINI_PRO_MODEL_ID, FREEBUFF_GLM_MODEL_ID, @@ -28,7 +30,7 @@ import type { GetUserPreferencesFn } from '../_post' describe('/api/v1/chat/completions POST endpoint', () => { const mockUserData: Record = { 'test-api-key-123': { - id: 'user-123', + id: TEST_USER_ID, banned: false, }, 'test-api-key-no-credits': { @@ -161,6 +163,13 @@ describe('/api/v1/chat/completions POST endpoint', () => { status: 'running', } } + if (runId === 'run-free-deepseek-flash') { + return { + agent_id: 'base2-free-deepseek-flash', + ancestor_run_ids: [], + status: 'running', + } + } if (runId === 'run-reviewer-direct') { return { agent_id: 'code-reviewer-minimax', @@ -795,9 +804,20 @@ describe('/api/v1/chat/completions POST endpoint', () => { FETCH_PATH_TEST_TIMEOUT_MS, ) - it( - 'lets the DeepSeek V4 free agent use the direct DeepSeek provider', - async () => { + it.each([ + { + codebuffModel: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + upstreamModel: 'deepseek-v4-pro', + runId: 'run-free-deepseek', + }, + { + codebuffModel: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + upstreamModel: 'deepseek-v4-flash', + runId: 'run-free-deepseek-flash', + }, + ])( + 'lets $codebuffModel use the direct DeepSeek provider', + async ({ codebuffModel, upstreamModel, runId }) => { const fetchedBodies: Record[] = [] const fetchedUrls: string[] = [] const fetchViaDeepSeek = mock( @@ -811,7 +831,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { return new Response( JSON.stringify({ id: 'test-id', - model: 'deepseek-v4-pro', + model: upstreamModel, choices: [{ message: { content: 'test response' } }], usage: { prompt_tokens: 10, @@ -834,10 +854,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { method: 'POST', headers: allowedFreeModeHeaders('test-api-key-new-free'), body: JSON.stringify({ - model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + model: codebuffModel, stream: false, codebuff_metadata: { - run_id: 'run-free-deepseek', + run_id: runId, client_id: 'test-client-id-123', cost_mode: 'free', }, @@ -861,8 +881,8 @@ describe('/api/v1/chat/completions POST endpoint', () => { const body = await response.json() expect(response.status).toBe(200) expect(fetchedUrls[0]).toBe('https://api.deepseek.com/chat/completions') - expect(fetchedBodies[0].model).toBe('deepseek-v4-pro') - expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID) + expect(fetchedBodies[0].model).toBe(upstreamModel) + expect(body.model).toBe(codebuffModel) expect(body.provider).toBe('DeepSeek') }, FETCH_PATH_TEST_TIMEOUT_MS, diff --git a/web/src/lib/ad-providers/types.ts b/web/src/lib/ad-providers/types.ts index ced439e8f7..8f6558d31f 100644 --- a/web/src/lib/ad-providers/types.ts +++ b/web/src/lib/ad-providers/types.ts @@ -6,7 +6,7 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' * shape to expect when firing impressions. Add a new id here when wiring in * another provider (e.g. 'zeroclick'). */ -export type AdProviderId = 'gravity' | 'carbon' +export type AdProviderId = 'gravity' | 'carbon' | 'zeroclick' /** * Normalized ad shape returned by every provider. The CLI renders against @@ -22,6 +22,12 @@ export type NormalizedAd = { clickUrl: string /** Primary impression pixel URL. Fired once when the ad becomes visible. */ impUrl: string + /** + * Provider-specific impression ids that must be reported from the client + * device. ZeroClick impressions use POST /api/v2/impressions with offer ids, + * not a GET pixel URL. + */ + impressionIds?: string[] /** * Additional impression pixels (e.g. Carbon's `pixel` field). Each string * may contain `[timestamp]` which must be substituted at fire time. diff --git a/web/src/lib/ad-providers/zeroclick.ts b/web/src/lib/ad-providers/zeroclick.ts new file mode 100644 index 0000000000..af332cb938 --- /dev/null +++ b/web/src/lib/ad-providers/zeroclick.ts @@ -0,0 +1,182 @@ +import { createHash, randomUUID } from 'node:crypto' + +import type { + AdMessage, + AdProvider, + FetchAdInput, + FetchAdResult, + NormalizedAd, +} from './types' + +const ZEROCLICK_OFFERS_URL = 'https://zeroclick.dev/api/v2/offers' +const ZEROCLICK_CHOICE_LIMIT = 4 +const MAX_QUERY_LENGTH = 280 + +type ZeroClickOffer = { + id: string + title: string | null + subtitle?: string | null + content: string | null + cta: string | null + clickUrl: string + imageUrl?: string | null + brand?: { + name?: string | null + url?: string | null + iconUrl?: string | null + } | null + product?: { + title?: string | null + category?: string | null + image?: string | null + } | null +} + +function stableHash(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +function extractLastUserMessageContent(content: string): string { + const regex = /([\s\S]*?)<\/user_message>/gi + const matches = [...content.matchAll(regex)] + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1] + return lastMatch[1].trim() + } + return content.trim() +} + +function queryFromMessages(messages: AdMessage[]): string | null { + const lastUser = [...messages] + .reverse() + .find((m) => m.role === 'user' && m.content.trim()) + if (!lastUser) return null + + const query = extractLastUserMessageContent(lastUser.content) + .replace(/\s+/g, ' ') + .trim() + if (!query) return null + + return query.length > MAX_QUERY_LENGTH + ? query.slice(0, MAX_QUERY_LENGTH).trim() + : query +} + +function normalize(raw: ZeroClickOffer, servedId: string): NormalizedAd | null { + if (!raw.id || !raw.clickUrl) return null + + const title = + raw.title?.trim() || + raw.product?.title?.trim() || + raw.brand?.name?.trim() || + 'Sponsored' + const content = [raw.subtitle, raw.content] + .map((part) => part?.trim()) + .filter(Boolean) + .join(' ') + + return { + adText: content || title, + title, + cta: raw.cta?.trim() || 'Learn more', + url: raw.brand?.url?.trim() || '', + favicon: + raw.imageUrl?.trim() || + raw.product?.image?.trim() || + raw.brand?.iconUrl?.trim() || + '', + clickUrl: raw.clickUrl, + // Keep this URL-shaped so existing client/server validation can identify + // the served ad. The actual ZeroClick impression is a client-side POST using + // impressionIds, so do not put provider tracking IDs in this local key. + impUrl: `https://codebuff.com/ads/zeroclick-impression/${servedId}`, + impressionIds: [raw.id], + } +} + +export function createZeroClickProvider(config: { + apiKey: string +}): AdProvider { + return { + id: 'zeroclick', + fetchAd: async (input: FetchAdInput): Promise => { + const { + userId, + sessionId, + clientIp, + userAgent, + device, + messages = [], + logger, + fetch, + } = input + + if (!clientIp) { + logger.debug('[ads:zeroclick] Missing required clientIp') + return null + } + + const query = queryFromMessages(messages) + const requestBody = { + method: 'server', + ipAddress: clientIp, + ...(userAgent ? { userAgent } : {}), + origin: 'https://codebuff.com', + ...(query ? { query } : {}), + limit: ZEROCLICK_CHOICE_LIMIT, + groupingId: input.surface ?? 'choice', + userId: `codebuff:${stableHash(userId)}`, + userSessionId: sessionId + ? `codebuff:${stableHash(sessionId)}` + : undefined, + userLocale: device?.locale, + } + + const response = await fetch(ZEROCLICK_OFFERS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-zc-api-key': config.apiKey, + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + let errorBody: unknown + try { + const contentType = response.headers.get('content-type') ?? '' + errorBody = contentType.includes('application/json') + ? await response.json() + : await response.text() + } catch { + errorBody = 'Unable to parse error response' + } + logger.error( + { + request: { ...requestBody, ipAddress: '[redacted]' }, + response: errorBody, + status: response.status, + }, + '[ads:zeroclick] API returned error', + ) + return null + } + + const offers = (await response.json()) as ZeroClickOffer[] | unknown + if (!Array.isArray(offers) || offers.length === 0) { + logger.debug('[ads:zeroclick] No offers returned') + return null + } + + const ads = offers + .map((offer) => normalize(offer, randomUUID())) + .filter((ad) => ad !== null) + if (ads.length === 0) { + logger.debug('[ads:zeroclick] No renderable offers returned') + return null + } + + return { ads } + }, + } +} diff --git a/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts index 35ba1957bc..fb9d58e216 100644 --- a/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts +++ b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts @@ -51,6 +51,18 @@ describe('normalizeDeepSeekRequestBody', () => { }) }) + it('maps DeepSeek V4 Flash to the direct DeepSeek model id', () => { + const body: ChatCompletionRequestBody = { + model: 'deepseek/deepseek-v4-flash', + messages: [{ role: 'user', content: 'Hello' }], + } + + expect(normalizeDeepSeekRequestBody(body)).toEqual({ + ...body, + model: 'deepseek-v4-flash', + }) + }) + it('does not throw on minimal provider-path bodies without messages', () => { const body = { model: 'deepseek/deepseek-v4-pro', diff --git a/web/src/llm-api/deepseek-request-body.ts b/web/src/llm-api/deepseek-request-body.ts index 582e690ef7..33c3ffcb59 100644 --- a/web/src/llm-api/deepseek-request-body.ts +++ b/web/src/llm-api/deepseek-request-body.ts @@ -5,6 +5,8 @@ import type { ChatCompletionRequestBody } from './types' export const DEEPSEEK_MODEL_IDS: Record = { [deepseekModels.deepseekV4ProDirect]: deepseekModels.deepseekV4ProDirect, [deepseekModels.deepseekV4Pro]: deepseekModels.deepseekV4ProDirect, + [deepseekModels.deepseekV4FlashDirect]: deepseekModels.deepseekV4FlashDirect, + [deepseekModels.deepseekV4Flash]: deepseekModels.deepseekV4FlashDirect, } export function getDeepSeekModelId(openrouterModel: string): string { diff --git a/web/src/llm-api/deepseek.ts b/web/src/llm-api/deepseek.ts index 0378514102..e2adfdfca9 100644 --- a/web/src/llm-api/deepseek.ts +++ b/web/src/llm-api/deepseek.ts @@ -1,6 +1,7 @@ import { Agent } from 'undici' import { PROFIT_MARGIN } from '@codebuff/common/constants/limits' +import { deepseekModels } from '@codebuff/common/constants/model-config' import { getErrorObject } from '@codebuff/common/util/error' import { env } from '@codebuff/internal/env' @@ -43,6 +44,17 @@ const DEEPSEEK_V4_PRO_PRICING: DeepSeekPricing = { outputCostPerToken: 0.87 / 1_000_000, } +const DEEPSEEK_V4_FLASH_PRICING: DeepSeekPricing = { + inputCostPerToken: 0.14 / 1_000_000, + cachedInputCostPerToken: 0.0028 / 1_000_000, + outputCostPerToken: 0.28 / 1_000_000, +} + +const DEEPSEEK_PRICING_BY_DIRECT_MODEL_ID: Record = { + [deepseekModels.deepseekV4ProDirect]: DEEPSEEK_V4_PRO_PRICING, + [deepseekModels.deepseekV4FlashDirect]: DEEPSEEK_V4_FLASH_PRICING, +} + const DEEPSEEK_MODELS: Record< string, { deepseekId: string; pricing: DeepSeekPricing } @@ -51,7 +63,7 @@ const DEEPSEEK_MODELS: Record< model, { deepseekId, - pricing: DEEPSEEK_V4_PRO_PRICING, + pricing: getPricingForDeepSeekId(deepseekId), }, ]), ) @@ -70,6 +82,14 @@ function getDeepSeekPricing(model: string): DeepSeekPricing { return entry.pricing } +function getPricingForDeepSeekId(deepseekId: string): DeepSeekPricing { + const pricing = DEEPSEEK_PRICING_BY_DIRECT_MODEL_ID[deepseekId] + if (!pricing) { + throw new Error(`No DeepSeek pricing found for direct model: ${deepseekId}`) + } + return pricing +} + type StreamState = { responseText: string reasoningText: string diff --git a/web/src/server/free-session/config.ts b/web/src/server/free-session/config.ts index b096fd9890..da51cee0e7 100644 --- a/web/src/server/free-session/config.ts +++ b/web/src/server/free-session/config.ts @@ -1,4 +1,5 @@ import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_GLM_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, @@ -55,6 +56,7 @@ export function getSessionGraceMs(): number { * queue). */ const INSTANT_ADMIT_CAPACITY: Record = { + [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID]: 1000, [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]: 1000, [FREEBUFF_GLM_MODEL_ID]: 50, [FREEBUFF_KIMI_MODEL_ID]: 1000,