From 6073bb6706f3fa6c6dd20bbdfef03d10996120a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 8 Apr 2026 22:32:05 +0200 Subject: [PATCH] Fix AI model registry staleness on self-hosted instances (#19427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #19422. Self-hosted users hitting "No AI models are available" after configuring API keys via the admin panel were victims of a stale `AiModelRegistryService` cache. Only the four `addAiProvider` / `removeAiProvider` / `addModelToProvider` / `removeModelFromProvider` mutations called `refreshRegistry()` — setting an API key through `set/update/deleteDatabaseConfigVariable` left the registry pointing at the pre-mutation provider state. Rather than patch each mutation site (and re-introduce the same class of bug on the next one), the registry now invalidates lazily based on the LLM config-group hash, mirroring the pattern `WebSearchDriverFactory` already uses via `DriverFactoryBase`. Any mutation to an LLM-tagged config variable is picked up automatically on the next read — callers never have to remember to refresh. - Extracted `getConfigGroupHash` into a shared util reused by both `DriverFactoryBase` and `AiModelRegistryService` (and switched the hash to `JSON.stringify` so object-typed config vars like `AI_PROVIDERS` actually contribute meaningfully). - `AiModelRegistryService` gates all internal `Map` access behind private getters that call `ensureFresh()`, so future read paths can't accidentally observe stale state. Build path uses underscored backing fields directly to avoid recursing. - Dropped the now-redundant `refreshRegistry()` public method and its four call sites in `admin-panel.resolver.ts`. ## Test plan - [x] `nx typecheck twenty-server` passes - [x] Existing admin-panel + ai-models specs pass (79 tests) - [ ] Manual: on a self-hosted instance, set `OPENAI_API_KEY` (or another provider key) via the admin panel `setDatabaseConfigVariable` mutation and confirm AI features work without a server restart - [ ] Manual: existing flows (`addAiProvider`, `addModelToProvider`, etc.) still pick up new providers on the next read 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- .../admin-panel/admin-panel.resolver.ts | 4 -- .../captcha/captcha-driver.factory.ts | 6 ++- .../code-interpreter-driver.factory.ts | 10 +++-- .../__tests__/email-driver.factory.spec.ts | 18 +++++++-- .../email/email-driver.factory.ts | 10 +++-- .../drivers/emailing-domain-driver.factory.ts | 6 ++- .../file-storage-driver.factory.spec.ts | 16 +++++++- .../file-storage-driver.factory.ts | 10 +++-- .../logic-function-driver.factory.ts | 6 ++- .../twenty-config/dynamic-factory.base.ts | 36 +++-------------- .../services/config-group-hash.service.ts | 40 +++++++++++++++++++ .../twenty-config/twenty-config.module.ts | 4 +- .../web-search/web-search-driver.factory.ts | 10 +++-- .../constants/ai-models-types.const.spec.ts | 5 +++ .../services/ai-model-registry.service.ts | 35 +++++++++++++--- 15 files changed, 152 insertions(+), 64 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/services/config-group-hash.service.ts diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index 70894bc31e..9c932ad41b 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -403,7 +403,6 @@ export class AdminPanelResolver { customProviders[providerName] = providerConfig; await this.twentyConfigService.set('AI_PROVIDERS', customProviders); - this.aiModelRegistryService.refreshRegistry(); return true; } @@ -420,7 +419,6 @@ export class AdminPanelResolver { delete customProviders[providerName]; await this.twentyConfigService.set('AI_PROVIDERS', customProviders); - this.aiModelRegistryService.refreshRegistry(); return true; } @@ -475,7 +473,6 @@ export class AdminPanelResolver { }; await this.twentyConfigService.set('AI_PROVIDERS', customProviders); - this.aiModelRegistryService.refreshRegistry(); return true; } @@ -508,7 +505,6 @@ export class AdminPanelResolver { }; await this.twentyConfigService.set('AI_PROVIDERS', customProviders); - this.aiModelRegistryService.refreshRegistry(); return true; } diff --git a/packages/twenty-server/src/engine/core-modules/captcha/captcha-driver.factory.ts b/packages/twenty-server/src/engine/core-modules/captcha/captcha-driver.factory.ts index 5763d033f0..26b58191a6 100644 --- a/packages/twenty-server/src/engine/core-modules/captcha/captcha-driver.factory.ts +++ b/packages/twenty-server/src/engine/core-modules/captcha/captcha-driver.factory.ts @@ -8,15 +8,17 @@ import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; 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 { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class CaptchaDriverFactory extends DriverFactoryBase { constructor( twentyConfigService: TwentyConfigService, + configGroupHashService: ConfigGroupHashService, private readonly secureHttpClientService: SecureHttpClientService, ) { - super(twentyConfigService); + super(twentyConfigService, configGroupHashService); } protected buildConfigKey(): string { @@ -26,7 +28,7 @@ export class CaptchaDriverFactory extends DriverFactoryBase { - constructor(twentyConfigService: TwentyConfigService) { - super(twentyConfigService); + constructor( + twentyConfigService: TwentyConfigService, + configGroupHashService: ConfigGroupHashService, + ) { + super(twentyConfigService, configGroupHashService); } protected buildConfigKey(): string { const driverType = this.twentyConfigService.get('CODE_INTERPRETER_TYPE'); if (driverType === CodeInterpreterDriverType.E_2_B) { - return `e2b|${this.getConfigGroupHash(ConfigVariablesGroup.CODE_INTERPRETER_CONFIG)}`; + return `e2b|${this.configGroupHashService.computeHash(ConfigVariablesGroup.CODE_INTERPRETER_CONFIG)}`; } return driverType; diff --git a/packages/twenty-server/src/engine/core-modules/email/__tests__/email-driver.factory.spec.ts b/packages/twenty-server/src/engine/core-modules/email/__tests__/email-driver.factory.spec.ts index 353f8e868a..a56250b1b2 100644 --- a/packages/twenty-server/src/engine/core-modules/email/__tests__/email-driver.factory.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/email/__tests__/email-driver.factory.spec.ts @@ -2,15 +2,20 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { EmailDriverFactory } from 'src/engine/core-modules/email/email-driver.factory'; import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum'; +import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; describe('EmailDriverFactory', () => { let factory: EmailDriverFactory; let twentyConfigService: TwentyConfigService; + let configGroupHashService: ConfigGroupHashService; const mockTwentyConfigService = { get: jest.fn(), }; + const mockConfigGroupHashService = { + computeHash: jest.fn().mockReturnValue(''), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -20,11 +25,18 @@ describe('EmailDriverFactory', () => { provide: TwentyConfigService, useValue: mockTwentyConfigService, }, + { + provide: ConfigGroupHashService, + useValue: mockConfigGroupHashService, + }, ], }).compile(); factory = module.get(EmailDriverFactory); twentyConfigService = module.get(TwentyConfigService); + configGroupHashService = module.get( + ConfigGroupHashService, + ); jest.clearAllMocks(); }); @@ -44,7 +56,7 @@ describe('EmailDriverFactory', () => { it('should return smtp config key for smtp driver', () => { jest.spyOn(twentyConfigService, 'get').mockReturnValue(EmailDriver.SMTP); jest - .spyOn(factory as any, 'getConfigGroupHash') + .spyOn(configGroupHashService, 'computeHash') .mockReturnValue('smtp-hash-123'); const result = factory['buildConfigKey'](); @@ -179,7 +191,7 @@ describe('EmailDriverFactory', () => { } }); jest - .spyOn(factory as any, 'getConfigGroupHash') + .spyOn(configGroupHashService, 'computeHash') .mockReturnValue('smtp-hash-123'); const driver2 = factory.getCurrentDriver(); @@ -214,7 +226,7 @@ describe('EmailDriverFactory', () => { }); jest - .spyOn(factory as any, 'getConfigGroupHash') + .spyOn(configGroupHashService, 'computeHash') .mockReturnValue('smtp-hash-123'); jest.spyOn(factory as any, 'createDriver').mockImplementation(() => { diff --git a/packages/twenty-server/src/engine/core-modules/email/email-driver.factory.ts b/packages/twenty-server/src/engine/core-modules/email/email-driver.factory.ts index 4773652773..3f955f4653 100644 --- a/packages/twenty-server/src/engine/core-modules/email/email-driver.factory.ts +++ b/packages/twenty-server/src/engine/core-modules/email/email-driver.factory.ts @@ -7,12 +7,16 @@ import { SmtpDriver } from 'src/engine/core-modules/email/drivers/smtp.driver'; import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum'; 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 { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class EmailDriverFactory extends DriverFactoryBase { - constructor(twentyConfigService: TwentyConfigService) { - super(twentyConfigService); + constructor( + twentyConfigService: TwentyConfigService, + configGroupHashService: ConfigGroupHashService, + ) { + super(twentyConfigService, configGroupHashService); } protected buildConfigKey(): string { @@ -23,7 +27,7 @@ export class EmailDriverFactory extends DriverFactoryBase } if (driver === EmailDriver.SMTP) { - const emailConfigHash = this.getConfigGroupHash( + const emailConfigHash = this.configGroupHashService.computeHash( ConfigVariablesGroup.EMAIL_SETTINGS, ); diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory.ts index f9cc036a26..c928c3f850 100644 --- a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory.ts +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory.ts @@ -9,23 +9,25 @@ import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domai import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain'; 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 { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class EmailingDomainDriverFactory extends DriverFactoryBase { constructor( twentyConfigService: TwentyConfigService, + configGroupHashService: ConfigGroupHashService, private readonly awsSesClientProvider: AwsSesClientProvider, private readonly awsSesHandleErrorService: AwsSesHandleErrorService, ) { - super(twentyConfigService); + super(twentyConfigService, configGroupHashService); } protected buildConfigKey(): string { const driver = EmailingDomainDriver.AWS_SES; if (driver === EmailingDomainDriver.AWS_SES) { - const awsConfigHash = this.getConfigGroupHash( + const awsConfigHash = this.configGroupHashService.computeHash( ConfigVariablesGroup.AWS_SES_SETTINGS, ); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage-driver.factory.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage-driver.factory.spec.ts index 07f3ae1027..39a759d86f 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage-driver.factory.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage-driver.factory.spec.ts @@ -3,15 +3,20 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface'; import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; +import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; describe('FileStorageDriverFactory', () => { let factory: FileStorageDriverFactory; let twentyConfigService: TwentyConfigService; + let configGroupHashService: ConfigGroupHashService; const mockTwentyConfigService = { get: jest.fn(), }; + const mockConfigGroupHashService = { + computeHash: jest.fn().mockReturnValue(''), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -21,11 +26,18 @@ describe('FileStorageDriverFactory', () => { provide: TwentyConfigService, useValue: mockTwentyConfigService, }, + { + provide: ConfigGroupHashService, + useValue: mockConfigGroupHashService, + }, ], }).compile(); factory = module.get(FileStorageDriverFactory); twentyConfigService = module.get(TwentyConfigService); + configGroupHashService = module.get( + ConfigGroupHashService, + ); jest.clearAllMocks(); }); @@ -57,7 +69,7 @@ describe('FileStorageDriverFactory', () => { .spyOn(twentyConfigService, 'get') .mockReturnValue(StorageDriverType.S_3); jest - .spyOn(factory as any, 'getConfigGroupHash') + .spyOn(configGroupHashService, 'computeHash') .mockReturnValue('s3-hash-123'); const result = factory['buildConfigKey'](); @@ -259,7 +271,7 @@ describe('FileStorageDriverFactory', () => { } }); jest - .spyOn(factory as any, 'getConfigGroupHash') + .spyOn(configGroupHashService, 'computeHash') .mockReturnValue('s3-hash-123'); const driver2 = factory.getCurrentDriver(); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage-driver.factory.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage-driver.factory.ts index c9738a2b58..b6041dbbc1 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage-driver.factory.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage-driver.factory.ts @@ -10,13 +10,17 @@ import { S3Driver } from 'src/engine/core-modules/file-storage/drivers/s3.driver import { ValidatedStorageDriver } from 'src/engine/core-modules/file-storage/drivers/validated-storage.driver'; 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 { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { resolveAbsolutePath } from 'src/utils/resolve-absolute-path'; @Injectable() export class FileStorageDriverFactory extends DriverFactoryBase { - constructor(twentyConfigService: TwentyConfigService) { - super(twentyConfigService); + constructor( + twentyConfigService: TwentyConfigService, + configGroupHashService: ConfigGroupHashService, + ) { + super(twentyConfigService, configGroupHashService); } protected buildConfigKey(): string { @@ -29,7 +33,7 @@ export class FileStorageDriverFactory extends DriverFactoryBase { } if (storageType === StorageDriverType.S_3) { - const storageConfigHash = this.getConfigGroupHash( + const storageConfigHash = this.configGroupHashService.computeHash( ConfigVariablesGroup.STORAGE_CONFIG, ); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory.ts index fb562964bc..7694e7136d 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory.ts @@ -15,24 +15,26 @@ import { LogicFunctionResourceService } from 'src/engine/core-modules/logic-func import { SdkClientArchiveService } from 'src/engine/core-modules/sdk-client/sdk-client-archive.service'; 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 { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class LogicFunctionDriverFactory extends DriverFactoryBase { constructor( twentyConfigService: TwentyConfigService, + configGroupHashService: ConfigGroupHashService, private readonly logicFunctionResourceService: LogicFunctionResourceService, private readonly sdkClientArchiveService: SdkClientArchiveService, private readonly cacheLockService: CacheLockService, ) { - super(twentyConfigService); + super(twentyConfigService, configGroupHashService); } protected buildConfigKey(): string { const driverType = this.twentyConfigService.get('LOGIC_FUNCTION_TYPE'); if (driverType === LogicFunctionDriverType.LAMBDA) { - return `lambda|${this.getConfigGroupHash(ConfigVariablesGroup.LOGIC_FUNCTION_CONFIG)}`; + return `lambda|${this.configGroupHashService.computeHash(ConfigVariablesGroup.LOGIC_FUNCTION_CONFIG)}`; } return driverType; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/dynamic-factory.base.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/dynamic-factory.base.ts index d0b6ce575d..12b5cdddbb 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/dynamic-factory.base.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/dynamic-factory.base.ts @@ -1,15 +1,14 @@ -import { createHash } from 'crypto'; - -import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; -import { type ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; +import { type ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { TypedReflect } from 'src/utils/typed-reflect'; export abstract class DriverFactoryBase { private currentDriver: TDriver | null = null; private currentConfigKey: string | null = null; - constructor(protected readonly twentyConfigService: TwentyConfigService) {} + constructor( + protected readonly twentyConfigService: TwentyConfigService, + protected readonly configGroupHashService: ConfigGroupHashService, + ) {} getCurrentDriver(): TDriver { let configKey: string; @@ -43,31 +42,6 @@ export abstract class DriverFactoryBase { return this.currentDriver; } - protected getConfigGroupHash(group: ConfigVariablesGroup): string { - const groupVariables = this.getConfigVariablesByGroup(group); - - const configValues = groupVariables - .map((key) => `${key}=${this.twentyConfigService.get(key)}`) - .sort() - .join('|'); - - return createHash('sha256') - .update(configValues) - .digest('hex') - .substring(0, 16); - } - - private getConfigVariablesByGroup( - group: ConfigVariablesGroup, - ): Array { - const metadata = - TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {}; - - return Object.keys(metadata) - .filter((key) => metadata[key]?.group === group) - .map((key) => key as keyof ConfigVariables); - } - protected abstract buildConfigKey(): string; protected abstract createDriver(): TDriver; } diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/services/config-group-hash.service.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/services/config-group-hash.service.ts new file mode 100644 index 0000000000..ed82029b4f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/services/config-group-hash.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { createHash } from 'crypto'; + +import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; +import { type 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'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +@Injectable() +export class ConfigGroupHashService { + constructor(private readonly twentyConfigService: TwentyConfigService) {} + + computeHash(group: ConfigVariablesGroup): string { + const groupVariables = this.getConfigVariablesByGroup(group); + + const configValues = groupVariables + .map( + (key) => `${key}=${JSON.stringify(this.twentyConfigService.get(key))}`, + ) + .sort() + .join('|'); + + return createHash('sha256') + .update(configValues) + .digest('hex') + .substring(0, 16); + } + + private getConfigVariablesByGroup( + group: ConfigVariablesGroup, + ): Array { + const metadata = + TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {}; + + return Object.keys(metadata) + .filter((key) => metadata[key]?.group === group) + .map((key) => key as keyof ConfigVariables); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.module.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.module.ts index ae2399c77b..3ed97b7737 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.module.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.module.ts @@ -3,6 +3,7 @@ import { type DynamicModule, Global, Module } from '@nestjs/common'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants'; import { DatabaseConfigModule } from 'src/engine/core-modules/twenty-config/drivers/database-config.module'; +import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { ConfigurableModuleClass } from 'src/engine/core-modules/twenty-config/twenty-config.module-definition'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -22,12 +23,13 @@ export class TwentyConfigModule extends ConfigurableModuleClass { imports, providers: [ TwentyConfigService, + ConfigGroupHashService, { provide: CONFIG_VARIABLES_INSTANCE_TOKEN, useValue: new ConfigVariables(), }, ], - exports: [TwentyConfigService], + exports: [TwentyConfigService, ConfigGroupHashService], }; } } 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 index bf0984bbfb..ad75402f38 100644 --- 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 @@ -7,19 +7,23 @@ 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 { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class WebSearchDriverFactory extends DriverFactoryBase { - constructor(twentyConfigService: TwentyConfigService) { - super(twentyConfigService); + constructor( + twentyConfigService: TwentyConfigService, + configGroupHashService: ConfigGroupHashService, + ) { + super(twentyConfigService, configGroupHashService); } protected buildConfigKey(): string { const driverType = this.twentyConfigService.get('WEB_SEARCH_DRIVER'); if (driverType !== WebSearchDriverType.DISABLED) { - return `${driverType}|${this.getConfigGroupHash(ConfigVariablesGroup.LLM)}`; + return `${driverType}|${this.configGroupHashService.computeHash(ConfigVariablesGroup.LLM)}`; } return driverType; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.spec.ts index 3ecbf1bec9..5bba714302 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.spec.ts @@ -1,5 +1,6 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service'; import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service'; @@ -119,6 +120,10 @@ describe('AiModelRegistryService', () => { provide: AiModelPreferencesService, useValue: mockPreferencesService, }, + { + provide: ConfigGroupHashService, + useValue: { computeHash: jest.fn().mockReturnValue('') }, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts index a3968aad4e..1ee47997b3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts @@ -3,6 +3,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { type LanguageModel } from 'ai'; import { type AiSdkPackage } from 'twenty-shared/ai'; +import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; +import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service'; import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum'; import { @@ -50,13 +52,29 @@ export class AiModelRegistryService { string, { providerName: string; modelDef: AiProviderModelConfig } > = new Map(); + private currentConfigHash: string | null = null; constructor( private readonly providerConfigService: ProviderConfigService, private readonly sdkProviderFactory: SdkProviderFactoryService, private readonly preferencesService: AiModelPreferencesService, - ) { + private readonly configGroupHashService: ConfigGroupHashService, + ) {} + + // The registry is rebuilt lazily whenever the LLM-group config hash changes, + // so any mutation to an LLM-tagged config variable is picked up automatically + // on the next read — no explicit refresh from callers needed. + private ensureFresh(): void { + const configHash = this.configGroupHashService.computeHash( + ConfigVariablesGroup.LLM, + ); + + if (configHash === this.currentConfigHash) { + return; + } + this.buildModelRegistry(); + this.currentConfigHash = configHash; } private buildModelRegistry(): void { @@ -146,14 +164,20 @@ export class AiModelRegistryService { } getModel(modelId: string): RegisteredAIModel | undefined { + this.ensureFresh(); + return this.modelRegistry.get(modelId); } getAvailableModels(): RegisteredAIModel[] { + this.ensureFresh(); + return Array.from(this.modelRegistry.values()); } getModelConfig(modelId: string): AIModelConfig | undefined { + this.ensureFresh(); + return this.modelConfigCache.get(modelId); } @@ -205,6 +229,8 @@ export class AiModelRegistryService { } getEffectiveModelConfig(modelId: string): AIModelConfig { + this.ensureFresh(); + if (isAutoSelectModelId(modelId)) { const defaultModel = modelId === AUTO_SELECT_FAST_MODEL_ID @@ -304,6 +330,7 @@ export class AiModelRegistryService { providerName?: string; name?: string; }> { + this.ensureFresh(); const recommended = this.getRecommendedModelIds(); return Array.from(this.modelConfigCache.values()).map((modelConfig) => { @@ -340,6 +367,8 @@ export class AiModelRegistryService { } private validateModelInRegistry(modelId: string): void { + this.ensureFresh(); + if (!this.providerModelDefCache.has(modelId)) { throw new AgentException( `Cannot update model "${modelId}": not found in registry`, @@ -356,10 +385,6 @@ export class AiModelRegistryService { return this.providerConfigService.getCatalogProviderNames(); } - refreshRegistry(): void { - this.buildModelRegistry(); - } - resolveModelForAgent(agent: { modelId: string } | null): RegisteredAIModel { const aiModel = this.getEffectiveModelConfig( agent?.modelId ?? AUTO_SELECT_SMART_MODEL_ID,