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:
@@ -3376,6 +3376,7 @@ enum UsageOperationType {
|
||||
AI_WORKFLOW_TOKEN
|
||||
WORKFLOW_EXECUTION
|
||||
CODE_EXECUTION
|
||||
WEB_SEARCH
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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 {}
|
||||
|
||||
+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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+1
@@ -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, {
|
||||
|
||||
+9
@@ -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') ?? '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
+14
@@ -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[]>;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type WebSearchBillingContext = {
|
||||
workspaceId: string;
|
||||
userWorkspaceId?: string;
|
||||
};
|
||||
+3
@@ -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];
|
||||
+6
@@ -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;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type WebSearchResult = {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
};
|
||||
+55
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
@@ -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) {
|
||||
|
||||
+1
@@ -4,4 +4,5 @@ export interface AgentExecutionResult {
|
||||
result: object;
|
||||
usage: LanguageModelUsage;
|
||||
cacheCreationTokens: number;
|
||||
nativeWebSearchCallCount: number;
|
||||
}
|
||||
|
||||
+3
@@ -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;
|
||||
+36
@@ -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<UsageEvent>(
|
||||
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,
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type StepResult, type ToolSet } from 'ai';
|
||||
|
||||
const WEB_SEARCH_TOOL_NAME = 'web_search';
|
||||
|
||||
export const countNativeWebSearchCallsFromSteps = (
|
||||
steps: StepResult<ToolSet>[],
|
||||
): number =>
|
||||
steps.reduce(
|
||||
(count, step) =>
|
||||
count +
|
||||
step.toolCalls.filter(
|
||||
(toolCall) => toolCall.toolName === WEB_SEARCH_TOOL_NAME,
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
+21
-16
@@ -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);
|
||||
|
||||
+11
-1
@@ -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<AgentEntity>,
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user