diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index 206106e8449..1eff71eb2eb 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -163,7 +163,33 @@ describe("discoverOpenAICompatibleLocalModels", () => { expect(models).toEqual([]); expect(loggerWarnMock).toHaveBeenCalledWith( - expect.stringContaining("local llama.cpp discovery response is not valid JSON"), + expect.stringContaining("local llama.cpp discovery: malformed JSON response"), + ); + expect(release).toHaveBeenCalledOnce(); + }); + + it("logs a warning when discovery JSON contains invalid UTF-8 bytes", async () => { + const release = vi.fn(async () => undefined); + const body = new Uint8Array([ + ...new TextEncoder().encode('{"data":[{"id":"qwen3-'), + 0xff, + ...new TextEncoder().encode('"}]}'), + ]); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(body, { status: 200 }), + finalUrl: "http://127.0.0.1:8080/v1/models", + release, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "local llama.cpp", + env: {}, + }); + + expect(models).toEqual([]); + expect(loggerWarnMock).toHaveBeenCalledWith( + expect.stringContaining("local llama.cpp discovery: malformed JSON response"), ); expect(release).toHaveBeenCalledOnce(); }); diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index b62d91f494d..2050b7455e2 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -10,6 +10,7 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization import type { ApiKeyCredential, AuthProfileCredential } from "../agents/auth-profiles/types.js"; import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js"; import { parseConfiguredModelVisibilityEntries } from "../agents/model-selection-shared.js"; +import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, @@ -18,7 +19,6 @@ import { import type { ModelDefinitionConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; // Builds setup metadata for self-hosted provider plugins. -import { readResponseWithLimit } from "../infra/http-body.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -93,23 +93,10 @@ function readPositiveInteger(value: unknown): number | undefined { return Math.trunc(value); } -/** - * Reads and parses a self-hosted discovery JSON body under a hard byte cap. - * Mirrors the byte-bounded reader pattern shared across provider/media reads so - * an untrusted endpoint cannot stream an unbounded body into memory. - */ -async function readSelfHostedDiscoveryJson(response: Response, label: string): Promise { - const bytes = await readResponseWithLimit(response, SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES, { - onOverflow: ({ size, maxBytes }) => - new Error( - `${label} discovery response body too large: ${size} bytes (limit: ${maxBytes} bytes)`, - ), +async function readSelfHostedDiscoveryJson(response: Response, label: string): Promise { + return await readProviderJsonResponse(response, `${label} discovery`, { + maxBytes: SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES, }); - try { - return JSON.parse(new TextDecoder().decode(bytes)) as unknown; - } catch (cause) { - throw new Error(`${label} discovery response is not valid JSON`, { cause }); - } } async function cancelUnreadResponseBody(response: Response): Promise { @@ -159,10 +146,10 @@ async function discoverLlamaCppRuntimeContextTokens(params: { await cancelUnreadResponseBody(response); return undefined; } - const data = (await readSelfHostedDiscoveryJson( + const data = await readSelfHostedDiscoveryJson( response, "llama.cpp /props", - )) as LlamaCppPropsResponse; + ); return ( readPositiveInteger(data.default_generation_settings?.n_ctx) ?? readPositiveInteger(data.n_ctx) @@ -207,10 +194,10 @@ export async function discoverOpenAICompatibleLocalModels(params: { log.warn(`Failed to discover ${params.label} models: ${response.status}`); return []; } - const data = (await readSelfHostedDiscoveryJson( + const data = await readSelfHostedDiscoveryJson( response, params.label, - )) as OpenAICompatModelsResponse; + ); const models = data.data ?? []; if (models.length === 0) { log.warn(`No ${params.label} models found on local instance`);