feat(editor): Switch credits pill after a top-up or depleted allowance (#36239)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alexander Gekov 2026-08-17 12:06:26 +00:00 committed by GitHub
parent a5e3213df4
commit 516c19e2ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 366 additions and 58 deletions

View File

@ -0,0 +1,5 @@
export interface AiGatewayWalletResponse {
budget: number;
balance: number;
hasEverToppedUp: boolean;
}

View File

@ -24,6 +24,7 @@ export type {
AiGatewayUsageMetadata,
AiGatewayUsageResponse,
} from './ai/ai-gateway-usage-response.dto';
export type { AiGatewayWalletResponse } from './ai/ai-gateway-wallet-response.dto';
export {
InstanceAiConfirmRequestDto,

View File

@ -673,7 +673,7 @@ describe('AiController', () => {
});
it('should return wallet from aiGatewayService', async () => {
const walletData = { budget: 10, balance: 7 };
const walletData = { budget: 10, balance: 7, hasEverToppedUp: false };
aiGatewayService.getWallet.mockResolvedValue(walletData);
const result = await controller.getGatewayWallet(request);

View File

@ -1,4 +1,8 @@
import type { AiGatewayConfigDto, AiGatewayUsageResponse } from '@n8n/api-types';
import type {
AiGatewayConfigDto,
AiGatewayUsageResponse,
AiGatewayWalletResponse,
} from '@n8n/api-types';
import {
AiChatRequestDto,
AiApplySuggestionRequestDto,
@ -259,7 +263,7 @@ export class AiController {
@Licensed('feat:aiGateway')
@Get('/gateway/wallet')
async getGatewayWallet(req: AuthenticatedRequest): Promise<{ budget: number; balance: number }> {
async getGatewayWallet(req: AuthenticatedRequest): Promise<AiGatewayWalletResponse> {
this.aiGatewayService.assertEnabled();
try {
return await this.aiGatewayService.getWallet(req.user.id);

View File

@ -722,7 +722,18 @@ describe('AiGatewayService', () => {
await expect(service.getWallet(USER_ID)).rejects.toThrow(UserError);
});
it('returns budget and balance from gateway wallet', async () => {
it('returns budget, balance, and hasEverToppedUp from gateway wallet', async () => {
requestMock
.mockResolvedValueOnce(ok({ token: 'mock-jwt', expiresIn: 3600 }))
.mockResolvedValueOnce(ok({ budget: 10, balance: 7, hasEverToppedUp: true }));
const service = makeService();
const result = await service.getWallet(USER_ID);
expect(result).toEqual({ budget: 10, balance: 7, hasEverToppedUp: true });
});
it('defaults hasEverToppedUp to false when the gateway omits it', async () => {
requestMock
.mockResolvedValueOnce(ok({ token: 'mock-jwt', expiresIn: 3600 }))
.mockResolvedValueOnce(ok({ budget: 10, balance: 7 }));
@ -730,7 +741,7 @@ describe('AiGatewayService', () => {
const result = await service.getWallet(USER_ID);
expect(result).toEqual({ budget: 10, balance: 7 });
expect(result).toEqual({ budget: 10, balance: 7, hasEverToppedUp: false });
});
it('sends JWT Bearer token in Authorization header to credits endpoint', async () => {

View File

@ -2,6 +2,7 @@ import {
AiGatewayConfigDto,
getAgentModelProviderCredentialTypes,
type AiGatewayUsageResponse,
type AiGatewayWalletResponse,
} from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
@ -25,11 +26,6 @@ interface GatewayTokenResponse {
expiresIn: number;
}
interface GatewayWalletResponse {
budget: number;
balance: number;
}
export type AiGatewayAvailability =
| { available: true; config: AiGatewayConfigDto }
| { available: false };
@ -282,7 +278,7 @@ export class AiGatewayService {
/**
* Returns the current wallet (budget and remaining balance) for the given user.
*/
async getWallet(userId: string): Promise<GatewayWalletResponse> {
async getWallet(userId: string): Promise<AiGatewayWalletResponse> {
const baseUrl = this.requireBaseUrl();
const jwt = await this.getOrFetchToken(userId);
@ -301,12 +297,16 @@ export class AiGatewayService {
return this.parseWalletResponse(data);
}
private parseWalletResponse(data: unknown): GatewayWalletResponse {
const d = data as GatewayWalletResponse;
private parseWalletResponse(data: unknown): AiGatewayWalletResponse {
const d = data as { budget?: unknown; balance?: unknown; hasEverToppedUp?: unknown };
if (typeof d.budget !== 'number' || typeof d.balance !== 'number') {
throw new UserError('n8n credits returned an invalid wallet response.');
}
return d;
return {
budget: d.budget,
balance: d.balance,
hasEverToppedUp: d.hasEverToppedUp === true,
};
}
/**

View File

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import N8nActionPill from './ActionPill.vue';
const meta = {
title: 'Core/Action Pill',
component: N8nActionPill,
argTypes: {
type: {
control: 'select',
options: ['default', 'info', 'danger'],
},
size: {
control: 'select',
options: ['small', 'medium'],
},
},
args: {
text: 'Free credits',
},
} satisfies Meta<typeof N8nActionPill>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Info: Story = {
args: {
text: 'n8n credits',
type: 'info',
},
};
export const Danger: Story = {
args: {
text: 'No credits',
type: 'danger',
},
};

View File

@ -1,6 +1,7 @@
import { render, fireEvent } from '@testing-library/vue';
import { describe, it, expect } from 'vitest';
import * as stories from './ActionPill.stories';
import ActionPill from './ActionPill.vue';
describe('N8nActionPill', () => {
@ -45,6 +46,20 @@ describe('N8nActionPill', () => {
expect(container).toMatchSnapshot();
});
it('renders info type correctly', () => {
const { container } = render(ActionPill, {
props: { text: 'n8n credits', type: 'info' },
});
expect(container).toMatchSnapshot();
});
it('documents default, info, and danger variants in Storybook', () => {
expect(stories.default.title).toBe('Core/Action Pill');
expect(stories.default.argTypes?.type.options).toEqual(['default', 'info', 'danger']);
expect(stories.Info.args).toEqual({ text: 'n8n credits', type: 'info' });
expect(stories.Danger.args).toEqual({ text: 'No credits', type: 'danger' });
});
it('renders small size correctly', () => {
const { container } = render(ActionPill, {
props: { text: 'Label', size: 'small' },

View File

@ -17,8 +17,8 @@ withDefaults(
clickable?: boolean;
/** 'small' matches PreviewTag scale (for menus/lists); 'medium' is the default. */
size?: 'small' | 'medium';
/** Visual variant. 'default' is green; 'danger' is red for depleted/error states. */
type?: 'default' | 'danger';
/** Visual variant. */
type?: 'default' | 'danger' | 'info';
}>(),
{
text: undefined,
@ -42,6 +42,7 @@ const hovered = ref(false);
$style.root,
size === 'small' && $style.small,
type === 'danger' && $style.danger,
type === 'info' && $style.info,
clickable && $style.clickable,
hoverText && hovered && $style.pressed,
]"
@ -91,6 +92,15 @@ const hovered = ref(false);
}
}
.info {
background-color: var(--color--blue-50);
color: var(--color--blue-800);
&.clickable:hover {
background-color: color-mix(in srgb, var(--color--blue-50), var(--color--neutral-black) 5%);
}
}
.clickable {
cursor: pointer;
user-select: none;

View File

@ -61,6 +61,18 @@ exports[`N8nActionPill > snapshots > renders default type correctly 1`] = `
</div>
`;
exports[`N8nActionPill > snapshots > renders info type correctly 1`] = `
<div>
<span
class="root info"
>
n8n credits
</span>
</div>
`;
exports[`N8nActionPill > snapshots > renders small size correctly 1`] = `
<div>
<span

View File

@ -3,7 +3,7 @@ import type { DropdownMenuItemProps } from '../N8nDropdownMenu/DropdownMenu.type
export interface AiModelSelectorMenuItemData {
badgeLabel?: string;
/** Right-aligned action pill on the item (e.g. an n8n credits balance). */
actionPill?: { text: string; type?: 'default' | 'danger' };
actionPill?: { text: string; type?: 'default' | 'danger' | 'info' };
description?: string;
descriptionTooltipTeleported?: boolean;
fullName?: string;

View File

@ -0,0 +1,29 @@
import { render } from '@testing-library/vue';
import NodeCreatorNode from './NodeCreatorNode.vue';
function renderNode(tag: { text: string; pill?: boolean; type?: string }) {
return render(NodeCreatorNode, {
props: { title: 'OpenAI', tag },
global: { stubs: ['N8nIcon'] },
});
}
describe('N8nNodeCreatorNode', () => {
it('passes info type to the credits pill', () => {
const { getByText } = renderNode({ text: 'n8n credits', pill: true, type: 'info' });
expect(getByText('n8n credits').className).toContain('info');
});
it('passes danger type to the credits pill', () => {
const { getByText } = renderNode({ text: 'No credits', pill: true, type: 'danger' });
expect(getByText('No credits').className).toContain('danger');
});
it('defaults the credits pill type when tag type is not info or danger', () => {
const { getByText } = renderNode({ text: 'Free credits', pill: true, type: 'success' });
const className = getByText('Free credits').className;
expect(className).not.toContain('info');
expect(className).not.toContain('danger');
});
});

View File

@ -47,7 +47,12 @@ const { t } = useI18n();
<div :class="$style.details">
<span :class="$style.name" data-test-id="node-creator-item-name" v-text="title" />
<PreviewTag v-if="tag?.preview" size="small" :class="$style.previewTag" :text="tag.text" />
<N8nActionPill v-else-if="tag?.pill" size="small" :text="tag.text" />
<N8nActionPill
v-else-if="tag?.pill"
size="small"
:text="tag.text"
:type="tag.type === 'info' || tag.type === 'danger' ? tag.type : 'default'"
/>
<ElTag
v-else-if="tag"
:class="$style.tag"

View File

@ -95,6 +95,7 @@
"generic.beta": "beta",
"generic.betaProper": "Beta",
"generic.freeCredits": "Free credits",
"generic.n8nCredits": "n8n credits",
"generic.yes": "Yes",
"generic.no": "No",
"generic.rating": "Rating",

View File

@ -61,7 +61,11 @@ describe('useAiGateway', () => {
it('should fetch and update balance and budget when enabled', async () => {
mockIsAiGatewayEnabled.value = true;
mockGetGatewayWallet.mockResolvedValue({ balance: 7, budget: 10 });
mockGetGatewayWallet.mockResolvedValue({
balance: 7,
budget: 10,
hasEverToppedUp: false,
});
const { fetchWallet, balance, budget } = useAiGateway();

View File

@ -13,6 +13,7 @@ export function useAiGateway() {
const balance = computed(() => aiGatewayStore.balance);
const budget = computed(() => aiGatewayStore.budget);
const creditsLabelKey = computed(() => aiGatewayStore.creditsLabelKey);
const fetchError = computed(() => aiGatewayStore.fetchError);
const isEnabled = computed(() => settingsStore.isAiGatewayEnabled);
@ -59,6 +60,7 @@ export function useAiGateway() {
isEnabled,
balance,
budget,
creditsLabelKey,
fetchError,
fetchConfig,
fetchWallet,

View File

@ -1,7 +1,7 @@
import { createPinia, setActivePinia } from 'pinia';
import { describe, it, vi, beforeEach, expect } from 'vitest';
import type { INode } from 'n8n-workflow';
import { useAiGatewayStore } from './aiGateway.store';
import { useAiGatewayStore, usesFreeCreditsLabel } from './aiGateway.store';
const mockGetGatewayConfig = vi.fn();
const mockGetGatewayWallet = vi.fn();
@ -124,14 +124,19 @@ describe('aiGateway.store', () => {
});
describe('fetchWallet()', () => {
it('should update balance and budget', async () => {
mockGetGatewayWallet.mockResolvedValue({ balance: 7, budget: 10 });
it('should update balance, budget, and hasEverToppedUp', async () => {
mockGetGatewayWallet.mockResolvedValue({
balance: 7,
budget: 10,
hasEverToppedUp: true,
});
const store = useAiGatewayStore();
await store.fetchWallet();
expect(store.balance).toBe(7);
expect(store.budget).toBe(10);
expect(store.hasEverToppedUp).toBe(true);
expect(store.fetchError).toBeNull();
});
@ -157,6 +162,57 @@ describe('aiGateway.store', () => {
expect(store.fetchError).toBeNull();
expect(store.balance).toBe(3);
});
it('shares an in-flight wallet fetch', async () => {
let resolveWallet!: (value: {
balance: number;
budget: number;
hasEverToppedUp: boolean;
}) => void;
mockGetGatewayWallet.mockReturnValue(
new Promise((resolve) => {
resolveWallet = resolve;
}),
);
const store = useAiGatewayStore();
const first = store.fetchWallet();
const second = store.fetchWallet();
resolveWallet({ balance: 1, budget: 2, hasEverToppedUp: false });
await Promise.all([first, second]);
expect(mockGetGatewayWallet).toHaveBeenCalledOnce();
expect(store.balance).toBe(1);
});
});
describe('usesFreeCreditsLabel()', () => {
it.each([
{ hasEverToppedUp: false, balance: 1.26, expected: true },
{ hasEverToppedUp: true, balance: 1.26, expected: false },
{ hasEverToppedUp: false, balance: 0, expected: false },
{ hasEverToppedUp: true, balance: 0, expected: false },
{ hasEverToppedUp: undefined, balance: undefined, expected: true },
])(
'hasEverToppedUp=$hasEverToppedUp balance=$balance → $expected',
({ hasEverToppedUp, balance, expected }) => {
expect(usesFreeCreditsLabel(hasEverToppedUp, balance)).toBe(expected);
},
);
it('drives creditsLabelKey from the fetched wallet', async () => {
mockGetGatewayWallet.mockResolvedValue({
balance: 5,
budget: 10,
hasEverToppedUp: true,
});
const store = useAiGatewayStore();
expect(store.creditsLabelKey).toBe('generic.freeCredits');
await store.fetchWallet();
expect(store.creditsLabelKey).toBe('generic.n8nCredits');
});
});
describe('fetchUsage()', () => {

View File

@ -1,4 +1,4 @@
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import type { INode } from 'n8n-workflow';
import type { AiGatewayConfigDto, AiGatewayUsageEntry } from '@n8n/api-types';
@ -22,12 +22,26 @@ function toError(e: unknown): Error {
export const stripToolSuffix = (nodeName: string) =>
nodeName.replace(/HitlTool$/, '').replace(/Tool$/, '');
/** Free credits until we know they topped up or the balance is gone. */
export function usesFreeCreditsLabel(
hasEverToppedUp: boolean | undefined,
balance: number | undefined,
): boolean {
return hasEverToppedUp !== true && (balance === undefined || balance > 0);
}
export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
const rootStore = useRootStore();
const config = ref<AiGatewayConfigDto | null>(null);
const balance = ref<number | undefined>(undefined);
const budget = ref<number | undefined>(undefined);
const hasEverToppedUp = ref<boolean | undefined>(undefined);
const creditsLabelKey = computed((): 'generic.freeCredits' | 'generic.n8nCredits' =>
usesFreeCreditsLabel(hasEverToppedUp.value, balance.value)
? 'generic.freeCredits'
: 'generic.n8nCredits',
);
const usageEntries = ref<AiGatewayUsageEntry[]>([]);
const usageTotal = ref<number>(0);
const fetchError = ref<Error | null>(null);
@ -35,6 +49,7 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
// Every model selector fetches on mount, so several can be in flight before the
// first response lands. Share the promise rather than firing one request each.
let configFetch: Promise<void> | null = null;
let walletFetch: Promise<void> | null = null;
async function fetchConfig(): Promise<void> {
if (config.value !== null) return;
@ -52,14 +67,20 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
}
async function fetchWallet(): Promise<void> {
try {
const data = await getGatewayWallet(rootStore.restApiContext);
balance.value = data.balance;
budget.value = data.budget;
fetchError.value = null;
} catch (error) {
fetchError.value = toError(error);
}
walletFetch ??= (async () => {
try {
const data = await getGatewayWallet(rootStore.restApiContext);
balance.value = data.balance;
budget.value = data.budget;
hasEverToppedUp.value = data.hasEverToppedUp;
fetchError.value = null;
} catch (error) {
fetchError.value = toError(error);
} finally {
walletFetch = null;
}
})();
await walletFetch;
}
async function fetchUsage(offset = 0, limit = 50): Promise<void> {
@ -189,6 +210,8 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
config,
balance,
budget,
hasEverToppedUp,
creditsLabelKey,
usageEntries,
usageTotal,
fetchError,

View File

@ -1607,18 +1607,15 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
const editorColumn = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
expect(editorColumn.props('agent')).toBeTruthy();
vi.useFakeTimers();
try {
editorColumn.vm.$emit('toggle-mcp-access', true);
await nextTick();
await vi.runOnlyPendingTimersAsync();
expect(toggleAgentMcpAccess).toHaveBeenCalledExactlyOnceWith('a2', true);
} finally {
vi.useRealTimers();
}
editorColumn.vm.$emit('toggle-mcp-access', true);
await nextTick();
// Switching agents flushes the pending MCP debounce — same path as
// production, and not dependent on fake timers + nextTick.
await wrapper.setProps({ artifactAgentId: 'a3', artifactAgentPending: true });
await flushPromises();
expect(toggleAgentMcpAccess).toHaveBeenCalledExactlyOnceWith('a2', true);
await wrapper.setProps({ artifactAgentPending: false });
mcpSave.resolve({ updatedCount: 1, updatedIds: ['a2'], unchangedIds: [] });
await flushPromises();

View File

@ -46,6 +46,7 @@ const aiGatewayState = vi.hoisted(() => ({
balance: { value: undefined as number | undefined },
fetchWallet: vi.fn(),
fetchConfig: vi.fn(),
creditsLabelKey: { value: 'generic.freeCredits' as 'generic.freeCredits' | 'generic.n8nCredits' },
}));
const baseText = vi.hoisted(() =>
vi.fn((key: string, options?: { interpolate?: Record<string, string | number> }) => {
@ -56,6 +57,7 @@ const baseText = vi.hoisted(() =>
'agents.modelSelector.connectTo': 'Connect to {provider}',
'agents.modelSelector.models': 'Models',
'generic.freeCredits': 'Free credits',
'generic.n8nCredits': 'n8n credits',
'agents.modelSelector.credentialsMissing': 'Credentials missing',
'agents.modelSelector.noMatch': 'No match',
'agents.modelSelector.noModels': 'No models',
@ -139,6 +141,7 @@ vi.mock('@/app/composables/useAiGateway', () => ({
isCredentialTypeSupported: (type: string) => aiGatewayState.supportedTypes.has(type),
canServeCredentialType: (type: string) => aiGatewayState.supportedTypes.has(type),
balance: aiGatewayState.balance,
creditsLabelKey: aiGatewayState.creditsLabelKey,
fetchWallet: aiGatewayState.fetchWallet,
fetchConfig: aiGatewayState.fetchConfig,
}),
@ -232,6 +235,7 @@ describe('AgentModelSelector', () => {
aiGatewayState.isEnabled.value = false;
aiGatewayState.supportedTypes = new Set<string>();
aiGatewayState.balance.value = undefined;
aiGatewayState.creditsLabelKey.value = 'generic.freeCredits';
aiGatewayState.fetchWallet.mockReset();
aiGatewayState.fetchConfig.mockReset();
});
@ -269,6 +273,19 @@ describe('AgentModelSelector', () => {
});
});
it('shows the n8n credits pill on covered providers after a top-up', async () => {
aiGatewayState.isEnabled.value = true;
aiGatewayState.supportedTypes = new Set(['anthropicApi']);
aiGatewayState.creditsLabelKey.value = 'generic.n8nCredits';
const wrapper = await mountSelector({ anthropic: null });
expect(getProviderItem(wrapper, 'anthropic')?.data?.actionPill).toEqual({
text: 'n8n credits',
type: 'info',
});
});
it('does not show the Free credits pill on unsupported providers', async () => {
aiGatewayState.isEnabled.value = true;
aiGatewayState.supportedTypes = new Set<string>();

View File

@ -386,7 +386,13 @@ function providerToMenuItem(provider: AgentModelProvider): MenuItem {
? i18n.baseText('agents.modelSelector.freeCredits.badge')
: undefined,
actionPill: isAiGatewayManagedAvailable
? { text: i18n.baseText('generic.freeCredits'), type: 'default' as const }
? {
text: i18n.baseText(aiGateway.creditsLabelKey.value),
type:
aiGateway.creditsLabelKey.value === 'generic.freeCredits'
? ('default' as const)
: ('info' as const),
}
: undefined,
},
children: [...connectHeader, ...connectItems, ...modelsHeader, ...modelsSection],

View File

@ -9,7 +9,11 @@ import type {
} from '@/features/ai/assistant/assistant.types';
import { makeRestApiRequest, streamRequest } from '@n8n/rest-api-client';
import { getObjectSizeInKB } from '@/app/utils/objectUtils';
import type { AiGatewayConfigDto, AiGatewayUsageResponse } from '@n8n/api-types';
import type {
AiGatewayConfigDto,
AiGatewayUsageResponse,
AiGatewayWalletResponse,
} from '@n8n/api-types';
import type { IDataObject } from 'n8n-workflow';
export function chatWithBuilder(
@ -133,10 +137,7 @@ export async function getGatewayConfig(ctx: IRestApiContext): Promise<AiGatewayC
return await makeRestApiRequest(ctx, 'GET', '/ai/gateway/config');
}
export async function getGatewayWallet(ctx: IRestApiContext): Promise<{
budget: number;
balance: number;
}> {
export async function getGatewayWallet(ctx: IRestApiContext): Promise<AiGatewayWalletResponse> {
return await makeRestApiRequest(ctx, 'GET', '/ai/gateway/wallet');
}

View File

@ -56,6 +56,7 @@ vi.mock('@/app/composables/useAiGateway', () => ({
canServeCredentialType: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -1473,6 +1474,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -1544,6 +1546,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchError: computed(() => null),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
@ -1574,6 +1577,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchError: computed(() => null),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
@ -1668,6 +1672,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -1713,6 +1718,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => 2.75),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -1864,6 +1870,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -2028,6 +2035,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -2068,6 +2076,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -2107,6 +2116,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -2147,6 +2157,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -2289,6 +2300,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -2416,6 +2428,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
@ -2521,6 +2534,7 @@ describe('NodeCredentials', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: computed(() => undefined),
budget: computed(() => undefined),
creditsLabelKey: computed(() => 'generic.freeCredits'),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),

View File

@ -90,6 +90,7 @@ vi.mock('@/app/composables/useAiGateway', () => ({
isNodePropertyHidden: vi.fn(() => false),
balance: { value: undefined },
budget: { value: undefined },
creditsLabelKey: { value: 'generic.freeCredits' },
fetchError: { value: null },
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),
@ -1953,6 +1954,7 @@ describe('ParameterInputList', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
creditsLabelKey: { value: 'generic.freeCredits' } as never,
fetchError: { value: null } as never,
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),
@ -1990,6 +1992,7 @@ describe('ParameterInputList', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
creditsLabelKey: { value: 'generic.freeCredits' } as never,
fetchError: { value: null } as never,
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),
@ -2050,6 +2053,7 @@ describe('ParameterInputList', () => {
),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
creditsLabelKey: { value: 'generic.freeCredits' } as never,
fetchError: { value: null } as never,
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),
@ -2100,6 +2104,7 @@ describe('ParameterInputList', () => {
),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
creditsLabelKey: { value: 'generic.freeCredits' } as never,
fetchError: { value: null } as never,
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),
@ -2168,6 +2173,7 @@ describe('ParameterInputList', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
creditsLabelKey: { value: 'generic.freeCredits' } as never,
fetchError: { value: null } as never,
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),
@ -2204,6 +2210,7 @@ describe('ParameterInputList', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
creditsLabelKey: { value: 'generic.freeCredits' } as never,
fetchError: { value: null } as never,
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),
@ -2244,6 +2251,7 @@ describe('ParameterInputList', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
creditsLabelKey: { value: 'generic.freeCredits' } as never,
fetchError: { value: null } as never,
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),
@ -2289,6 +2297,7 @@ describe('ParameterInputList', () => {
isNodePropertyHidden: vi.fn(() => false),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
creditsLabelKey: { value: 'generic.freeCredits' } as never,
fetchError: { value: null } as never,
fetchConfig: vi.fn(),
fetchWallet: vi.fn(),

View File

@ -148,21 +148,25 @@ describe('useNodeCreatorStore', () => {
settingsStore.isAiGatewayEnabled = true;
const aiGatewayStore = mockedStore(useAiGatewayStore);
aiGatewayStore.fetchConfig = vi.fn();
aiGatewayStore.fetchWallet = vi.fn();
nodeCreatorStore.onCreatorOpened({ source, mode, workflow_id });
expect(aiGatewayStore.fetchConfig).toHaveBeenCalled();
expect(aiGatewayStore.fetchWallet).toHaveBeenCalled();
});
it('does not fetch the gateway config when AI Gateway is disabled', () => {
it('does not fetch the gateway config or wallet when AI Gateway is disabled', () => {
const settingsStore = mockedStore(useSettingsStore);
settingsStore.isAiGatewayEnabled = false;
const aiGatewayStore = mockedStore(useAiGatewayStore);
aiGatewayStore.fetchConfig = vi.fn();
aiGatewayStore.fetchWallet = vi.fn();
nodeCreatorStore.onCreatorOpened({ source, mode, workflow_id });
expect(aiGatewayStore.fetchConfig).not.toHaveBeenCalled();
expect(aiGatewayStore.fetchWallet).not.toHaveBeenCalled();
});
});

View File

@ -358,10 +358,11 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
}) {
resetNodesPanelSession();
// Warm up the AI Gateway config so the n8n Connect search boost has the
// supported-node list by the time the user types. Cached after first load.
// Config for Connect search boost; wallet for the credits pill.
if (useSettingsStore().isAiGatewayEnabled) {
void useAiGatewayStore().fetchConfig();
const aiGatewayStore = useAiGatewayStore();
void aiGatewayStore.fetchConfig();
void aiGatewayStore.fetchWallet();
}
trackNodeCreatorEvent('User opened nodes panel', {

View File

@ -68,6 +68,7 @@ vi.mock('@/app/stores/aiGateway.store', async (importOriginal) => ({
useAiGatewayStore: vi.fn(() => ({
isNodeSupported: vi.fn(() => false),
isNodeTypeVersionSupported: vi.fn(() => true),
creditsLabelKey: 'generic.freeCredits',
})),
}));
@ -790,6 +791,7 @@ describe('NodeCreator - utils', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => true),
creditsLabelKey: 'generic.freeCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
vi.mocked(useNodeTypesStore).mockReturnValue({
getNodeVersions: vi.fn(() => [1, 1.1]),
@ -801,6 +803,32 @@ describe('NodeCreator - utils', () => {
expect(result.properties.tag).toEqual({ text: 'Free credits', pill: true });
});
it('should show n8n credits badge after a top-up or depleted allowance', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => true),
creditsLabelKey: 'generic.n8nCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
const [result] = finalizeItems([makeGatewayNode()]) as NodeCreateElement[];
expect(result.properties.tag).toEqual({ text: 'n8n credits', pill: true, type: 'info' });
});
it('should retag credits when the wallet arrives after the first finalize', () => {
const items = [makeGatewayNode()];
const [first] = finalizeItems(items) as NodeCreateElement[];
expect(first.properties.tag).toEqual({ text: 'Free credits', pill: true });
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => true),
creditsLabelKey: 'generic.n8nCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
const [second] = finalizeItems(items) as NodeCreateElement[];
expect(second.properties.tag).toEqual({ text: 'n8n credits', pill: true, type: 'info' });
});
it('should suppress Free credits badge when latest version is below the minimum', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
@ -828,6 +856,7 @@ describe('NodeCreator - utils', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported,
creditsLabelKey: 'generic.freeCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
finalizeItems([makeGatewayNode('my-node')]);
@ -843,6 +872,7 @@ describe('NodeCreator - utils', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported,
creditsLabelKey: 'generic.freeCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
finalizeItems([makeGatewayNode('my-node')]);
@ -860,6 +890,7 @@ describe('NodeCreator - utils', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported,
creditsLabelKey: 'generic.freeCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
finalizeItems([makeGatewayNode('my-node')]);
@ -871,6 +902,7 @@ describe('NodeCreator - utils', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn((name: string) => name === 'llamaParsePlatform'),
isNodeTypeVersionSupported: vi.fn(() => true),
creditsLabelKey: 'generic.freeCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
const [result] = finalizeItems([
@ -885,6 +917,7 @@ describe('NodeCreator - utils', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn((name: string) => name === '@vendor/n8n-nodes-connect.connect'),
isNodeTypeVersionSupported: vi.fn(() => true),
creditsLabelKey: 'generic.freeCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
const [result] = finalizeItems([
@ -1011,6 +1044,7 @@ describe('NodeCreator - utils', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn((name: string) => name.startsWith('supported')),
isNodeTypeVersionSupported: vi.fn(() => true),
creditsLabelKey: 'generic.freeCredits',
} as unknown as ReturnType<typeof useAiGatewayStore>);
vi.mocked(useNodeTypesStore).mockReturnValue({
getNodeVersions: vi.fn(() => [1]),

View File

@ -474,10 +474,11 @@ function applyNodeTags(element: INodeCreateElement): INodeCreateElement {
text: i18n.baseText('generic.betaProper'),
};
} else if (isAiGatewayEligibleNode(element.properties.name)) {
element.properties.tag = {
text: i18n.baseText('generic.freeCredits'),
pill: true,
};
const creditsLabelKey = useAiGatewayStore().creditsLabelKey;
element.properties.tag =
creditsLabelKey === 'generic.freeCredits'
? { text: i18n.baseText(creditsLabelKey), pill: true }
: { text: i18n.baseText(creditsLabelKey), pill: true, type: 'info' };
}
return element;
@ -485,10 +486,16 @@ function applyNodeTags(element: INodeCreateElement): INodeCreateElement {
export function finalizeItems(items: INodeCreateElement[]): INodeCreateElement[] {
return items
.map((item) => ({
...item,
uuid: `${item.key}-${uuidv4()}`,
}))
.map((item) =>
item.type !== 'node'
? { ...item, uuid: `${item.key}-${uuidv4()}` }
: {
...item,
uuid: `${item.key}-${uuidv4()}`,
// Clone so applyNodeTags cannot stamp a stale credits tag onto baselineItems.
properties: { ...item.properties },
},
)
.map(applyNodeTags);
}