Skip to content

Commit dbd5689

Browse files
committed
fix(agents): cancel model scan error bodies
1 parent 44b0644 commit dbd5689

2 files changed

Lines changed: 83 additions & 65 deletions

File tree

src/agents/model-scan.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ describe("scanOpenRouterModels", () => {
103103
expect(result?.createdAtMs).toBeNull();
104104
});
105105

106+
it("cancels catalog error response bodies", async () => {
107+
const response = new Response("unavailable", { status: 503 });
108+
const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
109+
const fetchImpl = withFetchPreconnect(async () => response);
110+
111+
await expect(scanOpenRouterModels({ fetchImpl, probe: false })).rejects.toThrow(/HTTP 503/);
112+
113+
expect(cancel).toHaveBeenCalledOnce();
114+
});
115+
106116
it("requires an API key when probing", async () => {
107117
const fetchImpl = createFetchFixture({ data: [] });
108118
await withEnvAsync({ OPENROUTER_API_KEY: undefined }, async () => {

src/agents/model-scan.ts

Lines changed: 73 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -188,73 +188,81 @@ async function fetchOpenRouterModels(
188188
fetchImpl: typeof fetch,
189189
timeoutMs: number,
190190
): Promise<OpenRouterModelMeta[]> {
191-
const res = await withTimeout(timeoutMs, (signal) =>
192-
fetchImpl(OPENROUTER_MODELS_URL, {
193-
headers: { Accept: "application/json" },
194-
signal,
195-
}),
196-
);
197-
if (!res.ok) {
198-
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
199-
}
200-
const payload = (await res.json()) as { data?: unknown };
201-
const entries = Array.isArray(payload.data) ? payload.data : [];
202-
203-
return entries
204-
.map((entry) => {
205-
if (!entry || typeof entry !== "object") {
206-
return null;
207-
}
208-
const obj = entry as Record<string, unknown>;
209-
const id = normalizeOptionalString(obj.id) ?? "";
210-
if (!id) {
211-
return null;
212-
}
213-
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
214-
215-
const contextLength =
216-
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
217-
? obj.context_length
218-
: null;
219-
220-
const maxCompletionTokens =
221-
typeof obj.max_completion_tokens === "number" && Number.isFinite(obj.max_completion_tokens)
222-
? obj.max_completion_tokens
223-
: typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens)
224-
? obj.max_output_tokens
191+
let res: Response | undefined;
192+
try {
193+
res = await withTimeout(timeoutMs, (signal) =>
194+
fetchImpl(OPENROUTER_MODELS_URL, {
195+
headers: { Accept: "application/json" },
196+
signal,
197+
}),
198+
);
199+
if (!res.ok) {
200+
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
201+
}
202+
const payload = (await res.json()) as { data?: unknown };
203+
const entries = Array.isArray(payload.data) ? payload.data : [];
204+
205+
return entries
206+
.map((entry) => {
207+
if (!entry || typeof entry !== "object") {
208+
return null;
209+
}
210+
const obj = entry as Record<string, unknown>;
211+
const id = normalizeOptionalString(obj.id) ?? "";
212+
if (!id) {
213+
return null;
214+
}
215+
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
216+
217+
const contextLength =
218+
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
219+
? obj.context_length
225220
: null;
226221

227-
const supportedParameters = Array.isArray(obj.supported_parameters)
228-
? normalizeStringEntries(
229-
obj.supported_parameters.filter((value) => typeof value === "string"),
230-
)
231-
: [];
232-
233-
const supportedParametersCount = supportedParameters.length;
234-
const supportsToolsMeta = supportedParameters.includes("tools");
235-
236-
const modality =
237-
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
238-
239-
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
240-
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
241-
const pricing = parseOpenRouterPricing(obj.pricing);
242-
243-
return {
244-
id,
245-
name,
246-
contextLength,
247-
maxCompletionTokens,
248-
supportedParameters,
249-
supportedParametersCount,
250-
supportsToolsMeta,
251-
modality,
252-
inferredParamB,
253-
createdAtMs,
254-
pricing,
255-
} satisfies OpenRouterModelMeta;
256-
})
257-
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
222+
const maxCompletionTokens =
223+
typeof obj.max_completion_tokens === "number" &&
224+
Number.isFinite(obj.max_completion_tokens)
225+
? obj.max_completion_tokens
226+
: typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens)
227+
? obj.max_output_tokens
228+
: null;
229+
230+
const supportedParameters = Array.isArray(obj.supported_parameters)
231+
? normalizeStringEntries(
232+
obj.supported_parameters.filter((value) => typeof value === "string"),
233+
)
234+
: [];
235+
236+
const supportedParametersCount = supportedParameters.length;
237+
const supportsToolsMeta = supportedParameters.includes("tools");
238+
239+
const modality =
240+
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
241+
242+
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
243+
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
244+
const pricing = parseOpenRouterPricing(obj.pricing);
245+
246+
return {
247+
id,
248+
name,
249+
contextLength,
250+
maxCompletionTokens,
251+
supportedParameters,
252+
supportedParametersCount,
253+
supportsToolsMeta,
254+
modality,
255+
inferredParamB,
256+
createdAtMs,
257+
pricing,
258+
} satisfies OpenRouterModelMeta;
259+
})
260+
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
261+
} finally {
262+
if (res && !res.bodyUsed) {
263+
await res.body?.cancel().catch(() => undefined);
264+
}
265+
}
258266
}
259267

260268
async function probeTool(

0 commit comments

Comments
 (0)