From 0c8dc8cb72282c2ea55a4b9b92fe1039c293291f Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Thu, 7 May 2026 10:29:00 +0800 Subject: [PATCH] fix(providers): derive API key status from OpenClaw auth-profiles first (#979) --- .../services/providers/provider-service.ts | 21 ++++- electron/utils/openclaw-auth.ts | 51 ++++++++++++ .../provider-service-stale-cleanup.test.ts | 83 ++++++++++++++++++- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index 62d0e93..f1980d3 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -28,7 +28,11 @@ import { setDefaultProvider, storeApiKey, } from '../../utils/secure-storage'; -import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../utils/openclaw-auth'; +import { + getActiveOpenClawProviders, + getOpenClawProvidersConfig, + getProviderApiKeyFromOpenClaw, +} from '../../utils/openclaw-auth'; import { getAliasSourceTypes, getOpenClawProviderKeyForType } from '../../utils/provider-keys'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; @@ -370,7 +374,10 @@ export class ProviderService { const accounts = await this.listAccounts(); const results: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = []; for (const account of accounts) { - const apiKey = await getApiKey(account.id); + const runtimeProviderKey = getOpenClawProviderKeyForType(account.vendorId, account.id); + const apiKey = (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) + ?? (await getApiKey(account.id)) + ?? (runtimeProviderKey !== account.id ? await getApiKey(runtimeProviderKey) : null); results.push({ accountId: account.id, hasKey: !!apiKey, @@ -387,6 +394,16 @@ export class ProviderService { /** Check whether an account has an API key stored. */ async hasAccountApiKey(accountId: string): Promise { + const account = await this.getAccount(accountId); + const runtimeProviderKey = account + ? getOpenClawProviderKeyForType(account.vendorId, account.id) + : accountId; + if (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) { + return true; + } + if (runtimeProviderKey !== accountId && (await hasApiKey(runtimeProviderKey))) { + return true; + } return this._hasProviderApiKeyInternal(accountId); } diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index b0bd349..f4a37cb 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -418,6 +418,57 @@ async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Pr await writeJsonFile(getAuthProfilesPath(agentId), store); } +function getApiKeyFromAuthProfilesStore( + store: AuthProfilesStore, + provider: string, +): string | null { + const profileIds = [ + store.lastGood?.[provider], + ...(store.order?.[provider] ?? []), + `${provider}:default`, + ].filter((id): id is string => Boolean(id)); + + for (const profileId of profileIds) { + const profile = store.profiles[profileId]; + if (profile?.type === 'api_key' && profile.provider === provider && profile.key) { + return profile.key; + } + } + + for (const profile of Object.values(store.profiles)) { + if (profile.type === 'api_key' && profile.provider === provider && profile.key) { + return profile.key; + } + } + + return null; +} + +/** + * Read the API key OpenClaw will use for a runtime provider key. + * + * This intentionally reads auth-profiles.json rather than ClawX's provider + * cache, so UI status can reflect providers imported or preserved by the + * OpenClaw runtime across overwrite installs. + */ +export async function getProviderApiKeyFromOpenClaw( + provider: string, + agentId?: string, +): Promise { + const agentIds = agentId ? [agentId] : await discoverAgentIds(); + if (agentIds.length === 0) agentIds.push('main'); + + for (const id of agentIds) { + const store = await readAuthProfiles(id); + const apiKey = getApiKeyFromAuthProfilesStore(store, provider); + if (apiKey) { + return apiKey; + } + } + + return null; +} + // ── Agent Discovery ────────────────────────────────────────────── async function discoverAgentIds(): Promise { diff --git a/tests/unit/provider-service-stale-cleanup.test.ts b/tests/unit/provider-service-stale-cleanup.test.ts index 3f2e810..03a8e2f 100644 --- a/tests/unit/provider-service-stale-cleanup.test.ts +++ b/tests/unit/provider-service-stale-cleanup.test.ts @@ -7,9 +7,12 @@ const mocks = vi.hoisted(() => ({ saveProviderAccount: vi.fn(), getActiveOpenClawProviders: vi.fn(), getOpenClawProvidersConfig: vi.fn(), + getProviderApiKeyFromOpenClaw: vi.fn(), getOpenClawProviderKeyForType: vi.fn(), getAliasSourceTypes: vi.fn(), getProviderDefinition: vi.fn(), + getApiKey: vi.fn(), + hasApiKey: vi.fn(), loggerWarn: vi.fn(), loggerInfo: vi.fn(), })); @@ -32,6 +35,7 @@ vi.mock('@electron/services/providers/provider-store', () => ({ vi.mock('@electron/utils/openclaw-auth', () => ({ getActiveOpenClawProviders: mocks.getActiveOpenClawProviders, getOpenClawProvidersConfig: mocks.getOpenClawProvidersConfig, + getProviderApiKeyFromOpenClaw: mocks.getProviderApiKeyFromOpenClaw, })); vi.mock('@electron/utils/provider-keys', () => ({ @@ -42,8 +46,8 @@ vi.mock('@electron/utils/provider-keys', () => ({ vi.mock('@electron/utils/secure-storage', () => ({ deleteApiKey: vi.fn(), deleteProvider: vi.fn(), - getApiKey: vi.fn(), - hasApiKey: vi.fn(), + getApiKey: mocks.getApiKey, + hasApiKey: mocks.hasApiKey, saveProvider: vi.fn(), setDefaultProvider: vi.fn(), storeApiKey: vi.fn(), @@ -100,6 +104,9 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)', mocks.getAliasSourceTypes.mockReturnValue([]); mocks.getProviderDefinition.mockReturnValue(undefined); mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: {}, defaultModel: undefined }); + mocks.getProviderApiKeyFromOpenClaw.mockResolvedValue(null); + mocks.getApiKey.mockResolvedValue(null); + mocks.hasApiKey.mockResolvedValue(false); mocks.listProviderAccounts.mockResolvedValue([]); service = new ProviderService(); }); @@ -389,3 +396,75 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)', ])); }); }); + +describe('ProviderService.listAccountsKeyInfo', () => { + let service: ProviderService; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureProviderStoreMigrated.mockResolvedValue(undefined); + setupDefaultKeyMapping(); + mocks.getAliasSourceTypes.mockReturnValue([]); + mocks.getProviderDefinition.mockReturnValue(undefined); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: {}, defaultModel: undefined }); + mocks.getProviderApiKeyFromOpenClaw.mockResolvedValue(null); + mocks.getApiKey.mockResolvedValue(null); + mocks.hasApiKey.mockResolvedValue(false); + service = new ProviderService(); + }); + + it('prefers OpenClaw runtime auth when reporting account key status', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ + id: 'custom-ui-account-id', + vendorId: 'custom' as ProviderAccount['vendorId'], + }), + ]); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['custom-runtime'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { 'custom-runtime': { baseUrl: 'https://llm.example.com/v1' } }, + defaultModel: undefined, + }); + mocks.getOpenClawProviderKeyForType.mockReturnValue('custom-runtime'); + mocks.getProviderApiKeyFromOpenClaw.mockResolvedValue('sk-openclaw-runtime-key'); + + const result = await service.listAccountsKeyInfo(); + + expect(mocks.getProviderApiKeyFromOpenClaw).toHaveBeenCalledWith('custom-runtime'); + expect(mocks.getApiKey).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + accountId: 'custom-ui-account-id', + hasKey: true, + keyMasked: 'sk-o***************-key', + }, + ]); + }); + + it('falls back to ClawX local secrets when OpenClaw has no runtime key', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ + id: 'openrouter-ui-account-id', + vendorId: 'openrouter' as ProviderAccount['vendorId'], + }), + ]); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openrouter'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { openrouter: { baseUrl: 'https://openrouter.ai/api/v1' } }, + defaultModel: undefined, + }); + mocks.getOpenClawProviderKeyForType.mockReturnValue('openrouter'); + mocks.getApiKey.mockImplementation(async (id: string) => ( + id === 'openrouter-ui-account-id' ? 'sk-local-provider-key' : null + )); + + const result = await service.listAccountsKeyInfo(); + + expect(mocks.getProviderApiKeyFromOpenClaw).toHaveBeenCalledWith('openrouter'); + expect(mocks.getApiKey).toHaveBeenCalledWith('openrouter-ui-account-id'); + expect(result[0]).toMatchObject({ + accountId: 'openrouter-ui-account-id', + hasKey: true, + }); + }); +});