fix: validate lmstudio discovered context lengths

This commit is contained in:
Peter Steinberger
2026-05-28 18:10:55 -04:00
parent 8e806e9125
commit 80f7e36ddc
3 changed files with 42 additions and 21 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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.