From fece179cf93adf2ec111d9ccd0eb77f96c48d019 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Thu, 16 Jul 2026 21:01:40 +0800 Subject: [PATCH] fix(models): honor scan timeout while reading OpenRouter catalog (#108972) * fix(models): bound OpenRouter catalog response reads * refactor(models): reuse catalog timeout helper Co-authored-by: Alix-007 --------- Co-authored-by: Peter Steinberger --- src/agents/model-scan.test.ts | 64 +++++++++++++++++- src/agents/model-scan.ts | 118 +++++++++++++++++----------------- 2 files changed, 123 insertions(+), 59 deletions(-) diff --git a/src/agents/model-scan.test.ts b/src/agents/model-scan.test.ts index db9155c27e87..c2c0c1060ece 100644 --- a/src/agents/model-scan.test.ts +++ b/src/agents/model-scan.test.ts @@ -177,7 +177,7 @@ describe("scanOpenRouterModels", () => { }); }); - it("applies the scan timeout to the OpenRouter catalog request", async () => { + it("applies the scan timeout before the OpenRouter catalog responds", async () => { vi.useFakeTimers(); const fetchImpl: typeof fetch = async (_input, init) => await new Promise((_resolve, reject) => { @@ -203,6 +203,68 @@ describe("scanOpenRouterModels", () => { await scan; }); + it("keeps the catalog timeout active while reading a streaming body", async () => { + vi.useFakeTimers(); + const timeoutMs = 20; + const chunkIntervalMs = 5; + const encoder = new TextEncoder(); + let chunkCount = 0; + let abortCount = 0; + + const fetchImpl = withFetchPreconnect(async (_input, init) => { + const signal = typeof init === "object" && init ? init.signal : undefined; + if (!signal) { + throw new Error("Expected catalog request signal"); + } + let interval: ReturnType | undefined; + let completionTimer: ReturnType | undefined; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"data":[')); + interval = setInterval(() => { + chunkCount += 1; + controller.enqueue(encoder.encode(" ")); + }, chunkIntervalMs); + completionTimer = setTimeout(() => { + clearInterval(interval); + controller.enqueue(encoder.encode("]}")); + controller.close(); + }, timeoutMs + chunkIntervalMs); + signal.addEventListener( + "abort", + () => { + abortCount += 1; + clearInterval(interval); + clearTimeout(completionTimer); + controller.error(signal.reason); + }, + { once: true }, + ); + }, + cancel() { + clearInterval(interval); + clearTimeout(completionTimer); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + const scan = expect( + scanOpenRouterModels({ fetchImpl, probe: false, timeoutMs }), + ).rejects.toThrow(/aborted/i); + + await vi.advanceTimersByTimeAsync(timeoutMs - 1); + expect(chunkCount).toBe(3); + expect(abortCount).toBe(0); + + await vi.advanceTimersByTimeAsync(chunkIntervalMs + 1); + await scan; + expect(abortCount).toBe(1); + expect(vi.getTimerCount()).toBe(0); + }); + it("caps oversized scan timeouts before scheduling catalog aborts", async () => { // Timer APIs cannot safely schedule above the platform max; cap before // creating the catalog abort timeout. diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index d7a83eca05fa..ebb40744e6ca 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -218,74 +218,76 @@ async function fetchOpenRouterModels( ): Promise { let res: Response | undefined; try { - res = await withTimeout(timeoutMs, (signal) => - fetchImpl(OPENROUTER_MODELS_URL, { + // fetch resolves after headers, so keep the shared timeout active until + // the provider-controlled catalog body has been consumed. + return await withTimeout(timeoutMs, async (signal) => { + res = await fetchImpl(OPENROUTER_MODELS_URL, { headers: { Accept: "application/json" }, signal, - }), - ); - if (!res.ok) { - throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); - } - const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown }; - const entries = Array.isArray(payload.data) ? payload.data : []; + }); + if (!res.ok) { + throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); + } + const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown }; + const entries = Array.isArray(payload.data) ? payload.data : []; - return entries - .map((entry) => { - if (!entry || typeof entry !== "object") { - return null; - } - const obj = entry as Record; - const id = normalizeOptionalString(obj.id) ?? ""; - if (!id) { - return null; - } - const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id; + return entries + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } + const obj = entry as Record; + const id = normalizeOptionalString(obj.id) ?? ""; + if (!id) { + return null; + } + const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id; - const contextLength = - typeof obj.context_length === "number" && Number.isFinite(obj.context_length) - ? obj.context_length - : null; - - const maxCompletionTokens = - typeof obj.max_completion_tokens === "number" && - Number.isFinite(obj.max_completion_tokens) - ? obj.max_completion_tokens - : typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens) - ? obj.max_output_tokens + const contextLength = + typeof obj.context_length === "number" && Number.isFinite(obj.context_length) + ? obj.context_length : null; - const supportedParameters = Array.isArray(obj.supported_parameters) - ? normalizeStringEntries( - obj.supported_parameters.filter((value) => typeof value === "string"), - ) - : []; + const maxCompletionTokens = + typeof obj.max_completion_tokens === "number" && + Number.isFinite(obj.max_completion_tokens) + ? obj.max_completion_tokens + : typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens) + ? obj.max_output_tokens + : null; - const supportedParametersCount = supportedParameters.length; - const supportsToolsMeta = supportedParameters.includes("tools"); + const supportedParameters = Array.isArray(obj.supported_parameters) + ? normalizeStringEntries( + obj.supported_parameters.filter((value) => typeof value === "string"), + ) + : []; - const modality = - typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null; + const supportedParametersCount = supportedParameters.length; + const supportsToolsMeta = supportedParameters.includes("tools"); - const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`); - const createdAtMs = normalizeCreatedAtMs(obj.created_at); - const pricing = parseOpenRouterPricing(obj.pricing); + const modality = + typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null; - return { - id, - name, - contextLength, - maxCompletionTokens, - supportedParameters, - supportedParametersCount, - supportsToolsMeta, - modality, - inferredParamB, - createdAtMs, - pricing, - } satisfies OpenRouterModelMeta; - }) - .filter((entry): entry is OpenRouterModelMeta => Boolean(entry)); + const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`); + const createdAtMs = normalizeCreatedAtMs(obj.created_at); + const pricing = parseOpenRouterPricing(obj.pricing); + + return { + id, + name, + contextLength, + maxCompletionTokens, + supportedParameters, + supportedParametersCount, + supportsToolsMeta, + modality, + inferredParamB, + createdAtMs, + pricing, + } satisfies OpenRouterModelMeta; + }) + .filter((entry): entry is OpenRouterModelMeta => Boolean(entry)); + }); } finally { if (res && !res.bodyUsed) { await res.body?.cancel().catch(() => undefined);