feat: 增加gpt-image-2 模型
This commit is contained in:
@@ -7,7 +7,8 @@ from config import get_settings
|
||||
from db import get_db
|
||||
from models import Suite
|
||||
from schemas import (
|
||||
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, SuiteCreateResponse, TextMaterial,
|
||||
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS,
|
||||
SuiteCreateResponse, TextMaterial, resolve_provider,
|
||||
PlanRequest, PlanResponse, PlanItemOut,
|
||||
)
|
||||
from services.generator import run_suite
|
||||
@@ -84,10 +85,13 @@ async def generate_suite(
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
provider_name = req.provider or settings.image_provider
|
||||
# 插件只传模型名:已知模型直接路由到对应 provider(gpt-image-2 → rightapi)
|
||||
provider_name = resolve_provider(req.model, req.provider, settings.image_provider)
|
||||
model = req.model
|
||||
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})")
|
||||
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})")
|
||||
suite = Suite(
|
||||
product_id=None,
|
||||
style_set=req.style_set,
|
||||
|
||||
@@ -13,7 +13,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config import get_settings
|
||||
from db import get_db
|
||||
from models import Product, ProductAsset, Suite, SuiteImage, STATUS_OK
|
||||
from schemas import PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut
|
||||
from schemas import (
|
||||
PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS,
|
||||
SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut, resolve_provider,
|
||||
)
|
||||
from services import storage
|
||||
from services.generator import run_suite
|
||||
|
||||
@@ -76,9 +79,12 @@ async def create_suite(
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
provider_name = req.provider or settings.image_provider
|
||||
# 模型名优先路由:已知模型直接定位 provider(gpt-image-2 → rightapi)
|
||||
provider_name = resolve_provider(req.model, req.provider, settings.image_provider)
|
||||
if provider_name == "tongyi" and req.model and req.model not in TONGYI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}(tongyi 支持: {TONGYI_MODELS})")
|
||||
if provider_name == "rightapi" and req.model and req.model not in RIGHTAPI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}(rightapi 支持: {RIGHTAPI_MODELS})")
|
||||
suite = Suite(
|
||||
product_id=product.id,
|
||||
style_set=req.style_set,
|
||||
|
||||
@@ -41,6 +41,12 @@ class Settings(BaseSettings):
|
||||
dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点
|
||||
dashscope_model: str = "wan2.7-image-pro"
|
||||
|
||||
# RightAPI(OpenAI 兼容中转,gpt-image 系列)
|
||||
rightapi_api_key: str = ""
|
||||
rightapi_base_url: str = "https://rightapi.ai/draw"
|
||||
rightapi_image_model: str = "gpt-image-2"
|
||||
rightapi_image_quality: str = "high" # auto | low | medium | high
|
||||
|
||||
# DeepSeek(出图方案规划器)
|
||||
deepseek_api_key: str = ""
|
||||
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||||
|
||||
@@ -50,6 +50,7 @@ async def health():
|
||||
"provider": settings.image_provider,
|
||||
"ark_configured": bool(settings.ark_api_key),
|
||||
"dashscope_configured": bool(settings.dashscope_api_key),
|
||||
"rightapi_configured": bool(settings.rightapi_api_key),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,22 @@ SUPPORTED_TYPES = [
|
||||
# 通义(DashScope)生图模型白名单:插件下拉可选的模型
|
||||
TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro"]
|
||||
|
||||
# RightAPI 生图模型白名单
|
||||
RIGHTAPI_MODELS = ["gpt-image-2"]
|
||||
|
||||
# 模型 → provider 推断表:插件只传模型名,服务端据此路由(模型名优先于 provider 字段)
|
||||
MODEL_PROVIDERS: dict[str, str] = {
|
||||
**{m: "tongyi" for m in TONGYI_MODELS},
|
||||
**{m: "rightapi" for m in RIGHTAPI_MODELS},
|
||||
}
|
||||
|
||||
|
||||
def resolve_provider(model: str | None, requested: str | None, default: str) -> str:
|
||||
"""已知模型名直接定位 provider;未知模型回退到请求指定的 provider 或默认值。"""
|
||||
if model and model in MODEL_PROVIDERS:
|
||||
return MODEL_PROVIDERS[model]
|
||||
return requested or default
|
||||
|
||||
|
||||
# ── 采集上传 ──
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True) -> str:
|
||||
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon/WB),1:1 方图(国内)。"""
|
||||
if provider == "doubao":
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
if provider == "rightapi":
|
||||
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
|
||||
if ratio == "3:4":
|
||||
return "1536*2048" if is_wan else "768*1024"
|
||||
@@ -70,27 +73,34 @@ def _bytes_to_data_uri(data: bytes, mime: str) -> str:
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
|
||||
|
||||
|
||||
async def _resolve_ref(url: str) -> str:
|
||||
"""参考图 URL → data URI。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。
|
||||
async def _resolve_ref_bytes(url: str) -> tuple[bytes, str]:
|
||||
"""参考图 URL → (bytes, mime)。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。
|
||||
|
||||
生图 API 的服务器无法访问 127.0.0.1,代理 URL 也不能直接透传,
|
||||
所以统一在本地解析成 base64 data URI 再进请求体。
|
||||
所以统一在本地解析成原始字节再进请求体(data URI 或 multipart)。
|
||||
"""
|
||||
if url.startswith("data:"):
|
||||
return url
|
||||
head, _, b64 = url.partition(",")
|
||||
mime = head[5:].split(";", 1)[0] or "image/jpeg"
|
||||
return base64.b64decode(b64), mime
|
||||
path = storage.local_path(url)
|
||||
if path is not None:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
return _bytes_to_data_uri(path.read_bytes(), mime)
|
||||
return path.read_bytes(), mime
|
||||
if url.startswith(("http://", "https://")):
|
||||
from api.proxy import guess_referer
|
||||
data, ctype = await storage.download_bytes(url, referer=guess_referer(url))
|
||||
if not ctype.startswith("image/"):
|
||||
ctype = "image/jpeg"
|
||||
return _bytes_to_data_uri(data, ctype)
|
||||
return data, ctype
|
||||
raise FileNotFoundError(f"无法解析参考图: {url}")
|
||||
|
||||
|
||||
async def _resolve_ref(url: str) -> str:
|
||||
data, mime = await _resolve_ref_bytes(url)
|
||||
return _bytes_to_data_uri(data, mime)
|
||||
|
||||
|
||||
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
|
||||
|
||||
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
|
||||
@@ -198,7 +208,56 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
|
||||
return dl.content
|
||||
|
||||
|
||||
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi}
|
||||
# ── Provider:RightAPI(gpt-image,OpenAI 兼容中转)──────────────────────
|
||||
|
||||
async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
|
||||
"""gpt-image 系列:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。
|
||||
|
||||
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
|
||||
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
|
||||
"""
|
||||
s = get_settings()
|
||||
if not s.rightapi_api_key:
|
||||
raise RuntimeError("未配置 RIGHTAPI_API_KEY(.env)")
|
||||
model = model or s.rightapi_image_model
|
||||
base = s.rightapi_base_url.rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||
common = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"quality": s.rightapi_image_quality,
|
||||
"output_format": "jpeg", # 与落盘 .jpg 后缀一致
|
||||
"n": 1,
|
||||
}
|
||||
if ref_images:
|
||||
files = []
|
||||
for i, u in enumerate(ref_images):
|
||||
data, mime = await _resolve_ref_bytes(u)
|
||||
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime)))
|
||||
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
|
||||
else:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/generations",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=common,
|
||||
)
|
||||
_raise_api_error(resp, "RightAPI")
|
||||
item = resp.json()["data"][0]
|
||||
b64 = item.get("b64_json") or ""
|
||||
if b64:
|
||||
return base64.b64decode(b64.split(",", 1)[-1] if "," in b64 else b64)
|
||||
img_url = item.get("url") or ""
|
||||
if not img_url:
|
||||
raise RuntimeError(f"RightAPI 响应里没有图片数据: {item}")
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
|
||||
|
||||
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi}
|
||||
|
||||
|
||||
# ── 任务执行器 ────────────────────────────────────────────────────────────
|
||||
@@ -280,9 +339,10 @@ async def run_suite(suite_id: str) -> None:
|
||||
|
||||
raw = suite.context if not product else (product.raw or {})
|
||||
ctx = build_context(raw or {}, fallback_name=product.name if product else "")
|
||||
model = suite.model or (
|
||||
settings.dashscope_model if provider_name == "tongyi" else settings.ark_image_model
|
||||
)
|
||||
model = suite.model or {
|
||||
"tongyi": settings.dashscope_model,
|
||||
"rightapi": settings.rightapi_image_model,
|
||||
}.get(provider_name, settings.ark_image_model)
|
||||
is_wan = provider_name == "tongyi" and _is_wan_model(model)
|
||||
size = _image_size(provider_name, suite.ratio, is_wan=is_wan)
|
||||
|
||||
@@ -317,7 +377,9 @@ async def run_suite(suite_id: str) -> None:
|
||||
else:
|
||||
refs = _refs_for_job(list(suite.ref_images or []), job)
|
||||
data = await generator(prompt, refs, size=size, model=model)
|
||||
key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=".jpg")
|
||||
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
|
||||
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
|
||||
key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=ext)
|
||||
image_row.stored_url = storage.public_url(key)
|
||||
image_row.status = STATUS_OK
|
||||
ok += 1
|
||||
|
||||
Reference in New Issue
Block a user