feat(image): support GPT image editing
This commit is contained in:
parent
57655984dd
commit
dfda6fd772
|
|
@ -9,9 +9,8 @@
|
||||||
# HTTP 400 "images endpoint requires an image model"。
|
# HTTP 400 "images endpoint requires an image model"。
|
||||||
# - gpt-image-2 支持 size / quality:尺寸可用 auto 或满足约束的 WIDTHxHEIGHT,
|
# - gpt-image-2 支持 size / quality:尺寸可用 auto 或满足约束的 WIDTHxHEIGHT,
|
||||||
# quality 可用 auto / low / medium / high。输出固定 PNG,透明背景当前模型不支持。
|
# quality 可用 auto / low / medium / high。输出固定 PNG,透明背景当前模型不支持。
|
||||||
# - 2026-07-31 实测网关 /images/edits 暂不可用:官方 JSON 返回
|
# - 2026-08-03 实测 gpt-image-2 已可通过 /images/edits multipart 单图改图,
|
||||||
# convert_request_failed,官方 multipart(image[] / image)均返回 NextPart: EOF;
|
# 响应同时含 b64_json 与 data URL;当前账号未开放 gpt-image-2-ad。
|
||||||
# Responses API 也静默丢弃强制 image_generation tool。网关修复前不暴露改图参数。
|
|
||||||
# - 复杂图片可能需 ~2min(慢于 seedream)
|
# - 复杂图片可能需 ~2min(慢于 seedream)
|
||||||
# - 价格:网关未公布价目,price 暂 0(usage tokens 记进 units,拿到价目后回填对账)
|
# - 价格:网关未公布价目,price 暂 0(usage tokens 记进 units,拿到价目后回填对账)
|
||||||
# - 服务器需代理出口(直连 unifyllm.ai TLS 失败),同文本模型
|
# - 服务器需代理出口(直连 unifyllm.ai TLS 失败),同文本模型
|
||||||
|
|
@ -24,6 +23,7 @@ image:
|
||||||
model_id: gpt-image-2
|
model_id: gpt-image-2
|
||||||
display_name: GPT 生图
|
display_name: GPT 生图
|
||||||
endpoint: /images/generations
|
endpoint: /images/generations
|
||||||
|
edit_endpoint: /images/edits
|
||||||
default_size: auto
|
default_size: auto
|
||||||
default_quality: auto
|
default_quality: auto
|
||||||
price_cny_per_image: 0 # 网关价目未知,成本先记 0;拿到价目改这里 + 重启
|
price_cny_per_image: 0 # 网关价目未知,成本先记 0;拿到价目改这里 + 重启
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,8 @@ _MEDIA_SEEDREAM_SEG = """\
|
||||||
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径。"""
|
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径。"""
|
||||||
_MEDIA_GPT_IMAGE_SEG = """\
|
_MEDIA_GPT_IMAGE_SEG = """\
|
||||||
- `gpt_image` —— GPT 图像生成(本 run 用户在顶栏选了「GPT 生图」,seedream 不可用;其他地方提到 seedream 的指引按 gpt_image 理解)。产物自动落 `<task_dir>/figures/`,复杂图可能需 **~2min**(慢,调用前告知用户稍等)。
|
- `gpt_image` —— GPT 图像生成(本 run 用户在顶栏选了「GPT 生图」,seedream 不可用;其他地方提到 seedream 的指引按 gpt_image 理解)。产物自动落 `<task_dir>/figures/`,复杂图可能需 **~2min**(慢,调用前告知用户稍等)。
|
||||||
- **仅文生图**:支持 `size`(`auto` 或合法 `WIDTHxHEIGHT`)与 `quality`(`auto/low/medium/high`)。当前网关改图端点不可用;用户要修改已有图片时明确说明,并建议在顶栏切回「豆包 Seedream」。
|
- **文生图**(不传 `reference_images`):从零按 prompt 画。**改图 i2i**(传 `reference_images=["图片路径"]`):基于单张已有图片修改,原图保留、结果另存。用户要修改刚生成/上传的图时必须走改图,不要重新文生图。
|
||||||
|
- **改图调用前必须明确提示用户**:将基于「参考图文件名」编辑,会消耗 1 次图片额度,预计 1–2 分钟,原图保留且结果另存;提示后再调工具。支持 `size` 与 `quality`(`auto/low/medium/high`),当前仅支持单张参考图。
|
||||||
- **调用前必须先 `load_skill('imagegen')`** —— 其中「何时该用 / mermaid 反向选型 / 模糊度诊断 / prompt 装配 / 先给用户过目再调」的流程完全适用;参数以本工具 schema 为准。
|
- **调用前必须先 `load_skill('imagegen')`** —— 其中「何时该用 / mermaid 反向选型 / 模糊度诊断 / prompt 装配 / 先给用户过目再调」的流程完全适用;参数以本工具 schema 为准。
|
||||||
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。"""
|
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。"""
|
||||||
_MEDIA_DIAGRAM_FORK_SEG = """\
|
_MEDIA_DIAGRAM_FORK_SEG = """\
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import yaml
|
||||||
|
|
||||||
from core.paths import ROOT
|
from core.paths import ROOT
|
||||||
|
|
||||||
|
|
||||||
_DOUBAO_YAML = ROOT / "config" / "media" / "doubao.yaml"
|
_DOUBAO_YAML = ROOT / "config" / "media" / "doubao.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,7 +74,6 @@ class ArkClient:
|
||||||
base_url=cfg.base_url,
|
base_url=cfg.base_url,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {cfg.api_key}",
|
"Authorization": f"Bearer {cfg.api_key}",
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
timeout=timeout_s,
|
timeout=timeout_s,
|
||||||
)
|
)
|
||||||
|
|
@ -89,6 +87,28 @@ class ArkClient:
|
||||||
raise ArkTimeoutError(f"network error calling POST {path}: {e}") from e
|
raise ArkTimeoutError(f"network error calling POST {path}: {e}") from e
|
||||||
return self._parse(resp, f"POST {path}")
|
return self._parse(resp, f"POST {path}")
|
||||||
|
|
||||||
|
def post_multipart(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
data: dict[str, str],
|
||||||
|
files: dict[str, tuple[str, bytes, str]],
|
||||||
|
*,
|
||||||
|
timeout_s: float | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""POST multipart/form-data;边界与 Content-Type 交给 httpx 生成。"""
|
||||||
|
try:
|
||||||
|
resp = self._client.post(
|
||||||
|
path,
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
timeout=timeout_s or self.timeout_s,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
raise ArkTimeoutError(f"timeout calling POST {path}: {e}") from e
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise ArkTimeoutError(f"network error calling POST {path}: {e}") from e
|
||||||
|
return self._parse(resp, f"POST {path}")
|
||||||
|
|
||||||
def get_json(self, path: str, *, timeout_s: Optional[float] = None) -> dict:
|
def get_json(self, path: str, *, timeout_s: Optional[float] = None) -> dict:
|
||||||
try:
|
try:
|
||||||
resp = self._client.get(path, timeout=timeout_s or self.timeout_s)
|
resp = self._client.get(path, timeout=timeout_s or self.timeout_s)
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ def main() -> int:
|
||||||
dry = "--dry" in sys.argv
|
dry = "--dry" in sys.argv
|
||||||
fails = 0
|
fails = 0
|
||||||
|
|
||||||
from core.ark_client import ArkConfig
|
|
||||||
from core.agent_builder import _choose_image_variant, _media_tools_block
|
from core.agent_builder import _choose_image_variant, _media_tools_block
|
||||||
|
from core.ark_client import ArkConfig
|
||||||
|
|
||||||
gw = ArkConfig.load(ROOT / "config" / "media" / "unifyllm.yaml")
|
gw = ArkConfig.load(ROOT / "config" / "media" / "unifyllm.yaml")
|
||||||
ark = ArkConfig.load()
|
ark = ArkConfig.load()
|
||||||
|
|
@ -44,6 +44,12 @@ def main() -> int:
|
||||||
f"{image_cfg.get('model_id') or 'MISSING'}"
|
f"{image_cfg.get('model_id') or 'MISSING'}"
|
||||||
)
|
)
|
||||||
fails += 0 if model_ok else 1
|
fails += 0 if model_ok else 1
|
||||||
|
edit_endpoint_ok = image_cfg.get("edit_endpoint") == "/images/edits"
|
||||||
|
print(
|
||||||
|
f"[{'OK' if edit_endpoint_ok else 'FAIL'}] gpt_image edit_endpoint="
|
||||||
|
f"{image_cfg.get('edit_endpoint') or 'MISSING'}"
|
||||||
|
)
|
||||||
|
fails += 0 if edit_endpoint_ok else 1
|
||||||
|
|
||||||
# variant 选择:显式 gpt_image / 显式 seedream_5 / 空 fallback
|
# variant 选择:显式 gpt_image / 显式 seedream_5 / 空 fallback
|
||||||
cases = [
|
cases = [
|
||||||
|
|
@ -66,8 +72,13 @@ def main() -> int:
|
||||||
and "`seedream`" not in blk.split("\n")[0]
|
and "`seedream`" not in blk.split("\n")[0]
|
||||||
and "size" in gpt_seg
|
and "size" in gpt_seg
|
||||||
and "quality" in gpt_seg
|
and "quality" in gpt_seg
|
||||||
|
and "reference_images" in gpt_seg
|
||||||
|
and "1 次图片额度" in gpt_seg
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[{'OK' if ok else 'FAIL'}] media block(gpt_image) includes "
|
||||||
|
"size/quality/edit guidance"
|
||||||
)
|
)
|
||||||
print(f"[{'OK' if ok else 'FAIL'}] media block(gpt_image) includes size/quality guidance")
|
|
||||||
fails += 0 if ok else 1
|
fails += 0 if ok else 1
|
||||||
blk2 = _media_tools_block(ark is not None, "seedream")
|
blk2 = _media_tools_block(ark is not None, "seedream")
|
||||||
ok = ("- `seedream`" in blk2) and ("- `gpt_image`" not in blk2)
|
ok = ("- `seedream`" in blk2) and ("- `gpt_image`" not in blk2)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import struct
|
import struct
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
@ -6,7 +7,9 @@ import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from core.ark_client import ArkConfig
|
import httpx
|
||||||
|
|
||||||
|
from core.ark_client import ArkClient, ArkConfig
|
||||||
from tools.gpt_image import GptImageTool
|
from tools.gpt_image import GptImageTool
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -16,6 +19,7 @@ def _png_stub(width: int = 1536, height: int = 864) -> bytes:
|
||||||
|
|
||||||
class _FakeArkClient:
|
class _FakeArkClient:
|
||||||
json_call = None
|
json_call = None
|
||||||
|
multipart_call = None
|
||||||
|
|
||||||
def __init__(self, *_args, **_kwargs):
|
def __init__(self, *_args, **_kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
@ -33,10 +37,51 @@ class _FakeArkClient:
|
||||||
"usage": {"output_tokens": 123},
|
"usage": {"output_tokens": 123},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def post_multipart(self, endpoint, data, files, *, timeout_s=None):
|
||||||
|
type(self).multipart_call = (endpoint, data, files, timeout_s)
|
||||||
|
return {
|
||||||
|
"data": [{"b64_json": base64.b64encode(_png_stub()).decode("ascii")}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ArkClientMultipartTests(unittest.TestCase):
|
||||||
|
def test_json_and_multipart_set_their_own_content_types(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.append(request)
|
||||||
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
|
||||||
|
client = ArkClient(
|
||||||
|
ArkConfig(api_key="test", base_url="https://example.test/v1", raw={})
|
||||||
|
)
|
||||||
|
client._client.close()
|
||||||
|
client._client = httpx.Client(
|
||||||
|
base_url="https://example.test/v1",
|
||||||
|
headers={"Authorization": "Bearer test"},
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
client.post_json("/images/generations", {"prompt": "draw"})
|
||||||
|
client.post_multipart(
|
||||||
|
"/images/edits",
|
||||||
|
{"prompt": "edit"},
|
||||||
|
{"image": ("reference.png", b"png-bytes", "image/png")},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
self.assertEqual(seen[0].headers["content-type"], "application/json")
|
||||||
|
self.assertTrue(seen[1].headers["content-type"].startswith("multipart/form-data;"))
|
||||||
|
self.assertIn(b'name="prompt"', seen[1].content)
|
||||||
|
self.assertIn(b'filename="reference.png"', seen[1].content)
|
||||||
|
self.assertIn(b"png-bytes", seen[1].content)
|
||||||
|
|
||||||
|
|
||||||
class GptImageToolTests(unittest.TestCase):
|
class GptImageToolTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
_FakeArkClient.json_call = None
|
_FakeArkClient.json_call = None
|
||||||
|
_FakeArkClient.multipart_call = None
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
self.root = Path(self.tmp.name)
|
self.root = Path(self.tmp.name)
|
||||||
self.working_dir = self.root / "task"
|
self.working_dir = self.root / "task"
|
||||||
|
|
@ -44,6 +89,7 @@ class GptImageToolTests(unittest.TestCase):
|
||||||
self.cfg = {
|
self.cfg = {
|
||||||
"model_id": "gpt-image-2",
|
"model_id": "gpt-image-2",
|
||||||
"endpoint": "/images/generations",
|
"endpoint": "/images/generations",
|
||||||
|
"edit_endpoint": "/images/edits",
|
||||||
"default_size": "auto",
|
"default_size": "auto",
|
||||||
"default_quality": "auto",
|
"default_quality": "auto",
|
||||||
"request_timeout_s": 300,
|
"request_timeout_s": 300,
|
||||||
|
|
@ -86,6 +132,69 @@ class GptImageToolTests(unittest.TestCase):
|
||||||
self.assertEqual(body["quality"], "high")
|
self.assertEqual(body["quality"], "high")
|
||||||
self.assertIn("size=1536x864", result)
|
self.assertIn("size=1536x864", result)
|
||||||
self.assertIn("quality=high", result)
|
self.assertIn("quality=high", result)
|
||||||
|
self.assertIsNone(_FakeArkClient.multipart_call)
|
||||||
|
|
||||||
|
def test_image_edit_uses_multipart_and_records_derivation(self):
|
||||||
|
reference = self.working_dir / "reference.png"
|
||||||
|
reference.write_bytes(_png_stub(180, 252))
|
||||||
|
|
||||||
|
result = self._execute(
|
||||||
|
prompt="add a blue border",
|
||||||
|
reference_images=["reference.png"],
|
||||||
|
size="1024x1024",
|
||||||
|
quality="low",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(_FakeArkClient.json_call)
|
||||||
|
endpoint, form, files, timeout = _FakeArkClient.multipart_call
|
||||||
|
self.assertEqual(endpoint, "/images/edits")
|
||||||
|
self.assertEqual(timeout, 300)
|
||||||
|
self.assertEqual(form["model"], "gpt-image-2")
|
||||||
|
self.assertEqual(form["prompt"], "add a blue border")
|
||||||
|
self.assertEqual(form["n"], "1")
|
||||||
|
self.assertEqual(form["size"], "1024x1024")
|
||||||
|
self.assertEqual(form["quality"], "low")
|
||||||
|
self.assertEqual(form["response_format"], "b64_json")
|
||||||
|
filename, raw, mime = files["image"]
|
||||||
|
self.assertEqual(filename, "reference.png")
|
||||||
|
self.assertEqual(raw, reference.read_bytes())
|
||||||
|
self.assertEqual(mime, "image/png")
|
||||||
|
self.assertIn("mode=i2i", result)
|
||||||
|
self.assertIn("reference=", result)
|
||||||
|
|
||||||
|
meta_path = next((self.working_dir / "figures").glob("*.meta.json"))
|
||||||
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(meta["mode"], "i2i")
|
||||||
|
self.assertEqual(meta["reference_images"], ["task/reference.png"])
|
||||||
|
|
||||||
|
def test_image_edit_rejects_multiple_or_missing_references(self):
|
||||||
|
result = self._execute(
|
||||||
|
prompt="edit",
|
||||||
|
reference_images=["one.png", "two.png"],
|
||||||
|
)
|
||||||
|
self.assertIn("仅支持单张", result)
|
||||||
|
self.assertIsNone(_FakeArkClient.multipart_call)
|
||||||
|
|
||||||
|
result = self._execute(prompt="edit", reference_images=["missing.png"])
|
||||||
|
self.assertIn("图片找不到或越界", result)
|
||||||
|
self.assertIsNone(_FakeArkClient.multipart_call)
|
||||||
|
|
||||||
|
def test_image_edit_accepts_data_url_response_fallback(self):
|
||||||
|
reference = self.working_dir / "reference.png"
|
||||||
|
reference.write_bytes(_png_stub(180, 252))
|
||||||
|
encoded = base64.b64encode(_png_stub()).decode("ascii")
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
_FakeArkClient,
|
||||||
|
"post_multipart",
|
||||||
|
return_value={"data": [{"url": f"data:image/png;base64,{encoded}"}]},
|
||||||
|
):
|
||||||
|
result = self._execute(
|
||||||
|
prompt="edit",
|
||||||
|
reference_images=["reference.png"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.startswith("[gpt_image]"))
|
||||||
|
|
||||||
def test_size_validation(self):
|
def test_size_validation(self):
|
||||||
self.assertEqual(self.tool._normalize_size("auto"), ("auto", ""))
|
self.assertEqual(self.tool._normalize_size("auto"), ("auto", ""))
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""gpt_image: 调 unifyllm 网关的 OpenAI Images API 生图,产物落 working_dir/figures/。
|
"""gpt_image: 调 unifyllm 网关的 OpenAI Images API 生图 / 改图。
|
||||||
|
|
||||||
第二图像后端(第一个是豆包 seedream):模型 ID + 单价全在 `config/media/unifyllm.yaml`,
|
第二图像后端(第一个是豆包 seedream):模型 ID + 单价全在 `config/media/unifyllm.yaml`,
|
||||||
本 tool 只装配:
|
本 tool 只装配:
|
||||||
- 文生图走 /images/generations JSON,gpt-image-2 支持自定义尺寸和质量档;
|
- 文生图走 /images/generations JSON,gpt-image-2 支持自定义尺寸和质量档;
|
||||||
- 当前 unifyllm 网关的 /images/edits 不可用,暂不暴露改图参数;
|
- 单图改图走 /images/edits multipart/form-data;
|
||||||
- 响应直接返 b64_json,无需二次下载;
|
- 响应直接返 b64_json,无需二次下载;
|
||||||
- 复杂图片可能需 ~2min。
|
- 复杂图片可能需 ~2min。
|
||||||
完成后:
|
完成后:
|
||||||
|
|
@ -23,11 +23,12 @@ from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
|
|
||||||
from core.ark_client import ArkClient, ArkConfig, ArkError
|
from core.ark_client import ArkClient, ArkConfig, ArkError
|
||||||
|
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
|
||||||
from core.storage.usage import record_image_usage
|
from core.storage.usage import record_image_usage
|
||||||
|
|
||||||
from .base import Tool
|
from .base import Tool
|
||||||
|
from .image_ref import load_image_as_data_url
|
||||||
from .media_common import quota_gate, record_usage_safe, stamped_path, write_meta
|
from .media_common import quota_gate, record_usage_safe, stamped_path, write_meta
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,10 +36,10 @@ class GptImageTool(Tool):
|
||||||
name = "gpt_image"
|
name = "gpt_image"
|
||||||
description = (
|
description = (
|
||||||
"Generate an image via GPT Image, saved to working_dir/figures/. Supports custom size "
|
"Generate an image via GPT Image, saved to working_dir/figures/. Supports custom size "
|
||||||
"and quality. Complex images may take up to ~2 minutes. Image-to-image editing is not "
|
"and quality, plus single-reference image editing. For editing, pass reference_images "
|
||||||
"available through the current gateway; ask the user to switch to 豆包 Seedream for "
|
"with the existing image path; the original is preserved and the result is saved as a "
|
||||||
"editing an existing image. Don't generate decoratively — only when the user actually "
|
"new image. Complex images may take up to ~2 minutes. Don't generate decoratively — "
|
||||||
"wants an image. Returns the saved relative path."
|
"only when the user actually wants an image. Returns the saved relative path."
|
||||||
)
|
)
|
||||||
parameters = {
|
parameters = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -47,6 +48,14 @@ class GptImageTool(Tool):
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。",
|
"description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。",
|
||||||
},
|
},
|
||||||
|
"reference_images": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": (
|
||||||
|
"改图(image-to-image):传 1 张 task_dir 内已存在图片的相对路径。"
|
||||||
|
"不传 = 从零文生图;当前仅支持单张参考图。"
|
||||||
|
),
|
||||||
|
},
|
||||||
"size": {
|
"size": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
|
|
@ -89,12 +98,36 @@ class GptImageTool(Tool):
|
||||||
def execute(
|
def execute(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
|
reference_images: list | None = None,
|
||||||
size: Optional[str] = None,
|
size: Optional[str] = None,
|
||||||
quality: Optional[str] = None,
|
quality: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not (prompt or "").strip():
|
if not (prompt or "").strip():
|
||||||
return "[Error] prompt 不能为空"
|
return "[Error] prompt 不能为空"
|
||||||
|
|
||||||
|
refs = [str(r).strip() for r in (reference_images or []) if str(r).strip()]
|
||||||
|
if len(refs) > 1:
|
||||||
|
return (
|
||||||
|
"[Error] reference_images 当前仅支持单张参考图(传了 "
|
||||||
|
f"{len(refs)} 张)。请只传 1 张。"
|
||||||
|
)
|
||||||
|
ref_bytes = b""
|
||||||
|
ref_mime = ""
|
||||||
|
ref_disp = ""
|
||||||
|
if refs:
|
||||||
|
data_url, ref_disp, ref_err = load_image_as_data_url(
|
||||||
|
refs[0],
|
||||||
|
working_dir=self.working_dir,
|
||||||
|
user_root=self.user_root,
|
||||||
|
display_fn=self._display,
|
||||||
|
)
|
||||||
|
if ref_err:
|
||||||
|
return ref_err
|
||||||
|
header, encoded = data_url.split(",", 1)
|
||||||
|
ref_mime = header.removeprefix("data:").removesuffix(";base64")
|
||||||
|
ref_bytes = base64.b64decode(encoded)
|
||||||
|
is_i2i = bool(ref_bytes)
|
||||||
|
|
||||||
cfg = self.cfg
|
cfg = self.cfg
|
||||||
chosen_size, size_err = self._normalize_size(
|
chosen_size, size_err = self._normalize_size(
|
||||||
size if size is not None else cfg.get("default_size", "auto")
|
size if size is not None else cfg.get("default_size", "auto")
|
||||||
|
|
@ -115,7 +148,11 @@ class GptImageTool(Tool):
|
||||||
return quota_err
|
return quota_err
|
||||||
|
|
||||||
model_id = cfg["model_id"]
|
model_id = cfg["model_id"]
|
||||||
endpoint = cfg.get("endpoint", "/images/generations")
|
endpoint = (
|
||||||
|
cfg.get("edit_endpoint", "/images/edits")
|
||||||
|
if is_i2i
|
||||||
|
else cfg.get("endpoint", "/images/generations")
|
||||||
|
)
|
||||||
timeout_s = float(cfg.get("request_timeout_s", 300))
|
timeout_s = float(cfg.get("request_timeout_s", 300))
|
||||||
price = float(cfg.get("price_cny_per_image", 0))
|
price = float(cfg.get("price_cny_per_image", 0))
|
||||||
|
|
||||||
|
|
@ -130,7 +167,20 @@ class GptImageTool(Tool):
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
try:
|
try:
|
||||||
with ArkClient(self.gw_cfg, timeout_s=timeout_s) as client:
|
with ArkClient(self.gw_cfg, timeout_s=timeout_s) as client:
|
||||||
resp = client.post_json(endpoint, body, timeout_s=timeout_s)
|
if is_i2i:
|
||||||
|
form = {
|
||||||
|
key: str(value).lower() if isinstance(value, bool) else str(value)
|
||||||
|
for key, value in body.items()
|
||||||
|
}
|
||||||
|
form["response_format"] = "b64_json"
|
||||||
|
resp = client.post_multipart(
|
||||||
|
endpoint,
|
||||||
|
form,
|
||||||
|
{"image": (Path(refs[0]).name or "reference.png", ref_bytes, ref_mime)},
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resp = client.post_json(endpoint, body, timeout_s=timeout_s)
|
||||||
except ArkError as e:
|
except ArkError as e:
|
||||||
return f"[Error] gpt_image API: {e}"
|
return f"[Error] gpt_image API: {e}"
|
||||||
|
|
||||||
|
|
@ -138,6 +188,10 @@ class GptImageTool(Tool):
|
||||||
b64 = ""
|
b64 = ""
|
||||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||||
b64 = data[0].get("b64_json") or ""
|
b64 = data[0].get("b64_json") or ""
|
||||||
|
if not b64:
|
||||||
|
image_url = str(data[0].get("url") or "")
|
||||||
|
if image_url.startswith("data:") and ";base64," in image_url:
|
||||||
|
b64 = image_url.split(",", 1)[1]
|
||||||
if not b64:
|
if not b64:
|
||||||
return f"[Error] gpt_image response 缺 b64_json: {json.dumps(resp, ensure_ascii=False)[:300]}"
|
return f"[Error] gpt_image response 缺 b64_json: {json.dumps(resp, ensure_ascii=False)[:300]}"
|
||||||
try:
|
try:
|
||||||
|
|
@ -162,7 +216,8 @@ class GptImageTool(Tool):
|
||||||
"requested_size": chosen_size,
|
"requested_size": chosen_size,
|
||||||
"quality": actual_quality,
|
"quality": actual_quality,
|
||||||
"requested_quality": chosen_quality,
|
"requested_quality": chosen_quality,
|
||||||
"mode": "t2i",
|
"mode": "i2i" if is_i2i else "t2i",
|
||||||
|
"reference_images": [ref_disp] if is_i2i else [],
|
||||||
"cost_cny": price,
|
"cost_cny": price,
|
||||||
"output_tokens": output_tokens,
|
"output_tokens": output_tokens,
|
||||||
"elapsed_s": round(elapsed, 2),
|
"elapsed_s": round(elapsed, 2),
|
||||||
|
|
@ -185,10 +240,12 @@ class GptImageTool(Tool):
|
||||||
# 首行 banner 协议同 seedream(`key=value · ` 分隔,前端 extractMediaBanner 解析);
|
# 首行 banner 协议同 seedream(`key=value · ` 分隔,前端 extractMediaBanner 解析);
|
||||||
# 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。
|
# 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。
|
||||||
cost_seg = f" · cost=¥{price:.2f}" if price > 0 else ""
|
cost_seg = f" · cost=¥{price:.2f}" if price > 0 else ""
|
||||||
|
mode_seg = " · mode=i2i" if is_i2i else ""
|
||||||
|
ref_line = f"\nreference={ref_disp}" if is_i2i else ""
|
||||||
result = (
|
result = (
|
||||||
f"[gpt_image] model={model_id} · size={actual_size} · quality={actual_quality}"
|
f"[gpt_image] model={model_id} · size={actual_size} · quality={actual_quality}"
|
||||||
f"{cost_seg} · elapsed={elapsed:.1f}s\n"
|
f"{cost_seg} · elapsed={elapsed:.1f}s{mode_seg}\n"
|
||||||
f"saved: {disp}\n"
|
f"saved: {disp}{ref_line}\n"
|
||||||
f"prompt={prompt!r}"
|
f"prompt={prompt!r}"
|
||||||
)
|
)
|
||||||
if self.user_root is None:
|
if self.user_root is None:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue