From 18c3bf29e266644352661c6481414685083e9828 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 16 Jan 2026 15:03:18 +1300 Subject: [PATCH] fix(24-01): complete costCalculator multi-provider type migration - Update calculatePredictedCost to produce CostBreakdownItem[] format - Add unknownPricingCount to return value (was missing) - CostDialog already using modelName property --- src/components/CostDialog.tsx | 12 +- src/utils/costCalculator.ts | 215 ++++++++++++++++++++++++++++------ 2 files changed, 189 insertions(+), 38 deletions(-) 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, }; }