mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(ollama): migrate legacy api-key marker configs (#123341)
This commit is contained in:
committed by
GitHub
parent
177a16cdca
commit
66bfb5dce1
@@ -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: {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user