diff --git a/extensions/ollama/doctor-contract-api.test.ts b/extensions/ollama/doctor-contract-api.test.ts new file mode 100644 index 000000000000..f83a26697dc6 --- /dev/null +++ b/extensions/ollama/doctor-contract-api.test.ts @@ -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["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 | undefined { + return config.models?.providers?.["ollama-cloud"] as Record | 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"); + }); +}); diff --git a/extensions/ollama/doctor-contract-api.ts b/extensions/ollama/doctor-contract-api.ts new file mode 100644 index 000000000000..db610ee157d7 --- /dev/null +++ b/extensions/ollama/doctor-contract-api.ts @@ -0,0 +1 @@ +export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js"; diff --git a/extensions/ollama/src/config-compat.ts b/extensions/ollama/src/config-compat.ts new file mode 100644 index 000000000000..71b8e6d26040 --- /dev/null +++ b/extensions/ollama/src/config-compat.ts @@ -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; + message: string; + match: (value: unknown) => boolean; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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: [] }; +} diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index cab978d49134..b7e8a83dd167 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -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> } }; + }) => ({ + 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", () => { diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 69d56f9fe442..1385c1cfdfcb 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -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"); }