From 9b56d80d22b9d319c00d0c0ab2366b6830bc551a Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Fri, 27 Mar 2026 23:32:56 +0800 Subject: [PATCH] fix: clean up deleted provider state correctly (#696) --- .../providers/provider-runtime-sync.ts | 25 +- electron/shared/providers/registry.ts | 19 +- electron/utils/browser-oauth.ts | 2 +- electron/utils/openclaw-auth.ts | 120 ++++++-- src/lib/providers.ts | 15 +- tests/unit/openclaw-auth.test.ts | 269 ++++++++++++++++++ tests/unit/provider-runtime-sync.test.ts | 60 ++++ tests/unit/providers.test.ts | 32 +++ 8 files changed, 510 insertions(+), 32 deletions(-) diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 23253107b..512d89cb6 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -6,6 +6,7 @@ import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../ import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry'; import { removeProviderFromOpenClaw, + removeProviderKeyFromOpenClaw, saveOAuthTokenToOpenClaw, saveProviderKeyToOpenClaw, setOpenClawDefaultModel, @@ -20,7 +21,7 @@ import { listAgentsSnapshot } from '../../utils/agent-config'; const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli'; const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`; const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex'; -const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.3-codex`; +const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.4`; type RuntimeProviderSyncContext = { runtimeProviderKey: string; @@ -347,6 +348,24 @@ async function syncProviderToRuntime( return context; } +async function removeDeletedProviderFromOpenClaw( + provider: ProviderConfig, + providerId: string, + runtimeProviderKey?: string, +): Promise { + const keys = new Set(); + if (runtimeProviderKey) { + keys.add(runtimeProviderKey); + } else { + keys.add(await resolveRuntimeProviderKey({ ...provider, id: providerId })); + } + keys.add(providerId); + + for (const key of keys) { + await removeProviderFromOpenClaw(key); + } +} + function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null { const trimmed = modelRef.trim(); const separatorIndex = trimmed.indexOf('/'); @@ -538,7 +557,7 @@ export async function syncDeletedProviderToRuntime( } const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId }); - await removeProviderFromOpenClaw(ock); + await removeDeletedProviderFromOpenClaw(provider, providerId, ock); scheduleGatewayRefresh( gatewayManager, @@ -557,7 +576,7 @@ export async function syncDeletedProviderApiKeyToRuntime( } const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId }); - await removeProviderFromOpenClaw(ock); + await removeProviderKeyFromOpenClaw(ock); } export async function syncDefaultProviderToRuntime( diff --git a/electron/shared/providers/registry.ts b/electron/shared/providers/registry.ts index b6fffbd96..434f19969 100644 --- a/electron/shared/providers/registry.ts +++ b/electron/shared/providers/registry.ts @@ -29,9 +29,12 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ requiresApiKey: true, category: 'official', envVar: 'OPENAI_API_KEY', - defaultModelId: 'gpt-5.2', + defaultModelId: 'gpt-5.4', isOAuth: true, supportsApiKey: true, + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'gpt-5.4', supportedAuthModes: ['api_key', 'oauth_browser'], defaultAuthMode: 'api_key', supportsMultipleAccounts: true, @@ -50,9 +53,12 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ requiresApiKey: true, category: 'official', envVar: 'GEMINI_API_KEY', - defaultModelId: 'gemini-3.1-pro-preview', + defaultModelId: 'gemini-3-pro-preview', isOAuth: true, supportsApiKey: true, + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'gemini-3-pro-preview', supportedAuthModes: ['api_key', 'oauth_browser'], defaultAuthMode: 'api_key', supportsMultipleAccounts: true, @@ -171,6 +177,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io', category: 'official', envVar: 'MINIMAX_API_KEY', @@ -193,6 +202,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/', category: 'official', envVar: 'MINIMAX_CN_API_KEY', @@ -214,6 +226,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model', + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'coder-model', category: 'official', envVar: 'QWEN_API_KEY', supportedAuthModes: ['oauth_device'], diff --git a/electron/utils/browser-oauth.ts b/electron/utils/browser-oauth.ts index f698b0d48..942595841 100644 --- a/electron/utils/browser-oauth.ts +++ b/electron/utils/browser-oauth.ts @@ -12,7 +12,7 @@ export type BrowserOAuthProviderType = 'google' | 'openai'; const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli'; const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview'; const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex'; -const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.3-codex'; +const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.4'; class BrowserOAuthManager extends EventEmitter { private activeProvider: BrowserOAuthProviderType | null = null; diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 2994ce996..2f1dc727b 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -93,6 +93,82 @@ interface AuthProfilesStore { lastGood?: Record; } +function removeProfilesForProvider(store: AuthProfilesStore, provider: string): boolean { + const removedProfileIds = new Set(); + + for (const [profileId, profile] of Object.entries(store.profiles)) { + if (profile?.provider !== provider) { + continue; + } + delete store.profiles[profileId]; + removedProfileIds.add(profileId); + } + + if (removedProfileIds.size === 0) { + return false; + } + + if (store.order) { + for (const [orderProvider, profileIds] of Object.entries(store.order)) { + const nextProfileIds = profileIds.filter((profileId) => !removedProfileIds.has(profileId)); + if (nextProfileIds.length > 0) { + store.order[orderProvider] = nextProfileIds; + } else { + delete store.order[orderProvider]; + } + } + } + + if (store.lastGood) { + for (const [lastGoodProvider, profileId] of Object.entries(store.lastGood)) { + if (removedProfileIds.has(profileId)) { + delete store.lastGood[lastGoodProvider]; + } + } + } + + return true; +} + +function removeProfileFromStore( + store: AuthProfilesStore, + profileId: string, + expectedType?: AuthProfileEntry['type'] | OAuthProfileEntry['type'], +): boolean { + const profile = store.profiles[profileId]; + let changed = false; + const shouldCleanReferences = !profile || !expectedType || profile.type === expectedType; + if (profile && (!expectedType || profile.type === expectedType)) { + delete store.profiles[profileId]; + changed = true; + } + + if (shouldCleanReferences && store.order) { + for (const [orderProvider, profileIds] of Object.entries(store.order)) { + const nextProfileIds = profileIds.filter((id) => id !== profileId); + if (nextProfileIds.length !== profileIds.length) { + changed = true; + } + if (nextProfileIds.length > 0) { + store.order[orderProvider] = nextProfileIds; + } else { + delete store.order[orderProvider]; + } + } + } + + if (shouldCleanReferences && store.lastGood) { + for (const [lastGoodProvider, lastGoodProfileId] of Object.entries(store.lastGood)) { + if (lastGoodProfileId === profileId) { + delete store.lastGood[lastGoodProvider]; + changed = true; + } + } + } + + return changed; +} + // ── Auth Profiles I/O ──────────────────────────────────────────── function getAuthProfilesPath(agentId = 'main'): string { @@ -346,26 +422,14 @@ export async function removeProviderKeyFromOpenClaw( provider: string, agentId?: string ): Promise { - if (isOAuthProviderType(provider)) { - console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`); - return; - } const agentIds = agentId ? [agentId] : await discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); for (const id of agentIds) { const store = await readAuthProfiles(id); - const profileId = `${provider}:default`; - - delete store.profiles[profileId]; - - if (store.order?.[provider]) { - store.order[provider] = store.order[provider].filter((aid) => aid !== profileId); - if (store.order[provider].length === 0) delete store.order[provider]; + if (removeProfileFromStore(store, `${provider}:default`, 'api_key')) { + await writeAuthProfiles(store, id); } - if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider]; - - await writeAuthProfiles(store, id); } console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } @@ -379,14 +443,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise aid !== profileId); - if (store.order[provider].length === 0) delete store.order[provider]; - } - if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider]; + if (removeProfilesForProvider(store, provider)) { await writeAuthProfiles(store, id); } } @@ -435,6 +492,25 @@ export async function removeProviderFromOpenClaw(provider: string): Promise + : null); + const authProfiles = ( + auth?.profiles && typeof auth.profiles === 'object' + ? auth.profiles as Record + : null + ); + if (authProfiles) { + for (const [profileId, profile] of Object.entries(authProfiles)) { + if (profile?.provider !== provider) { + continue; + } + delete authProfiles[profileId]; + modified = true; + console.log(`Removed OpenClaw auth profile: ${profileId}`); + } + } + // Clean up agents.defaults.model references that point to the deleted provider. // Model refs use the format "providerType/modelId", e.g. "openai/gpt-4". // Leaving stale refs causes the Gateway to report "Unknown model" errors. diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 4d664e1e7..fc7579ac7 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -146,6 +146,10 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ requiresApiKey: true, isOAuth: true, supportsApiKey: true, + defaultModelId: 'gpt-5.4', + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'gpt-5.4', apiKeyUrl: 'https://platform.openai.com/api-keys', }, { @@ -157,15 +161,18 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ requiresApiKey: true, isOAuth: true, supportsApiKey: true, - defaultModelId: 'gemini-3.1-pro-preview', + defaultModelId: 'gemini-3-pro-preview', + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'gemini-3-pro-preview', apiKeyUrl: 'https://aistudio.google.com/app/apikey', }, { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'openai/gpt-5.4', defaultModelId: 'openai/gpt-5.4', docsUrl: 'https://openrouter.ai/models' }, - { id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' }, + { id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' }, { id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' }, { id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' }, - { id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' }, - { id: 'qwen-portal', name: 'Qwen (Global)', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model' }, + { id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' }, + { id: 'qwen-portal', name: 'Qwen (Global)', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'coder-model' }, { id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' }, { id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434/v1', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' }, { diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index 2ac2cc2c9..6a08d7d40 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -36,6 +36,11 @@ async function writeOpenClawJson(config: unknown): Promise { await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify(config, null, 2), 'utf8'); } +async function readOpenClawJson(): Promise> { + const content = await readFile(join(testHome, '.openclaw', 'openclaw.json'), 'utf8'); + return JSON.parse(content) as Record; +} + async function readAuthProfiles(agentId: string): Promise> { const content = await readFile(join(testHome, '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json'), 'utf8'); return JSON.parse(content) as Record; @@ -118,6 +123,188 @@ describe('saveProviderKeyToOpenClaw', () => { }); }); +describe('removeProviderKeyFromOpenClaw', () => { + beforeEach(async () => { + vi.resetModules(); + vi.restoreAllMocks(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('removes only the default api-key profile for a provider', async () => { + await writeAgentAuthProfiles('main', { + version: 1, + profiles: { + 'custom-abc12345:default': { + type: 'api_key', + provider: 'custom-abc12345', + key: 'sk-main', + }, + 'custom-abc12345:backup': { + type: 'api_key', + provider: 'custom-abc12345', + key: 'sk-backup', + }, + }, + order: { + 'custom-abc12345': [ + 'custom-abc12345:default', + 'custom-abc12345:backup', + ], + }, + lastGood: { + 'custom-abc12345': 'custom-abc12345:default', + }, + }); + + const { removeProviderKeyFromOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await removeProviderKeyFromOpenClaw('custom-abc12345', 'main'); + + const mainProfiles = await readAuthProfiles('main'); + expect(mainProfiles.profiles).toEqual({ + 'custom-abc12345:backup': { + type: 'api_key', + provider: 'custom-abc12345', + key: 'sk-backup', + }, + }); + expect(mainProfiles.order).toEqual({ + 'custom-abc12345': ['custom-abc12345:backup'], + }); + expect(mainProfiles.lastGood).toEqual({}); + }); + + it('cleans stale default-profile references even when the profile object is already missing', async () => { + await writeAgentAuthProfiles('main', { + version: 1, + profiles: { + 'custom-abc12345:backup': { + type: 'api_key', + provider: 'custom-abc12345', + key: 'sk-backup', + }, + }, + order: { + 'custom-abc12345': [ + 'custom-abc12345:default', + 'custom-abc12345:backup', + ], + }, + lastGood: { + 'custom-abc12345': 'custom-abc12345:default', + }, + }); + + const { removeProviderKeyFromOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await removeProviderKeyFromOpenClaw('custom-abc12345', 'main'); + + const mainProfiles = await readAuthProfiles('main'); + expect(mainProfiles.profiles).toEqual({ + 'custom-abc12345:backup': { + type: 'api_key', + provider: 'custom-abc12345', + key: 'sk-backup', + }, + }); + expect(mainProfiles.order).toEqual({ + 'custom-abc12345': ['custom-abc12345:backup'], + }); + expect(mainProfiles.lastGood).toEqual({}); + }); + + it('does not remove oauth default profiles when deleting only an api key', async () => { + await writeAgentAuthProfiles('main', { + version: 1, + profiles: { + 'openai-codex:default': { + type: 'oauth', + provider: 'openai-codex', + access: 'acc', + refresh: 'ref', + expires: 1, + }, + }, + order: { + 'openai-codex': ['openai-codex:default'], + }, + lastGood: { + 'openai-codex': 'openai-codex:default', + }, + }); + + const { removeProviderKeyFromOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await removeProviderKeyFromOpenClaw('openai-codex', 'main'); + + const mainProfiles = await readAuthProfiles('main'); + expect(mainProfiles.profiles).toEqual({ + 'openai-codex:default': { + type: 'oauth', + provider: 'openai-codex', + access: 'acc', + refresh: 'ref', + expires: 1, + }, + }); + expect(mainProfiles.order).toEqual({ + 'openai-codex': ['openai-codex:default'], + }); + expect(mainProfiles.lastGood).toEqual({ + 'openai-codex': 'openai-codex:default', + }); + }); + + it('removes api-key defaults for oauth-capable providers that support api keys', async () => { + await writeAgentAuthProfiles('main', { + version: 1, + profiles: { + 'minimax-portal:default': { + type: 'api_key', + provider: 'minimax-portal', + key: 'sk-minimax', + }, + 'minimax-portal:oauth-backup': { + type: 'oauth', + provider: 'minimax-portal', + access: 'acc', + refresh: 'ref', + expires: 1, + }, + }, + order: { + 'minimax-portal': [ + 'minimax-portal:default', + 'minimax-portal:oauth-backup', + ], + }, + lastGood: { + 'minimax-portal': 'minimax-portal:default', + }, + }); + + const { removeProviderKeyFromOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await removeProviderKeyFromOpenClaw('minimax-portal', 'main'); + + const mainProfiles = await readAuthProfiles('main'); + expect(mainProfiles.profiles).toEqual({ + 'minimax-portal:oauth-backup': { + type: 'oauth', + provider: 'minimax-portal', + access: 'acc', + refresh: 'ref', + expires: 1, + }, + }); + expect(mainProfiles.order).toEqual({ + 'minimax-portal': ['minimax-portal:oauth-backup'], + }); + expect(mainProfiles.lastGood).toEqual({}); + }); +}); + describe('sanitizeOpenClawConfig', () => { beforeEach(async () => { vi.resetModules(); @@ -292,4 +479,86 @@ describe('auth-backed provider discovery', () => { anthropic: {}, }); }); + + it('removes all matching auth profiles for a deleted provider so it does not reappear', 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' }, + ], + }, + models: { + providers: { + 'custom-abc12345': { + baseUrl: 'https://api.moonshot.cn/v1', + api: 'openai-completions', + }, + }, + }, + auth: { + profiles: { + 'custom-abc12345:oauth': { + type: 'oauth', + provider: 'custom-abc12345', + access: 'acc', + refresh: 'ref', + expires: 1, + }, + 'custom-abc12345:secondary': { + type: 'api_key', + provider: 'custom-abc12345', + key: 'sk-inline', + }, + }, + }, + }); + + await writeAgentAuthProfiles('main', { + version: 1, + profiles: { + 'custom-abc12345:default': { + type: 'api_key', + provider: 'custom-abc12345', + key: 'sk-main', + }, + 'custom-abc12345:backup': { + type: 'api_key', + provider: 'custom-abc12345', + key: 'sk-backup', + }, + }, + order: { + 'custom-abc12345': [ + 'custom-abc12345:default', + 'custom-abc12345:backup', + ], + }, + lastGood: { + 'custom-abc12345': 'custom-abc12345:backup', + }, + }); + + const { + getActiveOpenClawProviders, + getOpenClawProvidersConfig, + removeProviderFromOpenClaw, + } = await import('@electron/utils/openclaw-auth'); + + await expect(getActiveOpenClawProviders()).resolves.toEqual(new Set(['custom-abc12345'])); + + await removeProviderFromOpenClaw('custom-abc12345'); + + const mainProfiles = await readAuthProfiles('main'); + const config = await readOpenClawJson(); + const result = await getOpenClawProvidersConfig(); + + expect(mainProfiles.profiles).toEqual({}); + expect(mainProfiles.order).toEqual({}); + expect(mainProfiles.lastGood).toEqual({}); + expect((config.auth as { profiles?: Record }).profiles).toEqual({}); + expect((config.models as { providers?: Record }).providers).toEqual({}); + expect(result.providers).toEqual({}); + await expect(getActiveOpenClawProviders()).resolves.toEqual(new Set()); + }); }); diff --git a/tests/unit/provider-runtime-sync.test.ts b/tests/unit/provider-runtime-sync.test.ts index 772b3bcae..728a6ce70 100644 --- a/tests/unit/provider-runtime-sync.test.ts +++ b/tests/unit/provider-runtime-sync.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ getProviderConfig: vi.fn(), getProviderDefaultModel: vi.fn(), removeProviderFromOpenClaw: vi.fn(), + removeProviderKeyFromOpenClaw: vi.fn(), saveOAuthTokenToOpenClaw: vi.fn(), saveProviderKeyToOpenClaw: vi.fn(), setOpenClawDefaultModel: vi.fn(), @@ -46,6 +47,7 @@ vi.mock('@electron/utils/provider-registry', () => ({ vi.mock('@electron/utils/openclaw-auth', () => ({ removeProviderFromOpenClaw: mocks.removeProviderFromOpenClaw, + removeProviderKeyFromOpenClaw: mocks.removeProviderKeyFromOpenClaw, saveOAuthTokenToOpenClaw: mocks.saveOAuthTokenToOpenClaw, saveProviderKeyToOpenClaw: mocks.saveProviderKeyToOpenClaw, setOpenClawDefaultModel: mocks.setOpenClawDefaultModel, @@ -71,6 +73,7 @@ vi.mock('@electron/utils/logger', () => ({ import { syncAgentModelOverrideToRuntime, syncDefaultProviderToRuntime, + syncDeletedProviderApiKeyToRuntime, syncDeletedProviderToRuntime, syncSavedProviderToRuntime, } from '@electron/services/providers/provider-runtime-sync'; @@ -116,6 +119,7 @@ describe('provider-runtime-sync refresh strategy', () => { mocks.setOpenClawDefaultModelWithOverride.mockResolvedValue(undefined); mocks.saveProviderKeyToOpenClaw.mockResolvedValue(undefined); mocks.removeProviderFromOpenClaw.mockResolvedValue(undefined); + mocks.removeProviderKeyFromOpenClaw.mockResolvedValue(undefined); mocks.updateAgentModelProvider.mockResolvedValue(undefined); mocks.updateSingleAgentModelProvider.mockResolvedValue(undefined); mocks.listAgentsSnapshot.mockResolvedValue({ agents: [] }); @@ -137,6 +141,34 @@ describe('provider-runtime-sync refresh strategy', () => { expect(gateway.debouncedReload).not.toHaveBeenCalled(); }); + it('removes both runtime and stored account keys when deleting a custom provider', async () => { + const gateway = createGateway('running'); + const customProvider = createProvider({ + id: 'moonshot-cn', + type: 'custom', + baseUrl: 'https://api.moonshot.cn/v1', + }); + + await syncDeletedProviderToRuntime(customProvider, 'moonshot-cn', gateway as GatewayManager); + + expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('custom-moonshot'); + expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('moonshot-cn'); + expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledTimes(2); + expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1); + }); + + it('only clears the api-key profile when deleting a provider api key', async () => { + const openaiProvider = createProvider({ + id: 'openai-personal', + type: 'openai', + }); + + await syncDeletedProviderApiKeyToRuntime(openaiProvider, 'openai-personal'); + + expect(mocks.removeProviderKeyFromOpenClaw).toHaveBeenCalledWith('openai'); + expect(mocks.removeProviderFromOpenClaw).not.toHaveBeenCalled(); + }); + it('uses debouncedReload after switching default provider when gateway is running', async () => { const gateway = createGateway('running'); await syncDefaultProviderToRuntime('moonshot', gateway as GatewayManager); @@ -153,6 +185,34 @@ describe('provider-runtime-sync refresh strategy', () => { expect(gateway.debouncedRestart).not.toHaveBeenCalled(); }); + it('uses gpt-5.4 as the browser OAuth default model for OpenAI', async () => { + mocks.getProvider.mockResolvedValue( + createProvider({ + id: 'openai-personal', + type: 'openai', + model: undefined, + }), + ); + mocks.getProviderAccount.mockResolvedValue({ authMode: 'oauth_browser' }); + mocks.getProviderSecret.mockResolvedValue({ + type: 'oauth', + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: 123, + email: 'user@example.com', + subject: 'project-1', + }); + + const gateway = createGateway('running'); + await syncDefaultProviderToRuntime('openai-personal', gateway as GatewayManager); + + expect(mocks.setOpenClawDefaultModel).toHaveBeenCalledWith( + 'openai-codex', + 'openai-codex/gpt-5.4', + expect.any(Array), + ); + }); + it('syncs a targeted agent model override to runtime provider registry', async () => { mocks.getAllProviders.mockResolvedValue([ createProvider({ diff --git a/tests/unit/providers.test.ts b/tests/unit/providers.test.ts index f9434e33d..4c2f4578b 100644 --- a/tests/unit/providers.test.ts +++ b/tests/unit/providers.test.ts @@ -119,6 +119,38 @@ describe('provider metadata', () => { expect(shouldShowProviderModelId(siliconflow, true)).toBe(true); }); + it('shows OAuth model overrides only in dev mode and preserves defaults', () => { + const openai = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openai'); + const google = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'google'); + const minimax = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'minimax-portal'); + const minimaxCn = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'minimax-portal-cn'); + const qwen = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'qwen-portal'); + + expect(openai).toMatchObject({ showModelId: true, showModelIdInDevModeOnly: true, defaultModelId: 'gpt-5.4' }); + expect(google).toMatchObject({ showModelId: true, showModelIdInDevModeOnly: true, defaultModelId: 'gemini-3-pro-preview' }); + expect(minimax).toMatchObject({ showModelId: true, showModelIdInDevModeOnly: true, defaultModelId: 'MiniMax-M2.7' }); + expect(minimaxCn).toMatchObject({ showModelId: true, showModelIdInDevModeOnly: true, defaultModelId: 'MiniMax-M2.7' }); + expect(qwen).toMatchObject({ showModelId: true, showModelIdInDevModeOnly: true, defaultModelId: 'coder-model' }); + + expect(shouldShowProviderModelId(openai, false)).toBe(false); + expect(shouldShowProviderModelId(google, false)).toBe(false); + expect(shouldShowProviderModelId(minimax, false)).toBe(false); + expect(shouldShowProviderModelId(minimaxCn, false)).toBe(false); + expect(shouldShowProviderModelId(qwen, false)).toBe(false); + + expect(shouldShowProviderModelId(openai, true)).toBe(true); + expect(shouldShowProviderModelId(google, true)).toBe(true); + expect(shouldShowProviderModelId(minimax, true)).toBe(true); + expect(shouldShowProviderModelId(minimaxCn, true)).toBe(true); + expect(shouldShowProviderModelId(qwen, true)).toBe(true); + + expect(resolveProviderModelForSave(openai, ' ', true)).toBe('gpt-5.4'); + expect(resolveProviderModelForSave(google, ' ', true)).toBe('gemini-3-pro-preview'); + expect(resolveProviderModelForSave(minimax, ' ', true)).toBe('MiniMax-M2.7'); + expect(resolveProviderModelForSave(minimaxCn, ' ', true)).toBe('MiniMax-M2.7'); + expect(resolveProviderModelForSave(qwen, ' ', true)).toBe('coder-model'); + }); + it('saves OpenRouter and SiliconFlow model overrides by default', () => { const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter'); const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');