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:
Félix Malfait
2026-04-08 22:32:05 +02:00
committed by GitHub
parent 5874fb5f39
commit 6073bb6706
15 changed files with 152 additions and 64 deletions
@@ -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;
}
@@ -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,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],
};
}
}