fix: 修复资产/视频/三视图三处 bug + 脚本模型切 YunQi gemini
1. 视频「全部生成」串行短路:任一段提交失败就 break 致后面段不提交 (4 个框只跑 2 个)→ 改 Promise.allSettled 并发,单段失败不拖累其余, 部分失败也刷新并如实报告。(App.tsx) 2. 基础资产/三视图 AI 出图偶发失败「生成不出来」:中转站瞬时抖动 (网络/5xx/429/空返回)一次失败即退费 → 加退避重试 3 次,永久错误 (4xx 内容违规)仍立即退费。(services.py run_base_asset_task/run_triview_task) 3. 商品三视图无法用已有素材:商品卡加「使用已有素材」,选商品已有图 / 上传本地图直接 attach 成三视图(不走平台 AI 生成、不计费)。(pipeline.tsx) 4. 脚本助手:tokenssr 欠费致 GPT-5.5/Gemini 全挂 → 文本模型切 YunQi。 实测 YunQi gpt-5.5 频道扛不住重型结构化任务(skill 全提示词仅 1/5 成功、 常吐空无 JSON),gemini-3.1-pro 5/5 稳定 → 默认改 gemini,停用 gpt-5.5。 按「一 provider 一 key」拆 yunqi_gpt / yunqi_gemini 两 provider。 (.env / settings / 迁移 0017+0018) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,9 @@ VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072
|
||||
DEFAULT_TRIAL_CREDITS=1000.0000
|
||||
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
|
||||
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
|
||||
# YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度)
|
||||
YUNQI_GPT_API_KEY=sk-WX9ClkkjJmquaz4CsmtiUTNQ7fYgDmngNasDUUjbK504uFEd
|
||||
YUNQI_GEMINI_API_KEY=sk-WNmbNpwHiln5PUfSFgepXKdfoIe3L59rSpaYffp1hHqZ0s5N
|
||||
|
||||
# tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型
|
||||
TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0
|
||||
|
||||
@@ -206,11 +206,17 @@ TOKENSSR = {
|
||||
PROVIDER_BASE_URLS = {
|
||||
"volcengine": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
|
||||
"yunqi": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
|
||||
# YunQi 对话(脚本助手):gpt-5.5 与 gemini-3.1-pro-preview 在 YunQi 同一地址、各有独立计费 key,
|
||||
# 故拆成两个 provider(一 provider 一 key 是现有约定)。base_url 都指 YunQi。
|
||||
"yunqi_gpt": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
|
||||
"yunqi_gemini": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
|
||||
"tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
|
||||
}
|
||||
PROVIDER_KEYS = {
|
||||
"volcengine": env("VOLCANO_ARK_API_KEY", ""),
|
||||
"yunqi": env("YUNQI_API_KEY", ""),
|
||||
"yunqi_gpt": env("YUNQI_GPT_API_KEY", ""),
|
||||
"yunqi_gemini": env("YUNQI_GEMINI_API_KEY", ""),
|
||||
"tokenssr": env("TOKENSSR_API_KEY", ""),
|
||||
}
|
||||
# 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""脚本助手文本模型从 tokenssr 切到 YunQi(tokenssr 欠费致 GPT-5.5/Gemini 全挂)。
|
||||
|
||||
YunQi 同一地址(https://www.yunqiai.chat/v1)对 gpt / gemini 分开计费、各一把 key,故按
|
||||
「一 provider 一 key」拆成两个 provider:
|
||||
- yunqi_gpt → gpt-5.5(默认脚本模型) · key=.env YUNQI_GPT_API_KEY
|
||||
- yunqi_gemini → gemini-3.1-pro-preview · key=.env YUNQI_GEMINI_API_KEY
|
||||
凭证留空 → 运行时由 settings.PROVIDER_KEYS 从 .env 解析(密钥不进库)。
|
||||
模型名经实测确认:gpt-5.5 / gemini-3.1-pro-preview 在 YunQi 返回 200 且能流式出正文;
|
||||
其余名(gpt-5 / gemini-3-pro-preview …)YunQi 报 model_not_found。
|
||||
|
||||
同时停用旧的 tokenssr 文本模型(gpt-5.5 / gemini-3-pro-preview),让 get_default_model(text)
|
||||
选到新的 YunQi gpt-5.5。幂等:全部 update_or_create / 显式置位,可重复 apply。
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
YUNQI_BASE_URL = "https://www.yunqiai.chat/v1"
|
||||
|
||||
# (provider_name, display, model_name, model_display, is_default)
|
||||
YUNQI_TEXT = [
|
||||
("yunqi_gpt", "YunQi · GPT", "gpt-5.5", "GPT-5.5", True),
|
||||
("yunqi_gemini", "YunQi · Gemini", "gemini-3.1-pro-preview", "Gemini 3.1 Pro", False),
|
||||
]
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
ModelProvider = apps.get_model("ai", "ModelProvider")
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
|
||||
for prov_name, prov_display, model_name, model_display, is_default in YUNQI_TEXT:
|
||||
provider, _ = ModelProvider.objects.get_or_create(
|
||||
name=prov_name,
|
||||
defaults={
|
||||
"display_name": prov_display,
|
||||
"status": "active",
|
||||
"base_url": YUNQI_BASE_URL,
|
||||
"api_key": "", # 运行时从 .env(PROVIDER_KEYS)解析,密钥不进库
|
||||
"metadata": {"kind": "relay", "gateway": "yunqi"},
|
||||
},
|
||||
)
|
||||
# down→up 循环后 get_or_create 命中旧行(可能 disabled / 缺 base_url),显式补齐
|
||||
changed = []
|
||||
if provider.status != "active":
|
||||
provider.status = "active"; changed.append("status")
|
||||
if not provider.base_url:
|
||||
provider.base_url = YUNQI_BASE_URL; changed.append("base_url")
|
||||
if changed:
|
||||
provider.save(update_fields=changed)
|
||||
|
||||
ModelConfig.objects.update_or_create(
|
||||
provider=provider,
|
||||
name=model_name,
|
||||
capability="text",
|
||||
defaults={
|
||||
"display_name": model_display,
|
||||
"endpoint": "chat/completions",
|
||||
"unit_price": "1.0000",
|
||||
"status": "active",
|
||||
"is_default": is_default,
|
||||
"metadata": {"gateway": "yunqi", "verified": True},
|
||||
},
|
||||
)
|
||||
|
||||
# 停用旧的 tokenssr 文本模型(欠费),并清掉它们可能带的 is_default,避免与新默认打架
|
||||
ModelConfig.objects.filter(
|
||||
provider__name="tokenssr", capability="text",
|
||||
).update(status="disabled", is_default=False)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
# 回滚:停用 YunQi 文本模型,恢复 tokenssr 文本模型为 active(gpt-5.5 复位默认)
|
||||
ModelConfig.objects.filter(provider__name__in=["yunqi_gpt", "yunqi_gemini"], capability="text").update(
|
||||
status="disabled", is_default=False,
|
||||
)
|
||||
ModelConfig.objects.filter(provider__name="tokenssr", capability="text").update(status="active")
|
||||
ModelConfig.objects.filter(provider__name="tokenssr", capability="text", name="gpt-5.5").update(is_default=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0016_person_triview_16_9"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed, unseed),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""脚本默认模型从 gpt-5.5 改为 gemini-3.1-pro-preview,并停用 YunQi 的 gpt-5.5。
|
||||
|
||||
实测(完整电商脚本 skill ~28KB 重型结构化任务,各连发 5 次):
|
||||
- YunQi gpt-5.5 → 仅 1/5 成功(3 次吐空、1 次出文无合法 JSON,成功那次还要 69-95s)→ 频道扛不住,不可用
|
||||
- YunQi gemini-3.1-pro → 5/5 成功(每次 ~2300 字合法 JSON,30-48s 稳定)
|
||||
故默认切 gemini;gpt-5.5 停用(YunQi 频道修好后改回 active 即可,一行)。
|
||||
幂等:显式置位,可重复 apply。
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def apply(apps, schema_editor):
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
# gemini 设默认 + 确保 active
|
||||
ModelConfig.objects.filter(
|
||||
provider__name="yunqi_gemini", capability="text", name="gemini-3.1-pro-preview",
|
||||
).update(is_default=True, status="active")
|
||||
# gpt-5.5 取消默认 + 停用(频道不可用)
|
||||
ModelConfig.objects.filter(
|
||||
provider__name="yunqi_gpt", capability="text", name="gpt-5.5",
|
||||
).update(is_default=False, status="disabled")
|
||||
|
||||
|
||||
def revert(apps, schema_editor):
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
ModelConfig.objects.filter(provider__name="yunqi_gemini", capability="text", name="gemini-3.1-pro-preview").update(is_default=False)
|
||||
ModelConfig.objects.filter(provider__name="yunqi_gpt", capability="text", name="gpt-5.5").update(is_default=True, status="active")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0017_switch_script_to_yunqi"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply, revert),
|
||||
]
|
||||
@@ -2,11 +2,14 @@ import json
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
@@ -865,6 +868,38 @@ def build_person_frontal_prompt(description: str = "") -> str:
|
||||
return render_prompt("person_portrait", default, 描述=desc)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 出图重试(中转站偶发抖动 → 一次失败就「生成不出来」的根因兜底)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_IMAGE_GEN_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _is_transient_image_error(exc: Exception) -> bool:
|
||||
"""判定出图失败是否「瞬时」(值得重试)。瞬时:网络抖动 / 5xx / 429 限流 / 中转站吐空无 media url。
|
||||
永久(不重试,立即退费报错):4xx 内容违规 / 参数错(如 invalid_image_file)、配置缺失等。"""
|
||||
if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
|
||||
return True
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
code = getattr(getattr(exc, "response", None), "status_code", 0) or 0
|
||||
return code >= 500 or code == 429
|
||||
# extract_first_media_url 在响应没有 url 时抛 ValueError —— 多为中转站偶发空返回,重试常能拿到
|
||||
if isinstance(exc, ValueError) and "media url" in str(exc).lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _run_image_with_retry(make, *, attempts: int = _IMAGE_GEN_ATTEMPTS):
|
||||
"""跑一次出图(provider 调用 + 取 media url),瞬时错误按退避重试,永久错误立即抛。
|
||||
make() 须返回 (response, media)。退避 2s / 4s,总耗时上限 ~6s + 出图本身,worker 内执行对用户无感。"""
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return make()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if i == attempts - 1 or not _is_transient_image_error(exc):
|
||||
raise
|
||||
time.sleep(2 * (i + 1))
|
||||
|
||||
|
||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
|
||||
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
|
||||
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
|
||||
@@ -927,18 +962,22 @@ def run_base_asset_task(*, task_id: str) -> None:
|
||||
provider = get_image_provider(model_config)
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
if use_edit and ref_url:
|
||||
# 商品三视图:比例可在 admin 改(默认横)
|
||||
size = prompt_ratio_size("product_triview", "1536x1024")
|
||||
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size)
|
||||
else:
|
||||
# 场景默认横、人物立绘默认竖;两者比例都可在 admin 改
|
||||
if kind == BaseAssetGroup.Kind.SCENE:
|
||||
gen_size = prompt_ratio_size("scene", "1536x1024")
|
||||
def _make():
|
||||
if use_edit and ref_url:
|
||||
# 商品三视图:比例可在 admin 改(默认横)
|
||||
size = prompt_ratio_size("product_triview", "1536x1024")
|
||||
resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size)
|
||||
else:
|
||||
gen_size = prompt_ratio_size("person_portrait", "1024x1536")
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
|
||||
media = provider.extract_first_media_url(response)
|
||||
# 场景默认横、人物立绘默认竖;两者比例都可在 admin 改
|
||||
if kind == BaseAssetGroup.Kind.SCENE:
|
||||
gen_size = prompt_ratio_size("scene", "1536x1024")
|
||||
else:
|
||||
gen_size = prompt_ratio_size("person_portrait", "1024x1536")
|
||||
resp = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
|
||||
return resp, provider.extract_first_media_url(resp)
|
||||
|
||||
# 中转站偶发抖动(网络/5xx/限流/空返回)按退避重试,避免「一次失败就生成不出来」;永久错误仍立即退费
|
||||
response, media = _run_image_with_retry(_make)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = response
|
||||
@@ -1027,9 +1066,13 @@ def run_triview_task(*, task_id: str) -> None:
|
||||
provider = get_image_provider(model_config)
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
tri_size = prompt_ratio_size("person_triview", "1536x1024")
|
||||
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size)
|
||||
media = provider.extract_first_media_url(response)
|
||||
def _make():
|
||||
tri_size = prompt_ratio_size("person_triview", "1536x1024")
|
||||
resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size)
|
||||
return resp, provider.extract_first_media_url(resp)
|
||||
|
||||
# 同基础资产:瞬时抖动重试,永久错误立即退费报错
|
||||
response, media = _run_image_with_retry(_make)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = response
|
||||
|
||||
Reference in New Issue
Block a user