[Bug]: ollama-cloud runtime fails DNS lookup for ai.ollama.com, while ollama/<model>:cloud works (#92594)

* fix(ollama): repair retired cloud provider endpoint

Route configured Ollama Cloud provider ids through plugin doctor compatibility migrations so doctor --fix can rewrite the retired ai.ollama.com endpoint before runtime reads persisted config.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(doctor): align provider fixture with typed config

Ensure the doctor registry provider-scoped migration test uses a fully typed provider fixture so the test type-check shard validates the intended behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(ollama): align doctor fixture with typed config

Use fully typed provider and model fixtures in the Ollama doctor contract tests so the extension test type-check shard validates the migration behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ollama): preserve custom cloud provider base url

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ollama): avoid logging retired endpoint secrets

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
zhang-guiping
2026-06-16 14:17:57 +08:00
committed by GitHub
parent 4a0e376d1f
commit bb164384c2
5 changed files with 385 additions and 1 deletions

View File

@@ -0,0 +1,182 @@
// Ollama tests cover doctor contract config compatibility.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
type ModelDefinition = NonNullable<
NonNullable<OpenClawConfig["models"]>["providers"]
>[string]["models"][number];
const cloudModel: ModelDefinition = {
id: "kimi-k2.5:cloud",
name: "Kimi K2.5 Cloud",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 8192,
};
function readOllamaCloudProvider(config: OpenClawConfig): Record<string, unknown> | undefined {
return config.models?.providers?.["ollama-cloud"] as Record<string, unknown> | undefined;
}
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 retired Ollama Cloud provider baseUrl to the canonical endpoint", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ai.ollama.com",
api: "ollama",
models: [cloudModel],
},
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Updated models.providers.ollama-cloud.baseUrl from the retired Ollama Cloud endpoint to https://ollama.com.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [cloudModel],
});
expect(readOllamaCloudProvider(config)?.baseUrl).toBe("https://ai.ollama.com");
});
it("removes retired Ollama Cloud provider baseURL aliases when canonical baseUrl is present", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ollama.com",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: "https://ollama.com",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("migrates retired Ollama Cloud provider baseURL aliases when canonical baseUrl is blank", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: " ",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Updated models.providers.ollama-cloud.baseURL from the retired Ollama Cloud endpoint to https://ollama.com.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: " ",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("preserves custom canonical baseUrl when removing retired baseURL aliases", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://custom-ollama-cloud.example.test",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://custom-ollama-cloud.example.test",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: "https://custom-ollama-cloud.example.test",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("does not expose credentials or query parameters from the retired URL", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://user:password@ai.ollama.com/?token=secret",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes.join("\n")).not.toContain("user");
expect(result.changes.join("\n")).not.toContain("password");
expect(result.changes.join("\n")).not.toContain("secret");
expect(readOllamaCloudProvider(result.config)?.baseUrl).toBe("https://ollama.com");
});
});

View File

@@ -0,0 +1 @@
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";

View File

@@ -0,0 +1,103 @@
// Ollama helper module supports config compat behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_PROVIDER_ID } from "./defaults.js";
type LegacyConfigRule = {
path: Array<string | number>;
message: string;
match: (value: unknown) => boolean;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isRetiredOllamaCloudBaseUrl(value: unknown): value is string {
if (typeof value !== "string" || !value.trim()) {
return false;
}
try {
return new URL(value.trim()).hostname.toLowerCase() === "ai.ollama.com";
} catch {
return false;
}
}
function findRetiredOllamaCloudBaseUrl(provider: unknown): { key: "baseUrl" | "baseURL" } | null {
const record = asRecord(provider);
if (!record) {
return null;
}
if (isRetiredOllamaCloudBaseUrl(record.baseUrl)) {
return { key: "baseUrl" };
}
if (isRetiredOllamaCloudBaseUrl(record.baseURL)) {
return { key: "baseURL" };
}
return null;
}
export const legacyConfigRules: LegacyConfigRule[] = [
{
path: ["models", "providers", OLLAMA_CLOUD_PROVIDER_ID],
message:
'models.providers.ollama-cloud.baseUrl="https://ai.ollama.com" is retired; use "https://ollama.com". Run "openclaw doctor --fix".',
match: (value) => findRetiredOllamaCloudBaseUrl(value) !== null,
},
];
export function migrateOllamaCloudRetiredBaseUrl(config: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} | null {
const provider = config.models?.providers?.[OLLAMA_CLOUD_PROVIDER_ID];
const retired = findRetiredOllamaCloudBaseUrl(provider);
if (!retired) {
return null;
}
const nextConfig = structuredClone(config);
const nextModels = asRecord(nextConfig.models) ?? {};
nextConfig.models = nextModels as OpenClawConfig["models"];
const nextProviders = asRecord(nextModels.providers) ?? {};
nextModels.providers = nextProviders;
const nextProvider = asRecord(nextProviders[OLLAMA_CLOUD_PROVIDER_ID]) ?? {};
nextProviders[OLLAMA_CLOUD_PROVIDER_ID] = nextProvider;
const canonicalBaseUrl = nextProvider.baseUrl;
if (
retired.key === "baseURL" &&
typeof canonicalBaseUrl === "string" &&
canonicalBaseUrl.trim() &&
!isRetiredOllamaCloudBaseUrl(canonicalBaseUrl)
) {
delete nextProvider.baseURL;
return {
config: nextConfig,
changes: [
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
],
};
}
nextProvider.baseUrl = OLLAMA_CLOUD_BASE_URL;
if (retired.key === "baseURL") {
delete nextProvider.baseURL;
}
return {
config: nextConfig,
changes: [
`Updated models.providers.ollama-cloud.${retired.key} from the retired Ollama Cloud endpoint to ${OLLAMA_CLOUD_BASE_URL}.`,
],
};
}
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
return migrateOllamaCloudRetiredBaseUrl(cfg) ?? { config: cfg, changes: [] };
}

