From 46f255cc87dd3b3e82e69c34802e0640ae78bfe8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 17:00:41 -0700 Subject: [PATCH] fix(lmstudio): preserve saved model reasoning and code mode (#129675) --- extensions/lmstudio/index.test.ts | 5 +- .../lmstudio/provider-policy-api.test.ts | 120 ++++++++++++++++++ extensions/lmstudio/provider-policy-api.ts | 20 +++ extensions/lmstudio/src/model-reasoning.ts | 63 +++++++++ extensions/lmstudio/src/models.test.ts | 2 + extensions/lmstudio/src/models.ts | 71 ++--------- 6 files changed, 218 insertions(+), 63 deletions(-) create mode 100644 extensions/lmstudio/provider-policy-api.test.ts create mode 100644 extensions/lmstudio/provider-policy-api.ts create mode 100644 extensions/lmstudio/src/model-reasoning.ts diff --git a/extensions/lmstudio/index.test.ts b/extensions/lmstudio/index.test.ts index 71fa72c9722a..5f63cf3005b9 100644 --- a/extensions/lmstudio/index.test.ts +++ b/extensions/lmstudio/index.test.ts @@ -568,6 +568,7 @@ describe("lmstudio plugin", () => { reasoning: true, input: ["text", "image"], compat: { + codeMode: "preferred", supportsReasoningEffort: true, supportedReasoningEfforts: ["off", "on"], reasoningEffortMap: { off: "off", high: "on" }, @@ -575,6 +576,7 @@ describe("lmstudio plugin", () => { }, { id: "phi-4", + compat: { codeMode: "capable" }, }, { id: " ", @@ -600,6 +602,7 @@ describe("lmstudio plugin", () => { name: "Qwen 3 8B Instruct", compat: { supportsUsageInStreaming: true, + codeMode: "preferred", supportsReasoningEffort: true, supportedReasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"], reasoningEffortMap: { off: "none", none: "none", adaptive: "xhigh", max: "xhigh" }, @@ -613,7 +616,7 @@ describe("lmstudio plugin", () => { provider: "lmstudio", id: "phi-4", name: "phi-4", - compat: { supportsUsageInStreaming: true }, + compat: { supportsUsageInStreaming: true, codeMode: "capable" }, contextWindow: undefined, contextTokens: undefined, reasoning: undefined, diff --git a/extensions/lmstudio/provider-policy-api.test.ts b/extensions/lmstudio/provider-policy-api.test.ts new file mode 100644 index 000000000000..09cdd2181cf4 --- /dev/null +++ b/extensions/lmstudio/provider-policy-api.test.ts @@ -0,0 +1,120 @@ +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-types"; +import { describe, expect, it } from "vitest"; +import { normalizeConfig } from "./provider-policy-api.js"; + +function createModel(compat?: ModelDefinitionConfig["compat"]): ModelDefinitionConfig { + return { + id: "synthetic-reasoning-model", + name: "Synthetic reasoning model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_768, + maxTokens: 8_192, + ...(compat ? { compat } : {}), + }; +} + +describe("lmstudio lightweight provider policy", () => { + it("normalizes saved reasoning metadata without changing endpoint or transport settings", () => { + const legacyModel = createModel({ + codeMode: "preferred", + supportsTools: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: ["off", "on"], + reasoningEffortMap: { off: "off", high: "on" }, + }); + const unchangedModel = createModel({ codeMode: "capable" }); + const request = { + allowPrivateNetwork: false, + headers: { "X-Synthetic-Request": "synthetic" }, + }; + const headers = { "X-Synthetic-Provider": "synthetic" }; + const providerConfig: ModelProviderConfig = { + baseUrl: "http://lmstudio.internal:1234/api/v1/", + api: "openai-completions", + headers, + request, + params: { preserveCustomSetting: true }, + models: [legacyModel, unchangedModel], + }; + + const normalized = normalizeConfig({ provider: "lmstudio", providerConfig }); + + expect(normalized).toEqual({ + ...providerConfig, + models: [ + { + ...legacyModel, + compat: { + ...legacyModel.compat, + supportedReasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"], + reasoningEffortMap: { + off: "none", + none: "none", + adaptive: "xhigh", + max: "xhigh", + }, + }, + }, + unchangedModel, + ], + }); + expect(normalized.baseUrl).toBe(providerConfig.baseUrl); + expect(normalized.request).toBe(request); + expect(normalized.headers).toBe(headers); + expect(normalized.models[1]).toBe(unchangedModel); + expect(legacyModel.compat?.supportedReasoningEfforts).toEqual(["off", "on"]); + }); + + it("preserves provider and model identities when saved reasoning is already canonical", () => { + const models = [ + createModel({ + supportedReasoningEfforts: ["none", "low", "high"], + reasoningEffortMap: { off: "none", high: "high" }, + }), + createModel(), + ]; + const providerConfig: ModelProviderConfig = { + baseUrl: "http://localhost:1234/v1", + models, + }; + + expect(normalizeConfig({ provider: "lmstudio", providerConfig })).toBe(providerConfig); + expect(providerConfig.models).toBe(models); + }); + + it("ignores unrelated providers", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "http://localhost:1234/v1", + models: [createModel({ supportedReasoningEfforts: ["off", "on"] })], + }; + + expect(normalizeConfig({ provider: "openai", providerConfig })).toBe(providerConfig); + }); + + it.each([undefined, null, [], "invalid"])( + "leaves absent or malformed model compatibility metadata untouched: %j", + (compat) => { + const model = { ...createModel(), compat } as unknown as ModelDefinitionConfig; + const providerConfig: ModelProviderConfig = { + baseUrl: "http://localhost:1234/v1", + models: [model], + }; + + expect(normalizeConfig({ provider: "lmstudio", providerConfig })).toBe(providerConfig); + expect(providerConfig.models[0]).toBe(model); + }, + ); + + it("leaves partial provider declarations without model rows untouched", () => { + const providerConfig = { + baseUrl: "http://localhost:1234/v1", + } as ModelProviderConfig; + + expect(normalizeConfig({ provider: "lmstudio", providerConfig })).toBe(providerConfig); + }); +}); diff --git a/extensions/lmstudio/provider-policy-api.ts b/extensions/lmstudio/provider-policy-api.ts new file mode 100644 index 000000000000..2920a28a92bc --- /dev/null +++ b/extensions/lmstudio/provider-policy-api.ts @@ -0,0 +1,20 @@ +import type { ProviderNormalizeConfigContext } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeLmstudioTransportReasoningCompat } from "./src/model-reasoning.js"; + +/** Normalize saved reasoning metadata without activating provider runtime or changing transport. */ +export function normalizeConfig({ provider, providerConfig }: ProviderNormalizeConfigContext) { + if (provider.trim().toLowerCase() !== "lmstudio" || !Array.isArray(providerConfig.models)) { + return providerConfig; + } + const models = providerConfig.models.map((model) => { + const compat = model.compat; + if (!compat || typeof compat !== "object" || Array.isArray(compat)) { + return model; + } + const normalized = normalizeLmstudioTransportReasoningCompat(compat); + return normalized === compat ? model : { ...model, compat: normalized }; + }); + return models.some((model, index) => model !== providerConfig.models[index]) + ? { ...providerConfig, models } + : providerConfig; +} diff --git a/extensions/lmstudio/src/model-reasoning.ts b/extensions/lmstudio/src/model-reasoning.ts new file mode 100644 index 000000000000..c3daf0353650 --- /dev/null +++ b/extensions/lmstudio/src/model-reasoning.ts @@ -0,0 +1,63 @@ +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; + +export const LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS = [ + "minimal", + "low", + "medium", + "high", + "xhigh", +] as const; + +export const LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS = [ + "none", + ...LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS, +] as const; + +function resolveLmstudioEnabledTransportReasoningOption( + supportedReasoningEfforts: readonly string[], +): string | undefined { + return ( + supportedReasoningEfforts.find((option) => option === "xhigh") ?? + supportedReasoningEfforts.find((option) => option === "high") ?? + supportedReasoningEfforts.find((option) => option !== "none") + ); +} + +export 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; +} + +export 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), + }; +} diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index f502374b1e16..ea8c0b597b43 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -249,6 +249,7 @@ describe("lmstudio-models", () => { supportsTemperature: false, supportsUsageInStreaming: false, supportsTools: false, + codeMode: "preferred", supportsStrictMode: false, supportsJsonSchemaResponseFormat: false, requiresStringContent: true, @@ -294,6 +295,7 @@ describe("lmstudio-models", () => { supportsPromptCacheKey: 1, visibleReasoningDetailTypes: ["reasoning.summary", 1], maxTokensField: "max_output_tokens", + codeMode: "unsupported", thinkingFormat: "unsupported", toolSchemaProfile: 1, unsupportedToolSchemaKeywords: ["additionalProperties", ""], diff --git a/extensions/lmstudio/src/models.ts b/extensions/lmstudio/src/models.ts index 94faf4a5e66c..d6f9bddfa679 100644 --- a/extensions/lmstudio/src/models.ts +++ b/extensions/lmstudio/src/models.ts @@ -10,6 +10,12 @@ import { } from "openclaw/plugin-sdk/provider-setup"; import { asPositiveSafeInteger, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { LMSTUDIO_DEFAULT_BASE_URL, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js"; +import { + buildLmstudioReasoningEffortMap, + LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS, + LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS, + normalizeLmstudioTransportReasoningCompat, +} from "./model-reasoning.js"; export type LmstudioModelWire = { type?: "llm" | "embedding"; @@ -47,19 +53,6 @@ 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; - const LMSTUDIO_CONFIGURED_BOOLEAN_COMPAT_FIELDS = [ "supportsStore", "supportsPromptCacheKey", @@ -134,28 +127,6 @@ function resolveLmstudioTransportReasoningEfforts(allowedOptions: readonly strin ); } -function resolveLmstudioEnabledTransportReasoningOption( - supportedReasoningEfforts: readonly string[], -): string | undefined { - return ( - supportedReasoningEfforts.find((option) => option === "xhigh") ?? - supportedReasoningEfforts.find((option) => option === "high") ?? - supportedReasoningEfforts.find((option) => option !== "none") - ); -} - -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[], ): ModelDefinitionConfig["compat"] | undefined { @@ -173,33 +144,6 @@ function buildLmstudioReasoningCompat( }; } -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( entry: Pick, ): ModelDefinitionConfig["compat"] | undefined { @@ -379,6 +323,9 @@ function normalizeLmstudioConfiguredCompat(value: unknown): ModelDefinitionConfi compat[key] = configuredValue; } } + if (record.codeMode === "preferred" || record.codeMode === "capable") { + compat.codeMode = record.codeMode; + } const visibleReasoningDetailTypes = normalizeConfiguredCompatStringList( record.visibleReasoningDetailTypes, );