feat(baoyu-image-gen): add codex-cli provider wrapping codex-imagegen

Expose Codex CLI's built-in image_gen tool through baoyu-image-gen's
standard CLI + batch flow as a dedicated provider. The provider spawns
the bundled scripts/codex-imagegen/main.ts (synced from
packages/baoyu-codex-imagegen/src/) so the skill remains self-contained
and inherits retry, cache, file-lock, and JSONL logging. No
OPENAI_API_KEY required — uses the user's Codex subscription.

New env vars: BAOYU_CODEX_IMAGEGEN_{BIN,CACHE_DIR,TIMEOUT_MS,RETRIES,LOG_FILE}.
Hyphenated provider names now resolve under-scored env vars (e.g.,
BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY). codex-cli is never auto-selected
— pin via --provider or default_provider in EXTEND.md.
This commit is contained in:
Jim Liu 宝玉
2026-05-25 00:18:27 -05:00
parent 23fac03691
commit 49e9c46ca4
17 changed files with 1127 additions and 21 deletions
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { CliArgs } from "../types.ts";
import {
getDefaultModel,
getDefaultOutputExtension,
validateArgs,
} from "./codex-cli.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: "codex-cli",
model: null,
aspectRatio: null,
aspectRatioSource: null,
size: null,
quality: "2k",
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
test("codex-cli defaults to codex-image-gen model and PNG output", () => {
assert.equal(getDefaultModel(), "codex-image-gen");
assert.equal(getDefaultOutputExtension(), ".png");
});
test("codex-cli validateArgs rejects n>1 with a non-retryable message", () => {
assert.throws(
() => validateArgs("codex-image-gen", makeArgs({ n: 2 })),
/supports only n=1/,
);
});
test("codex-cli validateArgs rejects ratio-metadata dialect", () => {
assert.throws(
() => validateArgs("codex-image-gen", makeArgs({ imageApiDialect: "ratio-metadata" })),
/Invalid imageApiDialect/,
);
});
test("codex-cli validateArgs accepts default n=1 with no dialect", () => {
assert.doesNotThrow(() => validateArgs("codex-image-gen", makeArgs()));
});
test("codex-cli validateArgs accepts reference images (Codex image_gen supports refs)", () => {
assert.doesNotThrow(() =>
validateArgs("codex-image-gen", makeArgs({ referenceImages: ["/tmp/a.png", "/tmp/b.png"] })),
);
});
@@ -0,0 +1,197 @@
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { mkdir, readFile, rm, writeFile, access } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import type { CliArgs } from "../types";
const PROVIDER_FILE = fileURLToPath(import.meta.url);
const SCRIPTS_DIR = path.resolve(path.dirname(PROVIDER_FILE), "..");
const BUNDLED_WRAPPER = path.join(SCRIPTS_DIR, "codex-imagegen", "main.ts");
type WrapperOkResult = {
status: "ok";
path: string;
bytes: number;
elapsed_seconds: number;
thread_id: string | null;
attempts: number;
cached: boolean;
};
type WrapperErrorResult = {
status: "error";
path: string;
bytes: number;
error: string;
error_kind: string;
};
type WrapperResult = WrapperOkResult | WrapperErrorResult;
export function getDefaultModel(): string {
return "codex-image-gen";
}
export function getDefaultOutputExtension(): string {
return ".png";
}
export function validateArgs(_model: string, args: CliArgs): void {
if (args.n > 1) {
throw new Error(
"codex-cli provider supports only n=1 (Codex image_gen returns a single image per call).",
);
}
if (args.imageApiDialect && args.imageApiDialect !== "openai-native") {
throw new Error(
`Invalid imageApiDialect for codex-cli: ${args.imageApiDialect}. codex-cli does not use OpenAI Images API dialects.`,
);
}
}
async function exists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function resolveWrapperPath(): Promise<string> {
const override = process.env.BAOYU_CODEX_IMAGEGEN_BIN;
if (override) {
if (!(await exists(override))) {
throw new Error(
`Invalid BAOYU_CODEX_IMAGEGEN_BIN: ${override} does not exist.`,
);
}
return override;
}
if (await exists(BUNDLED_WRAPPER)) return BUNDLED_WRAPPER;
throw new Error(
`codex-cli wrapper not found at ${BUNDLED_WRAPPER}. ` +
`Reinstall baoyu-image-gen, or set BAOYU_CODEX_IMAGEGEN_BIN to a codex-imagegen main.ts (or .sh) path.`,
);
}
type SpawnResult = {
stdout: string;
stderr: string;
code: number;
};
async function spawnWrapper(wrapperPath: string, cliArgs: string[]): Promise<SpawnResult> {
return new Promise((resolve, reject) => {
const isTs = wrapperPath.endsWith(".ts");
const command = isTs ? "bun" : wrapperPath;
const args = isTs ? [wrapperPath, ...cliArgs] : cliArgs;
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
stderr += text;
process.stderr.write(text);
});
child.on("error", (err) => reject(err));
child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 }));
});
}
function parseWrapperJson(stdout: string): WrapperResult {
const trimmed = stdout.trim();
if (!trimmed) {
throw new Error("Invalid codex-cli response: empty stdout from wrapper.");
}
const lastLine = trimmed.split(/\r?\n/).pop() ?? trimmed;
try {
return JSON.parse(lastLine) as WrapperResult;
} catch (parseErr) {
throw new Error(
`Invalid codex-cli response: could not parse JSON from wrapper stdout (${(parseErr as Error).message}).`,
);
}
}
function parsePositiveInt(value: string | undefined): number | null {
if (!value) return null;
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getEnvOverride(name: string): string | null {
const value = process.env[name];
return value && value.length > 0 ? value : null;
}
export async function generateImage(
prompt: string,
_model: string,
args: CliArgs,
): Promise<Uint8Array> {
const wrapperPath = await resolveWrapperPath();
const sessionDir = path.join(tmpdir(), "baoyu-image-gen-codex-cli");
await mkdir(sessionDir, { recursive: true });
const token = randomBytes(8).toString("hex");
const tmpOutput = path.join(sessionDir, `out-${token}.png`);
const tmpPrompt = path.join(sessionDir, `prompt-${token}.md`);
await writeFile(tmpPrompt, prompt, "utf8");
const aspect = args.aspectRatio ?? "1:1";
const cliArgs: string[] = [
"--image",
tmpOutput,
"--prompt-file",
tmpPrompt,
"--aspect",
aspect,
];
for (const ref of args.referenceImages) {
cliArgs.push("--ref", path.resolve(ref));
}
const cacheDir = getEnvOverride("BAOYU_CODEX_IMAGEGEN_CACHE_DIR");
if (cacheDir) cliArgs.push("--cache-dir", cacheDir);
const timeoutMs = parsePositiveInt(process.env.BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS);
if (timeoutMs) cliArgs.push("--timeout", String(timeoutMs));
const retries = parsePositiveInt(process.env.BAOYU_CODEX_IMAGEGEN_RETRIES);
if (retries !== null) cliArgs.push("--retries", String(retries));
const logFile = getEnvOverride("BAOYU_CODEX_IMAGEGEN_LOG_FILE");
if (logFile) cliArgs.push("--log-file", logFile);
try {
const spawnResult = await spawnWrapper(wrapperPath, cliArgs);
const parsed = parseWrapperJson(spawnResult.stdout);
if (parsed.status === "error") {
throw new Error(
`Invalid codex-cli result (${parsed.error_kind}): ${parsed.error}`,
);
}
if (spawnResult.code !== 0) {
throw new Error(
`Invalid codex-cli result: wrapper exited with code ${spawnResult.code} despite reporting status=ok.`,
);
}
const bytes = await readFile(parsed.path ?? tmpOutput);
return new Uint8Array(bytes);
} finally {
await Promise.allSettled([
rm(tmpOutput, { force: true }),
rm(tmpPrompt, { force: true }),
]);
}
}