Skip to content

Commit aff6e22

Browse files
committed
fix(lmstudio): bound model load error bodies
1 parent 5df5aa1 commit aff6e22

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

extensions/lmstudio/src/models.fetch.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Lmstudio plugin module implements models.fetch behavior.
22
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
33
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
4-
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
4+
import {
5+
readProviderJsonArrayFieldResponse,
6+
readResponseTextLimited,
7+
} from "openclaw/plugin-sdk/provider-http";
58
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
69
import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup";
710
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -17,6 +20,7 @@ import {
1720
import { buildLmstudioAuthHeaders } from "./runtime.js";
1821

1922
const log = createSubsystemLogger("extensions/lmstudio/models");
23+
const LMSTUDIO_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
2024

2125
type LmstudioLoadResponse = {
2226
status?: string;
@@ -253,7 +257,7 @@ export async function ensureLmstudioModelLoaded(params: {
253257
});
254258
try {
255259
if (!response.ok) {
256-
const body = await response.text();
260+
const body = await readResponseTextLimited(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES);
257261
throw new Error(`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`);
258262
}
259263
let payload: LmstudioLoadResponse;

extensions/lmstudio/src/models.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ describe("lmstudio-models", () => {
4444
}
4545
return JSON.parse(init.body) as unknown;
4646
};
47+
const cancelTrackedResponse = (
48+
text: string,
49+
init: ResponseInit,
50+
): {
51+
response: Response;
52+
wasCanceled: () => boolean;
53+
} => {
54+
let canceled = false;
55+
const stream = new ReadableStream<Uint8Array>({
56+
start(controller) {
57+
controller.enqueue(new TextEncoder().encode(text));
58+
},
59+
cancel() {
60+
canceled = true;
61+
},
62+
});
63+
return {
64+
response: new Response(stream, init),
65+
wasCanceled: () => canceled,
66+
};
67+
};
4768
const createModelLoadFetchMock = (params?: {
4869
loadedContextLength?: number;
4970
maxContextLength?: number;
@@ -486,6 +507,39 @@ describe("lmstudio-models", () => {
486507
).rejects.toThrow("LM Studio model load returned malformed JSON");
487508
});
488509

510+
it("bounds model load error bodies", async () => {
511+
const body = `${"lmstudio load unavailable ".repeat(512)}tail`;
512+
const tracked = cancelTrackedResponse(body, { status: 503 });
513+
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
514+
const fetchMock = vi.fn(async (url: string | URL) => {
515+
if (String(url).endsWith("/api/v1/models")) {
516+
return {
517+
ok: true,
518+
json: async () => ({
519+
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
520+
}),
521+
};
522+
}
523+
if (String(url).endsWith("/api/v1/models/load")) {
524+
return tracked.response;
525+
}
526+
throw new Error(`Unexpected fetch URL: ${String(url)}`);
527+
});
528+
vi.stubGlobal("fetch", asFetch(fetchMock));
529+
530+
const error = await ensureLmstudioModelLoaded({
531+
baseUrl: "http://localhost:1234/v1",
532+
modelKey: "qwen3-8b-instruct",
533+
}).catch((caught: unknown) => caught);
534+
expect(error).toBeInstanceOf(Error);
535+
expect((error as Error).message).toMatch(
536+
/LM Studio model load failed \(503\): lmstudio load unavailable/,
537+
);
538+
expect((error as Error).message).not.toContain("tail");
539+
expect(tracked.wasCanceled()).toBe(true);
540+
expect(textSpy).not.toHaveBeenCalled();
541+
});
542+
489543
it("reloads model to the clamped default target when already loaded below the default window", async () => {
490544
const fetchMock = createModelLoadFetchMock({
491545
loadedContextLength: 4096,

0 commit comments

Comments
 (0)