From cf648eaac2c664cad1232517c3cd6195d942c69d Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 18 May 2026 09:17:24 +0800 Subject: [PATCH] fix: stabilize image gateway transport --- RULES.md | 1 + api/.env.example | 2 + api/main.py | 90 ++++++++++++++++++++++++++++++---- deploy/.env.production.example | 2 + docs/source-analysis.html | 16 +++++- 5 files changed, 100 insertions(+), 11 deletions(-) diff --git a/RULES.md b/RULES.md index 92addc4..4feb71d 100644 --- a/RULES.md +++ b/RULES.md @@ -62,6 +62,7 @@ - `PRODUCT_VIEW_MODEL`:同一产品素材池的视角标注/自动识别模型;当前按项目要求强制使用 `gpt-image-2` - `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`:OpenAI 兼容生图网关;当前所有生图入口一律强制使用 `gpt-image-2`,不做其他图片模型 fallback - `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2` +- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError,可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。 - `VOICE_PROVIDER`:配音通道,当前固定使用 `azure_openai` - `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY` - `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径 diff --git a/api/.env.example b/api/.env.example index ea284b2..d8a0b43 100644 --- a/api/.env.example +++ b/api/.env.example @@ -25,6 +25,8 @@ IMAGE_MODEL=gpt-image-2 GPT_IMAGE_MODEL=gpt-image-2 SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2 SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2 +# 可选:本地网络需要代理访问 ai.skg.com 时配置;launchd 不一定继承 shell 代理变量。 +AI_HTTP_PROXY= VIDEO_MODEL=seedance VIDEO_MODEL_SEEDANCE=seedance-2-fast VIDEO_MODEL_KLING=kling-omni diff --git a/api/main.py b/api/main.py index bfb2e3c..6ccd9fd 100644 --- a/api/main.py +++ b/api/main.py @@ -52,6 +52,15 @@ REWRITE_MODEL = os.getenv("REWRITE_MODEL", "gemini-2.5-pro") VISION_MODEL = os.getenv("VISION_MODEL", "gemini-2.5-flash") IMAGE_BASE_URL = os.getenv("IMAGE_BASE_URL", LLM_BASE_URL).strip() IMAGE_API_KEY = os.getenv("IMAGE_API_KEY", LLM_API_KEY).strip() +AI_HTTP_PROXY = ( + os.getenv("AI_HTTP_PROXY") + or os.getenv("IMAGE_HTTP_PROXY") + or os.getenv("HTTPS_PROXY") + or os.getenv("https_proxy") + or os.getenv("HTTP_PROXY") + or os.getenv("http_proxy") + or "" +).strip() # Product decision: every image-generation/editing path is locked to gpt-image-2. # Environment variables may still choose the gateway URL/key, but not the model. GPT_IMAGE_MODEL = "gpt-image-2" @@ -175,12 +184,33 @@ _MEDIA_BIN_CACHE: dict[str, str] = {} from openai import OpenAI _llm_client: OpenAI | None = None _image_client: OpenAI | None = None + +def ai_http_client(timeout: float = 120) -> httpx.Client: + """HTTP client for SKG AI gateway calls. + + launchd does not reliably inherit interactive-shell proxy variables, so the + app also supports an explicit AI_HTTP_PROXY / IMAGE_HTTP_PROXY in api/.env. + """ + kwargs: dict = {"timeout": timeout} + if AI_HTTP_PROXY: + kwargs["proxy"] = AI_HTTP_PROXY + return httpx.Client(**kwargs) + + +def openai_http_client(timeout: float = 120) -> httpx.Client | None: + return ai_http_client(timeout=timeout) if AI_HTTP_PROXY else None + + def llm() -> OpenAI: global _llm_client if _llm_client is None: if not LLM_API_KEY: raise RuntimeError("LLM_API_KEY 未配置") - _llm_client = OpenAI(base_url=LLM_BASE_URL or None, api_key=LLM_API_KEY) + kwargs = {"base_url": LLM_BASE_URL or None, "api_key": LLM_API_KEY} + http_client = openai_http_client() + if http_client: + kwargs["http_client"] = http_client + _llm_client = OpenAI(**kwargs) return _llm_client def image_llm() -> OpenAI: @@ -188,7 +218,11 @@ def image_llm() -> OpenAI: if _image_client is None: if not IMAGE_API_KEY: raise RuntimeError("IMAGE_API_KEY 或 LLM_API_KEY 未配置") - _image_client = OpenAI(base_url=IMAGE_BASE_URL or None, api_key=IMAGE_API_KEY) + kwargs = {"base_url": IMAGE_BASE_URL or None, "api_key": IMAGE_API_KEY} + http_client = openai_http_client() + if http_client: + kwargs["http_client"] = http_client + _image_client = OpenAI(**kwargs) return _image_client def product_view_llm() -> OpenAI: @@ -2576,18 +2610,55 @@ def _image_retry_delay(attempt: int, status_code: int = 0, body: str = "", retry return [1.0, 2.0, 4.0, 8.0][min(attempt, 3)] +def _image_is_transport_error(message: str) -> bool: + lower = message.lower() + return any( + token in lower + for token in ( + "connecterror", + "connecttimeout", + "readtimeout", + "timeout", + "nodename nor servname", + "name or service not known", + "temporary failure in name resolution", + "operation not permitted", + "connection refused", + "network is unreachable", + ) + ) + + def _image_failure_message(kind: str, attempts: int, last_err: str, capacity_seen: bool) -> str: if capacity_seen: return ( f"{kind} failed after {attempts} attempts: gpt-image-2 上游负载饱和," f"已自动退避重试仍失败,请稍后点重试。最后错误:{last_err}" ) + if _image_is_transport_error(last_err): + return ( + f"{kind} failed after {attempts} attempts: 图片网关网络/DNS 连接失败," + "请确认本机网络或在 api/.env 配置 AI_HTTP_PROXY / IMAGE_HTTP_PROXY 后重启后端。" + f"最后错误:{last_err}" + ) return f"{kind} failed after {attempts} attempts: {last_err}" def _image_error_status(error: Exception) -> int: msg = str(error) - return 503 if ("上游负载饱和" in msg or "HTTP 429" in msg or "saturated" in msg.lower()) else 500 + return 503 if ( + "上游负载饱和" in msg + or "HTTP 429" in msg + or "saturated" in msg.lower() + or _image_is_transport_error(msg) + ) else 500 + + +def _image_endpoint(path: str) -> str: + base = (IMAGE_BASE_URL or "").strip().rstrip("/") + if not base: + raise RuntimeError("IMAGE_BASE_URL 或 LLM_BASE_URL 未配置") + return f"{base}/{path.lstrip('/')}" def _prepare_image_edit_bytes(image_path: Path, max_side: int) -> bytes: @@ -2645,9 +2716,9 @@ def _image_edit_call( retry_after: str | None = None try: if current_mode == "edit": - with httpx.Client(timeout=120) as client: + with ai_http_client(timeout=120) as client: r = client.post( - f"{IMAGE_BASE_URL}/images/edits", + _image_endpoint("/images/edits"), headers={ "Authorization": f"Bearer {IMAGE_API_KEY}", }, @@ -2696,7 +2767,7 @@ def _image_edit_call( item = data_arr[0] b64 = item.get("b64_json") if not b64 and item.get("url"): - with httpx.Client(timeout=120) as client: + with ai_http_client(timeout=120) as client: image_resp = client.get(item["url"]) image_resp.raise_for_status() return image_resp.content, effective_mode @@ -2943,6 +3014,7 @@ def health() -> dict: "product_view": PRODUCT_VIEW_MODEL, "image": IMAGE_MODEL, "image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default", + "ai_proxy_configured": bool(AI_HTTP_PROXY), "image_fallbacks": [GPT_IMAGE_MODEL], "subject_image": SUBJECT_ASSET_IMAGE_MODEL, "subject_image_fallbacks": SUBJECT_ASSET_IMAGE_MODELS, @@ -3267,9 +3339,9 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job: if current_mode == "edit": if img_bytes_in is None: raise RuntimeError("edit mode reference image missing") - with httpx.Client(timeout=120) as client: + with ai_http_client(timeout=120) as client: r = client.post( - f"{IMAGE_BASE_URL}/images/edits", + _image_endpoint("/images/edits"), headers={ "Authorization": f"Bearer {IMAGE_API_KEY}", }, @@ -3322,7 +3394,7 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job: if b64: out_bytes = b64lib.b64decode(b64) elif item.get("url"): - with httpx.Client(timeout=120) as client: + with ai_http_client(timeout=120) as client: image_resp = client.get(item["url"]) image_resp.raise_for_status() out_bytes = image_resp.content diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 2e86f1a..98f966f 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -30,6 +30,8 @@ IMAGE_MODEL=gpt-image-2 GPT_IMAGE_MODEL=gpt-image-2 SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2 SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2 +# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking. +AI_HTTP_PROXY= # Audio rewrite and Azure OpenAI TTS AUDIO_REWRITE_MODEL=gemini-2.5-pro diff --git a/docs/source-analysis.html b/docs/source-analysis.html index c366c1c..58b4b88 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -889,13 +889,13 @@ ProductRefStateItem { 应用清洗POST /cleanup/applyapplyCleanedFrame物理覆盖 frames/{idx}.jpg,并备份原图。 元素增改删POST/PATCH/DELETE /elementsaddElement/updateElement/deleteElement让用户修正 Vision 错误,避免候选结果锁死。 元素提取POST /elements/{element_id}/cutoutcutoutElement调用图像模型生成独立白底素材图,每次累积一张 cutout。 - 主体资产包POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id}generateSubjectAssets
deleteSubjectAsset根据参考帧和可选内置形象重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,也可传 character_id 选择 5 套内置透明骨架形象之一。当前源视频工作区支持 subject_style=transparent_humansource_actor 两种相似主体:透明骨架人会保持透明/半透明皮肤包裹可见白色骨架,普通真人会保持正常广告演员方向。两种模式都使用 reconstruction_mode=similar,后端最多把 10 张参考图作为独立 image[] 提交给 gpt-image-2,生成默认 10 张 2048 高清白底图:正面、左右 45、左右侧、背面、肩颈正/左右近景、后颈肩背特写。Prompt 明确这是肩颈按摩设备视频素材,要求脖颈、锁骨、肩线、上背和肩胛区域清晰无遮挡;内置形象只作为创意方向,不照抄。后端强制使用 gpt-image-2,不再接受前端或环境变量切到其他图片模型,也不做图片模型 fallback;后端会加身份锁定约束,统一性别表现、年龄段、体型、材质、风格和视觉身份,避免整套图混成不同人物。如果参考帧是竖屏,prompt 会明确要求竖版 9:16 风格画布,落盘也按源帧纵横比归一化。前端白底视图缩略图和关键帧一样,鼠标停留会用顶层浮层放大预览,点击仍打开原图;后端每个 view 单独调用一次生图,并明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版,保证一个视角一张照片。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 + 主体资产包POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id}generateSubjectAssets
deleteSubjectAsset根据参考帧和可选内置形象重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,也可传 character_id 选择 5 套内置透明骨架形象之一。当前源视频工作区支持 subject_style=transparent_humansource_actor 两种相似主体:透明骨架人会保持透明/半透明皮肤包裹可见白色骨架,普通真人会保持正常广告演员方向。两种模式都使用 reconstruction_mode=similar,后端最多把 10 张参考图作为独立 image[] 提交给 gpt-image-2,生成默认 10 张 2048 高清白底图:正面、左右 45、左右侧、背面、肩颈正/左右近景、后颈肩背特写。Prompt 明确这是肩颈按摩设备视频素材,要求脖颈、锁骨、肩线、上背和肩胛区域清晰无遮挡;内置形象只作为创意方向,不照抄。后端强制使用 gpt-image-2,不再接受前端或环境变量切到其他图片模型,也不做图片模型 fallback;后端会加身份锁定约束,统一性别表现、年龄段、体型、材质、风格和视觉身份,避免整套图混成不同人物。如果参考帧是竖屏,prompt 会明确要求竖版 9:16 风格画布,落盘也按源帧纵横比归一化。前端白底视图缩略图和关键帧一样,鼠标停留会用顶层浮层放大预览,点击仍打开原图;后端每个 view 单独调用一次生图,并明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版,保证一个视角一张照片。图片调用走统一 ai_http_client,可用 AI_HTTP_PROXY / IMAGE_HTTP_PROXY 处理本地 launchd 代理继承问题;网络/DNS 失败返回 503 并提示检查代理配置。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 首尾帧资产POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;新流程传 asset_role=first_frame/last_frame,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 scene_assets 并自动填入产品融合镜头。 产品图库GET /product-library/skglistProductLibrary读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 产品图入库到 jobPOST /jobs/{id}/assetsPOST /jobs/{id}/assets/product-libraryuploadStoryboardAssetcopyProductLibraryAsset上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。注意该接口只写图片文件,产品素材池列表另由 PUT /jobs/{id}/product-refs 持久化。 产品素材池保存PUT /jobs/{id}/product-refssaveProductRefs把当前 job 的产品素材池列表、识别视角、用途标签、方向、结构点、备注、AI 补图和删除结果保存到 Job.product_refs / state.json。前端上传、识别完成、补角度、编辑备注和删除时都会同步保存;刷新页面或热更新后从 job 恢复,不再要求重新上传和重新识别。 产品视角识别POST /jobs/{id}/assets/product-views/analyzeanalyzeProductViews读取同一产品素材池,按批次把多张图一次性提交给 PRODUCT_VIEW_MODEL=gpt-image-2 做视角标注,不限制只看前 6 张;识别对象被固定为套在脖子上的 U 形肩颈按摩仪。返回 viewbackgrounduse_tagsorientationlandmarks、中文备注、生成风险和置信度;orientation 明确佩戴者左/右、上/下、内外侧和开口方向对应图中哪边,避免把图片左右误当产品左右。前端不再要求用户手动选择视角,也不做不同产品身份判断。 - 产品缺角度补图POST /jobs/{id}/assets/product-anglegenerateProductAngleAsset用当前同一产品素材池作为参考,通过 gpt-image-2 自动补全缺失视角,输出新的 ImageRef(kind="asset")。前端不再固定传第一张图,而是按目标视角给已上传/已标注参考图打分,优先选择真实上传图、目标相邻视角、侧厚/触点/底部对应用途标签和低风险高置信图,最多传 6 张;后端通过 /images/edits multipart 的多张 image[] 直接提交给 gpt-image-2,不再把参考图拼成一张板,降低模型误解成拼图/多产品的概率。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例,并禁止输出拼图/多产品;遇到上游 429 / saturated 会按退避节奏重试,最终仍失败时返回 503 和可读提示。 + 产品缺角度补图POST /jobs/{id}/assets/product-anglegenerateProductAngleAsset用当前同一产品素材池作为参考,通过 gpt-image-2 自动补全缺失视角,输出新的 ImageRef(kind="asset")。前端不再固定传第一张图,而是按目标视角给已上传/已标注参考图打分,优先选择真实上传图、目标相邻视角、侧厚/触点/底部对应用途标签和低风险高置信图,最多传 6 张;后端通过 /images/edits multipart 的多张 image[] 直接提交给 gpt-image-2,不再把参考图拼成一张板,降低模型误解成拼图/多产品的概率。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例,并禁止输出拼图/多产品;遇到上游 429 / saturated 会按退避节奏重试,最终仍失败时返回 503 和可读提示;遇到 DNS / ConnectError 也返回 503,并提示配置 AI_HTTP_PROXY / IMAGE_HTTP_PROXY。 角色库GET /character-library/skglistCharacterLibrary读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。 角色图入库到 jobPOST /jobs/{id}/assets/character-librarycopyCharacterLibraryAssets把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。 产品融合引导图POST /jobs/{id}/product-fusion/guidecreateProductFusionGuide旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。 @@ -1005,6 +1005,18 @@ ProductRefStateItem {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-18 · 生图网关增加显式代理和网络错误提示

+ API + Ops +
+
+

问题:相似主体高清视图包调用 gpt-image-2 时出现 ConnectError: nodename nor servname provided。排查确认 IMAGE_BASE_URL 配置正确、模型列表可访问,错误发生在连接层;本地 launchd 启动的 API 进程没有稳定继承交互 shell 的代理环境。

+

改动:api/main.py 新增 AI_HTTP_PROXY / IMAGE_HTTP_PROXY 支持,OpenAI-compatible client 和所有图片 /images/edits / 图片 URL 下载统一走 ai_http_client/health 新增 models.ai_proxy_configured,只显示是否配置代理,不暴露代理地址。图片 DNS、ConnectError、连接拒绝、网络不可达等传输层失败会归类为 503,并提示检查本机网络或在 api/.env 配置代理后重启后端。

+

影响:api/main.pyapi/.env.exampledeploy/.env.production.exampleRULES.mddocs/source-analysis.html。后续如果生图失败但模型名仍是 gpt-image-2,先区分 429 上游饱和、400 参数错误和 503 网络/代理错误,不要直接改 prompt 或换模型。

+
+

2026-05-18 · 相似主体升级为内置形象 + 肩颈高清视图包