From 11e82bdef2f96082d26b69219aa244724e089f3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 15:05:16 -0400 Subject: [PATCH] fix(lmstudio): cap model fetch timeout delays --- extensions/lmstudio/src/models.fetch.ts | 6 ++-- extensions/lmstudio/src/models.test.ts | 41 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/extensions/lmstudio/src/models.fetch.ts b/extensions/lmstudio/src/models.fetch.ts index 7e36eee2f07e..b70fabea9ae7 100644 --- a/extensions/lmstudio/src/models.fetch.ts +++ b/extensions/lmstudio/src/models.fetch.ts @@ -1,4 +1,5 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; +import { clampTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup"; @@ -44,11 +45,12 @@ async function fetchLmstudioEndpoint(params: { ssrfPolicy?: SsrFPolicy; auditContext: string; }): Promise<{ response: Response; release: () => Promise }> { + const timeoutMs = clampTimerTimeoutMs(params.timeoutMs) ?? 1; if (params.ssrfPolicy) { return await fetchWithSsrFGuard({ url: params.url, init: params.init, - timeoutMs: params.timeoutMs, + timeoutMs, fetchImpl: params.fetchImpl, policy: params.ssrfPolicy, auditContext: params.auditContext, @@ -58,7 +60,7 @@ async function fetchLmstudioEndpoint(params: { return { response: await fetchFn(params.url, { ...params.init, - signal: AbortSignal.timeout(params.timeoutMs), + signal: AbortSignal.timeout(timeoutMs), }), release: async () => {}, }; diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index 9cf0feea5ee9..8d9df900f4a0 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -1,3 +1,4 @@ +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_MAX_TOKENS, @@ -90,6 +91,7 @@ describe("lmstudio-models", () => { afterEach(() => { fetchWithSsrFGuardMock.mockReset(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -379,6 +381,45 @@ describe("lmstudio-models", () => { } }); + it("caps oversized direct fetch timeouts before discovering models", async () => { + const timeoutController = new AbortController(); + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => ({ + ok: true, + status: 200, + requestInit: init, + json: async () => ({ models: [] }), + })); + + const result = await fetchLmstudioModels({ + baseUrl: "http://localhost:1234/v1", + timeoutMs: Number.MAX_SAFE_INTEGER, + fetchImpl: asFetch(fetchMock), + }); + + expect(result.reachable).toBe(true); + expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS); + expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(timeoutController.signal); + }); + + it("caps oversized guarded-fetch timeouts before discovering models", async () => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(JSON.stringify({ models: [] }), { status: 200 }), + release: vi.fn(async () => undefined), + }); + + const result = await fetchLmstudioModels({ + baseUrl: "http://localhost:1234/v1", + timeoutMs: Number.MAX_SAFE_INTEGER, + ssrfPolicy: {}, + }); + + expect(result.reachable).toBe(true); + expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).toMatchObject({ + timeoutMs: MAX_TIMER_TIMEOUT_MS, + }); + }); + it("skips model load when already loaded", async () => { const fetchMock = createModelLoadFetchMock({ loadedContextLength: 64000 }); vi.stubGlobal("fetch", asFetch(fetchMock));