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
@@ -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();
|
||||
|
||||
|
||||
+30
-5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user