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,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();
@@ -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,