From 2fd1e7b32dd8843fc36772ae3dd6da483e84f162 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 22:46:14 +0100 Subject: [PATCH] fix: normalize LM Studio binary reasoning efforts --- CHANGELOG.md | 1 + docs/providers/lmstudio.md | 11 +- extensions/lmstudio/index.test.ts | 4 +- extensions/lmstudio/src/models.test.ts | 41 +++++--- extensions/lmstudio/src/models.ts | 137 +++++++++++++++++-------- 5 files changed, 134 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab20d2b45a..2e8f4f653dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Doctor/plugins: preserve unmanaged third-party plugin `node_modules` during `doctor --fix`, while still pruning OpenClaw-managed runtime dependency caches. - Gateway/restart: add `openclaw gateway restart --force` and `--wait `, log active task run IDs before restart deferral timers, and report timeout restarts as explicit forced restarts. - Discord: persist slash-command deploy hashes across process restarts so unchanged command sets skip redeploy and avoid restart-loop 429s. +- Providers/LM Studio: normalize binary `off`/`on` reasoning metadata from Gemma 4 and other local models to LM Studio's accepted OpenAI-compatible `reasoning_effort` values. - Plugins/externalization: keep ACPX, Google Chat, and LINE publishable plugin dist trees out of the core npm package file list. - Plugins/ClawHub: fall back to version metadata when the artifact resolver route is missing and keep the Docker ClawHub fixture aligned with npm-pack artifact resolution, avoiding false version-not-found failures during plugin install validation. Thanks @vincentkoc. - Status/channels: show configured channels in `openclaw status` and config-only `openclaw channels status` output even when the Gateway is unreachable, avoiding empty Channels tables on WSL and other no-Gateway paths. Thanks @vincentkoc. diff --git a/docs/providers/lmstudio.md b/docs/providers/lmstudio.md index aa3d43d33a7..29ec208d66f 100644 --- a/docs/providers/lmstudio.md +++ b/docs/providers/lmstudio.md @@ -117,10 +117,13 @@ Same streaming usage behavior applies to these OpenAI-compatible local backends: ### Thinking compatibility When LM Studio's `/api/v1/models` discovery reports model-specific reasoning -options, OpenClaw preserves those native values in model compat metadata. For -binary thinking models that advertise `allowed_options: ["off", "on"]`, -OpenClaw maps disabled thinking to `off` and enabled `/think` levels to `on` -instead of sending OpenAI-only values such as `low` or `medium`. +options, OpenClaw exposes the matching OpenAI-compatible `reasoning_effort` +values in model compat metadata. Current LM Studio builds can advertise binary +UI options such as `allowed_options: ["off", "on"]` while rejecting those values +on `/v1/chat/completions`; OpenClaw normalizes that binary discovery shape to +`none`, `minimal`, `low`, `medium`, `high`, and `xhigh` before sending requests. +Older saved LM Studio config that contains `off`/`on` reasoning maps is +normalized the same way when the catalog is loaded. ### Explicit configuration diff --git a/extensions/lmstudio/index.test.ts b/extensions/lmstudio/index.test.ts index bfd8f955043..936d259dd7f 100644 --- a/extensions/lmstudio/index.test.ts +++ b/extensions/lmstudio/index.test.ts @@ -181,8 +181,8 @@ describe("lmstudio plugin", () => { compat: { supportsUsageInStreaming: true, supportsReasoningEffort: true, - supportedReasoningEfforts: ["off", "on"], - reasoningEffortMap: { off: "off", high: "on" }, + supportedReasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"], + reasoningEffortMap: { off: "none", none: "none", adaptive: "xhigh", max: "xhigh" }, }, contextWindow: 32768, contextTokens: 8192, diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index dad5b33a262..b3d8515bd7b 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -146,7 +146,7 @@ describe("lmstudio-models", () => { ).toBe(false); }); - it("maps LM Studio native reasoning options into OpenAI-compatible effort compat", () => { + it("maps LM Studio binary reasoning options into OpenAI-compatible effort compat", () => { expect( resolveLmstudioReasoningCompat({ capabilities: { @@ -158,13 +158,30 @@ describe("lmstudio-models", () => { }), ).toEqual({ supportsReasoningEffort: true, - supportedReasoningEfforts: ["off", "on"], + supportedReasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"], reasoningEffortMap: expect.objectContaining({ - off: "off", - none: "off", - low: "on", - medium: "on", - high: "on", + off: "none", + none: "none", + adaptive: "xhigh", + max: "xhigh", + }), + }); + + expect( + resolveLmstudioReasoningCompat({ + capabilities: { + reasoning: { + allowed_options: ["low", "medium", "high"], + default: "low", + }, + }, + }), + ).toEqual({ + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium", "high"], + reasoningEffortMap: expect.objectContaining({ + adaptive: "high", + max: "high", }), }); @@ -243,12 +260,12 @@ describe("lmstudio-models", () => { compat: { supportsUsageInStreaming: true, supportsReasoningEffort: true, - supportedReasoningEfforts: ["off", "on"], + supportedReasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"], reasoningEffortMap: expect.objectContaining({ - off: "off", - none: "off", - medium: "on", - high: "on", + off: "none", + none: "none", + adaptive: "xhigh", + max: "xhigh", }), }, contextWindow: 262144, diff --git a/extensions/lmstudio/src/models.ts b/extensions/lmstudio/src/models.ts index db190ee53b0..4d28e3c1e8e 100644 --- a/extensions/lmstudio/src/models.ts +++ b/extensions/lmstudio/src/models.ts @@ -43,6 +43,19 @@ type LmstudioConfiguredCatalogEntry = { compat?: ModelDefinitionConfig["compat"]; }; +const LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS = [ + "minimal", + "low", + "medium", + "high", + "xhigh", +] as const; + +const LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS = [ + "none", + ...LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS, +] as const; + function normalizeReasoningOption(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -72,36 +85,92 @@ function normalizeReasoningOptions(value: unknown): string[] { ]; } -function resolveLmstudioReasoningDefault( - reasoning: LmstudioReasoningCapabilityWire, -): string | null { - const normalizedDefault = normalizeReasoningOption(reasoning.default); - return normalizedDefault && isReasoningEnabledOption(normalizedDefault) - ? normalizedDefault - : null; +function isLmstudioBinaryReasoningOptions(allowedOptions: readonly string[]): boolean { + return ( + allowedOptions.some((option) => option === "on") && + allowedOptions.every((option) => option === "on" || option === "off") + ); } -function resolveLmstudioEnabledReasoningOption( - allowedOptions: readonly string[], - reasoning: LmstudioReasoningCapabilityWire, -): string | undefined { - const normalizedDefault = resolveLmstudioReasoningDefault(reasoning); - if (normalizedDefault && allowedOptions.includes(normalizedDefault)) { - return normalizedDefault; +function resolveLmstudioTransportReasoningEfforts(allowedOptions: readonly string[]): string[] { + if (isLmstudioBinaryReasoningOptions(allowedOptions)) { + return allowedOptions.includes("off") + ? [...LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS] + : [...LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS]; } + return [ + ...new Set( + allowedOptions + .map((option) => (option === "off" ? "none" : option)) + .filter((option) => option !== "on"), + ), + ]; +} + +function resolveLmstudioEnabledTransportReasoningOption( + supportedReasoningEfforts: readonly string[], +): string | undefined { return ( - allowedOptions.find((option) => option === "on" || option === "default") ?? - allowedOptions.find((option) => isReasoningEnabledOption(option)) + supportedReasoningEfforts.find((option) => option === "xhigh") ?? + supportedReasoningEfforts.find((option) => option === "high") ?? + supportedReasoningEfforts.find((option) => option !== "none") ); } -function resolveLmstudioDisabledReasoningOption( +function buildLmstudioReasoningEffortMap( + supportedReasoningEfforts: readonly string[], +): Record | undefined { + const disabled = supportedReasoningEfforts.includes("none") ? "none" : undefined; + const max = resolveLmstudioEnabledTransportReasoningOption(supportedReasoningEfforts); + const map = { + ...(disabled ? { off: disabled, none: disabled } : {}), + ...(max ? { adaptive: max, max } : {}), + }; + return Object.keys(map).length > 0 ? map : undefined; +} + +function buildLmstudioReasoningCompat( allowedOptions: readonly string[], -): string | undefined { - return ( - allowedOptions.find((option) => option === "off") ?? - allowedOptions.find((option) => option === "none") - ); +): ModelDefinitionConfig["compat"] | undefined { + const supportedReasoningEfforts = resolveLmstudioTransportReasoningEfforts(allowedOptions); + if (supportedReasoningEfforts.length === 0) { + return undefined; + } + if (!supportedReasoningEfforts.some((option) => option !== "none")) { + return undefined; + } + return { + supportsReasoningEffort: true, + supportedReasoningEfforts, + reasoningEffortMap: buildLmstudioReasoningEffortMap(supportedReasoningEfforts), + }; +} + +function normalizeLmstudioTransportReasoningCompat( + compat: NonNullable, +): NonNullable { + const supportedReasoningEfforts = compat.supportedReasoningEfforts; + const map = compat.reasoningEffortMap; + const hasBinarySupported = + Array.isArray(supportedReasoningEfforts) && + supportedReasoningEfforts.some((option) => option === "on"); + const hasBinaryMapValue = + map !== undefined && Object.values(map).some((value) => value === "on" || value === "off"); + if (!hasBinarySupported && !hasBinaryMapValue) { + return compat; + } + const hasDisabled = + supportedReasoningEfforts?.includes("off") === true || + supportedReasoningEfforts?.includes("none") === true || + Object.values(map ?? {}).some((value) => value === "off" || value === "none"); + const normalizedSupportedReasoningEfforts = hasDisabled + ? [...LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS] + : [...LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS]; + return { + ...compat, + supportedReasoningEfforts: normalizedSupportedReasoningEfforts, + reasoningEffortMap: buildLmstudioReasoningEffortMap(normalizedSupportedReasoningEfforts), + }; } export function resolveLmstudioReasoningCompat( @@ -115,25 +184,7 @@ export function resolveLmstudioReasoningCompat( if (allowedOptions.length === 0) { return undefined; } - const enabled = resolveLmstudioEnabledReasoningOption(allowedOptions, reasoning); - if (!enabled) { - return undefined; - } - const disabled = resolveLmstudioDisabledReasoningOption(allowedOptions); - return { - supportsReasoningEffort: true, - supportedReasoningEfforts: allowedOptions, - reasoningEffortMap: { - ...(disabled ? { off: disabled, none: disabled } : {}), - minimal: enabled, - low: enabled, - medium: enabled, - high: enabled, - xhigh: enabled, - adaptive: enabled, - max: enabled, - }, - }; + return buildLmstudioReasoningCompat(allowedOptions); } /** @@ -235,7 +286,9 @@ function normalizeLmstudioConfiguredCompat(value: unknown): ModelDefinitionConfi if (reasoningEffortMap) { compat.reasoningEffortMap = reasoningEffortMap; } - return Object.keys(compat).length > 0 ? compat : undefined; + return Object.keys(compat).length > 0 + ? normalizeLmstudioTransportReasoningCompat(compat) + : undefined; } function toFetchableLmstudioBaseUrl(value: string): string {