From 80f7e36ddc5965a8b7fc586929cf7fec00a1f190 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 28 May 2026 18:10:55 -0400 Subject: [PATCH] fix: validate lmstudio discovered context lengths --- extensions/lmstudio/src/models.fetch.ts | 15 +++-------- extensions/lmstudio/src/models.test.ts | 36 +++++++++++++++++++++++++ extensions/lmstudio/src/models.ts | 12 +++------ 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/extensions/lmstudio/src/models.fetch.ts b/extensions/lmstudio/src/models.fetch.ts index 36b2f297f84..7e36eee2f07 100644 --- a/extensions/lmstudio/src/models.fetch.ts +++ b/extensions/lmstudio/src/models.fetch.ts @@ -3,6 +3,7 @@ import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asPositiveSafeInteger } from "openclaw/plugin-sdk/string-coerce-runtime"; import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js"; import { buildLmstudioModelName, @@ -214,18 +215,8 @@ export async function ensureLmstudioModelLoaded(params: { } const matchingModel = preflight.models.find((entry) => entry.key?.trim() === modelKey); const loadedContextWindow = matchingModel ? resolveLoadedContextWindow(matchingModel) : null; - const advertisedContextLimit = - matchingModel?.max_context_length !== undefined && - Number.isFinite(matchingModel.max_context_length) && - matchingModel.max_context_length > 0 - ? Math.floor(matchingModel.max_context_length) - : null; - const requestedContextLength = - params.requestedContextLength !== undefined && - Number.isFinite(params.requestedContextLength) && - params.requestedContextLength > 0 - ? Math.floor(params.requestedContextLength) - : null; + const advertisedContextLimit = asPositiveSafeInteger(matchingModel?.max_context_length) ?? null; + const requestedContextLength = asPositiveSafeInteger(params.requestedContextLength) ?? null; const contextLengthForLoad = advertisedContextLimit === null ? (requestedContextLength ?? LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH) diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index d0b7cc24e93..9cf0feea5ee 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -10,6 +10,7 @@ import { fetchLmstudioModels, } from "./models.fetch.js"; import { + mapLmstudioWireEntry, normalizeLmstudioConfiguredCatalogEntry, normalizeLmstudioProviderConfig, resolveLmstudioInferenceBase, @@ -160,6 +161,23 @@ describe("lmstudio-models", () => { }); }); + it("drops malformed discovered context metadata", () => { + const model = mapLmstudioWireEntry({ + type: "llm", + key: "bad-context", + max_context_length: 32768.5, + loaded_instances: [{ id: "loaded", config: { context_length: Number.POSITIVE_INFINITY } }], + }); + + expect(model).toMatchObject({ + id: "bad-context", + contextWindow: SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + contextTokens: LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, + maxTokens: SELF_HOSTED_DEFAULT_MAX_TOKENS, + loaded: false, + }); + }); + it("resolves reasoning capability for supported and unsupported options", () => { expect(resolveLmstudioReasoningCapability({ capabilities: undefined })).toBe(false); expect( @@ -499,6 +517,24 @@ describe("lmstudio-models", () => { expectLoadContextLength(fetchMock, 8192); }); + it("omits malformed context lengths before loading models", async () => { + const fetchMock = createModelLoadFetchMock({ + loadedContextLength: 4096.5, + maxContextLength: 32768.5, + }); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + requestedContextLength: 8192.5, + }), + ).resolves.toBeUndefined(); + + expectLoadContextLength(fetchMock, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH); + }); + it("throws when model discovery fails", async () => { const fetchMock = vi.fn(async () => ({ ok: false, diff --git a/extensions/lmstudio/src/models.ts b/extensions/lmstudio/src/models.ts index 2121f3fa25a..8ac8494a830 100644 --- a/extensions/lmstudio/src/models.ts +++ b/extensions/lmstudio/src/models.ts @@ -209,11 +209,10 @@ export function resolveLoadedContextWindow( let contextWindow: number | null = null; for (const instance of loadedInstances) { // Discovery payload is external JSON, so tolerate malformed entries. - const length = instance?.config?.context_length; - if (length === undefined || !Number.isFinite(length) || length <= 0) { + const normalized = asPositiveSafeInteger(instance?.config?.context_length); + if (normalized === undefined) { continue; } - const normalized = Math.floor(length); contextWindow = contextWindow === null ? normalized : Math.max(contextWindow, normalized); } return contextWindow; @@ -463,12 +462,7 @@ export function mapLmstudioWireEntry(entry: LmstudioModelWire): LmstudioModelBas return null; } const loadedContextWindow = resolveLoadedContextWindow(entry); - const advertisedContextWindow = - entry.max_context_length !== undefined && - Number.isFinite(entry.max_context_length) && - entry.max_context_length > 0 - ? Math.floor(entry.max_context_length) - : null; + const advertisedContextWindow = asPositiveSafeInteger(entry.max_context_length) ?? null; const contextWindow = advertisedContextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW; // Keep native/advertised context window metadata in catalog, but use a practical // default target for model loading unless callers explicitly override it.