Files
openclaw/extensions/qa-lab/src/model-catalog.runtime.ts
Peter Steinberger db02a96c4c refactor(process): route bounded commands through Execa (#106495)
* refactor(process): centralize bounded command execution

* refactor(process): migrate core one-shot commands

* refactor(plugins): migrate one-shot commands

* fix(process): await Windows tree termination

* chore(plugin-sdk): refresh process runtime surface

* refactor(process): migrate remaining bounded commands

* refactor(process): normalize command result handling

* refactor(process): split execution responsibilities

* chore(plugin-sdk): refresh API baseline

* chore(process): remove release-owned changelog entry

* fix(process): narrow binary command input checks

* fix(process): cap sandbox command output

* fix(qa-lab): preserve exact node probe env

* chore(ci): refresh dead export baseline

* fix(process): preserve force-kill command deadlines

* fix(process): avoid post-exit timeout reclassification

* test(process): update scp staging wrapper mock

* test(process): update remaining wrapper mocks

* refactor(qa-lab): preserve Execa tar execution
2026-07-13 11:07:35 -07:00

187 lines
6.0 KiB
TypeScript

// Qa Lab plugin module implements model catalog behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { QA_CHILD_STDERR_TAIL_BYTES, QA_CHILD_STDOUT_MAX_BYTES } from "./child-output.js";
import { resolveQaNodeExecPath } from "./node-exec.js";
import {
isPreferredQaLiveFrontierCatalogModel,
QA_FRONTIER_CATALOG_ALTERNATE_MODEL,
QA_FRONTIER_CATALOG_PRIMARY_MODEL,
QA_FRONTIER_PROVIDER_IDS,
} from "./providers/live-frontier/catalog.js";
import {
createQaChannelGatewayConfig,
QA_CHANNEL_REQUIRED_PLUGIN_IDS,
} from "./qa-channel-transport.js";
import { buildQaGatewayConfig } from "./qa-gateway-config.js";
type ModelRow = {
key: string;
name: string;
input: string;
available: boolean | null;
missing: boolean;
};
export type QaRunnerModelOption = {
key: string;
name: string;
provider: string;
input: string;
preferred: boolean;
};
function splitModelKey(key: string) {
const slash = key.indexOf("/");
if (slash <= 0 || slash === key.length - 1) {
return null;
}
return {
provider: key.slice(0, slash),
model: key.slice(slash + 1),
};
}
function selectQaRunnerModelOptions(rows: ModelRow[]): QaRunnerModelOption[] {
const options = rows
.filter((row) => row.available === true && !row.missing)
.map((row) => {
const parsed = splitModelKey(row.key);
return {
key: row.key,
name: row.name,
provider: parsed?.provider ?? "unknown",
input: row.input,
preferred: isPreferredQaLiveFrontierCatalogModel(row.key),
} satisfies QaRunnerModelOption;
});
return options.toSorted((left, right) => {
if (left.preferred !== right.preferred) {
return left.preferred ? -1 : 1;
}
const providerCompare = left.provider.localeCompare(right.provider);
if (providerCompare !== 0) {
return providerCompare;
}
return left.name.localeCompare(right.name);
});
}
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"
);
}
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() {
return new Error(CATALOG_ABORT_ERROR_MESSAGE);
}
export async function loadQaRunnerModelOptions(params: { repoRoot: string; signal?: AbortSignal }) {
const tempRoot = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-qa-model-catalog-"),
);
const workspaceDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const homeDir = path.join(tempRoot, "home");
const configPath = path.join(tempRoot, "openclaw.json");
try {
await Promise.all([
fs.mkdir(workspaceDir, { recursive: true }),
fs.mkdir(stateDir, { recursive: true }),
fs.mkdir(homeDir, { recursive: true }),
]);
const cfg = buildQaGatewayConfig({
bind: "loopback",
gatewayPort: 0,
gatewayToken: "qa-model-catalog",
workspaceDir,
providerMode: "live-frontier",
primaryModel: QA_FRONTIER_CATALOG_PRIMARY_MODEL,
alternateModel: QA_FRONTIER_CATALOG_ALTERNATE_MODEL,
enabledProviderIds: [...QA_FRONTIER_PROVIDER_IDS],
imageGenerationModel: null,
controlUiEnabled: false,
transportPluginIds: QA_CHANNEL_REQUIRED_PLUGIN_IDS,
transportConfig: createQaChannelGatewayConfig({
baseUrl: "http://127.0.0.1:9",
}),
});
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
const nodeExecPath = await resolveQaNodeExecPath();
const result = await runCommandWithTimeout(
[nodeExecPath, "dist/index.js", "models", "list", "--all", "--json"],
{
cwd: params.repoRoot,
env: {
HOME: homeDir,
OPENCLAW_HOME: homeDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_OAUTH_DIR: path.join(stateDir, "credentials"),
OPENCLAW_CODEX_DISCOVERY_LIVE: "0",
},
killProcessTree: true,
maxOutputBytes: {
stdout: QA_CHILD_STDOUT_MAX_BYTES,
stderr: QA_CHILD_STDERR_TAIL_BYTES,
},
outputCapture: { stdout: "head", stderr: "tail" },
signal: params.signal,
terminateOnOutputLimit: { stdout: true },
},
);
if (
params.signal?.aborted ||
(result.termination === "signal" && !result.outputLimitExceeded)
) {
throw createCatalogAbortError();
}
if (result.outputLimitExceeded || result.stdoutTruncatedBytes) {
throw new Error(
`qa model catalog stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`,
);
}
if (result.code !== 0) {
const stderrText = result.stderr.trim();
const stderrDetail = result.stderrTruncatedBytes
? `[qa model catalog stderr truncated to last ${QA_CHILD_STDERR_TAIL_BYTES} bytes]\n${stderrText}`
: stderrText;
throw new Error(`qa model catalog failed (${result.code ?? "unknown"}): ${stderrDetail}`);
}
return parseQaRunnerModelOptionsOutput(result.stdout);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
}