diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index a1bf117a2..4614edf96 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -23,6 +23,14 @@ const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini- const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex'; const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.4`; +/** + * Provider types that are not in the built-in provider registry (no `providerConfig.api`). + * They require explicit api-protocol defaulting to `openai-completions`. + */ +function isUnregisteredProviderType(type: string): boolean { + return type === 'custom' || type === 'ollama'; +} + type RuntimeProviderSyncContext = { runtimeProviderKey: string; meta: ReturnType; @@ -44,7 +52,7 @@ function normalizeProviderBaseUrl( return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; } - if (config.type === 'custom' || config.type === 'ollama') { + if (isUnregisteredProviderType(config.type)) { const protocol = apiProtocol || config.apiProtocol || 'openai-completions'; if (protocol === 'openai-responses') { return normalized.replace(/\/responses?$/i, ''); @@ -65,7 +73,7 @@ function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProvide } export function getOpenClawProviderKey(type: string, providerId: string): string { - if (type === 'custom' || type === 'ollama') { + if (isUnregisteredProviderType(type)) { // If the providerId is already a runtime key (e.g. re-seeded from openclaw.json // as "custom-XXXXXXXX"), return it directly to avoid double-hashing. const prefix = `${type}-`; @@ -286,7 +294,7 @@ async function syncProviderSecretToRuntime( async function resolveRuntimeSyncContext(config: ProviderConfig): Promise { const runtimeProviderKey = await resolveRuntimeProviderKey(config); const meta = getProviderConfig(config.type); - const api = config.apiProtocol || ((config.type === 'custom' || config.type === 'ollama') ? 'openai-completions' : meta?.api); + const api = config.apiProtocol || (isUnregisteredProviderType(config.type) ? 'openai-completions' : meta?.api); if (!api) { return null; } @@ -315,7 +323,7 @@ async function syncCustomProviderAgentModel( runtimeProviderKey: string, apiKey: string | undefined, ): Promise { - if (config.type !== 'custom' && config.type !== 'ollama') { + if (!isUnregisteredProviderType(config.type)) { return; } @@ -402,7 +410,7 @@ async function buildAgentModelProviderEntry( authHeader?: boolean; } | null> { const meta = getProviderConfig(config.type); - const api = config.apiProtocol || ((config.type === 'custom' || config.type === 'ollama') ? 'openai-completions' : meta?.api); + const api = config.apiProtocol || (isUnregisteredProviderType(config.type) ? 'openai-completions' : meta?.api); const baseUrl = normalizeProviderBaseUrl(config, config.baseUrl || meta?.baseUrl, api); if (!api || !baseUrl) { return null; @@ -411,7 +419,7 @@ async function buildAgentModelProviderEntry( let apiKey: string | undefined; let authHeader: boolean | undefined; - if (config.type === 'custom') { + if (isUnregisteredProviderType(config.type)) { apiKey = (await getApiKey(config.id)) || undefined; } else if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') { const accountApiKey = await getApiKey(config.id); @@ -507,7 +515,7 @@ export async function syncUpdatedProviderToRuntime( const defaultProviderId = await getDefaultProvider(); if (defaultProviderId === config.id) { const modelOverride = config.model ? `${ock}/${config.model}` : undefined; - if (config.type !== 'custom') { + if (!isUnregisteredProviderType(config.type)) { if (shouldUseExplicitDefaultOverride(config, ock)) { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api), @@ -593,7 +601,7 @@ export async function syncDefaultProviderToRuntime( ? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`) : undefined; - if (provider.type === 'custom' || provider.type === 'ollama') { + if (isUnregisteredProviderType(provider.type)) { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'), api: provider.apiProtocol || 'openai-completions', @@ -689,7 +697,7 @@ export async function syncDefaultProviderToRuntime( } if ( - (provider.type === 'custom' || provider.type === 'ollama') && + isUnregisteredProviderType(provider.type) && providerKey && provider.baseUrl ) { diff --git a/tests/unit/provider-runtime-sync.test.ts b/tests/unit/provider-runtime-sync.test.ts index 48b232df0..2bed33bb8 100644 --- a/tests/unit/provider-runtime-sync.test.ts +++ b/tests/unit/provider-runtime-sync.test.ts @@ -76,6 +76,7 @@ import { syncDeletedProviderApiKeyToRuntime, syncDeletedProviderToRuntime, syncSavedProviderToRuntime, + syncUpdatedProviderToRuntime, } from '@electron/services/providers/provider-runtime-sync'; function createProvider(overrides: Partial = {}): ProviderConfig { @@ -310,4 +311,51 @@ describe('provider-runtime-sync refresh strategy', () => { expect.any(Array), ); }); + it('syncs updated Ollama provider as default with correct override config', async () => { + const ollamaProvider = createProvider({ + id: 'ollamafd', + type: 'ollama', + name: 'Ollama', + model: 'qwen3:30b', + baseUrl: 'http://localhost:11434/v1', + }); + + mocks.getProviderConfig.mockReturnValue(undefined); + mocks.getProviderSecret.mockResolvedValue({ type: 'local', apiKey: 'ollama-local' }); + mocks.getDefaultProvider.mockResolvedValue('ollamafd'); + + const gateway = createGateway('running'); + await syncUpdatedProviderToRuntime(ollamaProvider, undefined, gateway as GatewayManager); + + // Should use the custom/ollama branch with explicit override + expect(mocks.setOpenClawDefaultModelWithOverride).toHaveBeenCalledWith( + 'ollama-ollamafd', + 'ollama-ollamafd/qwen3:30b', + expect.objectContaining({ + baseUrl: 'http://localhost:11434/v1', + api: 'openai-completions', + }), + expect.any(Array), + ); + // Should NOT call the non-override path + expect(mocks.setOpenClawDefaultModel).not.toHaveBeenCalled(); + expect(gateway.debouncedReload).toHaveBeenCalledTimes(1); + }); + + it('removes Ollama provider from runtime on delete', async () => { + const ollamaProvider = createProvider({ + id: 'ollamafd', + type: 'ollama', + name: 'Ollama', + model: 'qwen3:30b', + baseUrl: 'http://localhost:11434/v1', + }); + + const gateway = createGateway('running'); + await syncDeletedProviderToRuntime(ollamaProvider, 'ollamafd', gateway as GatewayManager); + + expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('ollama-ollamafd'); + expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('ollamafd'); + expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1); + }); });