mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 04:41:14 +00:00
* 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
187 lines
6.0 KiB
TypeScript
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 });
|
|
}
|
|
}
|