diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index 37489823..c73a282b 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/src/app/api/generate/__tests__/route.test.ts @@ -41,7 +41,7 @@ vi.mock("@/lib/images", () => ({ deleteImages: vi.fn(), })); -import { POST, clearFalInputMappingCache } from "../route"; +import { POST, buildMediaResponse, clearFalInputMappingCache } from "../route"; // Store original env const originalEnv = { ...process.env }; @@ -909,6 +909,22 @@ describe("/api/generate route", () => { }); describe("Response handling", () => { + it("should return image URL when provider output is URL-only", async () => { + const response = buildMediaResponse({ + type: "image", + data: "", + url: "https://r2cdn.copilotbase.com/r2cdn2/generated.png", + }); + + const data = await response.json(); + + expect(data).toEqual({ + success: true, + image: "https://r2cdn.copilotbase.com/r2cdn2/generated.png", + contentType: "image", + }); + }); + it("should return proper response structure with image", async () => { process.env.GEMINI_API_KEY = "test-gemini-key"; diff --git a/src/app/api/generate/providers/__tests__/newapiwg.test.ts b/src/app/api/generate/providers/__tests__/newapiwg.test.ts index d777f287..46b29d55 100644 --- a/src/app/api/generate/providers/__tests__/newapiwg.test.ts +++ b/src/app/api/generate/providers/__tests__/newapiwg.test.ts @@ -150,6 +150,22 @@ describe("NewApiWG generation payloads", () => { if (urlStr.includes("/chat/completions") && init?.method === "POST") { capturedBody = JSON.parse(init.body as string); + if (capturedBody?.model === "gpt-image-2-all") { + return new Response(JSON.stringify({ + id: "chatcmpl-test", + object: "chat.completion", + model: "gpt-image-2-all", + choices: [{ + message: { + role: "assistant", + content: "![image](https://r2cdn.copilotbase.com/r2cdn2/generated.png)\n\n", + }, + finish_reason: "stop", + }], + }), { + status: 200, + }); + } return new Response(JSON.stringify({ choices: [{ message: { @@ -352,6 +368,35 @@ describe("NewApiWG generation payloads", () => { expect(capturedBody!.messages).toEqual([{ role: "user", content: "draw a banana" }]); }); + it("extracts markdown image URLs from gpt-image-2-all chat responses", async () => { + const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ + model: { + id: "gpt-image-2-all", + name: "gpt-image-2-all", + description: null, + provider: "newapiwg", + capabilities: ["text-to-image", "image-to-image"], + }, + prompt: "draw a banana", + images: ["data:image/png;base64,input"], + })); + + expect(result.success).toBe(true); + expect(result.outputs?.[0]).toEqual({ + type: "image", + data: "", + url: "https://r2cdn.copilotbase.com/r2cdn2/generated.png", + }); + expect(capturedBody!.model).toBe("gpt-image-2-all"); + expect(capturedBody!.messages).toEqual([{ + role: "user", + content: [ + { type: "text", text: "draw a banana" }, + { type: "image_url", image_url: { url: "data:image/png;base64,input" } }, + ], + }]); + }); + it("routes Seedream image models through images generations", async () => { const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ model: { diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index a47c44bd..10662ab2 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -375,7 +375,7 @@ export const isDraggingNodeRef = { current: false }; export function WorkflowCanvas() { const { t } = useI18n(); - const { nodes, edges, groups, isModalOpen, showQuickstart, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds, hoveredNodeId, providerSettings } = + const { nodes, edges, groups, isModalOpen, showQuickstart, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds, hoveredNodeId, providerSettings, providerEnvStatus } = useWorkflowStore(useShallow((state) => ({ nodes: state.nodes, edges: state.edges, @@ -388,6 +388,7 @@ export function WorkflowCanvas() { skippedNodeIds: state.skippedNodeIds, hoveredNodeId: state.hoveredNodeId, providerSettings: state.providerSettings, + providerEnvStatus: state.providerEnvStatus, }))); const onNodesChange = useWorkflowStore((state) => state.onNodesChange); const onEdgesChange = useWorkflowStore((state) => state.onEdgesChange); @@ -409,6 +410,7 @@ export function WorkflowCanvas() { const clearWorkflow = useWorkflowStore((state) => state.clearWorkflow); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId); + const updateProviderEnvStatus = useWorkflowStore((state) => state.updateProviderEnvStatus); const openAnnotationModal = useAnnotationStore((state) => state.openModal); const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow(); const { show: showToast } = useToast(); @@ -420,6 +422,24 @@ export function WorkflowCanvas() { const [isChatOpen, setIsChatOpen] = useState(false); const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false); const [showNewProjectSetup, setShowNewProjectSetup] = useState(false); + + useEffect(() => { + let cancelled = false; + fetch("/api/env-status") + .then((response) => response.ok ? response.json() : null) + .then((status) => { + if (!cancelled && status) { + updateProviderEnvStatus(status); + } + }) + .catch(() => { + // Settings modal can still show provider errors; this status is only a hint. + }); + + return () => { + cancelled = true; + }; + }, [updateProviderEnvStatus]); const [expandingNode, setExpandingNode] = useState<{ id: string; type: string } | null>(null); // Fallback model picker state @@ -2834,11 +2854,14 @@ export function WorkflowCanvas() { onModelSelected={(model) => { const currentNode = nodes.find((node) => node.id === fallbackDialogState.nodeId); const currentData = currentNode?.data as any; - const selectedModel = - fallbackDialogState.capability === "video" && + const shouldPromoteFallback = + (fallbackDialogState.capability === "image" || fallbackDialogState.capability === "video") && currentData?.selectedModel?.provider === "gemini" && !providerSettings.providers.gemini?.apiKey && - model.provider !== "gemini" + !providerEnvStatus?.gemini && + model.provider !== "gemini"; + const selectedModel = + shouldPromoteFallback ? { provider: model.provider, modelId: model.id, @@ -2846,16 +2869,30 @@ export function WorkflowCanvas() { capabilities: model.capabilities, } : undefined; - updateNodeData(fallbackDialogState.nodeId, { - ...(selectedModel ? { selectedModel, inputSchema: undefined } : {}), - fallbackModel: { - provider: model.provider, - modelId: model.id, - displayName: model.name, - capabilities: model.capabilities, - }, - fallbackParameters: {}, - }); + if (selectedModel) { + updateNodeData(fallbackDialogState.nodeId, { + selectedModel, + parameters: {}, + inputSchema: undefined, + fallbackModel: undefined, + fallbackParameters: undefined, + status: currentData?.status === "error" ? "idle" : currentData?.status, + error: currentData?.status === "error" ? null : currentData?.error, + __usedFallback: undefined, + __fallbackModelUsed: undefined, + __primaryError: undefined, + }); + } else { + updateNodeData(fallbackDialogState.nodeId, { + fallbackModel: { + provider: model.provider, + modelId: model.id, + displayName: model.name, + capabilities: model.capabilities, + }, + fallbackParameters: {}, + }); + } setFallbackDialogState(null); }} /> diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index 878e6962..c682a73b 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act } from "@testing-library/react"; import { useWorkflowStore } from "../workflowStore"; +import { defaultProviderSettings } from "../utils/localStorage"; import type { WorkflowNode, WorkflowEdge } from "@/types"; // Mock the Toast hook @@ -2305,6 +2306,62 @@ describe("workflowStore integration tests", () => { expect(useWorkflowStore.getState().viewedCommentNodeIds.size).toBe(0); }); + + it("should promote a NewApiWG fallback when env status is known before workflow load", async () => { + useWorkflowStore.setState({ + providerSettings: defaultProviderSettings, + providerEnvStatus: { + newapiwg: true, + gemini: false, + openai: false, + anthropic: false, + replicate: false, + fal: false, + kie: false, + wavespeed: false, + }, + }); + + const store = useWorkflowStore.getState(); + await store.loadWorkflow({ + id: "test-workflow", + name: "Test", + nodes: [ + createTestNode("nanoBanana-1", "nanoBanana", { + selectedModel: { + provider: "gemini", + modelId: "nano-banana-pro", + displayName: "Nano Banana Pro", + }, + fallbackModel: { + provider: "newapiwg", + modelId: "gpt-image-2-all", + displayName: "gpt-image-2-all", + }, + fallbackParameters: { size: "1024x1024", quality: "auto" }, + status: "error", + error: "Primary failed: API key not configured", + __usedFallback: true, + __fallbackModelUsed: "gpt-image-2-all", + __primaryError: "API key not configured", + }), + ], + edges: [], + }); + + const node = useWorkflowStore.getState().nodes[0]; + expect(node.data.selectedModel).toMatchObject({ + provider: "newapiwg", + modelId: "gpt-image-2-all", + }); + expect(node.data.parameters).toEqual({ size: "1024x1024", quality: "auto" }); + expect(node.data.fallbackModel).toBeUndefined(); + expect(node.data.fallbackParameters).toBeUndefined(); + expect(node.data.status).toBe("idle"); + expect(node.data.error).toBeNull(); + expect(node.data.__usedFallback).toBeUndefined(); + expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(true); + }); }); }); diff --git a/src/store/execution/__tests__/generateVideoExecutor.test.ts b/src/store/execution/__tests__/generateVideoExecutor.test.ts index 8408908e..79d6479c 100644 --- a/src/store/execution/__tests__/generateVideoExecutor.test.ts +++ b/src/store/execution/__tests__/generateVideoExecutor.test.ts @@ -242,12 +242,15 @@ describe("executeGenerateVideo", () => { modelId: "viduq2-pro-fast", }); expect(body.parameters).toEqual({ duration: 5 }); - expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", { + expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", expect.objectContaining({ selectedModel: expect.objectContaining({ provider: "newapiwg", modelId: "viduq2-pro-fast", }), - }); + parameters: { duration: 5 }, + fallbackModel: undefined, + fallbackParameters: undefined, + })); }); it("uses NewApiWG default video model when Gemini is unavailable and no fallback is set", async () => { @@ -281,6 +284,42 @@ describe("executeGenerateVideo", () => { }); }); + it("uses NewApiWG default video model when NewApiWG is configured via env", async () => { + const node = makeNode({ + selectedModel: { + provider: "gemini", + modelId: "veo-3.1-fast/image-to-video", + displayName: "VEO 3.1 Fast I2V", + }, + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/video.mp4" }), + }); + + const ctx = makeCtx(node, { + providerEnvStatus: { + newapiwg: true, + gemini: false, + openai: false, + anthropic: false, + replicate: false, + fal: false, + kie: false, + wavespeed: false, + }, + }); + await executeGenerateVideo(ctx); + + const request = mockFetch.mock.calls[0]; + const body = JSON.parse(request[1].body); + expect(body.selectedModel).toMatchObject({ + provider: "newapiwg", + modelId: "viduq2-pro-fast", + }); + expect(request[1].headers["X-NewApiWG-Key"]).toBeUndefined(); + }); + it("should update node with video result on success", async () => { const node = makeNode(); mockFetch.mockResolvedValueOnce({ diff --git a/src/store/execution/__tests__/nanoBananaExecutor.test.ts b/src/store/execution/__tests__/nanoBananaExecutor.test.ts index 40ec1a86..d01002fc 100644 --- a/src/store/execution/__tests__/nanoBananaExecutor.test.ts +++ b/src/store/execution/__tests__/nanoBananaExecutor.test.ts @@ -217,12 +217,93 @@ describe("executeNanoBanana", () => { const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.selectedModel.provider).toBe("newapiwg"); expect(body.selectedModel.modelId).toBe("apiyi_nano_banana_2"); - expect(ctx.updateNodeData).toHaveBeenCalledWith("gen-1", { + expect(ctx.updateNodeData).toHaveBeenCalledWith("gen-1", expect.objectContaining({ selectedModel: expect.objectContaining({ provider: "newapiwg", modelId: "apiyi_nano_banana_2", }), + fallbackModel: undefined, + fallbackParameters: undefined, + })); + }); + + it("reroutes stored Gemini image nodes when NewApiWG is configured via env", async () => { + const node = makeNode(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }), + }); + + const ctx = makeCtx(node, { + providerEnvStatus: { + newapiwg: true, + gemini: false, + openai: false, + anthropic: false, + replicate: false, + fal: false, + kie: false, + wavespeed: false, + }, + }); + + await executeNanoBanana(ctx); + + const request = mockFetch.mock.calls[0]; + const body = JSON.parse(request[1].body); + expect(body.selectedModel).toMatchObject({ + provider: "newapiwg", + modelId: "apiyi_nano_banana_2", + }); + expect(request[1].headers["X-NewApiWG-Key"]).toBeUndefined(); + }); + + it("promotes a NewApiWG fallback image model when stored Gemini has no key", async () => { + const node = makeNode({ + fallbackModel: { + provider: "newapiwg", + modelId: "gpt-image-2-all", + displayName: "gpt-image-2-all", + capabilities: ["text-to-image", "image-to-image"], + }, + fallbackParameters: { quality: "auto", size: "1024x1024" }, + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }), + }); + + const ctx = makeCtx(node, { + providerEnvStatus: { + newapiwg: true, + gemini: false, + openai: false, + anthropic: false, + replicate: false, + fal: false, + kie: false, + wavespeed: false, + }, }); + + await executeNanoBanana(ctx); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.selectedModel).toMatchObject({ + provider: "newapiwg", + modelId: "gpt-image-2-all", + }); + expect(body.parameters).toEqual({ quality: "auto", size: "1024x1024" }); + expect(ctx.updateNodeData).toHaveBeenCalledWith("gen-1", expect.objectContaining({ + selectedModel: expect.objectContaining({ + provider: "newapiwg", + modelId: "gpt-image-2-all", + }), + parameters: { quality: "auto", size: "1024x1024" }, + fallbackModel: undefined, + fallbackParameters: undefined, + })); }); it("should update node with result on success", async () => { diff --git a/src/store/execution/generateVideoExecutor.ts b/src/store/execution/generateVideoExecutor.ts index ec75956d..73a74536 100644 --- a/src/store/execution/generateVideoExecutor.ts +++ b/src/store/execution/generateVideoExecutor.ts @@ -68,6 +68,7 @@ export async function executeGenerateVideo( getFreshNode, signal, providerSettings, + providerEnvStatus, addIncurredCost, generationsPath, getNodes, @@ -119,14 +120,14 @@ export async function executeGenerateVideo( let primaryParameters: Record | undefined; let fallbackModel = nodeData.fallbackModel; - if (!primaryModel?.modelId && shouldPreferNewApiWGVideoModel(providerSettings)) { + if (!primaryModel?.modelId && shouldPreferNewApiWGVideoModel(providerSettings, providerEnvStatus)) { primaryModel = createNewApiWGDefaultVideoModel(); updateNodeData(node.id, { selectedModel: primaryModel }); } if ( primaryModel?.provider === "gemini" && - shouldPreferNewApiWGVideoModel(providerSettings) + shouldPreferNewApiWGVideoModel(providerSettings, providerEnvStatus) ) { const promotedModel = fallbackModel && fallbackModel.provider !== "gemini" @@ -137,7 +138,18 @@ export async function executeGenerateVideo( ? nodeData.fallbackParameters : nodeData.parameters; fallbackModel = undefined; - updateNodeData(node.id, { selectedModel: promotedModel }); + updateNodeData(node.id, { + selectedModel: promotedModel, + parameters: primaryParameters ?? {}, + fallbackModel: undefined, + fallbackParameters: undefined, + status: nodeData.status === "error" ? "idle" : nodeData.status, + error: nodeData.status === "error" ? null : nodeData.error, + __usedFallback: undefined, + __fallbackModelUsed: undefined, + __primaryError: undefined, + inputSchema: undefined, + }); } if (!primaryModel?.modelId) { diff --git a/src/store/execution/nanoBananaExecutor.ts b/src/store/execution/nanoBananaExecutor.ts index 64ad1507..87ef9ee3 100644 --- a/src/store/execution/nanoBananaExecutor.ts +++ b/src/store/execution/nanoBananaExecutor.ts @@ -43,6 +43,7 @@ export async function executeNanoBanana( getNodes, signal, providerSettings, + providerEnvStatus, addIncurredCost, addToGlobalHistory, generationsPath, @@ -357,20 +358,42 @@ export async function executeNanoBanana( // Synthesize a SelectedModel for the primary from legacy fields if selectedModel is missing. let primaryModel: SelectedModel = - nodeData.selectedModel ?? createDefaultImageModel(nodeData.model, providerSettings); + nodeData.selectedModel ?? createDefaultImageModel(nodeData.model, providerSettings, providerEnvStatus); + let primaryParameters: Record | undefined; + let fallbackModel = nodeData.fallbackModel; if ( primaryModel.provider === "gemini" && - shouldPreferNewApiWGImageModel(providerSettings) + shouldPreferNewApiWGImageModel(providerSettings, providerEnvStatus) ) { - primaryModel = createNewApiWGDefaultImageModel(); - updateNodeData(node.id, { selectedModel: primaryModel }); + const promotedModel = + fallbackModel && fallbackModel.provider !== "gemini" + ? fallbackModel + : createNewApiWGDefaultImageModel(); + primaryModel = promotedModel; + primaryParameters = fallbackModel === promotedModel + ? nodeData.fallbackParameters + : nodeData.parameters; + fallbackModel = undefined; + updateNodeData(node.id, { + selectedModel: promotedModel, + parameters: primaryParameters ?? {}, + fallbackModel: undefined, + fallbackParameters: undefined, + status: nodeData.status === "error" ? "idle" : nodeData.status, + error: nodeData.status === "error" ? null : nodeData.error, + __usedFallback: undefined, + __fallbackModelUsed: undefined, + __primaryError: undefined, + inputSchema: undefined, + }); } await runWithFallback({ nodeId: node.id, primary: primaryModel, - fallback: nodeData.fallbackModel, + primaryParameters, + fallback: fallbackModel, fallbackParameters: nodeData.fallbackParameters, updateNodeData, runOnce, diff --git a/src/store/execution/types.ts b/src/store/execution/types.ts index 861b7956..9d6770c4 100644 --- a/src/store/execution/types.ts +++ b/src/store/execution/types.ts @@ -10,6 +10,7 @@ import type { WorkflowEdge, WorkflowNodeData, ProviderSettings, + ProviderEnvStatus, ImageHistoryItem, } from "@/types"; import type { ConnectedInputs } from "@/store/utils/connectedInputs"; @@ -41,6 +42,7 @@ export interface NodeExecutionContext { getNodes: () => WorkflowNode[]; signal?: AbortSignal; providerSettings: ProviderSettings; + providerEnvStatus?: ProviderEnvStatus | null; addIncurredCost: (cost: number) => void; addToGlobalHistory: (item: Omit) => void; generationsPath: string | null; diff --git a/src/store/utils/defaultImageModel.ts b/src/store/utils/defaultImageModel.ts index bf43b569..bc8183e4 100644 --- a/src/store/utils/defaultImageModel.ts +++ b/src/store/utils/defaultImageModel.ts @@ -1,4 +1,4 @@ -import type { ModelType, ProviderSettings, SelectedModel } from "@/types"; +import type { ModelType, ProviderEnvStatus, ProviderSettings, ProviderType, SelectedModel } from "@/types"; import { MODEL_DISPLAY_NAMES } from "@/types"; const NEWAPIWG_DEFAULT_IMAGE_MODEL_ID = "apiyi_nano_banana_2"; @@ -40,23 +40,37 @@ export function createGeminiLegacyImageModel(model: string): SelectedModel { }; } +function hasProviderCredential( + providerSettings: ProviderSettings, + provider: ProviderType, + providerEnvStatus?: ProviderEnvStatus | null +): boolean { + return Boolean( + providerSettings.providers[provider]?.apiKey || + providerEnvStatus?.[provider] + ); +} + export function shouldPreferNewApiWGImageModel( - providerSettings: ProviderSettings + providerSettings: ProviderSettings, + providerEnvStatus?: ProviderEnvStatus | null ): boolean { - return Boolean(providerSettings.providers.newapiwg?.apiKey); + return hasProviderCredential(providerSettings, "newapiwg", providerEnvStatus); } export function shouldPreferNewApiWGVideoModel( - providerSettings: ProviderSettings + providerSettings: ProviderSettings, + providerEnvStatus?: ProviderEnvStatus | null ): boolean { - return Boolean(providerSettings.providers.newapiwg?.apiKey); + return hasProviderCredential(providerSettings, "newapiwg", providerEnvStatus); } export function createDefaultImageModel( legacyModel: string, - providerSettings: ProviderSettings + providerSettings: ProviderSettings, + providerEnvStatus?: ProviderEnvStatus | null ): SelectedModel { - if (shouldPreferNewApiWGImageModel(providerSettings)) { + if (shouldPreferNewApiWGImageModel(providerSettings, providerEnvStatus)) { return createNewApiWGDefaultImageModel(); } return createGeminiLegacyImageModel(legacyModel); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index ce2c4540..ceb77eb3 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -14,6 +14,7 @@ import { WorkflowEdge, NodeType, NanoBananaNodeData, + GenerateVideoNodeData, OutputGalleryNodeData, WorkflowNodeData, ImageHistoryItem, @@ -21,6 +22,8 @@ import { GroupColor, ProviderType, ProviderSettings, + ProviderEnvStatus, + SelectedModel, RecentModel, CanvasNavigationSettings, MatchMode, @@ -336,9 +339,11 @@ interface WorkflowStore { // Provider settings state providerSettings: ProviderSettings; + providerEnvStatus: ProviderEnvStatus | null; // Provider settings actions updateProviderSettings: (settings: ProviderSettings) => void; + updateProviderEnvStatus: (status: ProviderEnvStatus) => void; updateProviderApiKey: (providerId: ProviderType, apiKey: string | null) => void; toggleProvider: (providerId: ProviderType, enabled: boolean) => void; @@ -528,6 +533,65 @@ function syncUndoFlags(set: (partial: Partial) => void): void { set({ canUndo: undoManager.canUndo, canRedo: undoManager.canRedo }); } +function hasProviderCredential( + providerSettings: ProviderSettings, + providerEnvStatus: ProviderEnvStatus | null, + provider: ProviderType +): boolean { + return Boolean( + providerSettings.providers[provider]?.apiKey || + providerEnvStatus?.[provider] + ); +} + +function shouldPromoteFallbackModel( + data: { selectedModel?: SelectedModel; fallbackModel?: SelectedModel }, + providerSettings: ProviderSettings, + providerEnvStatus: ProviderEnvStatus | null +): boolean { + const fallbackProvider = data.fallbackModel?.provider; + return Boolean( + data.selectedModel?.provider === "gemini" && + fallbackProvider && + fallbackProvider !== "gemini" && + !hasProviderCredential(providerSettings, providerEnvStatus, "gemini") && + hasProviderCredential(providerSettings, providerEnvStatus, fallbackProvider) + ); +} + +function promoteUnavailableGeminiFallbacks( + nodes: WorkflowNode[], + providerSettings: ProviderSettings, + providerEnvStatus: ProviderEnvStatus | null +): { nodes: WorkflowNode[]; changed: boolean } { + let changed = false; + const nextNodes = nodes.map((node) => { + if (node.type !== "nanoBanana" && node.type !== "generateVideo") return node; + const data = node.data as NanoBananaNodeData | GenerateVideoNodeData; + if (!shouldPromoteFallbackModel(data, providerSettings, providerEnvStatus)) return node; + + changed = true; + return { + ...node, + data: { + ...data, + selectedModel: data.fallbackModel, + parameters: data.fallbackParameters ?? data.parameters ?? {}, + fallbackModel: undefined, + fallbackParameters: undefined, + status: data.status === "error" ? "idle" : data.status, + error: data.status === "error" ? null : data.error, + __usedFallback: undefined, + __fallbackModelUsed: undefined, + __primaryError: undefined, + inputSchema: undefined, + } as WorkflowNodeData, + } as WorkflowNode; + }) as WorkflowNode[]; + + return { nodes: nextNodes, changed }; +} + const workflowStoreImpl: StateCreator = (set, get) => ({ nodes: [], edges: [], @@ -562,6 +626,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // Provider settings initial state providerSettings: getProviderSettings(), + providerEnvStatus: null, // Model search dialog initial state modelSearchOpen: false, @@ -1216,6 +1281,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ getNodes: () => get().nodes, signal, providerSettings: get().providerSettings, + providerEnvStatus: get().providerEnvStatus, addIncurredCost: (cost: number) => get().addIncurredCost(cost), addToGlobalHistory: (item) => get().addToGlobalHistory(item), generationsPath: get().generationsPath, @@ -2163,6 +2229,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // Migrate legacy nanoBanana nodes: derive selectedModel from model field if missing const providerSettings = get().providerSettings; + const providerEnvStatus = get().providerEnvStatus; workflow.nodes = workflow.nodes.map((node) => { if (node.type === "nanoBanana") { const data = node.data as NanoBananaNodeData; @@ -2171,7 +2238,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ ...node, data: { ...data, - selectedModel: createDefaultImageModel(data.model, providerSettings), + selectedModel: createDefaultImageModel(data.model, providerSettings, providerEnvStatus), }, }; } @@ -2232,6 +2299,18 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ } } + const promotedModels = promoteUnavailableGeminiFallbacks( + hydratedWorkflow.nodes as WorkflowNode[], + providerSettings, + providerEnvStatus + ); + if (promotedModels.changed) { + hydratedWorkflow = { + ...hydratedWorkflow, + nodes: promotedModels.nodes, + }; + } + // Load cost data for this workflow const costData = workflow.id ? loadWorkflowCostData(workflow.id) : null; @@ -2260,7 +2339,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ ? joinBrowserFileSystemPath(directoryPath, "generations") : null), lastSavedAt: savedConfig?.lastSavedAt || null, - hasUnsavedChanges: false, + hasUnsavedChanges: promotedModels.changed, // Restore cost data incurredCost: costData?.incurredCost || 0, // Track where imageRefs are valid from @@ -2655,10 +2734,22 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // Provider settings actions updateProviderSettings: (settings: ProviderSettings) => { - set({ providerSettings: settings }); + const promoted = promoteUnavailableGeminiFallbacks(get().nodes, settings, get().providerEnvStatus); + set({ + providerSettings: settings, + ...(promoted.changed ? { nodes: promoted.nodes, hasUnsavedChanges: true } : {}), + }); saveProviderSettings(settings); }, + updateProviderEnvStatus: (status: ProviderEnvStatus) => { + const promoted = promoteUnavailableGeminiFallbacks(get().nodes, get().providerSettings, status); + set({ + providerEnvStatus: status, + ...(promoted.changed ? { nodes: promoted.nodes, hasUnsavedChanges: true } : {}), + }); + }, + updateProviderApiKey: (providerId: ProviderType, apiKey: string | null) => { const { providerSettings } = get(); const updated: ProviderSettings = { diff --git a/src/types/providers.ts b/src/types/providers.ts index d641e4fa..c41f4f4a 100644 --- a/src/types/providers.ts +++ b/src/types/providers.ts @@ -37,6 +37,8 @@ export interface ProviderSettings { providers: Record; } +export type ProviderEnvStatus = Record; + // LLM Provider Options export type LLMProvider = "newapiwg" | "google" | "openai" | "anthropic";