From feb529fd2bed0dba53ea6ff66f6e7795c1d6c14a Mon Sep 17 00:00:00 2001 From: WhatsSkiLL Date: Wed, 22 Jul 2026 07:45:03 +0200 Subject: [PATCH] fix(ollama): preserve cloud model context windows (#112430) * fix(ollama): preserve cloud model context windows * fix(ollama): recognize cloud model source forms Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> --------- Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> Co-authored-by: Peter Steinberger --- extensions/ollama/src/provider-models.test.ts | 21 +++++++++++ extensions/ollama/src/provider-models.ts | 33 ++++++++++++++++- extensions/ollama/src/setup.test.ts | 37 ++++++++++--------- extensions/ollama/src/setup.ts | 10 +---- 4 files changed, 74 insertions(+), 27 deletions(-) diff --git a/extensions/ollama/src/provider-models.test.ts b/extensions/ollama/src/provider-models.test.ts index 150c01253ccd..be6e9667ccc3 100644 --- a/extensions/ollama/src/provider-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -7,6 +7,7 @@ import { buildOllamaModelDefinition, capLocalOllamaProviderContext, enrichOllamaModelsWithContext, + isOllamaCloudModel, fetchOllamaModels, queryOllamaModelShowInfo, resolveOllamaApiBase, @@ -30,13 +31,33 @@ describe("ollama provider models", () => { models: [ buildOllamaModelDefinition("qwen3.5:4b", 262_144), buildOllamaModelDefinition("small", 16_384), + buildOllamaModelDefinition("glm-5.2:cloud", 1_000_000), + buildOllamaModelDefinition("gpt-oss:120b-cloud", 131_072), + buildOllamaModelDefinition("local-cloud", 65_536), ], }); expect(provider.models).toEqual([ expect.objectContaining({ contextWindow: 262_144, contextTokens: 32_768 }), expect.objectContaining({ contextWindow: 16_384, contextTokens: 16_384 }), + expect.objectContaining({ id: "glm-5.2:cloud", contextWindow: 1_000_000 }), + expect.objectContaining({ id: "gpt-oss:120b-cloud", contextWindow: 131_072 }), + expect.objectContaining({ id: "local-cloud", contextTokens: 32_768 }), ]); + expect(provider.models?.[2]).not.toHaveProperty("contextTokens"); + expect(provider.models?.[3]).not.toHaveProperty("contextTokens"); + }); + + it.each([ + ["glm-5.2:cloud", true], + ["gpt-oss:120b-cloud", true], + ["local-cloud", false], + ["invalid:cloud-cloud", false], + ["invalid:local:cloud", false], + ["invalid:local-cloud", false], + ["invalid:cloud:local", false], + ])("classifies Ollama model source %s", (modelId, expected) => { + expect(isOllamaCloudModel(modelId)).toBe(expected); }); it("sets discovered models with context windows from /api/show", async () => { diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index 93d86285c9a3..07e652824192 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -256,6 +256,37 @@ export async function enrichOllamaModelsWithContext( return enriched; } +type OllamaModelSource = "cloud" | "local"; + +function parseOllamaModelSourceSuffix( + modelName: string, +): { base: string; source: OllamaModelSource } | undefined { + const sourceSeparator = modelName.lastIndexOf(":"); + if (sourceSeparator < 0) { + return undefined; + } + const source = modelName.slice(sourceSeparator + 1); + if (source === "cloud" || source === "local") { + return { base: modelName.slice(0, sourceSeparator), source }; + } + if (!source.includes("/") && source.endsWith("-cloud")) { + return { + base: modelName.slice(0, sourceSeparator + 1) + source.slice(0, -"-cloud".length), + source: "cloud", + }; + } + return undefined; +} + +export function isOllamaCloudModel(modelName: string | undefined): boolean { + const normalized = modelName?.trim().toLowerCase(); + if (!normalized) { + return false; + } + const parsed = parseOllamaModelSourceSuffix(normalized); + return parsed?.source === "cloud" && parseOllamaModelSourceSuffix(parsed.base) === undefined; +} + export function isReasoningModelHeuristic(modelId: string): boolean { return /r1|reasoning|think|reason/i.test(modelId); } @@ -304,7 +335,7 @@ export function buildOllamaModelDefinition( } export function capLocalOllamaModelContext(model: ModelDefinitionConfig): ModelDefinitionConfig { - if (typeof model.contextWindow !== "number") { + if (isOllamaCloudModel(model.id) || typeof model.contextWindow !== "number") { return model; } return { diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index bd7aa0b5c033..5a257e5d891a 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -986,26 +986,27 @@ describe("ollama setup", () => { expect(runtime.log).toHaveBeenCalledWith("Default Ollama model: gemma4:latest"); }); - it("accepts cloud models in non-interactive mode without pulling", async () => { - const fetchMock = createOllamaFetchMock({ tags: [] }); - vi.stubGlobal("fetch", fetchMock); - const runtime = createRuntime(); + it.each(["kimi-k2.5:cloud", "gpt-oss:120b-cloud"])( + "accepts cloud model %s in non-interactive mode without pulling", + async (modelId) => { + const fetchMock = createOllamaFetchMock({ tags: [] }); + vi.stubGlobal("fetch", fetchMock); + const runtime = createRuntime(); - const result = await configureOllamaNonInteractive({ - nextConfig: {}, - opts: { - customBaseUrl: "http://127.0.0.1:11434", - customModelId: "kimi-k2.5:cloud", - }, - runtime, - }); + const result = await configureOllamaNonInteractive({ + nextConfig: {}, + opts: { + customBaseUrl: "http://127.0.0.1:11434", + customModelId: modelId, + }, + runtime, + }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toContain( - "kimi-k2.5:cloud", - ); - expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/kimi-k2.5:cloud" }); - }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toContain(modelId); + expect(result.agents?.defaults?.model).toEqual({ primary: `ollama/${modelId}` }); + }, + ); it("exits when Ollama is unreachable", async () => { const fetchMock = createOllamaFetchMock({ diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index f677d3a35e76..aedf0c57336a 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -19,10 +19,7 @@ import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onbo import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_DEFAULT_MODELS, @@ -37,6 +34,7 @@ import { buildOllamaModelDefinition, enrichOllamaModelsWithContext, fetchOllamaModels, + isOllamaCloudModel, resolveOllamaApiBase, type OllamaModelWithContext, } from "./provider-models.js"; @@ -121,10 +119,6 @@ function normalizeOllamaModelName(value: string | undefined): string | undefined return trimmed; } -function isOllamaCloudModel(modelName: string | undefined): boolean { - return normalizeOptionalLowercaseString(modelName)?.endsWith(":cloud") === true; -} - function formatOllamaPullStatus(status: string): { text: string; hidePercent: boolean } { const trimmed = status.trim(); const partStatusMatch = trimmed.match(/^([a-z-]+)\s+(?:sha256:)?[a-f0-9]{8,}$/i);