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 <li.long15@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-16 21:01:40 +08:00
committed by GitHub
parent 1f05cb45cf
commit fece179cf9
2 changed files with 123 additions and 59 deletions

View File

@@ -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<Response>((_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<typeof setInterval> | undefined;
let completionTimer: ReturnType<typeof setTimeout> | undefined;
return new Response(
new ReadableStream<Uint8Array>({
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.

View File

@@ -218,74 +218,76 @@ async function fetchOpenRouterModels(
): Promise<OpenRouterModelMeta[]> {
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<string, unknown>;
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<string, unknown>;
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);