feat: replace hardcoded AI model constants with JSON seed catalog (#18818)
## Summary - Replaces per-provider TypeScript constant files (`openai-models.const.ts`, `anthropic-models.const.ts`, etc.) with a single `ai-providers.json` catalog as the source of truth - Adds runtime model discovery via AI SDK for self-hosted providers, with `models.dev` enrichment for pricing/capabilities - Introduces composite model IDs (`provider/modelId`) for canonical, conflict-free identification - Simplifies provider configuration: API keys are injected from environment variables (e.g., `OPENAI_API_KEY`) - Adds admin panel UI for provider management (add/remove/test), model discovery, recommended model configuration, and default fast/smart model selection per workspace - Removes deprecated config variables (`AI_DISABLED_MODEL_IDS`, `AUTO_ENABLE_NEW_AI_MODELS`, etc.) - Adds database migration for composite model ID format ## Test plan - [ ] Server typecheck passes - [ ] Frontend typecheck passes - [ ] Server unit tests pass - [ ] Frontend unit tests pass - [ ] CI pipeline green - [ ] Admin panel AI tab loads correctly - [ ] Provider discovery works for configured providers - [ ] Model recommendation toggles persist - [ ] Default fast/smart model selection works Made with [Cursor](https://cursor.com)
This commit is contained in:
+2
-2
@@ -8,7 +8,7 @@ import {
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
|
||||
@@ -83,7 +83,7 @@ describe('AiAgentRoleService', () => {
|
||||
name: 'Test Agent',
|
||||
description: 'Test agent for unit tests',
|
||||
prompt: 'You are a test agent',
|
||||
modelId: 'gpt-4o' as ModelId,
|
||||
modelId: 'openai/gpt-4o' as ModelId,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -251,7 +251,7 @@ export class AgentService {
|
||||
|
||||
const {
|
||||
flatAgentMaps: recomputedFlatAgentMaps,
|
||||
flatRoleTargetByAgentIdMaps: recmputedFlatRoleTargetByAgentIdMaps,
|
||||
flatRoleTargetByAgentIdMaps: recomputedFlatRoleTargetByAgentIdMaps,
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatAgentMaps',
|
||||
'flatRoleTargetByAgentIdMaps',
|
||||
@@ -263,7 +263,7 @@ export class AgentService {
|
||||
});
|
||||
|
||||
const existingRoleTarget =
|
||||
recmputedFlatRoleTargetByAgentIdMaps[flatAgentToUpdate.id];
|
||||
recomputedFlatRoleTargetByAgentIdMaps[flatAgentToUpdate.id];
|
||||
|
||||
return {
|
||||
...updatedAgent,
|
||||
|
||||
@@ -11,7 +11,7 @@ import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
|
||||
@ObjectType('Agent')
|
||||
export class AgentDTO {
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/typ
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
|
||||
import { AgentResponseFormatJson } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-json.validator';
|
||||
import { AgentResponseFormatText } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-text.validator';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
|
||||
@InputType()
|
||||
export class CreateAgentInput {
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/typ
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
|
||||
import { AgentResponseFormatJson } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-json.validator';
|
||||
import { AgentResponseFormatText } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-text.validator';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
|
||||
@InputType()
|
||||
export class UpdateAgentInput {
|
||||
|
||||
+2
-4
@@ -10,10 +10,8 @@ import {
|
||||
|
||||
import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/types/agent-response-format.type';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
|
||||
import {
|
||||
DEFAULT_SMART_MODEL,
|
||||
ModelId,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
||||
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
|
||||
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceEventEmitterModule, AiModelsModule],
|
||||
providers: [AIBillingService],
|
||||
exports: [AIBillingService],
|
||||
providers: [AiBillingService],
|
||||
exports: [AiBillingService],
|
||||
})
|
||||
export class AiBillingModule {}
|
||||
|
||||
+10
-13
@@ -2,16 +2,13 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import {
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
describe('AIBillingService', () => {
|
||||
let service: AIBillingService;
|
||||
describe('AiBillingService', () => {
|
||||
let service: AiBillingService;
|
||||
let mockWorkspaceEventEmitter: jest.Mocked<WorkspaceEventEmitter>;
|
||||
let mockAiModelRegistryService: jest.Mocked<
|
||||
Pick<AiModelRegistryService, 'getEffectiveModelConfig'>
|
||||
@@ -20,8 +17,8 @@ describe('AIBillingService', () => {
|
||||
const openaiModelConfig = {
|
||||
modelId: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
modelFamily: ModelFamily.GPT,
|
||||
sdkPackage: '@ai-sdk/openai',
|
||||
inputCostPerMillionTokens: 2.5,
|
||||
outputCostPerMillionTokens: 10.0,
|
||||
cachedInputCostPerMillionTokens: 1.25,
|
||||
@@ -30,8 +27,8 @@ describe('AIBillingService', () => {
|
||||
const anthropicModelConfig = {
|
||||
modelId: 'claude-sonnet-4-5-20250929',
|
||||
label: 'Claude Sonnet 4.5',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
modelFamily: ModelFamily.CLAUDE,
|
||||
sdkPackage: '@ai-sdk/anthropic',
|
||||
inputCostPerMillionTokens: 3.0,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
cachedInputCostPerMillionTokens: 0.3,
|
||||
@@ -65,7 +62,7 @@ describe('AIBillingService', () => {
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AIBillingService,
|
||||
AiBillingService,
|
||||
{
|
||||
provide: WorkspaceEventEmitter,
|
||||
useValue: mockEventEmitterMethods,
|
||||
@@ -77,7 +74,7 @@ describe('AIBillingService', () => {
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AIBillingService>(AIBillingService);
|
||||
service = module.get<AiBillingService>(AiBillingService);
|
||||
mockWorkspaceEventEmitter = module.get(WorkspaceEventEmitter);
|
||||
mockAiModelRegistryService = module.get(AiModelRegistryService);
|
||||
});
|
||||
|
||||
+3
-8
@@ -7,7 +7,7 @@ import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/bil
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
|
||||
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
@@ -17,8 +17,8 @@ export type BillingUsageInput = {
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AIBillingService {
|
||||
private readonly logger = new Logger(AIBillingService.name);
|
||||
export class AiBillingService {
|
||||
private readonly logger = new Logger(AiBillingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
@@ -27,11 +27,6 @@ export class AIBillingService {
|
||||
|
||||
calculateCost(modelId: ModelId, billingInput: BillingUsageInput): number {
|
||||
const model = this.aiModelRegistryService.getEffectiveModelConfig(modelId);
|
||||
|
||||
if (!model) {
|
||||
throw new Error(`AI model with id ${modelId} not found`);
|
||||
}
|
||||
|
||||
const { usage, cacheCreationTokens = 0 } = billingInput;
|
||||
|
||||
const breakdown = computeCostBreakdown(model, {
|
||||
|
||||
+6
-8
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
ModelFamily,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
|
||||
export type TokenUsageInput = {
|
||||
inputTokens?: number;
|
||||
@@ -47,17 +45,17 @@ export const computeCostBreakdown = (
|
||||
const cachedInputTokens = safeNumber(usage.cachedInputTokens);
|
||||
const cacheCreationTokens = safeNumber(usage.cacheCreationTokens);
|
||||
|
||||
const isAnthropicFamily = model.modelFamily === ModelFamily.ANTHROPIC;
|
||||
const isAnthropicTokenReporting = model.modelFamily === ModelFamily.CLAUDE;
|
||||
|
||||
const adjustedInputTokens = isAnthropicFamily
|
||||
const adjustedInputTokens = isAnthropicTokenReporting
|
||||
? rawInputTokens
|
||||
: Math.max(0, rawInputTokens - cachedInputTokens);
|
||||
|
||||
const adjustedOutputTokens = isAnthropicFamily
|
||||
const adjustedOutputTokens = isAnthropicTokenReporting
|
||||
? rawOutputTokens
|
||||
: Math.max(0, rawOutputTokens - reasoningTokens);
|
||||
|
||||
const totalInputTokens = isAnthropicFamily
|
||||
const totalInputTokens = isAnthropicTokenReporting
|
||||
? rawInputTokens + cachedInputTokens + cacheCreationTokens
|
||||
: rawInputTokens + cacheCreationTokens;
|
||||
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ export class AgentChatController {
|
||||
) {
|
||||
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
|
||||
throw new AgentException(
|
||||
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
|
||||
'No AI models are available. Configure at least one AI provider.',
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/
|
||||
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
|
||||
import { extractCacheCreationTokens } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
|
||||
import { AgentChatService } from './agent-chat.service';
|
||||
|
||||
+57
-38
@@ -1,8 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { groq } from '@ai-sdk/groq';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import {
|
||||
convertToModelMessages,
|
||||
stepCountIs,
|
||||
@@ -36,7 +33,7 @@ import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agen
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
|
||||
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
|
||||
import {
|
||||
@@ -44,11 +41,17 @@ import {
|
||||
type ExtractedFile,
|
||||
} from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util';
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
AI_SDK_ANTHROPIC,
|
||||
AI_SDK_BEDROCK,
|
||||
AI_SDK_OPENAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import {
|
||||
AiModelRegistryService,
|
||||
type RegisteredAIModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
|
||||
|
||||
export type ChatExecutionOptions = {
|
||||
@@ -72,11 +75,12 @@ export class ChatExecutionService {
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly skillService: SkillService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly aiBillingService: AIBillingService,
|
||||
private readonly aiBillingService: AiBillingService,
|
||||
private readonly agentActorContextService: AgentActorContextService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly systemPromptBuilder: SystemPromptBuilderService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
private readonly sdkProviderFactory: SdkProviderFactoryService,
|
||||
) {}
|
||||
|
||||
async streamChat({
|
||||
@@ -138,7 +142,7 @@ export class ChatExecutionService {
|
||||
);
|
||||
|
||||
const { tools: nativeSearchTools, callableToolNames: searchToolNames } =
|
||||
this.getNativeWebSearchTools(registeredModel.inferenceProvider);
|
||||
this.getNativeWebSearchTools(registeredModel);
|
||||
|
||||
// Direct tools: native provider tools + preloaded tools.
|
||||
// These are callable directly AND as fallback through execute_tool.
|
||||
@@ -203,9 +207,9 @@ export class ChatExecutionService {
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
providerOptions:
|
||||
registeredModel.inferenceProvider === InferenceProvider.ANTHROPIC
|
||||
registeredModel.sdkPackage === AI_SDK_ANTHROPIC
|
||||
? { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
||||
: registeredModel.inferenceProvider === InferenceProvider.BEDROCK
|
||||
: registeredModel.sdkPackage === AI_SDK_BEDROCK
|
||||
? { bedrock: { cacheControl: { type: 'ephemeral' } } }
|
||||
: undefined,
|
||||
};
|
||||
@@ -325,46 +329,61 @@ export class ChatExecutionService {
|
||||
return context;
|
||||
}
|
||||
|
||||
private getNativeWebSearchTools(inferenceProvider: InferenceProvider): {
|
||||
private getNativeWebSearchTools(model: RegisteredAIModel): {
|
||||
tools: ToolSet;
|
||||
callableToolNames: string[];
|
||||
} {
|
||||
switch (inferenceProvider) {
|
||||
case InferenceProvider.ANTHROPIC:
|
||||
return {
|
||||
tools: { web_search: anthropic.tools.webSearch_20250305() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
case InferenceProvider.BEDROCK: {
|
||||
const bedrockProvider =
|
||||
this.aiModelRegistryService.getBedrockProvider();
|
||||
const empty = { tools: {}, callableToolNames: [] };
|
||||
const providerName = model.providerName;
|
||||
|
||||
if (bedrockProvider) {
|
||||
return {
|
||||
tools: {
|
||||
web_search:
|
||||
bedrockProvider.tools.webSearch_20250305() as ToolSet[string],
|
||||
},
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
if (!providerName) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_ANTHROPIC: {
|
||||
const provider =
|
||||
this.sdkProviderFactory.getRawAnthropicProvider(providerName);
|
||||
|
||||
if (!provider) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return { tools: {}, callableToolNames: [] };
|
||||
}
|
||||
case InferenceProvider.OPENAI:
|
||||
return {
|
||||
tools: { web_search: openai.tools.webSearch() },
|
||||
tools: { web_search: provider.tools.webSearch_20250305() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
case InferenceProvider.GROQ:
|
||||
}
|
||||
case AI_SDK_BEDROCK: {
|
||||
const provider =
|
||||
this.sdkProviderFactory.getRawBedrockProvider(providerName);
|
||||
|
||||
if (!provider) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
tools: {
|
||||
web_search: groq.tools.browserSearch({}) as ToolSet[string],
|
||||
web_search: provider.tools.webSearch_20250305() as ToolSet[string],
|
||||
},
|
||||
callableToolNames: [],
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
}
|
||||
case AI_SDK_OPENAI: {
|
||||
const provider =
|
||||
this.sdkProviderFactory.getRawOpenAIProvider(providerName);
|
||||
|
||||
if (!provider) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
tools: { web_search: provider.tools.webSearch() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { tools: {}, callableToolNames: [] };
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-3
@@ -1,12 +1,27 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
|
||||
import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { AiService } from 'src/engine/metadata-modules/ai/ai-models/services/ai.service';
|
||||
import { ModelsDevCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/models-dev-catalog.service';
|
||||
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AiModelRegistryService, AiService, AgentModelConfigService],
|
||||
exports: [AiModelRegistryService, AiService, AgentModelConfigService],
|
||||
providers: [
|
||||
ProviderConfigService,
|
||||
SdkProviderFactoryService,
|
||||
ModelsDevCatalogService,
|
||||
AiModelPreferencesService,
|
||||
AiModelRegistryService,
|
||||
AgentModelConfigService,
|
||||
],
|
||||
exports: [
|
||||
AiModelRegistryService,
|
||||
AgentModelConfigService,
|
||||
SdkProviderFactoryService,
|
||||
ModelsDevCatalogService,
|
||||
],
|
||||
})
|
||||
export class AiModelsModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+258
@@ -0,0 +1,258 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
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';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
|
||||
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
|
||||
|
||||
const DEFAULT_PROVIDERS: AiProvidersConfig = loadDefaultAiProviders();
|
||||
|
||||
const EXPECTED_PROVIDERS = ['openai', 'anthropic', 'google', 'xai', 'mistral'];
|
||||
|
||||
describe('Default AI Providers (ai-providers.json)', () => {
|
||||
it('should have at least one model per provider', () => {
|
||||
EXPECTED_PROVIDERS.forEach((providerName) => {
|
||||
const config = DEFAULT_PROVIDERS[providerName];
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect((config?.models?.length ?? 0) > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have all required fields for each model', () => {
|
||||
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
expect(model.name).toBeDefined();
|
||||
expect(model.label).toBeDefined();
|
||||
expect(model.inputCostPerMillionTokens).toBeDefined();
|
||||
expect(model.outputCostPerMillionTokens).toBeDefined();
|
||||
expect(model.contextWindowTokens).toBeGreaterThan(0);
|
||||
expect(model.maxOutputTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique model IDs across all providers', () => {
|
||||
const allCompositeIds: string[] = [];
|
||||
|
||||
Object.entries(DEFAULT_PROVIDERS).forEach(([key, config]) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
allCompositeIds.push(buildCompositeModelId(key, model.name));
|
||||
});
|
||||
});
|
||||
|
||||
const unique = new Set(allCompositeIds);
|
||||
|
||||
expect(unique.size).toBe(allCompositeIds.length);
|
||||
});
|
||||
|
||||
it('should have at least one non-deprecated model per provider', () => {
|
||||
EXPECTED_PROVIDERS.forEach((providerName) => {
|
||||
const config = DEFAULT_PROVIDERS[providerName];
|
||||
const hasActiveModel = (config?.models ?? []).some(
|
||||
(model) => !model.isDeprecated,
|
||||
);
|
||||
|
||||
expect(hasActiveModel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have source set to catalog for all models', () => {
|
||||
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
expect(model.source).toBe('catalog');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have npm field set for all providers', () => {
|
||||
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
|
||||
expect(config.npm).toBeDefined();
|
||||
expect(config.npm).toMatch(/^@ai-sdk\//);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiModelRegistryService', () => {
|
||||
let service: AiModelRegistryService;
|
||||
let mockConfigService: jest.Mocked<TwentyConfigService>;
|
||||
let mockPreferencesService: {
|
||||
getPreferences: jest.Mock;
|
||||
getRecommendedModelIds: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockConfigService = {
|
||||
get: jest.fn().mockReturnValue({}),
|
||||
} as any;
|
||||
|
||||
const mockProviderConfigService = {
|
||||
getResolvedProviders: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
mockPreferencesService = {
|
||||
getPreferences: jest.fn().mockReturnValue({}),
|
||||
getRecommendedModelIds: jest.fn().mockReturnValue(new Set()),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiModelRegistryService,
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
{
|
||||
provide: ProviderConfigService,
|
||||
useValue: mockProviderConfigService,
|
||||
},
|
||||
{
|
||||
provide: SdkProviderFactoryService,
|
||||
useValue: { clearCache: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: AiModelPreferencesService,
|
||||
useValue: mockPreferencesService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AiModelRegistryService>(AiModelRegistryService);
|
||||
});
|
||||
|
||||
it('should throw when no models are available for DEFAULT_SMART_MODEL', () => {
|
||||
expect(() => service.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
|
||||
'No AI models are available. Configure at least one AI provider.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return effective model config for DEFAULT_SMART_MODEL when models are available', () => {
|
||||
jest.spyOn(service, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'openai/gpt-5.2',
|
||||
sdkPackage: '@ai-sdk/openai',
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.spyOn(service, 'getModel').mockReturnValue({
|
||||
modelId: 'openai/gpt-5.2',
|
||||
sdkPackage: '@ai-sdk/openai',
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const result = service.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.modelId).toBe('openai/gpt-5.2');
|
||||
expect(result.sdkPackage).toBe('@ai-sdk/openai');
|
||||
});
|
||||
|
||||
it('should return effective model config for DEFAULT_SMART_MODEL with custom model', () => {
|
||||
jest.spyOn(service, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'custom/mistral',
|
||||
sdkPackage: '@ai-sdk/openai-compatible',
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.spyOn(service, 'getModel').mockReturnValue({
|
||||
modelId: 'custom/mistral',
|
||||
sdkPackage: '@ai-sdk/openai-compatible',
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const result = service.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.modelId).toBe('custom/mistral');
|
||||
expect(result.sdkPackage).toBe('@ai-sdk/openai-compatible');
|
||||
expect(result.label).toBe('custom/mistral');
|
||||
expect(result.inputCostPerMillionTokens).toBe(0);
|
||||
expect(result.outputCostPerMillionTokens).toBe(0);
|
||||
});
|
||||
|
||||
it('should return effective model config for custom model', () => {
|
||||
jest.spyOn(service, 'getModel').mockReturnValue({
|
||||
modelId: 'custom/mistral',
|
||||
sdkPackage: '@ai-sdk/openai-compatible',
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const result = service.getEffectiveModelConfig('custom/mistral');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.modelId).toBe('custom/mistral');
|
||||
expect(result.sdkPackage).toBe('@ai-sdk/openai-compatible');
|
||||
expect(result.label).toBe('custom/mistral');
|
||||
expect(result.inputCostPerMillionTokens).toBe(0);
|
||||
expect(result.outputCostPerMillionTokens).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent model', () => {
|
||||
jest.spyOn(service, 'getModel').mockReturnValue(undefined);
|
||||
|
||||
expect(() => service.getEffectiveModelConfig('non-existent-model')).toThrow(
|
||||
'Model with ID non-existent-model not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should find first available model from preferences list', () => {
|
||||
mockPreferencesService.getPreferences.mockReturnValue({
|
||||
defaultFastModels: [
|
||||
'openai/gpt-5-mini',
|
||||
'anthropic/claude-haiku-4-5-20251001',
|
||||
'google/gemini-3-flash-preview',
|
||||
],
|
||||
});
|
||||
|
||||
const getModelSpy = jest
|
||||
.spyOn(service, 'getModel')
|
||||
.mockImplementation((modelId: string) => {
|
||||
if (modelId === 'anthropic/claude-haiku-4-5-20251001') {
|
||||
return {
|
||||
modelId: 'anthropic/claude-haiku-4-5-20251001',
|
||||
sdkPackage: '@ai-sdk/anthropic',
|
||||
model: {} as any,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = service.getDefaultSpeedModel();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.modelId).toBe('anthropic/claude-haiku-4-5-20251001');
|
||||
expect(getModelSpy).toHaveBeenCalledWith('openai/gpt-5-mini');
|
||||
expect(getModelSpy).toHaveBeenCalledWith(
|
||||
'anthropic/claude-haiku-4-5-20251001',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to any available model if none in list are available', () => {
|
||||
mockPreferencesService.getPreferences.mockReturnValue({
|
||||
defaultFastModels: ['model-a', 'model-b', 'model-c'],
|
||||
});
|
||||
|
||||
jest.spyOn(service, 'getModel').mockReturnValue(undefined);
|
||||
jest.spyOn(service, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'fallback-model',
|
||||
sdkPackage: '@ai-sdk/openai-compatible',
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = service.getDefaultSpeedModel();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.modelId).toBe('fallback-model');
|
||||
});
|
||||
});
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
export enum InferenceProvider {
|
||||
NONE = 'none',
|
||||
OPENAI = 'openai',
|
||||
ANTHROPIC = 'anthropic',
|
||||
BEDROCK = 'bedrock',
|
||||
GOOGLE = 'google',
|
||||
MISTRAL = 'mistral',
|
||||
OPENAI_COMPATIBLE = 'open_ai_compatible',
|
||||
XAI = 'xai',
|
||||
GROQ = 'groq',
|
||||
}
|
||||
|
||||
export enum ModelFamily {
|
||||
OPENAI = 'openai',
|
||||
ANTHROPIC = 'anthropic',
|
||||
GOOGLE = 'google',
|
||||
MISTRAL = 'mistral',
|
||||
XAI = 'xai',
|
||||
}
|
||||
|
||||
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
|
||||
export const DEFAULT_SMART_MODEL = 'default-smart-model' as const;
|
||||
|
||||
export type ModelId =
|
||||
| typeof DEFAULT_FAST_MODEL
|
||||
| typeof DEFAULT_SMART_MODEL
|
||||
// OpenAI models
|
||||
| 'gpt-5.2'
|
||||
| 'gpt-5-mini'
|
||||
| 'gpt-4.1'
|
||||
| 'gpt-4.1-mini'
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4o-mini'
|
||||
| 'gpt-4-turbo'
|
||||
| 'o3'
|
||||
| 'o4-mini'
|
||||
// Anthropic models
|
||||
| 'claude-opus-4-6'
|
||||
| 'claude-sonnet-4-6'
|
||||
| 'claude-sonnet-4-5-20250929'
|
||||
| 'claude-haiku-4-5-20251001'
|
||||
| 'claude-opus-4-5-20251101'
|
||||
| 'claude-opus-4-20250514'
|
||||
| 'claude-sonnet-4-20250514'
|
||||
| 'claude-3-5-haiku-20241022'
|
||||
// xAI models
|
||||
| 'grok-4'
|
||||
| 'grok-4-1-fast-reasoning'
|
||||
| 'grok-3'
|
||||
| 'grok-3-mini'
|
||||
// Google models
|
||||
| 'gemini-3.1-pro-preview'
|
||||
| 'gemini-3-flash-preview'
|
||||
| 'gemini-3.1-flash-lite-preview'
|
||||
| 'gemini-2.5-pro'
|
||||
| 'gemini-2.5-flash'
|
||||
// Bedrock models (Anthropic via AWS)
|
||||
| 'anthropic.claude-opus-4-6-v1'
|
||||
| 'anthropic.claude-sonnet-4-6'
|
||||
// Groq models
|
||||
| 'openai/gpt-oss-120b'
|
||||
// Mistral models
|
||||
| 'mistral-large-latest'
|
||||
| string; // Allow custom model names
|
||||
|
||||
export type SupportedFileType =
|
||||
| 'image/png'
|
||||
| 'image/jpeg'
|
||||
| 'image/gif'
|
||||
| 'image/webp'
|
||||
| 'application/pdf'
|
||||
| 'text/plain'
|
||||
| 'text/html'
|
||||
| 'text/csv'
|
||||
| 'application/json';
|
||||
|
||||
export type LongContextCost = {
|
||||
inputCostPerMillionTokens: number;
|
||||
outputCostPerMillionTokens: number;
|
||||
cachedInputCostPerMillionTokens?: number;
|
||||
cacheCreationCostPerMillionTokens?: number;
|
||||
thresholdTokens: number;
|
||||
};
|
||||
|
||||
export interface AIModelConfig {
|
||||
modelId: ModelId;
|
||||
label: string;
|
||||
description: string;
|
||||
modelFamily: ModelFamily;
|
||||
inferenceProvider: InferenceProvider;
|
||||
inputCostPerMillionTokens: number;
|
||||
outputCostPerMillionTokens: number;
|
||||
cachedInputCostPerMillionTokens?: number;
|
||||
cacheCreationCostPerMillionTokens?: number;
|
||||
longContextCost?: LongContextCost;
|
||||
contextWindowTokens: number;
|
||||
maxOutputTokens: number;
|
||||
supportedFileTypes?: SupportedFileType[];
|
||||
doesSupportThinking?: boolean;
|
||||
nativeCapabilities?: {
|
||||
webSearch?: boolean;
|
||||
twitterSearch?: boolean;
|
||||
};
|
||||
deprecated?: boolean;
|
||||
isRecommended?: boolean;
|
||||
}
|
||||
-234
@@ -1,234 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
|
||||
import {
|
||||
AI_MODELS,
|
||||
DEFAULT_SMART_MODEL,
|
||||
InferenceProvider,
|
||||
} from './ai-models.const';
|
||||
|
||||
describe('AI_MODELS', () => {
|
||||
it('should have at least one model per inference provider', () => {
|
||||
const inferenceProviders = [
|
||||
InferenceProvider.OPENAI,
|
||||
InferenceProvider.ANTHROPIC,
|
||||
InferenceProvider.BEDROCK,
|
||||
InferenceProvider.GOOGLE,
|
||||
InferenceProvider.XAI,
|
||||
InferenceProvider.GROQ,
|
||||
InferenceProvider.MISTRAL,
|
||||
];
|
||||
|
||||
inferenceProviders.forEach((inferenceProvider) => {
|
||||
const modelsForProvider = AI_MODELS.filter(
|
||||
(model) => model.inferenceProvider === inferenceProvider,
|
||||
);
|
||||
|
||||
expect(modelsForProvider.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have all required fields for each model', () => {
|
||||
AI_MODELS.forEach((model) => {
|
||||
expect(model.modelId).toBeDefined();
|
||||
expect(model.label).toBeDefined();
|
||||
expect(model.description).toBeDefined();
|
||||
expect(model.modelFamily).toBeDefined();
|
||||
expect(model.inferenceProvider).toBeDefined();
|
||||
expect(model.inputCostPerMillionTokens).toBeDefined();
|
||||
expect(model.outputCostPerMillionTokens).toBeDefined();
|
||||
expect(model.contextWindowTokens).toBeGreaterThan(0);
|
||||
expect(model.maxOutputTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique model IDs', () => {
|
||||
const modelIds = AI_MODELS.map((model) => model.modelId);
|
||||
const uniqueModelIds = new Set(modelIds);
|
||||
|
||||
expect(uniqueModelIds.size).toBe(modelIds.length);
|
||||
});
|
||||
|
||||
it('should have at least one non-deprecated model per inference provider', () => {
|
||||
const inferenceProviders = [
|
||||
InferenceProvider.OPENAI,
|
||||
InferenceProvider.ANTHROPIC,
|
||||
InferenceProvider.BEDROCK,
|
||||
InferenceProvider.GOOGLE,
|
||||
InferenceProvider.XAI,
|
||||
InferenceProvider.GROQ,
|
||||
InferenceProvider.MISTRAL,
|
||||
];
|
||||
|
||||
inferenceProviders.forEach((inferenceProvider) => {
|
||||
const activeModelsForProvider = AI_MODELS.filter(
|
||||
(model) =>
|
||||
model.inferenceProvider === inferenceProvider && !model.deprecated,
|
||||
);
|
||||
|
||||
expect(activeModelsForProvider.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiModelRegistryService', () => {
|
||||
let SERVICE: AiModelRegistryService;
|
||||
let MOCK_CONFIG_SERVICE: jest.Mocked<TwentyConfigService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
MOCK_CONFIG_SERVICE = {
|
||||
get: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const MODULE: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiModelRegistryService,
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: MOCK_CONFIG_SERVICE,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
SERVICE = MODULE.get<AiModelRegistryService>(AiModelRegistryService);
|
||||
});
|
||||
|
||||
it('should return effective model config for DEFAULT_SMART_MODEL', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-5.2');
|
||||
|
||||
expect(() => SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
|
||||
'No AI models are available. Please configure at least one AI provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_BEDROCK_REGION, GOOGLE_API_KEY, XAI_API_KEY, GROQ_API_KEY, or MISTRAL_API_KEY).',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return effective model config for DEFAULT_SMART_MODEL when models are available', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-5.2');
|
||||
|
||||
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'gpt-5.2',
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'gpt-5.2',
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('gpt-5.2');
|
||||
expect(RESULT.inferenceProvider).toBe(InferenceProvider.OPENAI);
|
||||
});
|
||||
|
||||
it('should return effective model config for DEFAULT_SMART_MODEL with custom model', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('mistral');
|
||||
|
||||
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'mistral',
|
||||
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'mistral',
|
||||
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('mistral');
|
||||
expect(RESULT.inferenceProvider).toBe(InferenceProvider.OPENAI_COMPATIBLE);
|
||||
expect(RESULT.label).toBe('mistral');
|
||||
expect(RESULT.inputCostPerMillionTokens).toBe(0);
|
||||
expect(RESULT.outputCostPerMillionTokens).toBe(0);
|
||||
});
|
||||
|
||||
it('should return effective model config for specific model', () => {
|
||||
const RESULT = SERVICE.getEffectiveModelConfig('gpt-5.2');
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('gpt-5.2');
|
||||
expect(RESULT.inferenceProvider).toBe(InferenceProvider.OPENAI);
|
||||
});
|
||||
|
||||
it('should return effective model config for custom model', () => {
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'mistral',
|
||||
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig('mistral');
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('mistral');
|
||||
expect(RESULT.inferenceProvider).toBe(InferenceProvider.OPENAI_COMPATIBLE);
|
||||
expect(RESULT.label).toBe('mistral');
|
||||
expect(RESULT.inputCostPerMillionTokens).toBe(0);
|
||||
expect(RESULT.outputCostPerMillionTokens).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent model', () => {
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue(undefined);
|
||||
|
||||
expect(() => SERVICE.getEffectiveModelConfig('non-existent-model')).toThrow(
|
||||
'Model with ID non-existent-model not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should find first available model from comma-separated list', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue(
|
||||
'gpt-5-mini,claude-haiku-4-5-20251001,gemini-3-flash-preview',
|
||||
);
|
||||
|
||||
const getModelSpy = jest
|
||||
.spyOn(SERVICE, 'getModel')
|
||||
.mockImplementation((modelId: string) => {
|
||||
if (modelId === 'claude-haiku-4-5-20251001') {
|
||||
return {
|
||||
modelId: 'claude-haiku-4-5-20251001',
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
model: {} as any,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = SERVICE.getDefaultSpeedModel();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.modelId).toBe('claude-haiku-4-5-20251001');
|
||||
expect(getModelSpy).toHaveBeenCalledWith('gpt-5-mini');
|
||||
expect(getModelSpy).toHaveBeenCalledWith('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should fall back to any available model if none in list are available', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('model-a,model-b,model-c');
|
||||
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue(undefined);
|
||||
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'fallback-model',
|
||||
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = SERVICE.getDefaultSpeedModel();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.modelId).toBe('fallback-model');
|
||||
});
|
||||
});
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
export {
|
||||
DEFAULT_FAST_MODEL,
|
||||
DEFAULT_SMART_MODEL,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
type AIModelConfig,
|
||||
type ModelId,
|
||||
type SupportedFileType,
|
||||
} from './ai-models-types.const';
|
||||
|
||||
import { type AIModelConfig } from './ai-models-types.const';
|
||||
import { ANTHROPIC_MODELS } from './anthropic-models.const';
|
||||
import { BEDROCK_MODELS } from './bedrock-models.const';
|
||||
import { GOOGLE_MODELS } from './google-models.const';
|
||||
import { GROQ_MODELS } from './groq-models.const';
|
||||
import { MISTRAL_MODELS } from './mistral-models.const';
|
||||
import { OPENAI_MODELS } from './openai-models.const';
|
||||
import { XAI_MODELS } from './xai-models.const';
|
||||
|
||||
export const AI_MODELS: AIModelConfig[] = [
|
||||
...OPENAI_MODELS,
|
||||
...ANTHROPIC_MODELS,
|
||||
...BEDROCK_MODELS,
|
||||
...GOOGLE_MODELS,
|
||||
...XAI_MODELS,
|
||||
...GROQ_MODELS,
|
||||
...MISTRAL_MODELS,
|
||||
];
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export const AI_SDK_OPENAI = '@ai-sdk/openai' as const;
|
||||
export const AI_SDK_ANTHROPIC = '@ai-sdk/anthropic' as const;
|
||||
export const AI_SDK_GOOGLE = '@ai-sdk/google' as const;
|
||||
export const AI_SDK_MISTRAL = '@ai-sdk/mistral' as const;
|
||||
export const AI_SDK_XAI = '@ai-sdk/xai' as const;
|
||||
export const AI_SDK_BEDROCK = '@ai-sdk/amazon-bedrock' as const;
|
||||
export const AI_SDK_OPENAI_COMPATIBLE = '@ai-sdk/openai-compatible' as const;
|
||||
-271
@@ -1,271 +0,0 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from './ai-models-types.const';
|
||||
|
||||
export const ANTHROPIC_MODELS: AIModelConfig[] = [
|
||||
// Active models
|
||||
{
|
||||
modelId: 'claude-opus-4-6',
|
||||
label: 'Claude Opus 4.6',
|
||||
description:
|
||||
'Flagship Claude model for software engineering, scientific reasoning, and agent teams',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
inputCostPerMillionTokens: 5.0,
|
||||
outputCostPerMillionTokens: 25.0,
|
||||
cachedInputCostPerMillionTokens: 0.5,
|
||||
cacheCreationCostPerMillionTokens: 6.25,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 10.0,
|
||||
outputCostPerMillionTokens: 37.5,
|
||||
cachedInputCostPerMillionTokens: 1.0,
|
||||
cacheCreationCostPerMillionTokens: 12.5,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 1000000,
|
||||
maxOutputTokens: 128000,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
isRecommended: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-sonnet-4-6',
|
||||
label: 'Claude Sonnet 4.6',
|
||||
description:
|
||||
'Most capable Sonnet model with strong coding, computer use, and agent planning',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
inputCostPerMillionTokens: 3.0,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
cachedInputCostPerMillionTokens: 0.3,
|
||||
cacheCreationCostPerMillionTokens: 3.75,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 6.0,
|
||||
outputCostPerMillionTokens: 22.5,
|
||||
cachedInputCostPerMillionTokens: 0.6,
|
||||
cacheCreationCostPerMillionTokens: 7.5,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
isRecommended: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-sonnet-4-5-20250929',
|
||||
label: 'Claude Sonnet 4.5',
|
||||
description:
|
||||
'Previous gen Sonnet with 1M native context for long-context workflows',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
inputCostPerMillionTokens: 3.0,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
cachedInputCostPerMillionTokens: 0.3,
|
||||
cacheCreationCostPerMillionTokens: 3.75,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 6.0,
|
||||
outputCostPerMillionTokens: 22.5,
|
||||
cachedInputCostPerMillionTokens: 0.6,
|
||||
cacheCreationCostPerMillionTokens: 7.5,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'claude-haiku-4-5-20251001',
|
||||
label: 'Claude Haiku 4.5',
|
||||
description: 'Fast and cost-effective model for high-speed processing',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
inputCostPerMillionTokens: 1.0,
|
||||
outputCostPerMillionTokens: 5.0,
|
||||
cachedInputCostPerMillionTokens: 0.1,
|
||||
cacheCreationCostPerMillionTokens: 1.25,
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: false,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Deprecated models - kept for backward compatibility with existing agents
|
||||
{
|
||||
modelId: 'claude-opus-4-5-20251101',
|
||||
label: 'Claude Opus 4.5',
|
||||
description: 'Previous flagship model superseded by Opus 4.6',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
inputCostPerMillionTokens: 5.0,
|
||||
outputCostPerMillionTokens: 25.0,
|
||||
cachedInputCostPerMillionTokens: 0.5,
|
||||
cacheCreationCostPerMillionTokens: 6.25,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 10.0,
|
||||
outputCostPerMillionTokens: 37.5,
|
||||
cachedInputCostPerMillionTokens: 1.0,
|
||||
cacheCreationCostPerMillionTokens: 12.5,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
label: 'Claude Sonnet 4',
|
||||
description: 'Previous gen Sonnet superseded by Sonnet 4.6',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
inputCostPerMillionTokens: 3.0,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
cachedInputCostPerMillionTokens: 0.3,
|
||||
cacheCreationCostPerMillionTokens: 3.75,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 6.0,
|
||||
outputCostPerMillionTokens: 22.5,
|
||||
cachedInputCostPerMillionTokens: 0.6,
|
||||
cacheCreationCostPerMillionTokens: 7.5,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-opus-4-20250514',
|
||||
label: 'Claude Opus 4',
|
||||
description: 'Legacy Opus model with extended thinking',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
inputCostPerMillionTokens: 15.0,
|
||||
outputCostPerMillionTokens: 75.0,
|
||||
cachedInputCostPerMillionTokens: 1.5,
|
||||
cacheCreationCostPerMillionTokens: 18.75,
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
label: 'Claude Haiku 3.5',
|
||||
description: 'Legacy fast model superseded by Haiku 4.5',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
inputCostPerMillionTokens: 0.8,
|
||||
outputCostPerMillionTokens: 4.0,
|
||||
cachedInputCostPerMillionTokens: 0.08,
|
||||
cacheCreationCostPerMillionTokens: 1.0,
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: false,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
];
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from './ai-models-types.const';
|
||||
|
||||
export const BEDROCK_MODELS: AIModelConfig[] = [
|
||||
{
|
||||
modelId: 'anthropic.claude-opus-4-6-v1',
|
||||
label: 'Claude Opus 4.6 (Bedrock)',
|
||||
description:
|
||||
'Flagship Claude model via AWS Bedrock for enterprise deployments',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.BEDROCK,
|
||||
inputCostPerMillionTokens: 5.0,
|
||||
outputCostPerMillionTokens: 25.0,
|
||||
cachedInputCostPerMillionTokens: 0.5,
|
||||
cacheCreationCostPerMillionTokens: 6.25,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 10.0,
|
||||
outputCostPerMillionTokens: 37.5,
|
||||
cachedInputCostPerMillionTokens: 1.0,
|
||||
cacheCreationCostPerMillionTokens: 12.5,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 1000000,
|
||||
maxOutputTokens: 128000,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'anthropic.claude-sonnet-4-6',
|
||||
label: 'Claude Sonnet 4.6 (Bedrock)',
|
||||
description:
|
||||
'Balanced Claude model via AWS Bedrock with strong coding and agent planning',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.BEDROCK,
|
||||
inputCostPerMillionTokens: 3.0,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
cachedInputCostPerMillionTokens: 0.3,
|
||||
cacheCreationCostPerMillionTokens: 3.75,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 6.0,
|
||||
outputCostPerMillionTokens: 22.5,
|
||||
cachedInputCostPerMillionTokens: 0.6,
|
||||
cacheCreationCostPerMillionTokens: 7.5,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
// Configuration: $0.00_001 = 1 credit
|
||||
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.00_0001 = 1_000_000 credits per dollar
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from './ai-models-types.const';
|
||||
|
||||
export const GOOGLE_MODELS: AIModelConfig[] = [
|
||||
// Active models
|
||||
{
|
||||
modelId: 'gemini-3.1-pro-preview',
|
||||
label: 'Gemini 3.1 Pro',
|
||||
description:
|
||||
'Most advanced Gemini model for reasoning, coding, and agentic workflows',
|
||||
modelFamily: ModelFamily.GOOGLE,
|
||||
inferenceProvider: InferenceProvider.GOOGLE,
|
||||
inputCostPerMillionTokens: 2.0,
|
||||
outputCostPerMillionTokens: 12.0,
|
||||
cachedInputCostPerMillionTokens: 0.2,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 4.0,
|
||||
outputCostPerMillionTokens: 18.0,
|
||||
cachedInputCostPerMillionTokens: 0.4,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
isRecommended: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-3-flash-preview',
|
||||
label: 'Gemini 3 Flash',
|
||||
description: 'Fast frontier-class Gemini model at low cost with 1M context',
|
||||
modelFamily: ModelFamily.GOOGLE,
|
||||
inferenceProvider: InferenceProvider.GOOGLE,
|
||||
inputCostPerMillionTokens: 0.5,
|
||||
outputCostPerMillionTokens: 3.0,
|
||||
cachedInputCostPerMillionTokens: 0.05,
|
||||
contextWindowTokens: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-3.1-flash-lite-preview',
|
||||
label: 'Gemini 3.1 Flash Lite',
|
||||
description:
|
||||
'Ultra-low-cost Gemini model for high-volume tasks with 1M context',
|
||||
modelFamily: ModelFamily.GOOGLE,
|
||||
inferenceProvider: InferenceProvider.GOOGLE,
|
||||
inputCostPerMillionTokens: 0.25,
|
||||
outputCostPerMillionTokens: 1.5,
|
||||
contextWindowTokens: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
},
|
||||
|
||||
// Deprecated models - kept for backward compatibility with existing agents
|
||||
{
|
||||
modelId: 'gemini-2.5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
description: 'Previous gen Gemini Pro superseded by 3.1 Pro',
|
||||
modelFamily: ModelFamily.GOOGLE,
|
||||
inferenceProvider: InferenceProvider.GOOGLE,
|
||||
inputCostPerMillionTokens: 1.25,
|
||||
outputCostPerMillionTokens: 10.0,
|
||||
cachedInputCostPerMillionTokens: 0.315,
|
||||
longContextCost: {
|
||||
inputCostPerMillionTokens: 2.5,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
thresholdTokens: 200_000,
|
||||
},
|
||||
contextWindowTokens: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-2.5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
description: 'Previous gen Flash superseded by Gemini 3 Flash',
|
||||
modelFamily: ModelFamily.GOOGLE,
|
||||
inferenceProvider: InferenceProvider.GOOGLE,
|
||||
inputCostPerMillionTokens: 0.3,
|
||||
outputCostPerMillionTokens: 2.5,
|
||||
cachedInputCostPerMillionTokens: 0.075,
|
||||
contextWindowTokens: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
deprecated: true,
|
||||
},
|
||||
];
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from './ai-models-types.const';
|
||||
|
||||
export const GROQ_MODELS: AIModelConfig[] = [
|
||||
{
|
||||
modelId: 'openai/gpt-oss-120b',
|
||||
label: 'GPT-OSS 120B (Groq)',
|
||||
description:
|
||||
'Large-scale open-source model with ultra-fast inference via Groq',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.GROQ,
|
||||
inputCostPerMillionTokens: 0.15,
|
||||
outputCostPerMillionTokens: 0.6,
|
||||
cachedInputCostPerMillionTokens: 0.075,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
];
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from './ai-models-types.const';
|
||||
|
||||
export const MISTRAL_MODELS: AIModelConfig[] = [
|
||||
{
|
||||
modelId: 'mistral-large-latest',
|
||||
label: 'Mistral Large',
|
||||
description:
|
||||
'Flagship Mistral model with strong reasoning and 256K context',
|
||||
modelFamily: ModelFamily.MISTRAL,
|
||||
inferenceProvider: InferenceProvider.MISTRAL,
|
||||
inputCostPerMillionTokens: 0.5,
|
||||
outputCostPerMillionTokens: 1.5,
|
||||
contextWindowTokens: 256000,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
];
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
|
||||
export const MODEL_FAMILY_LABELS: Record<string, string> = {
|
||||
[ModelFamily.GPT]: 'GPT',
|
||||
[ModelFamily.CLAUDE]: 'Claude',
|
||||
[ModelFamily.GEMINI]: 'Gemini',
|
||||
[ModelFamily.MISTRAL]: 'Mistral',
|
||||
[ModelFamily.GROK]: 'Grok',
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const MODELS_DEV_API_URL = 'https://models.dev/api.json';
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from './ai-models-types.const';
|
||||
|
||||
export const OPENAI_MODELS: AIModelConfig[] = [
|
||||
// Active models
|
||||
{
|
||||
modelId: 'gpt-5.2',
|
||||
label: 'GPT-5.2',
|
||||
description:
|
||||
'Most advanced OpenAI model for coding, agentic tasks, and complex reasoning',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 1.75,
|
||||
outputCostPerMillionTokens: 14.0,
|
||||
cachedInputCostPerMillionTokens: 0.175,
|
||||
contextWindowTokens: 400000,
|
||||
maxOutputTokens: 128000,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
isRecommended: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-5-mini',
|
||||
label: 'GPT-5 Mini',
|
||||
description: 'Fast and cost-efficient GPT-5 variant for well-defined tasks',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 0.25,
|
||||
outputCostPerMillionTokens: 2.0,
|
||||
cachedInputCostPerMillionTokens: 0.025,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 32768,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4.1',
|
||||
label: 'GPT-4.1',
|
||||
description: 'Strong model with 1M context, cost-effective output pricing',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 2.0,
|
||||
outputCostPerMillionTokens: 8.0,
|
||||
cachedInputCostPerMillionTokens: 0.5,
|
||||
contextWindowTokens: 1047576,
|
||||
maxOutputTokens: 32768,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
isRecommended: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4.1-mini',
|
||||
label: 'GPT-4.1 Mini',
|
||||
description: 'Budget-friendly model with 1M context for lightweight tasks',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 0.4,
|
||||
outputCostPerMillionTokens: 1.6,
|
||||
cachedInputCostPerMillionTokens: 0.1,
|
||||
contextWindowTokens: 1047576,
|
||||
maxOutputTokens: 32768,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Deprecated models - kept for backward compatibility with existing agents
|
||||
{
|
||||
modelId: 'o3',
|
||||
label: 'o3',
|
||||
description:
|
||||
'Reasoning model for complex queries, coding, math, and science',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 2.0,
|
||||
outputCostPerMillionTokens: 8.0,
|
||||
cachedInputCostPerMillionTokens: 0.5,
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 100000,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'o4-mini',
|
||||
label: 'o4-mini',
|
||||
description:
|
||||
'Cost-effective reasoning model for math, coding, and visual tasks',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 1.1,
|
||||
outputCostPerMillionTokens: 4.4,
|
||||
cachedInputCostPerMillionTokens: 0.275,
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 100000,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
description:
|
||||
'Previous generation multimodal model with strong reasoning and vision',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 2.5,
|
||||
outputCostPerMillionTokens: 10.0,
|
||||
cachedInputCostPerMillionTokens: 1.25,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4o-mini',
|
||||
label: 'GPT-4o Mini',
|
||||
description: 'Previous generation fast model for lightweight tasks',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 0.15,
|
||||
outputCostPerMillionTokens: 0.6,
|
||||
cachedInputCostPerMillionTokens: 0.075,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4-turbo',
|
||||
label: 'GPT-4 Turbo',
|
||||
description: 'Legacy high-performance model with vision capabilities',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
inputCostPerMillionTokens: 10.0,
|
||||
outputCostPerMillionTokens: 30.0,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 4096,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
deprecated: true,
|
||||
},
|
||||
];
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from './ai-models-types.const';
|
||||
|
||||
export const XAI_MODELS: AIModelConfig[] = [
|
||||
// Active models
|
||||
{
|
||||
modelId: 'grok-4',
|
||||
label: 'Grok-4',
|
||||
description:
|
||||
'Most capable Grok model with enhanced reasoning, web and Twitter search',
|
||||
modelFamily: ModelFamily.XAI,
|
||||
inferenceProvider: InferenceProvider.XAI,
|
||||
inputCostPerMillionTokens: 3.0,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
cachedInputCostPerMillionTokens: 0.75,
|
||||
contextWindowTokens: 256000,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
isRecommended: true,
|
||||
},
|
||||
{
|
||||
modelId: 'grok-4-1-fast-reasoning',
|
||||
label: 'Grok 4.1 Fast',
|
||||
description:
|
||||
'Next-generation tool-calling agent with 2M context for advanced agentic workflows',
|
||||
modelFamily: ModelFamily.XAI,
|
||||
inferenceProvider: InferenceProvider.XAI,
|
||||
inputCostPerMillionTokens: 0.2,
|
||||
outputCostPerMillionTokens: 0.5,
|
||||
cachedInputCostPerMillionTokens: 0.05,
|
||||
contextWindowTokens: 2000000,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Deprecated models - kept for backward compatibility with existing agents
|
||||
{
|
||||
modelId: 'grok-3',
|
||||
label: 'Grok-3',
|
||||
description:
|
||||
'Advanced model with web and Twitter search, optimized for real-time information',
|
||||
modelFamily: ModelFamily.XAI,
|
||||
inferenceProvider: InferenceProvider.XAI,
|
||||
inputCostPerMillionTokens: 3.0,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
cachedInputCostPerMillionTokens: 0.75,
|
||||
contextWindowTokens: 131072,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
modelId: 'grok-3-mini',
|
||||
label: 'Grok-3 Mini',
|
||||
description:
|
||||
'Lightweight model with web and Twitter search for fast, cost-effective operations',
|
||||
modelFamily: ModelFamily.XAI,
|
||||
inferenceProvider: InferenceProvider.XAI,
|
||||
inputCostPerMillionTokens: 0.3,
|
||||
outputCostPerMillionTokens: 0.5,
|
||||
cachedInputCostPerMillionTokens: 0.07,
|
||||
contextWindowTokens: 131072,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
];
|
||||
+37
-17
@@ -1,34 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
import { ToolSet } from 'ai';
|
||||
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
|
||||
import { InferenceProvider } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import {
|
||||
AI_SDK_ANTHROPIC,
|
||||
AI_SDK_BEDROCK,
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import {
|
||||
AiModelRegistryService,
|
||||
RegisteredAIModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
|
||||
@Injectable()
|
||||
export class AgentModelConfigService {
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly sdkProviderFactory: SdkProviderFactoryService,
|
||||
) {}
|
||||
|
||||
getProviderOptions(
|
||||
model: RegisteredAIModel,
|
||||
agent: FlatAgentWithRoleId,
|
||||
): ProviderOptions {
|
||||
switch (model.inferenceProvider) {
|
||||
case InferenceProvider.XAI:
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_XAI:
|
||||
return this.getXaiProviderOptions(agent);
|
||||
case InferenceProvider.ANTHROPIC:
|
||||
case AI_SDK_ANTHROPIC:
|
||||
return this.getAnthropicProviderOptions(model);
|
||||
case InferenceProvider.BEDROCK:
|
||||
case AI_SDK_BEDROCK:
|
||||
return this.getBedrockProviderOptions(model);
|
||||
default:
|
||||
return {};
|
||||
@@ -45,16 +50,25 @@ export class AgentModelConfigService {
|
||||
return tools;
|
||||
}
|
||||
|
||||
switch (model.inferenceProvider) {
|
||||
case InferenceProvider.ANTHROPIC:
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_ANTHROPIC:
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
tools.web_search = anthropic.tools.webSearch_20250305();
|
||||
const anthropicProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawAnthropicProvider(
|
||||
model.providerName,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (anthropicProvider) {
|
||||
tools.web_search = anthropicProvider.tools.webSearch_20250305();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case InferenceProvider.BEDROCK: {
|
||||
case AI_SDK_BEDROCK: {
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
const bedrockProvider =
|
||||
this.aiModelRegistryService.getBedrockProvider();
|
||||
const bedrockProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawBedrockProvider(model.providerName)
|
||||
: undefined;
|
||||
|
||||
if (bedrockProvider) {
|
||||
tools.web_search =
|
||||
@@ -63,9 +77,15 @@ export class AgentModelConfigService {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case InferenceProvider.OPENAI:
|
||||
case AI_SDK_OPENAI:
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
tools.web_search = openai.tools.webSearch();
|
||||
const openaiProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawOpenAIProvider(model.providerName)
|
||||
: undefined;
|
||||
|
||||
if (openaiProvider) {
|
||||
tools.web_search = openaiProvider.tools.webSearch();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -105,7 +125,7 @@ export class AgentModelConfigService {
|
||||
private getAnthropicProviderOptions(
|
||||
model: RegisteredAIModel,
|
||||
): ProviderOptions {
|
||||
if (!model.doesSupportThinking) {
|
||||
if (!model.supportsReasoning) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -120,7 +140,7 @@ export class AgentModelConfigService {
|
||||
}
|
||||
|
||||
private getBedrockProviderOptions(model: RegisteredAIModel): ProviderOptions {
|
||||
if (!model.doesSupportThinking) {
|
||||
if (!model.supportsReasoning) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
|
||||
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
|
||||
|
||||
@Injectable()
|
||||
export class AiModelPreferencesService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
getPreferences(): AiModelPreferences {
|
||||
return this.twentyConfigService.get('AI_MODEL_PREFERENCES');
|
||||
}
|
||||
|
||||
getRecommendedModelIds(): Set<string> {
|
||||
const prefs = this.getPreferences();
|
||||
|
||||
return new Set(prefs.recommendedModels ?? []);
|
||||
}
|
||||
|
||||
async setModelAdminEnabled(modelId: string, enabled: boolean): Promise<void> {
|
||||
await this.togglePreferenceList(modelId, 'disabledModels', !enabled);
|
||||
}
|
||||
|
||||
async setModelRecommended(
|
||||
modelId: string,
|
||||
recommended: boolean,
|
||||
): Promise<void> {
|
||||
await this.togglePreferenceList(modelId, 'recommendedModels', recommended);
|
||||
}
|
||||
|
||||
async setDefaultModel(role: AiModelRole, modelId: string): Promise<void> {
|
||||
const prefs = { ...this.getPreferences() };
|
||||
const key =
|
||||
role === AiModelRole.FAST ? 'defaultFastModels' : 'defaultSmartModels';
|
||||
|
||||
const current = prefs[key] ?? [];
|
||||
const filtered = current.filter((id) => id !== modelId);
|
||||
|
||||
prefs[key] = [modelId, ...filtered];
|
||||
|
||||
await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs);
|
||||
}
|
||||
|
||||
private async togglePreferenceList(
|
||||
modelId: string,
|
||||
key: 'disabledModels' | 'recommendedModels',
|
||||
add: boolean,
|
||||
): Promise<void> {
|
||||
const prefs = { ...this.getPreferences() };
|
||||
const current = prefs[key] ?? [];
|
||||
|
||||
if (add) {
|
||||
if (!current.includes(modelId)) {
|
||||
prefs[key] = [...current, modelId];
|
||||
}
|
||||
} else {
|
||||
prefs[key] = current.filter((id) => id !== modelId);
|
||||
}
|
||||
|
||||
await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs);
|
||||
}
|
||||
}
|
||||
+196
-349
@@ -1,37 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
createAmazonBedrock,
|
||||
type AmazonBedrockProvider,
|
||||
} from '@ai-sdk/amazon-bedrock';
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { google } from '@ai-sdk/google';
|
||||
import { groq } from '@ai-sdk/groq';
|
||||
import { mistral } from '@ai-sdk/mistral';
|
||||
import { createOpenAI, openai } from '@ai-sdk/openai';
|
||||
import { xai } from '@ai-sdk/xai';
|
||||
import { type LanguageModel } from 'ai';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
|
||||
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import {
|
||||
AI_MODELS,
|
||||
DEFAULT_FAST_MODEL,
|
||||
DEFAULT_SMART_MODEL,
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
type AIModelConfig,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { ANTHROPIC_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/anthropic-models.const';
|
||||
import { BEDROCK_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/bedrock-models.const';
|
||||
import { GOOGLE_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/google-models.const';
|
||||
import { GROQ_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/groq-models.const';
|
||||
import { MISTRAL_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/mistral-models.const';
|
||||
import { OPENAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/openai-models.const';
|
||||
import { XAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/xai-models.const';
|
||||
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';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
|
||||
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
import { DEFAULT_CONTEXT_WINDOW_TOKENS } from 'src/engine/metadata-modules/ai/ai-models/types/default-context-window-tokens.const';
|
||||
import { DEFAULT_FAST_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-fast-model.const';
|
||||
import { DEFAULT_MAX_OUTPUT_TOKENS } from 'src/engine/metadata-modules/ai/ai-models/types/default-max-output-tokens.const';
|
||||
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
|
||||
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
|
||||
import { inferModelFamily } from 'src/engine/metadata-modules/ai/ai-models/utils/infer-model-family.util';
|
||||
import { isDefaultModelSentinel } from 'src/engine/metadata-modules/ai/ai-models/utils/is-default-model-sentinel.util';
|
||||
import {
|
||||
isModelAllowedByWorkspace,
|
||||
type WorkspaceModelAvailabilitySettings,
|
||||
@@ -39,201 +30,117 @@ import {
|
||||
|
||||
export interface RegisteredAIModel {
|
||||
modelId: string;
|
||||
inferenceProvider: InferenceProvider;
|
||||
sdkPackage: AiSdkPackage;
|
||||
model: LanguageModel;
|
||||
doesSupportThinking?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
providerName?: string;
|
||||
modelsDevName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiModelRegistryService {
|
||||
private readonly logger = new Logger(AiModelRegistryService.name);
|
||||
private modelRegistry: Map<string, RegisteredAIModel> = new Map();
|
||||
private bedrockProvider: AmazonBedrockProvider | null = null;
|
||||
private modelConfigCache: Map<string, AIModelConfig> = new Map();
|
||||
private providerModelDefCache: Map<
|
||||
string,
|
||||
{ providerName: string; modelDef: AiProviderModelConfig }
|
||||
> = new Map();
|
||||
|
||||
constructor(private twentyConfigService: TwentyConfigService) {
|
||||
constructor(
|
||||
private readonly providerConfigService: ProviderConfigService,
|
||||
private readonly sdkProviderFactory: SdkProviderFactoryService,
|
||||
private readonly preferencesService: AiModelPreferencesService,
|
||||
) {
|
||||
this.buildModelRegistry();
|
||||
}
|
||||
|
||||
getBedrockProvider(): AmazonBedrockProvider | null {
|
||||
return this.bedrockProvider;
|
||||
}
|
||||
|
||||
private buildModelRegistry(): void {
|
||||
this.modelRegistry.clear();
|
||||
this.bedrockProvider = null;
|
||||
this.sdkProviderFactory.clearCache();
|
||||
this.modelConfigCache.clear();
|
||||
this.providerModelDefCache.clear();
|
||||
|
||||
const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY');
|
||||
const providers = this.providerConfigService.getResolvedProviders();
|
||||
|
||||
if (openaiApiKey) {
|
||||
this.registerOpenAIModels();
|
||||
}
|
||||
this.registerModelsFromProviders(providers);
|
||||
}
|
||||
|
||||
const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
|
||||
private registerModelsFromProviders(providers: AiProvidersConfig): void {
|
||||
for (const [providerKey, config] of Object.entries(providers)) {
|
||||
if (!config.npm) {
|
||||
this.logger.warn(
|
||||
`Skipping provider "${providerKey}": missing npm field`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (anthropicApiKey) {
|
||||
this.registerAnthropicModels();
|
||||
}
|
||||
const models = config.models ?? [];
|
||||
|
||||
const xaiApiKey = this.twentyConfigService.get('XAI_API_KEY');
|
||||
if (models.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (xaiApiKey) {
|
||||
this.registerXaiModels();
|
||||
}
|
||||
const isConfigured = !!(config.apiKey || config.accessKeyId);
|
||||
|
||||
const groqApiKey = this.twentyConfigService.get('GROQ_API_KEY');
|
||||
const sdkInstance = isConfigured
|
||||
? this.sdkProviderFactory.createProvider(providerKey, config)
|
||||
: undefined;
|
||||
|
||||
if (groqApiKey) {
|
||||
this.registerGroqModels();
|
||||
}
|
||||
for (const modelDef of models) {
|
||||
const compositeId = buildCompositeModelId(providerKey, modelDef.name);
|
||||
|
||||
const googleApiKey = this.twentyConfigService.get('GOOGLE_API_KEY');
|
||||
this.modelConfigCache.set(
|
||||
compositeId,
|
||||
this.toAIModelConfig(compositeId, config, modelDef),
|
||||
);
|
||||
|
||||
if (googleApiKey) {
|
||||
this.registerGoogleModels();
|
||||
}
|
||||
this.providerModelDefCache.set(compositeId, {
|
||||
providerName: providerKey,
|
||||
modelDef,
|
||||
});
|
||||
|
||||
const mistralApiKey = this.twentyConfigService.get('MISTRAL_API_KEY');
|
||||
|
||||
if (mistralApiKey) {
|
||||
this.registerMistralModels();
|
||||
}
|
||||
|
||||
const bedrockRegion = this.twentyConfigService.get('AWS_BEDROCK_REGION');
|
||||
|
||||
if (bedrockRegion) {
|
||||
this.registerBedrockModels(bedrockRegion);
|
||||
}
|
||||
|
||||
const openaiCompatibleBaseUrl = this.twentyConfigService.get(
|
||||
'OPENAI_COMPATIBLE_BASE_URL',
|
||||
);
|
||||
const openaiCompatibleModelNames = this.twentyConfigService.get(
|
||||
'OPENAI_COMPATIBLE_MODEL_NAMES',
|
||||
);
|
||||
|
||||
if (openaiCompatibleBaseUrl && openaiCompatibleModelNames) {
|
||||
this.registerOpenAICompatibleModels(
|
||||
openaiCompatibleBaseUrl,
|
||||
openaiCompatibleModelNames,
|
||||
);
|
||||
if (sdkInstance) {
|
||||
this.modelRegistry.set(compositeId, {
|
||||
modelId: compositeId,
|
||||
sdkPackage: config.npm,
|
||||
model: sdkInstance.createModel(modelDef.name),
|
||||
supportsReasoning: modelDef.supportsReasoning,
|
||||
providerName: providerKey,
|
||||
modelsDevName: config.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerOpenAIModels(): void {
|
||||
OPENAI_MODELS.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
model: openai(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerAnthropicModels(): void {
|
||||
ANTHROPIC_MODELS.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
model: anthropic(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerXaiModels(): void {
|
||||
XAI_MODELS.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
inferenceProvider: InferenceProvider.XAI,
|
||||
model: xai(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerGroqModels(): void {
|
||||
GROQ_MODELS.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
inferenceProvider: InferenceProvider.GROQ,
|
||||
model: groq(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerGoogleModels(): void {
|
||||
GOOGLE_MODELS.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
inferenceProvider: InferenceProvider.GOOGLE,
|
||||
model: google(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerMistralModels(): void {
|
||||
MISTRAL_MODELS.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
inferenceProvider: InferenceProvider.MISTRAL,
|
||||
model: mistral(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerBedrockModels(region: string): void {
|
||||
const accessKeyId = this.twentyConfigService.get(
|
||||
'AWS_BEDROCK_ACCESS_KEY_ID',
|
||||
);
|
||||
const secretAccessKey = this.twentyConfigService.get(
|
||||
'AWS_BEDROCK_SECRET_ACCESS_KEY',
|
||||
);
|
||||
const sessionToken = this.twentyConfigService.get(
|
||||
'AWS_BEDROCK_SESSION_TOKEN',
|
||||
);
|
||||
|
||||
this.bedrockProvider = createAmazonBedrock({
|
||||
region,
|
||||
...(accessKeyId && secretAccessKey
|
||||
? { accessKeyId, secretAccessKey, sessionToken }
|
||||
: {}),
|
||||
});
|
||||
|
||||
BEDROCK_MODELS.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
inferenceProvider: InferenceProvider.BEDROCK,
|
||||
model: this.bedrockProvider!(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerOpenAICompatibleModels(
|
||||
baseUrl: string,
|
||||
modelNamesString: string,
|
||||
): void {
|
||||
const apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY');
|
||||
const provider = createOpenAI({
|
||||
baseURL: baseUrl,
|
||||
apiKey: apiKey,
|
||||
});
|
||||
|
||||
const modelNames = modelNamesString
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
modelNames.forEach((modelId) => {
|
||||
this.modelRegistry.set(modelId, {
|
||||
modelId,
|
||||
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
|
||||
model: provider(modelId),
|
||||
});
|
||||
});
|
||||
private toAIModelConfig(
|
||||
compositeId: string,
|
||||
providerConfig: AiProviderConfig,
|
||||
modelDef: AiProviderModelConfig,
|
||||
): AIModelConfig {
|
||||
return {
|
||||
modelId: compositeId,
|
||||
label: modelDef.label,
|
||||
sdkPackage: providerConfig.npm,
|
||||
description: modelDef.description ?? compositeId,
|
||||
modelFamily:
|
||||
modelDef.modelFamily ??
|
||||
inferModelFamily(providerConfig.name ?? '', modelDef.name),
|
||||
dataResidency: providerConfig.dataResidency,
|
||||
inputCostPerMillionTokens: modelDef.inputCostPerMillionTokens ?? 0,
|
||||
outputCostPerMillionTokens: modelDef.outputCostPerMillionTokens ?? 0,
|
||||
cachedInputCostPerMillionTokens: modelDef.cachedInputCostPerMillionTokens,
|
||||
cacheCreationCostPerMillionTokens:
|
||||
modelDef.cacheCreationCostPerMillionTokens,
|
||||
longContextCost: modelDef.longContextCost,
|
||||
contextWindowTokens:
|
||||
modelDef.contextWindowTokens ?? DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
maxOutputTokens: modelDef.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
modalities: modelDef.modalities,
|
||||
supportsReasoning: modelDef.supportsReasoning,
|
||||
isDeprecated: modelDef.isDeprecated,
|
||||
};
|
||||
}
|
||||
|
||||
getModel(modelId: string): RegisteredAIModel | undefined {
|
||||
@@ -244,14 +151,17 @@ export class AiModelRegistryService {
|
||||
return Array.from(this.modelRegistry.values());
|
||||
}
|
||||
|
||||
private getFirstAvailableModelFromList(
|
||||
modelIdList: string,
|
||||
): RegisteredAIModel | undefined {
|
||||
const modelIds = modelIdList
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0);
|
||||
getModelConfig(modelId: string): AIModelConfig | undefined {
|
||||
return this.modelConfigCache.get(modelId);
|
||||
}
|
||||
|
||||
getRecommendedModelIds(): Set<string> {
|
||||
return this.preferencesService.getRecommendedModelIds();
|
||||
}
|
||||
|
||||
private getFirstAvailableModelFromList(
|
||||
modelIds: string[],
|
||||
): RegisteredAIModel | undefined {
|
||||
for (const modelId of modelIds) {
|
||||
const model = this.getModel(modelId);
|
||||
|
||||
@@ -264,42 +174,27 @@ export class AiModelRegistryService {
|
||||
}
|
||||
|
||||
getDefaultSpeedModel(): RegisteredAIModel {
|
||||
const defaultModelIds = this.twentyConfigService.get(
|
||||
'DEFAULT_AI_SPEED_MODEL_ID',
|
||||
);
|
||||
let model = this.getFirstAvailableModelFromList(defaultModelIds);
|
||||
|
||||
if (!model) {
|
||||
const availableModels = this.getAvailableModels();
|
||||
|
||||
model = availableModels[0];
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
throw new AgentException(
|
||||
'No AI models are available. Please configure at least one AI provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_BEDROCK_REGION, GOOGLE_API_KEY, XAI_API_KEY, GROQ_API_KEY, or MISTRAL_API_KEY).',
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
return model;
|
||||
return this.getDefaultModelForRole(AiModelRole.FAST);
|
||||
}
|
||||
|
||||
getDefaultPerformanceModel(): RegisteredAIModel {
|
||||
const defaultModelIds = this.twentyConfigService.get(
|
||||
'DEFAULT_AI_PERFORMANCE_MODEL_ID',
|
||||
);
|
||||
let model = this.getFirstAvailableModelFromList(defaultModelIds);
|
||||
return this.getDefaultModelForRole(AiModelRole.SMART);
|
||||
}
|
||||
|
||||
private getDefaultModelForRole(role: AiModelRole): RegisteredAIModel {
|
||||
const prefs = this.preferencesService.getPreferences();
|
||||
const preferenceKey =
|
||||
role === AiModelRole.FAST ? 'defaultFastModels' : 'defaultSmartModels';
|
||||
|
||||
let model = this.getFirstAvailableModelFromList(prefs[preferenceKey] ?? []);
|
||||
|
||||
if (!model) {
|
||||
const availableModels = this.getAvailableModels();
|
||||
|
||||
model = availableModels[0];
|
||||
model = this.getAvailableModels()[0];
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
throw new AgentException(
|
||||
'No AI models are available. Please configure at least one AI provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_BEDROCK_REGION, GOOGLE_API_KEY, XAI_API_KEY, GROQ_API_KEY, or MISTRAL_API_KEY).',
|
||||
'No AI models are available. Configure at least one AI provider.',
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
@@ -308,29 +203,22 @@ export class AiModelRegistryService {
|
||||
}
|
||||
|
||||
getEffectiveModelConfig(modelId: string): AIModelConfig {
|
||||
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
|
||||
if (isDefaultModelSentinel(modelId)) {
|
||||
const defaultModel =
|
||||
modelId === DEFAULT_FAST_MODEL
|
||||
? this.getDefaultSpeedModel()
|
||||
: this.getDefaultPerformanceModel();
|
||||
|
||||
const modelConfig = AI_MODELS.find(
|
||||
(model) => model.modelId === defaultModel.modelId,
|
||||
return (
|
||||
this.modelConfigCache.get(defaultModel.modelId) ??
|
||||
this.createDefaultConfigForCustomModel(defaultModel)
|
||||
);
|
||||
|
||||
if (modelConfig) {
|
||||
return modelConfig;
|
||||
}
|
||||
|
||||
return this.createDefaultConfigForCustomModel(defaultModel);
|
||||
}
|
||||
|
||||
const predefinedModel = AI_MODELS.find(
|
||||
(model) => model.modelId === modelId,
|
||||
);
|
||||
const config = this.modelConfigCache.get(modelId);
|
||||
|
||||
if (predefinedModel) {
|
||||
return predefinedModel;
|
||||
if (config) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const registeredModel = this.getModel(modelId);
|
||||
@@ -352,43 +240,27 @@ export class AiModelRegistryService {
|
||||
modelId: registeredModel.modelId,
|
||||
label: registeredModel.modelId,
|
||||
description: `Custom model: ${registeredModel.modelId}`,
|
||||
modelFamily: this.inferModelFamily(registeredModel.inferenceProvider),
|
||||
inferenceProvider: registeredModel.inferenceProvider,
|
||||
modelFamily: inferModelFamily(
|
||||
registeredModel.modelsDevName ?? '',
|
||||
registeredModel.modelId,
|
||||
),
|
||||
sdkPackage: registeredModel.sdkPackage,
|
||||
inputCostPerMillionTokens: 0,
|
||||
outputCostPerMillionTokens: 0,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 4096,
|
||||
contextWindowTokens: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
private inferModelFamily(inferenceProvider: InferenceProvider): ModelFamily {
|
||||
const providerToFamily: Partial<Record<InferenceProvider, ModelFamily>> = {
|
||||
[InferenceProvider.OPENAI]: ModelFamily.OPENAI,
|
||||
[InferenceProvider.ANTHROPIC]: ModelFamily.ANTHROPIC,
|
||||
[InferenceProvider.BEDROCK]: ModelFamily.ANTHROPIC,
|
||||
[InferenceProvider.GOOGLE]: ModelFamily.GOOGLE,
|
||||
[InferenceProvider.MISTRAL]: ModelFamily.MISTRAL,
|
||||
[InferenceProvider.XAI]: ModelFamily.XAI,
|
||||
[InferenceProvider.GROQ]: ModelFamily.OPENAI,
|
||||
};
|
||||
|
||||
return providerToFamily[inferenceProvider] ?? ModelFamily.OPENAI;
|
||||
}
|
||||
|
||||
isModelAdminAllowed(modelId: string): boolean {
|
||||
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
|
||||
if (isDefaultModelSentinel(modelId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const autoEnable = this.twentyConfigService.get(
|
||||
'AI_AUTO_ENABLE_NEW_MODELS',
|
||||
);
|
||||
const disabledIds = this.twentyConfigService.get('AI_DISABLED_MODEL_IDS');
|
||||
const enabledIds = this.twentyConfigService.get('AI_ENABLED_MODEL_IDS');
|
||||
const prefs = this.preferencesService.getPreferences();
|
||||
const disabledModels = prefs.disabledModels ?? [];
|
||||
|
||||
return autoEnable
|
||||
? !disabledIds.includes(modelId)
|
||||
: enabledIds.includes(modelId);
|
||||
return !disabledModels.includes(modelId);
|
||||
}
|
||||
|
||||
validateModelAvailability(
|
||||
@@ -402,7 +274,13 @@ export class AiModelRegistryService {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isModelAllowedByWorkspace(modelId, workspace)) {
|
||||
if (
|
||||
!isModelAllowedByWorkspace(
|
||||
modelId,
|
||||
workspace,
|
||||
this.getRecommendedModelIds(),
|
||||
)
|
||||
) {
|
||||
throw new AgentException(
|
||||
'The selected model is not available in this workspace.',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
@@ -420,111 +298,80 @@ export class AiModelRegistryService {
|
||||
modelConfig: AIModelConfig;
|
||||
isAvailable: boolean;
|
||||
isAdminEnabled: boolean;
|
||||
isRecommended: boolean;
|
||||
providerName?: string;
|
||||
name?: string;
|
||||
}> {
|
||||
return AI_MODELS.map((model) => ({
|
||||
modelConfig: model,
|
||||
isAvailable: this.modelRegistry.has(model.modelId),
|
||||
isAdminEnabled: this.isModelAdminAllowed(model.modelId),
|
||||
}));
|
||||
const recommended = this.getRecommendedModelIds();
|
||||
|
||||
return Array.from(this.modelConfigCache.values()).map((modelConfig) => {
|
||||
const registered = this.modelRegistry.get(modelConfig.modelId);
|
||||
const cached = this.providerModelDefCache.get(modelConfig.modelId);
|
||||
|
||||
return {
|
||||
modelConfig,
|
||||
isAvailable: !!registered,
|
||||
isAdminEnabled: this.isModelAdminAllowed(modelConfig.modelId),
|
||||
isRecommended: recommended.has(modelConfig.modelId),
|
||||
providerName: registered?.providerName ?? cached?.providerName,
|
||||
name: cached?.modelDef.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async setModelAdminEnabled(modelId: string, enabled: boolean): Promise<void> {
|
||||
const isKnownModel = AI_MODELS.some((model) => model.modelId === modelId);
|
||||
this.validateModelInRegistry(modelId);
|
||||
await this.preferencesService.setModelAdminEnabled(modelId, enabled);
|
||||
}
|
||||
|
||||
if (!isKnownModel) {
|
||||
async setModelRecommended(
|
||||
modelId: string,
|
||||
recommended: boolean,
|
||||
): Promise<void> {
|
||||
this.validateModelInRegistry(modelId);
|
||||
await this.preferencesService.setModelRecommended(modelId, recommended);
|
||||
}
|
||||
|
||||
async setDefaultModel(role: AiModelRole, modelId: string): Promise<void> {
|
||||
this.validateModelInRegistry(modelId);
|
||||
await this.preferencesService.setDefaultModel(role, modelId);
|
||||
}
|
||||
|
||||
private validateModelInRegistry(modelId: string): void {
|
||||
if (!this.providerModelDefCache.has(modelId)) {
|
||||
throw new AgentException(
|
||||
`Unknown model ID: ${modelId}`,
|
||||
`Cannot update model "${modelId}": not found in registry`,
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const autoEnable = this.twentyConfigService.get(
|
||||
'AI_AUTO_ENABLE_NEW_MODELS',
|
||||
);
|
||||
const disabledIds = this.twentyConfigService.get('AI_DISABLED_MODEL_IDS');
|
||||
const enabledIds = this.twentyConfigService.get('AI_ENABLED_MODEL_IDS');
|
||||
getResolvedProvidersForAdmin(): AiProvidersConfig {
|
||||
return this.providerConfigService.getResolvedProviders();
|
||||
}
|
||||
|
||||
if (autoEnable) {
|
||||
const newDisabledIds = enabled
|
||||
? disabledIds.filter((id) => id !== modelId)
|
||||
: disabledIds.includes(modelId)
|
||||
? disabledIds
|
||||
: [...disabledIds, modelId];
|
||||
|
||||
await this.twentyConfigService.set(
|
||||
'AI_DISABLED_MODEL_IDS',
|
||||
newDisabledIds,
|
||||
);
|
||||
} else {
|
||||
const newEnabledIds = enabled
|
||||
? enabledIds.includes(modelId)
|
||||
? enabledIds
|
||||
: [...enabledIds, modelId]
|
||||
: enabledIds.filter((id) => id !== modelId);
|
||||
|
||||
await this.twentyConfigService.set('AI_ENABLED_MODEL_IDS', newEnabledIds);
|
||||
}
|
||||
getCatalogProviderNames(): Set<string> {
|
||||
return this.providerConfigService.getCatalogProviderNames();
|
||||
}
|
||||
|
||||
refreshRegistry(): void {
|
||||
this.buildModelRegistry();
|
||||
}
|
||||
|
||||
async resolveModelForAgent(agent: { modelId: string } | null) {
|
||||
resolveModelForAgent(agent: { modelId: string } | null): RegisteredAIModel {
|
||||
const aiModel = this.getEffectiveModelConfig(
|
||||
agent?.modelId ?? DEFAULT_SMART_MODEL,
|
||||
);
|
||||
|
||||
await this.validateApiKey(aiModel.inferenceProvider);
|
||||
const registeredModel = this.getModel(aiModel.modelId);
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new AgentException(
|
||||
`Model ${aiModel.modelId} not found in registry`,
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
`Model ${aiModel.modelId} not found in registry. Check that the corresponding AI provider is configured.`,
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
return registeredModel;
|
||||
}
|
||||
|
||||
async validateApiKey(inferenceProvider: InferenceProvider): Promise<void> {
|
||||
let apiKey: string | undefined;
|
||||
|
||||
switch (inferenceProvider) {
|
||||
case InferenceProvider.OPENAI:
|
||||
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
|
||||
break;
|
||||
case InferenceProvider.ANTHROPIC:
|
||||
apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
|
||||
break;
|
||||
case InferenceProvider.XAI:
|
||||
apiKey = this.twentyConfigService.get('XAI_API_KEY');
|
||||
break;
|
||||
case InferenceProvider.GROQ:
|
||||
apiKey = this.twentyConfigService.get('GROQ_API_KEY');
|
||||
break;
|
||||
case InferenceProvider.GOOGLE:
|
||||
apiKey = this.twentyConfigService.get('GOOGLE_API_KEY');
|
||||
break;
|
||||
case InferenceProvider.MISTRAL:
|
||||
apiKey = this.twentyConfigService.get('MISTRAL_API_KEY');
|
||||
break;
|
||||
case InferenceProvider.BEDROCK:
|
||||
apiKey = this.twentyConfigService.get('AWS_BEDROCK_REGION');
|
||||
break;
|
||||
case InferenceProvider.OPENAI_COMPATIBLE:
|
||||
apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY');
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new AgentException(
|
||||
`${inferenceProvider.toUpperCase()} API key not configured. Please set the appropriate environment variable.`,
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { LanguageModel, type ModelMessage, streamText } from 'ai';
|
||||
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
constructor(private aiModelRegistryService: AiModelRegistryService) {}
|
||||
|
||||
getModel(modelId: string | undefined) {
|
||||
const registeredModel = modelId
|
||||
? this.aiModelRegistryService.getModel(modelId)
|
||||
: this.aiModelRegistryService.getDefaultPerformanceModel();
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error(
|
||||
modelId
|
||||
? `Model "${modelId}" is not available. Please check your configuration.`
|
||||
: 'No AI models are available. Please configure at least one provider.',
|
||||
);
|
||||
}
|
||||
|
||||
return registeredModel.model;
|
||||
}
|
||||
|
||||
streamText({
|
||||
messages,
|
||||
options,
|
||||
}: {
|
||||
messages: ModelMessage[];
|
||||
options: {
|
||||
temperature?: number;
|
||||
maxOutputTokens?: number;
|
||||
model: LanguageModel;
|
||||
};
|
||||
}): ReturnType<typeof streamText> {
|
||||
return streamText({
|
||||
model: options.model,
|
||||
messages,
|
||||
temperature: options?.temperature,
|
||||
maxOutputTokens: options?.maxOutputTokens,
|
||||
experimental_telemetry: AI_TELEMETRY_CONFIG,
|
||||
});
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { inferAiSdkPackage } from 'twenty-shared/ai';
|
||||
|
||||
import { MODELS_DEV_API_URL } from 'src/engine/metadata-modules/ai/ai-models/constants/models-dev.const';
|
||||
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
|
||||
|
||||
export type ModelsDevModelSuggestion = {
|
||||
// models.dev catalog key for the model (often a bare id). Not the composite `provider/modelName` workspace id used in the registry.
|
||||
modelId: string;
|
||||
// Display name from the catalog (`model.name`), or the catalog key when absent.
|
||||
name: string;
|
||||
inputCostPerMillionTokens: number;
|
||||
outputCostPerMillionTokens: number;
|
||||
cachedInputCostPerMillionTokens?: number;
|
||||
cacheCreationCostPerMillionTokens?: number;
|
||||
contextWindowTokens: number;
|
||||
maxOutputTokens: number;
|
||||
modalities: string[];
|
||||
supportsReasoning: boolean;
|
||||
};
|
||||
|
||||
export type ModelsDevProviderSuggestion = {
|
||||
id: string;
|
||||
modelCount: number;
|
||||
npm: string;
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const NON_LANGUAGE_PATTERNS = [
|
||||
'embed',
|
||||
'tts',
|
||||
'whisper',
|
||||
'dall-e',
|
||||
'moderation',
|
||||
'text-to-speech',
|
||||
'speech-to-text',
|
||||
'imagen',
|
||||
'aqa',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ModelsDevCatalogService {
|
||||
private readonly logger = new Logger(ModelsDevCatalogService.name);
|
||||
private cache: ModelsDevData | null = null;
|
||||
private cacheTimestamp = 0;
|
||||
|
||||
async getProviderSuggestions(): Promise<ModelsDevProviderSuggestion[]> {
|
||||
const data = await this.getCachedData();
|
||||
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(data)
|
||||
.filter(([, provider]) => {
|
||||
const models = Object.keys(provider.models ?? {});
|
||||
|
||||
return models.some((modelId) => this.isLanguageModel(modelId));
|
||||
})
|
||||
.map(([id, provider]) => ({
|
||||
id,
|
||||
modelCount: Object.keys(provider.models ?? {}).filter((modelId) =>
|
||||
this.isLanguageModel(modelId),
|
||||
).length,
|
||||
npm: inferAiSdkPackage(id),
|
||||
}))
|
||||
.sort((a, b) => b.modelCount - a.modelCount);
|
||||
}
|
||||
|
||||
async getModelSuggestions(
|
||||
providerType: string,
|
||||
): Promise<ModelsDevModelSuggestion[]> {
|
||||
const data = await this.getCachedData();
|
||||
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const providerData = data[providerType];
|
||||
|
||||
if (!providerData?.models) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(providerData.models)
|
||||
.filter(([modelId]) => this.isLanguageModel(modelId))
|
||||
.map(([modelId, model]) => ({
|
||||
modelId,
|
||||
name: model.name ?? modelId,
|
||||
inputCostPerMillionTokens: model.cost?.input ?? 0,
|
||||
outputCostPerMillionTokens: model.cost?.output ?? 0,
|
||||
cachedInputCostPerMillionTokens: model.cost?.cache_read,
|
||||
cacheCreationCostPerMillionTokens: model.cost?.cache_write,
|
||||
contextWindowTokens: model.limit?.context ?? 0,
|
||||
maxOutputTokens: model.limit?.output ?? 0,
|
||||
modalities: (model.modalities?.input ?? []).filter(
|
||||
(modality) => modality !== 'text',
|
||||
),
|
||||
supportsReasoning: model.reasoning ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getCachedData(): Promise<ModelsDevData | null> {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.cache && now - this.cacheTimestamp < CACHE_TTL_MS) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(MODELS_DEV_API_URL, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`models.dev API returned ${response.status}, using cached data`,
|
||||
);
|
||||
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
this.cache = await response.json();
|
||||
this.cacheTimestamp = now;
|
||||
|
||||
return this.cache;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch models.dev: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
|
||||
return this.cache;
|
||||
}
|
||||
}
|
||||
|
||||
private isLanguageModel(modelId: string): boolean {
|
||||
const id = modelId.toLowerCase();
|
||||
|
||||
return !NON_LANGUAGE_PATTERNS.some((pattern) => id.includes(pattern));
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
import { extractConfigVariableName } from 'src/engine/metadata-modules/ai/ai-models/utils/extract-config-variable-name.util';
|
||||
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
|
||||
|
||||
@Injectable()
|
||||
export class ProviderConfigService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
getCatalogProviderNames(): Set<string> {
|
||||
return new Set(Object.keys(loadDefaultAiProviders()));
|
||||
}
|
||||
|
||||
getResolvedProviders(): AiProvidersConfig {
|
||||
const rawCatalog = loadDefaultAiProviders();
|
||||
// Only resolve {{VAR}} templates in the committed catalog — never in
|
||||
// user-supplied custom providers, to prevent config variable exfiltration.
|
||||
const catalog = this.resolveTemplates(rawCatalog);
|
||||
const custom = this.twentyConfigService.get('AI_PROVIDERS');
|
||||
|
||||
return { ...catalog, ...custom };
|
||||
}
|
||||
|
||||
private resolveTemplates(providers: AiProvidersConfig): AiProvidersConfig {
|
||||
const result: AiProvidersConfig = {};
|
||||
|
||||
for (const [name, config] of Object.entries(providers)) {
|
||||
result[name] = this.resolveProviderTemplates(config);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private resolveProviderTemplates(config: AiProviderConfig): AiProviderConfig {
|
||||
return {
|
||||
...config,
|
||||
apiKey: this.resolveTemplate(config.apiKey),
|
||||
accessKeyId: this.resolveTemplate(config.accessKeyId),
|
||||
secretAccessKey: this.resolveTemplate(config.secretAccessKey),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveTemplate(value?: string): string | undefined {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const varName = extractConfigVariableName(value);
|
||||
|
||||
if (!varName) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Registered config variables first (supports admin panel / DB overrides),
|
||||
// then fall back to process.env for vars not in ConfigVariables
|
||||
// (e.g. when CI replaces the catalog with custom provider entries).
|
||||
try {
|
||||
const resolved = this.twentyConfigService.get(
|
||||
varName as keyof ConfigVariables,
|
||||
) as string | undefined;
|
||||
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
} catch {
|
||||
// Not a registered config variable — fall through to env
|
||||
}
|
||||
|
||||
return process.env[varName] || undefined;
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
createAmazonBedrock,
|
||||
type AmazonBedrockProvider,
|
||||
} from '@ai-sdk/amazon-bedrock';
|
||||
import { createAnthropic, type AnthropicProvider } from '@ai-sdk/anthropic';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { createMistral } from '@ai-sdk/mistral';
|
||||
import { createOpenAI, type OpenAIProvider } from '@ai-sdk/openai';
|
||||
import { createXai } from '@ai-sdk/xai';
|
||||
import { type LanguageModel } from 'ai';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
|
||||
import {
|
||||
AI_SDK_ANTHROPIC,
|
||||
AI_SDK_BEDROCK,
|
||||
AI_SDK_GOOGLE,
|
||||
AI_SDK_MISTRAL,
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_OPENAI_COMPATIBLE,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
|
||||
|
||||
export type AiSdkProviderInstance = {
|
||||
createModel: (modelId: string) => LanguageModel;
|
||||
rawProvider: unknown;
|
||||
sdkPackage: AiSdkPackage;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SdkProviderFactoryService {
|
||||
private readonly providerInstances = new Map<string, AiSdkProviderInstance>();
|
||||
|
||||
createProvider(
|
||||
providerName: string,
|
||||
config: AiProviderConfig,
|
||||
): AiSdkProviderInstance {
|
||||
const cached = this.providerInstances.get(providerName);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const instance = this.buildProviderInstance(config);
|
||||
|
||||
this.providerInstances.set(providerName, instance);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
getRawProvider<T>(
|
||||
providerName: string,
|
||||
...allowedPackages: string[]
|
||||
): T | undefined {
|
||||
const instance = this.providerInstances.get(providerName);
|
||||
|
||||
if (!instance || !allowedPackages.includes(instance.sdkPackage)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return instance.rawProvider as T;
|
||||
}
|
||||
|
||||
getRawBedrockProvider(
|
||||
providerName: string,
|
||||
): AmazonBedrockProvider | undefined {
|
||||
return this.getRawProvider<AmazonBedrockProvider>(
|
||||
providerName,
|
||||
AI_SDK_BEDROCK,
|
||||
);
|
||||
}
|
||||
|
||||
getRawAnthropicProvider(providerName: string): AnthropicProvider | undefined {
|
||||
return this.getRawProvider<AnthropicProvider>(
|
||||
providerName,
|
||||
AI_SDK_ANTHROPIC,
|
||||
);
|
||||
}
|
||||
|
||||
getRawOpenAIProvider(providerName: string): OpenAIProvider | undefined {
|
||||
return this.getRawProvider<OpenAIProvider>(
|
||||
providerName,
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_OPENAI_COMPATIBLE,
|
||||
);
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.providerInstances.clear();
|
||||
}
|
||||
|
||||
private buildProviderInstance(
|
||||
config: AiProviderConfig,
|
||||
): AiSdkProviderInstance {
|
||||
switch (config.npm) {
|
||||
case AI_SDK_OPENAI:
|
||||
return this.buildStandardProvider(config, createOpenAI);
|
||||
case AI_SDK_ANTHROPIC:
|
||||
return this.buildStandardProvider(config, createAnthropic);
|
||||
case AI_SDK_GOOGLE:
|
||||
return this.buildStandardProvider(config, createGoogleGenerativeAI);
|
||||
case AI_SDK_MISTRAL:
|
||||
return this.buildStandardProvider(config, createMistral);
|
||||
case AI_SDK_XAI:
|
||||
return this.buildStandardProvider(config, createXai);
|
||||
case AI_SDK_BEDROCK:
|
||||
return this.buildBedrockProvider(config);
|
||||
case AI_SDK_OPENAI_COMPATIBLE:
|
||||
return this.buildOpenAICompatibleProvider(config);
|
||||
default:
|
||||
throw new Error(`Unsupported SDK package: ${config.npm}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildStandardProvider(
|
||||
config: AiProviderConfig,
|
||||
factory: (opts: { apiKey?: string; baseURL?: string }) => CallableFunction,
|
||||
): AiSdkProviderInstance {
|
||||
const provider = factory({
|
||||
...(config.apiKey && { apiKey: config.apiKey }),
|
||||
...(config.baseUrl && { baseURL: config.baseUrl }),
|
||||
});
|
||||
|
||||
return {
|
||||
createModel: (modelId: string) =>
|
||||
(provider as CallableFunction)(modelId) as LanguageModel,
|
||||
rawProvider: provider,
|
||||
sdkPackage: config.npm,
|
||||
};
|
||||
}
|
||||
|
||||
private buildBedrockProvider(
|
||||
config: AiProviderConfig,
|
||||
): AiSdkProviderInstance {
|
||||
const provider = createAmazonBedrock({
|
||||
region: config.region ?? 'us-east-1',
|
||||
...(config.accessKeyId &&
|
||||
config.secretAccessKey && {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
sessionToken: config.sessionToken,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
createModel: (modelId: string) => provider(modelId),
|
||||
rawProvider: provider,
|
||||
sdkPackage: AI_SDK_BEDROCK,
|
||||
};
|
||||
}
|
||||
|
||||
private buildOpenAICompatibleProvider(
|
||||
config: AiProviderConfig,
|
||||
): AiSdkProviderInstance {
|
||||
if (!config.baseUrl) {
|
||||
throw new Error('baseUrl is required for openai-compatible providers');
|
||||
}
|
||||
|
||||
const provider = createOpenAI({
|
||||
baseURL: config.baseUrl,
|
||||
apiKey: config.apiKey ?? '',
|
||||
});
|
||||
|
||||
return {
|
||||
createModel: (modelId: string) => provider(modelId),
|
||||
rawProvider: provider,
|
||||
sdkPackage: AI_SDK_OPENAI_COMPATIBLE,
|
||||
};
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
|
||||
import { type LongContextCost } from 'src/engine/metadata-modules/ai/ai-models/types/long-context-cost.type';
|
||||
import { type ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
|
||||
// TODO: rename to AiModelConfig for consistency with service naming (AiModelRegistryService, etc.)
|
||||
export type AIModelConfig = {
|
||||
// Composite model id (`provider/modelName`) used in the registry and GraphQL; same shape as SDK routing when applicable.
|
||||
modelId: string;
|
||||
sdkPackage: AiSdkPackage;
|
||||
label: string;
|
||||
description: string;
|
||||
modelFamily?: ModelFamily;
|
||||
dataResidency?: DataResidency;
|
||||
inputCostPerMillionTokens: number;
|
||||
outputCostPerMillionTokens: number;
|
||||
contextWindowTokens: number;
|
||||
maxOutputTokens: number;
|
||||
cachedInputCostPerMillionTokens?: number;
|
||||
cacheCreationCostPerMillionTokens?: number;
|
||||
longContextCost?: LongContextCost;
|
||||
modalities?: string[];
|
||||
supportsReasoning?: boolean;
|
||||
isDeprecated?: boolean;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// Preference lists use composite model ids (`provider/modelName`), aligned with the registry.
|
||||
export type AiModelPreferences = {
|
||||
disabledModels?: string[];
|
||||
recommendedModels?: string[];
|
||||
defaultFastModels?: string[];
|
||||
defaultSmartModels?: string[];
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export enum AiModelRole {
|
||||
FAST = 'fast',
|
||||
SMART = 'smart',
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
|
||||
|
||||
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
|
||||
|
||||
export type AiProviderConfig = {
|
||||
npm: AiSdkPackage;
|
||||
// Optional provider display/catalog name (e.g. models.dev label). Not a model name; per-model names live on `models[].name`.
|
||||
name?: string;
|
||||
label?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
region?: string;
|
||||
dataResidency?: DataResidency;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
sessionToken?: string;
|
||||
models?: AiProviderModelConfig[];
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type LongContextCost } from 'src/engine/metadata-modules/ai/ai-models/types/long-context-cost.type';
|
||||
import { type ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
|
||||
export type AiModelSource = 'catalog' | 'manual';
|
||||
|
||||
export type AiProviderModelConfig = {
|
||||
// Bare model name passed to the AI SDK (e.g. `gpt-4o`, `claude-3-opus`), not the composite `provider/modelName` id.
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
modelFamily?: ModelFamily;
|
||||
inputCostPerMillionTokens?: number;
|
||||
outputCostPerMillionTokens?: number;
|
||||
cachedInputCostPerMillionTokens?: number;
|
||||
cacheCreationCostPerMillionTokens?: number;
|
||||
longContextCost?: LongContextCost;
|
||||
contextWindowTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
modalities?: string[];
|
||||
supportsReasoning?: boolean;
|
||||
isDeprecated?: boolean;
|
||||
source?: AiModelSource;
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
|
||||
|
||||
export type AiProvidersConfig = Record<string, AiProviderConfig>;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_MAX_OUTPUT_TOKENS = 4_096;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_SMART_MODEL = 'default-smart-model' as const;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type LongContextCost = {
|
||||
inputCostPerMillionTokens: number;
|
||||
outputCostPerMillionTokens: number;
|
||||
cachedInputCostPerMillionTokens?: number;
|
||||
cacheCreationCostPerMillionTokens?: number;
|
||||
thresholdTokens: number;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export enum ModelFamily {
|
||||
GPT = 'gpt',
|
||||
CLAUDE = 'claude',
|
||||
GEMINI = 'gemini',
|
||||
MISTRAL = 'mistral',
|
||||
GROK = 'grok',
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Composite workspace model identifier, usually `provider/modelName` (see `buildCompositeModelId`).
|
||||
export type ModelId = string;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { type ModelsDevProvider } from './models-dev-provider.type';
|
||||
|
||||
export type ModelsDevData = Record<string, ModelsDevProvider>;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
export type ModelsDevModel = {
|
||||
// Model id as returned by models.dev (may match the record key in `ModelsDevProvider.models`).
|
||||
id: string;
|
||||
// Human-readable model name from the catalog.
|
||||
name: string;
|
||||
family?: string;
|
||||
status?: 'deprecated' | 'beta';
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
cost?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
};
|
||||
limit?: { context?: number; output?: number };
|
||||
modalities?: { input?: string[]; output?: string[] };
|
||||
knowledge?: string;
|
||||
release?: string;
|
||||
updated?: string;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type ModelsDevModel } from './models-dev-model.type';
|
||||
|
||||
export type ModelsDevProvider = {
|
||||
// Provider id from models.dev (e.g. `openai`).
|
||||
id: string;
|
||||
// Keys are model identifiers in that provider’s catalog (bare ids), not composite `provider/modelName` workspace ids.
|
||||
models: Record<string, ModelsDevModel>;
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
const COMPOSITE_SEPARATOR = '/';
|
||||
|
||||
export const buildCompositeModelId = (
|
||||
providerName: string,
|
||||
modelName: string,
|
||||
): string => {
|
||||
if (providerName === modelName.split(COMPOSITE_SEPARATOR)[0]) {
|
||||
return modelName;
|
||||
}
|
||||
|
||||
return `${providerName}${COMPOSITE_SEPARATOR}${modelName}`;
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
const CONFIG_VAR_TEMPLATE_REGEX = /^\{\{(\w+)\}\}$/;
|
||||
|
||||
export const extractConfigVariableName = (
|
||||
value: string | undefined,
|
||||
): string | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = CONFIG_VAR_TEMPLATE_REGEX.exec(value);
|
||||
|
||||
return match?.[1];
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
|
||||
// Maps models.dev provider names to model families.
|
||||
const NAME_TO_MODEL_FAMILY: Record<string, ModelFamily> = {
|
||||
openai: ModelFamily.GPT,
|
||||
anthropic: ModelFamily.CLAUDE,
|
||||
google: ModelFamily.GEMINI,
|
||||
mistral: ModelFamily.MISTRAL,
|
||||
xai: ModelFamily.GROK,
|
||||
};
|
||||
|
||||
// For aggregator providers (Groq, Bedrock, etc.), detect model family
|
||||
// from the model's raw ID rather than assuming a fixed mapping.
|
||||
const MODEL_ID_FAMILY_PATTERNS: [RegExp, ModelFamily][] = [
|
||||
[/claude/i, ModelFamily.CLAUDE],
|
||||
[/gpt|o[134]-|chatgpt/i, ModelFamily.GPT],
|
||||
[/gemini/i, ModelFamily.GEMINI],
|
||||
[/mistral|mixtral|pixtral/i, ModelFamily.MISTRAL],
|
||||
[/grok/i, ModelFamily.GROK],
|
||||
];
|
||||
|
||||
export const inferModelFamily = (
|
||||
providerName: string,
|
||||
modelName?: string,
|
||||
): ModelFamily | undefined => {
|
||||
if (modelName) {
|
||||
for (const [pattern, family] of MODEL_ID_FAMILY_PATTERNS) {
|
||||
if (pattern.test(modelName)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NAME_TO_MODEL_FAMILY[providerName];
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { DEFAULT_FAST_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-fast-model.const';
|
||||
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
|
||||
|
||||
export const isDefaultModelSentinel = (modelId: string): boolean =>
|
||||
modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL;
|
||||
+5
-18
@@ -1,35 +1,22 @@
|
||||
import {
|
||||
AI_MODELS,
|
||||
DEFAULT_FAST_MODEL,
|
||||
DEFAULT_SMART_MODEL,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { isDefaultModelSentinel } from 'src/engine/metadata-modules/ai/ai-models/utils/is-default-model-sentinel.util';
|
||||
|
||||
export type WorkspaceModelAvailabilitySettings = {
|
||||
useRecommendedModels: boolean;
|
||||
autoEnableNewAiModels: boolean;
|
||||
disabledAiModelIds: string[];
|
||||
enabledAiModelIds: string[];
|
||||
};
|
||||
|
||||
const RECOMMENDED_MODEL_IDS = new Set(
|
||||
AI_MODELS.filter((model) => model.isRecommended).map(
|
||||
(model) => model.modelId,
|
||||
),
|
||||
);
|
||||
|
||||
export const isModelAllowedByWorkspace = (
|
||||
modelId: string,
|
||||
workspace: WorkspaceModelAvailabilitySettings,
|
||||
recommendedModelIds?: Set<string>,
|
||||
): boolean => {
|
||||
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
|
||||
if (isDefaultModelSentinel(modelId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (workspace.useRecommendedModels) {
|
||||
return RECOMMENDED_MODEL_IDS.has(modelId);
|
||||
return recommendedModelIds?.has(modelId) ?? false;
|
||||
}
|
||||
|
||||
return workspace.autoEnableNewAiModels
|
||||
? !workspace.disabledAiModelIds.includes(modelId)
|
||||
: workspace.enabledAiModelIds.includes(modelId);
|
||||
return workspace.enabledAiModelIds.includes(modelId);
|
||||
};
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
|
||||
import defaultAiProviders from '../ai-providers.json';
|
||||
|
||||
export const loadDefaultAiProviders = (): AiProvidersConfig => {
|
||||
const raw = defaultAiProviders as unknown as AiProvidersConfig;
|
||||
const result: AiProvidersConfig = {};
|
||||
|
||||
for (const [key, config] of Object.entries(raw)) {
|
||||
result[key] = {
|
||||
...config,
|
||||
name: key,
|
||||
models: (config.models ?? []).map(
|
||||
(model): AiProviderModelConfig => ({ ...model, source: 'catalog' }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// TODO: derive default model preferences dynamically from the catalog
|
||||
// instead of hardcoding model IDs that become stale as models evolve
|
||||
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
|
||||
|
||||
const DEFAULT_FAST_MODELS = [
|
||||
'openai/gpt-5-mini',
|
||||
'anthropic/claude-haiku-4-5-20251001',
|
||||
'google/gemini-3-flash-preview',
|
||||
'xai/grok-4-1-fast',
|
||||
'mistral/mistral-large-latest',
|
||||
];
|
||||
|
||||
const DEFAULT_SMART_MODELS = [
|
||||
'openai/gpt-5.2',
|
||||
'anthropic/claude-sonnet-4-6',
|
||||
'google/gemini-3.1-pro-preview',
|
||||
'xai/grok-4',
|
||||
'mistral/mistral-large-latest',
|
||||
];
|
||||
|
||||
const DEFAULT_RECOMMENDED_MODELS = [
|
||||
'openai/gpt-5.2',
|
||||
'openai/gpt-4.1',
|
||||
'anthropic/claude-opus-4-6',
|
||||
'anthropic/claude-sonnet-4-6',
|
||||
'google/gemini-3.1-pro-preview',
|
||||
'xai/grok-4',
|
||||
];
|
||||
|
||||
export const loadDefaultModelPreferences = (): AiModelPreferences => {
|
||||
return {
|
||||
disabledModels: [],
|
||||
recommendedModels: DEFAULT_RECOMMENDED_MODELS,
|
||||
defaultFastModels: DEFAULT_FAST_MODELS,
|
||||
defaultSmartModels: DEFAULT_SMART_MODELS,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user