diff --git a/src/components/CostIndicator.tsx b/src/components/CostIndicator.tsx index e07e872b..e701d206 100644 --- a/src/components/CostIndicator.tsx +++ b/src/components/CostIndicator.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import { useWorkflowStore } from "@/store/workflowStore"; -import { calculatePredictedCost, formatCost } from "@/utils/costCalculator"; +import { calculatePredictedCost, formatCost, hasNonGeminiProviders } from "@/utils/costCalculator"; import { CostDialog } from "./CostDialog"; export function CostIndicator() { @@ -14,9 +14,10 @@ export function CostIndicator() { return calculatePredictedCost(nodes); }, [nodes]); + const nonGemini = useMemo(() => hasNonGeminiProviders(nodes), [nodes]); const hasAnyNodes = predictedCost.nodeCount > 0; - if (!hasAnyNodes && incurredCost === 0) { + if (nonGemini || (!hasAnyNodes && incurredCost === 0)) { return null; } diff --git a/src/components/__tests__/CostIndicator.test.tsx b/src/components/__tests__/CostIndicator.test.tsx index c1fc165c..84350c43 100644 --- a/src/components/__tests__/CostIndicator.test.tsx +++ b/src/components/__tests__/CostIndicator.test.tsx @@ -441,4 +441,170 @@ describe("CostIndicator", () => { expect(screen.queryByTitle("View cost details")).not.toBeInTheDocument(); }); }); + + describe("Non-Gemini Provider Hiding", () => { + it("should not render when a nanoBanana node has a non-Gemini selectedModel", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "fal", + modelId: "fal-ai/flux", + displayName: "Flux", + }, + }, + }, + ]; + + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ nodes })); + }); + + render(); + + expect(screen.queryByTitle("View cost details")).not.toBeInTheDocument(); + }); + + it("should not render when a generateVideo node has a non-Gemini selectedModel", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "generateVideo", + position: { x: 0, y: 0 }, + data: { + selectedModel: { + provider: "kie", + modelId: "kling-video", + displayName: "Kling Video", + }, + status: "idle", + }, + }, + ]; + + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ nodes })); + }); + + render(); + + expect(screen.queryByTitle("View cost details")).not.toBeInTheDocument(); + }); + + it("should not render when a generate3d node has a non-Gemini selectedModel", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "generate3d", + position: { x: 0, y: 0 }, + data: { + selectedModel: { + provider: "fal", + modelId: "fal-3d", + displayName: "Fal 3D", + }, + status: "idle", + }, + }, + ]; + + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ nodes })); + }); + + render(); + + expect(screen.queryByTitle("View cost details")).not.toBeInTheDocument(); + }); + + it("should render when a nanoBanana node has selectedModel with provider gemini", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "gemini", + modelId: "nano-banana", + displayName: "Nano Banana", + }, + }, + }, + ]; + + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ nodes })); + }); + + render(); + + expect(screen.getByTitle("View cost details")).toBeInTheDocument(); + }); + + it("should render when a nanoBanana node has no selectedModel (legacy Gemini)", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + }, + }, + ]; + + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ nodes })); + }); + + render(); + + expect(screen.getByTitle("View cost details")).toBeInTheDocument(); + }); + + it("should not render when mix of Gemini and non-Gemini nodes exist", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + }, + }, + { + id: "node-2", + type: "nanoBanana", + position: { x: 100, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "replicate", + modelId: "some-model", + displayName: "Some Model", + }, + }, + }, + ]; + + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ nodes })); + }); + + render(); + + expect(screen.queryByTitle("View cost details")).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/utils/__tests__/costCalculator.test.ts b/src/utils/__tests__/costCalculator.test.ts new file mode 100644 index 00000000..e94d1b19 --- /dev/null +++ b/src/utils/__tests__/costCalculator.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from "vitest"; +import { hasNonGeminiProviders } from "@/utils/costCalculator"; +import { WorkflowNode } from "@/types"; + +describe("hasNonGeminiProviders", () => { + it("should return false for empty nodes array", () => { + expect(hasNonGeminiProviders([])).toBe(false); + }); + + it("should return false for non-generation nodes only", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "prompt", + position: { x: 0, y: 0 }, + data: { prompt: "test" }, + }, + { + id: "2", + type: "imageInput", + position: { x: 0, y: 0 }, + data: {}, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(false); + }); + + it("should return false for nanoBanana node with no selectedModel (legacy Gemini)", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { model: "nano-banana", resolution: "1K" }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(false); + }); + + it("should return false for nanoBanana node with gemini selectedModel", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana-pro", + resolution: "1K", + selectedModel: { + provider: "gemini", + modelId: "nano-banana-pro", + displayName: "Nano Banana Pro", + }, + }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(false); + }); + + it("should return true for nanoBanana node with fal provider", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "fal", + modelId: "fal-ai/flux", + displayName: "Flux", + }, + }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(true); + }); + + it("should return true for nanoBanana node with replicate provider", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "replicate", + modelId: "some-model", + displayName: "Some Model", + }, + }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(true); + }); + + it("should return true for nanoBanana node with kie provider", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "kie", + modelId: "kie-model", + displayName: "Kie Model", + }, + }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(true); + }); + + it("should return true for nanoBanana node with wavespeed provider", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "wavespeed", + modelId: "ws-model", + displayName: "WaveSpeed Model", + }, + }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(true); + }); + + it("should return true for generateVideo node with non-Gemini provider", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "generateVideo", + position: { x: 0, y: 0 }, + data: { + selectedModel: { + provider: "kie", + modelId: "kling-video", + displayName: "Kling Video", + }, + status: "idle", + }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(true); + }); + + it("should return false for generateVideo node with no selectedModel", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "generateVideo", + position: { x: 0, y: 0 }, + data: { status: "idle" }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(false); + }); + + it("should return true for generate3d node with non-Gemini provider", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "generate3d", + position: { x: 0, y: 0 }, + data: { + selectedModel: { + provider: "fal", + modelId: "fal-3d", + displayName: "Fal 3D", + }, + status: "idle", + }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(true); + }); + + it("should return false for generate3d node with no selectedModel", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "generate3d", + position: { x: 0, y: 0 }, + data: { status: "idle" }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(false); + }); + + it("should return true when mixed Gemini and non-Gemini nodes exist", () => { + const nodes: WorkflowNode[] = [ + { + id: "1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "gemini", + modelId: "nano-banana", + displayName: "Nano Banana", + }, + }, + }, + { + id: "2", + type: "nanoBanana", + position: { x: 100, y: 0 }, + data: { + model: "nano-banana", + resolution: "1K", + selectedModel: { + provider: "fal", + modelId: "fal-ai/flux", + displayName: "Flux", + }, + }, + }, + ]; + expect(hasNonGeminiProviders(nodes)).toBe(true); + }); +}); diff --git a/src/utils/costCalculator.ts b/src/utils/costCalculator.ts index 8f59cea8..320faf21 100644 --- a/src/utils/costCalculator.ts +++ b/src/utils/costCalculator.ts @@ -1,4 +1,4 @@ -import { ModelType, Resolution, NanoBananaNodeData, GenerateVideoNodeData, SplitGridNodeData, WorkflowNode, ProviderType } from "@/types"; +import { ModelType, Resolution, NanoBananaNodeData, GenerateVideoNodeData, Generate3DNodeData, SplitGridNodeData, WorkflowNode, ProviderType } from "@/types"; // Pricing in USD per image (Gemini API) export const PRICING = { @@ -240,6 +240,28 @@ export function calculatePredictedCost( }; } +/** + * Check whether any generation node in the workflow uses a non-Gemini provider. + * Used to hide the CostIndicator when pricing data would be incomplete/misleading. + */ +export function hasNonGeminiProviders(nodes: WorkflowNode[]): boolean { + return nodes.some((node) => { + if (node.type === "nanoBanana") { + const data = node.data as NanoBananaNodeData; + return data.selectedModel?.provider !== undefined && data.selectedModel.provider !== "gemini"; + } + if (node.type === "generateVideo") { + const data = node.data as GenerateVideoNodeData; + return data.selectedModel?.provider !== undefined && data.selectedModel.provider !== "gemini"; + } + if (node.type === "generate3d") { + const data = node.data as Generate3DNodeData; + return data.selectedModel?.provider !== undefined && data.selectedModel.provider !== "gemini"; + } + return false; + }); +} + export function formatCost(cost: number): string { if (cost === 0) return "$0.00"; if (cost < 0.01) return "<$0.01";