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:
@@ -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 {}
|
||||
|
||||
+5
@@ -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>;
|
||||
+29
@@ -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.`,
|
||||
),
|
||||
});
|
||||
+48
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user