feat(editor): Disable restricted model providers in the agent model selector (#40658)

This commit is contained in:
Theodore Nastos 2026-10-09 12:59:23 +00:00 committed by GitHub
parent ff7ef60841
commit cde7a9dbd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 224 additions and 11 deletions

View File

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

View File

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

View File

@ -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);
}

View File

@ -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.",

View File

@ -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' });

View File

@ -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[];

View File

@ -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' }];

View File

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

View File

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