fix(providers): derive API key status from OpenClaw auth-profiles first (#979)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user