Files
codex-app-server/cloud/imagegen.py
T
host 7a920cee59 fix: imagegen MCP env 必须写死本地网关 base URL
首轮生产验收失败根因:_prepare_tenant 只把 IMAGE_GEN_* 透传
进 MCP env,没写 IMAGE_GEN_BASE_URL,也没带 SUB2API_BASE_URL,
于是 imagegen.py 回落到公网 sub2.diante.me,而它不在
Tinyproxy 白名单里,API 调用 403 Filtered。
修法:worker 在写 MCP env 前用 imagegen.build_mcp_env 算好
base(显式 IMAGE_GEN_BASE_URL > SUB2API_BASE_URL),并把
网关 host 加进 NO_PROXY;组装逻辑抽成可单测 helper,新增
2 项回归测试(本地网关解析、显式 base 优先)。
2026-10-30 03:07:31 +08:00

342 lines
13 KiB
Python

"""Image generation through the sub2api relay (image group).
Measured 2026-10-30: the relay's image group (group 4) serves
``gpt-image-2.5-flare`` at ``/v1/images/generations`` and returns a
download URL (not ``b64_json``) for the finished PNG. Production
task keys live in group 2 (``codex-system``), whose
``allow_image_generation`` is False, so a task's own gateway key
cannot generate images -- this tool uses a separate system-level
image key, ``IMAGE_GEN_API_KEY``, like the search keys. Never fall
back to the task user's gateway key: it 404s with
``model_not_found`` and the failure looks like a broken tool.
Env (read by the MCP process, never printed in errors):
- ``IMAGE_GEN_BASE_URL``: relay origin, no trailing ``/v1``.
Defaults to ``SUB2API_BASE_URL`` (local gateway origin,
reachable from task containers via NO_PROXY), else
``https://sub2.diante.me``.
- ``IMAGE_GEN_API_KEY``: system image-group key. Missing key is a
configuration error, reported as such, never as a model error.
- ``IMAGE_GEN_MODEL``: default model, default
``gpt-image-2.5-flare`` (verified 2026-10-30: 200, real PNG
1024x1024, 829K, via the relay).
Network notes for task containers: the API call to the local
gateway origin must bypass the egress proxy (gateway host in
NO_PROXY); downloading the returned image URL goes through the
egress proxy, so the image CDN host (observed:
``*.qinglanapi.top``) must be on the Tinyproxy whitelist, or the
download step fails after a successful generation -- the tool
reports that distinctly.
"""
import base64
import json
import os
import struct
import time
import urllib.error
import urllib.request
DEFAULT_MODEL = "gpt-image-2.5-flare"
DEFAULT_BASE_URL = "https://sub2.diante.me"
ALLOWED_SIZES = ("1024x1024", "1536x1024", "1024x1536", "auto")
API_TIMEOUT = 180
DOWNLOAD_TIMEOUT = 120
class ImageGenError(RuntimeError):
"""Image generation failure. Messages never contain API keys."""
def _base_url():
base = (os.environ.get("IMAGE_GEN_BASE_URL", "").strip()
or os.environ.get("SUB2API_BASE_URL", "").strip()
or DEFAULT_BASE_URL)
return base.rstrip("/")
def _api_key():
return os.environ.get("IMAGE_GEN_API_KEY", "").strip()
def _default_model():
return os.environ.get("IMAGE_GEN_MODEL", "").strip() or DEFAULT_MODEL
def _detect_format(raw):
"""Return (format, width, height) from magic bytes; width/height
only for PNG (IHDR), otherwise None -- the caller verifies with
PIL in shell when it needs exact dimensions."""
if raw[:8] == b"\x89PNG\r\n\x1a\n" and len(raw) >= 24:
width, height = struct.unpack(">II", raw[16:24])
return "png", width, height
if raw[:3] == b"\xff\xd8\xff":
return "jpeg", None, None
if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
return "webp", None, None
if raw[:6] in (b"GIF87a", b"GIF89a"):
return "gif", None, None
return "unknown", None, None
def _post_json(url, key, body, request=None):
if request is not None:
return request("POST", url, body, key)
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method="POST", headers={
"Authorization": "Bearer " + key, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=API_TIMEOUT) as resp:
return resp.status, json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
payload = json.loads(raw or "{}")
except ValueError:
payload = {"raw": raw[:400]}
return exc.code, payload
except Exception as exc: # noqa: BLE001 - surfaced as tool error
return 0, {"error": str(exc)[:300]}
def _download(url, download=None):
if download is not None:
return download(url)
req = urllib.request.Request(url, headers={
"User-Agent": "codex-imagegen/1.0"})
try:
with urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT) as resp:
return resp.read()
except Exception as exc: # noqa: BLE001 - surfaced as tool error
raise ImageGenError("image download failed: %s" % exc)
def generate(prompt, output_filename=None, size="1024x1024",
model=None, request=None, download=None, cwd=None):
"""Generate one image and save it to the current working
directory (the task worktree when run as a Codex MCP server).
``output_filename`` must be a plain basename (no separators,
no ``..``); default is ``generated-<epoch>.png``. Returns a
dict with path, format, dimensions (PNG only), byte size and
the relay's usage block. Raises ImageGenError on any failure;
the message never contains the API key.
"""
if not prompt or not str(prompt).strip():
raise ImageGenError("prompt is required")
if size not in ALLOWED_SIZES:
raise ImageGenError("unsupported size: %r (allowed: %s)"
% (size, ", ".join(ALLOWED_SIZES)))
key = _api_key()
if not key:
raise ImageGenError(
"image generation not configured: IMAGE_GEN_API_KEY "
"is not set in the MCP environment")
name = output_filename or ("generated-%d.png" % int(time.time()))
if (os.path.basename(name) != name or name in (".", "..")
or "/" in name or "\\" in name):
raise ImageGenError(
"output_filename must be a plain basename, got %r" % name)
use_model = model or _default_model()
status, data = _post_json(
_base_url() + "/v1/images/generations", key,
{"model": use_model, "prompt": str(prompt), "size": size},
request=request)
if status != 200:
err = (data or {}).get("error") or {}
msg = err.get("message") if isinstance(err, dict) else str(err)
raise ImageGenError(
"image generation failed: HTTP %s: %s"
% (status, (msg or data or "unknown error")))
items = (data or {}).get("data") or []
if not items or not isinstance(items[0], dict):
raise ImageGenError("image generation returned no image data")
first = items[0]
b64 = first.get("b64_json")
if b64:
try:
raw = base64.b64decode(b64)
except Exception as exc: # noqa: BLE001
raise ImageGenError("invalid b64_json in response: %s" % exc)
elif first.get("url"):
raw = _download(first["url"], download=download)
else:
raise ImageGenError(
"image generation returned neither url nor b64_json "
"(keys: %s)" % ", ".join(sorted(first.keys())))
if not raw:
raise ImageGenError("downloaded image is empty")
fmt, width, height = _detect_format(raw)
workdir = os.path.abspath(cwd or os.getcwd())
path = os.path.join(workdir, name)
with open(path, "wb") as fh:
fh.write(raw)
result = {"path": path, "filename": name, "format": fmt,
"bytes": len(raw), "model": use_model, "size": size,
"revised_prompt": first.get("revised_prompt"),
"usage": data.get("usage")}
if width is not None:
result["width"] = width
result["height"] = height
return result
# ------------------------------------------------------- codex-home glue
def ensure_codex_home_imagegen(codex_home_dir, command_args=None,
script_dir=None):
"""Seed imagegen MCP files + config into a tenant codex-home.
Mirrors ``ensure_codex_home_websearch``: copies ``imagegen.py``
and ``imagegen_mcp.py`` only when missing or identical (a
tenant-modified script is never silently overwritten, it is
reported in ``script_differs``), and appends the
``[mcp_servers.imagegen]`` section only when absent. Keys are
never written here -- they arrive via
``ensure_imagegen_mcp_env`` from worker env.
"""
import shutil
home = os.path.abspath(codex_home_dir)
os.makedirs(home, exist_ok=True)
here = os.path.abspath(script_dir or os.path.dirname(__file__))
script_changed = False
script_differs = []
for name in ("imagegen.py", "imagegen_mcp.py"):
src = os.path.join(here, name)
dst = os.path.join(home, name)
if not os.path.exists(src):
continue
if not os.path.exists(dst):
shutil.copyfile(src, dst)
script_changed = True
else:
with open(src, "rb") as fh:
src_bytes = fh.read()
with open(dst, "rb") as fh:
dst_bytes = fh.read()
if src_bytes != dst_bytes:
script_differs.append(name)
continue
config_path = os.path.join(home, "config.toml")
if command_args is None:
command_args = ["python3",
os.path.join(home, "imagegen_mcp.py")]
section = ("\n[mcp_servers.imagegen]\n"
"command = %s\n"
"args = [%s]\n"
% (json.dumps(command_args[0]),
", ".join(json.dumps(a) for a in command_args[1:])))
config_changed = False
existing = ""
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as fh:
existing = fh.read()
if "[mcp_servers.imagegen]" not in existing:
with open(config_path, "a", encoding="utf-8") as fh:
if existing and not existing.endswith("\n"):
fh.write("\n")
fh.write(section)
config_changed = True
return {"config_path": config_path,
"script_path": os.path.join(home, "imagegen_mcp.py"),
"config_changed": config_changed,
"script_changed": script_changed,
"script_differs": script_differs}
def ensure_imagegen_mcp_env(codex_home_dir, env):
"""Write [mcp_servers.imagegen.env] into a tenant config.toml.
Same rationale as ``ensure_websearch_mcp_env``: Codex's MCP
subprocess does not inherit the Codex process env, so the env
must live in config.toml. Idempotent in-place replacement of
the env subsection; other sections untouched. Returns True
when the file was written.
"""
if not env:
return False
home = os.path.abspath(codex_home_dir)
config_path = os.path.join(home, "config.toml")
try:
existing = ""
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as fh:
existing = fh.read()
if "[mcp_servers.imagegen]" not in existing:
existing += ("\n[mcp_servers.imagegen]\n"
"command = \"python3\"\n"
"args = [\"/var/lib/codex-app-server/"
"codex-home/imagegen_mcp.py\"]\n")
env_lines = ["[mcp_servers.imagegen.env]"]
for key in sorted(env):
env_lines.append("%s = %s" % (key, json.dumps(env[key])))
env_block = "\n".join(env_lines) + "\n"
lines = existing.splitlines(keepends=True)
start = end = None
for i, line in enumerate(lines):
if line.strip() == "[mcp_servers.imagegen.env]":
start = i
elif start is not None and line.startswith("["):
end = i
break
if start is not None:
if end is None:
end = len(lines)
lines[start:end] = [env_block]
new_text = "".join(lines)
else:
insert_at = None
in_section = False
for i, line in enumerate(lines):
if line.strip() == "[mcp_servers.imagegen]":
in_section = True
elif in_section and line.startswith("["):
insert_at = i
break
if insert_at is None:
insert_at = len(lines)
lines.insert(insert_at, env_block)
new_text = "".join(lines)
if new_text == existing:
return False
with open(config_path, "w", encoding="utf-8") as fh:
fh.write(new_text)
return True
except Exception:
return False
def build_mcp_env(worker_environ, proxy_env=None):
"""Build the [mcp_servers.imagegen.env] dict from worker env.
The MCP process only sees config.toml env, never the worker's
own env, so IMAGE_GEN_BASE_URL must be resolved HERE (worker
IMAGE_GEN_BASE_URL, else SUB2API_BASE_URL, else absent and
imagegen.py falls back to the public relay domain -- which is
NOT on the Tinyproxy whitelist, so a missing base here caused
a 403 Filtered in the first production verification, 2026-10-30).
proxy_env supplies HTTP(S)_PROXY for downloading the returned
image URL; NO_PROXY gains the gateway host for direct API calls.
Never injects the task user's gateway key.
"""
env = dict(proxy_env) if proxy_env else {}
for key in ("IMAGE_GEN_BASE_URL", "IMAGE_GEN_API_KEY",
"IMAGE_GEN_MODEL"):
if worker_environ.get(key):
env[key] = worker_environ[key]
base = (worker_environ.get("IMAGE_GEN_BASE_URL", "")
or worker_environ.get("SUB2API_BASE_URL", ""))
if base:
env["IMAGE_GEN_BASE_URL"] = base
host = base.split("://", 1)[-1].split("/", 1)[0]
host = host.split(":", 1)[0]
parts = [p for p in env.get("NO_PROXY", "").split(",") if p]
for item in ("localhost", "127.0.0.1", host):
if item and item not in parts:
parts.append(item)
env["NO_PROXY"] = ",".join(parts)
env["no_proxy"] = ",".join(parts)
return env