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:
@@ -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
+26
-19
@@ -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'
|
||||
> & {
|
||||
|
||||
-2
@@ -70,8 +70,6 @@ describe('useColumnDefinitionsFromObjectMetadata', () => {
|
||||
eventLogRetentionDays: 365 * 3,
|
||||
fastModel: DEFAULT_FAST_MODEL,
|
||||
smartModel: DEFAULT_SMART_MODEL,
|
||||
autoEnableNewAiModels: true,
|
||||
disabledAiModelIds: [],
|
||||
enabledAiModelIds: [],
|
||||
useRecommendedModels: true,
|
||||
});
|
||||
|
||||
+45
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+177
-160
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+149
@@ -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>
|
||||
);
|
||||
};
|
||||
+245
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+91
@@ -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',
|
||||
);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const AI_PROVIDER_SOURCE = {
|
||||
CATALOG: 'catalog',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
+17
@@ -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: '🇫🇷' },
|
||||
};
|
||||
+10
@@ -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}`,
|
||||
}));
|
||||
+22
@@ -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 },
|
||||
};
|
||||
+7
@@ -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)
|
||||
}
|
||||
`;
|
||||
+7
@@ -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)
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REMOVE_AI_PROVIDER = gql`
|
||||
mutation RemoveAiProvider($providerName: String!) {
|
||||
removeAiProvider(providerName: $providerName)
|
||||
}
|
||||
`;
|
||||
+10
@@ -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)
|
||||
}
|
||||
`;
|
||||
+10
@@ -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)
|
||||
}
|
||||
`;
|
||||
+7
@@ -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)
|
||||
}
|
||||
`;
|
||||
+12
-3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_AI_PROVIDERS = gql`
|
||||
query GetAiProviders {
|
||||
getAiProviders
|
||||
}
|
||||
`;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_MODELS_DEV_PROVIDERS = gql`
|
||||
query GetModelsDevProviders {
|
||||
getModelsDevProviders {
|
||||
id
|
||||
modelCount
|
||||
npm
|
||||
}
|
||||
}
|
||||
`;
|
||||
+18
@@ -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';
|
||||
+5
@@ -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'>;
|
||||
+13
@@ -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;
|
||||
};
|
||||
+34
@@ -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,
|
||||
}));
|
||||
+46
-1
@@ -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');
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+3
-1
@@ -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>
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+394
@@ -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
@@ -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"]}'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-7
@@ -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'`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+38
@@ -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.
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
+23
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+233
-13
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+2
-4
@@ -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 = ({
|
||||
|
||||
+5
-8
@@ -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,
|
||||
},
|
||||
|
||||
+61
-18
@@ -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()
|
||||
|
||||
+2
@@ -35,6 +35,8 @@ describe('ClientConfigService', () => {
|
||||
provide: AiModelRegistryService,
|
||||
useValue: {
|
||||
getAdminFilteredModels: jest.fn().mockReturnValue([]),
|
||||
getRecommendedModelIds: jest.fn().mockReturnValue(new Set()),
|
||||
getModelConfig: jest.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+54
-26
@@ -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,
|
||||
|
||||
+7
@@ -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', () => {
|
||||
|
||||
+7
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -4,4 +4,5 @@ export enum ConfigVariableType {
|
||||
ARRAY = 'array',
|
||||
STRING = 'string',
|
||||
ENUM = 'enum',
|
||||
JSON = 'json',
|
||||
}
|
||||
|
||||
+71
@@ -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;
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
-11
@@ -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 })
|
||||
|
||||
+7
-9
@@ -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,
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ import {
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
|
||||
@@ -83,7 +83,7 @@ describe('AiAgentRoleService', () => {
|
||||
name: 'Test Agent',
|
||||
description: 'Test agent for unit tests',
|
||||
prompt: 'You are a test agent',
|
||||
modelId: 'gpt-4o' as ModelId,
|
||||
modelId: 'openai/gpt-4o' as ModelId,
|
||||
workspaceId: testWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -251,7 +251,7 @@ export class AgentService {
|
||||
|
||||
const {
|
||||
flatAgentMaps: recomputedFlatAgentMaps,
|
||||
flatRoleTargetByAgentIdMaps: recmputedFlatRoleTargetByAgentIdMaps,
|
||||
flatRoleTargetByAgentIdMaps: recomputedFlatRoleTargetByAgentIdMaps,
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatAgentMaps',
|
||||
'flatRoleTargetByAgentIdMaps',
|
||||
@@ -263,7 +263,7 @@ export class AgentService {
|
||||
});
|
||||
|
||||
const existingRoleTarget =
|
||||
recmputedFlatRoleTargetByAgentIdMaps[flatAgentToUpdate.id];
|
||||
recomputedFlatRoleTargetByAgentIdMaps[flatAgentToUpdate.id];
|
||||
|
||||
return {
|
||||
...updatedAgent,
|
||||
|
||||
@@ -11,7 +11,7 @@ import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
|
||||
@ObjectType('Agent')
|
||||
export class AgentDTO {
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/typ
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
|
||||
import { AgentResponseFormatJson } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-json.validator';
|
||||
import { AgentResponseFormatText } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-text.validator';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
|
||||
@InputType()
|
||||
export class CreateAgentInput {
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/typ
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
|
||||
import { AgentResponseFormatJson } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-json.validator';
|
||||
import { AgentResponseFormatText } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-text.validator';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
|
||||
@InputType()
|
||||
export class UpdateAgentInput {
|
||||
|
||||
+2
-4
@@ -10,10 +10,8 @@ import {
|
||||
|
||||
import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/types/agent-response-format.type';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
|
||||
import {
|
||||
DEFAULT_SMART_MODEL,
|
||||
ModelId,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
||||
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
|
||||
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceEventEmitterModule, AiModelsModule],
|
||||
providers: [AIBillingService],
|
||||
exports: [AIBillingService],
|
||||
providers: [AiBillingService],
|
||||
exports: [AiBillingService],
|
||||
})
|
||||
export class AiBillingModule {}
|
||||
|
||||
+10
-13
@@ -2,16 +2,13 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import {
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
describe('AIBillingService', () => {
|
||||
let service: AIBillingService;
|
||||
describe('AiBillingService', () => {
|
||||
let service: AiBillingService;
|
||||
let mockWorkspaceEventEmitter: jest.Mocked<WorkspaceEventEmitter>;
|
||||
let mockAiModelRegistryService: jest.Mocked<
|
||||
Pick<AiModelRegistryService, 'getEffectiveModelConfig'>
|
||||
@@ -20,8 +17,8 @@ describe('AIBillingService', () => {
|
||||
const openaiModelConfig = {
|
||||
modelId: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
modelFamily: ModelFamily.OPENAI,
|
||||
inferenceProvider: InferenceProvider.OPENAI,
|
||||
modelFamily: ModelFamily.GPT,
|
||||
sdkPackage: '@ai-sdk/openai',
|
||||
inputCostPerMillionTokens: 2.5,
|
||||
outputCostPerMillionTokens: 10.0,
|
||||
cachedInputCostPerMillionTokens: 1.25,
|
||||
@@ -30,8 +27,8 @@ describe('AIBillingService', () => {
|
||||
const anthropicModelConfig = {
|
||||
modelId: 'claude-sonnet-4-5-20250929',
|
||||
label: 'Claude Sonnet 4.5',
|
||||
modelFamily: ModelFamily.ANTHROPIC,
|
||||
inferenceProvider: InferenceProvider.ANTHROPIC,
|
||||
modelFamily: ModelFamily.CLAUDE,
|
||||
sdkPackage: '@ai-sdk/anthropic',
|
||||
inputCostPerMillionTokens: 3.0,
|
||||
outputCostPerMillionTokens: 15.0,
|
||||
cachedInputCostPerMillionTokens: 0.3,
|
||||
@@ -65,7 +62,7 @@ describe('AIBillingService', () => {
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AIBillingService,
|
||||
AiBillingService,
|
||||
{
|
||||
provide: WorkspaceEventEmitter,
|
||||
useValue: mockEventEmitterMethods,
|
||||
@@ -77,7 +74,7 @@ describe('AIBillingService', () => {
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AIBillingService>(AIBillingService);
|
||||
service = module.get<AiBillingService>(AiBillingService);
|
||||
mockWorkspaceEventEmitter = module.get(WorkspaceEventEmitter);
|
||||
mockAiModelRegistryService = module.get(AiModelRegistryService);
|
||||
});
|
||||
|
||||
+3
-8
@@ -7,7 +7,7 @@ import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/bil
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
|
||||
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
@@ -17,8 +17,8 @@ export type BillingUsageInput = {
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AIBillingService {
|
||||
private readonly logger = new Logger(AIBillingService.name);
|
||||
export class AiBillingService {
|
||||
private readonly logger = new Logger(AiBillingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
@@ -27,11 +27,6 @@ export class AIBillingService {
|
||||
|
||||
calculateCost(modelId: ModelId, billingInput: BillingUsageInput): number {
|
||||
const model = this.aiModelRegistryService.getEffectiveModelConfig(modelId);
|
||||
|
||||
if (!model) {
|
||||
throw new Error(`AI model with id ${modelId} not found`);
|
||||
}
|
||||
|
||||
const { usage, cacheCreationTokens = 0 } = billingInput;
|
||||
|
||||
const breakdown = computeCostBreakdown(model, {
|
||||
|
||||
+6
-8
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
type AIModelConfig,
|
||||
ModelFamily,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
|
||||
export type TokenUsageInput = {
|
||||
inputTokens?: number;
|
||||
@@ -47,17 +45,17 @@ export const computeCostBreakdown = (
|
||||
const cachedInputTokens = safeNumber(usage.cachedInputTokens);
|
||||
const cacheCreationTokens = safeNumber(usage.cacheCreationTokens);
|
||||
|
||||
const isAnthropicFamily = model.modelFamily === ModelFamily.ANTHROPIC;
|
||||
const isAnthropicTokenReporting = model.modelFamily === ModelFamily.CLAUDE;
|
||||
|
||||
const adjustedInputTokens = isAnthropicFamily
|
||||
const adjustedInputTokens = isAnthropicTokenReporting
|
||||
? rawInputTokens
|
||||
: Math.max(0, rawInputTokens - cachedInputTokens);
|
||||
|
||||
const adjustedOutputTokens = isAnthropicFamily
|
||||
const adjustedOutputTokens = isAnthropicTokenReporting
|
||||
? rawOutputTokens
|
||||
: Math.max(0, rawOutputTokens - reasoningTokens);
|
||||
|
||||
const totalInputTokens = isAnthropicFamily
|
||||
const totalInputTokens = isAnthropicTokenReporting
|
||||
? rawInputTokens + cachedInputTokens + cacheCreationTokens
|
||||
: rawInputTokens + cacheCreationTokens;
|
||||
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ export class AgentChatController {
|
||||
) {
|
||||
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
|
||||
throw new AgentException(
|
||||
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
|
||||
'No AI models are available. Configure at least one AI provider.',
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/
|
||||
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
|
||||
import { extractCacheCreationTokens } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
|
||||
import { AgentChatService } from './agent-chat.service';
|
||||
|
||||
+57
-38
@@ -1,8 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { groq } from '@ai-sdk/groq';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import {
|
||||
convertToModelMessages,
|
||||
stepCountIs,
|
||||
@@ -36,7 +33,7 @@ import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agen
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
|
||||
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
|
||||
import {
|
||||
@@ -44,11 +41,17 @@ import {
|
||||
type ExtractedFile,
|
||||
} from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util';
|
||||
import {
|
||||
type AIModelConfig,
|
||||
InferenceProvider,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
AI_SDK_ANTHROPIC,
|
||||
AI_SDK_BEDROCK,
|
||||
AI_SDK_OPENAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import {
|
||||
AiModelRegistryService,
|
||||
type RegisteredAIModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
|
||||
|
||||
export type ChatExecutionOptions = {
|
||||
@@ -72,11 +75,12 @@ export class ChatExecutionService {
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly skillService: SkillService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly aiBillingService: AIBillingService,
|
||||
private readonly aiBillingService: AiBillingService,
|
||||
private readonly agentActorContextService: AgentActorContextService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly systemPromptBuilder: SystemPromptBuilderService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
private readonly sdkProviderFactory: SdkProviderFactoryService,
|
||||
) {}
|
||||
|
||||
async streamChat({
|
||||
@@ -138,7 +142,7 @@ export class ChatExecutionService {
|
||||
);
|
||||
|
||||
const { tools: nativeSearchTools, callableToolNames: searchToolNames } =
|
||||
this.getNativeWebSearchTools(registeredModel.inferenceProvider);
|
||||
this.getNativeWebSearchTools(registeredModel);
|
||||
|
||||
// Direct tools: native provider tools + preloaded tools.
|
||||
// These are callable directly AND as fallback through execute_tool.
|
||||
@@ -203,9 +207,9 @@ export class ChatExecutionService {
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
providerOptions:
|
||||
registeredModel.inferenceProvider === InferenceProvider.ANTHROPIC
|
||||
registeredModel.sdkPackage === AI_SDK_ANTHROPIC
|
||||
? { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
||||
: registeredModel.inferenceProvider === InferenceProvider.BEDROCK
|
||||
: registeredModel.sdkPackage === AI_SDK_BEDROCK
|
||||
? { bedrock: { cacheControl: { type: 'ephemeral' } } }
|
||||
: undefined,
|
||||
};
|
||||
@@ -325,46 +329,61 @@ export class ChatExecutionService {
|
||||
return context;
|
||||
}
|
||||
|
||||
private getNativeWebSearchTools(inferenceProvider: InferenceProvider): {
|
||||
private getNativeWebSearchTools(model: RegisteredAIModel): {
|
||||
tools: ToolSet;
|
||||
callableToolNames: string[];
|
||||
} {
|
||||
switch (inferenceProvider) {
|
||||
case InferenceProvider.ANTHROPIC:
|
||||
return {
|
||||
tools: { web_search: anthropic.tools.webSearch_20250305() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
case InferenceProvider.BEDROCK: {
|
||||
const bedrockProvider =
|
||||
this.aiModelRegistryService.getBedrockProvider();
|
||||
const empty = { tools: {}, callableToolNames: [] };
|
||||
const providerName = model.providerName;
|
||||
|
||||
if (bedrockProvider) {
|
||||
return {
|
||||
tools: {
|
||||
web_search:
|
||||
bedrockProvider.tools.webSearch_20250305() as ToolSet[string],
|
||||
},
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
if (!providerName) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_ANTHROPIC: {
|
||||
const provider =
|
||||
this.sdkProviderFactory.getRawAnthropicProvider(providerName);
|
||||
|
||||
if (!provider) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return { tools: {}, callableToolNames: [] };
|
||||
}
|
||||
case InferenceProvider.OPENAI:
|
||||
return {
|
||||
tools: { web_search: openai.tools.webSearch() },
|
||||
tools: { web_search: provider.tools.webSearch_20250305() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
case InferenceProvider.GROQ:
|
||||
}
|
||||
case AI_SDK_BEDROCK: {
|
||||
const provider =
|
||||
this.sdkProviderFactory.getRawBedrockProvider(providerName);
|
||||
|
||||
if (!provider) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
tools: {
|
||||
web_search: groq.tools.browserSearch({}) as ToolSet[string],
|
||||
web_search: provider.tools.webSearch_20250305() as ToolSet[string],
|
||||
},
|
||||
callableToolNames: [],
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
}
|
||||
case AI_SDK_OPENAI: {
|
||||
const provider =
|
||||
this.sdkProviderFactory.getRawOpenAIProvider(providerName);
|
||||
|
||||
if (!provider) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
tools: { web_search: provider.tools.webSearch() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { tools: {}, callableToolNames: [] };
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user