"""gpt_image: 调 unifyllm 网关的 OpenAI Images API 生图,产物落 working_dir/figures/。 第二图像后端(第一个是豆包 seedream):模型 ID + 单价全在 `config/media/unifyllm.yaml`, 本 tool 只装配。与 seedream 的差异(网关实测,见 yaml 头注释): - 只支持文生图,**不支持改图(i2i)/ 自定尺寸 / 质量档**(网关忽略 size/quality 参数, 实际尺寸由上游模型按画面自动决定),所以参数只有 prompt; - 响应直接返 b64_json,无需二次下载; - 单张 ~35-40s(慢于 seedream 3-5s)。 完成后: - 图片落 `/figures/-.png` + 同名 `.meta.json` - usage_events 写 kind="image" 一行(model_profile="unifyllm.", usage tokens 记进 units → 网关价目公布后可回填对账) """ from __future__ import annotations import base64 import json import secrets import time from datetime import datetime from pathlib import Path from typing import Optional from uuid import UUID from core.ark_client import ArkClient, ArkConfig, ArkError from core.storage.usage import check_daily_quota, record_image_usage from .base import Tool class GptImageTool(Tool): name = "gpt_image" description = ( "Generate an image (text-to-image only) via the GPT image backend, " "saved to working_dir/figures/. Slower than seedream (~40-60s/image); output size is " "chosen automatically by the model and CANNOT be specified. Does NOT support " "image-to-image editing — if the user wants to modify an existing image or needs an " "exact size/aspect ratio, tell them to switch the image model back to 豆包 Seedream " "(顶栏图像模型下拉). Don't generate decoratively — only when the user actually " "wants an image. Returns the saved relative path." ) parameters = { "type": "object", "properties": { "prompt": { "type": "string", "description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。尺寸/比例不可控,别在 prompt 里承诺具体分辨率。", }, }, "required": ["prompt"], } def __init__( self, *, gw_cfg: ArkConfig, image_variant_cfg: dict, variant_key: str, working_dir: Path, task_id: UUID, user_id: UUID, base_dir: Optional[Path] = None, user_root: Optional[Path] = None, daily_limit: int = 0, ) -> None: super().__init__(base_dir, user_root=user_root) self.gw_cfg = gw_cfg self.cfg = image_variant_cfg self.variant_key = variant_key # 'gpt_image' → usage_events.model_profile = "unifyllm.gpt_image" self.working_dir = Path(working_dir) self.task_id = task_id self.user_id = user_id self.daily_limit = int(daily_limit) # 0 / 负 = 不限;与 seedream 共享 kind="image" 每日配额 def execute(self, prompt: str) -> str: if not (prompt or "").strip(): return "[Error] prompt 不能为空" # 每账号每日配额(kind="image" 与 seedream 同口径合计;失败不落库故 retry 不计) if self.daily_limit > 0: used, over = check_daily_quota(user_id=self.user_id, kind="image", limit=self.daily_limit) if over: return ( f"[Error] 已达每日图片生成上限({used}/{self.daily_limit} 张)," f"次日 00:00 重置。" ) cfg = self.cfg model_id = cfg["model_id"] endpoint = cfg.get("endpoint", "/images/generations") timeout_s = float(cfg.get("request_timeout_s", 300)) price = float(cfg.get("price_cny_per_image", 0)) body = {"model": model_id, "prompt": prompt, "n": 1} t0 = time.monotonic() try: with ArkClient(self.gw_cfg, timeout_s=timeout_s) as client: resp = client.post_json(endpoint, body, timeout_s=timeout_s) except ArkError as e: return f"[Error] gpt_image API: {e}" data = resp.get("data") b64 = "" if isinstance(data, list) and data and isinstance(data[0], dict): b64 = data[0].get("b64_json") or "" if not b64: return f"[Error] gpt_image response 缺 b64_json: {json.dumps(resp, ensure_ascii=False)[:300]}" try: img_bytes = base64.b64decode(b64) except Exception as e: return f"[Error] gpt_image b64 解码失败: {e}" ts = datetime.now().strftime("%Y%m%d-%H%M%S") short = secrets.token_hex(3) dest_png = self.working_dir / "figures" / f"{ts}-{short}.png" dest_png.parent.mkdir(parents=True, exist_ok=True) dest_png.write_bytes(img_bytes) elapsed = time.monotonic() - t0 # 网关回填的实际尺寸/质量/tokens(价目未知期成本记 price snapshot,tokens 留对账) actual_size = str(resp.get("size") or "") quality = str(resp.get("quality") or "") usage = resp.get("usage") or {} output_tokens = int(usage.get("output_tokens") or 0) meta = { "prompt": prompt, "model_id": model_id, "size": actual_size, "quality": quality, "mode": "t2i", "cost_cny": price, "output_tokens": output_tokens, "elapsed_s": round(elapsed, 2), "ts": datetime.now().isoformat(timespec="seconds"), } dest_png.with_suffix(".meta.json").write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" ) try: record_image_usage( task_id=self.task_id, user_id=self.user_id, model_profile=f"unifyllm.{self.variant_key}", n_images=1, size=actual_size, price_cny_per_image=price, extra_units={"output_tokens": output_tokens, "quality": quality}, ) except Exception as e: print(f"[gpt_image] record_image_usage failed: {type(e).__name__}: {e}", flush=True) disp = self._display(dest_png) # 首行 banner 协议同 seedream(`key=value · ` 分隔,前端 extractMediaBanner 解析); # 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。 cost_seg = f" · cost=¥{price:.2f}" if price > 0 else "" return ( f"[gpt_image] model={model_id} · size={actual_size}{cost_seg} · elapsed={elapsed:.1f}s\n" f"saved: {disp}\n" f"prompt={prompt!r}" )