diff --git a/extensions/huggingface/models.test.ts b/extensions/huggingface/models.test.ts index 60ae22001d35..beb2e4ed4451 100644 --- a/extensions/huggingface/models.test.ts +++ b/extensions/huggingface/models.test.ts @@ -25,6 +25,15 @@ function stubAbortSignalTimeout() { return vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); } +function responseFromReader(reader: ReadableStreamDefaultReader): 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; + 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; + 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); diff --git a/extensions/huggingface/models.ts b/extensions/huggingface/models.ts index 462c682ed815..fc856eca880c 100644 --- a/extensions/huggingface/models.ts +++ b/extensions/huggingface/models.ts @@ -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( + response, + "huggingface.model-discovery", + ); const data = body?.data; if (!Array.isArray(data) || data.length === 0) { return HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition);