diff --git a/electron/services/providers/provider-migration.ts b/electron/services/providers/provider-migration.ts index 83b29892b..73711c119 100644 --- a/electron/services/providers/provider-migration.ts +++ b/electron/services/providers/provider-migration.ts @@ -6,7 +6,7 @@ import { } from './provider-store'; import { getClawXProviderStore } from './store-instance'; -const PROVIDER_STORE_SCHEMA_VERSION = 1; +const PROVIDER_STORE_SCHEMA_VERSION = 2; export async function ensureProviderStoreMigrated(): Promise { const store = await getClawXProviderStore(); @@ -16,19 +16,31 @@ export async function ensureProviderStoreMigrated(): Promise { return; } - const legacyProviders = (store.get('providers') ?? {}) as Record; - const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null; - const existingDefaultAccountId = await getDefaultProviderAccountId(); + // v0 → v1: migrate legacy `providers` entries to `providerAccounts`. + if (schemaVersion < 1) { + const legacyProviders = (store.get('providers') ?? {}) as Record; + const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null; + const existingDefaultAccountId = await getDefaultProviderAccountId(); - for (const provider of Object.values(legacyProviders)) { - const account = providerConfigToAccount(provider, { - isDefault: provider.id === defaultProviderId, - }); - await saveProviderAccount(account); + for (const provider of Object.values(legacyProviders)) { + const account = providerConfigToAccount(provider, { + isDefault: provider.id === defaultProviderId, + }); + await saveProviderAccount(account); + } + + if (!existingDefaultAccountId && defaultProviderId) { + store.set('defaultProviderAccountId', defaultProviderId); + } } - if (!existingDefaultAccountId && defaultProviderId) { - store.set('defaultProviderAccountId', defaultProviderId); + // v1 → v2: clear the legacy `providers` store. + // The old `saveProvider()` was duplicating entries into this store, causing + // phantom and duplicate accounts when the migration above re-runs. + // Now that createAccount/updateAccount no longer write to `providers`, + // we clear it to prevent stale entries from causing issues. + if (schemaVersion < 2) { + store.set('providers', {}); } store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION); diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index 37c8eacfd..a3fcf476e 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -11,6 +11,7 @@ import type { import { BUILTIN_PROVIDER_TYPES } from '../../shared/providers/types'; import { ensureProviderStoreMigrated } from './provider-migration'; import { + deleteProviderAccount, getDefaultProviderAccountId, getProviderAccount, listProviderAccounts, @@ -24,12 +25,11 @@ import { deleteProvider, getApiKey, hasApiKey, - saveProvider, setDefaultProvider, storeApiKey, } from '../../utils/secure-storage'; import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../utils/openclaw-auth'; -import { getOpenClawProviderKeyForType } from '../../utils/provider-keys'; +import { getAliasSourceTypes, getOpenClawProviderKeyForType } from '../../utils/provider-keys'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; @@ -60,93 +60,82 @@ export class ProviderService { async listAccounts(): Promise { await ensureProviderStoreMigrated(); - let accounts = await listProviderAccounts(); - // Seed: when ClawX store is empty but OpenClaw config has providers, - // create ProviderAccount entries so the settings panel isn't blank. - // This covers users who configured providers via CLI or openclaw.json directly. - if (accounts.length === 0) { - const activeProviders = await getActiveOpenClawProviders(); - if (activeProviders.size > 0) { - accounts = await this.seedAccountsFromOpenClawConfig(); - } - return accounts; + // ── openclaw.json is the ONLY source of truth ── + // The provider list is derived entirely from openclaw.json. + // The electron-store is only used as a metadata cache (label, authMode, etc.). + + const { providers: openClawProviders, defaultModel } = await getOpenClawProvidersConfig(); + const activeProviders = await getActiveOpenClawProviders(); + + if (activeProviders.size === 0) { + return []; } - // Sync check: hide accounts whose provider no longer exists in OpenClaw - // JSON (e.g. user deleted openclaw.json manually). We intentionally do - // NOT delete from the store — this preserves API key associations so that - // when the user restores the config, accounts reappear with keys intact. - { - const activeProviders = await getActiveOpenClawProviders(); - // When OpenClaw config has no providers (e.g. user deleted the file), - // treat ALL accounts as stale so ClawX stays in sync. - const configEmpty = activeProviders.size === 0; + // Read store accounts as a lookup cache (NOT as the source of what to display). + const allStoreAccounts = await listProviderAccounts(); - if (configEmpty) { - logger.info('[provider-sync] OpenClaw config empty — hiding all provider accounts from display'); - return []; - } + // Index store accounts by their openclaw runtime key for fast lookup. + const storeByKey = new Map(); + for (const account of allStoreAccounts) { + const ock = getOpenClawProviderKeyForType(account.vendorId, account.id); + const group = storeByKey.get(ock) ?? []; + group.push(account); + storeByKey.set(ock, group); + } - accounts = accounts.filter((account) => { - const openClawKey = getOpenClawProviderKeyForType(account.vendorId, account.id); - const isActive = - activeProviders.has(account.vendorId) || - activeProviders.has(account.id) || - activeProviders.has(openClawKey); + const result: ProviderAccount[] = []; + const processedKeys = new Set(); - if (!isActive) { - logger.info(`[provider-sync] Hiding stale provider account "${account.id}" (not in OpenClaw config)`); + // For each active provider in openclaw.json, produce exactly ONE account. + for (const key of activeProviders) { + if (processedKeys.has(key)) continue; + processedKeys.add(key); + + const storeGroup = storeByKey.get(key) ?? []; + + if (storeGroup.length > 0) { + // Pick the best store account for this key: + // 1. Prefer alias variants (e.g. minimax-portal-cn over minimax-portal) + // 2. Among equal variants, prefer the most recently updated + const aliasAccounts = storeGroup.filter((a) => a.vendorId !== key); + const candidates = aliasAccounts.length > 0 ? aliasAccounts : storeGroup; + candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + result.push(candidates[0]); + + // Clean up orphaned duplicates from the store. + const kept = candidates[0]; + for (const account of storeGroup) { + if (account.id !== kept.id) { + logger.info( + `[provider-sync] Removing orphaned account "${account.id}" for key "${key}" (keeping "${kept.id}")`, + ); + await deleteProviderAccount(account.id); + } + } + } else { + // No store account for this key — create a seed from openclaw.json. + const entry = openClawProviders[key]; + if (entry) { + const seeded = ProviderService.buildAccountsFromOpenClawEntries( + { [key]: entry }, + new Set(), + new Set(), + defaultModel, + ); + for (const account of seeded) { + await saveProviderAccount(account); + result.push(account); + logger.info(`[provider-sync] Seeded provider account "${account.id}" from openclaw.json`); + } } - return isActive; - }); - } - - // Import: detect providers in OpenClaw config not yet in the ClawX store. - { - const { providers: openClawProviders, defaultModel } = await getOpenClawProvidersConfig(); - const existingIds = new Set(accounts.map((a) => a.id)); - const existingVendorIds = new Set(accounts.map((a) => a.vendorId)); - const newAccounts = ProviderService.buildAccountsFromOpenClawEntries( - openClawProviders, existingIds, existingVendorIds, defaultModel, - ); - for (const account of newAccounts) { - await saveProviderAccount(account); - accounts.push(account); - } - if (newAccounts.length > 0) { - logger.info( - `[provider-sync] Imported ${newAccounts.length} new provider(s) from openclaw.json: ${newAccounts.map((a) => a.id).join(', ')}`, - ); } } - return accounts; + return result; } - /** - * Seed the ClawX provider store from openclaw.json when the store is empty. - * This is a one-time operation for users who configured providers externally. - */ - private async seedAccountsFromOpenClawConfig(): Promise { - const { providers, defaultModel } = await getOpenClawProvidersConfig(); - const seeded = ProviderService.buildAccountsFromOpenClawEntries( - providers, new Set(), new Set(), defaultModel, - ); - - for (const account of seeded) { - await saveProviderAccount(account); - } - - if (seeded.length > 0) { - logger.info( - `[provider-seed] Seeded ${seeded.length} provider account(s) from openclaw.json: ${seeded.map((a) => a.id).join(', ')}`, - ); - } - - return seeded; - } /** * Build ProviderAccount objects from OpenClaw config entries, skipping any @@ -176,6 +165,13 @@ export class ProviderService { // created "openrouter-uuid" via UI — no need to import bare "openrouter"). if (existingVendorIds.has(vendorId)) continue; + // Skip if an alias source type already exists. + // e.g. openclaw.json has "minimax-portal" but account vendorId is "minimax-portal-cn" + const aliasSources = getAliasSourceTypes(key); + if (aliasSources.some((source) => existingVendorIds.has(source))) { + continue; + } + const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl : definition?.providerConfig?.baseUrl; // Infer model from the default model if it belongs to this provider @@ -221,7 +217,8 @@ export class ProviderService { async createAccount(account: ProviderAccount, apiKey?: string): Promise { await ensureProviderStoreMigrated(); - await saveProvider(providerAccountToConfig(account)); + // Only save to providerAccounts store — do NOT call saveProvider() which + // writes to the legacy `providers` store and causes phantom/duplicate issues. await saveProviderAccount(account); if (apiKey !== undefined && apiKey.trim()) { await storeApiKey(account.id, apiKey.trim()); @@ -247,7 +244,7 @@ export class ProviderService { updatedAt: patch.updatedAt ?? new Date().toISOString(), }; - await saveProvider(providerAccountToConfig(nextAccount)); + // Only save to providerAccounts store — skip legacy saveProvider(). await saveProviderAccount(nextAccount); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); diff --git a/electron/utils/provider-keys.ts b/electron/utils/provider-keys.ts index 7dac4414f..583a1ad3b 100644 --- a/electron/utils/provider-keys.ts +++ b/electron/utils/provider-keys.ts @@ -25,6 +25,16 @@ export function getOpenClawProviderKeyForType(type: string, providerId: string): return PROVIDER_KEY_ALIASES[type] ?? type; } +/** + * Get all vendorId values that map to the given openclaw.json key via alias. + * e.g. getAliasSourceTypes('minimax-portal') → ['minimax-portal-cn'] + */ +export function getAliasSourceTypes(openClawKey: string): string[] { + return Object.entries(PROVIDER_KEY_ALIASES) + .filter(([, target]) => target === openClawKey) + .map(([source]) => source); +} + export function isOAuthProviderType(type: string): boolean { return OAUTH_PROVIDER_TYPE_SET.has(type); } diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 14d8316b9..b54f7436b 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -1076,11 +1076,8 @@ function AddProviderDialog({ const handleStartOAuth = async () => { if (!selectedType) return; - if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) { - toast.error(t('aiProviders.toast.minimaxConflict')); - return; - } - if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) { + const hasMinimax = existingVendorIds.has('minimax-portal') || existingVendorIds.has('minimax-portal-cn'); + if ((selectedType === 'minimax-portal' || selectedType === 'minimax-portal-cn') && hasMinimax) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } @@ -1133,6 +1130,11 @@ function AddProviderDialog({ }; const availableTypes = PROVIDER_TYPE_INFO.filter((type) => { + // MiniMax portal variants are mutually exclusive — hide BOTH variants + // when either one already exists (account may have vendorId of either variant). + const hasMinimax = existingVendorIds.has('minimax-portal') || existingVendorIds.has('minimax-portal-cn'); + if ((type.id === 'minimax-portal' || type.id === 'minimax-portal-cn') && hasMinimax) return false; + const vendor = vendorMap.get(type.id); if (!vendor) { return !existingVendorIds.has(type.id) || type.id === 'custom'; @@ -1143,11 +1145,8 @@ function AddProviderDialog({ const handleAdd = async () => { if (!selectedType) return; - if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) { - toast.error(t('aiProviders.toast.minimaxConflict')); - return; - } - if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) { + const hasMinimax = existingVendorIds.has('minimax-portal') || existingVendorIds.has('minimax-portal-cn'); + if ((selectedType === 'minimax-portal' || selectedType === 'minimax-portal-cn') && hasMinimax) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } diff --git a/tests/unit/provider-service-stale-cleanup.test.ts b/tests/unit/provider-service-stale-cleanup.test.ts index 420aa342d..55adcb8fe 100644 --- a/tests/unit/provider-service-stale-cleanup.test.ts +++ b/tests/unit/provider-service-stale-cleanup.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ getActiveOpenClawProviders: vi.fn(), getOpenClawProvidersConfig: vi.fn(), getOpenClawProviderKeyForType: vi.fn(), + getAliasSourceTypes: vi.fn(), loggerWarn: vi.fn(), loggerInfo: vi.fn(), })); @@ -34,6 +35,7 @@ vi.mock('@electron/utils/openclaw-auth', () => ({ vi.mock('@electron/utils/provider-keys', () => ({ getOpenClawProviderKeyForType: mocks.getOpenClawProviderKeyForType, + getAliasSourceTypes: mocks.getAliasSourceTypes, })); vi.mock('@electron/utils/secure-storage', () => ({ @@ -77,129 +79,82 @@ function makeAccount(overrides: Partial = {}): ProviderAccount }; } -describe('ProviderService.listAccounts stale-account cleanup', () => { +/** + * Default mock: getOpenClawProviderKeyForType maps type to itself, + * except minimax-portal-cn → minimax-portal (alias). + */ +function setupDefaultKeyMapping() { + mocks.getOpenClawProviderKeyForType.mockImplementation( + (type: string) => type === 'minimax-portal-cn' ? 'minimax-portal' : type, + ); +} + +describe('ProviderService.listAccounts (openclaw.json as sole source of truth)', () => { let service: ProviderService; beforeEach(() => { vi.clearAllMocks(); mocks.ensureProviderStoreMigrated.mockResolvedValue(undefined); - mocks.getOpenClawProviderKeyForType.mockImplementation( - (type: string, id: string) => `${type}/${id}`, - ); + setupDefaultKeyMapping(); + mocks.getAliasSourceTypes.mockReturnValue([]); mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: {}, defaultModel: undefined }); + mocks.listProviderAccounts.mockResolvedValue([]); service = new ProviderService(); }); - it('hides ALL accounts when activeProviders is empty (config missing/deleted)', async () => { - const accounts = [ - makeAccount({ id: 'custom-1', vendorId: 'custom' as ProviderAccount['vendorId'] }), + it('returns empty when activeProviders is empty', async () => { + mocks.listProviderAccounts.mockResolvedValue([ makeAccount({ id: 'moonshot-1', vendorId: 'moonshot' as ProviderAccount['vendorId'] }), - makeAccount({ id: 'anthropic-1', vendorId: 'anthropic' as ProviderAccount['vendorId'] }), - ]; - mocks.listProviderAccounts.mockResolvedValue(accounts); + ]); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set()); const result = await service.listAccounts(); - // All accounts hidden (not deleted) when config is empty expect(result).toEqual([]); - expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); }); - it('hides stale non-builtin accounts when config has active providers', async () => { - const accounts = [ + it('returns only providers present in openclaw.json, ignoring extra store accounts', async () => { + mocks.listProviderAccounts.mockResolvedValue([ makeAccount({ id: 'moonshot-1', vendorId: 'moonshot' as ProviderAccount['vendorId'] }), - makeAccount({ id: 'custom-stale', vendorId: 'custom' as ProviderAccount['vendorId'] }), - ]; - mocks.listProviderAccounts.mockResolvedValue(accounts); - // Only moonshot is active in config + makeAccount({ id: 'custom-orphan', vendorId: 'custom' as ProviderAccount['vendorId'] }), + ]); + // Only moonshot is active — custom is NOT in openclaw.json mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { moonshot: { baseUrl: 'https://api.moonshot.cn/v1' } }, + defaultModel: undefined, + }); const result = await service.listAccounts(); - // custom-stale hidden (not deleted) from display - expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0].id).toBe('moonshot-1'); }); - it('hides builtin provider accounts when not in activeProviders', async () => { - const accounts = [ - makeAccount({ id: 'anthropic-1', vendorId: 'anthropic' as ProviderAccount['vendorId'] }), - makeAccount({ id: 'openai-1', vendorId: 'openai' as ProviderAccount['vendorId'] }), - ]; - mocks.listProviderAccounts.mockResolvedValue(accounts); - // Config has moonshot, but NOT anthropic or openai - mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot'])); - - const result = await service.listAccounts(); - - // Builtin accounts also hidden when not in OpenClaw config - expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); - expect(result).toEqual([]); - }); - - it('returns empty when no accounts and no active OpenClaw providers', async () => { - mocks.listProviderAccounts.mockResolvedValue([]); - mocks.getActiveOpenClawProviders.mockResolvedValue(new Set()); - - const result = await service.listAccounts(); - - expect(result).toEqual([]); - expect(mocks.getActiveOpenClawProviders).toHaveBeenCalled(); - expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); - }); - - it('matches accounts by vendorId, id, or openClawKey', async () => { - const accounts = [ - makeAccount({ id: 'custom-abc', vendorId: 'custom' as ProviderAccount['vendorId'] }), - ]; - mocks.listProviderAccounts.mockResolvedValue(accounts); - // The openClawKey matches - mocks.getOpenClawProviderKeyForType.mockReturnValue('custom/custom-abc'); - mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['custom/custom-abc'])); - - const result = await service.listAccounts(); - - expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); - expect(result).toEqual(accounts); - }); - - it('imports new providers from OpenClaw config not yet in ClawX store', async () => { - const accounts = [ - makeAccount({ id: 'moonshot', vendorId: 'moonshot' as ProviderAccount['vendorId'] }), - ]; - mocks.listProviderAccounts.mockResolvedValue(accounts); - mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot', 'siliconflow'])); + it('seeds new account from openclaw.json when no store match exists', async () => { + mocks.listProviderAccounts.mockResolvedValue([]); // empty store + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['siliconflow'])); mocks.getOpenClawProvidersConfig.mockResolvedValue({ - providers: { - moonshot: { baseUrl: 'https://api.moonshot.cn/v1' }, - siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1' }, - }, + providers: { siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1' } }, defaultModel: undefined, }); const result = await service.listAccounts(); - // moonshot already exists, siliconflow should be imported expect(mocks.saveProviderAccount).toHaveBeenCalledTimes(1); expect(mocks.saveProviderAccount).toHaveBeenCalledWith( expect.objectContaining({ id: 'siliconflow' }), ); - expect(result).toHaveLength(2); - expect(result.map((a: ProviderAccount) => a.id)).toContain('siliconflow'); + expect(result).toHaveLength(1); }); - it('does not import providers already in ClawX store', async () => { - const accounts = [ - makeAccount({ id: 'moonshot', vendorId: 'moonshot' as ProviderAccount['vendorId'] }), - ]; - mocks.listProviderAccounts.mockResolvedValue(accounts); + it('uses store metadata when match exists (does not re-seed)', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ id: 'moonshot', vendorId: 'moonshot' as ProviderAccount['vendorId'], label: 'My Moonshot' }), + ]); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot'])); mocks.getOpenClawProvidersConfig.mockResolvedValue({ - providers: { - moonshot: { baseUrl: 'https://api.moonshot.cn/v1' }, - }, + providers: { moonshot: { baseUrl: 'https://api.moonshot.cn/v1' } }, defaultModel: undefined, }); @@ -207,28 +162,130 @@ describe('ProviderService.listAccounts stale-account cleanup', () => { expect(mocks.saveProviderAccount).not.toHaveBeenCalled(); expect(result).toHaveLength(1); + expect(result[0].label).toBe('My Moonshot'); }); - it('does not create duplicate when account id differs but vendorId matches', async () => { - // User added openrouter via UI → id is "openrouter-uuid", vendorId is "openrouter" - // openclaw.json has "openrouter" entry → should NOT import because vendorId matches - const accounts = [ + it('matches UUID-based store account to openclaw key via getOpenClawProviderKeyForType', async () => { + mocks.listProviderAccounts.mockResolvedValue([ makeAccount({ id: 'openrouter-uuid-1234', vendorId: 'openrouter' as ProviderAccount['vendorId'] }), - ]; - mocks.listProviderAccounts.mockResolvedValue(accounts); + ]); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openrouter'])); mocks.getOpenClawProvidersConfig.mockResolvedValue({ - providers: { - openrouter: { baseUrl: 'https://openrouter.ai/api/v1' }, - }, - defaultModel: 'openrouter/openai/gpt-5.4', + providers: { openrouter: { baseUrl: 'https://openrouter.ai/api/v1' } }, + defaultModel: undefined, }); const result = await service.listAccounts(); - // Should NOT create a duplicate "openrouter" account expect(mocks.saveProviderAccount).not.toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0].id).toBe('openrouter-uuid-1234'); }); + + it('prefers CN alias account over Global phantom for minimax-portal key', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ + id: 'minimax-portal', + vendorId: 'minimax-portal' as ProviderAccount['vendorId'], + label: 'MiniMax (Global)', + updatedAt: '2026-03-20T00:00:00.000Z', + }), + makeAccount({ + id: 'minimax-portal-cn-uuid', + vendorId: 'minimax-portal-cn' as ProviderAccount['vendorId'], + label: 'MiniMax (CN)', + updatedAt: '2026-03-21T00:00:00.000Z', + }), + ]); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['minimax-portal'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { 'minimax-portal': { baseUrl: 'https://api.minimaxi.com/anthropic' } }, + defaultModel: undefined, + }); + + const result = await service.listAccounts(); + + // Only CN should remain, phantom Global deleted from store + expect(result).toHaveLength(1); + expect(result[0].id).toBe('minimax-portal-cn-uuid'); + expect(result[0].label).toBe('MiniMax (CN)'); + expect(mocks.deleteProviderAccount).toHaveBeenCalledWith('minimax-portal'); + }); + + it('shows only one CN when only CN account exists (no phantom)', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ + id: 'minimax-portal-cn-uuid', + vendorId: 'minimax-portal-cn' as ProviderAccount['vendorId'], + label: 'MiniMax (CN)', + }), + ]); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['minimax-portal'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { 'minimax-portal': { baseUrl: 'https://api.minimaxi.com/anthropic' } }, + defaultModel: undefined, + }); + + const result = await service.listAccounts(); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('minimax-portal-cn-uuid'); + expect(mocks.saveProviderAccount).not.toHaveBeenCalled(); + expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); + }); + + it('deduplicates multiple CN accounts from delete+re-add, keeps newest', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ + id: 'minimax-portal-cn-uuid1', + vendorId: 'minimax-portal-cn' as ProviderAccount['vendorId'], + updatedAt: '2026-03-20T00:00:00.000Z', + }), + makeAccount({ + id: 'minimax-portal-cn-uuid2', + vendorId: 'minimax-portal-cn' as ProviderAccount['vendorId'], + updatedAt: '2026-03-21T00:00:00.000Z', + }), + makeAccount({ + id: 'minimax-portal-cn-uuid3', + vendorId: 'minimax-portal-cn' as ProviderAccount['vendorId'], + updatedAt: '2026-03-22T00:00:00.000Z', + }), + ]); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['minimax-portal'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { 'minimax-portal': {} }, + defaultModel: undefined, + }); + + const result = await service.listAccounts(); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('minimax-portal-cn-uuid3'); + expect(mocks.deleteProviderAccount).toHaveBeenCalledTimes(2); + expect(mocks.deleteProviderAccount).toHaveBeenCalledWith('minimax-portal-cn-uuid1'); + expect(mocks.deleteProviderAccount).toHaveBeenCalledWith('minimax-portal-cn-uuid2'); + }); + + it('handles multiple active providers from openclaw.json correctly', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ id: 'openrouter-uuid', vendorId: 'openrouter' as ProviderAccount['vendorId'] }), + makeAccount({ id: 'minimax-portal-cn-uuid', vendorId: 'minimax-portal-cn' as ProviderAccount['vendorId'] }), + ]); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openrouter', 'minimax-portal'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { + openrouter: { baseUrl: 'https://openrouter.ai/api/v1' }, + 'minimax-portal': { baseUrl: 'https://api.minimaxi.com/anthropic' }, + }, + defaultModel: undefined, + }); + + const result = await service.listAccounts(); + + expect(result).toHaveLength(2); + const ids = result.map((a: ProviderAccount) => a.id); + expect(ids).toContain('openrouter-uuid'); + expect(ids).toContain('minimax-portal-cn-uuid'); + }); });