From ea572975d87c3328953d06422fc9601139c3a36f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 6 Apr 2026 12:02:46 +0200 Subject: [PATCH] feat: generic web search driver abstraction with Exa support and billing (#19341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 5 +- .../src/generated-metadata/graphql.ts | 1 + packages/twenty-server/package.json | 1 + .../src/database/clickHouse/seeds/fixtures.ts | 7 ++ .../engine/core-modules/core-engine.module.ts | 2 + .../providers/action-tool.provider.ts | 11 ++ .../providers/native-model-tool.provider.ts | 6 ++ .../engine/core-modules/tool/tool.module.ts | 3 + .../web-search-tool/web-search-input.type.ts | 5 + .../web-search-tool/web-search-tool.schema.ts | 29 +++++ .../tools/web-search-tool/web-search-tool.ts | 48 +++++++++ .../twenty-config/config-variables.ts | 30 ++++++ .../usage/enums/usage-operation-type.enum.ts | 1 + .../constants/web-search-categories.const.ts | 9 ++ .../web-search/drivers/disabled.driver.ts | 19 ++++ .../web-search/drivers/exa.driver.ts | 52 +++++++++ .../interfaces/web-search-driver.interface.ts | 14 +++ .../types/web-search-billing-context.type.ts | 4 + .../types/web-search-category.type.ts | 3 + .../types/web-search-options.type.ts | 6 ++ .../types/web-search-result.type.ts | 5 + .../web-search/web-search-driver.factory.ts | 55 ++++++++++ .../web-search/web-search.interface.ts | 4 + .../web-search/web-search.module.ts | 17 +++ .../web-search/web-search.service.ts | 102 ++++++++++++++++++ .../services/agent-async-executor.service.ts | 7 ++ .../types/agent-execution-result.type.ts | 1 + ...native-web-search-cost-per-call-dollars.ts | 3 + .../ai-billing/services/ai-billing.service.ts | 36 +++++++ ...native-web-search-calls-from-steps.util.ts | 15 +++ .../services/chat-execution.service.ts | 37 ++++--- .../ai-agent/ai-agent.workflow-action.ts | 12 ++- yarn.lock | 46 +++++++- 34 files changed, 575 insertions(+), 22 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-input.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.schema.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/constants/web-search-categories.const.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/drivers/disabled.driver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/drivers/exa.driver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/drivers/interfaces/web-search-driver.interface.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/types/web-search-billing-context.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/types/web-search-category.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/types/web-search-options.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/types/web-search-result.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/web-search-driver.factory.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/web-search.interface.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/web-search.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/web-search/web-search.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 0026f9e708..c44f8d3306 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3376,6 +3376,7 @@ enum UsageOperationType { AI_WORKFLOW_TOKEN WORKFLOW_EXECUTION CODE_EXECUTION + WEB_SEARCH } type Mutation { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 990ca83b83..1ec9767dfd 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2824,7 +2824,7 @@ export type SortNulls = 'NULLS_FIRST' | 'NULLS_LAST' export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT' | 'USAGE_EVENT' -export type UsageOperationType = 'AI_CHAT_TOKEN' | 'AI_WORKFLOW_TOKEN' | 'WORKFLOW_EXECUTION' | 'CODE_EXECUTION' +export type UsageOperationType = 'AI_CHAT_TOKEN' | 'AI_WORKFLOW_TOKEN' | 'WORKFLOW_EXECUTION' | 'CODE_EXECUTION' | 'WEB_SEARCH' export interface Mutation { addQueryToEventStream: Scalars['Boolean'] @@ -9430,7 +9430,8 @@ export const enumUsageOperationType = { AI_CHAT_TOKEN: 'AI_CHAT_TOKEN' as const, AI_WORKFLOW_TOKEN: 'AI_WORKFLOW_TOKEN' as const, WORKFLOW_EXECUTION: 'WORKFLOW_EXECUTION' as const, - CODE_EXECUTION: 'CODE_EXECUTION' as const + CODE_EXECUTION: 'CODE_EXECUTION' as const, + WEB_SEARCH: 'WEB_SEARCH' as const } export const enumAnalyticsType = { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index e20f183fb2..503b78b1c3 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5683,6 +5683,7 @@ export enum UsageOperationType { AI_CHAT_TOKEN = 'AI_CHAT_TOKEN', AI_WORKFLOW_TOKEN = 'AI_WORKFLOW_TOKEN', CODE_EXECUTION = 'CODE_EXECUTION', + WEB_SEARCH = 'WEB_SEARCH', WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION' } diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index f1c1d27724..3a45668c4b 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -104,6 +104,7 @@ "deep-equal": "2.2.3", "dompurify": "3.3.3", "dotenv": "16.4.5", + "exa-js": "^2.11.0", "express": "4.22.1", "express-session": "^1.18.2", "file-type": "^21.3.1", diff --git a/packages/twenty-server/src/database/clickHouse/seeds/fixtures.ts b/packages/twenty-server/src/database/clickHouse/seeds/fixtures.ts index ac9ae9fa7b..16a3dece86 100644 --- a/packages/twenty-server/src/database/clickHouse/seeds/fixtures.ts +++ b/packages/twenty-server/src/database/clickHouse/seeds/fixtures.ts @@ -144,6 +144,13 @@ const buildUsageEventFixtures = (): UsageEventFixture[] => { baseQuantity: 1, unit: 'INVOCATION', }, + { + resourceType: 'AI', + operationType: 'WEB_SEARCH', + baseCreditsMicro: 7000, + baseQuantity: 1, + unit: 'INVOCATION', + }, ]; // Pseudo-random using a seed for reproducibility across runs diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 272cc24b6d..e5ed04e8cd 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 18edbf2cf0..c24c11533a 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts index 582466efd1..2f8de30d90 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts @@ -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 { @@ -30,6 +32,10 @@ export class NativeModelToolProvider implements NativeToolProvider { return {}; } + if (!this.webSearchService.shouldUseNativeSearch()) { + return {}; + } + const registeredModel = await this.aiModelRegistryService.resolveModelForAgent(context.agent); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index 19948b9bcd..1b3c27f61f 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-input.type.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-input.type.ts new file mode 100644 index 0000000000..060257889c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-input.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.schema.ts new file mode 100644 index 0000000000..95a5f53dc9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.schema.ts @@ -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.`, + ), +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.ts new file mode 100644 index 0000000000..54fc478a70 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.ts @@ -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 { + 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', + }; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 831cc66d3f..537de39770 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts b/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts index b2d1d655e6..55e05da54d 100644 --- a/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts @@ -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, { diff --git a/packages/twenty-server/src/engine/core-modules/web-search/constants/web-search-categories.const.ts b/packages/twenty-server/src/engine/core-modules/web-search/constants/web-search-categories.const.ts new file mode 100644 index 0000000000..3f82e101d0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/constants/web-search-categories.const.ts @@ -0,0 +1,9 @@ +export const WEB_SEARCH_CATEGORIES = [ + 'company', + 'research paper', + 'news', + 'pdf', + 'personal site', + 'financial report', + 'people', +] as const; diff --git a/packages/twenty-server/src/engine/core-modules/web-search/drivers/disabled.driver.ts b/packages/twenty-server/src/engine/core-modules/web-search/drivers/disabled.driver.ts new file mode 100644 index 0000000000..8a3223bf75 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/drivers/disabled.driver.ts @@ -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 { + throw new Error(this.reason); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/web-search/drivers/exa.driver.ts b/packages/twenty-server/src/engine/core-modules/web-search/drivers/exa.driver.ts new file mode 100644 index 0000000000..f1a069cd4e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/drivers/exa.driver.ts @@ -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 { + 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') ?? '', + })); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/web-search/drivers/interfaces/web-search-driver.interface.ts b/packages/twenty-server/src/engine/core-modules/web-search/drivers/interfaces/web-search-driver.interface.ts new file mode 100644 index 0000000000..e4e9c9691a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/drivers/interfaces/web-search-driver.interface.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-billing-context.type.ts b/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-billing-context.type.ts new file mode 100644 index 0000000000..e71aac66a1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-billing-context.type.ts @@ -0,0 +1,4 @@ +export type WebSearchBillingContext = { + workspaceId: string; + userWorkspaceId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-category.type.ts b/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-category.type.ts new file mode 100644 index 0000000000..4e661efefc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-category.type.ts @@ -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]; diff --git a/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-options.type.ts b/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-options.type.ts new file mode 100644 index 0000000000..27b3549409 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-options.type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-result.type.ts b/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-result.type.ts new file mode 100644 index 0000000000..e7870f8841 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/types/web-search-result.type.ts @@ -0,0 +1,5 @@ +export type WebSearchResult = { + title: string; + url: string; + snippet: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/web-search/web-search-driver.factory.ts b/packages/twenty-server/src/engine/core-modules/web-search/web-search-driver.factory.ts new file mode 100644 index 0000000000..bf0984bbfb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/web-search-driver.factory.ts @@ -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 { + 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`, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/web-search/web-search.interface.ts b/packages/twenty-server/src/engine/core-modules/web-search/web-search.interface.ts new file mode 100644 index 0000000000..04323016ab --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/web-search.interface.ts @@ -0,0 +1,4 @@ +export enum WebSearchDriverType { + EXA = 'EXA', + DISABLED = 'DISABLED', +} diff --git a/packages/twenty-server/src/engine/core-modules/web-search/web-search.module.ts b/packages/twenty-server/src/engine/core-modules/web-search/web-search.module.ts new file mode 100644 index 0000000000..8e909f3e6a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/web-search.module.ts @@ -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], + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/web-search/web-search.service.ts b/packages/twenty-server/src/engine/core-modules/web-search/web-search.service.ts new file mode 100644 index 0000000000..c2422810f6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/web-search/web-search.service.ts @@ -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 { + 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( + 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, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index 5433ed8508..ae9ac2912e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -19,6 +19,7 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo import { ToolCategory } from 'twenty-shared/ai'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; +import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util'; import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util'; import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billing/utils/merge-language-model-usage.util'; import { @@ -207,6 +208,10 @@ export class AgentAsyncExecutorService { textResponse.steps, ); + const nativeWebSearchCallCount = countNativeWebSearchCallsFromSteps( + textResponse.steps, + ); + const agentSchema = agent?.responseFormat?.type === 'json' ? agent.responseFormat.schema @@ -217,6 +222,7 @@ export class AgentAsyncExecutorService { result: { response: textResponse.text }, usage: textResponse.usage, cacheCreationTokens, + nativeWebSearchCallCount, }; } @@ -246,6 +252,7 @@ export class AgentAsyncExecutorService { structuredResult.usage, ), cacheCreationTokens, + nativeWebSearchCallCount, }; } catch (error) { if (error instanceof AgentException) { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts index fba4154ac7..ed7fe7a1a8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts @@ -4,4 +4,5 @@ export interface AgentExecutionResult { result: object; usage: LanguageModelUsage; cacheCreationTokens: number; + nativeWebSearchCallCount: number; } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars.ts new file mode 100644 index 0000000000..445c335bc4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars.ts @@ -0,0 +1,3 @@ +// Anthropic: $10/1k searches = $0.01/search +// OpenAI: $10/1k searches = $0.01/search +export const NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS = 0.01; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service.ts index d5e4d152fe..1ddafefee9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service.ts @@ -7,6 +7,7 @@ import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-op 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 { NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS } from 'src/engine/metadata-modules/ai/ai-billing/constants/native-web-search-cost-per-call-dollars'; import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util'; import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util'; import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type'; @@ -80,6 +81,41 @@ export class AiBillingService { ); } + billNativeWebSearchUsage( + nativeWebSearchCallCount: number, + workspaceId: string, + userWorkspaceId?: string | null, + ): void { + if (nativeWebSearchCallCount <= 0) { + return; + } + + const costInDollars = + nativeWebSearchCallCount * NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS; + const creditsUsedMicro = Math.round( + convertDollarsToBillingCredits(costInDollars), + ); + + this.logger.log( + `Native web search billing: ${nativeWebSearchCallCount} calls, $${costInDollars.toFixed(4)}`, + ); + + this.workspaceEventEmitter.emitCustomBatchEvent( + USAGE_RECORDED, + [ + { + resourceType: UsageResourceType.AI, + operationType: UsageOperationType.WEB_SEARCH, + creditsUsedMicro, + quantity: nativeWebSearchCallCount, + unit: UsageUnit.INVOCATION, + userWorkspaceId: userWorkspaceId || null, + }, + ], + workspaceId, + ); + } + private emitAiTokenUsageEvent( workspaceId: string, creditsUsedMicro: number, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util.ts new file mode 100644 index 0000000000..c7829761fb --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util.ts @@ -0,0 +1,15 @@ +import { type StepResult, type ToolSet } from 'ai'; + +const WEB_SEARCH_TOOL_NAME = 'web_search'; + +export const countNativeWebSearchCallsFromSteps = ( + steps: StepResult[], +): number => + steps.reduce( + (count, step) => + count + + step.toolCalls.filter( + (toolCall) => toolCall.toolName === WEB_SEARCH_TOOL_NAME, + ).length, + 0, + ); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 36e48fb531..2f062fcbbb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -39,6 +39,7 @@ import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/ import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type'; import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; +import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util'; import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util'; import { MessagePruningService } from 'src/engine/metadata-modules/ai/ai-chat/services/message-pruning.service'; import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service'; @@ -58,6 +59,7 @@ import { } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type'; +import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service'; import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; export type ChatExecutionOptions = { @@ -93,6 +95,7 @@ export class ChatExecutionService { private readonly exceptionHandlerService: ExceptionHandlerService, private readonly sdkProviderFactory: SdkProviderFactoryService, private readonly messagePruningService: MessagePruningService, + private readonly webSearchService: WebSearchService, ) {} async streamChat({ @@ -160,8 +163,12 @@ export class ChatExecutionService { registeredModel.modelId, ); + const useNativeSearch = this.webSearchService.shouldUseNativeSearch(); + const { tools: nativeSearchTools, callableToolNames: searchToolNames } = - this.getNativeWebSearchTools(registeredModel); + useNativeSearch + ? this.getNativeWebSearchTools(registeredModel) + : { tools: {}, callableToolNames: [] }; // Direct tools: native provider tools + preloaded tools. // These are callable directly AND as fallback through execute_tool. @@ -309,6 +316,17 @@ export class ChatExecutionService { null, userWorkspaceId, ); + + if (useNativeSearch) { + const nativeWebSearchCallCount = + countNativeWebSearchCallsFromSteps(steps); + + this.aiBillingService.billNativeWebSearchUsage( + nativeWebSearchCallCount, + workspace.id, + userWorkspaceId, + ); + } }; const stream = streamText({ @@ -449,21 +467,8 @@ export class ChatExecutionService { callableToolNames: ['web_search'], }; } - case AI_SDK_BEDROCK: { - const provider = - this.sdkProviderFactory.getRawBedrockProvider(providerName); - - if (!provider) { - return empty; - } - - return { - tools: { - web_search: provider.tools.webSearch_20250305() as ToolSet[string], - }, - callableToolNames: ['web_search'], - }; - } + case AI_SDK_BEDROCK: + return empty; case AI_SDK_OPENAI: { const provider = this.sdkProviderFactory.getRawOpenAIProvider(providerName); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts index fde9079f6d..4cf5388f3f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts @@ -10,6 +10,7 @@ import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-age import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; +import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service'; import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants'; import { WorkflowStepExecutorException, @@ -27,6 +28,7 @@ export class AiAgentWorkflowAction implements WorkflowAction { constructor( private readonly aiAgentExecutionService: AgentAsyncExecutorService, private readonly aiBillingService: AiBillingService, + private readonly webSearchService: WebSearchService, private readonly workflowExecutionContextService: WorkflowExecutionContextService, @InjectRepository(AgentEntity) private readonly agentRepository: Repository, @@ -79,7 +81,7 @@ export class AiAgentWorkflowAction implements WorkflowAction { ? executionContext.authContext.userWorkspaceId : null; - const { result, usage, cacheCreationTokens } = + const { result, usage, cacheCreationTokens, nativeWebSearchCallCount } = await this.aiAgentExecutionService.executeAgent({ agent, userPrompt: resolveInput(prompt, context) as string, @@ -99,6 +101,14 @@ export class AiAgentWorkflowAction implements WorkflowAction { userWorkspaceId, ); + if (this.webSearchService.shouldUseNativeSearch()) { + this.aiBillingService.billNativeWebSearchUsage( + nativeWebSearchCallCount, + workspaceId, + userWorkspaceId, + ); + } + return { result, }; diff --git a/yarn.lock b/yarn.lock index 481c585b74..7486b13723 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34335,7 +34335,7 @@ __metadata: languageName: node linkType: hard -"cross-fetch@npm:4.1.0": +"cross-fetch@npm:4.1.0, cross-fetch@npm:~4.1.0": version: 4.1.0 resolution: "cross-fetch@npm:4.1.0" dependencies: @@ -36081,7 +36081,7 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:~16.4.5": +"dotenv@npm:~16.4.5, dotenv@npm:~16.4.7": version: 16.4.7 resolution: "dotenv@npm:16.4.7" checksum: 10c0/be9f597e36a8daf834452daa1f4cc30e5375a5968f98f46d89b16b983c567398a330580c88395069a77473943c06b877d1ca25b4afafcdd6d4adb549e8293462 @@ -37948,6 +37948,19 @@ __metadata: languageName: node linkType: hard +"exa-js@npm:^2.11.0": + version: 2.11.0 + resolution: "exa-js@npm:2.11.0" + dependencies: + cross-fetch: "npm:~4.1.0" + dotenv: "npm:~16.4.7" + openai: "npm:^5.0.1" + zod: "npm:^3.22.0" + zod-to-json-schema: "npm:^3.20.0" + checksum: 10c0/3545b98e55f8196c8c2c415684803a3bda0977abdb0950142898ce680eabcd3a855f5d0e4d30ca0cd19ddf41e5e81d4db5992ea35e5b38129ce1f41be94d324c + languageName: node + linkType: hard + "execa@npm:^1.0.0": version: 1.0.0 resolution: "execa@npm:1.0.0" @@ -50570,6 +50583,23 @@ __metadata: languageName: node linkType: hard +"openai@npm:^5.0.1": + version: 5.23.2 + resolution: "openai@npm:5.23.2" + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + bin: + openai: bin/cli + checksum: 10c0/8ee37f37c64fd04a61622c4782d231e8f0245e2d85956d833e43d30946edbf07d5276cd74e09aa5d23006de8da43dad312246a9dd7ca6ca6d65d983fc976ffa6 + languageName: node + linkType: hard + "openapi-fetch@npm:^0.9.7": version: 0.9.8 resolution: "openapi-fetch@npm:0.9.8" @@ -60582,6 +60612,7 @@ __metadata: deep-equal: "npm:2.2.3" dompurify: "npm:3.3.3" dotenv: "npm:16.4.5" + exa-js: "npm:^2.11.0" express: "npm:4.22.1" express-session: "npm:^1.18.2" file-type: "npm:^21.3.1" @@ -64632,6 +64663,15 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:^3.20.0": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10c0/dd300554393903022487688af14fbda5c719ba8179702bb55b3aa86318830467f0f7beb7d654036975ac963dc4843b72e59636448bfff9a0608f277bb6a14939 + languageName: node + linkType: hard + "zod-to-json-schema@npm:^3.20.3": version: 3.24.6 resolution: "zod-to-json-schema@npm:3.24.6" @@ -64648,7 +64688,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.20.6, zod@npm:^3.23.8, zod@npm:^3.25.76": +"zod@npm:^3.20.6, zod@npm:^3.22.0, zod@npm:^3.23.8, zod@npm:^3.25.76": version: 3.25.76 resolution: "zod@npm:3.25.76" checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c