Browse Source

feat: hide CostIndicator when non-Gemini providers are in workflow

The pricing calculator only has reliable data for Gemini models. When
non-Gemini providers (fal, replicate, kie, wavespeed) are used, the
displayed cost is incomplete/misleading. Add hasNonGeminiProviders()
utility and use it to hide the CostIndicator entirely in that case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
fb022140c9
  1. 5
      src/components/CostIndicator.tsx
  2. 166
      src/components/__tests__/CostIndicator.test.tsx
  3. 235
      src/utils/__tests__/costCalculator.test.ts
  4. 24
      src/utils/costCalculator.ts

5
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;
}

166
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(<CostIndicator />);
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(<CostIndicator />);
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(<CostIndicator />);
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(<CostIndicator />);
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(<CostIndicator />);
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(<CostIndicator />);
expect(screen.queryByTitle("View cost details")).not.toBeInTheDocument();
});
});
});

235
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);
});
});

24
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";

Loading…
Cancel
Save