mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 04:11:12 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user