mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-06 01:55:37 +00:00
[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:
182
extensions/ollama/doctor-contract-api.test.ts
Normal file
182
extensions/ollama/doctor-contract-api.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
1
extensions/ollama/doctor-contract-api.ts
Normal file
1
extensions/ollama/doctor-contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";
|
||||
103
extensions/ollama/src/config-compat.ts
Normal file
103
extensions/ollama/src/config-compat.ts
Normal 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: [] };
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user