mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(lmstudio): preserve saved model reasoning and code mode (#129675)
This commit is contained in:
committed by
GitHub
parent
088c45ab7c
commit
46f255cc87
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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", ""],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user