fix(gateway): wrap malformed pricing catalog json

This commit is contained in:
Vincent Koc
2026-05-14 19:15:20 +08:00
parent c96181fdbe
commit 8754094292
3 changed files with 48 additions and 1 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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`);
}