diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index b555d67ed..6663c7e1e 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -11,9 +11,8 @@ import { } from 'react' import { useShallow } from 'zustand/react/shallow' -import { getAdsEnabled, handleAdsDisable } from './commands/ads' +import { getAdsEnabled } from './commands/ads' import { routeUserPrompt, addBashMessageToHistory } from './commands/router' -import { AdBanner } from './components/ad-banner' import { ChoiceAdBanner } from './components/choice-ad-banner' import { ChatInputBar } from './components/chat-input-bar' import { LoadPreviousButton } from './components/load-previous-button' @@ -175,13 +174,7 @@ export const Chat = ({ }) const hasSubscription = subscriptionData?.hasSubscription ?? false - const { ad, adData, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) - const [adsManuallyDisabled, setAdsManuallyDisabled] = useState(false) - - const handleDisableAds = useCallback(() => { - handleAdsDisable() - setAdsManuallyDisabled(true) - }, []) + const { adData, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) // Set initial mode from CLI flag on mount useEffect(() => { @@ -1466,19 +1459,11 @@ export const Chat = ({ /> )} - {ad && (IS_FREEBUFF || (!adsManuallyDisabled && getAdsEnabled())) && ( - adData?.variant === 'choice' ? ( - - ) : ( - - ) + {adData && (IS_FREEBUFF || getAdsEnabled()) && ( + )} {reviewMode ? ( diff --git a/cli/src/components/ad-banner.tsx b/cli/src/components/ad-banner.tsx deleted file mode 100644 index 4910952a7..000000000 --- a/cli/src/components/ad-banner.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { TextAttributes } from '@opentui/core' -import { safeOpen } from '../utils/open-url' -import React, { useState } from 'react' - -import { Button } from './button' -import { Clickable } from './clickable' -import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' -import { useTheme } from '../hooks/use-theme' -import { IS_FREEBUFF } from '../utils/constants' - -import type { AdResponse } from '../hooks/use-gravity-ad' - -interface AdBannerProps { - ad: AdResponse - onDisableAds: () => void - isFreeMode: boolean -} - -const extractDomain = (url: string): string => { - try { - const parsed = new URL(url) - return parsed.hostname.replace(/^www\./, '') - } catch { - return url - } -} - -export const AdBanner: React.FC = ({ ad, onDisableAds, isFreeMode }) => { - const theme = useTheme() - const { separatorWidth, terminalWidth } = useTerminalDimensions() - const [isLinkHovered, setIsLinkHovered] = useState(false) - const [showInfoPanel, setShowInfoPanel] = useState(false) - const [isAdLabelHovered, setIsAdLabelHovered] = useState(false) - const [isHideHovered, setIsHideHovered] = useState(false) - const [isCloseHovered, setIsCloseHovered] = useState(false) - - // Use 'url' field for display domain (the actual destination) - const domain = extractDomain(ad.url) - // Use cta field for button text, with title as fallback - const ctaText = ad.cta || ad.title || 'Learn more' - - // Calculate available width for ad text - // Account for: padding (2), "Ad ?" label with space (5) - const maxTextWidth = separatorWidth - 7 - - // Wrapper for hover detection - makes entire ad content clickable - const handleAdMouseOver = () => setIsLinkHovered(true) - const handleAdMouseOut = () => setIsLinkHovered(false) - const handleAdClick = () => { - if (ad.clickUrl) { - safeOpen(ad.clickUrl) - } - } - - return ( - - {/* Horizontal divider line */} - {'─'.repeat(terminalWidth)} - {/* Clickable ad content area - wrapped in Button for click detection */} - - {/* Info panel: shown when Ad label is clicked, below the ad */} - {showInfoPanel && ( - - {' ' + '┄'.repeat(separatorWidth - 2)} - - - {IS_FREEBUFF - ? 'Ads help keep Freebuff free.' - : 'Ads are optional. Feel free to hide them anytime.'} - - - - - {isFreeMode && !IS_FREEBUFF ? ( - - Ads are required in Free mode. - - ) : ( - <> - - · - - Use /ads:enable to show again - - - )} - - - )} - - ) -} diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 8913093a2..e67823f7a 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -2,7 +2,6 @@ import { TextAttributes } from '@opentui/core' import { useRenderer } from '@opentui/react' import React, { useMemo, useState } from 'react' -import { AdBanner } from './ad-banner' import { Button } from './button' import { ChoiceAdBanner } from './choice-ad-banner' import { FreebuffModelSelector } from './freebuff-model-selector' @@ -73,9 +72,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. - const { ad, adData, recordImpression } = useGravityAd({ + // Uses Carbon (BuySellAds); in-chat ads still use the Gravity default. + const { adData, recordImpression } = useGravityAd({ enabled: true, forceStart: true, + provider: 'carbon', }) useFreebuffCtrlCExit() @@ -261,21 +262,17 @@ export const WaitingRoomScreen: React.FC = ({ {/* Ad banner pinned to the bottom, same look-and-feel as in chat. */} - {ad && ( + {adData && ( - {adData?.variant === 'choice' ? ( - - ) : ( - {}} isFreeMode /> - )} + )} {/* Horizontal separator (mirrors chat input divider style) */} - {!ad && ( + {!adData && ( {'─'.repeat(terminalWidth)} diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index 02e419b30..a86870fe5 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -1,15 +1,18 @@ import { getErrorObject } from '@codebuff/common/util/error' import { + markFreebuffSessionCountryBlocked, markFreebuffSessionEnded, markFreebuffSessionSuperseded, refreshFreebuffSession, } from '../use-freebuff-session' import { getProjectRoot } from '../../project-files' import { useChatStore } from '../../state/chat-store' +import { IS_FREEBUFF } from '../../utils/constants' import { processBashContext } from '../../utils/bash-context-processor' import { markRunningAgentsAsCancelled } from '../../utils/block-operations' import { + getCountryCodeFromFreeModeError, getFreebuffGateErrorKind, isOutOfCreditsError, isFreeModeUnavailableError, @@ -389,6 +392,11 @@ export const handleRunCompletion = (params: { if (isFreeModeUnavailableError(output)) { updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE) + if (IS_FREEBUFF) { + markFreebuffSessionCountryBlocked( + getCountryCodeFromFreeModeError(output) ?? 'UNKNOWN', + ) + } finalizeAfterError() return } @@ -484,6 +492,11 @@ export const handleRunError = (params: { if (isFreeModeUnavailableError(error)) { updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE) + if (IS_FREEBUFF) { + markFreebuffSessionCountryBlocked( + getCountryCodeFromFreeModeError(error) ?? 'UNKNOWN', + ) + } return } diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index 407d4afd4..79deea1cf 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -280,6 +280,21 @@ export function markFreebuffSessionSuperseded(): void { controller?.apply({ status: 'superseded' }) } +/** Flip into the terminal `country_blocked` state from outside the poll loop. + * Used when the chat-completions gate rejects on country even though the + * session-level country check had failed open (null detection → admitted). + * Transitioning the session state here unmounts the Chat surface in favor of + * the waiting-room's country_blocked message, so the user can't keep typing + * and sending doomed requests. */ +export function markFreebuffSessionCountryBlocked(countryCode: string): void { + if (!IS_FREEBUFF) return + controller?.abort() + controller?.apply({ status: 'country_blocked', countryCode }) + // Best-effort DELETE so we don't hold a waiting-room seat on a session the + // server is already refusing to serve at chat time. + releaseFreebuffSlot().catch(() => {}) +} + /** Flip into the local `ended` state without an instanceId (server has lost * our row). The chat surface stays mounted with the rejoin banner. */ export function markFreebuffSessionEnded(): void { diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 5b48a97f2..e52b4bdd8 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -16,7 +16,7 @@ const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then p 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 -// Ad response type (matches Gravity API response, credits added after impression) +// Ad response type (normalized shape across providers; credits added after impression) export type AdResponse = { adText: string title: string @@ -30,6 +30,12 @@ export type AdResponse = { export type AdVariant = 'banner' | 'choice' +/** + * 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 AdData = | { variant: 'banner'; ad: AdResponse } | { variant: 'choice'; ads: AdResponse[] } @@ -102,9 +108,12 @@ 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 + /** Which ad network to query. Defaults to Gravity. */ + provider?: AdProvider }): GravityAdState => { const enabled = options?.enabled ?? true const forceStart = options?.forceStart ?? false + const provider: AdProvider = options?.provider ?? 'gravity' const [ad, setAd] = useState(null) const [adData, setAdData] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -159,7 +168,7 @@ export const useGravityAd = (options?: { const authToken = getAuthToken() if (!authToken) { - logger.warn('[gravity] No auth token, skipping impression recording') + logger.warn('[ads] No auth token, skipping impression recording') return } @@ -179,7 +188,7 @@ export const useGravityAd = (options?: { if (data.creditsGranted > 0) { logger.info( { creditsGranted: data.creditsGranted }, - '[gravity] Ad impression credits granted', + '[ads] Ad impression credits granted', ) setAd((cur) => cur?.impUrl === impUrl @@ -205,7 +214,7 @@ export const useGravityAd = (options?: { } }) .catch((err) => { - logger.debug({ err }, '[gravity] Failed to record ad impression') + logger.debug({ err }, '[ads] Failed to record ad impression') }) } @@ -235,7 +244,7 @@ export const useGravityAd = (options?: { const authToken = getAuthToken() if (!authToken) { - logger.warn('[gravity] No auth token available') + logger.warn('[ads] No auth token available') return null } @@ -277,16 +286,21 @@ export const useGravityAd = (options?: { Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ + provider, messages: adMessages, sessionId: useChatStore.getState().chatSessionId, device: getDeviceInfo(), + // 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( - { status: response.status, response: await response.json() }, - '[gravity] Web API returned error', + { provider, status: response.status, response: await response.json() }, + '[ads] Web API returned error', ) return null } @@ -304,7 +318,7 @@ export const useGravityAd = (options?: { return null } catch (err) { - logger.error({ err }, '[gravity] Failed to fetch ad') + logger.error({ err }, '[ads] Failed to fetch ad') return null } } @@ -465,3 +479,22 @@ function getDeviceInfo(): DeviceInfo { return { os, timezone, locale } } + +/** + * Useragent string passed to ad providers. Carbon (BuySellAds) requires a + * plausible browser useragent for targeting and fraud screening. We send a + * stable desktop Chrome-on-{os} UA per platform so targeting is consistent + * across users on the same platform without sharing anything identifying. + * + * Chrome version needs bumping periodically — stale UAs look bot-ish to ad + * networks. Last bumped: 2026-04-21. Revisit roughly every 6 months. + */ +const AD_CHROME_VERSION = '124.0.0.0' +function getAdUserAgent(): string { + const osUA: Record = { + darwin: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + win32: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + linux: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + } + return osUA[process.platform] ?? osUA.linux +} diff --git a/cli/src/utils/error-handling.ts b/cli/src/utils/error-handling.ts index 0ff889482..5bedce5d4 100644 --- a/cli/src/utils/error-handling.ts +++ b/cli/src/utils/error-handling.ts @@ -57,6 +57,22 @@ export const isFreeModeUnavailableError = (error: unknown): boolean => { return false } +/** + * Extract the detected countryCode off a free_mode_unavailable error, if the + * server included one. Used to populate the country_blocked screen after the + * chat-completions gate rejects a user whose session-level country check had + * previously failed open (null country detection → admitted → now blocked). + */ +export const getCountryCodeFromFreeModeError = ( + error: unknown, +): string | null => { + if (!isFreeModeUnavailableError(error)) return null + const candidate = (error as { countryCode?: unknown }).countryCode + return typeof candidate === 'string' && candidate.length > 0 + ? candidate + : null +} + /** * Freebuff waiting-room gate errors returned by /api/v1/chat/completions. * diff --git a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts index d3d1d65bd..eb982d368 100644 --- a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts +++ b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts @@ -233,6 +233,152 @@ describe('tool validation error handling', () => { expect(errorEvents.length).toBe(0) }) + it('should parse input JSON string from AI SDK before validation', async () => { + // The AI SDK can emit tool-call chunks with `input` as a raw JSON string + // when upstream schema validation fails and the repair function returns + // the original tool call unchanged. The stream parser should parse the + // string into an object before handing it to the tool executor. + const agentWithReadFiles: AgentTemplate = { + ...testAgentTemplate, + toolNames: ['read_files', 'end_turn'], + } + + const stringInputToolCallChunk = { + type: 'tool-call' as const, + toolName: 'read_files', + toolCallId: 'string-input-tool-call-id', + input: JSON.stringify({ paths: ['test.ts'] }) as any, + } + + async function* mockStream() { + yield stringInputToolCallChunk + return promptSuccess('mock-message-id') + } + + const sessionState = getInitialSessionState(mockFileContext) + const agentState = sessionState.mainAgentState + + agentRuntimeImpl.requestFiles = async () => ({ + 'test.ts': 'console.log("test")', + }) + + const responseChunks: (string | PrintModeEvent)[] = [] + + await processStream({ + ...agentRuntimeImpl, + agentContext: {}, + agentState, + agentStepId: 'test-step-id', + agentTemplate: agentWithReadFiles, + ancestorRunIds: [], + clientSessionId: 'test-session', + fileContext: mockFileContext, + fingerprintId: 'test-fingerprint', + fullResponse: '', + localAgentTemplates: { 'test-agent': agentWithReadFiles }, + messages: [], + prompt: 'test prompt', + repoId: undefined, + repoUrl: undefined, + runId: 'test-run-id', + signal: new AbortController().signal, + stream: mockStream(), + system: 'test system', + tools: {}, + userId: 'test-user', + userInputId: 'test-input-id', + onCostCalculated: async () => {}, + onResponseChunk: (chunk) => { + responseChunks.push(chunk) + }, + }) + + const toolCallEvents = responseChunks.filter( + (chunk): chunk is Extract => + typeof chunk !== 'string' && chunk.type === 'tool_call', + ) + expect(toolCallEvents.length).toBe(1) + expect(toolCallEvents[0].toolName).toBe('read_files') + expect(toolCallEvents[0].input).toEqual({ paths: ['test.ts'] }) + + const errorEvents = responseChunks.filter( + (chunk): chunk is Extract => + typeof chunk !== 'string' && chunk.type === 'error', + ) + expect(errorEvents.length).toBe(0) + }) + + it('should emit a clear error when tool input is an unparseable string', async () => { + const agentWithReadFiles: AgentTemplate = { + ...testAgentTemplate, + toolNames: ['read_files', 'end_turn'], + } + + const invalidStringToolCallChunk = { + type: 'tool-call' as const, + toolName: 'read_files', + toolCallId: 'invalid-string-tool-call-id', + input: '{"paths": ["test.ts"' as any, // truncated/malformed JSON + } + + async function* mockStream() { + yield invalidStringToolCallChunk + return promptSuccess('mock-message-id') + } + + const sessionState = getInitialSessionState(mockFileContext) + const agentState = sessionState.mainAgentState + + const responseChunks: (string | PrintModeEvent)[] = [] + + const result = await processStream({ + ...agentRuntimeImpl, + agentContext: {}, + agentState, + agentStepId: 'test-step-id', + agentTemplate: agentWithReadFiles, + ancestorRunIds: [], + clientSessionId: 'test-session', + fileContext: mockFileContext, + fingerprintId: 'test-fingerprint', + fullResponse: '', + localAgentTemplates: { 'test-agent': agentWithReadFiles }, + messages: [], + prompt: 'test prompt', + repoId: undefined, + repoUrl: undefined, + runId: 'test-run-id', + signal: new AbortController().signal, + stream: mockStream(), + system: 'test system', + tools: {}, + userId: 'test-user', + userInputId: 'test-input-id', + onCostCalculated: async () => {}, + onResponseChunk: (chunk) => { + responseChunks.push(chunk) + }, + }) + + const errorEvents = responseChunks.filter( + (chunk): chunk is Extract => + typeof chunk !== 'string' && chunk.type === 'error', + ) + expect(errorEvents.length).toBe(1) + expect(errorEvents[0].message).toContain( + 'tool arguments were a string, not a JSON object', + ) + expect(errorEvents[0].message).toContain('Original tool call input:') + + expect(result.hadToolCallError).toBe(true) + + const toolCallEvents = responseChunks.filter( + (chunk): chunk is Extract => + typeof chunk !== 'string' && chunk.type === 'tool_call', + ) + expect(toolCallEvents.length).toBe(0) + }) + it('should preserve tool_call/tool_result ordering when custom tool setup is async', async () => { const toolName = 'delayed_custom_tool' const agentWithCustomTool: AgentTemplate = { diff --git a/packages/agent-runtime/src/tool-stream-parser.ts b/packages/agent-runtime/src/tool-stream-parser.ts index 82a37111b..cd4ca58df 100644 --- a/packages/agent-runtime/src/tool-stream-parser.ts +++ b/packages/agent-runtime/src/tool-stream-parser.ts @@ -77,7 +77,17 @@ export async function* processStreamWithTools(params: { input: any contents?: string }): Promise { - const { toolName, input, contents } = params + const { toolName, contents } = params + let { input } = params + + // AI SDK sometimes emits tool-call chunks with a raw JSON string as `input` + // when its repair pass can't produce a parsed object. Try to parse; if it + // fails, leave as string — the executor surfaces a clear error. + if (typeof input === 'string') { + try { + input = JSON.parse(input) + } catch {} + } const processor = processors[toolName] ?? defaultProcessor(toolName) diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index da0cfbd3b..78906f4ab 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -51,6 +51,18 @@ export type ToolCallError = { error: string } & Pick +function stringInputError( + toolName: string, + toolCallId: string, +): ToolCallError { + return { + toolName, + toolCallId, + input: {}, + error: `Invalid parameters for ${toolName}: tool arguments were a string, not a JSON object. This usually means the model emitted malformed JSON (e.g. unescaped newlines or quotes inside a string value). Re-issue the tool call with properly escaped JSON.`, + } +} + export function parseRawToolCall(params: { rawToolCall: { toolName: T @@ -64,6 +76,10 @@ export function parseRawToolCall(params: { const processedParameters = rawToolCall.input const paramsSchema = toolParams[toolName].inputSchema + if (typeof processedParameters === 'string') { + return stringInputError(toolName, rawToolCall.toolCallId) + } + const result = paramsSchema.safeParse(processedParameters) if (!result.success) { @@ -388,6 +404,10 @@ export function parseRawCustomToolCall(params: { } } + if (typeof rawToolCall.input === 'string') { + return stringInputError(toolName, rawToolCall.toolCallId) + } + const processedParameters: Record = {} for (const [param, val] of Object.entries(rawToolCall.input ?? {})) { processedParameters[param] = val diff --git a/packages/internal/src/db/migrations/0045_mean_sleeper.sql b/packages/internal/src/db/migrations/0045_mean_sleeper.sql new file mode 100644 index 000000000..0f0f9c4d7 --- /dev/null +++ b/packages/internal/src/db/migrations/0045_mean_sleeper.sql @@ -0,0 +1,3 @@ +ALTER TABLE "ad_impression" ALTER COLUMN "payout" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "ad_impression" ADD COLUMN "provider" text DEFAULT 'gravity' NOT NULL;--> statement-breakpoint +ALTER TABLE "ad_impression" ADD COLUMN "extra_pixels" text[]; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0045_snapshot.json b/packages/internal/src/db/migrations/meta/0045_snapshot.json new file mode 100644 index 000000000..a421bd575 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0045_snapshot.json @@ -0,0 +1,3227 @@ +{ + "id": "76196ef1-2384-4edd-b832-c9ff8085d809", + "prevId": "108f2bd2-7ddc-4c15-b351-28f2b55d5348", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gravity'" + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extra_pixels": { + "name": "extra_pixels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": [ + "imp_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": [ + "publisher_id", + "id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": [ + "agent_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": [ + "user_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session": { + "name": "free_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "free_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active_instance_id": { + "name": "active_instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_session_queue": { + "name": "idx_free_session_queue", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_session_expiry": { + "name": "idx_free_session_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_user_id_user_id_fk": { + "name": "free_session_user_id_user_id_fk", + "tableFrom": "free_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": [ + "org_id", + "feature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": [ + "referrer_id", + "referred_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": [ + "fingerprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": [ + "pending", + "completed" + ] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": [ + "running", + "completed", + "skipped" + ] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": [ + "anthropic", + "gemini", + "openai" + ] + }, + "public.free_session_status": { + "name": "free_session_status", + "schema": "public", + "values": [ + "queued", + "active" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "referral_legacy", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": [ + "web", + "pat", + "cli" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index bba4ab5ed..f67ef37dc 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1776719872222, "tag": "0044_violet_stingray", "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1776813242936, + "tag": "0045_mean_sleeper", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index ba481c89a..b6f170d29 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -431,7 +431,10 @@ export const adImpression = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), - // Ad content from Gravity API + // Which upstream ad network served this ad ('gravity', 'carbon', 'zeroclick', ...) + provider: text('provider').notNull().default('gravity'), + + // Ad content (normalized across providers) ad_text: text('ad_text').notNull(), title: text('title').notNull(), cta: text('cta').notNull().default(''), @@ -439,7 +442,13 @@ export const adImpression = pgTable( favicon: text('favicon').notNull(), click_url: text('click_url').notNull(), imp_url: text('imp_url').notNull().unique(), // Unique to prevent duplicates - payout: numeric('payout', { precision: 10, scale: 6 }).notNull(), + // Extra tracking pixel URLs (e.g. Carbon's `pixel` field, `||`-separated). + // Each string may contain `[timestamp]` which is substituted at fire time. + extra_pixels: text('extra_pixels').array(), + // Payout is Gravity-shaped; Carbon uses CPM and reports no per-impression + // payout, so this is nullable to avoid polluting revenue dashboards with + // fake numbers. + payout: numeric('payout', { precision: 10, scale: 6 }), // Credit tracking credits_granted: integer('credits_granted').notNull(), diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 25ce2931d..98a874a7a 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -12,6 +12,11 @@ export const serverEnvSchema = clientEnvSchema.extend({ LINKUP_API_KEY: z.string().min(1), CONTEXT7_API_KEY: z.string().optional(), GRAVITY_API_KEY: z.string().min(1), + // 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 + // test key from BSA docs and is safe to use in dev. + CARBON_ZONE_KEY: z.string().min(1).optional(), PORT: z.coerce.number().min(1000), // Web/Database variables @@ -82,6 +87,7 @@ export const serverProcessEnv: ServerInput = { LINKUP_API_KEY: process.env.LINKUP_API_KEY, CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY, GRAVITY_API_KEY: process.env.GRAVITY_API_KEY, + CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY, PORT: process.env.PORT, // Web/Database variables diff --git a/packages/internal/src/env.ts b/packages/internal/src/env.ts index a0af1c971..b32f90564 100644 --- a/packages/internal/src/env.ts +++ b/packages/internal/src/env.ts @@ -35,6 +35,15 @@ if (isCI) { // Only log environment in non-production if (process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod') { console.log('Using environment:', process.env.NEXT_PUBLIC_CB_ENVIRONMENT) + + // `CVADC53U` is the public test zone documented by BuySellAds — safe to use + // in dev/CI so nobody has to configure anything to see Carbon ads render. + // Prod intentionally has no default: if CARBON_ZONE_KEY isn't set there, + // waiting-room requests return no ad rather than silently hitting test + // inventory. + if (!process.env.CARBON_ZONE_KEY) { + process.env.CARBON_ZONE_KEY = 'CVADC53U' + } } export const env = serverEnvSchema.parse(serverProcessEnv) diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 39daa5d31..fc1fa07a5 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -1,7 +1,4 @@ -import { createHash } from 'crypto' - import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { buildArray } from '@codebuff/common/util/array' import { getErrorObject } from '@codebuff/common/util/error' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -10,6 +7,14 @@ import { z } from 'zod' import { requireUserFromApiKey } from '../_helpers' +import { createCarbonProvider } from '@/lib/ad-providers/carbon' +import { createGravityProvider } from '@/lib/ad-providers/gravity' + +import type { + AdProvider, + AdProviderId, + NormalizedAd, +} from '@/lib/ad-providers/types' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { @@ -18,28 +23,6 @@ import type { } from '@codebuff/common/types/contracts/logger' import type { NextRequest } from 'next/server' -const DEFAULT_PAYOUT = 0.04 - -// A/B test: 50% of users see the "choice" ad variant (4 ads as bullet points) -type AdVariant = 'banner' | 'choice' - -const CHOICE_AD_PLACEMENT_IDS = [ - 'choice-ad-1', - 'choice-ad-2', - 'choice-ad-3', - 'choice-ad-4', -] - -/** - * Deterministically assign a user to an ad variant based on their userId. - * Uses a hash so the assignment is stable across requests. - */ -function getAdVariant(userId: string): AdVariant { - const hash = createHash('sha256').update(`ad-variant:${userId}`).digest() - // Use first byte: even = banner, odd = choice (50/50 split) - return hash[0] % 2 === 0 ? 'banner' : 'choice' -} - const messageSchema = z.object({ role: z.string(), content: z.string(), @@ -51,14 +34,20 @@ const deviceSchema = z.object({ locale: z.string().optional(), }) +const providerSchema = z.enum(['gravity', 'carbon']).default('gravity') + const bodySchema = z.object({ - messages: z.array(messageSchema), + provider: providerSchema.optional(), + messages: z.array(messageSchema).optional().default([]), sessionId: z.string().optional(), device: deviceSchema.optional(), + /** Browser/CLI useragent passed through to providers that require it. */ + userAgent: z.string().optional(), }) -export type GravityEnv = { +export type AdsEnv = { GRAVITY_API_KEY: string + CARBON_ZONE_KEY?: string CB_ENVIRONMENT: string } @@ -69,7 +58,7 @@ export async function postAds(params: { loggerWithContext: LoggerWithContextFn trackEvent: TrackEventFn fetch: typeof globalThis.fetch - serverEnv: GravityEnv + serverEnv: AdsEnv }) { const { req, @@ -92,22 +81,14 @@ export async function postAds(params: { const { userId, userInfo, logger } = authed.data - // Check if Gravity API key is configured - if (!serverEnv.GRAVITY_API_KEY) { - logger.warn('[ads] GRAVITY_API_KEY not configured') - return NextResponse.json({ ad: null }, { status: 200 }) - } - - // Extract client IP from request headers + // Client IP comes in via the load balancer's X-Forwarded-For header. Every + // provider that targets or bills by IP (Gravity, Carbon, ...) needs this. const forwardedFor = req.headers.get('x-forwarded-for') const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : (req.headers.get('x-real-ip') ?? undefined) - // Parse and validate request body - let messages: z.infer['messages'] - let sessionId: string | undefined - let deviceInfo: z.infer | undefined + let parsedBody: z.infer try { const json = await req.json() const parsed = bodySchema.safeParse(json) @@ -118,243 +99,144 @@ export async function postAds(params: { { status: 400 }, ) } - - // Filter out messages with no content and extract user message content from tags - messages = parsed.data.messages - .filter((message) => message.content) - .map((message) => { - // For user messages, extract content from the last tag if present - if (message.role === 'user') { - return { - ...message, - content: extractLastUserMessageContent(message.content), - } - } - return message - }) - sessionId = parsed.data.sessionId - deviceInfo = parsed.data.device + parsedBody = parsed.data } catch { - logger.error( - { error: 'Invalid JSON in request body' }, - '[ads] Invalid request body', - ) return NextResponse.json( { error: 'Invalid JSON in request body' }, { status: 400 }, ) } - // Keep just the last user message and the last assistant message before it - const lastUserMessageIndex = messages.findLastIndex( - (message) => message.role === 'user', - ) - const lastUserMessage = messages[lastUserMessageIndex] - const lastAssistantMessage = messages - .slice(0, lastUserMessageIndex) - .findLast((message) => message.role === 'assistant') - const filteredMessages = buildArray(lastAssistantMessage, lastUserMessage) - - // Build device object for Gravity API - const device = clientIp - ? { - ip: clientIp, - ...(deviceInfo?.os ? { os: deviceInfo.os } : {}), - ...(deviceInfo?.timezone ? { timezone: deviceInfo.timezone } : {}), - ...(deviceInfo?.locale ? { locale: deviceInfo.locale } : {}), + const providerId: AdProviderId = parsedBody.provider ?? 'gravity' + const userAgent = + parsedBody.userAgent ?? 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 NextResponse.json({ ad: null, provider: providerId }, { status: 200 }) } - : undefined - - // Determine A/B test variant for this user - const variant = getAdVariant(userId) - - // Build placements based on variant - const placements = - variant === 'choice' - ? CHOICE_AD_PLACEMENT_IDS.map((id) => ({ - placement: 'below_response', - placement_id: id, - })) - : [{ placement: 'below_response', placement_id: 'code-assist-ad' }] + provider = createCarbonProvider({ zoneKey: serverEnv.CARBON_ZONE_KEY }) + } else { + if (!serverEnv.GRAVITY_API_KEY) { + logger.warn('[ads] GRAVITY_API_KEY not configured') + return NextResponse.json({ ad: null, provider: providerId }, { status: 200 }) + } + provider = createGravityProvider({ apiKey: serverEnv.GRAVITY_API_KEY }) + } try { - const requestBody = { - messages: filteredMessages, - sessionId: sessionId ?? userId, - placements, - testAd: serverEnv.CB_ENVIRONMENT !== 'prod', - relevancy: 0, - ...(device ? { device } : {}), - user: { - id: userId, - email: userInfo.email, - }, - } - // Call Gravity API - const response = await fetch('https://server.trygravity.ai/api/v1/ad', { - method: 'POST', - headers: { - Authorization: `Bearer ${serverEnv.GRAVITY_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), + const result = await provider.fetchAd({ + userId, + userEmail: userInfo.email ?? null, + sessionId: parsedBody.sessionId, + clientIp, + userAgent, + device: parsedBody.device, + messages: parsedBody.messages, + testMode: serverEnv.CB_ENVIRONMENT !== 'prod', + logger, + fetch, }) - // Handle 204 No Content first (no body to parse) - if (response.status === 204) { - logger.debug( - { request: requestBody, status: response.status }, - '[ads] No ad available from Gravity API', - ) - return NextResponse.json({ ad: null, variant }, { status: 200 }) - } - - // Check response.ok BEFORE parsing JSON to handle HTML error pages gracefully - if (!response.ok) { - // Try to get response body for logging, but don't fail if it's not JSON - let errorBody: unknown - try { - const contentType = response.headers.get('content-type') ?? '' - if (contentType.includes('application/json')) { - errorBody = await response.json() - } else { - // Likely an HTML error page from load balancer/CDN - errorBody = await response.text() - } - } catch { - errorBody = 'Unable to parse error response' - } - logger.error( - { request: requestBody, response: errorBody, status: response.status }, - '[ads] Gravity API returned error', + if (!result) { + return NextResponse.json( + { ad: null, provider: provider.id }, + { status: 200 }, ) - return NextResponse.json({ ad: null, variant }, { status: 200 }) } - // Now safe to parse JSON body since response.ok is true - const ads = await response.json() + const adsToPersist: NormalizedAd[] = + result.variant === 'choice' ? result.ads : [result.ad] - if (!Array.isArray(ads) || ads.length === 0) { - logger.debug( - { request: requestBody, response: ads, status: response.status }, - '[ads] No ads returned from Gravity API', - ) - return NextResponse.json({ ad: null, variant }, { status: 200 }) - } - - // Store all returned ads in the database (skip duplicates via imp_url unique constraint) - // Wrapped in try/catch so DB failures don't prevent serving ads to the client + // Persist served ads so the impression endpoint can validate + fire the + // correct pixels. Any DB failure is logged but doesn't block serving. try { - for (const ad of ads) { - const payout = ad.payout || DEFAULT_PAYOUT - await db - .insert(schema.adImpression) - .values({ - user_id: userId, - ad_text: ad.adText, - title: ad.title, - cta: ad.cta, - url: ad.url, - favicon: ad.favicon, - click_url: ad.clickUrl, - imp_url: ad.impUrl, - payout: String(payout), - credits_granted: 0, - }) - .onConflictDoNothing() - } + await Promise.all( + adsToPersist.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(), + ), + ) } catch (dbError) { logger.warn( { userId, - adCount: ads.length, + provider: provider.id, + adCount: adsToPersist.length, error: dbError instanceof Error ? { name: dbError.name, message: dbError.message } : dbError, }, - '[ads] Failed to persist ad_impression rows, serving ads anyway', + '[ads] Failed to persist ad_impression rows, serving anyway', ) } - // Strip payout from all ads before returning to client - const sanitizeAd = (ad: Record) => { - const { payout: _payout, ...rest } = ad + // Strip server-only fields before sending to the CLI. + const toClient = (ad: NormalizedAd) => { + const { payout: _p, extraPixels: _e, ...rest } = ad return rest } - if (variant === 'choice') { - // Return all ads for the choice variant (up to 4) - const sanitizedAds = ads.map(sanitizeAd) - + if (result.variant === 'choice') { logger.info( - { - variant, - adCount: sanitizedAds.length, - request: requestBody, - status: response.status, - }, - '[ads] Fetched choice ads from Gravity API', + { provider: provider.id, variant: 'choice', adCount: result.ads.length }, + '[ads] Fetched choice ads', ) - - return NextResponse.json({ ads: sanitizedAds, variant }) + return NextResponse.json({ + ads: result.ads.map(toClient), + variant: 'choice', + provider: provider.id, + }) } - // Banner variant: return single ad (existing behavior) - const ad = ads[0] - const payout = ad.payout || DEFAULT_PAYOUT - logger.info( - { - ad, - variant, - request: requestBody, - status: response.status, - payout: { - included: ad.payout && ad.payout > 0, - recieved: ad.payout, - default: DEFAULT_PAYOUT, - final: payout, - }, - }, - '[ads] Fetched ad from Gravity API', + { provider: provider.id, variant: 'banner' }, + '[ads] Fetched banner ad', ) - - return NextResponse.json({ ad: sanitizeAd(ad), variant }) + return NextResponse.json({ + ad: toClient(result.ad), + variant: 'banner', + provider: provider.id, + }) } catch (error) { logger.error( { userId, - messages, - status: 500, + provider: providerId, error: error instanceof Error ? { name: error.name, message: error.message } : error, }, - '[ads] Failed to fetch ad from Gravity API', + '[ads] Failed to fetch ad', ) return NextResponse.json( - { ad: null, variant, error: getErrorObject(error) }, + { + ad: null, + provider: providerId, + error: getErrorObject(error), + }, { status: 500 }, ) } } - -/** - * Extract the content from the last tag in a string. - * If no tag is found, returns the original content. - */ -function extractLastUserMessageContent(content: string): string { - // Find all ... matches - const regex = /([\s\S]*?)<\/user_message>/gi - const matches = [...content.matchAll(regex)] - - if (matches.length > 0) { - // Return the content from the last match - const lastMatch = matches[matches.length - 1] - return lastMatch[1].trim() - } - - return content -} diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index 51482b9f3..3d6e53aee 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -178,23 +178,37 @@ export async function postAdImpression(params: { ) } - // Fire the impression pixel to Gravity - try { - await fetch(impUrl) - logger.info({ userId, impUrl }, '[ads] Fired impression pixel') - } catch (error) { - logger.warn( - { - impUrl, - error: - error instanceof Error - ? { name: error.name, message: error.message } - : error, - }, - '[ads] Failed to fire impression pixel', - ) - // Continue anyway - we still want to record the impression - } + // 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', + ) // No credits granted for ad impressions const creditsGranted = 0 diff --git a/web/src/app/api/v1/ads/route.ts b/web/src/app/api/v1/ads/route.ts index 6023c1483..0b90fd1ee 100644 --- a/web/src/app/api/v1/ads/route.ts +++ b/web/src/app/api/v1/ads/route.ts @@ -18,6 +18,7 @@ export async function POST(req: NextRequest) { fetch, serverEnv: { GRAVITY_API_KEY: env.GRAVITY_API_KEY, + CARBON_ZONE_KEY: env.CARBON_ZONE_KEY, CB_ENVIRONMENT: env.NEXT_PUBLIC_CB_ENVIRONMENT, }, }) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 8809697f3..1f71b7792 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -286,6 +286,7 @@ export async function postChatCompletions(params: { { error: 'free_mode_unavailable', message: 'Free mode is not available in your country.', + countryCode, }, { status: 403 }, ) diff --git a/web/src/lib/ad-providers/carbon.ts b/web/src/lib/ad-providers/carbon.ts new file mode 100644 index 000000000..64a926436 --- /dev/null +++ b/web/src/lib/ad-providers/carbon.ts @@ -0,0 +1,170 @@ +import type { + AdProvider, + FetchAdInput, + FetchAdResult, + NormalizedAd, +} from './types' + +/** + * BuySellAds (Carbon) Ad Serving API. + * + * Docs: https://docs.buysellads.com/ad-serving-api + * + * Key facts: + * - GET https://srv.buysellads.com/ads/{zonekey}.json + * - Required query params: `useragent` (URL-encoded) and `forwardedip` (IPv4) + * - The test zone key `CVADC53U` is public and safe to use while developing. + * - Response has an `ads` array. An ad is only considered filled if the first + * entry has a `statlink` (click URL). `statimp` is the primary impression + * pixel. An optional `pixel` field contains additional tracking pixels + * separated by `||`, each of which may contain `[timestamp]`. + * - A single zone request returns one ad. To populate the choice ad panel we + * issue multiple concurrent requests and dedupe by description — Carbon + * rotates through its fill pool per-request, so repeated calls usually yield + * different creatives. + */ +const CARBON_URL_BASE = 'https://srv.buysellads.com/ads' + +// How many concurrent zone fetches to issue when filling the choice panel. +// Four matches the Gravity choice layout and gives enough headroom that +// dedupe still leaves us multiple distinct ads on typical fill rates. +const CARBON_CHOICE_FETCH_COUNT = 4 + +type CarbonAd = { + statlink?: string + statimp?: string + statview?: string + description?: string + company?: string + callToAction?: string + image?: string + logo?: string + pixel?: string +} + +type CarbonResponse = { + ads?: CarbonAd[] +} + +/** + * Carbon returns `//srv.buysellads.com/...` for its pixel URLs. Normalize to + * https:// so we (and the CLI) can fetch them directly. + */ +function withScheme(url: string): string { + if (url.startsWith('//')) return `https:${url}` + return url +} + +function splitPixels(pixel: string | undefined): string[] { + if (!pixel) return [] + return pixel + .split('||') + .map((s) => s.trim()) + .filter(Boolean) + .map(withScheme) +} + +function normalizeCarbonAd(raw: CarbonAd): NormalizedAd | null { + // Per Carbon docs: if `statlink` is missing the zone had no fill. + if (!raw.statlink || !raw.statimp) return null + + const clickUrl = withScheme(raw.statlink) + const impUrl = withScheme(raw.statimp) + + // `statview` is Carbon's IAB viewable-impression pixel (separate from the + // regular impression `statimp`). Our CLI ad is definitively viewable when + // rendered, so fire it alongside any advertiser pixels. + const extraPixels = [ + ...(raw.statview ? [withScheme(raw.statview)] : []), + ...splitPixels(raw.pixel), + ] + + return { + adText: raw.description ?? '', + title: raw.company ?? '', + cta: raw.callToAction ?? 'Learn more', + // Carbon doesn't expose a destination URL — `statlink` is a tracker + // that 302s to the advertiser. Leave `url` empty so the UI doesn't + // render "srv.buysellads.com" as the ad's domain. Clicks use + // `clickUrl` and get correctly routed through tracking. + url: '', + favicon: raw.image ?? raw.logo ?? '', + clickUrl, + impUrl, + extraPixels, + } +} + +export function createCarbonProvider(config: { + zoneKey: string +}): AdProvider { + return { + id: 'carbon', + fetchAd: async (input: FetchAdInput): Promise => { + const { clientIp, userAgent, testMode, logger, fetch } = input + + if (!clientIp || !userAgent) { + logger.debug( + { hasIp: !!clientIp, hasUA: !!userAgent }, + '[ads:carbon] Missing required clientIp or userAgent', + ) + return null + } + + const params = new URLSearchParams({ + useragent: userAgent, + forwardedip: clientIp, + }) + // Carbon's `ignore=yes` loads ads without counting impressions. Use it + // in non-prod so we never accidentally bill advertisers for dev traffic. + if (testMode) params.set('ignore', 'yes') + + const url = `${CARBON_URL_BASE}/${config.zoneKey}.json?${params.toString()}` + + const fetchOne = async (): Promise => { + const response = await fetch(url, { method: 'GET' }) + if (!response.ok) { + let body: unknown + try { + body = await response.text() + } catch { + body = 'Unable to parse error response' + } + logger.error( + { url, status: response.status, body }, + '[ads:carbon] API returned error', + ) + return null + } + const data = (await response.json()) as CarbonResponse + const first = data.ads?.[0] + if (!first) return null + return normalizeCarbonAd(first) + } + + const results = await Promise.all( + Array.from({ length: CARBON_CHOICE_FETCH_COUNT }, fetchOne), + ) + + // Dedupe by description — Carbon issues a fresh tracker URL per request + // even for the same creative, so clickUrl/impUrl can't serve as a + // stable identity key. + const seen = new Set() + const ads: NormalizedAd[] = [] + for (const ad of results) { + if (!ad) continue + const key = ad.adText || ad.title + if (!key || seen.has(key)) continue + seen.add(key) + ads.push(ad) + } + + if (ads.length === 0) { + logger.debug({ url }, '[ads:carbon] No ad fill') + return null + } + + return { variant: 'choice', ads } + }, + } +} diff --git a/web/src/lib/ad-providers/gravity.ts b/web/src/lib/ad-providers/gravity.ts new file mode 100644 index 000000000..ed9209cb0 --- /dev/null +++ b/web/src/lib/ad-providers/gravity.ts @@ -0,0 +1,190 @@ +import { createHash } from 'crypto' + +import { buildArray } from '@codebuff/common/util/array' + +import type { + AdMessage, + AdProvider, + AdVariant, + FetchAdInput, + FetchAdResult, + NormalizedAd, +} from './types' + +const GRAVITY_URL = 'https://server.trygravity.ai/api/v1/ad' +const BANNER_PLACEMENT_ID = 'code-assist-ad' +const CHOICE_PLACEMENT_IDS = [ + 'choice-ad-1', + 'choice-ad-2', + 'choice-ad-3', + 'choice-ad-4', +] + +type GravityRawAd = { + adText: string + title: string + cta: string + url: string + favicon: string + clickUrl: string + impUrl: string + payout?: number +} + +function normalize(raw: GravityRawAd): NormalizedAd { + return { + adText: raw.adText, + title: raw.title, + cta: raw.cta, + url: raw.url, + favicon: raw.favicon, + clickUrl: raw.clickUrl, + impUrl: raw.impUrl, + payout: raw.payout, + } +} + +/** + * A/B test: deterministically assign a user to the `banner` or `choice` + * variant based on their userId. Stable across requests. + */ +function getGravityVariant(userId: string): AdVariant { + const hash = createHash('sha256').update(`ad-variant:${userId}`).digest() + return hash[0] % 2 === 0 ? 'banner' : 'choice' +} + +/** + * Extract the content from the last tag in a string. + * The CLI wraps raw user text in that tag; if no tag is found, returns the + * original content. + */ +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 +} + +/** + * Gravity only wants the last user turn plus the last preceding assistant + * turn for relevancy signals. We also strip empties and normalize user + * messages through the tag. + */ +function prepareGravityMessages(messages: AdMessage[]): AdMessage[] { + const cleaned = messages + .filter((m) => m.content) + .map((m) => + m.role === 'user' + ? { ...m, content: extractLastUserMessageContent(m.content) } + : m, + ) + const lastUserIndex = cleaned.findLastIndex((m) => m.role === 'user') + const lastUser = lastUserIndex >= 0 ? cleaned[lastUserIndex] : undefined + const lastAssistant = cleaned + .slice(0, lastUserIndex >= 0 ? lastUserIndex : cleaned.length) + .findLast((m) => m.role === 'assistant') + return buildArray(lastAssistant, lastUser) +} + +export function createGravityProvider(config: { apiKey: string }): AdProvider { + return { + id: 'gravity', + fetchAd: async (input: FetchAdInput): Promise => { + const { + userId, + userEmail, + sessionId, + clientIp, + device, + messages = [], + testMode, + logger, + fetch, + } = input + + const variant = getGravityVariant(userId) + const filteredMessages = prepareGravityMessages(messages) + + const placements = + variant === 'choice' + ? CHOICE_PLACEMENT_IDS.map((id) => ({ + placement: 'below_response', + placement_id: id, + })) + : [{ placement: 'below_response', placement_id: BANNER_PLACEMENT_ID }] + + const deviceBody = clientIp + ? { + ip: clientIp, + ...(device?.os ? { os: device.os } : {}), + ...(device?.timezone ? { timezone: device.timezone } : {}), + ...(device?.locale ? { locale: device.locale } : {}), + } + : undefined + + const requestBody = { + messages: filteredMessages, + sessionId: sessionId ?? userId, + placements, + testAd: testMode, + relevancy: 0, + ...(deviceBody ? { device: deviceBody } : {}), + user: { + id: userId, + email: userEmail ?? undefined, + }, + } + + const response = await fetch(GRAVITY_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (response.status === 204) { + logger.debug( + { request: requestBody, status: response.status }, + '[ads:gravity] No ad available', + ) + return null + } + + 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, response: errorBody, status: response.status }, + '[ads:gravity] API returned error', + ) + return null + } + + const ads = (await response.json()) as GravityRawAd[] | unknown + if (!Array.isArray(ads) || ads.length === 0) { + logger.debug( + { request: requestBody, status: response.status }, + '[ads:gravity] No ads returned', + ) + return null + } + + if (variant === 'choice') { + return { variant: 'choice', ads: ads.map(normalize) } + } + return { variant: 'banner', ad: normalize(ads[0]) } + }, + } +} diff --git a/web/src/lib/ad-providers/types.ts b/web/src/lib/ad-providers/types.ts new file mode 100644 index 000000000..5b664332b --- /dev/null +++ b/web/src/lib/ad-providers/types.ts @@ -0,0 +1,69 @@ +import type { Logger } from '@codebuff/common/types/contracts/logger' + +/** + * Identifies which upstream ad network served an ad. Stored on + * `ad_impression.provider` so we can slice analytics and know which request + * 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 AdVariant = 'banner' | 'choice' + +/** + * Normalized ad shape returned by every provider. The CLI renders against + * this shape; provider modules are responsible for mapping their upstream + * response into it. + */ +export type NormalizedAd = { + adText: string + title: string + cta: string + url: string + favicon: string + clickUrl: string + /** Primary impression pixel URL. Fired once when the ad becomes visible. */ + impUrl: string + /** + * Additional impression pixels (e.g. Carbon's `pixel` field). Each string + * may contain `[timestamp]` which must be substituted at fire time. + */ + extraPixels?: string[] + /** Server-only: stripped before the ad is sent to the client. */ + payout?: number +} + +export type AdMessage = { role: string; content: string } + +export type AdDeviceInfo = { + os?: 'macos' | 'windows' | 'linux' + timezone?: string + locale?: string +} + +export type FetchAdInput = { + userId: string + userEmail: string | null + sessionId?: string + /** Client IP, parsed from X-Forwarded-For upstream. */ + clientIp?: string + /** Browser/CLI useragent string, passed through to upstream. */ + userAgent?: string + device?: AdDeviceInfo + /** Last user + last preceding assistant message, if any. Used by Gravity. */ + messages?: AdMessage[] + /** Set in non-prod so providers can request test ads. */ + testMode: boolean + logger: Logger + fetch: typeof globalThis.fetch +} + +export type FetchAdResult = + | { variant: 'banner'; ad: NormalizedAd } + | { variant: 'choice'; ads: NormalizedAd[] } + | null + +export type AdProvider = { + id: AdProviderId + fetchAd: (input: FetchAdInput) => Promise +} diff --git a/web/src/server/free-session/abuse-detection.ts b/web/src/server/free-session/abuse-detection.ts index c6675021e..b62a04835 100644 --- a/web/src/server/free-session/abuse-detection.ts +++ b/web/src/server/free-session/abuse-detection.ts @@ -141,28 +141,54 @@ export async function identifyBotSuspects(params: { agentDiversity.map((a) => [a.user_id!, Number(a.distinctAgents24h)]), ) - // Max inter-message quiet gap in the 24h window (in hours). A gap ≥ 4h is - // a strong "user slept" counter-signal — bots don't take circadian breaks. - // Uses LAG() so it needs a CTE; run as raw SQL. + // Largest gap of usage (in hours) within the observation window — where + // the window is bounded by GREATEST(user.created_at, now - 24h). For each + // user we consider three kinds of gap: window_start → first msg, gaps + // between consecutive msgs, and last msg → now. Max of those is the + // quiet gap. + // + // Clipping the window to signup matters: a 0.2d-old account can only + // plausibly have a gap up to its age. Without the clip, LAG() on an empty + // pre-window history would silently omit any leading-boundary gap, so a + // fresh bot with dense activity reads as "low quiet gap" correctly — but + // for heavy accounts that only started hitting us within the last few + // hours, we also want to count post-activity quiet time toward the gap. + const nowIso = now.toISOString() const quietGaps = await db.execute(sql` - WITH ordered AS ( - SELECT user_id, finished_at, - LAG(finished_at) OVER (PARTITION BY user_id ORDER BY finished_at) AS prev - FROM ${schema.message} - WHERE user_id IN (${sql.join( + WITH bounds AS ( + SELECT id AS user_id, + GREATEST(created_at, ${cutoffIso}::timestamptz) AS window_start + FROM ${schema.user} + WHERE id IN (${sql.join( userIds.map((id) => sql`${id}`), sql`, `, )}) - AND agent_id IN (${sql.join( + ), + msgs AS ( + SELECT m.user_id, m.finished_at, b.window_start + FROM ${schema.message} m + JOIN bounds b ON b.user_id = m.user_id + WHERE m.finished_at >= b.window_start + AND m.agent_id IN (${sql.join( FREEBUFF_ROOT_AGENT_IDS.map((a) => sql`${a}`), sql`, `, )}) - AND finished_at >= ${cutoffIso}::timestamptz + ), + gaps AS ( + SELECT user_id, + finished_at, + COALESCE( + LAG(finished_at) OVER (PARTITION BY user_id ORDER BY finished_at), + window_start + ) AS prev + FROM msgs ) SELECT user_id, - MAX(EXTRACT(EPOCH FROM (finished_at - prev))) / 3600.0 AS max_gap_hours - FROM ordered - WHERE prev IS NOT NULL + GREATEST( + MAX(EXTRACT(EPOCH FROM (finished_at - prev)) / 3600.0), + EXTRACT(EPOCH FROM (${nowIso}::timestamptz - MAX(finished_at))) / 3600.0 + ) AS max_gap_hours + FROM gaps GROUP BY user_id `) const quietGapByUser = new Map() @@ -245,6 +271,38 @@ export async function identifyBotSuspects(params: { score += 15 } + // --- Region signal (corroborating, scored only when stacked with usage) --- + // The free tier is intended for users in approved regions: English-speaking + // (US, UK, Canada, Australia, NZ, Ireland) and western-European markets. + // We have no IP data, so region is inferred from email provider and the + // unicode characters in the display name. CJK indicators (Chinese/Japanese/ + // Korean Unicode in name, Chinese-provider emails, .edu.cn domains) are + // the only signal we can detect reliably, and empirically our abuse + // clusters are overwhelmingly from these provider pools. Diaspora users + // from approved regions may trip this flag, so it only contributes to the + // score when combined with heavy usage (the combination, not the region + // alone, is what justifies the score bump). + const hasCjkName = + !!s.name && + /[一-鿿぀-ヿ가-힯]/.test(s.name) + const hasChineseDomain = + !!s.email && + /@(qq|163|126|sina|sina\.cn|foxmail|aliyun|139|yeah|tom)\.(com|cn|net)$/i.test( + s.email, + ) + const hasCnEduDomain = !!s.email && /\.edu\.cn$/i.test(s.email) + const nonApprovedRegion = + hasCjkName || hasChineseDomain || hasCnEduDomain + if (nonApprovedRegion) { + const reasons: string[] = [] + if (hasCjkName) reasons.push('cjk-name') + if (hasChineseDomain) reasons.push('cn-provider') + if (hasCnEduDomain) reasons.push('cn-edu') + flags.push(`non-approved-region[${reasons.join(',')}]`) + if (msgs24h >= 500) score += 40 + else if (msgs24h >= 300) score += 25 + } + // --- Email/handle pattern flags (purely informational) --- // These are too noisy in isolation (many real users have digits in their // email, use plus-aliases for privacy, or sign up via duck.com). They're diff --git a/web/src/server/free-session/abuse-review.ts b/web/src/server/free-session/abuse-review.ts index bf079ea78..4c833805c 100644 --- a/web/src/server/free-session/abuse-review.ts +++ b/web/src/server/free-session/abuse-review.ts @@ -40,24 +40,30 @@ You will see: - Creation clusters: sets of codebuff accounts created within 30 minutes of each other. Counter-signals are mitigating evidence that should PULL DOWN your confidence: -- \`quiet-gap:Xh\` — the user went X hours between messages in the last 24h. Bots don't sleep; a gap ≥ 4h is strong evidence of a human circadian pattern, ≥ 8h is nearly conclusive. -- \`diverse-agents:N\` — the user invoked N distinct agents in 24h. Real developers pipeline through basher, file-picker, code-reviewer, thinker alongside the root agent. Bot farms stay narrow (typically 1–3 agents). N ≥ 6 is a meaningful counter-signal, N ≥ 10 is very strong. +- \`quiet-gap:Xh\` — the user went X hours between messages in the last 24h. Bots don't sleep; a gap ≥ 3h is a real circadian signal, ≥ 5h is strong, ≥ 8h is nearly conclusive. A ≥5h gap by itself defeats any "round-the-clock" claim: the account is demonstrably NOT running 24/7, full stop. +- \`diverse-agents:N\` — the user invoked N distinct agents in 24h. Real developers pipeline through basher, file-picker, code-reviewer, thinker alongside the root agent. Bot farms stay narrow (typically 1–3 agents). N ≥ 5 is a meaningful counter-signal, N ≥ 8 is very strong. - \`gh-established:Xy\` — the linked GitHub account is X years old. Buying an old GitHub is rare at our scale. -When an account has strong counter-signals alongside its red flags, tier it DOWN. A user with \`very-heavy:1000/24h\` AND \`quiet-gap:10h diverse-agents:12 gh-established:3y\` is almost certainly a legitimate power user, not a bot, no matter how high the raw message count is. +When an account has strong counter-signals alongside its red flags, tier it DOWN. A user with \`very-heavy:1000/24h\` AND \`quiet-gap:6h diverse-agents:6 gh-established:1y\` is almost certainly a legitimate power user, not a bot, no matter how high the raw message count is. -A very young GitHub account (gh_age < 7d, especially < 1d) combined with heavy usage is one of the strongest bot signals we have: real developers almost never create a GitHub account on the same day they start running an agent. Weigh this heavily in tiering. +A very young GitHub account (gh_age < 7d, especially < 1d) combined with heavy usage is one of the strongest bot signals we have: real developers almost never create a GitHub account on the same day they start running an agent. Weigh this heavily — fresh GH + heavy usage is TIER 1 even with a moderate (3–6h) quiet gap, because the fresh-GH signal is difficult to fake at scale. -Conversely, an established GitHub account (gh_age ≥ 1 year, especially ≥ 3 years) is a strong counter-signal. Account-age spoofing by buying old accounts is possible but uncommon at our abuse scale. An established GitHub + a natural agent mix (basher, code-reviewer, file-picker alongside the root agent) + some activity gaps during the day reads like an excited first-day power user, not a bot. Don't tier these as HIGH unless there are two independent per-account signals (e.g. true 24/7 distinct_hours AND suspicious email pattern). +Conversely, a GitHub account older than ~30 days is meaningful counter-evidence. The "day-1 of coding = day-1 of GitHub" pattern that makes fresh-GH such a strong bot signal doesn't apply once the GH predates the codebuff account by a month or more. gh_age ≥ 30d + a moderate quiet gap (≥4h) + any agent diversity reads like an excited power user, not a bot. Don't tier these as HIGH unless there's a genuinely unambiguous per-account signal (true near-continuous activity, see below). + +The free tier is intended for users in approved regions: English-speaking (US, UK, Canada, Australia, NZ, Ireland) and western-European markets. We have no IP geolocation, so region is inferred heuristically — the \`non-approved-region[...]\` flag fires when the account has a CJK-character display name (\`cjk-name\`), a Chinese email provider (\`cn-provider\` — qq.com, 163.com, 126.com, sina.com, foxmail.com, aliyun.com, 139.com, yeah.net, tom.com), or a \`.edu.cn\` domain (\`cn-edu\`). Empirically our abuse clusters are overwhelmingly from these provider pools, and heavy free-tier usage from them strongly correlates with VPN-based farming. BUT real diaspora developers from approved regions exist and trip this flag too. So: region alone is NEVER grounds for a ban. Treat it as corroborating evidence that RAISES confidence when stacked with heavy usage (msgs_24h ≥ 300) or other bot signals — a \`non-approved-region\` user with \`very-heavy\` usage on a young account is TIER 1; the same user with established-GH + low usage + diverse-agents stays in TIER 2. Creation-cluster membership is a WEAK signal on its own. The detector is purely temporal — accounts created within 30 minutes of each other. At normal signup volume, unrelated real users routinely land in the same window (product launches, HN/Reddit posts, timezone-aligned bursts). A cluster is only actionable when its members share a concrete cross-account pattern: matching email-local stems or digit siblings (\`v6apiworker\` / \`v8apiworker\`), a shared uncommon domain (\`@mail.hnust.edu.cn\`), sequential-number naming, or near-identical msgs_24h / distinct_hours footprints across multiple members. Absent such a shared pattern, treat a cluster list as background noise and tier members purely on their per-account signals. When you do use a cluster as evidence, name the shared pattern explicitly — "cluster sharing the \`vNNapiworker\` stem", not "member of 5-account creation cluster". Produce a markdown report with two sections: ## TIER 1 — HIGH CONFIDENCE (ban) -Accounts whose OWN behavior shows strong automation: round-the-clock usage (distinct_hours_24h ≥ 20 AND msgs_24h ≥ 50), or heavy day-1 activity (msgs_24h ≥ 400) on a <1d-old codebuff account linked to a <7d-old GitHub login. A single account may also qualify when multiple weaker signals stack (e.g. heavy usage + fresh GH + throwaway-domain email + round-the-clock pattern). +The bar is high — if you are choosing between TIER 1 and TIER 2, choose TIER 2. -Cluster membership is NOT sufficient for TIER 1 on its own. Include it only as corroboration when the cluster shares an explicit cross-account pattern (see above); lead each reason line with the strongest per-account signal, and mention the cluster last. +Qualifying signals (any one of these, taken on its own, justifies TIER 1): +1. **Near-continuous activity** — distinct_hours_24h ≥ 18. 15–18 distinct hours is NOT near-continuous, even with heavy message counts — that's a normal motivated power user. +2. **No quiet gap and heavy usage** — max_quiet_gap < 6h AND high message count (msgs_24h ≥ 700). +2. **Fresh-GH + another signal** — gh_age < 7d AND (msgs_24h ≥ 700, or cluster with email pattern, or another signal). The fresh GitHub is a strong signal, but you also need something else to justify a ban. +3. **Multi-signal stack with independent automation evidence** — e.g. cluster of accounts with a shared pattern and heavy usage. One line of reasoning per account. Group cluster members together under a cluster heading ONLY when the cluster shares a concrete pattern.