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
@@ -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 {