fix(ollama): migrate legacy api-key marker configs (#123341)

This commit is contained in:
Peter Steinberger
2026-08-13 15:39:48 -07:00
committed by GitHub
parent 177a16cdca
commit 66bfb5dce1
6 changed files with 256 additions and 13 deletions
@@ -26,12 +26,90 @@ function readOllamaCloudProvider(config: OpenClawConfig): Record<string, unknown
return config.models?.providers?.["ollama-cloud"] as Record<string, unknown> | 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<string, unknown>,
),
).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: {
+82 -10
View File
@@ -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<string | number>;
message: string;
match: (value: unknown) => boolean;
match: (value: unknown, root?: Record<string, unknown>) => boolean;
};
function isLegacyOllamaLocalConfig(provider: unknown, root?: Record<string, unknown>): 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<string, unknown>)) {
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 };
}
+1
View File
@@ -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";
+2 -2
View File
@@ -11,12 +11,12 @@ type OllamaProviderConfigInput = Omit<Partial<ModelProviderConfig>, "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?: {
@@ -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();
});
});
+3 -1
View File
@@ -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();