mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
feat(baoyu-image-gen): add codex-cli provider wrapping codex-imagegen
Expose Codex CLI's built-in image_gen tool through baoyu-image-gen's
standard CLI + batch flow as a dedicated provider. The provider spawns
the bundled scripts/codex-imagegen/main.ts (synced from
packages/baoyu-codex-imagegen/src/) so the skill remains self-contained
and inherits retry, cache, file-lock, and JSONL logging. No
OPENAI_API_KEY required — uses the user's Codex subscription.
New env vars: BAOYU_CODEX_IMAGEGEN_{BIN,CACHE_DIR,TIMEOUT_MS,RETRIES,LOG_FILE}.
Hyphenated provider names now resolve under-scored env vars (e.g.,
BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY). codex-cli is never auto-selected
— pin via --provider or default_provider in EXTEND.md.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-image-gen
|
name: baoyu-image-gen
|
||||||
description: AI image generation with OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope, Z.AI GLM-Image, MiniMax, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
description: AI image generation with OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope, Z.AI GLM-Image, MiniMax, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||||
version: 2.0.0
|
version: 2.1.0
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
|
||||||
@@ -81,6 +81,9 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider d
|
|||||||
# OpenAI GPT Image 2
|
# OpenAI GPT Image 2
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai --model gpt-image-2
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai --model gpt-image-2
|
||||||
|
|
||||||
|
# Codex CLI (uses logged-in Codex subscription — no OPENAI_API_KEY required; requires `codex` on PATH)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider codex-cli --ar 16:9
|
||||||
|
|
||||||
# Batch mode
|
# Batch mode
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||||
```
|
```
|
||||||
@@ -104,7 +107,7 @@ When the user wants a person/object preserved from reference images:
|
|||||||
| `--image <path>` | Output image path (required in single-image mode) |
|
| `--image <path>` | Output image path (required in single-image mode) |
|
||||||
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
| `--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) |
|
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||||
| `--provider google\|openai\|azure\|openrouter\|dashscope\|zai\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
| `--provider google\|openai\|azure\|openrouter\|dashscope\|zai\|minimax\|jimeng\|seedream\|replicate\|codex-cli` | Force provider (default: auto-detect; `codex-cli` is never auto-selected — must be pinned via CLI or EXTEND.md) |
|
||||||
| `--model <id>`, `-m` | Model ID — see provider references for defaults and allowed values |
|
| `--model <id>`, `-m` | Model ID — see provider references for defaults and allowed values |
|
||||||
| `--ar <ratio>` | Aspect ratio (`16:9`, `1:1`, `4:3`, …) |
|
| `--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) |
|
| `--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) |
|
||||||
@@ -137,8 +140,13 @@ When the user wants a person/object preserved from reference images:
|
|||||||
| `OPENAI_IMAGE_API_DIALECT` | `openai-native` \| `ratio-metadata` |
|
| `OPENAI_IMAGE_API_DIALECT` | `openai-native` \| `ratio-metadata` |
|
||||||
| `OPENROUTER_HTTP_REFERER`, `OPENROUTER_TITLE` | Optional OpenRouter attribution |
|
| `OPENROUTER_HTTP_REFERER`, `OPENROUTER_TITLE` | Optional OpenRouter attribution |
|
||||||
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
|
| `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>_CONCURRENCY` | Per-provider concurrency (e.g., `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY`; for codex-cli use `BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY`) |
|
||||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Per-provider start-gap |
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Per-provider start-gap |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_BIN` | Override the codex-imagegen wrapper path for the `codex-cli` provider (default: bundled `scripts/codex-imagegen/main.ts`; accepts `.ts` or legacy `.sh`/binary) |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_CACHE_DIR` | Enable idempotency cache for the `codex-cli` provider (off by default) |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS` | Per-attempt `codex exec` timeout for the `codex-cli` provider (default: 300000 ms) |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_RETRIES` | Wrapper-side retry attempts on retryable errors for the `codex-cli` provider (default: 2) |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_LOG_FILE` | Append JSONL diagnostic log for the `codex-cli` provider |
|
||||||
|
|
||||||
**Load priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
**Load priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||||
|
|
||||||
@@ -149,10 +157,10 @@ When the user wants a person/object preserved from reference images:
|
|||||||
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:
|
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 `imagegen` skill/tool.
|
- In Codex runtime: use the native `imagegen` skill/tool.
|
||||||
- In non-Codex runtimes with `codex` CLI installed and logged in: use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it (for example `baoyu-cover-image`). Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths.
|
- In non-Codex runtimes with `codex` CLI installed and logged in: use `baoyu-image-gen --provider codex-cli` (preferred — it gives you the same retry / cache / batch flow as every other provider). The provider spawns the bundled `scripts/codex-imagegen/main.ts`; the same code lives upstream at `packages/baoyu-codex-imagegen/src/main.ts` for standalone callers.
|
||||||
- In Hermes runtimes with a native `image_generate` tool: use that tool as a fallback, and state whether reference images were passed directly or reconstructed from extracted traits.
|
- In Hermes runtimes with a native `image_generate` tool: 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-image-gen`, 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`.
|
Do not modify the existing `openai` provider to silently consume Codex OAuth. The first-class Codex-CLI path is the dedicated `codex-cli` provider, which has its own auth (Codex login), route (`codex exec`), request shape, and tests. See `references/codex-oauth-vs-openai-api-key.md`.
|
||||||
|
|
||||||
## Model Resolution
|
## Model Resolution
|
||||||
|
|
||||||
@@ -194,13 +202,15 @@ Each provider has its own quirks (model families, size rules, ref support, limit
|
|||||||
| MiniMax (image-01, subject-reference) | `references/providers/minimax.md` |
|
| MiniMax (image-01, subject-reference) | `references/providers/minimax.md` |
|
||||||
| OpenRouter (multimodal models, `/chat/completions` flow) | `references/providers/openrouter.md` |
|
| OpenRouter (multimodal models, `/chat/completions` flow) | `references/providers/openrouter.md` |
|
||||||
| Replicate (nano-banana, Seedream, Wan) | `references/providers/replicate.md` |
|
| Replicate (nano-banana, Seedream, Wan) | `references/providers/replicate.md` |
|
||||||
|
| Codex CLI (wraps bundled `scripts/codex-imagegen/`; Codex login, no `OPENAI_API_KEY`) | `references/providers/codex-cli.md` |
|
||||||
|
|
||||||
## Provider Selection
|
## 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)
|
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)
|
2. `--provider` specified → use it (if `--ref`, must be google/openai/azure/openrouter/replicate/seedream/minimax/codex-cli)
|
||||||
3. Only one API key present → use that provider
|
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
|
4. Multiple keys → default priority: Google → OpenAI → Azure → OpenRouter → DashScope → Z.AI → MiniMax → Replicate → Jimeng → Seedream
|
||||||
|
5. `codex-cli` is **never auto-selected** — set `default_provider: codex-cli` in EXTEND.md or pass `--provider codex-cli`. It spawns `codex exec` via the bundled `scripts/codex-imagegen/main.ts` TS entrypoint (run with `bun`) and uses the user's Codex subscription (no `OPENAI_API_KEY`). Requires `codex` on `PATH` with an active `codex login`.
|
||||||
|
|
||||||
## Quality Presets
|
## Quality Presets
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This is expected. The `openai` provider uses the public OpenAI Images API and ne
|
|||||||
2. If it fails only because `OPENAI_API_KEY` is missing, do not leave the user waiting.
|
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:
|
3. Prefer a Codex/native raster backend in this order:
|
||||||
- Codex runtime native `imagegen` skill/tool, if available.
|
- Codex runtime native `imagegen` skill/tool, if available.
|
||||||
- Repo-level `scripts/codex-imagegen.sh`, if `codex` CLI is installed/logged in and the calling skill supports the wrapper.
|
- `baoyu-image-gen --provider codex-cli` (preferred — wraps the bundled `scripts/codex-imagegen/main.ts`; the underlying repo-level package lives at `packages/baoyu-codex-imagegen/src/main.ts` for standalone callers), if `codex` CLI is installed/logged in.
|
||||||
- Hermes native `image_generate`, if available.
|
- Hermes native `image_generate`, if available.
|
||||||
4. Be transparent about reference-image behavior:
|
4. Be transparent about reference-image behavior:
|
||||||
- If the fallback backend accepts references, pass the reference images.
|
- If the fallback backend accepts references, pass the reference images.
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ Codex / ChatGPT login is different. Codex image generation is driven by Codex OA
|
|||||||
## What to use instead
|
## What to use instead
|
||||||
|
|
||||||
- If running inside Codex and the native `imagegen` skill/tool is available, use it directly.
|
- If running inside Codex and the native `imagegen` skill/tool is available, use it directly.
|
||||||
- If running outside Codex but the `codex` CLI is installed and logged in, use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it. The wrapper invokes `codex exec` and the Codex `image_gen` tool; no `OPENAI_API_KEY` is required.
|
- If running outside Codex but the `codex` CLI is installed and logged in, call `baoyu-image-gen --provider codex-cli` (preferred). It spawns the bundled `scripts/codex-imagegen/main.ts` and surfaces its retry/cache/log machinery through baoyu-image-gen's standard CLI + batch flow. Standalone callers outside this skill can run the same code at `packages/baoyu-codex-imagegen/src/main.ts`. Both invoke `codex exec` and the Codex `image_gen` tool; no `OPENAI_API_KEY` is required.
|
||||||
- If running inside Hermes and a native `image_generate` tool 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 running inside Hermes and a native `image_generate` tool 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-image-gen` itself to support Codex OAuth, add a distinct provider such as `openai-codex` rather than modifying the existing `openai` provider.
|
- `baoyu-image-gen` already exposes a distinct `codex-cli` provider (wraps the bundled `scripts/codex-imagegen/`); do not modify the existing `openai` provider to add Codex OAuth.
|
||||||
|
|
||||||
## Reference-image prompting note
|
## Reference-image prompting note
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
|||||||
---
|
---
|
||||||
version: 1
|
version: 1
|
||||||
|
|
||||||
default_provider: null # google|openai|azure|openrouter|dashscope|zai|minimax|replicate|null (null = auto-detect)
|
default_provider: null # google|openai|azure|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|codex-cli|null (null = auto-detect; codex-cli is never auto-detected — pin it here or via --provider)
|
||||||
|
|
||||||
default_quality: null # normal|2k|null (null = use default: 2k)
|
default_quality: null # normal|2k|null (null = use default: 2k)
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ default_model:
|
|||||||
zai: null # e.g., "glm-image"
|
zai: null # e.g., "glm-image"
|
||||||
minimax: null # e.g., "image-01"
|
minimax: null # e.g., "image-01"
|
||||||
replicate: null # e.g., "google/nano-banana-2"
|
replicate: null # e.g., "google/nano-banana-2"
|
||||||
|
codex-cli: null # Logical label only — Codex image_gen has no user-selectable model. Default: "codex-image-gen"
|
||||||
|
|
||||||
batch:
|
batch:
|
||||||
max_workers: 10
|
max_workers: 10
|
||||||
@@ -58,6 +59,9 @@ batch:
|
|||||||
minimax:
|
minimax:
|
||||||
concurrency: 3
|
concurrency: 3
|
||||||
start_interval_ms: 1100
|
start_interval_ms: 1100
|
||||||
|
codex-cli:
|
||||||
|
concurrency: 1
|
||||||
|
start_interval_ms: 2000
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,6 +83,7 @@ batch:
|
|||||||
| `default_model.zai` | string\|null | null | Z.AI default model |
|
| `default_model.zai` | string\|null | null | Z.AI default model |
|
||||||
| `default_model.minimax` | string\|null | null | MiniMax default model |
|
| `default_model.minimax` | string\|null | null | MiniMax default model |
|
||||||
| `default_model.replicate` | string\|null | null | Replicate default model |
|
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||||
|
| `default_model.codex-cli` | string\|null | null | Codex-CLI logical label (Codex image_gen has no user-selectable model) |
|
||||||
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
||||||
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
||||||
| `batch.provider_limits.<provider>.start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider |
|
| `batch.provider_limits.<provider>.start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider |
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Codex CLI (`--provider codex-cli`)
|
||||||
|
|
||||||
|
Read when the user picks `--provider codex-cli`, sets `default_provider: codex-cli`, or asks for "Codex image generation without an OpenAI API key". This provider is a thin baoyu-image-gen wrapper around the bundled `scripts/codex-imagegen/main.ts` (synced from `packages/baoyu-codex-imagegen`), which spawns `codex exec --json --sandbox danger-full-access` and routes the request to Codex CLI's built-in `image_gen` tool. The Codex CLI uses the **user's Codex / ChatGPT subscription** — no `OPENAI_API_KEY` is read or sent.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @openai/codex
|
||||||
|
codex login # signs in with the user's OpenAI / Codex account
|
||||||
|
codex --version # confirm >= 0.130
|
||||||
|
```
|
||||||
|
|
||||||
|
`bun` is required for running the underlying wrapper (`scripts/codex-imagegen/main.ts`, carrying `#!/usr/bin/env bun`). If `bun` is missing from the runtime, `npx -y bun` works as a fallback.
|
||||||
|
|
||||||
|
## Selection
|
||||||
|
|
||||||
|
- **Never auto-selected.** `detectProvider` only picks `codex-cli` when it is pinned explicitly: pass `--provider codex-cli` or set `default_provider: codex-cli` in EXTEND.md.
|
||||||
|
- Choose this provider when:
|
||||||
|
- The user has a Codex subscription and explicitly does **not** want to manage an OpenAI API key.
|
||||||
|
- You need Codex's specific `image_gen` behavior or quality.
|
||||||
|
- Avoid this provider when latency matters — Codex CLI is typically 5–10× slower than direct OpenAI / Google API calls (except on cache hits).
|
||||||
|
|
||||||
|
## Supported flags
|
||||||
|
|
||||||
|
| Flag | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| `--prompt <text>` / `--promptfiles <files>` | Required. Written to a temp file and passed to the wrapper as `--prompt-file`. |
|
||||||
|
| `--image <path>` | Required. Final output PNG location. |
|
||||||
|
| `--ar <ratio>` | Mapped to wrapper's `--aspect`. Supported by Codex: `1:1` (default), `16:9`, `9:16`, `4:3`, `2.35:1`. |
|
||||||
|
| `--ref <files...>` | Mapped to wrapper's repeated `--ref`. Codex's `image_gen` accepts reference images for style/composition guidance. |
|
||||||
|
| `--n` | Must be `1`. `validateArgs` throws if `n > 1` because Codex `image_gen` returns a single image per call. |
|
||||||
|
| `--imageApiDialect` | Not applicable. Throws if set to a non-default value. |
|
||||||
|
| `--size`, `--imageSize`, `--quality` | Silently ignored — Codex picks pixel dimensions from the aspect ratio. |
|
||||||
|
| `--model`, `-m` | Logical label only. The wrapper does not forward a model selector to Codex; the underlying engine is whichever model Codex's `image_gen` currently uses. Default label: `codex-image-gen`. |
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_BIN` | Override the wrapper path. Default: bundled `scripts/codex-imagegen/main.ts` resolved relative to this skill's installed location. Accepts a `.ts` file (spawned with `bun`) or a legacy `.sh`/binary (spawned directly). |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_CACHE_DIR` | Enable the wrapper's idempotency cache. Disabled by default; set to e.g. `~/.cache/baoyu-codex-imagegen` for high-value reuse. |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS` | Per-attempt `codex exec` timeout in ms. Default: `300000` (5 min). Raise for slow networks or large prompts. |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_RETRIES` | Wrapper-side retry attempts on retryable errors. Default: `2` (3 total attempts). |
|
||||||
|
| `BAOYU_CODEX_IMAGEGEN_LOG_FILE` | Append a structured JSONL diagnostic log. Useful when triaging timeouts or `agent_refused` errors. |
|
||||||
|
| `BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY` | Batch-mode concurrency for the `codex-cli` provider. Default: `1` — Codex exec is a heavy single-process workflow; raising this rarely helps. |
|
||||||
|
| `BAOYU_IMAGE_GEN_CODEX_CLI_START_INTERVAL_MS` | Batch-mode minimum start-gap. Default: `2000` ms. |
|
||||||
|
|
||||||
|
## Error model
|
||||||
|
|
||||||
|
The wrapper emits a single JSON line on stdout. On failure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
The provider re-throws each wrapper error as `Invalid codex-cli result (<error_kind>): <message>`. The `"Invalid "` prefix triggers `isRetryableGenerationError` to mark it **non-retryable** in baoyu-image-gen's outer retry loop — the wrapper has already retried internally per `BAOYU_CODEX_IMAGEGEN_RETRIES`, so re-spawning Codex from main.ts would only multiply latency without changing the outcome.
|
||||||
|
|
||||||
|
`error_kind` values to expect:
|
||||||
|
|
||||||
|
| Kind | Cause | Action |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `codex_not_installed` | `codex` not on `PATH` or unreadable | `npm install -g @openai/codex`, then `codex login`. |
|
||||||
|
| `invalid_args` | Programmer error in the spawn invocation | Inspect provider source; usually a path-injection guard fired. |
|
||||||
|
| `prompt_file_missing` | Temp prompt file vanished mid-call | Retry once; check `$TMPDIR` permissions. |
|
||||||
|
| `spawn_failed` | OS / process-launch failure | Verify `bun` or `npx` is installed; check filesystem permissions. |
|
||||||
|
| `timeout` | `codex exec` exceeded `--timeout` | Raise `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS`; check network. |
|
||||||
|
| `no_image_gen_tool_use` | Codex agent answered without calling `image_gen` | Often transient — retry. If persistent, refine the prompt. |
|
||||||
|
| `output_missing` / `invalid_png` | Agent reported success but file is absent or not a valid PNG | Retry; check disk space. |
|
||||||
|
| `agent_refused` | Codex agent refused (policy or content) | Adjust the prompt; surface the refusal to the user. |
|
||||||
|
| `lock_busy` | Another `codex-imagegen` invocation holds the file lock | Wait or set a distinct `--cache-dir` per concurrent caller. |
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- Slow: 5–10× direct OpenAI API latency (except cache hits).
|
||||||
|
- Subject to the same TOS as interactive `codex exec` use — programmatic invocation from baoyu-image-gen is the same usage class.
|
||||||
|
- Stateful: requires `codex login` to be live; an expired session manifests as `codex_not_installed` or `agent_refused`.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `references/codex-oauth-vs-openai-api-key.md` — why Codex OAuth is not interchangeable with `OPENAI_API_KEY`.
|
||||||
|
- `references/codex-image2-fallback.md` — when to fall back to `codex-cli` from a failed `openai` provider call.
|
||||||
@@ -77,8 +77,19 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.p
|
|||||||
|
|
||||||
# Replicate Wan 2.7 Image Pro
|
# Replicate Wan 2.7 Image Pro
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A concept frame" --image out.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A concept frame" --image out.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
|
||||||
|
|
||||||
|
# Codex CLI (uses Codex / ChatGPT subscription — no OPENAI_API_KEY; requires `codex` on PATH and `codex login`)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.png --provider codex-cli --ar 16:9
|
||||||
|
|
||||||
|
# Codex CLI with reference images (style/composition guidance)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "Match this color palette" --image out.png --provider codex-cli --ref source.png --ar 1:1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notes on `codex-cli`:
|
||||||
|
- Never auto-selected — pin via `--provider codex-cli` or `default_provider: codex-cli` in EXTEND.md.
|
||||||
|
- Only `n=1` supported (Codex `image_gen` returns one image per call); `--size`, `--imageSize`, `--quality`, and `--imageApiDialect` are ignored or rejected.
|
||||||
|
- Typically 5–10× slower than direct OpenAI / Google API calls (except on cache hits). Tune via `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS`, `BAOYU_CODEX_IMAGEGEN_RETRIES`, and `BAOYU_CODEX_IMAGEGEN_CACHE_DIR`.
|
||||||
|
|
||||||
## Batch Mode
|
## Batch Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, writeFile, copyFile, stat } from "node:fs/promises";
|
||||||
|
import { existsSync, openSync, closeSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
|
||||||
|
export function cacheKey(prompt: string, aspect: string, refs: string[]): string {
|
||||||
|
const h = createHash("sha256");
|
||||||
|
h.update(prompt);
|
||||||
|
h.update("|");
|
||||||
|
h.update(aspect);
|
||||||
|
h.update("|");
|
||||||
|
for (const r of [...refs].sort()) h.update(r);
|
||||||
|
return h.digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupCache(cacheDir: string, key: string): Promise<string | null> {
|
||||||
|
const entry = path.join(cacheDir, `${key}.png`);
|
||||||
|
try {
|
||||||
|
const s = await stat(entry);
|
||||||
|
if (s.size > 1000) return entry;
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeCache(cacheDir: string, key: string, sourcePath: string): Promise<void> {
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
const entry = path.join(cacheDir, `${key}.png`);
|
||||||
|
await copyFile(sourcePath, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileLock {
|
||||||
|
private fd: number | null = null;
|
||||||
|
constructor(private lockPath: string) {}
|
||||||
|
|
||||||
|
async acquire(timeoutMs = 30_000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
await mkdir(path.dirname(this.lockPath), { recursive: true });
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
this.fd = openSync(this.lockPath, "wx");
|
||||||
|
return;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code !== "EEXIST") throw e;
|
||||||
|
if (await this.isStale()) {
|
||||||
|
try {
|
||||||
|
await this.release(true);
|
||||||
|
} catch {}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await delay(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to acquire lock at ${this.lockPath} within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isStale(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const s = await stat(this.lockPath);
|
||||||
|
return Date.now() - s.mtimeMs > 10 * 60 * 1000;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async release(force = false): Promise<void> {
|
||||||
|
if (this.fd != null) {
|
||||||
|
try {
|
||||||
|
closeSync(this.fd);
|
||||||
|
} catch {}
|
||||||
|
this.fd = null;
|
||||||
|
}
|
||||||
|
if (existsSync(this.lockPath) || force) {
|
||||||
|
const { unlink } = await import("node:fs/promises");
|
||||||
|
try {
|
||||||
|
await unlink(this.lockPath);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { appendFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
ts: string;
|
||||||
|
level: "info" | "warn" | "error";
|
||||||
|
event: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsonLogger {
|
||||||
|
constructor(private logFile: string | null, public verbose: boolean) {}
|
||||||
|
|
||||||
|
async log(level: LogEntry["level"], event: string, extra: Record<string, unknown> = {}): Promise<void> {
|
||||||
|
const entry: LogEntry = { ts: new Date().toISOString(), level, event, ...extra };
|
||||||
|
const line = JSON.stringify(entry);
|
||||||
|
if (this.verbose) process.stderr.write(`[${level}] ${event} ${jsonExtras(extra)}\n`);
|
||||||
|
if (this.logFile) {
|
||||||
|
await mkdir(path.dirname(this.logFile), { recursive: true });
|
||||||
|
await appendFile(this.logFile, line + "\n", "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info(event: string, extra?: Record<string, unknown>) {
|
||||||
|
return this.log("info", event, extra);
|
||||||
|
}
|
||||||
|
warn(event: string, extra?: Record<string, unknown>) {
|
||||||
|
return this.log("warn", event, extra);
|
||||||
|
}
|
||||||
|
error(event: string, extra?: Record<string, unknown>) {
|
||||||
|
return this.log("error", event, extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonExtras(extra: Record<string, unknown>): string {
|
||||||
|
const entries = Object.entries(extra);
|
||||||
|
if (entries.length === 0) return "";
|
||||||
|
return entries.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ");
|
||||||
|
}
|
||||||
+326
@@ -0,0 +1,326 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { readFile, mkdir, copyFile, stat } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
import { GenError, type CliOptions, type GenerateResult } from "./types.ts";
|
||||||
|
import { runCodexExec } from "./spawn.ts";
|
||||||
|
import { findCpToTarget, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
|
||||||
|
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
|
||||||
|
import { JsonLogger } from "./logger.ts";
|
||||||
|
|
||||||
|
const HELP = `codex-imagegen — generate images via Codex CLI's image_gen tool
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
codex-imagegen --image <output.png> [--prompt <text> | --prompt-file <path>] [options]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--image <path> Output PNG path
|
||||||
|
--prompt <text> Prompt text (or use --prompt-file)
|
||||||
|
--prompt-file <path> Read prompt from file
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--aspect <ratio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:1
|
||||||
|
--ref <file> Reference image (repeatable)
|
||||||
|
--timeout <ms> Codex exec timeout in ms. Default: 300000
|
||||||
|
--retries <n> Retry attempts on retryable errors. Default: 2
|
||||||
|
--retry-delay <ms> Base retry delay (exponential). Default: 1500
|
||||||
|
--cache-dir <path> Enable idempotency cache. Disabled by default.
|
||||||
|
--log-file <path> Append JSONL log
|
||||||
|
-v, --verbose Verbose stderr logging
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Stdout: single JSON line on success or failure.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SHELL_METACHAR = /[;|&`$<>\n\r()'"]/;
|
||||||
|
|
||||||
|
function assertSafePath(label: string, value: string): void {
|
||||||
|
if (SHELL_METACHAR.test(value)) {
|
||||||
|
throw new GenError(
|
||||||
|
"invalid_args",
|
||||||
|
`${label} contains shell metacharacters and would be unsafe to interpolate into the codex instruction: ${value}`,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliOptions {
|
||||||
|
const opts: CliOptions = {
|
||||||
|
prompt: "",
|
||||||
|
promptFile: null,
|
||||||
|
outputPath: "",
|
||||||
|
aspect: "1:1",
|
||||||
|
refImages: [],
|
||||||
|
timeoutMs: 300_000,
|
||||||
|
retries: 2,
|
||||||
|
retryDelayMs: 1500,
|
||||||
|
cacheDir: null,
|
||||||
|
logFile: null,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
const next = () => argv[++i];
|
||||||
|
switch (a) {
|
||||||
|
case "--prompt": opts.prompt = next(); break;
|
||||||
|
case "--prompt-file": opts.promptFile = next(); break;
|
||||||
|
case "--image": opts.outputPath = next(); break;
|
||||||
|
case "--aspect": opts.aspect = next(); break;
|
||||||
|
case "--ref": opts.refImages.push(next()); break;
|
||||||
|
case "--timeout": opts.timeoutMs = Number(next()); break;
|
||||||
|
case "--retries": opts.retries = Number(next()); break;
|
||||||
|
case "--retry-delay": opts.retryDelayMs = Number(next()); break;
|
||||||
|
case "--cache-dir": opts.cacheDir = next(); break;
|
||||||
|
case "--log-file": opts.logFile = next(); break;
|
||||||
|
case "-v":
|
||||||
|
case "--verbose": opts.verbose = true; break;
|
||||||
|
case "-h":
|
||||||
|
case "--help": process.stdout.write(HELP); process.exit(0);
|
||||||
|
default: throw new GenError("invalid_args", `Unknown argument: ${a}`, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false);
|
||||||
|
if (opts.prompt && opts.promptFile) {
|
||||||
|
throw new GenError("invalid_args", "--prompt and --prompt-file are mutually exclusive", false);
|
||||||
|
}
|
||||||
|
if (!opts.prompt && !opts.promptFile) {
|
||||||
|
throw new GenError("invalid_args", "--prompt or --prompt-file required", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve every filesystem path to absolute up front, so behavior is
|
||||||
|
// independent of the caller's cwd. This matters when the wrapper is
|
||||||
|
// invoked from a skill running in an arbitrary working directory.
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const toAbs = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
|
||||||
|
|
||||||
|
opts.outputPath = toAbs(opts.outputPath);
|
||||||
|
if (opts.promptFile) opts.promptFile = toAbs(opts.promptFile);
|
||||||
|
opts.refImages = opts.refImages.map(toAbs);
|
||||||
|
if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir);
|
||||||
|
if (opts.logFile) opts.logFile = toAbs(opts.logFile);
|
||||||
|
|
||||||
|
// The output and ref paths are interpolated raw into the agent instruction
|
||||||
|
// sent to `codex exec --sandbox danger-full-access`. A path containing shell
|
||||||
|
// metacharacters could be misread by the agent's shell when it cp's the
|
||||||
|
// result into place. Reject upfront rather than trusting the agent to quote.
|
||||||
|
assertSafePath("--image path", opts.outputPath);
|
||||||
|
for (const ref of opts.refImages) assertSafePath("--ref path", ref);
|
||||||
|
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrompt(opts: CliOptions): Promise<string> {
|
||||||
|
if (opts.prompt) return opts.prompt;
|
||||||
|
const file = opts.promptFile!;
|
||||||
|
try {
|
||||||
|
return await readFile(file, "utf-8");
|
||||||
|
} catch {
|
||||||
|
throw new GenError("prompt_file_missing", `Prompt file not found: ${file}`, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstruction(prompt: string, opts: CliOptions): string {
|
||||||
|
const refHint = opts.refImages.length > 0
|
||||||
|
? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`
|
||||||
|
: "";
|
||||||
|
return `You have an internal tool called image_gen for image generation. Use it.
|
||||||
|
|
||||||
|
TASK: Generate an image with the spec below, then save to disk.
|
||||||
|
|
||||||
|
PROMPT:
|
||||||
|
${prompt}
|
||||||
|
|
||||||
|
ASPECT RATIO: ${opts.aspect}
|
||||||
|
OUTPUT PATH: ${opts.outputPath}
|
||||||
|
${refHint}
|
||||||
|
STEPS:
|
||||||
|
1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.
|
||||||
|
2. Move or copy the resulting image from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
|
||||||
|
3. Verify with: ls -la ${opts.outputPath}
|
||||||
|
4. Reply with ONLY this JSON line (no markdown fences, no other text):
|
||||||
|
{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}
|
||||||
|
|
||||||
|
HARD CONSTRAINTS:
|
||||||
|
- Do NOT use curl, wget, Python, or any external API.
|
||||||
|
- Do NOT use bash to fabricate an image; only image_gen produces real pixels.
|
||||||
|
- Use ONLY the image_gen internal tool.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptGenerate(
|
||||||
|
opts: CliOptions,
|
||||||
|
instruction: string,
|
||||||
|
attempt: number,
|
||||||
|
log: JsonLogger,
|
||||||
|
): Promise<{ bytes: number; threadId: string | null; usage: any; toolCalls: any[] }> {
|
||||||
|
await log.info("attempt.start", { attempt, output: opts.outputPath, aspect: opts.aspect });
|
||||||
|
|
||||||
|
const run = await runCodexExec({
|
||||||
|
instruction,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
refImages: opts.refImages,
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.info("codex.completed", {
|
||||||
|
duration_ms: run.durationMs,
|
||||||
|
thread_id: run.threadId,
|
||||||
|
tool_calls: run.toolCalls.length,
|
||||||
|
usage: run.usage,
|
||||||
|
raw_log: run.rawLogPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// verify: thread id must be present
|
||||||
|
if (!run.threadId) {
|
||||||
|
throw new GenError("agent_refused", "No thread id in event stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/)
|
||||||
|
const ver = await verifyImageGenWasInvoked(run.threadId);
|
||||||
|
if (!ver.ok) {
|
||||||
|
// secondary verify: did tool_calls include cp/mv from generated_images to our target
|
||||||
|
if (!findCpToTarget(run.toolCalls, opts.outputPath)) {
|
||||||
|
throw new GenError("no_image_gen_tool_use", `image_gen was not invoked: ${ver.reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify output
|
||||||
|
const { bytes } = await verifyOutput(opts.outputPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
threadId: run.threadId,
|
||||||
|
usage: run.usage,
|
||||||
|
toolCalls: run.toolCalls.map((tc) => ({ tool: tc.tool, status: tc.status })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate(opts: CliOptions, log: JsonLogger): Promise<GenerateResult> {
|
||||||
|
const startEpoch = Date.now();
|
||||||
|
const prompt = await loadPrompt(opts);
|
||||||
|
|
||||||
|
// Cache lookup
|
||||||
|
if (opts.cacheDir) {
|
||||||
|
const key = cacheKey(prompt, opts.aspect, opts.refImages);
|
||||||
|
const cached = await lookupCache(opts.cacheDir, key);
|
||||||
|
if (cached) {
|
||||||
|
await mkdir(path.dirname(opts.outputPath), { recursive: true });
|
||||||
|
await copyFile(cached, opts.outputPath);
|
||||||
|
const s = await stat(opts.outputPath);
|
||||||
|
await log.info("cache.hit", { key, source: cached });
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
path: opts.outputPath,
|
||||||
|
bytes: s.size,
|
||||||
|
elapsed_seconds: 0,
|
||||||
|
thread_id: null,
|
||||||
|
attempts: 0,
|
||||||
|
cached: true,
|
||||||
|
usage: null,
|
||||||
|
tool_calls: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await log.info("cache.miss", { key });
|
||||||
|
}
|
||||||
|
|
||||||
|
// lock to prevent concurrent codex exec
|
||||||
|
const lockDir = opts.cacheDir ?? path.join(homedir(), ".cache", "baoyu-codex-imagegen");
|
||||||
|
const lock = new FileLock(path.join(lockDir, "codex-exec.lock"));
|
||||||
|
try {
|
||||||
|
await lock.acquire(60_000);
|
||||||
|
} catch (e) {
|
||||||
|
throw new GenError("lock_busy", String(e), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(path.dirname(opts.outputPath), { recursive: true });
|
||||||
|
const instruction = buildInstruction(prompt, opts);
|
||||||
|
|
||||||
|
let lastErr: GenError | null = null;
|
||||||
|
let lastAttempt = 0;
|
||||||
|
try {
|
||||||
|
for (let attempt = 1; attempt <= opts.retries + 1; attempt++) {
|
||||||
|
lastAttempt = attempt;
|
||||||
|
try {
|
||||||
|
const result = await attemptGenerate(opts, instruction, attempt, log);
|
||||||
|
|
||||||
|
// write to cache
|
||||||
|
if (opts.cacheDir) {
|
||||||
|
const key = cacheKey(prompt, opts.aspect, opts.refImages);
|
||||||
|
await storeCache(opts.cacheDir, key, opts.outputPath);
|
||||||
|
await log.info("cache.stored", { key });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
path: opts.outputPath,
|
||||||
|
bytes: result.bytes,
|
||||||
|
elapsed_seconds: Math.round((Date.now() - startEpoch) / 1000),
|
||||||
|
thread_id: result.threadId,
|
||||||
|
attempts: attempt,
|
||||||
|
cached: false,
|
||||||
|
usage: result.usage,
|
||||||
|
tool_calls: result.toolCalls,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
|
||||||
|
await log.warn("attempt.failed", {
|
||||||
|
attempt,
|
||||||
|
kind: lastErr.kind,
|
||||||
|
retryable: lastErr.retryable,
|
||||||
|
error: lastErr.message,
|
||||||
|
});
|
||||||
|
if (!lastErr.retryable || attempt > opts.retries) break;
|
||||||
|
const wait = opts.retryDelayMs * Math.pow(2, attempt - 1);
|
||||||
|
await log.info("retry.wait", { wait_ms: wait, next_attempt: attempt + 1 });
|
||||||
|
await delay(wait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await lock.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = lastErr ?? new GenError("spawn_failed", "Unknown failure");
|
||||||
|
err.attempts = lastAttempt;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let opts: CliOptions;
|
||||||
|
try {
|
||||||
|
opts = parseArgs(process.argv.slice(2));
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof GenError ? e : new GenError("invalid_args", String(e), false);
|
||||||
|
process.stderr.write(`Error: ${err.message}\n`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = new JsonLogger(opts.logFile, opts.verbose);
|
||||||
|
await log.info("start", { output: opts.outputPath, aspect: opts.aspect, refs: opts.refImages.length });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await generate(opts, log);
|
||||||
|
await log.info("done", { bytes: result.bytes, attempts: result.attempts, cached: result.cached });
|
||||||
|
process.stdout.write(JSON.stringify(result) + "\n");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
|
||||||
|
await log.error("failed", { kind: err.kind, error: err.message, attempts: err.attempts ?? 0 });
|
||||||
|
const out: GenerateResult = {
|
||||||
|
status: "error",
|
||||||
|
path: opts.outputPath,
|
||||||
|
bytes: 0,
|
||||||
|
elapsed_seconds: 0,
|
||||||
|
thread_id: null,
|
||||||
|
attempts: err.attempts ?? 0,
|
||||||
|
cached: false,
|
||||||
|
usage: null,
|
||||||
|
tool_calls: [],
|
||||||
|
error: err.message,
|
||||||
|
error_kind: err.kind,
|
||||||
|
};
|
||||||
|
process.stdout.write(JSON.stringify(out) + "\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { CodexRunResult, ToolCall, TokenUsage } from "./types.ts";
|
||||||
|
|
||||||
|
export function parseEventStream(raw: string): Omit<CodexRunResult, "rawLogPath" | "durationMs"> {
|
||||||
|
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
||||||
|
let threadId: string | null = null;
|
||||||
|
let agentMessage: string | null = null;
|
||||||
|
let usage: TokenUsage | null = null;
|
||||||
|
const toolCallsById = new Map<string, ToolCall>();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
let event: any;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = event?.type;
|
||||||
|
if (type === "thread.started") {
|
||||||
|
threadId = event.thread_id ?? null;
|
||||||
|
} else if (type === "item.started" || type === "item.completed") {
|
||||||
|
const item = event.item;
|
||||||
|
if (!item?.id) continue;
|
||||||
|
const tc: ToolCall = {
|
||||||
|
id: item.id,
|
||||||
|
tool: deriveToolName(item),
|
||||||
|
status: item.status ?? (type === "item.completed" ? "completed" : "in_progress"),
|
||||||
|
command: item.command,
|
||||||
|
};
|
||||||
|
toolCallsById.set(item.id, tc);
|
||||||
|
if (item.type === "agent_message" && type === "item.completed") {
|
||||||
|
agentMessage = String(item.text ?? "");
|
||||||
|
}
|
||||||
|
} else if (type === "turn.completed") {
|
||||||
|
const u = event.usage;
|
||||||
|
if (u) {
|
||||||
|
usage = {
|
||||||
|
input: u.input_tokens ?? 0,
|
||||||
|
cached_input: u.cached_input_tokens ?? 0,
|
||||||
|
output: u.output_tokens ?? 0,
|
||||||
|
reasoning: u.reasoning_output_tokens ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
toolCalls: Array.from(toolCallsById.values()),
|
||||||
|
agentMessage,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveToolName(item: any): string {
|
||||||
|
if (item.type === "command_execution") return "shell";
|
||||||
|
if (item.type === "agent_message") return "agent_message";
|
||||||
|
if (item.type === "image_gen" || item.type === "image_generation") return "image_gen";
|
||||||
|
if (typeof item.tool === "string") return item.tool;
|
||||||
|
return item.type ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasImageGenInvocation(toolCalls: ToolCall[]): boolean {
|
||||||
|
return toolCalls.some((tc) => tc.tool === "image_gen");
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { writeFile, mkdtemp } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { GenError, type CodexRunResult } from "./types.ts";
|
||||||
|
import { parseEventStream } from "./parser.ts";
|
||||||
|
|
||||||
|
export interface SpawnInput {
|
||||||
|
instruction: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
refImages?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCodexExec(input: SpawnInput): Promise<CodexRunResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const logDir = await mkdtemp(path.join(tmpdir(), "codex-imggen-"));
|
||||||
|
const rawLogPath = path.join(logDir, "stream.jsonl");
|
||||||
|
|
||||||
|
// --skip-git-repo-check: lets the wrapper run from non-git cwds
|
||||||
|
// (e.g. /tmp, or a skill installed under ~/.claude/plugins/...).
|
||||||
|
// Without it, codex refuses with "Not inside a trusted directory".
|
||||||
|
const args = [
|
||||||
|
"exec",
|
||||||
|
"--json",
|
||||||
|
"--sandbox",
|
||||||
|
"danger-full-access",
|
||||||
|
"--skip-git-repo-check",
|
||||||
|
];
|
||||||
|
for (const img of input.refImages ?? []) {
|
||||||
|
args.push("--image", img);
|
||||||
|
}
|
||||||
|
args.push("-");
|
||||||
|
|
||||||
|
let timedOut = false;
|
||||||
|
const child = spawn("codex", args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.write(input.instruction);
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
setTimeout(() => child.kill("SIGKILL"), 2000);
|
||||||
|
}, input.timeoutMs);
|
||||||
|
|
||||||
|
const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||||
|
child.on("close", (code, signal) => resolve({ code, signal }));
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
await writeFile(rawLogPath, stdout + (stderr ? `\n--- stderr ---\n${stderr}` : ""));
|
||||||
|
|
||||||
|
if (timedOut) {
|
||||||
|
throw new GenError("timeout", `codex exec exceeded ${input.timeoutMs}ms (log: ${rawLogPath})`);
|
||||||
|
}
|
||||||
|
if (exit.code !== 0) {
|
||||||
|
if (stderr.includes("command not found") || stderr.includes("not found: codex")) {
|
||||||
|
throw new GenError("codex_not_installed", "codex CLI not installed", false);
|
||||||
|
}
|
||||||
|
throw new GenError(
|
||||||
|
"spawn_failed",
|
||||||
|
`codex exec exited ${exit.code} signal=${exit.signal} (log: ${rawLogPath})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseEventStream(stdout);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
rawLogPath,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export interface CliOptions {
|
||||||
|
prompt: string;
|
||||||
|
promptFile: string | null;
|
||||||
|
outputPath: string;
|
||||||
|
aspect: string;
|
||||||
|
refImages: string[];
|
||||||
|
timeoutMs: number;
|
||||||
|
retries: number;
|
||||||
|
retryDelayMs: number;
|
||||||
|
cacheDir: string | null;
|
||||||
|
logFile: string | null;
|
||||||
|
verbose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
status: string;
|
||||||
|
command?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
input: number;
|
||||||
|
cached_input: number;
|
||||||
|
output: number;
|
||||||
|
reasoning: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexRunResult {
|
||||||
|
threadId: string | null;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
agentMessage: string | null;
|
||||||
|
usage: TokenUsage | null;
|
||||||
|
rawLogPath: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateResult {
|
||||||
|
status: "ok" | "error";
|
||||||
|
path: string;
|
||||||
|
bytes: number;
|
||||||
|
elapsed_seconds: number;
|
||||||
|
thread_id: string | null;
|
||||||
|
attempts: number;
|
||||||
|
cached: boolean;
|
||||||
|
usage: TokenUsage | null;
|
||||||
|
tool_calls: { tool: string; status: string }[];
|
||||||
|
error?: string;
|
||||||
|
error_kind?: ErrorKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ErrorKind =
|
||||||
|
| "codex_not_installed"
|
||||||
|
| "invalid_args"
|
||||||
|
| "prompt_file_missing"
|
||||||
|
| "spawn_failed"
|
||||||
|
| "timeout"
|
||||||
|
| "no_image_gen_tool_use"
|
||||||
|
| "output_missing"
|
||||||
|
| "invalid_png"
|
||||||
|
| "agent_refused"
|
||||||
|
| "lock_busy";
|
||||||
|
|
||||||
|
export const RETRYABLE: ReadonlySet<ErrorKind> = new Set([
|
||||||
|
"spawn_failed",
|
||||||
|
"timeout",
|
||||||
|
"no_image_gen_tool_use",
|
||||||
|
"output_missing",
|
||||||
|
"invalid_png",
|
||||||
|
"agent_refused",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class GenError extends Error {
|
||||||
|
attempts?: number;
|
||||||
|
constructor(public kind: ErrorKind, message: string, public retryable?: boolean) {
|
||||||
|
super(message);
|
||||||
|
this.retryable = retryable ?? RETRYABLE.has(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { stat, readdir } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { GenError } from "./types.ts";
|
||||||
|
import type { ToolCall } from "./types.ts";
|
||||||
|
|
||||||
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
export function codexHome(): string {
|
||||||
|
return process.env.CODEX_HOME ?? path.join(homedir(), ".codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyImageGenWasInvoked(threadId: string | null): Promise<{ ok: boolean; reason?: string }> {
|
||||||
|
if (!threadId) return { ok: false, reason: "no thread id" };
|
||||||
|
const dir = path.join(codexHome(), "generated_images", threadId);
|
||||||
|
try {
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
const pngs = entries.filter((e) => e.toLowerCase().endsWith(".png"));
|
||||||
|
if (pngs.length === 0) return { ok: false, reason: `no PNG in ${dir}` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, reason: `cannot read ${dir}: ${e?.code ?? e?.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCpToTarget(toolCalls: ToolCall[], target: string): boolean {
|
||||||
|
return toolCalls.some(
|
||||||
|
(tc) =>
|
||||||
|
tc.tool === "shell" &&
|
||||||
|
typeof tc.command === "string" &&
|
||||||
|
(tc.command.includes(target) || tc.command.includes(path.basename(target))) &&
|
||||||
|
/\b(cp|mv|cat)\b/.test(tc.command) &&
|
||||||
|
tc.command.includes("generated_images"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> {
|
||||||
|
let s;
|
||||||
|
try {
|
||||||
|
s = await stat(outputPath);
|
||||||
|
} catch {
|
||||||
|
throw new GenError("output_missing", `Output file not created: ${outputPath}`);
|
||||||
|
}
|
||||||
|
if (s.size < 1000) {
|
||||||
|
throw new GenError("invalid_png", `Output file too small (${s.size} bytes)`);
|
||||||
|
}
|
||||||
|
const file = Bun.file(outputPath);
|
||||||
|
const head = new Uint8Array(await file.slice(0, 8).arrayBuffer());
|
||||||
|
for (let i = 0; i < PNG_MAGIC.length; i++) {
|
||||||
|
if (head[i] !== PNG_MAGIC[i]) {
|
||||||
|
throw new GenError("invalid_png", `Output is not a valid PNG (magic mismatch)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bytes: s.size };
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
|||||||
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
azure: { concurrency: 3, startIntervalMs: 1100 },
|
azure: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
|
"codex-cli": { concurrency: 1, startIntervalMs: 2000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
@@ -78,7 +79,7 @@ Options:
|
|||||||
--image <path> Output image path (required in single-image mode)
|
--image <path> Output image path (required in single-image mode)
|
||||||
--batchfile <path> JSON batch file for multi-image generation
|
--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)
|
--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)
|
--provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure|codex-cli Force provider (auto-detect by default)
|
||||||
-m, --model <id> Model ID
|
-m, --model <id> Model ID
|
||||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||||
--size <WxH> Size (e.g., 1024x1024)
|
--size <WxH> Size (e.g., 1024x1024)
|
||||||
@@ -154,8 +155,13 @@ Environment variables:
|
|||||||
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-2)
|
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-2)
|
||||||
SEEDREAM_BASE_URL Custom Seedream endpoint
|
SEEDREAM_BASE_URL Custom Seedream endpoint
|
||||||
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
||||||
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency (use underscores: BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY)
|
||||||
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
|
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
|
||||||
|
BAOYU_CODEX_IMAGEGEN_BIN Path to codex-imagegen wrapper (default: bundled scripts/codex-imagegen/main.ts; accepts .ts or legacy .sh/binary)
|
||||||
|
BAOYU_CODEX_IMAGEGEN_CACHE_DIR Enable idempotency cache for codex-cli provider (default: disabled)
|
||||||
|
BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS Per-attempt codex exec timeout for codex-cli provider (default: 300000)
|
||||||
|
BAOYU_CODEX_IMAGEGEN_RETRIES Codex-side retry attempts on retryable errors (default: 2)
|
||||||
|
BAOYU_CODEX_IMAGEGEN_LOG_FILE Append JSONL diagnostic log for codex-cli provider
|
||||||
|
|
||||||
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||||
}
|
}
|
||||||
@@ -258,7 +264,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
v !== "replicate" &&
|
v !== "replicate" &&
|
||||||
v !== "jimeng" &&
|
v !== "jimeng" &&
|
||||||
v !== "seedream" &&
|
v !== "seedream" &&
|
||||||
v !== "azure"
|
v !== "azure" &&
|
||||||
|
v !== "codex-cli"
|
||||||
) {
|
) {
|
||||||
throw new Error(`Invalid provider: ${v}`);
|
throw new Error(`Invalid provider: ${v}`);
|
||||||
}
|
}
|
||||||
@@ -430,6 +437,7 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
jimeng: null,
|
jimeng: null,
|
||||||
seedream: null,
|
seedream: null,
|
||||||
azure: null,
|
azure: null,
|
||||||
|
"codex-cli": null,
|
||||||
};
|
};
|
||||||
currentKey = "default_model";
|
currentKey = "default_model";
|
||||||
currentProvider = null;
|
currentProvider = null;
|
||||||
@@ -458,7 +466,8 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
key === "replicate" ||
|
key === "replicate" ||
|
||||||
key === "jimeng" ||
|
key === "jimeng" ||
|
||||||
key === "seedream" ||
|
key === "seedream" ||
|
||||||
key === "azure"
|
key === "azure" ||
|
||||||
|
key === "codex-cli"
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
config.batch ??= {};
|
config.batch ??= {};
|
||||||
@@ -477,7 +486,8 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
key === "replicate" ||
|
key === "replicate" ||
|
||||||
key === "jimeng" ||
|
key === "jimeng" ||
|
||||||
key === "seedream" ||
|
key === "seedream" ||
|
||||||
key === "azure"
|
key === "azure" ||
|
||||||
|
key === "codex-cli"
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const cleaned = value.replace(/['"]/g, "");
|
const cleaned = value.replace(/['"]/g, "");
|
||||||
@@ -630,10 +640,11 @@ export function getConfiguredProviderRateLimits(
|
|||||||
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
||||||
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
||||||
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
|
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
|
||||||
|
"codex-cli": { ...DEFAULT_PROVIDER_RATE_LIMITS["codex-cli"] },
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
|
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli"] as Provider[]) {
|
||||||
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase().replace(/-/g, "_")}`;
|
||||||
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||||
configured[provider] = {
|
configured[provider] = {
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -699,10 +710,11 @@ export function detectProvider(args: CliArgs): Provider {
|
|||||||
args.provider !== "replicate" &&
|
args.provider !== "replicate" &&
|
||||||
args.provider !== "seedream" &&
|
args.provider !== "seedream" &&
|
||||||
args.provider !== "minimax" &&
|
args.provider !== "minimax" &&
|
||||||
args.provider !== "dashscope"
|
args.provider !== "dashscope" &&
|
||||||
|
args.provider !== "codex-cli"
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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."
|
"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, --provider minimax for MiniMax subject-reference workflows, or --provider codex-cli (Codex image_gen with references)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,6 +851,7 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
|||||||
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
|
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
|
||||||
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
|
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
|
||||||
if (provider === "azure") return (await import("./providers/azure")) as ProviderModule;
|
if (provider === "azure") return (await import("./providers/azure")) as ProviderModule;
|
||||||
|
if (provider === "codex-cli") return (await import("./providers/codex-cli")) as ProviderModule;
|
||||||
return (await import("./providers/openai")) as ProviderModule;
|
return (await import("./providers/openai")) as ProviderModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,6 +883,7 @@ function getModelForProvider(
|
|||||||
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
|
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 === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
|
||||||
if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure;
|
if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure;
|
||||||
|
if (provider === "codex-cli" && extendConfig.default_model["codex-cli"]) return extendConfig.default_model["codex-cli"];
|
||||||
}
|
}
|
||||||
return providerModule.getDefaultModel();
|
return providerModule.getDefaultModel();
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1118,7 @@ async function runBatchTasks(
|
|||||||
const acquireProvider = createProviderGate(providerRateLimits);
|
const acquireProvider = createProviderGate(providerRateLimits);
|
||||||
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
||||||
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
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[]) {
|
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli"] as Provider[]) {
|
||||||
const limit = providerRateLimits[provider];
|
const limit = providerRateLimits[provider];
|
||||||
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
getDefaultModel,
|
||||||
|
getDefaultOutputExtension,
|
||||||
|
validateArgs,
|
||||||
|
} from "./codex-cli.ts";
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: "codex-cli",
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
aspectRatioSource: null,
|
||||||
|
size: null,
|
||||||
|
quality: "2k",
|
||||||
|
imageSize: null,
|
||||||
|
imageSizeSource: null,
|
||||||
|
imageApiDialect: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("codex-cli defaults to codex-image-gen model and PNG output", () => {
|
||||||
|
assert.equal(getDefaultModel(), "codex-image-gen");
|
||||||
|
assert.equal(getDefaultOutputExtension(), ".png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("codex-cli validateArgs rejects n>1 with a non-retryable message", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("codex-image-gen", makeArgs({ n: 2 })),
|
||||||
|
/supports only n=1/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("codex-cli validateArgs rejects ratio-metadata dialect", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("codex-image-gen", makeArgs({ imageApiDialect: "ratio-metadata" })),
|
||||||
|
/Invalid imageApiDialect/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("codex-cli validateArgs accepts default n=1 with no dialect", () => {
|
||||||
|
assert.doesNotThrow(() => validateArgs("codex-image-gen", makeArgs()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("codex-cli validateArgs accepts reference images (Codex image_gen supports refs)", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs("codex-image-gen", makeArgs({ referenceImages: ["/tmp/a.png", "/tmp/b.png"] })),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { mkdir, readFile, rm, writeFile, access } from "node:fs/promises";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
|
const PROVIDER_FILE = fileURLToPath(import.meta.url);
|
||||||
|
const SCRIPTS_DIR = path.resolve(path.dirname(PROVIDER_FILE), "..");
|
||||||
|
const BUNDLED_WRAPPER = path.join(SCRIPTS_DIR, "codex-imagegen", "main.ts");
|
||||||
|
|
||||||
|
type WrapperOkResult = {
|
||||||
|
status: "ok";
|
||||||
|
path: string;
|
||||||
|
bytes: number;
|
||||||
|
elapsed_seconds: number;
|
||||||
|
thread_id: string | null;
|
||||||
|
attempts: number;
|
||||||
|
cached: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WrapperErrorResult = {
|
||||||
|
status: "error";
|
||||||
|
path: string;
|
||||||
|
bytes: number;
|
||||||
|
error: string;
|
||||||
|
error_kind: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WrapperResult = WrapperOkResult | WrapperErrorResult;
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return "codex-image-gen";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultOutputExtension(): string {
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(_model: string, args: CliArgs): void {
|
||||||
|
if (args.n > 1) {
|
||||||
|
throw new Error(
|
||||||
|
"codex-cli provider supports only n=1 (Codex image_gen returns a single image per call).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.imageApiDialect && args.imageApiDialect !== "openai-native") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid imageApiDialect for codex-cli: ${args.imageApiDialect}. codex-cli does not use OpenAI Images API dialects.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWrapperPath(): Promise<string> {
|
||||||
|
const override = process.env.BAOYU_CODEX_IMAGEGEN_BIN;
|
||||||
|
if (override) {
|
||||||
|
if (!(await exists(override))) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid BAOYU_CODEX_IMAGEGEN_BIN: ${override} does not exist.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
if (await exists(BUNDLED_WRAPPER)) return BUNDLED_WRAPPER;
|
||||||
|
throw new Error(
|
||||||
|
`codex-cli wrapper not found at ${BUNDLED_WRAPPER}. ` +
|
||||||
|
`Reinstall baoyu-image-gen, or set BAOYU_CODEX_IMAGEGEN_BIN to a codex-imagegen main.ts (or .sh) path.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpawnResult = {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function spawnWrapper(wrapperPath: string, cliArgs: string[]): Promise<SpawnResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const isTs = wrapperPath.endsWith(".ts");
|
||||||
|
const command = isTs ? "bun" : wrapperPath;
|
||||||
|
const args = isTs ? [wrapperPath, ...cliArgs] : cliArgs;
|
||||||
|
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString("utf8");
|
||||||
|
stderr += text;
|
||||||
|
process.stderr.write(text);
|
||||||
|
});
|
||||||
|
child.on("error", (err) => reject(err));
|
||||||
|
child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWrapperJson(stdout: string): WrapperResult {
|
||||||
|
const trimmed = stdout.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("Invalid codex-cli response: empty stdout from wrapper.");
|
||||||
|
}
|
||||||
|
const lastLine = trimmed.split(/\r?\n/).pop() ?? trimmed;
|
||||||
|
try {
|
||||||
|
return JSON.parse(lastLine) as WrapperResult;
|
||||||
|
} catch (parseErr) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid codex-cli response: could not parse JSON from wrapper stdout (${(parseErr as Error).message}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: string | undefined): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvOverride(name: string): string | null {
|
||||||
|
const value = process.env[name];
|
||||||
|
return value && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
_model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const wrapperPath = await resolveWrapperPath();
|
||||||
|
|
||||||
|
const sessionDir = path.join(tmpdir(), "baoyu-image-gen-codex-cli");
|
||||||
|
await mkdir(sessionDir, { recursive: true });
|
||||||
|
const token = randomBytes(8).toString("hex");
|
||||||
|
const tmpOutput = path.join(sessionDir, `out-${token}.png`);
|
||||||
|
const tmpPrompt = path.join(sessionDir, `prompt-${token}.md`);
|
||||||
|
await writeFile(tmpPrompt, prompt, "utf8");
|
||||||
|
|
||||||
|
const aspect = args.aspectRatio ?? "1:1";
|
||||||
|
const cliArgs: string[] = [
|
||||||
|
"--image",
|
||||||
|
tmpOutput,
|
||||||
|
"--prompt-file",
|
||||||
|
tmpPrompt,
|
||||||
|
"--aspect",
|
||||||
|
aspect,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const ref of args.referenceImages) {
|
||||||
|
cliArgs.push("--ref", path.resolve(ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheDir = getEnvOverride("BAOYU_CODEX_IMAGEGEN_CACHE_DIR");
|
||||||
|
if (cacheDir) cliArgs.push("--cache-dir", cacheDir);
|
||||||
|
|
||||||
|
const timeoutMs = parsePositiveInt(process.env.BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS);
|
||||||
|
if (timeoutMs) cliArgs.push("--timeout", String(timeoutMs));
|
||||||
|
|
||||||
|
const retries = parsePositiveInt(process.env.BAOYU_CODEX_IMAGEGEN_RETRIES);
|
||||||
|
if (retries !== null) cliArgs.push("--retries", String(retries));
|
||||||
|
|
||||||
|
const logFile = getEnvOverride("BAOYU_CODEX_IMAGEGEN_LOG_FILE");
|
||||||
|
if (logFile) cliArgs.push("--log-file", logFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const spawnResult = await spawnWrapper(wrapperPath, cliArgs);
|
||||||
|
const parsed = parseWrapperJson(spawnResult.stdout);
|
||||||
|
|
||||||
|
if (parsed.status === "error") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid codex-cli result (${parsed.error_kind}): ${parsed.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnResult.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid codex-cli result: wrapper exited with code ${spawnResult.code} despite reporting status=ok.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await readFile(parsed.path ?? tmpOutput);
|
||||||
|
return new Uint8Array(bytes);
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled([
|
||||||
|
rm(tmpOutput, { force: true }),
|
||||||
|
rm(tmpPrompt, { force: true }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ export type Provider =
|
|||||||
| "replicate"
|
| "replicate"
|
||||||
| "jimeng"
|
| "jimeng"
|
||||||
| "seedream"
|
| "seedream"
|
||||||
| "azure";
|
| "azure"
|
||||||
|
| "codex-cli";
|
||||||
export type Quality = "normal" | "2k";
|
export type Quality = "normal" | "2k";
|
||||||
export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata";
|
export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata";
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export type ExtendConfig = {
|
|||||||
jimeng: string | null;
|
jimeng: string | null;
|
||||||
seedream: string | null;
|
seedream: string | null;
|
||||||
azure: string | null;
|
azure: string | null;
|
||||||
|
"codex-cli": string | null;
|
||||||
};
|
};
|
||||||
batch?: {
|
batch?: {
|
||||||
max_workers?: number | null;
|
max_workers?: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user