
Baoyu Imagine
- 24 installs
- 941 repo stars
- Updated August 5, 2026
- guanyang/open-agent-hub
This is a copy of baoyu-imagine by guanyang - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
baoyu-imagine is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- baoyu-imagine
- AI & Agent Building
- AI-coding skill
Baoyu Imagine by the numbers
- 24 all-time installs (skills.sh)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/guanyang/open-agent-hub --skill baoyu-imagineAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 941 |
| Last updated | August 5, 2026 |
| Repository | guanyang/open-agent-hub ↗ |
What it does
Helps with ai & agent building tasks.
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 (豆包) and Replicate.
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. Main script: {baseDir}/scripts/main.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-imagine/EXTEND.md | Project |
${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-imagine/EXTEND.md | XDG |
$HOME/.baoyu-skills/baoyu-imagine/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-image-gen/EXTEND.md exists and the new path doesn't, the runtime renames it to baoyu-imagine. 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
# Batch mode
${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) |
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) |
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS | Per-provider start-gap |
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: use the repo-levelscripts/codex-imagegen.shwrapper when the calling skill supports it (for examplebaoyu-cover-image). Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths. - 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. If first-class Codex OAuth support is added to baoyu-imagine, implement it as a distinct provider (for example openai-codex) with its own auth, route, request shape, docs, 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-preview" and the env var sets GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview, 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 |
Provider Selection
1. --ref provided + no --provider → auto-select Google → OpenAI → Azure → OpenRouter → Replicate → Seedream → MiniMax (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) 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
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 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-imagine'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/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-imagine 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-imagine 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. - Repo-level
scripts/codex-imagegen.sh, ifcodexCLI is installed/logged in and the calling skill supports the wrapper. - 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-imagine --provider openai can use Codex OAuth without a dedicated provider implementation.
Codex OAuth vs OpenAI API key for baoyu-imagine
baoyu-imagine --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-imagine'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, use the repo-levelscripts/codex-imagegen.shwrapper when the calling skill supports it. The wrapper invokescodex 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. - If the user wants
baoyu-imagineitself to support Codex OAuth, add a distinct provider such asopenai-codexrather than modifying the existingopenaiprovider.
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"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-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
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-preview (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-imagine/EXTEND.md | Current project |
| User | $HOME/.baoyu-skills/baoyu-imagine/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
---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-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
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-imagine, 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-preview (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-imagine,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-imagine, 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-imagine"
- 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]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|null (null = auto-detect)
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-preview", "gemini-3.1-flash-image-preview"
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-preview"
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"
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
---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 |
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-preview"
openai: "gpt-image-2"
azure: "gpt-image-2"
openrouter: "google/gemini-3.1-flash-image-preview"
dashscope: "qwen-image-2.0-pro"
zai: "glm-image"
minimax: "image-01"
replicate: "google/nano-banana-2"
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
---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-imagine 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-imagine 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-imagine 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-imagine 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-preview.
Common Models
Use full OpenRouter model IDs:
google/gemini-3.1-flash-image-preview(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-imagine 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-imagine 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-imagine (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-imagine 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-preview --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-preview --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 2048x1152Batch 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-imagine", "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-imagine-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-imagine 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:
npx -y tsx 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-imagine batch tasks (default: replicate)
--model <id> Explicit model for baoyu-imagine batch tasks (default: resolved by baoyu-imagine 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 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,
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-imagine 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-preview
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-preview");
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("loadExtendConfig renames legacy EXTEND.md when the new path is missing", async () => {
const root = await makeTempDir("baoyu-imagine-extend-");
const cwd = path.join(root, "project");
const home = path.join(root, "home");
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "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-imagine-extend-dual-");
const cwd = path.join(root, "project");
const home = path.join(root, "home");
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "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-imagine-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-imagine 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-imagine 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 path from "node:path";
import process from "node:process";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import type {
BatchFile,
BatchTaskInput,
CliArgs,
ExtendConfig,
OpenAIImageApiDialect,
Provider,
} from "./types";
type ProviderModule = {
getDefaultModel: () => string;
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
validateArgs?: (model: string, args: CliArgs) => void;
getDefaultOutputExtension?: (model: string, args: CliArgs) => string;
};
type PreparedTask = {
id: string;
prompt: string;
args: CliArgs;
provider: Provider;
model: string;
outputPath: string;
providerModule: ProviderModule;
};
type TaskResult = {
id: string;
provider: Provider;
model: string;
outputPath: string;
success: boolean;
attempts: number;
error: string | null;
};
type ProviderRateLimit = {
concurrency: number;
startIntervalMs: number;
};
type LoadedBatchTasks = {
tasks: BatchTaskInput[];
jobs: number | null;
batchDir: string;
};
const MAX_ATTEMPTS = 3;
const DEFAULT_MAX_WORKERS = 10;
const POLL_WAIT_MS = 250;
const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
replicate: { concurrency: 5, startIntervalMs: 700 },
google: { concurrency: 3, startIntervalMs: 1100 },
openai: { concurrency: 3, startIntervalMs: 1100 },
openrouter: { concurrency: 3, startIntervalMs: 1100 },
dashscope: { concurrency: 3, startIntervalMs: 1100 },
zai: { concurrency: 3, startIntervalMs: 1100 },
minimax: { concurrency: 3, startIntervalMs: 1100 },
jimeng: { concurrency: 3, startIntervalMs: 1100 },
seedream: { concurrency: 3, startIntervalMs: 1100 },
azure: { concurrency: 3, startIntervalMs: 1100 },
};
function printUsage(): void {
console.log(`Usage:
npx -y bun scripts/main.ts --prompt "A cat" --image cat.png
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
npx -y bun scripts/main.ts --batchfile batch.json
Options:
-p, --prompt <text> 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|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure Force provider (auto-detect by default)
-m, --model <id> Model ID
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
--size <WxH> Size (e.g., 1024x1024)
--quality normal|2k Quality preset (default: 2k)
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
--imageApiDialect <id> OpenAI-compatible image dialect: openai-native|ratio-metadata
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, Seedream 4.0/4.5/5.0, or DashScope wan2.7-image*)
--n <count> Number of images for the current task (default: 1; Replicate currently requires 1)
--json JSON output
-h, --help Show help
Batch 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"
}
]
}
Behavior:
- Batch mode automatically runs in parallel when pending tasks >= 2
- Each image retries automatically up to 3 attempts
- Batch summary reports success count, failure count, and per-image errors
- Replicate currently supports single-image save semantics only; --n must stay at 1
Environment variables:
OPENAI_API_KEY OpenAI API key
OPENROUTER_API_KEY OpenRouter API key
GOOGLE_API_KEY Google API key
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
DASHSCOPE_API_KEY DashScope API key
ZAI_API_KEY Z.AI API key
BIGMODEL_API_KEY Backward-compatible alias for Z.AI API key
MINIMAX_API_KEY MiniMax API key
REPLICATE_API_TOKEN Replicate API token
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
ARK_API_KEY Seedream/Ark API key
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-2)
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
ZAI_IMAGE_MODEL Default Z.AI model (glm-image)
BIGMODEL_IMAGE_MODEL Backward-compatible alias for Z.AI model (glm-image)
MINIMAX_IMAGE_MODEL Default MiniMax model (image-01)
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-2)
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
OPENAI_BASE_URL Custom OpenAI endpoint
OPENAI_IMAGE_API_DIALECT OpenAI-compatible image dialect (openai-native|ratio-metadata)
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
OPENROUTER_BASE_URL Custom OpenRouter endpoint
OPENROUTER_HTTP_REFERER Optional app URL for OpenRouter attribution
OPENROUTER_TITLE Optional app name for OpenRouter attribution
GOOGLE_BASE_URL Custom Google endpoint
DASHSCOPE_BASE_URL Custom DashScope endpoint
ZAI_BASE_URL Custom Z.AI endpoint
BIGMODEL_BASE_URL Backward-compatible alias for Z.AI endpoint
MINIMAX_BASE_URL Custom MiniMax endpoint
REPLICATE_BASE_URL Custom Replicate endpoint
JIMENG_BASE_URL Custom Jimeng endpoint
AZURE_OPENAI_API_KEY Azure OpenAI API key
AZURE_OPENAI_BASE_URL Azure OpenAI resource or deployment endpoint
AZURE_OPENAI_DEPLOYMENT Default Azure deployment name
AZURE_API_VERSION Azure API version (default: 2025-04-01-preview)
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-2)
SEEDREAM_BASE_URL Custom Seedream endpoint
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
}
export function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {
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,
};
const positional: string[] = [];
const takeMany = (i: number): { items: string[]; next: number } => {
const items: string[] = [];
let j = i + 1;
while (j < argv.length) {
const v = argv[j]!;
if (v.startsWith("-")) break;
items.push(v);
j++;
}
return { items, next: j - 1 };
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--help" || a === "-h") {
out.help = true;
continue;
}
if (a === "--json") {
out.json = true;
continue;
}
if (a === "--prompt" || a === "-p") {
const v = argv[++i];
if (!v) throw new Error(`Missing value for ${a}`);
out.prompt = v;
continue;
}
if (a === "--promptfiles") {
const { items, next } = takeMany(i);
if (items.length === 0) throw new Error("Missing files for --promptfiles");
out.promptFiles.push(...items);
i = next;
continue;
}
if (a === "--image") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --image");
out.imagePath = v;
continue;
}
if (a === "--batchfile") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --batchfile");
out.batchFile = v;
continue;
}
if (a === "--jobs") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --jobs");
out.jobs = parseInt(v, 10);
if (isNaN(out.jobs) || out.jobs < 1) throw new Error(`Invalid worker count: ${v}`);
continue;
}
if (a === "--provider") {
const v = argv[++i];
if (
v !== "google" &&
v !== "openai" &&
v !== "openrouter" &&
v !== "dashscope" &&
v !== "zai" &&
v !== "minimax" &&
v !== "replicate" &&
v !== "jimeng" &&
v !== "seedream" &&
v !== "azure"
) {
throw new Error(`Invalid provider: ${v}`);
}
out.provider = v;
continue;
}
if (a === "--model" || a === "-m") {
const v = argv[++i];
if (!v) throw new Error(`Missing value for ${a}`);
out.model = v;
continue;
}
if (a === "--ar") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --ar");
out.aspectRatio = v;
out.aspectRatioSource = "cli";
continue;
}
if (a === "--size") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --size");
out.size = v;
continue;
}
if (a === "--quality") {
const v = argv[++i];
if (v !== "normal" && v !== "2k") throw new Error(`Invalid quality: ${v}`);
out.quality = v;
continue;
}
if (a === "--imageSize") {
const v = argv[++i]?.toUpperCase();
if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`Invalid imageSize: ${v}`);
out.imageSize = v;
out.imageSizeSource = "cli";
continue;
}
if (a === "--imageApiDialect") {
const v = argv[++i];
if (v !== "openai-native" && v !== "ratio-metadata") {
throw new Error(`Invalid imageApiDialect: ${v}`);
}
out.imageApiDialect = v;
continue;
}
if (a === "--ref" || a === "--reference") {
const { items, next } = takeMany(i);
if (items.length === 0) throw new Error(`Missing files for ${a}`);
out.referenceImages.push(...items);
i = next;
continue;
}
if (a === "--n") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --n");
out.n = parseInt(v, 10);
if (isNaN(out.n) || out.n < 1) throw new Error(`Invalid count: ${v}`);
continue;
}
if (a.startsWith("-")) {
throw new Error(`Unknown option: ${a}`);
}
positional.push(a);
}
if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) {
out.prompt = positional.join(" ");
}
return out;
}
async function loadEnvFile(p: string): Promise<Record<string, string>> {
try {
const content = await readFile(p, "utf8");
const env: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
let val = trimmed.slice(idx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
env[key] = val;
}
return env;
} catch {
return {};
}
}
async function loadEnv(): Promise<void> {
const home = homedir();
const cwd = process.cwd();
const homeEnv = await loadEnvFile(path.join(home, ".baoyu-skills", ".env"));
const cwdEnv = await loadEnvFile(path.join(cwd, ".baoyu-skills", ".env"));
for (const [k, v] of Object.entries(homeEnv)) {
if (!process.env[k]) process.env[k] = v;
}
for (const [k, v] of Object.entries(cwdEnv)) {
if (!process.env[k]) process.env[k] = v;
}
}
export function extractYamlFrontMatter(content: string): string | null {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
return match ? match[1] : null;
}
export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
const config: Partial<ExtendConfig> = {};
const lines = yaml.split("\n");
let currentKey: string | null = null;
let currentProvider: Provider | null = null;
for (const line of lines) {
const trimmed = line.trim();
const indent = line.match(/^\s*/)?.[0].length ?? 0;
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
const colonIdx = trimmed.indexOf(":");
const key = trimmed.slice(0, colonIdx).trim();
let value = trimmed.slice(colonIdx + 1).trim();
if (value === "null" || value === "") {
value = "null";
}
if (key === "version") {
config.version = value === "null" ? 1 : parseInt(value, 10);
} else if (key === "default_provider") {
config.default_provider = value === "null" ? null : (value as Provider);
} else if (key === "default_quality") {
config.default_quality = value === "null" ? null : value as "normal" | "2k";
} else if (key === "default_aspect_ratio") {
const cleaned = value.replace(/['"]/g, "");
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
} else if (key === "default_image_size") {
config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K";
} else if (key === "default_image_api_dialect") {
config.default_image_api_dialect =
value === "null" ? null : parseOpenAIImageApiDialect(value);
} else if (key === "default_model") {
config.default_model = {
google: null,
openai: null,
openrouter: null,
dashscope: null,
zai: null,
minimax: null,
replicate: null,
jimeng: null,
seedream: null,
azure: null,
};
currentKey = "default_model";
currentProvider = null;
} else if (key === "batch") {
config.batch = {};
currentKey = "batch";
currentProvider = null;
} else if (currentKey === "batch" && indent >= 2 && key === "max_workers") {
config.batch ??= {};
config.batch.max_workers = value === "null" ? null : parseInt(value, 10);
} else if (currentKey === "batch" && indent >= 2 && key === "provider_limits") {
config.batch ??= {};
config.batch.provider_limits ??= {};
currentKey = "provider_limits";
currentProvider = null;
} else if (
currentKey === "provider_limits" &&
indent >= 4 &&
(
key === "google" ||
key === "openai" ||
key === "openrouter" ||
key === "dashscope" ||
key === "zai" ||
key === "minimax" ||
key === "replicate" ||
key === "jimeng" ||
key === "seedream" ||
key === "azure"
)
) {
config.batch ??= {};
config.batch.provider_limits ??= {};
config.batch.provider_limits[key] ??= {};
currentProvider = key;
} else if (
currentKey === "default_model" &&
(
key === "google" ||
key === "openai" ||
key === "openrouter" ||
key === "dashscope" ||
key === "zai" ||
key === "minimax" ||
key === "replicate" ||
key === "jimeng" ||
key === "seedream" ||
key === "azure"
)
) {
const cleaned = value.replace(/['"]/g, "");
config.default_model![key] = cleaned === "null" ? null : cleaned;
} else if (
currentKey === "provider_limits" &&
currentProvider &&
indent >= 6 &&
(key === "concurrency" || key === "start_interval_ms")
) {
config.batch ??= {};
config.batch.provider_limits ??= {};
const providerLimit = (config.batch.provider_limits[currentProvider] ??= {});
if (key === "concurrency") {
providerLimit.concurrency = value === "null" ? null : parseInt(value, 10);
} else {
providerLimit.start_interval_ms = value === "null" ? null : parseInt(value, 10);
}
}
}
}
return config;
}
export function parseOpenAIImageApiDialect(
value: string | undefined | null
): OpenAIImageApiDialect | null {
if (!value) return null;
const normalized = value.replace(/['"]/g, "").trim();
if (normalized === "openai-native" || normalized === "ratio-metadata") return normalized;
throw new Error(`Invalid OpenAI image API dialect: ${value}`);
}
type ExtendConfigPathPair = {
current: string;
legacy: string;
};
function getExtendConfigPathPairs(cwd: string, home: string): ExtendConfigPathPair[] {
return [
{
current: path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
legacy: path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
},
{
current: path.join(home, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
legacy: path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
},
];
}
async function exists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function migrateLegacyExtendConfig(cwd: string, home: string): Promise<void> {
for (const { current, legacy } of getExtendConfigPathPairs(cwd, home)) {
const [hasCurrent, hasLegacy] = await Promise.all([exists(current), exists(legacy)]);
if (hasCurrent || !hasLegacy) continue;
await mkdir(path.dirname(current), { recursive: true });
await rename(legacy, current);
}
}
export async function loadExtendConfig(
cwd = process.cwd(),
home = homedir(),
): Promise<Partial<ExtendConfig>> {
await migrateLegacyExtendConfig(cwd, home);
const paths = getExtendConfigPathPairs(cwd, home).map(({ current }) => current);
for (const p of paths) {
try {
const content = await readFile(p, "utf8");
const yaml = extractYamlFrontMatter(content);
if (!yaml) continue;
return parseSimpleYaml(yaml);
} catch {
continue;
}
}
return {};
}
export function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
const aspectRatio = args.aspectRatio ?? extend.default_aspect_ratio ?? null;
const imageSize = args.imageSize ?? extend.default_image_size ?? null;
const imageApiDialect =
args.imageApiDialect ??
extend.default_image_api_dialect ??
parseOpenAIImageApiDialect(process.env.OPENAI_IMAGE_API_DIALECT);
return {
...args,
provider: args.provider ?? extend.default_provider ?? null,
quality: args.quality ?? extend.default_quality ?? null,
aspectRatio,
aspectRatioSource:
args.aspectRatioSource ??
(args.aspectRatio !== null ? "cli" : (aspectRatio !== null ? "config" : null)),
imageSize,
imageSizeSource:
args.imageSizeSource ??
(args.imageSize !== null ? "cli" : (imageSize !== null ? "config" : null)),
imageApiDialect,
};
}
export function parsePositiveInt(value: string | undefined): number | null {
if (!value) return null;
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
export function parsePositiveBatchInt(value: unknown): number | null {
if (value === null || value === undefined) return null;
if (typeof value === "number") {
return Number.isInteger(value) && value > 0 ? value : null;
}
if (typeof value === "string") {
return parsePositiveInt(value);
}
return null;
}
export function getConfiguredMaxWorkers(extendConfig: Partial<ExtendConfig>): number {
const envValue = parsePositiveInt(process.env.BAOYU_IMAGE_GEN_MAX_WORKERS);
const configValue = extendConfig.batch?.max_workers ?? null;
return Math.max(1, envValue ?? configValue ?? DEFAULT_MAX_WORKERS);
}
export function getConfiguredProviderRateLimits(
extendConfig: Partial<ExtendConfig>
): Record<Provider, ProviderRateLimit> {
const configured: Record<Provider, ProviderRateLimit> = {
replicate: { ...DEFAULT_PROVIDER_RATE_LIMITS.replicate },
google: { ...DEFAULT_PROVIDER_RATE_LIMITS.google },
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
zai: { ...DEFAULT_PROVIDER_RATE_LIMITS.zai },
minimax: { ...DEFAULT_PROVIDER_RATE_LIMITS.minimax },
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
};
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
configured[provider] = {
concurrency:
parsePositiveInt(process.env[`${envPrefix}_CONCURRENCY`]) ??
extendLimit?.concurrency ??
configured[provider].concurrency,
startIntervalMs:
parsePositiveInt(process.env[`${envPrefix}_START_INTERVAL_MS`]) ??
extendLimit?.start_interval_ms ??
configured[provider].startIntervalMs,
};
}
return configured;
}
async function readPromptFromFiles(files: string[]): Promise<string> {
const parts: string[] = [];
for (const f of files) {
parts.push(await readFile(f, "utf8"));
}
return parts.join("\n\n");
}
async function readPromptFromStdin(): Promise<string | null> {
if (process.stdin.isTTY) return null;
try {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const value = Buffer.concat(chunks).toString("utf8").trim();
return value.length > 0 ? value : null;
} catch {
return null;
}
}
export function normalizeOutputImagePath(p: string, defaultExtension = ".png"): string {
const full = path.resolve(p);
const ext = path.extname(full);
if (ext) return full;
return `${full}${defaultExtension}`;
}
function inferProviderFromModel(model: string | null): Provider | null {
if (!model) return null;
const normalized = model.trim();
if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream";
if (normalized === "image-01" || normalized === "image-01-live") return "minimax";
if (normalized === "glm-image" || normalized === "cogview-4-250304") return "zai";
return null;
}
export function detectProvider(args: CliArgs): Provider {
if (
args.referenceImages.length > 0 &&
args.provider &&
args.provider !== "google" &&
args.provider !== "openai" &&
args.provider !== "azure" &&
args.provider !== "openrouter" &&
args.provider !== "replicate" &&
args.provider !== "seedream" &&
args.provider !== "minimax" &&
args.provider !== "dashscope"
) {
throw new Error(
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider dashscope with a wan2.7 image model, --provider seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows."
);
}
if (args.provider) return args.provider;
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
const hasAzure = !!(process.env.AZURE_OPENAI_API_KEY && process.env.AZURE_OPENAI_BASE_URL);
const hasOpenai = !!process.env.OPENAI_API_KEY;
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
const hasZai = !!(process.env.ZAI_API_KEY || process.env.BIGMODEL_API_KEY);
const hasMinimax = !!process.env.MINIMAX_API_KEY;
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
const hasSeedream = !!process.env.ARK_API_KEY;
const modelProvider = inferProviderFromModel(args.model);
if (modelProvider === "seedream") {
if (!hasSeedream) {
throw new Error("Model looks like a Volcengine ARK image model, but ARK_API_KEY is not set.");
}
return "seedream";
}
if (modelProvider === "minimax") {
if (!hasMinimax) {
throw new Error("Model looks like a MiniMax image model, but MINIMAX_API_KEY is not set.");
}
return "minimax";
}
if (modelProvider === "zai") {
if (!hasZai) {
throw new Error("Model looks like a Z.AI image model, but ZAI_API_KEY is not set.");
}
return "zai";
}
if (args.referenceImages.length > 0) {
if (hasGoogle) return "google";
if (hasOpenai) return "openai";
if (hasAzure) return "azure";
if (hasOpenrouter) return "openrouter";
if (hasReplicate) return "replicate";
if (hasSeedream) return "seedream";
if (hasMinimax) return "minimax";
throw new Error(
"Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, or MiniMax. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, or MINIMAX_API_KEY, or remove --ref."
);
}
const available = [
hasGoogle && "google",
hasOpenai && "openai",
hasAzure && "azure",
hasOpenrouter && "openrouter",
hasDashscope && "dashscope",
hasZai && "zai",
hasMinimax && "minimax",
hasReplicate && "replicate",
hasJimeng && "jimeng",
hasSeedream && "seedream",
].filter(Boolean) as Provider[];
if (available.length === 1) return available[0]!;
if (available.length > 1) return available[0]!;
throw new Error(
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
);
}
export type ReferenceImageValidationOptions = {
allowRemoteUrls?: boolean;
};
function isRemoteReferenceImage(refPath: string): boolean {
return /^https?:\/\//i.test(refPath);
}
function shouldAllowRemoteReferenceImages(provider: Provider | null): boolean {
return provider === "dashscope";
}
export async function validateReferenceImages(
referenceImages: string[],
options: ReferenceImageValidationOptions = {},
): Promise<void> {
for (const refPath of referenceImages) {
if (options.allowRemoteUrls && isRemoteReferenceImage(refPath)) continue;
const fullPath = path.resolve(refPath);
try {
await access(fullPath);
} catch {
throw new Error(`Reference image not found: ${fullPath}`);
}
}
}
export function isRetryableGenerationError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
const nonRetryableMarkers = [
"Reference image",
"not supported",
"only supported",
"No API key found",
"is required",
"Invalid ",
"Unexpected ",
"API error (400)",
"API error (401)",
"API error (402)",
"API error (403)",
"API error (404)",
"temporarily disabled",
"supports saving exactly one image",
"supports only",
"support exactly one output image",
"support aspect ratios in",
"requires total pixels between",
"accept at most",
];
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
}
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
if (provider === "zai") return (await import("./providers/zai")) as ProviderModule;
if (provider === "minimax") return (await import("./providers/minimax")) as ProviderModule;
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
if (provider === "azure") return (await import("./providers/azure")) as ProviderModule;
return (await import("./providers/openai")) as ProviderModule;
}
async function loadPromptForArgs(args: CliArgs): Promise<string | null> {
let prompt: string | null = args.prompt;
if (!prompt && args.promptFiles.length > 0) {
prompt = await readPromptFromFiles(args.promptFiles);
}
return prompt;
}
function getModelForProvider(
provider: Provider,
requestedModel: string | null,
extendConfig: Partial<ExtendConfig>,
providerModule: ProviderModule
): string {
if (requestedModel) return requestedModel;
if (extendConfig.default_model) {
if (provider === "google" && extendConfig.default_model.google) return extendConfig.default_model.google;
if (provider === "openai" && extendConfig.default_model.openai) return extendConfig.default_model.openai;
if (provider === "openrouter" && extendConfig.default_model.openrouter) {
return extendConfig.default_model.openrouter;
}
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
if (provider === "zai" && extendConfig.default_model.zai) return extendConfig.default_model.zai;
if (provider === "minimax" && extendConfig.default_model.minimax) return extendConfig.default_model.minimax;
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure;
}
return providerModule.getDefaultModel();
}
async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<PreparedTask> {
if (!args.quality) args.quality = "2k";
const prompt = (await loadPromptForArgs(args)) ?? (await readPromptFromStdin());
if (!prompt) throw new Error("Prompt is required");
if (!args.imagePath) throw new Error("--image is required");
if (args.referenceImages.length > 0) {
await validateReferenceImages(args.referenceImages, {
allowRemoteUrls: shouldAllowRemoteReferenceImages(args.provider),
});
}
const provider = detectProvider(args);
const providerModule = await loadProviderModule(provider);
const model = getModelForProvider(provider, args.model, extendConfig, providerModule);
providerModule.validateArgs?.(model, args);
const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, args) ?? ".png";
return {
id: "single",
prompt,
args,
provider,
model,
outputPath: normalizeOutputImagePath(args.imagePath, defaultOutputExtension),
providerModule,
};
}
export async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks> {
const resolvedBatchFilePath = path.resolve(batchFilePath);
const content = await readFile(resolvedBatchFilePath, "utf8");
const parsed = JSON.parse(content.replace(/^\uFEFF/, "")) as BatchFile;
const batchDir = path.dirname(resolvedBatchFilePath);
if (Array.isArray(parsed)) {
return {
tasks: parsed,
jobs: null,
batchDir,
};
}
if (parsed && typeof parsed === "object" && Array.isArray(parsed.tasks)) {
const jobs = parsePositiveBatchInt(parsed.jobs);
if (parsed.jobs !== undefined && parsed.jobs !== null && jobs === null) {
throw new Error("Invalid batch file. jobs must be a positive integer when provided.");
}
return {
tasks: parsed.tasks,
jobs,
batchDir,
};
}
throw new Error("Invalid batch file. Expected an array of tasks or an object with a tasks array.");
}
export function resolveBatchPath(batchDir: string, filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);
}
function resolveBatchReferencePath(batchDir: string, filePath: string): string {
return isRemoteReferenceImage(filePath) ? filePath : resolveBatchPath(batchDir, filePath);
}
export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
return {
...baseArgs,
prompt: task.prompt ?? null,
promptFiles: task.promptFiles ? task.promptFiles.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
imagePath: task.image ? resolveBatchPath(batchDir, task.image) : null,
provider: task.provider ?? baseArgs.provider ?? null,
model: task.model ?? baseArgs.model ?? null,
aspectRatio: task.ar ?? baseArgs.aspectRatio ?? null,
aspectRatioSource: task.ar != null ? "task" : (baseArgs.aspectRatioSource ?? null),
size: task.size ?? baseArgs.size ?? null,
quality: task.quality ?? baseArgs.quality ?? null,
imageSize: task.imageSize ?? baseArgs.imageSize ?? null,
imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null),
imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null,
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchReferencePath(batchDir, filePath)) : [],
n: task.n ?? baseArgs.n,
batchFile: null,
jobs: baseArgs.jobs,
json: baseArgs.json,
help: false,
};
}
async function prepareBatchTasks(
args: CliArgs,
extendConfig: Partial<ExtendConfig>
): Promise<{ tasks: PreparedTask[]; jobs: number | null }> {
if (!args.batchFile) throw new Error("--batchfile is required in batch mode");
const { tasks: taskInputs, jobs: batchJobs, batchDir } = await loadBatchTasks(args.batchFile);
if (taskInputs.length === 0) throw new Error("Batch file does not contain any tasks.");
const prepared: PreparedTask[] = [];
for (let i = 0; i < taskInputs.length; i++) {
const task = taskInputs[i]!;
const taskArgs = createTaskArgs(args, task, batchDir);
const prompt = await loadPromptForArgs(taskArgs);
if (!prompt) throw new Error(`Task ${i + 1} is missing prompt or promptFiles.`);
if (!taskArgs.imagePath) throw new Error(`Task ${i + 1} is missing image output path.`);
if (taskArgs.referenceImages.length > 0) {
await validateReferenceImages(taskArgs.referenceImages, {
allowRemoteUrls: shouldAllowRemoteReferenceImages(taskArgs.provider),
});
}
const provider = detectProvider(taskArgs);
const providerModule = await loadProviderModule(provider);
const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule);
providerModule.validateArgs?.(model, taskArgs);
const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, taskArgs) ?? ".png";
prepared.push({
id: task.id || `task-${String(i + 1).padStart(2, "0")}`,
prompt,
args: taskArgs,
provider,
model,
outputPath: normalizeOutputImagePath(taskArgs.imagePath, defaultOutputExtension),
providerModule,
});
}
return {
tasks: prepared,
jobs: args.jobs ?? batchJobs,
};
}
async function writeImage(outputPath: string, imageData: Uint8Array): Promise<void> {
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, imageData);
}
async function generatePreparedTask(task: PreparedTask): Promise<TaskResult> {
console.error(`Using ${task.provider} / ${task.model} for ${task.id}`);
console.error(
`Switch model: --model <id> | EXTEND.md default_model.${task.provider} | env ${task.provider.toUpperCase()}_IMAGE_MODEL`
);
let attempts = 0;
while (attempts < MAX_ATTEMPTS) {
attempts += 1;
try {
const imageData = await task.providerModule.generateImage(task.prompt, task.model, task.args);
await writeImage(task.outputPath, imageData);
return {
id: task.id,
provider: task.provider,
model: task.model,
outputPath: task.outputPath,
success: true,
attempts,
error: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const canRetry = attempts < MAX_ATTEMPTS && isRetryableGenerationError(error);
if (canRetry) {
console.error(`[${task.id}] Attempt ${attempts}/${MAX_ATTEMPTS} failed, retrying...`);
continue;
}
return {
id: task.id,
provider: task.provider,
model: task.model,
outputPath: task.outputPath,
success: false,
attempts,
error: message,
};
}
}
return {
id: task.id,
provider: task.provider,
model: task.model,
outputPath: task.outputPath,
success: false,
attempts: MAX_ATTEMPTS,
error: "Unknown failure",
};
}
function createProviderGate(providerRateLimits: Record<Provider, ProviderRateLimit>) {
const state = new Map<Provider, { active: number; lastStartedAt: number }>();
return async function acquire(provider: Provider): Promise<() => void> {
const limit = providerRateLimits[provider];
while (true) {
const current = state.get(provider) ?? { active: 0, lastStartedAt: 0 };
const now = Date.now();
const enoughCapacity = current.active < limit.concurrency;
const enoughGap = now - current.lastStartedAt >= limit.startIntervalMs;
if (enoughCapacity && enoughGap) {
state.set(provider, { active: current.active + 1, lastStartedAt: now });
return () => {
const latest = state.get(provider) ?? { active: 1, lastStartedAt: now };
state.set(provider, {
active: Math.max(0, latest.active - 1),
lastStartedAt: latest.lastStartedAt,
});
};
}
await new Promise((resolve) => setTimeout(resolve, POLL_WAIT_MS));
}
};
}
export function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {
const requested = jobs ?? Math.min(taskCount, maxWorkers);
return Math.max(1, Math.min(requested, taskCount, maxWorkers));
}
async function runBatchTasks(
tasks: PreparedTask[],
jobs: number | null,
extendConfig: Partial<ExtendConfig>
): Promise<TaskResult[]> {
if (tasks.length === 1) {
return [await generatePreparedTask(tasks[0]!)];
}
const maxWorkers = getConfiguredMaxWorkers(extendConfig);
const providerRateLimits = getConfiguredProviderRateLimits(extendConfig);
const acquireProvider = createProviderGate(providerRateLimits);
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
const limit = providerRateLimits[provider];
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
}
let nextIndex = 0;
const results: TaskResult[] = new Array(tasks.length);
const worker = async (): Promise<void> => {
while (true) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= tasks.length) return;
const task = tasks[currentIndex]!;
const release = await acquireProvider(task.provider);
try {
results[currentIndex] = await generatePreparedTask(task);
} finally {
release();
}
}
};
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
function printBatchSummary(results: TaskResult[]): void {
const successCount = results.filter((result) => result.success).length;
const failureCount = results.length - successCount;
console.error("");
console.error("Batch generation summary:");
console.error(`- Total: ${results.length}`);
console.error(`- Succeeded: ${successCount}`);
console.error(`- Failed: ${failureCount}`);
if (failureCount > 0) {
console.error("Failure reasons:");
for (const result of results.filter((item) => !item.success)) {
console.error(`- ${result.id}: ${result.error}`);
}
}
}
function emitJson(payload: unknown): void {
console.log(JSON.stringify(payload, null, 2));
}
async function runSingleMode(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<void> {
const task = await prepareSingleTask(args, extendConfig);
const result = await generatePreparedTask(task);
if (!result.success) {
throw new Error(result.error || "Generation failed");
}
if (args.json) {
emitJson({
savedImage: result.outputPath,
provider: result.provider,
model: result.model,
attempts: result.attempts,
prompt: task.prompt.slice(0, 200),
});
return;
}
console.log(result.outputPath);
}
async function runBatchMode(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<void> {
const { tasks, jobs } = await prepareBatchTasks(args, extendConfig);
const results = await runBatchTasks(tasks, jobs, extendConfig);
printBatchSummary(results);
if (args.json) {
emitJson({
mode: "batch",
total: results.length,
succeeded: results.filter((item) => item.success).length,
failed: results.filter((item) => !item.success).length,
results,
});
}
if (results.some((item) => !item.success)) {
process.exitCode = 1;
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
return;
}
await loadEnv();
const extendConfig = await loadExtendConfig();
const mergedArgs = mergeConfig(args, extendConfig);
if (!mergedArgs.quality) mergedArgs.quality = "2k";
if (mergedArgs.batchFile) {
await runBatchMode(mergedArgs, extendConfig);
return;
}
await runSingleMode(mergedArgs, extendConfig);
}
function isDirectExecution(metaUrl: string): boolean {
const entryPath = process.argv[1];
if (!entryPath) return false;
try {
return path.resolve(entryPath) === fileURLToPath(metaUrl);
} catch {
return false;
}
}
if (isDirectExecution(import.meta.url)) {
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exit(1);
});
}
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
addAspectRatioToPrompt,
buildGoogleUrl,
buildPromptWithAspect,
extractInlineImageData,
extractPredictedImageData,
getGoogleImageSize,
isGoogleImagen,
isGoogleMultimodal,
normalizeGoogleModelId,
} from "./google.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,
...overrides,
};
}
test("Google provider helpers normalize model IDs and select image size defaults", () => {
assert.equal(
normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"),
"gemini-3.1-flash-image-preview",
);
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
assert.equal(getGoogleImageSize(makeArgs({ imageSize: null, quality: "2k" })), "2K");
assert.equal(getGoogleImageSize(makeArgs({ imageSize: "4K", quality: "normal" })), "4K");
});
test("Google URL builder appends v1beta when the base URL does not already include it", (t) => {
useEnv(t, { GOOGLE_BASE_URL: "https://generativelanguage.googleapis.com" });
assert.equal(
buildGoogleUrl("models/demo:generateContent"),
"https://generativelanguage.googleapis.com/v1beta/models/demo:generateContent",
);
});
test("Google URL and prompt helpers preserve existing v1beta paths and aspect hints", (t) => {
useEnv(t, { GOOGLE_BASE_URL: "https://example.com/custom/v1beta/" });
assert.equal(
buildGoogleUrl("/models/demo:predict"),
"https://example.com/custom/v1beta/models/demo:predict",
);
assert.equal(
addAspectRatioToPrompt("A city skyline", "16:9"),
"A city skyline Aspect ratio: 16:9.",
);
assert.equal(
buildPromptWithAspect("A city skyline", "16:9", "2k"),
"A city skyline Aspect ratio: 16:9. High resolution 2048px.",
);
});
test("Google response extractors find inline and predicted image payloads", () => {
assert.equal(
extractInlineImageData({
candidates: [
{
content: {
parts: [{ inlineData: { data: "inline-base64" } }],
},
},
],
}),
"inline-base64",
);
assert.equal(
extractPredictedImageData({
predictions: [{ image: { imageBytes: "predicted-base64" } }],
}),
"predicted-base64",
);
assert.equal(
extractPredictedImageData({
generatedImages: [{ bytesBase64Encoded: "generated-base64" }],
}),
"generated-base64",
);
});