From 3d74b28f267ffcb00e1452f3d57682ba3f046a35 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Mon, 27 Apr 2026 14:33:50 +0800 Subject: [PATCH] feat(project): init --- SKILL.md | 184 ++++ agents/openai.yaml | 6 + config/base_url.txt | 2 + config/token.txt | 2 + references/api.md | 315 ++++++ .../generate_image.cpython-312.pyc | Bin 0 -> 48550 bytes .../runtime_config.cpython-312.pyc | Bin 0 -> 16254 bytes scripts/__pycache__/web_app.cpython-312.pyc | Bin 0 -> 39774 bytes scripts/export_share_package.ps1 | 114 ++ scripts/generate_image.py | 992 ++++++++++++++++++ scripts/runtime_config.py | 399 +++++++ scripts/web_app.py | 775 ++++++++++++++ 12 files changed, 2789 insertions(+) create mode 100644 SKILL.md create mode 100644 agents/openai.yaml create mode 100644 config/base_url.txt create mode 100644 config/token.txt create mode 100644 references/api.md create mode 100644 scripts/__pycache__/generate_image.cpython-312.pyc create mode 100644 scripts/__pycache__/runtime_config.cpython-312.pyc create mode 100644 scripts/__pycache__/web_app.cpython-312.pyc create mode 100644 scripts/export_share_package.ps1 create mode 100644 scripts/generate_image.py create mode 100644 scripts/runtime_config.py create mode 100644 scripts/web_app.py diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..6d30e73 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,184 @@ +--- +name: gpt-image-2-generator +description: Use this skill when the user wants to generate or edit images with GPT Image 2 through an apicodex-compatible or OpenAI-compatible image API. It uses the bundled Python tool, reads token and base URL from the current working directory's .env or config files before falling back to the skill config, supports prompt-only generation, prompt plus reference images, sync b64 responses, async task polling, and a local web UI. +--- + +# GPT Image 2 生图 + +## 何时使用 + +当用户要求“生图”“改图”“参考图生成”“多图融合”“启动本地网页生图工具”时,使用这个 skill。 + +- 优先直接运行随 skill 提供的脚本,不要临时手写 HTTP 请求。 +- 始终传入 `prompt`。 +- 如果用户提供图片、本地路径、图片 URL 或 data URI,就额外传入一个或多个 `--image-ref`。 + +## 运行约定 + +从 Codex 使用本 skill 时,命令应保持在用户项目目录执行,不要先 `cd` 到 skill 目录。 + +原因: + +- 脚本会优先读取当前执行目录的配置。 +- 这样它才能复用用户项目里的 `.env`、`config/token.txt`、`config/base_url.txt` 和输出目录设置。 + +## 配置优先级 + +按下面顺序解析配置: + +1. 进程环境变量 +2. 当前执行目录的 `.env` +3. 当前执行目录的 `config/` +4. skill 自带的 `config/` 作为兜底 + +优先识别这些键: + +- `IMAGE_API_KEY` +- `IMAGE_BASE_URL` +- `IMAGE_MODEL` +- `IMAGE_OUTPUT_DIR` +- `GPT_IMAGE_2_TOKEN` +- `GPT_IMAGE_2_BASE_URL` +- `GPT_IMAGE_2_MODEL` +- `GPT_IMAGE_2_OUTPUT_DIR` +- `APICODEX_API_KEY` +- `APICODEX_BASE_URL` + +支持的当前执行目录配置文件: + +- `config/token.txt` +- `config/base_url.txt` +- `config/model.txt` +- `config/output_dir.txt` + +`base_url` 可以写成根地址,例如 `https://apicodex.xyz`,脚本会自动补成 `/v1`;也可以直接写完整 API 前缀,例如 `https://apicodex.xyz/v1`。 + +脚本支持两种请求风格: + +- `apicodex` +- `openai` + +默认按 `base_url` 自动推断;如果需要手动指定,可在 `.env` 中设置: + +- `IMAGE_REQUEST_STYLE=apicodex` +- `IMAGE_REQUEST_STYLE=openai` + +也支持 `GPT_IMAGE_2_REQUEST_STYLE` 和 `IMAGE_API_STYLE`。 + +绝对不要在聊天中输出用户的完整 token。 + +## 标准流程 + +1. 先直接运行脚本。 +2. 如果输出包含 `No usable token found`,告诉用户需要 apicodex 兼容网关 token。 +3. 如果用户还没有配置文件,优先提供 `.env` 模板,或直接帮用户初始化模板文件。 +4. 用户提供 token 后,运行 `--save-token` 保存到当前执行目录的 `config/token.txt`。 +5. 如果用户还需要自定义网关地址,运行 `--save-base-url` 保存到当前执行目录的 `config/base_url.txt`。 +6. 重新执行原命令。 + +## 推荐的 .env + +推荐优先用当前项目目录的 `.env`,最小配置如下: + +```dotenv +IMAGE_API_KEY=your_apicodex_token_here +IMAGE_BASE_URL=https://apicodex.xyz/v1 +IMAGE_MODEL=gpt-image-2 +IMAGE_REQUEST_STYLE=auto +IMAGE_OUTPUT_DIR=./output +``` + +如果当前网关更接近 OpenAI-compatible,可以继续沿用项目已有的: + +```dotenv +IMAGE_API_KEY=your_gateway_token_here +IMAGE_BASE_URL=https://your-gateway.example/v1 +IMAGE_MODEL=gpt-image-2 +IMAGE_REQUEST_STYLE=openai +IMAGE_OUTPUT_DIR=./output +``` + +`--size` 同时支持: + +- 比例,例如 `2:3`、`16:9` +- 像素尺寸,例如 `1024x1536`、`2048x3584` + +当请求风格是 `openai` 且你传入比例尺寸时,脚本会自动换算成常见像素尺寸再发送。 + +如果用户问“`.env` 怎么写”,直接给上面的模板;也可以直接运行下面两个命令。 + +## 常用命令 + +先确认脚本实际会读取哪份配置: + +```bash +python3 ~/.codex/skills/gpt-image-2-generator/scripts/generate_image.py --show-config +``` + +直接打印 `.env` 模板: + +```bash +python3 ~/.codex/skills/gpt-image-2-generator/scripts/generate_image.py --print-env-template +``` + +在当前执行目录写出 `.env.example`: + +```bash +python3 ~/.codex/skills/gpt-image-2-generator/scripts/generate_image.py --init-config +``` + +保存 token 到当前执行目录: + +```bash +python3 ~/.codex/skills/gpt-image-2-generator/scripts/generate_image.py --save-token "" +``` + +保存 base URL 到当前执行目录: + +```bash +python3 ~/.codex/skills/gpt-image-2-generator/scripts/generate_image.py --save-base-url "https://apicodex.xyz/v1" +``` + +纯文本生图: + +```bash +python3 ~/.codex/skills/gpt-image-2-generator/scripts/generate_image.py --prompt "一只橘猫坐在窗台看夕阳,水彩画风格" --size 16:9 +``` + +显式走 OpenAI-compatible 风格: + +```bash +python3 ~/.codex/skills/gpt-image-2-generator/scripts/generate_image.py --prompt "电影感人像" --size 1024x1536 --request-style openai +``` + +带参考图: + +```bash +python3 ~/.codex/skills/gpt-image-2-generator/scripts/generate_image.py --prompt "把参考图融合成复古海报" --size 4:3 --image-ref ./photo-a.png --image-ref https://example.com/photo-b.jpg +``` + +Windows 上优先用: + +```powershell +py -3 %USERPROFILE%\.codex\skills\gpt-image-2-generator\scripts\generate_image.py --show-config +``` + +## 网页工具 + +- `python3 ~/.codex/skills/gpt-image-2-generator/scripts/web_app.py --open` +- 默认地址 `http://127.0.0.1:7862/` +- 网页工具与 CLI 使用同一套执行目录配置解析逻辑 + +## 返回模式 + +脚本同时兼容: + +- 同步 `data[*].b64_json` +- 同步 URL / data URI 返回 +- 异步 `task_id` / `data.id` 轮询 + +## 参考资料 + +需要接口细节、响应样例、比例列表或轮询样例时,再读取: + +- `references/api.md` diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..b185736 --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "GPT Image 2 生图" + short_description: "用当前项目目录的 .env / config 配置调用 GPT Image 2 生成、编辑和融合图片。" + default_prompt: "Use $gpt-image-2-generator to generate or edit an image with the current project's IMAGE_API_KEY / IMAGE_BASE_URL settings and save the output locally." +policy: + allow_implicit_invocation: true diff --git a/config/base_url.txt b/config/base_url.txt new file mode 100644 index 0000000..1d165f5 --- /dev/null +++ b/config/base_url.txt @@ -0,0 +1,2 @@ +# API base URL. Host root and full /v1 path are both accepted. +https://apicodex.xyz/v1 diff --git a/config/token.txt b/config/token.txt new file mode 100644 index 0000000..114f928 --- /dev/null +++ b/config/token.txt @@ -0,0 +1,2 @@ +# 在下一行填入 apicodex.xyz 的 API token,不要带 Bearer 前缀。 +sk-65ae5ac3b46fdbfefc3a133ceb731eac0f9a4ee12a4f13989ff29272811ebe43 diff --git a/references/api.md b/references/api.md new file mode 100644 index 0000000..90525ce --- /dev/null +++ b/references/api.md @@ -0,0 +1,315 @@ +# GPT Image 2 示例文档 + +本文档基于用户站 `https://apicodex.xyz` 的接入方式整理,包含: + +- 文生图示例 +- 带参考图示例 +- 支持的比例列表 +- 实测返回结构 +- 同步返回与异步轮询的兼容写法 + +## 接口地址 + +```text +POST https://apicodex.xyz/v1/images/generations +``` + +## 鉴权 + +请求头使用 Bearer Token: + +```python +headers = { + "Authorization": "Bearer ", + "Content-Type": "application/json", +} +``` + +## 支持的比例 + +当前整理到的 `size` 比例共有 13 种: + +- `1:1` +- `16:9` +- `9:16` +- `4:3` +- `3:4` +- `3:2` +- `2:3` +- `5:4` +- `4:5` +- `2:1` +- `1:2` +- `21:9` +- `9:21` + +说明: + +- 上述 13 种比例按你提供的接口说明整理 +- 本文档里实际请求验证过的是 `16:9` + +建议只通过 `size` 传比例,不要在 `prompt` 里重复写比例。 + +## 文生图示例 + +```python +import requests + +url = "https://apicodex.xyz/v1/images/generations" + +payload = { + "model": "gpt-image-2", + "prompt": "一只橘猫坐在窗台上看夕阳,水彩画风格", + "n": 1, + "size": "16:9", +} + +headers = { + "Authorization": "Bearer ", + "Content-Type": "application/json", +} + +response = requests.post(url, json=payload, headers=headers, timeout=300) +print(response.json()) +``` + +## 带参考图示例 + +注意:不要把参考图放进 `/v1/images/generations` 的 `image_urls` 字段。当前实测该字段会被忽略,返回 usage 中 `input_tokens_details.image_tokens` 仍为 `0`。带图请求应走: + +```text +POST https://apicodex.xyz/v1/images/edits +``` + +### 参考图为 URL + +```python +import requests + +image_url = "https://example.com/photo.jpg" +image_resp = requests.get(image_url, timeout=60) +image_resp.raise_for_status() + +url = "https://apicodex.xyz/v1/images/edits" +data = { + "model": "gpt-image-2", + "prompt": "把这张照片变成水彩画风格", + "size": "1:1", +} +files = [ + ("image[]", ("photo.jpg", image_resp.content, image_resp.headers.get("Content-Type") or "image/jpeg")), +] + +headers = { + "Authorization": "Bearer ", +} + +response = requests.post(url, data=data, files=files, headers=headers, timeout=300) +print(response.json()) +``` + +### 参考图为 base64 + +```python +import requests +import base64 + +url = "https://apicodex.xyz/v1/images/edits" + +data = { + "model": "gpt-image-2", + "prompt": "把这张照片变成水彩画风格", + "size": "1:1", +} +image_bytes = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAA...") +files = [ + ("image[]", ("reference.png", image_bytes, "image/png")), +] + +headers = { + "Authorization": "Bearer ", +} + +response = requests.post(url, data=data, files=files, headers=headers, timeout=300) +print(response.json()) +``` + +### 多张参考图 + +```python +import requests + +url = "https://apicodex.xyz/v1/images/edits" + +data = { + "model": "gpt-image-2", + "prompt": "把这两张照片融合成一张海报", + "size": "4:3", +} +files = [ + ("image[]", ("photo-a.jpg", open("photo-a.jpg", "rb"), "image/jpeg")), + ("image[]", ("photo-b.png", open("photo-b.png", "rb"), "image/png")), +] + +headers = { + "Authorization": "Bearer ", +} + +try: + response = requests.post(url, data=data, files=files, headers=headers, timeout=300) + print(response.json()) +finally: + for _, file_tuple in files: + file_tuple[1].close() +``` + +## 实测结果 + +本仓库在 `2026-04-22`(America/Los_Angeles)对以下请求做了真实测试: + +```json +{ + "model": "gpt-image-2", + "prompt": "一只橘猫坐在窗台上看夕阳,水彩画风格", + "n": 1, + "size": "16:9" +} +``` + +实测结论: + +- 请求返回 `HTTP 200` +- `size: "16:9"` 被接口接受 +- 当前实测返回是“同步结果”,不是 `task_id` +- 顶层字段为 `created` 和 `data` +- `data[0]` 中包含 `b64_json` 和 `revised_prompt` +- 本次实测的 `revised_prompt` 为 `黄昏里的橘猫窗台景` +- 本次解码后的样例图尺寸为 `1196x1315`,没有严格匹配 `16:9` + +实测返回结构可概括为: + +```json +{ + "created": 1776919131, + "data": [ + { + "b64_json": "", + "revised_prompt": "黄昏里的橘猫窗台景" + } + ] +} +``` + +本次测试产物已保存到仓库临时目录: + +- `tmp/imagegen/gpt-image-2-16-9-response.json` +- `tmp/imagegen/gpt-image-2-16-9-sample.png` + +## 同步返回时的取图方式 + +如果接口直接返回 `b64_json`,可以这样保存图片: + +```python +import base64 +import requests + +url = "https://apicodex.xyz/v1/images/generations" + +payload = { + "model": "gpt-image-2", + "prompt": "一只橘猫坐在窗台上看夕阳,水彩画风格", + "n": 1, + "size": "16:9", +} + +headers = { + "Authorization": "Bearer ", + "Content-Type": "application/json", +} + +response = requests.post(url, json=payload, headers=headers, timeout=300) +data = response.json() + +img_b64 = data["data"][0]["b64_json"] +with open("output.png", "wb") as f: + f.write(base64.b64decode(img_b64)) +``` + +## 异步轮询写法 + +你提供的说明里写的是“提交后返回 `task_id`,再轮询 `/v1/tasks/{task_id}`”。如果后续该上游切回异步模式,可以用下面这种兼容写法: + +```python +import base64 +import time +import requests + +url = "https://apicodex.xyz/v1/images/generations" +headers = { + "Authorization": "Bearer ", + "Content-Type": "application/json", +} +payload = { + "model": "gpt-image-2", + "prompt": "a corgi astronaut on the moon, cinematic, 8k", + "size": "16:9", + "n": 1, +} + +resp = requests.post(url, json=payload, headers=headers, timeout=300) +resp.raise_for_status() +data = resp.json() + +# 情况 1:直接同步返回 base64 +if isinstance(data, dict) and data.get("data") and data["data"][0].get("b64_json"): + with open("output.png", "wb") as f: + f.write(base64.b64decode(data["data"][0]["b64_json"])) + print("saved output.png") + +# 情况 2:返回 task_id,需要轮询 +elif isinstance(data, dict) and data.get("data", {}).get("id"): + task_id = data["data"]["id"] + task_url = f"https://apicodex.xyz/v1/tasks/{task_id}" + + time.sleep(10) + while True: + task_resp = requests.get(task_url, headers=headers, timeout=60) + task_resp.raise_for_status() + task = task_resp.json() + + status = task["data"]["status"] + if status == "completed": + print(task["data"]["result"]["images"][0]["url"][0]) + break + if status == "failed": + raise RuntimeError(task["data"]["error"]["message"]) + + time.sleep(5) + +else: + raise RuntimeError(f"unexpected response: {data}") +``` + +## 如果是异步结果,取图字段 + +如果接口返回的是任务模式,则最终图片 URL 取值为: + +```text +data.result.images[0].url[0] +``` + +任务状态说明: + +- `pending`: 已提交或排队中 +- `processing`: 上游处理中 +- `completed`: 成功 +- `failed`: 失败 + +## 注意事项 + +- 单张图接口耗时可能较长,建议客户端超时至少设到 `300s` +- 本次实测中,`size: "16:9"` 虽然被接口接受,但返回图片尺寸并未严格命中 `16:9`;如果你的业务依赖固定画幅,建议在下载后自行校验并按需裁切 +- 如果使用异步模式,首次轮询建议在提交后等待 `10~20` 秒 +- 轮询间隔建议 `3~5` 秒,不要毫秒级高频轮询 +- 建议失败时做有限次重试,尤其是 TLS 或上游网络抖动场景 +- 如果服务端返回的是 `b64_json`,说明当前是同步模式,不需要再调 `/v1/tasks/{task_id}` diff --git a/scripts/__pycache__/generate_image.cpython-312.pyc b/scripts/__pycache__/generate_image.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..174b9b8d1d5b36cfd7f59db9caa5ed75de755e6c GIT binary patch literal 48550 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 Jovd;1_YdObl~n)$ literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/runtime_config.cpython-312.pyc b/scripts/__pycache__/runtime_config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..457b58ab5f0302d1123455d336b208161b76725a GIT binary patch literal 16254 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 OjRovkz(#A5=YIg!_xk_< literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/web_app.cpython-312.pyc b/scripts/__pycache__/web_app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9be8ace8bbeae51c0e778d4f0e48b72623b89fab GIT binary patch literal 39774 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 H3fKP&O1jLq literal 0 HcmV?d00001 diff --git a/scripts/export_share_package.ps1 b/scripts/export_share_package.ps1 new file mode 100644 index 0000000..c3f19ed --- /dev/null +++ b/scripts/export_share_package.ps1 @@ -0,0 +1,114 @@ +param( + [string]$OutputDir, + [string]$PackageName +) + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectRoot = (Resolve-Path (Join-Path $scriptDir '..')).Path +$projectName = Split-Path $projectRoot -Leaf +$shareBaseName = "gpt-image-2-generator" +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' + +if ([string]::IsNullOrWhiteSpace($OutputDir)) { + $OutputDir = Join-Path $projectRoot 'dist' +} +$OutputDir = [System.IO.Path]::GetFullPath($OutputDir) + +if ([string]::IsNullOrWhiteSpace($PackageName)) { + $PackageName = "$shareBaseName-share-$timestamp" +} + +$zipPath = Join-Path $OutputDir ($PackageName + '.zip') +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("$shareBaseName-share-export-" + [System.Guid]::NewGuid().ToString('N')) +$stageDir = Join-Path $tempRoot $PackageName + +$excludedTopDirs = @('outputs', 'dist', '.git', '.idea', '.vscode') +$excludedFileNames = @('history.jsonl') +$excludedExtensions = @('.pyc', '.pyo') + +function Should-SkipFile { + param([string]$RelativePath) + + $normalized = $RelativePath -replace '/', '\\' + $parts = $normalized.Split('\\', [System.StringSplitOptions]::RemoveEmptyEntries) + if ($parts.Count -eq 0) { + return $true + } + + if ($excludedTopDirs -contains $parts[0]) { + return $true + } + if ($parts -contains '__pycache__') { + return $true + } + if ($normalized -ieq 'config\token.txt' -or $normalized -ieq 'config\base_url.txt') { + return $true + } + + $fileName = [System.IO.Path]::GetFileName($normalized) + if ($excludedFileNames -contains $fileName) { + return $true + } + + $extension = [System.IO.Path]::GetExtension($normalized) + if ($excludedExtensions -contains $extension) { + return $true + } + + return $false +} + +function Ensure-ParentDirectory { + param([string]$FilePath) + + $parent = Split-Path -Parent $FilePath + if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } +} + +try { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + New-Item -ItemType Directory -Path $stageDir -Force | Out-Null + + $files = Get-ChildItem -Path $projectRoot -Recurse -File + foreach ($file in $files) { + $relativePath = $file.FullName.Substring($projectRoot.Length).TrimStart('\') + if (Should-SkipFile -RelativePath $relativePath) { + continue + } + + $destination = Join-Path $stageDir $relativePath + Ensure-ParentDirectory -FilePath $destination + Copy-Item -Path $file.FullName -Destination $destination -Force + } + + $placeholderTokenPath = Join-Path $stageDir 'config\token.txt' + Ensure-ParentDirectory -FilePath $placeholderTokenPath + @( + '# 在下一行填入 apicodex.xyz 的 API token,不要带 Bearer 前缀。', + 'YOUR_API_TOKEN_HERE' + ) | Set-Content -Path $placeholderTokenPath -Encoding UTF8 + + $placeholderBaseUrlPath = Join-Path $stageDir 'config\base_url.txt' + Ensure-ParentDirectory -FilePath $placeholderBaseUrlPath + @( + '# 在下一行填入 API base URL,可写根地址或完整 /v1 地址。', + 'https://apicodex.xyz/v1' + ) | Set-Content -Path $placeholderBaseUrlPath -Encoding UTF8 + + if (Test-Path $zipPath) { + Remove-Item -Path $zipPath -Force + } + Compress-Archive -Path $stageDir -DestinationPath $zipPath -Force + + Write-Host '可分享版导出完成:' $zipPath + Write-Host '已自动排除:config\token.txt(真实内容)、config\base_url.txt(真实内容)、outputs、history.jsonl、__pycache__、*.pyc、dist。' +} +finally { + if (Test-Path $tempRoot) { + Remove-Item -Path $tempRoot -Recurse -Force + } +} diff --git a/scripts/generate_image.py b/scripts/generate_image.py new file mode 100644 index 0000000..f7d2964 --- /dev/null +++ b/scripts/generate_image.py @@ -0,0 +1,992 @@ +from __future__ import annotations + +import argparse +import base64 +import binascii +from dataclasses import dataclass +import json +import mimetypes +import os +import re +import socket +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any + +from runtime_config import ( + ConfigError, + DEFAULT_BASE_URL, + DEFAULT_MODEL, + ROOT_DIR, + build_api_url, + infer_request_style, + load_runtime_config, + normalize_base_url, + normalize_token, + parse_env_file, + runtime_config_summary, + save_base_url as save_runtime_base_url, + save_token as save_runtime_token, +) + + +SUPPORTED_ASPECT_RATIOS = ( + "1:1", + "16:9", + "9:16", + "4:3", + "3:4", + "3:2", + "2:3", + "5:4", + "4:5", + "2:1", + "1:2", + "21:9", + "9:21", +) +PIXEL_SIZE_PATTERN = re.compile(r"^(?P\d{2,5})x(?P\d{2,5})$") +OPENAI_RATIO_SIZE_MAP = { + "1:1": "1024x1024", + "16:9": "1536x864", + "9:16": "864x1536", + "4:3": "1536x1152", + "3:4": "1152x1536", + "3:2": "1536x1024", + "2:3": "1024x1536", + "5:4": "1280x1024", + "4:5": "1024x1280", + "2:1": "1536x768", + "1:2": "768x1536", + "21:9": "1680x720", + "9:21": "720x1680", +} +SIZE_ENV_VARS = ( + "GPT_IMAGE_2_SIZE", + "IMAGE_SIZE", + "OPENAI_IMAGE_SIZE", +) +EDIT_SIZE_ENV_VARS = ( + "GPT_IMAGE_2_EDIT_SIZE", + "IMAGE_EDIT_SIZE", + "OPENAI_IMAGE_EDIT_SIZE", +) +EDIT_QUALITY_ENV_VARS = ( + "GPT_IMAGE_2_EDIT_QUALITY", + "IMAGE_EDIT_QUALITY", + "OPENAI_IMAGE_EDIT_QUALITY", +) +EDIT_BACKGROUND_ENV_VARS = ( + "GPT_IMAGE_2_EDIT_BACKGROUND", + "IMAGE_EDIT_BACKGROUND", + "OPENAI_IMAGE_EDIT_BACKGROUND", +) +EDIT_OUTPUT_FORMAT_ENV_VARS = ( + "GPT_IMAGE_2_EDIT_OUTPUT_FORMAT", + "IMAGE_EDIT_OUTPUT_FORMAT", + "OPENAI_IMAGE_EDIT_OUTPUT_FORMAT", +) +EDIT_INPUT_FIDELITY_ENV_VARS = ( + "GPT_IMAGE_2_EDIT_INPUT_FIDELITY", + "IMAGE_EDIT_INPUT_FIDELITY", + "OPENAI_IMAGE_EDIT_INPUT_FIDELITY", +) +REQUEST_STYLE_CHOICES = ("auto", "apicodex", "openai") +REQUEST_RETRY_ATTEMPTS = max(1, int(os.environ.get("IMAGE_RETRY_ATTEMPTS", "2"))) +REQUEST_RETRY_DELAY_SECONDS = max(0.0, float(os.environ.get("IMAGE_RETRY_DELAY_SECONDS", "2"))) + + +class ImageGenerationError(RuntimeError): + pass + + +class ImageRequestError(ImageGenerationError): + def __init__(self, message: str, *, status: int | None = None, timeout: bool = False): + super().__init__(message) + self.status = status + self.timeout = timeout + + +@dataclass(frozen=True) +class ImageInputFile: + filename: str + mime_type: str + data: bytes + + +@dataclass(frozen=True) +class RequestTarget: + name: str + base_url: str + token: str + request_style: str + source: str + + +def normalize_size_value(raw_size: str) -> str: + value = raw_size.strip().lower().replace(" ", "") + match = PIXEL_SIZE_PATTERN.fullmatch(value) + if match: + width = int(match.group("width")) + height = int(match.group("height")) + if width < 64 or height < 64: + raise ImageGenerationError(f"Unsupported size: {raw_size}") + return f"{width}x{height}" + return value + + +def is_supported_size(size: str) -> bool: + return size in SUPPORTED_ASPECT_RATIOS or PIXEL_SIZE_PATTERN.fullmatch(size) is not None + + +def resolve_api_size(requested_size: str, request_style: str) -> str: + if request_style == "openai" and requested_size in OPENAI_RATIO_SIZE_MAP: + return OPENAI_RATIO_SIZE_MAP[requested_size] + return requested_size + + +def lookup_env_setting(env_map: dict[str, str], env_vars: tuple[str, ...]) -> str | None: + for env_name in env_vars: + value = os.environ.get(env_name, "").strip() + if value: + return value + for env_name in env_vars: + value = env_map.get(env_name, "").strip() + if value: + return value + return None + + +def resolve_requested_size( + size: str | None, + request_style: str, + has_refs: bool, + env_map: dict[str, str], +) -> tuple[str, str]: + if size and size.strip(): + requested_size = normalize_size_value(size) + else: + env_vars = EDIT_SIZE_ENV_VARS if has_refs else SIZE_ENV_VARS + env_size = lookup_env_setting(env_map, env_vars) + requested_size = normalize_size_value(env_size) if env_size else "1:1" + + if not is_supported_size(requested_size): + raise ImageGenerationError(f"Unsupported size: {requested_size}") + return requested_size, resolve_api_size(requested_size, request_style) + + +def resolve_optional_edit_field( + explicit_value: str | None, + env_map: dict[str, str], + env_vars: tuple[str, ...], +) -> str | None: + if explicit_value is not None and explicit_value.strip(): + return explicit_value.strip() + value = lookup_env_setting(env_map, env_vars) + return value.strip() if value else None + + +def build_env_template() -> str: + return "\n".join( + [ + "# Required: apicodex-compatible image API token", + "IMAGE_API_KEY=your_apicodex_token_here", + "", + "# Optional: host root or full /v1 path are both accepted", + f"IMAGE_BASE_URL={DEFAULT_BASE_URL}/v1", + "", + "# Optional: defaults shown below", + f"IMAGE_MODEL={DEFAULT_MODEL}", + "IMAGE_REQUEST_STYLE=auto", + "IMAGE_OUTPUT_DIR=./output", + "", + ] + ) + + +def write_env_template( + *, + env_file: str | Path | None = None, + overwrite: bool = False, +) -> Path: + execution_root = Path.cwd() + if env_file: + target_path = Path(env_file).expanduser() + if not target_path.is_absolute(): + target_path = (execution_root / target_path).resolve() + else: + target_path = execution_root / ".env.example" + + if target_path.exists() and not overwrite: + raise ConfigError(f"Config template already exists: {target_path}. Use --force to overwrite.") + + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(build_env_template(), encoding="utf-8") + return target_path + +def read_json_response(request: urllib.request.Request, timeout: int) -> dict[str, Any]: + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise ImageRequestError(f"HTTP {error.code}: {body}", status=error.code) from error + except urllib.error.URLError as error: + if is_timeout_error(error): + raise ImageRequestError(f"Timeout: {error}", timeout=True) from error + raise ImageRequestError(f"Network error: {error}") from error + except (TimeoutError, socket.timeout) as error: + raise ImageRequestError(f"Timeout: {error}", timeout=True) from error + + if not body: + return {} + try: + return json.loads(body) + except json.JSONDecodeError as error: + raise ImageGenerationError(f"API returned non-JSON response: {body[:500]}") from error + + +def request_json(method: str, url: str, token: str, payload: dict[str, Any] | None = None, timeout: int = 300) -> dict[str, Any]: + data = None + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "gpt-image-2-generator/1.0", + } + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + return read_json_response(request, timeout) + + +def request_multipart_json( + url: str, + token: str, + fields: dict[str, Any], + files: list[ImageInputFile], + image_field_name: str = "image[]", + timeout: int = 300, +) -> dict[str, Any]: + boundary = f"----gpt-image-2-{uuid.uuid4().hex}" + body = bytearray() + + def add(value: str | bytes) -> None: + if isinstance(value, str): + body.extend(value.encode("utf-8")) + else: + body.extend(value) + + for name, value in fields.items(): + add(f"--{boundary}\r\n") + add(f'Content-Disposition: form-data; name="{name}"\r\n\r\n') + add(str(value)) + add("\r\n") + + for image_file in files: + add(f"--{boundary}\r\n") + add( + f'Content-Disposition: form-data; name="{image_field_name}"; ' + f'filename="{image_file.filename}"\r\n' + ) + add(f"Content-Type: {image_file.mime_type}\r\n\r\n") + add(image_file.data) + add("\r\n") + + add(f"--{boundary}--\r\n") + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(body)), + "User-Agent": "gpt-image-2-generator/1.0", + } + request = urllib.request.Request(url, data=bytes(body), headers=headers, method="POST") + return read_json_response(request, timeout) + + +def is_timeout_error(error: urllib.error.URLError) -> bool: + reason = getattr(error, "reason", None) + if isinstance(reason, (TimeoutError, socket.timeout)): + return True + return "timed out" in str(reason).lower() or "timeout" in str(error).lower() + + +def is_failover_eligible_error(error: BaseException) -> bool: + if isinstance(error, ImageRequestError): + if error.status in {502, 503, 504, 524}: + return True + if error.timeout: + return True + message = str(error).lower() + return "timed out" in message or "timeout" in message + + +def read_first_config_line(path: Path) -> str: + if not path.exists(): + return "" + for raw_line in path.read_text(encoding="utf-8-sig").splitlines(): + line = raw_line.strip() + if line and not line.startswith("#"): + return line + return "" + + +def load_skill_fallback_target(runtime_config: Any) -> RequestTarget | None: + skill_config_dir = ROOT_DIR / "config" + base_url = normalize_base_url(read_first_config_line(skill_config_dir / "base_url.txt")) + token = normalize_token(read_first_config_line(skill_config_dir / "token.txt")) + request_style_raw = read_first_config_line(skill_config_dir / "request_style.txt").strip().lower() + + if not base_url or not token: + return None + + request_style = request_style_raw if request_style_raw in {"apicodex", "openai"} else infer_request_style(base_url) + primary_token = normalize_token(runtime_config.token or "") + if runtime_config.base_url == base_url and primary_token == token: + return None + + return RequestTarget( + name="skill-fallback", + base_url=base_url, + token=token, + request_style=request_style, + source=str(skill_config_dir), + ) + + +def build_request_targets(runtime_config: Any, request_style: str) -> list[RequestTarget]: + targets = [ + RequestTarget( + name="primary", + base_url=runtime_config.base_url, + token=runtime_config.token or "", + request_style=request_style, + source=runtime_config.base_url_source, + ) + ] + fallback_target = load_skill_fallback_target(runtime_config) + if fallback_target is not None: + targets.append(fallback_target) + return targets + + +def format_request_error(error: BaseException) -> str: + if isinstance(error, ImageRequestError): + if error.status is not None: + return f"HTTP {error.status}" + if error.timeout: + return "timeout" + return error.__class__.__name__ + + +def should_retry_request(error: BaseException) -> bool: + if isinstance(error, ImageRequestError): + if error.status in {429, 500, 502, 503, 504, 524}: + return True + if error.timeout: + return True + message = str(error).lower() + return "timed out" in message or "timeout" in message + + +def submit_request_for_target( + target: RequestTarget, + *, + runtime_config: Any, + env_map: dict[str, str], + prompt: str, + size: str | None, + n: int, + has_refs: bool, + image_files: list[ImageInputFile], + timeout: int, + quality: str | None, + background: str | None, + output_format: str | None, + input_fidelity: str | None, +) -> tuple[dict[str, Any], str, str, str]: + generations_api_url = build_api_url(target.base_url, "images/generations") + edits_api_url = build_api_url(target.base_url, "images/edits") + payload: dict[str, Any] = { + "model": runtime_config.model, + "prompt": prompt, + } + requested_size, api_size = resolve_requested_size(size, target.request_style, has_refs, env_map) + payload["size"] = api_size + if target.request_style == "apicodex" or n != 1: + payload["n"] = n + + if has_refs: + edit_quality = resolve_optional_edit_field(quality, env_map, EDIT_QUALITY_ENV_VARS) + edit_background = resolve_optional_edit_field(background, env_map, EDIT_BACKGROUND_ENV_VARS) + edit_output_format = resolve_optional_edit_field(output_format, env_map, EDIT_OUTPUT_FORMAT_ENV_VARS) + edit_input_fidelity = resolve_optional_edit_field(input_fidelity, env_map, EDIT_INPUT_FIDELITY_ENV_VARS) + if edit_quality: + payload["quality"] = edit_quality + if edit_background: + payload["background"] = edit_background + if edit_output_format: + payload["output_format"] = edit_output_format + if edit_input_fidelity: + payload["input_fidelity"] = edit_input_fidelity + image_field_name = "image[]" if target.request_style == "apicodex" or len(image_files) != 1 else "image" + response = request_multipart_json( + edits_api_url, + target.token, + payload, + image_files, + image_field_name=image_field_name, + timeout=timeout, + ) + else: + response = request_json("POST", generations_api_url, target.token, payload, timeout=timeout) + + return response, target.request_style, requested_size, api_size + + +def safe_filename(filename: str, fallback: str, mime_type: str) -> str: + candidate = re.sub(r"[^A-Za-z0-9._-]+", "_", Path(filename).name).strip("._") + if not candidate: + candidate = fallback + if not Path(candidate).suffix: + candidate += mimetypes.guess_extension(mime_type) or ".png" + return candidate + + +def read_data_uri_image(value: str, index: int) -> ImageInputFile: + match = re.match(r"^data:(?Pimage/[^;,]+)(?P(?:;[^,]+)*),(?P.+)$", value, flags=re.DOTALL) + if not match or ";base64" not in match.group("params").lower(): + raise ImageGenerationError("Reference image data URI must be a base64 image") + + mime_type = match.group("mime") + try: + data = base64.b64decode(match.group("data"), validate=True) + except (ValueError, binascii.Error) as error: + raise ImageGenerationError("Reference image data URI contains invalid base64") from error + return ImageInputFile( + filename=safe_filename("", f"reference-{index}", mime_type), + mime_type=mime_type, + data=data, + ) + + +def download_reference_image(url: str, index: int, timeout: int) -> ImageInputFile: + request = urllib.request.Request(url, headers={"User-Agent": "gpt-image-2-generator/1.0"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + data = response.read() + content_type = response.headers.get("Content-Type") + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise ImageGenerationError(f"Reference image URL returned HTTP {error.code}: {body[:500]}") from error + except urllib.error.URLError as error: + raise ImageGenerationError(f"Reference image URL could not be downloaded: {error}") from error + + mime_type = (content_type or "").split(";")[0].strip().lower() + if not mime_type.startswith("image/"): + mime_type = mimetypes.guess_type(urllib.parse.urlparse(url).path)[0] or "image/png" + filename = safe_filename(Path(urllib.parse.urlparse(url).path).name, f"reference-{index}", mime_type) + return ImageInputFile(filename=filename, mime_type=mime_type, data=data) + + +def read_local_reference_image(image_ref: str, index: int) -> ImageInputFile: + value = image_ref.strip() + path = Path(value).expanduser() + if not path.is_absolute(): + cwd_path = Path.cwd() / path + root_path = ROOT_DIR / path + path = cwd_path if cwd_path.exists() else root_path + + if not path.exists(): + raise ImageGenerationError(f"Reference image not found: {image_ref}") + + mime_type = mimetypes.guess_type(str(path))[0] or "image/png" + return ImageInputFile( + filename=safe_filename(path.name, f"reference-{index}", mime_type), + mime_type=mime_type, + data=path.read_bytes(), + ) + + +def image_ref_to_input_file(image_ref: str, index: int, timeout: int) -> ImageInputFile: + value = image_ref.strip() + lowered = value.lower() + if lowered.startswith("data:"): + return read_data_uri_image(value, index) + if lowered.startswith(("http://", "https://")): + return download_reference_image(value, index, timeout) + return read_local_reference_image(value, index) + + +def save_json(path: Path, data: dict[str, Any]) -> None: + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def unique_path(path: Path) -> Path: + if not path.exists(): + return path + for index in range(2, 1000): + candidate = path.with_name(f"{path.stem}-{index}{path.suffix}") + if not candidate.exists(): + return candidate + raise ImageGenerationError(f"Cannot create unique output path for {path}") + + +def save_b64_image(image_b64: str, output_dir: Path, stem: str, index: int, mime: str | None = None) -> Path: + extension = mimetypes.guess_extension(mime or "image/png") or ".png" + output_path = unique_path(output_dir / f"{stem}-{index}{extension}") + output_path.write_bytes(base64.b64decode(image_b64)) + return output_path + + +def save_data_uri(data_uri: str, output_dir: Path, stem: str, index: int) -> Path: + match = re.match(r"^data:(?P[^;]+);base64,(?P.+)$", data_uri, flags=re.DOTALL) + if not match: + raise ImageGenerationError("Unsupported data URI image format") + return save_b64_image(match.group("data"), output_dir, stem, index, match.group("mime")) + + +def extract_b64_items(data: Any) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + + def visit(value: Any) -> None: + if isinstance(value, dict): + if isinstance(value.get("b64_json"), str): + items.append(value) + return + for child in value.values(): + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + + visit(data) + return items + + +def extract_image_values(data: Any) -> list[str]: + values: list[str] = [] + + def add(value: Any) -> None: + if isinstance(value, str) and value.startswith(("http://", "https://", "data:image/")): + values.append(value) + elif isinstance(value, list): + for item in value: + add(item) + + candidates = [] + if isinstance(data, dict): + candidates.append(data.get("url")) + nested_data = data.get("data") + if isinstance(nested_data, list): + candidates.extend(item.get("url") for item in nested_data if isinstance(item, dict)) + if isinstance(nested_data, dict): + result = nested_data.get("result") + if isinstance(result, dict): + images = result.get("images") + if isinstance(images, list): + candidates.extend(item.get("url") for item in images if isinstance(item, dict)) + candidates.append(result.get("url")) + result = data.get("result") + if isinstance(result, dict): + images = result.get("images") + if isinstance(images, list): + candidates.extend(item.get("url") for item in images if isinstance(item, dict)) + + for candidate in candidates: + add(candidate) + return values + + +def extract_task_id(data: dict[str, Any]) -> str | None: + for key in ("task_id", "id"): + value = data.get(key) + if isinstance(value, str) and value: + return value + + nested_data = data.get("data") + if isinstance(nested_data, dict): + for key in ("task_id", "id"): + value = nested_data.get(key) + if isinstance(value, str) and value: + return value + return None + + +def get_task_status(task: dict[str, Any]) -> str | None: + nested_data = task.get("data") + if isinstance(nested_data, dict) and isinstance(nested_data.get("status"), str): + return nested_data["status"] + status = task.get("status") + return status if isinstance(status, str) else None + + +def get_task_error(task: dict[str, Any]) -> str: + nested_data = task.get("data") + error = nested_data.get("error") if isinstance(nested_data, dict) else task.get("error") + if isinstance(error, dict): + return str(error.get("message") or error) + if error: + return str(error) + return "task failed" + + +def guess_download_extension(url: str, content_type: str | None) -> str: + suffix = Path(urllib.parse.urlparse(url).path).suffix.lower() + if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: + return suffix + if content_type: + return mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png" + return ".png" + + +def download_image(url: str, output_dir: Path, stem: str, index: int, timeout: int) -> Path: + request = urllib.request.Request(url, headers={"User-Agent": "gpt-image-2-generator/1.0"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + content_type = response.headers.get("Content-Type") + extension = guess_download_extension(url, content_type) + output_path = unique_path(output_dir / f"{stem}-{index}{extension}") + output_path.write_bytes(response.read()) + return output_path + + +def save_image_values(values: list[str], output_dir: Path, stem: str, timeout: int) -> list[Path]: + paths: list[Path] = [] + for index, value in enumerate(values, start=1): + if value.startswith("data:image/"): + paths.append(save_data_uri(value, output_dir, stem, index)) + else: + paths.append(download_image(value, output_dir, stem, index, timeout)) + return paths + + +def generate_image( + prompt: str, + size: str | None = None, + image_refs: list[str] | None = None, + n: int = 1, + output_dir: str | Path | None = None, + timeout: int = 300, + initial_poll_delay: float = 10, + poll_interval: float = 5, + max_wait: float = 300, + config_dir: str | Path | None = None, + env_file: str | Path | None = None, + base_url: str | None = None, + model: str | None = None, + request_style: str | None = None, + quality: str | None = None, + background: str | None = None, + output_format: str | None = None, + input_fidelity: str | None = None, +) -> dict[str, Any]: + prompt = prompt.strip() + if not prompt: + raise ImageGenerationError("Prompt is required") + if n < 1: + raise ImageGenerationError("n must be >= 1") + + runtime_config = load_runtime_config( + execution_root=Path.cwd(), + config_dir=config_dir, + env_file=env_file, + base_url_override=base_url, + model_override=model, + output_dir_override=output_dir, + require_token=True, + ) + env_map = parse_env_file(runtime_config.env_file) + resolved_request_style = request_style.strip().lower() if request_style else runtime_config.request_style + if resolved_request_style == "auto": + resolved_request_style = runtime_config.request_style + if resolved_request_style not in {"apicodex", "openai"}: + raise ImageGenerationError( + f"Unsupported request style: {resolved_request_style}. Use one of: {', '.join(REQUEST_STYLE_CHOICES)}" + ) + + refs = [ref.strip() for ref in (image_refs or []) if ref.strip()] + output_path = runtime_config.output_dir + output_path.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + stem = f"gpt-image-2-{timestamp}" + request_type = "edit" if refs else "generation" + image_files = [image_ref_to_input_file(ref, index, timeout) for index, ref in enumerate(refs, start=1)] if refs else [] + targets = build_request_targets(runtime_config, resolved_request_style) + response: dict[str, Any] | None = None + selected_target: RequestTarget | None = None + requested_size = "" + api_size = "" + last_error: BaseException | None = None + for target_index, target in enumerate(targets): + for attempt in range(1, REQUEST_RETRY_ATTEMPTS + 1): + try: + response, resolved_request_style, requested_size, api_size = submit_request_for_target( + target, + runtime_config=runtime_config, + env_map=env_map, + prompt=prompt, + size=size, + n=n, + has_refs=bool(refs), + image_files=image_files, + timeout=timeout, + quality=quality, + background=background, + output_format=output_format, + input_fidelity=input_fidelity, + ) + selected_target = target + break + except BaseException as error: + last_error = error + if attempt < REQUEST_RETRY_ATTEMPTS and should_retry_request(error): + print( + f"request attempt {attempt}/{REQUEST_RETRY_ATTEMPTS} to {target.base_url} failed with {format_request_error(error)}; retrying in {REQUEST_RETRY_DELAY_SECONDS:g}s", + file=sys.stderr, + ) + time.sleep(REQUEST_RETRY_DELAY_SECONDS) + continue + next_target = targets[target_index + 1] if target_index + 1 < len(targets) else None + if next_target is not None and is_failover_eligible_error(error): + print( + f"request to {target.base_url} failed with {format_request_error(error)}; switching to fallback {next_target.base_url}", + file=sys.stderr, + ) + break + raise + if response is not None and selected_target is not None: + break + if response is None or selected_target is None: + raise ImageGenerationError(f"Image request failed: {last_error}") + + task_api_base = build_api_url(selected_target.base_url, "tasks/") + response_path = output_path / f"{stem}-response.json" + save_json(response_path, response) + + image_paths: list[Path] = [] + revised_prompts: list[str] = [] + + b64_items = extract_b64_items(response) + if b64_items: + for index, item in enumerate(b64_items, start=1): + image_paths.append(save_b64_image(item["b64_json"], output_path, stem, index)) + if isinstance(item.get("revised_prompt"), str): + revised_prompts.append(item["revised_prompt"]) + return { + "images": [str(path) for path in image_paths], + "response_path": str(response_path), + "revised_prompts": revised_prompts, + "request_type": request_type, + "mode": "sync-b64", + "base_url": selected_target.base_url, + "model": runtime_config.model, + "request_style": resolved_request_style, + "requested_size": requested_size, + "api_size": api_size, + "output_dir": str(output_path), + } + + image_values = extract_image_values(response) + if image_values: + image_paths = save_image_values(image_values, output_path, stem, timeout) + return { + "images": [str(path) for path in image_paths], + "response_path": str(response_path), + "revised_prompts": revised_prompts, + "request_type": request_type, + "mode": "sync-url", + "base_url": selected_target.base_url, + "model": runtime_config.model, + "request_style": resolved_request_style, + "requested_size": requested_size, + "api_size": api_size, + "output_dir": str(output_path), + } + + task_id = extract_task_id(response) + if not task_id: + raise ImageGenerationError(f"Unexpected response. Saved raw response to {response_path}") + + time.sleep(initial_poll_delay) + deadline = time.time() + max_wait + last_task: dict[str, Any] | None = None + while time.time() <= deadline: + last_task = request_json( + "GET", + urllib.parse.urljoin(task_api_base, urllib.parse.quote(task_id)), + runtime_config.token or "", + timeout=timeout, + ) + status = get_task_status(last_task) + if status == "completed": + task_path = output_path / f"{stem}-task.json" + save_json(task_path, last_task) + + b64_items = extract_b64_items(last_task) + if b64_items: + for index, item in enumerate(b64_items, start=1): + image_paths.append(save_b64_image(item["b64_json"], output_path, stem, index)) + if isinstance(item.get("revised_prompt"), str): + revised_prompts.append(item["revised_prompt"]) + else: + image_paths = save_image_values(extract_image_values(last_task), output_path, stem, timeout) + + if not image_paths: + raise ImageGenerationError(f"Task completed but no image was found. Saved task response to {task_path}") + return { + "images": [str(path) for path in image_paths], + "response_path": str(response_path), + "task_response_path": str(task_path), + "revised_prompts": revised_prompts, + "request_type": request_type, + "mode": "async-task", + "base_url": selected_target.base_url, + "model": runtime_config.model, + "request_style": resolved_request_style, + "requested_size": requested_size, + "api_size": api_size, + "output_dir": str(output_path), + } + if status == "failed": + raise ImageGenerationError(get_task_error(last_task)) + time.sleep(poll_interval) + + if last_task is not None: + task_path = output_path / f"{stem}-task-timeout.json" + save_json(task_path, last_task) + raise ImageGenerationError(f"Task polling timed out. Saved last task response to {task_path}") + raise ImageGenerationError("Task polling timed out before the first task response") + + +def save_token(token: str, config_dir: str | Path | None = None) -> Path: + return save_runtime_token(token, execution_root=Path.cwd(), config_dir=config_dir) + + +def save_base_url(base_url: str, config_dir: str | Path | None = None) -> Path: + return save_runtime_base_url(base_url, execution_root=Path.cwd(), config_dir=config_dir) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate or edit images with GPT Image 2 via apicodex-style or OpenAI-compatible APIs") + parser.add_argument("--save-token", metavar="TOKEN", help="Save an API token to /config/token.txt and exit") + parser.add_argument("--save-base-url", metavar="URL", help="Save an API base URL to /config/base_url.txt and exit") + parser.add_argument("--print-env-template", action="store_true", help="Print a recommended .env template and exit") + parser.add_argument("--init-config", action="store_true", help="Write a recommended env template file and exit") + parser.add_argument("--prompt", required=False, default=None, help="Text prompt") + parser.add_argument("--size", default=None, help="Size such as 2:3 or 1024x1536. If omitted, tries env defaults before falling back to 1:1.") + parser.add_argument("--n", default=1, type=int, help="Number of images") + parser.add_argument("--image-ref", action="append", default=[], help="Reference image URL, data URI, or local path") + parser.add_argument("--output-dir", default=None, help="Output directory. Defaults to /outputs.") + parser.add_argument("--timeout", default=300, type=int, help="HTTP timeout in seconds") + parser.add_argument("--initial-poll-delay", default=10, type=float, help="Seconds to wait before polling async tasks") + parser.add_argument("--poll-interval", default=5, type=float, help="Async task poll interval in seconds") + parser.add_argument("--max-wait", default=300, type=float, help="Maximum async task wait time in seconds") + parser.add_argument("--config-dir", help="Config directory. Defaults to /config.") + parser.add_argument("--env-file", help="Env file. Defaults to /.env.") + parser.add_argument("--base-url", help="Override the API base URL for this request.") + parser.add_argument("--model", help="Override the model for this request.") + parser.add_argument("--request-style", choices=REQUEST_STYLE_CHOICES, default=None, help="Force request style. Defaults to auto/inferred from base URL.") + parser.add_argument("--quality", default=None, help="Optional image edit quality value.") + parser.add_argument("--background", default=None, help="Optional image edit background value.") + parser.add_argument("--output-format", default=None, help="Optional image edit output format value.") + parser.add_argument("--input-fidelity", default=None, help="Optional image edit input fidelity value.") + parser.add_argument("--show-config", action="store_true", help="Print the resolved runtime configuration and exit") + parser.add_argument("--force", action="store_true", help="Allow overwriting files when used with --init-config") + return parser + + +def main() -> int: + args = build_parser().parse_args() + + if args.save_token: + try: + path = save_token(args.save_token, config_dir=args.config_dir) + print(f"Token saved to {path}") + return 0 + except (ConfigError, Exception) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + if args.save_base_url: + try: + path = save_base_url(args.save_base_url, config_dir=args.config_dir) + print(f"Base URL saved to {path}") + return 0 + except (ConfigError, Exception) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + if args.print_env_template: + print(build_env_template(), end="") + return 0 + + if args.init_config: + try: + path = write_env_template(env_file=args.env_file or ".env.example", overwrite=args.force) + print(f"Config template written to {path}") + return 0 + except (ConfigError, Exception) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + if args.show_config: + try: + config = load_runtime_config( + execution_root=Path.cwd(), + config_dir=args.config_dir, + env_file=args.env_file, + base_url_override=args.base_url, + model_override=args.model, + output_dir_override=args.output_dir, + require_token=False, + ) + print(json.dumps(runtime_config_summary(config), ensure_ascii=False, indent=2)) + return 0 + except (ConfigError, Exception) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + if not args.prompt: + print( + "ERROR: --prompt is required (unless using --save-token / --save-base-url / --print-env-template / --init-config / --show-config)", + file=sys.stderr, + ) + return 1 + + try: + result = generate_image( + prompt=args.prompt, + size=args.size, + image_refs=args.image_ref, + n=args.n, + output_dir=args.output_dir, + timeout=args.timeout, + initial_poll_delay=args.initial_poll_delay, + poll_interval=args.poll_interval, + max_wait=args.max_wait, + config_dir=args.config_dir, + env_file=args.env_file, + base_url=args.base_url, + model=args.model, + request_style=args.request_style, + quality=args.quality, + background=args.background, + output_format=args.output_format, + input_fidelity=args.input_fidelity, + ) + except (ConfigError, Exception) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/runtime_config.py b/scripts/runtime_config.py new file mode 100644 index 0000000..448e2bc --- /dev/null +++ b/scripts/runtime_config.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +from typing import Callable +import urllib.parse + + +ROOT_DIR = Path(__file__).resolve().parents[1] +DEFAULT_BASE_URL = "https://apicodex.xyz" +DEFAULT_MODEL = "gpt-image-2" +DEFAULT_OUTPUT_DIR_NAME = "outputs" +DEFAULT_REQUEST_STYLE = "apicodex" + +TOKEN_PLACEHOLDERS = {"YOUR_API_TOKEN_HERE", "", "Bearer "} +BASE_URL_PLACEHOLDERS = {"", "https://example.com", "https://example.com/v1"} +MODEL_PLACEHOLDERS = {""} +REQUEST_STYLE_VALUES = {"auto", "apicodex", "openai"} + +TOKEN_ENV_VARS = ( + "GPT_IMAGE_2_TOKEN", + "APICODEX_API_KEY", + "APICODEX_TOKEN", + "IMAGE_API_KEY", + "CODEX_API_KEY", + "OPENAI_API_KEY", +) +BASE_URL_ENV_VARS = ( + "GPT_IMAGE_2_BASE_URL", + "APICODEX_BASE_URL", + "IMAGE_BASE_URL", + "OPENAI_BASE_URL", + "OPENAI_API_BASE", +) +MODEL_ENV_VARS = ( + "GPT_IMAGE_2_MODEL", + "IMAGE_MODEL", +) +REQUEST_STYLE_ENV_VARS = ( + "GPT_IMAGE_2_REQUEST_STYLE", + "IMAGE_REQUEST_STYLE", + "IMAGE_API_STYLE", +) +OUTPUT_DIR_ENV_VARS = ( + "GPT_IMAGE_2_OUTPUT_DIR", + "IMAGE_OUTPUT_DIR", +) + + +class ConfigError(RuntimeError): + pass + + +@dataclass(frozen=True) +class ConfigValue: + value: str + source: str + + +@dataclass(frozen=True) +class RuntimeConfig: + execution_root: Path + env_file: Path + project_config_dir: Path + skill_root: Path + skill_config_dir: Path + token: str | None + token_source: str | None + base_url: str + base_url_source: str + model: str + model_source: str + request_style: str + request_style_source: str + output_dir: Path + output_dir_source: str + + +def normalize_token(raw_token: str) -> str: + return raw_token.strip().removeprefix("Bearer ").strip() + + +def normalize_base_url(raw_value: str) -> str: + value = raw_value.strip().strip('"').strip("'").rstrip("/") + if not value: + return "" + + parsed = urllib.parse.urlparse(value) + if not parsed.scheme or not parsed.netloc: + return value + + path = parsed.path.rstrip("/") + if not path: + path = "/v1" + normalized = parsed._replace(path=path) + return urllib.parse.urlunparse(normalized).rstrip("/") + + +def normalize_model(raw_value: str) -> str: + return raw_value.strip() + + +def normalize_request_style(raw_value: str) -> str: + value = raw_value.strip().strip('"').strip("'").lower() + if not value: + return "" + if value not in REQUEST_STYLE_VALUES: + raise ConfigError( + f"Unsupported request style: {raw_value}. Use one of: {', '.join(sorted(REQUEST_STYLE_VALUES))}" + ) + return value + + +def infer_request_style(base_url: str) -> str: + parsed = urllib.parse.urlparse(normalize_base_url(base_url)) + host = (parsed.netloc or "").lower() + if "apicodex" in host: + return "apicodex" + if "openai" in host: + return "openai" + return "openai" + + +def missing_token_message() -> str: + env_list = ", ".join(TOKEN_ENV_VARS) + return ( + f"No usable token found. Set one of these environment variables: {env_list}, " + "or configure the current working directory with .env / config/token.txt. " + f"Skill fallback config is {ROOT_DIR / 'config' / 'token.txt'}." + ) + + +def build_api_url(base_url: str, path_fragment: str) -> str: + base = normalize_base_url(base_url).rstrip("/") + "/" + return urllib.parse.urljoin(base, path_fragment.lstrip("/")) + + +def load_runtime_config( + *, + execution_root: str | Path | None = None, + config_dir: str | Path | None = None, + env_file: str | Path | None = None, + base_url_override: str | None = None, + model_override: str | None = None, + output_dir_override: str | Path | None = None, + require_token: bool = True, +) -> RuntimeConfig: + resolved_execution_root = Path(execution_root or os.getcwd()).expanduser().resolve() + resolved_config_dir = _resolve_project_config_dir(resolved_execution_root, config_dir) + resolved_env_file = _resolve_env_file(resolved_execution_root, env_file) + skill_config_dir = ROOT_DIR / "config" + env_map = parse_env_file(resolved_env_file) + + token_value = _select_text_value( + env_vars=TOKEN_ENV_VARS, + env_map=env_map, + file_candidates=(resolved_config_dir / "token.txt", skill_config_dir / "token.txt"), + normalizer=normalize_token, + placeholders=TOKEN_PLACEHOLDERS, + ) + if require_token and token_value is None: + raise ConfigError(missing_token_message()) + + base_url_value = _select_text_value( + env_vars=BASE_URL_ENV_VARS, + env_map=env_map, + file_candidates=( + resolved_config_dir / "base_url.txt", + resolved_config_dir / "baseurl.txt", + skill_config_dir / "base_url.txt", + skill_config_dir / "baseurl.txt", + ), + normalizer=normalize_base_url, + placeholders=BASE_URL_PLACEHOLDERS, + override=(base_url_override, "cli:--base-url") if base_url_override else None, + ) or ConfigValue(normalize_base_url(DEFAULT_BASE_URL), "default") + + model_value = _select_text_value( + env_vars=MODEL_ENV_VARS, + env_map=env_map, + file_candidates=(resolved_config_dir / "model.txt", skill_config_dir / "model.txt"), + normalizer=normalize_model, + placeholders=MODEL_PLACEHOLDERS, + override=(model_override, "cli:--model") if model_override else None, + ) or ConfigValue(DEFAULT_MODEL, "default") + + request_style_value = _select_text_value( + env_vars=REQUEST_STYLE_ENV_VARS, + env_map=env_map, + file_candidates=(resolved_config_dir / "request_style.txt", skill_config_dir / "request_style.txt"), + normalizer=normalize_request_style, + placeholders=set(), + ) + if request_style_value is None or request_style_value.value == "auto": + resolved_request_style = infer_request_style(base_url_value.value) + request_style_source = ( + f"{request_style_value.source}->inferred:base-url" + if request_style_value is not None + else "inferred:base-url" + ) + else: + resolved_request_style = request_style_value.value + request_style_source = request_style_value.source + + output_dir_value = _select_text_value( + env_vars=OUTPUT_DIR_ENV_VARS, + env_map=env_map, + file_candidates=(resolved_config_dir / "output_dir.txt", skill_config_dir / "output_dir.txt"), + normalizer=lambda value: value.strip(), + placeholders=set(), + override=(str(output_dir_override), "cli:--output-dir") if output_dir_override else None, + ) + if output_dir_value is None: + resolved_output_dir = resolved_execution_root / DEFAULT_OUTPUT_DIR_NAME + output_dir_source = "default" + else: + resolved_output_dir = resolve_path_from_root(output_dir_value.value, resolved_execution_root) + output_dir_source = output_dir_value.source + + return RuntimeConfig( + execution_root=resolved_execution_root, + env_file=resolved_env_file, + project_config_dir=resolved_config_dir, + skill_root=ROOT_DIR, + skill_config_dir=skill_config_dir, + token=token_value.value if token_value else None, + token_source=token_value.source if token_value else None, + base_url=base_url_value.value, + base_url_source=base_url_value.source, + model=model_value.value, + model_source=model_value.source, + request_style=resolved_request_style, + request_style_source=request_style_source, + output_dir=resolved_output_dir, + output_dir_source=output_dir_source, + ) + + +def runtime_config_summary(config: RuntimeConfig) -> dict[str, str | bool | None]: + return { + "execution_root": str(config.execution_root), + "env_file": str(config.env_file), + "env_file_exists": config.env_file.exists(), + "project_config_dir": str(config.project_config_dir), + "skill_config_dir": str(config.skill_config_dir), + "token_configured": bool(config.token), + "token_source": config.token_source, + "base_url": config.base_url, + "base_url_source": config.base_url_source, + "model": config.model, + "model_source": config.model_source, + "request_style": config.request_style, + "request_style_source": config.request_style_source, + "output_dir": str(config.output_dir), + "output_dir_source": config.output_dir_source, + } + + +def save_token(token: str, *, execution_root: str | Path | None = None, config_dir: str | Path | None = None) -> Path: + normalized = normalize_token(token) + if not normalized or normalized in TOKEN_PLACEHOLDERS: + raise ConfigError("Token cannot be empty") + + target_dir = _resolve_project_config_dir(Path(execution_root or os.getcwd()).expanduser().resolve(), config_dir) + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / "token.txt" + target_path.write_text( + f"# apicodex.xyz API token\n{normalized}\n", + encoding="utf-8", + ) + return target_path + + +def save_base_url( + base_url: str, + *, + execution_root: str | Path | None = None, + config_dir: str | Path | None = None, +) -> Path: + normalized = normalize_base_url(base_url) + if not normalized or normalized in BASE_URL_PLACEHOLDERS: + raise ConfigError("Base URL cannot be empty") + + target_dir = _resolve_project_config_dir(Path(execution_root or os.getcwd()).expanduser().resolve(), config_dir) + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / "base_url.txt" + target_path.write_text( + f"# API base URL. Either host root or full /v1 path is accepted.\n{normalized}\n", + encoding="utf-8", + ) + return target_path + + +def resolve_path_from_root(value: str, root: Path) -> Path: + path = Path(value).expanduser() + if path.is_absolute(): + return path + return (root / path).resolve() + + +def parse_env_file(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + + values: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8-sig").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + if "=" not in line: + continue + key, raw_value = line.split("=", 1) + key = key.strip() + if not key: + continue + values[key] = _parse_env_value(raw_value.strip()) + return values + + +def _parse_env_value(raw_value: str) -> str: + if not raw_value: + return "" + if raw_value[0] in {'"', "'"} and raw_value[-1] == raw_value[0]: + return raw_value[1:-1] + if " #" in raw_value: + raw_value = raw_value.split(" #", 1)[0].rstrip() + return raw_value.strip() + + +def _resolve_project_config_dir(execution_root: Path, config_dir: str | Path | None) -> Path: + raw_value = config_dir or os.environ.get("GPT_IMAGE_2_CONFIG_DIR") + if not raw_value: + return execution_root / "config" + return resolve_path_from_root(str(raw_value), execution_root) + + +def _resolve_env_file(execution_root: Path, env_file: str | Path | None) -> Path: + raw_value = env_file or os.environ.get("GPT_IMAGE_2_ENV_FILE") + if not raw_value: + return execution_root / ".env" + return resolve_path_from_root(str(raw_value), execution_root) + + +def _select_text_value( + *, + env_vars: tuple[str, ...], + env_map: dict[str, str], + file_candidates: tuple[Path, ...], + normalizer: Callable[[str], str], + placeholders: set[str], + override: tuple[str, str] | None = None, +) -> ConfigValue | None: + if override is not None: + resolved = _coerce_text_value(override[0], override[1], normalizer, placeholders) + if resolved is not None: + return resolved + + for env_name in env_vars: + resolved = _coerce_text_value(os.environ.get(env_name, ""), f"env:{env_name}", normalizer, placeholders) + if resolved is not None: + return resolved + + for env_name in env_vars: + if env_name not in env_map: + continue + resolved = _coerce_text_value(env_map[env_name], f"env-file:{env_name}", normalizer, placeholders) + if resolved is not None: + return resolved + + for candidate in file_candidates: + resolved = _coerce_text_value(_read_config_text(candidate), f"file:{candidate}", normalizer, placeholders) + if resolved is not None: + return resolved + return None + + +def _coerce_text_value( + raw_value: str, + source: str, + normalizer: Callable[[str], str], + placeholders: set[str], +) -> ConfigValue | None: + value = normalizer(raw_value) + if not value or value in placeholders: + return None + return ConfigValue(value=value, source=source) + + +def _read_config_text(path: Path) -> str: + if not path.exists(): + return "" + for line in path.read_text(encoding="utf-8-sig").splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + return stripped + return "" diff --git a/scripts/web_app.py b/scripts/web_app.py new file mode 100644 index 0000000..276d865 --- /dev/null +++ b/scripts/web_app.py @@ -0,0 +1,775 @@ +from __future__ import annotations + +import argparse +import html +import io +import json +import mimetypes +import sys +import threading +import urllib.parse +import webbrowser +import zipfile +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +ROOT_DIR = SCRIPT_DIR.parent +MAX_PREVIEW_STRING = 240 +MAX_HISTORY_ITEMS = 20 + +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import generate_image # noqa: E402 +import runtime_config # noqa: E402 + + +RUNTIME_CONFIG = runtime_config.load_runtime_config( + execution_root=Path.cwd(), + require_token=False, +) +OUTPUT_DIR = RUNTIME_CONFIG.output_dir +HISTORY_FILE = OUTPUT_DIR / "history.jsonl" + + +HTML_PAGE = r""" + + + + + GPT Image 2 生图工具 + + + + + + + +""" + + +def json_bytes(data: dict[str, Any]) -> bytes: + return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + + +def ensure_output_dir() -> None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +def safe_output_path(name: str) -> Path: + candidate = OUTPUT_DIR / Path(name).name + resolved = candidate.resolve() + output_root = OUTPUT_DIR.resolve() + if resolved.parent != output_root: + raise ValueError("invalid file name") + return resolved + + +def build_file_meta(path_value: str | Path | None) -> dict[str, str] | None: + if not path_value: + return None + path = Path(path_value) + name = path.name + return { + "name": name, + "path": str(path), + "url": f"/outputs/{urllib.parse.quote(name)}", + } + + +def sanitize_for_preview(value: Any) -> Any: + if isinstance(value, dict): + return {key: sanitize_for_preview(item) for key, item in value.items()} + if isinstance(value, list): + if len(value) > 20: + trimmed = value[:20] + return [sanitize_for_preview(item) for item in trimmed] + [f"... truncated {len(value) - 20} more items"] + return [sanitize_for_preview(item) for item in value] + if isinstance(value, str) and len(value) > MAX_PREVIEW_STRING: + return f"{value[:MAX_PREVIEW_STRING]}... " + return value + + +def load_json_preview(path_value: str | Path | None) -> Any | None: + if not path_value: + return None + path = Path(path_value) + if not path.exists(): + return None + try: + return sanitize_for_preview(json.loads(path.read_text(encoding="utf-8"))) + except Exception: + return None + + +def append_history(item: dict[str, Any]) -> None: + ensure_output_dir() + with HISTORY_FILE.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(item, ensure_ascii=False) + "\n") + + +def load_history(limit: int = MAX_HISTORY_ITEMS) -> list[dict[str, Any]]: + if not HISTORY_FILE.exists(): + return [] + items: list[dict[str, Any]] = [] + for line in HISTORY_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + items.append(json.loads(line)) + except json.JSONDecodeError: + continue + return list(reversed(items[-limit:])) + + +def build_history_entry( + request_mode: str, + prompt: str, + size: str, + n: int, + ref_count: int, + images: list[dict[str, str]], + result: dict[str, Any], + response_preview: Any | None, + task_response_preview: Any | None, +) -> dict[str, Any]: + return { + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "request_mode": request_mode, + "request_type": result.get("request_type"), + "result_mode": result.get("mode"), + "base_url": result.get("base_url"), + "model": result.get("model"), + "request_style": result.get("request_style"), + "prompt": prompt, + "size": result.get("requested_size", size), + "api_size": result.get("api_size"), + "n": n, + "ref_count": ref_count, + "images": images, + "response_file": build_file_meta(result.get("response_path")), + "task_response_file": build_file_meta(result.get("task_response_path")), + "response_preview": response_preview, + "task_response_preview": task_response_preview, + } + + +class WebHandler(BaseHTTPRequestHandler): + server_version = "GPTImage2Web/2.0" + + def log_message(self, format: str, *args: Any) -> None: + print(f"{self.address_string()} - {format % args}") + + def send_body(self, status: int, body: bytes, content_type: str, extra_headers: dict[str, str] | None = None) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + if extra_headers: + for key, value in extra_headers.items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def send_json(self, status: int, data: dict[str, Any]) -> None: + self.send_body(status, json_bytes(data), "application/json; charset=utf-8") + + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path in {"/", "/index.html"}: + self.send_body(200, HTML_PAGE.encode("utf-8"), "text/html; charset=utf-8") + return + if parsed.path == "/health": + self.send_json(200, {"ok": True}) + return + if parsed.path == "/api/runtime-config": + self.handle_runtime_config() + return + if parsed.path == "/api/history": + self.handle_history(parsed.query) + return + if parsed.path == "/api/download-all": + self.handle_download_all(parsed.query) + return + if parsed.path.startswith("/outputs/"): + self.serve_output(parsed.path) + return + self.send_json(404, {"ok": False, "error": "not found"}) + + def do_POST(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path != "/api/generate": + self.send_json(404, {"ok": False, "error": "not found"}) + return + + try: + length = int(self.headers.get("Content-Length", "0")) + if length > 80 * 1024 * 1024: + self.send_json(413, {"ok": False, "error": "request too large"}) + return + + payload = json.loads(self.rfile.read(length).decode("utf-8")) + prompt = str(payload.get("prompt", "")).strip() + size = str(payload.get("size", "16:9")) + n = int(payload.get("n", 1)) + image_refs = [str(item) for item in payload.get("image_refs", []) if str(item).strip()] + request_mode = "prompt+images" if image_refs else "prompt-only" + + if not prompt: + raise ValueError("请输入 prompt") + + result = generate_image.generate_image( + prompt=prompt, + size=size, + n=n, + image_refs=image_refs, + output_dir=str(OUTPUT_DIR), + timeout=int(payload.get("timeout", 300)), + initial_poll_delay=float(payload.get("initial_poll_delay", 10)), + poll_interval=float(payload.get("poll_interval", 5)), + max_wait=float(payload.get("max_wait", 300)), + ) + except (ValueError, generate_image.ImageGenerationError) as error: + self.send_json(400, {"ok": False, "error": str(error)}) + return + except Exception as error: + self.send_json(500, {"ok": False, "error": str(error)}) + return + + images = [build_file_meta(path) for path in result.get("images", [])] + images = [item for item in images if item is not None] + response_preview = load_json_preview(result.get("response_path")) + task_response_preview = load_json_preview(result.get("task_response_path")) + history_entry = build_history_entry( + request_mode=request_mode, + prompt=prompt, + size=size, + n=n, + ref_count=len(image_refs), + images=images, + result=result, + response_preview=response_preview, + task_response_preview=task_response_preview, + ) + append_history(history_entry) + + self.send_json(200, { + "ok": True, + "request_mode": request_mode, + "request_type": result.get("request_type"), + "mode": result.get("mode"), + "base_url": result.get("base_url"), + "model": result.get("model"), + "request_style": result.get("request_style"), + "size": result.get("requested_size", size), + "api_size": result.get("api_size"), + "n": n, + "images": images, + "response_file": build_file_meta(result.get("response_path")), + "task_response_file": build_file_meta(result.get("task_response_path")), + "response_preview": response_preview, + "task_response_preview": task_response_preview, + "revised_prompts": result.get("revised_prompts", []), + }) + + def handle_history(self, query: str) -> None: + params = urllib.parse.parse_qs(query) + try: + limit = int(params.get("limit", [str(MAX_HISTORY_ITEMS)])[0]) + except ValueError: + limit = MAX_HISTORY_ITEMS + limit = max(1, min(limit, 100)) + self.send_json(200, {"ok": True, "items": load_history(limit)}) + + def handle_runtime_config(self) -> None: + self.send_json(200, {"ok": True, "config": runtime_config.runtime_config_summary(RUNTIME_CONFIG)}) + + def handle_download_all(self, query: str) -> None: + params = urllib.parse.parse_qs(query) + raw_names = params.get("names", [""])[0] + names = [Path(name).name for name in raw_names.split(",") if name.strip()] + if not names: + self.send_json(400, {"ok": False, "error": "no image names provided"}) + return + + try: + paths = [safe_output_path(name) for name in names] + except ValueError as error: + self.send_json(400, {"ok": False, "error": str(error)}) + return + + existing = [path for path in paths if path.exists() and path.is_file()] + if not existing: + self.send_json(404, {"ok": False, "error": "no output files found"}) + return + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for path in existing: + archive.write(path, arcname=path.name) + body = buffer.getvalue() + filename = f"gpt-image-2-bundle-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip" + self.send_body( + 200, + body, + "application/zip", + { + "Content-Disposition": f'attachment; filename="{filename}"', + }, + ) + + def serve_output(self, request_path: str) -> None: + name = Path(urllib.parse.unquote(request_path.removeprefix("/outputs/"))).name + try: + resolved = safe_output_path(name) + if not resolved.exists() or not resolved.is_file(): + self.send_json(404, {"ok": False, "error": "output not found"}) + return + content_type = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream" + self.send_body(200, resolved.read_bytes(), content_type) + except Exception as error: + self.send_json(500, {"ok": False, "error": html.escape(str(error))}) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Start the GPT Image 2 local web UI") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=7862, type=int) + parser.add_argument("--open", action="store_true", help="Open browser after startup") + args = parser.parse_args() + + ensure_output_dir() + server = ThreadingHTTPServer((args.host, args.port), WebHandler) + url = f"http://{args.host}:{args.port}/" + print(f"GPT Image 2 web tool is running: {url}") + print(f"Execution root: {RUNTIME_CONFIG.execution_root}") + print(f"Project config dir: {RUNTIME_CONFIG.project_config_dir}") + print(f"Skill fallback config dir: {RUNTIME_CONFIG.skill_config_dir}") + print(f"Base URL: {RUNTIME_CONFIG.base_url} ({RUNTIME_CONFIG.base_url_source})") + print(f"Output dir: {OUTPUT_DIR}") + print("Press Ctrl+C to stop.") + if args.open: + threading.Timer(1.0, lambda: webbrowser.open(url)).start() + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())
+
+

GPT Image 2 生图工具

+

优先读取当前执行目录的 .envconfig/,再回退到 skill 自带 config/。输入 prompt,可选添加一张或多张参考图,服务端会自动选择纯文本生成或带图编辑,并兼容 apicodex 风格与 OpenAI-compatible 网关。

+
正在读取运行时配置…
+ + + + +
+ + + + +
+ +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + +
本地图片会在浏览器中转换为 data URI 后提交给本机服务。尺寸既可填比例,如 2:3,也可填像素,如 1024x1536
+ +
+ 高级参数:超时和异步轮询 +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+ +
+
+

JSON 详情面板

+ +
+

生成完成后,可在这里查看接口返回的 JSON 预览。

+
+
+

响应 JSON 预览

+
暂无
+
+
+

任务 JSON 预览

+
暂无
+
+
+
+ +
+
+

历史生成记录

+ 显示最近 20 条 +
+
暂无历史记录
+
+