164 lines
6.5 KiB
Python
164 lines
6.5 KiB
Python
"""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)。
|
|
完成后:
|
|
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png` + 同名 `.meta.json`
|
|
- usage_events 写 kind="image" 一行(model_profile="unifyllm.<variant>",
|
|
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}"
|
|
)
|