diff --git a/src/components/CostDialog.tsx b/src/components/CostDialog.tsx
index 3960a769..db7b992c 100644
--- a/src/components/CostDialog.tsx
+++ b/src/components/CostDialog.tsx
@@ -61,17 +61,23 @@ export function CostDialog({ predictedCost, incurredCost, onClose }: CostDialogP
{predictedCost.breakdown.map((item, idx) => (
- {item.count}x {item.model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro"}
- {item.model === "nano-banana-pro" && ` (${item.resolution})`}
+ {item.count}x {item.modelName}
+ {item.unitCost === null && " (no pricing)"}
- {formatCost(item.subtotal)}
+ {item.subtotal !== null ? formatCost(item.subtotal) : "—"}
))}
)}
+ {predictedCost.unknownPricingCount > 0 && (
+
+ {predictedCost.unknownPricingCount} model{predictedCost.unknownPricingCount > 1 ? "s" : ""} without pricing
+
+ )}
+
{predictedCost.nodeCount === 0 && (
No generation nodes in workflow
diff --git a/src/utils/costCalculator.ts b/src/utils/costCalculator.ts
index 16abd217..8f59cea8 100644
--- a/src/utils/costCalculator.ts
+++ b/src/utils/costCalculator.ts
@@ -1,4 +1,4 @@
-import { ModelType, Resolution, NanoBananaNodeData, SplitGridNodeData, WorkflowNode } from "@/types";
+import { ModelType, Resolution, NanoBananaNodeData, GenerateVideoNodeData, SplitGridNodeData, WorkflowNode, ProviderType } from "@/types";
// Pricing in USD per image (Gemini API)
export const PRICING = {
@@ -22,40 +22,189 @@ export function calculateGenerationCost(model: ModelType, resolution: Resolution
return PRICING["nano-banana-pro"][resolution];
}
+/**
+ * Pricing info for external provider models
+ */
+export interface ModelPricing {
+ unitCost: number;
+ unit: string; // "image", "video", "second", etc.
+}
+
+/**
+ * Get cost info from ProviderModel pricing field
+ * Returns null if pricing is unavailable (e.g., Replicate has no pricing API)
+ */
+export function getModelCost(pricing: { type: 'per-run' | 'per-second'; amount: number } | null | undefined): ModelPricing | null {
+ if (!pricing) return null;
+ return {
+ unitCost: pricing.amount,
+ unit: pricing.type === 'per-run' ? 'image' : 'second',
+ };
+}
+
+/**
+ * Cost breakdown item supporting multiple providers
+ */
export interface CostBreakdownItem {
- model: ModelType;
- resolution: Resolution;
+ provider: ProviderType;
+ modelId: string;
+ modelName: string;
count: number;
- unitCost: number;
- subtotal: number;
+ unitCost: number | null; // null means pricing unavailable
+ unit: string; // "image", "video", "second", etc.
+ subtotal: number | null; // null if unitCost is null
}
+/**
+ * Result of predicted cost calculation
+ */
export interface PredictedCostResult {
- totalCost: number;
+ totalCost: number; // Only includes known pricing
breakdown: CostBreakdownItem[];
nodeCount: number;
+ unknownPricingCount: number; // Count of items without pricing
}
-export function calculatePredictedCost(nodes: WorkflowNode[]): PredictedCostResult {
- const breakdown: Map = new Map();
+/**
+ * Legacy cost breakdown item for backward compatibility
+ * @deprecated Use CostBreakdownItem instead
+ */
+export interface LegacyCostBreakdownItem {
+ model: ModelType;
+ resolution: Resolution;
+ count: number;
+ unitCost: number;
+ subtotal: number;
+}
+/**
+ * Calculate predicted cost for all generation nodes in the workflow.
+ * Handles nanoBanana (image) and generateVideo (video) nodes.
+ *
+ * @param nodes - Workflow nodes to analyze
+ * @param modelPricing - Optional map of modelId -> pricing for external providers.
+ * If not provided, only Gemini models get pricing.
+ * @returns PredictedCostResult with total cost, breakdown, and counts
+ */
+export function calculatePredictedCost(
+ nodes: WorkflowNode[],
+ modelPricing?: Map
+): PredictedCostResult {
+ // Group by provider + modelId for breakdown
+ const breakdown: Map = new Map();
let nodeCount = 0;
+ let unknownPricingCount = 0;
+
+ /**
+ * Helper to add an item to the breakdown map
+ */
+ function addToBreakdown(
+ provider: ProviderType,
+ modelId: string,
+ modelName: string,
+ unit: string,
+ unitCost: number | null,
+ count: number = 1
+ ) {
+ const key = `${provider}:${modelId}`;
+ const existing = breakdown.get(key);
+ if (existing) {
+ existing.count += count;
+ if (existing.subtotal !== null && unitCost !== null) {
+ existing.subtotal += count * unitCost;
+ }
+ } else {
+ breakdown.set(key, {
+ provider,
+ modelId,
+ modelName,
+ count,
+ unitCost,
+ unit,
+ subtotal: unitCost !== null ? count * unitCost : null,
+ });
+ }
+ nodeCount += count;
+ if (unitCost === null) {
+ unknownPricingCount += count;
+ }
+ }
+
+ /**
+ * Get pricing for a model.
+ * First checks modelPricing map, then falls back to hardcoded Gemini pricing.
+ */
+ function getPricing(
+ provider: ProviderType,
+ modelId: string,
+ resolution?: Resolution
+ ): { unitCost: number; unit: string } | null {
+ // Check external pricing map first
+ if (modelPricing?.has(modelId)) {
+ return modelPricing.get(modelId)!;
+ }
+
+ // Fallback to hardcoded Gemini pricing for legacy models
+ if (provider === "gemini") {
+ if (modelId === "nano-banana" || modelId === "gemini-2.5-flash-preview-image-generation") {
+ return { unitCost: PRICING["nano-banana"]["1K"], unit: "image" };
+ }
+ if (modelId === "nano-banana-pro" || modelId === "gemini-3-pro-image-preview") {
+ const res = resolution || "1K";
+ return { unitCost: PRICING["nano-banana-pro"][res], unit: "image" };
+ }
+ }
+
+ // No pricing available (e.g., Replicate)
+ return null;
+ }
nodes.forEach((node) => {
+ // Handle nanoBanana (image generation) nodes
if (node.type === "nanoBanana") {
const data = node.data as NanoBananaNodeData;
- const model = data.model;
- const resolution = model === "nano-banana" ? "1K" : data.resolution;
- const unitCost = calculateGenerationCost(model, resolution);
- const key = `${model}-${resolution}`;
-
- const existing = breakdown.get(key);
- if (existing) {
- existing.count++;
+
+ // Determine provider and model info
+ let provider: ProviderType;
+ let modelId: string;
+ let modelName: string;
+
+ if (data.selectedModel) {
+ // New multi-provider model selection
+ provider = data.selectedModel.provider;
+ modelId = data.selectedModel.modelId;
+ modelName = data.selectedModel.displayName;
} else {
- breakdown.set(key, { model, resolution, count: 1, unitCost });
+ // Legacy Gemini-only model
+ provider = "gemini";
+ modelId = data.model;
+ modelName = data.model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro";
+ }
+
+ const resolution = data.model === "nano-banana" ? "1K" : data.resolution;
+ const pricing = getPricing(provider, modelId, resolution);
+ const unitCost = pricing?.unitCost ?? null;
+ const unit = pricing?.unit ?? "image";
+
+ addToBreakdown(provider, modelId, modelName, unit, unitCost);
+ }
+
+ // Handle generateVideo nodes
+ if (node.type === "generateVideo") {
+ const data = node.data as GenerateVideoNodeData;
+
+ // generateVideo requires selectedModel (no legacy fallback)
+ if (data.selectedModel) {
+ const provider = data.selectedModel.provider;
+ const modelId = data.selectedModel.modelId;
+ const modelName = data.selectedModel.displayName;
+
+ const pricing = getPricing(provider, modelId);
+ const unitCost = pricing?.unitCost ?? null;
+ const unit = pricing?.unit ?? "video";
+
+ addToBreakdown(provider, modelId, modelName, unit, unitCost);
}
- nodeCount++;
}
// SplitGrid nodes create child nanoBanana nodes - count those from settings
@@ -66,32 +215,28 @@ export function calculatePredictedCost(nodes: WorkflowNode[]): PredictedCostResu
if (data.isConfigured && data.targetCount > 0) {
const model = data.generateSettings.model;
const resolution = model === "nano-banana" ? "1K" : data.generateSettings.resolution;
- const unitCost = calculateGenerationCost(model, resolution);
- const key = `splitGrid-${model}-${resolution}`;
-
- const count = data.targetCount;
- const existing = breakdown.get(key);
- if (existing) {
- existing.count += count;
- } else {
- breakdown.set(key, { model, resolution, count, unitCost });
- }
- nodeCount += count;
+ const modelName = model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro";
+
+ const pricing = getPricing("gemini", model, resolution);
+ const unitCost = pricing?.unitCost ?? null;
+ const unit = pricing?.unit ?? "image";
+
+ addToBreakdown("gemini", model, modelName, unit, unitCost, data.targetCount);
}
}
});
- const breakdownArray = Array.from(breakdown.values()).map((item) => ({
- ...item,
- subtotal: item.count * item.unitCost,
- }));
-
- const totalCost = breakdownArray.reduce((sum, item) => sum + item.subtotal, 0);
+ const breakdownArray = Array.from(breakdown.values());
+ const totalCost = breakdownArray.reduce(
+ (sum, item) => sum + (item.subtotal ?? 0),
+ 0
+ );
return {
totalCost,
breakdown: breakdownArray,
nodeCount,
+ unknownPricingCount,
};
}