mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Disable restricted model providers in the agent model selector (#40658)
This commit is contained in:
parent
ff7ef60841
commit
cde7a9dbd9
|
|
@ -121,6 +121,53 @@ describe('N8nAiModelSelectorDropdown', () => {
|
|||
expect(getByText('Connected')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows the restricted badge in the trigger instead of the credential name', () => {
|
||||
const { getByTestId, queryByTestId } = render(N8nAiModelSelectorDropdown, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
selectedCredentialName: 'Production OpenAI credential',
|
||||
restrictedLabel: 'Restricted',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('ai-model-selector-restricted-badge')).toHaveTextContent('Restricted');
|
||||
expect(queryByTestId('ai-model-selector-credential')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the restricted badge while loading', () => {
|
||||
const { queryByTestId } = render(N8nAiModelSelectorDropdown, {
|
||||
props: { ...defaultProps, restrictedLabel: 'Restricted', isLoading: true },
|
||||
});
|
||||
|
||||
expect(queryByTestId('ai-model-selector-restricted-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the scope line and lock on a restricted item', async () => {
|
||||
const { getByTestId } = render(N8nAiModelSelectorDropdown, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items: [
|
||||
...baseItems,
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
disabled: true,
|
||||
data: { restrictedLabel: 'Restricted on this instance' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('ai-model-selector'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('ai-model-selector-restriction')).toHaveTextContent(
|
||||
'Restricted on this instance',
|
||||
),
|
||||
);
|
||||
expect(getByTestId('ai-model-selector-restricted-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits select and search events', async () => {
|
||||
const { getByTestId, getByText, emitted } = render(N8nAiModelSelectorDropdown, {
|
||||
props: {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export interface AiModelSelectorMenuItemData {
|
|||
loading?: boolean;
|
||||
/** Trailing "✓ <label>" marker, e.g. for the provider currently in use. */
|
||||
connectedLabel?: string;
|
||||
/** A policy blocks this item; the label is the scope line rendered under the item label. */
|
||||
restrictedLabel?: string;
|
||||
}
|
||||
|
||||
export type AiModelSelectorMenuItem<
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const {
|
|||
selectedCredentialName,
|
||||
credentialsMissing = false,
|
||||
credentialsMissingLabel,
|
||||
restrictedLabel,
|
||||
noMatchLabel,
|
||||
showBorder = true,
|
||||
disabled = false,
|
||||
|
|
@ -52,6 +53,8 @@ const {
|
|||
credentialsMissing?: boolean;
|
||||
/** Text shown when credentials are required but missing. */
|
||||
credentialsMissingLabel?: string;
|
||||
/** Badge shown in the trigger when a policy blocks the selected item. */
|
||||
restrictedLabel?: string;
|
||||
/** Empty-state text shown when search returns no matching items. */
|
||||
noMatchLabel: string;
|
||||
/** Whether the trigger button should render with a border. */
|
||||
|
|
@ -134,7 +137,16 @@ defineExpose({
|
|||
</N8nText>
|
||||
<span v-if="isLoading" :class="$style.loading"></span>
|
||||
<N8nBadge
|
||||
v-if="credentialsMissing && !isLoading"
|
||||
v-else-if="restrictedLabel"
|
||||
variant="danger"
|
||||
size="small"
|
||||
:class="$style.credsBadge"
|
||||
data-test-id="ai-model-selector-restricted-badge"
|
||||
>
|
||||
{{ restrictedLabel }}
|
||||
</N8nBadge>
|
||||
<N8nBadge
|
||||
v-else-if="credentialsMissing"
|
||||
variant="danger"
|
||||
size="small"
|
||||
:class="$style.credsBadge"
|
||||
|
|
@ -142,7 +154,7 @@ defineExpose({
|
|||
{{ resolvedCredentialsMissingLabel }}
|
||||
</N8nBadge>
|
||||
<N8nText
|
||||
v-else-if="selectedCredentialName && !isLoading"
|
||||
v-else-if="selectedCredentialName"
|
||||
bold
|
||||
color="text-light"
|
||||
:data-test-id="credentialDataTestId"
|
||||
|
|
@ -217,8 +229,19 @@ defineExpose({
|
|||
</template>
|
||||
|
||||
<template #item-trailing="{ item, ui }">
|
||||
<span v-if="item.data?.restrictedLabel" :class="[$style.restricted, ui.class]">
|
||||
<N8nText size="xsmall" color="text-light" data-test-id="ai-model-selector-restriction">
|
||||
{{ item.data.restrictedLabel }}
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
icon="lock"
|
||||
size="xsmall"
|
||||
color="text-light"
|
||||
data-test-id="ai-model-selector-restricted-icon"
|
||||
/>
|
||||
</span>
|
||||
<N8nTooltip
|
||||
v-if="item.data?.description"
|
||||
v-else-if="item.data?.description"
|
||||
:content="truncateBeforeLast(item.data.description, 320, 0)"
|
||||
:class="ui.class"
|
||||
placement="right"
|
||||
|
|
@ -330,7 +353,8 @@ defineExpose({
|
|||
margin-inline: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.connected {
|
||||
.connected,
|
||||
.restricted {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
|
|
@ -338,6 +362,10 @@ defineExpose({
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.restricted {
|
||||
padding-left: var(--spacing--md);
|
||||
}
|
||||
|
||||
.connectedIcon {
|
||||
color: var(--color--success);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5739,6 +5739,7 @@
|
|||
"agents.modelSelector.connectTo": "Connect to {provider}",
|
||||
"agents.modelSelector.models": "Models",
|
||||
"agents.modelSelector.credentialsMissing": "Credentials missing",
|
||||
"agents.modelSelector.restricted": "Restricted",
|
||||
"agents.modelSelector.freeCredits.label": "Use free OpenAI credits",
|
||||
"agents.modelSelector.freeCredits.badge": "free credits",
|
||||
"agents.modelSelector.freeCredits.description": "Get {credits} free OpenAI API credits. Try it with gpt-5-mini.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
|
||||
import { useAgentEvalsStore } from '../agentEvals.store';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
|
@ -237,6 +238,12 @@ describe('AgentBuilderEditorColumn', () => {
|
|||
expect(tabs.text()).not.toContain('Raw');
|
||||
});
|
||||
|
||||
it('loads the type availability policies for the project', async () => {
|
||||
await mountColumn();
|
||||
|
||||
expect(useTypeAvailabilityPoliciesStore().fetchForProject).toHaveBeenCalledWith('project-1');
|
||||
});
|
||||
|
||||
it('does not render per-tab headings or descriptions for Sessions and Settings', async () => {
|
||||
const sessionsWrapper = await mountColumn({ activeMainTab: 'sessions' });
|
||||
const settingsWrapper = await mountColumn({ activeMainTab: 'settings' });
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
|
||||
import type * as typeAvailabilityPolicies from '@n8n/frontend-module-type-availability-policies';
|
||||
import type * as permissions from '@n8n/permissions';
|
||||
|
||||
import type {
|
||||
|
|
@ -29,6 +30,7 @@ type TestMenuItem = {
|
|||
credentialType?: string;
|
||||
provider?: string;
|
||||
connectedLabel?: string;
|
||||
restrictedLabel?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -45,6 +47,9 @@ const freeAiCreditsState = vi.hoisted(() => ({
|
|||
claimCreditsAndGetCredential: vi.fn(),
|
||||
}));
|
||||
const canCreateCredentials = vi.hoisted(() => ({ value: true }));
|
||||
const restrictedCredentialTypes = vi.hoisted(() => ({
|
||||
value: {} as Record<string, 'instance' | 'project'>,
|
||||
}));
|
||||
const openNewCredential = vi.hoisted(() => vi.fn());
|
||||
const openModalWithData = vi.hoisted(() => vi.fn());
|
||||
const aiGatewayState = vi.hoisted(() => ({
|
||||
|
|
@ -79,6 +84,9 @@ const baseText = vi.hoisted(() =>
|
|||
'agents.modelSelector.freeCredits.description':
|
||||
'Get {credits} free OpenAI API credits. Try it with gpt-5-mini.',
|
||||
'generic.loadingEllipsis': 'Loading...',
|
||||
'agents.modelSelector.restricted': 'Restricted',
|
||||
'typeAvailabilityPolicies.restrictedNode.scope.instance': 'Restricted on this instance',
|
||||
'typeAvailabilityPolicies.restrictedNode.scope.project': 'Restricted in this project',
|
||||
}[key] ?? key;
|
||||
|
||||
return Object.entries(options?.interpolate ?? {}).reduce(
|
||||
|
|
@ -94,6 +102,16 @@ vi.mock('@n8n/i18n', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/frontend-module-type-availability-policies', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof typeAvailabilityPolicies>()),
|
||||
useTypeAvailabilityPoliciesStore: () => ({
|
||||
getCredentialTypeAvailability: (name: string) => {
|
||||
const scope = restrictedCredentialTypes.value[name];
|
||||
return scope ? { name, available: false, scope } : { name, available: true };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/permissions', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof permissions>()),
|
||||
getResourcePermissions: () => ({ credential: { create: canCreateCredentials.value } }),
|
||||
|
|
@ -110,6 +128,7 @@ vi.mock('@n8n/design-system', async () => {
|
|||
'selectedLabel',
|
||||
'selectedCredentialName',
|
||||
'credentialsMissing',
|
||||
'restrictedLabel',
|
||||
'noMatchLabel',
|
||||
'disabled',
|
||||
'dataTestId',
|
||||
|
|
@ -265,6 +284,7 @@ describe('AgentModelSelector', () => {
|
|||
freeAiCreditsState.claimingCredits.value = false;
|
||||
freeAiCreditsState.claimCreditsAndGetCredential.mockReset();
|
||||
canCreateCredentials.value = true;
|
||||
restrictedCredentialTypes.value = {};
|
||||
aiGatewayState.isEnabled.value = false;
|
||||
aiGatewayState.supportedTypes = new Set<string>();
|
||||
aiGatewayState.balance.value = undefined;
|
||||
|
|
@ -337,6 +357,65 @@ describe('AgentModelSelector', () => {
|
|||
expect(items.filter((item) => item.data?.connectedLabel).length).toBe(1);
|
||||
});
|
||||
|
||||
it('disables a provider whose credential type is restricted on the instance', async () => {
|
||||
restrictedCredentialTypes.value = { openAiApi: 'instance' };
|
||||
|
||||
const wrapper = await mountSelector({ anthropic: null });
|
||||
|
||||
expect(getProviderItem(wrapper, 'openai')).toMatchObject({
|
||||
disabled: true,
|
||||
data: { restrictedLabel: 'Restricted on this instance' },
|
||||
});
|
||||
expect(getProviderItem(wrapper, 'openai')?.children).toBeUndefined();
|
||||
expect(getProviderItem(wrapper, 'anthropic')?.children?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the project scope line for a project-level restriction', async () => {
|
||||
restrictedCredentialTypes.value = { openAiApi: 'project' };
|
||||
|
||||
const wrapper = await mountSelector({ anthropic: null });
|
||||
|
||||
expect(getProviderItem(wrapper, 'openai')?.data?.restrictedLabel).toBe(
|
||||
'Restricted in this project',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a multi-credential-type provider usable while one type is still allowed', async () => {
|
||||
restrictedCredentialTypes.value = { azureOpenAiApi: 'instance' };
|
||||
|
||||
const wrapper = await mountSelector({ anthropic: null });
|
||||
|
||||
expect(getProviderItem(wrapper, 'azure-openai')?.disabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('disables a multi-credential-type provider once every type is restricted', async () => {
|
||||
restrictedCredentialTypes.value = {
|
||||
azureOpenAiApi: 'instance',
|
||||
azureEntraCognitiveServicesOAuth2Api: 'instance',
|
||||
};
|
||||
|
||||
const wrapper = await mountSelector({ anthropic: null });
|
||||
|
||||
expect(getProviderItem(wrapper, 'azure-openai')?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the restricted badge for a selected restricted provider and pins it without the connected marker', async () => {
|
||||
restrictedCredentialTypes.value = { anthropicApi: 'instance' };
|
||||
|
||||
const wrapper = await mountSelector({ anthropic: 'anthropic-cred' });
|
||||
const items = getDropdown(wrapper).props('items') as TestMenuItem[];
|
||||
|
||||
expect(getDropdown(wrapper).props('restrictedLabel')).toBe('Restricted');
|
||||
expect(items[0]).toMatchObject({ id: 'anthropic', disabled: true });
|
||||
expect(items[0].data?.connectedLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows no restricted badge for an unrestricted selection', async () => {
|
||||
const wrapper = await mountSelector({ anthropic: 'anthropic-cred' });
|
||||
|
||||
expect(getDropdown(wrapper).props('restrictedLabel')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not mark anything as connected when there is no selected model', async () => {
|
||||
const wrapper = await mountSelector({ anthropic: null }, { selectedModel: null });
|
||||
const items = getDropdown(wrapper).props('items') as TestMenuItem[];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { N8nCard, N8nIcon, N8nTabs, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { AgentConfigValidationIssue, AgentFileDto } from '@n8n/api-types';
|
||||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
} from '../types';
|
||||
import type { ToolOpenTarget, ToolPickerMode } from './AgentCapabilitiesSection.types';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
|
||||
import AgentSessionsListView from '../views/AgentSessionsListView.vue';
|
||||
import AgentAdvancedPanel from './AgentAdvancedPanel.vue';
|
||||
import AgentBudgetPanel from './AgentBudgetPanel.vue';
|
||||
|
|
@ -74,6 +75,15 @@ const isMcpAvailable = computed(
|
|||
() => settingsStore.isModuleActive('mcp') && !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled,
|
||||
);
|
||||
|
||||
const typeAvailabilityPoliciesStore = useTypeAvailabilityPoliciesStore();
|
||||
watch(
|
||||
() => props.projectId,
|
||||
(projectId) => {
|
||||
if (projectId) void typeAvailabilityPoliciesStore.fetchForProject(projectId);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeMainTab': [tab: AgentBuilderMainTab];
|
||||
'update:config': [updates: Partial<AgentJsonConfig>, meta?: { source: 'auto' }];
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
|
|||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useFreeAiCredits } from '@/app/composables/useFreeAiCredits';
|
||||
import { useAiGateway } from '@/app/composables/useAiGateway';
|
||||
import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
|
||||
import { AI_GATEWAY_MANAGED_TAG, type CredentialTypeAvailability } from '@n8n/api-types';
|
||||
import {
|
||||
SCOPE_LABEL_KEY,
|
||||
useTypeAvailabilityPoliciesStore,
|
||||
} from '@n8n/frontend-module-type-availability-policies';
|
||||
import ModelSelectorTriggerIcon from './model-selector/ModelSelectorTriggerIcon.vue';
|
||||
import ModelSelectorItemLeadingIcon from './model-selector/ModelSelectorItemLeadingIcon.vue';
|
||||
import { buildMenuItemId, parseMenuItemId } from './model-selector/menuItemId';
|
||||
|
|
@ -87,6 +91,7 @@ const dropdownRef = useTemplateRef('dropdownRef');
|
|||
const credentialsStore = useCredentialsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const uiStore = useUIStore();
|
||||
const typeAvailabilityPoliciesStore = useTypeAvailabilityPoliciesStore();
|
||||
const aiGateway = useAiGateway();
|
||||
const { ensureLoaded, getDefaultModelForPicker, getVerificationStatus } = useModelCatalog();
|
||||
const pendingDefaultCredential = ref<{
|
||||
|
|
@ -213,13 +218,41 @@ const freeOpenAiCreditsDescription = computed(() =>
|
|||
}),
|
||||
);
|
||||
|
||||
function getProviderRestriction(provider: AgentModelProvider): CredentialTypeAvailability | null {
|
||||
const availabilities = getProviderCredentialTypes(provider).map((credentialType) =>
|
||||
typeAvailabilityPoliciesStore.getCredentialTypeAvailability(credentialType),
|
||||
);
|
||||
|
||||
return availabilities.every((availability) => !availability.available) ? availabilities[0] : null;
|
||||
}
|
||||
|
||||
const restrictedLabel = computed(() =>
|
||||
selectedModel && getProviderRestriction(selectedModel.provider)
|
||||
? i18n.baseText('agents.modelSelector.restricted')
|
||||
: undefined,
|
||||
);
|
||||
|
||||
function providerToMenuItem(provider: AgentModelProvider): MenuItem {
|
||||
const definition = AGENT_MODEL_PROVIDER_DEFINITIONS[provider];
|
||||
const credentialTypes = getProviderCredentialTypes(provider);
|
||||
const restriction = getProviderRestriction(provider);
|
||||
if (restriction) {
|
||||
return {
|
||||
id: provider,
|
||||
label: definition.displayName,
|
||||
disabled: true,
|
||||
data: {
|
||||
provider,
|
||||
credentialType: credentialTypes[0],
|
||||
restrictedLabel: i18n.baseText(SCOPE_LABEL_KEY[restriction.scope ?? 'instance']),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const credentialOptions = getCredentialsForProvider(provider);
|
||||
const selectedProviderCredentialId = credentials?.[provider] ?? null;
|
||||
const models = modelsByProvider[provider]?.models ?? [];
|
||||
const modelsUnavailable = modelsByProvider[provider]?.unavailable === true;
|
||||
const credentialTypes = getProviderCredentialTypes(provider);
|
||||
const isAiGatewayManagedSelected = selectedProviderCredentialId === AI_GATEWAY_MANAGED_TAG;
|
||||
const hasProviderCredential =
|
||||
isAiGatewayManagedSelected ||
|
||||
|
|
@ -451,10 +484,14 @@ const menu = computed(() => {
|
|||
|
||||
if (selectedProvider) {
|
||||
const item = providerToMenuItem(selectedProvider);
|
||||
menuItems.push({
|
||||
...item,
|
||||
data: { ...item.data, connectedLabel: i18n.baseText('agents.modelSelector.connected') },
|
||||
});
|
||||
menuItems.push(
|
||||
item.data?.restrictedLabel
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
data: { ...item.data, connectedLabel: i18n.baseText('agents.modelSelector.connected') },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (aiGatewayProviders.length) {
|
||||
|
|
@ -660,6 +697,7 @@ defineExpose({
|
|||
:selected-label="selectedLabel"
|
||||
:selected-credential-name="selectedCredentialName"
|
||||
:credentials-missing="isCredentialsMissing"
|
||||
:restricted-label="restrictedLabel"
|
||||
:no-match-label="i18n.baseText('agents.modelSelector.noMatch')"
|
||||
:disabled="disabled"
|
||||
data-test-id="agent-model-selector"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export { TypeAvailabilityPoliciesModule } from './type-availability-policies.module';
|
||||
export { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
|
||||
export { SCOPE_LABEL_KEY } from './type-availability-policies.constants';
|
||||
export {
|
||||
describeNodeTypeRestriction,
|
||||
getNodeTypeRestriction,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user