From 875409429244ca860890301effb3ddfc65f5f9fb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 14 May 2026 19:15:20 +0800 Subject: [PATCH] fix(gateway): wrap malformed pricing catalog json --- CHANGELOG.md | 1 + src/gateway/model-pricing-cache.test.ts | 41 +++++++++++++++++++++++++ src/gateway/model-pricing-cache.ts | 7 ++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d51b7fb35..4e7b85f0860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai - Voice-call provider APIs: report malformed successful guarded JSON responses with provider-prefixed errors instead of leaking raw parser failures. - Realtime transcription: report malformed provider websocket JSON frames with owned parser errors instead of leaking raw `SyntaxError` objects. - Microsoft Foundry: report malformed Azure CLI token JSON with owned auth errors instead of leaking raw parser failures. +- Gateway/model pricing: report malformed external pricing catalog JSON with source-owned errors instead of leaking raw parser failures. - Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom. - Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd. - CLI/onboarding: forward provider-specific auth flags (e.g. `--openai-api-key`) through the onboarding wizard so they reach provider auth methods via `ctx.opts`, letting `--openai-api-key "$OPENAI_API_KEY"` skip the redundant "use existing env var?" prompt in non-interactive harnesses. (#81669) Thanks @sjf. diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 4db755086f0..afe276bf5fe 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -452,6 +452,47 @@ describe("model-pricing-cache", () => { }); }); + it("records malformed remote pricing catalog JSON as source failures", async () => { + const config = { + agents: { + defaults: { + model: { primary: "custom/gpt-remote" }, + }, + }, + models: { + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-completions", + models: [{ id: "gpt-remote" }], + }, + }, + }, + } as unknown as OpenClawConfig; + const fetchImpl = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("openrouter.ai")) { + return new Response("{not json", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await refreshGatewayModelPricingCache({ config, fetchImpl }); + + const health = getGatewayModelPricingHealth(); + expect(health.state).toBe("degraded"); + expect(health.sources).toHaveLength(1); + expect(health.sources[0]?.source).toBe("openrouter"); + expect(health.sources[0]?.state).toBe("degraded"); + expect(health.sources[0]?.detail).toContain("OpenRouter pricing response is malformed JSON"); + }); + it("records and clears scheduled refresh rejections for health surfaces", async () => { vi.useFakeTimers(); try { diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index a85e8cc97a4..6bebc5c6e55 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -261,7 +261,12 @@ async function readPricingJsonObject( if (buffer.byteLength > MAX_PRICING_CATALOG_BYTES) { throw new Error(`${source} pricing response too large: ${buffer.byteLength} bytes`); } - const payload = JSON.parse(Buffer.from(buffer).toString("utf8")) as unknown; + let payload: unknown; + try { + payload = JSON.parse(Buffer.from(buffer).toString("utf8")) as unknown; + } catch { + throw new Error(`${source} pricing response is malformed JSON`); + } if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new Error(`${source} pricing response is not a JSON object`); }