diff --git a/CHANGELOG.md b/CHANGELOG.md index cc28cad806f..2daf64f2fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Model selection: include the rejected provider/model ref and allowlist recovery hint when a stored session override is cleared, so local model selections such as Gemma GGUF variants do not fall back to the default with a generic message. Refs #71069. Thanks @CyberRaccoonTeam. - OpenAI-compatible providers: drop malformed event-only or blank-data SSE frames before the OpenAI SDK stream parser sees them, so proxies that split `event:` from `data:` no longer crash streaming runs with `Unexpected end of JSON input`. Fixes #52802. Thanks @LyHug. - Gateway/OpenAI-compatible streaming: strip `` tags split across streamed model deltas before they reach SSE clients, so `/v1/chat/completions` no longer emits tag remnants or drops content when final-answer wrappers cross chunk boundaries. Fixes #63325. Thanks @tzwickl. +- Ollama: resolve explicitly selected signed-in `:cloud` models through `/api/show` when `/api/tags` omits them, so working models such as `gemini-3-flash-preview:cloud` and `deepseek-v4-pro:cloud` do not fail dynamic model resolution before the native `/api/chat` transport runs. Fixes #73909. Thanks @chtse53. - Local model prompt caching: keep stable Project Context above volatile channel/session prompt guidance and stop embedding current channel names in the message tool description, so Ollama, MLX, llama.cpp, and other prefix-cache backends avoid avoidable full prompt reprocessing across channel turns. Fixes #40256; supersedes #40296. Thanks @rhclaw and @sriram369. - Gateway/OpenAI-compatible API: guard provider policy lookup against runtime providers with non-array `models` values, so `/v1/chat/completions` no longer fails with `provider?.models?.some is not a function`. Fixes #66744; carries forward #66761. Thanks @MightyMoud, @MukundaKatta. - WhatsApp/Web: pass explicit Baileys socket timings into every WhatsApp Web socket and expose `web.whatsapp.*` keepalive, connect, and query timeout settings so unstable networks can avoid repeated 408 disconnect and opening-handshake timeout loops. Fixes #56365. (#73580) Thanks @velvet-shark. diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index 321bdd53e6f..417bd99c240 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -192,6 +192,12 @@ When you set `OLLAMA_API_KEY` (or an auth profile) and **do not** define `models This avoids manual model entries while keeping the catalog aligned with the local Ollama instance. You can use a full ref such as `ollama/:latest` in local `infer model run`; OpenClaw resolves that installed model from Ollama's live catalog without requiring a hand-written `models.json` entry. +For signed-in Ollama hosts, some `:cloud` models may be usable through `/api/chat` +and `/api/show` before they appear in `/api/tags`. When you explicitly select a +full `ollama/:cloud` ref, OpenClaw validates that exact missing model with +`/api/show` and adds it to the runtime catalog only if Ollama confirms model +metadata. Typos still fail as unknown models instead of being auto-created. + ```bash # See what models are available ollama list diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 46db238f598..950b03daf7c 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -20,6 +20,21 @@ const promptAndConfigureOllamaMock = vi.hoisted(() => ); const ensureOllamaModelPulledMock = vi.hoisted(() => vi.fn(async () => {})); const buildOllamaProviderMock = vi.hoisted(() => vi.fn()); +const queryOllamaModelShowInfoMock = vi.hoisted(() => vi.fn()); +const buildOllamaModelDefinitionMock = vi.hoisted(() => + vi.fn((modelId: string, contextWindow?: number, capabilities?: string[]) => ({ + id: modelId, + name: modelId, + reasoning: capabilities?.includes("thinking") ?? false, + input: capabilities?.includes("vision") ? ["text", "image"] : ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: contextWindow ?? 8192, + maxTokens: 8192, + compat: capabilities + ? { supportsTools: capabilities.includes("tools"), supportsUsageInStreaming: true } + : { supportsUsageInStreaming: true }, + })), +); const createConfiguredOllamaStreamFnMock = vi.hoisted(() => vi.fn((_params: { model: unknown; providerBaseUrl?: string }) => ({}) as never), ); @@ -29,6 +44,8 @@ vi.mock("./api.js", () => ({ ensureOllamaModelPulled: ensureOllamaModelPulledMock, configureOllamaNonInteractive: vi.fn(), buildOllamaProvider: buildOllamaProviderMock, + queryOllamaModelShowInfo: queryOllamaModelShowInfoMock, + buildOllamaModelDefinition: buildOllamaModelDefinitionMock, })); vi.mock("./src/stream.js", async (importOriginal) => { @@ -43,6 +60,8 @@ beforeEach(() => { promptAndConfigureOllamaMock.mockClear(); ensureOllamaModelPulledMock.mockClear(); buildOllamaProviderMock.mockReset(); + queryOllamaModelShowInfoMock.mockReset(); + buildOllamaModelDefinitionMock.mockClear(); createConfiguredOllamaStreamFnMock.mockClear(); }); @@ -420,6 +439,102 @@ describe("ollama plugin", () => { } }); + it("resolves requested Ollama cloud models that are omitted from tags but confirmed by show", async () => { + const provider = registerProvider(); + const previous = process.env.OLLAMA_API_KEY; + process.env.OLLAMA_API_KEY = "ollama-local"; + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [ + { + id: "kimi-k2.5:cloud", + name: "kimi-k2.5:cloud", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 8192, + }, + ], + }); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1048576, + capabilities: ["completion", "tools", "thinking"], + }); + + try { + await provider.prepareDynamicModel?.({ + config: {}, + provider: "ollama", + modelId: "deepseek-v4-pro:cloud", + modelRegistry: { find: vi.fn(() => null) }, + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "deepseek-v4-pro:cloud", + ); + expect( + provider.resolveDynamicModel?.({ + config: {}, + provider: "ollama", + modelId: "deepseek-v4-pro:cloud", + modelRegistry: { find: vi.fn(() => null) }, + } as never), + ).toMatchObject({ + provider: "ollama", + id: "deepseek-v4-pro:cloud", + api: "ollama", + baseUrl: "http://127.0.0.1:11434", + reasoning: true, + compat: { supportsTools: true }, + }); + } finally { + if (previous === undefined) { + delete process.env.OLLAMA_API_KEY; + } else { + process.env.OLLAMA_API_KEY = previous; + } + } + }); + + it("keeps unknown requested Ollama models unresolved when show has no metadata", async () => { + const provider = registerProvider(); + const previous = process.env.OLLAMA_API_KEY; + process.env.OLLAMA_API_KEY = "ollama-local"; + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({}); + + try { + await provider.prepareDynamicModel?.({ + config: {}, + provider: "ollama", + modelId: "depseek-v4-pro:cloud", + modelRegistry: { find: vi.fn(() => null) }, + } as never); + + expect( + provider.resolveDynamicModel?.({ + config: {}, + provider: "ollama", + modelId: "depseek-v4-pro:cloud", + modelRegistry: { find: vi.fn(() => null) }, + } as never), + ).toBeUndefined(); + } finally { + if (previous === undefined) { + delete process.env.OLLAMA_API_KEY; + } else { + process.env.OLLAMA_API_KEY = previous; + } + } + }); + it("skips implicit localhost discovery when a custom remote Ollama provider is configured", async () => { const provider = registerProvider(); diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 46b55691e2b..1621f364388 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -19,10 +19,13 @@ import { OPENAI_COMPATIBLE_REPLAY_HOOKS, } from "openclaw/plugin-sdk/provider-model-shared"; import { + OLLAMA_DEFAULT_BASE_URL, + buildOllamaModelDefinition, buildOllamaProvider, configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, + queryOllamaModelShowInfo, } from "./api.js"; import { OLLAMA_DEFAULT_API_KEY, @@ -100,6 +103,29 @@ function toDynamicOllamaModel(params: { }; } +async function resolveRequestedDynamicOllamaModel(params: { + provider: string; + providerConfig: ModelProviderConfig; + modelId: string; +}): Promise { + const showInfo = await queryOllamaModelShowInfo( + readProviderBaseUrl(params.providerConfig) ?? OLLAMA_DEFAULT_BASE_URL, + params.modelId, + ); + if (typeof showInfo.contextWindow !== "number" && (showInfo.capabilities?.length ?? 0) === 0) { + return undefined; + } + return toDynamicOllamaModel({ + provider: params.provider, + providerConfig: params.providerConfig, + model: buildOllamaModelDefinition( + params.modelId, + showInfo.contextWindow, + showInfo.capabilities, + ), + }); +} + export default definePluginEntry({ id: "ollama", name: "Ollama Provider", @@ -268,16 +294,24 @@ export default definePluginEntry({ } const baseUrl = readProviderBaseUrl(providerConfig); const provider = await buildOllamaProvider(baseUrl, { quiet: true }); - dynamicModelCache.set( - buildDynamicCacheKey(ctx.provider, baseUrl), - (provider.models ?? []).map((model) => - toDynamicOllamaModel({ - provider: ctx.provider, - providerConfig: provider, - model, - }), - ), + const dynamicModels = (provider.models ?? []).map((model) => + toDynamicOllamaModel({ + provider: ctx.provider, + providerConfig: provider, + model, + }), ); + if (!dynamicModels.some((model) => model.id === ctx.modelId)) { + const requestedModel = await resolveRequestedDynamicOllamaModel({ + provider: ctx.provider, + providerConfig: provider, + modelId: ctx.modelId, + }); + if (requestedModel) { + dynamicModels.push(requestedModel); + } + } + dynamicModelCache.set(buildDynamicCacheKey(ctx.provider, baseUrl), dynamicModels); }, resolveDynamicModel: (ctx) => { const providerConfig = resolveConfiguredOllamaProviderConfig({