feat: generic web search driver abstraction with Exa support and billing (#19341)

## Summary

- Introduces a pluggable `WebSearchDriver` abstraction (interface,
factory, service, module) so web search is no longer tied to native
provider tools (Anthropic/OpenAI)
- **Exa** is the first driver implementation with support for
category-filtered search (company, people, news, research paper, etc.) —
particularly useful for CRM workflows
- Per-query billing for both Exa ($0.007/query) and native provider
surcharges ($0.01/query for Anthropic/OpenAI) via the existing
`USAGE_RECORDED` pipeline
- New config variables: `WEB_SEARCH_DRIVER` (EXA/DISABLED),
`EXA_API_KEY`, `WEB_SEARCH_PREFER_NATIVE` (default false — prefers Exa
over native when both available)
- `WEB_SEARCH` operation type added for usage tracking and Stripe
metering

### Architecture

```
WebSearchDriver (interface)
├── ExaDriver          — Exa neural search with category support
└── DisabledDriver     — throws when search is disabled

WebSearchDriverFactory (extends DriverFactoryBase)
└── creates driver based on WEB_SEARCH_DRIVER config

WebSearchService (facade)
├── search(query, options?, billingContext?)
├── isEnabled()
└── emits USAGE_RECORDED events per query

WebSearchTool (Tool implementation)
└── registered in ActionToolProvider, available via tool catalog
```

### Native search billing gap fixed

Anthropic and OpenAI both charge $0.01/search on top of token costs. The
token costs were already billed, but the per-call surcharge was not.
Added `countNativeWebSearchCallsFromSteps` utility +
`billNativeWebSearchUsage` to `AiBillingService`, wired into both chat
and workflow agent paths.

## Test plan

- [ ] Set `WEB_SEARCH_DRIVER=EXA` + `EXA_API_KEY=...` and verify AI chat
can search the web
- [ ] Verify category parameter works (ask about a specific
company/person)
- [ ] Set `WEB_SEARCH_DRIVER=DISABLED` and verify search tool is not
exposed
- [ ] Set `WEB_SEARCH_PREFER_NATIVE=true` with Anthropic model and
verify native search is used
- [ ] Verify usage events are emitted in ClickHouse for both Exa and
native search paths
- [ ] Verify existing billing tests pass (`npx jest
ai-billing.service.spec.ts`)


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-04-06 12:02:46 +02:00
committed by GitHub
parent 8acfacc69c
commit ea572975d8
34 changed files with 575 additions and 22 deletions
@@ -23,6 +23,7 @@ import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/ti
import { CaptchaModule } from 'src/engine/core-modules/captcha/captcha.module';
import { CloudflareModule } from 'src/engine/core-modules/cloudflare/cloudflare.module';
import { CodeInterpreterModule } from 'src/engine/core-modules/code-interpreter/code-interpreter.module';
import { WebSearchModule } from 'src/engine/core-modules/web-search/web-search.module';
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
@@ -146,6 +147,7 @@ import { FileModule } from './file/file.module';
AiBillingModule,
LogicFunctionModule.forRoot(),
CodeInterpreterModule.forRoot(),
WebSearchModule.forRoot(),
SearchModule,
ApiKeyModule,
PageLayoutModule,
@@ -19,8 +19,10 @@ import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/sen
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { WebSearchTool } from 'src/engine/core-modules/tool/tools/web-search-tool/web-search-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
@@ -36,7 +38,9 @@ export class ActionToolProvider implements ToolProvider {
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly navigateAppTool: NavigateAppTool,
private readonly webSearchTool: WebSearchTool,
private readonly codeInterpreterService: CodeInterpreterService,
private readonly webSearchService: WebSearchService,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {
@@ -47,6 +51,7 @@ export class ActionToolProvider implements ToolProvider {
['search_help_center', this.searchHelpCenterTool],
['code_interpreter', this.codeInterpreterTool],
['navigate_app', this.navigateAppTool],
['web_search', this.webSearchTool],
]);
// Register each action tool as a static handler in the executor
@@ -141,6 +146,12 @@ export class ActionToolProvider implements ToolProvider {
);
}
if (this.webSearchService.isEnabled()) {
descriptors.push(
this.buildDescriptor('web_search', this.webSearchTool, includeSchemas),
);
}
return descriptors;
}
@@ -7,6 +7,7 @@ import { type NativeToolProvider } from 'src/engine/core-modules/tool-provider/i
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@@ -19,6 +20,7 @@ export class NativeModelToolProvider implements NativeToolProvider {
constructor(
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly webSearchService: WebSearchService,
) {}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
@@ -30,6 +32,10 @@ export class NativeModelToolProvider implements NativeToolProvider {
return {};
}
if (!this.webSearchService.shouldUseNativeSearch()) {
return {};
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(context.agent);
@@ -14,6 +14,7 @@ import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/sen
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { WebSearchTool } from 'src/engine/core-modules/tool/tools/web-search-tool/web-search-tool';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
@@ -45,6 +46,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
SearchHelpCenterTool,
CodeInterpreterTool,
NavigateAppTool,
WebSearchTool,
],
exports: [
HttpTool,
@@ -54,6 +56,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
SearchHelpCenterTool,
CodeInterpreterTool,
NavigateAppTool,
WebSearchTool,
],
})
export class ToolModule {}
@@ -0,0 +1,5 @@
import { type z } from 'zod';
import { type WebSearchInputZodSchema } from 'src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.schema';
export type WebSearchInput = z.infer<typeof WebSearchInputZodSchema>;
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { WEB_SEARCH_CATEGORIES } from 'src/engine/core-modules/web-search/constants/web-search-categories.const';
export const WEB_SEARCH_DEFAULT_NUM_RESULTS = 10;
export const WEB_SEARCH_MAX_NUM_RESULTS = 30;
export const WebSearchInputZodSchema = z.object({
query: z
.string()
.describe(
'The search query to look up on the web. Be specific and include relevant keywords for better results.',
),
category: z
.enum(WEB_SEARCH_CATEGORIES)
.optional()
.describe(
'Optional content category to focus the search. Use "company" for business/organization info, "people" for person profiles, "news" for recent articles, "research paper" for academic content.',
),
numResults: z
.number()
.int()
.min(1)
.max(WEB_SEARCH_MAX_NUM_RESULTS)
.optional()
.describe(
`Number of search results to return. Defaults to ${WEB_SEARCH_DEFAULT_NUM_RESULTS}, max ${WEB_SEARCH_MAX_NUM_RESULTS}. Use more results when you need comprehensive coverage.`,
),
});
@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { type WebSearchInput } from 'src/engine/core-modules/tool/tools/web-search-tool/web-search-input.type';
import { WebSearchInputZodSchema } from 'src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.schema';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
@Injectable()
export class WebSearchTool implements Tool {
description =
'Search the web for real-time information. Returns relevant results with titles, URLs, and content snippets. Supports optional category filtering for company, people, news, and other content types.';
inputSchema = WebSearchInputZodSchema;
constructor(private readonly webSearchService: WebSearchService) {}
async execute(
parameters: ToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
const { query, category, numResults } = parameters as WebSearchInput;
try {
const results = await this.webSearchService.search(
query,
{ category, numResults },
{
workspaceId: context.workspaceId,
userWorkspaceId: context.userWorkspaceId,
},
);
return {
success: true,
message: `Found ${results.length} results for "${query}"${category ? ` (category: ${category})` : ''}`,
result: results,
};
} catch (error) {
return {
success: false,
message: `Web search failed for "${query}"`,
error: error instanceof Error ? error.message : 'Web search failed',
};
}
}
}
@@ -19,6 +19,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
@@ -661,6 +662,35 @@ export class ConfigVariables {
@CastToPositiveNumber()
CODE_INTERPRETER_TIMEOUT_MS = 300_000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'Web search driver type - EXA for Exa search, DISABLED to turn off',
type: ConfigVariableType.STRING,
options: Object.values(WebSearchDriverType),
})
@IsOptional()
@CastToUpperSnakeCase()
WEB_SEARCH_DRIVER: WebSearchDriverType = WebSearchDriverType.DISABLED;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description: 'Exa API key for web search',
type: ConfigVariableType.STRING,
isSensitive: true,
})
@ValidateIf((env) => env.WEB_SEARCH_DRIVER === WebSearchDriverType.EXA)
EXA_API_KEY?: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'When true, use native provider search (Anthropic/OpenAI) when available. When false, always prefer the configured driver (e.g. Exa).',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
WEB_SEARCH_PREFER_NATIVE = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ANALYTICS_CONFIG,
description: 'Enable or disable analytics for telemetry',
@@ -7,6 +7,7 @@ export enum UsageOperationType {
AI_WORKFLOW_TOKEN = 'AI_WORKFLOW_TOKEN',
WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION',
CODE_EXECUTION = 'CODE_EXECUTION',
WEB_SEARCH = 'WEB_SEARCH',
}
registerEnumType(UsageOperationType, {
@@ -0,0 +1,9 @@
export const WEB_SEARCH_CATEGORIES = [
'company',
'research paper',
'news',
'pdf',
'personal site',
'financial report',
'people',
] as const;
@@ -0,0 +1,19 @@
import {
type WebSearchCostModel,
type WebSearchDriver,
} from 'src/engine/core-modules/web-search/drivers/interfaces/web-search-driver.interface';
import { type WebSearchResult } from 'src/engine/core-modules/web-search/types/web-search-result.type';
export class DisabledWebSearchDriver implements WebSearchDriver {
readonly costModel: WebSearchCostModel = {
baseResultCount: 0,
baseCostDollars: 0,
costPerAdditionalResultDollars: 0,
};
constructor(private readonly reason: string) {}
async search(): Promise<WebSearchResult[]> {
throw new Error(this.reason);
}
}
@@ -0,0 +1,52 @@
import Exa from 'exa-js';
import {
type WebSearchCostModel,
type WebSearchDriver,
} from 'src/engine/core-modules/web-search/drivers/interfaces/web-search-driver.interface';
import { type WebSearchOptions } from 'src/engine/core-modules/web-search/types/web-search-options.type';
import { type WebSearchResult } from 'src/engine/core-modules/web-search/types/web-search-result.type';
const DEFAULT_NUM_RESULTS = 10;
const MAX_HIGHLIGHT_CHARACTERS = 4000;
// Exa charges $7/1k requests for auto search type (up to 10 results)
// Additional results above 10 cost $1/1k = $0.001 each
const EXA_BASE_COST_DOLLARS = 0.007;
const EXA_COST_PER_ADDITIONAL_RESULT_DOLLARS = 0.001;
export class ExaDriver implements WebSearchDriver {
readonly costModel: WebSearchCostModel = {
baseResultCount: DEFAULT_NUM_RESULTS,
baseCostDollars: EXA_BASE_COST_DOLLARS,
costPerAdditionalResultDollars: EXA_COST_PER_ADDITIONAL_RESULT_DOLLARS,
};
private readonly client: Exa;
constructor(apiKey: string) {
this.client = new Exa(apiKey);
}
async search(
query: string,
options?: WebSearchOptions,
): Promise<WebSearchResult[]> {
const numResults = options?.numResults ?? DEFAULT_NUM_RESULTS;
const response = await this.client.search(query, {
type: 'auto',
numResults,
category: options?.category,
contents: {
highlights: { maxCharacters: MAX_HIGHLIGHT_CHARACTERS },
},
});
return response.results.map((result) => ({
title: result.title ?? '',
url: result.url,
snippet: result.highlights?.join('\n') ?? '',
}));
}
}
@@ -0,0 +1,14 @@
import { type WebSearchOptions } from 'src/engine/core-modules/web-search/types/web-search-options.type';
import { type WebSearchResult } from 'src/engine/core-modules/web-search/types/web-search-result.type';
export type WebSearchCostModel = {
baseResultCount: number;
baseCostDollars: number;
costPerAdditionalResultDollars: number;
};
export interface WebSearchDriver {
readonly costModel: WebSearchCostModel;
search(query: string, options?: WebSearchOptions): Promise<WebSearchResult[]>;
}
@@ -0,0 +1,4 @@
export type WebSearchBillingContext = {
workspaceId: string;
userWorkspaceId?: string;
};
@@ -0,0 +1,3 @@
import { type WEB_SEARCH_CATEGORIES } from 'src/engine/core-modules/web-search/constants/web-search-categories.const';
export type WebSearchCategory = (typeof WEB_SEARCH_CATEGORIES)[number];
@@ -0,0 +1,6 @@
import { type WebSearchCategory } from 'src/engine/core-modules/web-search/types/web-search-category.type';
export type WebSearchOptions = {
category?: WebSearchCategory;
numResults?: number;
};
@@ -0,0 +1,5 @@
export type WebSearchResult = {
title: string;
url: string;
snippet: string;
};
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { type WebSearchDriver } from 'src/engine/core-modules/web-search/drivers/interfaces/web-search-driver.interface';
import { DisabledWebSearchDriver } from 'src/engine/core-modules/web-search/drivers/disabled.driver';
import { ExaDriver } from 'src/engine/core-modules/web-search/drivers/exa.driver';
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
import { DriverFactoryBase } from 'src/engine/core-modules/twenty-config/dynamic-factory.base';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class WebSearchDriverFactory extends DriverFactoryBase<WebSearchDriver> {
constructor(twentyConfigService: TwentyConfigService) {
super(twentyConfigService);
}
protected buildConfigKey(): string {
const driverType = this.twentyConfigService.get('WEB_SEARCH_DRIVER');
if (driverType !== WebSearchDriverType.DISABLED) {
return `${driverType}|${this.getConfigGroupHash(ConfigVariablesGroup.LLM)}`;
}
return driverType;
}
protected createDriver(): WebSearchDriver {
const driverType = this.twentyConfigService.get('WEB_SEARCH_DRIVER');
switch (driverType) {
case WebSearchDriverType.DISABLED:
return new DisabledWebSearchDriver(
'Web search is disabled. Set WEB_SEARCH_DRIVER to EXA and provide EXA_API_KEY to enable it.',
);
case WebSearchDriverType.EXA: {
const apiKey = this.twentyConfigService.get('EXA_API_KEY');
if (!apiKey) {
throw new Error(
'EXA_API_KEY is required when WEB_SEARCH_DRIVER is EXA',
);
}
return new ExaDriver(apiKey);
}
default:
throw new Error(
`Invalid web search driver type (${driverType}), check your .env file`,
);
}
}
}
@@ -0,0 +1,4 @@
export enum WebSearchDriverType {
EXA = 'EXA',
DISABLED = 'DISABLED',
}
@@ -0,0 +1,17 @@
import { type DynamicModule, Global } from '@nestjs/common';
import { WebSearchDriverFactory } from 'src/engine/core-modules/web-search/web-search-driver.factory';
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@Global()
export class WebSearchModule {
static forRoot(): DynamicModule {
return {
module: WebSearchModule,
imports: [TwentyConfigModule],
providers: [WebSearchDriverFactory, WebSearchService],
exports: [WebSearchService],
};
}
}
@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import {
type WebSearchCostModel,
type WebSearchDriver,
} from 'src/engine/core-modules/web-search/drivers/interfaces/web-search-driver.interface';
import { type WebSearchBillingContext } from 'src/engine/core-modules/web-search/types/web-search-billing-context.type';
import { type WebSearchOptions } from 'src/engine/core-modules/web-search/types/web-search-options.type';
import { type WebSearchResult } from 'src/engine/core-modules/web-search/types/web-search-result.type';
import { WebSearchDriverFactory } from 'src/engine/core-modules/web-search/web-search-driver.factory';
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/metadata-modules/ai/ai-billing/constants/dollar-to-credit-multiplier';
@Injectable()
export class WebSearchService {
constructor(
private readonly webSearchDriverFactory: WebSearchDriverFactory,
private readonly twentyConfigService: TwentyConfigService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
) {}
isEnabled(): boolean {
return (
this.twentyConfigService.get('WEB_SEARCH_DRIVER') !==
WebSearchDriverType.DISABLED
);
}
shouldUseNativeSearch(): boolean {
return (
this.twentyConfigService.get('WEB_SEARCH_PREFER_NATIVE') ||
!this.isEnabled()
);
}
async search(
query: string,
options?: WebSearchOptions,
billingContext?: WebSearchBillingContext,
): Promise<WebSearchResult[]> {
const driver = this.webSearchDriverFactory.getCurrentDriver();
const results = await driver.search(query, options);
if (billingContext) {
this.emitUsageEvent(driver, results.length, billingContext);
}
return results;
}
static computeQueryCostDollars(
costModel: WebSearchCostModel,
numResults: number,
): number {
const additionalResults = Math.max(
0,
numResults - costModel.baseResultCount,
);
return (
costModel.baseCostDollars +
additionalResults * costModel.costPerAdditionalResultDollars
);
}
private emitUsageEvent(
driver: WebSearchDriver,
numResults: number,
billingContext: WebSearchBillingContext,
): void {
const costDollars = WebSearchService.computeQueryCostDollars(
driver.costModel,
numResults,
);
const creditsUsedMicro = Math.round(
costDollars * DOLLAR_TO_CREDIT_MULTIPLIER,
);
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
{
resourceType: UsageResourceType.AI,
operationType: UsageOperationType.WEB_SEARCH,
creditsUsedMicro,
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceContext: this.twentyConfigService.get('WEB_SEARCH_DRIVER'),
userWorkspaceId: billingContext.userWorkspaceId ?? null,
},
],
billingContext.workspaceId,
);
}
}