feat: add two-layer AI model availability filtering (#18170)

## Summary

- **Admin-level filtering**: New AI tab in admin panel with server-wide
model availability controls (whitelist/blacklist via
`AI_AUTO_ENABLE_NEW_MODELS`, `AI_DISABLED_MODEL_IDS`,
`AI_ENABLED_MODEL_IDS` config variables). Dedicated
`setAdminAiModelEnabled` mutation replaces frontend config-variable
manipulation. Filter dropdown to show/hide unconfigured and deprecated
models.
- **Workspace-level filtering**: Per-workspace controls with "Use best
models only" mode (curated list backed by `isRecommended` flag), or
custom whitelist/blacklist. Separate Smart/Fast model selectors with
"Best (...)" virtual options.
- **Security enforcement**: Both layers enforced at every backend
execution point — workspace update, agent create/update, chat execution.
Model ID validated against known models before config mutation. All
admin endpoints protected by `AdminPanelGuard`.

## Changes

### Backend (`twenty-server`)
- New config variables for admin-level model filtering
- `AiModelRegistryService`: `getAllModelsWithStatus()`,
`setModelAdminEnabled()` with model ID validation,
`isModelAdminAllowed()`
- `AdminPanelResolver`: `getAdminAiModels` query,
`setAdminAiModelEnabled` mutation
- `WorkspaceEntity`: new fields (`autoEnableNewAiModels`,
`disabledAiModelIds`, `enabledAiModelIds`, `useRecommendedModels`)
- `WorkspaceService`: model validation on `smartModel`/`fastModel`
updates
- `AgentResolver`: model availability checks on create/update
- `isModelAllowedByWorkspace` centralized utility
- `isRecommended` flag on model definitions
- Two TypeORM migrations

### Frontend (`twenty-front`)
- New `SettingsAdminAI` component with search, filter dropdown
(unconfigured/deprecated), and model toggle cards
- AI tab added to admin panel navigation
- `useWorkspaceAiModelAvailability` hook for workspace-level filtering
- `SettingsAIModelsTab` redesigned: merged sections, "Use best models
only" toggle, conditional available models list
- `getModelIcon`/`getModelProviderLabel` shared utilities with GraphQL
enum casing normalization
- Updated generated GraphQL types and mock data

## Test plan
- [ ] Toggle models on/off in admin panel AI tab and verify they
appear/disappear in workspace settings
- [ ] Enable "Use best models only" in workspace settings and verify
only recommended models are selectable
- [ ] Disable recommended mode and verify whitelist/blacklist toggles
work correctly
- [ ] Verify deprecated models hidden by default, shown greyed out when
filter enabled
- [ ] Verify unconfigured models hidden by default, shown disabled when
filter enabled
- [ ] Try setting a disabled model as Smart/Fast model — should be
rejected
- [ ] Try creating an agent with a disabled model — should be rejected
- [ ] Verify admin panel AI tab requires admin access


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-02-24 10:14:24 +01:00
committed by GitHub
parent c369baf63a
commit 9a3852bf04
41 changed files with 1320 additions and 101 deletions
@@ -0,0 +1,233 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
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,
IconFilter,
IconPlug,
IconRobot,
IconSearch,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import {
useCreateDatabaseConfigVariableMutation,
useGetAdminAiModelsQuery,
useSetAdminAiModelEnabledMutation,
} from '~/generated-metadata/graphql';
import { getModelIcon } from '~/pages/settings/ai/utils/getModelIcon';
import { getModelProviderLabel } from '~/pages/settings/ai/utils/getModelProviderLabel';
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
export const SettingsAdminAI = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const [searchQuery, setSearchQuery] = useState('');
const [showUnconfigured, setShowUnconfigured] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
const { refetch: refetchClientConfig } = useClientConfig();
const { data } = useGetAdminAiModelsQuery();
const [createConfigVariable] = useCreateDatabaseConfigVariableMutation();
const [setModelEnabled] = useSetAdminAiModelEnabledMutation();
const autoEnableNewModels =
data?.getAdminAiModels?.autoEnableNewModels ?? true;
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 }],
});
await refetchClientConfig();
} catch {
enqueueErrorSnackBar({
message: t`Failed to update auto-enable setting`,
});
}
};
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`,
});
}
};
let filteredModels = models;
if (!showUnconfigured) {
filteredModels = filteredModels.filter((model) => model.isAvailable);
}
if (!showDeprecated) {
filteredModels = filteredModels.filter((model) => !model.deprecated);
}
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 providerLabel = getModelProviderLabel(modelFamily);
if (isDeprecated === true) {
return providerLabel ? t`${providerLabel} — Deprecated` : t`Deprecated`;
}
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`}
/>
<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}
/>
</Card>
</Section>
<Section>
<H2Title
title={t`All Models`}
description={t`Toggle model availability across all workspaces`}
/>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="admin-model-search"
LeftIcon={IconSearch}
placeholder={t`Search a model...`}
value={searchQuery}
onChange={setSearchQuery}
/>
<Dropdown
dropdownId="admin-ai-models-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
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>
}
/>
</StyledSearchAndFilterContainer>
<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>
</>
);
};
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const SET_ADMIN_AI_MODEL_ENABLED = gql`
mutation SetAdminAiModelEnabled($modelId: String!, $enabled: Boolean!) {
setAdminAiModelEnabled(modelId: $modelId, enabled: $enabled)
}
`;
@@ -0,0 +1,19 @@
import { gql } from '@apollo/client';
export const GET_ADMIN_AI_MODELS = gql`
query GetAdminAiModels {
getAdminAiModels {
autoEnableNewModels
models {
modelId
label
modelFamily
inferenceProvider
isAvailable
isAdminEnabled
deprecated
isRecommended
}
}
}
`;
@@ -4,7 +4,12 @@ import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAd
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { t } from '@lingui/core/macro';
import { IconHeart, IconSettings2, IconVariable } from 'twenty-ui/display';
import {
IconHeart,
IconSettings2,
IconSparkles,
IconVariable,
} from 'twenty-ui/display';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
export const SettingsAdminContent = () => {
@@ -19,15 +24,21 @@ export const SettingsAdminContent = () => {
Icon: IconSettings2,
disabled: !canAccessFullAdminPanel && !canImpersonate,
},
{
id: SETTINGS_ADMIN_TABS.AI,
title: t`AI`,
Icon: IconSparkles,
disabled: !canAccessFullAdminPanel,
},
{
id: SETTINGS_ADMIN_TABS.CONFIG_VARIABLES,
title: t`Config Variables`,
title: t`Config`,
Icon: IconVariable,
disabled: !canAccessFullAdminPanel,
},
{
id: SETTINGS_ADMIN_TABS.HEALTH_STATUS,
title: t`Health Status`,
title: t`Health`,
Icon: IconHeart,
disabled: !canAccessFullAdminPanel,
},
@@ -1,3 +1,4 @@
import { SettingsAdminAI } from '@/settings/admin-panel/ai/components/SettingsAdminAI';
import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral';
import { SettingsAdminConfigVariables } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables';
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
@@ -15,6 +16,8 @@ export const SettingsAdminTabContent = () => {
switch (activeTabId) {
case SETTINGS_ADMIN_TABS.GENERAL:
return <SettingsAdminGeneral />;
case SETTINGS_ADMIN_TABS.AI:
return <SettingsAdminAI />;
case SETTINGS_ADMIN_TABS.CONFIG_VARIABLES:
return <SettingsAdminConfigVariables />;
case SETTINGS_ADMIN_TABS.HEALTH_STATUS:
@@ -1,5 +1,6 @@
export const SETTINGS_ADMIN_TABS = {
GENERAL: 'general',
AI: 'ai',
CONFIG_VARIABLES: 'config-variables',
HEALTH_STATUS: 'health-status',
};