Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions apps/nestjs-backend/scripts/validate-dual-db-cutover.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,24 @@ const main = async () => {
]);

const schemaDiff = diffSets(sourceSchemas, targetSchemas);
const schemaTableCountDiffs = compareCountMaps(sourceSchemaTableCounts, targetSchemaTableCounts);
const schemaTableCountDiffs = compareCountMaps(
sourceSchemaTableCounts,
targetSchemaTableCounts
);

const [sourceDataCounts, targetDataCounts, sourceMetaCounts, targetMetaCounts, undoFunctionExists] =
await Promise.all([
getTableCounts(sourceClient, DATA_PLANE_TABLES),
getTableCounts(dataClient, DATA_PLANE_TABLES),
getTableCounts(sourceClient, META_PLANE_TABLES),
getTableCounts(metaClient, META_PLANE_TABLES),
getFunctionExists(dataClient, '__teable_capture_undo_row'),
]);
const [
sourceDataCounts,
targetDataCounts,
sourceMetaCounts,
targetMetaCounts,
undoFunctionExists,
] = await Promise.all([
getTableCounts(sourceClient, DATA_PLANE_TABLES),
getTableCounts(dataClient, DATA_PLANE_TABLES),
getTableCounts(sourceClient, META_PLANE_TABLES),
getTableCounts(metaClient, META_PLANE_TABLES),
getFunctionExists(dataClient, '__teable_capture_undo_row'),
]);

const dataCountDiffs = compareCountMaps(sourceDataCounts, targetDataCounts);
const metaCountDiffs = compareCountMaps(sourceMetaCounts, targetMetaCounts);
Expand Down
1 change: 1 addition & 0 deletions apps/nestjs-backend/src/configs/threshold.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const thresholdConfig = registerAs('threshold', () => ({
jitter: Number(process.env.BACKEND_DB_DEADLOCK_JITTER ?? 1.0),
},
baseNodeMaxFolderDepth: Number(process.env.BASE_NODE_MAX_FOLDER_DEPTH ?? 2),
maxOwnedSpaceCount: Number(process.env.MAX_SPACE_OWNER_COUNT ?? 10),
changeEmailSendCodeMailRate: Number(process.env.BACKEND_CHANGE_EMAIL_SEND_CODE_MAIL_RATE ?? 30),
resetPasswordSendMailRate: Number(process.env.BACKEND_RESET_PASSWORD_SEND_MAIL_RATE ?? 30),
signupVerificationSendCodeMailRate: Number(
Expand Down
142 changes: 141 additions & 1 deletion apps/nestjs-backend/src/features/ai/ai.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { LLMProviderType } from '@teable/openapi';
import { describe, expect, it } from 'vitest';
import type { LLMProvider } from '@teable/openapi';
import { describe, expect, it, vi } from 'vitest';
import { AiService } from './ai.service';

const openAIProviderName = 'custom-openai';
Expand Down Expand Up @@ -61,3 +63,141 @@ describe('AiService.getModelTags', () => {
expect(tags).toEqual([]);
});
});

describe('AiService model mappings', () => {
const service = Object.create(AiService.prototype) as AiService & {
baseConfig: { isCloud: boolean };
};
const sourceModelKey = `${LLMProviderType.AI_GATEWAY}@anthropic/claude-sonnet-4@teable`;
const targetModelKey = `${LLMProviderType.OPENAI}@gpt-4.1@teable`;
const providers: LLMProvider[] = [
{
type: LLMProviderType.OPENAI,
name: 'teable',
models: 'gpt-4.1',
isInstance: true,
modelConfigs: {
'gpt-4.1': {
inputRate: 100,
outputRate: 200,
},
},
},
];

it('resolves enabled gateway mapping to instance custom provider in cloud', () => {
service.baseConfig = { isCloud: true };

expect(
service.resolveModelMapping(sourceModelKey, providers, {
llmProviders: providers,
modelMappings: [{ sourceModelKey, targetModelKey, enabled: true }],
})
).toEqual({
requestedModelKey: sourceModelKey,
effectiveModelKey: targetModelKey,
mapped: true,
});
});

it('does not apply model mappings outside cloud', () => {
service.baseConfig = { isCloud: false };

expect(
service.resolveModelMapping(sourceModelKey, providers, {
llmProviders: providers,
modelMappings: [{ sourceModelKey, targetModelKey, enabled: true }],
})
).toEqual({
requestedModelKey: sourceModelKey,
effectiveModelKey: sourceModelKey,
mapped: false,
});
});

it('rejects mapped targets without pricing config', () => {
service.baseConfig = { isCloud: true };

expect(() =>
service.resolveModelMapping(sourceModelKey, [{ ...providers[0], modelConfigs: undefined }], {
llmProviders: providers,
modelMappings: [{ sourceModelKey, targetModelKey, enabled: true }],
})
).toThrow('AI model mapping target pricing is not configured');
});
});

describe('AiService.getSimplifiedAIConfig', () => {
const spaceProvider = {
type: LLMProviderType.OPENAI,
name: 'space-provider',
models: 'space-model',
isInstance: false,
};
const instanceProvider = {
type: LLMProviderType.ANTHROPIC,
name: 'teable',
models: 'claude-sonnet-4-6',
isInstance: true,
modelConfigs: {
'claude-sonnet-4-6': {
ability: {
image: { url: false, base64: true },
pdf: { url: false, base64: true },
toolCall: true,
},
},
},
};

const createService = (isCloud: boolean) => {
const service = Object.create(AiService.prototype) as AiService & {
baseConfig: { isCloud: boolean };
getAIConfig: ReturnType<typeof vi.fn>;
};
service.baseConfig = { isCloud };
service.getAIConfig = vi.fn().mockResolvedValue({
enable: true,
llmProviders: [spaceProvider, instanceProvider],
chatModel: {
lg: `${LLMProviderType.AI_GATEWAY}@anthropic/claude-sonnet-4@teable`,
},
embeddingModel: `${LLMProviderType.OPENAI}@text-embedding-3-small@space-provider`,
translationModel: `${LLMProviderType.OPENAI}@gpt-4.1-mini@space-provider`,
capabilities: { disableActions: [] },
gatewayModels: [{ id: 'anthropic/claude-sonnet-4', enabled: true }],
attachmentTransferMode: 'base64',
aiGatewayApiKey: 'secret-key',
aiGatewayApiKeys: ['secret-key-2'],
vertexByokCredential: {
project: 'project',
location: 'us-central1',
googleCredentials: {
privateKey: 'private-key',
clientEmail: 'client@example.com',
},
},
});
return service;
};

it('omits instance providers and secret config from cloud user config', async () => {
const config = await createService(true).getSimplifiedAIConfig('base-id');

expect(config?.llmProviders).toEqual([spaceProvider]);
expect(config?.embeddingModel).toBe(
`${LLMProviderType.OPENAI}@text-embedding-3-small@space-provider`
);
expect(config?.translationModel).toBe(`${LLMProviderType.OPENAI}@gpt-4.1-mini@space-provider`);
expect(config?.attachmentTransferMode).toBe('base64');
expect(config).not.toHaveProperty('aiGatewayApiKey');
expect(config).not.toHaveProperty('aiGatewayApiKeys');
expect(config).not.toHaveProperty('vertexByokCredential');
});

it('keeps instance providers outside cloud', async () => {
const config = await createService(false).getSimplifiedAIConfig('base-id');

expect(config?.llmProviders).toEqual([spaceProvider, instanceProvider]);
});
});
Loading
Loading