Fix AI model registry staleness on self-hosted instances (#19427)
## 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 <noreply@anthropic.com>
This commit is contained in:
+5
-31
@@ -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<TDriver> {
|
||||
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<TDriver> {
|
||||
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<keyof ConfigVariables> {
|
||||
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;
|
||||
}
|
||||
|
||||
+40
@@ -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<keyof ConfigVariables> {
|
||||
const metadata =
|
||||
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
|
||||
|
||||
return Object.keys(metadata)
|
||||
.filter((key) => metadata[key]?.group === group)
|
||||
.map((key) => key as keyof ConfigVariables);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user