Skip to content

Commit 9c7a05c

Browse files
committed
fix(gateway): bound pricing catalog streams
1 parent d97574a commit 9c7a05c

2 files changed

Lines changed: 70 additions & 5 deletions

File tree

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,6 +1305,71 @@ describe("model-pricing-cache", () => {
13051305
cacheWrite: 0,
13061306
});
13071307
});
1308+
1309+
it("bounds streamed LiteLLM catalog responses without content-length", async () => {
1310+
const config = {
1311+
agents: {
1312+
defaults: {
1313+
model: { primary: "kimi/kimi-k2.6" },
1314+
},
1315+
},
1316+
} as unknown as OpenClawConfig;
1317+
1318+
const liteLLMCancel = vi.fn(async () => undefined);
1319+
let liteLLMPullCount = 0;
1320+
const fetchImpl = withFetchPreconnect(async (input: RequestInfo | URL) => {
1321+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
1322+
if (url.includes("openrouter.ai")) {
1323+
return new Response(
1324+
JSON.stringify({
1325+
data: [
1326+
{
1327+
id: "moonshotai/kimi-k2.6",
1328+
pricing: {
1329+
prompt: "0.00000095",
1330+
completion: "0.000004",
1331+
input_cache_read: "0.00000016",
1332+
},
1333+
},
1334+
],
1335+
}),
1336+
{
1337+
status: 200,
1338+
headers: { "Content-Type": "application/json" },
1339+
},
1340+
);
1341+
}
1342+
return new Response(
1343+
new ReadableStream({
1344+
pull(controller) {
1345+
liteLLMPullCount += 1;
1346+
controller.enqueue(new Uint8Array(liteLLMPullCount === 1 ? 5 * 1024 * 1024 : 1));
1347+
},
1348+
cancel: liteLLMCancel,
1349+
}),
1350+
{
1351+
status: 200,
1352+
headers: { "Content-Type": "application/json" },
1353+
},
1354+
);
1355+
});
1356+
1357+
await refreshGatewayModelPricingCache({ config, fetchImpl });
1358+
1359+
expect(liteLLMPullCount).toBeGreaterThanOrEqual(2);
1360+
expect(liteLLMCancel).toHaveBeenCalledOnce();
1361+
const health = getGatewayModelPricingHealth();
1362+
expect(health.sources[0]?.source).toBe("litellm");
1363+
expect(health.sources[0]?.detail).toContain(
1364+
"LiteLLM pricing response too large: 5242881 bytes",
1365+
);
1366+
expect(getCachedGatewayModelPricing({ provider: "kimi", model: "kimi-k2.6" })).toEqual({
1367+
input: 0.95,
1368+
output: 4,
1369+
cacheRead: 0.16,
1370+
cacheWrite: 0,
1371+
});
1372+
});
13081373
});
13091374

13101375
function createManifestRecord(overrides: Partial<PluginManifestRecord>): PluginManifestRecord {

src/gateway/model-pricing-cache.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Gateway model-pricing refresh and normalization.
22
// Fetches, normalizes, and schedules cached pricing for model usage estimates.
33
import type { ModelCatalogCost } from "@openclaw/model-catalog-core/model-catalog-types";
4+
import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit";
45
import {
56
normalizeOptionalString,
67
resolvePrimaryStringValue,
@@ -289,13 +290,12 @@ async function readPricingJsonObject(
289290
if (contentLength !== null && contentLength > MAX_PRICING_CATALOG_BYTES) {
290291
throw new Error(`${source} pricing response too large: ${contentLength} bytes`);
291292
}
292-
const buffer = await response.arrayBuffer();
293-
if (buffer.byteLength > MAX_PRICING_CATALOG_BYTES) {
294-
throw new Error(`${source} pricing response too large: ${buffer.byteLength} bytes`);
295-
}
293+
const buffer = await readResponseWithLimit(response, MAX_PRICING_CATALOG_BYTES, {
294+
onOverflow: ({ size }) => new Error(`${source} pricing response too large: ${size} bytes`),
295+
});
296296
let payload: unknown;
297297
try {
298-
payload = JSON.parse(Buffer.from(buffer).toString("utf8")) as unknown;
298+
payload = JSON.parse(buffer.toString("utf8")) as unknown;
299299
} catch {
300300
throw new Error(`${source} pricing response is malformed JSON`);
301301
}

0 commit comments

Comments
 (0)