feat: AI Gateway Top Up Flow (#28113)

Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
This commit is contained in:
Michael Kret 2026-04-10 19:13:06 +03:00 committed by GitHub
parent 9ab974b7b0
commit 2c4b9749c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1135 additions and 354 deletions

View file

@ -8,7 +8,7 @@ import { N8N_VERSION, AI_ASSISTANT_SDK_VERSION } from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import type { License } from '@/license';
import { AiGatewayService } from '@/services/ai-gateway.service';
import type { Project, User } from '@n8n/db';
import type { Project, User, UserRepository } from '@n8n/db';
import type { OwnershipService } from '@/services/ownership.service';
const BASE_URL = 'http://gateway.test';
@ -33,6 +33,7 @@ function makeService({
baseUrl = BASE_URL as string | null,
isAiGatewayLicensed = true,
ownershipService = mock<OwnershipService>(),
userRepository = mock<UserRepository>({ findOneBy: jest.fn().mockResolvedValue(null) }),
} = {}) {
const globalConfig = {
aiAssistant: { baseUrl: baseUrl ?? undefined },
@ -51,6 +52,7 @@ function makeService({
licenseState,
instanceSettings,
ownershipService,
userRepository,
);
}
@ -192,6 +194,60 @@ describe('AiGatewayService', () => {
);
});
it('includes userEmail and userName in token body when user exists', async () => {
const userRepository = mock<UserRepository>({
findOneBy: jest
.fn()
.mockResolvedValue(
mock<User>({ email: 'alice@example.com', firstName: 'Alice', lastName: 'Smith' }),
),
});
mockConfigThenToken(fetchMock);
const service = makeService({ userRepository });
await service.getSyntheticCredential({ credentialType: 'googlePalmApi', userId: USER_ID });
expect(fetchMock).toHaveBeenNthCalledWith(
2,
`${BASE_URL}/v1/gateway/credentials`,
expect.objectContaining({
body: JSON.stringify({
licenseCert: LICENSE_CERT,
userEmail: 'alice@example.com',
userName: 'Alice Smith',
}),
}),
);
});
it('omits userName from token body when user has no first or last name', async () => {
const userRepository = mock<UserRepository>({
findOneBy: jest
.fn()
.mockResolvedValue(
mock<User>({ email: 'alice@example.com', firstName: '', lastName: '' }),
),
});
mockConfigThenToken(fetchMock);
const service = makeService({ userRepository });
await service.getSyntheticCredential({ credentialType: 'googlePalmApi', userId: USER_ID });
const body = JSON.parse(fetchMock.mock.calls[1][1].body as string);
expect(body.userEmail).toBe('alice@example.com');
expect(body.userName).toBeUndefined();
});
it('omits userEmail and userName from token body when user is not found', async () => {
mockConfigThenToken(fetchMock);
const service = makeService(); // userRepository returns null by default
await service.getSyntheticCredential({ credentialType: 'googlePalmApi', userId: USER_ID });
const body = JSON.parse(fetchMock.mock.calls[1][1].body as string);
expect(body).toEqual({ licenseCert: LICENSE_CERT });
});
it('caches config and token — second call makes no additional fetches', async () => {
mockConfigThenToken(fetchMock);
const service = makeService();

View file

@ -2,6 +2,7 @@ import { LicenseState } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
import { Service } from '@n8n/di';
import { UserRepository } from '@n8n/db';
import { InstanceSettings } from 'n8n-core';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
@ -42,6 +43,7 @@ export class AiGatewayService {
private readonly licenseState: LicenseState,
private readonly instanceSettings: InstanceSettings,
private readonly ownershipService: OwnershipService,
private readonly userRepository: UserRepository,
) {}
/**
@ -168,12 +170,15 @@ export class AiGatewayService {
throw new UserError(`Failed to fetch AI Gateway credits: HTTP ${response.status}`);
}
const data = (await response.json()) as GatewayCreditsResponse;
if (typeof data.creditsQuota !== 'number' || typeof data.creditsRemaining !== 'number') {
return this.parseCreditsResponse(await response.json());
}
private parseCreditsResponse(data: unknown): GatewayCreditsResponse {
const d = data as GatewayCreditsResponse;
if (typeof d.creditsQuota !== 'number' || typeof d.creditsRemaining !== 'number') {
throw new UserError('AI Gateway returned an invalid credits response.');
}
return data;
return d;
}
private requireBaseUrl(): string {
@ -254,12 +259,21 @@ export class AiGatewayService {
private async fetchAndCacheToken(userId: string, key: string): Promise<string> {
const baseUrl = this.requireBaseUrl();
const licenseCert = await this.license.loadCertStr();
const [licenseCert, user] = await Promise.all([
this.license.loadCertStr(),
this.userRepository.findOneBy({ id: userId }),
]);
const response = await fetch(`${baseUrl}/v1/gateway/credentials`, {
method: 'POST',
headers: this.buildGatewayCredentialsHeaders(userId),
body: JSON.stringify({ licenseCert }),
body: JSON.stringify({
licenseCert,
...(user?.email && { userEmail: user.email }),
...(user && {
userName: [user.firstName, user.lastName].filter(Boolean).join(' ') || undefined,
}),
}),
});
if (!response.ok) {

View file

@ -0,0 +1,112 @@
<script lang="ts" setup>
/**
* A small pill-shaped label that can optionally act as a button.
* Use for inline status indicators, counts, or any short contextual label
*/
import { ref } from 'vue';
defineOptions({ name: 'N8nActionPill' });
withDefaults(
defineProps<{
/** Text to display when the default slot is empty. */
text?: string;
/** When set, swaps to this text on hover (e.g. show a count normally, an action label on hover). */
hoverText?: string;
/** Enables pointer cursor and hover styles — use when the pill triggers an action. */
clickable?: boolean;
/** 'small' matches BetaTag/PreviewTag scale (for menus/lists); 'medium' is the default. */
size?: 'small' | 'medium';
}>(),
{
text: undefined,
hoverText: undefined,
clickable: false,
size: 'medium',
},
);
defineEmits<{
click: [event: MouseEvent];
}>();
const hovered = ref(false);
</script>
<template>
<span
:class="[
$style.root,
size === 'small' && $style.small,
clickable && $style.clickable,
hoverText && hovered && $style.pressed,
]"
@mouseenter="hoverText && (hovered = true)"
@mouseleave="hoverText && (hovered = false)"
@click="$emit('click', $event)"
>
<span v-if="hoverText" :class="$style.labelGrid">
<span :class="[$style.label, hovered && $style.labelHidden]"
><slot>{{ text }}</slot></span
>
<span :class="[$style.label, !hovered && $style.labelHidden]">{{ hoverText }}</span>
</span>
<slot v-else>{{ text }}</slot>
</span>
</template>
<style lang="scss" module>
.root {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: var(--spacing--4xs) var(--spacing--2xs);
border: none;
border-radius: 16px;
background-color: var(--color--green-100);
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
color: var(--color--green-900);
white-space: nowrap;
box-shadow: none;
}
.small {
padding: var(--spacing--5xs) var(--spacing--4xs);
font-size: var(--font-size--3xs);
}
.clickable {
cursor: pointer;
user-select: none;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover {
background-color: var(--color--green-200);
color: var(--color--green-950);
}
}
.pressed {
background-color: var(--color--green-200);
color: var(--color--green-950);
}
.labelGrid {
display: grid;
}
.label {
grid-area: 1 / 1;
text-align: center;
transition: opacity 0.15s;
}
.labelHidden {
opacity: 0;
pointer-events: none;
}
</style>

View file

@ -182,6 +182,7 @@ import IconLucidePencilOff from '~icons/lucide/pencil-off';
import IconLucidePin from '~icons/lucide/pin';
import IconLucidePlay from '~icons/lucide/play';
import IconLucidePlug from '~icons/lucide/plug';
import IconLucidePlugZap from '~icons/lucide/plug-zap';
import IconLucidePlus from '~icons/lucide/plus';
import IconLucidePocketKnife from '~icons/lucide/pocket-knife';
import IconLucidePower from '~icons/lucide/power';
@ -404,6 +405,7 @@ export const deprecatedIconSet = {
play: IconLucidePlay,
'play-circle': IconLucideCirclePlay,
plug: IconLucidePlug,
'plug-zap': IconLucidePlugZap,
plus: IconLucidePlus,
'plus-circle': IconLucideCirclePlus,
'plus-square': IconLucideSquarePlus,
@ -645,6 +647,7 @@ export const updatedIconSet = {
pin: IconLucidePin,
play: IconLucidePlay,
plug: IconLucidePlug,
'plug-zap': IconLucidePlugZap,
plus: IconLucidePlus,
'pocket-knife': IconLucidePocketKnife,
power: IconLucidePower,

View file

@ -4,6 +4,7 @@ import { computed } from 'vue';
import type { IMenuItem } from '@n8n/design-system/types';
import BetaTag from '../BetaTag/BetaTag.vue';
import N8nActionPill from '../N8nActionPill/ActionPill.vue';
import N8nIcon from '../N8nIcon';
import type { IconName } from '../N8nIcon/icons';
import N8nRoute from '../N8nRoute';
@ -143,6 +144,7 @@ const tooltipPlacement = computed(() => {
text="New"
:class="$style.newTag"
/>
<N8nActionPill v-if="!compact && item.creditsTag" size="small" :text="item.creditsTag" />
</div>
<N8nIcon v-if="item.children && !compact" icon="chevron-right" color="text-light" />
</N8nRoute>

View file

@ -3,6 +3,7 @@ import { ElTag } from 'element-plus';
import { useI18n } from '../../composables/useI18n';
import type { NodeCreatorTag } from '../../types/node-creator-node';
import N8nActionPill from '../N8nActionPill/ActionPill.vue';
import N8nBadge from '../N8nBadge';
import N8nIcon from '../N8nIcon';
@ -44,8 +45,9 @@ const { t } = useI18n();
<div>
<div :class="$style.details">
<span :class="$style.name" data-test-id="node-creator-item-name" v-text="title" />
<N8nActionPill v-if="tag?.pill" size="small" :text="tag.text" />
<ElTag
v-if="tag"
v-else-if="tag"
:class="$style.tag"
disable-transitions
size="small"

View file

@ -178,10 +178,10 @@ watch(
<style lang="scss" module>
.popoverContent {
--popover-slide-x: 0px;
--popover-slide-y: 0px;
--popover-origin-x: center;
--popover-origin-y: center;
--popover--offset--slide-x: 0;
--popover--offset--slide-y: 0;
--popover--offset--origin-x: center;
--popover--offset--origin-y: center;
border-radius: var(--radius);
background-color: var(--color--foreground--tint-2);
@ -191,7 +191,7 @@ watch(
rgba(0, 0, 0, 0.1) 0 10px 15px -3px,
rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
will-change: transform, opacity;
transform-origin: var(--popover-origin-x) var(--popover-origin-y);
transform-origin: var(--popover--offset--origin-x) var(--popover--offset--origin-y);
&.enableSlideIn {
animation-duration: var(--duration--snappy);
@ -208,53 +208,54 @@ watch(
}
.popoverContent[data-state='open'][data-side='top'] {
--popover-slide-y: -2px;
--popover-origin-y: bottom;
--popover--offset--slide-y: -2px;
--popover--offset--origin-y: bottom;
}
.popoverContent[data-state='open'][data-side='right'] {
--popover-slide-x: 2px;
--popover-origin-x: left;
--popover--offset--slide-x: 2px;
--popover--offset--origin-x: left;
}
.popoverContent[data-state='open'][data-side='bottom'] {
--popover-slide-y: 2px;
--popover-origin-y: top;
--popover--offset--slide-y: 2px;
--popover--offset--origin-y: top;
}
.popoverContent[data-state='open'][data-side='left'] {
--popover-slide-x: -2px;
--popover-origin-x: right;
--popover--offset--slide-x: -2px;
--popover--offset--origin-x: right;
}
.popoverContent[data-state='open'][data-side='top'][data-align='start'],
.popoverContent[data-state='open'][data-side='bottom'][data-align='start'] {
--popover-slide-x: -2px;
--popover-origin-x: left;
--popover--offset--slide-x: -2px;
--popover--offset--origin-x: left;
}
.popoverContent[data-state='open'][data-side='top'][data-align='end'],
.popoverContent[data-state='open'][data-side='bottom'][data-align='end'] {
--popover-slide-x: 2px;
--popover-origin-x: right;
--popover--offset--slide-x: 2px;
--popover--offset--origin-x: right;
}
.popoverContent[data-state='open'][data-side='left'][data-align='start'],
.popoverContent[data-state='open'][data-side='right'][data-align='start'] {
--popover-slide-y: -2px;
--popover-origin-y: top;
--popover--offset--slide-y: -2px;
--popover--offset--origin-y: top;
}
.popoverContent[data-state='open'][data-side='left'][data-align='end'],
.popoverContent[data-state='open'][data-side='right'][data-align='end'] {
--popover-slide-y: 2px;
--popover-origin-y: bottom;
--popover--offset--slide-y: 2px;
--popover--offset--origin-y: bottom;
}
@keyframes popoverIn {
from {
opacity: 0;
transform: translate(var(--popover-slide-x), var(--popover-slide-y)) scale(0.96);
transform: translate(var(--popover--offset--slide-x), var(--popover--offset--slide-y))
scale(0.96);
}
to {
opacity: 1;

View file

@ -60,6 +60,7 @@ export { default as N8nOption } from './N8nOption';
export { default as N8nSectionHeader } from './N8nSectionHeader';
export { default as N8nSelectableList } from './N8nSelectableList';
export { default as N8nPreviewTag } from './PreviewTag/PreviewTag.vue';
export { default as N8nActionPill } from './N8nActionPill/ActionPill.vue';
export { default as N8nPopover } from './N8nPopover';
export { default as N8nPopoverReka } from './N8nPopover'; // Alias for backwards compatibility
export { default as N8nPromptInput } from './N8nPromptInput';

View file

@ -40,6 +40,7 @@ export type IMenuItem = {
beta?: boolean;
preview?: boolean;
new?: boolean;
creditsTag?: string;
};
export interface ICustomMenuItem {

View file

@ -3,4 +3,6 @@ import type { ElTag } from 'element-plus';
export type NodeCreatorTag = {
text: string;
type?: (typeof ElTag)['type'];
/** When true, renders as N8nActionPill instead of ElTag. */
pill?: boolean;
};

View file

@ -2004,9 +2004,15 @@
"nodeCredentials.emptyState.noCredentials": "No credentials yet",
"nodeCredentials.emptyState.setupCredential": "Set up credential",
"nodeCredentials.oauth.accountConnected": "Account connected successfully",
"aiGateway.toggle.label": "Connect via n8n Gateway",
"aiGateway.toggle.description": "n8n Gateway is the easy way to manage AI model usage",
"aiGateway.toggle.tokensRemaining": "{count} credits remaining",
"aiGateway.toggle.label": "Connect via n8n Connect",
"aiGateway.toggle.description": "n8n Connect is the easy way to manage AI model usage",
"aiGateway.toggle.topUp": "Top up",
"aiGateway.credentialMode.sectionLabel": "Credential",
"aiGateway.credentialMode.creditsShort": "{count} credits",
"aiGateway.credentialMode.n8nConnect.title": "n8n Connect",
"aiGateway.credentialMode.n8nConnect.subtitle": "No API key required",
"aiGateway.credentialMode.own.title": "My own credential",
"aiGateway.credentialMode.own.subtitle": "Use your own API key",
"nodeCredentials.oauth.accountConnectionFailed": "Account connection failed",
"nodeErrorView.cause": "Cause",
"nodeErrorView.copyToClipboard": "Copy to Clipboard",
@ -4198,19 +4204,24 @@
"settings.ai.confirm.message.builderDisabled": "Disabling data sending will reduce the effectiveness of AI features. Are you sure you want to proceed?",
"settings.ai.confirm.message.builderEnabled": "Disabling data sending will turn off the AI Workflow Builder and reduce the effectiveness of AI features. Are you sure you want to proceed?",
"settings.ai.confirm.confirmButtonText": "Yes, disable",
"settings.n8nGateway": "n8n Gateway",
"settings.n8nGateway.title": "n8n Gateway",
"settings.n8nGateway.description": "View your AI model usage through the n8n Gateway.",
"settings.n8nGateway.usage.title": "Usage",
"settings.n8nGateway.usage.refresh": "Refresh",
"settings.n8nGateway.usage.col.date": "Date",
"settings.n8nGateway.usage.col.provider": "Provider",
"settings.n8nGateway.usage.col.model": "Model",
"settings.n8nGateway.usage.col.inputTokens": "Input Tokens",
"settings.n8nGateway.usage.col.outputTokens": "Output Tokens",
"settings.n8nGateway.usage.col.credits": "Credits",
"settings.n8nGateway.usage.empty": "No usage records found.",
"settings.n8nGateway.usage.loadMore": "Load more",
"settings.n8nConnect": "n8n Connect",
"settings.n8nConnect.title": "n8n Connect",
"settings.n8nConnect.description": "The easy way to manage AI usage and cost",
"settings.n8nConnect.usage.title": "Usage",
"settings.n8nConnect.usage.refresh": "Refresh",
"settings.n8nConnect.usage.col.date": "Date",
"settings.n8nConnect.usage.col.provider": "Provider",
"settings.n8nConnect.usage.col.model": "Model",
"settings.n8nConnect.usage.col.inputTokens": "Input Tokens",
"settings.n8nConnect.usage.col.outputTokens": "Output Tokens",
"settings.n8nConnect.usage.col.credits": "Credits",
"settings.n8nConnect.usage.empty": "No usage records found.",
"settings.n8nConnect.usage.loadMore": "Load more",
"settings.n8nConnect.credits.title": "Credits",
"settings.n8nConnect.credits.remaining": "{remaining} / {quota} credits remaining",
"settings.n8nConnect.credits.quota": "credits total",
"settings.n8nConnect.credits.topUp": "Top up credits",
"settings.n8nConnect.usage.refresh.tooltip": "Refresh usage records",
"settings.instanceAi": "Instance AI",
"settings.instanceAi.description": "Configure the Instance AI agent, model, memory, and permissions.",
"settings.n8nAgent": "n8n Agent",

View file

@ -4,10 +4,12 @@ import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import AiGatewayToggle from './AiGatewayToggle.vue';
import AiGatewaySelector from './AiGatewaySelector.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
const mockFetchCredits = vi.fn().mockResolvedValue(undefined);
const mockCreditsRemaining = ref<number | undefined>(undefined);
@ -28,9 +30,9 @@ vi.mock('vue-router', async (importOriginal) => ({
useRouter: vi.fn(() => ({})),
}));
const renderComponent = createComponentRenderer(AiGatewayToggle);
const renderComponent = createComponentRenderer(AiGatewaySelector);
describe('AiGatewayToggle', () => {
describe('AiGatewaySelector', () => {
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
beforeEach(() => {
@ -43,73 +45,87 @@ describe('AiGatewayToggle', () => {
});
describe('rendering', () => {
it('should render the toggle switch and label', () => {
it('should render both radio cards', () => {
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
expect(screen.getByTestId('ai-gateway-toggle-switch')).toBeInTheDocument();
expect(screen.getByText('Connect via n8n Gateway')).toBeInTheDocument();
expect(screen.getByTestId('ai-gateway-selector')).toBeInTheDocument();
expect(screen.getByTestId('ai-gateway-selector-connect')).toBeInTheDocument();
expect(screen.getByTestId('ai-gateway-mode-card-own')).toBeInTheDocument();
expect(screen.getByText('n8n Connect')).toBeInTheDocument();
expect(screen.getByText('My own credential')).toBeInTheDocument();
});
it('should not show the callout when disabled', () => {
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
expect(
screen.queryByText('n8n Gateway is the easy way to manage AI model usage'),
).not.toBeInTheDocument();
});
it('should show the callout when enabled', () => {
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
expect(
screen.getByText('n8n Gateway is the easy way to manage AI model usage'),
).toBeInTheDocument();
});
it('should show credits badge when creditsRemaining is defined', () => {
it('should show credits badge when aiGatewayEnabled and creditsRemaining is defined', () => {
mockCreditsRemaining.value = 5;
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
expect(screen.getByText('5 credits remaining')).toBeInTheDocument();
expect(screen.getByText('5 credits')).toBeInTheDocument();
});
it('should not show credits badge when creditsRemaining is undefined', () => {
mockCreditsRemaining.value = undefined;
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
expect(screen.queryByText(/credits remaining/)).not.toBeInTheDocument();
expect(screen.queryByText(/\d+ credits$/)).not.toBeInTheDocument();
});
it('should disable the switch in readonly mode', () => {
it('should not show credits badge when aiGatewayEnabled is false', () => {
mockCreditsRemaining.value = 5;
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
expect(screen.queryByText(/\d+ credits$/)).not.toBeInTheDocument();
});
it('should disable both cards in readonly mode', () => {
renderComponent({ props: { aiGatewayEnabled: false, readonly: true } });
expect(screen.getByTestId('ai-gateway-toggle-switch')).toBeDisabled();
expect(screen.getByTestId('ai-gateway-selector-connect')).toBeDisabled();
expect(screen.getByTestId('ai-gateway-mode-card-own')).toBeDisabled();
});
});
describe('toggle emission', () => {
it('should emit toggle with true when switched on', async () => {
describe('selection', () => {
it('should emit select with true when n8n Connect card is clicked while disabled', async () => {
const { emitted } = renderComponent({
props: { aiGatewayEnabled: false, readonly: false },
});
await userEvent.click(screen.getByTestId('ai-gateway-toggle-switch'));
await userEvent.click(screen.getByTestId('ai-gateway-selector-connect'));
expect(emitted('toggle')).toBeTruthy();
expect(emitted('toggle')![0]).toEqual([true]);
});
it('should emit toggle with false when switched off', async () => {
it('should emit select with false when own credential card is clicked while gateway is active', async () => {
const { emitted } = renderComponent({
props: { aiGatewayEnabled: true, readonly: false },
});
await userEvent.click(screen.getByTestId('ai-gateway-toggle-switch'));
await userEvent.click(screen.getByTestId('ai-gateway-mode-card-own'));
expect(emitted('toggle')).toBeTruthy();
expect(emitted('toggle')![0]).toEqual([false]);
});
it('should not emit when n8n Connect card is clicked while already selected', async () => {
const { emitted } = renderComponent({
props: { aiGatewayEnabled: true, readonly: false },
});
await userEvent.click(screen.getByTestId('ai-gateway-selector-connect'));
expect(emitted('toggle')).toBeFalsy();
});
it('should not emit when own credential card is clicked while already selected', async () => {
const { emitted } = renderComponent({
props: { aiGatewayEnabled: false, readonly: false },
});
await userEvent.click(screen.getByTestId('ai-gateway-mode-card-own'));
expect(emitted('toggle')).toBeFalsy();
});
});
describe('fetchCredits — mount watch (immediate)', () => {
@ -175,4 +191,28 @@ describe('AiGatewayToggle', () => {
expect(mockFetchCredits).not.toHaveBeenCalled();
});
});
describe('top-up badge', () => {
it('opens top-up modal when badge is clicked', async () => {
mockCreditsRemaining.value = 5;
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
const uiStore = useUIStore();
vi.spyOn(uiStore, 'openModalWithData');
await userEvent.click(screen.getByText('5 credits'));
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: AI_GATEWAY_TOP_UP_MODAL_KEY,
data: { credentialType: undefined },
});
});
it('renders "Top up" label alongside the credits label in the badge', () => {
mockCreditsRemaining.value = 5;
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
expect(screen.getByText('Top up')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,270 @@
<script setup lang="ts">
import { watch } from 'vue';
import { N8nActionPill } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useAiGateway } from '@/app/composables/useAiGateway';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
const props = defineProps<{
aiGatewayEnabled: boolean;
readonly: boolean;
credentialType?: string;
}>();
const emit = defineEmits<{
toggle: [enabled: boolean];
}>();
const i18n = useI18n();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const telemetry = useTelemetry();
const { creditsRemaining, fetchCredits } = useAiGateway();
// Fetch when enabled (on mount if already enabled, or when toggled on)
watch(
() => props.aiGatewayEnabled,
(enabled) => {
if (enabled) void fetchCredits();
},
{ immediate: true },
);
// Refresh after each execution completes so the badge reflects consumed credits.
watch(
() => workflowsStore.workflowExecutionData,
(executionData) => {
if (
(executionData?.finished || executionData?.stoppedAt !== undefined) &&
props.aiGatewayEnabled
) {
void fetchCredits();
}
},
);
function selectGateway(): void {
if (props.readonly || props.aiGatewayEnabled) return;
emit('toggle', true);
}
function selectOwnCredential(): void {
if (props.readonly || !props.aiGatewayEnabled) return;
emit('toggle', false);
}
function onBadgeClick(event: MouseEvent): void {
event.stopPropagation();
if (props.readonly) return;
telemetry.track('User clicked ai gateway top up', {
source: 'credential_selector',
credential_type: props.credentialType,
});
uiStore.openModalWithData({
name: AI_GATEWAY_TOP_UP_MODAL_KEY,
data: { credentialType: props.credentialType },
});
}
</script>
<template>
<div
:class="[$style.wrapper, !aiGatewayEnabled && $style.withGap]"
data-test-id="ai-gateway-selector"
>
<div role="radiogroup" :aria-label="i18n.baseText('aiGateway.credentialMode.sectionLabel')">
<button
type="button"
role="radio"
:aria-checked="aiGatewayEnabled"
:disabled="readonly"
data-test-id="ai-gateway-selector-connect"
:class="[$style.card, aiGatewayEnabled ? $style.cardSelected : $style.cardIdle]"
@click="selectGateway"
>
<span :class="$style.cardMain">
<span
:class="[$style.radioOuter, aiGatewayEnabled && $style.radioOuterOn]"
aria-hidden="true"
/>
<span :class="$style.textBlock">
<span :class="$style.title">
{{ i18n.baseText('aiGateway.credentialMode.n8nConnect.title') }}
</span>
<span :class="$style.subtitle">
{{ i18n.baseText('aiGateway.credentialMode.n8nConnect.subtitle') }}
</span>
</span>
</span>
<N8nActionPill
v-if="aiGatewayEnabled && creditsRemaining !== undefined"
:clickable="!readonly"
size="small"
:text="
i18n.baseText('aiGateway.credentialMode.creditsShort', {
interpolate: { count: String(creditsRemaining) },
})
"
:hover-text="!readonly ? i18n.baseText('aiGateway.toggle.topUp') : undefined"
@click="onBadgeClick"
/>
</button>
<button
type="button"
role="radio"
:aria-checked="!aiGatewayEnabled"
:disabled="readonly"
data-test-id="ai-gateway-mode-card-own"
:class="[$style.card, !aiGatewayEnabled ? $style.cardSelected : $style.cardIdle]"
@click="selectOwnCredential"
>
<span :class="$style.cardMain">
<span
:class="[$style.radioOuter, !aiGatewayEnabled && $style.radioOuterOn]"
aria-hidden="true"
/>
<span :class="$style.textBlock">
<span :class="$style.title">
{{ i18n.baseText('aiGateway.credentialMode.own.title') }}
</span>
<span :class="$style.subtitle">
{{ i18n.baseText('aiGateway.credentialMode.own.subtitle') }}
</span>
</span>
</span>
</button>
</div>
</div>
</template>
<style lang="scss" module>
.wrapper {
display: flex;
flex-direction: column;
margin-top: var(--spacing--4xs);
}
.withGap {
margin-bottom: var(--spacing--2xs);
}
.card {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
width: 100%;
text-align: left;
padding: var(--spacing--2xs) var(--spacing--xs);
border: var(--border-width) var(--border-style) var(--color--foreground);
border-radius: var(--radius);
cursor: pointer;
background: transparent;
font: inherit;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&:not(:last-child) {
margin-bottom: var(--spacing--2xs);
}
&:disabled {
cursor: not-allowed;
opacity: 0.85;
}
&:focus-visible {
outline: 2px solid var(--color--primary);
outline-offset: 2px;
}
}
.cardSelected {
background-color: var(--color--foreground);
border-color: var(--color--foreground--shade-1);
.title {
color: var(--color--text--shade-1);
}
.subtitle {
color: var(--color--text--tint-1);
}
}
.cardIdle {
&:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color--foreground) 30%, transparent);
}
}
.cardMain {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
flex: 1;
}
.radioOuter {
position: relative;
flex-shrink: 0;
box-sizing: border-box;
width: 1rem;
height: 1rem;
border-radius: 50%;
border: var(--border-width) var(--border-style) var(--color--text--tint-2);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.radioOuterOn {
border-color: var(--color--primary);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--color--primary);
transform: translate(-50%, -50%);
}
}
.textBlock {
display: flex;
align-items: baseline;
gap: var(--spacing--3xs);
min-width: 0;
flex: 1;
}
.title {
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--regular);
line-height: var(--line-height--sm);
color: var(--color--text--tint-1);
white-space: nowrap;
}
.subtitle {
font-size: var(--font-size--3xs);
font-weight: var(--font-weight--regular);
line-height: var(--line-height--sm);
color: var(--color--text--tint-2);
white-space: nowrap;
}
</style>

View file

@ -1,98 +0,0 @@
<script setup lang="ts">
import { watch } from 'vue';
import { N8nCallout, N8nSwitch2, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useAiGateway } from '@/app/composables/useAiGateway';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
const props = defineProps<{
aiGatewayEnabled: boolean;
readonly: boolean;
}>();
const emit = defineEmits<{
toggle: [enabled: boolean];
}>();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const { creditsRemaining, fetchCredits } = useAiGateway();
// Fetch when enabled (on mount if already enabled, or when toggled on)
watch(
() => props.aiGatewayEnabled,
(enabled) => {
if (enabled) void fetchCredits();
},
{ immediate: true },
);
// Refresh after each execution completes so the badge reflects consumed credits.
// An execution is considered done when finished===true (saved runs) or stoppedAt is set
// (step/test runs) mirrors the same check used in workflows.store.ts.
watch(
() => workflowsStore.workflowExecutionData,
(executionData) => {
if (
(executionData?.finished || executionData?.stoppedAt !== undefined) &&
props.aiGatewayEnabled
) {
void fetchCredits();
}
},
);
</script>
<template>
<div :class="$style.wrapper" data-test-id="ai-gateway-toggle">
<div :class="$style.toggleRow">
<N8nSwitch2
:model-value="props.aiGatewayEnabled"
data-test-id="ai-gateway-toggle-switch"
@update:model-value="(val) => emit('toggle', Boolean(val))"
:disabled="props.readonly"
/>
<N8nText size="small" color="text-dark">
{{ i18n.baseText('aiGateway.toggle.label') }}
</N8nText>
</div>
<N8nCallout v-if="props.aiGatewayEnabled" theme="success" iconless>
{{ i18n.baseText('aiGateway.toggle.description') }}
<template v-if="creditsRemaining !== undefined" #trailingContent>
<span :class="$style.tokensBadge">
{{
i18n.baseText('aiGateway.toggle.tokensRemaining', {
interpolate: { count: String(creditsRemaining) },
})
}}
</span>
</template>
</N8nCallout>
</div>
</template>
<style lang="scss" module>
.wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
margin-top: var(--spacing--xs);
}
.toggleRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.tokensBadge {
flex-shrink: 0;
padding: var(--spacing--4xs) var(--spacing--xs);
border: var(--border-width) var(--border-style) var(--callout--border-color--success);
border-radius: var(--radius);
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
color: var(--color--success--shade-1);
white-space: nowrap;
}
</style>

View file

@ -19,6 +19,7 @@ import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHe
import { useKeybindings } from '@/app/composables/useKeybindings';
import { useSidebarLayout } from '@/app/composables/useSidebarLayout';
import { useSettingsItems } from '@/app/composables/useSettingsItems';
import { useAiGateway } from '@/app/composables/useAiGateway';
import MainSidebarHeader from '@/app/components/MainSidebarHeader.vue';
import BottomMenu from '@/app/components/BottomMenu.vue';
import MainSidebarSourceControl from '@/app/components/MainSidebarSourceControl.vue';
@ -51,6 +52,7 @@ const { isCollapsed, sidebarWidth, onResizeStart, onResize, onResizeEnd, toggleC
useSidebarLayout();
const { settingsItems } = useSettingsItems();
const { fetchCredits, isEnabled: isAiGatewayEnabled } = useAiGateway();
// Component data
const basePath = ref('');
@ -224,6 +226,7 @@ watch(isCollapsed, () => {
onMounted(() => {
basePath.value = rootStore.baseUrl;
if (isAiGatewayEnabled.value) void fetchCredits();
void nextTick(() => {
checkOverflow();

View file

@ -38,6 +38,7 @@ import {
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
AI_BUILDER_DIFF_MODAL_KEY,
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
AI_GATEWAY_TOP_UP_MODAL_KEY,
} from '@/app/constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@ -130,6 +131,7 @@ import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishMod
import UpdatesPanel from './UpdatesPanel.vue';
import CredentialResolverEditModal from '@/app/components/CredentialResolverEditModal.vue';
import AIBuilderDiffModal from '@/features/ai/assistant/components/Agent/AIBuilderDiffModal.vue';
import AiGatewayTopUpModal from '@/features/ai/gateway/components/AiGatewayTopUpModal.vue';
import InstanceAiCredentialSetupModal, {
type InstanceAiCredentialSetupModalData,
} from '@/features/ai/instanceAi/components/InstanceAiCredentialSetupModal.vue';
@ -501,6 +503,10 @@ import InstanceAiCredentialSetupModal, {
</template>
</ModalRoot>
<ModalRoot :name="AI_GATEWAY_TOP_UP_MODAL_KEY">
<AiGatewayTopUpModal />
</ModalRoot>
<!-- Dynamic modals from modules -->
<DynamicModalLoader />
</div>

View file

@ -1,8 +1,10 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { ABOUT_MODAL_KEY } from '@/app/constants';
import { N8nIcon, N8nLink, N8nMenuItem, N8nText } from '@n8n/design-system';
import { useSettingsItems } from '../composables/useSettingsItems';
import { useAiGateway } from '../composables/useAiGateway';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUIStore } from '../stores/ui.store';
@ -16,6 +18,11 @@ const rootStore = useRootStore();
const uiStore = useUIStore();
const { settingsItems } = useSettingsItems();
const { fetchCredits, isEnabled } = useAiGateway();
onMounted(() => {
if (isEnabled.value) void fetchCredits();
});
</script>
<template>

View file

@ -29,30 +29,16 @@ vi.mock('@n8n/stores/useRootStore', () => ({
}));
const mockIsAiGatewayEnabled = ref(false);
const mockGetVariant = vi.fn().mockReturnValue(undefined);
vi.mock('@/app/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({ isAiGatewayEnabled: mockIsAiGatewayEnabled.value })),
}));
vi.mock('@/app/stores/posthog.store', () => ({
usePostHog: vi.fn(() => ({ getVariant: mockGetVariant })),
}));
vi.mock('@/app/constants', async () => {
const actual = await vi.importActual('@/app/constants');
return {
...actual,
AI_GATEWAY_EXPERIMENT: { name: 'ai_gateway', variant: 'enabled' },
};
});
describe('useAiGateway', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockIsAiGatewayEnabled.value = false;
mockGetVariant.mockReturnValue(undefined);
mockGetGatewayConfig.mockResolvedValue({ nodes: [], credentialTypes: [], providerConfig: {} });
});
@ -68,7 +54,6 @@ describe('useAiGateway', () => {
});
it('should fetch and update creditsRemaining and creditsQuota when enabled', async () => {
mockGetVariant.mockReturnValue('enabled');
mockIsAiGatewayEnabled.value = true;
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 7, creditsQuota: 10 });
@ -82,7 +67,6 @@ describe('useAiGateway', () => {
});
it('should keep previous values on API error', async () => {
mockGetVariant.mockReturnValue('enabled');
mockIsAiGatewayEnabled.value = true;
// First successful call
@ -101,7 +85,6 @@ describe('useAiGateway', () => {
});
it('should share credits state across multiple composable instances', async () => {
mockGetVariant.mockReturnValue('enabled');
mockIsAiGatewayEnabled.value = true;
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 3, creditsQuota: 5 });

View file

@ -1,14 +1,11 @@
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useSettingsStore } from '@/app/stores/settings.store';
import { usePostHog } from '@/app/stores/posthog.store';
import { AI_GATEWAY_EXPERIMENT } from '@/app/constants';
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
export function useAiGateway() {
const settingsStore = useSettingsStore();
const postHogStore = usePostHog();
const router = useRouter();
const { saveCurrentWorkflow } = useWorkflowSaving({ router });
const aiGatewayStore = useAiGatewayStore();
@ -17,11 +14,7 @@ export function useAiGateway() {
const creditsQuota = computed(() => aiGatewayStore.creditsQuota);
const fetchError = computed(() => aiGatewayStore.fetchError);
const isEnabled = computed(
() =>
postHogStore.getVariant(AI_GATEWAY_EXPERIMENT.name) === AI_GATEWAY_EXPERIMENT.variant &&
settingsStore.isAiGatewayEnabled,
);
const isEnabled = computed(() => settingsStore.isAiGatewayEnabled);
async function fetchCredits(): Promise<void> {
if (!isEnabled.value) return;

View file

@ -1,5 +1,6 @@
import { useRouter } from 'vue-router';
import { useUserHelpers } from './useUserHelpers';
import { useAiGateway } from './useAiGateway';
import { computed } from 'vue';
import type { IMenuItem } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@ -8,8 +9,6 @@ import { useUIStore } from '../stores/ui.store';
import { useSettingsStore } from '../stores/settings.store';
import { hasPermission } from '../utils/rbac/permissions';
import { MIGRATION_REPORT_TARGET_VERSION } from '@n8n/api-types';
import { usePostHog } from '../stores/posthog.store';
import { AI_GATEWAY_EXPERIMENT } from '../constants/experiments';
export function useSettingsItems() {
const router = useRouter();
@ -17,7 +16,7 @@ export function useSettingsItems() {
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const { canUserAccessRouteByName } = useUserHelpers(router);
const postHogStore = usePostHog();
const { creditsRemaining } = useAiGateway();
const settingsItems = computed<IMenuItem[]>(() => {
const menuItems: IMenuItem[] = [
@ -55,15 +54,19 @@ export function useSettingsItems() {
route: { to: { name: VIEWS.AI_SETTINGS } },
},
{
id: 'settings-n8n-gateway',
icon: 'network',
label: i18n.baseText('settings.n8nGateway'),
id: 'settings-n8n-connect',
icon: 'plug-zap',
label: i18n.baseText('settings.n8nConnect'),
position: 'top',
available:
postHogStore.getVariant(AI_GATEWAY_EXPERIMENT.name) === AI_GATEWAY_EXPERIMENT.variant &&
settingsStore.isAiGatewayEnabled &&
canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS),
settingsStore.isAiGatewayEnabled && canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS),
route: { to: { name: VIEWS.AI_GATEWAY_SETTINGS } },
creditsTag:
creditsRemaining.value !== undefined
? i18n.baseText('aiGateway.credentialMode.creditsShort', {
interpolate: { count: String(creditsRemaining.value) },
})
: undefined,
},
{
id: 'settings-project-roles',

View file

@ -104,8 +104,6 @@ export const CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT = createExperiment('077_chat_hu
export const FLOATING_CHAT_HUB_PANEL_EXPERIMENT = createExperiment('078_floating_chat_hub_panel');
export const AI_GATEWAY_EXPERIMENT = createExperiment('080_ai_gateway');
export const EXPERIMENTS_TO_TRACK = [
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
TEMPLATE_ONBOARDING_EXPERIMENT.name,
@ -131,5 +129,4 @@ export const EXPERIMENTS_TO_TRACK = [
AA_EXPERIMENT_CHECK.name,
CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT.name,
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
AI_GATEWAY_EXPERIMENT.name,
];

View file

@ -32,6 +32,7 @@ export const STOP_MANY_EXECUTIONS_MODAL_KEY = 'stopManyExecutions';
export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
export const WHATS_NEW_MODAL_KEY = 'whatsNew';
export const WORKFLOW_DIFF_MODAL_KEY = 'workflowDiff';
export const AI_GATEWAY_TOP_UP_MODAL_KEY = 'aiGatewayTopUp';
export const EXPERIMENT_TEMPLATE_RECO_V2_KEY = 'templateRecoV2';
export const EXPERIMENT_TEMPLATE_RECO_V3_KEY = 'templateRecoV3';
export const BINARY_DATA_VIEW_MODAL_KEY = 'binaryDataView';

View file

@ -22,7 +22,7 @@ import { projectsRoutes } from '@/features/collaboration/projects/projects.route
import { MfaRequiredError } from '@n8n/rest-api-client';
import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources';
import { usePostHog } from '@/app/stores/posthog.store';
import { TEMPLATE_SETUP_EXPERIENCE, AI_GATEWAY_EXPERIMENT } from '@/app/constants/experiments';
import { TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants/experiments';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
const ChangePasswordView = async () =>
@ -679,7 +679,7 @@ export const routes: RouteRecordRaw[] = [
},
},
{
path: 'n8n-gateway',
path: 'n8n-connect',
name: VIEWS.AI_GATEWAY_SETTINGS,
component: SettingsAiGatewayView,
meta: {
@ -687,18 +687,14 @@ export const routes: RouteRecordRaw[] = [
middlewareOptions: {
custom: () => {
const settingsStore = useSettingsStore();
const postHogStore = usePostHog();
return (
postHogStore.getVariant(AI_GATEWAY_EXPERIMENT.name) ===
AI_GATEWAY_EXPERIMENT.variant && settingsStore.isAiGatewayEnabled
);
return settingsStore.isAiGatewayEnabled;
},
},
telemetry: {
pageCategory: 'settings',
getProperties() {
return {
feature: 'ai-gateway',
feature: 'n8n-connect',
};
},
},

View file

@ -40,6 +40,7 @@ import {
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
AI_BUILDER_DIFF_MODAL_KEY,
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
AI_GATEWAY_TOP_UP_MODAL_KEY,
} from '@/app/constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@ -173,6 +174,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
AI_BUILDER_DIFF_MODAL_KEY,
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
AI_GATEWAY_TOP_UP_MODAL_KEY,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {

View file

@ -0,0 +1,85 @@
import { describe, it, vi, beforeEach, expect } from 'vitest';
import { screen } from '@testing-library/vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import AiGatewayTopUpModal from './AiGatewayTopUpModal.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { useUIStore } from '@/app/stores/ui.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
vi.mock('@/app/components/Modal.vue', () => ({
default: {
props: ['name', 'title'],
template:
'<div :data-modal-name="name" :data-modal-title="title"><slot name="content" /><slot name="footer" /></div>',
},
}));
const renderComponent = createComponentRenderer(AiGatewayTopUpModal);
function renderModal() {
const pinia = createTestingPinia();
setActivePinia(pinia);
return { ...renderComponent({ pinia }), pinia };
}
function renderModalWithCredentialType(
credentialTypeName: string,
options: { displayName?: string; documentationUrl?: string } = {},
) {
const pinia = createTestingPinia();
setActivePinia(pinia);
const uiStore = useUIStore();
uiStore.modalsById[AI_GATEWAY_TOP_UP_MODAL_KEY] = {
open: true,
data: { credentialType: credentialTypeName },
};
const credStore = useCredentialsStore();
credStore.state.credentialTypes[credentialTypeName] = {
name: credentialTypeName,
displayName: options.displayName ?? credentialTypeName,
documentationUrl: options.documentationUrl,
properties: [],
};
return renderComponent({ pinia });
}
describe('AiGatewayTopUpModal.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows the coming-soon content', () => {
renderModal();
expect(screen.getByText('Credit top up is coming soon')).toBeInTheDocument();
});
it('does not render any buy UI or footer buttons', () => {
renderModal();
expect(screen.queryByTestId('ai-gateway-topup-preset')).not.toBeInTheDocument();
expect(screen.queryByTestId('ai-gateway-topup-custom')).not.toBeInTheDocument();
expect(screen.queryByTestId('ai-gateway-topup-buy')).not.toBeInTheDocument();
});
describe('credentials docs link', () => {
it('does not show docs link when no credentialType is provided', () => {
renderModal();
expect(
screen.queryByTestId('ai-gateway-topup-credentials-docs-link'),
).not.toBeInTheDocument();
});
it('shows descriptive docs link with credential display name', () => {
renderModalWithCredentialType('openAiApi', { displayName: 'OpenAI' });
const link = screen.getByTestId('ai-gateway-topup-credentials-docs-link');
expect(link).toBeInTheDocument();
expect(link.textContent).toContain('See how to configure the OpenAI credential');
});
it('shows docs link when credentialType has a documentationUrl', () => {
renderModalWithCredentialType('openAiApi', { documentationUrl: 'openai' });
expect(screen.getByTestId('ai-gateway-topup-credentials-docs-link')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,127 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
import { useUIStore } from '@/app/stores/ui.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { BUILTIN_CREDENTIALS_DOCS_URL } from '@/app/constants/urls';
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
import Modal from '@/app/components/Modal.vue';
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const modalData = computed(() => uiStore.modalsById[AI_GATEWAY_TOP_UP_MODAL_KEY]?.data);
const credentialType = computed<string | undefined>(
() => modalData.value?.credentialType as string | undefined,
);
const credentialTypeInfo = computed(() => {
if (!credentialType.value) return null;
return credentialsStore.getCredentialTypeByName(credentialType.value) ?? null;
});
const credentialDocsUrl = computed(() => {
const type = credentialTypeInfo.value;
if (!type?.documentationUrl) return '';
if (type.documentationUrl.startsWith('http')) {
return type.documentationUrl;
}
return `${BUILTIN_CREDENTIALS_DOCS_URL}${type.documentationUrl}/`;
});
const credentialDocsLinkText = computed(() => {
const name = credentialTypeInfo.value?.displayName;
return name ? `See how to configure the ${name} credential` : 'See credential setup guide';
});
</script>
<template>
<Modal
:name="AI_GATEWAY_TOP_UP_MODAL_KEY"
width="520px"
custom-class="ai-gateway-topup-dialog"
data-test-id="ai-gateway-topup-modal"
>
<template #content>
<div :class="$style.contentWrapper">
<div :class="$style.body">
<N8nIcon icon="hourglass" size="xlarge" color="text-base" :class="$style.icon" />
<N8nText :class="$style.title" bold color="text-dark"
>Credit top up is coming soon</N8nText
>
<div :class="$style.paragraphs">
<p :class="$style.paragraph">
You'll be notified in the coming weeks when this feature becomes available.
</p>
<p :class="$style.paragraph">
In the meantime you can switch to using your own credentials.
</p>
<p v-if="credentialType" :class="$style.paragraph">
<N8nLink
:to="credentialDocsUrl"
new-window
data-test-id="ai-gateway-topup-credentials-docs-link"
>
{{ credentialDocsLinkText }}
</N8nLink>
</p>
</div>
</div>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.contentWrapper {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 300px;
}
.body {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing--sm);
padding: var(--spacing--sm) 0 var(--spacing--xl);
}
.icon {
flex-shrink: 0;
}
.title {
font-size: var(--font-size--lg);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--md);
margin: 0;
}
.paragraphs {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
}
.paragraph {
margin: 0;
font-size: var(--font-size--sm);
line-height: var(--line-height--md);
font-weight: var(--font-weight--regular);
color: var(--color--text--tint-1);
text-align: center;
}
</style>
<style lang="scss">
.ai-gateway-topup-dialog.el-dialog {
background-color: var(--color--background);
}
</style>

View file

@ -4,12 +4,16 @@ import { screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import SettingsAiGatewayView from './SettingsAiGatewayView.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { useUIStore } from '@/app/stores/ui.store';
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
const mockGetGatewayUsage = vi.fn();
const mockGetGatewayCredits = vi.fn();
vi.mock('@/features/ai/assistant/assistant.api', () => ({
getGatewayConfig: vi.fn(),
getGatewayCredits: vi.fn(),
getGatewayCredits: (...args: unknown[]) => mockGetGatewayCredits(...args),
getGatewayUsage: (...args: unknown[]) => mockGetGatewayUsage(...args),
}));
@ -49,6 +53,45 @@ describe('SettingsAiGatewayView', () => {
vi.clearAllMocks();
setActivePinia(createPinia());
mockGetGatewayUsage.mockResolvedValue({ entries: [], total: 0 });
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 42, creditsQuota: 100 });
});
describe('credits card', () => {
it('should display creditsRemaining after fetching', async () => {
renderComponent();
await waitFor(() => expect(screen.getByTestId('settings-ai-gateway')).toBeInTheDocument());
const store = useAiGatewayStore();
await waitFor(() => expect(store.creditsRemaining).toBe(42));
expect(screen.getByText('42 credits')).toBeInTheDocument();
});
it('should not render the credits number before data loads', () => {
mockGetGatewayCredits.mockReturnValue(new Promise(() => {})); // never resolves
renderComponent();
expect(screen.queryByTestId('ai-gateway-topup-button')).not.toBeNull(); // button present
// number not yet visible (creditsRemaining undefined)
expect(screen.queryByText('42')).not.toBeInTheDocument();
});
it('should open top-up modal when "Top up credits" button is clicked', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('ai-gateway-topup-button')).toBeInTheDocument(),
);
const uiStore = useUIStore();
vi.spyOn(uiStore, 'openModalWithData');
await userEvent.click(screen.getByTestId('ai-gateway-topup-button'));
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: AI_GATEWAY_TOP_UP_MODAL_KEY,
data: {},
});
});
});
describe('on mount', () => {
@ -98,7 +141,7 @@ describe('SettingsAiGatewayView', () => {
it('should re-fetch from offset=0 when refresh is clicked', async () => {
mockGetGatewayUsage.mockResolvedValue({ entries: MOCK_ENTRIES, total: 100 });
renderComponent();
await waitFor(() => expect(mockGetGatewayUsage).toHaveBeenCalledOnce());
await waitFor(() => expect(screen.getByText('gemini-pro')).toBeInTheDocument());
mockGetGatewayUsage.mockClear();
await userEvent.click(screen.getByRole('button', { name: /refresh/i }));

View file

@ -1,22 +1,94 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { N8nHeading, N8nText, N8nButton } from '@n8n/design-system';
import {
N8nActionBox,
N8nButton,
N8nDataTableServer,
N8nHeading,
N8nLoading,
N8nText,
N8nTooltip,
N8nActionPill,
} from '@n8n/design-system';
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
import type { AiGatewayUsageEntry } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
import { useUIStore } from '@/app/stores/ui.store';
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const telemetry = useTelemetry();
const aiGatewayStore = useAiGatewayStore();
const uiStore = useUIStore();
const isLoading = ref(false);
const isAppending = ref(false);
const offset = ref(0);
const PAGE_SIZE = 50;
const creditsRemaining = computed(() => aiGatewayStore.creditsRemaining);
const creditsBadgeText = computed(() =>
creditsRemaining.value !== undefined
? i18n.baseText('aiGateway.credentialMode.creditsShort', {
interpolate: { count: String(creditsRemaining.value) },
})
: undefined,
);
const entries = computed(() => aiGatewayStore.usageEntries);
const total = computed(() => aiGatewayStore.usageTotal);
const hasMore = computed(() => offset.value + PAGE_SIZE < total.value);
const showUsageSectionSkeleton = computed(() => isLoading.value && !isAppending.value);
const tableHeaders = ref<Array<TableHeader<AiGatewayUsageEntry>>>([
{
title: i18n.baseText('settings.n8nConnect.usage.col.date'),
key: 'timestamp',
width: 200,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.n8nConnect.usage.col.provider'),
key: 'provider',
width: 120,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.n8nConnect.usage.col.model'),
key: 'model',
width: 220,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.n8nConnect.usage.col.inputTokens'),
key: 'inputTokens',
width: 120,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.n8nConnect.usage.col.outputTokens'),
key: 'outputTokens',
width: 120,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.n8nConnect.usage.col.credits'),
key: 'creditsDeducted',
width: 100,
disableSort: true,
resize: false,
},
]);
function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, {
day: '2-digit',
@ -31,7 +103,12 @@ function formatTokens(value?: number): string {
return value !== undefined ? String(value) : '—';
}
function rowId(row: AiGatewayUsageEntry, index: number): string {
return `${row.timestamp}-${row.model}-${row.provider}-${index}`;
}
async function load(): Promise<void> {
isAppending.value = false;
offset.value = 0;
isLoading.value = true;
try {
@ -47,83 +124,119 @@ async function refresh(): Promise<void> {
async function loadMore(): Promise<void> {
if (isLoading.value) return;
isAppending.value = true;
offset.value += PAGE_SIZE;
isLoading.value = true;
try {
await aiGatewayStore.fetchMoreUsage(offset.value, PAGE_SIZE);
} finally {
isLoading.value = false;
isAppending.value = false;
}
}
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.n8nGateway.title'));
await load();
documentTitle.set(i18n.baseText('settings.n8nConnect.title'));
await Promise.all([aiGatewayStore.fetchCredits(), load()]);
});
</script>
<template>
<div :class="$style.container" data-test-id="settings-ai-gateway">
<div :class="$style.header">
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.n8nGateway.title') }}</N8nHeading>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.n8nGateway.description') }}
</N8nText>
</div>
<div :class="$style.section">
<div :class="$style.sectionHeader">
<N8nHeading size="large">{{ i18n.baseText('settings.n8nGateway.usage.title') }}</N8nHeading>
<N8nButton
:label="i18n.baseText('settings.n8nGateway.usage.refresh')"
icon="refresh-cw"
type="secondary"
:loading="isLoading"
@click="refresh"
/>
<header :class="$style.mainHeader" data-test-id="ai-gateway-settings-header">
<div :class="$style.headings">
<div :class="$style.headingRow">
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.n8nConnect.title') }}</N8nHeading>
<N8nActionPill
v-if="creditsBadgeText"
size="medium"
:text="creditsBadgeText"
data-test-id="ai-gateway-header-credits-badge"
/>
</div>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.n8nConnect.description') }}
</N8nText>
</div>
<N8nButton
:label="i18n.baseText('settings.n8nConnect.credits.topUp')"
icon="hand-coins"
variant="solid"
data-test-id="ai-gateway-topup-button"
@click="
telemetry.track('User clicked ai gateway top up', { source: 'settings_page' });
uiStore.openModalWithData({ name: AI_GATEWAY_TOP_UP_MODAL_KEY, data: {} });
"
/>
</header>
<div :class="$style.tableWrapper">
<table :class="$style.table">
<thead>
<tr>
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.date') }}</th>
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.provider') }}</th>
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.model') }}</th>
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.inputTokens') }}</th>
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.outputTokens') }}</th>
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.credits') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="entries.length === 0 && !isLoading">
<td colspan="6" :class="$style.empty">
{{ i18n.baseText('settings.n8nGateway.usage.empty') }}
</td>
</tr>
<tr v-for="(entry, idx) in entries" :key="idx">
<td>{{ formatDate(entry.timestamp) }}</td>
<td>
<span :class="$style.badge">
{{ entry.provider }}
</span>
</td>
<td>{{ entry.model }}</td>
<td>{{ formatTokens(entry.inputTokens) }}</td>
<td>{{ formatTokens(entry.outputTokens) }}</td>
<td>{{ entry.creditsDeducted }}</td>
</tr>
</tbody>
</table>
<div :class="$style.usageTableContainer">
<div v-if="showUsageSectionSkeleton">
<N8nLoading :loading="true" variant="h1" :class="$style.usageTableHeader" />
<N8nLoading :loading="true" variant="p" :rows="5" :shrink-last="false" />
</div>
<div v-if="hasMore" :class="$style.loadMore">
<N8nButton
:label="i18n.baseText('settings.n8nGateway.usage.loadMore')"
type="secondary"
:loading="isLoading"
@click="loadMore"
<div v-else>
<div :class="$style.usageTableHeader">
<N8nHeading size="medium" :bold="true">
{{ i18n.baseText('settings.n8nConnect.usage.title') }}
</N8nHeading>
<div :class="$style.usageTableActions">
<N8nTooltip :content="i18n.baseText('settings.n8nConnect.usage.refresh.tooltip')">
<N8nButton
variant="subtle"
icon-only
size="small"
icon="refresh-cw"
:aria-label="i18n.baseText('generic.refresh')"
:loading="isLoading && !isAppending"
@click="refresh"
/>
</N8nTooltip>
</div>
</div>
<N8nActionBox
v-if="entries.length === 0"
:heading="i18n.baseText('settings.n8nConnect.usage.empty')"
/>
<N8nDataTableServer
v-else
:class="$style.gatewayUsageTable"
:headers="tableHeaders"
:items="entries"
:items-length="entries.length"
:loading="isLoading && isAppending"
:item-value="rowId"
>
<template #[`item.timestamp`]="{ item }">
{{ formatDate(item.timestamp) }}
</template>
<template #[`item.provider`]="{ item }">
<span :class="$style.providerBadge">
{{ item.provider }}
</span>
</template>
<template #[`item.model`]="{ item }">
{{ item.model }}
</template>
<template #[`item.inputTokens`]="{ item }">
{{ formatTokens(item.inputTokens) }}
</template>
<template #[`item.outputTokens`]="{ item }">
{{ formatTokens(item.outputTokens) }}
</template>
<template #[`item.creditsDeducted`]="{ item }">
{{ item.creditsDeducted }}
</template>
</N8nDataTableServer>
<div v-if="hasMore && entries.length > 0" :class="$style.loadMore">
<N8nButton
:label="i18n.baseText('settings.n8nConnect.usage.loadMore')"
type="secondary"
:loading="isLoading && isAppending"
@click="loadMore"
/>
</div>
</div>
</div>
</div>
@ -133,66 +246,63 @@ onMounted(async () => {
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--xl);
gap: var(--spacing--lg);
padding-bottom: var(--spacing--2xl);
}
.header {
.mainHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing--md);
@media (max-width: 820px) {
flex-direction: column;
align-items: flex-start;
}
}
.headings {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
flex: 1;
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.sectionHeader {
.headingRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--5xs);
}
.usageTableContainer {
:global(.table-pagination) {
display: none;
}
}
.usageTableHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing--sm);
}
.tableWrapper {
border: var(--border);
border-radius: var(--radius);
overflow: hidden;
.usageTableActions {
display: flex;
align-items: center;
gap: var(--spacing--xs);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size--sm);
th {
text-align: left;
padding: var(--spacing--xs) var(--spacing--sm);
color: var(--color--text--tint-1);
font-weight: var(--font-weight--bold);
border-bottom: var(--border);
background-color: var(--color--background);
}
td {
padding: var(--spacing--xs) var(--spacing--sm);
color: var(--color--text);
border-bottom: var(--border);
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: none;
}
.empty {
text-align: center;
color: var(--color--text--tint-2);
padding: var(--spacing--xl);
.gatewayUsageTable {
tr:last-child {
border-bottom: none !important;
}
}
.badge {
.providerBadge {
display: inline-block;
padding: var(--spacing--5xs) var(--spacing--2xs);
border-radius: var(--radius);
@ -205,6 +315,6 @@ onMounted(async () => {
.loadMore {
display: flex;
justify-content: center;
padding-top: var(--spacing--xs);
padding-top: var(--spacing--md);
}
</style>

View file

@ -51,7 +51,7 @@ const isHovered = useElementHover(triggerRef);
<style lang="scss" module>
.reasoningTrigger {
color: var(--text-color--subtler);
color: var(--color--text--tint-2);
}
.reasoningContent {

View file

@ -77,7 +77,7 @@ function handleClick(e: MouseEvent) {
}
.metadata {
color: var(--text-color--subtler);
color: var(--color--text--tint-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View file

@ -867,7 +867,7 @@ describe('NodeCredentials', () => {
});
});
describe('AI Gateway toggle (onAiGatewayToggle)', () => {
describe('AI Gateway toggle (onAiGatewaySelector)', () => {
const googlePalmApiCredType: ICredentialType = {
name: 'googlePalmApi',
displayName: 'Google PaLM API',
@ -943,7 +943,7 @@ describe('NodeCredentials', () => {
renderComponent({
props: { node: nodeWithCred, overrideCredType: 'googlePalmApi' },
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
// Both the toggle and the credential dropdown should be visible
@ -960,7 +960,7 @@ describe('NodeCredentials', () => {
renderComponent({
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi' },
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
@ -990,7 +990,7 @@ describe('NodeCredentials', () => {
renderComponent({
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi' },
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
@ -1012,7 +1012,7 @@ describe('NodeCredentials', () => {
renderComponent({
props: { node: googleAiNode, overrideCredType: 'googlePalmApi' },
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
expect(screen.queryByTestId('ai-gateway-toggle')).not.toBeInTheDocument();
@ -1027,7 +1027,7 @@ describe('NodeCredentials', () => {
renderComponent({
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi', readonly: true },
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
@ -1044,7 +1044,7 @@ describe('NodeCredentials', () => {
renderComponent({
props: { node: nodeWithCred, overrideCredType: 'googlePalmApi', readonly: true },
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
// Toggle is shown (disabled) so users can see the gateway is supported for this type
@ -1060,7 +1060,7 @@ describe('NodeCredentials', () => {
props: { node: googleAiNode, overrideCredType: 'googlePalmApi' },
global: {
stubs: {
AiGatewayToggle: {
AiGatewaySelector: {
template:
'<button data-test-id="ai-gateway-toggle-on" @click="$emit(\'toggle\', true)" />',
props: ['aiGatewayEnabled'],
@ -1107,7 +1107,7 @@ describe('NodeCredentials', () => {
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi' },
global: {
stubs: {
AiGatewayToggle: {
AiGatewaySelector: {
template:
'<button data-test-id="ai-gateway-toggle-off" @click="$emit(\'toggle\', false)" />',
props: ['aiGatewayEnabled'],
@ -1146,7 +1146,7 @@ describe('NodeCredentials', () => {
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi' },
global: {
stubs: {
AiGatewayToggle: {
AiGatewaySelector: {
template:
'<button data-test-id="ai-gateway-toggle-off" @click="$emit(\'toggle\', false)" />',
props: ['aiGatewayEnabled'],

View file

@ -39,7 +39,7 @@ import { getResourcePermissions } from '@n8n/permissions';
import { useNodeCredentialOptions } from '../composables/useNodeCredentialOptions';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
import { useAiGateway } from '@/app/composables/useAiGateway';
import AiGatewayToggle from '@/app/components/AiGatewayToggle.vue';
import AiGatewaySelector from '@/app/components/AiGatewaySelector.vue';
import {
N8nBadge,
@ -215,7 +215,17 @@ watch(
const allOptions = types.map((type) => type.options).flat();
if (allOptions.length === 0) return;
if (allOptions.length === 0) {
// No credentials configured auto-enable AI Gateway for supported types
if (aiGateway.isEnabled.value) {
for (const { type } of types) {
if (aiGateway.isCredentialTypeSupported(type.name)) {
onAiGatewaySelector(type.name, true);
}
}
}
return;
}
const mostRecentCredential = allOptions.reduce(
(mostRecent, current) =>
@ -513,14 +523,14 @@ function isAiGatewayManagedCredentials(credentialType: string): boolean {
return aiGateway.isEnabled.value && selected.value[credentialType]?.__aiGatewayManaged === true;
}
function showAiGatewayToggle(credentialType: string): boolean {
function showAiGatewaySelector(credentialType: string): boolean {
if (!aiGateway.isEnabled.value) return false;
if (isAiGatewayManagedCredentials(credentialType)) return true;
if (!aiGateway.isCredentialTypeSupported(credentialType)) return false;
return true;
}
function onAiGatewayToggle(credentialType: string, enable: boolean): void {
function onAiGatewaySelector(credentialType: string, enable: boolean): void {
const credentials = { ...(props.node.credentials ?? {}) };
if (enable) {
@ -670,6 +680,13 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
<template v-if="$slots['label-postfix']" #options>
<slot name="label-postfix" />
</template>
<AiGatewaySelector
v-if="showAiGatewaySelector(type.name)"
:ai-gateway-enabled="isAiGatewayManagedCredentials(type.name)"
:readonly="readonly"
:credential-type="type.name"
@toggle="onAiGatewaySelector(type.name, $event)"
/>
<div v-if="readonly && !isAiGatewayManagedCredentials(type.name)">
<N8nInput
:model-value="getSelectedName(type.name)"
@ -859,12 +876,6 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
</I18nT>
</N8nNotice>
</N8nInputLabel>
<AiGatewayToggle
v-if="showAiGatewayToggle(type.name)"
:ai-gateway-enabled="isAiGatewayManagedCredentials(type.name)"
:readonly="readonly"
@toggle="onAiGatewayToggle(type.name, $event)"
/>
</div>
</div>
</template>

View file

@ -17,7 +17,6 @@ import {
AI_CATEGORY_VECTOR_STORES,
AI_SUBCATEGORY,
AI_TRANSFORM_NODE_TYPE,
AI_GATEWAY_EXPERIMENT,
BETA_NODES,
CORE_NODES_CATEGORY,
DEFAULT_SUBCATEGORY,
@ -37,7 +36,6 @@ import type { NodeViewItemSection } from './views/viewsData';
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { usePostHog } from '@/app/stores/posthog.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import type { NodeIconSource } from '@/app/utils/nodeIcon';
import { SampleTemplates } from '@/features/workflows/templates/utils/workflowSamples';
@ -299,13 +297,12 @@ function applyNodeTags(element: INodeCreateElement): INodeCreateElement {
text: i18n.baseText('generic.betaProper'),
};
} else if (
usePostHog().getVariant(AI_GATEWAY_EXPERIMENT.name) === AI_GATEWAY_EXPERIMENT.variant &&
useSettingsStore().isAiGatewayEnabled &&
useAiGatewayStore().isNodeSupported(element.properties.name)
) {
element.properties.tag = {
type: 'success',
text: i18n.baseText('generic.freeCredits'),
pill: true,
};
}