fix(ollama): enable cloud max thinking (#121074)

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
This commit is contained in:
Vito Cappello
2026-08-10 22:06:54 -04:00
committed by GitHub
parent 498433a58a
commit 9320bd379e
10 changed files with 297 additions and 108 deletions
+4 -2
View File
@@ -1120,8 +1120,10 @@ For full setup and behavior, see [Ollama Web Search](/tools/ollama-search).
For native requests, thinking control is forwarded directly: `/think off`
and `openclaw agent --thinking off` send top-level `think: false` unless
an explicit `params.think`/`params.thinking` is configured; `/think
low|medium|high` send the matching effort string; `/think max` maps to
Ollama's highest effort, `think: "high"`.
low|medium|high` send the matching effort string. Verified full-effort
Ollama Cloud families such as GLM 5.2 and DeepSeek V4 also send native
`think: "max"` for `/think max`; other models and local servers keep the
compatible `think: "high"` mapping.
<Tip>
For the OpenAI-compatible endpoint instead, see "Legacy OpenAI-compatible mode" above — streaming and tool calling may not work together there.
+1 -1
View File
@@ -29,7 +29,7 @@ title: "Thinking levels"
- Anthropic Claude Opus 4.7+ also exposes `/think max`; it maps to the same provider-owned max effort path.
- Direct DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`.
- OpenRouter-routed DeepSeek V4 models expose `/think xhigh` and send OpenRouter-supported `reasoning.effort` values instead of DeepSeek-native top-level `reasoning_effort`. Lower non-off levels map to `high`, and stored `max` overrides fall back to `xhigh`.
- Ollama thinking-capable models expose `/think low|medium|high|max`; `max` maps to native `think: "high"` because Ollama's native API accepts `low`, `medium`, and `high` effort strings.
- Ollama thinking-capable models expose `/think low|medium|high|max`. Verified full-effort Ollama Cloud families such as GLM 5.2 and DeepSeek V4 send each matching native `think` effort, including `max`; other models and local Ollama keep the compatible `high` mapping for `/think max`.
- OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value.
- GPT-5.6 Sol and Terra expose native `/think ultra` through the Codex runtime. GPT-5.6 Luna exposes levels through `max` because its Codex catalog does not advertise Ultra.
- The embedded OpenClaw runtime exposes logical `/think ultra` for GPT-5.6 Sol, Terra, and Luna. It sends provider max effort and adds run-scoped proactive sub-agent orchestration guidance.
+29 -15
View File
@@ -238,8 +238,12 @@ async function augmentOllamaCatalog(
function captureWrappedOllamaPayload(
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "max" | undefined,
route: { provider?: string; modelId?: string; baseUrl?: string } = {},
) {
const provider = registerProvider();
const providerId = route.provider ?? "ollama";
const modelId = route.modelId ?? "qwen3.5:9b";
const baseUrl = route.baseUrl ?? "http://127.0.0.1:11434";
let payloadSeen: Record<string, unknown> | undefined;
const baseStreamFn = vi.fn((_model, _context, options) => {
const payload: Record<string, unknown> = {
@@ -256,22 +260,22 @@ function captureWrappedOllamaPayload(
config: {
models: {
providers: {
ollama: {
[providerId]: {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
baseUrl,
models: [],
},
},
},
},
provider: "ollama",
modelId: "qwen3.5:9b",
provider: providerId,
modelId,
thinkingLevel,
model: {
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
baseUrl: "http://127.0.0.1:11434",
provider: providerId,
id: modelId,
baseUrl,
contextWindow: 131_072,
},
streamFn: baseStreamFn,
@@ -283,8 +287,8 @@ function captureWrappedOllamaPayload(
void wrapped(
{
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
provider: providerId,
id: modelId,
} as never,
{} as never,
{},
@@ -2422,13 +2426,13 @@ describe("ollama plugin", () => {
thinkingLevel: "off" as const,
expectedThink: false,
},
...(["low", "medium", "high"] as const).map((thinkingLevel) => ({
name: `preserves native Ollama ${thinkingLevel} thinking on the wire`,
thinkingLevel,
expectedThink: thinkingLevel,
})),
{
name: "wraps native Ollama payloads with top-level think effort when thinking is enabled",
thinkingLevel: "low" as const,
expectedThink: "low",
},
{
name: "maps native Ollama max thinking to the highest supported wire effort",
name: "keeps the compatible local Ollama max mapping",
thinkingLevel: "max" as const,
expectedThink: "high",
},
@@ -2444,6 +2448,16 @@ describe("ollama plugin", () => {
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.think).toBeUndefined();
});
it("preserves native Ollama Cloud max thinking on the wire", () => {
const { payloadSeen } = captureWrappedOllamaPayload("max", {
provider: "ollama-cloud",
modelId: "glm-5.2",
baseUrl: "https://ollama.com",
});
expect(payloadSeen?.think).toBe("max");
});
it("keeps native Ollama thinking off by default while exposing opt-in effort levels", () => {
const provider = registerProvider();
+51 -3
View File
@@ -106,14 +106,62 @@ describe("ollama provider policy public artifact", () => {
).toBeUndefined();
});
it("exposes max thinking for reasoning-capable models without full plugin activation", () => {
expect(resolveThinkingProfile({ reasoning: true })).toEqual({
it("exposes every native effort for reasoning-capable models without full plugin activation", () => {
expect(
resolveThinkingProfile({ provider: "ollama", modelId: "qwen3:32b", reasoning: true }),
).toEqual({
levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }, { id: "max" }],
defaultLevel: "off",
});
expect(resolveThinkingProfile({ reasoning: false })).toEqual({
expect(
resolveThinkingProfile({ provider: "ollama", modelId: "llama3.2", reasoning: false }),
).toEqual({
levels: [{ id: "off" }],
defaultLevel: "off",
});
});
it.each(["glm-5.2", "deepseek-v4-pro:cloud"])(
"exposes full native effort for cloud model %s when lightweight projections omit metadata",
(modelId) => {
expect(resolveThinkingProfile({ provider: "ollama-cloud", modelId }).levels).toEqual([
{ id: "off" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
{ id: "max" },
]);
},
);
it.each(["minimax-m2.7", "glm-5.1", "kimi-k2.5", "custom-thinking-model"])(
"does not invent effort levels for catalog-light cloud model %s",
(modelId) => {
expect(resolveThinkingProfile({ provider: "ollama-cloud", modelId }).levels).toEqual([
{ id: "off" },
]);
},
);
it("keeps explicit non-reasoning metadata authoritative for known cloud model ids", () => {
expect(
resolveThinkingProfile({
provider: "ollama-cloud",
modelId: "glm-5.2",
reasoning: false,
}).levels,
).toEqual([{ id: "off" }]);
});
it("does not infer thinking support for unknown models without catalog metadata", () => {
expect(resolveThinkingProfile({ provider: "ollama", modelId: "llama3.2" }).levels).toEqual([
{ id: "off" },
]);
});
it("does not apply cloud catalog facts to an unqualified local model", () => {
expect(resolveThinkingProfile({ provider: "ollama", modelId: "glm-5.2" }).levels).toEqual([
{ id: "off" },
]);
});
});
+14 -4
View File
@@ -1,10 +1,13 @@
// Ollama API module exposes the plugin public contract.
import type {
ProviderDefaultThinkingPolicyContext,
ProviderNormalizeResolvedModelContext,
ProviderThinkingProfile,
} from "openclaw/plugin-sdk/plugin-entry";
import { isCloudModelRef, normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
import { OLLAMA_CLOUD_PROVIDER_ID, OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
import { supportsOllamaCloudFullThinkingEffort } from "./src/model-reasoning.js";
type OllamaProviderConfigDraft = Partial<ModelProviderConfig>;
@@ -64,9 +67,16 @@ export function projectConfiguredModelRow(ctx: ProviderNormalizeResolvedModelCon
}
export function resolveThinkingProfile({
modelId,
provider,
reasoning,
}: {
reasoning?: boolean;
}): ProviderThinkingProfile {
return reasoning ? OLLAMA_REASONING_THINKING_PROFILE : OLLAMA_NON_REASONING_THINKING_PROFILE;
}: ProviderDefaultThinkingPolicyContext): ProviderThinkingProfile {
const isCloudRoute =
normalizeProviderId(provider) === OLLAMA_CLOUD_PROVIDER_ID || isCloudModelRef(modelId);
const supportsThinking =
reasoning === true ||
(reasoning === undefined && isCloudRoute && supportsOllamaCloudFullThinkingEffort(modelId));
return supportsThinking
? OLLAMA_REASONING_THINKING_PROFILE
: OLLAMA_NON_REASONING_THINKING_PROFILE;
}
+14
View File
@@ -0,0 +1,14 @@
// Ollama plugin module owns model-specific native thinking contracts.
export function supportsOllamaCloudFullThinkingEffort(modelId: string): boolean {
// These hosted families accept low, medium, high, and max even when
// lightweight catalog projections omit their reasoning metadata.
const normalized = normalizeOllamaCloudModelId(modelId);
return normalized === "glm-5.2" || /^deepseek-v4-(?:flash|pro)$/.test(normalized);
}
function normalizeOllamaCloudModelId(modelId: string): string {
return modelId
.trim()
.toLowerCase()
.replace(/(?::cloud|-cloud)$/, "");
}
+2 -10
View File
@@ -16,6 +16,7 @@ import {
OLLAMA_DEFAULT_MAX_TOKENS,
OLLAMA_LOCAL_CONTEXT_TOKENS,
} from "./defaults.js";
import { supportsOllamaCloudFullThinkingEffort } from "./model-reasoning.js";
export type OllamaTagModel = {
name: string;
@@ -343,15 +344,6 @@ export function isReasoningModelHeuristic(modelId: string): boolean {
return /r1|reasoning|think|reason/i.test(modelId);
}
function isKnownOllamaCloudReasoningModel(modelId: string): boolean {
// Match both the canonical direct-host id and the local `:cloud` routing alias.
const normalized = modelId
.trim()
.toLowerCase()
.replace(/:cloud$/, "");
return normalized === "glm-5.2" || /^deepseek-v4-(?:flash|pro)$/.test(normalized);
}
export function buildOllamaModelDefinition(
modelId: string,
contextWindow?: number,
@@ -361,7 +353,7 @@ export function buildOllamaModelDefinition(
const hasVision = capabilities?.includes("vision") ?? false;
const input: ("text" | "image")[] = hasVision ? ["text", "image"] : ["text"];
const reasoning =
isKnownOllamaCloudReasoningModel(modelId) ||
supportsOllamaCloudFullThinkingEffort(modelId) ||
(capabilities === undefined
? isReasoningModelHeuristic(modelId)
: capabilities.includes("thinking"));
+44 -26
View File
@@ -15,8 +15,9 @@ import {
} from "openclaw/plugin-sdk/provider-stream-shared";
import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js";
import { supportsOllamaCloudFullThinkingEffort } from "./model-reasoning.js";
export type OllamaThinkValue = boolean | "low" | "medium" | "high";
export type OllamaThinkValue = boolean | "low" | "medium" | "high" | "max";
export function resolveConfiguredOllamaProviderConfig(params: {
config?: OpenClawConfig;
@@ -114,42 +115,56 @@ function createOllamaThinkingWrapper(
});
}
function resolveOllamaThinkValue(thinkingLevel: unknown): OllamaThinkValue | undefined {
if (thinkingLevel === "off") {
function normalizeOllamaThinkValue(
value: unknown,
nativeMax: boolean,
): OllamaThinkValue | undefined {
if (typeof value === "boolean") {
return value;
}
if (value === "off") {
return false;
}
if (thinkingLevel === "low" || thinkingLevel === "medium" || thinkingLevel === "high") {
return thinkingLevel;
if (value === "low" || value === "medium" || value === "high") {
return value;
}
if (thinkingLevel === "minimal") {
if (value === "max") {
// Verified full-effort Cloud families accept native max. Keep the shipped
// high fallback for local and model-specific contracts without that tier.
return nativeMax ? "max" : "high";
}
if (value === "minimal") {
return "low";
}
if (thinkingLevel === "xhigh" || thinkingLevel === "adaptive" || thinkingLevel === "max") {
if (value === "xhigh" || value === "adaptive") {
// These OpenClaw-only tiers are not advertised by Ollama; keep their established high mapping.
return "high";
}
return undefined;
}
function resolveOllamaThinkValue(
thinkingLevel: unknown,
nativeMax: boolean,
): OllamaThinkValue | undefined {
return normalizeOllamaThinkValue(thinkingLevel, nativeMax);
}
export function resolveOllamaThinkParamValue(
params: Record<string, unknown> | undefined,
nativeMax = false,
): OllamaThinkValue | undefined {
const raw = params?.think ?? params?.thinking;
if (typeof raw === "boolean") {
return raw;
}
if (raw === "off") {
return false;
}
if (raw === "low" || raw === "medium" || raw === "high") {
return raw;
}
if (raw === "minimal") {
return "low";
}
if (raw === "xhigh" || raw === "adaptive" || raw === "max") {
return "high";
}
return undefined;
return normalizeOllamaThinkValue(params?.think ?? params?.thinking, nativeMax);
}
export function supportsNativeOllamaMax(
model: Pick<ProviderRuntimeModel, "id" | "provider"> | undefined,
providerId?: string,
): boolean {
const isCloudProvider =
normalizeProviderId(model?.provider ?? "") === "ollama-cloud" ||
normalizeProviderId(providerId ?? "") === "ollama-cloud";
return isCloudProvider && supportsOllamaCloudFullThinkingEffort(model?.id ?? "");
}
export function shouldForwardNativeOllamaThink(
@@ -209,9 +224,12 @@ export function createConfiguredOllamaCompatStreamWrapper(
streamFn = wrapOllamaCompatNumCtx(streamFn, resolveOllamaNumCtx(model));
}
const configuredThinkValue = model ? resolveOllamaThinkParamValue(model.params) : undefined;
const nativeMax = supportsNativeOllamaMax(model, ctx.provider);
const configuredThinkValue = model
? resolveOllamaThinkParamValue(model.params, nativeMax)
: undefined;
const runtimeThinkValue = isNativeOllamaTransport
? resolveOllamaThinkValue(ctx.thinkingLevel)
? resolveOllamaThinkValue(ctx.thinkingLevel, nativeMax)
: undefined;
// "off" is also the implicit agent default. Preserve explicit native Ollama
// model config unless the active run requests a non-off thinking level.
+136 -46
View File
@@ -320,7 +320,16 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
expect(payload.options).toEqual({ num_ctx: 131072 });
});
it.each([
it.each<{
name: string;
id: string;
contextWindow: number;
provider?: string;
reasoning?: boolean;
thinkingLevel: string;
params?: Record<string, unknown>;
expectedThink: boolean | string | undefined;
}>([
{
name: "forwards think=false on native Ollama chat requests when thinking is off",
id: "qwen3:32b",
@@ -353,57 +362,99 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
thinkingLevel: "low",
expectedThink: undefined,
},
{
name: "forwards the native think effort on native Ollama chat requests when thinking is enabled",
id: "qwen3:32b",
...(["low", "medium", "high"] as const).map((thinkingLevel) => ({
name: `preserves native Ollama ${thinkingLevel} thinking on the wire`,
id: "gpt-oss:20b",
contextWindow: 131072,
thinkingLevel: "low",
expectedThink: "low",
},
thinkingLevel,
expectedThink: thinkingLevel,
})),
{
name: "maps native Ollama max thinking to think=high on the wire",
name: "keeps the compatible local Ollama max mapping",
id: "gpt-oss:20b",
contextWindow: 131072,
thinkingLevel: "max",
expectedThink: "high",
},
])("$name", async ({ id, contextWindow, reasoning, thinkingLevel, params, expectedThink }) => {
await withSuccessfulOllamaFetch(async (fetchMock) => {
const model = {
api: "ollama",
provider: "ollama",
id,
contextWindow,
...(reasoning === undefined ? {} : { reasoning }),
...(params ? { params } : {}),
};
const wrapped = expectDefined(
createConfiguredOllamaCompatStreamWrapper({
provider: "ollama",
modelId: id,
model,
streamFn: createOllamaStreamFn("http://ollama-host:11434"),
thinkingLevel,
} as never),
"wrapped Ollama stream function",
);
const stream = await Promise.resolve(
wrapped(
model as never,
{ messages: [{ role: "user", content: "hello" }] } as never,
{} as never,
),
);
await collectStreamEvents(stream);
{
name: "does not infer native max support from a local cloud model alias",
id: "glm-5.2:cloud",
contextWindow: 131072,
thinkingLevel: "max",
expectedThink: "high",
},
{
name: "preserves native Ollama Cloud max thinking on the wire",
id: "glm-5.2",
provider: "ollama-cloud",
contextWindow: 131072,
thinkingLevel: "max",
expectedThink: "max",
},
{
name: "keeps the high fallback for Ollama Cloud GPT-OSS",
id: "gpt-oss:120b",
provider: "ollama-cloud",
contextWindow: 131072,
thinkingLevel: "max",
expectedThink: "high",
},
{
name: "keeps the high fallback for Cloud models without a verified max tier",
id: "kimi-k2.5",
provider: "ollama-cloud",
contextWindow: 131072,
thinkingLevel: "max",
expectedThink: "high",
},
])(
"$name",
async ({
id,
provider = "ollama",
contextWindow,
reasoning,
thinkingLevel,
params,
expectedThink,
}) => {
await withSuccessfulOllamaFetch(async (fetchMock) => {
const model = {
api: "ollama",
provider,
id,
contextWindow,
...(reasoning === undefined ? {} : { reasoning }),
...(params ? { params } : {}),
};
const wrapped = expectDefined(
createConfiguredOllamaCompatStreamWrapper({
provider,
modelId: id,
model,
streamFn: createOllamaStreamFn("http://ollama-host:11434"),
thinkingLevel,
} as never),
"wrapped Ollama stream function",
);
const stream = await Promise.resolve(
wrapped(
model as never,
{ messages: [{ role: "user", content: "hello" }] } as never,
{} as never,
),
);
await collectStreamEvents(stream);
const requestBody = getGuardedFetchJsonBody(fetchMock);
expect(requestBody.think).toBe(expectedThink);
expect(requireOptionalRecord(requestBody.options)?.think).toBeUndefined();
if (reasoning !== false) {
expect(requireOptionalRecord(requestBody.options)?.num_ctx).toBeUndefined();
}
});
});
const requestBody = getGuardedFetchJsonBody(fetchMock);
expect(requestBody.think).toBe(expectedThink);
expect(requireOptionalRecord(requestBody.options)?.think).toBeUndefined();
if (reasoning !== false) {
expect(requireOptionalRecord(requestBody.options)?.num_ctx).toBeUndefined();
}
});
},
);
it("passes resolved provider request timeouts to native Ollama chat fetches", async () => {
await withMockNdjsonFetch(
@@ -2645,16 +2696,55 @@ describe("createOllamaStreamFn", () => {
);
});
it("maps configured native Ollama params.thinking=max to the stable top-level think value", async () => {
it.each(["low", "medium", "high"] as const)(
"preserves configured native Ollama params.thinking=%s",
async (thinking) => {
await expectSuccessfulOllamaRequest(
{ baseUrl: "http://ollama-host:11434", model: { params: { thinking } } },
({ body }) => {
expect(body.think).toBe(thinking);
expect(requireOptionalRecord(body.options)?.think).toBeUndefined();
},
);
},
);
it("keeps configured local Ollama params.thinking=max compatible", async () => {
await expectSuccessfulOllamaRequest(
{ baseUrl: "http://ollama-host:11434", model: { params: { thinking: "max" } } },
({ body }) => {
expect(body.think).toBe("high");
expect(requireOptionalRecord(body.options)?.think).toBeUndefined();
},
);
});
it("preserves configured Ollama Cloud params.thinking=max", async () => {
await expectSuccessfulOllamaRequest(
{
baseUrl: "https://ollama.com",
model: { provider: "ollama-cloud", id: "glm-5.2", params: { thinking: "max" } },
},
({ body }) => {
expect(body.think).toBe("max");
},
);
});
it.each(["gpt-oss:120b", "kimi-k2.5", "custom-thinking-model"])(
"keeps configured Ollama Cloud %s params.thinking=max compatible",
async (id) => {
await expectSuccessfulOllamaRequest(
{
baseUrl: "https://ollama.com",
model: { provider: "ollama-cloud", id, params: { thinking: "max" } },
},
({ body }) => {
expect(body.think).toBe("high");
},
);
},
);
it("uses the default loopback policy when baseUrl is empty", async () => {
await expectSuccessfulOllamaRequest({ baseUrl: "" }, ({ request }) => {
expect(request.url).toBe("http://127.0.0.1:11434/api/chat");
+2 -1
View File
@@ -35,6 +35,7 @@ import {
type OllamaThinkValue,
resolveOllamaConfiguredNumCtx,
resolveOllamaThinkParamValue,
supportsNativeOllamaMax,
shouldForwardNativeOllamaThink,
} from "./stream-compat.js";
import { OLLAMA_INCOMPLETE_STREAM_ERROR } from "./stream-contract.js";
@@ -245,7 +246,7 @@ function resolveOllamaTopLevelParams(
}
}
}
const think = resolveOllamaThinkParamValue(params);
const think = resolveOllamaThinkParamValue(params, supportsNativeOllamaMax(model));
if (think !== undefined && shouldForwardNativeOllamaThink(model, think)) {
requestParams.think = think;
}