fix(lmstudio): preserve saved model reasoning and code mode (#129675)

This commit is contained in:
Peter Steinberger
2026-08-25 17:00:41 -07:00
committed by GitHub
parent 088c45ab7c
commit 46f255cc87
6 changed files with 218 additions and 63 deletions
+4 -1
View File
@@ -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,
@@ -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);
});
});
@@ -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;
}
@@ -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<string, string> | 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<ModelDefinitionConfig["compat"]>,
): NonNullable<ModelDefinitionConfig["compat"]> {
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),
};
}
+2
View File
@@ -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", ""],
+9 -62
View File
@@ -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<string, string> | 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<ModelDefinitionConfig["compat"]>,
): NonNullable<ModelDefinitionConfig["compat"]> {
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<LmstudioModelWire, "capabilities">,
): 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,
);