Skip to content

Commit 86b24ac

Browse files
committed
fix(gateway): cancel pricing fetch bodies
1 parent d236612 commit 86b24ac

2 files changed

Lines changed: 84 additions & 15 deletions

File tree

src/gateway/model-pricing-cache.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,45 @@ describe("model-pricing-cache", () => {
454454
});
455455
});
456456

457+
it("cancels remote pricing error response bodies", async () => {
458+
const config = {
459+
agents: {
460+
defaults: {
461+
model: { primary: "custom/gpt-remote" },
462+
},
463+
},
464+
models: {
465+
providers: {
466+
custom: {
467+
baseUrl: "https://models.example/v1",
468+
api: "openai-completions",
469+
models: [{ id: "gpt-remote" }],
470+
},
471+
},
472+
},
473+
} as unknown as OpenClawConfig;
474+
const openRouterResponse = new Response("rate limited", { status: 429 });
475+
const cancel = vi.spyOn(openRouterResponse.body!, "cancel").mockResolvedValue(undefined);
476+
const fetchImpl = withFetchPreconnect(async (input: RequestInfo | URL) => {
477+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
478+
if (url.includes("openrouter.ai")) {
479+
return openRouterResponse;
480+
}
481+
return new Response(JSON.stringify({}), {
482+
status: 200,
483+
headers: { "Content-Type": "application/json" },
484+
});
485+
});
486+
487+
await refreshGatewayModelPricingCache({ config, fetchImpl });
488+
489+
expect(cancel).toHaveBeenCalledOnce();
490+
const health = getGatewayModelPricingHealth();
491+
expect(health.state).toBe("degraded");
492+
expect(health.sources[0]?.source).toBe("openrouter");
493+
expect(health.sources[0]?.detail).toContain("HTTP 429");
494+
});
495+
457496
it("records malformed remote pricing catalog JSON as source failures", async () => {
458497
const config = {
459498
agents: {
@@ -1222,6 +1261,7 @@ describe("model-pricing-cache", () => {
12221261
},
12231262
} as unknown as OpenClawConfig;
12241263

1264+
const liteLLMCancel = vi.fn(async () => undefined);
12251265
const fetchImpl = withFetchPreconnect(async (input: RequestInfo | URL) => {
12261266
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
12271267
if (url.includes("openrouter.ai")) {
@@ -1244,17 +1284,20 @@ describe("model-pricing-cache", () => {
12441284
},
12451285
);
12461286
}
1247-
return new Response("{}", {
1287+
const liteLLMResponse = new Response("{}", {
12481288
status: 200,
12491289
headers: {
12501290
"Content-Type": "application/json",
12511291
"Content-Length": "6000000",
12521292
},
12531293
});
1294+
vi.spyOn(liteLLMResponse.body!, "cancel").mockImplementation(liteLLMCancel);
1295+
return liteLLMResponse;
12541296
});
12551297

12561298
await refreshGatewayModelPricingCache({ config, fetchImpl });
12571299

1300+
expect(liteLLMCancel).toHaveBeenCalledOnce();
12581301
expect(getCachedGatewayModelPricing({ provider: "kimi", model: "kimi-k2.6" })).toEqual({
12591302
input: 0.95,
12601303
output: 4,

src/gateway/model-pricing-cache.ts

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,12 @@ function toCachedModelPricing(
275275
};
276276
}
277277

278+
async function cancelUnreadResponseBody(response: Response | undefined): Promise<void> {
279+
if (response?.bodyUsed !== true) {
280+
await response?.body?.cancel().catch(() => undefined);
281+
}
282+
}
283+
278284
async function readPricingJsonObject(
279285
response: Response,
280286
source: string,
@@ -299,6 +305,28 @@ async function readPricingJsonObject(
299305
return payload as Record<string, unknown>;
300306
}
301307

308+
async function fetchPricingJsonObject(params: {
309+
fetchImpl: typeof fetch;
310+
url: string;
311+
source: string;
312+
failureLabel: string;
313+
signal?: AbortSignal;
314+
}): Promise<Record<string, unknown>> {
315+
let response: Response | undefined;
316+
try {
317+
response = await params.fetchImpl(params.url, {
318+
headers: { Accept: "application/json" },
319+
signal: createPricingFetchSignal(params.signal),
320+
});
321+
if (!response.ok) {
322+
throw new Error(`${params.failureLabel}: HTTP ${response.status}`);
323+
}
324+
return await readPricingJsonObject(response, params.source);
325+
} finally {
326+
await cancelUnreadResponseBody(response);
327+
}
328+
}
329+
302330
// ---------------------------------------------------------------------------
303331
// LiteLLM tiered-pricing parsing
304332
// ---------------------------------------------------------------------------
@@ -383,14 +411,13 @@ async function fetchLiteLLMPricingCatalog(
383411
fetchImpl: typeof fetch,
384412
signal?: AbortSignal,
385413
): Promise<LiteLLMPricingCatalog> {
386-
const response = await fetchImpl(LITELLM_PRICING_URL, {
387-
headers: { Accept: "application/json" },
388-
signal: createPricingFetchSignal(signal),
414+
const payload = await fetchPricingJsonObject({
415+
fetchImpl,
416+
url: LITELLM_PRICING_URL,
417+
source: "LiteLLM",
418+
failureLabel: "LiteLLM pricing fetch failed",
419+
signal,
389420
});
390-
if (!response.ok) {
391-
throw new Error(`LiteLLM pricing fetch failed: HTTP ${response.status}`);
392-
}
393-
const payload = await readPricingJsonObject(response, "LiteLLM");
394421
const catalog: LiteLLMPricingCatalog = new Map();
395422
for (const [key, value] of Object.entries(payload)) {
396423
if (!value || typeof value !== "object") {
@@ -1059,14 +1086,13 @@ async function fetchOpenRouterPricingCatalog(
10591086
fetchImpl: typeof fetch,
10601087
signal?: AbortSignal,
10611088
): Promise<Map<string, OpenRouterPricingEntry>> {
1062-
const response = await fetchImpl(OPENROUTER_MODELS_URL, {
1063-
headers: { Accept: "application/json" },
1064-
signal: createPricingFetchSignal(signal),
1089+
const payload = await fetchPricingJsonObject({
1090+
fetchImpl,
1091+
url: OPENROUTER_MODELS_URL,
1092+
source: "OpenRouter",
1093+
failureLabel: "OpenRouter /models failed",
1094+
signal,
10651095
});
1066-
if (!response.ok) {
1067-
throw new Error(`OpenRouter /models failed: HTTP ${response.status}`);
1068-
}
1069-
const payload = await readPricingJsonObject(response, "OpenRouter");
10701096
const entries = Array.isArray(payload.data) ? payload.data : [];
10711097
const catalog = new Map<string, OpenRouterPricingEntry>();
10721098
for (const entry of entries) {

0 commit comments

Comments
 (0)