diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index f24ca98c4..2af677d17 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -28,7 +28,7 @@ import { setDefaultProvider, storeApiKey, } from '../../utils/secure-storage'; -import { getActiveOpenClawProviders } from '../../utils/openclaw-auth'; +import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../utils/openclaw-auth'; import { getOpenClawProviderKeyForType } from '../../utils/provider-keys'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; @@ -60,26 +60,50 @@ export class ProviderService { async listAccounts(): Promise { await ensureProviderStoreMigrated(); - const accounts = await listProviderAccounts(); + 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; + } // Sync check: remove stale accounts whose provider no longer exists in // OpenClaw JSON (e.g. user deleted openclaw.json manually). - if (accounts.length > 0) { + { const activeProviders = await getActiveOpenClawProviders(); - const configMissing = activeProviders.size === 0; + + // If the OpenClaw config is empty or unreadable, skip cleanup entirely + // to avoid accidentally wiping valid accounts during transient states + // (e.g. gateway restart, file lock, first launch before config sync). + if (activeProviders.size === 0) { + logger.warn( + '[provider-sync] OpenClaw config has no active providers — skipping stale-account cleanup to preserve existing accounts', + ); + return accounts; + } + const staleIds: string[] = []; for (const account of accounts) { const isBuiltin = (BUILTIN_PROVIDER_TYPES as readonly string[]).includes(account.vendorId); + // Builtin providers (anthropic, openai, etc.) are always retained + // because they don't require an explicit models.providers entry in + // openclaw.json — the runtime recognises them natively. + if (isBuiltin) continue; + const openClawKey = getOpenClawProviderKeyForType(account.vendorId, account.id); const isActive = activeProviders.has(account.vendorId) || activeProviders.has(account.id) || activeProviders.has(openClawKey); - // If openclaw.json is completely empty/missing, drop ALL accounts. - // Otherwise only drop non-builtin accounts that are not in the config. - if (configMissing || (!isBuiltin && !isActive)) { + if (!isActive) { staleIds.push(account.id); } } @@ -96,6 +120,63 @@ export class ProviderService { return accounts; } + /** + * 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(); + + // Determine the provider prefix from the default model (e.g. "siliconflow/deepseek..." → "siliconflow") + const defaultModelProvider = defaultModel?.includes('/') + ? defaultModel.split('/')[0] + : undefined; + + const now = new Date().toISOString(); + const seeded: ProviderAccount[] = []; + + for (const [key, entry] of Object.entries(providers)) { + const definition = getProviderDefinition(key); + const isBuiltin = (BUILTIN_PROVIDER_TYPES as readonly string[]).includes(key); + const vendorId = isBuiltin ? key : 'custom'; + + const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl : definition?.providerConfig?.baseUrl; + + // Infer model from the default model if it belongs to this provider + let model: string | undefined; + if (defaultModelProvider === key && defaultModel) { + model = defaultModel; + } else if (definition?.defaultModelId) { + model = definition.defaultModelId; + } + + const account: ProviderAccount = { + id: key, + vendorId: (vendorId as ProviderAccount['vendorId']), + label: definition?.name ?? key.charAt(0).toUpperCase() + key.slice(1), + authMode: definition?.defaultAuthMode ?? 'api_key', + baseUrl, + apiProtocol: definition?.providerConfig?.api, + model, + enabled: true, + isDefault: false, + createdAt: now, + updatedAt: now, + }; + + await saveProviderAccount(account); + seeded.push(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; + } + async getAccount(accountId: string): Promise { await ensureProviderStoreMigrated(); return getProviderAccount(accountId); diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index e413c39f4..f2b158c6f 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -747,6 +747,42 @@ export async function getActiveOpenClawProviders(): Promise> { return activeProviders; } +/** + * Read models.providers entries and agents.defaults.model from openclaw.json. + * Used by ClawX to seed the provider store when it's empty but providers are + * configured externally (e.g. via CLI or by editing openclaw.json directly). + */ +export async function getOpenClawProvidersConfig(): Promise<{ + providers: Record>; + defaultModel: string | undefined; +}> { + try { + const config = await readOpenClawJson(); + + const models = config.models as Record | undefined; + const providers = + models?.providers && typeof models.providers === 'object' + ? (models.providers as Record>) + : {}; + + const agents = config.agents as Record | undefined; + const defaults = + agents?.defaults && typeof agents.defaults === 'object' + ? (agents.defaults as Record) + : undefined; + const modelConfig = + defaults?.model && typeof defaults.model === 'object' + ? (defaults.model as Record) + : undefined; + const defaultModel = + typeof modelConfig?.primary === 'string' ? modelConfig.primary : undefined; + + return { providers, defaultModel }; + } catch { + return { providers: {}, defaultModel: undefined }; + } +} + /** * Write the ClawX gateway token into ~/.openclaw/openclaw.json. */ diff --git a/tests/unit/provider-service-stale-cleanup.test.ts b/tests/unit/provider-service-stale-cleanup.test.ts new file mode 100644 index 000000000..b16d6acd1 --- /dev/null +++ b/tests/unit/provider-service-stale-cleanup.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + ensureProviderStoreMigrated: vi.fn(), + listProviderAccounts: vi.fn(), + deleteProviderAccount: vi.fn(), + saveProviderAccount: vi.fn(), + getActiveOpenClawProviders: vi.fn(), + getOpenClawProvidersConfig: vi.fn(), + getOpenClawProviderKeyForType: vi.fn(), + loggerWarn: vi.fn(), + loggerInfo: vi.fn(), +})); + +vi.mock('@electron/services/providers/provider-migration', () => ({ + ensureProviderStoreMigrated: mocks.ensureProviderStoreMigrated, +})); + +vi.mock('@electron/services/providers/provider-store', () => ({ + listProviderAccounts: mocks.listProviderAccounts, + deleteProviderAccount: mocks.deleteProviderAccount, + getProviderAccount: vi.fn(), + getDefaultProviderAccountId: vi.fn(), + providerAccountToConfig: vi.fn(), + providerConfigToAccount: vi.fn(), + saveProviderAccount: mocks.saveProviderAccount, + setDefaultProviderAccount: vi.fn(), +})); + +vi.mock('@electron/utils/openclaw-auth', () => ({ + getActiveOpenClawProviders: mocks.getActiveOpenClawProviders, + getOpenClawProvidersConfig: mocks.getOpenClawProvidersConfig, +})); + +vi.mock('@electron/utils/provider-keys', () => ({ + getOpenClawProviderKeyForType: mocks.getOpenClawProviderKeyForType, +})); + +vi.mock('@electron/utils/secure-storage', () => ({ + deleteApiKey: vi.fn(), + deleteProvider: vi.fn(), + getApiKey: vi.fn(), + hasApiKey: vi.fn(), + saveProvider: vi.fn(), + setDefaultProvider: vi.fn(), + storeApiKey: vi.fn(), +})); + +vi.mock('@electron/utils/logger', () => ({ + logger: { + debug: vi.fn(), + info: mocks.loggerInfo, + warn: mocks.loggerWarn, + error: vi.fn(), + }, +})); + +vi.mock('@electron/shared/providers/registry', () => ({ + PROVIDER_DEFINITIONS: [], + getProviderDefinition: vi.fn(), +})); + +import { ProviderService } from '@electron/services/providers/provider-service'; +import type { ProviderAccount } from '@electron/shared/providers/types'; + +function makeAccount(overrides: Partial = {}): ProviderAccount { + return { + id: 'test-account', + vendorId: 'moonshot' as ProviderAccount['vendorId'], + label: 'Test', + authMode: 'api_key' as ProviderAccount['authMode'], + enabled: true, + isDefault: false, + createdAt: '2026-03-19T00:00:00.000Z', + updatedAt: '2026-03-19T00:00:00.000Z', + ...overrides, + }; +} + +describe('ProviderService.listAccounts stale-account cleanup', () => { + let service: ProviderService; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureProviderStoreMigrated.mockResolvedValue(undefined); + mocks.getOpenClawProviderKeyForType.mockImplementation( + (type: string, id: string) => `${type}/${id}`, + ); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: {}, defaultModel: undefined }); + service = new ProviderService(); + }); + + it('preserves all accounts when activeProviders is empty (config missing/unreadable)', async () => { + const accounts = [ + makeAccount({ id: 'custom-1', vendorId: 'custom' 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()); + + const result = await service.listAccounts(); + + // All accounts should be preserved — none deleted + expect(result).toEqual(accounts); + expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); + expect(mocks.loggerWarn).toHaveBeenCalledWith( + expect.stringContaining('skipping stale-account cleanup'), + ); + }); + + it('removes stale non-builtin accounts when config has active providers', async () => { + const accounts = [ + 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 + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot'])); + + const result = await service.listAccounts(); + + // custom-stale should be deleted (non-builtin, not active) + expect(mocks.deleteProviderAccount).toHaveBeenCalledWith('custom-stale'); + expect(mocks.deleteProviderAccount).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('moonshot-1'); + }); + + it('never removes builtin provider accounts even if 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 some providers, but NOT anthropic or openai explicitly + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot'])); + + const result = await service.listAccounts(); + + // Builtin accounts should be preserved regardless + expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); + expect(result).toEqual(accounts); + }); + + 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); + }); +});