Co-authored-by: Kagura Chen <daniyuu19@sjtu.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
5e519f9aa6
commit
5836ba6b13
@@ -28,7 +28,7 @@ import {
|
|||||||
setDefaultProvider,
|
setDefaultProvider,
|
||||||
storeApiKey,
|
storeApiKey,
|
||||||
} from '../../utils/secure-storage';
|
} from '../../utils/secure-storage';
|
||||||
import { getActiveOpenClawProviders } from '../../utils/openclaw-auth';
|
import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../utils/openclaw-auth';
|
||||||
import { getOpenClawProviderKeyForType } from '../../utils/provider-keys';
|
import { 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,26 +60,50 @@ export class ProviderService {
|
|||||||
|
|
||||||
async listAccounts(): Promise<ProviderAccount[]> {
|
async listAccounts(): Promise<ProviderAccount[]> {
|
||||||
await ensureProviderStoreMigrated();
|
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
|
// Sync check: remove stale accounts whose provider no longer exists in
|
||||||
// OpenClaw JSON (e.g. user deleted openclaw.json manually).
|
// OpenClaw JSON (e.g. user deleted openclaw.json manually).
|
||||||
if (accounts.length > 0) {
|
{
|
||||||
const activeProviders = await getActiveOpenClawProviders();
|
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[] = [];
|
const staleIds: string[] = [];
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const isBuiltin = (BUILTIN_PROVIDER_TYPES as readonly string[]).includes(account.vendorId);
|
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 openClawKey = getOpenClawProviderKeyForType(account.vendorId, account.id);
|
||||||
const isActive =
|
const isActive =
|
||||||
activeProviders.has(account.vendorId) ||
|
activeProviders.has(account.vendorId) ||
|
||||||
activeProviders.has(account.id) ||
|
activeProviders.has(account.id) ||
|
||||||
activeProviders.has(openClawKey);
|
activeProviders.has(openClawKey);
|
||||||
|
|
||||||
// If openclaw.json is completely empty/missing, drop ALL accounts.
|
if (!isActive) {
|
||||||
// Otherwise only drop non-builtin accounts that are not in the config.
|
|
||||||
if (configMissing || (!isBuiltin && !isActive)) {
|
|
||||||
staleIds.push(account.id);
|
staleIds.push(account.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +120,63 @@ export class ProviderService {
|
|||||||
return accounts;
|
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<ProviderAccount[]> {
|
||||||
|
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<ProviderAccount | null> {
|
async getAccount(accountId: string): Promise<ProviderAccount | null> {
|
||||||
await ensureProviderStoreMigrated();
|
await ensureProviderStoreMigrated();
|
||||||
return getProviderAccount(accountId);
|
return getProviderAccount(accountId);
|
||||||
|
|||||||
@@ -747,6 +747,42 @@ export async function getActiveOpenClawProviders(): Promise<Set<string>> {
|
|||||||
return activeProviders;
|
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<string, Record<string, unknown>>;
|
||||||
|
defaultModel: string | undefined;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const config = await readOpenClawJson();
|
||||||
|
|
||||||
|
const models = config.models as Record<string, unknown> | undefined;
|
||||||
|
const providers =
|
||||||
|
models?.providers && typeof models.providers === 'object'
|
||||||
|
? (models.providers as Record<string, Record<string, unknown>>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const agents = config.agents as Record<string, unknown> | undefined;
|
||||||
|
const defaults =
|
||||||
|
agents?.defaults && typeof agents.defaults === 'object'
|
||||||
|
? (agents.defaults as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const modelConfig =
|
||||||
|
defaults?.model && typeof defaults.model === 'object'
|
||||||
|
? (defaults.model as Record<string, unknown>)
|
||||||
|
: 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.
|
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
|
||||||
*/
|
*/
|
||||||
|
|||||||
171
tests/unit/provider-service-stale-cleanup.test.ts
Normal file
171
tests/unit/provider-service-stale-cleanup.test.ts
Normal file
@@ -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> = {}): 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<string>());
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user