#!/usr/bin/env python3 # -*- coding: utf-8 -*- """le-gpt-image 生图 CLI:复用本机 Codex 的 ChatGPT 登录调 GPT Image,无需 OpenAI API Key。 用法: gpt_image.py generate --prompt "..." --out cover.png --size portrait --quality high gpt_image.py edit --image in.png --prompt "把背景换成雪山" --out out.png gpt_image.py check # 查看凭证状态(不生图、不打印任何密钥) 协议参考 jdmnk/codex-imagegen-cli(Apache-2.0)与 OpenAI Codex CLI 的请求格式: 读取 ~/.codex/auth.json → access_token 临近过期时用 refresh_token 换新并原子写回 → 带 Codex 客户端请求头 POST chatgpt.com/backend-api/codex/images/generations|edits。 仅用 python3 标准库;Pillow 为可选增强(存在时把改图输入压缩成 WebP,否则原样内嵌)。 """ from __future__ import annotations import argparse import base64 import json import math import os import platform import re import shutil import subprocess import sys import tempfile import time from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any, Sequence from urllib import error, request try: import fcntl except ImportError: # Windows:无 flock,退化为无锁刷新 fcntl = None # type: ignore[assignment] try: import tomllib except ImportError: # Python < 3.11 tomllib = None # type: ignore[assignment] # 可选传输层:curl_cffi 以 Chrome TLS 指纹发请求。 # chatgpt.com 的 Cloudflare 会质询 python-urllib/curl 的握手指纹(403 cf-mitigated: challenge), # Chrome 指纹(及 Codex/ChatGPT.app 的网络栈)不受影响。未安装时回退 urllib。 try: from curl_cffi import requests as _cffi_requests _HAS_CFFI = True except ImportError: _HAS_CFFI = False __version__ = "1.0.0" DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex" REFRESH_URL = "https://auth.openai.com/oauth/token" CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" NATIVE_MODEL = "gpt-image-2" # 请求格式匹配值,实际图像模型由服务端决定 DEFAULT_REASONING_MODEL = "gpt-5.5" ORIGINATOR = "codex_cli_rs" MAX_EDIT_IMAGES = 5 MAX_RETRIES = 4 TOKEN_LEEWAY_SECONDS = 60 INPUT_MAX_EDGE = 1536 # 尺寸别名 → 具体像素(全部满足 16 倍数 / 边≤3840 / 1:3–3:1 / 总像素上下限) SIZE_ALIASES = { "1k": "1024x1024", "square": "1024x1024", "2k": "2048x2048", "4k": "3840x2160", "portrait": "1024x1536", "landscape": "1536x1024", "wide": "1536x640", "tall": "1024x2048", } class CliError(Exception): """预期内的用户可见失败。""" class HttpError(CliError): def __init__(self, status: int, body: str, retry_after: str | None = None) -> None: self.status = status self.body = body self.retry_after = retry_after super().__init__(f"HTTP {status}: {body[:800]}") def _log(msg: str) -> None: print(msg, file=sys.stderr) def _warn(msg: str) -> None: print(f"警告: {msg}", file=sys.stderr) # ---------------------------------------------------------------- 凭证与令牌 def _jwt_payload(token: str) -> dict[str, Any]: parts = token.split(".") if len(parts) < 2: return {} try: padded = parts[1] + "=" * (-len(parts[1]) % 4) payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")) except (ValueError, UnicodeDecodeError): return {} return payload if isinstance(payload, dict) else {} def _token_is_expiring(access_token: str, leeway: int = TOKEN_LEEWAY_SECONDS) -> bool: exp = _jwt_payload(access_token).get("exp") if not isinstance(exp, (int, float)): return False return exp <= datetime.now(timezone.utc).timestamp() + leeway def _codex_home(args: argparse.Namespace) -> Path: if getattr(args, "codex_home", None): return Path(args.codex_home).expanduser().resolve() if os.environ.get("CODEX_HOME"): return Path(os.environ["CODEX_HOME"]).expanduser().resolve() return Path.home() / ".codex" def _auth_file(args: argparse.Namespace) -> Path: if getattr(args, "auth_file", None): return Path(args.auth_file).expanduser().resolve() return _codex_home(args) / "auth.json" def _load_auth(auth_file: Path) -> dict[str, Any]: if not auth_file.exists(): raise CliError( f"未找到 Codex 凭证文件: {auth_file}\n" "本 skill 走 ChatGPT 订阅通道,需要先在本机登录 Codex CLI(`codex login` 选 ChatGPT 账号),\n" '且 Codex 需使用文件凭证存储(config.toml 里 cli_auth_credentials_store = "file")。' ) try: data = json.loads(auth_file.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise CliError(f"凭证文件不可读或不是合法 JSON: {auth_file} ({exc})") from exc if not isinstance(data, dict): raise CliError(f"凭证文件内容应为 JSON 对象: {auth_file}") return data def _tokens(auth: dict[str, Any]) -> dict[str, Any]: tokens = auth.get("tokens") if not isinstance(tokens, dict): raise CliError("凭证里没有 ChatGPT tokens,请重新 `codex login`(选 ChatGPT 登录)。") return tokens def _access_token(auth: dict[str, Any]) -> str: token = _tokens(auth).get("access_token") if not isinstance(token, str) or not token: raise CliError("缺少 access_token,请重新 `codex login`。") return token def _id_token_claims(auth: dict[str, Any]) -> dict[str, Any]: id_token = _tokens(auth).get("id_token") if isinstance(id_token, str) and id_token: payload = _jwt_payload(id_token) claim = payload.get("https://api.openai.com/auth") return claim if isinstance(claim, dict) else {} return {} def _account_id(auth: dict[str, Any]) -> str | None: account_id = _tokens(auth).get("account_id") if isinstance(account_id, str) and account_id: return account_id account_id = _id_token_claims(auth).get("chatgpt_account_id") return account_id if isinstance(account_id, str) and account_id else None def _auth_headers(auth: dict[str, Any]) -> dict[str, str]: headers = { "Authorization": f"Bearer {_access_token(auth)}", "Accept": "application/json", "Content-Type": "application/json", "originator": ORIGINATOR, "User-Agent": ( f"{ORIGINATOR}/{_codex_version()} ({platform.system()} {platform.machine()}) " f"le-gpt-image/{__version__}" ), "version": _codex_version(), } account_id = _account_id(auth) if account_id: headers["ChatGPT-Account-ID"] = account_id if _id_token_claims(auth).get("chatgpt_account_is_fedramp"): headers["X-OpenAI-Fedramp"] = "true" return headers def _codex_version() -> str: codex_bin = shutil.which("codex") if not codex_bin: return __version__ try: result = subprocess.run( [codex_bin, "--version"], check=False, text=True, capture_output=True, timeout=5 ) except (OSError, subprocess.SubprocessError): return __version__ match = re.search(r"(\d+\.\d+\.\d+)", f"{result.stdout}\n{result.stderr}") return match.group(1) if match else __version__ def _atomic_write(path: Path, data: bytes, *, replace: bool) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) try: with os.fdopen(fd, "wb") as output: output.write(data) output.flush() os.fsync(output.fileno()) if replace: os.replace(temporary, path) else: # 原子防覆盖:生成中途出现的输出文件绝不能被悄悄覆盖 try: os.link(temporary, path) except FileExistsError as exc: raise CliError(f"输出文件已存在: {path}(加 --force 才允许覆盖)") from exc finally: Path(temporary).unlink(missing_ok=True) @contextmanager def _refresh_lock(auth_file: Path, timeout: float): """与并发实例互斥的刷新锁;锁文件独立于 auth.json,才能扛住原子替换。""" lock_path = auth_file.with_name(auth_file.name + ".gptimg.lock") fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) with os.fdopen(fd, "r+b") as lock: if fcntl is None: yield return deadline = time.monotonic() + min(timeout, 30.0) while True: try: fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) break except (BlockingIOError, OSError) as exc: if time.monotonic() >= deadline: raise CliError("等待另一进程刷新凭证超时,请重试。") from exc time.sleep(0.05) try: yield finally: fcntl.flock(lock.fileno(), fcntl.LOCK_UN) def _browser_headers(headers: dict[str, str]) -> dict[str, str]: """curl_cffi 路径的请求头:剥掉会破坏 Chrome 模拟的自定义 UA/version。 实测「Chrome TLS 指纹 + codex_cli_rs UA」的矛盾组合会被边缘直接掐断连接; 纯浏览器头(Authorization/originator/ChatGPT-Account-ID)可完整走通生图。 """ trimmed = dict(headers) trimmed.pop("User-Agent", None) trimmed.pop("version", None) return trimmed def _post_json( url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float ) -> dict[str, Any]: if _HAS_CFFI: try: response = _cffi_requests.post( url, json=payload, headers=_browser_headers(headers), timeout=timeout, impersonate="chrome", ) except Exception as exc: # curl_cffi 的各类传输异常 raise CliError(f"网络请求失败: {exc}") from exc if response.status_code >= 400: raise HttpError( response.status_code, response.text or "", response.headers.get("Retry-After"), ) raw = response.text else: body = json.dumps(payload).encode("utf-8") req = request.Request(url, data=body, headers=headers, method="POST") try: with request.urlopen(req, timeout=timeout) as response: raw = response.read().decode("utf-8") except error.HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") raise HttpError( exc.code, raw, exc.headers.get("Retry-After") if exc.headers else None ) from exc except (error.URLError, OSError) as exc: raise CliError(f"网络请求失败: {exc}") from exc if not raw: return {} try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise CliError("响应不是合法 JSON。") from exc return parsed if isinstance(parsed, dict) else {} def _refresh_auth(auth: dict[str, Any], auth_file: Path, *, timeout: float) -> dict[str, Any]: import copy with _refresh_lock(auth_file, timeout): current = _load_auth(auth_file) if auth_file.exists() else auth # 别的进程刚刷新过就直接用新的 if ( current is not auth and current.get("tokens") != auth.get("tokens") and not _token_is_expiring(_access_token(current)) ): return current updated = copy.deepcopy(current) tokens = _tokens(updated) refresh_token = tokens.get("refresh_token") if not isinstance(refresh_token, str) or not refresh_token: raise CliError("缺少 refresh_token,请重新 `codex login`。") try: refreshed = _post_json( REFRESH_URL, headers={"Accept": "application/json", "Content-Type": "application/json"}, payload={ "client_id": CODEX_CLIENT_ID, "grant_type": "refresh_token", "refresh_token": refresh_token, }, timeout=timeout, ) except HttpError as exc: # OAuth 错误体可能带凭证内容,绝不回显 raise CliError(f"刷新令牌失败(HTTP {exc.status}),请重新 `codex login`。") from exc access = refreshed.get("access_token") if not isinstance(access, str) or not access: raise CliError("刷新响应里没有 access_token,凭证文件未改动。") for key in ("access_token", "refresh_token", "id_token"): value = refreshed.get(key) if isinstance(value, str) and value: tokens[key] = value if not isinstance(tokens.get("id_token"), str) or not tokens["id_token"]: raise CliError("刷新后 id_token 无效,请重新 `codex login`。") updated["last_refresh"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") _atomic_write(auth_file, (json.dumps(updated, indent=2) + "\n").encode(), replace=True) return updated def _load_ready_auth(args: argparse.Namespace) -> tuple[dict[str, Any], Path]: auth_file = _auth_file(args) auth = _load_auth(auth_file) if _token_is_expiring(_access_token(auth)): auth = _refresh_auth(auth, auth_file, timeout=args.timeout) return auth, auth_file # ---------------------------------------------------------------- 请求体 def _mime_and_bytes(path: Path) -> tuple[bytes, str]: raw = path.read_bytes() if raw.startswith(b"\x89PNG\r\n\x1a\n"): mime = "image/png" elif raw.startswith(b"\xff\xd8\xff"): mime = "image/jpeg" elif raw[0:4] == b"RIFF" and raw[8:12] == b"WEBP": mime = "image/webp" elif raw.startswith((b"GIF87a", b"GIF89a")): mime = "image/gif" else: raise CliError(f"无法识别图片格式(支持 PNG/JPEG/WebP/GIF): {path}") return raw, mime def _data_url_for_image(path: Path, max_edge: int) -> str: if not path.is_file(): raise CliError(f"输入图片不存在: {path}") try: # Pillow 可选增强:压到最长边 max_edge 的 WebP,省流量 from io import BytesIO from PIL import Image, ImageOps # type: ignore[import-not-found] with Image.open(path) as source: source.load() image = ImageOps.exif_transpose(source) width, height = image.size edge = max(width, height) if 0 < max_edge < edge: scale = max_edge / float(edge) image = image.resize( (max(1, round(width * scale)), max(1, round(height * scale))), Image.Resampling.LANCZOS, ) if image.mode not in {"RGB", "RGBA"}: image = image.convert("RGBA" if "A" in image.getbands() else "RGB") output = BytesIO() image.save(output, "WEBP", quality=90) raw, mime = output.getvalue(), "image/webp" except ImportError: raw, mime = _mime_and_bytes(path) except OSError as exc: raise CliError(f"读取图片失败 {path}: {exc}") from exc encoded = base64.b64encode(raw).decode("ascii") return f"data:{mime};base64,{encoded}" STYLE_TRANSFER_PROMPT = ( "Create a new version of the first input image. Preserve its main subject, identity, " "composition, proportions, and important details. Use the final input image only as a style " "reference: apply its visual medium, rendering approach, color palette, lighting, texture, " "finish, and mood. Do not copy the style reference's subject or scene content." ) def _native_payload( *, prompt: str, args: argparse.Namespace, mode: str, images: Sequence[Path] | None ) -> dict[str, Any]: payload: dict[str, Any] = { "model": NATIVE_MODEL, "prompt": prompt, "size": args.size, "quality": args.quality, "background": args.background, } if mode == "edit": if not images or len(images) > MAX_EDIT_IMAGES: raise CliError(f"改图需要 1–{MAX_EDIT_IMAGES} 张输入图。") payload["images"] = [ {"image_url": _data_url_for_image(path, args.input_max_edge)} for path in images ] return payload def _reasoning_model(args: argparse.Namespace) -> str: if args.model: return args.model if os.environ.get("CODEX_IMAGEGEN_MODEL"): return os.environ["CODEX_IMAGEGEN_MODEL"] config_file = _codex_home(args) / "config.toml" if tomllib is not None and config_file.exists(): try: config = tomllib.loads(config_file.read_text(encoding="utf-8")) model = config.get("model") if isinstance(model, str) and model.strip(): return model except (OSError, UnicodeError, Exception): pass return DEFAULT_REASONING_MODEL def _responses_payload( *, prompt: str, args: argparse.Namespace, mode: str, images: Sequence[Path] | None ) -> dict[str, Any]: content: list[dict[str, Any]] = [{"type": "input_text", "text": prompt}] if images: if len(images) > MAX_EDIT_IMAGES: raise CliError(f"改图最多 {MAX_EDIT_IMAGES} 张输入图。") for image in images: content.append( {"type": "input_image", "image_url": _data_url_for_image(image, args.input_max_edge)} ) instructions = ( "Use the available image generation tool to generate exactly one PNG image for the " "user request. Do not use any other tool." ) if mode == "edit": instructions += " Treat the provided input images as edit/reference images for the request." return { "model": _reasoning_model(args), "instructions": instructions, "input": [{"type": "message", "role": "user", "content": content}], "tools": [ { "type": "image_generation", "output_format": "png", "size": args.size, "quality": args.quality, "background": args.background, } ], "tool_choice": {"type": "image_generation"}, "parallel_tool_calls": False, "store": False, "stream": True, "prompt_cache_key": "le-gpt-image", } # ---------------------------------------------------------------- 调用与落盘 def _native_result( url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float ) -> bytes: response = _post_json(url, headers=headers, payload=payload, timeout=timeout) data = response.get("data") if not isinstance(data, list) or len(data) != 1 or not isinstance(data[0], dict): error_obj = response.get("error") if isinstance(error_obj, dict): raise CliError(f"生图失败: {error_obj.get('message') or error_obj}") raise CliError("响应里没有图像数据。") encoded = data[0].get("b64_json") if not isinstance(encoded, str) or not encoded: raise CliError("响应里的图像数据为空。") return base64.b64decode(encoded) def _sse_data_lines(url: str, headers: dict[str, str], payload: dict[str, Any], timeout: float): req_headers = {**headers, "Accept": "text/event-stream", "Content-Type": "application/json"} if _HAS_CFFI: req_headers = _browser_headers(req_headers) try: response = _cffi_requests.post( url, json=payload, headers=req_headers, timeout=timeout, impersonate="chrome", stream=True, ) if response.status_code >= 400: raise HttpError( response.status_code, response.text or "", response.headers.get("Retry-After"), ) for raw_line in response.iter_lines(): if isinstance(raw_line, bytes): line = raw_line.decode("utf-8", "replace") else: line = str(raw_line) stripped = line.strip() if stripped.startswith("data:"): data = stripped[5:].strip() if data and data != "[DONE]": yield data except HttpError: raise except Exception as exc: raise CliError(f"网络请求失败: {exc}") from exc return req = request.Request( url, data=json.dumps(payload).encode("utf-8"), headers=req_headers, method="POST" ) try: with request.urlopen(req, timeout=timeout) as response: buf = "" while True: chunk = response.read(4096) if not chunk: break buf += chunk.decode("utf-8", "replace") while "\n" in buf: line, buf = buf.split("\n", 1) stripped = line.strip() if stripped.startswith("data:"): data = stripped[5:].strip() if data and data != "[DONE]": yield data tail = buf.strip() if tail.startswith("data:"): data = tail[5:].strip() if data and data != "[DONE]": yield data except error.HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") raise HttpError(exc.code, raw, exc.headers.get("Retry-After") if exc.headers else None) from exc except (error.URLError, OSError) as exc: raise CliError(f"网络请求失败: {exc}") from exc def _responses_result( url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float ) -> bytes: last_status: str | None = None for data in _sse_data_lines(url, headers, payload, timeout): try: event = json.loads(data) except json.JSONDecodeError as exc: raise CliError("响应流里包含非法 JSON。") from exc if not isinstance(event, dict): continue if event.get("type") in {"response.failed", "response.incomplete", "error"}: error_obj = event.get("error") or (event.get("response") or {}).get("error") message = error_obj.get("message") if isinstance(error_obj, dict) else None raise CliError(f"responses 通道生图失败: {message or event}") items: list[Any] = [event.get("item")] if event.get("type") == "response.completed": response_obj = event.get("response") if isinstance(response_obj, dict) and isinstance(response_obj.get("output"), list): items.extend(response_obj["output"]) for item in items: if isinstance(item, dict) and item.get("type") == "image_generation_call": last_status = item.get("status") result = item.get("result") if isinstance(result, str) and result and last_status == "completed": return base64.b64decode(result) if last_status: raise CliError(f"响应流结束但没有拿到图像(最后状态: {last_status})。") raise CliError("响应流里没有图像生成结果。") def _retry_decision(exc: HttpError, attempt: int) -> float | None: """只在明确限流时重试;传输类/5xx 故意不重放——图像可能已消耗额度。""" if exc.status != 429: return None delay = min(2.0**attempt, 16.0) if exc.retry_after: try: wait = float(exc.retry_after) except ValueError: wait = 0.0 delay = max(delay, wait) return delay if math.isfinite(delay) and delay <= 300 else None def _png_size(image_bytes: bytes) -> tuple[int, int] | None: if image_bytes[:8] != b"\x89PNG\r\n\x1a\n" or len(image_bytes) < 24: return None width = int.from_bytes(image_bytes[16:20], "big") height = int.from_bytes(image_bytes[20:24], "big") return width, height def _write_image(image_bytes: bytes, out_path: Path, *, force: bool, requested_size: str) -> None: actual = _png_size(image_bytes) if actual is None: raise CliError("服务端返回的不是有效 PNG 图像。") _atomic_write(out_path, image_bytes, replace=force) if requested_size != "auto": requested = requested_size.lower().replace("x", "×") actual_text = f"{actual[0]}x{actual[1]}" if requested_size != actual_text: _warn(f"请求尺寸 {requested_size},服务端实际返回 {actual_text},已按实际尺寸保存。") _log(f" 已保存 {actual[0]}x{actual[1]} PNG") def _call_and_save( *, args: argparse.Namespace, url: str, payload: dict[str, Any], native: bool, out_path: Path ) -> None: if args.dry_run: preview = json.dumps( { "method": "POST", "url": url, "headers": {"Authorization": "Bearer "}, "payload": _redact_payload(payload), "outputs": [str(out_path)], }, ensure_ascii=False, indent=2, ) print(preview) return auth, auth_file = _load_ready_auth(args) headers = _auth_headers(auth) call = _native_result if native else _responses_result attempt = 0 refreshed_401 = False t0 = time.monotonic() while True: try: image_bytes = call(url, headers=headers, payload=payload, timeout=args.timeout) break except HttpError as exc: if exc.status == 401 and not refreshed_401: auth = _refresh_auth(auth, auth_file, timeout=args.timeout) headers = _auth_headers(auth) refreshed_401 = True continue delay = _retry_decision(exc, attempt) if delay is None or attempt >= MAX_RETRIES: raise attempt += 1 _warn(f"被限流,{delay:.0f}s 后重试({attempt}/{MAX_RETRIES})") time.sleep(delay) _write_image(image_bytes, out_path, force=args.force, requested_size=args.size) _log(f" 完成({time.monotonic() - t0:.1f}s)") def _redact_payload(payload: dict[str, Any]) -> dict[str, Any]: def redact(value: Any) -> Any: if isinstance(value, dict): return { key: (f"{str(item).split(';base64,', 1)[0]};base64," if key == "image_url" and isinstance(item, str) else redact(item)) for key, item in value.items() } if isinstance(value, list): return [redact(item) for item in value] return value return redact(payload) # ---------------------------------------------------------------- 参数与入口 def _parse_size(value: str) -> str: value = value.strip().lower() if value in SIZE_ALIASES: return SIZE_ALIASES[value] if value == "auto": return value match = re.fullmatch(r"([1-9][0-9]{0,3})x([1-9][0-9]{0,3})", value) if not match: raise argparse.ArgumentTypeError( f"尺寸须为 auto、别名({'/'.join(SIZE_ALIASES)})或 WIDTHxHEIGHT,收到: {value}" ) width, height = map(int, match.groups()) if not ( width % 16 == height % 16 == 0 and max(width, height) <= 3840 and max(width, height) <= 3 * min(width, height) and 655360 <= width * height <= 8294400 ): raise argparse.ArgumentTypeError( "尺寸约束不满足: 宽高为 16 的倍数、最长边 ≤3840、宽高比 1:3–3:1、" "总像素 655360–8294400。" ) return value def _slugify(text: str) -> str: value = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") return value[:60] or "image" def _default_out(prompt: str) -> Path: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") return Path("fig") / f"{stamp}-{_slugify(prompt)}.png" def _resolve_path(raw: str, cd: Path) -> Path: path = Path(raw).expanduser() if not path.is_absolute(): path = cd / path return path.resolve() def _read_prompt(args: argparse.Namespace) -> str | None: if args.prompt is not None and args.prompt_file is not None: raise CliError("--prompt 与 --prompt-file 只能二选一。") if args.prompt_file is not None: text = Path(args.prompt_file).read_text(encoding="utf-8").strip() elif args.prompt is not None: text = args.prompt.strip() else: return None if not text: raise CliError("prompt 不能为空。") return text def _validate_common(args: argparse.Namespace) -> Path: if not math.isfinite(args.timeout) or args.timeout <= 0: raise CliError("--timeout 须为正数。") if args.n < 1: raise CliError("--n 至少为 1。") if args.input_max_edge < 0: raise CliError("--input-max-edge 须 ≥0。") if args.background == "transparent": raise CliError("当前 Codex 图像通道不支持透明背景,请用 auto 或 opaque。") if args.backend == "native" and getattr(args, "model", None): raise CliError("--model 只在 --backend responses 下有效(它选的是推理模型,不是图像模型)。") cd = Path(getattr(args, "cd", None) or os.getcwd()).expanduser().resolve() if not cd.is_dir(): raise CliError(f"--cd 目录不存在: {cd}") return cd def _output_paths(out_path: Path, count: int) -> list[Path]: if count <= 1: return [out_path] return [ out_path.with_name(f"{out_path.stem}-{idx}{out_path.suffix or '.png'}") for idx in range(1, count + 1) ] def _run_jobs( *, args: argparse.Namespace, mode: str, prompt: str, out_path: Path, images: Sequence[Path] | None, ) -> None: paths = _output_paths(out_path, args.n) if not args.dry_run: for path in paths: if path.exists() and not args.force: raise CliError(f"输出文件已存在: {path}(加 --force 才允许覆盖)") count_text = f"{args.n}× " if args.n > 1 else "" _log(f"{mode} {count_text}{out_path}") if args.backend == "native": url = args.base_url.rstrip("/") + ("/images/edits" if mode == "edit" else "/images/generations") payload = _native_payload(prompt=prompt, args=args, mode=mode, images=images) native = True else: url = args.base_url.rstrip("/") + "/responses" payload = _responses_payload(prompt=prompt, args=args, mode=mode, images=images) native = False for path in paths: _call_and_save(args=args, url=url, payload=payload, native=native, out_path=path) print(str(path), flush=True) # stdout 输出最终路径,供 agent 解析 def _cmd_generate(args: argparse.Namespace) -> int: cd = _validate_common(args) prompt = _read_prompt(args) if prompt is None: raise CliError("缺少 prompt,用 --prompt 或 --prompt-file。") out_path = _resolve_path(args.out, cd) if args.out else (cd / _default_out(prompt)) _run_jobs(args=args, mode="generate", prompt=prompt, out_path=out_path, images=None) return 0 def _cmd_edit(args: argparse.Namespace) -> int: cd = _validate_common(args) prompt = _read_prompt(args) if args.style_image: extra = f"\n\nAdditional instruction: {prompt}" if prompt else "" prompt = STYLE_TRANSFER_PROMPT + extra elif prompt is None: raise CliError("缺少 prompt,用 --prompt、--prompt-file 或 --style-image。") out_path = _resolve_path(args.out, cd) if args.out else (cd / _default_out(prompt)) images = [_resolve_path(raw, cd) for raw in args.image] if args.style_image: images.append(_resolve_path(args.style_image, cd)) _run_jobs(args=args, mode="edit", prompt=prompt, out_path=out_path, images=images) return 0 def _cmd_check(args: argparse.Namespace) -> int: auth_file = _auth_file(args) auth = _load_auth(auth_file) print(f"凭证文件: {auth_file}") print(f"登录方式: {auth.get('auth_mode', '未知')}") if auth.get("auth_mode") and auth["auth_mode"] != "chatgpt": raise CliError("当前不是 ChatGPT 登录态(可能是 API Key 模式),请 `codex login` 选 ChatGPT。") tokens = _tokens(auth) account_id = _account_id(auth) print(f"账号 ID: {account_id[:8]}…(已截断)" if account_id else "账号 ID: 未找到") plan = _id_token_claims(auth).get("chatgpt_plan_type") print(f"套餐类型: {plan or '未知'}") exp = _jwt_payload(_access_token(auth)).get("exp") if isinstance(exp, (int, float)): hours = (exp - datetime.now(timezone.utc).timestamp()) / 3600 print(f"access_token: 剩余 {hours:.1f} 小时") else: print("access_token: 无法解析有效期") print(f"refresh_token: {'有' if tokens.get('refresh_token') else '无'}") if args.refresh: _refresh_auth(auth, auth_file, timeout=args.timeout) print("已刷新令牌并写回凭证文件。") else: print("提示: 加 --refresh 可强制刷新令牌。") return 0 def _add_common_args(parser: argparse.ArgumentParser, *, require_out: bool) -> None: parser.add_argument("--prompt") parser.add_argument("--prompt-file") parser.add_argument("--out", required=require_out, help="输出路径;缺省为 fig/时间戳-slug.png") parser.add_argument("--force", action="store_true", help="允许覆盖已存在的输出文件") parser.add_argument( "--size", type=_parse_size, default="auto", help="尺寸: auto / 别名(1k 2k 4k square portrait landscape wide tall) / WIDTHxHEIGHT", ) parser.add_argument("--quality", choices=["auto", "low", "medium", "high"], default="auto") parser.add_argument("--background", choices=["auto", "opaque"], default="auto") parser.add_argument("--n", type=int, default=1, help="生成张数(逐张请求,文件名加 -1..-n)") parser.add_argument( "--backend", choices=["native", "responses"], default="native", help="native=GPT Image 直连通道(默认);responses=走推理模型的备用通道", ) parser.add_argument("--model", help="responses 通道的推理模型(默认读 Codex 配置或 gpt-5.5)") parser.add_argument("--base-url", default=os.environ.get("CODEX_IMAGEGEN_BASE_URL", DEFAULT_BASE_URL)) parser.add_argument("--timeout", type=float, default=300.0) parser.add_argument("--auth-file", help="自定义 auth.json 路径") parser.add_argument("--codex-home", help="自定义 Codex 目录(默认 $CODEX_HOME 或 ~/.codex)") parser.add_argument("--cd", help="相对路径的基准目录") parser.add_argument("--input-max-edge", type=int, default=INPUT_MAX_EDGE, help="改图输入压缩的最长边像素,0 为不压缩") parser.add_argument("--dry-run", action="store_true", help="只打印请求(凭证打码),不联网不生图") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="gpt_image.py", description="用本机 Codex ChatGPT 登录调 GPT Image 生图/改图(无需 API Key)", ) parser.add_argument("--version", "-v", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True) generate = subparsers.add_parser("generate", help="文生图") _add_common_args(generate, require_out=False) generate.set_defaults(func=_cmd_generate) edit = subparsers.add_parser("edit", help="参考图改图 / 风格迁移") _add_common_args(edit, require_out=False) edit.add_argument("--image", action="append", required=True, help="输入图片路径,可重复(1–5 张)") edit.add_argument("--style-image", help="风格参考图:内容取 --image,画风取此图(此时 --prompt 变为可选补充)") edit.set_defaults(func=_cmd_edit) check = subparsers.add_parser("check", help="检查/刷新本机凭证状态") check.add_argument("--refresh", action="store_true", help="强制刷新令牌") check.add_argument("--timeout", type=float, default=60.0) check.add_argument("--auth-file") check.add_argument("--codex-home") check.set_defaults(func=_cmd_check) return parser def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) if not getattr(args, "dry_run", False) and args.func is not _cmd_check: _log( "传输层: curl_cffi (Chrome 指纹)" if _HAS_CFFI else "传输层: urllib(若被 Cloudflare 质询,pip install curl_cffi 后重试)" ) try: return int(args.func(args)) except CliError as exc: print(f"错误: {exc}", file=sys.stderr) return 1 except (OSError, UnicodeError) as exc: print(f"错误: {exc}", file=sys.stderr) return 1 except KeyboardInterrupt: print("已中断。", file=sys.stderr) return 130 if __name__ == "__main__": raise SystemExit(main())