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:
Félix Malfait
2026-03-21 16:03:58 +01:00
committed by GitHub
parent 50a0bef0e4
commit 908aefe7c1
181 changed files with 7580 additions and 2748 deletions
+63
View File
@@ -0,0 +1,63 @@
name: AI Catalog Sync
on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
pull-requests: write
jobs:
sync-catalog:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Build twenty-server
run: npx nx build twenty-server
- name: Run catalog sync
run: npx nx run twenty-server:command-no-deps ai:sync-models-dev
- name: Check for changes
id: changes
run: |
if git diff --quiet packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-providers.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync AI model catalog from models.dev'
title: 'chore: sync AI model catalog from models.dev'
body: |
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based on the latest data.
New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same model family.
**Please review before merging** — verify no critical models were incorrectly deprecated.
branch: chore/ai-catalog-sync
base: main
labels: ai, automated
delete-branch: true
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@ import {
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledContainer = styled.div`
position: relative;
@@ -77,19 +78,6 @@ const StyledSectionTitle = styled.span`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
const formatTokenCount = (count: number): string => {
if (count >= 1_000_000_000) {
return `${(count / 1_000_000_000).toFixed(1)}B`;
}
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return count.toString();
};
const formatCredits = (credits: number): string => {
if (Number.isInteger(credits)) {
return credits.toLocaleString();
@@ -164,8 +152,15 @@ export const AIChatContextUsageButton = () => {
{formattedPercentage}%
</StyledContextWindowValue>
<StyledContextWindowValue>
{formatTokenCount(agentChatUsage.conversationSize)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}{' '}
{formatNumber(agentChatUsage.conversationSize, {
abbreviate: true,
decimals: 1,
})}{' '}
/{' '}
{formatNumber(agentChatUsage.contextWindowTokens, {
abbreviate: true,
decimals: 1,
})}{' '}
{t`tokens`}
</StyledContextWindowValue>
</StyledRow>
@@ -193,11 +188,17 @@ export const AIChatContextUsageButton = () => {
<StyledSectionTitle>{t`Last message`}</StyledSectionTitle>
<SettingsBillingLabelValueItem
label={t`Input tokens`}
value={`${formatTokenCount(lastMessage.inputTokens)}${getCachedLabel(lastMessage)}`}
value={`${formatNumber(lastMessage.inputTokens, {
abbreviate: true,
decimals: 1,
})}${getCachedLabel(lastMessage)}`}
/>
<SettingsBillingLabelValueItem
label={t`Output tokens`}
value={formatTokenCount(lastMessage.outputTokens)}
value={formatNumber(lastMessage.outputTokens, {
abbreviate: true,
decimals: 1,
})}
/>
<SettingsBillingLabelValueItem
label={t`Cost`}
@@ -215,11 +216,17 @@ export const AIChatContextUsageButton = () => {
<StyledSectionTitle>{t`Conversation`}</StyledSectionTitle>
<SettingsBillingLabelValueItem
label={t`Input tokens`}
value={formatTokenCount(agentChatUsage.inputTokens)}
value={formatNumber(agentChatUsage.inputTokens, {
abbreviate: true,
decimals: 1,
})}
/>
<SettingsBillingLabelValueItem
label={t`Output tokens`}
value={formatTokenCount(agentChatUsage.outputTokens)}
value={formatNumber(agentChatUsage.outputTokens, {
abbreviate: true,
decimals: 1,
})}
/>
<SettingsBillingLabelValueItem
label={t`Total cost`}
@@ -5,19 +5,14 @@ import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { getModelProviderLabel } from '~/pages/settings/ai/utils/getModelProviderLabel';
export const useAiModelOptions = (
includeDeprecated = false,
): SelectOption<string>[] => {
export const useAiModelOptions = (): SelectOption<string>[] => {
const aiModels = useAtomStateValue(aiModelsState);
const { isModelEnabled } = useWorkspaceAiModelAvailability();
return aiModels
.filter(
(model) =>
(includeDeprecated || !model.deprecated) &&
isModelEnabled(model.modelId, model),
(model) => !model.isDeprecated && isModelEnabled(model.modelId, model),
)
.map((model) => ({
value: model.modelId,
@@ -25,7 +20,9 @@ export const useAiModelOptions = (
model.modelId === DEFAULT_FAST_MODEL ||
model.modelId === DEFAULT_SMART_MODEL
? model.label
: `${model.label} (${getModelProviderLabel(model.modelFamily) || model.inferenceProvider})`,
: model.modelFamilyLabel
? `${model.label} (${model.modelFamilyLabel})`
: model.label,
}))
.sort((a, b) => a.label.localeCompare(b.label));
};
@@ -54,5 +51,7 @@ export const useAiModelLabel = (
return model.label;
}
return `${model.label} (${getModelProviderLabel(model.modelFamily) || model.inferenceProvider})`;
return model.modelFamilyLabel
? `${model.label} (${model.modelFamilyLabel})`
: model.label;
};
@@ -17,8 +17,6 @@ export const useWorkspaceAiModelAvailability = () => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const useRecommendedModels = currentWorkspace?.useRecommendedModels ?? true;
const autoEnableNewAiModels = currentWorkspace?.autoEnableNewAiModels ?? true;
const disabledAiModelIds = currentWorkspace?.disabledAiModelIds ?? [];
const enabledAiModelIds = currentWorkspace?.enabledAiModelIds ?? [];
const isModelEnabled = (
@@ -33,13 +31,11 @@ export const useWorkspaceAiModelAvailability = () => {
return model?.isRecommended === true;
}
return autoEnableNewAiModels
? !disabledAiModelIds.includes(modelId)
: enabledAiModelIds.includes(modelId);
return enabledAiModelIds.includes(modelId);
};
const realModels = aiModels.filter(
(model) => !isVirtualModel(model.modelId) && !model.deprecated,
(model) => !isVirtualModel(model.modelId) && !model.isDeprecated,
);
const enabledModels = realModels.filter((model) =>
@@ -57,8 +53,6 @@ export const useWorkspaceAiModelAvailability = () => {
realModels,
allModelsWithAvailability,
useRecommendedModels,
autoEnableNewAiModels,
disabledAiModelIds,
enabledAiModelIds,
};
};
@@ -1,14 +1,18 @@
import { type AgentChatMessageUIToolCallPart } from '@/ai/types/AgentChatMessageUIToolCallPart';
import { type UIMessagePart } from 'ai';
import { type UIDataTypes, type UIMessagePart, type UITools } from 'ai';
const isNavigateAppToolCallPart = (
part: UIMessagePart<UIDataTypes, UITools>,
): boolean =>
part.type === 'tool-execute_tool' &&
typeof part.input === 'object' &&
part.input !== null &&
'toolName' in part.input &&
(part.input as { toolName?: string }).toolName === 'navigate_app';
export const extractUIToolCallParts = (
messageParts: UIMessagePart<any, any>[],
): AgentChatMessageUIToolCallPart[] => {
const uiToolCallParts = messageParts.filter(
(probablePart) =>
probablePart.type === 'tool-execute_tool' &&
probablePart.input?.toolName === 'navigate_app',
messageParts: UIMessagePart<UIDataTypes, UITools>[],
): AgentChatMessageUIToolCallPart[] =>
messageParts.filter(
isNavigateAppToolCallPart,
) as unknown as AgentChatMessageUIToolCallPart[];
return uiToolCallParts;
};
@@ -1,9 +1,15 @@
import { type ExtendedUIMessage } from 'twenty-shared/ai';
type ToolCallInput = { toolName?: string };
const isToolCallInput = (value: unknown): value is ToolCallInput =>
typeof value === 'object' && value !== null && 'toolName' in value;
export const isUIToolCallMessage = (message: ExtendedUIMessage) => {
return message.parts.some(
(part) =>
part.type === 'tool-execute_tool' &&
(part.input as any)?.toolName === 'navigate_app',
isToolCallInput(part.input) &&
part.input.toolName === 'navigate_app',
);
};
@@ -74,8 +74,6 @@ const mockWorkspace = {
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
routerModel: 'auto',
autoEnableNewAiModels: true,
disabledAiModelIds: [],
enabledAiModelIds: [],
useRecommendedModels: true,
workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK,
@@ -380,6 +380,30 @@ const SettingsAdminConfigVariableDetails = lazy(() =>
})),
);
const SettingsAdminNewAiProvider = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminNewAiProvider').then(
(module) => ({
default: module.SettingsAdminNewAiProvider,
}),
),
);
const SettingsAdminAiProviderDetail = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminAiProviderDetail').then(
(module) => ({
default: module.SettingsAdminAiProviderDetail,
}),
),
);
const SettingsAdminNewAiModel = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminNewAiModel').then(
(module) => ({
default: module.SettingsAdminNewAiModel,
}),
),
);
const SettingsUpdates = lazy(() =>
import('~/pages/settings/updates/SettingsUpdates').then((module) => ({
default: module.SettingsUpdates,
@@ -700,6 +724,18 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AdminPanelConfigVariableDetails}
element={<SettingsAdminConfigVariableDetails />}
/>
<Route
path={SettingsPath.AdminPanelNewAiProvider}
element={<SettingsAdminNewAiProvider />}
/>
<Route
path={SettingsPath.AdminPanelNewAiModel}
element={<SettingsAdminNewAiModel />}
/>
<Route
path={SettingsPath.AdminPanelAiProviderDetail}
element={<SettingsAdminAiProviderDetail />}
/>
</>
)}
@@ -40,8 +40,6 @@ export type CurrentWorkspace = Pick<
| 'smartModel'
| 'aiAdditionalInstructions'
| 'editableProfileFields'
| 'autoEnableNewAiModels'
| 'disabledAiModelIds'
| 'enabledAiModelIds'
| 'useRecommendedModels'
> & {
@@ -70,8 +70,6 @@ describe('useColumnDefinitionsFromObjectMetadata', () => {
eventLogRetentionDays: 365 * 3,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
autoEnableNewAiModels: true,
disabledAiModelIds: [],
enabledAiModelIds: [],
useRecommendedModels: true,
});
@@ -0,0 +1,45 @@
import { styled } from '@linaria/react';
import { type IconComponentProps } from 'twenty-ui/display';
type ModelsDevProviderLogoProps = {
logoUrl: string;
} & Pick<IconComponentProps, 'className' | 'size' | 'style'>;
const resolvePixelSize = (
size: IconComponentProps['size'] | undefined,
): number => {
if (typeof size === 'number') {
return size;
}
if (typeof size === 'string') {
const parsed = parseInt(size, 10);
return Number.isNaN(parsed) ? 16 : parsed;
}
return 16;
};
const StyledLogo = styled.img`
object-fit: contain;
`;
export const ModelsDevProviderLogo = ({
logoUrl,
size,
className,
style,
}: ModelsDevProviderLogoProps) => {
const pixelSize = resolvePixelSize(size);
return (
<StyledLogo
alt=""
className={className}
height={pixelSize}
src={logoUrl}
style={style}
width={pixelSize}
/>
);
};
@@ -1,206 +1,223 @@
import { useState } from 'react';
import { useMemo } from 'react';
import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { H2Title, IconBolt, IconLock, IconRobot } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { Tag } from 'twenty-ui/components';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { billingState } from '@/client-config/states/billingState';
import { AI_PROVIDER_SOURCE } from '@/settings/admin-panel/ai/constants/AiProviderSource';
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { Select } from '@/ui/input/components/Select';
import { SettingsAdminAiModelsTable } from '@/settings/admin-panel/ai/components/SettingsAdminAiModelsTable';
import { SettingsAdminAiProviderListCard } from '@/settings/admin-panel/ai/components/SettingsAdminAiProviderListCard';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { GET_AI_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getAiProviders';
import { type GetAiProvidersResult } from '@/settings/admin-panel/ai/types/GetAiProvidersResult';
import { parseProviderItems } from '@/settings/admin-panel/ai/utils/parseProviderItems';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { t } from '@lingui/core/macro';
import { H2Title, IconArchive, IconPlug, IconRobot } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { useMutation, useQuery } from '@apollo/client/react';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SET_ADMIN_AI_MODEL_RECOMMENDED } from '@/settings/admin-panel/ai/graphql/mutations/setAdminAiModelRecommended';
import { SET_ADMIN_DEFAULT_AI_MODEL } from '@/settings/admin-panel/ai/graphql/mutations/setAdminDefaultAiModel';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import {
CreateDatabaseConfigVariableDocument,
GetAdminAiModelsDocument,
SetAdminAiModelEnabledDocument,
AiModelRole,
type AdminAiModelConfig,
} from '~/generated-metadata/graphql';
import { getModelIcon } from '~/pages/settings/ai/utils/getModelIcon';
import { getModelProviderLabel } from '~/pages/settings/ai/utils/getModelProviderLabel';
export const SettingsAdminAI = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const [searchQuery, setSearchQuery] = useState('');
const [showUnconfigured, setShowUnconfigured] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const { refetch: refetchClientConfig } = useClientConfig();
const { data } = useQuery(GetAdminAiModelsDocument);
const [createConfigVariable] = useMutation(
CreateDatabaseConfigVariableDocument,
);
const [setModelEnabled] = useMutation(SetAdminAiModelEnabledDocument);
const { data, loading: isLoadingModels } = useQuery<{
getAdminAiModels: {
defaultSmartModelId?: string | null;
defaultFastModelId?: string | null;
models: AdminAiModelConfig[];
};
}>(GET_ADMIN_AI_MODELS);
const autoEnableNewModels =
data?.getAdminAiModels?.autoEnableNewModels ?? true;
const [setModelRecommended] = useMutation(SET_ADMIN_AI_MODEL_RECOMMENDED);
const [setDefaultModel] = useMutation(SET_ADMIN_DEFAULT_AI_MODEL);
const { data: providersData, loading: isLoadingProviders } =
useQuery<GetAiProvidersResult>(GET_AI_PROVIDERS, {
skip: isBillingEnabled,
});
const models = data?.getAdminAiModels?.models ?? [];
const handleAutoEnableToggle = async (checked: boolean) => {
try {
await createConfigVariable({
variables: {
key: 'AI_AUTO_ENABLE_NEW_MODELS',
value: checked,
},
refetchQueries: [{ query: GET_ADMIN_AI_MODELS }],
});
const providerItems = useMemo(
() => parseProviderItems(providersData?.getAiProviders ?? {}),
[providersData],
);
await refetchClientConfig();
} catch {
enqueueErrorSnackBar({
message: t`Failed to update auto-enable setting`,
});
}
};
const catalogProviders = useMemo(
() =>
providerItems
.filter((provider) => provider.source === AI_PROVIDER_SOURCE.CATALOG)
.sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)),
[providerItems],
);
const handleModelToggle = async (
const customProviders = providerItems.filter(
(provider) => provider.source === AI_PROVIDER_SOURCE.CUSTOM,
);
if (isLoadingProviders || isLoadingModels) {
return <SettingsAdminTabSkeletonLoader />;
}
const handleRecommendedToggle = async (
modelId: string,
isCurrentlyEnabled: boolean,
isCurrentlyRecommended: boolean,
) => {
try {
await setModelEnabled({
variables: {
modelId,
enabled: !isCurrentlyEnabled,
},
await setModelRecommended({
variables: { modelId, recommended: !isCurrentlyRecommended },
refetchQueries: [{ query: GET_ADMIN_AI_MODELS }],
});
await refetchClientConfig();
} catch {
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
message: t`Failed to update model recommendation`,
});
}
};
let filteredModels = models;
const defaultSmartModelId = data?.getAdminAiModels?.defaultSmartModelId;
const defaultFastModelId = data?.getAdminAiModels?.defaultFastModelId;
if (!showUnconfigured) {
filteredModels = filteredModels.filter((model) => model.isAvailable);
}
const enabledModels = models.filter(
(model) => model.isAvailable && model.isAdminEnabled && !model.isDeprecated,
);
if (!showDeprecated) {
filteredModels = filteredModels.filter((model) => !model.deprecated);
}
const availableModelOptions = enabledModels.map((model) => ({
value: model.modelId,
label: model.label,
Icon: getModelIcon(model.modelFamily, model.providerName),
}));
if (searchQuery.trim().length > 0) {
const query = searchQuery.toLowerCase();
filteredModels = filteredModels.filter(
(model) =>
model.label.toLowerCase().includes(query) ||
(model.modelFamily?.toLowerCase().includes(query) ?? false) ||
model.inferenceProvider.toLowerCase().includes(query),
);
}
const getModelDescription = (
modelFamily: string | null | undefined,
isAvailable: boolean,
isDeprecated: boolean | null | undefined,
const handleDefaultModelChange = async (
role: AiModelRole,
modelId: string,
) => {
const providerLabel = getModelProviderLabel(modelFamily);
if (isDeprecated === true) {
return providerLabel ? t`${providerLabel} — Deprecated` : t`Deprecated`;
try {
await setDefaultModel({
variables: { role, modelId },
refetchQueries: [{ query: GET_ADMIN_AI_MODELS }],
});
await refetchClientConfig();
} catch {
enqueueErrorSnackBar({
message: t`Failed to update default model`,
});
}
if (!isAvailable) {
return providerLabel
? t`${providerLabel} — API key not configured`
: t`API key not configured`;
}
return providerLabel;
};
return (
<>
<Section>
<H2Title
title={t`Admin Model Controls`}
description={t`Server-wide AI model availability settings`}
/>
{!isBillingEnabled && (
<>
<Section>
<H2Title
title={t`Providers`}
description={t`Built-in providers activated by API key. Click to manage models.`}
/>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconRobot}
title={t`Automatically enable new models`}
description={t`When enabled, newly added models are available to all workspaces by default`}
checked={autoEnableNewModels}
onChange={handleAutoEnableToggle}
<SettingsAdminAiProviderListCard
providers={catalogProviders}
showAddButton={false}
/>
</Section>
<Section>
<H2Title
title={t`Custom Providers`}
description={t`Add custom endpoints, private gateways, or additional regions.`}
adornment={
<Tag
text={t`Enterprise`}
color="transparent"
Icon={IconLock}
variant="border"
/>
}
/>
<SettingsAdminAiProviderListCard
providers={customProviders}
showAddButton
/>
</Section>
</>
)}
{availableModelOptions.length > 0 && (
<Section>
<H2Title
title={t`Default Models`}
description={t`Configure the default AI models for all workspaces`}
/>
</Card>
</Section>
<Section>
<H2Title
title={t`All Models`}
description={t`Toggle model availability across all workspaces`}
/>
<Card rounded>
<SettingsOptionCardContentSelect
Icon={IconRobot}
title={t`Smart Model`}
description={t`Default model for chats and complex reasoning`}
>
<Select
dropdownId="admin-smart-model-select"
value={defaultSmartModelId ?? undefined}
onChange={(value: string) =>
handleDefaultModelChange(AiModelRole.SMART, value)
}
options={availableModelOptions}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
</SettingsOptionCardContentSelect>
<SettingsOptionCardContentSelect
Icon={IconBolt}
title={t`Fast Model`}
description={t`Default model for lightweight tasks`}
>
<Select
dropdownId="admin-fast-model-select"
value={defaultFastModelId ?? undefined}
onChange={(value: string) =>
handleDefaultModelChange(AiModelRole.FAST, value)
}
options={availableModelOptions}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
</SettingsOptionCardContentSelect>
</Card>
</Section>
)}
<SearchInput
placeholder={t`Search a model...`}
value={searchQuery}
onChange={setSearchQuery}
filterDropdown={(filterButton) => (
<Dropdown
dropdownId="admin-ai-models-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconPlug}
onToggleChange={() =>
setShowUnconfigured(!showUnconfigured)
}
toggled={showUnconfigured}
text={t`Unconfigured models`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeprecated(!showDeprecated)}
toggled={showDeprecated}
text={t`Deprecated models`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
/>
{enabledModels.length > 0 && (
<Section>
<H2Title
title={t`Recommended Models`}
description={t`Select which models appear as recommended in the workspace model picker`}
/>
<Card rounded>
{filteredModels.map((model, index) => (
<SettingsOptionCardContentToggle
key={model.modelId}
Icon={getModelIcon(model.modelFamily)}
title={model.label}
description={getModelDescription(
model.modelFamily,
model.isAvailable,
model.deprecated,
)}
checked={model.isAdminEnabled}
onChange={() =>
handleModelToggle(model.modelId, model.isAdminEnabled)
}
disabled={!model.isAvailable || model.deprecated === true}
divider={index < filteredModels.length - 1}
/>
))}
</Card>
</Section>
<SettingsAdminAiModelsTable
models={enabledModels}
onToggle={handleRecommendedToggle}
checkedField="isRecommended"
anchorPrefix="recommended-model-row"
/>
</Section>
)}
</>
);
};
@@ -0,0 +1,149 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import {
IconBolt,
IconCoins,
IconFileText,
IconFlag,
IconServer,
IconTag,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { getDataResidencyDisplay } from '@/settings/admin-panel/ai/utils/getDataResidencyDisplay';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { type ModelFamily } from '~/generated-metadata/graphql';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledNameValue = styled.span`
align-items: center;
display: flex;
gap: 4px;
`;
const StyledHoverCardWrapper = styled.div`
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
overflow: hidden;
`;
type SettingsAdminAiModelHoverCardProps = {
label: string;
modelFamily?: ModelFamily | null;
providerName?: string | null;
providerLabel: string;
contextWindowTokens?: number | null;
maxOutputTokens?: number | null;
inputCostPerMillionTokens?: number | null;
outputCostPerMillionTokens?: number | null;
dataResidency?: string | null;
};
const formatCost = (
inputCost?: number | null,
outputCost?: number | null,
): string => {
if (inputCost == null && outputCost == null) {
return '—';
}
const parts: string[] = [];
if (inputCost != null) {
parts.push(`$${inputCost} in`);
}
if (outputCost != null) {
parts.push(`$${outputCost} out`);
}
return parts.join(' / ');
};
export const SettingsAdminAiModelHoverCard = ({
label,
modelFamily,
providerName,
providerLabel,
contextWindowTokens,
maxOutputTokens,
inputCostPerMillionTokens,
outputCostPerMillionTokens,
dataResidency,
}: SettingsAdminAiModelHoverCardProps) => {
const ModelIcon = getModelIcon(modelFamily, providerName);
const items = [
{
Icon: IconTag,
label: t`Name`,
value: (
<StyledNameValue>
<ModelIcon size={14} />
{label}
</StyledNameValue>
),
},
{
Icon: IconServer,
label: t`Provider`,
value: providerLabel || '—',
},
...(inputCostPerMillionTokens != null || outputCostPerMillionTokens != null
? [
{
Icon: IconCoins,
label: t`Cost / 1M`,
value: formatCost(
inputCostPerMillionTokens,
outputCostPerMillionTokens,
),
},
]
: []),
...(contextWindowTokens != null
? [
{
Icon: IconFileText,
label: t`Context`,
value: `${formatNumber(contextWindowTokens, {
abbreviate: true,
decimals: 1,
})} tokens`,
},
]
: []),
...(maxOutputTokens != null
? [
{
Icon: IconBolt,
label: t`Max output`,
value: `${formatNumber(maxOutputTokens, {
abbreviate: true,
decimals: 1,
})} tokens`,
},
]
: []),
...(dataResidency
? [
{
Icon: IconFlag,
label: t`Data residency`,
value: getDataResidencyDisplay(dataResidency),
},
]
: []),
];
return (
<StyledHoverCardWrapper>
<SettingsAdminTableCard
rounded
items={items}
gridAutoColumns="120px 1fr"
/>
</StyledHoverCardWrapper>
);
};
@@ -0,0 +1,245 @@
import { useContext, useState } from 'react';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { Trans } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { AppTooltip, IconTrash, TooltipDelay } from 'twenty-ui/display';
import { Checkbox, IconButton } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAdminAiModelHoverCard } from '@/settings/admin-panel/ai/components/SettingsAdminAiModelHoverCard';
import { type AdminAiModelConfig } from '~/generated-metadata/graphql';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
const getProviderDisplayLabel = (
model: Pick<AdminAiModelConfig, 'providerLabel' | 'providerName'>,
): string => model.providerLabel ?? model.providerName ?? '';
const formatCost = (
model: Pick<
AdminAiModelConfig,
'inputCostPerMillionTokens' | 'outputCostPerMillionTokens'
>,
): string => {
const input = model.inputCostPerMillionTokens;
const output = model.outputCostPerMillionTokens;
if (!isDefined(input) && !isDefined(output)) {
return '—';
}
const formatValue = (value: number | null | undefined) =>
isDefined(value) ? `$${value}` : '—';
return `${formatValue(input)} / ${formatValue(output)}`;
};
type SecondaryColumn = 'provider' | 'cost';
const GRID_TEMPLATE_COLUMNS: Record<SecondaryColumn, string> = {
provider: '1fr 120px 40px',
cost: '1fr 140px 40px',
};
const GRID_TEMPLATE_COLUMNS_WITH_REMOVE: Record<SecondaryColumn, string> = {
provider: '1fr 120px 40px 32px',
cost: '1fr 140px 40px 32px',
};
const StyledModelNameCell = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
overflow: hidden;
`;
const StyledModelLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledDeprecatedSuffix = styled.span`
color: ${themeCssVariables.font.color.light};
`;
const hoverCardTooltipClass = css`
background-color: transparent !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 0 !important;
`;
const sanitizeIdForSelector = (id: string): string =>
id.replace(/[^a-zA-Z0-9-_]/g, '_');
type SettingsAdminAiModelsTableProps = {
models: AdminAiModelConfig[];
onToggle: (modelId: string, currentValue: boolean) => void;
checkedField: 'isAdminEnabled' | 'isRecommended';
anchorPrefix: string;
showDisabledState?: boolean;
onRemove?: (model: AdminAiModelConfig) => void;
secondaryColumn?: SecondaryColumn;
};
export const SettingsAdminAiModelsTable = ({
models,
onToggle,
checkedField,
anchorPrefix,
showDisabledState = false,
onRemove,
secondaryColumn = 'provider',
}: SettingsAdminAiModelsTableProps) => {
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const { theme } = useContext(ThemeContext);
const hoveredModel = models.find((model) => model.modelId === hoveredModelId);
const hasRemove = isDefined(onRemove);
const gridColumns = hasRemove
? GRID_TEMPLATE_COLUMNS_WITH_REMOVE[secondaryColumn]
: GRID_TEMPLATE_COLUMNS[secondaryColumn];
return (
<>
<Table>
<TableRow gridTemplateColumns={gridColumns}>
<TableHeader>
<Trans>Name</Trans>
</TableHeader>
<TableHeader align="right">
{secondaryColumn === 'provider' ? (
<Trans>Provider</Trans>
) : (
<Trans>Cost / 1M tokens</Trans>
)}
</TableHeader>
<TableHeader />
{hasRemove && <TableHeader />}
</TableRow>
<TableBody>
{models.map((model) => {
const ModelIcon = getModelIcon(
model.modelFamily,
model.providerName,
);
const displayLabel = getProviderDisplayLabel(model);
const safeId = sanitizeIdForSelector(model.modelId);
const isChecked = model[checkedField] === true;
const isDisabled =
showDisabledState &&
(!model.isAvailable || model.isDeprecated === true);
return (
<div
key={model.modelId}
id={`${anchorPrefix}-${safeId}`}
onMouseEnter={() => setHoveredModelId(model.modelId)}
onMouseLeave={() => setHoveredModelId(null)}
>
<TableRow
gridTemplateColumns={gridColumns}
onClick={
isDisabled
? undefined
: () => onToggle(model.modelId, isChecked)
}
>
<TableCell
color={
isDisabled
? themeCssVariables.font.color.light
: themeCssVariables.font.color.primary
}
>
<StyledModelNameCell>
<ModelIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={
isDisabled
? theme.font.color.light
: theme.font.color.secondary
}
/>
<StyledModelLabel>{model.label}</StyledModelLabel>
{showDisabledState && model.isDeprecated && (
<StyledDeprecatedSuffix>
· Deprecated
</StyledDeprecatedSuffix>
)}
</StyledModelNameCell>
</TableCell>
<TableCell
align="right"
color={themeCssVariables.font.color.tertiary}
>
{secondaryColumn === 'provider'
? displayLabel
: formatCost(model)}
</TableCell>
<TableCell
align="right"
onClick={(event) => event.stopPropagation()}
>
<Checkbox
checked={isChecked}
disabled={isDisabled}
onChange={() => onToggle(model.modelId, isChecked)}
/>
</TableCell>
{hasRemove && (
<TableCell align="right">
<IconButton
Icon={IconTrash}
accent="danger"
variant="tertiary"
size="small"
onClick={(event) => {
event.stopPropagation();
onRemove(model);
}}
/>
</TableCell>
)}
</TableRow>
</div>
);
})}
</TableBody>
</Table>
{hoveredModel && (
<AppTooltip
anchorSelect={`#${anchorPrefix}-${sanitizeIdForSelector(hoveredModel.modelId)}`}
place="left"
noArrow
offset={8}
delay={TooltipDelay.noDelay}
className={hoverCardTooltipClass}
width="320px"
>
<SettingsAdminAiModelHoverCard
label={hoveredModel.label}
modelFamily={hoveredModel.modelFamily}
providerName={hoveredModel.providerName}
providerLabel={getProviderDisplayLabel(hoveredModel)}
contextWindowTokens={hoveredModel.contextWindowTokens}
maxOutputTokens={hoveredModel.maxOutputTokens}
inputCostPerMillionTokens={hoveredModel.inputCostPerMillionTokens}
outputCostPerMillionTokens={hoveredModel.outputCostPerMillionTokens}
dataResidency={hoveredModel.dataResidency}
/>
</AppTooltip>
)}
</>
);
};
@@ -0,0 +1,91 @@
import { Link, useNavigate } from 'react-router-dom';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconPlug, Status } from 'twenty-ui/display';
import { type AiProviderItem } from '@/settings/admin-panel/ai/types/AiProviderItem';
import { getProviderIcon } from '@/settings/admin-panel/ai/utils/getProviderIcon';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
const StyledLinkContainer = styled.div`
> a {
text-decoration: none;
}
`;
type SettingsAdminAiProviderListCardProps = {
providers: AiProviderItem[];
showAddButton?: boolean;
};
const getProviderDescription = (provider: AiProviderItem): string => {
const parts: string[] = [];
if (provider.region) {
parts.push(provider.region);
}
if (provider.baseUrl) {
parts.push(provider.baseUrl);
}
if (provider.apiKey) {
parts.push(t`API key configured`);
} else if (provider.hasAccessKey) {
parts.push(t`IAM credentials`);
}
return parts.join(' · ');
};
export const SettingsAdminAiProviderListCard = ({
providers,
showAddButton = true,
}: SettingsAdminAiProviderListCardProps) => {
const navigate = useNavigate();
if (providers.length === 0 && showAddButton) {
return (
<StyledLinkContainer>
<Link to={getSettingsPath(SettingsPath.AdminPanelNewAiProvider)}>
<SettingsCard title={t`Add Custom Provider`} Icon={<IconPlug />} />
</Link>
</StyledLinkContainer>
);
}
if (providers.length === 0) {
return null;
}
return (
<SettingsListCard
items={providers}
rounded
RowIconFn={(provider) => getProviderIcon(provider.name ?? provider.id)}
getItemLabel={(provider) => provider.label ?? provider.id}
getItemDescription={getProviderDescription}
RowRightComponent={({ item: provider }) =>
provider.apiKey || provider.hasAccessKey ? (
<Status color="green" text={t`Configured`} weight="medium" />
) : (
<Status color="orange" text={t`No credentials`} weight="medium" />
)
}
to={(provider) =>
getSettingsPath(SettingsPath.AdminPanelAiProviderDetail, {
providerName: provider.id,
})
}
hasFooter={showAddButton}
footerButtonLabel={t`Add Custom Provider`}
onFooterButtonClick={() =>
navigate(getSettingsPath(SettingsPath.AdminPanelNewAiProvider))
}
/>
);
};
@@ -0,0 +1,9 @@
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
export const AI_ADMIN_PATH = getSettingsPath(
SettingsPath.AdminPanel,
undefined,
undefined,
'ai',
);
@@ -0,0 +1,4 @@
export const AI_PROVIDER_SOURCE = {
CATALOG: 'catalog',
CUSTOM: 'custom',
} as const;
@@ -0,0 +1,17 @@
import { type DataResidency } from 'twenty-shared/ai';
export const DATA_RESIDENCY_CONFIG: Record<
DataResidency,
{ label: string; flag: string }
> = {
us: { label: 'United States', flag: '🇺🇸' },
eu: { label: 'European Union', flag: '🇪🇺' },
global: { label: 'Global', flag: '🌐' },
uk: { label: 'United Kingdom', flag: '🇬🇧' },
ap: { label: 'Asia Pacific', flag: '🌏' },
jp: { label: 'Japan', flag: '🇯🇵' },
au: { label: 'Australia', flag: '🇦🇺' },
ca: { label: 'Canada', flag: '🇨🇦' },
de: { label: 'Germany', flag: '🇩🇪' },
fr: { label: 'France', flag: '🇫🇷' },
};
@@ -0,0 +1,10 @@
import { type DataResidency } from 'twenty-shared/ai';
import { DATA_RESIDENCY_CONFIG } from '@/settings/admin-panel/ai/constants/DataResidencyConfig';
export const DATA_RESIDENCY_OPTIONS = (
Object.keys(DATA_RESIDENCY_CONFIG) as DataResidency[]
).map((key) => ({
value: key,
label: `${DATA_RESIDENCY_CONFIG[key].flag} ${DATA_RESIDENCY_CONFIG[key].label}`,
}));
@@ -0,0 +1,22 @@
import {
IconBrandGemini,
IconBrandMistral,
IconBrandXai,
IconModelClaude,
IconProviderOpenai,
IconRobot,
type IconComponent,
} from 'twenty-ui/display';
import { ModelFamily } from '~/generated-metadata/graphql';
export type ModelIconConfigKey = ModelFamily | 'FALLBACK';
export const MODEL_ICON_CONFIG: Record<ModelIconConfigKey, IconComponent> = {
[ModelFamily.GPT]: IconProviderOpenai,
[ModelFamily.CLAUDE]: IconModelClaude,
[ModelFamily.GEMINI]: IconBrandGemini,
[ModelFamily.MISTRAL]: IconBrandMistral,
[ModelFamily.GROK]: IconBrandXai,
FALLBACK: IconRobot,
};
@@ -0,0 +1,19 @@
import {
IconBrandAnthropic,
IconBrandMistral,
IconBrandXai,
IconGoogle,
IconProviderOpenai,
IconRobot,
type IconComponent,
} from 'twenty-ui/display';
export const PROVIDER_ICON_CONFIG: Record<string, { Icon: IconComponent }> = {
openai: { Icon: IconProviderOpenai },
anthropic: { Icon: IconBrandAnthropic },
bedrock: { Icon: IconRobot },
google: { Icon: IconGoogle },
mistral: { Icon: IconBrandMistral },
xai: { Icon: IconBrandXai },
'openai-compatible': { Icon: IconProviderOpenai },
};
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const ADD_AI_PROVIDER = gql`
mutation AddAiProvider($providerName: String!, $providerConfig: JSON!) {
addAiProvider(providerName: $providerName, providerConfig: $providerConfig)
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const ADD_MODEL_TO_PROVIDER = gql`
mutation AddModelToProvider($providerName: String!, $modelConfig: JSON!) {
addModelToProvider(providerName: $providerName, modelConfig: $modelConfig)
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const REMOVE_AI_PROVIDER = gql`
mutation RemoveAiProvider($providerName: String!) {
removeAiProvider(providerName: $providerName)
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const REMOVE_MODEL_FROM_PROVIDER = gql`
mutation RemoveModelFromProvider(
$providerName: String!
$modelName: String!
) {
removeModelFromProvider(providerName: $providerName, modelName: $modelName)
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const SET_ADMIN_AI_MODEL_RECOMMENDED = gql`
mutation SetAdminAiModelRecommended(
$modelId: String!
$recommended: Boolean!
) {
setAdminAiModelRecommended(modelId: $modelId, recommended: $recommended)
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const SET_ADMIN_DEFAULT_AI_MODEL = gql`
mutation SetAdminDefaultAiModel($role: AiModelRole!, $modelId: String!) {
setAdminDefaultAiModel(role: $role, modelId: $modelId)
}
`;
@@ -3,16 +3,25 @@ import { gql } from '@apollo/client';
export const GET_ADMIN_AI_MODELS = gql`
query GetAdminAiModels {
getAdminAiModels {
autoEnableNewModels
defaultSmartModelId
defaultFastModelId
models {
modelId
label
modelFamily
inferenceProvider
sdkPackage
isAvailable
isAdminEnabled
deprecated
isDeprecated
isRecommended
contextWindowTokens
maxOutputTokens
inputCostPerMillionTokens
outputCostPerMillionTokens
providerName
providerLabel
name
dataResidency
}
}
}
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const GET_AI_PROVIDERS = gql`
query GetAiProviders {
getAiProviders
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_MODELS_DEV_PROVIDERS = gql`
query GetModelsDevProviders {
getModelsDevProviders {
id
modelCount
npm
}
}
`;
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const GET_MODELS_DEV_SUGGESTIONS = gql`
query GetModelsDevSuggestions($providerType: String!) {
getModelsDevSuggestions(providerType: $providerType) {
modelId
name
inputCostPerMillionTokens
outputCostPerMillionTokens
cachedInputCostPerMillionTokens
cacheCreationCostPerMillionTokens
contextWindowTokens
maxOutputTokens
modalities
supportsReasoning
}
}
`;
@@ -0,0 +1,23 @@
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
import { type AiProviderSource } from '@/settings/admin-panel/ai/types/AiProviderSource';
// AiProviderItem = RawAiProviderConfig (from the backend's Record<string,
// RawAiProviderConfig>) enriched with the `id` key (same as the Record key).
// Fields are defined here; RawAiProviderConfig is Omit<AiProviderItem, 'id'>.
export type AiProviderItem = {
id: string;
npm: AiSdkPackage;
// Optional provider display/catalog name from config (not a model name; models use `models[].name` on the backend).
name?: string;
label?: string;
source?: AiProviderSource;
baseUrl?: string;
region?: string;
dataResidency?: DataResidency;
apiKey?: string;
apiKeyConfigVariable?: string;
accessKeyId?: string;
secretAccessKey?: string;
hasAccessKey?: boolean;
};
@@ -0,0 +1 @@
export type AiProviderSource = 'catalog' | 'custom';
@@ -0,0 +1,5 @@
import { type RawAiProviderConfig } from '@/settings/admin-panel/ai/types/RawAiProviderConfig';
export type GetAiProvidersResult = {
getAiProviders: Record<string, RawAiProviderConfig>;
};
@@ -0,0 +1,5 @@
import { type AiProviderItem } from '@/settings/admin-panel/ai/types/AiProviderItem';
// Backend stores providers as Record<providerId, RawAiProviderConfig>; the id is the
// record key, not a field on the value. Same shape as AiProviderItem minus `id`.
export type RawAiProviderConfig = Omit<AiProviderItem, 'id'>;
@@ -0,0 +1,13 @@
import { isDataResidency } from 'twenty-shared/ai';
import { DATA_RESIDENCY_CONFIG } from '@/settings/admin-panel/ai/constants/DataResidencyConfig';
export const getDataResidencyDisplay = (residency: string): string => {
if (isDataResidency(residency)) {
const entry = DATA_RESIDENCY_CONFIG[residency];
return `${entry.flag} ${entry.label}`;
}
return `🌐 ${residency.toUpperCase()}`;
};
@@ -0,0 +1,22 @@
import { type IconComponent } from 'twenty-ui/display';
import { MODEL_ICON_CONFIG } from '@/settings/admin-panel/ai/constants/ModelIconConfig';
import { isModelIconKey } from '@/settings/admin-panel/ai/utils/isModelIconKey';
import { getProviderIcon } from '@/settings/admin-panel/ai/utils/getProviderIcon';
import { type ModelFamily } from '~/generated-metadata/graphql';
export const getModelIcon = (
modelFamily: ModelFamily | null | undefined,
providerName?: string | null,
): IconComponent => {
if (modelFamily && isModelIconKey(modelFamily)) {
return MODEL_ICON_CONFIG[modelFamily];
}
if (providerName) {
return getProviderIcon(providerName);
}
return MODEL_ICON_CONFIG.FALLBACK;
};
@@ -0,0 +1,34 @@
import { type IconComponent, type IconComponentProps } from 'twenty-ui/display';
import { ModelsDevProviderLogo } from '@/settings/admin-panel/ai/components/ModelsDevProviderLogo';
const MODELS_DEV_LOGO_BASE = 'https://models.dev/logos';
const logoIconCache = new Map<string, IconComponent>();
type LogoIconProps = IconComponentProps;
export const getModelsDevLogoIcon = (providerId: string): IconComponent => {
const cached = logoIconCache.get(providerId);
if (cached) {
return cached;
}
const logoUrl = `${MODELS_DEV_LOGO_BASE}/${providerId}.svg`;
const LogoIcon = ({ size = 16, className, style }: LogoIconProps) => (
<ModelsDevProviderLogo
className={className}
logoUrl={logoUrl}
size={size}
style={style}
/>
);
LogoIcon.displayName = `ModelsDevLogo(${providerId})`;
logoIconCache.set(providerId, LogoIcon);
return LogoIcon;
};
@@ -0,0 +1,10 @@
import { type IconComponent } from 'twenty-ui/display';
import { PROVIDER_ICON_CONFIG } from '@/settings/admin-panel/ai/constants/ProviderConfig';
import { isKnownProviderId } from '@/settings/admin-panel/ai/utils/isKnownProviderId';
import { getModelsDevLogoIcon } from '@/settings/admin-panel/ai/utils/getModelsDevLogoIcon';
export const getProviderIcon = (providerType: string): IconComponent =>
isKnownProviderId(providerType)
? PROVIDER_ICON_CONFIG[providerType].Icon
: getModelsDevLogoIcon(providerType);
@@ -0,0 +1,6 @@
import { PROVIDER_ICON_CONFIG } from '@/settings/admin-panel/ai/constants/ProviderConfig';
export type KnownProviderId = keyof typeof PROVIDER_ICON_CONFIG;
export const isKnownProviderId = (id: string): id is KnownProviderId =>
id in PROVIDER_ICON_CONFIG;
@@ -0,0 +1,7 @@
import {
MODEL_ICON_CONFIG,
type ModelIconConfigKey,
} from '@/settings/admin-panel/ai/constants/ModelIconConfig';
export const isModelIconKey = (key: string): key is ModelIconConfigKey =>
key in MODEL_ICON_CONFIG;
@@ -0,0 +1,10 @@
import { type AiProviderItem } from '@/settings/admin-panel/ai/types/AiProviderItem';
import { type RawAiProviderConfig } from '@/settings/admin-panel/ai/types/RawAiProviderConfig';
export const parseProviderItems = (
rawProviders: Record<string, RawAiProviderConfig>,
): AiProviderItem[] =>
Object.entries(rawProviders).map(([key, config]) => ({
...config,
id: key,
}));
@@ -5,17 +5,34 @@ import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ConfigVariableValue } from 'twenty-shared/types';
import { CustomError } from 'twenty-shared/utils';
import { CodeEditor } from 'twenty-ui/input';
import { MenuItemMultiSelect } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ConfigVariableType } from '~/generated-metadata/graphql';
import { type ConfigVariableOptions } from '@/settings/admin-panel/config-variables/types/ConfigVariableOptions';
const StyledJsonEditorContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledJsonEditorLabel = styled.span`
color: ${themeCssVariables.font.color.light};
display: block;
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin-bottom: ${themeCssVariables.spacing[1]};
`;
type ConfigVariableDatabaseInputProps = {
label: string;
value: ConfigVariableValue;
onChange: (value: string | number | boolean | string[] | null) => void;
onChange: (value: ConfigVariableValue) => void;
type: ConfigVariableType;
options?: ConfigVariableOptions;
disabled?: boolean;
@@ -190,6 +207,34 @@ export const ConfigVariableDatabaseInput = ({
/>
);
case ConfigVariableType.JSON:
return (
<StyledJsonEditorContainer>
<StyledJsonEditorLabel>{label}</StyledJsonEditorLabel>
<CodeEditor
value={
typeof value === 'string'
? value
: value !== null && value !== undefined
? JSON.stringify(value, null, 2)
: ''
}
language="json"
height="200px"
options={{
readOnly: disabled === true,
}}
onChange={(text) => {
try {
onChange(JSON.parse(text) as Record<string, unknown>);
} catch {
onChange(text as unknown as ConfigVariableValue);
}
}}
/>
</StyledJsonEditorContainer>
);
default:
throw new CustomError(`Unsupported type: ${type}`, 'UNSUPPORTED_TYPE');
}
@@ -11,7 +11,7 @@ import { ConfigVariableDatabaseInput } from './ConfigVariableDatabaseInput';
type ConfigVariableValueInputProps = {
variable: ConfigVariable;
value: ConfigVariableValue;
onChange: (value: string | number | boolean | string[] | null) => void;
onChange: (value: ConfigVariableValue) => void;
disabled?: boolean;
};
@@ -40,7 +40,9 @@ export const SettingsAdminConfigVariablesRow = ({
? variable.value
? 'true'
: 'false'
: variable.value;
: typeof variable.value === 'object' && variable.value !== null
? JSON.stringify(variable.value)
: variable.value;
return (
<StyledTableRowContainer>
@@ -16,6 +16,7 @@ export const useConfigVariableForm = (variable?: ConfigVariable) => {
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string(), z.unknown()),
z.null(),
]),
});
@@ -39,7 +40,10 @@ export const useConfigVariableForm = (variable?: ConfigVariable) => {
((typeof currentValue === 'string' && currentValue.trim() !== '') ||
typeof currentValue === 'boolean' ||
typeof currentValue === 'number' ||
(Array.isArray(currentValue) && currentValue.length > 0))
(Array.isArray(currentValue) && currentValue.length > 0) ||
(typeof currentValue === 'object' &&
currentValue !== null &&
!Array.isArray(currentValue)))
);
return {
@@ -92,8 +92,6 @@ export const USER_QUERY_FRAGMENT = gql`
fastModel
smartModel
aiAdditionalInstructions
autoEnableNewAiModels
disabledAiModelIds
enabledAiModelIds
useRecommendedModels
isTwoFactorAuthenticationEnforced
@@ -0,0 +1,394 @@
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { AI_ADMIN_PATH } from '@/settings/admin-panel/ai/constants/AiAdminPath';
import { AI_PROVIDER_SOURCE } from '@/settings/admin-panel/ai/constants/AiProviderSource';
import {
H2Title,
type IconComponent,
IconFlag,
IconKey,
IconPlug,
IconPlus,
IconServer,
IconTag,
IconTrash,
IconWorld,
} from 'twenty-ui/display';
import { Button, SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { RoundedLink, UndecoratedLink } from 'twenty-ui/navigation';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsAdminAiModelsTable } from '@/settings/admin-panel/ai/components/SettingsAdminAiModelsTable';
import { REMOVE_AI_PROVIDER } from '@/settings/admin-panel/ai/graphql/mutations/removeAiProvider';
import { REMOVE_MODEL_FROM_PROVIDER } from '@/settings/admin-panel/ai/graphql/mutations/removeModelFromProvider';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { GET_AI_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getAiProviders';
import { type GetAiProvidersResult } from '@/settings/admin-panel/ai/types/GetAiProvidersResult';
import { getDataResidencyDisplay } from '@/settings/admin-panel/ai/utils/getDataResidencyDisplay';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import {
type AdminAiModelConfig,
SetAdminAiModelEnabledDocument,
} from '~/generated-metadata/graphql';
const REMOVE_PROVIDER_MODAL_ID = 'settings-ai-provider-remove';
const REMOVE_MODEL_MODAL_ID = 'settings-ai-model-remove';
export const SettingsAdminAiProviderDetail = () => {
const { providerName } = useParams<{ providerName: string }>();
const navigate = useNavigate();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const { refetch: refetchClientConfig } = useClientConfig();
const { openModal } = useModal();
const [searchQuery, setSearchQuery] = useState('');
const [modelToRemove, setModelToRemove] = useState<{
modelId: string;
label: string;
name: string;
} | null>(null);
const { data: providersData, loading: isLoadingProviders } =
useQuery<GetAiProvidersResult>(GET_AI_PROVIDERS);
const { data: modelsData, loading: isLoadingModels } = useQuery<{
getAdminAiModels: {
models: AdminAiModelConfig[];
};
}>(GET_ADMIN_AI_MODELS);
const [setModelEnabled] = useMutation(SetAdminAiModelEnabledDocument);
const [removeAiProvider] = useMutation(REMOVE_AI_PROVIDER);
const [removeModelFromProvider] = useMutation(REMOVE_MODEL_FROM_PROVIDER);
const handleRemoveProvider = async () => {
if (!providerName) {
return;
}
try {
await removeAiProvider({
variables: { providerName },
refetchQueries: [
{ query: GET_AI_PROVIDERS },
{ query: GET_ADMIN_AI_MODELS },
],
});
enqueueSuccessSnackBar({
message: t`Provider "${provider?.label ?? providerName}" removed`,
});
navigate(AI_ADMIN_PATH);
} catch {
enqueueErrorSnackBar({
message: t`Failed to remove provider`,
});
}
};
const handleRemoveModel = async () => {
if (!providerName || !modelToRemove) {
return;
}
try {
await removeModelFromProvider({
variables: {
providerName,
modelName: modelToRemove.name,
},
refetchQueries: [
{ query: GET_AI_PROVIDERS },
{ query: GET_ADMIN_AI_MODELS },
],
});
await refetchClientConfig();
enqueueSuccessSnackBar({
message: t`Model "${modelToRemove.label}" removed`,
});
setModelToRemove(null);
} catch {
enqueueErrorSnackBar({
message: t`Failed to remove model`,
});
}
};
const providerConfig =
providerName && providersData?.getAiProviders
? providersData.getAiProviders[providerName]
: undefined;
const provider = useMemo(
() =>
providerName && isDefined(providerConfig)
? { id: providerName, ...providerConfig }
: undefined,
[providerName, providerConfig],
);
const isCustomProvider = provider?.source === AI_PROVIDER_SOURCE.CUSTOM;
const providerModels = useMemo(() => {
const allModels = modelsData?.getAdminAiModels?.models ?? [];
return allModels.filter((model) => model.providerName === providerName);
}, [modelsData, providerName]);
const filteredModels =
searchQuery.trim().length === 0
? providerModels
: providerModels.filter((model) => {
const query = searchQuery.toLowerCase();
return (
model.label.toLowerCase().includes(query) ||
model.modelId.toLowerCase().includes(query)
);
});
const handleModelToggle = async (
modelId: string,
isCurrentlyEnabled: boolean,
) => {
try {
await setModelEnabled({
variables: { modelId, enabled: !isCurrentlyEnabled },
refetchQueries: [{ query: GET_ADMIN_AI_MODELS }],
});
await refetchClientConfig();
} catch {
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
});
}
};
const handleModelRemoveClick = (model: AdminAiModelConfig) => {
setModelToRemove({
modelId: model.modelId,
label: model.label,
name: model.name ?? model.modelId,
});
openModal(REMOVE_MODEL_MODAL_ID);
};
const providerInfoItems = useMemo(() => {
if (!provider) {
return [];
}
const items: Array<{
Icon: IconComponent;
label: string;
value: string | React.ReactNode;
}> = [
{
Icon: IconTag,
label: t`Name`,
value: provider.label ?? provider.id,
},
{
Icon: IconPlug,
label: t`SDK`,
value: provider.npm,
},
];
if (provider.apiKeyConfigVariable) {
const envVar = provider.apiKeyConfigVariable;
const configPath = getSettingsPath(
SettingsPath.AdminPanelConfigVariableDetails,
{ variableName: envVar },
);
items.push({
Icon: IconKey,
label: t`API Key`,
value: (
<RoundedLink
href={configPath}
label={provider.apiKey ?? t`Configure`}
onClick={(event) => {
event.preventDefault();
navigate(configPath);
}}
/>
),
});
} else if (provider.apiKey) {
items.push({
Icon: IconKey,
label: t`API Key`,
value: provider.apiKey,
});
}
if (provider.baseUrl) {
items.push({
Icon: IconWorld,
label: t`Base URL`,
value: provider.baseUrl,
});
}
if (provider.region) {
items.push({
Icon: IconServer,
label: t`Region`,
value: provider.region,
});
}
if (provider.hasAccessKey) {
items.push({
Icon: IconKey,
label: t`Credentials`,
value: t`IAM credentials configured`,
});
}
if (isCustomProvider && provider.dataResidency) {
items.push({
Icon: IconFlag,
label: t`Data Residency`,
value: getDataResidencyDisplay(provider.dataResidency),
});
}
return items;
}, [provider, navigate, isCustomProvider]);
const newModelPath = providerName
? getSettingsPath(SettingsPath.AdminPanelNewAiModel, { providerName })
: undefined;
if (isLoadingProviders || isLoadingModels) {
return <SettingsSkeletonLoader />;
}
return (
<SubMenuTopBarContainer
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`AI`,
href: AI_ADMIN_PATH,
},
{
children: provider?.label ?? providerName ?? '',
},
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title={provider?.label ?? providerName ?? ''}
description={provider?.npm ?? ''}
/>
{provider && (
<SettingsAdminTableCard
rounded
items={providerInfoItems}
gridAutoColumns="120px 1fr"
/>
)}
</Section>
<Section>
<H2Title
title={t`Models`}
description={
isCustomProvider
? t`Models for this provider. Toggle to enable or disable.`
: t`Built-in models from this provider. Toggle to enable or disable.`
}
/>
{providerModels.length > 3 && (
<SearchInput
placeholder={t`Search a model...`}
value={searchQuery}
onChange={setSearchQuery}
/>
)}
{filteredModels.length > 0 && (
<SettingsAdminAiModelsTable
models={filteredModels}
onToggle={handleModelToggle}
checkedField="isAdminEnabled"
anchorPrefix="provider-model-row"
showDisabledState
secondaryColumn="cost"
onRemove={isCustomProvider ? handleModelRemoveClick : undefined}
/>
)}
{isCustomProvider && newModelPath && (
<UndecoratedLink to={newModelPath}>
<Button
Icon={IconPlus}
title={t`Add Model`}
variant="secondary"
/>
</UndecoratedLink>
)}
</Section>
{isCustomProvider && (
<Section>
<H2Title
title={t`Danger zone`}
description={t`Remove this provider and disconnect all its models`}
/>
<Button
Icon={IconTrash}
title={t`Remove provider`}
variant="secondary"
accent="danger"
onClick={() => openModal(REMOVE_PROVIDER_MODAL_ID)}
/>
</Section>
)}
</SettingsPageContainer>
<ConfirmationModal
modalInstanceId={REMOVE_PROVIDER_MODAL_ID}
title={t`Remove provider "${provider?.label ?? providerName}"`}
subtitle={t`This will disconnect all models from this provider. Models will no longer be available until a new provider is configured.`}
onConfirmClick={handleRemoveProvider}
confirmButtonText={t`Remove`}
confirmButtonAccent="danger"
/>
<ConfirmationModal
modalInstanceId={REMOVE_MODEL_MODAL_ID}
title={t`Remove model "${modelToRemove?.label ?? ''}"`}
subtitle={t`This model will be removed from the provider. You can re-add it later.`}
onConfirmClick={handleRemoveModel}
confirmButtonText={t`Remove`}
confirmButtonAccent="danger"
/>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,536 @@
import { useMemo, useState } from 'react';
import { styled } from '@linaria/react';
import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title, IconPlus } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ADD_MODEL_TO_PROVIDER } from '@/settings/admin-panel/ai/graphql/mutations/addModelToProvider';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { GET_AI_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getAiProviders';
import { GET_MODELS_DEV_SUGGESTIONS } from '@/settings/admin-panel/ai/graphql/queries/getModelsDevSuggestions';
import { type GetAiProvidersResult } from '@/settings/admin-panel/ai/types/GetAiProvidersResult';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Checkbox, Toggle } from 'twenty-ui/input';
const StyledComboInputContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[4]};
`;
const MODALITY_OPTIONS = [
{ value: 'image', label: t`Image` },
{ value: 'pdf', label: t`PDF` },
{ value: 'audio', label: t`Audio` },
{ value: 'video', label: t`Video` },
];
const StyledCheckboxRow = styled.div`
align-items: center;
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[1]} 0;
`;
const StyledModalitiesContainer = styled.div`
display: flex;
flex-direction: column;
`;
type ModelSuggestion = {
modelId: string;
name: string;
inputCostPerMillionTokens: number;
outputCostPerMillionTokens: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
contextWindowTokens: number;
maxOutputTokens: number;
modalities: string[];
supportsReasoning: boolean;
};
type FormValues = {
name: string;
label: string;
inputCostPerMillionTokens: string;
outputCostPerMillionTokens: string;
cachedInputCostPerMillionTokens: string;
cacheCreationCostPerMillionTokens: string;
contextWindowTokens: string;
maxOutputTokens: string;
modalities: string[];
supportsReasoning: boolean;
};
export const SettingsAdminNewAiModel = () => {
const { providerName } = useParams<{ providerName: string }>();
const navigate = useNavigate();
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCustomModelId, setIsCustomModelId] = useState(false);
const { data: providersData } =
useQuery<GetAiProvidersResult>(GET_AI_PROVIDERS);
const provider =
providerName && providersData?.getAiProviders
? providersData.getAiProviders[providerName]
: undefined;
const modelsDevName = provider?.name;
const { data: suggestionsData } = useQuery<{
getModelsDevSuggestions: ModelSuggestion[];
}>(GET_MODELS_DEV_SUGGESTIONS, {
variables: { providerType: modelsDevName ?? '' },
skip: !modelsDevName,
});
const suggestions = useMemo(
() => suggestionsData?.getModelsDevSuggestions ?? [],
[suggestionsData?.getModelsDevSuggestions],
);
const suggestionsByModelId = useMemo(() => {
const map = new Map<string, ModelSuggestion>();
for (const suggestion of suggestions) {
map.set(suggestion.modelId, suggestion);
}
return map;
}, [suggestions]);
const modelIdOptions = suggestions.map((suggestion) => ({
value: suggestion.modelId,
label: `${suggestion.name} (${suggestion.modelId})`,
}));
const [addModelToProvider] = useMutation(ADD_MODEL_TO_PROVIDER);
const form = useForm<FormValues>({
mode: 'onSubmit',
defaultValues: {
name: '',
label: '',
inputCostPerMillionTokens: '',
outputCostPerMillionTokens: '',
cachedInputCostPerMillionTokens: '',
cacheCreationCostPerMillionTokens: '',
contextWindowTokens: '',
maxOutputTokens: '',
modalities: [],
supportsReasoning: false,
},
});
const providerDetailPath = providerName
? getSettingsPath(SettingsPath.AdminPanelAiProviderDetail, {
providerName,
})
: getSettingsPath(SettingsPath.AdminPanel);
const handleModelIdSelected = (modelId: string) => {
form.setValue('name', modelId);
const suggestion = suggestionsByModelId.get(modelId);
if (isDefined(suggestion)) {
form.setValue('label', suggestion.name);
form.setValue(
'inputCostPerMillionTokens',
String(suggestion.inputCostPerMillionTokens),
);
form.setValue(
'outputCostPerMillionTokens',
String(suggestion.outputCostPerMillionTokens),
);
form.setValue(
'cachedInputCostPerMillionTokens',
isDefined(suggestion.cachedInputCostPerMillionTokens)
? String(suggestion.cachedInputCostPerMillionTokens)
: '',
);
form.setValue(
'cacheCreationCostPerMillionTokens',
isDefined(suggestion.cacheCreationCostPerMillionTokens)
? String(suggestion.cacheCreationCostPerMillionTokens)
: '',
);
form.setValue(
'contextWindowTokens',
String(suggestion.contextWindowTokens),
);
form.setValue('maxOutputTokens', String(suggestion.maxOutputTokens));
form.setValue('modalities', suggestion.modalities ?? []);
form.setValue('supportsReasoning', suggestion.supportsReasoning);
}
};
const handleSave = async () => {
if (!providerName) {
return;
}
const values = form.getValues();
if (!values.name.trim()) {
form.setError('name', {
type: 'manual',
message: t`Model ID is required`,
});
return;
}
if (!values.label.trim()) {
form.setError('label', {
type: 'manual',
message: t`Label is required`,
});
return;
}
const cachedInput = parseFloat(
values.cachedInputCostPerMillionTokens || '',
);
const cacheCreation = parseFloat(
values.cacheCreationCostPerMillionTokens || '',
);
const modelConfig = {
name: values.name.trim(),
label: values.label.trim(),
inputCostPerMillionTokens: parseFloat(
values.inputCostPerMillionTokens || '0',
),
outputCostPerMillionTokens: parseFloat(
values.outputCostPerMillionTokens || '0',
),
...(isFinite(cachedInput) && {
cachedInputCostPerMillionTokens: cachedInput,
}),
...(isFinite(cacheCreation) && {
cacheCreationCostPerMillionTokens: cacheCreation,
}),
contextWindowTokens: parseInt(values.contextWindowTokens || '0', 10),
maxOutputTokens: parseInt(values.maxOutputTokens || '0', 10),
...(values.modalities.length > 0 && {
modalities: values.modalities,
}),
supportsReasoning: values.supportsReasoning,
};
setIsSubmitting(true);
try {
await addModelToProvider({
variables: {
providerName,
modelConfig,
},
refetchQueries: [
{ query: GET_AI_PROVIDERS },
{ query: GET_ADMIN_AI_MODELS },
],
});
enqueueSuccessSnackBar({
message: t`Model "${values.label.trim()}" added`,
});
navigate(providerDetailPath);
} catch {
enqueueErrorSnackBar({
message: t`Failed to add model`,
});
} finally {
setIsSubmitting(false);
}
};
const hasSuggestions = modelIdOptions.length > 0;
const showModelSelect = hasSuggestions && !isCustomModelId;
return (
<form onSubmit={form.handleSubmit(handleSave)}>
<SubMenuTopBarContainer
title={t`New Model`}
links={[
{
children: <Trans>Admin Panel</Trans>,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: provider?.label ?? providerName ?? '',
href: providerDetailPath,
},
{ children: <Trans>New Model</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(providerDetailPath)}
isSaveDisabled={isSubmitting}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Model ID`}
description={
showModelSelect
? t`Select a known model or add a custom one`
: t`The model identifier used by the provider API`
}
/>
{showModelSelect ? (
<Controller
name="name"
control={form.control}
render={({ field: { value } }) => (
<Select
dropdownId="ai-model-id-select"
value={value || undefined}
onChange={handleModelIdSelected}
options={modelIdOptions}
withSearchInput
fullWidth
callToActionButton={{
text: t`Custom model ID`,
onClick: () => setIsCustomModelId(true),
Icon: IconPlus,
}}
/>
)}
/>
) : (
<Controller
name="name"
control={form.control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<TextInput
value={value}
onChange={onChange}
placeholder={t`e.g. gpt-4o`}
fullWidth
error={error?.message}
/>
)}
/>
)}
</Section>
<Section>
<H2Title
title={t`Label`}
description={t`Display name for the model`}
/>
<Controller
name="label"
control={form.control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<TextInput
value={value}
onChange={onChange}
placeholder={t`e.g. GPT-4o`}
fullWidth
error={error?.message}
/>
)}
/>
</Section>
<Section>
<H2Title
title={t`Pricing`}
description={t`Cost per million tokens (USD)`}
/>
<StyledComboInputContainer>
<Controller
name="inputCostPerMillionTokens"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextInput
label={t`Input`}
value={value}
onChange={onChange}
placeholder={t`e.g. 2.50`}
fullWidth
/>
)}
/>
<Controller
name="outputCostPerMillionTokens"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextInput
label={t`Output`}
value={value}
onChange={onChange}
placeholder={t`e.g. 10.00`}
fullWidth
/>
)}
/>
</StyledComboInputContainer>
</Section>
<Section>
<H2Title
title={t`Cache pricing`}
description={t`Cost per million tokens for cached input (USD)`}
/>
<StyledComboInputContainer>
<Controller
name="cachedInputCostPerMillionTokens"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextInput
label={t`Cache read`}
value={value}
onChange={onChange}
placeholder={t`e.g. 1.25`}
fullWidth
/>
)}
/>
<Controller
name="cacheCreationCostPerMillionTokens"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextInput
label={t`Cache write`}
value={value}
onChange={onChange}
placeholder={t`e.g. 3.75`}
fullWidth
/>
)}
/>
</StyledComboInputContainer>
</Section>
<Section>
<H2Title
title={t`Limits`}
description={t`Token limits for context and output`}
/>
<StyledComboInputContainer>
<Controller
name="contextWindowTokens"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextInput
label={t`Context window`}
value={value}
onChange={onChange}
placeholder={t`e.g. 128000`}
fullWidth
/>
)}
/>
<Controller
name="maxOutputTokens"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextInput
label={t`Max output`}
value={value}
onChange={onChange}
placeholder={t`e.g. 16384`}
fullWidth
/>
)}
/>
</StyledComboInputContainer>
</Section>
<Section>
<H2Title
title={t`Supported input types`}
description={t`Types of content this model can process besides text`}
/>
<Controller
name="modalities"
control={form.control}
render={({ field: { onChange, value } }) => (
<StyledModalitiesContainer>
{MODALITY_OPTIONS.map((option) => {
const isChecked = value.includes(option.value);
return (
<StyledCheckboxRow
key={option.value}
onClick={() => {
const updated = isChecked
? value.filter(
(modality) => modality !== option.value,
)
: [...value, option.value];
onChange(updated);
}}
>
<Checkbox
checked={isChecked}
onChange={(event) => {
event.stopPropagation();
const updated = event.target.checked
? [...value, option.value]
: value.filter(
(modality) => modality !== option.value,
);
onChange(updated);
}}
/>
<span>{option.label}</span>
</StyledCheckboxRow>
);
})}
</StyledModalitiesContainer>
)}
/>
</Section>
<Section>
<H2Title
title={t`Supports reasoning`}
description={t`Whether this model supports chain-of-thought reasoning`}
/>
<Controller
name="supportsReasoning"
control={form.control}
render={({ field: { onChange, value } }) => (
<Toggle value={value} onChange={onChange} />
)}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
</form>
);
};
@@ -0,0 +1,446 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery } from '@apollo/client/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { type AiSdkPackage, isDataResidency } from 'twenty-shared/ai';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconPlus, Info } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { AI_ADMIN_PATH } from '@/settings/admin-panel/ai/constants/AiAdminPath';
import { DATA_RESIDENCY_OPTIONS } from '@/settings/admin-panel/ai/constants/DataResidencyOptions';
import { ADD_AI_PROVIDER } from '@/settings/admin-panel/ai/graphql/mutations/addAiProvider';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { GET_AI_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getAiProviders';
import { GET_MODELS_DEV_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getModelsDevProviders';
import { type RawAiProviderConfig } from '@/settings/admin-panel/ai/types/RawAiProviderConfig';
import { slugify } from 'transliteration';
import { getProviderIcon } from '@/settings/admin-panel/ai/utils/getProviderIcon';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
type ModelsDevProvider = { id: string; modelCount: number; npm: AiSdkPackage };
type FormValues = {
npm: AiSdkPackage;
label: string;
apiKey: string;
baseUrl: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
dataResidency: string;
};
export const SettingsAdminNewAiProvider = () => {
const navigate = useNavigate();
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedModelsDevId, setSelectedModelsDevId] = useState<string | null>(
null,
);
const [isCustomMode, setIsCustomMode] = useState(false);
const [addAiProvider] = useMutation(ADD_AI_PROVIDER);
const { data: modelsDevData } = useQuery<{
getModelsDevProviders: ModelsDevProvider[];
}>(GET_MODELS_DEV_PROVIDERS);
const modelsDevProviders = useMemo(
() => modelsDevData?.getModelsDevProviders ?? [],
[modelsDevData?.getModelsDevProviders],
);
const modelsDevByIdMap = useMemo(
() =>
new Map(modelsDevProviders.map((provider) => [provider.id, provider])),
[modelsDevProviders],
);
const providerOptions = useMemo(
() =>
modelsDevProviders.map((provider) => ({
value: provider.id,
label: `${provider.id.charAt(0).toUpperCase() + provider.id.slice(1)} (${provider.modelCount} models)`,
Icon: getProviderIcon(provider.id),
})),
[modelsDevProviders],
);
const form = useForm<FormValues>({
mode: 'onSubmit',
defaultValues: {
npm: '@ai-sdk/openai-compatible',
label: '',
apiKey: '',
baseUrl: '',
region: '',
accessKeyId: '',
secretAccessKey: '',
dataResidency: '',
},
});
const hasSelected = selectedModelsDevId !== null || isCustomMode;
const npmPackage = form.watch('npm');
const isBedrock = npmPackage === '@ai-sdk/amazon-bedrock';
const isOpenAICompatible = npmPackage === '@ai-sdk/openai-compatible';
const needsApiKey = !isBedrock;
const isModelsDevWithoutNativeSdk =
selectedModelsDevId !== null && isOpenAICompatible;
const handleProviderSelected = (providerId: string) => {
setSelectedModelsDevId(providerId);
setIsCustomMode(false);
const suggestion = modelsDevByIdMap.get(providerId);
form.setValue('npm', suggestion?.npm ?? '@ai-sdk/openai-compatible');
form.setValue(
'label',
providerId.charAt(0).toUpperCase() + providerId.slice(1),
);
};
const handleCustomMode = () => {
setSelectedModelsDevId(null);
setIsCustomMode(true);
form.setValue('npm', '@ai-sdk/openai-compatible');
form.setValue('label', '');
};
const handleSave = async () => {
const values = form.getValues();
if (!values.label.trim()) {
form.setError('label', {
type: 'manual',
message: t`Label is required`,
});
return;
}
const providerName = slugify(values.label, { separator: '-' });
if (!providerName) {
form.setError('label', {
type: 'manual',
message: t`Label must contain at least one alphanumeric character`,
});
return;
}
const config: Partial<RawAiProviderConfig> = {
npm: values.npm,
label: values.label.trim(),
...(selectedModelsDevId && { name: selectedModelsDevId }),
...(needsApiKey &&
values.apiKey.trim() && {
apiKey: values.apiKey.trim(),
}),
...(isOpenAICompatible &&
values.baseUrl.trim() && {
baseUrl: values.baseUrl.trim(),
}),
...(isDataResidency(values.dataResidency) && {
dataResidency: values.dataResidency,
}),
};
if (isBedrock) {
if (!values.region.trim()) {
form.setError('region', {
type: 'manual',
message: t`Region is required for Bedrock`,
});
return;
}
config.region = values.region.trim();
if (values.accessKeyId.trim()) {
config.accessKeyId = values.accessKeyId.trim();
}
if (values.secretAccessKey.trim()) {
config.secretAccessKey = values.secretAccessKey.trim();
}
}
if (!isBedrock && !isOpenAICompatible && !values.apiKey.trim()) {
form.setError('apiKey', {
type: 'manual',
message: t`API key is required`,
});
return;
}
if (isOpenAICompatible && !values.baseUrl.trim()) {
form.setError('baseUrl', {
type: 'manual',
message: t`Base URL is required`,
});
return;
}
setIsSubmitting(true);
try {
await addAiProvider({
variables: {
providerName,
providerConfig: config,
},
refetchQueries: [
{ query: GET_AI_PROVIDERS },
{ query: GET_ADMIN_AI_MODELS },
],
});
enqueueSuccessSnackBar({
message: t`Provider "${values.label.trim()}" added`,
});
navigate(AI_ADMIN_PATH);
} catch {
enqueueErrorSnackBar({
message: t`Failed to add provider`,
});
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={form.handleSubmit(handleSave)}>
<SubMenuTopBarContainer
title={t`New AI Provider`}
links={[
{
children: <Trans>Admin Panel</Trans>,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{ children: <Trans>New AI Provider</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(AI_ADMIN_PATH)}
isSaveDisabled={isSubmitting || !hasSelected}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Provider`}
description={t`Select a known provider or create a custom one`}
/>
<Select
dropdownId="ai-provider-models-dev-select"
value={selectedModelsDevId ?? undefined}
onChange={handleProviderSelected}
options={providerOptions}
withSearchInput
fullWidth
callToActionButton={{
text: t`Custom provider`,
onClick: handleCustomMode,
Icon: IconPlus,
}}
/>
</Section>
{isModelsDevWithoutNativeSdk && (
<Info
accent="blue"
text={t`This provider doesn't have a native SDK yet — it will use OpenAI-compatible mode. Need native support? Reach out to us.`}
/>
)}
{hasSelected && (
<>
<Section>
<H2Title
title={t`Label`}
description={t`A display name for this provider`}
/>
<Controller
name="label"
control={form.control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<TextInput
value={value}
onChange={onChange}
placeholder={
isCustomMode
? t`e.g. My OpenAI Proxy`
: t`e.g. OpenAI EU`
}
fullWidth
error={error?.message}
/>
)}
/>
</Section>
{needsApiKey && (
<Section>
<H2Title
title={t`API Key`}
description={t`Your provider API key for authentication`}
/>
<Controller
name="apiKey"
control={form.control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<TextInput
value={value}
onChange={onChange}
placeholder={t`sk-...`}
fullWidth
type="password"
error={error?.message}
/>
)}
/>
</Section>
)}
{isOpenAICompatible && (
<Section>
<H2Title
title={t`Base URL`}
description={t`The API endpoint for your OpenAI-compatible provider`}
/>
<Controller
name="baseUrl"
control={form.control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<TextInput
value={value}
onChange={onChange}
placeholder={t`https://api.example.com/v1`}
fullWidth
error={error?.message}
/>
)}
/>
</Section>
)}
<Section>
<H2Title
title={t`Data Residency`}
description={t`Region where inference data is processed (optional)`}
/>
<Controller
name="dataResidency"
control={form.control}
render={({ field: { onChange, value } }) => (
<Select
dropdownId="ai-provider-data-residency-select"
value={value}
onChange={onChange}
options={[
{ value: '', label: t`None` },
...DATA_RESIDENCY_OPTIONS,
]}
fullWidth
/>
)}
/>
</Section>
{isBedrock && (
<>
<Section>
<H2Title
title={t`Region`}
description={t`The AWS region for Bedrock`}
/>
<Controller
name="region"
control={form.control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<TextInput
value={value}
onChange={onChange}
placeholder={t`us-east-1`}
fullWidth
error={error?.message}
/>
)}
/>
</Section>
<Section>
<H2Title
title={t`Access Key ID`}
description={t`Optional — uses IAM role if empty`}
/>
<Controller
name="accessKeyId"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextInput
value={value}
onChange={onChange}
placeholder={t`AKIA...`}
fullWidth
/>
)}
/>
</Section>
<Section>
<H2Title
title={t`Secret Access Key`}
description={t`Optional — uses IAM role if empty`}
/>
<Controller
name="secretAccessKey"
control={form.control}
render={({ field: { onChange, value } }) => (
<TextInput
value={value}
onChange={onChange}
fullWidth
type="password"
/>
)}
/>
</Section>
</>
)}
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
</form>
);
};
@@ -14,6 +14,7 @@ import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { formatNumber } from '~/utils/format/formatNumber';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -145,18 +146,11 @@ export const SettingsAIPrompts = () => {
section.title !== 'User Context',
);
const formatTokenCount = (count: number): string => {
if (count >= 1000) {
const kTokens = (count / 1000).toFixed(1);
return t`~${kTokens}k tokens`;
}
return t`~${count} tokens`;
};
const totalTokenCount = isDefined(preview)
? formatTokenCount(preview.estimatedTokenCount)
? t`~${formatNumber(preview.estimatedTokenCount, {
abbreviate: true,
decimals: 1,
})} tokens`
: '';
const pageTitle = isDefined(preview)
? t`System Prompt (${totalTokenCount})`
@@ -182,7 +176,10 @@ export const SettingsAIPrompts = () => {
description={t`Read-only — managed by Twenty`}
adornment={
<StyledTokenBadge>
{formatTokenCount(section.estimatedTokenCount)}
{formatNumber(section.estimatedTokenCount, {
abbreviate: true,
decimals: 1,
})}
</StyledTokenBadge>
}
/>
@@ -18,7 +18,6 @@ import { t } from '@lingui/core/macro';
import {
H2Title,
IconBolt,
IconRobot,
IconSearch,
IconTwentyStar,
} from 'twenty-ui/display';
@@ -26,8 +25,8 @@ import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
import { getModelIcon } from '~/pages/settings/ai/utils/getModelIcon';
import { getModelProviderLabel } from '~/pages/settings/ai/utils/getModelProviderLabel';
import { getDataResidencyDisplay } from '@/settings/admin-panel/ai/utils/getDataResidencyDisplay';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
const StyledSearchContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
@@ -46,7 +45,6 @@ export const SettingsAIModelsTab = () => {
allModelsWithAvailability,
enabledModels,
useRecommendedModels,
autoEnableNewAiModels,
realModels,
} = useWorkspaceAiModelAvailability();
@@ -70,11 +68,17 @@ export const SettingsAIModelsTab = () => {
const smartAutoOption = buildVirtualModelOption(DEFAULT_SMART_MODEL);
const fastAutoOption = buildVirtualModelOption(DEFAULT_FAST_MODEL);
const modelOptions = enabledModels.map((model) => ({
value: model.modelId,
label: model.label,
Icon: getModelIcon(model.modelFamily),
}));
const modelOptions = enabledModels.map((model) => {
const residencyFlag = model.dataResidency
? ` ${getDataResidencyDisplay(model.dataResidency)}`
: '';
return {
value: model.modelId,
label: `${model.label}${residencyFlag}`,
Icon: getModelIcon(model.modelFamily, model.providerName),
};
});
const smartModelOptions = [...modelOptions];
@@ -167,61 +171,6 @@ export const SettingsAIModelsTab = () => {
}
};
const handleAutoEnableToggle = async (checked: boolean) => {
if (!currentWorkspace?.id) {
return;
}
const previousAutoEnable = currentWorkspace.autoEnableNewAiModels;
const previousDisabled = currentWorkspace.disabledAiModelIds ?? [];
const previousEnabled = currentWorkspace.enabledAiModelIds ?? [];
let newDisabledIds: string[] = [];
let newEnabledIds: string[] = [];
if (checked) {
newDisabledIds = realModels
.filter((model) => !previousEnabled.includes(model.modelId))
.map((model) => model.modelId);
newEnabledIds = [];
} else {
newEnabledIds = realModels
.filter((model) => !previousDisabled.includes(model.modelId))
.map((model) => model.modelId);
newDisabledIds = [];
}
try {
setCurrentWorkspace({
...currentWorkspace,
autoEnableNewAiModels: checked,
disabledAiModelIds: newDisabledIds,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: {
input: {
autoEnableNewAiModels: checked,
disabledAiModelIds: newDisabledIds,
enabledAiModelIds: newEnabledIds,
},
},
});
} catch {
setCurrentWorkspace({
...currentWorkspace,
autoEnableNewAiModels: previousAutoEnable,
disabledAiModelIds: previousDisabled,
enabledAiModelIds: previousEnabled,
});
enqueueErrorSnackBar({
message: t`Failed to update model availability settings`,
});
}
};
const handleModelToggle = async (
modelId: string,
isCurrentlyEnabled: boolean,
@@ -230,37 +179,21 @@ export const SettingsAIModelsTab = () => {
return;
}
const previousDisabled = currentWorkspace.disabledAiModelIds ?? [];
const previousEnabled = currentWorkspace.enabledAiModelIds ?? [];
let newDisabledIds = [...previousDisabled];
let newEnabledIds = [...previousEnabled];
if (autoEnableNewAiModels) {
if (isCurrentlyEnabled) {
newDisabledIds = [...previousDisabled, modelId];
} else {
newDisabledIds = previousDisabled.filter((id) => id !== modelId);
}
} else {
if (isCurrentlyEnabled) {
newEnabledIds = previousEnabled.filter((id) => id !== modelId);
} else {
newEnabledIds = [...previousEnabled, modelId];
}
}
const newEnabledIds = isCurrentlyEnabled
? previousEnabled.filter((id) => id !== modelId)
: [...previousEnabled, modelId];
try {
setCurrentWorkspace({
...currentWorkspace,
disabledAiModelIds: newDisabledIds,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: {
input: {
disabledAiModelIds: newDisabledIds,
enabledAiModelIds: newEnabledIds,
},
},
@@ -268,7 +201,6 @@ export const SettingsAIModelsTab = () => {
} catch {
setCurrentWorkspace({
...currentWorkspace,
disabledAiModelIds: previousDisabled,
enabledAiModelIds: previousEnabled,
});
@@ -285,7 +217,7 @@ export const SettingsAIModelsTab = () => {
return (
model.label.toLowerCase().includes(query) ||
(model.modelFamily?.toLowerCase().includes(query) ?? false) ||
model.inferenceProvider.toLowerCase().includes(query)
(model.sdkPackage?.toLowerCase().includes(query) ?? false)
);
})
: allModelsWithAvailability;
@@ -335,15 +267,6 @@ export const SettingsAIModelsTab = () => {
onChange={handleUseRecommendedToggle}
divider={!useRecommendedModels}
/>
{!useRecommendedModels && (
<SettingsOptionCardContentToggle
Icon={IconRobot}
title={t`Automatically mark new models as available`}
description={t`When enabled, new AI models will be available to users by default`}
checked={autoEnableNewAiModels}
onChange={handleAutoEnableToggle}
/>
)}
</Card>
</Section>
@@ -366,19 +289,29 @@ export const SettingsAIModelsTab = () => {
</StyledSearchContainer>
<Card rounded>
{filteredModels.map((model, index) => (
<SettingsOptionCardContentToggle
key={model.modelId}
Icon={getModelIcon(model.modelFamily)}
title={model.label}
description={getModelProviderLabel(model.modelFamily)}
checked={model.isEnabled}
onChange={() =>
handleModelToggle(model.modelId, model.isEnabled)
}
divider={index < filteredModels.length - 1}
/>
))}
{filteredModels.map((model, index) => {
const familyLabel = model.modelFamilyLabel ?? '';
const residency = model.dataResidency
? getDataResidencyDisplay(model.dataResidency)
: undefined;
const description = residency
? `${familyLabel} · ${residency}`
: familyLabel;
return (
<SettingsOptionCardContentToggle
key={model.modelId}
Icon={getModelIcon(model.modelFamily, model.providerName)}
title={model.label}
description={description}
checked={model.isEnabled}
onChange={() =>
handleModelToggle(model.modelId, model.isEnabled)
}
divider={index < filteredModels.length - 1}
/>
);
})}
</Card>
</Section>
)}
@@ -72,7 +72,7 @@ export const SettingsAgentSettingsTab = ({
const currentModelLabel = useAiModelLabel(formValues.modelId);
const currentModel = aiModels.find((m) => m.modelId === formValues.modelId);
const isCurrentModelDeprecated = currentModel?.deprecated === true;
const isCurrentModelDeprecated = currentModel?.isDeprecated === true;
const modelOptions = isCurrentModelDeprecated
? [
@@ -1,23 +0,0 @@
import {
IconBrandAnthropic,
IconBrandGoogle,
IconBrandMistral,
IconBrandOpenai,
IconBrandXai,
IconRobot,
type IconComponent,
} from 'twenty-ui/display';
// Keyed by the server-side ModelFamily enum values (lowercase) since
// the client config is fetched via REST, not GraphQL.
export const MODEL_FAMILY_CONFIG: Record<
string,
{ label: string; Icon: IconComponent }
> = {
openai: { label: 'OpenAI', Icon: IconBrandOpenai },
anthropic: { label: 'Anthropic', Icon: IconBrandAnthropic },
xai: { label: 'xAI', Icon: IconBrandXai },
google: { label: 'Google', Icon: IconBrandGoogle },
mistral: { label: 'Mistral', Icon: IconBrandMistral },
FALLBACK: { label: '', Icon: IconRobot },
};
@@ -1,15 +0,0 @@
import { type IconComponent } from 'twenty-ui/display';
import { MODEL_FAMILY_CONFIG } from '~/pages/settings/ai/constants/SettingsAiModelProviders';
export const getModelIcon = (
modelFamily: string | null | undefined,
): IconComponent => {
if (!modelFamily) {
return MODEL_FAMILY_CONFIG.FALLBACK.Icon;
}
const key = modelFamily.toLowerCase();
return MODEL_FAMILY_CONFIG[key]?.Icon ?? MODEL_FAMILY_CONFIG.FALLBACK.Icon;
};
@@ -1,13 +0,0 @@
import { MODEL_FAMILY_CONFIG } from '~/pages/settings/ai/constants/SettingsAiModelProviders';
export const getModelProviderLabel = (
modelFamily: string | null | undefined,
): string => {
if (!modelFamily) {
return '';
}
const key = modelFamily.toLowerCase();
return MODEL_FAMILY_CONFIG[key]?.label ?? modelFamily;
};
@@ -89,8 +89,6 @@ export const mockCurrentWorkspace = {
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
routerModel: 'auto',
autoEnableNewAiModels: true,
disabledAiModelIds: [],
enabledAiModelIds: [],
useRecommendedModels: true,
currentBillingSubscription: {
@@ -796,8 +796,6 @@ type Workspace {
fastModel: String!
smartModel: String!
aiAdditionalInstructions: String
autoEnableNewAiModels: Boolean!
disabledAiModelIds: [String!]
enabledAiModelIds: [String!]
useRecommendedModels: Boolean!
routerModel: String!
@@ -2443,48 +2441,49 @@ type ClientAIModelConfig {
modelId: String!
label: String!
modelFamily: ModelFamily
inferenceProvider: InferenceProvider!
modelFamilyLabel: String
sdkPackage: String
inputCostPerMillionTokensInCredits: Float!
outputCostPerMillionTokensInCredits: Float!
nativeCapabilities: NativeModelCapabilities
deprecated: Boolean
isDeprecated: Boolean
isRecommended: Boolean
providerName: String
dataResidency: String
}
enum ModelFamily {
OPENAI
ANTHROPIC
GOOGLE
GPT
CLAUDE
GEMINI
MISTRAL
XAI
}
enum InferenceProvider {
NONE
OPENAI
ANTHROPIC
BEDROCK
GOOGLE
MISTRAL
OPENAI_COMPATIBLE
XAI
GROQ
GROK
}
type AdminAIModelConfig {
modelId: String!
label: String!
modelFamily: ModelFamily
inferenceProvider: InferenceProvider!
modelFamilyLabel: String
sdkPackage: String
isAvailable: Boolean!
isAdminEnabled: Boolean!
deprecated: Boolean
isDeprecated: Boolean
isRecommended: Boolean
contextWindowTokens: Float
maxOutputTokens: Float
inputCostPerMillionTokens: Float
outputCostPerMillionTokens: Float
providerName: String
providerLabel: String
name: String
dataResidency: String
}
type AdminAIModels {
autoEnableNewModels: Boolean!
models: [AdminAIModelConfig!]!
defaultSmartModelId: String
defaultFastModelId: String
}
type Billing {
@@ -2587,6 +2586,7 @@ enum ConfigVariableType {
ARRAY
STRING
ENUM
JSON
}
type ConfigVariablesGroupData {
@@ -2746,6 +2746,25 @@ type AdminPanelHealthServiceData {
queues: [AdminPanelWorkerQueueHealth!]
}
type ModelsDevModelSuggestion {
modelId: String!
name: String!
inputCostPerMillionTokens: Float!
outputCostPerMillionTokens: Float!
cachedInputCostPerMillionTokens: Float
cacheCreationCostPerMillionTokens: Float
contextWindowTokens: Float!
maxOutputTokens: Float!
modalities: [String!]!
supportsReasoning: Boolean!
}
type ModelsDevProviderSuggestion {
id: String!
modelCount: Float!
npm: String!
}
type QueueMetricsDataPoint {
x: Float!
y: Float!
@@ -3202,6 +3221,9 @@ type Query {
getDatabaseConfigVariable(key: String!): ConfigVariable!
getQueueJobs(queueName: String!, state: JobState!, limit: Int = 50, offset: Int = 0): QueueJobsResponse!
findAllApplicationRegistrations: [ApplicationRegistration!]!
getAiProviders: JSON!
getModelsDevProviders: [ModelsDevProviderSuggestion!]!
getModelsDevSuggestions(providerType: String!): [ModelsDevModelSuggestion!]!
getPostgresCredentials: PostgresCredentials
findManyPublicDomains: [PublicDomain!]!
getEmailingDomains: [EmailingDomain!]!
@@ -3488,11 +3510,17 @@ type Mutation {
userLookupAdminPanel(userIdentifier: String!): UserLookup!
updateWorkspaceFeatureFlag(workspaceId: UUID!, featureFlag: String!, value: Boolean!): Boolean!
setAdminAiModelEnabled(modelId: String!, enabled: Boolean!): Boolean!
setAdminAiModelRecommended(modelId: String!, recommended: Boolean!): Boolean!
setAdminDefaultAiModel(role: AiModelRole!, modelId: String!): Boolean!
createDatabaseConfigVariable(key: String!, value: JSON!): Boolean!
updateDatabaseConfigVariable(key: String!, value: JSON!): Boolean!
deleteDatabaseConfigVariable(key: String!): Boolean!
retryJobs(queueName: String!, jobIds: [String!]!): RetryJobsResponse!
deleteJobs(queueName: String!, jobIds: [String!]!): DeleteJobsResponse!
addAiProvider(providerName: String!, providerConfig: JSON!): Boolean!
removeAiProvider(providerName: String!): Boolean!
addModelToProvider(providerName: String!, modelConfig: JSON!): Boolean!
removeModelFromProvider(providerName: String!, modelName: String!): Boolean!
enablePostgresProxy: PostgresCredentials!
disablePostgresProxy: PostgresCredentials!
createPublicDomain(domain: String!): PublicDomain!
@@ -4282,8 +4310,6 @@ input UpdateWorkspaceInput {
smartModel: String
aiAdditionalInstructions: String
editableProfileFields: [String!]
autoEnableNewAiModels: Boolean
disabledAiModelIds: [String!]
enabledAiModelIds: [String!]
useRecommendedModels: Boolean
}
@@ -4467,6 +4493,11 @@ input UpdateLabPublicFeatureFlagInput {
value: Boolean!
}
enum AiModelRole {
FAST
SMART
}
input CreateOneAppTokenInput {
"""The record to create"""
appToken: CreateAppTokenInput!
@@ -582,8 +582,6 @@ export interface Workspace {
fastModel: Scalars['String']
smartModel: Scalars['String']
aiAdditionalInstructions?: Scalars['String']
autoEnableNewAiModels: Scalars['Boolean']
disabledAiModelIds?: Scalars['String'][]
enabledAiModelIds?: Scalars['String'][]
useRecommendedModels: Scalars['Boolean']
routerModel: Scalars['String']
@@ -2039,34 +2037,45 @@ export interface ClientAIModelConfig {
modelId: Scalars['String']
label: Scalars['String']
modelFamily?: ModelFamily
inferenceProvider: InferenceProvider
modelFamilyLabel?: Scalars['String']
sdkPackage?: Scalars['String']
inputCostPerMillionTokensInCredits: Scalars['Float']
outputCostPerMillionTokensInCredits: Scalars['Float']
nativeCapabilities?: NativeModelCapabilities
deprecated?: Scalars['Boolean']
isDeprecated?: Scalars['Boolean']
isRecommended?: Scalars['Boolean']
providerName?: Scalars['String']
dataResidency?: Scalars['String']
__typename: 'ClientAIModelConfig'
}
export type ModelFamily = 'OPENAI' | 'ANTHROPIC' | 'GOOGLE' | 'MISTRAL' | 'XAI'
export type InferenceProvider = 'NONE' | 'OPENAI' | 'ANTHROPIC' | 'BEDROCK' | 'GOOGLE' | 'MISTRAL' | 'OPENAI_COMPATIBLE' | 'XAI' | 'GROQ'
export type ModelFamily = 'GPT' | 'CLAUDE' | 'GEMINI' | 'MISTRAL' | 'GROK'
export interface AdminAIModelConfig {
modelId: Scalars['String']
label: Scalars['String']
modelFamily?: ModelFamily
inferenceProvider: InferenceProvider
modelFamilyLabel?: Scalars['String']
sdkPackage?: Scalars['String']
isAvailable: Scalars['Boolean']
isAdminEnabled: Scalars['Boolean']
deprecated?: Scalars['Boolean']
isDeprecated?: Scalars['Boolean']
isRecommended?: Scalars['Boolean']
contextWindowTokens?: Scalars['Float']
maxOutputTokens?: Scalars['Float']
inputCostPerMillionTokens?: Scalars['Float']
outputCostPerMillionTokens?: Scalars['Float']
providerName?: Scalars['String']
providerLabel?: Scalars['String']
name?: Scalars['String']
dataResidency?: Scalars['String']
__typename: 'AdminAIModelConfig'
}
export interface AdminAIModels {
autoEnableNewModels: Scalars['Boolean']
models: AdminAIModelConfig[]
defaultSmartModelId?: Scalars['String']
defaultFastModelId?: Scalars['String']
__typename: 'AdminAIModels'
}
@@ -2163,7 +2172,7 @@ export interface ConfigVariable {
export type ConfigSource = 'ENVIRONMENT' | 'DATABASE' | 'DEFAULT'
export type ConfigVariableType = 'BOOLEAN' | 'NUMBER' | 'ARRAY' | 'STRING' | 'ENUM'
export type ConfigVariableType = 'BOOLEAN' | 'NUMBER' | 'ARRAY' | 'STRING' | 'ENUM' | 'JSON'
export interface ConfigVariablesGroupData {
variables: ConfigVariable[]
@@ -2302,6 +2311,27 @@ export interface AdminPanelHealthServiceData {
__typename: 'AdminPanelHealthServiceData'
}
export interface ModelsDevModelSuggestion {
modelId: Scalars['String']
name: Scalars['String']
inputCostPerMillionTokens: Scalars['Float']
outputCostPerMillionTokens: Scalars['Float']
cachedInputCostPerMillionTokens?: Scalars['Float']
cacheCreationCostPerMillionTokens?: Scalars['Float']
contextWindowTokens: Scalars['Float']
maxOutputTokens: Scalars['Float']
modalities: Scalars['String'][]
supportsReasoning: Scalars['Boolean']
__typename: 'ModelsDevModelSuggestion'
}
export interface ModelsDevProviderSuggestion {
id: Scalars['String']
modelCount: Scalars['Float']
npm: Scalars['String']
__typename: 'ModelsDevProviderSuggestion'
}
export interface QueueMetricsDataPoint {
x: Scalars['Float']
y: Scalars['Float']
@@ -2748,6 +2778,9 @@ export interface Query {
getDatabaseConfigVariable: ConfigVariable
getQueueJobs: QueueJobsResponse
findAllApplicationRegistrations: ApplicationRegistration[]
getAiProviders: Scalars['JSON']
getModelsDevProviders: ModelsDevProviderSuggestion[]
getModelsDevSuggestions: ModelsDevModelSuggestion[]
getPostgresCredentials?: PostgresCredentials
findManyPublicDomains: PublicDomain[]
getEmailingDomains: EmailingDomain[]
@@ -2942,11 +2975,17 @@ export interface Mutation {
userLookupAdminPanel: UserLookup
updateWorkspaceFeatureFlag: Scalars['Boolean']
setAdminAiModelEnabled: Scalars['Boolean']
setAdminAiModelRecommended: Scalars['Boolean']
setAdminDefaultAiModel: Scalars['Boolean']
createDatabaseConfigVariable: Scalars['Boolean']
updateDatabaseConfigVariable: Scalars['Boolean']
deleteDatabaseConfigVariable: Scalars['Boolean']
retryJobs: RetryJobsResponse
deleteJobs: DeleteJobsResponse
addAiProvider: Scalars['Boolean']
removeAiProvider: Scalars['Boolean']
addModelToProvider: Scalars['Boolean']
removeModelFromProvider: Scalars['Boolean']
enablePostgresProxy: PostgresCredentials
disablePostgresProxy: PostgresCredentials
createPublicDomain: PublicDomain
@@ -2972,6 +3011,8 @@ export interface Mutation {
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type AiModelRole = 'FAST' | 'SMART'
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'AppTarball'
@@ -3584,8 +3625,6 @@ export interface WorkspaceGenqlSelection{
fastModel?: boolean | number
smartModel?: boolean | number
aiAdditionalInstructions?: boolean | number
autoEnableNewAiModels?: boolean | number
disabledAiModelIds?: boolean | number
enabledAiModelIds?: boolean | number
useRecommendedModels?: boolean | number
routerModel?: boolean | number
@@ -5115,12 +5154,15 @@ export interface ClientAIModelConfigGenqlSelection{
modelId?: boolean | number
label?: boolean | number
modelFamily?: boolean | number
inferenceProvider?: boolean | number
modelFamilyLabel?: boolean | number
sdkPackage?: boolean | number
inputCostPerMillionTokensInCredits?: boolean | number
outputCostPerMillionTokensInCredits?: boolean | number
nativeCapabilities?: NativeModelCapabilitiesGenqlSelection
deprecated?: boolean | number
isDeprecated?: boolean | number
isRecommended?: boolean | number
providerName?: boolean | number
dataResidency?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -5129,18 +5171,28 @@ export interface AdminAIModelConfigGenqlSelection{
modelId?: boolean | number
label?: boolean | number
modelFamily?: boolean | number
inferenceProvider?: boolean | number
modelFamilyLabel?: boolean | number
sdkPackage?: boolean | number
isAvailable?: boolean | number
isAdminEnabled?: boolean | number
deprecated?: boolean | number
isDeprecated?: boolean | number
isRecommended?: boolean | number
contextWindowTokens?: boolean | number
maxOutputTokens?: boolean | number
inputCostPerMillionTokens?: boolean | number
outputCostPerMillionTokens?: boolean | number
providerName?: boolean | number
providerLabel?: boolean | number
name?: boolean | number
dataResidency?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AdminAIModelsGenqlSelection{
autoEnableNewModels?: boolean | number
models?: AdminAIModelConfigGenqlSelection
defaultSmartModelId?: boolean | number
defaultFastModelId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -5384,6 +5436,29 @@ export interface AdminPanelHealthServiceDataGenqlSelection{
__scalar?: boolean | number
}
export interface ModelsDevModelSuggestionGenqlSelection{
modelId?: boolean | number
name?: boolean | number
inputCostPerMillionTokens?: boolean | number
outputCostPerMillionTokens?: boolean | number
cachedInputCostPerMillionTokens?: boolean | number
cacheCreationCostPerMillionTokens?: boolean | number
contextWindowTokens?: boolean | number
maxOutputTokens?: boolean | number
modalities?: boolean | number
supportsReasoning?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ModelsDevProviderSuggestionGenqlSelection{
id?: boolean | number
modelCount?: boolean | number
npm?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface QueueMetricsDataPointGenqlSelection{
x?: boolean | number
y?: boolean | number
@@ -5887,6 +5962,9 @@ export interface QueryGenqlSelection{
getDatabaseConfigVariable?: (ConfigVariableGenqlSelection & { __args: {key: Scalars['String']} })
getQueueJobs?: (QueueJobsResponseGenqlSelection & { __args: {queueName: Scalars['String'], state: JobState, limit?: (Scalars['Int'] | null), offset?: (Scalars['Int'] | null)} })
findAllApplicationRegistrations?: ApplicationRegistrationGenqlSelection
getAiProviders?: boolean | number
getModelsDevProviders?: ModelsDevProviderSuggestionGenqlSelection
getModelsDevSuggestions?: (ModelsDevModelSuggestionGenqlSelection & { __args: {providerType: Scalars['String']} })
getPostgresCredentials?: PostgresCredentialsGenqlSelection
findManyPublicDomains?: PublicDomainGenqlSelection
getEmailingDomains?: EmailingDomainGenqlSelection
@@ -6100,11 +6178,17 @@ export interface MutationGenqlSelection{
userLookupAdminPanel?: (UserLookupGenqlSelection & { __args: {userIdentifier: Scalars['String']} })
updateWorkspaceFeatureFlag?: { __args: {workspaceId: Scalars['UUID'], featureFlag: Scalars['String'], value: Scalars['Boolean']} }
setAdminAiModelEnabled?: { __args: {modelId: Scalars['String'], enabled: Scalars['Boolean']} }
setAdminAiModelRecommended?: { __args: {modelId: Scalars['String'], recommended: Scalars['Boolean']} }
setAdminDefaultAiModel?: { __args: {role: AiModelRole, modelId: Scalars['String']} }
createDatabaseConfigVariable?: { __args: {key: Scalars['String'], value: Scalars['JSON']} }
updateDatabaseConfigVariable?: { __args: {key: Scalars['String'], value: Scalars['JSON']} }
deleteDatabaseConfigVariable?: { __args: {key: Scalars['String']} }
retryJobs?: (RetryJobsResponseGenqlSelection & { __args: {queueName: Scalars['String'], jobIds: Scalars['String'][]} })
deleteJobs?: (DeleteJobsResponseGenqlSelection & { __args: {queueName: Scalars['String'], jobIds: Scalars['String'][]} })
addAiProvider?: { __args: {providerName: Scalars['String'], providerConfig: Scalars['JSON']} }
removeAiProvider?: { __args: {providerName: Scalars['String']} }
addModelToProvider?: { __args: {providerName: Scalars['String'], modelConfig: Scalars['JSON']} }
removeModelFromProvider?: { __args: {providerName: Scalars['String'], modelName: Scalars['String']} }
enablePostgresProxy?: PostgresCredentialsGenqlSelection
disablePostgresProxy?: PostgresCredentialsGenqlSelection
createPublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String']} })
@@ -6381,7 +6465,7 @@ export interface UpdateViewFilterGroupInput {id?: (Scalars['UUID'] | null),paren
export interface ActivateWorkspaceInput {displayName?: (Scalars['String'] | null)}
export interface UpdateWorkspaceInput {subdomain?: (Scalars['String'] | null),customDomain?: (Scalars['String'] | null),displayName?: (Scalars['String'] | null),logo?: (Scalars['String'] | null),inviteHash?: (Scalars['String'] | null),isPublicInviteLinkEnabled?: (Scalars['Boolean'] | null),allowImpersonation?: (Scalars['Boolean'] | null),isGoogleAuthEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthEnabled?: (Scalars['Boolean'] | null),isPasswordAuthEnabled?: (Scalars['Boolean'] | null),isGoogleAuthBypassEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthBypassEnabled?: (Scalars['Boolean'] | null),isPasswordAuthBypassEnabled?: (Scalars['Boolean'] | null),defaultRoleId?: (Scalars['UUID'] | null),isTwoFactorAuthenticationEnforced?: (Scalars['Boolean'] | null),trashRetentionDays?: (Scalars['Float'] | null),eventLogRetentionDays?: (Scalars['Float'] | null),fastModel?: (Scalars['String'] | null),smartModel?: (Scalars['String'] | null),aiAdditionalInstructions?: (Scalars['String'] | null),editableProfileFields?: (Scalars['String'][] | null),autoEnableNewAiModels?: (Scalars['Boolean'] | null),disabledAiModelIds?: (Scalars['String'][] | null),enabledAiModelIds?: (Scalars['String'][] | null),useRecommendedModels?: (Scalars['Boolean'] | null)}
export interface UpdateWorkspaceInput {subdomain?: (Scalars['String'] | null),customDomain?: (Scalars['String'] | null),displayName?: (Scalars['String'] | null),logo?: (Scalars['String'] | null),inviteHash?: (Scalars['String'] | null),isPublicInviteLinkEnabled?: (Scalars['Boolean'] | null),allowImpersonation?: (Scalars['Boolean'] | null),isGoogleAuthEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthEnabled?: (Scalars['Boolean'] | null),isPasswordAuthEnabled?: (Scalars['Boolean'] | null),isGoogleAuthBypassEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthBypassEnabled?: (Scalars['Boolean'] | null),isPasswordAuthBypassEnabled?: (Scalars['Boolean'] | null),defaultRoleId?: (Scalars['UUID'] | null),isTwoFactorAuthenticationEnforced?: (Scalars['Boolean'] | null),trashRetentionDays?: (Scalars['Float'] | null),eventLogRetentionDays?: (Scalars['Float'] | null),fastModel?: (Scalars['String'] | null),smartModel?: (Scalars['String'] | null),aiAdditionalInstructions?: (Scalars['String'] | null),editableProfileFields?: (Scalars['String'][] | null),enabledAiModelIds?: (Scalars['String'][] | null),useRecommendedModels?: (Scalars['Boolean'] | null)}
export interface CreateApplicationRegistrationInput {name: Scalars['String'],description?: (Scalars['String'] | null),logoUrl?: (Scalars['String'] | null),author?: (Scalars['String'] | null),universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),websiteUrl?: (Scalars['String'] | null),termsUrl?: (Scalars['String'] | null)}
@@ -8163,6 +8247,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ModelsDevModelSuggestion_possibleTypes: string[] = ['ModelsDevModelSuggestion']
export const isModelsDevModelSuggestion = (obj?: { __typename?: any } | null): obj is ModelsDevModelSuggestion => {
if (!obj?.__typename) throw new Error('__typename is missing in "isModelsDevModelSuggestion"')
return ModelsDevModelSuggestion_possibleTypes.includes(obj.__typename)
}
const ModelsDevProviderSuggestion_possibleTypes: string[] = ['ModelsDevProviderSuggestion']
export const isModelsDevProviderSuggestion = (obj?: { __typename?: any } | null): obj is ModelsDevProviderSuggestion => {
if (!obj?.__typename) throw new Error('__typename is missing in "isModelsDevProviderSuggestion"')
return ModelsDevProviderSuggestion_possibleTypes.includes(obj.__typename)
}
const QueueMetricsDataPoint_possibleTypes: string[] = ['QueueMetricsDataPoint']
export const isQueueMetricsDataPoint = (obj?: { __typename?: any } | null): obj is QueueMetricsDataPoint => {
if (!obj?.__typename) throw new Error('__typename is missing in "isQueueMetricsDataPoint"')
@@ -9084,23 +9184,11 @@ export const enumAllMetadataName = {
}
export const enumModelFamily = {
OPENAI: 'OPENAI' as const,
ANTHROPIC: 'ANTHROPIC' as const,
GOOGLE: 'GOOGLE' as const,
GPT: 'GPT' as const,
CLAUDE: 'CLAUDE' as const,
GEMINI: 'GEMINI' as const,
MISTRAL: 'MISTRAL' as const,
XAI: 'XAI' as const
}
export const enumInferenceProvider = {
NONE: 'NONE' as const,
OPENAI: 'OPENAI' as const,
ANTHROPIC: 'ANTHROPIC' as const,
BEDROCK: 'BEDROCK' as const,
GOOGLE: 'GOOGLE' as const,
MISTRAL: 'MISTRAL' as const,
OPENAI_COMPATIBLE: 'OPENAI_COMPATIBLE' as const,
XAI: 'XAI' as const,
GROQ: 'GROQ' as const
GROK: 'GROK' as const
}
export const enumSupportDriver = {
@@ -9124,7 +9212,8 @@ export const enumConfigVariableType = {
NUMBER: 'NUMBER' as const,
ARRAY: 'ARRAY' as const,
STRING: 'STRING' as const,
ENUM: 'ENUM' as const
ENUM: 'ENUM' as const,
JSON: 'JSON' as const
}
export const enumConfigVariablesGroup = {
@@ -9217,6 +9306,11 @@ export const enumAnalyticsType = {
TRACK: 'TRACK' as const
}
export const enumAiModelRole = {
FAST: 'FAST' as const,
SMART: 'SMART' as const
}
export const enumWorkspaceMigrationActionType = {
delete: 'delete' as const,
create: 'create' as const,
File diff suppressed because it is too large Load Diff
+15
View File
@@ -82,3 +82,18 @@ FRONTEND_URL=http://localhost:3001
# CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty
# HTTP_TOOL_SAFE_MODE_ENABLED=true
# ALLOW_REQUESTS_TO_TWENTY_ICONS=true
# ———————— AI ————————
# API keys for built-in providers (also editable from Admin Panel > Config Variables):
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=
# XAI_API_KEY=
# GROQ_API_KEY=
# MISTRAL_API_KEY=
#
# Add custom providers (private gateway, extra regions, etc.):
# AI_PROVIDERS='{"my-gateway":{"type":"openai-compatible","baseUrl":"...","apiKey":"..."}}'
#
# Override model preferences (disabled, recommended, defaults):
# AI_MODEL_PREFERENCES='{"recommendedModels":["openai/gpt-5.2"],"defaultFastModels":["openai/gpt-5-mini"]}'
+4
View File
@@ -51,6 +51,10 @@
{
"include": "engine/workspace-manager/dev-seeder/data/sample-files/**",
"outDir": "dist/assets"
},
{
"include": "engine/metadata-modules/ai/ai-models/ai-providers.json",
"outDir": "dist"
}
]
},
-1
View File
@@ -18,7 +18,6 @@
"@ai-sdk/amazon-bedrock": "^3.0.83",
"@ai-sdk/anthropic": "^3.0.46",
"@ai-sdk/google": "^3.0.30",
"@ai-sdk/groq": "^3.0.24",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.30",
"@ai-sdk/provider-utils": "^4.0.15",
@@ -0,0 +1,360 @@
import * as fs from 'fs';
import * as path from 'path';
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import {
type AiSdkPackage,
NATIVE_AI_SDK_PROVIDER_IDS,
} from 'twenty-shared/ai';
import { MODELS_DEV_API_URL } from 'src/engine/metadata-modules/ai/ai-models/constants/models-dev.const';
import { type ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
import { inferModelFamily } from 'src/engine/metadata-modules/ai/ai-models/utils/infer-model-family.util';
const EXCLUDED_MODEL_PREFIXES = [
'text-embedding',
'embedding',
'dall-e',
'tts-',
'whisper',
'moderation',
'davinci',
'babbage',
'ada',
'curie',
'text-search',
'text-similarity',
'code-search',
'text-davinci',
'text-curie',
'text-babbage',
'text-ada',
'ft:',
'canary',
];
const EXCLUDED_MODEL_SUFFIXES = ['-audio-preview', '-realtime-preview'];
const LONG_CONTEXT_THRESHOLD_TOKENS = 200000;
type ProviderLabels = Record<string, string>;
const PROVIDER_LABELS: ProviderLabels = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Google',
mistral: 'Mistral',
xai: 'xAI',
};
const API_KEY_TEMPLATES: Record<string, string> = {
openai: '{{OPENAI_API_KEY}}',
anthropic: '{{ANTHROPIC_API_KEY}}',
google: '{{GOOGLE_API_KEY}}',
mistral: '{{MISTRAL_API_KEY}}',
xai: '{{XAI_API_KEY}}',
};
type LongContextCostEntry = {
inputCostPerMillionTokens: number;
outputCostPerMillionTokens: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
thresholdTokens: number;
};
type GeneratedModel = {
name: string;
label: string;
description?: string;
modelFamily?: ModelFamily;
inputCostPerMillionTokens?: number;
outputCostPerMillionTokens?: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
longContextCost?: LongContextCostEntry;
contextWindowTokens?: number;
maxOutputTokens?: number;
modalities?: string[];
supportsReasoning?: boolean;
isDeprecated?: boolean;
};
type GeneratedProvider = {
npm: AiSdkPackage;
label: string;
apiKey: string;
models: GeneratedModel[];
};
type CommandOptions = { dryRun?: boolean };
@Command({
name: 'ai:sync-models-dev',
description:
'Generate ai-providers.json from models.dev API data with objective inclusion/deprecation criteria',
})
export class AiSyncModelsDevCommand extends CommandRunner {
private readonly logger = new Logger(AiSyncModelsDevCommand.name);
@Option({
flags: '-d, --dry-run',
description: 'Print what would change without writing ai-providers.json',
required: false,
})
parseDryRun(): boolean {
return true;
}
async run(_args: string[], options?: CommandOptions): Promise<void> {
const dryRun = options?.dryRun ?? false;
this.logger.log('Fetching models.dev API...');
const response = await fetch(MODELS_DEV_API_URL);
if (!response.ok) {
this.logger.error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
return;
}
const data: ModelsDevData = await response.json();
this.logger.log(
`Fetched ${Object.keys(data).length} providers from models.dev`,
);
const generated = this.generateCatalog(data);
const json = JSON.stringify(generated, null, 2) + '\n';
this.printSummary(generated);
if (dryRun) {
this.logger.log('[DRY RUN] Would write ai-providers.json');
return;
}
const outputPath = path.resolve(
process.cwd(),
'src',
'engine',
'metadata-modules',
'ai',
'ai-models',
'ai-providers.json',
);
fs.writeFileSync(outputPath, json, 'utf-8');
this.logger.log(`Wrote ${outputPath}`);
}
private generateCatalog(
data: ModelsDevData,
): Record<string, GeneratedProvider> {
const result: Record<string, GeneratedProvider> = {};
for (const providerName of NATIVE_AI_SDK_PROVIDER_IDS) {
const providerData = data[providerName];
if (!providerData) {
this.logger.warn(`Provider "${providerName}" not found in models.dev`);
continue;
}
const models = this.buildModelsForProvider(
providerName,
providerData.models,
);
if (models.length === 0) {
this.logger.warn(
`No qualifying models for "${providerName}", skipping`,
);
continue;
}
result[providerName] = {
npm: `@ai-sdk/${providerName}`,
label: PROVIDER_LABELS[providerName] ?? providerName,
apiKey: API_KEY_TEMPLATES[providerName] ?? '',
models,
};
}
return result;
}
private buildModelsForProvider(
providerName: string,
modelsDevModels: Record<string, { name: string } & Record<string, unknown>>,
): GeneratedModel[] {
const qualifying: GeneratedModel[] = [];
for (const [modelId, modelData] of Object.entries(modelsDevModels)) {
if (!this.isLanguageModel(modelId)) continue;
if (!this.meetsInclusionCriteria(modelData)) continue;
const family = inferModelFamily(providerName, modelId);
const model: GeneratedModel = {
name: modelId,
label: modelData.name ?? modelId,
modelFamily: family,
};
this.extractCost(modelData, model);
this.extractLimits(modelData, model);
this.extractModalities(modelData, model);
if (modelData.reasoning === true) {
model.supportsReasoning = true;
}
if (modelData.status === 'deprecated') {
model.isDeprecated = true;
}
qualifying.push(model);
}
return qualifying;
}
private extractCost(
modelData: Record<string, unknown>,
model: GeneratedModel,
): void {
const cost = modelData.cost as Record<string, unknown> | undefined;
if (!cost) return;
if (typeof cost.input === 'number') {
model.inputCostPerMillionTokens = cost.input;
}
if (typeof cost.output === 'number') {
model.outputCostPerMillionTokens = cost.output;
}
if (typeof cost.cache_read === 'number') {
model.cachedInputCostPerMillionTokens = cost.cache_read;
}
if (typeof cost.cache_write === 'number') {
model.cacheCreationCostPerMillionTokens = cost.cache_write;
}
const longCtx = cost.context_over_200k as
| Record<string, unknown>
| undefined;
if (longCtx && typeof longCtx.input === 'number') {
model.longContextCost = {
inputCostPerMillionTokens: longCtx.input as number,
outputCostPerMillionTokens: (longCtx.output as number) ?? 0,
thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS,
};
if (typeof longCtx.cache_read === 'number') {
model.longContextCost.cachedInputCostPerMillionTokens =
longCtx.cache_read;
}
if (typeof longCtx.cache_write === 'number') {
model.longContextCost.cacheCreationCostPerMillionTokens =
longCtx.cache_write;
}
}
}
private extractLimits(
modelData: Record<string, unknown>,
model: GeneratedModel,
): void {
const limit = modelData.limit as Record<string, unknown> | undefined;
if (!limit) return;
if (typeof limit.context === 'number') {
model.contextWindowTokens = limit.context;
}
if (typeof limit.output === 'number') {
model.maxOutputTokens = limit.output;
}
}
private extractModalities(
modelData: Record<string, unknown>,
model: GeneratedModel,
): void {
const modalities = modelData.modalities as { input?: string[] } | undefined;
if (!modalities?.input) return;
const relevant = modalities.input.filter((modality) => modality !== 'text');
if (relevant.length > 0) {
model.modalities = relevant;
}
}
private isLanguageModel(modelId: string): boolean {
const lowerId = modelId.toLowerCase();
for (const prefix of EXCLUDED_MODEL_PREFIXES) {
if (lowerId.startsWith(prefix)) return false;
}
for (const suffix of EXCLUDED_MODEL_SUFFIXES) {
if (lowerId.endsWith(suffix)) return false;
}
return true;
}
private meetsInclusionCriteria(modelData: Record<string, unknown>): boolean {
if (modelData.status === 'beta') return false;
if (modelData.tool_call !== true) return false;
const cost = modelData.cost as
| { input?: number; output?: number }
| undefined;
if (cost?.input === undefined) return false;
const limit = modelData.limit as
| { context?: number; output?: number }
| undefined;
if (limit?.context === undefined) return false;
return true;
}
private printSummary(catalog: Record<string, GeneratedProvider>): void {
this.logger.log('=== Generation Summary ===');
let totalModels = 0;
let deprecatedCount = 0;
for (const [providerName, provider] of Object.entries(catalog)) {
const deprecatedModelCount = provider.models.filter(
(model) => model.isDeprecated,
).length;
const active = provider.models.length - deprecatedModelCount;
this.logger.log(
` ${providerName}: ${provider.models.length} models (${active} active, ${deprecatedModelCount} deprecated)`,
);
totalModels += provider.models.length;
deprecatedCount += deprecatedModelCount;
}
this.logger.log(
`Total: ${totalModels} models (${totalModels - deprecatedCount} active, ${deprecatedCount} deprecated)`,
);
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiSyncModelsDevCommand } from 'src/database/commands/ai-sync-models-dev.command';
import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { ListOrphanedWorkspaceEntitiesCommand } from 'src/database/commands/list-and-delete-orphaned-workspace-entities.command';
@@ -70,6 +71,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
StaleRegistrationCleanupModule,
],
providers: [
AiSyncModelsDevCommand,
DataSeedWorkspaceCommand,
ConfirmationQuestion,
CronRegisterAllCommand,
@@ -1,10 +1,5 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
import {
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
export class AddFastAndSmartModelsToWorkspace1763997530458
implements MigrationInterface
{
@@ -12,10 +7,10 @@ export class AddFastAndSmartModelsToWorkspace1763997530458
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "fastModel" character varying NOT NULL DEFAULT '${DEFAULT_FAST_MODEL}'`,
`ALTER TABLE "core"."workspace" ADD "fastModel" character varying NOT NULL DEFAULT 'default-fast-model'`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "smartModel" character varying NOT NULL DEFAULT '${DEFAULT_SMART_MODEL}'`,
`ALTER TABLE "core"."workspace" ADD "smartModel" character varying NOT NULL DEFAULT 'default-smart-model'`,
);
}
@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MigrateModelIdsToCompositeFormat1773900000000
implements MigrationInterface
{
name = 'MigrateModelIdsToCompositeFormat1773900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Reset workspace model columns to sentinel defaults.
// The runtime resolves these dynamically from admin preferences.
await queryRunner.query(
`UPDATE "core"."workspace"
SET "fastModel" = 'default-fast-model',
"smartModel" = 'default-smart-model'
WHERE "fastModel" != 'default-fast-model'
OR "smartModel" != 'default-smart-model'`,
);
// Clear per-workspace model allow/deny lists — admin preferences take over
await queryRunner.query(
`UPDATE "core"."workspace"
SET "disabledAiModelIds" = '{}',
"enabledAiModelIds" = '{}'
WHERE array_length("disabledAiModelIds", 1) > 0
OR array_length("enabledAiModelIds", 1) > 0`,
);
// Clear agent-specific model IDs so they fall back to workspace defaults
await queryRunner.query(
`UPDATE "core"."agent" SET "modelId" = NULL WHERE "modelId" IS NOT NULL`,
);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// No reversal needed — sentinel defaults and NULLed agent modelIds
// are safe to leave in place.
}
}
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SplitAiProvidersConfig1774000000000 implements MigrationInterface {
name = 'SplitAiProvidersConfig1774000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Clean up legacy keys — preferences will be re-derived from catalog defaults
await queryRunner.query(
`DELETE FROM "core"."keyValuePair"
WHERE "key" IN ('DEFAULT_AI_SPEED_MODEL_ID', 'DEFAULT_AI_PERFORMANCE_MODEL_ID')
AND "type" = 'CONFIG_VARIABLE'
AND "userId" IS NULL
AND "workspaceId" IS NULL`,
);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Legacy keys are not restored — they are superseded by AI_MODEL_PREFERENCES
}
}
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class DropWorkspaceAiColumns1774100000000 implements MigrationInterface {
name = 'DropWorkspaceAiColumns1774100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN IF EXISTS "autoEnableNewAiModels"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN IF EXISTS "disabledAiModelIds"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "disabledAiModelIds" character varying array NOT NULL DEFAULT '{}'`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "autoEnableNewAiModels" boolean NOT NULL DEFAULT true`,
);
}
}
@@ -10,6 +10,7 @@ import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-pan
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
import { ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/delete-jobs-response.dto';
@@ -34,6 +35,12 @@ import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/conf
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
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 { ModelsDevCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/models-dev-catalog.service';
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
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 { 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';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { ServerLevelImpersonateGuard } from 'src/engine/guards/server-level-impersonate.guard';
@@ -42,6 +49,8 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AdminPanelHealthServiceDataDTO } from './dtos/admin-panel-health-service-data.dto';
import { ModelsDevModelSuggestionDTO } from './dtos/models-dev-model-suggestion.dto';
import { ModelsDevProviderSuggestionDTO } from './dtos/models-dev-provider-suggestion.dto';
import { QueueMetricsDataDTO } from './dtos/queue-metrics-data.dto';
@UsePipes(ResolverValidationPipe)
@@ -65,6 +74,7 @@ export class AdminPanelResolver {
private featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly modelsDevCatalogService: ModelsDevCatalogService,
) {}
@UseGuards(ServerLevelImpersonateGuard)
@@ -147,24 +157,50 @@ export class AdminPanelResolver {
@UseGuards(AdminPanelGuard)
@Query(() => AdminAIModelsDTO)
async getAdminAiModels(): Promise<AdminAIModelsDTO> {
const resolvedProviders =
this.aiModelRegistryService.getResolvedProvidersForAdmin();
const models = this.aiModelRegistryService
.getAllModelsWithStatus()
.map(({ modelConfig, isAvailable, isAdminEnabled }) => ({
modelId: modelConfig.modelId,
label: modelConfig.label,
modelFamily: modelConfig.modelFamily,
inferenceProvider: modelConfig.inferenceProvider,
isAvailable,
isAdminEnabled,
deprecated: modelConfig.deprecated,
isRecommended: modelConfig.isRecommended,
}));
.map(
({
modelConfig,
isAvailable,
isAdminEnabled,
isRecommended,
providerName,
name,
}) => ({
modelId: modelConfig.modelId,
label: modelConfig.label,
modelFamily: modelConfig.modelFamily,
modelFamilyLabel: modelConfig.modelFamily
? MODEL_FAMILY_LABELS[modelConfig.modelFamily]
: undefined,
sdkPackage: modelConfig.sdkPackage,
isAvailable,
isAdminEnabled,
isDeprecated: modelConfig.isDeprecated ?? false,
isRecommended,
contextWindowTokens: modelConfig.contextWindowTokens,
maxOutputTokens: modelConfig.maxOutputTokens,
inputCostPerMillionTokens: modelConfig.inputCostPerMillionTokens,
outputCostPerMillionTokens: modelConfig.outputCostPerMillionTokens,
providerName,
providerLabel: providerName
? (resolvedProviders[providerName]?.label ?? providerName)
: undefined,
name,
dataResidency: modelConfig.dataResidency,
}),
);
const prefs = this.twentyConfigService.get('AI_MODEL_PREFERENCES');
return {
autoEnableNewModels: this.twentyConfigService.get(
'AI_AUTO_ENABLE_NEW_MODELS',
),
models,
defaultSmartModelId: prefs.defaultSmartModels?.[0],
defaultFastModelId: prefs.defaultFastModels?.[0],
};
}
@@ -179,6 +215,28 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async setAdminAiModelRecommended(
@Args('modelId', { type: () => String }) modelId: string,
@Args('recommended', { type: () => Boolean }) recommended: boolean,
): Promise<boolean> {
await this.aiModelRegistryService.setModelRecommended(modelId, recommended);
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async setAdminDefaultAiModel(
@Args('role', { type: () => AiModelRole }) role: AiModelRole,
@Args('modelId', { type: () => String }) modelId: string,
): Promise<boolean> {
await this.aiModelRegistryService.setDefaultModel(role, modelId);
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => ConfigVariableDTO)
async getDatabaseConfigVariable(
@@ -278,4 +336,166 @@ export class AdminPanelResolver {
> {
return this.applicationRegistrationService.findAll();
}
@UseGuards(AdminPanelGuard)
@Query(() => GraphQLJSON)
async getAiProviders(): Promise<Record<string, unknown>> {
const providers =
this.aiModelRegistryService.getResolvedProvidersForAdmin();
const catalogNames = this.aiModelRegistryService.getCatalogProviderNames();
const rawCatalog = loadDefaultAiProviders();
const masked: Record<string, Record<string, unknown>> = {};
for (const [key, config] of Object.entries(providers)) {
const isCatalog = catalogNames.has(key);
const rawConfig = isCatalog ? rawCatalog[key] : undefined;
const apiKeyConfigVariable = rawConfig
? extractConfigVariableName(rawConfig.apiKey)
: undefined;
masked[key] = {
npm: config.npm,
label: config.label ?? key,
source: isCatalog ? 'catalog' : 'custom',
...(config.name && { name: config.name }),
...(config.baseUrl && { baseUrl: config.baseUrl }),
...(config.region && { region: config.region }),
...(config.dataResidency && { dataResidency: config.dataResidency }),
...(config.apiKey && {
apiKey: `${config.apiKey.substring(0, 8)}...`,
}),
...(apiKeyConfigVariable && { apiKeyConfigVariable }),
hasAccessKey: !!(config.accessKeyId && config.secretAccessKey),
};
}
return masked;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async addAiProvider(
@Args('providerName', { type: () => String }) providerName: string,
@Args('providerConfig', { type: () => GraphQLJSON })
providerConfig: AiProviderConfig,
): Promise<boolean> {
if (!/^[a-zA-Z0-9_-]+$/.test(providerName)) {
throw new UserInputError('Invalid provider name');
}
const customProviders = {
...this.twentyConfigService.get('AI_PROVIDERS'),
};
customProviders[providerName] = providerConfig;
await this.twentyConfigService.set('AI_PROVIDERS', customProviders);
this.aiModelRegistryService.refreshRegistry();
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async removeAiProvider(
@Args('providerName', { type: () => String })
providerName: string,
): Promise<boolean> {
const customProviders = {
...this.twentyConfigService.get('AI_PROVIDERS'),
};
delete customProviders[providerName];
await this.twentyConfigService.set('AI_PROVIDERS', customProviders);
this.aiModelRegistryService.refreshRegistry();
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => [ModelsDevProviderSuggestionDTO])
async getModelsDevProviders(): Promise<ModelsDevProviderSuggestionDTO[]> {
return this.modelsDevCatalogService.getProviderSuggestions();
}
@UseGuards(AdminPanelGuard)
@Query(() => [ModelsDevModelSuggestionDTO])
async getModelsDevSuggestions(
@Args('providerType', { type: () => String }) providerType: string,
): Promise<ModelsDevModelSuggestionDTO[]> {
return this.modelsDevCatalogService.getModelSuggestions(providerType);
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async addModelToProvider(
@Args('providerName', { type: () => String }) providerName: string,
@Args('modelConfig', { type: () => GraphQLJSON })
modelConfig: AiProviderModelConfig,
): Promise<boolean> {
const customProviders = {
...this.twentyConfigService.get('AI_PROVIDERS'),
};
const existing = customProviders[providerName];
if (!existing) {
throw new UserInputError(
`Provider "${providerName}" not found in custom providers`,
);
}
const existingModels = existing.models ?? [];
const alreadyExists = existingModels.some(
(model: AiProviderModelConfig) => model.name === modelConfig.name,
);
if (alreadyExists) {
throw new UserInputError(
`Model "${modelConfig.name}" already exists on provider "${providerName}"`,
);
}
customProviders[providerName] = {
...existing,
models: [...existingModels, { ...modelConfig, source: 'manual' }],
};
await this.twentyConfigService.set('AI_PROVIDERS', customProviders);
this.aiModelRegistryService.refreshRegistry();
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async removeModelFromProvider(
@Args('providerName', { type: () => String }) providerName: string,
@Args('modelName', { type: () => String }) modelName: string,
): Promise<boolean> {
const customProviders = {
...this.twentyConfigService.get('AI_PROVIDERS'),
};
const existing = customProviders[providerName];
if (!existing) {
throw new UserInputError(
`Provider "${providerName}" not found in custom providers`,
);
}
const existingModels = existing.models ?? [];
customProviders[providerName] = {
...existing,
models: existingModels.filter(
(model: AiProviderModelConfig) => model.name !== modelName,
),
};
await this.twentyConfigService.set('AI_PROVIDERS', customProviders);
this.aiModelRegistryService.refreshRegistry();
return true;
}
}
@@ -0,0 +1,36 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('ModelsDevModelSuggestion')
export class ModelsDevModelSuggestionDTO {
@Field(() => String)
// models.dev catalog key for the model (often a bare id). Not the composite `provider/modelName` workspace id used in the registry.
modelId: string;
@Field(() => String)
// Display name from the catalog, or the catalog key when absent.
name: string;
@Field(() => Number)
inputCostPerMillionTokens: number;
@Field(() => Number)
outputCostPerMillionTokens: number;
@Field(() => Number, { nullable: true })
cachedInputCostPerMillionTokens?: number;
@Field(() => Number, { nullable: true })
cacheCreationCostPerMillionTokens?: number;
@Field(() => Number)
contextWindowTokens: number;
@Field(() => Number)
maxOutputTokens: number;
@Field(() => [String])
modalities: string[];
@Field(() => Boolean)
supportsReasoning: boolean;
}
@@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('ModelsDevProviderSuggestion')
export class ModelsDevProviderSuggestionDTO {
@Field(() => String)
id: string;
@Field(() => Number)
modelCount: number;
@Field(() => String)
npm: string;
}
@@ -1,9 +1,7 @@
import { type AgentManifest } from 'twenty-shared/application';
import {
DEFAULT_SMART_MODEL,
type 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 { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type';
export const fromAgentManifestToUniversalFlatAgent = ({
@@ -3,11 +3,8 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import {
InferenceProvider,
ModelFamily,
type ModelId,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { ClientConfigController } from './client-config.controller';
@@ -51,10 +48,10 @@ describe('ClientConfigController', () => {
},
aiModels: [
{
modelId: 'gpt-4o' as ModelId,
modelId: 'openai/gpt-4o' as ModelId,
label: 'GPT-4o',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
modelFamily: ModelFamily.GPT,
sdkPackage: '@ai-sdk/openai' as const,
inputCostPerMillionTokensInCredits: 2500000,
outputCostPerMillionTokensInCredits: 10000000,
},
@@ -1,5 +1,6 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { type AiSdkPackage } from 'twenty-shared/ai';
import { FeatureFlagKey } from 'twenty-shared/types';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
@@ -7,24 +8,22 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { AuthProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.dto';
import {
InferenceProvider,
ModelFamily,
ModelId,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
registerEnumType(FeatureFlagKey, {
name: 'FeatureFlagKey',
});
registerEnumType(InferenceProvider, {
name: 'InferenceProvider',
});
registerEnumType(ModelFamily, {
name: 'ModelFamily',
});
registerEnumType(AiModelRole, {
name: 'AiModelRole',
});
@ObjectType()
export class NativeModelCapabilities {
@Field(() => Boolean, { nullable: true })
@@ -37,6 +36,7 @@ export class NativeModelCapabilities {
@ObjectType()
export class ClientAIModelConfig {
@Field(() => String)
// Composite model id (`provider/modelName`) for this workspace; matches registry and admin APIs.
modelId: ModelId;
@Field(() => String)
@@ -45,8 +45,11 @@ export class ClientAIModelConfig {
@Field(() => ModelFamily, { nullable: true })
modelFamily?: ModelFamily;
@Field(() => InferenceProvider)
inferenceProvider: InferenceProvider;
@Field({ nullable: true })
modelFamilyLabel?: string;
@Field(() => String, { nullable: true })
sdkPackage: AiSdkPackage | null;
@Field(() => Number)
inputCostPerMillionTokensInCredits: number;
@@ -58,15 +61,22 @@ export class ClientAIModelConfig {
nativeCapabilities?: NativeModelCapabilities;
@Field(() => Boolean, { nullable: true })
deprecated?: boolean;
isDeprecated?: boolean;
@Field(() => Boolean, { nullable: true })
isRecommended?: boolean;
@Field(() => String, { nullable: true })
providerName?: string;
@Field(() => String, { nullable: true })
dataResidency?: string;
}
@ObjectType()
export class AdminAIModelConfig {
@Field(() => String)
// Composite model id (`provider/modelName`) used for toggles, defaults, and registry lookups.
modelId: string;
@Field(() => String)
@@ -75,8 +85,11 @@ export class AdminAIModelConfig {
@Field(() => ModelFamily, { nullable: true })
modelFamily?: ModelFamily;
@Field(() => InferenceProvider)
inferenceProvider: InferenceProvider;
@Field({ nullable: true })
modelFamilyLabel?: string;
@Field(() => String, { nullable: true })
sdkPackage: AiSdkPackage | null;
@Field(() => Boolean)
isAvailable: boolean;
@@ -85,19 +98,49 @@ export class AdminAIModelConfig {
isAdminEnabled: boolean;
@Field(() => Boolean, { nullable: true })
deprecated?: boolean;
isDeprecated?: boolean;
@Field(() => Boolean, { nullable: true })
isRecommended?: boolean;
@Field(() => Number, { nullable: true })
contextWindowTokens?: number;
@Field(() => Number, { nullable: true })
maxOutputTokens?: number;
@Field(() => Number, { nullable: true })
inputCostPerMillionTokens?: number;
@Field(() => Number, { nullable: true })
outputCostPerMillionTokens?: number;
@Field(() => String, { nullable: true })
providerName?: string;
@Field(() => String, { nullable: true })
providerLabel?: string;
@Field(() => String, { nullable: true })
// Bare SDK model name from the provider definition (`AiProviderModelConfig.name`), not the composite `modelId`.
name?: string;
@Field(() => String, { nullable: true })
dataResidency?: string;
}
@ObjectType('AdminAIModels')
export class AdminAIModelsDTO {
@Field(() => Boolean)
autoEnableNewModels: boolean;
@Field(() => [AdminAIModelConfig])
models: AdminAIModelConfig[];
@Field(() => String, { nullable: true })
// Composite model id for the default “smart” role (`provider/modelName`).
defaultSmartModelId?: string;
@Field(() => String, { nullable: true })
// Composite model id for the default “fast” role (`provider/modelName`).
defaultFastModelId?: string;
}
@ObjectType()
@@ -35,6 +35,8 @@ describe('ClientConfigService', () => {
provide: AiModelRegistryService,
useValue: {
getAdminFilteredModels: jest.fn().mockReturnValue([]),
getRecommendedModelIds: jest.fn().mockReturnValue(new Set()),
getModelConfig: jest.fn().mockReturnValue(undefined),
},
},
],
@@ -1,24 +1,28 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { type AiSdkPackage } from 'twenty-shared/ai';
import {
AI_SDK_ANTHROPIC,
AI_SDK_BEDROCK,
AI_SDK_OPENAI,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import {
type ClientAIModelConfig,
type ClientConfig,
type NativeModelCapabilities,
} from 'src/engine/core-modules/client-config/client-config.entity';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
InferenceProvider,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
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';
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Injectable()
@@ -29,6 +33,19 @@ export class ClientConfigService {
private aiModelRegistryService: AiModelRegistryService,
) {}
private deriveNativeCapabilities(
sdkPackage?: AiSdkPackage,
): NativeModelCapabilities | undefined {
switch (sdkPackage) {
case AI_SDK_OPENAI:
case AI_SDK_ANTHROPIC:
case AI_SDK_BEDROCK:
return { webSearch: true };
default:
return undefined;
}
}
private isCloudflareIntegrationEnabled(): boolean {
return (
!!this.twentyConfigService.get('CLOUDFLARE_API_KEY') &&
@@ -45,31 +62,42 @@ export class ClientConfigService {
const availableModels =
this.aiModelRegistryService.getAdminFilteredModels();
const recommendedModelIds =
this.aiModelRegistryService.getRecommendedModelIds();
const aiModels: ClientAIModelConfig[] = availableModels.map(
(registeredModel) => {
const builtInModel = AI_MODELS.find(
(m) => m.modelId === registeredModel.modelId,
const modelConfig = this.aiModelRegistryService.getModelConfig(
registeredModel.modelId,
);
const modelFamily = modelConfig?.modelFamily;
return {
modelId: registeredModel.modelId,
label: builtInModel?.label || registeredModel.modelId,
modelFamily: builtInModel?.modelFamily,
inferenceProvider: registeredModel.inferenceProvider,
nativeCapabilities: builtInModel?.nativeCapabilities,
inputCostPerMillionTokensInCredits: builtInModel
label: modelConfig?.label || registeredModel.modelId,
modelFamily,
modelFamilyLabel: modelFamily
? MODEL_FAMILY_LABELS[modelFamily]
: undefined,
sdkPackage: registeredModel.sdkPackage,
providerName: registeredModel.providerName,
nativeCapabilities: this.deriveNativeCapabilities(
registeredModel.sdkPackage,
),
inputCostPerMillionTokensInCredits: modelConfig
? convertDollarsToBillingCredits(
builtInModel.inputCostPerMillionTokens,
modelConfig.inputCostPerMillionTokens,
)
: 0,
outputCostPerMillionTokensInCredits: builtInModel
outputCostPerMillionTokensInCredits: modelConfig
? convertDollarsToBillingCredits(
builtInModel.outputCostPerMillionTokens,
modelConfig.outputCostPerMillionTokens,
)
: 0,
deprecated: builtInModel?.deprecated,
isRecommended: builtInModel?.isRecommended,
isDeprecated: modelConfig?.isDeprecated,
isRecommended: recommendedModelIds.has(registeredModel.modelId),
dataResidency: modelConfig?.dataResidency,
};
},
);
@@ -77,9 +105,8 @@ export class ClientConfigService {
if (aiModels.length > 0) {
const defaultSpeedModel =
this.aiModelRegistryService.getDefaultSpeedModel();
const defaultSpeedModelConfig = AI_MODELS.find(
(m) => m.modelId === defaultSpeedModel?.modelId,
);
const defaultSpeedModelConfig =
this.aiModelRegistryService.getModelConfig(defaultSpeedModel?.modelId);
const defaultSpeedModelLabel =
defaultSpeedModelConfig?.label ||
defaultSpeedModel?.modelId ||
@@ -87,9 +114,10 @@ export class ClientConfigService {
const defaultPerformanceModel =
this.aiModelRegistryService.getDefaultPerformanceModel();
const defaultPerformanceModelConfig = AI_MODELS.find(
(m) => m.modelId === defaultPerformanceModel?.modelId,
);
const defaultPerformanceModelConfig =
this.aiModelRegistryService.getModelConfig(
defaultPerformanceModel?.modelId,
);
const defaultPerformanceModelLabel =
defaultPerformanceModelConfig?.label ||
defaultPerformanceModel?.modelId ||
@@ -99,14 +127,14 @@ export class ClientConfigService {
{
modelId: DEFAULT_SMART_MODEL,
label: `Best (${defaultPerformanceModelLabel})`,
inferenceProvider: InferenceProvider.NONE,
sdkPackage: null,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
},
{
modelId: DEFAULT_FAST_MODEL,
label: `Best (${defaultSpeedModelLabel})`,
inferenceProvider: InferenceProvider.NONE,
sdkPackage: null,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
},
@@ -20,6 +20,9 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import { loadDefaultModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces';
import { LoggerDriverType } from 'src/engine/core-modules/logger/interfaces';
@@ -1212,168 +1215,76 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'Comma-separated list of AI model IDs for speed-optimized operations, in priority order. The first available model will be used.',
isSensitive: true,
description: 'API key for OpenAI models (GPT, o-series)',
type: ConfigVariableType.STRING,
})
@IsOptional()
DEFAULT_AI_SPEED_MODEL_ID =
'gpt-5-mini,claude-haiku-4-5-20251001,gemini-3-flash-preview,grok-4-1-fast-reasoning,mistral-large-latest';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'Comma-separated list of AI model IDs for performance-optimized operations, in priority order. The first available model will be used.',
type: ConfigVariableType.STRING,
})
@IsOptional()
DEFAULT_AI_PERFORMANCE_MODEL_ID =
'gpt-5.2,claude-sonnet-4-6,gemini-3.1-pro-preview,grok-4,mistral-large-latest';
OPENAI_API_KEY?: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for OpenAI integration',
description: 'API key for Anthropic models (Claude)',
type: ConfigVariableType.STRING,
})
@IsOptional()
OPENAI_API_KEY: string;
ANTHROPIC_API_KEY?: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Anthropic integration',
description: 'API key for Google AI models (Gemini)',
type: ConfigVariableType.STRING,
})
@IsOptional()
ANTHROPIC_API_KEY: string;
GOOGLE_API_KEY?: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description: 'Base URL for OpenAI-compatible LLM provider (e.g., Ollama)',
isSensitive: true,
description: 'API key for xAI models (Grok)',
type: ConfigVariableType.STRING,
})
@IsOptional()
@IsUrl({ require_tld: false, require_protocol: true })
OPENAI_COMPATIBLE_BASE_URL: string;
XAI_API_KEY?: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'Model names for OpenAI-compatible LLM provider (comma-separated, e.g., "llama3.1, mistral, codellama")',
isSensitive: true,
description: 'API key for Groq inference',
type: ConfigVariableType.STRING,
})
@IsOptional()
OPENAI_COMPATIBLE_MODEL_NAMES: string;
GROQ_API_KEY?: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Mistral models',
type: ConfigVariableType.STRING,
})
@IsOptional()
MISTRAL_API_KEY?: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description:
'API key for OpenAI-compatible LLM provider (optional for providers like Ollama)',
type: ConfigVariableType.STRING,
'AI provider configurations. Custom providers are deep-merged on top of the built-in catalog (ai-providers.json). Use for custom endpoints, extra regions, or credentials set via admin panel.',
type: ConfigVariableType.JSON,
})
@IsOptional()
OPENAI_COMPATIBLE_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for xAI integration',
type: ConfigVariableType.STRING,
})
@IsOptional()
XAI_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Groq integration',
type: ConfigVariableType.STRING,
})
@IsOptional()
GROQ_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Google AI (Gemini) integration',
type: ConfigVariableType.STRING,
})
@IsOptional()
GOOGLE_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Mistral AI integration',
type: ConfigVariableType.STRING,
})
@IsOptional()
MISTRAL_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description: 'AWS region for Bedrock integration (e.g., us-east-1)',
type: ConfigVariableType.STRING,
})
@IsAWSRegion()
@IsOptional()
AWS_BEDROCK_REGION: AwsRegion;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'AWS access key ID for Bedrock authentication',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_BEDROCK_ACCESS_KEY_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'AWS secret access key for Bedrock authentication',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_BEDROCK_SECRET_ACCESS_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'AWS session token for Bedrock (for IAM role-based auth)',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_BEDROCK_SESSION_TOKEN: string;
AI_PROVIDERS: AiProvidersConfig = {};
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'When true, newly added models are automatically available to all workspaces',
type: ConfigVariableType.BOOLEAN,
'AI model admin preferences: disabled models, recommended models, and default fast/smart model lists. Managed via admin panel or env.',
type: ConfigVariableType.JSON,
})
@IsOptional()
AI_AUTO_ENABLE_NEW_MODELS = true;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'Model IDs to disable (used when AI_AUTO_ENABLE_NEW_MODELS is true)',
type: ConfigVariableType.ARRAY,
})
@IsOptional()
AI_DISABLED_MODEL_IDS: string[] = [];
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'Model IDs to enable (used when AI_AUTO_ENABLE_NEW_MODELS is false)',
type: ConfigVariableType.ARRAY,
})
@IsOptional()
AI_ENABLED_MODEL_IDS: string[] = [];
AI_MODEL_PREFERENCES: AiModelPreferences = loadDefaultModelPreferences();
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
@@ -25,6 +25,7 @@ jest.mock(
string: createMockTransformer(),
array: createMockTransformer(),
enum: createMockTransformer(),
json: createMockTransformer(),
};
return {
@@ -64,6 +65,12 @@ const typeTransformers = typeTransformersModule.typeTransformers as {
getValidators: jest.Mock;
getTransformers: jest.Mock;
};
json: {
toApp: jest.Mock;
toStorage: jest.Mock;
getValidators: jest.Mock;
getTransformers: jest.Mock;
};
};
describe('ConfigValueConverterService', () => {
@@ -107,6 +107,13 @@ export class ConfigValueConverterService {
if (typeof defaultValue === 'boolean') return ConfigVariableType.BOOLEAN;
if (typeof defaultValue === 'number') return ConfigVariableType.NUMBER;
if (Array.isArray(defaultValue)) return ConfigVariableType.ARRAY;
if (
typeof defaultValue === 'object' &&
defaultValue !== null &&
!Array.isArray(defaultValue)
) {
return ConfigVariableType.JSON;
}
return ConfigVariableType.STRING;
}
@@ -4,4 +4,5 @@ export enum ConfigVariableType {
ARRAY = 'array',
STRING = 'string',
ENUM = 'enum',
JSON = 'json',
}
@@ -4,6 +4,7 @@ import {
IsBoolean,
IsEnum,
IsNumber,
IsObject,
IsString,
} from 'class-validator';
@@ -241,4 +242,74 @@ export const typeTransformers: Record<
getTransformers: (): PropertyDecorator[] => [],
},
[ConfigVariableType.JSON]: {
toApp: (value: unknown): Record<string, unknown> | undefined => {
if (value === null || value === undefined) return undefined;
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (
parsed !== null &&
typeof parsed === 'object' &&
!Array.isArray(parsed)
) {
return parsed;
}
throw new ConfigVariableException(
'Expected JSON object, got non-object value',
ConfigVariableExceptionCode.VALIDATION_FAILED,
);
} catch (error) {
if (error instanceof ConfigVariableException) {
throw error;
}
throw new ConfigVariableException(
`Failed to parse JSON string: ${error instanceof Error ? error.message : String(error)}`,
ConfigVariableExceptionCode.VALIDATION_FAILED,
);
}
}
throw new ConfigVariableException(
`Expected JSON object or string, got ${typeof value}`,
ConfigVariableExceptionCode.VALIDATION_FAILED,
);
},
toStorage: (value: Record<string, unknown>): Record<string, unknown> => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new ConfigVariableException(
`Expected JSON object, got ${Array.isArray(value) ? 'array' : typeof value}`,
ConfigVariableExceptionCode.VALIDATION_FAILED,
);
}
return JSON.parse(JSON.stringify(value));
},
getValidators: (): PropertyDecorator[] => [IsObject()],
getTransformers: (): PropertyDecorator[] => [
Transform(({ value }) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
}),
],
},
};
@@ -128,17 +128,6 @@ export class UpdateWorkspaceInput {
@IsOptional()
editableProfileFields?: string[];
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
autoEnableNewAiModels?: boolean;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
disabledAiModelIds?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@@ -89,8 +89,6 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
fastModel: PermissionFlagType.WORKSPACE,
smartModel: PermissionFlagType.WORKSPACE,
aiAdditionalInstructions: PermissionFlagType.WORKSPACE,
autoEnableNewAiModels: PermissionFlagType.AI_SETTINGS,
disabledAiModelIds: PermissionFlagType.AI_SETTINGS,
enabledAiModelIds: PermissionFlagType.AI_SETTINGS,
useRecommendedModels: PermissionFlagType.AI_SETTINGS,
};
@@ -231,18 +229,12 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
isDefined(payload.smartModel) || isDefined(payload.fastModel);
const isChangingAvailability =
payload.useRecommendedModels !== undefined ||
payload.autoEnableNewAiModels !== undefined ||
payload.disabledAiModelIds !== undefined ||
payload.enabledAiModelIds !== undefined;
if (isChangingModels || isChangingAvailability) {
const effectiveWorkspace = {
useRecommendedModels:
payload.useRecommendedModels ?? workspace.useRecommendedModels,
autoEnableNewAiModels:
payload.autoEnableNewAiModels ?? workspace.autoEnableNewAiModels,
disabledAiModelIds:
payload.disabledAiModelIds ?? workspace.disabledAiModelIds,
enabledAiModelIds:
payload.enabledAiModelIds ?? workspace.enabledAiModelIds,
};
@@ -260,7 +252,13 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
);
}
if (!isModelAllowedByWorkspace(modelId, effectiveWorkspace)) {
if (
!isModelAllowedByWorkspace(
modelId,
effectiveWorkspace,
this.aiModelRegistryService.getRecommendedModelIds(),
)
) {
throw new WorkspaceException(
'Selected model is not available in this workspace',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
@@ -34,11 +34,9 @@ import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import {
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
type ModelId,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
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';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
@@ -314,19 +312,6 @@ export class WorkspaceEntity {
@Column({ type: 'text', nullable: true })
aiAdditionalInstructions: string | null;
@Field(() => Boolean, { nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
autoEnableNewAiModels: boolean;
@Field(() => [String], { nullable: true })
@Column({
type: 'varchar',
array: true,
nullable: false,
default: '{}',
})
disabledAiModelIds: string[];
@Field(() => [String], { nullable: true })
@Column({
type: 'varchar',
@@ -221,20 +221,6 @@ export class WorkspaceResolver {
return workspace.smartModel;
}
@ResolveField(() => Boolean, { nullable: false })
async autoEnableNewAiModels(
@Parent() workspace: WorkspaceEntity,
): Promise<boolean> {
return workspace.autoEnableNewAiModels;
}
@ResolveField(() => [String], { nullable: true })
async disabledAiModelIds(
@Parent() workspace: WorkspaceEntity,
): Promise<string[]> {
return workspace.disabledAiModelIds;
}
@ResolveField(() => [String], { nullable: true })
async enabledAiModelIds(
@Parent() workspace: WorkspaceEntity,
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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';
@@ -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 {}
@@ -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);
});
@@ -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, {
@@ -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;
@@ -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,
);
}
@@ -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';
@@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show More