fix: use openclaw.json as single source of truth for provider list (#649)
This commit is contained in:
committed by
GitHub
Unverified
parent
859e3fd6c5
commit
05ae404dee
@@ -6,7 +6,7 @@ import {
|
|||||||
} from './provider-store';
|
} from './provider-store';
|
||||||
import { getClawXProviderStore } from './store-instance';
|
import { getClawXProviderStore } from './store-instance';
|
||||||
|
|
||||||
const PROVIDER_STORE_SCHEMA_VERSION = 1;
|
const PROVIDER_STORE_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
export async function ensureProviderStoreMigrated(): Promise<void> {
|
export async function ensureProviderStoreMigrated(): Promise<void> {
|
||||||
const store = await getClawXProviderStore();
|
const store = await getClawXProviderStore();
|
||||||
@@ -16,6 +16,8 @@ export async function ensureProviderStoreMigrated(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0 → v1: migrate legacy `providers` entries to `providerAccounts`.
|
||||||
|
if (schemaVersion < 1) {
|
||||||
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
|
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
|
||||||
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
|
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
|
||||||
const existingDefaultAccountId = await getDefaultProviderAccountId();
|
const existingDefaultAccountId = await getDefaultProviderAccountId();
|
||||||
@@ -30,6 +32,16 @@ export async function ensureProviderStoreMigrated(): Promise<void> {
|
|||||||
if (!existingDefaultAccountId && defaultProviderId) {
|
if (!existingDefaultAccountId && defaultProviderId) {
|
||||||
store.set('defaultProviderAccountId', 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);
|
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import { BUILTIN_PROVIDER_TYPES } from '../../shared/providers/types';
|
import { BUILTIN_PROVIDER_TYPES } from '../../shared/providers/types';
|
||||||
import { ensureProviderStoreMigrated } from './provider-migration';
|
import { ensureProviderStoreMigrated } from './provider-migration';
|
||||||
import {
|
import {
|
||||||
|
deleteProviderAccount,
|
||||||
getDefaultProviderAccountId,
|
getDefaultProviderAccountId,
|
||||||
getProviderAccount,
|
getProviderAccount,
|
||||||
listProviderAccounts,
|
listProviderAccounts,
|
||||||
@@ -24,12 +25,11 @@ import {
|
|||||||
deleteProvider,
|
deleteProvider,
|
||||||
getApiKey,
|
getApiKey,
|
||||||
hasApiKey,
|
hasApiKey,
|
||||||
saveProvider,
|
|
||||||
setDefaultProvider,
|
setDefaultProvider,
|
||||||
storeApiKey,
|
storeApiKey,
|
||||||
} from '../../utils/secure-storage';
|
} from '../../utils/secure-storage';
|
||||||
import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../utils/openclaw-auth';
|
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 type { ProviderWithKeyInfo } from '../../shared/providers/types';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
@@ -60,93 +60,82 @@ export class ProviderService {
|
|||||||
|
|
||||||
async listAccounts(): Promise<ProviderAccount[]> {
|
async listAccounts(): Promise<ProviderAccount[]> {
|
||||||
await ensureProviderStoreMigrated();
|
await ensureProviderStoreMigrated();
|
||||||
let accounts = await listProviderAccounts();
|
|
||||||
|
|
||||||
// Seed: when ClawX store is empty but OpenClaw config has providers,
|
// ── openclaw.json is the ONLY source of truth ──
|
||||||
// create ProviderAccount entries so the settings panel isn't blank.
|
// The provider list is derived entirely from openclaw.json.
|
||||||
// This covers users who configured providers via CLI or openclaw.json directly.
|
// The electron-store is only used as a metadata cache (label, authMode, etc.).
|
||||||
if (accounts.length === 0) {
|
|
||||||
|
const { providers: openClawProviders, defaultModel } = await getOpenClawProvidersConfig();
|
||||||
const activeProviders = await getActiveOpenClawProviders();
|
const activeProviders = await getActiveOpenClawProviders();
|
||||||
if (activeProviders.size > 0) {
|
|
||||||
accounts = await this.seedAccountsFromOpenClawConfig();
|
|
||||||
}
|
|
||||||
return accounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync check: hide accounts whose provider no longer exists in OpenClaw
|
if (activeProviders.size === 0) {
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (configEmpty) {
|
|
||||||
logger.info('[provider-sync] OpenClaw config empty — hiding all provider accounts from display');
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts = accounts.filter((account) => {
|
// Read store accounts as a lookup cache (NOT as the source of what to display).
|
||||||
const openClawKey = getOpenClawProviderKeyForType(account.vendorId, account.id);
|
const allStoreAccounts = await listProviderAccounts();
|
||||||
const isActive =
|
|
||||||
activeProviders.has(account.vendorId) ||
|
|
||||||
activeProviders.has(account.id) ||
|
|
||||||
activeProviders.has(openClawKey);
|
|
||||||
|
|
||||||
if (!isActive) {
|
// Index store accounts by their openclaw runtime key for fast lookup.
|
||||||
logger.info(`[provider-sync] Hiding stale provider account "${account.id}" (not in OpenClaw config)`);
|
const storeByKey = new Map<string, ProviderAccount[]>();
|
||||||
}
|
for (const account of allStoreAccounts) {
|
||||||
return isActive;
|
const ock = getOpenClawProviderKeyForType(account.vendorId, account.id);
|
||||||
});
|
const group = storeByKey.get(ock) ?? [];
|
||||||
|
group.push(account);
|
||||||
|
storeByKey.set(ock, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import: detect providers in OpenClaw config not yet in the ClawX store.
|
const result: ProviderAccount[] = [];
|
||||||
{
|
const processedKeys = new Set<string>();
|
||||||
const { providers: openClawProviders, defaultModel } = await getOpenClawProvidersConfig();
|
|
||||||
const existingIds = new Set(accounts.map((a) => a.id));
|
// For each active provider in openclaw.json, produce exactly ONE account.
|
||||||
const existingVendorIds = new Set(accounts.map((a) => a.vendorId));
|
for (const key of activeProviders) {
|
||||||
const newAccounts = ProviderService.buildAccountsFromOpenClawEntries(
|
if (processedKeys.has(key)) continue;
|
||||||
openClawProviders, existingIds, existingVendorIds, defaultModel,
|
processedKeys.add(key);
|
||||||
);
|
|
||||||
for (const account of newAccounts) {
|
const storeGroup = storeByKey.get(key) ?? [];
|
||||||
await saveProviderAccount(account);
|
|
||||||
accounts.push(account);
|
if (storeGroup.length > 0) {
|
||||||
}
|
// Pick the best store account for this key:
|
||||||
if (newAccounts.length > 0) {
|
// 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(
|
logger.info(
|
||||||
`[provider-sync] Imported ${newAccounts.length} new provider(s) from openclaw.json: ${newAccounts.map((a) => a.id).join(', ')}`,
|
`[provider-sync] Removing orphaned account "${account.id}" for key "${key}" (keeping "${kept.id}")`,
|
||||||
);
|
);
|
||||||
|
await deleteProviderAccount(account.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
return accounts;
|
// No store account for this key — create a seed from openclaw.json.
|
||||||
}
|
const entry = openClawProviders[key];
|
||||||
|
if (entry) {
|
||||||
/**
|
|
||||||
* 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<ProviderAccount[]> {
|
|
||||||
const { providers, defaultModel } = await getOpenClawProvidersConfig();
|
|
||||||
|
|
||||||
const seeded = ProviderService.buildAccountsFromOpenClawEntries(
|
const seeded = ProviderService.buildAccountsFromOpenClawEntries(
|
||||||
providers, new Set(), new Set(), defaultModel,
|
{ [key]: entry },
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
defaultModel,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const account of seeded) {
|
for (const account of seeded) {
|
||||||
await saveProviderAccount(account);
|
await saveProviderAccount(account);
|
||||||
|
result.push(account);
|
||||||
|
logger.info(`[provider-sync] Seeded provider account "${account.id}" from openclaw.json`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seeded.length > 0) {
|
return result;
|
||||||
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
|
* 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").
|
// created "openrouter-uuid" via UI — no need to import bare "openrouter").
|
||||||
if (existingVendorIds.has(vendorId)) continue;
|
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;
|
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl : definition?.providerConfig?.baseUrl;
|
||||||
|
|
||||||
// Infer model from the default model if it belongs to this provider
|
// 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<ProviderAccount> {
|
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
|
||||||
await ensureProviderStoreMigrated();
|
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);
|
await saveProviderAccount(account);
|
||||||
if (apiKey !== undefined && apiKey.trim()) {
|
if (apiKey !== undefined && apiKey.trim()) {
|
||||||
await storeApiKey(account.id, apiKey.trim());
|
await storeApiKey(account.id, apiKey.trim());
|
||||||
@@ -247,7 +244,7 @@ export class ProviderService {
|
|||||||
updatedAt: patch.updatedAt ?? new Date().toISOString(),
|
updatedAt: patch.updatedAt ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await saveProvider(providerAccountToConfig(nextAccount));
|
// Only save to providerAccounts store — skip legacy saveProvider().
|
||||||
await saveProviderAccount(nextAccount);
|
await saveProviderAccount(nextAccount);
|
||||||
if (apiKey !== undefined) {
|
if (apiKey !== undefined) {
|
||||||
const trimmedKey = apiKey.trim();
|
const trimmedKey = apiKey.trim();
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export function getOpenClawProviderKeyForType(type: string, providerId: string):
|
|||||||
return PROVIDER_KEY_ALIASES[type] ?? type;
|
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 {
|
export function isOAuthProviderType(type: string): boolean {
|
||||||
return OAUTH_PROVIDER_TYPE_SET.has(type);
|
return OAUTH_PROVIDER_TYPE_SET.has(type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1076,11 +1076,8 @@ function AddProviderDialog({
|
|||||||
const handleStartOAuth = async () => {
|
const handleStartOAuth = async () => {
|
||||||
if (!selectedType) return;
|
if (!selectedType) return;
|
||||||
|
|
||||||
if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
|
const hasMinimax = existingVendorIds.has('minimax-portal') || existingVendorIds.has('minimax-portal-cn');
|
||||||
toast.error(t('aiProviders.toast.minimaxConflict'));
|
if ((selectedType === 'minimax-portal' || selectedType === 'minimax-portal-cn') && hasMinimax) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
|
|
||||||
toast.error(t('aiProviders.toast.minimaxConflict'));
|
toast.error(t('aiProviders.toast.minimaxConflict'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1133,6 +1130,11 @@ function AddProviderDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const availableTypes = PROVIDER_TYPE_INFO.filter((type) => {
|
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);
|
const vendor = vendorMap.get(type.id);
|
||||||
if (!vendor) {
|
if (!vendor) {
|
||||||
return !existingVendorIds.has(type.id) || type.id === 'custom';
|
return !existingVendorIds.has(type.id) || type.id === 'custom';
|
||||||
@@ -1143,11 +1145,8 @@ function AddProviderDialog({
|
|||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!selectedType) return;
|
if (!selectedType) return;
|
||||||
|
|
||||||
if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
|
const hasMinimax = existingVendorIds.has('minimax-portal') || existingVendorIds.has('minimax-portal-cn');
|
||||||
toast.error(t('aiProviders.toast.minimaxConflict'));
|
if ((selectedType === 'minimax-portal' || selectedType === 'minimax-portal-cn') && hasMinimax) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
|
|
||||||
toast.error(t('aiProviders.toast.minimaxConflict'));
|
toast.error(t('aiProviders.toast.minimaxConflict'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getActiveOpenClawProviders: vi.fn(),
|
getActiveOpenClawProviders: vi.fn(),
|
||||||
getOpenClawProvidersConfig: vi.fn(),
|
getOpenClawProvidersConfig: vi.fn(),
|
||||||
getOpenClawProviderKeyForType: vi.fn(),
|
getOpenClawProviderKeyForType: vi.fn(),
|
||||||
|
getAliasSourceTypes: vi.fn(),
|
||||||
loggerWarn: vi.fn(),
|
loggerWarn: vi.fn(),
|
||||||
loggerInfo: vi.fn(),
|
loggerInfo: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -34,6 +35,7 @@ vi.mock('@electron/utils/openclaw-auth', () => ({
|
|||||||
|
|
||||||
vi.mock('@electron/utils/provider-keys', () => ({
|
vi.mock('@electron/utils/provider-keys', () => ({
|
||||||
getOpenClawProviderKeyForType: mocks.getOpenClawProviderKeyForType,
|
getOpenClawProviderKeyForType: mocks.getOpenClawProviderKeyForType,
|
||||||
|
getAliasSourceTypes: mocks.getAliasSourceTypes,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@electron/utils/secure-storage', () => ({
|
vi.mock('@electron/utils/secure-storage', () => ({
|
||||||
@@ -77,129 +79,82 @@ function makeAccount(overrides: Partial<ProviderAccount> = {}): 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;
|
let service: ProviderService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.ensureProviderStoreMigrated.mockResolvedValue(undefined);
|
mocks.ensureProviderStoreMigrated.mockResolvedValue(undefined);
|
||||||
mocks.getOpenClawProviderKeyForType.mockImplementation(
|
setupDefaultKeyMapping();
|
||||||
(type: string, id: string) => `${type}/${id}`,
|
mocks.getAliasSourceTypes.mockReturnValue([]);
|
||||||
);
|
|
||||||
mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: {}, defaultModel: undefined });
|
mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: {}, defaultModel: undefined });
|
||||||
|
mocks.listProviderAccounts.mockResolvedValue([]);
|
||||||
service = new ProviderService();
|
service = new ProviderService();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides ALL accounts when activeProviders is empty (config missing/deleted)', async () => {
|
it('returns empty when activeProviders is empty', async () => {
|
||||||
const accounts = [
|
mocks.listProviderAccounts.mockResolvedValue([
|
||||||
makeAccount({ id: 'custom-1', vendorId: 'custom' as ProviderAccount['vendorId'] }),
|
|
||||||
makeAccount({ id: 'moonshot-1', vendorId: 'moonshot' as ProviderAccount['vendorId'] }),
|
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<string>());
|
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set<string>());
|
||||||
|
|
||||||
const result = await service.listAccounts();
|
const result = await service.listAccounts();
|
||||||
|
|
||||||
// All accounts hidden (not deleted) when config is empty
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
expect(mocks.deleteProviderAccount).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides stale non-builtin accounts when config has active providers', async () => {
|
it('returns only providers present in openclaw.json, ignoring extra store accounts', async () => {
|
||||||
const accounts = [
|
mocks.listProviderAccounts.mockResolvedValue([
|
||||||
makeAccount({ id: 'moonshot-1', vendorId: 'moonshot' as ProviderAccount['vendorId'] }),
|
makeAccount({ id: 'moonshot-1', vendorId: 'moonshot' as ProviderAccount['vendorId'] }),
|
||||||
makeAccount({ id: 'custom-stale', vendorId: 'custom' as ProviderAccount['vendorId'] }),
|
makeAccount({ id: 'custom-orphan', vendorId: 'custom' as ProviderAccount['vendorId'] }),
|
||||||
];
|
]);
|
||||||
mocks.listProviderAccounts.mockResolvedValue(accounts);
|
// Only moonshot is active — custom is NOT in openclaw.json
|
||||||
// Only moonshot is active in config
|
|
||||||
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot']));
|
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot']));
|
||||||
|
mocks.getOpenClawProvidersConfig.mockResolvedValue({
|
||||||
|
providers: { moonshot: { baseUrl: 'https://api.moonshot.cn/v1' } },
|
||||||
|
defaultModel: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await service.listAccounts();
|
const result = await service.listAccounts();
|
||||||
|
|
||||||
// custom-stale hidden (not deleted) from display
|
|
||||||
expect(mocks.deleteProviderAccount).not.toHaveBeenCalled();
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].id).toBe('moonshot-1');
|
expect(result[0].id).toBe('moonshot-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides builtin provider accounts when not in activeProviders', async () => {
|
it('seeds new account from openclaw.json when no store match exists', async () => {
|
||||||
const accounts = [
|
mocks.listProviderAccounts.mockResolvedValue([]); // empty store
|
||||||
makeAccount({ id: 'anthropic-1', vendorId: 'anthropic' as ProviderAccount['vendorId'] }),
|
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['siliconflow']));
|
||||||
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']));
|
|
||||||
mocks.getOpenClawProvidersConfig.mockResolvedValue({
|
mocks.getOpenClawProvidersConfig.mockResolvedValue({
|
||||||
providers: {
|
providers: { siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1' } },
|
||||||
moonshot: { baseUrl: 'https://api.moonshot.cn/v1' },
|
|
||||||
siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1' },
|
|
||||||
},
|
|
||||||
defaultModel: undefined,
|
defaultModel: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.listAccounts();
|
const result = await service.listAccounts();
|
||||||
|
|
||||||
// moonshot already exists, siliconflow should be imported
|
|
||||||
expect(mocks.saveProviderAccount).toHaveBeenCalledTimes(1);
|
expect(mocks.saveProviderAccount).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.saveProviderAccount).toHaveBeenCalledWith(
|
expect(mocks.saveProviderAccount).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: 'siliconflow' }),
|
expect.objectContaining({ id: 'siliconflow' }),
|
||||||
);
|
);
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(1);
|
||||||
expect(result.map((a: ProviderAccount) => a.id)).toContain('siliconflow');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not import providers already in ClawX store', async () => {
|
it('uses store metadata when match exists (does not re-seed)', async () => {
|
||||||
const accounts = [
|
mocks.listProviderAccounts.mockResolvedValue([
|
||||||
makeAccount({ id: 'moonshot', vendorId: 'moonshot' as ProviderAccount['vendorId'] }),
|
makeAccount({ id: 'moonshot', vendorId: 'moonshot' as ProviderAccount['vendorId'], label: 'My Moonshot' }),
|
||||||
];
|
]);
|
||||||
mocks.listProviderAccounts.mockResolvedValue(accounts);
|
|
||||||
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot']));
|
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot']));
|
||||||
mocks.getOpenClawProvidersConfig.mockResolvedValue({
|
mocks.getOpenClawProvidersConfig.mockResolvedValue({
|
||||||
providers: {
|
providers: { moonshot: { baseUrl: 'https://api.moonshot.cn/v1' } },
|
||||||
moonshot: { baseUrl: 'https://api.moonshot.cn/v1' },
|
|
||||||
},
|
|
||||||
defaultModel: undefined,
|
defaultModel: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,28 +162,130 @@ describe('ProviderService.listAccounts stale-account cleanup', () => {
|
|||||||
|
|
||||||
expect(mocks.saveProviderAccount).not.toHaveBeenCalled();
|
expect(mocks.saveProviderAccount).not.toHaveBeenCalled();
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].label).toBe('My Moonshot');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not create duplicate when account id differs but vendorId matches', async () => {
|
it('matches UUID-based store account to openclaw key via getOpenClawProviderKeyForType', async () => {
|
||||||
// User added openrouter via UI → id is "openrouter-uuid", vendorId is "openrouter"
|
mocks.listProviderAccounts.mockResolvedValue([
|
||||||
// openclaw.json has "openrouter" entry → should NOT import because vendorId matches
|
|
||||||
const accounts = [
|
|
||||||
makeAccount({ id: 'openrouter-uuid-1234', vendorId: 'openrouter' as ProviderAccount['vendorId'] }),
|
makeAccount({ id: 'openrouter-uuid-1234', vendorId: 'openrouter' as ProviderAccount['vendorId'] }),
|
||||||
];
|
]);
|
||||||
mocks.listProviderAccounts.mockResolvedValue(accounts);
|
|
||||||
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openrouter']));
|
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openrouter']));
|
||||||
mocks.getOpenClawProvidersConfig.mockResolvedValue({
|
mocks.getOpenClawProvidersConfig.mockResolvedValue({
|
||||||
providers: {
|
providers: { openrouter: { baseUrl: 'https://openrouter.ai/api/v1' } },
|
||||||
openrouter: { baseUrl: 'https://openrouter.ai/api/v1' },
|
defaultModel: undefined,
|
||||||
},
|
|
||||||
defaultModel: 'openrouter/openai/gpt-5.4',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.listAccounts();
|
const result = await service.listAccounts();
|
||||||
|
|
||||||
// Should NOT create a duplicate "openrouter" account
|
|
||||||
expect(mocks.saveProviderAccount).not.toHaveBeenCalled();
|
expect(mocks.saveProviderAccount).not.toHaveBeenCalled();
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].id).toBe('openrouter-uuid-1234');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user