diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c173e777e4..7cfcbca6e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Changes + +- Ollama/onboard: populate the cloud-only model list from `ollama.com/api/tags` so `openclaw onboard` reflects the live cloud catalog instead of a static three-model seed; cap the discovered list at 500 and fall back to the previous hardcoded suggestions when ollama.com is unreachable or returns no models. (#68463) Thanks @BruceMacD. + ## 2026.4.20 ### Changes diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index 2805e00d639..46c5d95679a 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -114,6 +114,7 @@ describe("ollama setup", () => { it("puts suggested cloud model first in cloud mode", async () => { const prompter = createCloudPrompter(); + vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] })); const result = await promptAndConfigureOllama({ cfg: {}, env: {}, @@ -130,6 +131,7 @@ describe("ollama setup", () => { it("uses generic token flags for cloud-only setup", async () => { const prompter = createCloudPrompter(); + vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] })); const result = await promptAndConfigureOllama({ cfg: {}, @@ -189,7 +191,7 @@ describe("ollama setup", () => { it("cloud mode does not hit local Ollama endpoints", async () => { const prompter = createCloudPrompter(); - const fetchMock = vi.fn(); + const fetchMock = createOllamaFetchMock({ tags: [] }); vi.stubGlobal("fetch", fetchMock); await promptAndConfigureOllama({ @@ -199,7 +201,12 @@ describe("ollama setup", () => { allowSecretRefPrompt: false, }); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("127.0.0.1"))).toBe( + false, + ); + expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("ollama.com"))).toBe( + true, + ); }); it("rejects the local marker during cloud-only setup", async () => { @@ -250,6 +257,7 @@ describe("ollama setup", () => { }), note: vi.fn(async () => undefined), } as unknown as WizardPrompter; + vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] })); await promptAndConfigureOllama({ cfg: {}, @@ -315,8 +323,9 @@ describe("ollama setup", () => { ); }); - it("cloud mode seeds the hosted cloud model list", async () => { + it("cloud mode falls back to the hardcoded cloud model list when /api/tags is empty", async () => { const prompter = createCloudPrompter(); + vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] })); const result = await promptAndConfigureOllama({ cfg: {}, env: {}, @@ -333,6 +342,36 @@ describe("ollama setup", () => { ]); }); + it("cloud mode populates models from ollama.com /api/tags when reachable", async () => { + const prompter = createCloudPrompter(); + const fetchMock = createOllamaFetchMock({ + tags: ["qwen3-coder:480b-cloud", "gpt-oss:120b-cloud"], + show: { "qwen3-coder:480b-cloud": 262144 }, + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await promptAndConfigureOllama({ + cfg: {}, + env: {}, + prompter, + allowSecretRefPrompt: false, + }); + const models = result.config.models?.providers?.ollama?.models; + const modelIds = models?.map((m) => m.id); + + expect(modelIds).toEqual([ + "kimi-k2.5:cloud", + "minimax-m2.7:cloud", + "glm-5.1:cloud", + "qwen3-coder:480b-cloud", + "gpt-oss:120b-cloud", + ]); + expect(models?.find((m) => m.id === "qwen3-coder:480b-cloud")?.contextWindow).toBe(262144); + expect( + fetchMock.mock.calls.some((call) => requestUrl(call[0]) === "https://ollama.com/api/tags"), + ).toBe(true); + }); + it("uses /api/show context windows when building Ollama model configs", async () => { const prompter = { text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index 779fd2706c5..e1a866f8459 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -40,6 +40,7 @@ export { buildOllamaProvider }; const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL]; const OLLAMA_SUGGESTED_MODELS_CLOUD = ["kimi-k2.5:cloud", "minimax-m2.7:cloud", "glm-5.1:cloud"]; const OLLAMA_CONTEXT_ENRICH_LIMIT = 200; +const OLLAMA_CLOUD_MAX_DISCOVERED_MODELS = 500; type OllamaSetupOptions = { customBaseUrl?: string; @@ -499,14 +500,30 @@ export async function promptAndConfigureOllama(params: { secretInputMode: params.secretInputMode, allowSecretRefPrompt: params.allowSecretRefPrompt, }); + const { reachable, models: rawDiscoveredModels } = + await fetchOllamaModels(OLLAMA_CLOUD_BASE_URL); + const discoveredModels = rawDiscoveredModels.slice(0, OLLAMA_CLOUD_MAX_DISCOVERED_MODELS); + const enrichedModels = + reachable && discoveredModels.length > 0 + ? await enrichOllamaModelsWithContext( + OLLAMA_CLOUD_BASE_URL, + discoveredModels.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT), + ) + : []; + const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model])); + const discoveredModelNames = discoveredModels.map((model) => model.name); + const modelNames = + discoveredModelNames.length > 0 + ? mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_CLOUD, discoveredModelNames) + : OLLAMA_SUGGESTED_MODELS_CLOUD; return { credential, credentialMode, config: applyOllamaProviderConfig( params.cfg, OLLAMA_CLOUD_BASE_URL, - OLLAMA_SUGGESTED_MODELS_CLOUD, - undefined, + modelNames, + discoveredModelsByName, credential, ), };