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:
+233
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+7
@@ -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)
|
||||
}
|
||||
`;
|
||||
+19
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
-3
@@ -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,
|
||||
},
|
||||
|
||||
+3
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user