mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix: normalize LM Studio binary reasoning efforts
This commit is contained in:
@@ -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 <duration>`, 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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[],
|
||||
): 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<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(
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user