This commit is contained in:
2026-08-20 18:26:29 +08:00
parent 0d63239270
commit fdbc22441b
7 changed files with 917 additions and 72 deletions
View File
+140
View File
@@ -0,0 +1,140 @@
"""Agnes Image 2.1 Flash 文生图 / 图生图 / 多图合成的核心逻辑。"""
import base64
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from uuid import uuid4
from fastapi import HTTPException
API_BASE_URL = os.getenv("AGNES_API_BASE_URL", "https://apihub.agnes-ai.com/v1")
OUTPUT_DIR = Path("output")
MAX_RETRIES = 3
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
DEFAULT_MODEL = "agnes-image-2.1-flash"
VALID_RATIOS = {"1:1", "3:4", "4:3", "16:9", "9:16", "2:3", "3:2", "21:9"}
RATIO_DIMENSIONS = {
"1:1": {"1K": "1024x1024", "2K": "2048x2048", "3K": "3072x3072", "4K": "4096x4096"},
"3:4": {"1K": "864x1152", "2K": "1728x2304", "3K": "2592x3456", "4K": "3456x4608"},
"4:3": {"1K": "1152x864", "2K": "2304x1728", "3K": "3456x2592", "4K": "4608x3456"},
"16:9": {"1K": "1312x736", "2K": "2624x1472", "3K": "3936x2208", "4K": "5248x2944"},
"9:16": {"1K": "736x1312", "2K": "1472x2624", "3K": "2208x3936", "4K": "2944x5248"},
"2:3": {"1K": "832x1248", "2K": "1664x2496", "3K": "2496x3744", "4K": "3328x4992"},
"3:2": {"1K": "1248x832", "2K": "2496x1664", "3K": "3744x2496", "4K": "4992x3328"},
"21:9": {"1K": "1568x672", "2K": "3136x1344", "3K": "4704x2016", "4K": "6272x2688"},
}
def _resolve_size(size: str, ratio: str | None) -> str:
"""把 size 档位 (1K/2K/3K/4K) 配合 ratio 解析成实际像素尺寸,方便保存文件。"""
if ratio and ratio not in VALID_RATIOS:
raise HTTPException(status_code=400, detail=f"不支持的宽高比: {ratio}")
if size in {"1K", "2K", "3K", "4K"}:
if not ratio:
ratio = "1:1"
return RATIO_DIMENSIONS[ratio][size]
return size
def _build_payload(*, prompt: str, size: str, ratio: str | None, images: list[str] | None) -> dict:
"""组装请求体。注意 response_format 必须放在 extra_body 内。"""
payload: dict = {
"model": DEFAULT_MODEL,
"prompt": prompt,
"size": size,
}
extra_body: dict = {}
if ratio:
payload["ratio"] = ratio
if images:
extra_body["image"] = images
if extra_body:
payload["extra_body"] = extra_body
return payload
def _post_with_retry(url: str, headers: dict, payload: dict) -> dict:
"""调用 Agnes Image 接口,并对可恢复的上游错误进行指数退避重试。"""
data = json.dumps(payload).encode("utf-8")
for attempt in range(1, MAX_RETRIES + 1):
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=360) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
body = e.read().decode("utf-8", errors="replace")
raise HTTPException(status_code=502, detail=f"Agnes 图片服务错误: HTTP {e.code} {body}") from e
except (urllib.error.URLError, TimeoutError) as e:
if attempt == MAX_RETRIES:
raise HTTPException(status_code=504, detail="Agnes 图片服务连接或请求超时") from e
time.sleep(2 ** (attempt - 1))
def _download_image(url: str) -> bytes:
"""从 URL 或 data URI 下载图片。"""
if url.startswith("data:image/"):
try:
_, encoded = url.split(",", 1)
return base64.b64decode(encoded, validate=True)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=502, detail="图片 data URL 格式无效") from e
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
for attempt in range(1, MAX_RETRIES + 1):
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
raise HTTPException(status_code=502, detail=f"图片 URL 下载失败: HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError) as e:
if attempt == MAX_RETRIES:
raise HTTPException(status_code=504, detail="图片 URL 下载超时或网络连接失败") from e
time.sleep(2 ** (attempt - 1))
def generate_images(
*,
prompt: str,
size: str,
ratio: str | None,
images: list[str] | None,
profile_name: str,
):
"""生成图片并保存到本地,返回结果字典。支持文生图 / 图生图 / 多图合成。"""
api_key = os.getenv("AGNES_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="未配置环境变量 AGNES_API_KEY")
resolved_size = _resolve_size(size, ratio)
payload = _build_payload(prompt=prompt, size=size, ratio=ratio, images=images)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
response = _post_with_retry(f"{API_BASE_URL}/images/generations", headers, payload)
profile_dir = OUTPUT_DIR / profile_name
profile_dir.mkdir(parents=True, exist_ok=True)
saved = []
for item in response.get("data") or []:
try:
if item.get("b64_json"):
raw = base64.b64decode(item["b64_json"], validate=True)
elif item.get("url"):
raw = _download_image(item["url"])
else:
raise HTTPException(status_code=502, detail="Agnes 图片服务未返回 b64_json 或 url")
except ValueError as e:
raise HTTPException(status_code=502, detail="Agnes Base64 图片数据格式无效") from e
filename = f"{DEFAULT_MODEL}-{uuid4().hex}.png"
(profile_dir / filename).write_bytes(raw)
saved.append({"filename": filename, "url": f"/output/{profile_name}/{filename}", "bytes": len(raw)})
return {"model": DEFAULT_MODEL, "size": resolved_size, "ratio": ratio or "1:1", "images": saved}
+98
View File
@@ -0,0 +1,98 @@
"""GPT Image 和 Grok Image 文生图的核心逻辑。"""
import base64
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from uuid import uuid4
from fastapi import HTTPException
from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI
API_BASE_URL = os.getenv("IMAGE_API_BASE_URL", "https://api.slomerex.xyz/v1")
OUTPUT_DIR = Path("output")
MAX_RETRIES = 3
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
PROVIDERS = {
"grok": {
"api_key_env": "GROK_API_KEY",
"model": "grok-imagine-image-lite",
},
"gpt": {
"api_key_env": "GPT_API_KEY",
"model": "gpt-image-2-1K",
},
}
def _generate_with_retry(client: OpenAI, *, model: str, prompt: str, size: str, n: int):
"""调用图片接口,并对可恢复的上游错误进行指数退避重试。"""
for attempt in range(1, MAX_RETRIES + 1):
try:
return client.images.generate(model=model, prompt=prompt, size=size, n=n)
except APIStatusError as e:
if e.status_code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
raise HTTPException(status_code=502, detail=f"上游图片服务错误: HTTP {e.status_code}") from e
time.sleep(2 ** (attempt - 1))
except (APIConnectionError, APITimeoutError) as e:
if attempt == MAX_RETRIES:
raise HTTPException(status_code=504, detail="上游图片服务连接或请求超时") from e
time.sleep(2 ** (attempt - 1))
def _download_image(url: str) -> bytes:
"""从 URL 或 data URI 下载图片。"""
if url.startswith("data:image/"):
try:
_, encoded = url.split(",", 1)
return base64.b64decode(encoded, validate=True)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=502, detail="图片 data URL 格式无效") from e
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
for attempt in range(1, MAX_RETRIES + 1):
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
raise HTTPException(status_code=502, detail=f"图片 URL 下载失败: HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError) as e:
if attempt == MAX_RETRIES:
raise HTTPException(status_code=504, detail="图片 URL 下载超时或网络连接失败") from e
time.sleep(2 ** (attempt - 1))
def generate_images(*, provider: str, model: str | None, prompt: str, size: str, n: int, profile_name: str):
"""生成图片并保存到本地,返回结果字典。"""
config = PROVIDERS[provider]
api_key = os.getenv(config["api_key_env"])
if not api_key:
raise HTTPException(status_code=500, detail=f"未配置环境变量 {config['api_key_env']}")
resolved_model = model or config["model"]
profile_dir = OUTPUT_DIR / profile_name
profile_dir.mkdir(parents=True, exist_ok=True)
client = OpenAI(base_url=API_BASE_URL, api_key=api_key, timeout=120.0, max_retries=0)
response = _generate_with_retry(client, model=resolved_model, prompt=prompt, size=size, n=n)
images = []
for item in response.data:
try:
if item.b64_json:
raw = base64.b64decode(item.b64_json, validate=True)
elif item.url:
raw = _download_image(item.url)
else:
raise HTTPException(status_code=502, detail="上游图片服务未返回 b64_json 或 url")
except ValueError as e:
raise HTTPException(status_code=502, detail="上游 Base64 图片数据格式无效") from e
filename = f"{resolved_model}-{uuid4().hex}.png"
(profile_dir / filename).write_bytes(raw)
images.append({"filename": filename, "url": f"/output/{profile_name}/{filename}", "bytes": len(raw)})
return {"provider": provider, "model": resolved_model, "images": images}