From ac404b26487c2f578fa318f803524054065ce46a Mon Sep 17 00:00:00 2001 From: openperf <16864032@qq.com> Date: Sun, 3 May 2026 09:24:06 +0800 Subject: [PATCH] fix(ollama): restore catalog-driven num_ctx for native /api/chat --- extensions/ollama/src/stream-runtime.test.ts | 64 ++++++++++++++++++-- extensions/ollama/src/stream.ts | 30 ++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index a5176643e96..5adbaded8bb 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -208,7 +208,7 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => { }; expect(requestBody.think).toBe(false); expect(requestBody.options?.think).toBeUndefined(); - expect(requestBody.options?.num_ctx).toBeUndefined(); + expect(requestBody.options?.num_ctx).toBe(131072); }, ); }); @@ -310,7 +310,7 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => { }; expect(requestBody.think).toBe("low"); expect(requestBody.options?.think).toBeUndefined(); - expect(requestBody.options?.num_ctx).toBeUndefined(); + expect(requestBody.options?.num_ctx).toBe(131072); }, ); }); @@ -405,7 +405,7 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => { }; expect(requestBody.think).toBe("high"); expect(requestBody.options?.think).toBeUndefined(); - expect(requestBody.options?.num_ctx).toBeUndefined(); + expect(requestBody.options?.num_ctx).toBe(131072); }, ); }); @@ -1602,7 +1602,9 @@ describe("createOllamaStreamFn", () => { if (!requestBody.options) { throw new Error("Expected Ollama request options"); } - expect(requestBody.options?.num_ctx).toBeUndefined(); + // Catalog `contextWindow` flows through as `num_ctx` so the request + // does not silently truncate to Ollama's small Modelfile default. + expect(requestBody.options?.num_ctx).toBe(131072); expect(requestBody.options.num_predict).toBe(123); }, ); @@ -1657,6 +1659,60 @@ describe("createOllamaStreamFn", () => { ); }); + it("omits num_ctx when the model has no params.num_ctx and no catalog window", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', + ], + async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + // Override the helper default contextWindow back to undefined so the + // request body should leave Ollama's Modelfile to decide num_ctx. + model: { contextWindow: undefined }, + }); + + await collectStreamEvents(stream); + + const requestInit = getGuardedFetchCall(fetchMock).init ?? {}; + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } + const requestBody = JSON.parse(requestInit.body) as { + options?: { num_ctx?: number }; + }; + expect(requestBody.options?.num_ctx).toBeUndefined(); + }, + ); + }); + + it("falls back to catalog contextWindow as num_ctx when params.num_ctx is unset", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', + ], + async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { contextWindow: 32768 }, + }); + + await collectStreamEvents(stream); + + const requestInit = getGuardedFetchCall(fetchMock).init ?? {}; + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } + const requestBody = JSON.parse(requestInit.body) as { + options?: { num_ctx?: number }; + }; + expect(requestBody.options?.num_ctx).toBe(32768); + }, + ); + }); + it("maps configured native Ollama params.thinking=max to the stable top-level think value", async () => { await withMockNdjsonFetch( [ diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index e6348e736b1..440fc732f64 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -290,6 +290,34 @@ function resolveOllamaNumCtx(model: ProviderRuntimeModel): number { ); } +/** + * Resolves num_ctx for native /api/chat requests: + * 1. explicit `params.num_ctx` set on the model wins, + * 2. otherwise the catalog `contextWindow` / `maxTokens` is forwarded so + * OpenClaw's known model windows survive the trip and `/api/chat` does + * not silently truncate to Ollama's small Modelfile default (typically + * 2048 tokens) — which is too small for a system prompt plus tool + * definitions and produces "model picks wrong tools / says nonsense" + * symptoms on agent turns, + * 3. when neither is known, return undefined so the Modelfile decides. + * + * This intentionally differs from `resolveOllamaNumCtx` by not falling back + * to `DEFAULT_CONTEXT_TOKENS`: that constant is a sane wrapper-side guess for + * the OpenAI-compat path, but on the native path we prefer to leave num_ctx + * absent rather than guess a window for an unknown model. + */ +function resolveOllamaNativeNumCtx(model: ProviderRuntimeModel): number | undefined { + const configured = resolveOllamaConfiguredNumCtx(model); + if (configured !== undefined) { + return configured; + } + const catalog = model.contextWindow ?? model.maxTokens; + if (typeof catalog === "number" && Number.isFinite(catalog) && catalog > 0) { + return Math.floor(catalog); + } + return undefined; +} + function resolveOllamaModelOptions(model: ProviderRuntimeModel): Record { const options: Record = {}; const params = model.params; @@ -303,7 +331,7 @@ function resolveOllamaModelOptions(model: ProviderRuntimeModel): Record