
Baoyu Image Gen
- 25 installs
- 941 repo stars
- Updated August 5, 2026
- guanyang/antigravity-skills
Generates AI images via official APIs (OpenAI GPT Image 2, Azure, Google, OpenRouter, DashScope, GLM-Image, MiniMax, Replicate and more) with text-to-image, reference images, and batch runs.
About
An image-generation skill wrapping many official provider APIs, supporting text-to-image, reference images, aspect ratios, and batch generation from prompt files. A developer uses it to create images sequentially or in parallel batches after a blocking preferences setup.
- Multi-provider: GPT Image 2, Azure, Google, DashScope, MiniMax, Replicate, and others
- Blocking Step 0 loads or creates an EXTEND.md preferences file before generating
Baoyu Image Gen by the numbers
- 25 all-time installs (skills.sh)
- Ranked #990 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/guanyang/antigravity-skills --skill baoyu-image-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 941 |
| Last updated | August 5, 2026 |
| Repository | guanyang/antigravity-skills ↗ |
What it does
Generates AI images via official APIs (OpenAI GPT Image 2, Azure, Google, OpenRouter, DashScope, GLM-Image, MiniMax, Replicate and more) with text-to-image, reference images, and batch runs.
Files
Image Generation (AI SDK)
Official API-based image generation. Supports OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Z.AI GLM-Image, MiniMax, Jimeng (即梦), Seedream (豆包), Replicate and Agnes.
User Input Tools
When this skill prompts the user, follow this tool-selection rule (priority order):
1. Prefer built-in user-input tools exposed by the current agent runtime — e.g., AskUserQuestion, request_user_input, clarify, ask_user, or any equivalent. 2. Fallback: if no such tool exists, emit a numbered plain-text message and ask the user to reply with the chosen number/answer for each question. 3. Batching: if the tool supports multiple questions per call, combine all applicable questions into a single call; if only single-question, ask them one at a time in priority order.
Concrete AskUserQuestion references below are examples — substitute the local equivalent in other runtimes.
Script Directory
{baseDir} = this SKILL.md's directory. All scripts/... paths below are relative to {baseDir}. Main script: {baseDir}/scripts/main.ts. Batch payload helper: {baseDir}/scripts/build-batch.ts. Resolve ${BUN_X}: prefer bun; else npx -y bun; else suggest brew install oven-sh/bun/bun.
Step 0: Load Preferences ⛔ BLOCKING
This step MUST complete before any image generation — generation is blocked until EXTEND.md exists.
Check these paths in order; first hit wins:
| Path | Scope |
|---|---|
.baoyu-skills/baoyu-image-gen/EXTEND.md | Project |
${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-gen/EXTEND.md | XDG |
$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md | User home |
- Found → load, parse, apply. If
default_model.[provider]is null → ask model only. - Not found → run first-time setup (
references/config/first-time-setup.md) using AskUserQuestion to collect provider + model + quality + save location. Save EXTEND.md, then continue. Do not generate images before this completes.
Legacy compatibility: if .baoyu-skills/baoyu-imagine/EXTEND.md exists and the new path doesn't, the runtime renames it to baoyu-image-gen. If both exist, the runtime leaves them alone and uses the new path.
EXTEND.md keys: default provider, default quality, default aspect ratio, default image size, OpenAI image API dialect, default models, batch worker cap, provider-specific batch limits. Schema: references/config/preferences-schema.md.
Usage
Minimum working examples — see references/usage-examples.md for the full set including per-provider invocations and batch mode.
Identity-preserving reference prompts
When the user wants a real person/character/object preserved from reference images, do not replace the reference with a long generic description. Prefer short, hard identity-preservation language:
- "Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject."
- "Only change scene, clothing, pose, lighting, rendering style, and composition. Keep the face/proportions/hair/key accessories/overall identity from the references."
- If using multiple references, state that they are the same subject and should jointly define identity.
Pitfall: long descriptions like "young East Asian woman, oval face, clear eyes..." can cause the model to synthesize a new person matching the description instead of preserving the referenced person.
# Basic
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
# With aspect ratio and high quality
${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9 --quality 2k
# Prompt from files
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
# With reference image
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
# Specific provider
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider dashscope --model qwen-image-2.0-pro
# OpenAI GPT Image 2
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai --model gpt-image-2
# Codex CLI (uses logged-in Codex subscription — no OPENAI_API_KEY required; requires `codex` on PATH)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider codex-cli --ar 16:9
# Batch mode
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
# Build a batch file from outline.md + prompts/ (e.g. baoyu-article-illustrator output)
${BUN_X} {baseDir}/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4Reference-Image Identity Preservation
When the user wants a person/object preserved from reference images:
- Prefer a small curated set of existing source references (usually 2–4) over many images; large multi-megabyte refs can destabilize streaming providers.
- Make the prompt say the references are the same subject and the output must use that identity. Avoid long generic facial-feature descriptions that can cause the model to synthesize a new similar-looking person.
- Do not use newly generated outputs as references unless the user explicitly asks; generated refs compound drift.
- If results become too polished or influencer-like, reduce stylized refs and add explicit anti-beautification constraints (no face slimming, eye enlargement, heavy makeup, commercial travel shoot, over-smoothing).
- If the subject should look younger/older, preserve the face and express age through clothing, posture, scene, and styling; do not ask the model to change facial identity.
Options
| Option | Description |
|---|---|
--prompt <text>, -p | Prompt text |
--promptfiles <files...> | Read prompt from files (concatenated) |
--image <path> | Output image path (required in single-image mode) |
--batchfile <path> | JSON batch file for multi-image generation |
--jobs <count> | Worker count for batch mode (default: auto, max from config, built-in default 10) |
| `--provider google\ | openai\ |
--model <id>, -m | Model ID — see provider references for defaults and allowed values |
--ar <ratio> | Aspect ratio (16:9, 1:1, 4:3, …) |
--size <WxH> | Explicit size (e.g., 1024x1024; for gpt-image-2, width/height must be multiples of 16, max edge 3840px, ratio no wider than 3:1) |
| `--quality normal\ | 2k` |
| `--imageSize 1K\ | 2K\ |
| `--imageApiDialect openai-native\ | ratio-metadata` |
--ref <files...> | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, MiniMax subject-reference, Seedream 5.0/4.5/4.0, DashScope wan2.7-image-pro/wan2.7-image. Not supported by Jimeng, Seedream 3.0, SeedEdit 3.0, or any DashScope model outside the wan2.7-image* family |
--n <count> | Number of images. Replicate requires --n 1 (single-output save semantics) |
--json | JSON output |
Environment Variables
| Variable | Description |
|---|---|
OPENAI_API_KEY | OpenAI API key |
AZURE_OPENAI_API_KEY | Azure OpenAI API key |
OPENROUTER_API_KEY | OpenRouter API key |
GOOGLE_API_KEY | Google API key |
DASHSCOPE_API_KEY | DashScope API key |
ZAI_API_KEY (alias BIGMODEL_API_KEY) | Z.AI API key |
MINIMAX_API_KEY | MiniMax API key |
REPLICATE_API_TOKEN | Replicate API token |
JIMENG_ACCESS_KEY_ID, JIMENG_SECRET_ACCESS_KEY | Jimeng (即梦) Volcengine credentials |
ARK_API_KEY | Seedream (豆包) Volcengine ARK API key |
<PROVIDER>_IMAGE_MODEL | Per-provider model override (OPENAI_IMAGE_MODEL, GOOGLE_IMAGE_MODEL, DASHSCOPE_IMAGE_MODEL, ZAI_IMAGE_MODEL/BIGMODEL_IMAGE_MODEL, MINIMAX_IMAGE_MODEL, OPENROUTER_IMAGE_MODEL, REPLICATE_IMAGE_MODEL, JIMENG_IMAGE_MODEL, SEEDREAM_IMAGE_MODEL, AGNES_IMAGE_MODEL) |
AZURE_OPENAI_DEPLOYMENT (alias AZURE_OPENAI_IMAGE_MODEL) | Azure default deployment |
<PROVIDER>_BASE_URL | Per-provider endpoint override |
AZURE_API_VERSION | Azure image API version (default 2025-04-01-preview) |
JIMENG_REGION | Jimeng region (default cn-north-1) |
OPENAI_IMAGE_API_DIALECT | openai-native \ |
OPENROUTER_HTTP_REFERER, OPENROUTER_TITLE | Optional OpenRouter attribution |
BAOYU_IMAGE_GEN_MAX_WORKERS | Override batch worker cap |
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY | Per-provider concurrency (e.g., BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY; for codex-cli use BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY) |
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS | Per-provider start-gap |
BAOYU_CODEX_IMAGEGEN_BIN | Override the codex-imagegen wrapper path for the codex-cli provider (default: bundled scripts/codex-imagegen/main.ts; accepts .ts or legacy .sh/binary) |
BAOYU_CODEX_IMAGEGEN_CACHE_DIR | Enable idempotency cache for the codex-cli provider (off by default) |
BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS | Per-attempt codex exec timeout for the codex-cli provider (default: 300000 ms) |
BAOYU_CODEX_IMAGEGEN_RETRIES | Wrapper-side retry attempts on retryable errors for the codex-cli provider (default: 2) |
BAOYU_CODEX_IMAGEGEN_LOG_FILE | Append JSONL diagnostic log for the codex-cli provider |
Load priority: CLI args > EXTEND.md > env vars > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env
Codex/ChatGPT OAuth is not an OpenAI API key
--provider openai --model gpt-image-2 uses the standard OpenAI Images API (/v1/images/generations or /v1/images/edits) and requires OPENAI_API_KEY. A Codex or ChatGPT desktop login is a different entitlement and is not a drop-in replacement for OPENAI_API_KEY; do not paste a Codex OAuth token into OPENAI_API_KEY or only set OPENAI_BASE_URL to a Codex backend.
If the user wants to use their Codex subscription / GPT Image 2 entitlement without an OpenAI API key, route through a Codex-native backend instead of this skill's openai provider:
- In Codex runtime: use the native
imagegenskill/tool. - In non-Codex runtimes with
codexCLI installed and logged in: usebaoyu-image-gen --provider codex-cli(preferred — it gives you the same retry / cache / batch flow as every other provider). The provider spawns the bundledscripts/codex-imagegen/main.ts; the same code lives upstream atpackages/baoyu-codex-imagegen/src/main.tsfor standalone callers. - In Hermes runtimes with a native
image_generatetool: use that tool as a fallback, and state whether reference images were passed directly or reconstructed from extracted traits.
Do not modify the existing openai provider to silently consume Codex OAuth. The first-class Codex-CLI path is the dedicated codex-cli provider, which has its own auth (Codex login), route (codex exec), request shape, and tests. See references/codex-oauth-vs-openai-api-key.md.
Model Resolution
Priority (highest → lowest) applies to every provider:
1. CLI flag --model <id> 2. EXTEND.md default_model.[provider] 3. Env var <PROVIDER>_IMAGE_MODEL 4. Built-in default
For OpenAI, the built-in default is gpt-image-2. gpt-image-1.5, gpt-image-1, and GPT Image snapshots remain selectable with --model or OPENAI_IMAGE_MODEL.
For Azure, --model / default_model.azure is the Azure deployment name. AZURE_OPENAI_DEPLOYMENT is the preferred env var; AZURE_OPENAI_IMAGE_MODEL is kept as a backward-compatible alias. If your Azure deployment is named after the underlying model, use gpt-image-2; otherwise use the exact custom deployment name.
EXTEND.md overrides env vars: if EXTEND.md sets default_model.google: "gemini-3-pro-image" and the env var sets GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image, EXTEND.md wins.
Display model info before each generation:
Using [provider] / [model]Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL
OpenAI-Compatible Gateway Dialects
provider=openai means the auth and routing entrypoint is OpenAI-compatible. It does not guarantee the upstream image API uses OpenAI native semantics. When a gateway expects a different wire format, set default_image_api_dialect in EXTEND.md, OPENAI_IMAGE_API_DIALECT, or --imageApiDialect:
openai-native: pixelsize(1536x1024) and native OpenAI quality fieldsratio-metadata: aspect-ratiosize(16:9) plusmetadata.resolution(1K|2K|4K) andmetadata.orientation
Use openai-native for the OpenAI native API or strict clones; try ratio-metadata for compatibility gateways in front of Gemini or similar models. Current limitation: ratio-metadata applies only to text-to-image; reference-image edits still need openai-native or a provider with first-class edit support.
Provider-Specific Guides
Each provider has its own quirks (model families, size rules, ref support, limits). Read these when the user picks that provider or asks for non-default behavior:
| Provider | Reference |
|---|---|
| DashScope (Qwen-Image families, custom sizes) | references/providers/dashscope.md |
| Z.AI (GLM-Image, cogview-4) | references/providers/zai.md |
| MiniMax (image-01, subject-reference) | references/providers/minimax.md |
OpenRouter (multimodal models, /chat/completions flow) | references/providers/openrouter.md |
| Replicate (nano-banana, Seedream, Wan) | references/providers/replicate.md |
Codex CLI (wraps bundled scripts/codex-imagegen/; Codex login, no OPENAI_API_KEY) | references/providers/codex-cli.md |
| Agnes (agnes-image-2.1-flash, reference-image support) | references/providers/agnes.md |
Provider Selection
1. --ref provided + no --provider → auto-select Google → OpenAI → Azure → OpenRouter → Replicate → Seedream → MiniMax → Agnes (MiniMax's subject reference is more specialized toward character/portrait consistency) 2. --provider specified → use it (if --ref, must be google/openai/azure/openrouter/replicate/seedream/minimax/codex-cli/agnes) 3. Only one API key present → use that provider 4. Multiple keys → default priority: Google → OpenAI → Azure → OpenRouter → DashScope → Z.AI → MiniMax → Replicate → Jimeng → Seedream → Agnes 5. codex-cli is never auto-selected — set default_provider: codex-cli in EXTEND.md or pass --provider codex-cli. It spawns codex exec via the bundled scripts/codex-imagegen/main.ts TS entrypoint (run with bun) and uses the user's Codex subscription (no OPENAI_API_KEY). Requires codex on PATH with an active codex login.
Quality Presets
| Preset | Google imageSize | OpenAI size | OpenRouter size | Replicate resolution | Use case |
|---|---|---|---|---|---|
normal | 1K | 1024px target | 1K | 1K | Quick previews |
2k (default) | 2K | 2048px target | 2K | 2K | Covers, illustrations, infographics |
Google/OpenRouter imageSize can be overridden with --imageSize 1K|2K|4K.
For OpenAI native gpt-image-2, normal maps to quality=medium and a low-latency valid size near the requested aspect ratio; 2k maps to quality=high and 2048px-class sizes such as 2048x2048, 2048x1152, or 1152x2048. Use explicit --size for valid custom or 4K outputs, e.g. 3840x2160.
Aspect Ratios
Supported: 1:1, 16:9, 9:16, 4:3, 3:4, 2.35:1.
- Google multimodal:
imageConfig.aspectRatio - OpenAI:
gpt-image-2uses the closest valid custom size for the requested ratio; older GPT Image and DALL·E models use their closest supported fixed size - OpenRouter:
imageGenerationOptions.aspect_ratio; if only--size <WxH>is given, the ratio is inferred - Replicate: behavior is model-specific —
google/nano-banana*usesaspect_ratio,bytedance/seedream-*uses documented Replicate ratios, Wan 2.7 maps--arto a concretesize - MiniMax: official
aspect_ratiovalues; if--size <WxH>is given without--ar, sendswidth/heightforimage-01
Generation Mode
Default: sequential. Batch parallel: enabled automatically when --batchfile contains 2+ pending tasks.
| Situation | Prefer | Why |
|---|---|---|
| One image, or 1-2 simple images | Sequential | Lower coordination overhead, easier debugging |
| Multiple images with saved prompt files | Batch (--batchfile) | Reuses finalized prompts, applies shared throttling/retries, predictable throughput |
| Each image still needs its own reasoning / prompt writing / style exploration | Subagents | Work is still exploratory, each needs independent analysis |
Input is outline.md + prompts/ (e.g. from baoyu-article-illustrator) | Batch — use {baseDir}/scripts/build-batch.ts to assemble the payload | The outline + prompt files already contain everything needed |
Rule of thumb: once prompt files are saved and the task is "generate all of these", prefer batch over subagents. Use subagents only when generation is coupled with per-image thinking or divergent creative exploration.
Parallel behavior:
- Default worker count is automatic, capped by config, built-in default 10
- Provider-specific throttling applies only in batch mode; defaults are tuned for throughput while avoiding RPM bursts
- Override with
--jobs <count> - Each image retries up to 3 attempts
- Final output includes success count, failure count, and per-image failure reasons
Error Handling
- Missing API key → error with setup instructions
- Generation failure → auto-retry up to 3 attempts per image
- Invalid aspect ratio → warning, proceed with default
- Reference images with unsupported provider/model → error with fix hint
Codex image2 fallback
If --provider openai --model gpt-image-2 fails because OPENAI_API_KEY is missing but the current runtime has a native image-generation backend or the repo-level codex-imagegen wrapper is available, use that path rather than leaving the user waiting. Be explicit about whether the fallback is true reference-image generation or only a text-prompt reconstruction from extracted visual traits. See references/codex-image2-fallback.md.
References
| File | Content |
|---|---|
references/usage-examples.md | Extended CLI examples across providers and batch mode |
references/codex-oauth-vs-openai-api-key.md | Why Codex/ChatGPT OAuth image2 entitlement is not usable through baoyu-image-gen's standard OpenAI API-key provider |
references/codex-image2-fallback.md | Practical fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available |
references/providers/dashscope.md | DashScope families, sizes, limits |
references/providers/zai.md | Z.AI GLM-image / cogview-4 |
references/providers/minimax.md | MiniMax image-01 + subject reference |
references/providers/openrouter.md | OpenRouter multimodal flow |
references/providers/replicate.md | Replicate supported families + guardrails |
references/providers/agnes.md | Agnes (agnes-image-2.1-flash) sizing, refs, and limits |
references/config/preferences-schema.md | EXTEND.md schema |
references/config/first-time-setup.md | First-time setup flow |
Extension Support
Custom configurations via EXTEND.md. See Step 0 for paths and schema.
Codex Image2 Fallback
When using baoyu-image-gen with --provider openai --model gpt-image-2, the CLI can fail with:
OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script.This is expected. The openai provider uses the public OpenAI Images API and needs OPENAI_API_KEY. Codex / ChatGPT image2 entitlement is a separate runtime-native path.
Practical fallback pattern
1. Try baoyu-image-gen when provider credentials are available. 2. If it fails only because OPENAI_API_KEY is missing, do not leave the user waiting. 3. Prefer a Codex/native raster backend in this order:
- Codex runtime native
imagegenskill/tool, if available. baoyu-image-gen --provider codex-cli(preferred — wraps the bundledscripts/codex-imagegen/main.ts; the underlying repo-level package lives atpackages/baoyu-codex-imagegen/src/main.tsfor standalone callers), ifcodexCLI is installed/logged in.- Hermes native
image_generate, if available.
4. Be transparent about reference-image behavior:
- If the fallback backend accepts references, pass the reference images.
- If it does not, derive a concise identity-preserving prompt from the references and state that it is a text-description fallback, not strict reference-image editing.
5. Return the generated media path or structured backend error promptly.
User-facing wording
Use concise wording such as:
The OpenAI API path needs OPENAI_API_KEY; Codex login is a separate image2 backend. I used the available Codex/native image backend instead. Reference images were [passed directly / reconstructed from visual traits].Avoid implying that baoyu-image-gen --provider openai can use Codex OAuth without a dedicated provider implementation.
Codex OAuth vs OpenAI API key for baoyu-image-gen
baoyu-image-gen --provider openai uses the standard OpenAI Images API and requires OPENAI_API_KEY. It calls OpenAI-compatible image endpoints such as /images/generations and /images/edits.
Codex / ChatGPT login is different. Codex image generation is driven by Codex OAuth and the Codex runtime's image_gen capability, not by the public OpenAI Images API key path. A Codex OAuth token is not a drop-in replacement for OPENAI_API_KEY, and setting OPENAI_BASE_URL to a Codex backend will not make baoyu-image-gen's existing openai provider work because the auth, route, and payload shape differ.
What to use instead
- If running inside Codex and the native
imagegenskill/tool is available, use it directly. - If running outside Codex but the
codexCLI is installed and logged in, callbaoyu-image-gen --provider codex-cli(preferred). It spawns the bundledscripts/codex-imagegen/main.tsand surfaces its retry/cache/log machinery through baoyu-image-gen's standard CLI + batch flow. Standalone callers outside this skill can run the same code atpackages/baoyu-codex-imagegen/src/main.ts. Both invokecodex execand the Codeximage_gentool; noOPENAI_API_KEYis required. - If running inside Hermes and a native
image_generatetool is available, use that as a runtime-native fallback. Be explicit about whether reference images are passed directly or only reconstructed from extracted traits. baoyu-image-genalready exposes a distinctcodex-cliprovider (wraps the bundledscripts/codex-imagegen/); do not modify the existingopenaiprovider to add Codex OAuth.
Reference-image prompting note
When using actual reference images for identity preservation, avoid long generic descriptions of the subject. Long descriptions can cause the model to synthesize a new similar-looking person/object. Prefer direct wording:
Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject. Only change scene, clothing, pose, lighting, rendering style, and composition.
First-Time Setup
Overview
Triggered when: 1. No EXTEND.md found → full setup (provider + model + preferences) 2. EXTEND.md found but default_model.[provider] is null → model selection only
Setup Flow
No EXTEND.md found EXTEND.md found, model null
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ AskUserQuestion │ │ AskUserQuestion │
│ (full setup) │ │ (model only) │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ Create EXTEND.md │ │ Update EXTEND.md │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
Continue ContinueFlow 1: No EXTEND.md (Full Setup)
Language: Use user's input language or saved language preference.
Use AskUserQuestion with ALL questions in ONE call:
Question 1: Default Provider
header: "Provider"
question: "Default image generation provider?"
options:
- label: "Google (Recommended)"
description: "Gemini multimodal - high quality, reference images, flexible sizes"
- label: "OpenAI"
description: "GPT Image 2 - latest OpenAI image model, reference-image workflows"
- label: "Azure OpenAI"
description: "Azure-hosted GPT Image deployments with resource-specific routing"
- label: "OpenRouter"
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
- label: "DashScope"
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
- label: "Z.AI"
description: "GLM-image, strong poster and text-heavy image generation"
- label: "MiniMax"
description: "MiniMax image generation with subject-reference character workflows"
- label: "Replicate"
description: "Curated Replicate image families - nano-banana-2, Seedream, and Wan image models"
- label: "Agnes"
description: "Sapiens AI Agnes - optimized for high information density, complex layouts, reference-image support"Question 2: Default Google Model
Only show if user selected Google or auto-detect (no explicit provider).
header: "Google Model"
question: "Default Google image generation model?"
options:
- label: "gemini-3-pro-image (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"Question 2b: Default OpenRouter Model
Only show if user selected OpenRouter.
header: "OpenRouter Model"
question: "Default OpenRouter image generation model?"
options:
- label: "google/gemini-3.1-flash-image (Recommended)"
description: "Best general-purpose OpenRouter image model with reference-image workflows"
- label: "google/gemini-2.5-flash-image-preview"
description: "Fast Gemini preview model on OpenRouter"
- label: "black-forest-labs/flux.2-pro"
description: "Strong text-to-image quality through OpenRouter"Question 2c: Default Azure Deployment
Only show if user selected Azure OpenAI.
header: "Azure Deploy"
question: "Default Azure image deployment name?"
options:
- label: "gpt-image-2 (Recommended)"
description: "Use if your Azure deployment uses the GPT Image 2 model name"
- label: "gpt-image-1.5"
description: "Previous GPT Image deployment name"
- label: "gpt-image-1"
description: "Earlier GPT Image deployment name"Question 2d: Default MiniMax Model
Only show if user selected MiniMax.
header: "MiniMax Model"
question: "Default MiniMax image generation model?"
options:
- label: "image-01 (Recommended)"
description: "Best default, supports aspect ratios and custom width/height"
- label: "image-01-live"
description: "Faster variant, use aspect ratio instead of custom size"Question 2e: Default Z.AI Model
Only show if user selected Z.AI.
header: "Z.AI Model"
question: "Default Z.AI image generation model?"
options:
- label: "glm-image (Recommended)"
description: "Best default for posters, diagrams, and text-heavy images"
- label: "cogview-4-250304"
description: "Legacy Z.AI image model on the same endpoint"Question 3: Default Quality
header: "Quality"
question: "Default image quality?"
options:
- label: "2k (Recommended)"
description: "2048px - covers, illustrations, infographics"
- label: "normal"
description: "1024px - quick previews, drafts"Question 4: Save Location
header: "Save"
question: "Where to save preferences?"
options:
- label: "Project (Recommended)"
description: ".baoyu-skills/ (this project only)"
- label: "User"
description: "~/.baoyu-skills/ (all projects)"Save Locations
| Choice | Path | Scope |
|---|---|---|
| Project | .baoyu-skills/baoyu-image-gen/EXTEND.md | Current project |
| User | $HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md | All projects |
EXTEND.md Template
---
version: 1
default_provider: [selected provider or null]
default_quality: [selected quality]
default_aspect_ratio: null
default_image_size: null
default_image_api_dialect: null
default_model:
google: [selected google model or null]
openai: null
azure: [selected azure deployment or null]
openrouter: [selected openrouter model or null]
dashscope: null
zai: [selected Z.AI model or null]
minimax: [selected minimax model or null]
replicate: null
agnes: null
---If the user selects OpenAI but says their endpoint is only OpenAI-compatible and fronts another image model family, save default_image_api_dialect: ratio-metadata when they explicitly confirm the gateway expects aspect-ratio size plus metadata-based resolution. Otherwise leave it null / openai-native.
Flow 2: EXTEND.md Exists, Model Null
When EXTEND.md exists but default_model.[current_provider] is null, ask ONLY the model question for the current provider.
Google Model Selection
header: "Google Model"
question: "Choose a default Google image generation model?"
options:
- label: "gemini-3-pro-image (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"OpenAI Model Selection
header: "OpenAI Model"
question: "Choose a default OpenAI image generation model?"
options:
- label: "gpt-image-2 (Recommended)"
description: "Latest GPT Image model, flexible sizes up to 4K, high-fidelity image inputs"
- label: "gpt-image-1.5"
description: "Previous GPT Image model"
- label: "gpt-image-1"
description: "Earlier GPT Image model"Azure Deployment Selection
header: "Azure Deploy"
question: "Choose a default Azure image deployment name?"
options:
- label: "gpt-image-2 (Recommended)"
description: "Use when your Azure deployment name matches the GPT Image 2 model"
- label: "gpt-image-1.5"
description: "Use when your Azure deployment name matches the GPT Image 1.5 model"
- label: "gpt-image-1"
description: "Use when your Azure deployment name matches GPT-image-1"Notes for Azure setup:
- In
baoyu-image-gen, Azure--model/default_model.azureshould be the Azure deployment name, not just the underlying model family. - If the deployment name is custom, save that exact deployment name in
default_model.azure.
OpenRouter Model Selection
header: "OpenRouter Model"
question: "Choose a default OpenRouter image generation model?"
options:
- label: "google/gemini-3.1-flash-image (Recommended)"
description: "Recommended for image output and reference-image edits"
- label: "google/gemini-2.5-flash-image-preview"
description: "Fast preview-oriented image generation"
- label: "black-forest-labs/flux.2-pro"
description: "High-quality text-to-image through OpenRouter"DashScope Model Selection
header: "DashScope Model"
question: "Choose a default DashScope image generation model?"
options:
- label: "qwen-image-2.0-pro (Recommended)"
description: "Best DashScope model for text rendering and custom sizes"
- label: "qwen-image-2.0"
description: "Faster 2.0 variant with flexible output size"
- label: "qwen-image-max"
description: "Legacy Qwen model with five fixed output sizes"
- label: "qwen-image-plus"
description: "Legacy Qwen model, same current capability as qwen-image"
- label: "wan2.7-image-pro"
description: "Wan 2.7 Pro — supports up to 4K text-to-image and reference-image editing"
- label: "wan2.7-image"
description: "Wan 2.7 base — faster generation, up to 2K, supports reference-image editing"
- label: "z-image-turbo"
description: "Legacy DashScope model for compatibility"
- label: "z-image-ultra"
description: "Legacy DashScope model, higher quality but slower"Notes for DashScope setup:
- Prefer
qwen-image-2.0-prowhen the user needs custom--size, uncommon ratios like21:9, or strong Chinese/English text rendering. qwen-image-max/qwen-image-plus/qwen-imageonly support five fixed sizes:1664*928,1472*1104,1328*1328,1104*1472,928*1664.wan2.7-image-proandwan2.7-imageare the only DashScope models that accept--ref. Pick one of these when the user wants reference-image editing or multi-image fusion via DashScope.- In
baoyu-image-gen,qualityis a compatibility preset. It is not a native DashScope parameter.
Z.AI Model Selection
header: "Z.AI Model"
question: "Choose a default Z.AI image generation model?"
options:
- label: "glm-image (Recommended)"
description: "Current flagship image model with better text rendering and poster layouts"
- label: "cogview-4-250304"
description: "Legacy model on the sync image endpoint"Notes for Z.AI setup:
- Prefer
glm-imagefor posters, diagrams, and Chinese/English text-heavy layouts. - In
baoyu-image-gen, Z.AI currently exposes text-to-image only; reference images are not wired for this provider. - The sync Z.AI image API returns a downloadable image URL, which the runtime saves locally after download.
Replicate Model Selection
header: "Replicate Model"
question: "Choose a default Replicate image generation model?"
options:
- label: "google/nano-banana-2 (Recommended)"
description: "Current default for general Replicate image generation in baoyu-image-gen"
- label: "bytedance/seedream-4.5"
description: "Replicate Seedream 4.5 with validated local size/ref guardrails"
- label: "bytedance/seedream-5-lite"
description: "Replicate Seedream 5 Lite with validated local size/ref guardrails"
- label: "wan-video/wan-2.7-image-pro"
description: "Replicate Wan 2.7 Image Pro with 4K text-to-image support"MiniMax Model Selection
header: "MiniMax Model"
question: "Choose a default MiniMax image generation model?"
options:
- label: "image-01 (Recommended)"
description: "Best general-purpose MiniMax image model with custom width/height support"
- label: "image-01-live"
description: "Lower-latency MiniMax image model using aspect ratios"Notes for MiniMax setup:
image-01is the safest default. It supports officialaspect_ratiovalues and documented customwidth/heightoutput sizes.image-01-liveis useful when the user prefers faster generation and can work with aspect-ratio-based sizing.- MiniMax subject reference currently uses
subject_reference[].type = character; docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB.
Update EXTEND.md
After user selects a model:
1. Read existing EXTEND.md 2. If default_model: section exists → update the provider-specific key 3. If default_model: section missing → add the full section:
default_model:
google: [value or null]
openai: [value or null]
azure: [value or null]
openrouter: [value or null]
dashscope: [value or null]
zai: [value or null]
minimax: [value or null]
replicate: [value or null]
agnes: [value or null]Only set the selected provider's model; leave others as their current value or null.
After Setup
1. Create directory if needed 2. Write/update EXTEND.md with frontmatter 3. Confirm: "Preferences saved to [path]" 4. Continue with image generation
Preferences Schema
Full Schema
---
version: 1
default_provider: null # google|openai|azure|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|codex-cli|agnes|null (null = auto-detect; codex-cli is never auto-detected — pin it here or via --provider)
default_quality: null # normal|2k|null (null = use default: 2k)
default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides quality)
default_image_api_dialect: null # openai-native|ratio-metadata|null (OpenAI-compatible gateways; null = use env/default)
default_model:
google: null # e.g., "gemini-3-pro-image", "gemini-3.1-flash-image"
openai: null # e.g., "gpt-image-2", "gpt-image-1.5", "gpt-image-1"
azure: null # Azure deployment name, e.g., "gpt-image-2" or "image-prod"
openrouter: null # e.g., "google/gemini-3.1-flash-image"
dashscope: null # e.g., "qwen-image-2.0-pro"
zai: null # e.g., "glm-image"
minimax: null # e.g., "image-01"
replicate: null # e.g., "google/nano-banana-2"
codex-cli: null # Logical label only — Codex image_gen has no user-selectable model. Default: "codex-image-gen"
agnes: null # e.g., "agnes-image-2.1-flash"
batch:
max_workers: 10
provider_limits:
replicate:
concurrency: 5
start_interval_ms: 700
google:
concurrency: 3
start_interval_ms: 1100
openai:
concurrency: 3
start_interval_ms: 1100
azure:
concurrency: 3
start_interval_ms: 1100
openrouter:
concurrency: 3
start_interval_ms: 1100
dashscope:
concurrency: 3
start_interval_ms: 1100
zai:
concurrency: 3
start_interval_ms: 1100
minimax:
concurrency: 3
start_interval_ms: 1100
codex-cli:
concurrency: 1
start_interval_ms: 2000
agnes:
concurrency: 3
start_interval_ms: 1100
---Field Reference
| Field | Type | Default | Description |
|---|---|---|---|
version | int | 1 | Schema version |
default_provider | string\ | null | null |
default_quality | string\ | null | null |
default_aspect_ratio | string\ | null | null |
default_image_size | string\ | null | null |
default_image_api_dialect | string\ | null | null |
default_model.google | string\ | null | null |
default_model.openai | string\ | null | null |
default_model.azure | string\ | null | null |
default_model.openrouter | string\ | null | null |
default_model.dashscope | string\ | null | null |
default_model.zai | string\ | null | null |
default_model.minimax | string\ | null | null |
default_model.replicate | string\ | null | null |
default_model.codex-cli | string\ | null | null |
default_model.agnes | string\ | null | null |
batch.max_workers | int\ | null | 10 |
batch.provider_limits.<provider>.concurrency | int\ | null | provider default |
batch.provider_limits.<provider>.start_interval_ms | int\ | null | provider default |
Examples
Minimal:
---
version: 1
default_provider: google
default_quality: 2k
default_image_api_dialect: null
---Full:
---
version: 1
default_provider: google
default_quality: 2k
default_aspect_ratio: "16:9"
default_image_size: 2K
default_image_api_dialect: null
default_model:
google: "gemini-3-pro-image"
openai: "gpt-image-2"
azure: "gpt-image-2"
openrouter: "google/gemini-3.1-flash-image"
dashscope: "qwen-image-2.0-pro"
zai: "glm-image"
minimax: "image-01"
replicate: "google/nano-banana-2"
agnes: "agnes-image-2.1-flash"
batch:
max_workers: 10
provider_limits:
replicate:
concurrency: 5
start_interval_ms: 700
azure:
concurrency: 3
start_interval_ms: 1100
zai:
concurrency: 3
start_interval_ms: 1100
openrouter:
concurrency: 3
start_interval_ms: 1100
minimax:
concurrency: 3
start_interval_ms: 1100
agnes:
concurrency: 3
start_interval_ms: 1100
---Sapiens AI Agnes Image
Read when the user picks --provider agnes or sets default_model.agnes. Default model is agnes-image-2.1-flash.
Models
`agnes-image-2.1-flash` (only model)
- Text-to-image and image-to-image (with
--ref) in a single/images/generationsendpoint - Supports reference images as public URLs or Data URI (base64)
- Optimized for high information density, complex layouts, and rich details
- Size rules: both dimensions divisible by 32 (720px exception), long edge ≤ 2048, total pixels ≤ ~4M
- Default size:
1024x1024; custom--sizesupports arbitrary WxH within the above rules --arsupported: computed as 2048-based size (long edge ≤ 2048, short edge proportional, both snapped to 32px);1:1special-cased to1024x1024
Response Format
- The sync API always returns a URL
- Default (
--response-format file): downloads the image and saves as.png - Pass
--response-format url: writes the URL string to.txtinstead
--n Behavior
The Agnes API returns a single image per request regardless of the n parameter. Passing --n > 1 triggers a local error from validateArgs before any API call is made.
Behavior Notes
- API key required:
AGNES_API_KEY - Base URL:
https://apihub.agnes-ai.com/v1(override withAGNES_BASE_URL) - Model override:
AGNES_IMAGE_MODELenv response_formatis always embedded inextra_body(not at request top level)- Reference images: local files converted to Data URI base64 inline; remote URLs passed through
- Rate limit defaults: concurrency=3, startIntervalMs=1100 (override via
BAOYU_IMAGE_GEN_AGNES_CONCURRENCY/BAOYU_IMAGE_GEN_AGNES_START_INTERVAL_MS) - Timeout: 120s per request
Size Resolution
--size <WxH>wins over--ar--armaps to a concrete size using the algorithm: long edge ≤ 2048, short edge proportional, both dimensions snapped to 32px--ar 1:1is special-cased to1024x1024
Common --ar Results
| Aspect Ratio | Result |
|---|---|
1:1 | 1024x1024 |
16:9 | 2048x1152 |
4:3 | 2048x1536 |
3:2 | 2048x1376 |
21:9 | 2048x896 |
| Unlisted ratio | Computed on the fly (portrait mirror swaps width/height) |
Official References
Codex CLI (--provider codex-cli)
Read when the user picks --provider codex-cli, sets default_provider: codex-cli, or asks for "Codex image generation without an OpenAI API key". This provider is a thin baoyu-image-gen wrapper around the bundled scripts/codex-imagegen/main.ts (synced from packages/baoyu-codex-imagegen), which spawns codex exec --json --sandbox danger-full-access and routes the request to Codex CLI's built-in image_gen tool. The Codex CLI uses the user's Codex / ChatGPT subscription — no OPENAI_API_KEY is read or sent.
Prerequisites
npm install -g @openai/codex
codex login # signs in with the user's OpenAI / Codex account
codex --version # confirm >= 0.130bun is required for running the underlying wrapper (scripts/codex-imagegen/main.ts, carrying #!/usr/bin/env bun). If bun is missing from the runtime, npx -y bun works as a fallback.
Selection
- Never auto-selected.
detectProvideronly pickscodex-cliwhen it is pinned explicitly: pass--provider codex-clior setdefault_provider: codex-cliin EXTEND.md. - Choose this provider when:
- The user has a Codex subscription and explicitly does not want to manage an OpenAI API key.
- You need Codex's specific
image_genbehavior or quality. - Avoid this provider when latency matters — Codex CLI is typically 5–10× slower than direct OpenAI / Google API calls (except on cache hits).
Supported flags
| Flag | Behavior |
|---|---|
--prompt <text> / --promptfiles <files> | Required. Written to a temp file and passed to the wrapper as --prompt-file. |
--image <path> | Required. Final output PNG location. |
--ar <ratio> | Mapped to wrapper's --aspect. Supported by Codex: 1:1 (default), 16:9, 9:16, 4:3, 2.35:1. |
--ref <files...> | Mapped to wrapper's repeated --ref. Codex's image_gen accepts reference images for style/composition guidance. |
--n | Must be 1. validateArgs throws if n > 1 because Codex image_gen returns a single image per call. |
--imageApiDialect | Not applicable. Throws if set to a non-default value. |
--size, --imageSize, --quality | Silently ignored — Codex picks pixel dimensions from the aspect ratio. |
--model, -m | Logical label only. The wrapper does not forward a model selector to Codex; the underlying engine is whichever model Codex's image_gen currently uses. Default label: codex-image-gen. |
Environment variables
| Variable | Effect |
|---|---|
BAOYU_CODEX_IMAGEGEN_BIN | Override the wrapper path. Default: bundled scripts/codex-imagegen/main.ts resolved relative to this skill's installed location. Accepts a .ts file (spawned with bun) or a legacy .sh/binary (spawned directly). |
BAOYU_CODEX_IMAGEGEN_CACHE_DIR | Enable the wrapper's idempotency cache. Disabled by default; set to e.g. ~/.cache/baoyu-codex-imagegen for high-value reuse. |
BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS | Per-attempt codex exec timeout in ms. Default: 300000 (5 min). Raise for slow networks or large prompts. |
BAOYU_CODEX_IMAGEGEN_RETRIES | Wrapper-side retry attempts on retryable errors. Default: 2 (3 total attempts). |
BAOYU_CODEX_IMAGEGEN_LOG_FILE | Append a structured JSONL diagnostic log. Useful when triaging timeouts or agent_refused errors. |
BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY | Batch-mode concurrency for the codex-cli provider. Default: 1 — Codex exec is a heavy single-process workflow; raising this rarely helps. |
BAOYU_IMAGE_GEN_CODEX_CLI_START_INTERVAL_MS | Batch-mode minimum start-gap. Default: 2000 ms. |
Error model
The wrapper emits a single JSON line on stdout. On failure:
{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}The provider re-throws each wrapper error as Invalid codex-cli result (<error_kind>): <message>. The "Invalid " prefix triggers isRetryableGenerationError to mark it non-retryable in baoyu-image-gen's outer retry loop — the wrapper has already retried internally per BAOYU_CODEX_IMAGEGEN_RETRIES, so re-spawning Codex from main.ts would only multiply latency without changing the outcome.
error_kind values to expect:
| Kind | Cause | Action |
|---|---|---|
codex_not_installed | codex not on PATH or unreadable | npm install -g @openai/codex, then codex login. |
invalid_args | Programmer error in the spawn invocation | Inspect provider source; usually a path-injection guard fired. |
prompt_file_missing | Temp prompt file vanished mid-call | Retry once; check $TMPDIR permissions. |
spawn_failed | OS / process-launch failure | Verify bun or npx is installed; check filesystem permissions. |
timeout | codex exec exceeded --timeout | Raise BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS; check network. |
no_image_gen_tool_use | Codex agent answered without calling image_gen | Often transient — retry. If persistent, refine the prompt. |
output_missing / invalid_png | Agent reported success but file is absent or not a valid PNG | Retry; check disk space. |
agent_refused | Codex agent refused (policy or content) | Adjust the prompt; surface the refusal to the user. |
lock_busy | Another codex-imagegen invocation holds the file lock | Wait or set a distinct --cache-dir per concurrent caller. |
Trade-offs
- Slow: 5–10× direct OpenAI API latency (except cache hits).
- Subject to the same TOS as interactive
codex execuse — programmatic invocation from baoyu-image-gen is the same usage class. - Stateful: requires
codex loginto be live; an expired session manifests ascodex_not_installedoragent_refused.
See also
references/codex-oauth-vs-openai-api-key.md— why Codex OAuth is not interchangeable withOPENAI_API_KEY.references/codex-image2-fallback.md— when to fall back tocodex-clifrom a failedopenaiprovider call.
DashScope (阿里通义万象)
Read when the user picks --provider dashscope, sets default_model.dashscope, or asks for Qwen-Image behavior. The SKILL.md only names the default — this file covers model families, sizing rules, and limits.
Model Families
*`qwen-image-2.0** — recommended modern family. Members: qwen-image-2.0-pro, qwen-image-2.0-pro-2026-03-03, qwen-image-2.0, qwen-image-2.0-2026-03-03`.
- Free-form
sizein宽*高format - Total pixels must be between
512*512and2048*2048 - Default ≈
1024*1024 - Best choice for custom ratios (e.g.
21:9) and text-heavy Chinese/English layouts
Fixed-size family — qwen-image-max, qwen-image-max-2025-12-30, qwen-image-plus, qwen-image-plus-2026-01-09, qwen-image.
- Only five sizes allowed:
1664*928,1472*1104,1328*1328,1104*1472,928*1664 - Default is
1664*928 qwen-imagecurrently has the same capability asqwen-image-plus
*`wan2.7-image** — multimodal Wan 2.7 family. Members: wan2.7-image-pro, wan2.7-image`.
- Free-form
sizein宽*高format, plus aspect-ratio inference wan2.7-image-protext-to-image (no--ref): total pixels in[768*768, 4096*4096], ratio in[1:8, 8:1]wan2.7-image-prowith reference images andwan2.7-image(all scenarios): total pixels in[768*768, 2048*2048], ratio in[1:8, 8:1]- Default:
1024*1024(--quality normal) or2048*2048(--quality 2k); 4K requires explicit--size - Supports up to 9 reference images in
--ref(image editing / multi-image fusion) - Reference images are sent inline as base64 (or passed through if the path is an
http(s)://URL) - API does NOT use
prompt_extend; the skill omits it for this family - The Wan 2.7 API defaults
nto 4 in non-collage mode and bills per generated image. baoyu-image-gen forcesn: 1and rejects--n > 1to avoid silently paying for and discarding extra images.
Legacy — z-image-turbo, z-image-ultra, wanx-v1. Only use when the user explicitly asks for legacy behavior.
Size Resolution
--sizewins over--ar- For
qwen-image-2.0*: prefer explicit--size; otherwise infer from--arusing the recommended table below - For
qwen-image-max/plus/image: only use the five fixed sizes; if the requested ratio doesn't fit, switch toqwen-image-2.0-pro - For
wan2.7-image*: explicit--sizeis validated against the per-mode pixel/ratio limits; otherwise the size is derived from--arand--quality(normal≈ 1K,2k≈ 2K). To request 4K withwan2.7-image-protext-to-image, pass--sizeexplicitly (e.g.4096*4096,3840*2160) --qualityis a baoyu-image-gen preset, not an official DashScope field. The mapping ofnormal/2konto theqwen-image-2.0*andwan2.7-image*tables is an implementation choice, not an API guarantee
Recommended qwen-image-2.0* sizes
| Ratio | normal | 2k |
|---|---|---|
1:1 | 1024*1024 | 1536*1536 |
2:3 | 768*1152 | 1024*1536 |
3:2 | 1152*768 | 1536*1024 |
3:4 | 960*1280 | 1080*1440 |
4:3 | 1280*960 | 1440*1080 |
9:16 | 720*1280 | 1080*1920 |
16:9 | 1280*720 | 1920*1080 |
21:9 | 1344*576 | 2048*872 |
Reference Images
- Only
wan2.7-image-proandwan2.7-imageaccept--ref. Other DashScope models (qwen-image-2.0*, qwen-image-max/plus/image, legacy) reject--refand the user is steered to a different provider/model. - Up to 9 reference images per request. Local files are inlined as base64 data URLs;
http(s)://URLs are forwarded as-is. - Supplying any
--refautomatically clamps the wan2.7-image-pro pixel ceiling from 4K to 2K (the API only supports 4K for pure text-to-image with no image input).
Not Exposed
DashScope APIs also support negative_prompt, prompt_extend, watermark, thinking_mode, seed, bbox_list, enable_sequential, and color_palette. baoyu-image-gen does not expose them as CLI flags today; the wan2.7 family relies on the API defaults (e.g. thinking_mode=true). The skill always sends n=1 for wan2.7 — if you want grid/collage mode you currently need to call the API directly.
Official References
MiniMax
Read when the user picks --provider minimax or sets default_model.minimax. Default model is image-01.
Models
`image-01` (recommended default)
- Supports text-to-image and subject-reference image generation
- Supports official
aspect_ratiovalues:1:1,16:9,4:3,3:2,2:3,3:4,9:16,21:9 - Supports documented custom
width/heightvia--size <WxH> - Both width and height must be in
[512, 2048]and divisible by8
`image-01-live` — lower-latency variant
- Use
--arfor sizing; MiniMax documents customwidth/heightonly forimage-01
Subject Reference
--reffiles are sent as MiniMaxsubject_referencesubject_reference[].typeis currentlycharacter- Official docs say
image_filesupports public URLs or Base64 Data URLs; baoyu-image-gen sends local refs as Data URLs - Recommended refs: front-facing portraits, JPG/JPEG/PNG, under 10MB
Official References
OpenRouter
Read when the user picks --provider openrouter. Default model is google/gemini-3.1-flash-image.
Common Models
Use full OpenRouter model IDs:
google/gemini-3.1-flash-image(recommended — supports image output and reference-image workflows)google/gemini-2.5-flash-image-previewblack-forest-labs/flux.2-pro- Any other OpenRouter image-capable model ID
Behavior Notes
- OpenRouter image generation uses
/chat/completions, not the OpenAI/imagesendpoints --refrequires a multimodal model that supports both image input and image output--imageSizemaps toimageGenerationOptions.size--size <WxH>is converted to the nearest supported OpenRouter size, and the aspect ratio is inferred when possible
Replicate
Read when the user picks --provider replicate. Replicate support is intentionally scoped to model families baoyu-image-gen can validate locally and save without dropping outputs.
Supported Families
*`google/nano-banana** (default: google/nano-banana-2`)
- Supports prompt-only and reference-image generation
- Uses Replicate
aspect_ratio,resolution, andoutput_format --size <WxH>is accepted only as a shorthand for a documentedaspect_ratioplus1K/2K
`bytedance/seedream-4.5`
- Supports prompt-only and reference-image generation
- Uses Replicate
size,aspect_ratio, andimage_input - Local validation blocks unsupported
1Krequests before the API call
`bytedance/seedream-5-lite`
- Supports prompt-only and reference-image generation
- Uses Replicate
size,aspect_ratio, andimage_input - Local validation currently accepts
2K/3Konly
`wan-video/wan-2.7-image`
- Supports prompt-only and reference-image generation
- Uses Replicate
sizeandimages - Max output is 2K
`wan-video/wan-2.7-image-pro`
- Supports prompt-only and reference-image generation
- Uses Replicate
sizeandimages - 4K is allowed only for text-to-image; local validation blocks
4K + --ref
Guardrails
- Replicate currently supports only single-output save semantics in this tool — keep
--n 1 - If a model is outside the compatibility list above, baoyu-image-gen treats it as prompt-only and rejects advanced local options instead of guessing a nano-banana-style schema
Examples
# Default model
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
# Explicit model
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-bananaZ.AI GLM-Image
Read when the user picks --provider zai or sets default_model.zai. Default model is glm-image.
Models
`glm-image` (recommended default)
- Text-to-image only in baoyu-image-gen (no
--refsupport yet) - Native
qualityoptions arehdandstandard; this skill maps2k → hdandnormal → standard - Recommended sizes:
1280x1280,1568x1056,1056x1568,1472x1088,1088x1472,1728x960,960x1728 - Custom
--sizerequires width/height in[1024, 2048], divisible by32, total pixels ≤2^22
`cogview-4-250304` (legacy family, same endpoint)
- Custom
--sizerequires width/height in[512, 2048], divisible by16, total pixels ≤2^21
Behavior Notes
- The sync API returns a temporary URL; baoyu-image-gen downloads it and writes locally
--refis not supported for Z.AI in this skill yet- The sync API returns a single image, so
--n > 1is rejected
Official References
Usage Examples
Extended CLI examples. SKILL.md shows the minimum set; read this file when the user asks about provider-specific invocation, batch generation, or less-common flags.
Core Patterns
# Basic text-to-image
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
# With aspect ratio
${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9
# High quality
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
# Prompt from files
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
# With reference images (any provider family that supports refs)
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.pngPer-Provider
# OpenAI
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai --model gpt-image-2
# Azure OpenAI (model = deployment name)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider azure --model gpt-image-2
# OpenAI GPT Image 2 custom 4K size
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic landscape" --image out.png --provider openai --model gpt-image-2 --size 3840x2160
# Google with explicit model
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image --ref source.png
# OpenRouter (recommended default)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter
# OpenRouter with reference
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image --ref source.png
# DashScope (default model)
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
# DashScope Qwen-Image 2.0 Pro (custom size, Chinese text)
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
# DashScope legacy fixed-size
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
# DashScope Wan 2.7 Image Pro (4K text-to-image)
${BUN_X} {baseDir}/scripts/main.ts --prompt "一间有着精致窗户的花店" --image out.png --provider dashscope --model wan2.7-image-pro --size 4096x4096
# DashScope Wan 2.7 Image with reference image (multi-image fusion)
${BUN_X} {baseDir}/scripts/main.ts --prompt "把图2的涂鸦喷绘在图1的汽车上" --image out.png --provider dashscope --model wan2.7-image-pro --ref car.webp paint.webp
# Z.AI GLM-image
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
# Z.AI with custom size
${BUN_X} {baseDir}/scripts/main.ts --prompt "A science illustration with labels" --image out.png --provider zai --model glm-image --size 1472x1088
# MiniMax
${BUN_X} {baseDir}/scripts/main.ts --prompt "A fashion editorial portrait" --image out.jpg --provider minimax
# MiniMax with subject reference (character/portrait consistency)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A girl by the library window" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
# Replicate (default: google/nano-banana-2)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
# Replicate Seedream 4.5
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
# Replicate Wan 2.7 Image Pro
${BUN_X} {baseDir}/scripts/main.ts --prompt "A concept frame" --image out.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
# Codex CLI (uses Codex / ChatGPT subscription — no OPENAI_API_KEY; requires `codex` on PATH and `codex login`)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.png --provider codex-cli --ar 16:9
# Codex CLI with reference images (style/composition guidance)
${BUN_X} {baseDir}/scripts/main.ts --prompt "Match this color palette" --image out.png --provider codex-cli --ref source.png --ar 1:1
# Agnes (default model)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A detailed infographic" --image out.png --provider agnes
# Agnes with aspect ratio and URL output
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic scene" --image out.txt --provider agnes --ar 16:9 --response-format url
# Agnes with reference image
${BUN_X} {baseDir}/scripts/main.ts --prompt "Apply this style" --image out.png --provider agnes --ref source.pngNotes on codex-cli:
- Never auto-selected — pin via
--provider codex-cliordefault_provider: codex-cliin EXTEND.md. - Only
n=1supported (Codeximage_genreturns one image per call);--size,--imageSize,--quality, and--imageApiDialectare ignored or rejected. - Typically 5–10× slower than direct OpenAI / Google API calls (except on cache hits). Tune via
BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS,BAOYU_CODEX_IMAGEGEN_RETRIES, andBAOYU_CODEX_IMAGEGEN_CACHE_DIR.
Batch Mode
# Batch from saved prompt files
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
# Batch with explicit worker count
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --jsonBatch File Format
{
"jobs": 4,
"tasks": [
{
"id": "hero",
"promptFiles": ["prompts/hero.md"],
"image": "out/hero.png",
"provider": "replicate",
"model": "google/nano-banana-2",
"ar": "16:9",
"quality": "2k"
},
{
"id": "diagram",
"promptFiles": ["prompts/diagram.md"],
"image": "out/diagram.png",
"ref": ["references/original.png"]
}
]
}Paths in promptFiles, image, and ref are resolved relative to the batch file's directory. jobs is optional (overridden by CLI --jobs). A top-level array without the jobs wrapper is also accepted.
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import test from "node:test";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(import.meta.dirname, "..", "..", "..");
const scriptPath = path.join(repoRoot, "skills", "baoyu-image-gen", "scripts", "build-batch.ts");
async function makeFixture(): Promise<{
root: string;
outlinePath: string;
promptsDir: string;
outputPath: string;
}> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "baoyu-image-gen-build-batch-"));
const outlinePath = path.join(root, "outline.md");
const promptsDir = path.join(root, "prompts");
const outputPath = path.join(root, "batch.json");
await fs.mkdir(promptsDir, { recursive: true });
await fs.writeFile(
outlinePath,
`## Illustration 1
**Position**: demo
**Purpose**: demo
**Visual Content**: demo
**Filename**: 01-demo.png
`,
);
await fs.writeFile(path.join(promptsDir, "01-demo.md"), "A demo prompt\n");
return { root, outlinePath, promptsDir, outputPath };
}
async function runBuildBatch(args: string[]): Promise<void> {
await execFileAsync(process.execPath, ["--import", "tsx", scriptPath, ...args], {
cwd: repoRoot,
});
}
test("build-batch omits default model so baoyu-image-gen can resolve env or EXTEND defaults", async () => {
const fixture = await makeFixture();
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.equal(batch.tasks.length, 1);
assert.equal(batch.tasks[0]?.provider, "replicate");
assert.equal(Object.hasOwn(batch.tasks[0]!, "model"), false);
});
test("build-batch preserves explicit model overrides", async () => {
const fixture = await makeFixture();
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
"--model",
"acme/custom-model",
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.equal(batch.tasks[0]?.model, "acme/custom-model");
});
test("build-batch propagates direct-usage references from prompt frontmatter", async () => {
const fixture = await makeFixture();
await fs.writeFile(
path.join(fixture.promptsDir, "01-demo.md"),
`---
illustration_id: 01
type: infographic
references:
- ref_id: 01
filename: 01-ref-brand.png
usage: direct
- ref_id: 02
filename: 02-ref-style.png
usage: style
---
A demo prompt
`,
);
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.deepEqual(batch.tasks[0]?.ref, ["references/01-ref-brand.png"]);
});
test("build-batch omits ref field when no direct references exist", async () => {
const fixture = await makeFixture();
await fs.writeFile(
path.join(fixture.promptsDir, "01-demo.md"),
`---
illustration_id: 01
references:
- ref_id: 01
filename: 01-ref-palette.png
usage: palette
---
A demo prompt
`,
);
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.equal(Object.hasOwn(batch.tasks[0]!, "ref"), false);
});
test("build-batch honors --refs-dir override", async () => {
const fixture = await makeFixture();
await fs.writeFile(
path.join(fixture.promptsDir, "01-demo.md"),
`---
illustration_id: 01
references:
- ref_id: 01
filename: brand.png
usage: direct
---
A demo prompt
`,
);
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
"--refs-dir",
"refs",
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.deepEqual(batch.tasks[0]?.ref, ["refs/brand.png"]);
});
import path from "node:path";
import process from "node:process";
import { readdir, readFile, writeFile } from "node:fs/promises";
type CliArgs = {
outlinePath: string | null;
promptsDir: string | null;
outputPath: string | null;
imagesDir: string | null;
refsDir: string;
provider: string;
model: string | null;
aspectRatio: string;
quality: string;
jobs: number | null;
help: boolean;
};
type OutlineEntry = {
index: number;
filename: string;
};
type PromptReference = {
filename: string;
usage: "direct" | "style" | "palette";
};
function printUsage(): void {
console.log(`Usage:
bun <baseDir>/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
npx -y tsx <baseDir>/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
Options:
--outline <path> Path to outline.md
--prompts <path> Path to prompts directory
--output <path> Path to output batch.json
--images-dir <path> Directory for generated images
--refs-dir <path> Directory holding reference images, relative to batch file (default: references)
--provider <name> Provider for baoyu-image-gen batch tasks (default: replicate)
--model <id> Explicit model for baoyu-image-gen batch tasks (default: resolved by baoyu-image-gen config/env)
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
--quality <level> Quality for all tasks (default: 2k)
--jobs <count> Recommended worker count metadata (optional)
-h, --help Show help`);
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
outlinePath: null,
promptsDir: null,
outputPath: null,
imagesDir: null,
refsDir: "references",
provider: "replicate",
model: null,
aspectRatio: "16:9",
quality: "2k",
jobs: null,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const current = argv[i]!;
if (current === "--outline") args.outlinePath = argv[++i] ?? null;
else if (current === "--prompts") args.promptsDir = argv[++i] ?? null;
else if (current === "--output") args.outputPath = argv[++i] ?? null;
else if (current === "--images-dir") args.imagesDir = argv[++i] ?? null;
else if (current === "--refs-dir") args.refsDir = argv[++i] ?? args.refsDir;
else if (current === "--provider") args.provider = argv[++i] ?? args.provider;
else if (current === "--model") args.model = argv[++i] ?? args.model;
else if (current === "--ar") args.aspectRatio = argv[++i] ?? args.aspectRatio;
else if (current === "--quality") args.quality = argv[++i] ?? args.quality;
else if (current === "--jobs") {
const value = argv[++i];
args.jobs = value ? parseInt(value, 10) : null;
} else if (current === "--help" || current === "-h") {
args.help = true;
}
}
return args;
}
function parsePromptReferences(content: string): PromptReference[] {
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
if (!fmMatch) return [];
const lines = fmMatch[1]!.split(/\r?\n/);
const refs: PromptReference[] = [];
let current: Partial<PromptReference> | null = null;
let inReferences = false;
let listIndent = 0;
const flush = () => {
if (current?.filename) {
refs.push({
filename: current.filename,
usage: (current.usage ?? "direct") as PromptReference["usage"],
});
}
current = null;
};
const unquote = (raw: string): string => raw.trim().replace(/^["']|["']$/g, "");
for (const line of lines) {
if (!line.trim() || line.trim().startsWith("#")) continue;
const keyMatch = line.match(/^(\S[^:]*):\s*(.*)$/);
if (keyMatch) {
flush();
if (keyMatch[1] === "references") {
inReferences = true;
listIndent = 0;
continue;
}
inReferences = false;
continue;
}
if (!inReferences) continue;
const itemMatch = line.match(/^(\s*)-\s*(.*)$/);
if (itemMatch) {
flush();
listIndent = itemMatch[1]!.length;
current = {};
const rest = itemMatch[2]!.trim();
if (rest) {
const kv = rest.match(/^(\w+)\s*:\s*(.*)$/);
if (kv && (kv[1] === "filename" || kv[1] === "usage")) {
(current as Record<string, string>)[kv[1]] = unquote(kv[2]!);
}
}
continue;
}
const kvMatch = line.match(/^(\s+)(\w+)\s*:\s*(.*)$/);
if (kvMatch && kvMatch[1]!.length > listIndent && current) {
if (kvMatch[2] === "filename" || kvMatch[2] === "usage") {
(current as Record<string, string>)[kvMatch[2]!] = unquote(kvMatch[3]!);
}
}
}
flush();
return refs;
}
function parseOutline(content: string): OutlineEntry[] {
const entries: OutlineEntry[] = [];
const blocks = content.split(/^## Illustration\s+/m).slice(1);
for (const block of blocks) {
const indexMatch = block.match(/^(\d+)/);
const filenameMatch = block.match(/\*\*Filename\*\*:\s*(.+)/);
if (indexMatch && filenameMatch) {
entries.push({
index: parseInt(indexMatch[1]!, 10),
filename: filenameMatch[1]!.trim(),
});
}
}
return entries;
}
async function findPromptFile(promptsDir: string, entry: OutlineEntry): Promise<string | null> {
const files = await readdir(promptsDir);
const prefix = String(entry.index).padStart(2, "0");
const match = files.find((f) => f.startsWith(prefix) && f.endsWith(".md"));
return match ? path.join(promptsDir, match) : null;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
return;
}
if (!args.outlinePath) {
console.error("Error: --outline is required");
process.exit(1);
}
if (!args.promptsDir) {
console.error("Error: --prompts is required");
process.exit(1);
}
if (!args.outputPath) {
console.error("Error: --output is required");
process.exit(1);
}
const outlineContent = await readFile(args.outlinePath, "utf8");
const entries = parseOutline(outlineContent);
if (entries.length === 0) {
console.error("No illustration entries found in outline.");
process.exit(1);
}
const tasks = [];
for (const entry of entries) {
const promptFile = await findPromptFile(args.promptsDir, entry);
if (!promptFile) {
console.error(`Warning: No prompt file found for illustration ${entry.index}, skipping.`);
continue;
}
const imageDir = args.imagesDir ?? path.dirname(args.outputPath);
const promptContent = await readFile(promptFile, "utf8");
const refs = parsePromptReferences(promptContent)
.filter((r) => r.usage === "direct")
.map((r) => path.posix.join(args.refsDir, r.filename));
const task: Record<string, unknown> = {
id: `illustration-${String(entry.index).padStart(2, "0")}`,
promptFiles: [promptFile],
image: path.join(imageDir, entry.filename),
provider: args.provider,
ar: args.aspectRatio,
quality: args.quality,
};
if (args.model) task.model = args.model;
if (refs.length > 0) task.ref = refs;
tasks.push(task);
}
const output: Record<string, unknown> = { tasks };
if (args.jobs) output.jobs = args.jobs;
await writeFile(args.outputPath, JSON.stringify(output, null, 2) + "\n");
console.log(`Batch file written: ${args.outputPath} (${tasks.length} tasks)`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile, copyFile, stat } from "node:fs/promises";
import { existsSync, openSync, closeSync } from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
export function cacheKey(prompt: string, aspect: string, refs: string[]): string {
const h = createHash("sha256");
h.update(prompt);
h.update("|");
h.update(aspect);
h.update("|");
for (const r of [...refs].sort()) h.update(r);
return h.digest("hex").slice(0, 16);
}
export async function lookupCache(cacheDir: string, key: string): Promise<string | null> {
const entry = path.join(cacheDir, `${key}.png`);
try {
const s = await stat(entry);
if (s.size > 1000) return entry;
} catch {}
return null;
}
export async function storeCache(cacheDir: string, key: string, sourcePath: string): Promise<void> {
await mkdir(cacheDir, { recursive: true });
const entry = path.join(cacheDir, `${key}.png`);
await copyFile(sourcePath, entry);
}
export class FileLock {
private fd: number | null = null;
constructor(private lockPath: string) {}
async acquire(timeoutMs = 30_000): Promise<void> {
const start = Date.now();
await mkdir(path.dirname(this.lockPath), { recursive: true });
while (Date.now() - start < timeoutMs) {
try {
this.fd = openSync(this.lockPath, "wx");
return;
} catch (e: any) {
if (e.code !== "EEXIST") throw e;
if (await this.isStale()) {
try {
await this.release(true);
} catch {}
continue;
}
await delay(200);
}
}
throw new Error(`Failed to acquire lock at ${this.lockPath} within ${timeoutMs}ms`);
}
private async isStale(): Promise<boolean> {
try {
const s = await stat(this.lockPath);
return Date.now() - s.mtimeMs > 10 * 60 * 1000;
} catch {
return true;
}
}
async release(force = false): Promise<void> {
if (this.fd != null) {
try {
closeSync(this.fd);
} catch {}
this.fd = null;
}
if (existsSync(this.lockPath) || force) {
const { unlink } = await import("node:fs/promises");
try {
await unlink(this.lockPath);
} catch {}
}
}
}
import { appendFile, mkdir } from "node:fs/promises";
import path from "node:path";
export interface LogEntry {
ts: string;
level: "info" | "warn" | "error";
event: string;
[k: string]: unknown;
}
export class JsonLogger {
constructor(private logFile: string | null, public verbose: boolean) {}
async log(level: LogEntry["level"], event: string, extra: Record<string, unknown> = {}): Promise<void> {
const entry: LogEntry = { ts: new Date().toISOString(), level, event, ...extra };
const line = JSON.stringify(entry);
if (this.verbose) process.stderr.write(`[${level}] ${event} ${jsonExtras(extra)}\n`);
if (this.logFile) {
await mkdir(path.dirname(this.logFile), { recursive: true });
await appendFile(this.logFile, line + "\n", "utf-8");
}
}
info(event: string, extra?: Record<string, unknown>) {
return this.log("info", event, extra);
}
warn(event: string, extra?: Record<string, unknown>) {
return this.log("warn", event, extra);
}
error(event: string, extra?: Record<string, unknown>) {
return this.log("error", event, extra);
}
}
function jsonExtras(extra: Record<string, unknown>): string {
const entries = Object.entries(extra);
if (entries.length === 0) return "";
return entries.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ");
}
#!/usr/bin/env bun
import { readFile, mkdir, copyFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { GenError, type CliOptions, type GenerateResult } from "./types.ts";
import { runCodexExec } from "./spawn.ts";
import { hasImageGenEvidence, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
import { JsonLogger } from "./logger.ts";
const HELP = `codex-imagegen — generate images via Codex CLI's image_gen tool
Usage:
codex-imagegen --image <output.png> [--prompt <text> | --prompt-file <path>] [options]
Required:
--image <path> Output PNG path
--prompt <text> Prompt text (or use --prompt-file)
--prompt-file <path> Read prompt from file
Options:
--aspect <ratio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:1
--ref <file> Reference image (repeatable)
--timeout <ms> Codex exec timeout in ms. Default: 300000
--retries <n> Retry attempts on retryable errors. Default: 2
--retry-delay <ms> Base retry delay (exponential). Default: 1500
--cache-dir <path> Enable idempotency cache. Disabled by default.
--log-file <path> Append JSONL log
-v, --verbose Verbose stderr logging
-h, --help Show this help
Stdout: single JSON line on success or failure.
`;
const SHELL_METACHAR = /[;|&`$<>\n\r()'"]/;
function assertSafePath(label: string, value: string): void {
if (SHELL_METACHAR.test(value)) {
throw new GenError(
"invalid_args",
`${label} contains shell metacharacters and would be unsafe to interpolate into the codex instruction: ${value}`,
false,
);
}
}
function parseArgs(argv: string[]): CliOptions {
const opts: CliOptions = {
prompt: "",
promptFile: null,
outputPath: "",
aspect: "1:1",
refImages: [],
timeoutMs: 300_000,
retries: 2,
retryDelayMs: 1500,
cacheDir: null,
logFile: null,
verbose: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
switch (a) {
case "--prompt": opts.prompt = next(); break;
case "--prompt-file": opts.promptFile = next(); break;
case "--image": opts.outputPath = next(); break;
case "--aspect": opts.aspect = next(); break;
case "--ref": opts.refImages.push(next()); break;
case "--timeout": opts.timeoutMs = Number(next()); break;
case "--retries": opts.retries = Number(next()); break;
case "--retry-delay": opts.retryDelayMs = Number(next()); break;
case "--cache-dir": opts.cacheDir = next(); break;
case "--log-file": opts.logFile = next(); break;
case "-v":
case "--verbose": opts.verbose = true; break;
case "-h":
case "--help": process.stdout.write(HELP); process.exit(0);
default: throw new GenError("invalid_args", `Unknown argument: ${a}`, false);
}
}
if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false);
if (opts.prompt && opts.promptFile) {
throw new GenError("invalid_args", "--prompt and --prompt-file are mutually exclusive", false);
}
if (!opts.prompt && !opts.promptFile) {
throw new GenError("invalid_args", "--prompt or --prompt-file required", false);
}
// Resolve every filesystem path to absolute up front, so behavior is
// independent of the caller's cwd. This matters when the wrapper is
// invoked from a skill running in an arbitrary working directory.
const cwd = process.cwd();
const toAbs = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
opts.outputPath = toAbs(opts.outputPath);
if (opts.promptFile) opts.promptFile = toAbs(opts.promptFile);
opts.refImages = opts.refImages.map(toAbs);
if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir);
if (opts.logFile) opts.logFile = toAbs(opts.logFile);
// The output and ref paths are interpolated raw into the agent instruction
// sent to `codex exec --sandbox danger-full-access`. A path containing shell
// metacharacters could be misread by the agent's shell when it cp's the
// result into place. Reject upfront rather than trusting the agent to quote.
assertSafePath("--image path", opts.outputPath);
for (const ref of opts.refImages) assertSafePath("--ref path", ref);
return opts;
}
async function loadPrompt(opts: CliOptions): Promise<string> {
if (opts.prompt) return opts.prompt;
const file = opts.promptFile!;
try {
return await readFile(file, "utf-8");
} catch {
throw new GenError("prompt_file_missing", `Prompt file not found: ${file}`, false);
}
}
function buildInstruction(prompt: string, opts: CliOptions): string {
const refHint = opts.refImages.length > 0
? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`
: "";
return `You have an internal tool called image_gen for image generation. You MUST call it before doing anything else.
TASK: Generate an image with the spec below, then save to disk.
PROMPT:
${prompt}
ASPECT RATIO: ${opts.aspect}
OUTPUT PATH: ${opts.outputPath}
${refHint}
STEPS:
1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.
2. Move or copy ONLY the image produced by that image_gen call from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
3. Verify with: ls -la ${opts.outputPath}
4. Reply with ONLY this JSON line (no markdown fences, no other text):
{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}
HARD CONSTRAINTS:
- Do NOT search for, find, inspect, reuse, or copy any pre-existing files from $CODEX_HOME/generated_images/ or any other directory.
- Do NOT run ls/find/rg/grep/glob over $CODEX_HOME/generated_images/ before image_gen has been called.
- You MUST call image_gen first. Only after image_gen completes may you copy the newly created file from this turn.
- Do NOT use curl, wget, Python, or any external API.
- Do NOT use bash to fabricate an image; only image_gen produces real pixels.
- Use ONLY the image_gen internal tool.`;
}
async function attemptGenerate(
opts: CliOptions,
instruction: string,
attempt: number,
log: JsonLogger,
): Promise<{ bytes: number; threadId: string | null; usage: any; toolCalls: any[] }> {
await log.info("attempt.start", { attempt, output: opts.outputPath, aspect: opts.aspect });
const run = await runCodexExec({
instruction,
timeoutMs: opts.timeoutMs,
refImages: opts.refImages,
});
await log.info("codex.completed", {
duration_ms: run.durationMs,
thread_id: run.threadId,
tool_calls: run.toolCalls.length,
usage: run.usage,
raw_log: run.rawLogPath,
});
// verify: thread id must be present
if (!run.threadId) {
throw new GenError("agent_refused", "No thread id in event stream");
}
// verify image_gen ran in THIS thread. A PNG in this thread's
// generated_images dir is the real signal (image_gen does not surface as a
// stream item); the stream check is a forward-compatible fallback. The #185
// shortcut (copying an unrelated history image) yields neither.
const ver = await verifyImageGenWasInvoked(run.threadId);
if (!hasImageGenEvidence(run.toolCalls, ver.ok)) {
throw new GenError(
"no_image_gen_tool_use",
`image_gen was not invoked (no image_gen event in stream; ${ver.reason})`,
);
}
// verify output
const { bytes } = await verifyOutput(opts.outputPath);
return {
bytes,
threadId: run.threadId,
usage: run.usage,
toolCalls: run.toolCalls.map((tc) => ({ tool: tc.tool, status: tc.status })),
};
}
async function generate(opts: CliOptions, log: JsonLogger): Promise<GenerateResult> {
const startEpoch = Date.now();
const prompt = await loadPrompt(opts);
// Cache lookup
if (opts.cacheDir) {
const key = cacheKey(prompt, opts.aspect, opts.refImages);
const cached = await lookupCache(opts.cacheDir, key);
if (cached) {
await mkdir(path.dirname(opts.outputPath), { recursive: true });
await copyFile(cached, opts.outputPath);
const s = await stat(opts.outputPath);
await log.info("cache.hit", { key, source: cached });
return {
status: "ok",
path: opts.outputPath,
bytes: s.size,
elapsed_seconds: 0,
thread_id: null,
attempts: 0,
cached: true,
usage: null,
tool_calls: [],
};
}
await log.info("cache.miss", { key });
}
// lock to prevent concurrent codex exec
const lockDir = opts.cacheDir ?? path.join(homedir(), ".cache", "baoyu-codex-imagegen");
const lock = new FileLock(path.join(lockDir, "codex-exec.lock"));
try {
await lock.acquire(60_000);
} catch (e) {
throw new GenError("lock_busy", String(e), false);
}
await mkdir(path.dirname(opts.outputPath), { recursive: true });
const instruction = buildInstruction(prompt, opts);
let lastErr: GenError | null = null;
let lastAttempt = 0;
try {
for (let attempt = 1; attempt <= opts.retries + 1; attempt++) {
lastAttempt = attempt;
try {
const result = await attemptGenerate(opts, instruction, attempt, log);
// write to cache
if (opts.cacheDir) {
const key = cacheKey(prompt, opts.aspect, opts.refImages);
await storeCache(opts.cacheDir, key, opts.outputPath);
await log.info("cache.stored", { key });
}
return {
status: "ok",
path: opts.outputPath,
bytes: result.bytes,
elapsed_seconds: Math.round((Date.now() - startEpoch) / 1000),
thread_id: result.threadId,
attempts: attempt,
cached: false,
usage: result.usage,
tool_calls: result.toolCalls,
};
} catch (e) {
lastErr = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
await log.warn("attempt.failed", {
attempt,
kind: lastErr.kind,
retryable: lastErr.retryable,
error: lastErr.message,
});
if (!lastErr.retryable || attempt > opts.retries) break;
const wait = opts.retryDelayMs * Math.pow(2, attempt - 1);
await log.info("retry.wait", { wait_ms: wait, next_attempt: attempt + 1 });
await delay(wait);
}
}
} finally {
await lock.release();
}
const err = lastErr ?? new GenError("spawn_failed", "Unknown failure");
err.attempts = lastAttempt;
throw err;
}
async function main() {
let opts: CliOptions;
try {
opts = parseArgs(process.argv.slice(2));
} catch (e) {
const err = e instanceof GenError ? e : new GenError("invalid_args", String(e), false);
process.stderr.write(`Error: ${err.message}\n`);
process.exit(2);
}
const log = new JsonLogger(opts.logFile, opts.verbose);
await log.info("start", { output: opts.outputPath, aspect: opts.aspect, refs: opts.refImages.length });
try {
const result = await generate(opts, log);
await log.info("done", { bytes: result.bytes, attempts: result.attempts, cached: result.cached });
process.stdout.write(JSON.stringify(result) + "\n");
process.exit(0);
} catch (e) {
const err = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
await log.error("failed", { kind: err.kind, error: err.message, attempts: err.attempts ?? 0 });
const out: GenerateResult = {
status: "error",
path: opts.outputPath,
bytes: 0,
elapsed_seconds: 0,
thread_id: null,
attempts: err.attempts ?? 0,
cached: false,
usage: null,
tool_calls: [],
error: err.message,
error_kind: err.kind,
};
process.stdout.write(JSON.stringify(out) + "\n");
process.exit(1);
}
}
main();
import type { CodexRunResult, ToolCall, TokenUsage } from "./types.ts";
export function parseEventStream(raw: string): Omit<CodexRunResult, "rawLogPath" | "durationMs"> {
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
let threadId: string | null = null;
let agentMessage: string | null = null;
let usage: TokenUsage | null = null;
const toolCallsById = new Map<string, ToolCall>();
for (const line of lines) {
let event: any;
try {
event = JSON.parse(line);
} catch {
continue;
}
const type = event?.type;
if (type === "thread.started") {
threadId = event.thread_id ?? null;
} else if (type === "item.started" || type === "item.completed") {
const item = event.item;
if (!item?.id) continue;
const tc: ToolCall = {
id: item.id,
tool: deriveToolName(item),
status: item.status ?? (type === "item.completed" ? "completed" : "in_progress"),
command: item.command,
};
toolCallsById.set(item.id, tc);
if (item.type === "agent_message" && type === "item.completed") {
agentMessage = String(item.text ?? "");
}
} else if (type === "turn.completed") {
const u = event.usage;
if (u) {
usage = {
input: u.input_tokens ?? 0,
cached_input: u.cached_input_tokens ?? 0,
output: u.output_tokens ?? 0,
reasoning: u.reasoning_output_tokens ?? 0,
};
}
}
}
return {
threadId,
toolCalls: Array.from(toolCallsById.values()),
agentMessage,
usage,
};
}
function deriveToolName(item: any): string {
if (item.type === "command_execution") return "shell";
if (item.type === "agent_message") return "agent_message";
if (item.type === "image_gen" || item.type === "image_generation") return "image_gen";
if (typeof item.tool === "string") return item.tool;
return item.type ?? "unknown";
}
export function hasImageGenInvocation(toolCalls: ToolCall[]): boolean {
return toolCalls.some((tc) => tc.tool === "image_gen");
}
import { spawn } from "node:child_process";
import { writeFile, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { GenError, type CodexRunResult } from "./types.ts";
import { parseEventStream } from "./parser.ts";
export interface SpawnInput {
instruction: string;
timeoutMs: number;
refImages?: string[];
}
export async function runCodexExec(input: SpawnInput): Promise<CodexRunResult> {
const start = Date.now();
const logDir = await mkdtemp(path.join(tmpdir(), "codex-imggen-"));
const rawLogPath = path.join(logDir, "stream.jsonl");
// --skip-git-repo-check: lets the wrapper run from non-git cwds
// (e.g. /tmp, or a skill installed under ~/.claude/plugins/...).
// Without it, codex refuses with "Not inside a trusted directory".
const args = [
"exec",
"--json",
"--sandbox",
"danger-full-access",
"--skip-git-repo-check",
];
for (const img of input.refImages ?? []) {
args.push("--image", img);
}
args.push("-");
let timedOut = false;
const child = spawn("codex", args, { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.stdin.write(input.instruction);
child.stdin.end();
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 2000);
}, input.timeoutMs);
const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.on("close", (code, signal) => resolve({ code, signal }));
});
clearTimeout(timer);
await writeFile(rawLogPath, stdout + (stderr ? `\n--- stderr ---\n${stderr}` : ""));
if (timedOut) {
throw new GenError("timeout", `codex exec exceeded ${input.timeoutMs}ms (log: ${rawLogPath})`);
}
if (exit.code !== 0) {
if (stderr.includes("command not found") || stderr.includes("not found: codex")) {
throw new GenError("codex_not_installed", "codex CLI not installed", false);
}
throw new GenError(
"spawn_failed",
`codex exec exited ${exit.code} signal=${exit.signal} (log: ${rawLogPath})`,
);
}
const parsed = parseEventStream(stdout);
return {
...parsed,
rawLogPath,
durationMs: Date.now() - start,
};
}
export interface CliOptions {
prompt: string;
promptFile: string | null;
outputPath: string;
aspect: string;
refImages: string[];
timeoutMs: number;
retries: number;
retryDelayMs: number;
cacheDir: string | null;
logFile: string | null;
verbose: boolean;
}
export interface ToolCall {
id: string;
tool: string;
status: string;
command?: string;
}
export interface TokenUsage {
input: number;
cached_input: number;
output: number;
reasoning: number;
}
export interface CodexRunResult {
threadId: string | null;
toolCalls: ToolCall[];
agentMessage: string | null;
usage: TokenUsage | null;
rawLogPath: string;
durationMs: number;
}
export interface GenerateResult {
status: "ok" | "error";
path: string;
bytes: number;
elapsed_seconds: number;
thread_id: string | null;
attempts: number;
cached: boolean;
usage: TokenUsage | null;
tool_calls: { tool: string; status: string }[];
error?: string;
error_kind?: ErrorKind;
}
export type ErrorKind =
| "codex_not_installed"
| "invalid_args"
| "prompt_file_missing"
| "spawn_failed"
| "timeout"
| "no_image_gen_tool_use"
| "output_missing"
| "invalid_png"
| "agent_refused"
| "lock_busy";
export const RETRYABLE: ReadonlySet<ErrorKind> = new Set([
"spawn_failed",
"timeout",
"no_image_gen_tool_use",
"output_missing",
"invalid_png",
"agent_refused",
]);
export class GenError extends Error {
attempts?: number;
constructor(public kind: ErrorKind, message: string, public retryable?: boolean) {
super(message);
this.retryable = retryable ?? RETRYABLE.has(kind);
}
}
import { stat, readdir } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { GenError } from "./types.ts";
import type { ToolCall } from "./types.ts";
import { hasImageGenInvocation } from "./parser.ts";
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
export function codexHome(): string {
return process.env.CODEX_HOME ?? path.join(homedir(), ".codex");
}
export async function verifyImageGenWasInvoked(threadId: string | null): Promise<{ ok: boolean; reason?: string }> {
if (!threadId) return { ok: false, reason: "no thread id" };
const dir = path.join(codexHome(), "generated_images", threadId);
try {
const entries = await readdir(dir);
const pngs = entries.filter((e) => e.toLowerCase().endsWith(".png"));
if (pngs.length === 0) return { ok: false, reason: `no PNG in ${dir}` };
return { ok: true };
} catch (e: any) {
return { ok: false, reason: `cannot read ${dir}: ${e?.code ?? e?.message}` };
}
}
// Real evidence that image_gen ran in THIS thread. Codex's image_gen tool does
// not surface as a stream item, so a successful run shows only reasoning/shell/
// agent_message — `dirHasImage` (a PNG in this thread's generated_images dir) is
// what proves it. The stream check is kept as a forward-compatible signal in
// case a future Codex version emits the item. The #185 shortcut (copying an
// unrelated history image, which lives under a different thread id) yields
// neither, so it is correctly rejected.
export function hasImageGenEvidence(toolCalls: ToolCall[], dirHasImage: boolean): boolean {
return dirHasImage || hasImageGenInvocation(toolCalls);
}
export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> {
let s;
try {
s = await stat(outputPath);
} catch {
throw new GenError("output_missing", `Output file not created: ${outputPath}`);
}
if (s.size < 1000) {
throw new GenError("invalid_png", `Output file too small (${s.size} bytes)`);
}
const file = Bun.file(outputPath);
const head = new Uint8Array(await file.slice(0, 8).arrayBuffer());
for (let i = 0; i < PNG_MAGIC.length; i++) {
if (head[i] !== PNG_MAGIC[i]) {
throw new GenError("invalid_png", `Output is not a valid PNG (magic mismatch)`);
}
}
return { bytes: s.size };
}
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs, ExtendConfig } from "./types.ts";
import {
createTaskArgs,
detectProvider,
ensureDir,
getConfiguredMaxWorkers,
getConfiguredProviderRateLimits,
getWorkerCount,
isRetryableGenerationError,
loadBatchTasks,
loadExtendConfig,
mergeConfig,
normalizeOutputImagePath,
parseArgs,
parseOpenAIImageApiDialect,
parseSimpleYaml,
validateReferenceImages,
} from "./main.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
aspectRatioSource: null,
size: null,
quality: null,
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("parseArgs parses the main baoyu-image-gen CLI flags", () => {
const args = parseArgs([
"--promptfiles",
"prompts/system.md",
"prompts/content.md",
"--image",
"out/hero",
"--provider",
"zai",
"--quality",
"2k",
"--imageSize",
"4k",
"--imageApiDialect",
"ratio-metadata",
"--ref",
"ref/one.png",
"ref/two.jpg",
"--n",
"3",
"--jobs",
"5",
"--json",
]);
assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]);
assert.equal(args.imagePath, "out/hero");
assert.equal(args.provider, "zai");
assert.equal(args.quality, "2k");
assert.equal(args.aspectRatioSource, null);
assert.equal(args.imageSize, "4K");
assert.equal(args.imageSizeSource, "cli");
assert.equal(args.imageApiDialect, "ratio-metadata");
assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]);
assert.equal(args.n, 3);
assert.equal(args.jobs, 5);
assert.equal(args.json, true);
});
test("parseArgs falls back to positional prompt and rejects invalid provider", () => {
const positional = parseArgs(["draw", "a", "cat"]);
assert.equal(positional.prompt, "draw a cat");
assert.throws(
() => parseArgs(["--provider", "stability"]),
/Invalid provider/,
);
});
test("validateReferenceImages can skip remote URLs for providers that support them", async () => {
await validateReferenceImages(["https://example.com/ref.png"], { allowRemoteUrls: true });
await assert.rejects(
() => validateReferenceImages(["https://example.com/ref.png"]),
/Reference image not found/,
);
});
test("parseSimpleYaml parses nested defaults and provider limits", () => {
const yaml = `
version: 2
default_provider: openrouter
default_quality: normal
default_aspect_ratio: '16:9'
default_image_size: 2K
default_image_api_dialect: ratio-metadata
default_model:
google: gemini-3-pro-image
openai: gpt-image-2
zai: glm-image
azure: image-prod
minimax: image-01
batch:
max_workers: 8
provider_limits:
google:
concurrency: 2
start_interval_ms: 900
openai:
concurrency: 4
zai:
concurrency: 2
start_interval_ms: 1000
minimax:
concurrency: 2
start_interval_ms: 1400
azure:
concurrency: 1
start_interval_ms: 1500
`;
const config = parseSimpleYaml(yaml);
assert.equal(config.version, 2);
assert.equal(config.default_provider, "openrouter");
assert.equal(config.default_quality, "normal");
assert.equal(config.default_aspect_ratio, "16:9");
assert.equal(config.default_image_size, "2K");
assert.equal(config.default_image_api_dialect, "ratio-metadata");
assert.equal(config.default_model?.google, "gemini-3-pro-image");
assert.equal(config.default_model?.openai, "gpt-image-2");
assert.equal(config.default_model?.zai, "glm-image");
assert.equal(config.default_model?.azure, "image-prod");
assert.equal(config.default_model?.minimax, "image-01");
assert.equal(config.batch?.max_workers, 8);
assert.deepEqual(config.batch?.provider_limits?.google, {
concurrency: 2,
start_interval_ms: 900,
});
assert.deepEqual(config.batch?.provider_limits?.openai, {
concurrency: 4,
});
assert.deepEqual(config.batch?.provider_limits?.zai, {
concurrency: 2,
start_interval_ms: 1000,
});
assert.deepEqual(config.batch?.provider_limits?.minimax, {
concurrency: 2,
start_interval_ms: 1400,
});
assert.deepEqual(config.batch?.provider_limits?.azure, {
concurrency: 1,
start_interval_ms: 1500,
});
});
test("ensureDir creates nested dirs, is idempotent on an existing dir, and rethrows for a non-directory", async (t: TestContext) => {
const root = await makeTempDir("ensure-dir-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const nested = path.join(root, "a", "b", "c");
await ensureDir(nested);
assert.equal((await fs.stat(nested)).isDirectory(), true);
// Idempotent: a second call on an existing directory must not throw. This is the
// Bun-on-Windows regression the helper guards against (Bun wrongly throws EEXIST
// for mkdir(existingDir, { recursive: true })).
await ensureDir(nested);
// Rethrows when the path exists but is a file rather than a directory, so a real
// EEXIST against a non-directory is not silently swallowed.
const filePath = path.join(root, "not-a-dir");
await fs.writeFile(filePath, "x");
await assert.rejects(() => ensureDir(filePath));
});
test("loadExtendConfig renames legacy EXTEND.md when the new path is missing", async () => {
const root = await makeTempDir("baoyu-image-gen-extend-");
const cwd = path.join(root, "project");
const home = path.join(root, "home");
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md");
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
await fs.mkdir(home, { recursive: true });
await fs.writeFile(legacyPath, `---
default_provider: google
default_quality: 2k
---
`);
const config = await loadExtendConfig(cwd, home);
assert.equal(config.default_provider, "google");
assert.equal(config.default_quality, "2k");
await fs.access(currentPath);
await assert.rejects(() => fs.access(legacyPath));
});
test("loadExtendConfig leaves legacy EXTEND.md untouched when both paths exist", async () => {
const root = await makeTempDir("baoyu-image-gen-extend-dual-");
const cwd = path.join(root, "project");
const home = path.join(root, "home");
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md");
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
await fs.mkdir(path.dirname(currentPath), { recursive: true });
await fs.mkdir(home, { recursive: true });
await fs.writeFile(legacyPath, `---
default_provider: google
---
`);
await fs.writeFile(currentPath, `---
default_provider: openai
---
`);
const config = await loadExtendConfig(cwd, home);
assert.equal(config.default_provider, "openai");
assert.equal(await fs.readFile(legacyPath, "utf8"), `---
default_provider: google
---
`);
assert.equal(await fs.readFile(currentPath, "utf8"), `---
default_provider: openai
---
`);
});
test("mergeConfig only fills values missing from CLI args", () => {
const merged = mergeConfig(
makeArgs({
provider: "openai",
quality: null,
aspectRatio: null,
imageSize: "4K",
}),
{
default_provider: "google",
default_quality: "2k",
default_aspect_ratio: "3:2",
default_image_size: "2K",
default_image_api_dialect: "ratio-metadata",
} satisfies Partial<ExtendConfig>,
);
assert.equal(merged.provider, "openai");
assert.equal(merged.quality, "2k");
assert.equal(merged.aspectRatio, "3:2");
assert.equal(merged.aspectRatioSource, "config");
assert.equal(merged.imageSize, "4K");
assert.equal(merged.imageSizeSource, "cli");
assert.equal(merged.imageApiDialect, "ratio-metadata");
});
test("mergeConfig tags inherited imageSize defaults so providers can ignore incompatible config", () => {
const merged = mergeConfig(
makeArgs(),
{
default_image_size: "2K",
} satisfies Partial<ExtendConfig>,
);
assert.equal(merged.imageSize, "2K");
assert.equal(merged.imageSizeSource, "config");
});
test("mergeConfig falls back to OPENAI_IMAGE_API_DIALECT when CLI and EXTEND are unset", (t) => {
useEnv(t, {
OPENAI_IMAGE_API_DIALECT: "ratio-metadata",
});
const merged = mergeConfig(makeArgs(), {});
assert.equal(merged.imageApiDialect, "ratio-metadata");
});
test("parseOpenAIImageApiDialect validates supported values", () => {
assert.equal(parseOpenAIImageApiDialect("openai-native"), "openai-native");
assert.equal(parseOpenAIImageApiDialect("ratio-metadata"), "ratio-metadata");
assert.equal(parseOpenAIImageApiDialect(null), null);
assert.throws(
() => parseOpenAIImageApiDialect("gateway-magic"),
/Invalid OpenAI image API dialect/,
);
});
test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => {
assert.throws(
() =>
detectProvider(
makeArgs({
provider: "zai",
referenceImages: ["ref.png"],
}),
),
/Reference images require a ref-capable provider/,
);
useEnv(t, {
GOOGLE_API_KEY: "google-key",
OPENAI_API_KEY: "openai-key",
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(detectProvider(makeArgs()), "google");
});
test("detectProvider selects an available ref-capable provider for reference-image tasks", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: "openai-key",
AZURE_OPENAI_API_KEY: null,
AZURE_OPENAI_BASE_URL: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
"openai",
);
});
test("detectProvider selects Azure when only Azure credentials are configured", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: null,
AZURE_OPENAI_API_KEY: "azure-key",
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(detectProvider(makeArgs()), "azure");
assert.equal(
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
"azure",
);
});
test("detectProvider selects Z.AI when credentials are present or the model id matches", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: null,
AZURE_OPENAI_API_KEY: null,
AZURE_OPENAI_BASE_URL: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
ZAI_API_KEY: "zai-key",
BIGMODEL_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(detectProvider(makeArgs()), "zai");
assert.equal(detectProvider(makeArgs({ model: "glm-image" })), "zai");
});
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: "ark-key",
});
assert.equal(
detectProvider(
makeArgs({
model: "doubao-seedream-4-5-251128",
referenceImages: ["ref.png"],
}),
),
"seedream",
);
assert.equal(
detectProvider(
makeArgs({
provider: "seedream",
referenceImages: ["ref.png"],
}),
),
"seedream",
);
});
test("detectProvider allows DashScope reference-image workflows when explicitly chosen for wan2.7 models", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: null,
AZURE_OPENAI_API_KEY: null,
AZURE_OPENAI_BASE_URL: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: "dashscope-key",
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(
detectProvider(
makeArgs({
provider: "dashscope",
model: "wan2.7-image-pro",
referenceImages: ["ref.png"],
}),
),
"dashscope",
);
});
test("detectProvider selects MiniMax when only MiniMax credentials are configured or the model id matches", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: null,
AZURE_OPENAI_API_KEY: null,
AZURE_OPENAI_BASE_URL: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: "minimax-key",
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(detectProvider(makeArgs()), "minimax");
assert.equal(detectProvider(makeArgs({ referenceImages: ["ref.png"] })), "minimax");
assert.equal(detectProvider(makeArgs({ model: "image-01-live" })), "minimax");
});
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
useEnv(t, {
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5",
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
BAOYU_IMAGE_GEN_ZAI_CONCURRENCY: "4",
});
const extendConfig: Partial<ExtendConfig> = {
batch: {
max_workers: 7,
provider_limits: {
google: {
concurrency: 2,
start_interval_ms: 900,
},
zai: {
concurrency: 1,
start_interval_ms: 1200,
},
minimax: {
concurrency: 1,
start_interval_ms: 1500,
},
},
},
};
assert.equal(getConfiguredMaxWorkers(extendConfig), 12);
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).google, {
concurrency: 5,
startIntervalMs: 450,
});
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).zai, {
concurrency: 4,
startIntervalMs: 1200,
});
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, {
concurrency: 1,
startIntervalMs: 1500,
});
});
test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => {
const root = await makeTempDir("baoyu-image-gen-batch-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const batchFile = path.join(root, "jobs", "batch.json");
await fs.mkdir(path.dirname(batchFile), { recursive: true });
await fs.writeFile(
batchFile,
JSON.stringify({
jobs: 2,
tasks: [
{
id: "hero",
promptFiles: ["prompts/hero.md"],
image: "out/hero",
ref: ["refs/hero.png", "https://example.com/ref.png"],
ar: "16:9",
},
],
}),
);
const loaded = await loadBatchTasks(batchFile);
assert.equal(loaded.jobs, 2);
assert.equal(loaded.batchDir, path.dirname(batchFile));
assert.equal(loaded.tasks[0]?.id, "hero");
const taskArgs = createTaskArgs(
makeArgs({
provider: "replicate",
quality: "2k",
imageApiDialect: "ratio-metadata",
json: true,
}),
loaded.tasks[0]!,
loaded.batchDir,
);
assert.deepEqual(taskArgs.promptFiles, [
path.join(loaded.batchDir, "prompts/hero.md"),
]);
assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero"));
assert.deepEqual(taskArgs.referenceImages, [
path.join(loaded.batchDir, "refs/hero.png"),
"https://example.com/ref.png",
]);
assert.equal(taskArgs.provider, "replicate");
assert.equal(taskArgs.aspectRatio, "16:9");
assert.equal(taskArgs.quality, "2k");
assert.equal(taskArgs.imageApiDialect, "ratio-metadata");
assert.equal(taskArgs.json, true);
});
test("path normalization, worker count, and retry classification follow expected rules", () => {
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
assert.match(normalizeOutputImagePath("out/sample", ".jpg"), /out[\\/]+sample\.jpg$/);
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
assert.equal(getWorkerCount(8, null, 3), 3);
assert.equal(getWorkerCount(2, 6, 5), 2);
assert.equal(getWorkerCount(5, 0, 4), 1);
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
assert.equal(
isRetryableGenerationError(
new Error("Replicate returned 2 outputs, but baoyu-image-gen currently supports saving exactly one image per request."),
),
false,
);
assert.equal(
isRetryableGenerationError(
new Error("DashScope wan2.7 image models accept at most 9 reference images. Received 10."),
),
false,
);
assert.equal(
isRetryableGenerationError(
new Error("DashScope wan2.7 image models in baoyu-image-gen support exactly one output image per request."),
),
false,
);
assert.equal(
isRetryableGenerationError(
new Error("DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]."),
),
false,
);
assert.equal(
isRetryableGenerationError(
new Error("DashScope wan2.7-image requires total pixels between 768*768 and 2048*2048."),
),
false,
);
assert.equal(isRetryableGenerationError(new Error("socket hang up")), true);
});
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
buildRequestBody,
extractImageFromResponse,
parseAspectRatio,
resolveReferenceImages,
resolveSize,
snapDim,
validateArgs,
} from "./agnes.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
responseFormat: null,
...overrides,
};
}
test("snapDim rounds to the nearest multiple of 32", () => {
assert.equal(snapDim(767), 768);
assert.equal(snapDim(1023), 1024);
assert.equal(snapDim(1024), 1024);
assert.equal(snapDim(32), 32);
assert.equal(snapDim(0), 32);
assert.equal(snapDim(16), 32);
assert.equal(snapDim(48), 64);
});
test("parseAspectRatio parses valid ratios and rejects invalid inputs", () => {
assert.deepEqual(parseAspectRatio("3:4"), { width: 3, height: 4 });
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
assert.deepEqual(parseAspectRatio("1:1"), { width: 1, height: 1 });
assert.deepEqual(parseAspectRatio("1.5:1"), { width: 1.5, height: 1 });
assert.equal(parseAspectRatio(""), null);
assert.equal(parseAspectRatio("invalid"), null);
assert.equal(parseAspectRatio("3x4"), null);
assert.equal(parseAspectRatio("0:1"), null);
assert.equal(parseAspectRatio("1:0"), null);
});
test("resolveSize returns explicit --size directly", () => {
assert.equal(resolveSize({ size: "1024x1024" }), "1024x1024");
assert.equal(resolveSize({ size: "768x1024", aspectRatio: "16:9" }), "768x1024");
});
test("resolveSize returns default 1024x1024 when no size or ratio given", () => {
assert.equal(resolveSize({}), "1024x1024");
assert.equal(resolveSize({ size: null, aspectRatio: null }), "1024x1024");
});
test("resolveSize computes 32-aligned size within 2048 max edge", () => {
assert.equal(resolveSize({ aspectRatio: "1:1" }), "1024x1024");
assert.equal(resolveSize({ aspectRatio: "16:9" }), "2048x1152");
assert.equal(resolveSize({ aspectRatio: "4:3" }), "2048x1536");
assert.equal(resolveSize({ aspectRatio: "3:4" }), "1536x2048");
assert.equal(resolveSize({ aspectRatio: "9:16" }), "1152x2048");
});
test("resolveSize aligns to 32 and respects max edge", () => {
assert.equal(resolveSize({ aspectRatio: "3:1" }), "2048x672");
assert.equal(resolveSize({ aspectRatio: "1:3" }), "672x2048");
});
test("validateArgs rejects --n > 1", () => {
assert.throws(
() => validateArgs("agnes-image-2.1-flash", makeArgs({ n: 2 })),
/returns a single image per request/,
);
assert.doesNotThrow(() =>
validateArgs("agnes-image-2.1-flash", makeArgs({ n: 1 })),
);
});
test("buildRequestBody maps prompt, model, size, and reference images", () => {
const body = buildRequestBody("a cat", "agnes-image-2.1-flash", {
size: "1024x1024",
aspectRatio: null,
referenceImages: [],
});
assert.equal(body.model, "agnes-image-2.1-flash");
assert.equal(body.prompt, "a cat");
assert.equal(body.size, "1024x1024");
assert.deepEqual(body.extra_body, { response_format: "url" });
const bodyWithRef = buildRequestBody("a cat", "agnes-image-2.1-flash", {
size: null,
aspectRatio: "3:4",
referenceImages: ["https://example.com/ref.jpg"],
});
assert.equal(bodyWithRef.size, "1536x2048");
assert.deepEqual(bodyWithRef.image, ["https://example.com/ref.jpg"]);
});
test("extractImageFromResponse decodes b64_json payloads", async () => {
const fromBase64 = await extractImageFromResponse({
data: [{ b64_json: Buffer.from("hello").toString("base64") }],
});
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
});
test("extractImageFromResponse downloads URL payloads", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
globalThis.fetch = async () =>
new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { "Content-Type": "image/png" },
});
const fromUrl = await extractImageFromResponse({
data: [{ url: "https://example.com/output.png" }],
});
assert.deepEqual([...fromUrl], [1, 2, 3]);
});
test("extractImageFromResponse throws on empty data", async () => {
await assert.rejects(
() => extractImageFromResponse({ data: [] }),
/No image/,
);
await assert.rejects(
() => extractImageFromResponse({ data: [{}] }),
/No image/,
);
});
test("resolveReferenceImages converts local files to data URIs and passes URLs through", async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agnes-ref-"));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const localPath = path.join(dir, "ref.png");
const localBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
await fs.writeFile(localPath, localBytes);
const jpegPath = path.join(dir, "photo.jpeg");
await fs.writeFile(jpegPath, Buffer.from([0xff, 0xd8]));
const results = await resolveReferenceImages([
localPath,
"https://example.com/remote.jpg",
jpegPath,
]);
assert.equal(results.length, 3);
assert.match(results[0]!, /^data:image\/png;base64,/);
assert.match(results[1]!, /^https:\/\/example.com\/remote.jpg$/);
assert.match(results[2]!, /^data:image\/jpeg;base64,/);
});
test("resolveReferenceImages detects gif and webp mime types", async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agnes-mime-"));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const webpPath = path.join(dir, "ref.webp");
const gifPath = path.join(dir, "ref.gif");
await fs.writeFile(webpPath, Buffer.from([0x00]));
await fs.writeFile(gifPath, Buffer.from([0x00]));
const results = await resolveReferenceImages([webpPath, gifPath]);
assert.match(results[0]!, /^data:image\/webp;base64,/);
assert.match(results[1]!, /^data:image\/gif;base64,/);
});
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"] })),
);
});