From cf742ee871fbc2c31e78d99698c9fea388bd1c2d Mon Sep 17 00:00:00 2001 From: Hiro Date: Thu, 25 Jun 2026 21:50:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?bug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 432 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 376 insertions(+), 56 deletions(-) diff --git a/main.py b/main.py index 74321e993..6ca726aee 100644 --- a/main.py +++ b/main.py @@ -3659,15 +3659,37 @@ def collect(value, depth=0): if not isinstance(part, dict): continue inline = part.get("inlineData") or part.get("inline_data") or {} - if not isinstance(inline, dict): - continue - value = inline.get("data") - if value: - add_image({ - "type": "b64", - "value": value, - "mime_type": inline.get("mimeType") or inline.get("mime_type") or "image/png", - }) + if isinstance(inline, dict): + value = inline.get("data") + if value: + add_image({ + "type": "b64", + "value": value, + "mime_type": inline.get("mimeType") or inline.get("mime_type") or "image/png", + }) + file_data = part.get("fileData") or part.get("file_data") or {} + if isinstance(file_data, dict): + file_uri = ( + file_data.get("fileUri") + or file_data.get("file_uri") + or file_data.get("url") + or file_data.get("uri") + ) + if isinstance(file_uri, str) and file_uri.strip(): + add_image({ + "type": "url", + "value": file_uri.strip(), + }) + # 有些中转站会把图片 URL 放在 text / markdown 里 + text_value = part.get("text") + if isinstance(text_value, str) and text_value.strip(): + for image_url in re.findall(r'https?://[^\s\]\)"]+', text_value): + if re.search(r'\.(png|jpe?g|webp|gif)(\?|#|$)', image_url, re.I): + add_image({ + "type": "url", + "value": image_url, + }) + current = data if isinstance(current, dict) and isinstance(current.get("data"), dict) and isinstance(current["data"].get("result"), dict): @@ -3712,15 +3734,37 @@ def extract_image(data): if not isinstance(part, dict): continue inline = part.get("inlineData") or part.get("inline_data") or {} - if not isinstance(inline, dict): - continue - value = inline.get("data") - if value: - return { - "type": "b64", - "value": value, - "mime_type": inline.get("mimeType") or inline.get("mime_type") or "image/png", - } + if isinstance(inline, dict): + value = inline.get("data") + if value: + return { + "type": "b64", + "value": value, + "mime_type": inline.get("mimeType") or inline.get("mime_type") or "image/png", + } + + file_data = part.get("fileData") or part.get("file_data") or {} + if isinstance(file_data, dict): + file_uri = ( + file_data.get("fileUri") + or file_data.get("file_uri") + or file_data.get("url") + or file_data.get("uri") + ) + if isinstance(file_uri, str) and file_uri.strip(): + return { + "type": "url", + "value": file_uri.strip(), + } + + text_value = part.get("text") + if isinstance(text_value, str) and text_value.strip(): + for image_url in re.findall(r'https?://[^\s\]\)"]+', text_value): + if re.search(r'\.(png|jpe?g|webp|gif)(\?|#|$)', image_url, re.I): + return { + "type": "url", + "value": image_url, + } if isinstance(data.get("data"), dict) and isinstance(data["data"].get("result"), dict): data = data["data"] if isinstance(data.get("result"), dict): @@ -3751,17 +3795,45 @@ def extract_image(data): raise HTTPException(status_code=502, detail="无法识别生图接口返回格式") def extract_task_id(data): - if data.get("task_id"): - return str(data["task_id"]) - if data.get("id") and str(data.get("id", "")).startswith("task"): - return str(data["id"]) - nested = data.get("data") - if isinstance(nested, list) and nested: - first = nested[0] - if isinstance(first, dict): - return extract_task_id(first) - if isinstance(nested, dict): - return extract_task_id(nested) + if not isinstance(data, dict): + return None + + # 常见异步任务 ID 字段 + for key in ( + "task_id", "taskId", "taskID", + "job_id", "jobId", + "request_id", "requestId", + "generation_id", "generationId", + ): + value = data.get(key) + if value: + return str(value) + + # 有些中转站直接把任务 ID 放在 id 里,不一定以 task 开头 + value = data.get("id") + if value: + text = str(value) + if ( + text.startswith(("task", "job", "gen", "generation", "img")) + or re.fullmatch(r"[0-9a-fA-F-]{16,80}", text) + or data.get("status") + or data.get("task_status") + ): + return text + + # 递归兼容 data / result / task / job 包装层 + for key in ("data", "result", "task", "job", "output"): + nested = data.get(key) + if isinstance(nested, dict): + found = extract_task_id(nested) + if found: + return found + if isinstance(nested, list): + for item in nested: + found = extract_task_id(item) + if found: + return found + return None def extract_task_id_from_text(text): @@ -3773,6 +3845,131 @@ def images_api_unsupported(response): text = str(getattr(response, "text", "") or "").lower() return "images api is not supported" in text or "not supported for this platform" in text +def async_image_payload_variants(prompt, size, quality, model, image_refs=None, prefer_apimart=False): + """ + 给异步生图协议准备多种常见 JSON 格式。 + 真实 APIMart 优先 APIMart 格式; + 其他“支持异步协议”的中转站优先 OpenAI JSON 格式。 + """ + raw_size = str(size or "1024x1024").strip() or "1024x1024" + aspect_ratio, resolution = apimart_size_resolution(raw_size) + width, height = parse_size_pair(raw_size) + + q = str(quality or "").strip().lower() + if q not in {"low", "medium", "high"}: + q = "" + + image_values = [] + for ref in (image_refs or [])[:ONLINE_IMAGE_REFERENCE_MAX]: + try: + value = reference_to_data_url(ref, max_size=1536) + except Exception: + value = "" + if value: + image_values.append(value) + + variants = [] + + # 1) 第三方异步站通用 JSON:同时兼容 prompt/input 与 image/image_urls/images + openai_image = { + "model": model, + "prompt": prompt, + "input": prompt, + "size": raw_size, + "n": 1, + "response_format": "url", + } + if q: + openai_image["quality"] = q + if width and height: + openai_image["width"] = width + openai_image["height"] = height + if image_values: + # 不同中转站字段名不一致:有的认 image,有的认 image_urls,有的认 images + openai_image["image"] = image_values + openai_image["image_urls"] = image_values + openai_image["images"] = image_values + variants.append(("openai-json-universal", openai_image)) + + # 2) 另一种通用写法:image_urls + openai_image_urls = { + "model": model, + "prompt": prompt, + "size": raw_size, + "n": 1, + "response_format": "url", + } + if q: + openai_image_urls["quality"] = q + if width and height: + openai_image_urls["width"] = width + openai_image_urls["height"] = height + if image_values: + openai_image_urls["image_urls"] = image_values + variants.append(("openai-json-image_urls", openai_image_urls)) + + # 3) APIMart 风格:保留你原来能用的那套 + apimart_body = { + "model": model, + "prompt": prompt, + "n": 1, + "size": aspect_ratio, + "resolution": resolution, + "response_format": "url", + "official_fallback": False, + } + if q: + apimart_body["quality"] = q + if image_values: + apimart_body["image_urls"] = image_values + variants.append(("apimart", apimart_body)) + + # 4) extra_body 风格:部分聚合站把图片参数放这里 + extra = { + "response_format": "url", + "size": raw_size, + "aspect_ratio": aspect_ratio, + "aspectRatio": aspect_ratio, + "resolution": resolution, + } + if width and height: + extra["width"] = width + extra["height"] = height + if image_values: + extra["image"] = image_values + extra["image_urls"] = image_values + + extra_body = { + "model": model, + "prompt": prompt, + "size": raw_size, + "n": 1, + "extra_body": extra, + } + if q: + extra_body["quality"] = q + variants.append(("extra_body", extra_body)) + + if prefer_apimart: + order = ["apimart", "openai-json-universal", "openai-json-image_urls", "extra_body"] + else: + order = ["openai-json-universal", "openai-json-image_urls", "extra_body"] + + by_name = {name: body for name, body in variants} + out = [] + seen = set() + for name in order: + body = by_name.get(name) + if not body: + continue + sig = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) + if sig in seen: + continue + seen.add(sig) + out.append((name, body)) + + return out + def provider_protocol(provider): return str((provider or {}).get("protocol") or "openai").strip().lower() @@ -4621,12 +4818,40 @@ async def generate_jimeng_video(payload: CanvasVideoRequest, provider): IMAGE_TASK_SUCCESS_STATUSES = {"SUCCESS", "SUCCESSFUL", "SUCCEED", "SUCCEEDED", "COMPLETED", "COMPLETE", "DONE", "FINISHED", "OK", "READY"} IMAGE_TASK_FAILED_STATUSES = {"FAILURE", "FAILED", "FAIL", "ERROR", "ERRORED", "CANCELED", "CANCELLED", "TIMEOUT", "REJECTED", "EXPIRED"} -def image_task_url_for_provider(provider, task_id): +def image_task_url_candidates_for_provider(provider, task_id): base_url = (provider.get("base_url") if provider else AI_BASE_URL).rstrip("/") - is_apimart = is_apimart_provider(provider) - if is_apimart: - return f"{base_url}/tasks/{task_id}" if base_url.endswith("/v1") else f"{base_url}/v1/tasks/{task_id}" - return f"{base_url}/images/tasks/{task_id}" if base_url.endswith("/v1") else f"{base_url}/v1/images/tasks/{task_id}" + base_v1 = base_url if base_url.endswith("/v1") else f"{base_url}/v1" + task_id = urllib.parse.quote(str(task_id or "").strip(), safe="") + + candidates = [] + + if is_apimart_provider(provider): + # APIMart 真站一般是 /v1/tasks/{id} + candidates.extend([ + f"{base_v1}/tasks/{task_id}", + f"{base_v1}/images/tasks/{task_id}", + ]) + else: + # OpenAI 兼容异步站一般是 /v1/images/tasks/{id} + candidates.extend([ + f"{base_v1}/images/tasks/{task_id}", + f"{base_v1}/tasks/{task_id}", + ]) + + # 去重 + out = [] + seen = set() + for url in candidates: + if url and url not in seen: + seen.add(url) + out.append(url) + + return out + + +def image_task_url_for_provider(provider, task_id): + urls = image_task_url_candidates_for_provider(provider, task_id) + return urls[0] if urls else "" def image_task_data(payload): if isinstance(payload, dict) and isinstance(payload.get("data"), dict): @@ -4643,10 +4868,36 @@ def image_task_fail_reason(payload): return task_data.get("fail_reason") or task_data.get("message") or error.get("message") or (payload.get("message") if isinstance(payload, dict) else "") or "生图任务失败" async def fetch_image_task_payload(client, task_id, provider=None): - task_url = image_task_url_for_provider(provider, task_id) - response = await client.get(task_url, headers=api_headers(provider=provider)) - response.raise_for_status() - return response.json() + errors = [] + + for task_url in image_task_url_candidates_for_provider(provider, task_id): + try: + response = await client.get( + task_url, + headers=api_headers(provider=provider), + ) + + if response.status_code in (404, 405): + errors.append(f"{task_url} -> HTTP {response.status_code}") + continue + + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + text = exc.response.text[:300] + errors.append(f"{task_url} -> HTTP {status} {text}") + + if status in (404, 405): + continue + + raise + + raise HTTPException( + status_code=502, + detail="查询异步生图任务失败,已尝试:" + ";".join(errors[:4]), + ) async def wait_for_image_task(client, task_id, provider=None): is_apimart = is_apimart_provider(provider) @@ -7509,23 +7760,51 @@ def gemini_reference_part(ref): async def generate_gemini_provider_image(prompt, size, model, reference_images=None, provider=None): model_name = gemini_model_name(model) endpoint = gemini_endpoint_url(provider, model_name) - parts = [{"text": prompt.strip()}] + + ref_parts = [] for ref in (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX]: part = gemini_reference_part(ref) if part: - parts.append(part) + ref_parts.append(part) + + contents = [] + + # 不改用户提示词,不加关键词。 + # 只把每张参考图拆成独立 user content,避免部分 Gemini 中转适配器把多图 parts 混在一起理解。 + for part in ref_parts: + contents.append({ + "role": "user", + "parts": [part], + }) + + contents.append({ + "role": "user", + "parts": [{"text": prompt.strip()}], + }) + body = { - "contents": [{"role": "user", "parts": parts}], + "contents": contents, "generationConfig": { "responseModalities": ["TEXT", "IMAGE"], "imageConfig": gemini_image_config(size), }, } + + print( + f"[gemini-image] contents={len(contents)} refs={len(ref_parts)} " + f"prompt_len={len(str(prompt or ''))} size={size} model={model}" + ) + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=20.0, read=1800.0, write=120.0, pool=20.0)) as client: response = await client.post(endpoint, headers=api_headers(provider=provider), json=body) response.raise_for_status() raw = response.json() - return extract_image(raw), raw + try: + return extract_image(raw), raw + except Exception: + print("[gemini-image] 未解析到图片,原始返回如下:") + print(json.dumps(raw, ensure_ascii=False)[:8000]) + raise def volcengine_endpoint_url(provider): return provider_endpoint_url(provider, "image_generation_endpoint", "/api/v3/images/generations") @@ -8677,20 +8956,61 @@ async def post_openai_edits(edit_files=None): body = {"model": model, "prompt": prompt, "size": size, "extra_body": extra_body} response = await client.post(gen_url, headers=api_headers(provider=provider, model=model), json=body) elif is_apimart: - apimart_size, resolution = apimart_size_resolution(size) - # APIMart 的 GPT-Image-2 图生图仍走 /images/generations, - # 通过 image_urls 传参考图,不使用 OpenAI multipart /images/edits。 - body = { - "model": model, - "prompt": prompt, - "n": 1, - "size": apimart_size, - "resolution": resolution, - "official_fallback": False, - } - if image_refs: - body["image_urls"] = [reference_to_data_url(ref, max_size=1536) for ref in image_refs[:ONLINE_IMAGE_REFERENCE_MAX]] - response = await client.post(gen_url, headers=api_headers(provider=provider, model=model), json=body) + # APIMart 真站继续优先 APIMart 格式; + # 其他“支持异步协议”的中转站优先 OpenAI JSON 格式,避免只吃 prompt、不吃 size/quality/image。 + base_host = urllib.parse.urlparse(str(base_url or "")).netloc.lower() + prefer_apimart = base_host == "apimart.ai" or base_host.endswith(".apimart.ai") + + response = None + last_status = 0 + last_text = "" + + for variant_name, body in async_image_payload_variants( + prompt, + size, + quality, + model, + image_refs, + prefer_apimart=prefer_apimart, + ): + print( + f"[async-image] submit variant={variant_name} " + f"provider={provider.get('id')} model={model} " + f"prompt_len={len(str(body.get('prompt') or body.get('input') or ''))} " + f"image_count={len(body.get('image_urls') or body.get('image') or body.get('images') or [])} " + f"size={body.get('size')} quality={body.get('quality')} " + f"keys={list(body.keys())}" + ) + if isinstance(body.get("extra_body"), dict): + print(f"[async-image] extra_body keys={list(body['extra_body'].keys())}") + + resp = await client.post( + gen_url, + headers=api_headers(provider=provider, model=model), + json=body, + ) + + if resp.status_code < 400: + response = resp + break + + last_status = resp.status_code + last_text = resp.text[:800] + print(f"[async-image] variant={variant_name} failed status={last_status}: {last_text[:300]}") + + # 鉴权、限流、服务端错误不要反复重试,避免重复扣费或打爆上游 + if resp.status_code in (401, 403, 429) or resp.status_code >= 500: + resp.raise_for_status() + + # 400 / 404 / 405 / 415 / 422 多半是字段名或端点格式不兼容,可以换一种格式再试 + if resp.status_code not in (400, 404, 405, 415, 422): + resp.raise_for_status() + + if response is None: + raise HTTPException( + status_code=502, + detail=f"异步生图提交失败:{last_status} {last_text[:300]}", + ) elif is_gpt2 and not image_refs and not mask_refs: body = {"model": model, "prompt": prompt, "size": size} if quality: From e62b0bf4c26932f3da10eb9bea4914a979113bf4 Mon Sep 17 00:00:00 2001 From: Hiro Date: Thu, 25 Jun 2026 22:04:22 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E7=94=9F=E5=9B=BE=E5=9C=A8=20Gemini=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=92=8C=E7=AC=AC=E4=B8=89=E6=96=B9=E5=BC=82=E6=AD=A5=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8B=E7=9A=84=E5=85=BC=E5=AE=B9=E6=80=A7=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/main.py b/main.py index 6ca726aee..67712ad78 100644 --- a/main.py +++ b/main.py @@ -3690,7 +3690,6 @@ def collect(value, depth=0): "value": image_url, }) - current = data if isinstance(current, dict) and isinstance(current.get("data"), dict) and isinstance(current["data"].get("result"), dict): current = current["data"] @@ -7756,7 +7755,6 @@ def gemini_reference_part(ref): if isinstance(value, str) and value.startswith(("http://", "https://")): return {"fileData": {"mimeType": "image/png", "fileUri": value}} return None - async def generate_gemini_provider_image(prompt, size, model, reference_images=None, provider=None): model_name = gemini_model_name(model) endpoint = gemini_endpoint_url(provider, model_name) @@ -7769,7 +7767,6 @@ async def generate_gemini_provider_image(prompt, size, model, reference_images=N contents = [] - # 不改用户提示词,不加关键词。 # 只把每张参考图拆成独立 user content,避免部分 Gemini 中转适配器把多图 parts 混在一起理解。 for part in ref_parts: contents.append({ From 8c547280184b0ce00a2e000e80fbffc5b2c9fec6 Mon Sep 17 00:00:00 2001 From: Hiro Date: Fri, 26 Jun 2026 17:09:32 +0800 Subject: [PATCH 3/3] =?UTF-8?q?1.=E4=BC=98=E5=8C=96gemini=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=BC=A0=E5=85=A5=E9=80=BB=E8=BE=91,=E5=9B=BE?= =?UTF-8?q?=E5=83=8F=E8=AF=86=E5=88=AB=E8=83=BD=E5=8A=9B=E6=9B=B4=E5=BC=BA?= =?UTF-8?q?2.=E4=BC=98=E5=8C=96=E5=BC=82=E6=AD=A5=E5=8A=9F=E8=83=BD(?= =?UTF-8?q?=E9=99=A4apimart=E5=A4=96,=E5=85=B6=E4=BB=96=E7=BD=91=E7=AB=99?= =?UTF-8?q?=E8=B5=B0=E5=85=BC=E5=AE=B9=E5=8F=A3),3.=E4=BC=98=E5=8C=96gemin?= =?UTF-8?q?i=E9=81=87=E5=88=B0=E9=94=99=E8=AF=AF=E6=97=B6=E4=BC=A0?= =?UTF-8?q?=E5=85=A5=E7=9A=84=E9=80=BB=E8=BE=91.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 91 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/main.py b/main.py index 67712ad78..25aa1d646 100644 --- a/main.py +++ b/main.py @@ -526,6 +526,15 @@ def load_env_file(): CHAT_ATTACHMENT_MAX = int(os.getenv("CHAT_ATTACHMENT_MAX", "20")) ONLINE_IMAGE_REFERENCE_MAX = int(os.getenv("ONLINE_IMAGE_REFERENCE_MAX", "20")) +# ("GEMINI_SPLIT_CONTENTS", "0")不启用新 Gemini 拆分传图方式 +# {"role": "user", "parts": [文本, 图1, 图2, ...]} +# ("GEMINI_SPLIT_CONTENTS", "1")启用新的 Gemini 拆分传图方式 +# {"role": "user", "parts": [图1]}, +# {"role": "user", "parts": [图2]}, +# {"role": "user", "parts": [图...]}, +# {"role": "user", "parts": [{"text": prompt}]}, +GEMINI_SPLIT_CONTENTS = os.getenv("GEMINI_SPLIT_CONTENTS", "1") == "1" + FIELD_LABELS = { "prompt": "提示词", "message": "文本", @@ -3801,7 +3810,6 @@ def extract_task_id(data): for key in ( "task_id", "taskId", "taskID", "job_id", "jobId", - "request_id", "requestId", "generation_id", "generationId", ): value = data.get(key) @@ -3907,18 +3915,15 @@ def async_image_payload_variants(prompt, size, quality, model, image_refs=None, openai_image_urls["image_urls"] = image_values variants.append(("openai-json-image_urls", openai_image_urls)) - # 3) APIMart 风格:保留你原来能用的那套 + # 3) APIMart 风格:保留原来能用的那套 apimart_body = { "model": model, "prompt": prompt, "n": 1, "size": aspect_ratio, "resolution": resolution, - "response_format": "url", "official_fallback": False, } - if q: - apimart_body["quality"] = q if image_values: apimart_body["image_urls"] = image_values variants.append(("apimart", apimart_body)) @@ -7765,19 +7770,24 @@ async def generate_gemini_provider_image(prompt, size, model, reference_images=N if part: ref_parts.append(part) - contents = [] - - # 只把每张参考图拆成独立 user content,避免部分 Gemini 中转适配器把多图 parts 混在一起理解。 - for part in ref_parts: + if GEMINI_SPLIT_CONTENTS: + # 每张参考图拆成独立 user content。 + contents = [] + for part in ref_parts: + contents.append({ + "role": "user", + "parts": [part], + }) contents.append({ "role": "user", - "parts": [part], + "parts": [{"text": prompt.strip()}], }) - - contents.append({ - "role": "user", - "parts": [{"text": prompt.strip()}], - }) + else: + # 保持原来的 Gemini 传入结构,不改变原功能。 + parts = [{"text": prompt.strip()}] + for part in ref_parts: + parts.append(part) + contents = [{"role": "user", "parts": parts}] body = { "contents": contents, @@ -7788,14 +7798,59 @@ async def generate_gemini_provider_image(prompt, size, model, reference_images=N } print( - f"[gemini-image] contents={len(contents)} refs={len(ref_parts)} " + f"[gemini-image] split_contents={GEMINI_SPLIT_CONTENTS} " + f"contents={len(contents)} refs={len(ref_parts)} " f"prompt_len={len(str(prompt or ''))} size={size} model={model}" ) async with httpx.AsyncClient(timeout=httpx.Timeout(connect=20.0, read=1800.0, write=120.0, pool=20.0)) as client: response = await client.post(endpoint, headers=api_headers(provider=provider), json=body) - response.raise_for_status() - raw = response.json() + + raw = None + try: + raw = response.json() + except Exception: + raw = None + + if response.status_code >= 400: + task_id = extract_task_id(raw) if isinstance(raw, dict) else "" + if not task_id: + task_id = extract_task_id_from_text(response.text) + + # 有些上游虽然 HTTP 状态码是 4xx/5xx,但 body 里实际已经带了图片,先尝试解析。 + if isinstance(raw, dict): + try: + return extract_image(raw), raw + except Exception: + pass + + # 如果错误体里带 task_id,尝试按异步任务再查一次。 + if task_id: + try: + task_raw = await wait_for_image_task(client, task_id, provider) + return extract_image(task_raw), task_raw + except Exception as exc: + print(f"[gemini-image] HTTP {response.status_code} 后尝试查询任务失败 task_id={task_id}: {exc}") + + raw_preview = json.dumps(raw, ensure_ascii=False)[:1000] if isinstance(raw, dict) else (response.text or "")[:1000] + print(f"[gemini-image] HTTP错误 status={response.status_code} raw={raw_preview}") + + if response.status_code == 504: + raise HTTPException( + status_code=504, + detail=( + "Gemini 上游同步生图等待超时。上游后台可能仍会继续生成成功," + "但本次 HTTP 返回没有直接图片结果" + + (f",任务 ID:{task_id}" if task_id else ",也没有返回可查询的 task_id") + + f"。原始返回:{raw_preview[:500]}" + ) + ) + + response.raise_for_status() + + if raw is None: + raise HTTPException(status_code=502, detail=f"Gemini 生图接口返回非 JSON 响应:{(response.text or '')[:500]}") + try: return extract_image(raw), raw except Exception: