Skip to content

Commit b1838b3

Browse files
authored
Retrieve ai catalog at bootstrap (#20005)
# Introduction Migrating from build injection to a runtime bootstrap injection of the ai catalog that are dynamic to the env we're deploying to
1 parent fa3d0cd commit b1838b3

18 files changed

Lines changed: 389 additions & 123 deletions

packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/co
5656
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
5757
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
5858
import { extractConfigVariableName } from 'src/engine/metadata-modules/ai/ai-models/utils/extract-config-variable-name.util';
59-
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
59+
import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service';
6060
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
6161
import { AdminResolver } from 'src/engine/api/graphql/graphql-config/decorators/admin-resolver.decorator';
6262
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
@@ -98,6 +98,7 @@ export class AdminPanelResolver {
9898
private featureFlagService: FeatureFlagService,
9999
private readonly twentyConfigService: TwentyConfigService,
100100
private readonly aiModelRegistryService: AiModelRegistryService,
101+
private readonly defaultAiCatalogService: DefaultAiCatalogService,
101102
private readonly modelsDevCatalogService: ModelsDevCatalogService,
102103
private readonly usageAnalyticsService: UsageAnalyticsService,
103104
private readonly maintenanceModeService: MaintenanceModeService,
@@ -424,7 +425,7 @@ export class AdminPanelResolver {
424425
const providers =
425426
this.aiModelRegistryService.getResolvedProvidersForAdmin();
426427
const catalogNames = this.aiModelRegistryService.getCatalogProviderNames();
427-
const rawCatalog = loadDefaultAiProviders();
428+
const rawCatalog = this.defaultAiCatalogService.getDefaultAiCatalog();
428429
const masked: Record<string, Record<string, unknown>> = {};
429430

430431
for (const [key, config] of Object.entries(providers)) {

packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,15 @@ export class ConfigVariables {
13931393
@IsOptional()
13941394
AI_PROVIDERS: AiProvidersConfig = {};
13951395

1396+
@ConfigVariablesMetadata({
1397+
group: ConfigVariablesGroup.LLM,
1398+
description:
1399+
'Storage path for the AI catalog override (e.g. config/ai-catalog.json). When set, the catalog is fetched from the configured storage backend at startup instead of using the built-in ai-providers.json.',
1400+
type: ConfigVariableType.STRING,
1401+
})
1402+
@IsOptional()
1403+
AI_CATALOG_STORAGE_PATH?: string;
1404+
13961405
@ConfigVariablesMetadata({
13971406
group: ConfigVariablesGroup.LLM,
13981407
description:
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import defaultAiProviders from 'src/engine/metadata-modules/ai/ai-models/ai-providers.json';
2+
import { aiProvidersConfigSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.schema';
3+
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
4+
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
5+
import { normalizeAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/normalize-ai-providers.util';
6+
7+
const PROVIDERS = normalizeAiProviders(defaultAiProviders as AiProvidersConfig);
8+
9+
const EXPECTED_PROVIDER_NAMES = [
10+
'openai',
11+
'anthropic',
12+
'google',
13+
'xai',
14+
'mistral',
15+
];
16+
17+
describe('ai-providers.json integrity', () => {
18+
it('should pass Zod schema validation', () => {
19+
expect(() =>
20+
aiProvidersConfigSchema.parse(defaultAiProviders),
21+
).not.toThrow();
22+
});
23+
24+
it('should have at least one model per expected provider', () => {
25+
EXPECTED_PROVIDER_NAMES.forEach((providerName) => {
26+
const config = PROVIDERS[providerName];
27+
28+
expect(config).toBeDefined();
29+
expect((config?.models?.length ?? 0) > 0).toBe(true);
30+
});
31+
});
32+
33+
it('should have all required fields for each model', () => {
34+
Object.values(PROVIDERS).forEach((config) => {
35+
(config.models ?? []).forEach((model) => {
36+
expect(model.name).toBeDefined();
37+
expect(model.label).toBeDefined();
38+
expect(model.inputCostPerMillionTokens).toBeDefined();
39+
expect(model.outputCostPerMillionTokens).toBeDefined();
40+
expect(model.contextWindowTokens).toBeGreaterThan(0);
41+
expect(model.maxOutputTokens).toBeGreaterThan(0);
42+
});
43+
});
44+
});
45+
46+
it('should have unique composite model IDs across all providers', () => {
47+
const allCompositeIds: string[] = [];
48+
49+
Object.entries(PROVIDERS).forEach(([key, config]) => {
50+
(config.models ?? []).forEach((model) => {
51+
allCompositeIds.push(buildCompositeModelId(key, model.name));
52+
});
53+
});
54+
55+
expect(new Set(allCompositeIds).size).toBe(allCompositeIds.length);
56+
});
57+
58+
it('should have at least one non-deprecated model per expected provider', () => {
59+
EXPECTED_PROVIDER_NAMES.forEach((providerName) => {
60+
const config = PROVIDERS[providerName];
61+
const hasActiveModel = (config?.models ?? []).some(
62+
(model) => !model.isDeprecated,
63+
);
64+
65+
expect(hasActiveModel).toBe(true);
66+
});
67+
});
68+
69+
it('should set source to catalog for all models after normalization', () => {
70+
Object.values(PROVIDERS).forEach((config) => {
71+
(config.models ?? []).forEach((model) => {
72+
expect(model.source).toBe('catalog');
73+
});
74+
});
75+
});
76+
77+
it('should have npm field set for all providers', () => {
78+
Object.values(PROVIDERS).forEach((config) => {
79+
expect(config.npm).toBeDefined();
80+
expect(config.npm).toMatch(/^@ai-sdk\//);
81+
});
82+
});
83+
});

packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { Global, Module } from '@nestjs/common';
33
import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service';
44
import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service';
55
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
6+
import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service';
67
import { ModelsDevCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/models-dev-catalog.service';
78
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
89
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
910

1011
@Global()
1112
@Module({
1213
providers: [
14+
DefaultAiCatalogService,
1315
ProviderConfigService,
1416
SdkProviderFactoryService,
1517
ModelsDevCatalogService,
@@ -18,6 +20,7 @@ import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-mod
1820
AiModelConfigService,
1921
],
2022
exports: [
23+
DefaultAiCatalogService,
2124
AiModelRegistryService,
2225
AiModelConfigService,
2326
SdkProviderFactoryService,

packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.spec.ts renamed to packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-registry.service.spec.ts

Lines changed: 1 addition & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,82 +3,11 @@ import { Test, type TestingModule } from '@nestjs/testing';
33
import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service';
44
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
55
import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service';
6-
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
76
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
7+
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
88
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
9-
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
10-
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
11-
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
129
import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants';
1310

14-
const DEFAULT_PROVIDERS: AiProvidersConfig = loadDefaultAiProviders();
15-
16-
const EXPECTED_PROVIDERS = ['openai', 'anthropic', 'google', 'xai', 'mistral'];
17-
18-
describe('Default AI Providers (ai-providers.json)', () => {
19-
it('should have at least one model per provider', () => {
20-
EXPECTED_PROVIDERS.forEach((providerName) => {
21-
const config = DEFAULT_PROVIDERS[providerName];
22-
23-
expect(config).toBeDefined();
24-
expect((config?.models?.length ?? 0) > 0).toBe(true);
25-
});
26-
});
27-
28-
it('should have all required fields for each model', () => {
29-
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
30-
(config.models ?? []).forEach((model) => {
31-
expect(model.name).toBeDefined();
32-
expect(model.label).toBeDefined();
33-
expect(model.inputCostPerMillionTokens).toBeDefined();
34-
expect(model.outputCostPerMillionTokens).toBeDefined();
35-
expect(model.contextWindowTokens).toBeGreaterThan(0);
36-
expect(model.maxOutputTokens).toBeGreaterThan(0);
37-
});
38-
});
39-
});
40-
41-
it('should have unique model IDs across all providers', () => {
42-
const allCompositeIds: string[] = [];
43-
44-
Object.entries(DEFAULT_PROVIDERS).forEach(([key, config]) => {
45-
(config.models ?? []).forEach((model) => {
46-
allCompositeIds.push(buildCompositeModelId(key, model.name));
47-
});
48-
});
49-
50-
const unique = new Set(allCompositeIds);
51-
52-
expect(unique.size).toBe(allCompositeIds.length);
53-
});
54-
55-
it('should have at least one non-deprecated model per provider', () => {
56-
EXPECTED_PROVIDERS.forEach((providerName) => {
57-
const config = DEFAULT_PROVIDERS[providerName];
58-
const hasActiveModel = (config?.models ?? []).some(
59-
(model) => !model.isDeprecated,
60-
);
61-
62-
expect(hasActiveModel).toBe(true);
63-
});
64-
});
65-
66-
it('should have source set to catalog for all models', () => {
67-
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
68-
(config.models ?? []).forEach((model) => {
69-
expect(model.source).toBe('catalog');
70-
});
71-
});
72-
});
73-
74-
it('should have npm field set for all providers', () => {
75-
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
76-
expect(config.npm).toBeDefined();
77-
expect(config.npm).toMatch(/^@ai-sdk\//);
78-
});
79-
});
80-
});
81-
8211
describe('AiModelRegistryService', () => {
8312
let service: AiModelRegistryService;
8413
let mockConfigService: jest.Mocked<TwentyConfigService>;
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { Test, type TestingModule } from '@nestjs/testing';
2+
3+
import { Readable } from 'stream';
4+
5+
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
6+
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
7+
import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service';
8+
9+
const mockReadFile = jest.fn();
10+
11+
describe('DefaultAiCatalogService', () => {
12+
let service: DefaultAiCatalogService;
13+
let mockConfigService: jest.Mocked<TwentyConfigService>;
14+
15+
beforeEach(async () => {
16+
jest.clearAllMocks();
17+
18+
mockConfigService = {
19+
get: jest.fn().mockReturnValue(undefined),
20+
} as any;
21+
22+
const mockDriverFactory = {
23+
getCurrentDriver: jest.fn().mockReturnValue({ readFile: mockReadFile }),
24+
};
25+
26+
const module: TestingModule = await Test.createTestingModule({
27+
providers: [
28+
DefaultAiCatalogService,
29+
{ provide: TwentyConfigService, useValue: mockConfigService },
30+
{ provide: FileStorageDriverFactory, useValue: mockDriverFactory },
31+
],
32+
}).compile();
33+
34+
service = module.get(DefaultAiCatalogService);
35+
});
36+
37+
describe('onModuleInit', () => {
38+
it('should use built-in catalog when AI_CATALOG_STORAGE_PATH is not set', async () => {
39+
await service.onModuleInit();
40+
41+
const providers = service.getDefaultAiCatalog();
42+
43+
expect(providers).toBeDefined();
44+
expect(Object.keys(providers).length).toBeGreaterThan(0);
45+
expect(mockReadFile).not.toHaveBeenCalled();
46+
});
47+
48+
it('should load catalog from storage when AI_CATALOG_STORAGE_PATH is set', async () => {
49+
const catalog = JSON.stringify({
50+
customProvider: {
51+
npm: '@ai-sdk/openai',
52+
models: [
53+
{
54+
name: 'custom-model',
55+
label: 'Custom Model',
56+
inputCostPerMillionTokens: 1,
57+
outputCostPerMillionTokens: 2,
58+
contextWindowTokens: 4096,
59+
maxOutputTokens: 1024,
60+
},
61+
],
62+
},
63+
});
64+
65+
mockConfigService.get.mockImplementation((key: string) => {
66+
if (key === 'AI_CATALOG_STORAGE_PATH') return 'config/ai-catalog.json';
67+
68+
return undefined;
69+
});
70+
71+
mockReadFile.mockResolvedValue(Readable.from([Buffer.from(catalog)]));
72+
73+
await service.onModuleInit();
74+
75+
const providers = service.getDefaultAiCatalog();
76+
77+
expect(Object.keys(providers)).toEqual(['customProvider']);
78+
expect(providers['customProvider'].name).toBe('customProvider');
79+
expect(providers['customProvider'].models?.[0].source).toBe('catalog');
80+
expect(mockReadFile).toHaveBeenCalledWith({
81+
filePath: 'config/ai-catalog.json',
82+
});
83+
});
84+
85+
it('should reset catalog to empty object when storage read fails', async () => {
86+
mockConfigService.get.mockImplementation((key: string) => {
87+
if (key === 'AI_CATALOG_STORAGE_PATH') return 'config/ai-catalog.json';
88+
89+
return undefined;
90+
});
91+
92+
mockReadFile.mockRejectedValue(new Error('Network error'));
93+
94+
await service.onModuleInit();
95+
96+
expect(service.getDefaultAiCatalog()).toEqual({});
97+
});
98+
99+
it('should reset catalog to empty object when storage returns invalid JSON', async () => {
100+
mockConfigService.get.mockImplementation((key: string) => {
101+
if (key === 'AI_CATALOG_STORAGE_PATH') return 'config/ai-catalog.json';
102+
103+
return undefined;
104+
});
105+
106+
mockReadFile.mockResolvedValue(
107+
Readable.from([Buffer.from('not valid json')]),
108+
);
109+
110+
await service.onModuleInit();
111+
112+
expect(service.getDefaultAiCatalog()).toEqual({});
113+
});
114+
115+
it('should reset catalog to empty object when payload fails Zod validation', async () => {
116+
const invalidCatalog = JSON.stringify({
117+
badProvider: { models: 'not-an-array' },
118+
});
119+
120+
mockConfigService.get.mockImplementation((key: string) => {
121+
if (key === 'AI_CATALOG_STORAGE_PATH') return 'config/ai-catalog.json';
122+
123+
return undefined;
124+
});
125+
126+
mockReadFile.mockResolvedValue(
127+
Readable.from([Buffer.from(invalidCatalog)]),
128+
);
129+
130+
await service.onModuleInit();
131+
132+
expect(service.getDefaultAiCatalog()).toEqual({});
133+
});
134+
});
135+
});

0 commit comments

Comments
 (0)