From 088ea822a11f77daf283ef236f797b7acfa72131 Mon Sep 17 00:00:00 2001 From: jiajia Date: Fri, 22 May 2026 19:27:33 +0800 Subject: [PATCH] Keep default image generation moving through gateway overloads The default Popi Models image route can return transient upstream overloads for apiyi_nano_banana_2 even when the gateway configuration is valid. The executor now uses gpt-image-2-all as an implicit same-gateway fallback only for overload-shaped errors, while preserving explicit fallback behavior and surfacing non-overload errors unchanged. Constraint: The gateway can intermittently return system_disk_overloaded for the default image model Rejected: Change the default model globally | would alter every new image node instead of only recovering transient failures Rejected: Fallback on every error | would hide prompt, safety, or request-shape problems Confidence: high Scope-risk: moderate Directive: Keep implicit fallback predicates narrow so user-actionable errors remain visible Tested: npm run test:run Tested: npm run build --- .../__tests__/nanoBananaExecutor.test.ts | 62 +++++++++++++++++++ .../__tests__/runWithFallback.test.ts | 16 +++++ src/store/execution/nanoBananaExecutor.ts | 14 +++++ src/store/execution/runWithFallback.ts | 6 +- src/store/utils/defaultImageModel.ts | 14 +++++ 5 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src/store/execution/__tests__/nanoBananaExecutor.test.ts b/src/store/execution/__tests__/nanoBananaExecutor.test.ts index a0e8cb83..00e1df8c 100644 --- a/src/store/execution/__tests__/nanoBananaExecutor.test.ts +++ b/src/store/execution/__tests__/nanoBananaExecutor.test.ts @@ -195,6 +195,68 @@ describe("executeNanoBanana", () => { }); }); + it("falls back from the default NewApiWG image model on provider overload", async () => { + const node = makeNode({ + selectedModel: { + provider: "newapiwg", + modelId: "apiyi_nano_banana_2", + displayName: "apiyi_nano_banana_2", + capabilities: ["text-to-image", "image-to-image"], + }, + }); + + mockFetch + .mockResolvedValueOnce({ + ok: false, + text: () => Promise.resolve(JSON.stringify({ + error: "apiyi_nano_banana_2: provider is temporarily overloaded. Please retry in a moment or switch models.", + })), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,fallback" }), + }); + + const ctx = makeCtx(node); + await executeNanoBanana(ctx); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const fallbackBody = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(fallbackBody.selectedModel).toMatchObject({ + provider: "newapiwg", + modelId: "gpt-image-2-all", + }); + + const calls = (ctx.updateNodeData as ReturnType).mock.calls; + const stampCall = calls.find( + (c: unknown[]) => (c[1] as Record).__usedFallback === true + ); + expect(stampCall?.[1]).toMatchObject({ + __fallbackModelUsed: "gpt-image-2-all", + __primaryError: "apiyi_nano_banana_2: provider is temporarily overloaded. Please retry in a moment or switch models.", + }); + }); + + it("does not implicitly fall back from the default NewApiWG image model on non-overload errors", async () => { + const node = makeNode({ + selectedModel: { + provider: "newapiwg", + modelId: "apiyi_nano_banana_2", + displayName: "apiyi_nano_banana_2", + capabilities: ["text-to-image", "image-to-image"], + }, + }); + + mockFetch.mockResolvedValueOnce({ + ok: false, + text: () => Promise.resolve(JSON.stringify({ error: "bad request" })), + }); + + const ctx = makeCtx(node); + await expect(executeNanoBanana(ctx)).rejects.toThrow("bad request"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it("reroutes stored Gemini image nodes to NewApiWG when Gemini key is missing", async () => { const node = makeNode(); mockFetch.mockResolvedValueOnce({ diff --git a/src/store/execution/__tests__/runWithFallback.test.ts b/src/store/execution/__tests__/runWithFallback.test.ts index 2b379277..8856076e 100644 --- a/src/store/execution/__tests__/runWithFallback.test.ts +++ b/src/store/execution/__tests__/runWithFallback.test.ts @@ -73,6 +73,22 @@ describe("runWithFallback", () => { expect(runOnce).toHaveBeenCalledTimes(1); }); + it("primary fails but predicate rejects fallback: rethrows primary error", async () => { + const runOnce = vi.fn().mockRejectedValueOnce(new Error("primary boom")); + await expect( + runWithFallback({ + nodeId: "n1", + primary, + fallback, + updateNodeData, + runOnce, + shouldFallback: () => false, + }) + ).rejects.toThrow("primary boom"); + + expect(runOnce).toHaveBeenCalledTimes(1); + }); + it("primary fails, fallback succeeds: stamps metadata and clears error", async () => { const runOnce = vi .fn() diff --git a/src/store/execution/nanoBananaExecutor.ts b/src/store/execution/nanoBananaExecutor.ts index 789d4860..ce2661a5 100644 --- a/src/store/execution/nanoBananaExecutor.ts +++ b/src/store/execution/nanoBananaExecutor.ts @@ -14,6 +14,8 @@ import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders"; import { createDefaultImageModel, createNewApiWGDefaultImageModel, + createNewApiWGDefaultImageFallbackModel, + isNewApiWGDefaultImageModel, shouldPreferNewApiWGImageModel, } from "@/store/utils/defaultImageModel"; import { pollGenerateTask } from "./pollTaskCompletion"; @@ -31,6 +33,11 @@ export interface NanoBananaOptions { useStoredFallback?: boolean; } +function isProviderOverloadError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /provider is temporarily overloaded|system\s+disk\s+overloaded/i.test(message); +} + async function loadSavedGenerationImage(directoryPath: string, imageId: string): Promise { const response = await fetch("/api/load-generation", { method: "POST", @@ -419,6 +426,7 @@ export async function executeNanoBanana( nodeData.selectedModel ?? createDefaultImageModel(nodeData.model, providerSettings, providerEnvStatus); let primaryParameters: Record | undefined; let fallbackModel = nodeData.fallbackModel; + let shouldFallback: ((error: unknown) => boolean) | undefined; if ( primaryModel.provider === "gemini" && @@ -447,6 +455,11 @@ export async function executeNanoBanana( }); } + if (!fallbackModel && isNewApiWGDefaultImageModel(primaryModel)) { + fallbackModel = createNewApiWGDefaultImageFallbackModel(); + shouldFallback = isProviderOverloadError; + } + await runWithFallback({ nodeId: node.id, primary: primaryModel, @@ -455,6 +468,7 @@ export async function executeNanoBanana( fallbackParameters: nodeData.fallbackParameters, updateNodeData, runOnce, + shouldFallback, clearOutput: { outputImage: null }, }); } diff --git a/src/store/execution/runWithFallback.ts b/src/store/execution/runWithFallback.ts index 1b6fd75c..96f11714 100644 --- a/src/store/execution/runWithFallback.ts +++ b/src/store/execution/runWithFallback.ts @@ -23,6 +23,7 @@ export interface RunWithFallbackOptions { fallbackParameters?: Record; updateNodeData: (id: string, data: Partial) => void; runOnce: (model: SelectedModel, parametersOverride?: Record) => Promise; + shouldFallback?: (error: unknown) => boolean; /** Data to merge when transitioning to fallback (e.g. { outputImage: null }) */ clearOutput?: Partial; } @@ -44,7 +45,7 @@ function isSameModel(a: SelectedModel, b: SelectedModel): boolean { export async function runWithFallback( options: RunWithFallbackOptions ): Promise { - const { nodeId, primary, primaryParameters, fallback, fallbackParameters, updateNodeData, runOnce, clearOutput } = options; + const { nodeId, primary, primaryParameters, fallback, fallbackParameters, updateNodeData, runOnce, shouldFallback, clearOutput } = options; // Clear any prior fallback metadata before we start. updateNodeData(nodeId, { @@ -68,6 +69,9 @@ export async function runWithFallback( } const primaryErrMsg = errorMessage(primaryError); + if (shouldFallback && !shouldFallback(primaryError)) { + throw primaryError; + } // Clear error state and stale output, show the fallback is now running. updateNodeData(nodeId, { diff --git a/src/store/utils/defaultImageModel.ts b/src/store/utils/defaultImageModel.ts index daacd8ef..331115c0 100644 --- a/src/store/utils/defaultImageModel.ts +++ b/src/store/utils/defaultImageModel.ts @@ -2,6 +2,7 @@ import type { ModelType, ProviderEnvStatus, ProviderSettings, ProviderType, Sele import { MODEL_DISPLAY_NAMES } from "@/types"; const NEWAPIWG_DEFAULT_IMAGE_MODEL_ID = "apiyi_nano_banana_2"; +const NEWAPIWG_DEFAULT_IMAGE_FALLBACK_MODEL_ID = "gpt-image-2-all"; const NEWAPIWG_DEFAULT_VIDEO_MODEL_ID = "doubao-seedance-2-0-260128"; const DEFAULT_AUDIO_MODEL_ID = "elevenlabs/turbo-v2.5"; @@ -14,6 +15,19 @@ export function createNewApiWGDefaultImageModel(): SelectedModel { }; } +export function createNewApiWGDefaultImageFallbackModel(): SelectedModel { + return { + provider: "newapiwg", + modelId: NEWAPIWG_DEFAULT_IMAGE_FALLBACK_MODEL_ID, + displayName: NEWAPIWG_DEFAULT_IMAGE_FALLBACK_MODEL_ID, + capabilities: ["text-to-image", "image-to-image"], + }; +} + +export function isNewApiWGDefaultImageModel(model: SelectedModel): boolean { + return model.provider === "newapiwg" && model.modelId === NEWAPIWG_DEFAULT_IMAGE_MODEL_ID; +} + export function createNewApiWGDefaultVideoModel(): SelectedModel { return { provider: "newapiwg",