Files

936 lines
37 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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]
# 可选传输层: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"]
# 注意:读 Codex config 的 model 字段曾踩坑——本机 config 里是 gpt-6.1-sol,
# 它在内部 responses 端点带图像工具会 500。没显式指定时用实测可用的默认值。
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 <redacted>"},
"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,<redacted>"
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())