From 4399da20902d00638ceca9458f674c632affa3c6 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Thu, 18 Jun 2026 18:15:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E5=9B=BE=E5=83=8F=E4=B8=BB?= =?UTF-8?q?=E5=8A=9B=E4=BB=8E=20tokenssr=20=E5=88=87=E5=88=B0=20yunqi(?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20images/edits=20=E5=9B=BE=E7=94=9F=E5=9B=BE?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TOKENSSR 余额将尽,切换图像默认 provider 到 yunqi。yunqi 的图生图 端点需 Azure 风格 ?api-version=2025-04-01-preview,不带会 404。 - provider 加 api_version 字段,OpenAICompatible 生图/改图端点自动附加 ?api-version=(配了才加,tokenssr 等零影响) - build_provider 解析 api_version:DB(ModelProvider.metadata)优先 → .env 回退 - 迁移 0007:启用 yunqi:gpt-image-2 为唯一 active 图像模型,禁用其余 - 摘除死代码 YunqiProvider 导出(旧类写死"不支持图生图"会误导) 端到端验证:图生图(三视图,36s)+文生图(14s)均正常出图。 Co-Authored-By: Claude Opus 4.8 --- core/backend/airshelf/settings/base.py | 5 ++ .../migrations/0007_switch_image_to_yunqi.py | 66 +++++++++++++++++++ core/backend/apps/ai/providers/__init__.py | 2 - .../apps/ai/providers/openai_compatible.py | 13 +++- core/backend/apps/ai/providers/volcano.py | 3 + core/backend/apps/ai/services.py | 5 +- 6 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 core/backend/apps/ai/migrations/0007_switch_image_to_yunqi.py diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index d1c2813..f1b7baf 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -204,6 +204,11 @@ PROVIDER_KEYS = { "yunqi": env("YUNQI_API_KEY", ""), "tokenssr": env("TOKENSSR_API_KEY", ""), } +# 中转站生图/改图端点的 Azure 风格 api-version(yunqi 的 images/edits 必须带,否则 404)。 +# DB 的 ModelProvider.metadata.api_version 优先,留空回退到这里。 +PROVIDER_API_VERSIONS = { + "yunqi": env("YUNQI_API_VERSION", "2025-04-01-preview"), +} # 火山引擎人像素材库审核(真人资产绿/红盾)· AK/SK 暂借 AirDrama 已邀测账号,张业昌待换成 AirShelf 自有 ASSETS_API = { diff --git a/core/backend/apps/ai/migrations/0007_switch_image_to_yunqi.py b/core/backend/apps/ai/migrations/0007_switch_image_to_yunqi.py new file mode 100644 index 0000000..d32efde --- /dev/null +++ b/core/backend/apps/ai/migrations/0007_switch_image_to_yunqi.py @@ -0,0 +1,66 @@ +"""图像主力从 tokenssr:gpt-image-2 切到 yunqi:gpt-image-2(TOKENSSR 余额将尽)。 + +yunqi 的图生图(images/edits)需 Azure 风格 ?api-version=2025-04-01-preview —— 存进 +ModelProvider.metadata.api_version,运行时由 services.build_provider 注入,provider 据此拼 URL。 +凭证仍走 .env(YUNQI_API_KEY,即能走 edits 的老 key),不进库。 + +幂等:get_or_create / update_or_create,可重复 apply。 +""" +from django.db import migrations + +YUNQI_BASE_URL = "https://www.yunqiai.chat/v1" +API_VERSION = "2025-04-01-preview" + + +def switch(apps, schema_editor): + ModelProvider = apps.get_model("ai", "ModelProvider") + ModelConfig = apps.get_model("ai", "ModelConfig") + + yunqi, _ = ModelProvider.objects.get_or_create( + name="yunqi", + defaults={ + "display_name": "YunQi AI(New API 网关)", + "status": "active", + "base_url": YUNQI_BASE_URL, + "api_key": "", # 留空:运行时从 .env(YUNQI_API_KEY)解析,密钥不进库 + "metadata": {"kind": "relay", "api_version": API_VERSION}, + }, + ) + meta = yunqi.metadata or {} + meta["api_version"] = API_VERSION # edits 端点必带,否则 404 + yunqi.metadata = meta + if not yunqi.base_url: + yunqi.base_url = YUNQI_BASE_URL + yunqi.status = "active" # get_default_model 要求 provider__status=active + yunqi.save(update_fields=["metadata", "base_url", "status"]) + + ModelConfig.objects.update_or_create( + provider=yunqi, + name="gpt-image-2", + capability="image", + defaults={ + "display_name": "GPT-Image-2(参考图)", + "endpoint": "images/generations", # 文生图端点;图生图由 provider.image_edit 走 images/edits + "unit_price": "2.0000", + "status": "active", + "metadata": {"family": "gpt", "supports_reference": True, "recommended": True}, + }, + ) + + # 切到 yunqi 唯一 active 图像模型:禁用所有非 yunqi 图像模型(含 tokenssr、火山 Seedream)。 + # 否则 created_at 更早且 active 的模型会被 get_default_model(order_by created_at)顶替默认。 + ModelConfig.objects.filter(capability="image").exclude(provider__name="yunqi").update(status="disabled") + + +def revert(apps, schema_editor): + # 反向:停用 yunqi 图像,复活 tokenssr:gpt-image-2(回到 0006 后的状态) + ModelConfig = apps.get_model("ai", "ModelConfig") + ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="disabled") + ModelConfig.objects.filter( + provider__name="tokenssr", capability="image", name="gpt-image-2" + ).update(status="active") + + +class Migration(migrations.Migration): + dependencies = [("ai", "0006_seed_tokenssr_models")] + operations = [migrations.RunPython(switch, revert)] diff --git a/core/backend/apps/ai/providers/__init__.py b/core/backend/apps/ai/providers/__init__.py index c69c4db..8cba6a0 100644 --- a/core/backend/apps/ai/providers/__init__.py +++ b/core/backend/apps/ai/providers/__init__.py @@ -1,7 +1,6 @@ from .base import AIProvider, AIProviderResult from .openai_compatible import OpenAICompatibleProvider from .volcano import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider -from .yunqi import YunqiProvider __all__ = [ @@ -11,5 +10,4 @@ __all__ = [ "TtsNotConfigured", "VolcanoArkProvider", "VolcanoTtsProvider", - "YunqiProvider", ] diff --git a/core/backend/apps/ai/providers/openai_compatible.py b/core/backend/apps/ai/providers/openai_compatible.py index 334382d..dd86afa 100644 --- a/core/backend/apps/ai/providers/openai_compatible.py +++ b/core/backend/apps/ai/providers/openai_compatible.py @@ -24,6 +24,15 @@ class OpenAICompatibleProvider(VolcanoArkProvider): if not self.base_url: raise ValueError("OpenAICompatibleProvider requires base_url (中转站地址)") + def _endpoint_url(self, endpoint: str) -> str: + """拼端点 URL。若 provider 配了 api_version(如 yunqi),附加 Azure 风格 ?api-version=... + —— yunqi 的 images/edits 不带此参数会 404。tokenssr 等不配则零影响。""" + url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" + if self.api_version: + sep = "&" if "?" in url else "?" + url = f"{url}{sep}api-version={self.api_version}" + return url + def image_generation( self, *, @@ -41,7 +50,7 @@ class OpenAICompatibleProvider(VolcanoArkProvider): body["image"] = image # 实测中转站生图延迟可达 75s+,超时给到 300s response = requests.post( - f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", + self._endpoint_url(endpoint), headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, json=body, timeout=300, @@ -79,7 +88,7 @@ class OpenAICompatibleProvider(VolcanoArkProvider): raise ValueError("image_edit 至少需要一张参考图") data = {"model": model, "prompt": prompt, "size": size, "n": "1"} response = requests.post( - f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", + self._endpoint_url(endpoint), headers={"Authorization": f"Bearer {self.api_key}"}, # multipart 不要手设 Content-Type files=files, data=data, diff --git a/core/backend/apps/ai/providers/volcano.py b/core/backend/apps/ai/providers/volcano.py index a06a70b..cd8b4ae 100644 --- a/core/backend/apps/ai/providers/volcano.py +++ b/core/backend/apps/ai/providers/volcano.py @@ -15,6 +15,9 @@ from .base import AIProviderResult class VolcanoArkProvider: api_key: str | None = None base_url: str | None = None + # 部分 OpenAI 兼容中转站(如 yunqi)的生图/改图端点要求 Azure 风格的 ?api-version=... 查询参数, + # 不带会 404。火山官方直连用不到,默认 None。仅 OpenAICompatibleProvider 在拼 URL 时附加。 + api_version: str | None = None def __post_init__(self) -> None: self.api_key = self.api_key or settings.VOLCANO.get("ark_api_key") diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 8a71a70..c6c4abf 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -66,7 +66,10 @@ def build_provider(model_config: ModelConfig): base_url, api_key = resolve_provider_credentials(provider) if provider.name in OFFICIAL_DIRECT_PROVIDERS: return VolcanoArkProvider(base_url=base_url, api_key=api_key) - return OpenAICompatibleProvider(base_url=base_url, api_key=api_key) + # api_version:某些中转站(yunqi)的 images/edits 需 Azure 风格 ?api-version=...。 + # DB(ModelProvider.metadata.api_version)优先 → settings(.env)回退,与凭证同款可插拔。 + api_version = (provider.metadata or {}).get("api_version") or settings.PROVIDER_API_VERSIONS.get(provider.name) + return OpenAICompatibleProvider(base_url=base_url, api_key=api_key, api_version=api_version or None) def get_image_provider(model_config: ModelConfig):