import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { resolvePnpmRunner } from "./pnpm-runner.mjs"; type RunResult = { code: number | null; signal: string | null; stdout: string; stderr: string; }; type OutputCapture = { text: string; truncatedChars: number; }; type PnpmCommand = { args: string[]; command: string; env?: NodeJS.ProcessEnv; shell: boolean; windowsVerbatimArguments?: boolean; }; type ResolvePnpmCommandOptions = { comSpec?: string; env?: NodeJS.ProcessEnv; execPath?: string; npmExecPath?: string; platform?: NodeJS.Platform; }; const COMMAND_OUTPUT_MAX_CHARS = 512 * 1024; type ReproLog = (message: string) => void; type RunCommand = typeof runCommand; type RunZaiFallbackReproDeps = { env?: NodeJS.ProcessEnv; error?: ReproLog; log?: ReproLog; mkdtemp?: typeof fs.mkdtemp; mkdir?: typeof fs.mkdir; randomUUID?: typeof randomUUID; readFile?: typeof fs.readFile; rm?: typeof fs.rm; runCommand?: RunCommand; warn?: ReproLog; writeFile?: typeof fs.writeFile; }; function resolveEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined { const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); return key === undefined ? undefined : env[key]; } export function appendBoundedReproOutput( capture: OutputCapture, chunk: unknown, maxChars = COMMAND_OUTPUT_MAX_CHARS, ): OutputCapture { const nextText = capture.text + String(chunk); if (nextText.length <= maxChars) { return { text: nextText, truncatedChars: capture.truncatedChars }; } const truncatedChars = capture.truncatedChars + nextText.length - maxChars; return { text: nextText.slice(-maxChars), truncatedChars }; } function formatBoundedReproOutput(capture: OutputCapture): string { if (capture.truncatedChars === 0) { return capture.text; } return `[output truncated ${capture.truncatedChars} chars; showing tail]\n${capture.text}`; } export function resolveZaiFallbackPnpmCommand( args: string[], options: ResolvePnpmCommandOptions = {}, ): PnpmCommand { const env = options.env ?? process.env; const command = resolvePnpmRunner({ comSpec: options.comSpec ?? resolveEnvValue(env, "ComSpec"), npmExecPath: options.npmExecPath ?? env.npm_execpath, nodeExecPath: options.execPath ?? process.execPath, platform: options.platform, pnpmArgs: args, }); if (command.env === undefined) { const invocation = { ...command }; delete invocation.env; return invocation; } return command; } function pickAnthropicEnv(env: NodeJS.ProcessEnv): { type: "oauth" | "api"; value: string } | null { const oauth = env.ANTHROPIC_OAUTH_TOKEN?.trim(); if (oauth) { return { type: "oauth", value: oauth }; } const api = env.ANTHROPIC_API_KEY?.trim(); if (api) { return { type: "api", value: api }; } return null; } function pickZaiKey(env: NodeJS.ProcessEnv): string | null { return env.ZAI_API_KEY?.trim() ?? env.Z_AI_API_KEY?.trim() ?? null; } async function runCommand( label: string, args: string[], env: NodeJS.ProcessEnv, ): Promise { return await new Promise((resolve, reject) => { const command = resolveZaiFallbackPnpmCommand(args, { env }); const child = spawn(command.command, command.args, { env: command.env ?? env, shell: command.shell, stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: command.windowsVerbatimArguments, }); let stdout: OutputCapture = { text: "", truncatedChars: 0 }; let stderr: OutputCapture = { text: "", truncatedChars: 0 }; child.stdout.on("data", (chunk) => { const text = String(chunk); stdout = appendBoundedReproOutput(stdout, text); process.stdout.write(text); }); child.stderr.on("data", (chunk) => { const text = String(chunk); stderr = appendBoundedReproOutput(stderr, text); process.stderr.write(text); }); child.on("error", (err) => reject(err)); child.on("close", (code, signal) => { const result = { code, signal, stdout: formatBoundedReproOutput(stdout), stderr: formatBoundedReproOutput(stderr), }; if (code === 0) { resolve(result); return; } resolve(result); const summary = signal ? `${label} exited with signal ${signal}` : `${label} exited with code ${code}`; console.error(summary); }); }); } export async function runZaiFallbackRepro(deps: RunZaiFallbackReproDeps = {}): Promise { const env = deps.env ?? process.env; const log = deps.log ?? console.log; const warn = deps.warn ?? console.warn; const error = deps.error ?? console.error; const mkdtemp = deps.mkdtemp ?? fs.mkdtemp; const mkdir = deps.mkdir ?? fs.mkdir; const readFile = deps.readFile ?? fs.readFile; const rm = deps.rm ?? fs.rm; const writeFile = deps.writeFile ?? fs.writeFile; const run = deps.runCommand ?? runCommand; const createUuid = deps.randomUUID ?? randomUUID; const anthropic = pickAnthropicEnv(env); const zaiKey = pickZaiKey(env); if (!anthropic) { error("Missing ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY."); return 1; } if (!zaiKey) { error("Missing ZAI_API_KEY or Z_AI_API_KEY."); return 1; } const baseDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zai-fallback-")); const stateDir = path.join(baseDir, "state"); const configPath = path.join(baseDir, "openclaw.json"); try { await mkdir(stateDir, { recursive: true }); const config = { agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6", fallbacks: ["zai/glm-4.7"], }, models: { "anthropic/claude-opus-4-6": {}, "anthropic/claude-opus-4-5": {}, "zai/glm-4.7": {}, }, }, }, }; await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); const sessionId = env.OPENCLAW_ZAI_FALLBACK_SESSION_ID ?? createUuid(); const baseEnv: NodeJS.ProcessEnv = { ...env, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_STATE_DIR: stateDir, ZAI_API_KEY: zaiKey, Z_AI_API_KEY: "", }; const envValidAnthropic: NodeJS.ProcessEnv = { ...baseEnv, ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? anthropic.value : "", ANTHROPIC_API_KEY: anthropic.type === "api" ? anthropic.value : "", }; const envInvalidAnthropic: NodeJS.ProcessEnv = { ...baseEnv, ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? "invalid" : "", ANTHROPIC_API_KEY: anthropic.type === "api" ? "invalid" : "", }; log("== Run 1: create tool history (primary only)"); const toolPrompt = "Use the exec tool to create a file named zai-fallback-tool.txt with the content tool-ok. " + "Then use the read tool to display the file contents. Reply with just the file contents."; const run1 = await run( "run1", ["openclaw", "agent", "--local", "--session-id", sessionId, "--message", toolPrompt], envValidAnthropic, ); if (run1.code !== 0) { return run1.code ?? 1; } const sessionFile = path.join(stateDir, "agents", "main", "sessions", `${sessionId}.jsonl`); const transcript = await readFile(sessionFile, "utf8").catch(() => ""); if (!transcript.includes('"toolResult"')) { warn("Warning: no toolResult entries detected in session history."); } log("== Run 2: force auth failover to Z.AI"); const followupPrompt = "What is the content of zai-fallback-tool.txt? Reply with just the contents."; const run2 = await run( "run2", ["openclaw", "agent", "--local", "--session-id", sessionId, "--message", followupPrompt], envInvalidAnthropic, ); if (run2.code === 0) { log("PASS: fallback succeeded."); return 0; } error("FAIL: fallback failed."); return run2.code ?? 1; } finally { await rm(baseDir, { force: true, recursive: true }); } } async function main() { process.exitCode = await runZaiFallbackRepro(); } function isCliEntrypoint() { const entrypoint = process.argv[1]; return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href); } if (isCliEntrypoint()) { await main().catch((err: unknown) => { console.error(err); process.exit(1); }); }