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 <noreply@anthropic.com>
This commit is contained in:
李野
2026-02-26 17:16:15 +08:00
parent e736707628
commit b1f568d03d
@@ -1,9 +1,16 @@
import path from "node:path"; import path from "node:path";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { execSync } from "node:child_process";
import type { CliArgs } from "../types"; import type { CliArgs } from "../types";
const GOOGLE_MULTIMODAL_MODELS = ["gemini-3-pro-image-preview", "gemini-3-flash-preview"]; const GOOGLE_MULTIMODAL_MODELS = [
const GOOGLE_IMAGEN_MODELS = ["imagen-3.0-generate-002", "imagen-3.0-generate-001"]; "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 { export function getDefaultModel(): string {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview"; 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 { 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, ""); return base.replace(/\/+$/g, "");
} }
@@ -49,11 +57,46 @@ function toModelPath(model: string): string {
return `models/${modelId}`; return `models/${modelId}`;
} }
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> { function getHttpProxy(): string | null {
const apiKey = getGoogleApiKey(); return (
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required"); 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<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
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<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const res = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -70,7 +113,30 @@ async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
return (await res.json()) as T; return (await res.json()) as T;
} }
function buildPromptWithAspect(prompt: string, ar: string | null, quality: CliArgs["quality"]): string { async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
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<T>(url, apiKey, body);
}
return postGoogleJsonViaFetch<T>(url, apiKey, body);
}
function buildPromptWithAspect(
prompt: string,
ar: string | null,
quality: CliArgs["quality"],
): string {
let result = prompt; let result = prompt;
if (ar) { if (ar) {
result += ` Aspect ratio: ${ar}.`; result += ` Aspect ratio: ${ar}.`;
@@ -86,7 +152,9 @@ function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
return `${prompt} Aspect ratio: ${ar}.`; 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 buf = await readFile(p);
const ext = path.extname(p).toLowerCase(); const ext = path.extname(p).toLowerCase();
let mimeType = "image/png"; let mimeType = "image/png";
@@ -97,7 +165,9 @@ async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: s
} }
function extractInlineImageData(response: { function extractInlineImageData(response: {
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>; candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}): string | null { }): string | null {
for (const candidate of response.candidates || []) { for (const candidate of response.candidates || []) {
for (const part of candidate.content?.parts || []) { for (const part of candidate.content?.parts || []) {
@@ -112,16 +182,21 @@ function extractPredictedImageData(response: {
predictions?: Array<any>; predictions?: Array<any>;
generatedImages?: Array<any>; generatedImages?: Array<any>;
}): string | null { }): string | null {
const candidates = [...(response.predictions || []), ...(response.generatedImages || [])]; const candidates = [
...(response.predictions || []),
...(response.generatedImages || []),
];
for (const candidate of candidates) { for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue; if (!candidate || typeof candidate !== "object") continue;
if (typeof candidate.imageBytes === "string") return candidate.imageBytes; 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; if (typeof candidate.data === "string") return candidate.data;
const image = candidate.image; const image = candidate.image;
if (image && typeof image === "object") { if (image && typeof image === "object") {
if (typeof image.imageBytes === "string") return image.imageBytes; 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; if (typeof image.data === "string") return image.data;
} }
} }
@@ -131,10 +206,13 @@ function extractPredictedImageData(response: {
async function generateWithGemini( async function generateWithGemini(
prompt: string, prompt: string,
model: string, model: string,
args: CliArgs args: CliArgs,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio); 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) { for (const refPath of args.referenceImages) {
const { data, mimeType } = await readImageAsBase64(refPath); const { data, mimeType } = await readImageAsBase64(refPath);
parts.push({ inlineData: { data, mimeType } }); parts.push({ inlineData: { data, mimeType } });
@@ -147,7 +225,9 @@ async function generateWithGemini(
console.log("Generating image with Gemini...", imageConfig); console.log("Generating image with Gemini...", imageConfig);
const response = await postGoogleJson<{ const response = await postGoogleJson<{
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>; candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}>(`${toModelPath(model)}:generateContent`, { }>(`${toModelPath(model)}:generateContent`, {
contents: [ contents: [
{ {
@@ -171,12 +251,18 @@ async function generateWithGemini(
async function generateWithImagen( async function generateWithImagen(
prompt: string, prompt: string,
model: string, model: string,
args: CliArgs args: CliArgs,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality); const fullPrompt = buildPromptWithAspect(
prompt,
args.aspectRatio,
args.quality,
);
const imageSize = getGoogleImageSize(args); const imageSize = getGoogleImageSize(args);
if (imageSize === "4K") { 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<string, unknown> = { const parameters: Record<string, unknown> = {
@@ -212,12 +298,12 @@ async function generateWithImagen(
export async function generateImage( export async function generateImage(
prompt: string, prompt: string,
model: string, model: string,
args: CliArgs args: CliArgs,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
if (isGoogleImagen(model)) { if (isGoogleImagen(model)) {
if (args.referenceImages.length > 0) { if (args.referenceImages.length > 0) {
throw new Error( 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); return generateWithImagen(prompt, model, args);
@@ -225,7 +311,7 @@ export async function generateImage(
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) { if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
throw new Error( 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.",
); );
} }