diff --git a/extensions/ollama/doctor-contract-api.test.ts b/extensions/ollama/doctor-contract-api.test.ts index f83a26697dc6..07c3c1d81ad9 100644 --- a/extensions/ollama/doctor-contract-api.test.ts +++ b/extensions/ollama/doctor-contract-api.test.ts @@ -26,12 +26,90 @@ function readOllamaCloudProvider(config: OpenClawConfig): Record | undefined; } +function legacyLocalConfig(): OpenClawConfig { + return { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + apiKey: "OLLAMA_API_KEY", + models: [cloudModel], + }, + }, + }, + auth: { + profiles: { + "ollama:default": { provider: "ollama", mode: "api_key" }, + "openai:default": { provider: "openai", mode: "api_key" }, + }, + }, + agents: { defaults: { model: { primary: "ollama/kimi-k2.5:cloud" } } }, + } as OpenClawConfig; +} + describe("ollama doctor contract", () => { it("detects retired Ollama Cloud provider endpoints", () => { expect(legacyConfigRules[0]?.match({ baseUrl: "https://ai.ollama.com" })).toBe(true); expect(legacyConfigRules[0]?.match({ baseUrl: "https://ollama.com" })).toBe(false); }); + it("migrates the pre-#123190 local marker without replacing its catalog or default", () => { + const config = legacyLocalConfig(); + const localRule = legacyConfigRules[1]; + + expect( + localRule?.match( + config.models?.providers?.ollama, + config as unknown as Record, + ), + ).toBe(true); + + const result = normalizeCompatibilityConfig({ cfg: config }); + + expect(result.changes).toEqual([ + "Migrated models.providers.ollama.apiKey to ollama-local and removed the obsolete ollama:default auth profile marker.", + ]); + expect(result.config.models?.providers?.ollama).toEqual({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + apiKey: "ollama-local", + models: [cloudModel], + }); + expect(result.config.auth?.profiles).toEqual({ + "openai:default": { provider: "openai", mode: "api_key" }, + }); + expect(result.config.agents?.defaults?.model).toEqual({ + primary: "ollama/kimi-k2.5:cloud", + }); + expect(config.models?.providers?.ollama?.apiKey).toBe("OLLAMA_API_KEY"); + expect(config.auth?.profiles?.["ollama:default"]).toBeDefined(); + expect(normalizeCompatibilityConfig({ cfg: result.config })).toEqual({ + config: result.config, + changes: [], + }); + }); + + it("preserves current env-backed marker configs without the exact legacy profile", () => { + const withoutProfile = legacyLocalConfig(); + delete withoutProfile.auth?.profiles?.["ollama:default"]; + const customizedProfile = legacyLocalConfig(); + customizedProfile.auth!.profiles!["ollama:default"] = { + provider: "ollama", + mode: "api_key", + displayName: "Remote Ollama", + }; + + expect(normalizeCompatibilityConfig({ cfg: withoutProfile })).toEqual({ + config: withoutProfile, + changes: [], + }); + expect(normalizeCompatibilityConfig({ cfg: customizedProfile })).toEqual({ + config: customizedProfile, + changes: [], + }); + }); + it("migrates retired Ollama Cloud provider baseUrl to the canonical endpoint", () => { const config = { models: { diff --git a/extensions/ollama/src/config-compat.ts b/extensions/ollama/src/config-compat.ts index 56875d5319ca..024cca30ba55 100644 --- a/extensions/ollama/src/config-compat.ts +++ b/extensions/ollama/src/config-compat.ts @@ -1,14 +1,36 @@ // Ollama helper module supports config compat behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_PROVIDER_ID } from "./defaults.js"; +import { + OLLAMA_CLOUD_BASE_URL, + OLLAMA_CLOUD_PROVIDER_ID, + OLLAMA_DEFAULT_API_KEY, +} from "./defaults.js"; + +const OLLAMA_PROVIDER_ID = "ollama"; +const LEGACY_OLLAMA_API_KEY_MARKER = "OLLAMA_API_KEY"; +const LEGACY_OLLAMA_PROFILE_ID = "ollama:default"; type LegacyConfigRule = { path: Array; message: string; - match: (value: unknown) => boolean; + match: (value: unknown, root?: Record) => boolean; }; +function isLegacyOllamaLocalConfig(provider: unknown, root?: Record): boolean { + const providerRecord = asObjectRecord(provider); + const auth = asObjectRecord(root?.auth); + const profiles = asObjectRecord(auth?.profiles); + const profile = asObjectRecord(profiles?.[LEGACY_OLLAMA_PROFILE_ID]); + return ( + providerRecord?.api === "ollama" && + providerRecord.apiKey === LEGACY_OLLAMA_API_KEY_MARKER && + profile?.provider === OLLAMA_PROVIDER_ID && + profile.mode === "api_key" && + Object.keys(profile).length === 2 + ); +} + function isRetiredOllamaCloudBaseUrl(value: unknown): value is string { if (typeof value !== "string" || !value.trim()) { return false; @@ -41,8 +63,55 @@ export const legacyConfigRules: LegacyConfigRule[] = [ 'models.providers.ollama-cloud.baseUrl="https://ai.ollama.com" is retired; use "https://ollama.com". Run "openclaw doctor --fix".', match: (value) => findRetiredOllamaCloudBaseUrl(value) !== null, }, + { + path: ["models", "providers", OLLAMA_PROVIDER_ID], + message: + 'Legacy local Ollama authentication markers must be migrated. Run "openclaw doctor --fix".', + match: isLegacyOllamaLocalConfig, + }, ]; +function cloneProviderConfig(config: OpenClawConfig, providerId: string) { + const nextConfig = structuredClone(config); + const nextModels = asObjectRecord(nextConfig.models) ?? {}; + nextConfig.models = nextModels as OpenClawConfig["models"]; + const nextProviders = asObjectRecord(nextModels.providers) ?? {}; + nextModels.providers = nextProviders; + const nextProvider = asObjectRecord(nextProviders[providerId]) ?? {}; + nextProviders[providerId] = nextProvider; + return { nextConfig, nextProvider }; +} + +function migrateLegacyOllamaLocalConfig(config: OpenClawConfig): { + config: OpenClawConfig; + changes: string[]; +} | null { + const provider = config.models?.providers?.[OLLAMA_PROVIDER_ID]; + if (!isLegacyOllamaLocalConfig(provider, config as unknown as Record)) { + return null; + } + + const { nextConfig, nextProvider } = cloneProviderConfig(config, OLLAMA_PROVIDER_ID); + nextProvider.apiKey = OLLAMA_DEFAULT_API_KEY; + const nextAuth = asObjectRecord(nextConfig.auth); + const nextProfiles = asObjectRecord(nextAuth?.profiles); + if (nextAuth && nextProfiles) { + delete nextProfiles[LEGACY_OLLAMA_PROFILE_ID]; + if (Object.keys(nextProfiles).length === 0) { + delete nextAuth.profiles; + } + if (Object.keys(nextAuth).length === 0) { + delete nextConfig.auth; + } + } + return { + config: nextConfig, + changes: [ + `Migrated models.providers.${OLLAMA_PROVIDER_ID}.apiKey to ${OLLAMA_DEFAULT_API_KEY} and removed the obsolete ${LEGACY_OLLAMA_PROFILE_ID} auth profile marker.`, + ], + }; +} + function migrateOllamaCloudRetiredBaseUrl(config: OpenClawConfig): { config: OpenClawConfig; changes: string[]; @@ -53,13 +122,7 @@ function migrateOllamaCloudRetiredBaseUrl(config: OpenClawConfig): { return null; } - const nextConfig = structuredClone(config); - const nextModels = asObjectRecord(nextConfig.models) ?? {}; - nextConfig.models = nextModels as OpenClawConfig["models"]; - const nextProviders = asObjectRecord(nextModels.providers) ?? {}; - nextModels.providers = nextProviders; - const nextProvider = asObjectRecord(nextProviders[OLLAMA_CLOUD_PROVIDER_ID]) ?? {}; - nextProviders[OLLAMA_CLOUD_PROVIDER_ID] = nextProvider; + const { nextConfig, nextProvider } = cloneProviderConfig(config, OLLAMA_CLOUD_PROVIDER_ID); const canonicalBaseUrl = nextProvider.baseUrl; if ( @@ -94,5 +157,14 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): config: OpenClawConfig; changes: string[]; } { - return migrateOllamaCloudRetiredBaseUrl(cfg) ?? { config: cfg, changes: [] }; + let config = cfg; + const changes: string[] = []; + for (const migrate of [migrateLegacyOllamaLocalConfig, migrateOllamaCloudRetiredBaseUrl]) { + const result = migrate(config); + if (result) { + config = result.config; + changes.push(...result.changes); + } + } + return { config, changes }; } diff --git a/extensions/ollama/src/defaults.ts b/extensions/ollama/src/defaults.ts index 12dfaa5fb2ab..e20cd07bd48c 100644 --- a/extensions/ollama/src/defaults.ts +++ b/extensions/ollama/src/defaults.ts @@ -1,5 +1,6 @@ // Ollama plugin module implements defaults behavior. export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; +export const OLLAMA_DEFAULT_API_KEY = "ollama-local"; const OLLAMA_DOCKER_HOST_BASE_URL = "http://host.docker.internal:11434"; export const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; export const OLLAMA_CLOUD_PROVIDER_ID = "ollama-cloud"; diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index a5cc56995670..c44b3f653159 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -11,12 +11,12 @@ type OllamaProviderConfigInput = Omit, "models"> & models?: ModelDefinitionConfig[]; }; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; +import { OLLAMA_DEFAULT_API_KEY, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { readProviderBaseUrl } from "./provider-base-url.js"; import { resolveOllamaApiBase } from "./provider-models.js"; export const OLLAMA_PROVIDER_ID = "ollama"; -export const OLLAMA_DEFAULT_API_KEY = "ollama-local"; +export { OLLAMA_DEFAULT_API_KEY } from "./defaults.js"; export type OllamaPluginConfig = { discovery?: { diff --git a/src/gateway/server-methods/system-agent-setup-resolution.test.ts b/src/gateway/server-methods/system-agent-setup-resolution.test.ts new file mode 100644 index 000000000000..a74fef479a84 --- /dev/null +++ b/src/gateway/server-methods/system-agent-setup-resolution.test.ts @@ -0,0 +1,90 @@ +// OpenClaw setup resolution tests cover terminal provider guidance. +import { expectDefined } from "@openclaw/normalization-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resetCommandQueueStateForTest } from "../../process/command-queue.test-support.js"; +import { whenAdmittedWizardSessionSettled } from "./setup-admission.js"; +import { systemAgentHandlers } from "./system-agent.js"; +import type { GatewayRequestContext } from "./types.js"; + +const providerAuthChoiceMocks = vi.hoisted(() => ({ + applyAuthChoiceLoadedPluginProvider: vi.fn(), +})); +const setupSharedMocks = vi.hoisted(() => ({ + readSetupConfigFileSnapshot: vi.fn(), + writeWizardConfigFile: vi.fn(), +})); + +vi.mock("../../plugins/provider-auth-choice.js", () => ({ + applyAuthChoiceLoadedPluginProvider: providerAuthChoiceMocks.applyAuthChoiceLoadedPluginProvider, +})); +vi.mock("../../wizard/setup.shared.js", () => ({ + readSetupConfigFileSnapshot: setupSharedMocks.readSetupConfigFileSnapshot, + writeWizardConfigFile: setupSharedMocks.writeWizardConfigFile, +})); + +const config: OpenClawConfig = { + models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } }, +}; + +function makeContext() { + const wizardSessions = new Map(); + return { + wizardSessions, + context: { + wizardSessions, + findRunningWizard: () => undefined, + purgeWizardSession: (id: string) => wizardSessions.delete(id), + } as unknown as GatewayRequestContext, + }; +} + +describe("openclaw.setup provider resolution", () => { + beforeEach(() => { + setupSharedMocks.readSetupConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + hash: "setup-resolution-config", + sourceConfig: config, + config, + issues: [], + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + resetCommandQueueStateForTest(); + }); + + it.each([ + ["missing", null], + ["retryable", { config, retrySelection: true }], + ])("returns actionable doctor guidance when provider setup is %s", async (_, result) => { + providerAuthChoiceMocks.applyAuthChoiceLoadedPluginProvider.mockResolvedValueOnce(result); + const { wizardSessions, context } = makeContext(); + const handler = expectDefined( + systemAgentHandlers["openclaw.setup.prepare.start"], + "openclaw.setup.prepare.start handler", + ); + + await handler({ + params: { sessionId: "prepare-resolution-error", authChoice: "ollama" }, + respond: () => undefined, + context, + } as never); + + const session = expectDefined( + wizardSessions.get("prepare-resolution-error"), + "prepare wizard session", + ); + await expect(session.next()).resolves.toMatchObject({ + done: true, + status: "error", + error: + 'Error: Provider setup resolution failed for "ollama". Run `openclaw doctor --fix`, restart the Gateway, and try again.', + }); + await whenAdmittedWizardSessionSettled(session); + expect(setupSharedMocks.writeWizardConfigFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/system-agent.ts b/src/gateway/server-methods/system-agent.ts index 74ba6e699ddb..b833256d69b5 100644 --- a/src/gateway/server-methods/system-agent.ts +++ b/src/gateway/server-methods/system-agent.ts @@ -422,7 +422,9 @@ export const systemAgentHandlers: GatewayRequestHandlers = { }, }); if (!applied || applied.retrySelection) { - throw new Error(`Provider prepare method is unavailable: ${params.authChoice}`); + throw new Error( + `Provider setup resolution failed for "${params.authChoice}". Run \`openclaw doctor --fix\`, restart the Gateway, and try again.`, + ); } signal.throwIfAborted(); runnerSession.lockCancellation();