From e430e4ea0a1a1cacde3f61b999df4818ea67ea35 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Sun, 31 May 2026 18:41:04 +0530 Subject: [PATCH] fix(ai): route xAI search through Responses API as native tools (#21037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI deprecated Live Search, so the `searchParameters` provider option now returns 410. This routes all xAI models through the Responses API and binds web/X search as native agent tools, matching how Anthropic/OpenAI expose search. - xAI provider now uses `provider.responses()` — its `webSearch()`/`xSearch()` tools only run against the Responses endpoint, not chat completions - web/X search migrated from the `provider-option` variant to `sdk-tool` (`web_search`/`x_search`); deleted the dead `searchParameters` path, the `provider-option` variant, and `providerOptions` on `NativeModelBinding` - dropped a dead `rolePermissionConfig` param on `getAgentRoleId`, left over from #20331 --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait --- .../agent-async-executor.service.spec.ts | 2 +- .../services/agent-async-executor.service.ts | 15 +- .../services/chat-execution.service.ts | 15 +- ...native-model-tools-by-sdk-package.const.ts | 8 +- .../__tests__/ai-model-config.service.spec.ts | 230 ++++++++++++------ .../services/ai-model-config.service.ts | 149 ++++-------- .../services/native-tool-binder.interface.ts | 12 +- .../services/native-tool-binder.service.ts | 7 +- .../services/sdk-provider-factory.service.ts | 21 +- .../types/native-model-binding.type.ts | 7 - .../types/native-model-tools.type.ts | 12 +- 11 files changed, 252 insertions(+), 226 deletions(-) delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts index 69f9a36ece..2e72e3fd12 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts @@ -79,7 +79,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti { provide: NativeToolBinderService, useValue: { - bind: jest.fn().mockReturnValue({ tools: {}, providerOptions: {} }), + bind: jest.fn().mockReturnValue({}), }, }, { 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 ee5d45598d..3d5a9d99c9 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 @@ -18,7 +18,6 @@ import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-a import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; -import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -39,6 +38,7 @@ import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billi import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; import { AiException, @@ -87,7 +87,6 @@ export class AgentAsyncExecutorService { private async getAgentRoleId( agentId: string, workspaceId: string, - rolePermissionConfig?: RolePermissionConfig, ): Promise { const roleTarget = await this.roleTargetRepository.findOne(workspaceId, { where: { @@ -188,22 +187,20 @@ export class AgentAsyncExecutorService { ); } - const nativeBinding = this.nativeToolBinder.bind( + const nativeTools = this.nativeToolBinder.bind( registeredModel, nativeModelToolOptions, ); tools = { ...registryTools, - ...nativeBinding.tools, + ...nativeTools, }; - providerOptions = { - ...nativeBinding.providerOptions, - ...this.aiModelConfigService.getReasoningProviderOptions( + providerOptions = + this.aiModelConfigService.getReasoningProviderOptions( registeredModel, - ), - }; + ); } this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); 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 ee64f15bd1..2433cbc74a 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 @@ -25,7 +25,6 @@ import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-pr import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; -import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { createExecuteToolTool, @@ -61,6 +60,7 @@ import { } from 'src/engine/metadata-modules/ai/ai-chat/utils/inject-cache-breakpoint.util'; import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; import { type AiModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type'; import { getNativeModelCapabilities } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util'; import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; @@ -168,7 +168,7 @@ export class ChatExecutionService { const nativeCapabilities = getNativeModelCapabilities( registeredModel.sdkPackage, ); - const nativeBinding = this.nativeToolBinder.bind(registeredModel, { + const nativeTools = this.nativeToolBinder.bind(registeredModel, { webSearch: nativeCapabilities?.webSearch === true, twitterSearch: nativeCapabilities?.twitterSearch === true, }); @@ -178,12 +178,12 @@ export class ChatExecutionService { // serialized). execute_tool routes discovered tools through the registry. const directTools: ToolSet = { ...preloadedTools, - ...nativeBinding.tools, + ...nativeTools, }; const preloadedToolNames = [ ...Object.keys(preloadedTools), - ...Object.keys(nativeBinding.tools), + ...Object.keys(nativeTools), ]; // ToolSet is constant for the entire conversation — no mutation. @@ -399,10 +399,9 @@ export class ChatExecutionService { stopWhen: (step) => stepCountIs(AGENT_CONFIG.MAX_STEPS)(step) || hasNoMoreAvailableCredits, experimental_telemetry: AI_TELEMETRY_CONFIG, - providerOptions: { - ...nativeBinding.providerOptions, - ...getCallLevelCacheProviderOptions(registeredModel.sdkPackage), - }, + providerOptions: getCallLevelCacheProviderOptions( + registeredModel.sdkPackage, + ), prepareStep: ({ messages }) => { stepStartedAt = performance.now(); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const.ts index bb58811ec3..6aed87445b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const.ts @@ -27,12 +27,12 @@ export const NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE = { }, [AI_SDK_XAI]: { webSearch: { - kind: 'provider-option', - providerOptionKey: 'web', + kind: 'sdk-tool', + directToolName: 'web_search', }, twitterSearch: { - kind: 'provider-option', - providerOptionKey: 'x', + kind: 'sdk-tool', + directToolName: 'x_search', }, }, [AI_SDK_GOOGLE]: {}, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-config.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-config.service.spec.ts index 11f55c4be8..4073c0d123 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-config.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-config.service.spec.ts @@ -1,7 +1,9 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { AI_SDK_ANTHROPIC, + AI_SDK_OPENAI, AI_SDK_XAI, } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; @@ -11,124 +13,206 @@ 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'; -describe('AiModelConfigService.getNativeModelBinding — xAI search parameters', () => { +const XAI_WEB_TOOL = { __tool: 'xai-web-search' }; +const XAI_X_TOOL = { __tool: 'xai-x-search' }; +const ANTHROPIC_WEB_TOOL = { __tool: 'anthropic-web-search' }; +const OPENAI_WEB_TOOL = { __tool: 'openai-web-search' }; + +describe('AiModelConfigService.getNativeModelTools — tool-based native search', () => { let service: AiModelConfigService; + let sdkProviderFactory: { + getRawXaiProvider: jest.Mock; + getRawAnthropicProvider: jest.Mock; + getRawOpenAIProvider: jest.Mock; + }; const xaiModel: RegisteredAiModel = { modelId: 'xai/grok-4', sdkPackage: AI_SDK_XAI, model: {} as RegisteredAiModel['model'], + providerName: 'xai', }; beforeEach(async () => { + sdkProviderFactory = { + getRawXaiProvider: jest.fn().mockReturnValue({ + tools: { + webSearch: jest.fn().mockReturnValue(XAI_WEB_TOOL), + xSearch: jest.fn().mockReturnValue(XAI_X_TOOL), + }, + }), + getRawAnthropicProvider: jest.fn().mockReturnValue({ + tools: { + webSearch_20250305: jest.fn().mockReturnValue(ANTHROPIC_WEB_TOOL), + }, + }), + getRawOpenAIProvider: jest.fn().mockReturnValue({ + tools: { + webSearch: jest.fn().mockReturnValue(OPENAI_WEB_TOOL), + }, + }), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ AiModelConfigService, - { - provide: AiModelRegistryService, - useValue: {}, - }, - { - provide: SdkProviderFactoryService, - useValue: {}, - }, + { provide: AiModelRegistryService, useValue: {} }, + { provide: SdkProviderFactoryService, useValue: sdkProviderFactory }, ], }).compile(); service = module.get(AiModelConfigService); }); - it('returns empty options when neither webSearch nor twitterSearch is enabled', () => { + it('binds no tools when neither webSearch nor twitterSearch is enabled', () => { expect( - service.getNativeModelBinding(xaiModel, { + service.getNativeModelTools(xaiModel, { webSearch: false, twitterSearch: false, - }).providerOptions, + }), ).toEqual({}); }); - it('omits sources entirely when neither flag is enabled (no implicit "auto" search)', () => { - const result = service.getNativeModelBinding(xaiModel, {}).providerOptions; - - expect(result).toEqual({}); + it('binds no tools when no options are passed (no implicit "auto" search)', () => { + expect(service.getNativeModelTools(xaiModel, {})).toEqual({}); }); - it('emits only the web source when only webSearch is enabled', () => { - expect( - service.getNativeModelBinding(xaiModel, { - webSearch: true, - twitterSearch: false, - }).providerOptions, - ).toEqual({ - xai: { - searchParameters: { - mode: 'auto', - sources: [{ type: 'web' }], - }, - }, + it('binds only the xAI web_search tool when only webSearch is enabled', () => { + const tools = service.getNativeModelTools(xaiModel, { + webSearch: true, + twitterSearch: false, }); + + expect(tools).toEqual({ web_search: XAI_WEB_TOOL }); }); - it('emits only the x source when only twitterSearch is enabled', () => { - expect( - service.getNativeModelBinding(xaiModel, { - webSearch: false, - twitterSearch: true, - }).providerOptions, - ).toEqual({ - xai: { - searchParameters: { - mode: 'auto', - sources: [{ type: 'x' }], - }, - }, + it('binds only the xAI x_search tool when only twitterSearch is enabled', () => { + const tools = service.getNativeModelTools(xaiModel, { + webSearch: false, + twitterSearch: true, }); + + expect(tools).toEqual({ x_search: XAI_X_TOOL }); }); - it('emits both sources when webSearch and twitterSearch are enabled', () => { - expect( - service.getNativeModelBinding(xaiModel, { - webSearch: true, - twitterSearch: true, - }).providerOptions, - ).toEqual({ - xai: { - searchParameters: { - mode: 'auto', - sources: [{ type: 'web' }, { type: 'x' }], - }, - }, - }); - }); - - it('preserves source order — web before x — for deterministic provider payloads', () => { - const result = service.getNativeModelBinding(xaiModel, { + it('binds both xAI tools when webSearch and twitterSearch are enabled', () => { + const tools = service.getNativeModelTools(xaiModel, { webSearch: true, twitterSearch: true, - }).providerOptions; + }); - expect(result).toMatchObject({ - xai: { - searchParameters: { - sources: [{ type: 'web' }, { type: 'x' }], + expect(tools).toEqual({ + web_search: XAI_WEB_TOOL, + x_search: XAI_X_TOOL, + }); + }); + + it('binds no tools when the xAI provider cannot be resolved', () => { + sdkProviderFactory.getRawXaiProvider.mockReturnValueOnce(undefined); + + expect( + service.getNativeModelTools(xaiModel, { + webSearch: true, + twitterSearch: true, + }), + ).toEqual({}); + }); + + it('binds no tools when the model has no resolved providerName', () => { + const modelWithoutProvider: RegisteredAiModel = { + modelId: 'xai/grok-4', + sdkPackage: AI_SDK_XAI, + model: {} as RegisteredAiModel['model'], + }; + + expect( + service.getNativeModelTools(modelWithoutProvider, { + webSearch: true, + twitterSearch: true, + }), + ).toEqual({}); + expect(sdkProviderFactory.getRawXaiProvider).not.toHaveBeenCalled(); + }); + + it('binds the Anthropic web_search tool when webSearch is enabled', () => { + const anthropicModel: RegisteredAiModel = { + modelId: 'anthropic/claude-sonnet-4-6', + sdkPackage: AI_SDK_ANTHROPIC, + model: {} as RegisteredAiModel['model'], + providerName: 'anthropic', + }; + + expect( + service.getNativeModelTools(anthropicModel, { webSearch: true }), + ).toEqual({ web_search: ANTHROPIC_WEB_TOOL }); + }); + + it('binds the OpenAI web_search tool when webSearch is enabled', () => { + const openaiModel: RegisteredAiModel = { + modelId: 'openai/gpt-4.1', + sdkPackage: AI_SDK_OPENAI, + model: {} as RegisteredAiModel['model'], + providerName: 'openai', + }; + + expect( + service.getNativeModelTools(openaiModel, { webSearch: true }), + ).toEqual({ web_search: OPENAI_WEB_TOOL }); + }); +}); + +describe('AiModelConfigService.getReasoningProviderOptions', () => { + let service: AiModelConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AiModelConfigService, + { provide: AiModelRegistryService, useValue: {} }, + { provide: SdkProviderFactoryService, useValue: {} }, + ], + }).compile(); + + service = module.get(AiModelConfigService); + }); + + it('enables Anthropic thinking for reasoning-capable models', () => { + const anthropicModel: RegisteredAiModel = { + modelId: 'anthropic/claude-sonnet-4-6', + sdkPackage: AI_SDK_ANTHROPIC, + model: {} as RegisteredAiModel['model'], + supportsReasoning: true, + }; + + expect(service.getReasoningProviderOptions(anthropicModel)).toEqual({ + anthropic: { + thinking: { + type: 'enabled', + budgetTokens: AGENT_CONFIG.REASONING_BUDGET_TOKENS, }, }, }); }); - it('returns empty options for non-xAI models even when search flags are on', () => { + it('returns empty options for non-reasoning Anthropic models', () => { const anthropicModel: RegisteredAiModel = { - modelId: 'anthropic/claude-sonnet-4-6', + modelId: 'anthropic/claude-haiku', sdkPackage: AI_SDK_ANTHROPIC, model: {} as RegisteredAiModel['model'], supportsReasoning: false, }; - expect( - service.getNativeModelBinding(anthropicModel, { - webSearch: true, - twitterSearch: true, - }).providerOptions, - ).toEqual({}); + expect(service.getReasoningProviderOptions(anthropicModel)).toEqual({}); + }); + + it('returns empty options for xAI models (no reasoning provider options)', () => { + const xaiModel: RegisteredAiModel = { + modelId: 'xai/grok-4', + sdkPackage: AI_SDK_XAI, + model: {} as RegisteredAiModel['model'], + supportsReasoning: true, + }; + + expect(service.getReasoningProviderOptions(xaiModel)).toEqual({}); }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts index 4ac2084caf..9087d3d026 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { type ProviderOptions } from '@ai-sdk/provider-utils'; import { type ToolSet } from 'ai'; @@ -11,34 +11,21 @@ import { AI_SDK_OPENAI, AI_SDK_XAI, } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; -import { getNativeModelToolsForSdkPackage } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-tools-for-sdk-package.util'; import { AiModelRegistryService, RegisteredAiModel, } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; -import { type NativeModelBinding } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type'; -import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; +import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; +import { getNativeModelToolsForSdkPackage } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-tools-for-sdk-package.util'; @Injectable() export class AiModelConfigService { - private readonly logger = new Logger(AiModelConfigService.name); - constructor( private readonly aiModelRegistryService: AiModelRegistryService, private readonly sdkProviderFactory: SdkProviderFactoryService, ) {} - getNativeModelBinding( - model: RegisteredAiModel, - options: NativeModelToolOptions = {}, - ): NativeModelBinding { - return { - tools: this.getNativeModelTools(model, options), - providerOptions: this.getNativeSearchProviderOptions(model, options), - }; - } - getReasoningProviderOptions(model: RegisteredAiModel): ProviderOptions { switch (model.sdkPackage) { case AI_SDK_ANTHROPIC: @@ -50,54 +37,65 @@ export class AiModelConfigService { } } - private getNativeModelTools( + getNativeModelTools( model: RegisteredAiModel, - options: NativeModelToolOptions, + options: NativeModelToolOptions = {}, ): ToolSet { const tools: Record = {}; - if (options.webSearch !== true) { - return tools as ToolSet; - } + const nativeTools = getNativeModelToolsForSdkPackage(model.sdkPackage); + const providerName = model.providerName; - const webSearchTool = getNativeModelToolsForSdkPackage( - model.sdkPackage, - )?.webSearch; - - if (!isDefined(webSearchTool)) { - this.logger.warn( - `webSearch requested for sdkPackage="${model.sdkPackage}" but no native binding is registered. Skipping.`, - ); - - return tools as ToolSet; - } - - // provider-option bindings (e.g. xAI) are handled in getNativeSearchProviderOptions - if (webSearchTool.kind !== 'sdk-tool') { + if (!isDefined(nativeTools) || !isDefined(providerName)) { return tools as ToolSet; } switch (model.sdkPackage) { case AI_SDK_ANTHROPIC: { - const anthropicProvider = model.providerName - ? this.sdkProviderFactory.getRawAnthropicProvider(model.providerName) - : undefined; + if (options.webSearch === true && isDefined(nativeTools.webSearch)) { + const anthropicProvider = + this.sdkProviderFactory.getRawAnthropicProvider(providerName); - if (anthropicProvider) { - tools[webSearchTool.directToolName] = - anthropicProvider.tools.webSearch_20250305(); + if (isDefined(anthropicProvider)) { + tools[nativeTools.webSearch.directToolName] = + anthropicProvider.tools.webSearch_20250305(); + } } break; } case AI_SDK_OPENAI: { - const openaiProvider = model.providerName - ? this.sdkProviderFactory.getRawOpenAIProvider(model.providerName) - : undefined; + if (options.webSearch === true && isDefined(nativeTools.webSearch)) { + const openaiProvider = + this.sdkProviderFactory.getRawOpenAIProvider(providerName); - if (openaiProvider) { - tools[webSearchTool.directToolName] = - openaiProvider.tools.webSearch(); + if (isDefined(openaiProvider)) { + tools[nativeTools.webSearch.directToolName] = + openaiProvider.tools.webSearch(); + } + } + + break; + } + case AI_SDK_XAI: { + const xaiProvider = + this.sdkProviderFactory.getRawXaiProvider(providerName); + + if (!isDefined(xaiProvider)) { + break; + } + + if (options.webSearch === true && isDefined(nativeTools.webSearch)) { + tools[nativeTools.webSearch.directToolName] = + xaiProvider.tools.webSearch(); + } + + if ( + options.twitterSearch === true && + isDefined(nativeTools.twitterSearch) + ) { + tools[nativeTools.twitterSearch.directToolName] = + xaiProvider.tools.xSearch(); } break; @@ -107,63 +105,6 @@ export class AiModelConfigService { return tools as ToolSet; } - private getNativeSearchProviderOptions( - model: RegisteredAiModel, - options: NativeModelToolOptions, - ): ProviderOptions { - switch (model.sdkPackage) { - case AI_SDK_XAI: - return this.getXaiSearchProviderOptions(options); - default: - return {}; - } - } - - private getXaiSearchProviderOptions( - options: NativeModelToolOptions, - ): ProviderOptions { - const webSearchEnabled = options.webSearch === true; - const twitterSearchEnabled = options.twitterSearch === true; - - if (!webSearchEnabled && !twitterSearchEnabled) { - return {}; - } - - const sources: Array<{ type: string }> = []; - const xaiTools = getNativeModelToolsForSdkPackage(AI_SDK_XAI); - const webSearchTool = xaiTools?.webSearch; - const twitterSearchTool = xaiTools?.twitterSearch; - - if (webSearchEnabled) { - if (webSearchTool?.kind === 'provider-option') { - sources.push({ type: webSearchTool.providerOptionKey }); - } else { - this.logger.warn( - `webSearch requested for xAI but no provider-option binding is registered. Skipping.`, - ); - } - } - - if (twitterSearchEnabled) { - if (twitterSearchTool?.kind === 'provider-option') { - sources.push({ type: twitterSearchTool.providerOptionKey }); - } else { - this.logger.warn( - `twitterSearch requested for xAI but no provider-option binding is registered. Skipping.`, - ); - } - } - - return { - xai: { - searchParameters: { - mode: 'auto', - ...(sources.length > 0 && { sources }), - }, - }, - }; - } - private getAnthropicProviderOptions( model: RegisteredAiModel, ): ProviderOptions { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface.ts index 91ab10995f..93ea416645 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface.ts @@ -1,12 +1,10 @@ +import { type ToolSet } from 'ai'; + import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; -import { type NativeModelBinding } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type'; import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; -// Parallel to ToolProvider — resolves the complete native-model call payload -// (SDK-native tools + provider options), not registry descriptors. +// Parallel to ToolProvider — resolves the SDK-native tools the model can call +// directly (web search, X search, …), not registry descriptors. export interface NativeToolBinder { - bind( - model: RegisteredAiModel, - options: NativeModelToolOptions, - ): NativeModelBinding; + bind(model: RegisteredAiModel, options: NativeModelToolOptions): ToolSet; } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service.ts index 878f748e31..95d342eeb1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; +import { type ToolSet } from 'ai'; + import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { type NativeToolBinder } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface'; -import { type NativeModelBinding } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type'; import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; @Injectable() @@ -13,7 +14,7 @@ export class NativeToolBinderService implements NativeToolBinder { bind( model: RegisteredAiModel, options: NativeModelToolOptions = {}, - ): NativeModelBinding { - return this.aiModelConfigService.getNativeModelBinding(model, options); + ): ToolSet { + return this.aiModelConfigService.getNativeModelTools(model, options); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service.ts index 2d197eb922..448f880fbf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service.ts @@ -7,7 +7,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { createMistral } from '@ai-sdk/mistral'; import { createOpenAI, type OpenAIProvider } from '@ai-sdk/openai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { createXai } from '@ai-sdk/xai'; +import { createXai, type XaiProvider } from '@ai-sdk/xai'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { type LanguageModel } from 'ai'; import { type AiSdkPackage } from 'twenty-shared/ai'; @@ -75,6 +75,10 @@ export class SdkProviderFactoryService { return this.getRawProvider(providerName, AI_SDK_OPENAI); } + getRawXaiProvider(providerName: string): XaiProvider | undefined { + return this.getRawProvider(providerName, AI_SDK_XAI); + } + clearCache(): void { this.providerInstances.clear(); } @@ -92,7 +96,7 @@ export class SdkProviderFactoryService { case AI_SDK_MISTRAL: return this.buildStandardProvider(config, createMistral); case AI_SDK_XAI: - return this.buildStandardProvider(config, createXai); + return this.buildXaiProvider(config); case AI_SDK_BEDROCK: return this.buildBedrockProvider(config); case AI_SDK_OPENAI_COMPATIBLE: @@ -121,6 +125,19 @@ export class SdkProviderFactoryService { }; } + private buildXaiProvider(config: AiProviderConfig): AiSdkProviderInstance { + const provider = createXai({ + ...(config.apiKey && { apiKey: config.apiKey }), + ...(config.baseUrl && { baseURL: config.baseUrl }), + }); + + return { + createModel: (modelId: string) => provider.responses(modelId), + rawProvider: provider, + sdkPackage: AI_SDK_XAI, + }; + } + private buildBedrockProvider( config: AiProviderConfig, ): AiSdkProviderInstance { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type.ts deleted file mode 100644 index 9bc7a981c3..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type ProviderOptions } from '@ai-sdk/provider-utils'; -import { type ToolSet } from 'ai'; - -export type NativeModelBinding = { - tools: ToolSet; - providerOptions: ProviderOptions; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type.ts index 08ab624cdc..d2719040e4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type.ts @@ -3,13 +3,9 @@ import { type NativeModelToolKey } from 'src/engine/metadata-modules/ai/ai-model export type NativeModelTools = Partial< Record< NativeModelToolKey, - | { - kind: 'sdk-tool'; - directToolName: string; - } - | { - kind: 'provider-option'; - providerOptionKey: string; - } + { + kind: 'sdk-tool'; + directToolName: string; + } > >;