diff --git a/README.md b/README.md index dd6cc2f..f29d2a9 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ uv tool upgrade gpt-image-cli -Reads `OPENAI_API_KEY` from process env, then `.env`, then `~/.env` without overriding an already-set env var. +Reads `OPENAI_API_KEY` from process env, then `.env`, then `~/.env` without overriding an already-set env var. The optional Atlas Cloud text-to-image path reads `ATLASCLOUD_API_KEY` or `ATLAS_CLOUD_API_KEY`. > **Agent + API-key note.** Codex also has its own built-in image-generation skill, but that path is black-box and cannot be edited here; Codex users can switch to it if they prefer. Thanks to the related issue discussion for the simple safety tip: if you do not want an agent to accidentally use your OpenAI API key, run `unset OPENAI_API_KEY` before invoking the local CLI/skill. @@ -219,6 +219,16 @@ gpt-image -p "a photorealistic convenience store at 10pm" --size 1k --quality hi Under the hood: `POST /v1/images/generations` with `model=gpt-image-2`. +### Atlas Cloud text → image + +```bash +export ATLASCLOUD_API_KEY="your-atlascloud-api-key" +gpt-image --provider atlascloud -p "a photorealistic convenience store at 10pm" \ + --size 1k --quality high -f store.png +``` + +Under the hood: `POST /api/v1/model/generateImage` with `model=openai/gpt-image-2/text-to-image`, followed by prediction polling. The Atlas Cloud provider currently covers text-to-image generation only; reference edits and inpainting still use the OpenAI provider. + ### Text + reference image → image (edit) ```bash @@ -245,6 +255,8 @@ Under the hood: `POST /v1/images/edits` (multipart form), the official endpoint | Flag | Values | Default | Applies to | Notes | |---|---|---|---|---| | `-p, --prompt` | str | — required | both | Full prompt text. | +| `--provider` | `openai` · `atlascloud` | `openai` | generation | `atlascloud` uses Atlas Cloud's async text-to-image API and reads `ATLASCLOUD_API_KEY`. | +| `--model` | str | `gpt-image-2` | both | With `--provider atlascloud`, the default maps to `openai/gpt-image-2/text-to-image`. | | `-f, --file` | path | `./fig/YYYY-MM-DD-HH-MM-SS-.png` | both | Explicit output path. | | `-i, --image` | path (repeatable) | — | edits | Presence routes through `/v1/images/edits`. | | `-m, --mask` | path (PNG, alpha) | — | edits | Opaque = preserved, transparent = regenerated. Requires `-i`. | diff --git a/README.zh.md b/README.zh.md index fc93daf..21ee10b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -198,7 +198,7 @@ uv tool upgrade gpt-image-cli -按 process env、`.env`、`~/.env` 的顺序读取 `OPENAI_API_KEY`,且不会覆盖已经设置好的环境变量。 +按 process env、`.env`、`~/.env` 的顺序读取 `OPENAI_API_KEY`,且不会覆盖已经设置好的环境变量。可选的 Atlas Cloud 文生图路径会读取 `ATLASCLOUD_API_KEY` 或 `ATLAS_CLOUD_API_KEY`。 > **Agent 与 API Key 提醒。** 我们发现 Codex 其实自带生成 Image 的 skill,但它是黑盒的,无法在这里修改;Codex 用户如果更想走内置能力,可以自行切换。也感谢相关 issue 里提到的方法:如果你不想让 agent accidentally 调用你的 OpenAI API Key,直接在调用本地 CLI/Skill 前运行 `unset OPENAI_API_KEY` 即可。 @@ -219,6 +219,16 @@ gpt-image -p "晚上10点的逼真便利店" --size 1k --quality high -f store.p 底层实现:`POST /v1/images/generations`,使用 `model=gpt-image-2`。 +### Atlas Cloud 文本 → 图片 + +```bash +export ATLASCLOUD_API_KEY="your-atlascloud-api-key" +gpt-image --provider atlascloud -p "晚上10点的逼真便利店" \ + --size 1k --quality high -f store.png +``` + +底层实现:`POST /api/v1/model/generateImage`,使用 `model=openai/gpt-image-2/text-to-image`,随后轮询 prediction 结果。Atlas Cloud provider 目前只覆盖文本生成图片;参考图编辑和 inpainting 仍走 OpenAI provider。 + ### 文字 + 参考图像 → 图像(编辑) ```bash @@ -245,6 +255,8 @@ gpt-image -p "将天空替换为极光" \ | 标志 | 取值 | 默认值 | 适用范围 | 备注 | |---|---|---|---|---| | `-p, --prompt` | 字符串 | — 必需 | 两者 | 完整的提示文本。 | +| `--provider` | `openai` · `atlascloud` | `openai` | 生成 | `atlascloud` 使用 Atlas Cloud 异步文生图 API,并读取 `ATLASCLOUD_API_KEY`。 | +| `--model` | 字符串 | `gpt-image-2` | 两者 | 使用 `--provider atlascloud` 时,默认映射为 `openai/gpt-image-2/text-to-image`。 | | `-f, --file` | 路径 | `./fig/YYYY-MM-DD-HH-MM-SS-.png` | 两者 | 明确输出路径。 | | `-i, --image` | 路径(可重复) | — | 编辑 | 存在时走 `/v1/images/edits` 路由。 | | `-m, --mask` | 路径(PNG,带alpha通道) | — | 编辑 | 不透明 = 保留,透明 = 重新生成。需要 `-i`。 | diff --git a/skills/gpt-image/SKILL.md b/skills/gpt-image/SKILL.md index 0db0ea3..e6de336 100644 --- a/skills/gpt-image/SKILL.md +++ b/skills/gpt-image/SKILL.md @@ -1,7 +1,7 @@ --- name: gpt-image description: "Use this skill whenever a user asks to generate, create, draw, render, or edit images with GPT Image 2 / gpt-image-2, text-to-image, reference-image editing, inpainting, posters, typography, Chinese text, UI mockups, diagrams, or gallery prompts. Analyze the user's prompt, search the bundled Reference Gallery/craft files for matching design patterns, confer on direction when useful, then call the packaged `gpt-image` CLI or bundled `scripts/generate.py`. Do not write new image-generation code unless explicitly asked to modify this repo." -compatibility: "Requires Python 3.11+ and either `gpt-image`, `uv`, or `uvx`. CLI/API calls read `OPENAI_API_KEY` and may incur OpenAI API charges." +compatibility: "Requires Python 3.11+ and either `gpt-image`, `uv`, or `uvx`. CLI/API calls read `OPENAI_API_KEY` for OpenAI or `ATLASCLOUD_API_KEY` / `ATLAS_CLOUD_API_KEY` for Atlas Cloud text-to-image, and may incur API charges." metadata: {"openclaw":{"requires":{"anyBins":["gpt-image","uv","uvx"]},"primaryEnv":"OPENAI_API_KEY","homepage":"https://github.com/wuyoscar/gpt_image_2_skill"}} --- @@ -41,7 +41,8 @@ uvx --from git+https://github.com/wuyoscar/gpt_image_2_skill gpt-image -p "PROMP ## Key and cost rules -- CLI reads `OPENAI_API_KEY` from process env, then `.env`, then `~/.env` without overriding existing env; successful API calls may bill the user’s OpenAI account. +- CLI reads `OPENAI_API_KEY` from process env, then `.env`, then `~/.env` without overriding existing env; successful API calls may bill the user's OpenAI account. +- Optional `--provider atlascloud` reads `ATLASCLOUD_API_KEY` or `ATLAS_CLOUD_API_KEY` and uses Atlas Cloud's async text-to-image API. - If host/runtime has native platform-managed image generation and the user wants that path, use the host tool instead of this CLI. - If `OPENAI_API_KEY` is unset, report missing key or use host-native generation when requested; do not write secrets. - If user wants to avoid local-key use, respect `unset OPENAI_API_KEY`; if a key exists in `.env`/`~/.env`, tell them to remove/rename it for the session rather than working around it. @@ -52,10 +53,11 @@ uvx --from git+https://github.com/wuyoscar/gpt_image_2_skill gpt-image -p "PROMP | Flag | Values | Use | |---|---|---| | `-p, --prompt` | string | Required prompt/edit instruction | +| `--provider` | `openai`, `atlascloud` | Provider; `atlascloud` currently supports text-to-image generation | | `-f, --file` | path | Output path; auto-named if omitted | | `-i, --image` | repeatable path | Use edits endpoint; supports multiple references | | `-m, --mask` | PNG path | Inpaint with alpha mask; requires `-i` | -| `--model` | default `gpt-image-2` | Image model | +| `--model` | default `gpt-image-2` | Image model; with `--provider atlascloud`, the default maps to `openai/gpt-image-2/text-to-image` | | `--size` | `1k`, `2k`, `4k`, `portrait`, `landscape`, `square`, `wide`, `tall`, or literal | Canvas size | | `--quality` | `low`, `medium`, `high`, `auto` | Cost/quality dial | | `-n, --n` | integer | Number of images | @@ -83,6 +85,7 @@ Size policy: | Mode | Trigger | Endpoint | |---|---|---| | Text-to-image | no `-i` | `/v1/images/generations` | +| Atlas Cloud text-to-image | `--provider atlascloud`, no `-i` | `/api/v1/model/generateImage` + prediction polling | | Reference edit | one or more `-i` | `/v1/images/edits` | | Inpaint | `-i` + `-m` | `/v1/images/edits` with mask | diff --git a/skills/gpt-image/agents/openai.yaml b/skills/gpt-image/agents/openai.yaml index e91629e..81a8a7d 100644 --- a/skills/gpt-image/agents/openai.yaml +++ b/skills/gpt-image/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "GPT Image" - short_description: "Generate/edit GPT Image 2 visuals through the packaged CLI" + short_description: "Generate/edit GPT Image 2 visuals through the packaged CLI, with optional Atlas Cloud text-to-image" default_prompt: "Use $gpt-image to analyze my image request, search the Reference Gallery/craft files, confer on the best direction if needed, then call the packaged gpt-image CLI. Do not write a new image-generation script unless asked to modify this repo." diff --git a/src/gpt_image_cli/cli.py b/src/gpt_image_cli/cli.py index ae2dc26..b30c0c6 100644 --- a/src/gpt_image_cli/cli.py +++ b/src/gpt_image_cli/cli.py @@ -15,8 +15,10 @@ client.images.edit(...) — text + image(s) → image (with -i; mask via -m) Every documented parameter is exposed as a flag. Reads OPENAI_API_KEY from -process env, then .env, then ~/.env without overriding existing env. Writes the -returned PNG/JPEG/WebP bytes to disk and prints the output path(s) on stdout. +process env, then .env, then ~/.env without overriding existing env. The +optional Atlas Cloud text-to-image provider reads ATLASCLOUD_API_KEY or +ATLAS_CLOUD_API_KEY and uses Atlas Cloud's async media API. Writes the returned +PNG/JPEG/WebP bytes to disk and prints the output path(s) on stdout. Exit codes: 0 success, 1 API error, 2 bad args. @@ -46,9 +48,12 @@ import argparse import base64 +import json import os import re import sys +import time +import urllib.error import urllib.request from datetime import datetime from pathlib import Path @@ -80,8 +85,12 @@ def _load_env_chain() -> None: } DEFAULT_MODEL = "gpt-image-2" +ATLAS_CLOUD_DEFAULT_MODEL = "openai/gpt-image-2/text-to-image" +ATLAS_CLOUD_API_BASE = "https://api.atlascloud.ai/api/v1" DEFAULT_SIZE = "1024x1024" DEFAULT_MODERATION = "low" +POLL_INTERVAL_SECONDS = 3.0 +POLL_TIMEOUT_SECONDS = 300.0 def slugify(text: str, max_len: int = 30) -> str: @@ -112,6 +121,12 @@ def parse_args() -> argparse.Namespace: formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument("-p", "--prompt", required=True, help="Text prompt / edit instruction.") + p.add_argument( + "--provider", + default="openai", + choices=["openai", "atlascloud"], + help="Image generation provider. Default openai. atlascloud currently supports text-to-image generation.", + ) p.add_argument( "-f", "--file", help="Output path. Auto-generated as YYYY-MM-DD-HH-MM-SS-. if omitted " @@ -127,7 +142,11 @@ def parse_args() -> argparse.Namespace: help="Alpha-channel PNG mask (opaque = preserved, transparent = regenerated). " "Edits endpoint only; requires -i.", ) - p.add_argument("--model", default=DEFAULT_MODEL, help=f"Model ID (default {DEFAULT_MODEL}).") + p.add_argument( + "--model", + default=DEFAULT_MODEL, + help=f"Model ID. Defaults to {DEFAULT_MODEL} for openai and {ATLAS_CLOUD_DEFAULT_MODEL} for atlascloud.", + ) p.add_argument( "--size", default=DEFAULT_SIZE, help="Image size. Accepts literals (1024x1024, 1536x1024, 2048x2048, 3840x2160, " @@ -137,7 +156,7 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--quality", default="high", choices=["auto", "low", "medium", "high"], help="Rendering fidelity / budget knob (cost scales ~10× per step). Default high. " - "Use low for cheap drafts, medium for normal exploration, high for final text-heavy or shipping-facing assets.", + "Use low for cheap drafts, medium for normal exploration, high for final text-heavy or shipping-facing assets.", ) p.add_argument("-n", "--n", type=int, default=1, help="Number of images to return. Default 1.") p.add_argument( @@ -165,6 +184,18 @@ def parse_args() -> argparse.Namespace: "--user", default=None, help="Optional end-user identifier forwarded to OpenAI for abuse tracking.", ) + p.add_argument( + "--atlas-poll-interval", + type=float, + default=POLL_INTERVAL_SECONDS, + help=f"Seconds between Atlas Cloud prediction polls. Default {POLL_INTERVAL_SECONDS:g}.", + ) + p.add_argument( + "--atlas-timeout", + type=float, + default=POLL_TIMEOUT_SECONDS, + help=f"Maximum seconds to wait for an Atlas Cloud prediction. Default {POLL_TIMEOUT_SECONDS:g}.", + ) return p.parse_args() @@ -229,6 +260,123 @@ def call_edit(client: OpenAI, args: argparse.Namespace) -> Any: mask_handle.close() +def _atlascloud_api_key() -> str: + return os.environ.get("ATLASCLOUD_API_KEY") or os.environ.get("ATLAS_CLOUD_API_KEY") or "" + + +def _atlascloud_model(args: argparse.Namespace) -> str: + if args.model == DEFAULT_MODEL: + return ATLAS_CLOUD_DEFAULT_MODEL + return args.model + + +def _atlascloud_request(method: str, path: str, api_key: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + body = None if payload is None else json.dumps(payload).encode("utf-8") + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + req = urllib.request.Request( + f"{ATLAS_CLOUD_API_BASE}{path}", + data=body, + headers=headers, + method=method, + ) + try: + with urllib.request.urlopen(req, timeout=60) as response: # noqa: S310 - configured Atlas Cloud API host + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Atlas Cloud API error {exc.code}: {error_body}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Atlas Cloud API request failed: {exc}") from exc + + +def _prediction_payload(response: dict[str, Any]) -> dict[str, Any]: + data = response.get("data") + return data if isinstance(data, dict) else response + + +def _prediction_id(response: dict[str, Any]) -> str: + payload = _prediction_payload(response) + value = payload.get("id") or payload.get("prediction_id") + return str(value or "") + + +def _prediction_outputs(payload: dict[str, Any]) -> list[Any]: + for key in ("outputs", "output", "result", "images", "urls"): + value = payload.get(key) + if isinstance(value, list): + return value + if value: + return [value] + return [] + + +def call_atlascloud_generate(args: argparse.Namespace) -> list[Any]: + if args.image: + print( + "error: --provider atlascloud currently supports text-to-image only; omit --image/--mask.", + file=sys.stderr, + ) + sys.exit(2) + if args.n != 1: + print("error: --provider atlascloud currently supports one image per request; keep --n 1.", file=sys.stderr) + sys.exit(2) + if args.output_format == "webp": + print("error: --provider atlascloud supports png or jpeg output for this model, not webp.", file=sys.stderr) + sys.exit(2) + if args.background is not None: + print("error: --background is not supported by --provider atlascloud for this model.", file=sys.stderr) + sys.exit(2) + if args.output_compression is not None: + print("error: --compression is not supported by --provider atlascloud for this model.", file=sys.stderr) + sys.exit(2) + if args.input_fidelity is not None: + print("error: --input-fidelity is only valid for edit-capable OpenAI models.", file=sys.stderr) + sys.exit(2) + if args.user is not None: + print("error: --user is only forwarded for the OpenAI provider.", file=sys.stderr) + sys.exit(2) + + api_key = _atlascloud_api_key() + if not api_key: + print("error: ATLASCLOUD_API_KEY not set. Add it to env, or use --provider openai.", file=sys.stderr) + sys.exit(2) + + output_format = args.output_format or "png" + body = _filter_none( + { + "model": _atlascloud_model(args), + "prompt": args.prompt, + "size": resolve_size(args.size), + "quality": None if args.quality == "auto" else args.quality, + "output_format": output_format, + "moderation": args.moderation, + } + ) + submitted = _atlascloud_request("POST", "/model/generateImage", api_key, body) + prediction_id = _prediction_id(submitted) + if not prediction_id: + raise RuntimeError(f"Atlas Cloud response did not include a prediction id: {submitted}") + + deadline = time.monotonic() + max(1.0, args.atlas_timeout) + interval = max(1.0, args.atlas_poll_interval) + while time.monotonic() < deadline: + current = _prediction_payload(_atlascloud_request("GET", f"/model/prediction/{prediction_id}", api_key)) + status = str(current.get("status") or "").lower() + if status in {"completed", "succeeded", "success"}: + outputs = _prediction_outputs(current) + if outputs: + return outputs + raise RuntimeError(f"Atlas Cloud prediction completed without outputs: {current}") + if status in {"failed", "error", "canceled", "cancelled"}: + raise RuntimeError(f"Atlas Cloud prediction failed: {current}") + time.sleep(interval) + + raise RuntimeError(f"Atlas Cloud prediction timed out after {args.atlas_timeout:g}s: {prediction_id}") + + def write_outputs(data: list[Any], out_path: Path, n: int) -> list[Path]: out_path.parent.mkdir(parents=True, exist_ok=True) written: list[Path] = [] @@ -254,11 +402,45 @@ def write_outputs(data: list[Any], out_path: Path, n: int) -> list[Path]: return written +def _bytes_from_output(item: Any) -> bytes: + if isinstance(item, str): + if item.startswith("data:") and "," in item: + return base64.b64decode(item.split(",", 1)[1]) + if item.startswith(("http://", "https://")): + with urllib.request.urlopen(item, timeout=300) as response: # noqa: S310 - model output URL + return response.read() + return base64.b64decode(item) + if isinstance(item, dict): + for key in ("url", "download_url", "image_url"): + value = item.get(key) + if isinstance(value, str) and value: + return _bytes_from_output(value) + for key in ("b64_json", "base64", "data"): + value = item.get(key) + if isinstance(value, str) and value: + return _bytes_from_output(value) + raise ValueError(f"unsupported image output item: {item!r}") + + +def write_atlascloud_outputs(data: list[Any], out_path: Path) -> list[Path]: + out_path.parent.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + for i, item in enumerate(data): + if len(data) == 1: + target = out_path + else: + stem = out_path.with_suffix("") + target = stem.parent / f"{stem.name}_{i}{out_path.suffix}" + target.write_bytes(_bytes_from_output(item)) + written.append(target) + return written + + def main() -> int: args = parse_args() _load_env_chain() - if not os.environ.get("OPENAI_API_KEY"): + if args.provider == "openai" and not os.environ.get("OPENAI_API_KEY"): print( "error: OPENAI_API_KEY not set. Add it to env / .env / ~/.env, or use your host agent's native image tool.", file=sys.stderr, @@ -272,6 +454,21 @@ def main() -> int: ext = args.output_format or "png" out_path = Path(args.file).expanduser().resolve() if args.file else default_output_path(args.prompt, ext) + if args.provider == "atlascloud": + try: + outputs = call_atlascloud_generate(args) + except RuntimeError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + try: + written = write_atlascloud_outputs(outputs, out_path) + except (OSError, ValueError, urllib.error.URLError) as e: + print(f"error: failed to write Atlas Cloud output: {e}", file=sys.stderr) + return 1 + for p in written: + print(p) + return 0 + client = OpenAI() # auto-reads OPENAI_API_KEY try: