fix(qa-lab): wrap malformed model catalog json

This commit is contained in:
Vincent Koc
2026-05-14 19:18:37 +08:00
parent 8f7a3cbff3
commit b02de2e948
3 changed files with 59 additions and 3 deletions

View File

@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
- 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.
- QA Lab: report malformed model-catalog subprocess JSON with an owned error and ignore invalid catalog rows.
- 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

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { selectQaRunnerModelOptions } from "./model-catalog.runtime.js";
import {
parseQaRunnerModelOptionsOutput,
selectQaRunnerModelOptions,
} from "./model-catalog.runtime.js";
describe("qa runner model catalog", () => {
it("filters to available rows and prefers gpt-5.5 first", () => {
@@ -29,4 +32,29 @@ describe("qa runner model catalog", () => {
]).map((entry) => entry.key),
).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
});
it("reports malformed catalog JSON with an owned error", () => {
expect(() => parseQaRunnerModelOptionsOutput("{not json")).toThrow(
"qa model catalog returned malformed JSON",
);
});
it("ignores invalid catalog rows without failing the model picker", () => {
expect(
parseQaRunnerModelOptionsOutput(
JSON.stringify({
models: [
null,
{
key: "openai/gpt-5.5",
name: "gpt-5.5",
input: "text,image",
available: true,
missing: false,
},
],
}),
).map((entry) => entry.key),
).toEqual(["openai/gpt-5.5"]);
});
});

View File

@@ -68,6 +68,34 @@ export function selectQaRunnerModelOptions(rows: ModelRow[]): QaRunnerModelOptio
});
}
function isModelRow(value: unknown): value is ModelRow {
if (!value || typeof value !== "object") {
return false;
}
const row = value as Partial<ModelRow>;
return (
typeof row.key === "string" &&
typeof row.name === "string" &&
typeof row.input === "string" &&
(row.available === true || row.available === false || row.available === null) &&
typeof row.missing === "boolean"
);
}
export function parseQaRunnerModelOptionsOutput(stdout: string): QaRunnerModelOption[] {
let payload: unknown;
try {
payload = JSON.parse(stdout) as unknown;
} catch {
throw new Error("qa model catalog returned malformed JSON");
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("qa model catalog returned invalid JSON payload");
}
const rows = (payload as { models?: unknown }).models;
return selectQaRunnerModelOptions(Array.isArray(rows) ? rows.filter(isModelRow) : []);
}
const CATALOG_ABORT_ERROR_MESSAGE = "qa model catalog aborted";
function createCatalogAbortError() {
@@ -199,8 +227,7 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa
});
});
const payload = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { models?: ModelRow[] };
return selectQaRunnerModelOptions(payload.models ?? []);
return parseQaRunnerModelOptionsOutput(Buffer.concat(stdout).toString("utf8"));
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}