fix(providers): derive API key status from OpenClaw auth-profiles first (#979)

This commit is contained in:
paisley
2026-05-07 10:29:00 +08:00
committed by GitHub
parent 59579e0852
commit 0c8dc8cb72
3 changed files with 151 additions and 4 deletions

View File

@@ -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<boolean> {
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);
}

View File

@@ -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<string | null> {
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<string[]> {

View File

@@ -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,
});
});
});