From b1f568d03d0272daa9cb5d3bd764f15c8831cb97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=87=8E?= Date: Thu, 26 Feb 2026 17:16:15 +0800 Subject: [PATCH] fix(baoyu-image-gen): use curl fallback for Google API when HTTP proxy is detected Bun's fetch implementation has a known issue where long-lived connections through HTTP proxies (e.g., Clash, V2Ray) get their sockets closed unexpectedly, causing Google image generation requests to fail with "The socket connection was closed unexpectedly". This change adds automatic proxy detection and falls back to curl as the HTTP client when a proxy is configured (via https_proxy, http_proxy, HTTPS_PROXY, HTTP_PROXY, or ALL_PROXY environment variables). When no proxy is detected, the original fetch-based implementation is used. Co-Authored-By: Claude Opus 4.6 --- .../scripts/providers/google.ts | 130 +++++++++++++++--- 1 file changed, 108 insertions(+), 22 deletions(-) diff --git a/skills/baoyu-image-gen/scripts/providers/google.ts b/skills/baoyu-image-gen/scripts/providers/google.ts index e1a2729..1d8bcaa 100644 --- a/skills/baoyu-image-gen/scripts/providers/google.ts +++ b/skills/baoyu-image-gen/scripts/providers/google.ts @@ -1,9 +1,16 @@ import path from "node:path"; import { readFile } from "node:fs/promises"; +import { execSync } from "node:child_process"; import type { CliArgs } from "../types"; -const GOOGLE_MULTIMODAL_MODELS = ["gemini-3-pro-image-preview", "gemini-3-flash-preview"]; -const GOOGLE_IMAGEN_MODELS = ["imagen-3.0-generate-002", "imagen-3.0-generate-001"]; +const GOOGLE_MULTIMODAL_MODELS = [ + "gemini-3-pro-image-preview", + "gemini-3-flash-preview", +]; +const GOOGLE_IMAGEN_MODELS = [ + "imagen-3.0-generate-002", + "imagen-3.0-generate-001", +]; export function getDefaultModel(): string { return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview"; @@ -33,7 +40,8 @@ function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" { } function getGoogleBaseUrl(): string { - const base = process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com"; + const base = + process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com"; return base.replace(/\/+$/g, ""); } @@ -49,11 +57,46 @@ function toModelPath(model: string): string { return `models/${modelId}`; } -async function postGoogleJson(pathname: string, body: unknown): Promise { - const apiKey = getGoogleApiKey(); - if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required"); +function getHttpProxy(): string | null { + return ( + process.env.https_proxy || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.HTTP_PROXY || + process.env.ALL_PROXY || + null + ); +} - const res = await fetch(buildGoogleUrl(pathname), { +async function postGoogleJsonViaCurl( + url: string, + apiKey: string, + body: unknown, +): Promise { + const proxy = getHttpProxy(); + const bodyStr = JSON.stringify(body); + const proxyArgs = proxy ? `-x "${proxy}"` : ""; + + const result = execSync( + `curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`, + { input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 }, + ); + + const parsed = JSON.parse(result.toString()) as any; + if (parsed.error) { + throw new Error( + `Google API error (${parsed.error.code}): ${parsed.error.message}`, + ); + } + return parsed as T; +} + +async function postGoogleJsonViaFetch( + url: string, + apiKey: string, + body: unknown, +): Promise { + const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", @@ -70,7 +113,30 @@ async function postGoogleJson(pathname: string, body: unknown): Promise { return (await res.json()) as T; } -function buildPromptWithAspect(prompt: string, ar: string | null, quality: CliArgs["quality"]): string { +async function postGoogleJson(pathname: string, body: unknown): Promise { + const apiKey = getGoogleApiKey(); + if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required"); + + const url = buildGoogleUrl(pathname); + const proxy = getHttpProxy(); + + // When an HTTP proxy is detected, use curl instead of fetch. + // Bun's fetch has a known issue where long-lived connections through + // HTTP proxies get their sockets closed unexpectedly, causing image + // generation requests to fail with "socket connection was closed + // unexpectedly". Using curl as the HTTP client works around this. + if (proxy) { + return postGoogleJsonViaCurl(url, apiKey, body); + } + + return postGoogleJsonViaFetch(url, apiKey, body); +} + +function buildPromptWithAspect( + prompt: string, + ar: string | null, + quality: CliArgs["quality"], +): string { let result = prompt; if (ar) { result += ` Aspect ratio: ${ar}.`; @@ -86,7 +152,9 @@ function addAspectRatioToPrompt(prompt: string, ar: string | null): string { return `${prompt} Aspect ratio: ${ar}.`; } -async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: string }> { +async function readImageAsBase64( + p: string, +): Promise<{ data: string; mimeType: string }> { const buf = await readFile(p); const ext = path.extname(p).toLowerCase(); let mimeType = "image/png"; @@ -97,7 +165,9 @@ async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: s } function extractInlineImageData(response: { - candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>; + candidates?: Array<{ + content?: { parts?: Array<{ inlineData?: { data?: string } }> }; + }>; }): string | null { for (const candidate of response.candidates || []) { for (const part of candidate.content?.parts || []) { @@ -112,16 +182,21 @@ function extractPredictedImageData(response: { predictions?: Array; generatedImages?: Array; }): string | null { - const candidates = [...(response.predictions || []), ...(response.generatedImages || [])]; + const candidates = [ + ...(response.predictions || []), + ...(response.generatedImages || []), + ]; for (const candidate of candidates) { if (!candidate || typeof candidate !== "object") continue; if (typeof candidate.imageBytes === "string") return candidate.imageBytes; - if (typeof candidate.bytesBase64Encoded === "string") return candidate.bytesBase64Encoded; + if (typeof candidate.bytesBase64Encoded === "string") + return candidate.bytesBase64Encoded; if (typeof candidate.data === "string") return candidate.data; const image = candidate.image; if (image && typeof image === "object") { if (typeof image.imageBytes === "string") return image.imageBytes; - if (typeof image.bytesBase64Encoded === "string") return image.bytesBase64Encoded; + if (typeof image.bytesBase64Encoded === "string") + return image.bytesBase64Encoded; if (typeof image.data === "string") return image.data; } } @@ -131,10 +206,13 @@ function extractPredictedImageData(response: { async function generateWithGemini( prompt: string, model: string, - args: CliArgs + args: CliArgs, ): Promise { const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio); - const parts: Array<{ text?: string; inlineData?: { data: string; mimeType: string } }> = []; + const parts: Array<{ + text?: string; + inlineData?: { data: string; mimeType: string }; + }> = []; for (const refPath of args.referenceImages) { const { data, mimeType } = await readImageAsBase64(refPath); parts.push({ inlineData: { data, mimeType } }); @@ -147,7 +225,9 @@ async function generateWithGemini( console.log("Generating image with Gemini...", imageConfig); const response = await postGoogleJson<{ - candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>; + candidates?: Array<{ + content?: { parts?: Array<{ inlineData?: { data?: string } }> }; + }>; }>(`${toModelPath(model)}:generateContent`, { contents: [ { @@ -171,12 +251,18 @@ async function generateWithGemini( async function generateWithImagen( prompt: string, model: string, - args: CliArgs + args: CliArgs, ): Promise { - const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality); + const fullPrompt = buildPromptWithAspect( + prompt, + args.aspectRatio, + args.quality, + ); const imageSize = getGoogleImageSize(args); if (imageSize === "4K") { - console.error("Warning: Imagen models do not support 4K imageSize, using 2K instead."); + console.error( + "Warning: Imagen models do not support 4K imageSize, using 2K instead.", + ); } const parameters: Record = { @@ -212,12 +298,12 @@ async function generateWithImagen( export async function generateImage( prompt: string, model: string, - args: CliArgs + args: CliArgs, ): Promise { if (isGoogleImagen(model)) { if (args.referenceImages.length > 0) { throw new Error( - "Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview or gemini-3-flash-preview." + "Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview or gemini-3-flash-preview.", ); } return generateWithImagen(prompt, model, args); @@ -225,7 +311,7 @@ export async function generateImage( if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) { throw new Error( - "Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview or gemini-3-flash-preview." + "Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview or gemini-3-flash-preview.", ); }