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);