diff --git a/extensions/lmstudio/src/models.fetch.ts b/extensions/lmstudio/src/models.fetch.ts index 532799f87fb3..b49ab164cc0f 100644 --- a/extensions/lmstudio/src/models.fetch.ts +++ b/extensions/lmstudio/src/models.fetch.ts @@ -1,13 +1,13 @@ // Lmstudio plugin module implements models.fetch behavior. -import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; +import { createSubsystemLogger, redactToolPayloadText } from "openclaw/plugin-sdk/logging-core"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { readProviderJsonArrayFieldResponse, readProviderJsonResponse, - readResponseTextLimited, } 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"; +import { readResponseTextPrefix } from "openclaw/plugin-sdk/response-limit-runtime"; 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"; @@ -24,6 +24,24 @@ import { buildLmstudioAuthHeaders } from "./runtime.js"; const log = createSubsystemLogger("extensions/lmstudio/models"); const LMSTUDIO_ERROR_BODY_LIMIT_BYTES = 8 * 1024; +function redactLmstudioLoadError(value: string, headers: Record | undefined) { + const credentials = Object.entries(headers ?? {}) + .filter(([name]) => name.toLowerCase() !== "content-type") + .flatMap(([name, header]) => { + const normalized = header.trim(); + if (!normalized) { + return []; + } + return name.toLowerCase() === "authorization" + ? [normalized, normalized.replace(/^\S+\s+/u, "")] + : [normalized]; + }) + .toSorted((left, right) => right.length - left.length); + return redactToolPayloadText( + credentials.reduce((redacted, credential) => redacted.replaceAll(credential, "***"), value), + ); +} + type LmstudioLoadResponse = { status?: string; }; @@ -275,15 +293,16 @@ export async function ensureLmstudioModelLoaded(params: { } try { + const requestHeaders = buildLmstudioAuthHeaders({ + apiKey: params.apiKey, + headers: params.headers, + json: true, + }); const { response, release } = await fetchLmstudioEndpoint({ url: `${baseUrl}/api/v1/models/load`, init: { method: "POST", - headers: buildLmstudioAuthHeaders({ - apiKey: params.apiKey, - headers: params.headers, - json: true, - }), + headers: requestHeaders, body: JSON.stringify({ model: canonicalModelKey, // Ask LM Studio to load with our default target, capped to the model's own limit. @@ -297,9 +316,15 @@ export async function ensureLmstudioModelLoaded(params: { }); try { if (!response.ok) { - const body = await readResponseTextLimited(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES); + const bodyRead = await readResponseTextPrefix(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES, { + chunkTimeoutMs: 10_000, + }); + // A truncated credential cannot be identified safely; drop the entire diagnostic. + const detail = bodyRead.truncated + ? "" + : redactLmstudioLoadError(bodyRead.text, requestHeaders); throw new Error( - `LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`, + `LM Studio model load failed (${response.status})${detail ? `: ${detail}` : ""}`, ); } // Read the success body through the shared byte-capped reader so a misbehaving @@ -310,7 +335,8 @@ export async function ensureLmstudioModelLoaded(params: { "LM Studio model load", ); if (typeof payload.status === "string" && payload.status.toLowerCase() !== "loaded") { - throw new Error(`LM Studio model load returned unexpected status: ${payload.status}`); + const status = redactLmstudioLoadError(payload.status, requestHeaders); + throw new Error(`LM Studio model load returned unexpected status: ${status}`); } } finally { await release(); diff --git a/extensions/lmstudio/src/models.load-error.test.ts b/extensions/lmstudio/src/models.load-error.test.ts new file mode 100644 index 000000000000..ee00b7c14356 --- /dev/null +++ b/extensions/lmstudio/src/models.load-error.test.ts @@ -0,0 +1,58 @@ +import { createServer } from "node:http"; +import { describe, expect, it } from "vitest"; +import { ensureLmstudioModelLoaded } from "./models.fetch.js"; + +describe("LM Studio model-load error transport", () => { + it("redacts actual outbound credentials reflected by a model-load HTTP server", async () => { + let responseStatus = 502; + const server = createServer((request, response) => { + if (request.method === "GET") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify({ + models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }], + }), + ); + return; + } + const authorization = request.headers.authorization; + const proxyAuthorization = request.headers["x-proxy-auth"]; + if (typeof authorization !== "string" || typeof proxyAuthorization !== "string") { + response.writeHead(500); + response.end("missing expected authentication headers"); + return; + } + const reflected = `upstream rejected ${authorization}; proxy ${proxyAuthorization}`; + response.writeHead(responseStatus, { "Content-Type": "application/json" }); + response.end(responseStatus === 200 ? JSON.stringify({ status: reflected }) : reflected); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("LM Studio test server did not bind to a local port"); + } + for (const [status, expected] of [ + [502, "LM Studio model load failed (502): upstream rejected ***; proxy ***"], + [200, "LM Studio model load returned unexpected status: upstream rejected ***; proxy ***"], + ] as const) { + responseStatus = status; + await expect( + ensureLmstudioModelLoaded({ + baseUrl: `http://127.0.0.1:${address.port}`, + modelKey: "qwen3-8b-instruct", + apiKey: "sk-test", + headers: { "X-Proxy-Auth": "opaque-short" }, + }), + ).rejects.toThrow(expected); + } + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index aa91ee572ba9..f502374b1e16 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -895,8 +895,9 @@ describe("lmstudio-models", () => { expect(bytesEmitted).toBeLessThan(32 * 1024 * 1024); }); - it("bounds model load error bodies", async () => { - const body = `${"lmstudio load unavailable ".repeat(512)}tail`; + it("suppresses truncated model load error bodies", async () => { + const credential = "split-credential-xyz"; + const body = `${"x".repeat(8 * 1024 - 2)}${credential}${"y".repeat(1000)}`; const tracked = cancelTrackedResponse(body, { status: 503 }); const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); const fetchMock = vi.fn(async (url: string | URL) => { @@ -914,17 +915,106 @@ describe("lmstudio-models", () => { const error = await ensureLmstudioModelLoaded({ baseUrl: "http://localhost:1234/v1", + apiKey: credential, modelKey: "qwen3-8b-instruct", }).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toMatch( - /LM Studio model load failed \(503\): lmstudio load unavailable/, - ); - expect((error as Error).message).not.toContain("tail"); + expect((error as Error).message).toBe("LM Studio model load failed (503)"); + expect((error as Error).message).not.toContain(credential); expect(tracked.wasCanceled()).toBe(true); expect(textSpy).not.toHaveBeenCalled(); }); + it.each<{ + name: string; + params: Pick[0], "apiKey" | "headers">; + body: string; + status: number; + expected: string; + }>([ + { + name: "redacts trimmed short API keys without losing safe diagnostics", + params: { apiKey: " sk-test " }, + body: "upstream rejected Bearer sk-test; GPU out of memory", + status: 502, + expected: "LM Studio model load failed (502): upstream rejected ***; GPU out of memory", + }, + { + name: "redacts bare credentials from lowercase header-only bearer auth", + params: { headers: { authorization: "bearer opaque-short" } }, + body: "upstream rejected opaque-short", + status: 502, + expected: "LM Studio model load failed (502): upstream rejected ***", + }, + { + name: "redacts opaque custom authentication header values", + params: { headers: { "X-Proxy-Auth": "proxy-p7" } }, + body: "proxy rejected proxy-p7", + status: 502, + expected: "LM Studio model load failed (502): proxy rejected ***", + }, + { + name: "redacts overlapping credentials longest first", + params: { apiKey: "abc", headers: { "X-Proxy-Auth": "abcdef" } }, + body: "proxy rejected abcdef and abc", + status: 502, + expected: "LM Studio model load failed (502): proxy rejected *** and ***", + }, + { + name: "redacts only the authorization value actually sent", + params: { apiKey: "fresh", headers: { Authorization: "Bearer replaced-old" } }, + body: "stale replaced-old; active fresh", + status: 502, + expected: "LM Studio model load failed (502): stale replaced-old; active ***", + }, + { + name: "preserves synthetic markers and the generated content type", + params: { apiKey: "lmstudio-local" }, + body: "lmstudio-local rejected application/json; GPU out of memory", + status: 502, + expected: + "LM Studio model load failed (502): lmstudio-local rejected application/json; GPU out of memory", + }, + { + name: "redacts unrelated recognizable provider credentials", + params: {}, + body: "upstream Authorization: Bearer sk-test", + status: 502, + expected: "LM Studio model load failed (502): upstream Authorization: Bearer ***", + }, + { + name: "redacts reflected credentials in successful unexpected statuses", + params: { headers: { authorization: "bearer opaque-short" } }, + body: "backend rejected opaque-short", + status: 200, + expected: "LM Studio model load returned unexpected status: backend rejected ***", + }, + ])("$name", async ({ params, body, status, expected }) => { + const fetchMock = vi.fn(async (url: string | URL) => { + if (String(url).endsWith("/api/v1/models")) { + return jsonResponse({ + models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }], + }); + } + if (String(url).endsWith("/api/v1/models/load")) { + return status === 200 ? jsonResponse({ status: body }) : new Response(body, { status }); + } + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + const error = await ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + ...params, + }).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + message: expected, + resolvedModelKey: "qwen3-8b-instruct", + }); + }); + it("loads model with clamped context length and merged headers", async () => { const fetchMock = createModelLoadFetchMock({ maxContextLength: 32768 }); vi.stubGlobal("fetch", asFetch(fetchMock));