diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index f06e01763..2994ce996 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -146,6 +146,43 @@ const BUILTIN_CHANNEL_IDS = new Set([ 'googlechat', 'mattermost', ]); +const AUTH_PROFILE_PROVIDER_KEY_MAP: Record = { + 'openai-codex': 'openai', + 'google-gemini-cli': 'google', +}; + +function normalizeAuthProfileProviderKey(provider: string): string { + return AUTH_PROFILE_PROVIDER_KEY_MAP[provider] ?? provider; +} + +function addProvidersFromProfileEntries( + profiles: Record | undefined, + target: Set, +): void { + if (!profiles || typeof profiles !== 'object') { + return; + } + + for (const profile of Object.values(profiles)) { + const provider = typeof (profile as Record)?.provider === 'string' + ? ((profile as Record).provider as string) + : undefined; + if (!provider) continue; + target.add(normalizeAuthProfileProviderKey(provider)); + } +} + +async function getProvidersFromAuthProfileStores(): Promise> { + const providers = new Set(); + const agentIds = await discoverAgentIds(); + + for (const agentId of agentIds) { + const store = await readAuthProfiles(agentId); + addProvidersFromProfileEntries(store.profiles, providers); + } + + return providers; +} async function readOpenClawJson(): Promise> { return (await readJsonFile>(OPENCLAW_CONFIG_PATH)) ?? {}; @@ -794,6 +831,16 @@ export async function getActiveOpenClawProviders(): Promise> { if (primaryModel?.includes('/')) { activeProviders.add(primaryModel.split('/')[0]); } + + // 4. auth.profiles — OAuth/device-token based providers may exist only in + // auth-profiles without explicit models.providers entries yet. + const auth = config.auth as Record | undefined; + addProvidersFromProfileEntries(auth?.profiles as Record | undefined, activeProviders); + + const authProfileProviders = await getProvidersFromAuthProfileStores(); + for (const provider of authProfileProviders) { + activeProviders.add(provider); + } } catch (err) { console.warn('Failed to read openclaw.json for active providers:', err); } @@ -831,6 +878,21 @@ export async function getOpenClawProvidersConfig(): Promise<{ const defaultModel = typeof modelConfig?.primary === 'string' ? modelConfig.primary : undefined; + const authProviders = new Set(); + const auth = config.auth as Record | undefined; + addProvidersFromProfileEntries(auth?.profiles as Record | undefined, authProviders); + + const authProfileProviders = await getProvidersFromAuthProfileStores(); + for (const provider of authProfileProviders) { + authProviders.add(provider); + } + + for (const provider of authProviders) { + if (!providers[provider]) { + providers[provider] = {}; + } + } + return { providers, defaultModel }; } catch { return { providers: {}, defaultModel: undefined }; diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index ba8ab8d3c..2ac2cc2c9 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -41,6 +41,12 @@ async function readAuthProfiles(agentId: string): Promise; } +async function writeAgentAuthProfiles(agentId: string, store: Record): Promise { + const agentDir = join(testHome, '.openclaw', 'agents', agentId, 'agent'); + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, 'auth-profiles.json'), JSON.stringify(store, null, 2), 'utf8'); +} + describe('saveProviderKeyToOpenClaw', () => { beforeEach(async () => { vi.resetModules(); @@ -201,3 +207,89 @@ describe('sanitizeOpenClawConfig', () => { logSpy.mockRestore(); }); }); + +describe('auth-backed provider discovery', () => { + beforeEach(async () => { + vi.resetModules(); + vi.restoreAllMocks(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('detects active providers from openclaw auth profiles and per-agent auth stores', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true, workspace: '~/.openclaw/workspace', agentDir: '~/.openclaw/agents/main/agent' }, + { id: 'work', name: 'Work', workspace: '~/.openclaw/workspace-work', agentDir: '~/.openclaw/agents/work/agent' }, + ], + }, + auth: { + profiles: { + 'openai-codex:default': { type: 'oauth', provider: 'openai-codex', access: 'acc', refresh: 'ref', expires: 1 }, + 'anthropic:default': { type: 'api_key', provider: 'anthropic', key: 'sk-ant' }, + }, + }, + }); + + await writeAgentAuthProfiles('work', { + version: 1, + profiles: { + 'google-gemini-cli:default': { + type: 'oauth', + provider: 'google-gemini-cli', + access: 'goog-access', + refresh: 'goog-refresh', + expires: 2, + }, + }, + }); + + const { getActiveOpenClawProviders } = await import('@electron/utils/openclaw-auth'); + + await expect(getActiveOpenClawProviders()).resolves.toEqual( + new Set(['openai', 'anthropic', 'google']), + ); + }); + + it('seeds provider config entries from auth profiles when models.providers is empty', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true, workspace: '~/.openclaw/workspace', agentDir: '~/.openclaw/agents/main/agent' }, + { id: 'work', name: 'Work', workspace: '~/.openclaw/workspace-work', agentDir: '~/.openclaw/agents/work/agent' }, + ], + defaults: { + model: { + primary: 'openai/gpt-5.4', + }, + }, + }, + auth: { + profiles: { + 'openai-codex:default': { type: 'oauth', provider: 'openai-codex', access: 'acc', refresh: 'ref', expires: 1 }, + }, + }, + }); + + await writeAgentAuthProfiles('work', { + version: 1, + profiles: { + 'anthropic:default': { + type: 'api_key', + provider: 'anthropic', + key: 'sk-ant', + }, + }, + }); + + const { getOpenClawProvidersConfig } = await import('@electron/utils/openclaw-auth'); + const result = await getOpenClawProvidersConfig(); + + expect(result.defaultModel).toBe('openai/gpt-5.4'); + expect(result.providers).toMatchObject({ + openai: {}, + anthropic: {}, + }); + }); +}); diff --git a/tests/unit/provider-service-stale-cleanup.test.ts b/tests/unit/provider-service-stale-cleanup.test.ts index 55adcb8fe..de222223a 100644 --- a/tests/unit/provider-service-stale-cleanup.test.ts +++ b/tests/unit/provider-service-stale-cleanup.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ getOpenClawProvidersConfig: vi.fn(), getOpenClawProviderKeyForType: vi.fn(), getAliasSourceTypes: vi.fn(), + getProviderDefinition: vi.fn(), loggerWarn: vi.fn(), loggerInfo: vi.fn(), })); @@ -59,7 +60,7 @@ vi.mock('@electron/utils/logger', () => ({ vi.mock('@electron/shared/providers/registry', () => ({ PROVIDER_DEFINITIONS: [], - getProviderDefinition: vi.fn(), + getProviderDefinition: mocks.getProviderDefinition, })); import { ProviderService } from '@electron/services/providers/provider-service'; @@ -97,6 +98,7 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)', mocks.ensureProviderStoreMigrated.mockResolvedValue(undefined); setupDefaultKeyMapping(); mocks.getAliasSourceTypes.mockReturnValue([]); + mocks.getProviderDefinition.mockReturnValue(undefined); mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: {}, defaultModel: undefined }); mocks.listProviderAccounts.mockResolvedValue([]); service = new ProviderService(); @@ -288,4 +290,59 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)', expect(ids).toContain('openrouter-uuid'); expect(ids).toContain('minimax-portal-cn-uuid'); }); + + it('seeds builtin providers discovered from auth profiles without explicit models.providers entries', async () => { + mocks.listProviderAccounts.mockResolvedValue([]); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openai', 'anthropic'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { + openai: {}, + anthropic: {}, + }, + defaultModel: undefined, + }); + mocks.getProviderDefinition.mockImplementation((key: string) => { + if (key === 'openai') { + return { + id: 'openai', + name: 'OpenAI', + defaultAuthMode: 'oauth_browser', + defaultModelId: 'gpt-5.2', + providerConfig: { + baseUrl: 'https://api.openai.com/v1', + api: 'openai-responses', + }, + }; + } + if (key === 'anthropic') { + return { + id: 'anthropic', + name: 'Anthropic', + defaultAuthMode: 'api_key', + defaultModelId: 'claude-opus-4-6', + }; + } + return undefined; + }); + + const result = await service.listAccounts(); + + expect(mocks.saveProviderAccount).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + expect(result).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'openai', + vendorId: 'openai', + authMode: 'oauth_browser', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.2', + }), + expect.objectContaining({ + id: 'anthropic', + vendorId: 'anthropic', + authMode: 'api_key', + model: 'claude-opus-4-6', + }), + ])); + }); });