Skip to content

Commit db40ec4

Browse files
committed
fix: honor Ollama thinking catalog metadata
1 parent 67b16a4 commit db40ec4

32 files changed

Lines changed: 410 additions & 56 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah.
3131
- Gateway/Windows: route no-listener restart handoffs through the Windows supervisor without leaving restart tokens in flight, so failed task scheduling can be retried and successful handoffs do not coalesce later restart requests. (#69056) Thanks @Thatgfsj.
3232
- Gateway/model pricing: skip plugin manifest discovery during background pricing refreshes when `plugins.enabled: false`, so disabled-plugin setups do not keep rebuilding plugin metadata from the Gateway hot path. Fixes #73291. Thanks @slideshow-dingo and @fishgills.
33+
- Ollama/thinking: validate `/think` commands against live Ollama catalog reasoning metadata, so models whose `/api/show` capabilities include `thinking` expose `low`, `medium`, `high`, and `max` instead of being stuck on `off`. Fixes #73366. Thanks @cymise.
3334
- Gateway/sessions: remove automatic oversized `sessions.json` rotation backups, deprecate `session.maintenance.rotateBytes`, and teach `openclaw doctor --fix` to remove the ignored key so hot session writes no longer copy multi-MB stores. Refs #72338. Thanks @midhunmonachan and @DougButdorf.
3435
- Channels/Telegram: fail fast when Telegram rejects the startup `getMe` token probe with 401, so invalid or stale BotFather tokens are reported as token auth failures instead of misleading `deleteWebhook` cleanup failures. Fixes #47674. Thanks @samaedan-arch.
3536
- ACPX: keep generated Codex and Claude ACP wrapper startup paths working when remote or special state filesystems reject chmod, since OpenClaw invokes the wrappers through Node instead of executing them directly. Fixes #73333. Thanks @david-garcia-garcia.

docs/providers/ollama.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,14 @@ Choose your preferred setup method and mode.
181181

182182
When you set `OLLAMA_API_KEY` (or an auth profile) and **do not** define `models.providers.ollama` or another custom remote provider with `api: "ollama"`, OpenClaw discovers models from the local Ollama instance at `http://127.0.0.1:11434`.
183183

184-
| Behavior | Detail |
185-
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
186-
| Catalog query | Queries `/api/tags` |
187-
| Capability detection | Uses best-effort `/api/show` lookups to read `contextWindow`, expanded `num_ctx` Modelfile parameters, and capabilities including vision/tools |
188-
| Vision models | Models with a `vision` capability reported by `/api/show` are marked as image-capable (`input: ["text", "image"]`), so OpenClaw auto-injects images into the prompt |
189-
| Reasoning detection | Marks `reasoning` with a model-name heuristic (`r1`, `reasoning`, `think`) |
190-
| Token limits | Sets `maxTokens` to the default Ollama max-token cap used by OpenClaw |
191-
| Costs | Sets all costs to `0` |
184+
| Behavior | Detail |
185+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
186+
| Catalog query | Queries `/api/tags` |
187+
| Capability detection | Uses best-effort `/api/show` lookups to read `contextWindow`, expanded `num_ctx` Modelfile parameters, and capabilities including vision/tools |
188+
| Vision models | Models with a `vision` capability reported by `/api/show` are marked as image-capable (`input: ["text", "image"]`), so OpenClaw auto-injects images into the prompt |
189+
| Reasoning detection | Uses `/api/show` capabilities when available, including `thinking`; falls back to a model-name heuristic (`r1`, `reasoning`, `think`) when Ollama omits capabilities |
190+
| Token limits | Sets `maxTokens` to the default Ollama max-token cap used by OpenClaw |
191+
| Costs | Sets all costs to `0` |
192192

193193
This avoids manual model entries while keeping the catalog aligned with the local Ollama instance. You can use a full ref such as `ollama/<pulled-model>:latest` in local `infer model run`; OpenClaw resolves that installed model from Ollama's live catalog without requiring a hand-written `models.json` entry.
194194

@@ -836,7 +836,7 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
836836
</Accordion>
837837

838838
<Accordion title="Thinking control">
839-
For native Ollama models, OpenClaw forwards thinking control as Ollama expects it: top-level `think`, not `options.think`.
839+
For native Ollama models, OpenClaw forwards thinking control as Ollama expects it: top-level `think`, not `options.think`. Auto-discovered models whose `/api/show` response includes the `thinking` capability expose `/think low`, `/think medium`, `/think high`, and `/think max`; non-thinking models expose only `/think off`.
840840

841841
```bash
842842
openclaw agent --model ollama/gemma4 --thinking off

src/acp/translator.session-rate-limit.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type {
66
SetSessionModeRequest,
77
} from "@agentclientprotocol/sdk";
88
import { describe, expect, it, vi } from "vitest";
9-
import { listThinkingLevels } from "../auto-reply/thinking.js";
109
import type { GatewayClient } from "../gateway/client.js";
1110
import type { EventFrame } from "../gateway/protocol/index.js";
1211
import { createInMemorySessionStore } from "./session.js";
@@ -271,6 +270,11 @@ describe("acp session UX bridge behavior", () => {
271270
thinkingLevel: "high",
272271
modelProvider: "openai",
273272
model: "gpt-5.4",
273+
thinkingLevels: [
274+
{ id: "off", label: "off" },
275+
{ id: "medium", label: "medium" },
276+
{ id: "max", label: "max" },
277+
],
274278
verboseLevel: "full",
275279
reasoningLevel: "stream",
276280
responseUsage: "tokens",
@@ -307,9 +311,12 @@ describe("acp session UX bridge behavior", () => {
307311
const result = await agent.loadSession(createLoadSessionRequest("agent:main:work"));
308312

309313
expect(result.modes?.currentModeId).toBe("high");
310-
expect(result.modes?.availableModes.map((mode) => mode.id)).toEqual(
311-
listThinkingLevels("openai", "gpt-5.4"),
312-
);
314+
expect(result.modes?.availableModes.map((mode) => mode.id)).toEqual([
315+
"off",
316+
"medium",
317+
"max",
318+
"high",
319+
]);
313320
expect(result.configOptions).toEqual(
314321
expect.arrayContaining([
315322
expect.objectContaining({

src/acp/translator.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ type GatewaySessionPresentationRow = Pick<
117117
| "fastMode"
118118
| "modelProvider"
119119
| "model"
120+
| "thinkingLevels"
120121
| "verboseLevel"
121122
| "traceLevel"
122123
| "reasoningLevel"
@@ -247,7 +248,9 @@ function buildSessionPresentation(params: {
247248
...params.row,
248249
...params.overrides,
249250
};
250-
const availableLevelIds: string[] = [...listThinkingLevels(row.modelProvider, row.model)];
251+
const availableLevelIds: string[] = row.thinkingLevels?.map((level) => level.id) ?? [
252+
...listThinkingLevels(row.modelProvider, row.model),
253+
];
251254
const currentModeId = normalizeOptionalString(row.thinkingLevel) || "adaptive";
252255
if (!availableLevelIds.includes(currentModeId)) {
253256
availableLevelIds.push(currentModeId);
@@ -1268,6 +1271,7 @@ export class AcpGatewayAgent implements Agent {
12681271
derivedTitle: session.derivedTitle,
12691272
updatedAt: session.updatedAt,
12701273
thinkingLevel: session.thinkingLevel,
1274+
thinkingLevels: session.thinkingLevels,
12711275
modelProvider: session.modelProvider,
12721276
model: session.model,
12731277
fastMode: session.fastMode,

src/auto-reply/reply/directive-handling.fast-lane.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export async function applyInlineDirectivesFastLane(
7878
aliasIndex,
7979
allowedModelKeys,
8080
allowedModelCatalog,
81+
thinkingCatalog: await modelState.resolveThinkingCatalog(),
8182
resetModelOverride,
8283
provider,
8384
model,

src/auto-reply/reply/directive-handling.impl.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ export async function handleDirectiveOnly(
130130

131131
const resolvedProvider = modelSelection?.provider ?? provider;
132132
const resolvedModel = modelSelection?.model ?? model;
133+
const thinkingCatalog =
134+
params.thinkingCatalog && params.thinkingCatalog.length > 0
135+
? params.thinkingCatalog
136+
: allowedModelCatalog.length > 0
137+
? allowedModelCatalog
138+
: undefined;
133139
const fastModeState = resolveFastModeState({
134140
cfg: params.cfg,
135141
provider: resolvedProvider,
@@ -148,12 +154,12 @@ export async function handleDirectiveOnly(
148154
return {
149155
text: withOptions(
150156
`Current thinking level: ${level}.`,
151-
formatThinkingLevels(resolvedProvider, resolvedModel),
157+
formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog),
152158
),
153159
};
154160
}
155161
return {
156-
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: ${formatThinkingLevels(resolvedProvider, resolvedModel)}.`,
162+
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog)}.`,
157163
};
158164
}
159165
if (directives.hasVerboseDirective && !directives.verboseLevel) {
@@ -300,10 +306,11 @@ export async function handleDirectiveOnly(
300306
provider: resolvedProvider,
301307
model: resolvedModel,
302308
level: directives.thinkLevel,
309+
catalog: thinkingCatalog,
303310
})
304311
) {
305312
return {
306-
text: `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel)}.`,
313+
text: `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog)}.`,
307314
};
308315
}
309316

@@ -318,11 +325,13 @@ export async function handleDirectiveOnly(
318325
provider: resolvedProvider,
319326
model: resolvedModel,
320327
level: nextThinkLevel,
328+
catalog: thinkingCatalog,
321329
})
322330
? resolveSupportedThinkingLevel({
323331
provider: resolvedProvider,
324332
model: resolvedModel,
325333
level: nextThinkLevel,
334+
catalog: thinkingCatalog,
326335
})
327336
: undefined;
328337
const shouldRemapUnsupportedThinkLevel =

src/auto-reply/reply/directive-handling.mixed-inline.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ describe("mixed inline directives", () => {
8383
agentCfg: cfg.agents?.defaults,
8484
modelState: {
8585
resolveDefaultThinkingLevel: async () => "off",
86+
resolveThinkingCatalog: async () => [],
8687
allowedModelKeys: new Set(),
8788
allowedModelCatalog: [],
8889
resetModelOverride: false,
@@ -156,6 +157,7 @@ describe("mixed inline directives", () => {
156157
agentCfg: cfg.agents?.defaults,
157158
modelState: {
158159
resolveDefaultThinkingLevel: async () => "off",
160+
resolveThinkingCatalog: async () => [],
159161
allowedModelKeys: new Set(),
160162
allowedModelCatalog: [],
161163
resetModelOverride: false,

src/auto-reply/reply/directive-handling.model.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,54 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
931931
expect(result?.text).toContain("Options: off, minimal, low, medium, adaptive, high.");
932932
});
933933

934+
it("uses catalog reasoning metadata for provider-owned thinking levels", async () => {
935+
setDirectiveTestProviders([
936+
{
937+
id: "ollama",
938+
label: "Ollama",
939+
auth: [],
940+
resolveThinkingProfile: ({ reasoning }) => ({
941+
levels:
942+
reasoning === true
943+
? [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }, { id: "max" }]
944+
: [{ id: "off" }],
945+
defaultLevel: "off",
946+
}),
947+
},
948+
]);
949+
const sessionEntry = createSessionEntry();
950+
const sessionStore = { [sessionKey]: sessionEntry };
951+
952+
const result = await handleDirectiveOnly(
953+
createHandleParams({
954+
directives: parseInlineDirectives("/think medium"),
955+
provider: "ollama",
956+
model: "qwen3.6:35b-a3b-mxfp8",
957+
allowedModelCatalog: [
958+
{
959+
provider: "ollama",
960+
id: "qwen3.6:35b-a3b-mxfp8",
961+
name: "qwen3.6:35b-a3b-mxfp8",
962+
reasoning: true,
963+
},
964+
],
965+
thinkingCatalog: [
966+
{
967+
provider: "ollama",
968+
id: "qwen3.6:35b-a3b-mxfp8",
969+
name: "qwen3.6:35b-a3b-mxfp8",
970+
reasoning: true,
971+
},
972+
],
973+
sessionEntry,
974+
sessionStore,
975+
}),
976+
);
977+
978+
expect(result?.text).toContain("Thinking level set to medium.");
979+
expect(sessionEntry.thinkingLevel).toBe("medium");
980+
});
981+
934982
it("persists verbose on and off directives", async () => {
935983
const sessionEntry = createSessionEntry();
936984
const sessionStore = { [sessionKey]: sessionEntry };

src/auto-reply/reply/directive-handling.params.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
12
import type { ModelAliasIndex } from "../../agents/model-selection.js";
23
import type { SessionEntry } from "../../config/sessions.js";
34
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -23,6 +24,7 @@ export type HandleDirectiveOnlyCoreParams = {
2324
allowedModelCatalog: Awaited<
2425
ReturnType<typeof import("../../agents/model-catalog.js").loadModelCatalog>
2526
>;
27+
thinkingCatalog?: ModelCatalogEntry[];
2628
resetModelOverride: boolean;
2729
provider: string;
2830
model: string;
@@ -52,6 +54,7 @@ export type ApplyInlineDirectivesFastLaneParams = HandleDirectiveOnlyCoreParams
5254
agentCfg?: NonNullable<OpenClawConfig["agents"]>["defaults"];
5355
modelState: {
5456
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
57+
resolveThinkingCatalog: () => Promise<ModelCatalogEntry[] | undefined>;
5558
allowedModelKeys: Set<string>;
5659
allowedModelCatalog: Awaited<
5760
ReturnType<typeof import("../../agents/model-catalog.js").loadModelCatalog>

src/auto-reply/reply/directive-handling.persist.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "../../agents/agent-scope.js";
66
import { resolveContextTokensForModel } from "../../agents/context.js";
77
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
8+
import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
89
import { listLegacyRuntimeModelProviderAliases } from "../../agents/model-runtime-aliases.js";
910
import { normalizeProviderId, type ModelAliasIndex } from "../../agents/model-selection.js";
1011
import { updateSessionStore } from "../../config/sessions/store.js";
@@ -92,6 +93,7 @@ export async function persistInlineDirectives(params: {
9293
gatewayClientScopes?: string[];
9394
senderIsOwner?: boolean;
9495
markLiveSwitchPending?: boolean;
96+
thinkingCatalog?: ModelCatalogEntry[];
9597
}): Promise<{
9698
provider: string;
9799
model: string;
@@ -127,6 +129,10 @@ export async function persistInlineDirectives(params: {
127129
surface: params.surface,
128130
gatewayClientScopes: params.gatewayClientScopes,
129131
});
132+
const thinkingCatalog =
133+
params.thinkingCatalog && params.thinkingCatalog.length > 0
134+
? params.thinkingCatalog
135+
: undefined;
130136
const delegatedTraceAllowed = (params.gatewayClientScopes ?? []).includes("operator.admin");
131137
const activeAgentId = sessionKey
132138
? resolveSessionAgentId({ sessionKey, config: cfg })
@@ -273,12 +279,14 @@ export async function persistInlineDirectives(params: {
273279
provider,
274280
model,
275281
level: currentThinkingLevel,
282+
catalog: thinkingCatalog,
276283
})
277284
) {
278285
const remappedThinkingLevel = resolveSupportedThinkingLevel({
279286
provider,
280287
model,
281288
level: currentThinkingLevel,
289+
catalog: thinkingCatalog,
282290
});
283291
if (remappedThinkingLevel !== currentThinkingLevel) {
284292
sessionEntry.thinkingLevel = remappedThinkingLevel;

0 commit comments

Comments
 (0)