From 45919c570ff11c6c85e7770588bc1e91589c9787 Mon Sep 17 00:00:00 2001 From: jiajia Date: Wed, 29 Apr 2026 10:57:11 +0800 Subject: [PATCH] Share current local version Confidence: high Scope-risk: narrow Tested: git commit only Not-tested: colleague machine --- .../providers/__tests__/newapiwg.test.ts | 87 +++++++++++++++++ src/app/api/generate/providers/newapiwg.ts | 94 +++++++++++++++---- src/utils/__tests__/logger.test.ts | 25 +++++ src/utils/logger.ts | 10 +- 4 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 src/utils/__tests__/logger.test.ts diff --git a/src/app/api/generate/providers/__tests__/newapiwg.test.ts b/src/app/api/generate/providers/__tests__/newapiwg.test.ts index 42a7b59f..9c2e5c66 100644 --- a/src/app/api/generate/providers/__tests__/newapiwg.test.ts +++ b/src/app/api/generate/providers/__tests__/newapiwg.test.ts @@ -3,6 +3,7 @@ import { fetchNewApiWGModels, generateWithNewApiWG, inferNewApiWGCapabilities, + normalizeNewApiWGError, } from "../newapiwg"; import type { GenerationInput } from "@/lib/providers/types"; @@ -171,6 +172,7 @@ describe("NewApiWG generation payloads", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); it("passes schema-mapped dynamic image and audio inputs to video generation", async () => { @@ -257,6 +259,52 @@ describe("NewApiWG generation payloads", () => { }); }); + it("retries transient overload errors from Gemini native image generation", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async () => { + if (fetchMock.mock.calls.length === 1) { + return new Response(JSON.stringify({ error: { message: "system disk overloaded" } }), { + status: 500, + }); + } + return new Response(JSON.stringify({ + candidates: [{ + content: { + parts: [{ + inlineData: { + mimeType: "image/png", + data: "retry-success", + }, + }], + }, + }], + }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const promise = generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ + model: { + id: "apiyi_nano_banana_2", + name: "APIYI Nano Banana 2", + description: null, + provider: "newapiwg", + capabilities: ["text-to-image", "image-to-image"], + }, + prompt: "draw a banana", + })); + + await vi.advanceTimersByTimeAsync(500); + const result = await promise; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.success).toBe(true); + expect(result.outputs?.[0]).toEqual({ + type: "image", + data: "data:image/png;base64,retry-success", + }); + }); + it("routes sora image models through chat completions", async () => { const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ model: { @@ -297,4 +345,43 @@ describe("NewApiWG generation payloads", () => { expect(capturedBody!.model).toBe("seedream-5-0-260128"); expect(capturedBody!.image).toBe("data:image/png;base64,input"); }); + + it("normalizes transient provider overload errors", () => { + expect(normalizeNewApiWGError("system disk overloaded", "Nano Banana")).toBe( + "Nano Banana: provider is temporarily overloaded. Please retry in a moment or switch models." + ); + }); + + it("returns normalized overload errors from image generation", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + if (urlStr.includes("/images/generations") && init?.method === "POST") { + return new Response(JSON.stringify({ error: { message: "system disk overloaded" } }), { + status: 500, + }); + } + return new Response("Not Found", { status: 404 }); + })); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const promise = generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ + model: { + id: "seedream-5-0-260128", + name: "Seedream", + description: null, + provider: "newapiwg", + capabilities: ["text-to-image"], + }, + prompt: "draw a banana", + })); + + await vi.advanceTimersByTimeAsync(2000); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toBe( + "Seedream: provider is temporarily overloaded. Please retry in a moment or switch models." + ); + }); }); diff --git a/src/app/api/generate/providers/newapiwg.ts b/src/app/api/generate/providers/newapiwg.ts index eb4d1544..9d59ebf6 100644 --- a/src/app/api/generate/providers/newapiwg.ts +++ b/src/app/api/generate/providers/newapiwg.ts @@ -207,6 +207,56 @@ function firstString(value: unknown): string | undefined { return undefined; } +const NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS = [500, 1500]; + +function isNewApiWGTransientError(message: string): boolean { + return /system\s+disk\s+overloaded/i.test(message); +} + +export function normalizeNewApiWGError(message: string, modelName = "NewApiWG"): string { + const normalized = message.trim(); + if (isNewApiWGTransientError(normalized)) { + return `${modelName}: provider is temporarily overloaded. Please retry in a moment or switch models.`; + } + return normalized; +} + +async function wait(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchNewApiWGJsonWithRetry( + url: string, + init: RequestInit, + modelName: string, + fallbackError: string +): Promise<{ response: Response; data: unknown }> { + for (let attempt = 0; attempt <= NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS.length; attempt++) { + const response = await fetch(url, init); + const data = await response.json().catch(() => ({})); + + if (response.ok) { + return { response, data }; + } + + const message = firstString(data) || fallbackError; + const canRetry = + attempt < NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS.length && + isNewApiWGTransientError(message); + + if (!canRetry) { + return { response, data }; + } + + console.warn( + `[NewApiWG] ${modelName} returned transient overload; retrying attempt ${attempt + 2}` + ); + await wait(NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS[attempt]); + } + + throw new Error("unreachable"); +} + async function urlToDataUrl(url: string): Promise { const response = await fetch(url); if (!response.ok) throw new Error(`Failed to fetch generated media: ${response.status}`); @@ -419,7 +469,7 @@ async function generateImageViaNewApiWGChat( ] : prompt; - const response = await fetch(`${baseUrl}/chat/completions`, { + const { response, data } = await fetchNewApiWGJsonWithRetry(`${baseUrl}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -432,17 +482,18 @@ async function generateImageViaNewApiWGChat( ...dynamicInputsWithoutPrompt(input), ...(input.parameters || {}), }), - }); + }, input.model.name, "NewApiWG chat image API error"); - const data = await response.json().catch(() => ({})); if (!response.ok) { const message = firstString(data) || `NewApiWG chat image API error: ${response.status}`; - return { success: false, error: message }; + return { success: false, error: normalizeNewApiWGError(message, input.model.name) }; } const image = extractNewApiWGImage(data); if (!image) { - const text = data?.choices?.[0]?.message?.content; + const text = data && typeof data === "object" + ? firstString((data as Record).choices) + : undefined; return { success: false, error: typeof text === "string" && text.length > 0 @@ -529,7 +580,7 @@ async function generateImageViaNewApiWGGemini( : "2K"; const modelName = toGeminiNativeModelName(input.model.id); - const response = await fetch(`${normalizeNewApiWGGeminiBaseUrl(baseUrl)}/models/${encodeURIComponent(modelName)}:generateContent`, { + const { response, data } = await fetchNewApiWGJsonWithRetry(`${normalizeNewApiWGGeminiBaseUrl(baseUrl)}/models/${encodeURIComponent(modelName)}:generateContent`, { method: "POST", headers: { "Content-Type": "application/json", @@ -546,12 +597,11 @@ async function generateImageViaNewApiWGGemini( }, ...dynamicInputsWithoutPrompt(input), }), - }); + }, input.model.name, "NewApiWG Gemini image API error"); - const data = await response.json().catch(() => ({})); if (!response.ok) { const message = firstString(data) || `NewApiWG Gemini image API error: ${response.status}`; - return { success: false, error: message }; + return { success: false, error: normalizeNewApiWGError(message, input.model.name) }; } const image = extractGeminiNativeImage(data); @@ -611,7 +661,7 @@ export async function generateWithNewApiWG( const endpoint = `${normalizedBaseUrl}/${isVideo ? "video" : "images"}/generations`; const imageSize = typeof input.parameters?.size === "string" ? input.parameters.size : "1024x1024"; - const response = await fetch(endpoint, { + const { response, data } = await fetchNewApiWGJsonWithRetry(endpoint, { method: "POST", headers: { "Content-Type": "application/json", @@ -626,15 +676,13 @@ export async function generateWithNewApiWG( ...(!isVideo ? { size: imageSize, response_format: "b64_json" } : {}), ...(input.parameters || {}), }), - }); + }, input.model.name, `NewApiWG ${isVideo ? "video" : "image"} API error`); if (!response.ok) { - const error = await response.json().catch(() => ({})); - const message = firstString(error) || `NewApiWG image API error: ${response.status}`; - return { success: false, error: message }; + const message = firstString(data) || `NewApiWG image API error: ${response.status}`; + return { success: false, error: normalizeNewApiWGError(message, input.model.name) }; } - const data = await response.json(); if (isVideo) { const immediateUrl = getNewApiWGVideoUrl(data); if (immediateUrl) { @@ -647,17 +695,23 @@ export async function generateWithNewApiWG( } } - const first = data.data?.[0] ?? data.images?.[0] ?? data.videos?.[0] ?? data.output?.[0] ?? data; - const b64 = first?.b64_json || first?.base64 || first?.image_base64; - const url = first?.url || first?.image_url || first?.video_url || firstString(first); + const dataRecord = data && typeof data === "object" ? data as Record : {}; + const dataItems = Array.isArray(dataRecord.data) ? dataRecord.data : []; + const imageItems = Array.isArray(dataRecord.images) ? dataRecord.images : []; + const videoItems = Array.isArray(dataRecord.videos) ? dataRecord.videos : []; + const outputItems = Array.isArray(dataRecord.output) ? dataRecord.output : []; + const first = dataItems[0] ?? imageItems[0] ?? videoItems[0] ?? outputItems[0] ?? data; + const firstRecord = first && typeof first === "object" ? first as Record : {}; + const b64 = firstRecord.b64_json || firstRecord.base64 || firstRecord.image_base64; + const url = firstRecord.url || firstRecord.image_url || firstRecord.video_url || firstString(first); - if (b64) { + if (typeof b64 === "string" && b64.length > 0) { const mime = isVideo ? "video/mp4" : "image/png"; const prefix = b64.startsWith("data:") ? "" : `data:${mime};base64,`; return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: `${prefix}${b64}` }] }; } - if (url) { + if (typeof url === "string" && url.length > 0) { const dataUrl = url.startsWith("data:") ? url : await urlToDataUrl(url); return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: dataUrl, url }] }; } diff --git a/src/utils/__tests__/logger.test.ts b/src/utils/__tests__/logger.test.ts new file mode 100644 index 00000000..75dc8497 --- /dev/null +++ b/src/utils/__tests__/logger.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { logger } from "../logger"; + +describe("logger", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not emit browser-side application errors via console.error", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + logger.error("workflow.error", "Node execution failed in parallel batch", { + nodeId: "nanoBanana-2", + error: "system disk overloaded", + }); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "[workflow.error] Node execution failed in parallel batch", + { nodeId: "nanoBanana-2", error: "system disk overloaded" }, + "" + ); + }); +}); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 4c607cf9..7524be08 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -137,8 +137,14 @@ class Logger { this.currentSession.entries.push(entry); } - // Also log to console for development - const consoleMethod = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'; + // Also log to console for development. In the browser, Next dev treats + // console.error as a page-level error overlay, so keep application-level + // workflow failures visible without tripping the framework error UI. + const consoleMethod = level === 'error' + ? (this.isClient ? 'warn' : 'error') + : level === 'warn' + ? 'warn' + : 'log'; console[consoleMethod](`[${category}] ${message}`, context || '', error || ''); }