View File

@@ -13,7 +13,9 @@ import {
const tempDirs: string[] = [];
const mocks = getRegistryJitiMocks();
let applyPluginDoctorCompatibilityMigrations: typeof import("./doctor-contract-registry.js").applyPluginDoctorCompatibilityMigrations;
let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache;
let collectRelevantDoctorPluginIds: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIds;
let collectRelevantDoctorPluginIdsForTouchedPaths: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIdsForTouchedPaths;
let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules;
let listPluginDoctorSessionRouteStateOwners: typeof import("./doctor-contract-registry.js").listPluginDoctorSessionRouteStateOwners;
@@ -43,7 +45,9 @@ describe("doctor-contract-registry module loader", () => {
resetRegistryJitiMocks();
vi.resetModules();
({
applyPluginDoctorCompatibilityMigrations,
clearPluginDoctorContractRegistryCache,
collectRelevantDoctorPluginIds,
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
listPluginDoctorSessionRouteStateOwners,
@@ -347,6 +351,80 @@ describe("doctor-contract-registry module loader", () => {
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(2);
});
it("collects model provider ids for doctor compatibility migrations", () => {
expect(
collectRelevantDoctorPluginIds({
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ai.ollama.com",
},
},
},
}),
).toEqual(["ollama-cloud"]);
});
it("loads a plugin doctor contract when scoped by a contributed provider id", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "doctor-contract-api.ts"), "export {};\n", "utf-8");
mocks.createJiti.mockImplementation(() => () => ({
normalizeCompatibilityConfig: ({
cfg,
}: {
cfg: { models?: { providers?: Record<string, Record<string, unknown>> } };
}) => ({
config: {
...cfg,
models: {
...cfg.models,
providers: {
...cfg.models?.providers,
"ollama-cloud": {
...cfg.models?.providers?.["ollama-cloud"],
baseUrl: "https://ollama.com",
},
},
},
},
changes: ["normalized ollama cloud provider endpoint"],
}),
}));
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "ollama",
rootDir: pluginRoot,
channels: [],
providers: ["ollama", "ollama-cloud"],
},
],
diagnostics: [],
});
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ai.ollama.com",
models: [],
},
},
},
};
const result = applyPluginDoctorCompatibilityMigrations(config, {
config,
env: {},
pluginIds: ["ollama-cloud"],
});
expect(result.changes).toEqual(["normalized ollama cloud provider endpoint"]);
expect(result.config.models?.providers?.["ollama-cloud"]).toEqual({
baseUrl: "https://ollama.com",
models: [],
});
});
it("narrows touched-path doctor ids for scoped dry-run validation", () => {
expect(
collectRelevantDoctorPluginIdsForTouchedPaths({
@@ -360,6 +438,11 @@ describe("doctor-contract-registry module loader", () => {
"memory-wiki": {},
},
},
models: {
providers: {
"ollama-cloud": {},
},
},
talk: {
voiceId: "legacy-voice",
},
@@ -367,10 +450,11 @@ describe("doctor-contract-registry module loader", () => {
touchedPaths: [
["channels", "discord", "token"],
["plugins", "entries", "memory-wiki", "enabled"],
["models", "providers", "ollama-cloud", "baseUrl"],
["talk", "voiceId"],
],
}),
).toEqual(["discord", "elevenlabs", "memory-wiki"]);
).toEqual(["discord", "elevenlabs", "memory-wiki", "ollama-cloud"]);
});
it("falls back to the full doctor-id set when touched paths are too broad", () => {

View File

@@ -244,6 +244,13 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
}
}
const modelProviders = asNullableRecord(asNullableRecord(root.models)?.providers);
if (modelProviders) {
for (const providerId of Object.keys(modelProviders)) {
ids.add(providerId);
}
}
if (hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}
@@ -279,6 +286,13 @@ export function collectRelevantDoctorPluginIdsForTouchedPaths(params: {
ids.add(third);
continue;
}
if (first === "models") {
if (second !== "providers" || !third) {
return collectRelevantDoctorPluginIds(params.raw);
}
ids.add(third);
continue;
}
if (first === "talk" && hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}