fix(extensions/huggingface): bound model discovery JSON response read to prevent OOM (#101079)

* fix(extensions/huggingface): bound model discovery JSON response read to prevent OOM

* test(huggingface): prove bounded discovery cleanup

* test(huggingface): avoid unbound reader assertions

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
cxbAsDev
2026-07-07 06:26:27 +08:00
committed by GitHub
parent 2b868cac57
commit a464620141
2 changed files with 67 additions and 1 deletions

View File

@@ -25,6 +25,15 @@ function stubAbortSignalTimeout() {
return vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal);
}
function responseFromReader(reader: ReadableStreamDefaultReader<Uint8Array>): Response {
return {
ok: true,
status: 200,
headers: new Headers({ "Content-Type": "application/json" }),
body: { getReader: () => reader },
} as Response;
}
afterEach(() => {
restoreEnv("VITEST", ORIGINAL_VITEST);
restoreEnv("NODE_ENV", ORIGINAL_NODE_ENV);
@@ -108,6 +117,59 @@ describe("huggingface models", () => {
expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS);
});
it("falls back to the static catalog when the discovery response exceeds the byte cap", async () => {
process.env.VITEST = "false";
process.env.NODE_ENV = "development";
const chunk = new Uint8Array(1024 * 1024);
const read = vi.fn(async () => ({ done: false as const, value: chunk }));
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const reader = {
read,
cancel,
releaseLock,
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
vi.stubGlobal(
"fetch",
vi.fn(async () => responseFromReader(reader)),
);
const models = await discoverHuggingfaceModels("hf_test_token");
expect(models.map((m) => m.id)).toEqual(HUGGINGFACE_MODEL_CATALOG.map((m) => m.id));
expect(cancel).toHaveBeenCalledTimes(1);
expect(releaseLock).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledTimes(17);
});
it("parses a valid bounded discovery response", async () => {
process.env.VITEST = "false";
process.env.NODE_ENV = "development";
const modelId = "test-org/test-model";
const body = new TextEncoder().encode(JSON.stringify({ data: [{ id: modelId }] }));
const read = vi
.fn()
.mockResolvedValueOnce({ done: false, value: body })
.mockResolvedValueOnce({ done: true, value: undefined });
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const reader = {
read,
cancel,
releaseLock,
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
vi.stubGlobal(
"fetch",
vi.fn(async () => responseFromReader(reader)),
);
const models = await discoverHuggingfaceModels("hf_test_token");
expect(models.some((model) => model.id === modelId)).toBe(true);
expect(cancel).not.toHaveBeenCalled();
expect(releaseLock).toHaveBeenCalledTimes(1);
});
describe("isHuggingfacePolicyLocked", () => {
it("returns true for :cheapest and :fastest refs", () => {
expect(isHuggingfacePolicyLocked("huggingface/deepseek-ai/DeepSeek-R1:cheapest")).toBe(true);

View File

@@ -1,5 +1,6 @@
// Huggingface plugin module implements models behavior.
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-types";
import {
fetchWithSsrFGuard,
@@ -165,7 +166,10 @@ export async function discoverHuggingfaceModels(
return HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition);
}
const body = (await response.json()) as OpenAIListModelsResponse;
const body = await readProviderJsonResponse<OpenAIListModelsResponse>(
response,
"huggingface.model-discovery",
);
const data = body?.data;
if (!Array.isArray(data) || data.length === 0) {
return HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition);