Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot committed 2026-10-30 00:03:35 +08:00
1 parent 2fb1818ebf
commit b397f552f0
37 files changed
+889 -233

No files matched your search

+25 -25
View File
@@ -1,13 +1,22 @@
{
"sources": [
{
"id": "hallmark",
"pluginName": "hallmark",
"repo": "https://github.com/Nutlope/hallmark.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "8f6969f8922caabde9de1784959eae11ddf68073",
"syncedAt": "2026-10-29T15:25:46Z"
},
{
"id": "docx",
"pluginName": "docx",
"repo": "https://github.com/anthropics/skills.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
"syncedAt": "2026-10-10T16:00:00Z"
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
"syncedAt": "2026-10-29T16:00:00Z"
},
{
"id": "pdf",
@@ -15,8 +24,8 @@
"repo": "https://github.com/anthropics/skills.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
"syncedAt": "2026-10-10T16:00:00Z"
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
"syncedAt": "2026-10-29T16:00:00Z"
},
{
"id": "pptx",
@@ -24,8 +33,8 @@
"repo": "https://github.com/anthropics/skills.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
"syncedAt": "2026-10-10T16:00:00Z"
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
"syncedAt": "2026-10-29T16:00:00Z"
},
{
"id": "xlsx",
@@ -33,8 +42,8 @@
"repo": "https://github.com/anthropics/skills.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
"syncedAt": "2026-10-10T16:00:00Z"
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
"syncedAt": "2026-10-29T16:00:00Z"
},
{
"id": "find-skills",
@@ -42,8 +51,8 @@
"repo": "https://github.com/vercel-labs/skills.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "699d35b21bd3c97dbc0edeae062b2698898bb2e5",
"syncedAt": "2026-10-28T15:59:59Z"
"commit": "92a9a34ac3bf88e695e468b90c7bf32f163d32c8",
"syncedAt": "2026-10-29T16:00:00Z"
},
{
"id": "generate-agents-md",
@@ -177,8 +186,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "6fbac39e1d06a66ce3a8724b9366cb878b793f99",
"syncedAt": "2026-10-28T15:59:59Z"
"commit": "15baa7c570f12c78cd9ac6ead897724571e32e87",
"syncedAt": "2026-10-29T16:00:00Z"
},
{
"id": "grill-me",
@@ -186,8 +195,8 @@
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
"adapter": "skill-collection",
"commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c",
"syncedAt": "2026-10-27T07:55:04Z"
"commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa",
"syncedAt": "2026-10-29T16:00:00Z"
},
{
"id": "updating-gitea-repositories",
@@ -258,8 +267,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
"commit": "f922205f9e860de24673269f81dea51e9e33b0cb",
"syncedAt": "2026-10-28T15:59:59Z"
"commit": "79107ebd94e53c69865c1a49ac734ba8004491ba",
"syncedAt": "2026-10-29T16:00:00Z"
},
{
"id": "shuorenhua",
@@ -269,15 +278,6 @@
"adapter": "claude-skill",
"commit": "e33d5eb6420ba3468043791a57d71c153b0a8691",
"syncedAt": "2026-10-22T16:00:00Z"
},
{
"id": "hallmark",
"pluginName": "hallmark",
"repo": "https://github.com/Nutlope/hallmark.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "8f6969f8922caabde9de1784959eae11ddf68073",
"syncedAt": "2026-10-29T15:25:46Z"
}
]
}
@@ -2,8 +2,8 @@
"sourceId": "docx",
"repo": "https://github.com/anthropics/skills.git",
"ref": "main",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
"adapter": "claude-skill",
"sourcePath": "skills/docx",
"syncedAt": "2026-10-10T16:00:00Z"
"syncedAt": "2026-10-29T16:00:00Z"
}
@@ -2,8 +2,8 @@
"sourceId": "find-skills",
"repo": "https://github.com/vercel-labs/skills.git",
"ref": "main",
"commit": "699d35b21bd3c97dbc0edeae062b2698898bb2e5",
"commit": "92a9a34ac3bf88e695e468b90c7bf32f163d32c8",
"adapter": "claude-skill",
"sourcePath": "skills/find-skills",
"syncedAt": "2026-10-28T15:59:59Z"
"syncedAt": "2026-10-29T16:00:00Z"
}
@@ -2,8 +2,8 @@
"sourceId": "grill-me",
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
"commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c",
"commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa",
"adapter": "skill-collection",
"sourcePath": "skills/productivity",
"syncedAt": "2026-10-27T07:55:04Z"
"syncedAt": "2026-10-29T16:00:00Z"
}
@@ -25,6 +25,6 @@ Word each question so "yes" accepts your recommended answer.
Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent with a fresh context window to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-10-28T16:03:24Z"
"syncedAt": "2026-10-29T16:03:34Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "f922205f9e860de24673269f81dea51e9e33b0cb",
"commit": "79107ebd94e53c69865c1a49ac734ba8004491ba",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-10-28T15:59:59Z"
"syncedAt": "2026-10-29T16:00:00Z"
}
@@ -2,8 +2,8 @@
"sourceId": "pdf",
"repo": "https://github.com/anthropics/skills.git",
"ref": "main",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
"adapter": "claude-skill",
"sourcePath": "skills/pdf",
"syncedAt": "2026-10-10T16:00:00Z"
"syncedAt": "2026-10-29T16:00:00Z"
}
+4 -4
View File
@@ -106,7 +106,7 @@ Why you'd choose it, and where it isn't the right fit → [Why PPT Master](./doc
> [!IMPORTANT]
> ### This is a tool, not a wishing well
> `harness + model = agent` — PPT Master only owns the workflow; the model sets the ceiling. Recommended: **Kimi K3 (or Claude) with a large context window (~1M tokens) + AI image generation (`gpt-image-2` or Google `gemini-nano-banana-2.1`)**; other models can run the pipeline, with a quality gap.
> `harness + model = agent` — PPT Master only owns the workflow; the model sets the ceiling. Recommended: **Kimi K3 (or Claude) with a large context window (~1M tokens) + AI image generation (`gpt-image-2.5-sunburst` or Google `gemini-nano-banana-2.1`)**; other models can run the pipeline, with a quality gap.
>
> And don't expect a finished, perfect deck in one shot. The tool's value is taking most of the tedious work off your plate; the polishing that's left is yours — a natively editable deck exists precisely so you can keep working on it, not a flat image you can't touch. The cheaper the model, the more there is to do; if results disappoint, upgrade the model first, then check your usage against [Getting Started](./docs/getting-started.md) and the [example projects](https://hugohe3.github.io/ppt-master-examples/).
@@ -165,7 +165,7 @@ Knowing how to use Python and AI agents will matter more and more, and this proj
### 1. Prerequisites
**All you need to install is [Python](https://www.python.org/downloads/) 3.10+.** Everything else comes with one line — `pip install -r requirements.txt` — after you download the project in Step 3.
**All you need to install is [Python](https://www.python.org/downloads/) 3.10–3.14.** Everything else comes with one line — `pip install -r requirements.txt` — after you download the project in Step 3.
<details>
<summary><strong>Windows</strong> — see the dedicated <a href="./docs/windows-installation.md">step-by-step guide</a> ⚠️</summary>
@@ -217,7 +217,7 @@ Never used one of these? Don't worry — in this project they play exactly one r
| **IDE plugin / extension** | [Claude Code](https://claude.ai/code) (VS Code / JetBrains extension), [GitHub Copilot](https://github.com/features/copilot), [Cline](https://cline.bot/), etc. | Installed inside hosts like VS Code or JetBrains |
| **CLI agent** | [Claude Code](https://claude.ai/code) CLI, [Codex CLI](https://github.com/openai/codex), Gemini CLI, etc. | Runs in the terminal; suits scripting, remote, or server use |
> **Model recommendation**: for the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the pipeline, paired with AI image generation — **`gpt-image-2`** (OpenAI) or **`gemini-nano-banana-2.1`** (Google). Kimi Code, the project sponsor, is a great pick for pay-as-you-go access.
> **Model recommendation**: for the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the pipeline, paired with AI image generation — **`gpt-image-2.5-sunburst`** (OpenAI) or **`gemini-nano-banana-2.1`** (Google). Kimi Code, the project sponsor, is a great pick for pay-as-you-go access.
**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.ai/register?aff=ppt-master)**, **[APIKEY.FAN](https://apikey.fan/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.host/register?aff=WMLJ)** offer pay-as-you-go access to Claude, GPT, Gemini and more — no subscription required, with exclusive discounts for our users (details at the top of this page).
@@ -318,7 +318,7 @@ Whatever you state explicitly is followed; whatever you leave unspecified the ag
Two paths for non-user images, mixable per image in the same deck:
**A) AI generation** — use the agent host's native image tool when available, or `image_gen.py` with `IMAGE_BACKEND` plus the provider's `*_API_KEY`. Host-native generation needs no separate provider image API key; ask the agent to use its own image tool. Run `python3 skills/ppt-master/scripts/image_gen.py --list-backends` for the configured-provider path. `gpt-image-2` is currently the best default.
**A) AI generation** — use the agent host's native image tool when available, or `image_gen.py` with `IMAGE_BACKEND` plus the provider's `*_API_KEY`. Host-native generation needs no separate provider image API key; ask the agent to use its own image tool. Run `python3 skills/ppt-master/scripts/image_gen.py --list-backends` for the configured-provider path. `gpt-image-2.5-sunburst` is the current OpenAI default.
**B) Web image search** — `image_search.py`. **Zero-config works**; configure `PEXELS_API_KEY` / `PIXABAY_API_KEY` (both free) for consistently higher-quality results:
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "6fbac39e1d06a66ce3a8724b9366cb878b793f99",
"commit": "15baa7c570f12c78cd9ac6ead897724571e32e87",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-10-28T15:59:59Z"
"syncedAt": "2026-10-29T16:00:00Z"
}
@@ -43,9 +43,9 @@
# ─────────────────────────────────────────────────────────────
# IMAGE_BACKEND=openai
# OPENAI_API_KEY=sk-xxx
# OPENAI_MODEL=gpt-image-2
# OPENAI_MODEL=gpt-image-2.5-sunburst
# Legacy ChatGPT image API model / 旧版 ChatGPT 生图 API 模型
# OPENAI_MODEL=chatgpt-image-latest # Deprecated by OpenAI / OpenAI 已弃用
# OPENAI_MODEL=chatgpt-image-latest # Removed by OpenAI on 2026-12-01 / OpenAI 将于 2026-12-01 下线
# OPENAI_BASE_URL=http://127.0.0.1:3000/v1
# OpenAI-compatible providers may need these compatibility knobs.
# OpenAI-compatible 第三方平台可按需开启以下兼容参数。
@@ -59,8 +59,8 @@
# OPENAI_OUTPUT_FORMAT=png
# jpeg/webp only, 0-100
# OPENAI_OUTPUT_COMPRESSION=80
# GPT Image: auto / opaque / transparent (png/webp only; gpt-image-2 support is in preview)
# GPT Image:auto / opaque / transparent(透明背景仅限 png/webp;gpt-image-2 为预览支持)
# GPT Image: auto / opaque / transparent (png/webp only; gpt-image-2 is preview-only and may return 400)
# GPT Image:auto / opaque / transparent(透明背景仅限 png/webp;gpt-image-2 仅预览支持,可能返回 400)
# OPENAI_BACKGROUND=auto
# auto / low
# OPENAI_MODERATION=auto
@@ -8,7 +8,7 @@ Want to support the project or discuss a partnership? See the [sponsorship guide
## Current Model Recommendation
For the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the workflow, paired with **`gpt-image-2`** (OpenAI) or **`gemini-nano-banana-2.1`** (Google) for AI image generation. If model capability is limiting output quality, upgrade the driving model before weakening the workflow or its quality requirements.
For the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the workflow, paired with **`gpt-image-2.5-sunburst`** (OpenAI) or **`gemini-nano-banana-2.1`** (Google) for AI image generation. If model capability is limiting output quality, upgrade the driving model before weakening the workflow or its quality requirements.
## Kimi
@@ -8,7 +8,7 @@ PPT Master 始终免费开源。以下赞助方共同支持项目的持续维护
## 当前模型推荐
追求最佳效果,语言模型选 **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)**(或 Claude)驱动流程,搭配 AI 生图模型 **`gpt-image-2`**(OpenAI)或 **`gemini-nano-banana-2.1`**(Google)。如果模型能力正在拖累产出质量,应先升级驱动模型,而不是削弱工作流或质量要求。
追求最佳效果,语言模型选 **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)**(或 Claude)驱动流程,搭配 AI 生图模型 **`gpt-image-2.5-sunburst`**(OpenAI)或 **`gemini-nano-banana-2.1`**(Google)。如果模型能力正在拖累产出质量,应先升级驱动模型,而不是削弱工作流或质量要求。
## Kimi
@@ -310,7 +310,7 @@ Write `project/images/image_prompts.json`:
| `items[].type` | no | One of the 11 internal-composition types for a local structural infographic when a template genuinely fits; omitted for §4.1 E prose, `hero_page`, sheets, and single-subject/portrait |
| `items[].page_role` | yes | `local` (default) or `hero_page` |
| `items[].text_policy` | yes | `none` or `embedded`, judged per image (§5.3) |
| `items[].aspect_ratio` | yes | Passed to `image_gen.py --aspect_ratio`; every backend accepts a subset of the CLI union (e.g. gemini has no `3:1`), and `--manifest` fails the item before any request when the resolved backend rejects its ratio |
| `items[].aspect_ratio` | yes | The target shape as any positive `W:H` (e.g. `16:9`, `4:1`, `1.85:1`), derived from the placement, never narrowed to a backend. The backend outputs the nearest size its model allows and the manifest keeps this value; Executor fits the actual image through the crop policy, and a sheet is sliced from its actual size |
| `items[].prompt` | yes | The assembled paragraph |
| `items[].image_size` | no | `512px` / `1K` / `2K` / `4K` |
| `items[].model` | no | Per-item backend model override |
@@ -49,7 +49,7 @@ python3 scripts/image_gen.py --list-backends
Backends are grouped into Core / Extended / Experimental tiers. Run `python3 scripts/image_gen.py --list-backends` for the current list.
Each backend accepts its own subset of `--aspect_ratio` values; the authority is the `VALID_ASPECT_RATIOS` constant in its `scripts/image_backends/backend_<name>.py`, and `--manifest` rejects an unsupported ratio before any request. The default Gemini model, `gemini-nano-banana-2.1`, takes `1:1 1:4 1:8 2:3 3:2 3:4 4:1 4:3 4:5 5:4 8:1 9:16 16:9 21:9` (including extreme ratios, no `3:1`) at `1K/2K/4K`, but not `512px`. `gemini-3.1-flash-image` retains the same ratios and also supports `512px`; `gemini-2.5-flash-image` takes only `1:1 2:3 3:2 3:4 4:3 4:5 5:4 9:16 16:9 21:9` at `1K`.
`--aspect_ratio` and manifest `aspect_ratio` accept any positive `W:H`, decimals included (`1.85:1`); only a malformed value fails before any request. A ratio never fails a request: each backend outputs the nearest size its model allows and prints `Ratio: requested … -> output …` when the output differs by more than 1%; the manifest keeps the requested ratio and downstream placement crops to it. Backends whose models take free width and height (OpenAI GPT Image 2/2.5, MiniMax, Qwen, Volcengine, Zhipu, Tencent) keep their listed size for a listed ratio and fit any other ratio or tier to the model's official limits (`fit_size` in `backend_common.py`), at the tier's 1:1 pixel budget, clamping a ratio beyond the model's maximum. Backends with a fixed ratio set (Gemini, OpenRouter, fal, BFL, Ideogram, Replicate, Stability, ModelScope, SiliconFlow) send their nearest listed ratio. The default Gemini model, `gemini-nano-banana-2.1`, takes `1:1 1:4 1:8 2:3 3:2 3:4 4:1 4:3 4:5 5:4 8:1 9:16 16:9 21:9` (including extreme ratios, no `3:1`) at `1K/2K/4K`, but not `512px`. `gemini-3.1-flash-image` retains the same ratios and also supports `512px`; `gemini-2.5-flash-image` takes only `1:1 2:3 3:2 3:4 4:3 4:5 5:4 9:16 16:9 21:9` at `1K`.
Backend selection:
@@ -78,7 +78,7 @@ Example `.env`:
```env
IMAGE_BACKEND=openai
OPENAI_API_KEY=sk-xxx
OPENAI_MODEL=gpt-image-2
OPENAI_MODEL=gpt-image-2.5-sunburst
# Optional proxy
# OPENAI_BASE_URL=http://127.0.0.1:3000/v1
# OpenAI-compatible provider knobs:
@@ -90,7 +90,7 @@ OPENAI_MODEL=gpt-image-2
# OPENAI_OUTPUT_FORMAT=png
# jpeg/webp only, 0-100
# OPENAI_OUTPUT_COMPRESSION=80
# GPT Image: auto / opaque / transparent (png/webp only; gpt-image-2 support is in preview)
# GPT Image: auto / opaque / transparent (png/webp only; gpt-image-2 is preview-only and may return 400)
# OPENAI_BACKGROUND=auto
# auto / low
# OPENAI_MODERATION=auto
@@ -101,21 +101,21 @@ Example process environment:
```bash
export IMAGE_BACKEND=openai
export OPENAI_API_KEY=sk-xxx
export OPENAI_MODEL=gpt-image-2
export OPENAI_MODEL=gpt-image-2.5-sunburst
export OPENAI_OUTPUT_FORMAT=png
```
Current process environment wins over `.env`.
OpenAI backend notes:
- `gpt-image-2` is the default OpenAI model.
- `gpt-image-2.5-sunburst` is the default OpenAI model. `gpt-image-2.5-flare` shares its token rates; OpenAI positions Sunburst for editing precision and Flare for fast everyday generation ([models](https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst)).
- Requests are sent with plain `requests.post()` to improve compatibility with
OpenAI-compatible proxies that block the OpenAI SDK's `httpx` transport.
- For `gpt-image-2`, `image_size=512px` means a low-quality draft preset, not a literal 512px edge. The model requires both edges to be multiples of 16px, a long:short ratio no greater than 3:1, and total pixels between 655,360 and 8,294,400.
- `OPENAI_BACKGROUND=transparent` is supported by `gpt-image-2` (in preview), `gpt-image-2.5-sunburst`, and `gpt-image-2.5-flare`, including their published snapshots; use PNG (the default) or WebP, not JPEG.
- For `gpt-image-2`, `image_size=512px` means a low-quality draft preset, not a literal 512px edge. The model requires both edges to be multiples of 16px, a long:short ratio no greater than 3:1, and total pixels between 655,360 and 8,294,400. The 2.5 models share these rules and sizes; unlisted ratios are fitted to them, so `4:1` at 1K becomes `1776x592` (3:1).
- `OPENAI_BACKGROUND=transparent` is supported by `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their published snapshots. For `gpt-image-2` it is in preview: a key without preview access gets HTTP 400 `Transparent background is not supported for this model.`, which fails the item without retries. Use PNG (the default) or WebP, not JPEG.
- `OPENAI_QUALITY=xhigh` and `max` are accepted only for `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their published snapshots; older GPT Image models retain `auto/omit/low/medium/high`. See the [Images API parameters](https://developers.openai.com/api/reference/resources/images/methods/generate).
- For reference edits, `OPENAI_INPUT_FIDELITY` accepts `high/low` on `gpt-image-1` and `gpt-image-1.5`, but only `low` on `gpt-image-1-mini`. Leave it unset for `gpt-image-2` and the two 2.5 models (including snapshots); this backend keeps the existing omission requirement for 2.5 because its support is not explicitly documented in the [edit API parameters](https://developers.openai.com/api/reference/resources/images/methods/edit).
- OpenAI schedules `gpt-image-1` shutdown for 2026-10-23 and `gpt-image-1-mini` / `gpt-image-1.5` for 2026-12-01; see [deprecations](https://developers.openai.com/api/docs/deprecations).
- OpenAI shut down `gpt-image-1` on 2026-10-23 and DALL·E 2/3 on 2026-05-12, and removes `gpt-image-1-mini`, `gpt-image-1.5` (including `gpt-image-1.5-2025-12-16`) and `chatgpt-image-latest` on 2026-12-01, naming the two 2.5 models as replacements. The backend keeps their size and option handling for OpenAI-compatible providers that still serve them; see [deprecations](https://developers.openai.com/api/docs/deprecations).
- If `OPENAI_OUTPUT_FORMAT=jpeg` or `webp`, generated files use `.jpg` or `.webp` extensions instead of `.png`.
- OpenAI-compatible providers that reject OpenAI-specific fields can use `OPENAI_RESPONSE_FORMAT=omit`, `OPENAI_QUALITY=omit`, and `OPENAI_SIZE_PRESET=<preset>`. Valid response formats are `auto`, `b64_json`, `url`, and `omit`; valid size presets are `auto`, `legacy`, `gpt-image`, `gpt-image-2`, and `dall-e-2`.
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
poll_json,
require_api_key,
resolve_output_path,
@@ -180,6 +182,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the BFL backend."""
requested_ratio = aspect_ratio
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
note_aspect_ratio(requested_ratio, aspect_ratio)
resolved_model = model or os.environ.get("BFL_MODEL") or DEFAULT_MODEL
_resolve_request_options(aspect_ratio, image_size, resolved_model)
api_key = require_api_key(
@@ -208,9 +213,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -21,9 +21,14 @@ if __name__ == "__main__":
import base64
import io
import math
import os
import random
import re
import time
from decimal import Decimal, InvalidOperation
from email.utils import parsedate_to_datetime
from fractions import Fraction
import requests
@@ -35,6 +40,9 @@ except ImportError:
MAX_RETRIES = 3
# Longest provider-requested wait honored in-process; a longer Retry-After ends
# the backend's retries and the manifest run instead of blocking for hours.
MAX_RETRY_WAIT = 300
RETRY_BASE_DELAY = 10
RETRY_BACKOFF = 2
@@ -56,6 +64,10 @@ _GLOBAL_PERMANENT_ERROR_MARKERS = (
"insufficient credits",
"insufficient balance",
"insufficient_quota",
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
"exceeded your current quota",
"payment required",
"billing is not enabled",
@@ -109,7 +121,9 @@ def resolve_output_path(prompt: str, output_dir: str = None,
filename: str = None, ext: str = ".png") -> str:
"""Compute the final output file path based on parameters."""
if filename:
file_name = os.path.splitext(filename)[0]
# Drop only a real image extension; dots inside a stem ("v2.5") stay.
stem, suffix = os.path.splitext(filename)
file_name = stem if suffix.lower() in EXT_TO_PIL_FORMAT else filename
else:
safe = "".join(c for c in prompt if c.isalnum() or c in (" ", "_")).rstrip()
safe = safe.replace(" ", "_").lower()[:30]
@@ -331,6 +345,136 @@ def report_resolution(path: str) -> None:
pass
TIER_PIXELS = {
"512px": 512 * 512,
"1K": 1024 * 1024,
"2K": 2048 * 2048,
"4K": 4096 * 4096,
}
def parse_aspect_ratio(value) -> Fraction:
"""Parse any positive `W:H` (integers or decimals, e.g. `7:2`, `1.85:1`) exactly."""
text = str(value).strip()
parts = text.split(":")
if len(parts) != 2:
raise ValueError(f"Invalid aspect ratio '{value}'. Expected W:H, e.g. 16:9 or 1.85:1.")
try:
width, height = (Fraction(Decimal(part.strip())) for part in parts)
except (InvalidOperation, ValueError, OverflowError) as exc:
raise ValueError(
f"Invalid aspect ratio '{value}'. Expected W:H with positive numbers."
) from exc
if width <= 0 or height <= 0:
raise ValueError(f"Invalid aspect ratio '{value}'. Both sides must be positive.")
return width / height
def _log_ratio(ratio: Fraction) -> float:
"""Natural log of a ratio that stays finite for any exact W:H, however extreme."""
return math.log(ratio.numerator) - math.log(ratio.denominator)
def bounded_ratio(value) -> float:
"""Return a W:H (string or Fraction) as a float clamped to 1e-6..1e6 for size math."""
ratio = parse_aspect_ratio(value) if isinstance(value, str) else Fraction(value)
return float(min(max(ratio, Fraction(1, 10**6)), Fraction(10**6)))
def match_aspect_ratio(value, choices) -> str | None:
"""Return the choice numerically equal to `value` (`32:18` matches `16:9`), else None."""
target = parse_aspect_ratio(value)
for choice in choices:
if parse_aspect_ratio(choice) == target:
return choice
return None
def nearest_aspect_ratio(value, choices) -> str:
"""Return the choice whose ratio is closest to `value` on a log scale."""
exact = match_aspect_ratio(value, choices)
if exact is not None:
return exact
target = _log_ratio(parse_aspect_ratio(value))
return min(choices, key=lambda choice: abs(_log_ratio(parse_aspect_ratio(choice)) - target))
def fit_size(
ratio,
*,
pixels: int,
step: int = 1,
min_edge: int = 1,
max_edge: int | None = None,
min_pixels: int | None = None,
max_pixels: int | None = None,
max_ratio: float | None = None,
) -> tuple[int, int]:
"""
Compute a legal `(width, height)` for any ratio under a model's size rules.
The ratio is clamped to what `max_ratio` and the edge limits allow (orientation
kept), the pixel budget is clamped into `[min_pixels, max_pixels]`, and both
edges are rounded to `step`. Raises ValueError only when the rules themselves
admit no size.
"""
r = bounded_ratio(ratio)
limit = max_ratio or math.inf
lo_edge = math.ceil(min_edge / step) * step
hi_edge = math.floor(max_edge / step) * step if max_edge else None
if hi_edge:
limit = min(limit, hi_edge / lo_edge)
r = min(max(r, 1 / limit), limit)
budget = pixels
if min_pixels:
budget = max(budget, min_pixels)
if max_pixels:
budget = min(budget, max_pixels)
width, height = math.sqrt(budget * r), math.sqrt(budget / r)
short, long_ = min(width, height), max(width, height)
if short < lo_edge:
long_, short = long_ * lo_edge / short, lo_edge
if hi_edge and long_ > hi_edge:
short, long_ = short * hi_edge / long_, hi_edge
def snap(edge: float) -> int:
snapped = max(lo_edge, round(edge / step) * step)
return min(snapped, hi_edge) if hi_edge else snapped
short, long_ = snap(short), snap(long_)
while max_pixels and short * long_ > max_pixels and short > lo_edge:
long_ -= step
short = snap(long_ / r if r >= 1 else long_ * r)
while min_pixels and short * long_ < min_pixels and (not hi_edge or long_ < hi_edge):
long_ += step
short = snap(long_ / r if r >= 1 else long_ * r)
# Rounding can push a clamped ratio just past the limit; pull it back inside.
while long_ / short > limit:
widened = short + step
if (not max_pixels or widened * long_ <= max_pixels) and (not hi_edge or widened <= hi_edge):
short = widened
else:
long_ -= step
width, height = (long_, short) if r >= 1 else (short, long_)
area = width * height
if (max_pixels and area > max_pixels) or (min_pixels and area < min_pixels) or long_ / short > limit:
raise ValueError(
f"No legal size for ratio {ratio} under the model's limits "
f"(got {width}x{height}, pixels {min_pixels}-{max_pixels})."
)
return width, height
def note_aspect_ratio(requested, actual) -> None:
"""Print the adaptation when the output ratio differs from the requested one by >1%."""
drift = _log_ratio(parse_aspect_ratio(actual)) - _log_ratio(parse_aspect_ratio(requested))
if abs(drift) > math.log(1.01):
print(f" Ratio: requested {requested} -> output {actual} "
"(nearest the model allows; crop downstream)")
def normalize_image_size(image_size: str) -> str:
"""Normalize image size input to standard format."""
s = image_size.strip()
@@ -431,10 +575,34 @@ def is_rate_limit_error(exc: Exception) -> bool:
)
def retry_delay(attempt: int, rate_limited: bool) -> int:
"""Return the retry delay for a given attempt."""
def retry_after_seconds(exc: Exception | None) -> int | None:
"""Return the server-requested wait on `exc` or any error it was raised from."""
seen = set()
while exc is not None and id(exc) not in seen:
seen.add(id(exc))
retry_after = getattr(exc, "retry_after", None)
if retry_after is not None:
return retry_after
exc = exc.__cause__ or exc.__context__
return None
def retry_delay(attempt: int, rate_limited: bool, exc: Exception = None) -> int:
"""Return the retry delay for a given attempt.
A server-provided Retry-After (kept on `exc` by `http_error`) wins; otherwise
rate limits back off exponentially with jitter. A Retry-After beyond
MAX_RETRY_WAIT re-raises `exc`: retrying early only burns requests, so the
caller (the manifest runner) decides to stop instead.
"""
retry_after = retry_after_seconds(exc)
if retry_after is not None:
if retry_after > MAX_RETRY_WAIT:
raise exc
return retry_after
if rate_limited:
return RETRY_BASE_DELAY * (RETRY_BACKOFF ** attempt)
delay = RETRY_BASE_DELAY * (RETRY_BACKOFF ** attempt)
return delay + random.randint(0, delay // 4)
return 5
@@ -461,12 +629,38 @@ def require_api_key(*candidates: str, message: str) -> str:
raise ValueError(message)
def _parse_retry_after(headers) -> int | None:
"""Read Retry-After (seconds or HTTP date) or retry-after-ms as whole seconds."""
if not headers:
return None
millis = headers.get("retry-after-ms")
if isinstance(millis, str) and millis:
try:
return max(0, math.ceil(float(millis) / 1000))
except (ValueError, OverflowError):
pass
value = headers.get("retry-after")
if not isinstance(value, str) or not value:
return None
try:
return max(0, math.ceil(float(value)))
except (ValueError, OverflowError):
pass
try:
retry_at = parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
return max(0, math.ceil(retry_at.timestamp() - time.time()))
def http_error(response: requests.Response, label: str) -> RuntimeError:
"""Convert an HTTP response into a readable RuntimeError."""
body = response.text.strip()
if len(body) > 500:
body = body[:500] + "..."
return RuntimeError(f"{label} failed ({response.status_code}): {body}")
error = RuntimeError(f"{label} failed ({response.status_code}): {body}")
error.retry_after = _parse_retry_after(getattr(response, "headers", None))
return error
def poll_json(
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -145,6 +147,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the fal.ai backend."""
requested_ratio = aspect_ratio
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
note_aspect_ratio(requested_ratio, aspect_ratio)
resolved_model = model or os.environ.get("FAL_MODEL") or DEFAULT_MODEL
_resolve_request_options(aspect_ratio, image_size, resolved_model)
api_key = require_api_key(
@@ -174,9 +179,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -39,7 +39,9 @@ from image_backends.backend_common import (
MAX_RETRIES,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
resolve_output_path,
retry_delay,
save_image_bytes,
@@ -301,8 +303,14 @@ def generate(prompt: str,
image_size = normalize_image_size(image_size)
if aspect_ratio not in VALID_ASPECT_RATIOS:
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
requested_ratio = aspect_ratio
ratios = (
GEMINI_2_5_VALID_ASPECT_RATIOS
if _model_id(model) in GEMINI_2_5_FLASH_IMAGE_MODELS
else VALID_ASPECT_RATIOS
)
aspect_ratio = nearest_aspect_ratio(aspect_ratio, ratios)
note_aspect_ratio(requested_ratio, aspect_ratio)
if image_size not in VALID_IMAGE_SIZES:
raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
@@ -326,16 +334,16 @@ def generate(prompt: str,
if is_permanent_error(e):
raise
if attempt < max_retries and is_rate_limit_error(e):
delay = retry_delay(attempt, rate_limited=True)
delay = retry_delay(attempt, rate_limited=True, exc=last_error)
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
f"Waiting {delay}s before retry...")
time.sleep(delay)
elif attempt < max_retries:
delay = retry_delay(attempt, rate_limited=False)
delay = retry_delay(attempt, rate_limited=False, exc=last_error)
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay}s...")
time.sleep(delay)
else:
break
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -150,6 +152,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the Ideogram backend."""
requested_ratio = aspect_ratio
aspect_ratio = nearest_aspect_ratio(aspect_ratio, ASPECT_RATIO_MAP)
note_aspect_ratio(requested_ratio, aspect_ratio)
resolved_model = model or os.environ.get("IDEOGRAM_MODEL") or DEFAULT_MODEL
_resolve_request_options(aspect_ratio, image_size, resolved_model)
api_key = require_api_key(
@@ -178,9 +183,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -37,7 +37,11 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
TIER_PIXELS,
fit_size,
match_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -120,20 +124,25 @@ def _resolve_dimensions(aspect_ratio: str, image_size: str) -> tuple[int, int]:
"""Resolve width and height from the unified aspect_ratio/image_size pair."""
normalized = normalize_image_size(image_size)
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized)
if sizes is None:
if sizes is None and normalized not in TIER_PIXELS:
supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
raise ValueError(
f"Unsupported image size '{image_size}' for MiniMax backend. "
f"image-01 supports these logical sizes: {supported_sizes}."
)
dimensions = sizes.get(aspect_ratio)
if not dimensions:
supported = sorted(sizes)
raise ValueError(
f"Unsupported aspect ratio '{aspect_ratio}' for MiniMax backend. "
f"Supported: {supported}"
)
return dimensions
listed = match_aspect_ratio(aspect_ratio, sizes or {})
if listed:
return sizes[listed]
# Any other ratio or tier is fitted to the model's size rules.
budget = sizes["1:1"][0] * sizes["1:1"][1] if sizes else TIER_PIXELS[normalized]
width, height = fit_size(
aspect_ratio,
pixels=budget,
step=8,
min_edge=512,
max_edge=2048,
)
return width, height
def _extract_image_bytes(payload: dict) -> bytes | None:
@@ -161,6 +170,7 @@ def _generate_image(api_key: str, prompt: str,
"""Generate one image with the MiniMax backend."""
model = _validate_model(model)
width, height = _resolve_dimensions(aspect_ratio, image_size)
note_aspect_ratio(aspect_ratio, f"{width}:{height}")
url = _resolve_url(base_url)
headers = {
@@ -247,9 +257,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -18,7 +18,9 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -154,6 +156,10 @@ def generate(prompt: str,
"""Generate an image with retries using the ModelScope backend."""
resolved_model = _resolve_model(model)
normalized_size = normalize_image_size(image_size)
requested_ratio = aspect_ratio
ratios = ASPECT_RATIO_SIZE_MAP.get(normalized_size) or ASPECT_RATIO_SIZE_MAP["1K"]
aspect_ratio = nearest_aspect_ratio(aspect_ratio, ratios)
note_aspect_ratio(requested_ratio, aspect_ratio)
_resolve_size(aspect_ratio, normalized_size)
api_key = require_api_key(
"MODELSCOPE_API_KEY",
@@ -181,9 +187,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -8,7 +8,7 @@ Used by image_gen.py as a backend module.
Configuration keys:
OPENAI_API_KEY (required) API key
OPENAI_BASE_URL (optional) Custom API endpoint (e.g. http://127.0.0.1:3000/v1)
OPENAI_MODEL (optional) Model name (default: gpt-image-2)
OPENAI_MODEL (optional) Model name (default: gpt-image-2.5-sunburst)
OPENAI_SIZE_PRESET (optional) auto, legacy, gpt-image, gpt-image-2, or dall-e-2
OPENAI_RESPONSE_FORMAT (optional) auto, b64_json, url, or omit
OPENAI_QUALITY (optional) auto, omit, low, medium, high, xhigh, max, standard, or hd
@@ -54,10 +54,15 @@ import requests
from image_backends.backend_common import (
MAX_RETRIES,
download_image,
fit_size,
http_error,
is_permanent_error,
is_rate_limit_error,
match_aspect_ratio,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
parse_aspect_ratio,
resolve_output_path,
retry_delay,
save_image_bytes,
@@ -69,7 +74,8 @@ from image_backends.backend_common import (
# ╚══════════════════════════════════════════════════════════════════╝
# Aspect ratio -> DALL-E 3 / legacy compatible size mapping.
# Unknown OpenAI-compatible models use this table to preserve old behavior.
# Unknown OpenAI-compatible models use this table to preserve old behavior;
# other ratios take the nearest entry.
LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE = {
"1:1": "1024x1024",
"16:9": "1792x1024",
@@ -100,6 +106,8 @@ GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE = {
# GPT Image 2 supports flexible sizes when both edges are multiples of 16,
# the edge ratio is <= 3:1, and the total pixels are within model limits.
# Listed ratios use these sizes; any other ratio is fitted to the rules at
# the tier's 1:1 pixel budget (beyond 3:1 it is clamped to 3:1).
GPT_IMAGE_2_SIZES = {
"512px": {
"1:1": "1024x1024", "16:9": "1280x720", "9:16": "720x1280",
@@ -134,8 +142,6 @@ DALL_E_2_SIZE_BY_IMAGE_SIZE = {
"4K": "1024x1024",
}
VALID_ASPECT_RATIOS = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys())
# image_size -> quality mapping
IMAGE_SIZE_TO_QUALITY = {
"512px": "low",
@@ -144,7 +150,7 @@ IMAGE_SIZE_TO_QUALITY = {
"4K": "high",
}
DEFAULT_MODEL = "gpt-image-2"
DEFAULT_MODEL = "gpt-image-2.5-sunburst"
GPT_IMAGE_2_MIN_PIXELS = 655_360
GPT_IMAGE_2_MAX_PIXELS = 8_294_400
@@ -250,6 +256,19 @@ def _validate_gpt_image_2_size(size: str) -> None:
raise ValueError(f"Invalid gpt-image-2 size '{size}': {', '.join(errors)}")
def _nearest_listed_size(aspect_ratio: str, table: dict[str, str]) -> str:
"""Keep a listed ratio's size; otherwise pick the size whose real W:H is nearest.
Legacy keys are nominal (5:4 maps to 1536x1024), so unlisted ratios compare
against the sizes themselves, not the keys.
"""
listed = match_aspect_ratio(aspect_ratio, table)
if listed:
return table[listed]
sizes = {size.replace("x", ":"): size for size in table.values()}
return sizes[nearest_aspect_ratio(aspect_ratio, sizes)]
def _select_size(
model: str,
aspect_ratio: str,
@@ -261,16 +280,31 @@ def _select_size(
if preset in {"gpt-image-2"} or (
preset == "auto" and (_is_gpt_image_2(model) or _is_gpt_image_2_5(model))
):
size = GPT_IMAGE_2_SIZES[image_size][aspect_ratio]
sizes = GPT_IMAGE_2_SIZES[image_size]
listed = match_aspect_ratio(aspect_ratio, sizes)
if listed:
size = sizes[listed]
else:
square_w, square_h = _parse_size(sizes["1:1"])
width, height = fit_size(
aspect_ratio,
pixels=square_w * square_h,
step=16,
max_edge=GPT_IMAGE_2_MAX_EDGE,
min_pixels=GPT_IMAGE_2_MIN_PIXELS,
max_pixels=GPT_IMAGE_2_MAX_PIXELS,
max_ratio=GPT_IMAGE_2_MAX_RATIO,
)
size = f"{width}x{height}"
_validate_gpt_image_2_size(size)
return size
if preset in {"gpt-image", "gpt-image-legacy"} or (
preset == "auto" and _is_gpt_image_model(model)
):
return GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE[aspect_ratio]
return _nearest_listed_size(aspect_ratio, GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE)
if preset in {"dall-e-2", "dalle-2"} or (preset == "auto" and _is_dall_e_2(model)):
return DALL_E_2_SIZE_BY_IMAGE_SIZE[image_size]
return LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE[aspect_ratio]
return _nearest_listed_size(aspect_ratio, LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE)
def _supports_response_format(model: str) -> bool:
@@ -527,6 +561,7 @@ def _generate_image(api_key: str, prompt: str,
print(f" Model: {model}")
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
print(f" Size: {size} (from aspect_ratio={aspect_ratio})")
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
if size_preset and size_preset != "auto":
print(f" Size Preset: {size_preset}")
if quality is not None:
@@ -628,6 +663,7 @@ def _edit_image(api_key: str, prompt: str, reference_image: str,
print(f" Reference: {reference_image}")
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
print(f" Size: {size} (from aspect_ratio={aspect_ratio})")
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
if size_preset and size_preset != "auto":
print(f" Size Preset: {size_preset}")
if quality is not None:
@@ -712,7 +748,7 @@ def generate(prompt: str,
image_size: Image size, mapped to OpenAI quality
output_dir: Output directory
filename: Output filename (without extension)
model: Model name (default: gpt-image-2)
model: Model name (default: gpt-image-2.5-sunburst)
max_retries: Maximum number of retries
reference_image: Optional source image path. When set, the request
goes to /v1/images/edits (image-to-image) instead of
@@ -726,12 +762,7 @@ def generate(prompt: str,
image_size = normalize_image_size(image_size)
if aspect_ratio not in LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE:
supported = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys())
raise ValueError(
f"Unsupported aspect ratio '{aspect_ratio}' for OpenAI backend. "
f"Supported: {supported}"
)
parse_aspect_ratio(aspect_ratio)
_validate_request_options(
model,
@@ -762,16 +793,16 @@ def generate(prompt: str,
if is_permanent_error(e):
raise
if attempt < max_retries and is_rate_limit_error(e):
delay = retry_delay(attempt, rate_limited=True)
delay = retry_delay(attempt, rate_limited=True, exc=e)
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
f"Waiting {delay}s before retry...")
time.sleep(delay)
elif attempt < max_retries:
delay = retry_delay(attempt, rate_limited=False)
delay = retry_delay(attempt, rate_limited=False, exc=e)
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay}s...")
time.sleep(delay)
else:
break
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -36,7 +36,9 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
resolve_output_path,
retry_delay,
save_image_bytes,
@@ -189,8 +191,9 @@ def generate(prompt: str,
image_size = normalize_image_size(image_size)
if aspect_ratio not in VALID_ASPECT_RATIOS:
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
requested_ratio = aspect_ratio
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
note_aspect_ratio(requested_ratio, aspect_ratio)
if image_size not in VALID_IMAGE_SIZES:
raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
@@ -211,16 +214,16 @@ def generate(prompt: str,
if is_permanent_error(e):
raise
if attempt < max_retries and is_rate_limit_error(e):
delay = retry_delay(attempt, rate_limited=True)
delay = retry_delay(attempt, rate_limited=True, exc=last_error)
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
f"Waiting {delay}s before retry...")
time.sleep(delay)
elif attempt < max_retries:
delay = retry_delay(attempt, rate_limited=False)
delay = retry_delay(attempt, rate_limited=False, exc=last_error)
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay}s...")
time.sleep(delay)
else:
break
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -24,6 +24,7 @@ if __name__ == "__main__":
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend qwen")
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
import math
import os
import time
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
TIER_PIXELS,
fit_size,
match_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -110,20 +115,27 @@ def _resolve_size(aspect_ratio: str, image_size: str) -> str:
"""Resolve the target resolution for a ratio and logical size preset."""
normalized = normalize_image_size(image_size)
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized)
if sizes is None:
if sizes is None and normalized not in TIER_PIXELS:
supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
raise ValueError(
f"Unsupported image size '{image_size}' for Qwen backend. "
f"qwen-image-2.0-pro supports these logical sizes: {supported_sizes}."
)
size = sizes.get(aspect_ratio)
if not size:
supported = sorted(sizes)
raise ValueError(
f"Unsupported aspect ratio '{aspect_ratio}' for Qwen backend. "
f"Supported: {supported}"
)
return size
listed = match_aspect_ratio(aspect_ratio, sizes or {})
if listed:
return sizes[listed]
# Any other ratio or tier is fitted to the model's size rules.
budget = math.prod(map(int, sizes["1:1"].split("*"))) if sizes else TIER_PIXELS[normalized]
width, height = fit_size(
aspect_ratio,
pixels=budget,
step=32,
max_edge=2688,
min_pixels=512 * 512,
max_pixels=2048 * 2048,
max_ratio=7 / 3,
)
return f"{width}*{height}"
def _generate_image(api_key: str, prompt: str,
@@ -133,6 +145,7 @@ def _generate_image(api_key: str, prompt: str,
"""Generate one image with the Qwen backend."""
model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size)
note_aspect_ratio(aspect_ratio, size.replace("*", ":"))
url = _resolve_url(base_url)
headers = {
"Authorization": f"Bearer {api_key}",
@@ -217,9 +230,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
poll_json,
require_api_key,
resolve_output_path,
@@ -168,6 +170,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the Replicate backend."""
requested_ratio = aspect_ratio
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
note_aspect_ratio(requested_ratio, aspect_ratio)
resolved_model = model or os.environ.get("REPLICATE_MODEL") or DEFAULT_MODEL
_resolve_request_options(aspect_ratio, image_size, resolved_model)
api_key = require_api_key(
@@ -197,9 +202,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -148,6 +150,10 @@ def generate(prompt: str,
resolved_model = model or os.environ.get("SILICONFLOW_MODEL") or DEFAULT_MODEL
_validate_model(resolved_model)
normalized_size = normalize_image_size(image_size)
requested_ratio = aspect_ratio
ratios = ASPECT_RATIO_SIZE_MAP.get(normalized_size) or ASPECT_RATIO_SIZE_MAP["1K"]
aspect_ratio = nearest_aspect_ratio(aspect_ratio, ratios)
note_aspect_ratio(requested_ratio, aspect_ratio)
_resolve_size(aspect_ratio, normalized_size)
api_key = require_api_key(
"SILICONFLOW_API_KEY",
@@ -175,9 +181,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -34,7 +34,9 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
report_resolution,
require_api_key,
resolve_output_path,
@@ -140,6 +142,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the Stability backend."""
requested_ratio = aspect_ratio
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
note_aspect_ratio(requested_ratio, aspect_ratio)
base_url = os.environ.get("STABILITY_BASE_URL") or DEFAULT_BASE_URL
resolved_model = model or os.environ.get("STABILITY_MODEL")
_validate_request_options(aspect_ratio, image_size)
@@ -169,9 +174,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -40,7 +40,10 @@ from image_backends.backend_common import ( # noqa: E402
http_error,
is_permanent_error,
is_rate_limit_error,
bounded_ratio,
fit_size,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -57,6 +60,12 @@ MODEL_ENDPOINTS = {
}
SUPPORTED_MODELS = set(MODEL_ENDPOINTS)
IMAGE_SIZE_PIXELS = {"512px": 512, "1K": 1024, "2K": 2048, "4K": 4096}
# Official output limits; a size outside them is refitted, never rejected.
MODEL_SIZE_LIMITS = {
DEFAULT_MODEL: {"min_edge": 512, "max_edge": 2048, "max_pixels": 1024 * 1024},
"seedream-image-v5.0-pro": {"min_pixels": 921_600, "max_pixels": 4_624_220, "max_ratio": 16},
"seedream-image-v5.0-lite": {"min_pixels": 3_686_400, "max_pixels": 16_777_216, "max_ratio": 16},
}
def _validate_model(model: str) -> str:
@@ -80,6 +89,17 @@ def _resolve_url(base_url: str, model: str = DEFAULT_MODEL) -> str:
return base + MODEL_ENDPOINTS[model]
def _within_limits(width: int, height: int, limits: dict) -> bool:
"""Return whether a size satisfies one model's output limits."""
long_edge, short_edge = max(width, height), min(width, height)
return (
short_edge >= limits.get("min_edge", 1)
and long_edge <= limits.get("max_edge", long_edge)
and limits.get("min_pixels", 0) <= width * height <= limits.get("max_pixels", width * height)
and long_edge / short_edge <= limits.get("max_ratio", math.inf)
)
def _resolve_size(aspect_ratio: str, image_size: str, model: str = DEFAULT_MODEL) -> str:
"""Resolve a pixel size from the requested area and aspect ratio."""
normalized = normalize_image_size(image_size)
@@ -89,38 +109,17 @@ def _resolve_size(aspect_ratio: str, image_size: str, model: str = DEFAULT_MODEL
f"Unsupported image size '{image_size}' for Tencent backend. "
f"Supported logical sizes: {list(IMAGE_SIZE_PIXELS)}."
)
try:
ratio_width, ratio_height = (int(value) for value in aspect_ratio.split(":"))
if ratio_width <= 0 or ratio_height <= 0:
raise ValueError
except ValueError as exc:
raise ValueError(
f"Unsupported aspect ratio '{aspect_ratio}' for Tencent backend. "
"Use positive width:height integers, such as 16:9."
) from exc
ratio = bounded_ratio(aspect_ratio)
if model == "seedream-image-v5.0-lite":
pixels = max(pixels, 2048)
elif model == "seedream-image-v5.0-pro" and normalized not in {"1K", "2K"}:
raise ValueError(
f"Unsupported image size '{image_size}' for Seedream pro. "
"Use 1K or 2K; 4K requires seedream-image-v5.0-lite."
)
ratio = ratio_width / ratio_height
step = 16 if model == DEFAULT_MODEL else 1
width = math.floor(pixels * math.sqrt(ratio) / step) * step
height = math.floor(pixels / math.sqrt(ratio) / step) * step
if model == DEFAULT_MODEL and (
pixels > 1024
or not (512 <= width <= 2048 and 512 <= height <= 2048)
or width * height > 1024 * 1024
):
raise ValueError(
f"Unsupported image size '{image_size}' / aspect ratio '{aspect_ratio}' for Hy-Image-3.0. "
"Width and height must each be 512-2048 pixels in multiples of 16, "
"with area <= 1024x1024. Use 1K with a ratio from 1:4 to 4:1, or 512px at 1:1."
)
limits = MODEL_SIZE_LIMITS[model]
if not _within_limits(width, height, limits):
# Tiers or ratios beyond the model's limits move to the nearest legal size.
width, height = fit_size(ratio, pixels=pixels * pixels, step=step, **limits)
return f"{width}x{height}"
@@ -141,6 +140,7 @@ def _generate_image(api_key: str, prompt: str,
"""Generate one image with the Tencent backend."""
model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size, model)
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
url = _resolve_url(base_url, model)
headers = {
"Authorization": f"Bearer {api_key}",
@@ -236,9 +236,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -24,6 +24,7 @@ if __name__ == "__main__":
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend volcengine")
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
import math
import os
import time
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
TIER_PIXELS,
fit_size,
match_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -95,20 +100,26 @@ def _resolve_size(aspect_ratio: str, image_size: str) -> str:
"""Resolve the target resolution for a ratio and logical size preset."""
normalized = normalize_image_size(image_size)
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized)
if sizes is None:
if sizes is None and normalized not in TIER_PIXELS:
supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
raise ValueError(
f"Unsupported image size '{image_size}' for Volcengine backend. "
f"Seedream 4.5 supports these sizes: {supported_sizes}."
)
size = sizes.get(aspect_ratio)
if not size:
supported = sorted(sizes)
raise ValueError(
f"Unsupported aspect ratio '{aspect_ratio}' for Volcengine backend. "
f"Supported: {supported}"
)
return size
listed = match_aspect_ratio(aspect_ratio, sizes or {})
if listed:
return sizes[listed]
# Any other ratio or tier is fitted to the model's size rules.
budget = math.prod(map(int, sizes["1:1"].split("x"))) if sizes else TIER_PIXELS[normalized]
width, height = fit_size(
aspect_ratio,
pixels=budget,
step=32,
min_pixels=3_686_400,
max_pixels=16_777_216,
max_ratio=16,
)
return f"{width}x{height}"
def _generate_image(api_key: str, prompt: str,
@@ -118,6 +129,7 @@ def _generate_image(api_key: str, prompt: str,
"""Generate one image with the Volcengine backend."""
model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size)
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
url = _resolve_url(base_url)
headers = {
"Authorization": f"Bearer {api_key}",
@@ -196,9 +208,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -24,6 +24,7 @@ if __name__ == "__main__":
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend zhipu")
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
import math
import os
import time
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
http_error,
is_permanent_error,
is_rate_limit_error,
TIER_PIXELS,
fit_size,
match_aspect_ratio,
normalize_image_size,
note_aspect_ratio,
require_api_key,
resolve_output_path,
retry_delay,
@@ -96,20 +101,26 @@ def _resolve_size(aspect_ratio: str, image_size: str) -> str:
"""Resolve the target resolution for a ratio and logical size preset."""
normalized = normalize_image_size(image_size)
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized)
if sizes is None:
if sizes is None and normalized not in TIER_PIXELS:
supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
raise ValueError(
f"Unsupported image size '{image_size}' for Zhipu backend. "
f"GLM-Image supports these logical sizes: {supported_sizes}."
)
size = sizes.get(aspect_ratio)
if not size:
supported = sorted(sizes)
raise ValueError(
f"Unsupported aspect ratio '{aspect_ratio}' for Zhipu backend. "
f"Supported: {supported}"
)
return size
listed = match_aspect_ratio(aspect_ratio, sizes or {})
if listed:
return sizes[listed]
# Any other ratio or tier is fitted to the model's size rules.
budget = math.prod(map(int, sizes["1:1"].split("x"))) if sizes else TIER_PIXELS[normalized]
width, height = fit_size(
aspect_ratio,
pixels=budget,
step=32,
min_edge=512,
max_edge=2048,
max_pixels=2**22,
)
return f"{width}x{height}"
def _generate_image(api_key: str, prompt: str,
@@ -119,6 +130,7 @@ def _generate_image(api_key: str, prompt: str,
"""Generate one image with the Zhipu backend."""
model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size)
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
url = _resolve_url(base_url)
headers = {
"Authorization": f"Bearer {api_key}",
@@ -192,9 +204,9 @@ def generate(prompt: str,
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
@@ -92,15 +92,6 @@ DEPRECATED_IMAGE_KEYS = {
"IMAGE_BASE_URL",
}
# All aspect ratios accepted by the unified CLI
# (each backend validates its own subset internally)
ALL_ASPECT_RATIOS = [
"1:1", "1:2", "1:3", "1:4", "1:8",
"2:1", "2:3", "3:1", "3:2", "3:4", "4:1", "4:3",
"4:5", "5:4", "8:1", "9:16", "9:21", "10:16",
"16:9", "16:10", "21:9",
]
ALL_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
BACKEND_REGISTRY = {
@@ -117,7 +108,7 @@ BACKEND_REGISTRY = {
"module": "backend_openai",
"tier": "core",
"label": "OpenAI / OpenAI-compatible",
"default_model": "gpt-image-2",
"default_model": "gpt-image-2.5-sunburst",
"default_image_size": "1K",
"key_hint": "OPENAI_API_KEY",
"aliases": ["openai-compatible", "openai_compatible"],
@@ -384,19 +375,15 @@ def _print_backend_list() -> None:
_print_backend_resolution()
def _check_backend_aspect_ratio(backend_module, aspect_ratio: str) -> None:
"""Fail before the request when the resolved backend rejects this ratio.
def _aspect_ratio_arg(value: str) -> str:
"""argparse type: any positive W:H, kept as written for the backend to fit."""
from image_backends.backend_common import parse_aspect_ratio
``ALL_ASPECT_RATIOS`` is the union across backends; each backend module
may narrow it with ``VALID_ASPECT_RATIOS``.
"""
valid = getattr(backend_module, "VALID_ASPECT_RATIOS", None)
if valid and aspect_ratio not in valid:
name = getattr(backend_module, "__name__", "backend").rsplit(".", 1)[-1]
raise ValueError(
f"aspect_ratio '{aspect_ratio}' is not supported by {name}. "
f"Valid for this backend: {list(valid)}"
)
try:
parse_aspect_ratio(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
return value
def _resolve_backend() -> tuple[object, str]:
@@ -595,7 +582,7 @@ def load_manifest(path: str) -> dict:
Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`,
`page_role`, `text_policy`, `slice_grid`, `slice_names`, `last_error`.
"""
from image_backends.backend_common import normalize_image_size
from image_backends.backend_common import normalize_image_size, parse_aspect_ratio
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
@@ -672,11 +659,10 @@ def load_manifest(path: str) -> dict:
f"{prefix} status '{item['status']}' is invalid. "
f"Valid: {sorted(VALID_STATUSES)}"
)
if item["aspect_ratio"] not in ALL_ASPECT_RATIOS:
raise ValueError(
f"{prefix} aspect_ratio '{item['aspect_ratio']}' is invalid. "
f"Valid: {ALL_ASPECT_RATIOS}"
)
try:
parse_aspect_ratio(item["aspect_ratio"])
except ValueError as exc:
raise ValueError(f"{prefix} {exc}") from exc
if "image_size" in item:
image_size = item["image_size"]
if not isinstance(image_size, str) or not image_size.strip():
@@ -935,9 +921,11 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
output_dir = str(manifest_output_dir)
from image_backends.backend_common import (
MAX_RETRY_WAIT,
is_global_permanent_error,
is_permanent_error,
is_rate_limit_error,
retry_after_seconds,
validate_image_file,
)
@@ -992,7 +980,6 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
def _one(idx: int):
item = items[idx]
try:
_check_backend_aspect_ratio(backend_module, item["aspect_ratio"])
saved_path = backend_module.generate(
prompt=item["prompt"],
aspect_ratio=item["aspect_ratio"],
@@ -1020,6 +1007,7 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
)
rate_limited = False
server_wait = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as ex:
futures = [ex.submit(_one, i) for i in batch_idx]
for fut in concurrent.futures.as_completed(futures):
@@ -1050,6 +1038,7 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
)
elif is_rate_limit_error(exc):
rate_limited = True
server_wait = max(server_wait, retry_after_seconds(exc) or 0)
attempts = rate_limit_attempts.get(idx, 0) + 1
rate_limit_attempts[idx] = attempts
if (
@@ -1095,6 +1084,13 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
"Stopping new batches; untouched items remain retryable.\n"
)
break
if rate_limited and server_wait > MAX_RETRY_WAIT:
stopped_for_rate_limit = True
print(
f"\n Provider asked to wait {server_wait}s (over {MAX_RETRY_WAIT}s). "
"Stopping now; rerun the manifest later, unfinished items remain retryable.\n"
)
break
if stopped_for_rate_limit:
print(
"\n Persistent rate limit reached the run boundary. "
@@ -1103,12 +1099,14 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
break
if rate_limited and current > 1 and queue:
new_current = max(1, current // 2)
# Wait at least as long as the provider asked (Retry-After), never under 10s.
pause = max(10, server_wait)
print(
f"\n ⚠ Rate-limit hit — concurrency {current} → {new_current}, "
"pausing 10s before next batch\n"
f"pausing {pause}s before next batch\n"
)
current = new_current
time.sleep(10)
time.sleep(pause)
elif queue:
time.sleep(2)
@@ -1255,8 +1253,11 @@ def main() -> None:
)
)
parser.add_argument(
"--aspect_ratio", default="1:1", choices=ALL_ASPECT_RATIOS,
help=f"Aspect ratio. Default: 1:1."
"--aspect_ratio", default="1:1", type=_aspect_ratio_arg,
help=(
"Requested aspect ratio, any positive W:H (e.g. 16:9, 4:1, 1.85:1). "
"Each backend outputs the nearest size its model allows. Default: 1:1."
)
)
parser.add_argument(
"--image_size", default=None,
@@ -1422,7 +1423,10 @@ def main() -> None:
sys.exit(130)
md_path = render_manifest_md_to_file(args.manifest, manifest)
print(f"Rendered Markdown sidecar: {md_path}")
sys.exit(1 if failed else 0)
# A run stopped early (billing, rate limit) leaves retryable items without
# failing any; it must still exit non-zero.
unfinished = any(item["status"] in RETRYABLE_STATUSES for item in manifest["items"])
sys.exit(1 if failed or unfinished else 0)
# Single-image mode. Backfill the historical default prompt only here, so
# plain generation is byte-for-byte unchanged while edit mode still requires
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Regression tests for shared image-backend naming, error, and retry helpers."""
from __future__ import annotations
import os
import sys
import unittest
from email.utils import formatdate
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from image_backends import backend_common # noqa: E402
def _response(status: int, body: str, headers: dict | None = None) -> SimpleNamespace:
import requests
return SimpleNamespace(
status_code=status,
text=body,
headers=requests.structures.CaseInsensitiveDict(headers or {}),
)
class OutputPathTests(unittest.TestCase):
def test_cli_filename_keeps_dotted_stem(self) -> None:
path = backend_common.resolve_output_path("p", "/out", "gpt-image-2.5-sunburst_hero")
self.assertEqual(path, os.path.join("/out", "gpt-image-2.5-sunburst_hero.png"))
def test_image_extension_is_replaced(self) -> None:
path = backend_common.resolve_output_path("p", "/out", "hero.v2.PNG", ".webp")
self.assertEqual(path, os.path.join("/out", "hero.v2.webp"))
class BillingErrorTests(unittest.TestCase):
def test_openai_billing_codes_are_global_permanent(self) -> None:
for code in (
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
):
with self.subTest(code=code):
exc = backend_common.http_error(
_response(429, '{"error": {"code": "%s"}}' % code), "OpenAI image generation"
)
self.assertTrue(backend_common.is_global_permanent_error(exc))
self.assertFalse(backend_common.is_rate_limit_error(exc))
def test_plain_rate_limit_stays_retryable(self) -> None:
exc = backend_common.http_error(
_response(429, '{"error": {"code": "rate_limit_exceeded"}}'), "OpenAI image generation"
)
self.assertFalse(backend_common.is_permanent_error(exc))
self.assertTrue(backend_common.is_rate_limit_error(exc))
class RetryDelayTests(unittest.TestCase):
def test_retry_after_seconds_wins(self) -> None:
exc = backend_common.http_error(_response(429, "slow down", {"Retry-After": "7"}), "x")
self.assertEqual(backend_common.retry_delay(2, rate_limited=True, exc=exc), 7)
def test_retry_after_ms_rounds_up(self) -> None:
exc = backend_common.http_error(_response(429, "slow down", {"retry-after-ms": "1500"}), "x")
self.assertEqual(exc.retry_after, 2)
def test_retry_after_http_date(self) -> None:
with mock.patch.object(backend_common.time, "time", return_value=1_000_000.0):
exc = backend_common.http_error(
_response(503, "busy", {"Retry-After": formatdate(1_000_030, usegmt=True)}), "x"
)
self.assertEqual(exc.retry_after, 30)
def test_unrepresentable_retry_after_is_ignored(self) -> None:
for value in ("inf", "1e309", "nan", "soon"):
with self.subTest(value=value):
exc = backend_common.http_error(_response(429, "slow down", {"Retry-After": value}), "x")
self.assertIsNone(exc.retry_after)
def test_retry_after_is_capped_and_found_through_the_cause_chain(self) -> None:
cause = backend_common.http_error(_response(429, "slow down", {"Retry-After": "86400"}), "x")
try:
raise RuntimeError("Failed after 4 attempts") from cause
except RuntimeError as wrapped:
self.assertEqual(backend_common.retry_after_seconds(wrapped), 86400)
with self.assertRaises(RuntimeError):
backend_common.retry_delay(0, rate_limited=True, exc=wrapped)
at_cap = backend_common.http_error(_response(429, "slow down", {"Retry-After": "300"}), "x")
self.assertEqual(backend_common.retry_delay(0, rate_limited=True, exc=at_cap), 300)
def test_backend_stops_retrying_when_the_wait_exceeds_the_cap(self) -> None:
from image_backends import backend_openai
limited = _response(429, '{"error": {"code": "rate_limit_exceeded"}}', {"Retry-After": "86400"})
limited.ok = False
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "test"}), \
mock.patch.object(backend_openai.requests, "post", return_value=limited) as post, \
mock.patch.object(backend_openai.time, "sleep") as sleep, \
mock.patch("sys.stdout"):
with self.assertRaises(RuntimeError) as raised:
backend_openai.generate("p")
self.assertEqual(post.call_count, 1)
sleep.assert_not_called()
self.assertEqual(backend_common.retry_after_seconds(raised.exception), 86400)
def test_non_openai_backend_waits_for_retry_after(self) -> None:
from image_backends import backend_qwen
limited = _response(429, '{"code": "Throttling.RateQuota"}', {"Retry-After": "42"})
limited.json = lambda: {"code": "Throttling.RateQuota"}
limited.ok = False
with mock.patch.dict(os.environ, {"QWEN_API_KEY": "test"}), \
mock.patch.object(backend_qwen.requests, "post", return_value=limited), \
mock.patch.object(backend_qwen.time, "sleep") as sleep, \
mock.patch("sys.stdout"):
with self.assertRaises(RuntimeError):
backend_qwen.generate("p", max_retries=1)
sleep.assert_called_once_with(42)
def test_rate_limit_backoff_has_bounded_jitter(self) -> None:
exc = backend_common.http_error(_response(429, "slow down"), "x")
self.assertIsNone(exc.retry_after)
delays = {backend_common.retry_delay(1, rate_limited=True, exc=exc) for _ in range(200)}
self.assertTrue(all(20 <= delay <= 25 for delay in delays))
self.assertGreater(len(delays), 1)
class AspectRatioTests(unittest.TestCase):
def test_any_positive_ratio_parses_exactly(self) -> None:
self.assertEqual(backend_common.parse_aspect_ratio("1.85:1"), backend_common.parse_aspect_ratio("37:20"))
self.assertEqual(backend_common.parse_aspect_ratio(" 32 : 18 "), backend_common.parse_aspect_ratio("16:9"))
for value in ("abc", "16:9:1", "0:1", "1:0", "-1:2", "inf:1", "nan:1", ""):
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "Invalid aspect ratio"):
backend_common.parse_aspect_ratio(value)
def test_nearest_ratio_prefers_exact_then_log_distance(self) -> None:
choices = ["1:1", "16:9", "9:16", "21:9"]
self.assertEqual(backend_common.nearest_aspect_ratio("32:18", choices), "16:9")
self.assertEqual(backend_common.nearest_aspect_ratio("4:1", choices), "21:9")
self.assertEqual(backend_common.nearest_aspect_ratio("1:4", choices), "9:16")
def test_fit_size_clamps_ratio_and_keeps_orientation(self) -> None:
rules = {"step": 8, "min_edge": 512, "max_edge": 2048}
self.assertEqual(backend_common.fit_size("10:1", pixels=1024 * 1024, **rules), (2048, 512))
self.assertEqual(backend_common.fit_size("1:10", pixels=4096 * 4096, **rules), (512, 2048))
width, height = backend_common.fit_size("1:20", pixels=2048 * 2048, min_pixels=3_686_400,
max_pixels=16_777_216, max_ratio=16)
self.assertAlmostEqual(height / width, 16, delta=0.1)
self.assertTrue(3_686_400 <= width * height <= 16_777_216)
def test_fit_size_rounding_never_exceeds_the_ratio_limit(self) -> None:
width, height = backend_common.fit_size("1000:1", pixels=4096 * 4096, min_pixels=921_600,
max_pixels=4_624_220, max_ratio=16)
self.assertLessEqual(width / height, 16)
self.assertLessEqual(width * height, 4_624_220)
def test_extreme_integer_ratios_stay_finite(self) -> None:
huge, tiny = f"{10**309}:1", f"1:{10**324}"
self.assertEqual(backend_common.fit_size(huge, pixels=1024 * 1024, step=16, max_ratio=3), (1776, 592))
self.assertEqual(backend_common.nearest_aspect_ratio(tiny, ["1:1", "1:3"]), "1:3")
backend_common.note_aspect_ratio(tiny, "1:3")
class OpenAISizeTests(unittest.TestCase):
def test_listed_ratios_keep_their_table_sizes(self) -> None:
from image_backends import backend_openai
for tier, sizes in backend_openai.GPT_IMAGE_2_SIZES.items():
for ratio, size in sizes.items():
with self.subTest(tier=tier, ratio=ratio):
self.assertEqual(backend_openai._select_size("gpt-image-2.5-sunburst", ratio, tier), size)
def test_any_ratio_fits_gpt_image_2_rules(self) -> None:
from image_backends import backend_openai
for ratio in ("4:1", "1:4", "8:1", "3:1", "2:1", "1:2", "16:10", "10:16", "9:21", "1.85:1", "7:2"):
for tier in ("512px", "1K", "2K", "4K"):
with self.subTest(ratio=ratio, tier=tier):
size = backend_openai._select_size("gpt-image-2", ratio, tier)
backend_openai._validate_gpt_image_2_size(size)
self.assertEqual(backend_openai._select_size("gpt-image-2.5-flare", "4:1", "1K"), "1776x592")
def test_legacy_and_unknown_models_use_their_nearest_entry(self) -> None:
from image_backends import backend_openai
self.assertEqual(backend_openai._select_size("gpt-image-1.5", "4:1", "1K"), "1536x1024")
self.assertEqual(backend_openai._select_size("compatible-model", "1:4", "1K"), "1024x1792")
# Unlisted ratios compare against real sizes, not nominal keys (5:4 -> 1536x1024).
self.assertEqual(backend_openai._select_size("gpt-image-1.5", "1.2:1", "1K"), "1024x1024")
self.assertEqual(backend_openai._select_size("compatible-model", "1.24:1", "1K"), "1536x1024")
self.assertEqual(backend_openai._select_size("gpt-image-1.5", "5:4", "1K"), "1536x1024")
class ManifestAspectRatioTests(unittest.TestCase):
def _load(self, ratio: str) -> dict:
import json
import tempfile
import image_gen
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "image_prompts.json"
item = {"filename": "a.png", "prompt": "p", "aspect_ratio": ratio, "status": "Pending",
"page_role": "local", "text_policy": "none"}
path.write_text(json.dumps({"items": [item]}), encoding="utf-8")
return image_gen.load_manifest(str(path))
def test_manifest_keeps_any_requested_ratio(self) -> None:
for ratio in ("4:1", "1.85:1", "7:2"):
with self.subTest(ratio=ratio):
self.assertEqual(self._load(ratio)["items"][0]["aspect_ratio"], ratio)
def test_manifest_rejects_malformed_ratio(self) -> None:
with self.assertRaisesRegex(ValueError, "Invalid aspect ratio"):
self._load("0:1")
@@ -17,7 +17,7 @@ if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import image_gen # noqa: E402
from image_backends import backend_tencent # noqa: E402
from image_backends import backend_common, backend_tencent # noqa: E402
class TencentBackendTests(unittest.TestCase):
@@ -103,15 +103,14 @@ class TencentBackendTests(unittest.TestCase):
with self.subTest(ratio=ratio, size=logical_size):
self.assertEqual(backend_tencent._resolve_size(ratio, logical_size), resolution)
def test_hy_rejects_impossible_sizes_before_request(self) -> None:
for ratio, logical_size in (
("1:1", "2K"), ("1:1", "4K"), ("16:9", "512px"),
("1:8", "1K"), ("8:1", "1K"),
def test_hy_refits_out_of_range_sizes(self) -> None:
for ratio, logical_size, resolution in (
("1:1", "2K", "1024x1024"), ("1:1", "4K", "1024x1024"), ("16:9", "512px", "912x512"),
("1:8", "1K", "512x2048"), ("8:1", "1K", "2048x512"), ("1.85:1", "1K", "1392x752"),
):
with self.subTest(ratio=ratio, size=logical_size):
with self.assertRaisesRegex(ValueError, "512-2048.*multiples of 16.*1024x1024"):
backend_tencent.generate("test", aspect_ratio=ratio, image_size=logical_size)
self.post.assert_not_called()
backend_tencent.generate("test", aspect_ratio=ratio, image_size=logical_size)
self.assertEqual(self.post.call_args.kwargs["json"]["size"], resolution)
def test_seedream_lite_upgrades_small_sizes_once(self) -> None:
for logical_size in ("1K", "512px"):
@@ -130,25 +129,42 @@ class TencentBackendTests(unittest.TestCase):
("seedream-image-v5.0-lite", "2K", 2048),
("seedream-image-v5.0-lite", "4K", 4096),
):
for ratio in image_gen.ALL_ASPECT_RATIOS:
for ratio in ("1:1", "1:4", "1:8", "3:2", "4:1", "8:1", "9:16", "16:9", "21:9", "16:10", "1.85:1"):
with self.subTest(model=model, size=logical_size, ratio=ratio):
size = backend_tencent._resolve_size(ratio, logical_size, model)
width, height = map(int, size.split("x"))
ratio_width, ratio_height = map(int, ratio.split(":"))
expected = float(backend_common.parse_aspect_ratio(ratio))
self.assertAlmostEqual(width * height / side ** 2, 1.0, delta=0.004)
self.assertAlmostEqual(width / height, ratio_width / ratio_height, delta=0.025)
self.assertAlmostEqual(width / height, expected, delta=0.025)
def test_seedream_pro_rejects_512px_and_4k(self) -> None:
for logical_size in ("512px", "4K"):
def test_extreme_ratios_stay_within_every_model_limit(self) -> None:
for model in backend_tencent.SUPPORTED_MODELS:
for ratio in ("1000:1", "1:1000", f"{10**309}:1"):
for logical_size in ("512px", "1K", "2K", "4K"):
with self.subTest(model=model, ratio=ratio[:12], size=logical_size):
size = backend_tencent._resolve_size(ratio, logical_size, model)
width, height = map(int, size.split("x"))
limits = backend_tencent.MODEL_SIZE_LIMITS[model]
self.assertTrue(backend_tencent._within_limits(width, height, limits))
def test_seedream_clamps_ratio_beyond_16_to_1(self) -> None:
for model in ("seedream-image-v5.0-pro", "seedream-image-v5.0-lite"):
with self.subTest(model=model):
width, height = map(int, backend_tencent._resolve_size("1:20", "2K", model).split("x"))
limits = backend_tencent.MODEL_SIZE_LIMITS[model]
self.assertAlmostEqual(height / width, 16, delta=0.1)
self.assertTrue(limits["min_pixels"] <= width * height <= limits["max_pixels"])
def test_seedream_pro_refits_512px_and_4k_into_its_area(self) -> None:
for logical_size, resolution in (("512px", "960x960"), ("4K", "2150x2150")):
with self.subTest(size=logical_size):
with self.assertRaisesRegex(ValueError, "Use 1K or 2K"):
backend_tencent.generate("test", model="seedream-image-v5.0-pro", image_size=logical_size)
self.post.assert_not_called()
backend_tencent.generate("test", model="seedream-image-v5.0-pro", image_size=logical_size)
self.assertEqual(self.post.call_args.kwargs["json"]["size"], resolution)
def test_invalid_ratio_and_size_fail_before_request(self) -> None:
for ratio in ("invalid", "0:1", "1:0", "-1:2", "1:2:3"):
with self.subTest(ratio=ratio):
with self.assertRaisesRegex(ValueError, "Unsupported aspect ratio"):
with self.assertRaisesRegex(ValueError, "Invalid aspect ratio"):
backend_tencent.generate("test", aspect_ratio=ratio)
with self.assertRaisesRegex(ValueError, "Unsupported image size"):
backend_tencent.generate("test", image_size="3K")
@@ -242,7 +258,8 @@ class TencentBackendTests(unittest.TestCase):
def test_rate_limit_uses_shared_exponential_backoff(self) -> None:
limited = Mock(status_code=429, text='{"error": {"code": "ConcurrencyLimit"}}')
self.post.side_effect = [limited, limited, self.response]
backend_tencent.generate("test")
with patch.object(backend_common.random, "randint", return_value=0):
backend_tencent.generate("test")
self.assertEqual(self.post.call_count, 3)
self.assertEqual([call.args[0] for call in self.sleep.call_args_list], [10, 20])
@@ -50,3 +50,97 @@ class ManifestOutputNameTests(unittest.TestCase):
self.assertEqual(first.getpixel((0, 0)), (255, 0, 0))
self.assertEqual(second.getpixel((0, 0)), (0, 0, 255))
class ManifestRetryAfterTests(unittest.TestCase):
def test_rate_limited_batch_waits_for_server_retry_after(self) -> None:
from unittest import mock
import requests
from PIL import Image
calls: dict[str, int] = {}
def generate(**kwargs: object) -> str:
name = str(kwargs["filename"])
calls[name] = calls.get(name, 0) + 1
if name == "a.png" and calls[name] == 1:
headers = requests.structures.CaseInsensitiveDict({"Retry-After": "37"})
cause = backend_common.http_error(
SimpleNamespace(status_code=429, text="rate limit", headers=headers),
"OpenAI image generation",
)
raise RuntimeError(f"Failed after 4 attempts. Last error: {cause}") from cause
path = Path(str(kwargs["output_dir"])) / name
Image.new("RGB", (8, 8)).save(path, format="PNG")
return str(path)
with tempfile.TemporaryDirectory() as tmp:
manifest_path = Path(tmp) / "image_prompts.json"
items = [
{"filename": name, "prompt": "p", "aspect_ratio": "1:1", "status": "Pending",
"page_role": "local", "text_policy": "none"}
for name in ("a.png", "b.png")
]
manifest_path.write_text(json.dumps({"items": items}), encoding="utf-8")
manifest = image_gen.load_manifest(str(manifest_path))
with mock.patch.object(image_gen.time, "sleep") as sleep, \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
image_gen._run_manifest(
manifest, str(manifest_path), SimpleNamespace(generate=generate),
initial_concurrency=2, image_size="1K", output_dir=tmp, model=None,
)
self.assertIn(mock.call(37), sleep.call_args_list)
self.assertEqual([item["status"] for item in manifest["items"]], ["Generated", "Generated"])
def test_retry_after_beyond_the_cap_stops_the_run_instead_of_sleeping(self) -> None:
from unittest import mock
import requests
from PIL import Image
def generate(**kwargs: object) -> str:
name = str(kwargs["filename"])
if name == "a.png":
headers = requests.structures.CaseInsensitiveDict({"Retry-After": str(10**20)})
cause = backend_common.http_error(
SimpleNamespace(status_code=429, text="rate limit", headers=headers),
"OpenAI image generation",
)
raise RuntimeError(f"Failed after 4 attempts. Last error: {cause}") from cause
path = Path(str(kwargs["output_dir"])) / name
Image.new("RGB", (8, 8)).save(path, format="PNG")
return str(path)
with tempfile.TemporaryDirectory() as tmp:
manifest_path = Path(tmp) / "image_prompts.json"
items = [
{"filename": name, "prompt": "p", "aspect_ratio": "1:1", "status": "Pending",
"page_role": "local", "text_policy": "none"}
for name in ("a.png", "b.png", "c.png")
]
manifest_path.write_text(json.dumps({"items": items}), encoding="utf-8")
manifest = image_gen.load_manifest(str(manifest_path))
with mock.patch.object(image_gen.time, "sleep") as sleep, \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
result = image_gen._run_manifest(
manifest, str(manifest_path), SimpleNamespace(generate=generate),
initial_concurrency=2, image_size="1K", output_dir=tmp, model=None,
)
sleep.assert_not_called()
self.assertEqual(result, (1, 0, 0))
saved = json.loads(manifest_path.read_text(encoding="utf-8"))
statuses = {item["filename"]: item["status"] for item in saved["items"]}
self.assertEqual(statuses, {"a.png": "Pending", "b.png": "Generated", "c.png": "Pending"})
argv = ["image_gen.py", "--manifest", str(manifest_path), "--backend", "openai"]
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(image_gen, "_load_image_env_file"), \
mock.patch.object(image_gen, "_validate_runtime_config"), \
mock.patch.object(image_gen, "_resolve_backend",
return_value=(SimpleNamespace(generate=generate), "openai")), \
mock.patch.object(image_gen.time, "sleep"), \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as stopped:
image_gen.main()
self.assertEqual(stopped.exception.code, 1)
@@ -2,8 +2,8 @@
"sourceId": "pptx",
"repo": "https://github.com/anthropics/skills.git",
"ref": "main",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
"adapter": "claude-skill",
"sourcePath": "skills/pptx",
"syncedAt": "2026-10-10T16:00:00Z"
"syncedAt": "2026-10-29T16:00:00Z"
}
@@ -2,8 +2,8 @@
"sourceId": "xlsx",
"repo": "https://github.com/anthropics/skills.git",
"ref": "main",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
"adapter": "claude-skill",
"sourcePath": "skills/xlsx",
"syncedAt": "2026-10-10T16:00:00Z"
"syncedAt": "2026-10-29T16:00:00Z"
}